feat:用户多次输错密码被锁后可以由管理员解锁

This commit is contained in:
qian committed 2026-07-24 00:18:29 +08:00
1 parent ed0e6b5bbe
commit 9374c64bad
22 files changed
+342 -35

No files matched your search

+2
View File
@@ -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)
+9
View File
@@ -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:"导出用户列表"`
+7
View File
@@ -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 获取所有渠道配置
+1
View File
@@ -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"`
}
+1
View File
@@ -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)
+10
View File
@@ -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"`
}
+50
View File
@@ -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)
}
+29
View File
@@ -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 {
+24 -8
View File
@@ -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)
+36 -5
View File
@@ -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 != "" {
+24 -8
View File
@@ -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)
+4 -4
View File
@@ -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
+42
View File
@@ -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
+3 -1
View File
@@ -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 以该后缀结尾,
+4
View File
@@ -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 获取模型选项列表(不分页,用于下拉选择)
+2
View File
@@ -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.
+28 -6
View File
@@ -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 = ''
+34 -3
View File
@@ -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"