d28aba863e
- #45 Activate 幂等路径 Update 加错误检查,失败时返回 error 而非静默成功 - #46 Verify 加载 license 加 shop_id 过滤(防御纵深) - #48 删除 ErrDeviceMismatch 死代码(多设备迁移后已无单设备约束) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
180 lines
5.7 KiB
Go
180 lines
5.7 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/wangjia/jiu/backend/config"
|
|
"github.com/wangjia/jiu/backend/internal/model"
|
|
"github.com/wangjia/jiu/backend/internal/util"
|
|
)
|
|
|
|
var (
|
|
ErrLicenseNotFound = errors.New("license not found")
|
|
ErrLicenseInactive = errors.New("license is inactive")
|
|
ErrLicenseExpired = errors.New("license has expired")
|
|
ErrDeviceLimitExceed = errors.New("device limit reached — deactivate another device first")
|
|
)
|
|
|
|
type LicenseService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewLicenseService(db *gorm.DB) *LicenseService {
|
|
return &LicenseService{db: db}
|
|
}
|
|
|
|
// GenerateKey 生成许可证激活码
|
|
// 格式:HMAC-SHA256(shopID+licenseType+expiry, secret) → base32, 每5字符加'-'
|
|
func GenerateKey(shopID uint64, licenseType string, expiresAt *time.Time) string {
|
|
payload := fmt.Sprintf("%d:%s", shopID, licenseType)
|
|
if expiresAt != nil {
|
|
payload += ":" + expiresAt.Format("20060102")
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(config.C.License.HMACSecret))
|
|
mac.Write([]byte(payload))
|
|
raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(mac.Sum(nil))
|
|
// 截取前20字符,分4段,每段5字符
|
|
raw = strings.ToUpper(raw)[:20]
|
|
return fmt.Sprintf("%s-%s-%s-%s", raw[0:5], raw[5:10], raw[10:15], raw[15:20])
|
|
}
|
|
|
|
// Activate 激活许可证并绑定设备到 license_devices 表。
|
|
// 若该设备已绑定,则更新 last_seen_at(幂等)。
|
|
// 若是新设备,则校验是否超出 max_devices 上限。
|
|
func (s *LicenseService) Activate(shopID uint64, licenseKey, deviceID, deviceName, platform string) (*model.License, error) {
|
|
var lic model.License
|
|
if err := s.db.Where("license_key = ? AND shop_id = ?", licenseKey, shopID).First(&lic).Error; err != nil {
|
|
return nil, ErrLicenseNotFound
|
|
}
|
|
if !lic.IsActive {
|
|
return nil, ErrLicenseInactive
|
|
}
|
|
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
|
|
return nil, ErrLicenseExpired
|
|
}
|
|
|
|
var existing model.LicenseDevice
|
|
err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, deviceID).First(&existing).Error
|
|
if err == nil {
|
|
// Device already bound — just touch last_seen_at (handled by autoUpdateTime)
|
|
if err := s.db.Model(&existing).Update("device_name", deviceName).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &lic, nil
|
|
}
|
|
|
|
// New device — enforce max_devices
|
|
var count int64
|
|
s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count)
|
|
if int(count) >= lic.MaxDevices {
|
|
return nil, ErrDeviceLimitExceed
|
|
}
|
|
|
|
dev := model.LicenseDevice{
|
|
LicenseID: lic.ID,
|
|
ShopID: shopID,
|
|
DeviceID: deviceID,
|
|
DeviceName: deviceName,
|
|
Platform: platform,
|
|
}
|
|
if err := s.db.Create(&dev).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &lic, nil
|
|
}
|
|
|
|
// Verify 验证设备许可证(客户端启动时调用)。
|
|
// 通过 license_devices 表查找设备,再加载对应的许可证做有效性检查。
|
|
func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License, error) {
|
|
var dev model.LicenseDevice
|
|
if err := s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).First(&dev).Error; err != nil {
|
|
return nil, ErrLicenseNotFound
|
|
}
|
|
var lic model.License
|
|
if err := s.db.Where("id = ? AND shop_id = ? AND is_active = 1", dev.LicenseID, shopID).First(&lic).Error; err != nil {
|
|
return nil, ErrLicenseNotFound
|
|
}
|
|
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
|
|
return nil, ErrLicenseExpired
|
|
}
|
|
return &lic, nil
|
|
}
|
|
|
|
// ShopInfo 返回门店当前授权信息(取最新一条有效许可证)
|
|
func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, 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, ErrLicenseNotFound
|
|
}
|
|
return &lic, nil
|
|
}
|
|
|
|
// CountDevices 返回指定 license 下已绑定设备数。
|
|
func (s *LicenseService) CountDevices(licenseID uint64) (int64, error) {
|
|
var count int64
|
|
err := s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", licenseID).Count(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
// ListDevices 列出许可证下所有已绑定设备。
|
|
func (s *LicenseService) ListDevices(shopID uint64) ([]model.LicenseDevice, error) {
|
|
var devs []model.LicenseDevice
|
|
if err := s.db.Where("shop_id = ?", shopID).Order("activated_at DESC").Find(&devs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return devs, nil
|
|
}
|
|
|
|
// Deactivate 解绑设备(从 license_devices 删除该条记录)。
|
|
func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
|
|
return s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).
|
|
Delete(&model.LicenseDevice{}).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)
|
|
}
|
|
}
|