From 657c2930b1c19b63dd6c81e08cd884927f925c32 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Mon, 5 Jan 2026 01:46:39 -0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=9C=80=E5=90=8E=E7=99=BB=E5=BD=95=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=92=8C=E4=BC=9A=E5=91=98=E7=8A=B6=E6=80=81=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增数据库迁移文件添加last_login_time字段 - 在登录逻辑中更新最后登录时间 - 添加FindActiveSubscribesByUserIds方法查询用户订阅状态 - 在用户列表接口中聚合最后登录时间和会员状态信息 - 更新相关API定义和模型结构 - 修复迁移文件版本号冲突问题 - 移除omitempty标签确保字段始终返回 --- .../User Management Optimization Plan.md | 44 +++++ .../documents/最后上线时间字段显示修复计划.md | 29 ++++ .trae/documents/迁移文件重复修复计划.md | 25 +++ apis/types.api | 2 + .../02122_add_user_last_login_time.down.sql | 1 + .../02122_add_user_last_login_time.up.sql | 1 + internal/logic/admin/user/getUserListLogic.go | 28 +++ internal/logic/auth/emailLoginLogic.go | 161 +++++++++--------- internal/logic/auth/userLoginLogic.go | 10 ++ internal/model/user/model.go | 6 + internal/model/user/model_ext.go | 45 +++++ internal/model/user/user.go | 1 + internal/types/types.go | 2 + 13 files changed, 279 insertions(+), 76 deletions(-) create mode 100644 .trae/documents/User Management Optimization Plan.md create mode 100644 .trae/documents/最后上线时间字段显示修复计划.md create mode 100644 .trae/documents/迁移文件重复修复计划.md create mode 100644 initialize/migrate/database/02122_add_user_last_login_time.down.sql create mode 100644 initialize/migrate/database/02122_add_user_last_login_time.up.sql create mode 100644 internal/model/user/model_ext.go diff --git a/.trae/documents/User Management Optimization Plan.md b/.trae/documents/User Management Optimization Plan.md new file mode 100644 index 0000000..33b8e5f --- /dev/null +++ b/.trae/documents/User Management Optimization Plan.md @@ -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`。 diff --git a/.trae/documents/最后上线时间字段显示修复计划.md b/.trae/documents/最后上线时间字段显示修复计划.md new file mode 100644 index 0000000..ef17557 --- /dev/null +++ b/.trae/documents/最后上线时间字段显示修复计划.md @@ -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 也会返回字段。 + +## 补充 +如果用户是指“有数据但没显示”,那可能是登录逻辑或流量更新逻辑未触发。但首要步骤是让字段显式返回,以便排查是“无数据”还是“字段被隐藏”。 diff --git a/.trae/documents/迁移文件重复修复计划.md b/.trae/documents/迁移文件重复修复计划.md new file mode 100644 index 0000000..acbb3c3 --- /dev/null +++ b/.trae/documents/迁移文件重复修复计划.md @@ -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. **验证**: + - 确认目录下不再有重复前缀的文件。 + - 建议用户重新运行程序。 + +## 补充说明 +此操作仅涉及文件重命名,不修改文件内容,风险极低。 diff --git a/apis/types.api b/apis/types.api index 3cecd4a..783566b 100644 --- a/apis/types.api +++ b/apis/types.api @@ -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"` diff --git a/initialize/migrate/database/02122_add_user_last_login_time.down.sql b/initialize/migrate/database/02122_add_user_last_login_time.down.sql new file mode 100644 index 0000000..ca8258a --- /dev/null +++ b/initialize/migrate/database/02122_add_user_last_login_time.down.sql @@ -0,0 +1 @@ +ALTER TABLE user DROP COLUMN last_login_time; diff --git a/initialize/migrate/database/02122_add_user_last_login_time.up.sql b/initialize/migrate/database/02122_add_user_last_login_time.up.sql new file mode 100644 index 0000000..7180c05 --- /dev/null +++ b/initialize/migrate/database/02122_add_user_last_login_time.up.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time'; diff --git a/internal/logic/admin/user/getUserListLogic.go b/internal/logic/admin/user/getUserListLogic.go index fca5cb4..b2c27a9 100644 --- a/internal/logic/admin/user/getUserListLogic.go +++ b/internal/logic/admin/user/getUserListLogic.go @@ -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 { diff --git a/internal/logic/auth/emailLoginLogic.go b/internal/logic/auth/emailLoginLogic.go index 545d086..cebd312 100644 --- a/internal/logic/auth/emailLoginLogic.go +++ b/internal/logic/auth/emailLoginLogic.go @@ -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) diff --git a/internal/logic/auth/userLoginLogic.go b/internal/logic/auth/userLoginLogic.go index 6fe2b72..d63060f 100644 --- a/internal/logic/auth/userLoginLogic.go +++ b/internal/logic/auth/userLoginLogic.go @@ -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) diff --git a/internal/model/user/model.go b/internal/model/user/model.go index 0f254bc..94a87a6 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -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 { diff --git a/internal/model/user/model_ext.go b/internal/model/user/model_ext.go new file mode 100644 index 0000000..2f4f84e --- /dev/null +++ b/internal/model/user/model_ext.go @@ -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 +} diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 98bfbcb..9318aaf 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -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"` diff --git a/internal/types/types.go b/internal/types/types.go index b5d1190..58fb7b0 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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"`