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:
wangjia
2026-07-01 19:16:10 +08:00
parent 27dc59ed63
commit 6bac7fd2f0
7 changed files with 225 additions and 8 deletions
+45
View File
@@ -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:
//