Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 435e02278d | |||
| 64a64e7c0a | |||
| 2ac1cbfb24 |
@@ -5,6 +5,11 @@
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.57] - 2026-06-18
|
||||
|
||||
### 新功能
|
||||
- 未激活门店首次登录自动获得 30 天试用:此前手动建店或老数据无授权时既不提示也不限制,现在登录即开通试用,到期前会正常提醒并按阶段降级
|
||||
|
||||
## [1.0.56] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ set -euo pipefail
|
||||
|
||||
TAG="$1"
|
||||
VER="${TAG#client-v}"
|
||||
VER="${VER#v}" # 兼容 build-windows.yml 传裸 v 前缀(如 v1.0.4);pubspec 版本不能带 v
|
||||
|
||||
echo "==> compile-windows: version=${VER}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user