feat(用户管理): 添加最后登录时间和会员状态功能
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 4m57s
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 4m57s
- 新增数据库迁移文件添加last_login_time字段 - 在登录逻辑中更新最后登录时间 - 添加FindActiveSubscribesByUserIds方法查询用户订阅状态 - 在用户列表接口中聚合最后登录时间和会员状态信息 - 更新相关API定义和模型结构 - 修复迁移文件版本号冲突问题 - 移除omitempty标签确保字段始终返回
This commit is contained in:
parent
5598181a48
commit
657c2930b1
44
.trae/documents/User Management Optimization Plan.md
Normal file
44
.trae/documents/User Management Optimization Plan.md
Normal file
@ -0,0 +1,44 @@
|
||||
# 用户管理系统优化方案 (最终确认版)
|
||||
|
||||
根据您的要求,我们将重点实现 `last_login_time` 字段的存储与返回,以及在列表接口中聚合会员套餐信息。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 1. 数据库变更
|
||||
- **文件**: `initialize/migrate/database/02121_add_user_last_login_time.up.sql`
|
||||
- **内容**:
|
||||
```sql
|
||||
ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time';
|
||||
```
|
||||
- **说明**: 相比查询日志表,直接在用户表增加字段能极大提高列表页查询性能。
|
||||
|
||||
### 2. API 定义更新
|
||||
- **文件**: `apis/types.api`
|
||||
- **内容**: 修改 `User` 结构体,增加以下返回字段:
|
||||
- `last_login_time` (int64): 最后活跃时间戳。
|
||||
- `member_status` (string): 会员状态(显示当前生效的订阅套餐名称,无订阅显示空或特定标识)。
|
||||
|
||||
### 3. 后端模型与逻辑更新
|
||||
#### 3.1 User 模型更新
|
||||
- **文件**: `internal/model/user/user.go`
|
||||
- **内容**: `User` 结构体增加 `LastLoginTime *time.Time` 字段。
|
||||
|
||||
#### 3.2 登录逻辑更新 (记录活跃时间)
|
||||
- **文件**: `internal/logic/auth/userLoginLogic.go` (及其他登录逻辑如 `emailLoginLogic.go`)
|
||||
- **内容**: 在登录成功后,异步或同步更新当前用户的 `last_login_time`。
|
||||
|
||||
#### 3.3 用户列表逻辑更新 (数据聚合)
|
||||
- **文件**: `internal/logic/admin/user/getUserListLogic.go`
|
||||
- **内容**:
|
||||
1. **获取用户列表**: 包含新增的 `LastLoginTime` 数据。
|
||||
2. **批量查询订阅**: 根据当前页的用户 ID 列表,批量查询其**活跃订阅** (Active Subscription)。
|
||||
3. **数据组装**:
|
||||
- 将 `LastLoginTime` 转换为时间戳返回。
|
||||
- 将订阅的 `Name` (套餐名) 赋值给 `member_status`。
|
||||
|
||||
### 4. 文档更新
|
||||
- **文件**: `doc/说明文档.md`
|
||||
- **内容**: 更新进度记录,标记完成“最后活跃”与“会员状态”字段开发。
|
||||
|
||||
## 验证与交付
|
||||
- 提供 `curl` 验证命令,确认 `/v1/admin/user/list` 接口返回的 JSON 中包含 `last_login_time` 和 `member_status`。
|
||||
29
.trae/documents/最后上线时间字段显示修复计划.md
Normal file
29
.trae/documents/最后上线时间字段显示修复计划.md
Normal file
@ -0,0 +1,29 @@
|
||||
# 最后上线时间字段显示修复计划
|
||||
|
||||
## 问题分析
|
||||
用户反馈调用 `curl` 接口后,返回的 JSON 中没有看到“最后上线时间” (`last_login_time`) 字段。
|
||||
|
||||
**原因可能如下:**
|
||||
1. **字段被 `omitempty` 隐藏**: 在 `internal/types/types.go` 中,`LastLoginTime` 字段定义为 `json:"last_login_time,omitempty"`。这意味着如果值为 `0`,该字段在 JSON 序列化时会被忽略,不返回给前端。
|
||||
2. **数据确实为空**: 用户从未登录过,且没有活跃订阅产生的流量记录,导致计算出的 `LastLoginTime` 为 `0`。
|
||||
3. **数据库迁移未生效**: 虽然我们之前修复了迁移文件冲突,但如果数据库中旧的迁移记录未清理或新字段 `last_login_time` 未真正添加成功,会导致数据读取失败(但此时通常会报错,而非字段缺失)。
|
||||
|
||||
## 解决方案
|
||||
为了确保接口始终返回该字段(即使是 0),我们需要移除 `omitempty` 标签,或者确认前端能处理缺失该字段的情况。考虑到用户明确要求“没看到”,建议移除 `omitempty`,让其显式返回 `0` 或时间戳。
|
||||
|
||||
同时,我们通过 SQL 检查数据库结构,确保字段已存在。
|
||||
|
||||
## 实施步骤
|
||||
1. **修改 API 定义**:
|
||||
- 文件: `internal/types/types.go` (及 `apis/types.api` 如果需要重新生成代码,但直接改 go 文件更快捷验证)
|
||||
- 操作: 将 `LastLoginTime int64 json:"last_login_time,omitempty"` 修改为 `json:"last_login_time"` (移除 `omitempty`)。
|
||||
- 同理处理 `MemberStatus` 字段。
|
||||
|
||||
2. **验证数据库字段**:
|
||||
- 使用 SQL 工具或日志确认 `user` 表中是否存在 `last_login_time` 列。
|
||||
|
||||
3. **验证接口**:
|
||||
- 再次调用 `curl`,确认即使值为 0 也会返回字段。
|
||||
|
||||
## 补充
|
||||
如果用户是指“有数据但没显示”,那可能是登录逻辑或流量更新逻辑未触发。但首要步骤是让字段显式返回,以便排查是“无数据”还是“字段被隐藏”。
|
||||
25
.trae/documents/迁移文件重复修复计划.md
Normal file
25
.trae/documents/迁移文件重复修复计划.md
Normal file
@ -0,0 +1,25 @@
|
||||
# 迁移文件重复问题修复计划
|
||||
|
||||
## 问题分析
|
||||
根据终端日志报错 `panic: failed to init driver with path database: duplicate migration file: 02121_apple_iap_transactions.down.sql`,系统启动失败的原因是存在**重复的迁移版本号**。
|
||||
|
||||
在 `initialize/migrate/database/` 目录下,存在两个版本号相同的迁移文件:
|
||||
1. `02121_add_user_last_login_time.up.sql` (我们刚刚创建的)
|
||||
2. `02121_apple_iap_transactions.up.sql` (已存在的)
|
||||
|
||||
由于 `golang-migrate` 要求版本号必须唯一,这两个文件都使用了 `02121` 前缀,导致冲突。
|
||||
|
||||
## 解决方案
|
||||
将我们新创建的 `add_user_last_login_time` 迁移文件的版本号递增为 `02122`。
|
||||
|
||||
## 实施步骤
|
||||
1. **重命名迁移文件**:
|
||||
- `02121_add_user_last_login_time.up.sql` -> `02122_add_user_last_login_time.up.sql`
|
||||
- `02121_add_user_last_login_time.down.sql` -> `02122_add_user_last_login_time.down.sql`
|
||||
|
||||
2. **验证**:
|
||||
- 确认目录下不再有重复前缀的文件。
|
||||
- 建议用户重新运行程序。
|
||||
|
||||
## 补充说明
|
||||
此操作仅涉及文件重命名,不修改文件内容,风险极低。
|
||||
@ -26,6 +26,8 @@ type (
|
||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||
LastLoginTime int64 `json:"last_login_time"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||
UserDevices []UserDevice `json:"user_devices"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE user DROP COLUMN last_login_time;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time';
|
||||
@ -39,12 +39,40 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetUserListLogic failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// Batch fetch active subscriptions
|
||||
userIds := make([]int64, 0, len(list))
|
||||
for _, u := range list {
|
||||
userIds = append(userIds, u.Id)
|
||||
}
|
||||
activeSubs, err := l.svcCtx.UserModel.FindActiveSubscribesByUserIds(l.ctx, userIds)
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
l.Logger.Error("FindActiveSubscribesByUserIds failed", logger.Field("error", err.Error()))
|
||||
}
|
||||
|
||||
userRespList := make([]types.User, 0, len(list))
|
||||
|
||||
for _, item := range list {
|
||||
var u types.User
|
||||
tool.DeepCopy(&u, item)
|
||||
|
||||
// Set LastLoginTime
|
||||
if item.LastLoginTime != nil {
|
||||
u.LastLoginTime = item.LastLoginTime.Unix()
|
||||
}
|
||||
|
||||
// Set MemberStatus and update LastLoginTime from traffic
|
||||
if info, ok := activeSubs[item.Id]; ok {
|
||||
u.MemberStatus = info.MemberStatus
|
||||
|
||||
if info.LastTrafficAt != nil {
|
||||
trafficTime := info.LastTrafficAt.Unix()
|
||||
if trafficTime > u.LastLoginTime {
|
||||
u.LastLoginTime = trafficTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 AuthMethods
|
||||
authMethods := make([]types.UserAuthMethod, len(u.AuthMethods)) // 直接创建目标 slice
|
||||
for i, method := range u.AuthMethods {
|
||||
|
||||
@ -44,46 +44,45 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
|
||||
// Verify Code
|
||||
// Using "Security" type or "Register"? Since it can be used for both, we need to know what the frontend requested.
|
||||
// But usually, the "Get Code" interface requires a "type".
|
||||
// If the user doesn't exist, they probably requested "Register" code or "Login" code?
|
||||
// Let's assume the frontend requests a "Security" code or a specific "Login" code.
|
||||
// However, looking at resetPasswordLogic, it uses `constant.Security`.
|
||||
// Looking at userRegisterLogic, it uses `constant.Register`.
|
||||
// Since this is a "Login" interface, but implicitly registers, we might need to check which code was sent.
|
||||
// Or, more robustly, we check both? Or we decide on one.
|
||||
// Usually "Login" implies "Security" or "Login" type.
|
||||
// If we assume the user calls `/verify/email` with type "login" (if it exists) or "register".
|
||||
// For simplicity, let's assume `constant.Security` (Common for login) or we need to support `constant.Register` if it's a new user flow?
|
||||
// User flow:
|
||||
// 1. Enter Email -> Click "Get Code". The type sent to "Get Code" determines the Redis key.
|
||||
// DOES the frontend know if the user exists? Probably not (Privacy).
|
||||
// So the frontend probably sends type="login" (or similar).
|
||||
// Let's check `constant` package for available types? I don't see it.
|
||||
// Assuming `constant.Security` for generic verification.
|
||||
scenes := []string{constant.Security.String(), constant.Register.String()}
|
||||
var verified bool
|
||||
var cacheKeyUsed string
|
||||
var payload common.CacheKeyPayload
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.Code == req.Code {
|
||||
verified = true
|
||||
cacheKeyUsed = cacheKey
|
||||
break
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
|
||||
|
||||
// But usually, the "Get Code" interface requires a "type".
|
||||
// If the user doesn't exist, they probably requested "Register" code or "Login" code?
|
||||
// Let's assume the frontend requests a "Security" code or a specific "Login" code.
|
||||
// However, looking at resetPasswordLogic, it uses `constant.Security`.
|
||||
// Looking at userRegisterLogic, it uses `constant.Register`.
|
||||
// Since this is a "Login" interface, but implicitly registers, we might need to check which code was sent.
|
||||
// Or, more robustly, we check both? Or we decide on one.
|
||||
// Usually "Login" implies "Security" or "Login" type.
|
||||
// If we assume the user calls `/verify/email` with type "login" (if it exists) or "register".
|
||||
// For simplicity, let's assume `constant.Security` (Common for login) or we need to support `constant.Register` if it's a new user flow?
|
||||
// User flow:
|
||||
// 1. Enter Email -> Click "Get Code". The type sent to "Get Code" determines the Redis key.
|
||||
// DOES the frontend know if the user exists? Probably not (Privacy).
|
||||
// So the frontend probably sends type="login" (or similar).
|
||||
// Let's check `constant` package for available types? I don't see it.
|
||||
// Assuming `constant.Security` for generic verification.
|
||||
scenes := []string{constant.Security.String(), constant.Register.String()}
|
||||
var verified bool
|
||||
var cacheKeyUsed string
|
||||
var payload common.CacheKeyPayload
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.Code == req.Code {
|
||||
verified = true
|
||||
cacheKeyUsed = cacheKey
|
||||
break
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
|
||||
|
||||
// Check User
|
||||
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
@ -113,9 +112,9 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
|
||||
// Create User
|
||||
// Use a random password for email login user? Or empty?
|
||||
// User model usually requires password? `userRegisterLogic` encodes it.
|
||||
// We can set a random high-entropy password since they use email code to login.
|
||||
pwd := tool.EncodePassWord(uuidx.NewUUID().String())
|
||||
// User model usually requires password? `userRegisterLogic` encodes it.
|
||||
// We can set a random high-entropy password since they use email code to login.
|
||||
pwd := tool.EncodePassWord(uuidx.NewUUID().String())
|
||||
userInfo = &user.User{
|
||||
Password: pwd,
|
||||
Algo: "default",
|
||||
@ -156,41 +155,51 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
|
||||
// Record login status
|
||||
defer func() {
|
||||
if userInfo.Id != 0 {
|
||||
loginLog := log.Login{
|
||||
Method: "email_code",
|
||||
LoginIP: req.IP,
|
||||
UserAgent: req.UserAgent,
|
||||
Success: loginStatus,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, _ := loginLog.Marshal()
|
||||
l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeLogin.Uint8(),
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
ObjectID: userInfo.Id,
|
||||
Content: string(content),
|
||||
})
|
||||
|
||||
if isNewUser {
|
||||
registerLog := log.Register{
|
||||
AuthMethod: "email_code",
|
||||
Identifier: req.Email,
|
||||
RegisterIP: req.IP,
|
||||
UserAgent: req.UserAgent,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
regContent, _ := registerLog.Marshal()
|
||||
l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeRegister.Uint8(),
|
||||
ObjectID: userInfo.Id,
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Content: string(regContent),
|
||||
})
|
||||
}
|
||||
}
|
||||
if userInfo.Id != 0 {
|
||||
loginLog := log.Login{
|
||||
Method: "email_code",
|
||||
LoginIP: req.IP,
|
||||
UserAgent: req.UserAgent,
|
||||
Success: loginStatus,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, _ := loginLog.Marshal()
|
||||
l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeLogin.Uint8(),
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
ObjectID: userInfo.Id,
|
||||
Content: string(content),
|
||||
})
|
||||
|
||||
if isNewUser {
|
||||
registerLog := log.Register{
|
||||
AuthMethod: "email_code",
|
||||
Identifier: req.Email,
|
||||
RegisterIP: req.IP,
|
||||
UserAgent: req.UserAgent,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
regContent, _ := registerLog.Marshal()
|
||||
l.svcCtx.LogModel.Insert(l.ctx, &log.SystemLog{
|
||||
Type: log.TypeRegister.Uint8(),
|
||||
ObjectID: userInfo.Id,
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Content: string(regContent),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
userInfo.LastLoginTime = &now
|
||||
if err := l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil {
|
||||
l.Errorw("failed to update last login time",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Login (Generate Token)
|
||||
if l.ctx.Value(constant.LoginType) != nil {
|
||||
req.LoginType = l.ctx.Value(constant.LoginType).(string)
|
||||
|
||||
@ -81,6 +81,16 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
userInfo.LastLoginTime = &now
|
||||
if err := l.svcCtx.UserModel.Update(l.ctx, userInfo); err != nil {
|
||||
l.Errorw("failed to update last login time",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// Bind device to user if identifier is provided
|
||||
if req.Identifier != "" {
|
||||
bindLogic := NewBindDeviceLogic(l.ctx, l.svcCtx)
|
||||
|
||||
@ -114,6 +114,12 @@ type customUserLogicModel interface {
|
||||
|
||||
QueryDailyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error)
|
||||
QueryMonthlyUserStatisticsList(ctx context.Context, date time.Time) ([]UserStatisticsWithDate, error)
|
||||
FindActiveSubscribesByUserIds(ctx context.Context, userIds []int64) (map[int64]*UserStatusInfo, error)
|
||||
}
|
||||
|
||||
type UserStatusInfo struct {
|
||||
MemberStatus string
|
||||
LastTrafficAt *time.Time
|
||||
}
|
||||
|
||||
type UserStatisticsWithDate struct {
|
||||
|
||||
45
internal/model/user/model_ext.go
Normal file
45
internal/model/user/model_ext.go
Normal file
@ -0,0 +1,45 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FindActiveSubscribesByUserIds Find active subscriptions for multiple users
|
||||
func (m *customUserModel) FindActiveSubscribesByUserIds(ctx context.Context, userIds []int64) (map[int64]*UserStatusInfo, error) {
|
||||
if len(userIds) == 0 {
|
||||
return map[int64]*UserStatusInfo{}, nil
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
UserId int64
|
||||
Name string
|
||||
UpdatedAt *time.Time
|
||||
}
|
||||
var results []Result
|
||||
|
||||
// Query latest active subscription for each user
|
||||
err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error {
|
||||
return conn.Table("user_subscribe").
|
||||
Select("user_subscribe.user_id, subscribe.name, user_subscribe.updated_at").
|
||||
Joins("LEFT JOIN subscribe ON user_subscribe.subscribe_id = subscribe.id").
|
||||
Where("user_subscribe.user_id IN ? AND user_subscribe.status IN (0, 1) AND user_subscribe.expire_time > ?", userIds, time.Now()).
|
||||
Order("user_subscribe.created_at ASC"). // Ascending so we can overwrite in map to get the latest
|
||||
Scan(v).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userMap := make(map[int64]*UserStatusInfo)
|
||||
for _, r := range results {
|
||||
userMap[r.UserId] = &UserStatusInfo{
|
||||
MemberStatus: r.Name,
|
||||
LastTrafficAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return userMap, nil
|
||||
}
|
||||
@ -23,6 +23,7 @@ type User struct {
|
||||
EnableLoginNotify *bool `gorm:"default:false;not null;comment:Enable Login Notifications"`
|
||||
EnableSubscribeNotify *bool `gorm:"default:false;not null;comment:Enable Subscription Notifications"`
|
||||
EnableTradeNotify *bool `gorm:"default:false;not null;comment:Enable Trade Notifications"`
|
||||
LastLoginTime *time.Time `gorm:"comment:Last Login Time"`
|
||||
AuthMethods []AuthMethods `gorm:"foreignKey:UserId;references:Id"`
|
||||
UserDevices []Device `gorm:"foreignKey:UserId;references:Id"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||
|
||||
@ -2597,6 +2597,8 @@ type User struct {
|
||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||
LastLoginTime int64 `json:"last_login_time"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||
UserDevices []UserDevice `json:"user_devices"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user