From ebf79d0355c0bf87da2842271e59a2dd81d46443 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 10 Jun 2026 00:52:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20LicenseGuard=20=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E4=BB=B6=20+=20Flutter=20license=20model/repo=20(21D+21E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21D — 后端: - Claims 新增 LicenseExpiresAt (*int64 unix 秒),写入 JWT 避免每次查库 - middleware/license_guard.go: CalcLicensePhase / LicenseGuard / GetLicensePhase - grace(0-7d): 允许通行 - readonly(7-15d): 拦截非 GET 写操作 → 403 - locked(15d+): 全部拦截 → 403 - auth.go: issueTokens 在 JWT 中嵌入 license expires_at;Login 检查 locked 拒绝登录 - router: license/* 路由豁免 LicenseGuard(锁定时仍可激活/查状态) 21E — Flutter 前端: - models/license.dart: 新 LicenseInfo,含 phase/maxDevices,去掉旧 activatedAt - core/device/device_id.dart: 持久化 UUID-v4 作为设备 ID(SharedPreferences) - repositories/license_repository.dart: getInfo/activate/deactivate,激活时携带设备信息 - providers/license_provider.dart: 改接 LicenseRepository - settings_screen.dart: activatedAt 改为显示 maxDevices Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/middleware/auth.go | 15 ++-- backend/internal/middleware/license_guard.go | 77 +++++++++++++++++++ backend/internal/router/router.go | 25 +++--- backend/internal/service/auth.go | 40 ++++++++-- client/lib/core/device/device_id.dart | 53 +++++++++++++ client/lib/models/license.dart | 56 ++++++++++++++ client/lib/providers/license_provider.dart | 57 ++------------ .../lib/repositories/license_repository.dart | 55 +++++++++++++ .../lib/screens/settings/settings_screen.dart | 5 +- 9 files changed, 307 insertions(+), 76 deletions(-) create mode 100644 backend/internal/middleware/license_guard.go create mode 100644 client/lib/core/device/device_id.dart create mode 100644 client/lib/models/license.dart create mode 100644 client/lib/repositories/license_repository.dart diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index c5e1da5..26033a6 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -10,16 +10,18 @@ import ( ) type Claims struct { - UserID uint64 `json:"user_id"` - ShopID uint64 `json:"shop_id"` - Role string `json:"role"` + UserID uint64 `json:"user_id"` + ShopID uint64 `json:"shop_id"` + Role string `json:"role"` + LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual jwt.RegisteredClaims } const ( - CtxUserID = "user_id" - CtxShopID = "shop_id" - CtxRole = "role" + CtxUserID = "user_id" + CtxShopID = "shop_id" + CtxRole = "role" + CtxLicenseExpiresAt = "lic_exp" ) func JWT() gin.HandlerFunc { @@ -43,6 +45,7 @@ func JWT() gin.HandlerFunc { c.Set(CtxUserID, claims.UserID) c.Set(CtxShopID, claims.ShopID) c.Set(CtxRole, claims.Role) + c.Set(CtxLicenseExpiresAt, claims.LicenseExpiresAt) c.Next() } } diff --git a/backend/internal/middleware/license_guard.go b/backend/internal/middleware/license_guard.go new file mode 100644 index 0000000..251e215 --- /dev/null +++ b/backend/internal/middleware/license_guard.go @@ -0,0 +1,77 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + PhaseNormal = "normal" + PhaseGrace = "grace" // expired 0–7 days: writable, show banner + PhaseReadOnly = "readonly" // expired 7–15 days: read-only + PhaseLocked = "locked" // expired 15+ days: no login +) + +var ( + graceWindow = 7 * 24 * time.Hour + readOnlyWindow = 15 * 24 * time.Hour +) + +// CalcLicensePhase computes the degradation phase based on expires_at. +// nil expiresAt = perpetual license = normal. +func CalcLicensePhase(expiresAt *time.Time) string { + if expiresAt == nil { + return PhaseNormal + } + elapsed := time.Since(*expiresAt) + if elapsed <= 0 { + return PhaseNormal + } + if elapsed <= graceWindow { + return PhaseGrace + } + if elapsed <= readOnlyWindow { + return PhaseReadOnly + } + return PhaseLocked +} + +// GetLicensePhase returns the current phase for the authenticated request. +func GetLicensePhase(c *gin.Context) string { + v, _ := c.Get(CtxLicenseExpiresAt) + ptr, _ := v.(*int64) + if ptr == nil { + return PhaseNormal + } + t := time.Unix(*ptr, 0) + return CalcLicensePhase(&t) +} + +// LicenseGuard blocks write operations when the shop's license is expired (readonly/locked). +// License routes (/license/*) must be mounted outside this middleware so users can +// view status and activate a new key even when locked. +func LicenseGuard() gin.HandlerFunc { + return func(c *gin.Context) { + phase := GetLicensePhase(c) + switch phase { + case PhaseLocked: + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "授权已锁定,请续费或激活新授权码", + "phase": PhaseLocked, + }) + case PhaseReadOnly: + if c.Request.Method != http.MethodGet { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "授权已过期,当前为只读模式", + "phase": PhaseReadOnly, + }) + return + } + c.Next() + default: + c.Next() + } + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 615c798..c5368a5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -64,18 +64,23 @@ func Setup(r *gin.Engine, db *gorm.DB) { public.POST("/register", authH.Register) } - // 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作) + // 需要 JWT 的基础路由组 api := v1.Group("") - api.Use(middleware.JWT(), middleware.ReadOnly()) + api.Use(middleware.JWT()) + + // 许可证路由:豁免 LicenseGuard(锁定时仍需查看状态和激活) + license := api.Group("/license") + license.Use(middleware.ReadOnly()) { - // 许可证 - license := api.Group("/license") - { - license.GET("/info", licenseH.Info) - license.POST("/activate", licenseH.Activate) - license.GET("/verify", licenseH.Verify) - license.POST("/deactivate", licenseH.Deactivate) - } + license.GET("/info", licenseH.Info) + license.POST("/activate", licenseH.Activate) + license.GET("/verify", licenseH.Verify) + license.POST("/deactivate", licenseH.Deactivate) + } + + // 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作) + { + api.Use(middleware.ReadOnly(), middleware.LicenseGuard()) // 商品 products := api.Group("/products") diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 02a81cb..3f720c8 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -18,6 +18,7 @@ import ( var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrUserInactive = errors.New("user is disabled") + ErrLicenseLocked = errors.New("license locked, please renew or contact support") ) type AuthService struct { @@ -56,6 +57,10 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m return nil, nil, ErrInvalidCredentials } + if err := s.checkLicenseNotLocked(shop.ID); err != nil { + return nil, nil, err + } + pair, err := s.issueTokens(user.ID, shop.ID, user.Role) if err != nil { return nil, nil, err @@ -153,15 +158,37 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { return s.issueTokens(claims.UserID, claims.ShopID, claims.Role) } +func (s *AuthService) checkLicenseNotLocked(shopID uint64) error { + var lic model.License + if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). + Order("id DESC").First(&lic).Error; err != nil { + return nil // no license record → allow login + } + if middleware.CalcLicensePhase(lic.ExpiresAt) == middleware.PhaseLocked { + return ErrLicenseLocked + } + return nil +} + func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPair, error) { cfg := config.C.JWT now := time.Now() + // Embed license expires_at in JWT so LicenseGuard can check phase without DB. + var licExpAt *int64 + var lic model.License + if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). + Order("id DESC").First(&lic).Error; err == nil && lic.ExpiresAt != nil { + ts := lic.ExpiresAt.Unix() + licExpAt = &ts + } + accessExp := now.Add(time.Duration(cfg.AccessExpireMin) * time.Minute) accessClaims := middleware.Claims{ - UserID: userID, - ShopID: shopID, - Role: role, + UserID: userID, + ShopID: shopID, + Role: role, + LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(accessExp), IssuedAt: jwt.NewNumericDate(now), @@ -174,9 +201,10 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPai refreshExp := now.Add(time.Duration(cfg.RefreshExpireH) * time.Hour) refreshClaims := middleware.Claims{ - UserID: userID, - ShopID: shopID, - Role: role, + UserID: userID, + ShopID: shopID, + Role: role, + LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(refreshExp), IssuedAt: jwt.NewNumericDate(now), diff --git a/client/lib/core/device/device_id.dart b/client/lib/core/device/device_id.dart new file mode 100644 index 0000000..92743e1 --- /dev/null +++ b/client/lib/core/device/device_id.dart @@ -0,0 +1,53 @@ +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _kDeviceIdKey = 'device_id'; + +/// Returns a stable device identifier persisted across app launches. +/// On first call a random UUID-v4 is generated and stored in SharedPreferences. +/// Follows the rule: check kIsWeb before dart:io. +class DeviceId { + static String? _cached; + + static Future get() async { + if (_cached != null) return _cached!; + final prefs = await SharedPreferences.getInstance(); + var id = prefs.getString(_kDeviceIdKey); + if (id == null || id.isEmpty) { + id = _generateUuid(); + await prefs.setString(_kDeviceIdKey, id); + } + return _cached = id; + } + + /// Best-effort platform name for display purposes. + static String get platformName { + if (kIsWeb) return 'web'; + // defaultTargetPlatform is safe on all platforms (no dart:io needed) + switch (defaultTargetPlatform) { + case TargetPlatform.windows: + return 'windows'; + case TargetPlatform.macOS: + return 'macos'; + case TargetPlatform.android: + return 'android'; + case TargetPlatform.iOS: + return 'ios'; + case TargetPlatform.linux: + return 'linux'; + default: + return 'unknown'; + } + } + + static String _generateUuid() { + final rng = Random.secure(); + final bytes = List.generate(16, (_) => rng.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; + } +} diff --git a/client/lib/models/license.dart b/client/lib/models/license.dart new file mode 100644 index 0000000..c5d156a --- /dev/null +++ b/client/lib/models/license.dart @@ -0,0 +1,56 @@ +class LicenseInfo { + final int id; + final String type; // trial | monthly | annual | lifetime + final bool isActive; + final int maxDevices; + final DateTime? expiresAt; + final String phase; // normal | grace | readonly | locked + + const LicenseInfo({ + required this.id, + required this.type, + required this.isActive, + required this.maxDevices, + required this.phase, + this.expiresAt, + }); + + factory LicenseInfo.fromJson(Map json) { + return LicenseInfo( + id: (json['id'] as num?)?.toInt() ?? 0, + type: json['type'] as String? ?? 'trial', + isActive: json['is_active'] as bool? ?? false, + maxDevices: (json['max_devices'] as num?)?.toInt() ?? 3, + expiresAt: json['expires_at'] != null + ? DateTime.tryParse(json['expires_at'] as String) + : null, + phase: json['phase'] as String? ?? 'normal', + ); + } + + String get typeLabel { + switch (type) { + case 'monthly': + return '月度授权'; + case 'annual': + return '年度授权'; + case 'lifetime': + return '永久授权'; + default: + return '试用版'; + } + } + + bool get isExpired => + expiresAt != null && DateTime.now().isAfter(expiresAt!); + + int? get daysRemaining { + if (expiresAt == null) return null; + final diff = expiresAt!.difference(DateTime.now()).inDays; + return diff < 0 ? 0 : diff; + } + + bool get isReadOnlyPhase => phase == 'readonly' || phase == 'locked'; + bool get isLockedPhase => phase == 'locked'; + bool get needsAttention => phase == 'grace' || phase == 'readonly' || phase == 'locked'; +} diff --git a/client/lib/providers/license_provider.dart b/client/lib/providers/license_provider.dart index cac4ab5..1da67a0 100644 --- a/client/lib/providers/license_provider.dart +++ b/client/lib/providers/license_provider.dart @@ -1,53 +1,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api/api_client.dart'; import '../core/auth/auth_state.dart'; +import '../models/license.dart'; +import '../repositories/license_repository.dart'; -class LicenseInfo { - final String type; // trial / monthly / annual / lifetime - final bool isActive; - final DateTime? expiresAt; - final DateTime? activatedAt; +export '../models/license.dart'; - const LicenseInfo({ - required this.type, - required this.isActive, - this.expiresAt, - this.activatedAt, - }); - - factory LicenseInfo.fromJson(Map json) { - return LicenseInfo( - type: json['type'] as String? ?? 'trial', - isActive: json['is_active'] as bool? ?? false, - expiresAt: json['expires_at'] != null - ? DateTime.tryParse(json['expires_at'] as String) - : null, - activatedAt: json['activated_at'] != null - ? DateTime.tryParse(json['activated_at'] as String) - : null, - ); - } - - String get typeLabel { - switch (type) { - case 'monthly': return '月度授权'; - case 'annual': return '年度授权'; - case 'lifetime': return '永久授权'; - default: return '试用版'; - } - } - - /// 是否已过期 - bool get isExpired => - expiresAt != null && DateTime.now().isAfter(expiresAt!); - - /// 距到期剩余天数(null = 永久) - int? get daysRemaining { - if (expiresAt == null) return null; - final diff = expiresAt!.difference(DateTime.now()).inDays; - return diff < 0 ? 0 : diff; - } -} +final licenseRepositoryProvider = Provider( + (ref) => LicenseRepository(ref.read(apiClientProvider)), +); final licenseProvider = AsyncNotifierProvider(LicenseNotifier.new); @@ -61,11 +22,7 @@ class LicenseNotifier extends AsyncNotifier { Future _fetch() async { try { - final client = ref.read(apiClientProvider); - final resp = await client.get('/license/info'); - final data = resp.data['data']; - if (data == null) return null; - return LicenseInfo.fromJson(data as Map); + return await ref.read(licenseRepositoryProvider).getInfo(); } catch (_) { return null; } diff --git a/client/lib/repositories/license_repository.dart b/client/lib/repositories/license_repository.dart new file mode 100644 index 0000000..4ccada8 --- /dev/null +++ b/client/lib/repositories/license_repository.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/device/device_id.dart'; +import '../core/exceptions.dart'; +import '../models/license.dart'; + +class LicenseRepository { + final ApiClient _client; + const LicenseRepository(this._client); + + Future getInfo() async { + try { + final resp = await _client.get('/license/info'); + final data = resp.data['data']; + if (data == null) return null; + return LicenseInfo.fromJson(data as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取授权信息失败', + statusCode: e.response?.statusCode, + ); + } + } + + /// Activate a license key for this device. + Future activate(String licenseKey, {String? deviceName}) async { + final deviceId = await DeviceId.get(); + try { + await _client.post('/license/activate', data: { + 'license_key': licenseKey, + 'device_id': deviceId, + 'device_name': deviceName ?? DeviceId.platformName, + 'platform': DeviceId.platformName, + }); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '激活失败', + statusCode: e.response?.statusCode, + ); + } + } + + /// Deactivate (unbind) this device from its license. + Future deactivate() async { + final deviceId = await DeviceId.get(); + try { + await _client.post('/license/deactivate', data: {'device_id': deviceId}); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '解绑失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 779cc42..be98093 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -381,10 +381,7 @@ class _SettingsScreenState extends ConsumerState { value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!)) else const _ParamRow(label: '到期时间', value: '永久有效'), - if (lic.activatedAt != null) - _ParamRow( - label: '激活时间', - value: DateFormat('yyyy-MM-dd').format(lic.activatedAt!)), + _ParamRow(label: '设备上限', value: '${lic.maxDevices} 台'), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _showRenewLicenseDialog,