From 64a64e7c0a0b7a2306888022d91c614f2c62a84d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 18 Jun 2026 07:59:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E9=A6=96=E6=AC=A1=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=97=A0=E6=9C=89=E6=95=88=E6=8E=88=E6=9D=83=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8F=91=E6=94=BE=2030=20=E5=A4=A9=E8=AF=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未激活门店(seed/手动建店/老数据,无任何 license 行)此前被当作永久授权, 既不拦截也无提示。现在登录时若门店无有效授权,自动签发 30 天 trial, 之后照常走 grace/readonly/locked 降级链。须在 issueTokens 之前发放, 使 JWT 的 lic_exp 带上新到期日。幂等(已有有效授权不重发),签发失败 仅记日志不阻断登录。复用注册路径的发放逻辑(抽出 issueTrialLicense)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK --- backend/internal/service/auth.go | 25 +++++++++++++++++ backend/internal/service/auth_test.go | 40 +++++++++++++++++++++++++++ backend/internal/service/license.go | 23 ++++++++++----- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index fdce586..76db4b6 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -3,6 +3,7 @@ package service import ( "errors" "fmt" + "log" "sync" "time" @@ -127,6 +128,11 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) return nil, nil, err } + // 首次使用(门店尚无任何有效授权)自动发 30 天试用:seed/手动建店或老数据 + // 登录即转为 trial,「未激活」不再静默无限制。须在 issueTokens 之前,使 JWT + // 的 lic_exp 带上新试用到期日。 + s.ensureTrialOnFirstUse(shop.ID) + // 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。 pclass := model.PlatformClass(dev.Platform) quota := s.effectiveQuota(&shop, pclass) @@ -416,6 +422,25 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID) } +// ensureTrialOnFirstUse 门店首次使用(尚无任何 is_active 授权)时自动签发 30 天 trial, +// 使「未激活」门店在首次登录后即转为试用版;后续到期降级(grace/readonly/locked)链路照常生效。 +// 已有有效授权(含已过期但未锁定的 trial/付费)则跳过,不重复发放。 +// 签发失败仅记日志、不阻断登录(保持可用,门店维持未激活)。 +func (s *AuthService) ensureTrialOnFirstUse(shopID uint64) { + var count int64 + if err := s.db.Model(&model.License{}). + Where("shop_id = ? AND is_active = 1", shopID).Count(&count).Error; err != nil { + log.Printf("[license] count licenses for shop %d failed: %v", shopID, err) + return + } + if count > 0 { + return + } + if err := issueTrialLicense(s.db, shopID); err != nil { + log.Printf("[license] auto-trial for shop %d skipped: %v", shopID, err) + } +} + func (s *AuthService) checkLicenseNotLocked(shopID uint64) error { var lic model.License if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). diff --git a/backend/internal/service/auth_test.go b/backend/internal/service/auth_test.go index ea44cf7..e5d518f 100644 --- a/backend/internal/service/auth_test.go +++ b/backend/internal/service/auth_test.go @@ -2,10 +2,14 @@ package service import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/wangjia/jiu/backend/config" + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/util" "github.com/wangjia/jiu/backend/testutil" ) @@ -25,6 +29,42 @@ func TestAuthService_Login_Success(t *testing.T) { assert.Equal(t, "admin", user.Username) } +func TestAuthService_Login_AutoTrialOnFirstUse(t *testing.T) { + db := testutil.SetupTestDB() + priv, _, err := util.GenerateEd25519KeyPair() + require.NoError(t, err) + config.C.License.Ed25519PrivateKey = priv + + shop := testutil.CreateTestShop(db, "TRIAL001") + testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") + + // 前置:门店「未激活」——无任何授权行 + var before int64 + db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&before) + require.EqualValues(t, 0, before) + + svc := NewAuthService(db) + + // 首次登录 → 自动签发 30 天 trial + _, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + + var lic model.License + require.NoError(t, db.Where("shop_id = ? AND is_active = 1", shop.ID).First(&lic).Error) + assert.Equal(t, "trial", lic.Type) + assert.True(t, lic.IsActive) + require.NotNil(t, lic.ExpiresAt) + days := time.Until(*lic.ExpiresAt).Hours() / 24 + assert.InDelta(t, 30, days, 1, "试用期应约为 30 天") + + // 再次登录不重复发放 + _, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"}) + require.NoError(t, err) + var count int64 + db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&count) + assert.EqualValues(t, 1, count, "已有有效授权时不应再发放 trial") +} + func TestAuthService_Login_WrongPassword(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "HOTEL002") diff --git a/backend/internal/service/license.go b/backend/internal/service/license.go index 89d836e..161a4bc 100644 --- a/backend/internal/service/license.go +++ b/backend/internal/service/license.go @@ -143,12 +143,13 @@ func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error { Delete(&model.LicenseDevice{}).Error } -// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。 -// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。 -func createTrialLicense(tx *gorm.DB, shopID uint64) { +// issueTrialLicense 为门店签发并写入一条 30 天 trial license。 +// 私钥未配置或签发/落库失败时返回 error,由调用方决定如何处理(注册路径 Fatal、 +// 登录路径仅记日志)。 +func issueTrialLicense(db *gorm.DB, shopID uint64) error { privKey := config.C.License.Ed25519PrivateKey if privKey == "" { - log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID) + return fmt.Errorf("ed25519 private key not configured") } expiresAt := time.Now().Add(30 * 24 * time.Hour) @@ -162,8 +163,7 @@ func createTrialLicense(tx *gorm.DB, shopID uint64) { } token, err := util.IssueLicenseToken(payload, privKey) if err != nil { - log.Printf("[license] failed to issue trial token for shop %d: %v", shopID, err) - return + return err } lic := model.License{ @@ -174,7 +174,16 @@ func createTrialLicense(tx *gorm.DB, shopID uint64) { IsActive: true, MaxDevices: 1, } - if err := tx.Create(&lic).Error; err != nil { + return db.Create(&lic).Error +} + +// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。 +// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。 +func createTrialLicense(tx *gorm.DB, shopID uint64) { + if config.C.License.Ed25519PrivateKey == "" { + log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID) + } + if err := issueTrialLicense(tx, shopID); err != nil { log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err) } }