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:
@@ -90,6 +90,20 @@ type Device struct {
|
||||
// app 一关 ~90s 内转离线。
|
||||
const onlineWindow = 90 * time.Second
|
||||
|
||||
// staleWindow: devices not seen within this window are "stale" — excluded from the
|
||||
// plan-cap count and best-effort pruned on login. Covers free-plan reinstall churn
|
||||
// (each reinstall churns the device UUID, leaving zombie rows) without locking users out.
|
||||
const staleWindow = 30 * 24 * time.Hour
|
||||
|
||||
// DeviceLimitStatus reports the device-cap state. Over is true when the account's
|
||||
// active device count exceeds the plan cap; Devices lists the active set so the
|
||||
// client can present "remove a device" UX.
|
||||
type DeviceLimitStatus struct {
|
||||
Over bool `json:"over"`
|
||||
MaxDevices int `json:"max_devices"`
|
||||
Devices []Device `json:"devices,omitempty"`
|
||||
}
|
||||
|
||||
func toAPIDevice(d DeviceRow) Device {
|
||||
out := Device{UUID: d.UUID, Name: d.Name, Platform: d.Platform}
|
||||
if d.LastSeen.Valid {
|
||||
@@ -444,6 +458,37 @@ func (svc *Service) ResolvePlan(ctx context.Context, userID int64) (Plan, *apier
|
||||
return resolveEffectivePlan(time.Now().UTC(), status, subs, free)
|
||||
}
|
||||
|
||||
// CheckDeviceLimit prunes stale devices (best-effort churn cleanup), resolves the
|
||||
// plan cap, and reports whether the account's active device count exceeds it. Used
|
||||
// by the login flow (non-hard-reject gate) and the connect backstop. When over, the
|
||||
// active device list is included so the client can present "remove a device" UX.
|
||||
// The device is already registered by the time this runs, so "over" means
|
||||
// activeCount > cap. MaxDevices==0 means uncapped (never over).
|
||||
func (svc *Service) CheckDeviceLimit(ctx context.Context, userID int64) (*DeviceLimitStatus, *apierr.Error) {
|
||||
cutoff := time.Now().UTC().Add(-staleWindow)
|
||||
if _, err := svc.store.PruneStaleDevices(ctx, userID, cutoff); err != nil {
|
||||
_ = err // prune failure must never block login/connect
|
||||
}
|
||||
plan, apiErr := svc.ResolvePlan(ctx, userID)
|
||||
if apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
active, err := svc.store.CountActiveDevices(ctx, userID, cutoff)
|
||||
if err != nil {
|
||||
return nil, apierr.ErrInternal
|
||||
}
|
||||
st := &DeviceLimitStatus{MaxDevices: plan.MaxDevices}
|
||||
st.Over = plan.MaxDevices > 0 && active > plan.MaxDevices
|
||||
if st.Over {
|
||||
devs, apiErr := svc.ListDevices(ctx, userID)
|
||||
if apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
st.Devices = devs
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// resolveEffectivePlan is the pure plan-resolution rule (no I/O), unit-tested
|
||||
// directly:
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user