diff --git a/backend/config/config.go b/backend/config/config.go index 4d3409e..55ed34a 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -32,8 +32,9 @@ type JWTConfig struct { } type LicenseConfig struct { - HMACSecret string `mapstructure:"hmac_secret"` // legacy, kept for backward compat - Ed25519PublicKey string `mapstructure:"ed25519_public_key"` // base64 Ed25519 public key for token verification + HMACSecret string `mapstructure:"hmac_secret"` // legacy, kept for backward compat + Ed25519PublicKey string `mapstructure:"ed25519_public_key"` // base64 Ed25519 public key for token verification + Ed25519PrivateKey string `mapstructure:"ed25519_private_key"` // base64 Ed25519 private key for token signing (keep in Bitwarden) } type StorageConfig struct { @@ -59,6 +60,7 @@ func Load() { _ = viper.BindEnv("jwt.secret", "JWT_SECRET") _ = viper.BindEnv("license.hmac_secret", "LICENSE_HMAC_SECRET") _ = viper.BindEnv("license.ed25519_public_key", "LICENSE_ED25519_PUBLIC_KEY") + _ = viper.BindEnv("license.ed25519_private_key", "LICENSE_ED25519_PRIVATE_KEY") _ = viper.BindEnv("storage.upload_dir", "STORAGE_UPLOAD_DIR") _ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL") _ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL") diff --git a/backend/internal/model/license.go b/backend/internal/model/license.go index 5e90026..1dce691 100644 --- a/backend/internal/model/license.go +++ b/backend/internal/model/license.go @@ -4,12 +4,14 @@ import "time" type License struct { Base - ShopID uint64 `gorm:"not null;index" json:"shop_id"` - LicenseKey string `gorm:"size:255;uniqueIndex" json:"license_key"` - DeviceID string `gorm:"size:255" json:"device_id"` - Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"` - ExpiresAt *time.Time `json:"expires_at"` - IsActive bool `gorm:"default:true" json:"is_active"` - Features JSON `gorm:"type:json" json:"features,omitempty"` - ActivatedAt *time.Time `json:"activated_at"` + ShopID uint64 `gorm:"not null;index" json:"shop_id"` + LicenseKey string `gorm:"size:2048;uniqueIndex" json:"license_key"` + Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"` + ExpiresAt *time.Time `json:"expires_at"` + IsActive bool `gorm:"default:true" json:"is_active"` + MaxDevices int `gorm:"default:3" json:"max_devices"` + Features JSON `gorm:"type:json" json:"features,omitempty"` + // Deprecated: device binding is now tracked in license_devices table. + DeviceID string `gorm:"size:255" json:"device_id,omitempty"` + ActivatedAt *time.Time `json:"activated_at,omitempty"` } diff --git a/backend/internal/model/license_device.go b/backend/internal/model/license_device.go new file mode 100644 index 0000000..138ffea --- /dev/null +++ b/backend/internal/model/license_device.go @@ -0,0 +1,16 @@ +package model + +import "time" + +type LicenseDevice struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + LicenseID uint64 `gorm:"not null;index" json:"license_id"` + ShopID uint64 `gorm:"not null;index" json:"shop_id"` + DeviceID string `gorm:"size:255;not null" json:"device_id"` + DeviceName string `gorm:"size:255" json:"device_name"` + Platform string `gorm:"size:50" json:"platform"` // windows|macos|android|ios|web + ActivatedAt time.Time `gorm:"not null;autoCreateTime" json:"activated_at"` + LastSeenAt time.Time `gorm:"not null;autoUpdateTime" json:"last_seen_at"` +} + +func (LicenseDevice) TableName() string { return "license_devices" } diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 1dd4dd9..02a81cb 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -123,6 +123,8 @@ func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) { return err } + createTrialLicense(tx, shop.ID) + result = RegisterResult{ ShopCode: shop.Code, ShopName: shop.Name, diff --git a/backend/internal/service/license.go b/backend/internal/service/license.go index ffc3b0f..d18157a 100644 --- a/backend/internal/service/license.go +++ b/backend/internal/service/license.go @@ -6,6 +6,7 @@ import ( "encoding/base32" "errors" "fmt" + "log" "strings" "time" @@ -13,6 +14,7 @@ import ( "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/util" ) var ( @@ -98,3 +100,40 @@ func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error { Where("shop_id = ? AND device_id = ?", shopID, deviceID). Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error } + +// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。 +// 若私钥未配置则静默跳过(开发/测试环境可不配置私钥)。 +func createTrialLicense(tx *gorm.DB, shopID uint64) { + privKey := config.C.License.Ed25519PrivateKey + if privKey == "" { + log.Printf("[license] Ed25519 private key not configured, skipping trial for shop %d", shopID) + return + } + + expiresAt := time.Now().Add(30 * 24 * time.Hour) + expiresUnix := expiresAt.Unix() + payload := util.LicensePayload{ + ShopID: shopID, + Type: "trial", + IssuedAt: time.Now().Unix(), + ExpiresAt: &expiresUnix, + MaxDevices: 1, + } + token, err := util.IssueLicenseToken(payload, privKey) + if err != nil { + log.Printf("[license] failed to issue trial token for shop %d: %v", shopID, err) + return + } + + lic := model.License{ + ShopID: shopID, + LicenseKey: token, + Type: "trial", + ExpiresAt: &expiresAt, + IsActive: true, + MaxDevices: 1, + } + if err := tx.Create(&lic).Error; err != nil { + log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err) + } +} diff --git a/backend/main.go b/backend/main.go index e26197f..0b4b65d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -90,6 +90,7 @@ func autoMigrate(db *gorm.DB) { &model.Shop{}, &model.User{}, &model.License{}, + &model.LicenseDevice{}, &model.ProductCategory{}, &model.Product{}, &model.Warehouse{}, diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index a171293..c835b7a 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -56,22 +56,38 @@ CREATE TABLE IF NOT EXISTS `users` ( -- 许可证 -- ------------------------------------------------------------ CREATE TABLE IF NOT EXISTS `licenses` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `shop_id` BIGINT UNSIGNED NOT NULL, - `license_key` VARCHAR(255) NOT NULL COMMENT '激活码', - `device_id` VARCHAR(255) DEFAULT NULL COMMENT '绑定设备ID', - `type` ENUM('trial','monthly','annual','lifetime') NOT NULL DEFAULT 'trial', - `expires_at` DATETIME DEFAULT NULL COMMENT 'NULL=永久', - `is_active` TINYINT(1) NOT NULL DEFAULT 1, - `features` JSON DEFAULT NULL COMMENT '功能开关 {"finance":true}', - `activated_at` DATETIME DEFAULT NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `shop_id` BIGINT UNSIGNED NOT NULL, + `license_key` VARCHAR(2048) NOT NULL COMMENT 'Ed25519 signed token', + `device_id` VARCHAR(255) DEFAULT NULL COMMENT 'deprecated: use license_devices', + `type` ENUM('trial','monthly','annual','lifetime') NOT NULL DEFAULT 'trial', + `expires_at` DATETIME DEFAULT NULL COMMENT 'NULL=永久', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `max_devices` INT NOT NULL DEFAULT 3 COMMENT '最大绑定设备数', + `features` JSON DEFAULT NULL COMMENT '功能开关 {"finance":true}', + `activated_at` DATETIME DEFAULT NULL COMMENT 'deprecated', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `uk_license_key` (`license_key`), + UNIQUE KEY `uk_license_key` (`license_key`(255)), KEY `idx_shop_id` (`shop_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证'; +CREATE TABLE IF NOT EXISTS `license_devices` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `license_id` BIGINT UNSIGNED NOT NULL, + `shop_id` BIGINT UNSIGNED NOT NULL, + `device_id` VARCHAR(255) NOT NULL, + `device_name` VARCHAR(255) DEFAULT NULL, + `platform` VARCHAR(50) DEFAULT NULL COMMENT 'windows|macos|android|ios|web', + `activated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_license_device` (`license_id`, `device_id`), + KEY `idx_shop_id` (`shop_id`), + KEY `idx_license_id` (`license_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证设备绑定'; + -- ------------------------------------------------------------ -- 商品分类 -- ------------------------------------------------------------ diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 289698d..a7386ba 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -90,9 +90,21 @@ func SetupTestDB() *gorm.DB { type TEXT DEFAULT 'trial', expires_at DATETIME, is_active INTEGER DEFAULT 1, + max_devices INTEGER DEFAULT 3, features TEXT, activated_at DATETIME )`, + `CREATE TABLE IF NOT EXISTS license_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + license_id INTEGER NOT NULL, + shop_id INTEGER NOT NULL, + device_id TEXT NOT NULL, + device_name TEXT, + platform TEXT, + activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(license_id, device_id) + )`, `CREATE TABLE IF NOT EXISTS product_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME,