fix(backend): 架构质量改进批次二 (#32-37)
- #32 License 激活迁移到 license_devices 表:Activate/Verify/Deactivate 全部改用 license_devices,新增 max_devices 校验和 GET /license/devices 端点; Activate 现在校验 shop_id 防跨租户激活 - #33 checkInventory 从 StockOutHandler 移到 StockService.CheckInventoryAvailability - #34 新增 util/response.go 统一错误响应工具(RespondError/RespondSuccess/RespondCreated) - #35 生产模式 CORS Origin='*' 启动时 Fatal - #36 生产模式 License 私钥未配置启动时 Fatal - #37 新增 util/page.go ValidatePageSize,应用到 partner/product/stock_in/stock_out handler Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,10 +18,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLicenseNotFound = errors.New("license not found")
|
||||
ErrLicenseInactive = errors.New("license is inactive")
|
||||
ErrLicenseExpired = errors.New("license has expired")
|
||||
ErrDeviceMismatch = errors.New("license is bound to another device")
|
||||
ErrLicenseNotFound = errors.New("license not found")
|
||||
ErrLicenseInactive = errors.New("license is inactive")
|
||||
ErrLicenseExpired = errors.New("license has expired")
|
||||
ErrDeviceMismatch = errors.New("license is bound to another device")
|
||||
ErrDeviceLimitExceed = errors.New("device limit reached — deactivate another device first")
|
||||
)
|
||||
|
||||
type LicenseService struct {
|
||||
@@ -47,10 +48,12 @@ func GenerateKey(shopID uint64, licenseType string, expiresAt *time.Time) string
|
||||
return fmt.Sprintf("%s-%s-%s-%s", raw[0:5], raw[5:10], raw[10:15], raw[15:20])
|
||||
}
|
||||
|
||||
// Activate 激活许可证(绑定设备)
|
||||
func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License, error) {
|
||||
// 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 = ?", licenseKey).First(&lic).Error; err != nil {
|
||||
if err := s.db.Where("license_key = ? AND shop_id = ?", licenseKey, shopID).First(&lic).Error; err != nil {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
if !lic.IsActive {
|
||||
@@ -59,23 +62,44 @@ func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License,
|
||||
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
|
||||
return nil, ErrLicenseExpired
|
||||
}
|
||||
// 若已绑定设备,校验是否一致
|
||||
if lic.DeviceID != "" && lic.DeviceID != deviceID {
|
||||
return nil, ErrDeviceMismatch
|
||||
|
||||
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)
|
||||
s.db.Model(&existing).Update("device_name", deviceName)
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
lic.DeviceID = deviceID
|
||||
lic.ActivatedAt = &now
|
||||
s.db.Save(&lic)
|
||||
// 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 验证(客户端启动时调用)
|
||||
// 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("shop_id = ? AND device_id = ? AND is_active = 1", shopID, deviceID).
|
||||
First(&lic).Error; err != nil {
|
||||
if err := s.db.Where("id = ? AND is_active = 1", dev.LicenseID).First(&lic).Error; err != nil {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
|
||||
@@ -94,11 +118,19 @@ func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, error) {
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
// Deactivate 解绑设备(换机时使用)
|
||||
// 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.Model(&model.License{}).
|
||||
Where("shop_id = ? AND device_id = ?", shopID, deviceID).
|
||||
Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error
|
||||
return s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).
|
||||
Delete(&model.LicenseDevice{}).Error
|
||||
}
|
||||
|
||||
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
|
||||
|
||||
@@ -15,71 +15,106 @@ func TestLicenseService_Activate_Success(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC001")
|
||||
|
||||
// 创建许可证
|
||||
expiry := time.Now().Add(30 * 24 * time.Hour)
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "AAAAA-BBBBB-CCCCC-DDDDD",
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiry,
|
||||
MaxDevices: 3,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Activate("AAAAA-BBBBB-CCCCC-DDDDD", "device-001")
|
||||
result, err := svc.Activate(shop.ID, "AAAAA-BBBBB-CCCCC-DDDDD", "device-001", "Test PC", "windows")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "device-001", result.DeviceID)
|
||||
assert.NotNil(t, result.ActivatedAt)
|
||||
|
||||
// Verify device record was created
|
||||
var dev model.LicenseDevice
|
||||
require.NoError(t, db.Where("license_id = ? AND device_id = ?", lic.ID, "device-001").First(&dev).Error)
|
||||
assert.Equal(t, "Test PC", dev.DeviceName)
|
||||
assert.Equal(t, "windows", dev.Platform)
|
||||
}
|
||||
|
||||
func TestLicenseService_Activate_AlreadyBoundToDifferentDevice(t *testing.T) {
|
||||
func TestLicenseService_Activate_SameDeviceIdempotent(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC002")
|
||||
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "EEEEE-FFFFF-GGGGG-HHHHH",
|
||||
DeviceID: "existing-device",
|
||||
IsActive: true,
|
||||
MaxDevices: 3,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
// Pre-bind the device
|
||||
require.NoError(t, db.Create(&model.LicenseDevice{
|
||||
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "same-device",
|
||||
}).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Activate("EEEEE-FFFFF-GGGGG-HHHHH", "new-device")
|
||||
// Re-activating same device should succeed (idempotent)
|
||||
result, err := svc.Activate(shop.ID, "EEEEE-FFFFF-GGGGG-HHHHH", "same-device", "Updated Name", "windows")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrDeviceMismatch, err)
|
||||
assert.Nil(t, result)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Still only one device record
|
||||
var count int64
|
||||
db.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestLicenseService_Activate_SameDevice(t *testing.T) {
|
||||
func TestLicenseService_Activate_DeviceLimitExceeded(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC003")
|
||||
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID,
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "IIIII-JJJJJ-KKKKK-LLLLL",
|
||||
DeviceID: "same-device",
|
||||
IsActive: true,
|
||||
MaxDevices: 2,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
// Fill up the device limit
|
||||
require.NoError(t, db.Create(&model.LicenseDevice{LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "dev-1"}).Error)
|
||||
require.NoError(t, db.Create(&model.LicenseDevice{LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "dev-2"}).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
// 同一设备重新激活应该成功
|
||||
result, err := svc.Activate("IIIII-JJJJJ-KKKKK-LLLLL", "same-device")
|
||||
result, err := svc.Activate(shop.ID, "IIIII-JJJJJ-KKKKK-LLLLL", "dev-3", "", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "same-device", result.DeviceID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrDeviceLimitExceed, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestLicenseService_Activate_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC004")
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Activate("NONEX-ISTEN-TTTTT-LICCC", "device-001")
|
||||
result, err := svc.Activate(shop.ID, "NONEX-ISTEN-TTTTT-LICCC", "device-001", "", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrLicenseNotFound, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestLicenseService_Activate_WrongShop(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC004B")
|
||||
otherShop := testutil.CreateTestShop(db, "LIC004C")
|
||||
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID, LicenseKey: "OTHSH-BBBBB-CCCCC-DDDDD", IsActive: true, MaxDevices: 3,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
// otherShop cannot activate a license belonging to shop
|
||||
result, err := svc.Activate(otherShop.ID, "OTHSH-BBBBB-CCCCC-DDDDD", "device-001", "", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrLicenseNotFound, err)
|
||||
@@ -88,20 +123,16 @@ func TestLicenseService_Activate_NotFound(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Activate_Inactive(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC004")
|
||||
shop := testutil.CreateTestShop(db, "LIC005")
|
||||
|
||||
// 先创建激活的许可证,再禁用(避免 GORM 零值跳过问题)
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "MMMMM-NNNNN-OOOOO-PPPPP",
|
||||
IsActive: true,
|
||||
ShopID: shop.ID, LicenseKey: "MMMMM-NNNNN-OOOOO-PPPPP", IsActive: true, MaxDevices: 3,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
// 禁用
|
||||
require.NoError(t, db.Model(lic).Update("is_active", false).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Activate("MMMMM-NNNNN-OOOOO-PPPPP", "device-001")
|
||||
result, err := svc.Activate(shop.ID, "MMMMM-NNNNN-OOOOO-PPPPP", "device-001", "", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrLicenseInactive, err)
|
||||
@@ -110,20 +141,16 @@ func TestLicenseService_Activate_Inactive(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Activate_Expired(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC005")
|
||||
shop := testutil.CreateTestShop(db, "LIC006")
|
||||
|
||||
// 已过期
|
||||
expiry := time.Now().Add(-24 * time.Hour)
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "QQQQQ-RRRRR-SSSSS-TTTTT",
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiry,
|
||||
ShopID: shop.ID, LicenseKey: "QQQQQ-RRRRR-SSSSS-TTTTT", IsActive: true, ExpiresAt: &expiry, MaxDevices: 3,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Activate("QQQQQ-RRRRR-SSSSS-TTTTT", "device-001")
|
||||
result, err := svc.Activate(shop.ID, "QQQQQ-RRRRR-SSSSS-TTTTT", "device-001", "", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrLicenseExpired, err)
|
||||
@@ -132,40 +159,37 @@ func TestLicenseService_Activate_Expired(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Verify_Success(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC006")
|
||||
shop := testutil.CreateTestShop(db, "LIC007")
|
||||
|
||||
expiry := time.Now().Add(30 * 24 * time.Hour)
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX",
|
||||
DeviceID: "my-device",
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiry,
|
||||
ShopID: shop.ID, LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX", IsActive: true, ExpiresAt: &expiry,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
require.NoError(t, db.Create(&model.LicenseDevice{
|
||||
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "my-device",
|
||||
}).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Verify(shop.ID, "my-device")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "my-device", result.DeviceID)
|
||||
assert.Equal(t, lic.ID, result.ID)
|
||||
}
|
||||
|
||||
func TestLicenseService_Verify_Expired(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC007")
|
||||
shop := testutil.CreateTestShop(db, "LIC008")
|
||||
|
||||
// 已过期
|
||||
expiry := time.Now().Add(-1 * time.Hour)
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB",
|
||||
DeviceID: "expired-device",
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiry,
|
||||
ShopID: shop.ID, LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB", IsActive: true, ExpiresAt: &expiry,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
require.NoError(t, db.Create(&model.LicenseDevice{
|
||||
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "expired-device",
|
||||
}).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Verify(shop.ID, "expired-device")
|
||||
@@ -177,7 +201,7 @@ func TestLicenseService_Verify_Expired(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Verify_NotFound(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC008")
|
||||
shop := testutil.CreateTestShop(db, "LIC009")
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Verify(shop.ID, "nonexistent-device")
|
||||
@@ -189,17 +213,15 @@ func TestLicenseService_Verify_NotFound(t *testing.T) {
|
||||
|
||||
func TestLicenseService_Verify_NoExpiry(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "LIC009")
|
||||
shop := testutil.CreateTestShop(db, "LIC010")
|
||||
|
||||
// 永久许可证(无过期时间)
|
||||
lic := &model.License{
|
||||
ShopID: shop.ID,
|
||||
LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF",
|
||||
DeviceID: "lifetime-device",
|
||||
IsActive: true,
|
||||
ExpiresAt: nil,
|
||||
ShopID: shop.ID, LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF", IsActive: true, ExpiresAt: nil,
|
||||
}
|
||||
require.NoError(t, db.Create(lic).Error)
|
||||
require.NoError(t, db.Create(&model.LicenseDevice{
|
||||
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "lifetime-device",
|
||||
}).Error)
|
||||
|
||||
svc := NewLicenseService(db)
|
||||
result, err := svc.Verify(shop.ID, "lifetime-device")
|
||||
|
||||
@@ -287,3 +287,46 @@ func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string,
|
||||
})
|
||||
return no, err
|
||||
}
|
||||
|
||||
// CheckInventoryAvailability validates that the warehouse has sufficient stock for each item.
|
||||
// Returns an error describing the first shortage encountered.
|
||||
func (s *StockService) CheckInventoryAvailability(shopID, warehouseID uint64, items []model.StockOutItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
productIDs := make([]uint64, 0, len(items))
|
||||
for _, item := range items {
|
||||
productIDs = append(productIDs, item.ProductID)
|
||||
}
|
||||
|
||||
type inventorySum struct {
|
||||
ProductID uint64
|
||||
Total float64
|
||||
}
|
||||
var sums []inventorySum
|
||||
s.db.Model(&model.Inventory{}).
|
||||
Select("product_id, COALESCE(SUM(quantity), 0) AS total").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL",
|
||||
shopID, warehouseID, productIDs).
|
||||
Group("product_id").Scan(&sums)
|
||||
|
||||
sumMap := make(map[uint64]float64, len(sums))
|
||||
for _, s := range sums {
|
||||
sumMap[s.ProductID] = s.Total
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
have := sumMap[item.ProductID]
|
||||
if have < item.Quantity {
|
||||
var p model.Product
|
||||
s.db.Where("id = ?", item.ProductID).First(&p)
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("商品ID %d", item.ProductID)
|
||||
}
|
||||
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user