diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index daabb10..9bf23c3 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -538,3 +538,22 @@ func (a authDeviceRegistrar) RegisterDevice(ctx context.Context, userID int64, m } return id, nil } + +// CheckDeviceLimit adapts devices.CheckDeviceLimit → auth.DeviceLimit for the login +// over-limit gate. Returns nil (within cap) unless the account is over its plan cap. +func (a authDeviceRegistrar) CheckDeviceLimit(ctx context.Context, userID int64) (*auth.DeviceLimit, error) { + st, apiErr := a.svc.CheckDeviceLimit(ctx, userID) + if apiErr != nil { + return nil, apiErr + } + if st == nil || !st.Over { + return nil, nil + } + briefs := make([]auth.DeviceBrief, 0, len(st.Devices)) + for _, d := range st.Devices { + briefs = append(briefs, auth.DeviceBrief{ + UUID: d.UUID, Name: d.Name, Platform: d.Platform, LastSeen: d.LastSeen, + }) + } + return &auth.DeviceLimit{MaxDevices: st.MaxDevices, Devices: briefs}, nil +} diff --git a/server/internal/auth/device_register_test.go b/server/internal/auth/device_register_test.go index 8c4ae69..af37572 100644 --- a/server/internal/auth/device_register_test.go +++ b/server/internal/auth/device_register_test.go @@ -13,6 +13,8 @@ type fakeRegistrar struct { } deviceID int64 err error + limit *DeviceLimit // returned by CheckDeviceLimit (nil = within cap) + limitErr error } func (f *fakeRegistrar) RegisterDevice(_ context.Context, userID int64, meta DeviceMeta) (int64, error) { @@ -23,6 +25,10 @@ func (f *fakeRegistrar) RegisterDevice(_ context.Context, userID int64, meta Dev return f.deviceID, f.err } +func (f *fakeRegistrar) CheckDeviceLimit(_ context.Context, _ int64) (*DeviceLimit, error) { + return f.limit, f.limitErr +} + // Register/Login with a device should trigger RegisterDevice with the meta. func TestService_RegisterDevice_OnRegisterAndLogin(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{}) @@ -55,6 +61,39 @@ func TestService_RegisterDevice_OnRegisterAndLogin(t *testing.T) { } } +// Login surfaces the registrar's over-limit signal (non-hard-reject: tokens still +// issued, DeviceLimit attached for the client to present "remove a device" UX). +func TestService_Login_SurfacesDeviceLimit(t *testing.T) { + svc, _, _ := newService(t, ServiceConfig{}) + last := "2026-07-01T00:00:00Z" + reg := &fakeRegistrar{limit: &DeviceLimit{ + MaxDevices: 1, + Devices: []DeviceBrief{{UUID: "old", Name: "Old Phone", Platform: "android", LastSeen: &last}}, + }} + svc.SetDeviceRegistrar(reg) + ctx := context.Background() + const email = "lim@example.com" + const pw = "supersecret" + meta := DeviceMeta{DeviceID: "new-dev", Platform: "ios"} + if _, err := svc.SendCode(ctx, email, "1.1.1.1"); err != nil { + t.Fatalf("SendCode: %v", err) + } + code := codeInRedis(t, svc, email) + if _, e := svc.Register(ctx, email, code, pw, "", meta); e != nil { + t.Fatalf("Register: %v", e) + } + out, _, e := svc.Login(ctx, email, pw, "", meta) + if e != nil { + t.Fatalf("Login must succeed (non-hard-reject): %v", e) + } + if out.Tokens == nil { + t.Fatalf("login should still issue tokens") + } + if out.DeviceLimit == nil || out.DeviceLimit.MaxDevices != 1 || len(out.DeviceLimit.Devices) != 1 { + t.Fatalf("expected device_limit surfaced, got %+v", out.DeviceLimit) + } +} + // A registrar error (e.g. device cap) must NOT fail login/register. func TestService_RegisterDevice_BestEffort(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{}) diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go index 4f7f815..fde1423 100644 --- a/server/internal/auth/handler.go +++ b/server/internal/auth/handler.go @@ -77,9 +77,10 @@ type refreshRequest struct { } type tokenPairResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + DeviceLimit *DeviceLimit `json:"device_limit,omitempty"` // 登录时活跃设备超上限的信号(登录仍成功) } // SendCode handles POST /v1/auth/code. @@ -133,7 +134,14 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { }) return } - writeTokenPair(w, out.Tokens) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(tokenPairResponse{ + AccessToken: out.Tokens.AccessToken, + RefreshToken: out.Tokens.RefreshToken, + ExpiresIn: out.Tokens.ExpiresIn, + DeviceLimit: out.DeviceLimit, // 超限则带上,客户端据此弹「移除设备」页(登录仍成功) + }) } // Refresh handles POST /v1/auth/refresh. diff --git a/server/internal/auth/service.go b/server/internal/auth/service.go index 74910f2..e99b78a 100644 --- a/server/internal/auth/service.go +++ b/server/internal/auth/service.go @@ -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 diff --git a/server/internal/devices/devices_integration_test.go b/server/internal/devices/devices_integration_test.go index f6c1d3f..2ae718a 100644 --- a/server/internal/devices/devices_integration_test.go +++ b/server/internal/devices/devices_integration_test.go @@ -265,6 +265,55 @@ func TestDeviceLimitEnforced(t *testing.T) { } } +// TestCheckDeviceLimit covers the login-gate helper: within-cap, over-cap (active +// devices exceed the plan cap), and stale-prune (churned rows dropped + excluded). +func TestCheckDeviceLimit(t *testing.T) { + db := setupMySQL(t) + svc := devices.NewService(devices.NewStore(db), nil) + ctx := context.Background() + userID := createUser(t, db, "cap@example.com", "active") // free plan, cap 1 + + // 1 device → within cap. + d1 := newUUID(t, db) + if _, _, e := svc.RegisterIfAbsent(ctx, devices.RegisterInput{UserID: userID, DeviceUUID: d1, Name: "P1", Platform: "android"}); e != nil { + t.Fatalf("register d1: %v", e) + } + st, apiErr := svc.CheckDeviceLimit(ctx, userID) + if apiErr != nil { + t.Fatalf("CheckDeviceLimit: %v", apiErr) + } + if st.Over || st.MaxDevices != 1 { + t.Fatalf("1 device should be within cap 1, got %+v", st) + } + + // 2nd device (MaxDevices:0 bypasses the register-time cap) → over cap. + d2 := newUUID(t, db) + if _, _, e := svc.RegisterIfAbsent(ctx, devices.RegisterInput{UserID: userID, DeviceUUID: d2, Name: "P2", Platform: "ios"}); e != nil { + t.Fatalf("register d2: %v", e) + } + st, _ = svc.CheckDeviceLimit(ctx, userID) + if !st.Over || len(st.Devices) != 2 { + t.Fatalf("2 devices should exceed cap 1, got %+v", st) + } + + // Age d1 past staleWindow → pruned + excluded → back within cap. + if _, err := db.Exec(`UPDATE devices SET last_seen=? WHERE uuid=?`, + time.Now().UTC().Add(-40*24*time.Hour), d1); err != nil { + t.Fatalf("age d1: %v", err) + } + st, _ = svc.CheckDeviceLimit(ctx, userID) + if st.Over { + t.Fatalf("stale device should be pruned/excluded → within cap, got %+v", st) + } + var n int + if err := db.QueryRow(`SELECT COUNT(1) FROM devices WHERE uuid=?`, d1).Scan(&n); err != nil { + t.Fatalf("count d1: %v", err) + } + if n != 0 { + t.Errorf("stale device should be pruned, still present") + } +} + // TestDeleteOthersDevice verifies ownership enforcement and not-found handling. func TestDeleteOthersDevice(t *testing.T) { db := setupMySQL(t) diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index b188ed7..0927a86 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -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: // diff --git a/server/internal/devices/store.go b/server/internal/devices/store.go index d8488c3..7fdd510 100644 --- a/server/internal/devices/store.go +++ b/server/internal/devices/store.go @@ -131,6 +131,34 @@ func (s *Store) countDevicesTx(ctx context.Context, tx *sql.Tx, userID int64) (i return n, nil } +// CountActiveDevices counts the user's devices seen within the active window +// (last_seen > cutoff). Stale rows (churned free-plan reinstalls) and never-seen +// rows are excluded so they don't count against the plan cap. +func (s *Store) CountActiveDevices(ctx context.Context, userID int64, cutoff time.Time) (int, error) { + var n int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(1) FROM devices WHERE user_id=? AND last_seen IS NOT NULL AND last_seen > ?`, + userID, cutoff.UTC()).Scan(&n); err != nil { + return 0, fmt.Errorf("store.CountActiveDevices: %w", err) + } + return n, nil +} + +// PruneStaleDevices deletes the user's devices not seen since cutoff (best-effort +// churn cleanup). Stale devices are offline; their sessions/credentials have long +// expired and node resync reconciles any residue, so a plain row delete is safe. +// Returns the number of rows removed. +func (s *Store) PruneStaleDevices(ctx context.Context, userID int64, cutoff time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM devices WHERE user_id=? AND last_seen IS NOT NULL AND last_seen < ?`, + userID, cutoff.UTC()) + if err != nil { + return 0, fmt.Errorf("store.PruneStaleDevices: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} + // insertDeviceTx inserts a new device row inside tx and returns it. func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, userID int64, name, platform, clientVersion string) (*DeviceRow, error) { now := time.Now().UTC()