diff --git a/client/lib/services/connect_api.dart b/client/lib/services/connect_api.dart index 730222e..1799bd7 100644 --- a/client/lib/services/connect_api.dart +++ b/client/lib/services/connect_api.dart @@ -16,14 +16,22 @@ class ConnectApiException implements Exception { required this.statusCode, required this.messageZh, required this.messageEn, + this.code, + this.maxDevices, }); final int statusCode; final String messageZh; final String messageEn; + /// 服务端错误码(如 DEVICE_LIMIT_EXCEEDED);网络/解析错误时为 null。 + final String? code; + + /// DEVICE_LIMIT_EXCEEDED 时服务端下发的套餐设备上限。 + final int? maxDevices; + @override - String toString() => 'ConnectApiException($statusCode): $messageZh'; + String toString() => 'ConnectApiException($statusCode${code != null ? '/$code' : ''}): $messageZh'; } /// [ConnectApi] 封装 POST /v1/nodes/:id/connect 调用。 @@ -76,10 +84,25 @@ class ConnectApi { } if (response.statusCode != 200) { + // 解析结构化错误体(code/message/max_devices),失败则回退通用文案。 + String? code; + int? maxDevices; + String? zh, en; + try { + final m = jsonDecode(response.body); + if (m is Map) { + code = m['code'] as String?; + maxDevices = (m['max_devices'] as num?)?.toInt(); + zh = m['message_zh'] as String?; + en = m['message_en'] as String?; + } + } catch (_) {/* 非 JSON,用通用文案 */} throw ConnectApiException( statusCode: response.statusCode, - messageZh: '节点连接失败 (HTTP ${response.statusCode})', - messageEn: 'Connect failed (HTTP ${response.statusCode})', + code: code, + maxDevices: maxDevices, + messageZh: zh ?? '节点连接失败 (HTTP ${response.statusCode})', + messageEn: en ?? 'Connect failed (HTTP ${response.statusCode})', ); } diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 83b04df..3e9f907 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -16,6 +16,7 @@ import '../bridge/vpn_bridge_provider.dart'; import '../l10n/app_text.dart'; import '../models/node.dart'; import '../services/api_config.dart'; +import '../services/auth_api.dart'; import '../services/connect_api.dart'; import '../services/device_identity.dart'; import 'app_providers.dart'; @@ -193,6 +194,14 @@ class ConnectionController extends StateNotifier { // bridge.start() 不阻塞至连接建立;on 状态由 statusStream 回调驱动。 await _bridge.start(configJson); } on ConnectApiException catch (e) { + // 设备数超限(backstop):弹「移除设备」页(顶层路由据 deviceLimitProvider 切屏), + // 兜住"已登录但超限"的设备——它们一连就被拦、被提示,无需重新登录。连接回 off。 + if (e.code == 'DEVICE_LIMIT_EXCEEDED') { + _ref.read(deviceLimitProvider.notifier).state = + DeviceLimit(maxDevices: e.maxDevices ?? 0, devices: const []); + if (mounted) state = const ConnectionState(phase: VpnPhase.off); + return; + } // 把后端/网络错误冒泡到 UI(原静默回 off,用户不知所以)。 if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn); } catch (e) { diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index e96b9b4..ca1ef90 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -22,6 +22,8 @@ const ( paidCredentialTTL = 24 * time.Hour // freeCredentialTTL is the per-minute TTL for free users (per remaining minutes). freeMinuteTTL = time.Minute + // deviceStaleWindow: connect 设备上限 backstop 只数近此窗口活跃的设备(与 devices 侧一致)。 + deviceStaleWindow = 30 * 24 * time.Hour ) // nodeLoadReader reads a node's last-reported runtime load (for the data-plane @@ -162,6 +164,29 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { return } + // 1.4 设备数量上限 backstop(#16):活跃设备超套餐上限则拒连,返回 device_limit 信号让 + // 新客户端弹「移除设备」页。登录挡板只拦"新登录",这里兜住"已登录的超限设备"—— + // 它们下次一连就会被拦、被提示,无需重新登录。max_devices=0 表示不限。 + if ent.MaxDevices > 0 { + active, cerr := a.store.CountActiveDevices(r.Context(), uid, time.Now().UTC().Add(-deviceStaleWindow)) + if cerr != nil { + slog.Error("connect: count active devices failed", "user", uid, "err", cerr) + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if active > ent.MaxDevices { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "DEVICE_LIMIT_EXCEEDED", + "message_zh": "设备数已达上限,请移除一台设备后再连接", + "message_en": "Device limit reached. Remove a device to connect.", + "max_devices": ent.MaxDevices, + }) + return + } + } + // 1.5 GB 综合配额卡控(todo #5 Phase 2):按账户当日综合流量卡,超 plan.daily_mb 即拒。 // 对免费(与分钟门双卡)与付费(高上限防滥用)统一生效;daily_mb NULL = 不限。 if ent.DailyMB.Valid { diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index 04f3589..90dd817 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -146,6 +146,10 @@ func (m *mockNodeStore) AccountDayBytes(_ context.Context, _ int64, _ time.Time) return 0, nil } +func (m *mockNodeStore) CountActiveDevices(_ context.Context, _ int64, _ time.Time) (int, error) { + return 0, nil +} + func (m *mockNodeStore) UserDeviceByDpUUID(_ context.Context, dpUUID string) (int64, int64, bool, error) { if m.devicesByDpUUID != nil { if ud, ok := m.devicesByDpUUID[dpUUID]; ok { diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index 43ddd6d..82b3da0 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -35,6 +35,7 @@ type Entitlement struct { AdGate bool // true = free plan, require ad unlock + minute quota DailyMinutes sql.NullInt64 DailyMB sql.NullInt64 // 每日综合流量配额(MB, 按账户综合卡); NULL = 不限 + MaxDevices int // 套餐设备上限(free 1 / pro 3 / team 10);0 = 不限 ExpiresAt sql.NullTime // latest subscription expiry (nil = trial/active) } @@ -103,6 +104,10 @@ type NodeStore interface { // date — the basis for the GB 综合配额 connect gate. 0 when no usage yet. AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error) + // CountActiveDevices counts the user's devices seen since cutoff (active). Used + // by the connect device-limit backstop; stale rows are excluded. + CountActiveDevices(ctx context.Context, userID int64, cutoff time.Time) (int, error) + // AccumulateDeviceUsage adds bytes/minutes to usage_device_daily for // (deviceID, date); user_id is carried for per-account rollups/queries. AccumulateDeviceUsage(ctx context.Context, userID, deviceID int64, date time.Time, @@ -201,7 +206,7 @@ func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*E // Look up the best active subscription. const q = ` - SELECT p.code, p.ad_gate, p.daily_minutes, p.daily_mb, s.expires_at + SELECT p.code, p.ad_gate, p.daily_minutes, p.daily_mb, p.max_devices, s.expires_at FROM subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.user_id = ? AND s.expires_at > ? @@ -210,7 +215,7 @@ func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*E ` e := &Entitlement{DpUUID: dpUUID} err := s.db.QueryRowContext(ctx, q, userID, time.Now().UTC()).Scan( - &e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.DailyMB, &e.ExpiresAt, + &e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.DailyMB, &e.MaxDevices, &e.ExpiresAt, ) if err == sql.ErrNoRows { // No active subscription → free plan defaults (mirrors the free plan seed). @@ -218,6 +223,7 @@ func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*E e.AdGate = true e.DailyMinutes = sql.NullInt64{Valid: true, Int64: 10} e.DailyMB = sql.NullInt64{Valid: true, Int64: 500} + e.MaxDevices = 1 return e, nil } if err != nil { @@ -467,6 +473,19 @@ func (s *SQLNodeStore) AccountDayBytes(ctx context.Context, userID int64, date t return total.Int64, nil } +// CountActiveDevices counts the user's devices seen within the active window +// (last_seen > cutoff). Mirrors devices.Store.CountActiveDevices for the connect +// backstop; stale/never-seen rows are excluded. +func (s *SQLNodeStore) 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("nodes.SQLNodeStore.CountActiveDevices: %w", err) + } + return n, nil +} + // AccumulateUsage adds bytes/minutes to usage_daily for the given user and date. func (s *SQLNodeStore) AccumulateUsage( ctx context.Context, userID int64, date time.Time,