diff --git a/api/admin/admin.go b/api/admin/admin.go index 1a01448..e50d052 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -17,6 +17,7 @@ type IAdminV1 interface { AdminUserDelete(ctx context.Context, req *v1.AdminUserDeleteReq) (res *v1.AdminUserDeleteRes, err error) AdminUserUpdateStatus(ctx context.Context, req *v1.AdminUserUpdateStatusReq) (res *v1.AdminUserUpdateStatusRes, err error) AdminUserResetPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (res *v1.AdminUserResetPasswordRes, err error) + AdminUserUnlock(ctx context.Context, req *v1.AdminUserUnlockReq) (res *v1.AdminUserUnlockRes, err error) AdminUserExport(ctx context.Context, req *v1.AdminUserExportReq) (res *v1.AdminUserExportRes, err error) AgreementCreate(ctx context.Context, req *v1.AgreementCreateReq) (res *v1.AgreementCreateRes, err error) AgreementList(ctx context.Context, req *v1.AgreementListReq) (res *v1.AgreementListRes, err error) @@ -179,6 +180,7 @@ type IAdminV1 interface { AdminMemberDisable(ctx context.Context, req *v1.AdminMemberDisableReq) (res *v1.AdminMemberDisableRes, err error) AdminMemberEnable(ctx context.Context, req *v1.AdminMemberEnableReq) (res *v1.AdminMemberEnableRes, err error) AdminMemberResetPassword(ctx context.Context, req *v1.AdminMemberResetPasswordReq) (res *v1.AdminMemberResetPasswordRes, err error) + AdminMemberUnlock(ctx context.Context, req *v1.AdminMemberUnlockReq) (res *v1.AdminMemberUnlockRes, err error) PaymentChannelList(ctx context.Context, req *v1.PaymentChannelListReq) (res *v1.PaymentChannelListRes, err error) PaymentChannelSave(ctx context.Context, req *v1.PaymentChannelSaveReq) (res *v1.PaymentChannelSaveRes, err error) PaymentSettingsGet(ctx context.Context, req *v1.PaymentSettingsGetReq) (res *v1.PaymentSettingsGetRes, err error) diff --git a/api/admin/v1/admin_user.go b/api/admin/v1/admin_user.go index d263a7e..f43a024 100644 --- a/api/admin/v1/admin_user.go +++ b/api/admin/v1/admin_user.go @@ -28,6 +28,7 @@ type AdminUserItem struct { Status string `json:"status"` LastLoginAt string `json:"last_login_at"` LastLoginIp string `json:"last_login_ip"` + LockedUntil string `json:"locked_until"` CreatedAt string `json:"created_at"` } @@ -81,6 +82,14 @@ type AdminUserResetPasswordReq struct { type AdminUserResetPasswordRes struct{} +// AdminUserUnlockReq 解除管理员登录锁定请求 +type AdminUserUnlockReq struct { + g.Meta `path:"/users/{id}/unlock" method:"put" mime:"json" tags:"管理后台-用户管理" summary:"解除管理员登录锁定"` + Id int64 `json:"id" in:"path" v:"required" dc:"管理员ID"` +} + +type AdminUserUnlockRes struct{} + // AdminUserExportReq 导出用户列表请求 type AdminUserExportReq struct { g.Meta `path:"/users/export" method:"get" mime:"json" tags:"管理后台-用户管理" summary:"导出用户列表"` diff --git a/api/admin/v1/payment.go b/api/admin/v1/payment.go index 76b5b52..0e239a0 100644 --- a/api/admin/v1/payment.go +++ b/api/admin/v1/payment.go @@ -41,6 +41,13 @@ type AdminMemberResetPasswordRes struct { NewPassword string `json:"new_password"` } +type AdminMemberUnlockReq struct { + g.Meta `path:"/members/{id}/unlock" method:"put" mime:"json" tags:"管理后台-成员" summary:"解除成员登录锁定"` + Id int64 `json:"id" in:"path" v:"required|min:1" dc:"成员ID"` +} + +type AdminMemberUnlockRes struct{} + // === 支付渠道管理(单例配置模式:每种渠道类型只有一个配置) === // PaymentChannelListReq 获取所有渠道配置 diff --git a/api/admin/v1/tenant.go b/api/admin/v1/tenant.go index 62898cd..24cf348 100644 --- a/api/admin/v1/tenant.go +++ b/api/admin/v1/tenant.go @@ -128,6 +128,7 @@ type AdminMemberItem struct { LastLoginAt string `json:"last_login_at"` LastLoginIP string `json:"last_login_ip"` FailedAttempts int `json:"failed_attempts"` + LockedUntil string `json:"locked_until"` CreatedAt string `json:"created_at"` } diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index 9df7d76..f4c36cb 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -67,6 +67,7 @@ type ITenantV1 interface { TenantMemberResetPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (res *v1.TenantMemberResetPasswordRes, err error) TenantMemberRemove(ctx context.Context, req *v1.TenantMemberRemoveReq) (res *v1.TenantMemberRemoveRes, err error) TenantMemberUpdateRole(ctx context.Context, req *v1.TenantMemberUpdateRoleReq) (res *v1.TenantMemberUpdateRoleRes, err error) + TenantMemberUnlock(ctx context.Context, req *v1.TenantMemberUnlockReq) (res *v1.TenantMemberUnlockRes, err error) TenantMemberGet(ctx context.Context, req *v1.TenantMemberGetReq) (res *v1.TenantMemberGetRes, err error) TenantMemberUsage(ctx context.Context, req *v1.TenantMemberUsageReq) (res *v1.TenantMemberUsageRes, err error) TenantMemberApiKeys(ctx context.Context, req *v1.TenantMemberApiKeysReq) (res *v1.TenantMemberApiKeysRes, err error) diff --git a/api/tenant/v1/member.go b/api/tenant/v1/member.go index 3e610cb..5139708 100644 --- a/api/tenant/v1/member.go +++ b/api/tenant/v1/member.go @@ -25,6 +25,7 @@ type TenantMemberItem struct { DisplayName string `json:"display_name"` Role string `json:"role"` Status string `json:"status"` + LockedUntil string `json:"locked_until"` CreatedAt string `json:"created_at"` } @@ -152,6 +153,14 @@ type TenantMemberUpdateRoleReq struct { type TenantMemberUpdateRoleRes struct{} +// TenantMemberUnlockReq 解除成员登录锁定请求 +type TenantMemberUnlockReq struct { + g.Meta `path:"/members/{id}/unlock" method:"put" mime:"json" tags:"租户控制台-成员管理" summary:"解除成员登录锁定"` + Id int64 `json:"id" in:"path" v:"required" dc:"成员ID"` +} + +type TenantMemberUnlockRes struct{} + // TenantMemberGetReq 成员详情请求 type TenantMemberGetReq struct { g.Meta `path:"/members/{id}" method:"get" mime:"json" tags:"租户控制台-成员管理" summary:"成员详情"` @@ -165,6 +174,7 @@ type TenantMemberGetRes struct { DisplayName string `json:"display_name"` Role string `json:"role"` Status string `json:"status"` + LockedUntil string `json:"locked_until"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 896be81..0d4f6f0 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -566,3 +566,53 @@ t.Status = "FAILURE" dao.Xxx.Ctx(ctx).Where("id", t.ID).Where("status", oldStatus).Update(...) // WHERE status='IN_PROGRESS' ``` +### 2026-07-24:DO 更新无法置 NULL,且强类型时间字段连 gdb.Raw 也不可用 + +**问题**:用户解锁接口返回成功,但数据库 `locked_until` 未被清除(仍是锁定时间),账号依旧登不进。同样的写法潜伏在登录成功重置、锁定过期自愈、重置密码顺带清锁,共 9 处。 + +**原因(两层)**: + +1. **DO 更新对 nil 字段的 omit 行为**:GoFrame DO 对象更新时,值为 nil 的字段会被自动跳过、不进入 SET 子句("部分更新"设计意图,见 2026-05-28)。`Data(do.SysAdminUsers{..., LockedUntil: nil})` 中 `LockedUntil: nil` 被当作"未设置"而非"写 NULL",列原值保留。`Update` 不报错、affected rows 可能为 0,接口照常返回成功——极其隐蔽。 + +2. **本项目 DO 是混合类型**(`internal/model/do/sys_admin_users.go` 实测): + - `FailedAttempts any` —— 标准接口字段,赋 `0`(非 nil)正常写入 + - `LockedUntil *gtime.Time` —— **强类型时间指针**(非 any),赋 nil 被 omit + +**官方推荐与本项目现实的冲突**: + +GoFrame 官方(goframe-v2 skill)对"置 NULL"的推荐做法是 DO + `gdb.Raw("NULL")`: +```go +dao.Instances.Ctx(ctx).Where(cols.Id, id).Data(do.Instance{IdleSince: gdb.Raw("NULL")}).Update() +``` +但这要求该 DO 字段是 `any`/`interface{}`(才能接受 `gdb.Raw` 这个字符串类型)。**本项目的 `LockedUntil` 是强类型 `*gtime.Time`**,`gdb.Raw("NULL")` 无法赋值,编译报 `cannot use gdb.Raw("NULL") (of type gdb.Raw) as *gtime.Time`。官方推荐的 `gdb.Raw` 路径在本项目此类强类型字段上**不适用**(已实测编译失败)。 + +**修复**:本项目对强类型 `*gtime.Time` 字段置 NULL,只能用 `map[string]interface{}`(map 不触发 omit,nil 显式写 NULL): +```go +// 错误 — DO 的 nil 被 omit,locked_until 不会被置 NULL +dao.Xxx.Ctx(ctx).Where("id", id).Data(do.Xxx{ + FailedAttempts: 0, + LockedUntil: nil, // ← 被 omit,列原值保留 +}).Update() + +// 错误 — 本项目 LockedUntil 是强类型 *gtime.Time,gdb.Raw 类型不匹配,编译失败 +dao.Xxx.Ctx(ctx).Where("id", id).Data(do.Xxx{ + LockedUntil: gdb.Raw("NULL"), // ← cannot use gdb.Raw as *gtime.Time +}).Update() + +// 正确(本项目强类型时间字段置 NULL 的唯一可行途径)— map +dao.Xxx.Ctx(ctx).Where("id", id).Data(map[string]interface{}{ + "failed_attempts": 0, + "locked_until": nil, // ← 写入 NULL +}).Update() +``` +> 若 DO 字段是 `any`(如本项目的 `FailedAttempts`),官方 `gdb.Raw("NULL")` 可用、DO 方案更合规。本记录的 map 方案**仅针对强类型字段**(`*gtime.Time` 等)置 NULL 这一本项目特定场景;此处为一致起见,`failed_attempts` 也一并用 map。 + +**影响范围(9 处,本次全修)**:`admin/admin_user.go`(UnlockUser)、`admin/auth.go`(登录成功重置 + 锁定过期自愈)、`admin/member.go`(ResetMemberPassword + UnlockMember)、`tenant/auth.go`(登录成功重置 + 锁定过期自愈)、`tenant/member.go`(UnlockMember)、`tenant/email.go`(邮箱重置密码)。 + +**正确做法(按字段类型选择)**: +- 普通部分更新(非空字段)→ 用 DO struct(见 2026-05-28) +- 置 NULL/清空**且字段是 `any`/`interface{}`** → DO + `gdb.Raw("NULL")`(GoFrame 官方推荐) +- 置 NULL/清空**且字段是强类型**(本项目 `*gtime.Time`)→ `map[string]interface{}`(`gdb.Raw` 类型不匹配,map 是唯一途径) + +排查信号:接口返回成功但数据库某可空字段没变化,且代码用了 `Data(do.Xxx{Field: nil})`。 + diff --git a/internal/controller/admin/admin_v1_admin_user.go b/internal/controller/admin/admin_v1_admin_user.go index 60369ce..b3fc86f 100644 --- a/internal/controller/admin/admin_v1_admin_user.go +++ b/internal/controller/admin/admin_v1_admin_user.go @@ -28,3 +28,6 @@ func (c *ControllerV1) AdminUserResetPassword(ctx context.Context, req *v1.Admin func (c *ControllerV1) AdminUserExport(ctx context.Context, req *v1.AdminUserExportReq) (res *v1.AdminUserExportRes, err error) { return service.Admin().ExportUsers(ctx, req) } +func (c *ControllerV1) AdminUserUnlock(ctx context.Context, req *v1.AdminUserUnlockReq) (res *v1.AdminUserUnlockRes, err error) { + return service.Admin().UnlockUser(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_payment.go b/internal/controller/admin/admin_v1_payment.go index d2eaec8..9a81ddb 100644 --- a/internal/controller/admin/admin_v1_payment.go +++ b/internal/controller/admin/admin_v1_payment.go @@ -31,3 +31,6 @@ func (c *ControllerV1) PaymentSettingsGet(ctx context.Context, req *v1.PaymentSe func (c *ControllerV1) PaymentSettingsUpdate(ctx context.Context, req *v1.PaymentSettingsUpdateReq) (res *v1.PaymentSettingsUpdateRes, err error) { return service.Admin().UpdatePaymentSettings(ctx, req) } +func (c *ControllerV1) AdminMemberUnlock(ctx context.Context, req *v1.AdminMemberUnlockReq) (res *v1.AdminMemberUnlockRes, err error) { + return service.Admin().UnlockMember(ctx, req) +} diff --git a/internal/controller/tenant/tenant_v1_member.go b/internal/controller/tenant/tenant_v1_member.go index 90370aa..6b39e95 100644 --- a/internal/controller/tenant/tenant_v1_member.go +++ b/internal/controller/tenant/tenant_v1_member.go @@ -49,3 +49,6 @@ func (c *ControllerV1) TenantMemberApiKeys(ctx context.Context, req *v1.TenantMe func (c *ControllerV1) TenantMemberExport(ctx context.Context, req *v1.TenantMemberExportReq) (res *v1.TenantMemberExportRes, err error) { return service.Tenant().ExportMembers(ctx, req) } +func (c *ControllerV1) TenantMemberUnlock(ctx context.Context, req *v1.TenantMemberUnlockReq) (res *v1.TenantMemberUnlockRes, err error) { + return service.Tenant().UnlockMember(ctx, req) +} diff --git a/internal/logic/admin/admin_user.go b/internal/logic/admin/admin_user.go index e1fb0fc..99a9d92 100644 --- a/internal/logic/admin/admin_user.go +++ b/internal/logic/admin/admin_user.go @@ -36,6 +36,7 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A Status string `json:"status"` LastLoginAt *gtime.Time `json:"last_login_at"` LastLoginIp string `json:"last_login_ip"` + LockedUntil *gtime.Time `json:"locked_until"` CreatedAt *gtime.Time `json:"created_at"` } err = m.OrderDesc("id"). @@ -47,6 +48,10 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A items := make([]v1.AdminUserItem, len(users)) for i, u := range users { + lockedUntil := "" + if u.LockedUntil != nil { + lockedUntil = u.LockedUntil.String() + } items[i] = v1.AdminUserItem{ ID: u.Id, Username: u.Username, @@ -56,6 +61,7 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A Status: u.Status, LastLoginAt: u.LastLoginAt.String(), LastLoginIp: u.LastLoginIp, + LockedUntil: lockedUntil, CreatedAt: u.CreatedAt.String(), } } @@ -276,6 +282,29 @@ func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateSt return nil, nil } +// UnlockUser 清除管理员的登录锁定状态(重置失败计数与锁定截止时间)。 +func (s *sAdmin) UnlockUser(ctx context.Context, req *v1.AdminUserUnlockReq) (*v1.AdminUserUnlockRes, error) { + var user *struct { + Id int64 `json:"id"` + } + err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Scan(&user) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if user == nil { + return nil, common.NewNotFoundError("管理员") + } + + _, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Data(map[string]interface{}{ + "failed_attempts": 0, + "locked_until": nil, + }).Update() + if err != nil { + return nil, err + } + return nil, nil +} + // ResetUserPassword resets an admin user's password. func (s *sAdmin) ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (*v1.AdminUserResetPasswordRes, error) { if err := common.ValidatePassword(req.NewPassword); err != nil { diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index 086fdb6..4c04265 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -58,11 +58,27 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog } // Check account lockout - if user.LockedUntil != nil && time.Now().Before(user.LockedUntil.Time) { - remaining := time.Until(user.LockedUntil.Time).Minutes() - _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "账号已锁定") - return nil, common.NewBusinessError(consts.CodeAccountLocked, - fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining))) + if user.LockedUntil != nil { + if time.Now().Before(user.LockedUntil.Time) { + // 仍在锁定期内 + remaining := time.Until(user.LockedUntil.Time).Minutes() + _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "账号已锁定") + return nil, common.NewBusinessError(consts.CodeAccountLocked, + fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining))) + } + // 锁定已过期:重置失败计数,给用户重新 5 次机会(方案A) + _, err := dao.SysAdminUsers.Ctx(ctx). + Where("id", user.Id). + Data(map[string]interface{}{ + "failed_attempts": 0, + "locked_until": nil, + }).Update() + if err != nil { + g.Log().Errorf(ctx, "重置管理员锁定状态失败: %v", err) + } + // 同步更新内存对象,避免后续密码校验仍读到旧的失败计数 + user.FailedAttempts = 0 + user.LockedUntil = nil } // Verify password @@ -100,9 +116,9 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog if user.FailedAttempts > 0 { _, err := dao.SysAdminUsers.Ctx(ctx). Where("id", user.Id). - Data(do.SysAdminUsers{ - FailedAttempts: 0, - LockedUntil: nil, + Data(map[string]interface{}{ + "failed_attempts": 0, + "locked_until": nil, }).Update() if err != nil { g.Log().Errorf(ctx, "重置管理员登录失败次数失败: %v", err) diff --git a/internal/logic/admin/member.go b/internal/logic/admin/member.go index b670bb3..63eca45 100644 --- a/internal/logic/admin/member.go +++ b/internal/logic/admin/member.go @@ -157,9 +157,10 @@ func (s *sAdmin) ListAllMembers(ctx context.Context, req *v1.AdminMemberListReq) LastLoginAt *gtime.Time `json:"last_login_at"` LastLoginIp string `json:"last_login_ip"` FailedAttempts int `json:"failed_attempts"` + LockedUntil *gtime.Time `json:"locked_until"` CreatedAt *gtime.Time `json:"created_at"` } - err = m.Fields("tnt_users.id, tnt_users.tenant_id, t.name as tenant_name, t.code as tenant_code, tnt_users.username, tnt_users.email, tnt_users.display_name, tnt_users.role, tnt_users.status, tnt_users.last_login_at, tnt_users.last_login_ip, tnt_users.failed_attempts, tnt_users.created_at"). + err = m.Fields("tnt_users.id, tnt_users.tenant_id, t.name as tenant_name, t.code as tenant_code, tnt_users.username, tnt_users.email, tnt_users.display_name, tnt_users.role, tnt_users.status, tnt_users.last_login_at, tnt_users.last_login_ip, tnt_users.failed_attempts, tnt_users.locked_until, tnt_users.created_at"). OrderDesc("tnt_users.id"). Page(page, pageSize). Scan(&members) @@ -169,6 +170,10 @@ func (s *sAdmin) ListAllMembers(ctx context.Context, req *v1.AdminMemberListReq) items := make([]v1.AdminMemberItem, len(members)) for i, m := range members { + lockedUntil := "" + if m.LockedUntil != nil { + lockedUntil = m.LockedUntil.String() + } items[i] = v1.AdminMemberItem{ ID: m.Id, TenantID: m.TenantId, @@ -182,6 +187,7 @@ func (s *sAdmin) ListAllMembers(ctx context.Context, req *v1.AdminMemberListReq) LastLoginAt: m.LastLoginAt.String(), LastLoginIP: m.LastLoginIp, FailedAttempts: m.FailedAttempts, + LockedUntil: lockedUntil, CreatedAt: m.CreatedAt.String(), } } @@ -311,10 +317,10 @@ func (s *sAdmin) ResetMemberPassword(ctx context.Context, req *v1.AdminMemberRes _, err = dao.TntUsers.Ctx(ctx). Where("id", req.Id). - Data(do.TntUsers{ - PasswordHash: passwordHash, - FailedAttempts: 0, - LockedUntil: nil, + Data(map[string]interface{}{ + "password_hash": passwordHash, + "failed_attempts": 0, + "locked_until": nil, }).Update() if err != nil { return nil, err @@ -323,6 +329,31 @@ func (s *sAdmin) ResetMemberPassword(ctx context.Context, req *v1.AdminMemberRes return &v1.AdminMemberResetPasswordRes{NewPassword: newPassword}, nil } +// UnlockMember 清除成员的登录锁定状态(平台视角,跨租户)。 +func (s *sAdmin) UnlockMember(ctx context.Context, req *v1.AdminMemberUnlockReq) (*v1.AdminMemberUnlockRes, error) { + var user *struct { + Id int64 `json:"id"` + } + err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Scan(&user) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if user == nil { + return nil, common.NewNotFoundError("成员") + } + + _, err = dao.TntUsers.Ctx(ctx). + Where("id", req.Id). + Data(map[string]interface{}{ + "failed_attempts": 0, + "locked_until": nil, + }).Update() + if err != nil { + return nil, err + } + return nil, nil +} + // buildMemberFilters builds the WHERE conditions for member queries. func buildMemberFilters(m *gdb.Model, keyword, status, role string, tenantID int64) *gdb.Model { if keyword != "" { diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 604eadb..9198d58 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -357,11 +357,27 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant } // Check account lockout - if user.LockedUntil != nil && time.Now().Before(user.LockedUntil.Time) { - remaining := time.Until(user.LockedUntil.Time).Minutes() - _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已锁定") - return nil, common.NewBusinessError(consts.CodeAccountLocked, - fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining))) + if user.LockedUntil != nil { + if time.Now().Before(user.LockedUntil.Time) { + // 仍在锁定期内 + remaining := time.Until(user.LockedUntil.Time).Minutes() + _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已锁定") + return nil, common.NewBusinessError(consts.CodeAccountLocked, + fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining))) + } + // 锁定已过期:重置失败计数,给用户重新 5 次机会(方案A) + _, err := dao.TntUsers.Ctx(ctx). + Where("id", user.Id). + Data(map[string]interface{}{ + "failed_attempts": 0, + "locked_until": nil, + }).Update() + if err != nil { + g.Log().Errorf(ctx, "重置租户账号锁定状态失败: %v", err) + } + // 同步更新内存对象,避免后续密码校验仍读到旧的失败计数 + user.FailedAttempts = 0 + user.LockedUntil = nil } // Check status @@ -411,9 +427,9 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant if user.FailedAttempts > 0 { _, err := dao.TntUsers.Ctx(ctx). Where("id", user.Id). - Data(do.TntUsers{ - FailedAttempts: 0, - LockedUntil: nil, + Data(map[string]interface{}{ + "failed_attempts": 0, + "locked_until": nil, }).Update() if err != nil { g.Log().Errorf(ctx, "重置登录失败次数失败: %v", err) diff --git a/internal/logic/tenant/email.go b/internal/logic/tenant/email.go index 01a42a1..33b8b64 100644 --- a/internal/logic/tenant/email.go +++ b/internal/logic/tenant/email.go @@ -107,10 +107,10 @@ func (s *sTenant) ResetPassword(ctx context.Context, req *v1.TenantResetPassword // Update password _, err = dao.TntUsers.Ctx(ctx). Where("id", user.Id). - Data(do.TntUsers{ - PasswordHash: passwordHash, - FailedAttempts: 0, - LockedUntil: nil, + Data(map[string]interface{}{ + "password_hash": passwordHash, + "failed_attempts": 0, + "locked_until": nil, }).Update() if err != nil { return nil, err diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 170a091..62c2681 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -54,6 +54,7 @@ func (s *sTenant) ListMembers(ctx context.Context, req *v1.TenantMemberListReq) DisplayName string `json:"display_name"` Role string `json:"role"` Status string `json:"status"` + LockedUntil string `json:"locked_until"` CreatedAt string `json:"created_at"` } err = model.OrderDesc("id"). @@ -72,6 +73,7 @@ func (s *sTenant) ListMembers(ctx context.Context, req *v1.TenantMemberListReq) DisplayName: u.DisplayName, Role: u.Role, Status: u.Status, + LockedUntil: u.LockedUntil, CreatedAt: u.CreatedAt, } } @@ -800,6 +802,44 @@ func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberR return nil, nil } +// UnlockMember 解除成员登录锁定。仅 owner/admin 可操作;租户隔离双键校验。 +func (s *sTenant) UnlockMember(ctx context.Context, req *v1.TenantMemberUnlockReq) (*v1.TenantMemberUnlockRes, error) { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } + tenantID := middleware.GetTenantID(ctx) + + var user *struct { + Id int64 `json:"id"` + } + err := dao.TntUsers.Ctx(ctx). + Where("id", req.Id). + Where("tenant_id", tenantID). + Scan(&user) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if user == nil { + return nil, common.NewNotFoundError("成员") + } + + _, err = dao.TntUsers.Ctx(ctx). + Where("id", req.Id). + Where("tenant_id", tenantID). + Data(map[string]interface{}{ + "failed_attempts": 0, + "locked_until": nil, + }).Update() + if err != nil { + return nil, err + } + return nil, nil +} + // GetMember returns a single member's detail. func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v1.TenantMemberGetRes, error) { tenantID := middleware.GetTenantID(ctx) @@ -811,6 +851,7 @@ func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v DisplayName string `json:"display_name"` Role string `json:"role"` Status string `json:"status"` + LockedUntil string `json:"locked_until"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -832,6 +873,7 @@ func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v DisplayName: user.DisplayName, Role: user.Role, Status: user.Status, + LockedUntil: user.LockedUntil, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, }, nil diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 582895c..4a51582 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -67,6 +67,7 @@ var adminPermissionRules = []permissionRule{ {method: "POST", path: "/api/admin/users", perm: "user:create"}, {method: "PUT", prefix: "/api/admin/users/", suffix: "/status", perm: "user:edit"}, {method: "PUT", prefix: "/api/admin/users/", suffix: "/reset-password", perm: "user:edit"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/unlock", perm: "user:edit"}, {method: "PUT", prefix: "/api/admin/users/", perm: "user:edit"}, {method: "DELETE", prefix: "/api/admin/users/", perm: "user:delete"}, @@ -260,6 +261,7 @@ var adminPermissionRules = []permissionRule{ {method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:view"}, {method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:view"}, {method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:view"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:view"}, // ── redemption 兑换码管理 ── {method: "GET", path: "/api/admin/redemptions", perm: "redemption:view"}, @@ -401,7 +403,7 @@ func matchPermission(method, path string) string { // suffixMatches 判断 path 是否按【路径分段边界】匹配规则后缀 suffix,避免子串误匹配。 // 旧实现用 strings.Contains(remainder, "test") 会把 /.../latest 误判为命中 /test 规则 -//("latest" 里含 "test"),从而套用错误的权限点。 +// ("latest" 里含 "test"),从而套用错误的权限点。 // - suffix 以 "/" 结尾(如 "/keys/",用于匹配 /.../keys/{id} 这类带尾段的路由): // 要求 path 中出现完整的 "/keys/" 片段。 // - suffix 不以 "/" 结尾(如 "/status"、"/test"):要求 path 以该后缀结尾, diff --git a/internal/service/admin.go b/internal/service/admin.go index e9b212c..5845658 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -23,6 +23,8 @@ type ( DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v1.AdminUserDeleteRes, error) // UpdateUserStatus enables or disables an admin user. UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateStatusReq) (*v1.AdminUserUpdateStatusRes, error) + // UnlockUser 清除管理员的登录锁定状态(重置失败计数与锁定截止时间)。 + UnlockUser(ctx context.Context, req *v1.AdminUserUnlockReq) (*v1.AdminUserUnlockRes, error) // ResetUserPassword resets an admin user's password. ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (*v1.AdminUserResetPasswordRes, error) // ExportUsers exports admin users to CSV or Excel. @@ -241,6 +243,8 @@ type ( EnableMember(ctx context.Context, req *v1.AdminMemberEnableReq) (*v1.AdminMemberEnableRes, error) // ResetMemberPassword resets a member's password by admin, returns the new random password. ResetMemberPassword(ctx context.Context, req *v1.AdminMemberResetPasswordReq) (*v1.AdminMemberResetPasswordRes, error) + // UnlockMember 清除成员的登录锁定状态(平台视角,跨租户)。 + UnlockMember(ctx context.Context, req *v1.AdminMemberUnlockReq) (*v1.AdminMemberUnlockRes, error) // ExportMembers exports member list to CSV or Excel. ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq) (*v1.AdminMemberExportRes, error) // ListModelOptions 获取模型选项列表(不分页,用于下拉选择) diff --git a/internal/service/tenant.go b/internal/service/tenant.go index f97aaf4..3e70fa9 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -144,6 +144,8 @@ type ( UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpdateRoleReq) (*v1.TenantMemberUpdateRoleRes, error) // ResetMemberPassword resets a member's password. Only admins can reset other members' passwords. ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) + // UnlockMember 解除成员登录锁定。仅 owner/admin 可操作;租户隔离双键校验。 + UnlockMember(ctx context.Context, req *v1.TenantMemberUnlockReq) (*v1.TenantMemberUnlockRes, error) // GetMember returns a single member's detail. GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v1.TenantMemberGetRes, error) // GetMemberUsage returns usage statistics for a single member. diff --git a/web/admin/src/views/system/UsersPage.vue b/web/admin/src/views/system/UsersPage.vue index 90f89c0..934c3c9 100644 --- a/web/admin/src/views/system/UsersPage.vue +++ b/web/admin/src/views/system/UsersPage.vue @@ -67,6 +67,10 @@ const columns: TableColumnData[] = [ dataIndex: 'status', width: 80, render({ record }) { + const locked = !!record.locked_until && new Date(record.locked_until) > new Date() + if (locked) { + return h(Tag, { color: 'red', size: 'small' }, () => '锁定') + } const color = record.status === 'active' ? 'green' : undefined const label = record.status === 'active' ? '启用' : '禁用' return h(Tag, { color, size: 'small' }, () => label) @@ -79,18 +83,26 @@ const columns: TableColumnData[] = [ width: 260, fixed: 'right', render({ record }) { - return h(Space, { size: 4 }, () => [ + const locked = !!record.locked_until && new Date(record.locked_until) > new Date() + const actions = [ h(Button, { size: 'small', type: 'primary', onClick: () => openEdit(record) }, () => '编辑'), h(Popconfirm, { content: `确定${record.status === 'active' ? '禁用' : '启用'}该用户?`, onOk: () => toggleStatus(record), }, () => h(Button, { size: 'small' }, () => record.status === 'active' ? '禁用' : '启用')), h(Button, { size: 'small', onClick: () => openResetPassword(record) }, () => '重置密码'), - h(Popconfirm, { - content: '确定删除该用户?此操作不可撤销。', - onOk: () => deleteUser(record), - }, () => h(Button, { size: 'small', status: 'danger' }, () => '删除')), - ]) + ] + if (locked) { + actions.push(h(Popconfirm, { + content: '确定解除该用户的登录锁定?', + onOk: () => handleUnlock(record), + }, () => h(Button, { size: 'small', status: 'success' }, () => '解锁'))) + } + actions.push(h(Popconfirm, { + content: '确定删除该用户?此操作不可撤销。', + onOk: () => deleteUser(record), + }, () => h(Button, { size: 'small', status: 'danger' }, () => '删除'))) + return h(Space, { size: 4 }, () => actions) }, }, ] @@ -169,6 +181,16 @@ async function toggleStatus(row: any) { } } +async function handleUnlock(row: any) { + try { + await request.put(`/admin/users/${row.id}/unlock`) + Message.success('解锁成功') + fetchData() + } catch { + // error handled by interceptor + } +} + function generateRandomPassword(length = 16) { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%' let password = '' diff --git a/web/admin/src/views/tenant/MembersPage.vue b/web/admin/src/views/tenant/MembersPage.vue index 247e2d7..5ba8c9f 100644 --- a/web/admin/src/views/tenant/MembersPage.vue +++ b/web/admin/src/views/tenant/MembersPage.vue @@ -156,7 +156,9 @@ const columns: TableColumnData[] = [ dataIndex: 'status', width: 70, render({ record }) { - return h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusTagLabel[record.status] || record.status) + const locked = !!record.locked_until && new Date(record.locked_until) > new Date() + const key = locked ? 'locked' : record.status + return h(Tag, { color: statusTagColor[key], size: 'small' }, () => statusTagLabel[key] || record.status) }, }, { title: '所属租户', dataIndex: 'tenant_name', width: 130, ellipsis: true }, @@ -171,7 +173,8 @@ const columns: TableColumnData[] = [ fixed: 'right', render({ record }) { const isDisabled = record.status === 'disabled' - return h(Space, { size: 'mini' }, () => [ + const locked = !!record.locked_until && new Date(record.locked_until) > new Date() + const actions = [ h(Button, { type: 'text', size: 'mini', @@ -183,7 +186,16 @@ const columns: TableColumnData[] = [ size: 'mini', onClick: () => handleResetPassword(record), }, () => '重置密码'), - ]) + ] + if (locked) { + actions.push(h(Button, { + type: 'text', + size: 'mini', + status: 'success', + onClick: () => handleUnlock(record), + }, () => '解锁')) + } + return h(Space, { size: 'mini' }, () => actions) }, }, ] @@ -236,6 +248,25 @@ function handleToggleStatus(record: any) { }) } +function handleUnlock(record: any) { + Modal.warning({ + title: '确认解锁', + content: `确定要解除成员「${record.username}」的登录锁定吗?`, + hideCancel: false, + okText: '确定', + cancelText: '取消', + onOk: async () => { + try { + await request.put(`/admin/members/${record.id}/unlock`) + Message.success('解锁成功') + fetchData() + } catch { + // error toast already shown by interceptor + } + }, + }) +} + function handleResetPassword(record: any) { Modal.warning({ title: '确认重置密码', diff --git a/web/tenant/src/views/team/MemberDetailPage.vue b/web/tenant/src/views/team/MemberDetailPage.vue index 51bdc7f..fa9550b 100644 --- a/web/tenant/src/views/team/MemberDetailPage.vue +++ b/web/tenant/src/views/team/MemberDetailPage.vue @@ -23,6 +23,7 @@ interface MemberInfo { display_name: string role: string status: string + locked_until: string created_at: string updated_at: string } @@ -113,6 +114,10 @@ const statusDotClass: Record = { const isOwner = computed(() => member.value?.role === 'owner') const canManage = computed(() => !isOwner.value) +const isLocked = computed(() => { + if (!member.value?.locked_until) return false + return new Date(member.value.locked_until).getTime() > Date.now() +}) function formatNumber(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' @@ -137,6 +142,16 @@ async function fetchMemberDetail() { } } +async function handleUnlock() { + try { + await request.put(`/tenant/members/${memberId.value}/unlock`) + toast.success('成员已解锁') + fetchMemberDetail() + } catch { + // 错误由 request 拦截器统一提示 + } +} + async function fetchUsage() { try { const res: any = await request.get(`/tenant/members/${memberId.value}/usage`) @@ -434,6 +449,14 @@ onMounted(() => { 重置密码 +