用户软删除后使用记录仍在,但身份(邮箱)被 ent 软删除拦截器隐藏。本次在 三条管理员只读路径定点穿透软删除过滤,并把删除状态传播到前端标记,零新表/ 迁移/回填: - 后端穿透:富化 usage 日志(loadUsers)、用户搜索(ListWithFilters + UserListFilters.IncludeDeleted)、点击详情(GetByIDIncludeDeleted / GetUserIncludeDeleted + getById ?include_deleted 分支) - 状态传播:service.User / dto.User 新增 DeletedAt;SearchUsers 标记 deleted - 前端:表格与余额弹窗展示"已删除"徽标、筛选下拉标注并排序、点击走 include_deleted;新增 i18n admin.usage.userDeletedBadge - 安全:普通用户 usage 仅查本人(无 PII 泄漏);主用户列表与默认 getById 行为不变(已删用户仍 404);仅 admin 搜索设 IncludeDeleted 后端 build / 三态 vet / unit 全量 / 仓储集成全绿;前端 typecheck / vitest / 改动文件 eslint 全清。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
3.0 KiB
Go
104 lines
3.0 KiB
Go
package service
|
||
|
||
import (
|
||
"time"
|
||
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
type User struct {
|
||
ID int64
|
||
Email string
|
||
Username string
|
||
Notes string
|
||
AvatarURL string
|
||
AvatarSource string
|
||
AvatarMIME string
|
||
AvatarByteSize int
|
||
AvatarSHA256 string
|
||
PasswordHash string
|
||
Role string
|
||
Balance float64
|
||
Concurrency int
|
||
Status string
|
||
AllowedGroups []int64
|
||
TokenVersion int64 // Incremented on password change to invalidate existing tokens
|
||
// TokenVersionResolved indicates TokenVersion already contains the fingerprint-derived
|
||
// value expected in JWT claims and refresh-token state.
|
||
TokenVersionResolved bool
|
||
SignupSource string
|
||
LastLoginAt *time.Time
|
||
LastActiveAt *time.Time
|
||
LastUsedAt *time.Time
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
DeletedAt *time.Time // 非 nil 表示用户已软删除
|
||
|
||
// GroupRates 用户专属分组倍率配置
|
||
// map[groupID]rateMultiplier
|
||
GroupRates map[int64]float64
|
||
|
||
// TOTP 双因素认证字段
|
||
TotpSecretEncrypted *string // AES-256-GCM 加密的 TOTP 密钥
|
||
TotpEnabled bool // 是否启用 TOTP
|
||
TotpEnabledAt *time.Time // TOTP 启用时间
|
||
|
||
// 余额不足通知
|
||
BalanceNotifyEnabled bool
|
||
BalanceNotifyThresholdType string // "fixed" (default) | "percentage"
|
||
BalanceNotifyThreshold *float64
|
||
BalanceNotifyExtraEmails []NotifyEmailEntry
|
||
TotalRecharged float64
|
||
|
||
// RPMLimit 用户级每分钟请求数上限(0 = 不限制)。仅在所用分组未设置 rpm_limit
|
||
// 且该 (用户, 分组) 无 rpm_override 时作为全局兜底生效,计数键 rpm:u:{userID}:{min}。
|
||
RPMLimit int
|
||
|
||
// UserGroupRPMOverride 来自 auth cache snapshot 的 (user, group) RPM 覆盖值。
|
||
// nil = 该 API Key 对应的 (user, group) 无 override;非 nil 时 checkRPM 直接使用,
|
||
// 避免每请求查 DB。字段不持久化到数据库。
|
||
UserGroupRPMOverride *int
|
||
|
||
APIKeys []APIKey
|
||
Subscriptions []UserSubscription
|
||
}
|
||
|
||
func (u *User) IsAdmin() bool {
|
||
return u.Role == RoleAdmin
|
||
}
|
||
|
||
func (u *User) IsActive() bool {
|
||
return u.Status == StatusActive
|
||
}
|
||
|
||
// CanBindGroup checks whether a user can bind to a given group.
|
||
// For standard groups:
|
||
// - Public groups (non-exclusive): all users can bind
|
||
// - Exclusive groups: only users with the group in AllowedGroups can bind
|
||
func (u *User) CanBindGroup(groupID int64, isExclusive bool) bool {
|
||
// 公开分组(非专属):所有用户都可以绑定
|
||
if !isExclusive {
|
||
return true
|
||
}
|
||
// 专属分组:需要在 AllowedGroups 中
|
||
for _, id := range u.AllowedGroups {
|
||
if id == groupID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (u *User) SetPassword(password string) error {
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
u.PasswordHash = string(hash)
|
||
return nil
|
||
}
|
||
|
||
func (u *User) CheckPassword(password string) bool {
|
||
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
|
||
}
|