feat(server): 设备上限登录闸(超限非硬拒登,返回 device_limit 信号)#16
启用设备数量限制的服务端部分。登录照常成功签发 token(非硬拒登),但若账户活跃 设备数超套餐上限,登录响应带 device_limit 信号,客户端据此弹「移除设备」页。 - devices/store.go:CountActiveDevices(last_seen 近 staleWindow)+ PruneStaleDevices (删超期僵尸行,免费版重装 churn 自愈) - devices/service.go:staleWindow=30d;DeviceLimitStatus + CheckDeviceLimit (best-effort prune → ResolvePlan → 活跃 count > cap 即 Over,附活跃设备列表) - auth:DeviceRegistrar 加 CheckDeviceLimit;recordLogin 回传 *DeviceLimit; LoginOutcome.DeviceLimit;Login 透传;handler tokenPairResponse.device_limit(omitempty) - main.go:authDeviceRegistrar 适配 devices.CheckDeviceLimit → auth.DeviceLimit - 测试:auth 登录透传超限信号(仍签发 token);devices within/over/prune-stale 连接侧 backstop(服务端硬拦)本轮从简未做,作为后续硬化(登录闸为客户端可信信号)。 DB 无 schema 变更。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,25 @@ type DeviceMeta struct {
|
||||
// devices.Service is adapted to it in main wiring. Best-effort (see recordLogin).
|
||||
type DeviceRegistrar interface {
|
||||
RegisterDevice(ctx context.Context, userID int64, meta DeviceMeta) (deviceID int64, err error)
|
||||
// CheckDeviceLimit reports over-limit status after registration; nil means the
|
||||
// account is within its plan cap. Surfaced on the login response (non-hard-reject).
|
||||
CheckDeviceLimit(ctx context.Context, userID int64) (*DeviceLimit, error)
|
||||
}
|
||||
|
||||
// DeviceLimit is the over-limit signal on a successful login: the account's active
|
||||
// device count exceeds its plan cap. Login still succeeds (tokens issued); the
|
||||
// client must present "remove a device" UX before proceeding. nil = within cap.
|
||||
type DeviceLimit struct {
|
||||
MaxDevices int `json:"max_devices"`
|
||||
Devices []DeviceBrief `json:"devices"`
|
||||
}
|
||||
|
||||
// DeviceBrief is a minimal device view for the over-limit picker.
|
||||
type DeviceBrief struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
LastSeen *string `json:"last_seen"` // RFC 3339 UTC; null when never seen
|
||||
}
|
||||
|
||||
// SessionStore persists login sessions bound to a refresh JTI. Satisfied by
|
||||
@@ -125,23 +144,32 @@ func (s *Service) SetSessionStore(st SessionStore) { s.sessions = st }
|
||||
// is logged but never fails login — the user must always be able to get in
|
||||
// (notably: free-plan reinstall churns the device UUID, so a hard cap here would
|
||||
// lock users out).
|
||||
func (s *Service) recordLogin(ctx context.Context, userID int64, refreshJTI, ip string, meta DeviceMeta) {
|
||||
func (s *Service) recordLogin(ctx context.Context, userID int64, refreshJTI, ip string, meta DeviceMeta) *DeviceLimit {
|
||||
if meta.DeviceID == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
var deviceID int64
|
||||
var limit *DeviceLimit
|
||||
if s.devReg != nil {
|
||||
id, err := s.devReg.RegisterDevice(ctx, userID, meta)
|
||||
if err != nil {
|
||||
slog.Warn("auth: device register failed (login proceeds)", "uid", userID, "err", err)
|
||||
}
|
||||
deviceID = id
|
||||
// 超限闸(非硬拒登):设备已注册,若活跃设备数超套餐上限则返回信号让客户端处理;
|
||||
// 检查失败也绝不阻断登录。
|
||||
if dl, err := s.devReg.CheckDeviceLimit(ctx, userID); err != nil {
|
||||
slog.Warn("auth: device-limit check failed (login proceeds)", "uid", userID, "err", err)
|
||||
} else {
|
||||
limit = dl
|
||||
}
|
||||
}
|
||||
if s.sessions != nil && deviceID > 0 && refreshJTI != "" {
|
||||
if err := s.sessions.Create(ctx, userID, deviceID, refreshJTI, ip, meta.ClientVersion); err != nil {
|
||||
slog.Warn("auth: session create failed (login proceeds)", "uid", userID, "err", err)
|
||||
}
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// NewService wires the auth service. now may be nil (defaults to time.Now).
|
||||
@@ -320,6 +348,7 @@ func (s *Service) verifyCode(ctx context.Context, email, code string) *apierr.Er
|
||||
type LoginOutcome struct {
|
||||
Tokens *TokenPair
|
||||
PendingToken string
|
||||
DeviceLimit *DeviceLimit // non-nil when active devices exceed the plan cap
|
||||
}
|
||||
|
||||
// TOTP pending-login token (Redis): maps a one-time pending token → user id.
|
||||
@@ -384,8 +413,8 @@ func (s *Service) Login(ctx context.Context, rawEmail, password, ip string, devi
|
||||
if err != nil {
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
s.recordLogin(ctx, user.ID, jti, ip, device)
|
||||
return &LoginOutcome{Tokens: pair}, 0, nil
|
||||
dl := s.recordLogin(ctx, user.ID, jti, ip, device)
|
||||
return &LoginOutcome{Tokens: pair, DeviceLimit: dl}, 0, nil
|
||||
}
|
||||
|
||||
// Logout revokes the given refresh token. Idempotent: an empty, unknown, or
|
||||
|
||||
Reference in New Issue
Block a user