feat:用户多次输错密码被锁后可以由管理员解锁
This commit is contained in:
1 parent
ed0e6b5bbe
commit
9374c64bad
22 files changed
+342
-35
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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:"导出用户列表"`
|
||||
|
||||
@@ -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 获取所有渠道配置
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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})`。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 以该后缀结尾,
|
||||
|
||||
@@ -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 获取模型选项列表(不分页,用于下拉选择)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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: '确认重置密码',
|
||||
|
||||
@@ -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<string, string> = {
|
||||
|
||||
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(() => {
|
||||
<Icon name="key" size="xs" />
|
||||
重置密码
|
||||
</button>
|
||||
<button
|
||||
v-if="isLocked"
|
||||
@click="handleUnlock"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<Icon name="refresh" size="xs" />
|
||||
解除锁定
|
||||
</button>
|
||||
<button
|
||||
@click="showRemoveModal = true"
|
||||
class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50"
|
||||
|
||||
Reference in new issue
Block a user