feat(用户): 实现邮箱绑定功能并返回登录凭证
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m41s
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m41s
修改绑定邮箱接口返回登录凭证,优化用户数据迁移流程 添加用户缓存清理逻辑,确保设备绑定后数据一致性 完善邮箱验证和绑定逻辑的注释和错误处理
This commit is contained in:
parent
38655c0d38
commit
fde3210a88
@ -209,7 +209,7 @@ service ppanel {
|
||||
|
||||
@doc "Bind Email With Password"
|
||||
@handler BindEmailWithPassword
|
||||
post /bind_email_with_password (BindEmailWithPasswordRequest)
|
||||
post /bind_email_with_password (BindEmailWithPasswordRequest) returns (LoginResponse)
|
||||
|
||||
@doc "Bind Invite Code"
|
||||
@handler BindInviteCode
|
||||
|
||||
@ -20,7 +20,7 @@ func BindEmailWithPasswordHandler(svcCtx *svc.ServiceContext) func(c *gin.Contex
|
||||
}
|
||||
|
||||
l := user.NewBindEmailWithPasswordLogic(c.Request.Context(), svcCtx)
|
||||
err := l.BindEmailWithPassword(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
resp, err := l.BindEmailWithPassword(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,6 +187,28 @@ func (l *BindDeviceLogic) createDeviceForUser(identifier, ip, userAgent string,
|
||||
return err
|
||||
}
|
||||
|
||||
// 清理用户缓存,确保新设备能正确显示在用户的设备列表中
|
||||
userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, userId)
|
||||
if err != nil {
|
||||
l.Errorw("failed to find user for cache clearing",
|
||||
logger.Field("user_id", userId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
// 不因为缓存清理失败而返回错误,因为设备创建已经成功
|
||||
} else {
|
||||
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); err != nil {
|
||||
l.Errorw("failed to clear user cache",
|
||||
logger.Field("user_id", userId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
// 不因为缓存清理失败而返回错误,因为设备创建已经成功
|
||||
} else {
|
||||
l.Infow("cleared user cache after device creation",
|
||||
logger.Field("user_id", userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录设备创建成功的日志
|
||||
l.Infow("device created successfully",
|
||||
logger.Field("identifier", identifier),
|
||||
@ -321,6 +343,51 @@ func (l *BindDeviceLogic) rebindDeviceToNewUser(deviceInfo *user.Device, ip, use
|
||||
return err
|
||||
}
|
||||
|
||||
// 清理新用户的缓存,确保用户信息能正确更新
|
||||
// 这是关键步骤:设备迁移后必须清理缓存,否则用户看到的还是旧的设备列表
|
||||
newUser, err := l.svcCtx.UserModel.FindOne(l.ctx, newUserId)
|
||||
if err != nil {
|
||||
l.Errorw("failed to find new user for cache clearing",
|
||||
logger.Field("new_user_id", newUserId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
// 不因为缓存清理失败而返回错误,因为数据迁移已经成功
|
||||
} else {
|
||||
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, newUser); err != nil {
|
||||
l.Errorw("failed to clear new user cache",
|
||||
logger.Field("new_user_id", newUserId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
// 不因为缓存清理失败而返回错误,因为数据迁移已经成功
|
||||
} else {
|
||||
l.Infow("cleared new user cache after device rebinding",
|
||||
logger.Field("new_user_id", newUserId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理原用户的缓存(如果原用户没有被禁用的话)
|
||||
oldUser, err := l.svcCtx.UserModel.FindOne(l.ctx, oldUserId)
|
||||
if err != nil {
|
||||
l.Errorw("failed to find old user for cache clearing",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
// 不因为缓存清理失败而返回错误
|
||||
} else {
|
||||
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, oldUser); err != nil {
|
||||
l.Errorw("failed to clear old user cache",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
// 不因为缓存清理失败而返回错误
|
||||
} else {
|
||||
l.Infow("cleared old user cache after device rebinding",
|
||||
logger.Field("old_user_id", oldUserId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录设备重新绑定成功的日志
|
||||
l.Infow("device rebound successfully",
|
||||
logger.Field("identifier", deviceInfo.Identifier),
|
||||
|
||||
@ -2,12 +2,17 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/auth"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/jwt"
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
@ -32,53 +37,56 @@ func NewBindEmailWithPasswordLogic(ctx context.Context, svcCtx *svc.ServiceConte
|
||||
}
|
||||
}
|
||||
|
||||
func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailWithPasswordRequest) error {
|
||||
func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailWithPasswordRequest) (*types.LoginResponse, error) {
|
||||
// 获取当前设备用户
|
||||
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
logger.Error("current user is not found in context")
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 验证邮箱和密码是否匹配现有用户
|
||||
emailUser, err := l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "email not registered: %v", req.Email)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "email not registered: %v", req.Email)
|
||||
}
|
||||
logger.WithContext(l.ctx).Error(err)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user by email failed: %v", err.Error())
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user by email failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !tool.VerifyPassWord(req.Password, emailUser.Password) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "password incorrect")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "password incorrect")
|
||||
}
|
||||
|
||||
// 检查当前用户是否已经绑定了邮箱
|
||||
currentEmailMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", currentUser.Id)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId error")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId error")
|
||||
}
|
||||
|
||||
// 最终用户ID(可能是当前用户或邮箱用户)
|
||||
finalUserId := currentUser.Id
|
||||
|
||||
// 如果当前用户已经绑定了邮箱,检查是否是同一个邮箱
|
||||
if currentEmailMethod.Id > 0 {
|
||||
// 如果绑定的是同一个邮箱,直接返回成功
|
||||
// 如果绑定的是同一个邮箱,直接生成Token返回
|
||||
if currentEmailMethod.AuthIdentifier == req.Email {
|
||||
l.Infow("user is binding the same email that is already bound",
|
||||
logger.Field("user_id", currentUser.Id),
|
||||
logger.Field("email", req.Email),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
// 直接使用当前用户ID生成Token
|
||||
} else {
|
||||
// 如果是不同的邮箱,不允许重复绑定
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "current user already has email bound")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "current user already has email bound")
|
||||
}
|
||||
|
||||
} else {
|
||||
// 检查该邮箱是否已经被其他用户绑定
|
||||
existingEmailMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||
}
|
||||
|
||||
// 如果邮箱已经被其他用户绑定,需要进行数据迁移
|
||||
@ -89,11 +97,11 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
|
||||
// 获取当前用户的设备标识符
|
||||
deviceMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "device", currentUser.Id)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId device error")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByUserId device error")
|
||||
}
|
||||
|
||||
if deviceMethod.Id == 0 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "current user has no device identifier")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "current user has no device identifier")
|
||||
}
|
||||
|
||||
// 执行设备重新绑定,这会触发数据迁移
|
||||
@ -104,7 +112,7 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
|
||||
logger.Field("device_identifier", deviceMethod.AuthIdentifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind device to email user failed")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "bind device to email user failed")
|
||||
}
|
||||
|
||||
l.Infow("successfully bound device to email user with data migration",
|
||||
@ -112,6 +120,9 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
|
||||
logger.Field("email_user_id", emailUser.Id),
|
||||
logger.Field("device_identifier", deviceMethod.AuthIdentifier),
|
||||
)
|
||||
|
||||
// 数据迁移后,使用邮箱用户的ID
|
||||
finalUserId = emailUser.Id
|
||||
} else {
|
||||
// 邮箱未被绑定,直接为当前用户创建邮箱绑定
|
||||
emailMethod := &user.AuthMethods{
|
||||
@ -122,7 +133,7 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
|
||||
}
|
||||
|
||||
if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, emailMethod); err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
|
||||
}
|
||||
|
||||
l.Infow("successfully bound email to current user",
|
||||
@ -130,6 +141,35 @@ func (l *BindEmailWithPasswordLogic) BindEmailWithPassword(req *types.BindEmailW
|
||||
logger.Field("email", req.Email),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// 生成新的Token
|
||||
sessionId := uuidx.NewUUID().String()
|
||||
loginType := "device"
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
loginType = l.ctx.Value(constant.LoginType).(string)
|
||||
}
|
||||
|
||||
token, err := jwt.NewJwtToken(
|
||||
l.svcCtx.Config.JwtAuth.AccessSecret,
|
||||
time.Now().Unix(),
|
||||
l.svcCtx.Config.JwtAuth.AccessExpire,
|
||||
jwt.WithOption("UserId", finalUserId),
|
||||
jwt.WithOption("SessionId", sessionId),
|
||||
jwt.WithOption("LoginType", loginType),
|
||||
)
|
||||
if err != nil {
|
||||
l.Logger.Error("[BindEmailWithPassword] token generate error", logger.Field("error", err.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||
}
|
||||
|
||||
// 设置session缓存
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, finalUserId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||
}
|
||||
|
||||
return &types.LoginResponse{
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -30,37 +30,50 @@ func NewUpdateBindEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *U
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateBindEmail 更新用户绑定的邮箱地址
|
||||
// 该方法用于用户更新或绑定新的邮箱地址,支持首次绑定和修改已绑定邮箱
|
||||
func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest) error {
|
||||
// 从上下文中获取当前用户信息
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
logger.Error("current user is not found in context")
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 查询当前用户是否已有邮箱认证方式
|
||||
method, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", u.Id)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||
}
|
||||
|
||||
// 检查要绑定的邮箱是否已被其他用户使用
|
||||
m, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||
}
|
||||
// email already bind
|
||||
|
||||
// 如果邮箱已被绑定,返回错误
|
||||
if m.Id > 0 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind")
|
||||
}
|
||||
|
||||
// 如果用户还没有邮箱认证方式,创建新的认证记录
|
||||
if method.Id == 0 {
|
||||
method = &user.AuthMethods{
|
||||
UserId: u.Id,
|
||||
AuthType: "email",
|
||||
AuthIdentifier: req.Email,
|
||||
Verified: false,
|
||||
UserId: u.Id, // 用户ID
|
||||
AuthType: "email", // 认证类型为邮箱
|
||||
AuthIdentifier: req.Email, // 邮箱地址
|
||||
Verified: false, // 初始状态为未验证
|
||||
}
|
||||
// 插入新的认证方式记录
|
||||
if err := l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, method); err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "InsertUserAuthMethods error")
|
||||
}
|
||||
} else {
|
||||
method.Verified = false
|
||||
method.AuthIdentifier = req.Email
|
||||
// 如果用户已有邮箱认证方式,更新邮箱地址
|
||||
method.Verified = false // 重置验证状态
|
||||
method.AuthIdentifier = req.Email // 更新邮箱地址
|
||||
// 更新认证方式记录
|
||||
if err := l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method); err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
|
||||
}
|
||||
|
||||
@ -15,13 +15,16 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// VerifyEmailLogic 邮箱验证逻辑结构体
|
||||
// 用于处理用户邮箱验证码验证的业务逻辑
|
||||
type VerifyEmailLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// Verify Email
|
||||
// NewVerifyEmailLogic 创建邮箱验证逻辑实例
|
||||
// 用于初始化邮箱验证处理器
|
||||
func NewVerifyEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VerifyEmailLogic {
|
||||
return &VerifyEmailLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
@ -30,46 +33,68 @@ func NewVerifyEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Verif
|
||||
}
|
||||
}
|
||||
|
||||
// CacheKeyPayload Redis缓存中验证码的数据结构
|
||||
// 用于存储验证码和最后发送时间
|
||||
type CacheKeyPayload struct {
|
||||
Code string `json:"code"`
|
||||
LastAt int64 `json:"lastAt"`
|
||||
Code string `json:"code"` // 验证码
|
||||
LastAt int64 `json:"lastAt"` // 最后发送时间戳
|
||||
}
|
||||
|
||||
// VerifyEmail 验证邮箱验证码
|
||||
// 该方法用于验证用户输入的邮箱验证码是否正确,并将邮箱标记为已验证状态
|
||||
func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
||||
// 构建Redis缓存键,格式:认证码缓存前缀:安全标识:邮箱地址
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Security, req.Email)
|
||||
|
||||
// 从Redis中获取验证码缓存数据
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil {
|
||||
l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
|
||||
// 解析缓存中的验证码数据
|
||||
var payload CacheKeyPayload
|
||||
err = json.Unmarshal([]byte(value), &payload)
|
||||
if err != nil {
|
||||
l.Errorw("Redis Error", logger.Field("error", err.Error()), logger.Field("cacheKey", cacheKey))
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
|
||||
// 验证用户输入的验证码是否与缓存中的验证码匹配
|
||||
if payload.Code != req.Code {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
|
||||
}
|
||||
|
||||
// 验证成功后删除Redis中的验证码缓存(一次性使用)
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||
|
||||
// 从上下文中获取当前用户信息
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
logger.Error("current user is not found in context")
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
// 根据邮箱地址查找用户的邮箱认证方式记录
|
||||
method, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
|
||||
}
|
||||
|
||||
// 验证邮箱认证记录是否属于当前用户(安全检查)
|
||||
if method.UserId != u.Id {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
|
||||
}
|
||||
|
||||
// 将邮箱标记为已验证状态
|
||||
method.Verified = true
|
||||
|
||||
// 更新数据库中的认证方式记录
|
||||
err = l.svcCtx.UserModel.UpdateUserAuthMethods(l.ctx, method)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -172,8 +172,8 @@ func bindEmailWithPassword(serverURL, secret, token, email, password, userAgent
|
||||
|
||||
func main() {
|
||||
secret := "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
|
||||
serverURL := "http://localhost:8080"
|
||||
identifier := "AP4A.241205.013"
|
||||
serverURL := "http://127.0.0.1:8080"
|
||||
identifier := "AP4A.241205.A17"
|
||||
userAgent := "ppanel-go-test/1.0"
|
||||
|
||||
plain := map[string]interface{}{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user