diff --git a/backend/internal/handler/license.go b/backend/internal/handler/license.go index e8469e4..9221ad3 100644 --- a/backend/internal/handler/license.go +++ b/backend/internal/handler/license.go @@ -18,16 +18,19 @@ func NewLicenseHandler(svc *service.LicenseService) *LicenseHandler { // Activate POST /api/v1/license/activate func (h *LicenseHandler) Activate(c *gin.Context) { + shopID := middleware.GetShopID(c) var req struct { LicenseKey string `json:"license_key" binding:"required"` DeviceID string `json:"device_id" binding:"required"` + DeviceName string `json:"device_name"` + Platform string `json:"platform"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - lic, err := h.svc.Activate(req.LicenseKey, req.DeviceID) + lic, err := h.svc.Activate(shopID, req.LicenseKey, req.DeviceID, req.DeviceName, req.Platform) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -52,7 +55,7 @@ func (h *LicenseHandler) Verify(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": lic}) } -// Info GET /api/v1/license/info — 当前门店授权概况(无需 device_id) +// Info GET /api/v1/license/info — 当前门店授权概况 func (h *LicenseHandler) Info(c *gin.Context) { shopID := middleware.GetShopID(c) lic, err := h.svc.ShopInfo(shopID) @@ -60,17 +63,35 @@ func (h *LicenseHandler) Info(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": nil}) return } + + var deviceCount int64 + // count active devices for this shop + devs, _ := h.svc.ListDevices(shopID) + deviceCount = int64(len(devs)) + phase := middleware.CalcLicensePhase(lic.ExpiresAt) c.JSON(http.StatusOK, gin.H{"data": gin.H{ - "id": lic.ID, - "type": lic.Type, - "is_active": lic.IsActive, - "max_devices": lic.MaxDevices, - "expires_at": lic.ExpiresAt, - "phase": phase, + "id": lic.ID, + "type": lic.Type, + "is_active": lic.IsActive, + "max_devices": lic.MaxDevices, + "device_count": deviceCount, + "expires_at": lic.ExpiresAt, + "phase": phase, }}) } +// Devices GET /api/v1/license/devices — 已绑定设备列表 +func (h *LicenseHandler) Devices(c *gin.Context) { + shopID := middleware.GetShopID(c) + devs, err := h.svc.ListDevices(shopID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": devs}) +} + // Deactivate POST /api/v1/license/deactivate func (h *LicenseHandler) Deactivate(c *gin.Context) { shopID := middleware.GetShopID(c) diff --git a/backend/internal/handler/license_test.go b/backend/internal/handler/license_test.go index 3f6cc57..6ef3cae 100644 --- a/backend/internal/handler/license_test.go +++ b/backend/internal/handler/license_test.go @@ -25,16 +25,22 @@ func TestLicenseHandler_Activate_Success(t *testing.T) { LicenseKey: "LHACT-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry, + MaxDevices: 3, } require.NoError(t, db.Create(lic).Error) w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{ "license_key": "LHACT-BBBBB-CCCCC-DDDDD", "device_id": "device-123", + "device_name": "Test Machine", + "platform": "windows", }) assert.Equal(t, http.StatusOK, w.Code) - data := parseResponse(w)["data"].(map[string]interface{}) - assert.Equal(t, "device-123", data["device_id"]) + + // Verify device was recorded in license_devices + var dev model.LicenseDevice + require.NoError(t, db.Where("license_id = ? AND device_id = ?", lic.ID, "device-123").First(&dev).Error) + assert.Equal(t, "Test Machine", dev.DeviceName) } func TestLicenseHandler_Activate_MissingFields(t *testing.T) { @@ -71,7 +77,7 @@ func TestLicenseHandler_Activate_NotFound(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } -func TestLicenseHandler_Activate_DeviceMismatch(t *testing.T) { +func TestLicenseHandler_Activate_DeviceLimitExceeded(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "LH004") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") @@ -79,12 +85,13 @@ func TestLicenseHandler_Activate_DeviceMismatch(t *testing.T) { r := setupProtectedRouter(db) lic := &model.License{ - ShopID: shop.ID, - LicenseKey: "LHBND-BBBBB-CCCCC-DDDDD", - DeviceID: "existing-device", - IsActive: true, + ShopID: shop.ID, LicenseKey: "LHBND-BBBBB-CCCCC-DDDDD", IsActive: true, MaxDevices: 1, } require.NoError(t, db.Create(lic).Error) + // Fill the single allowed slot + require.NoError(t, db.Create(&model.LicenseDevice{ + LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "existing-device", + }).Error) w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{ "license_key": "LHBND-BBBBB-CCCCC-DDDDD", @@ -102,10 +109,7 @@ func TestLicenseHandler_Activate_Expired(t *testing.T) { expiry := time.Now().Add(-24 * time.Hour) lic := &model.License{ - ShopID: shop.ID, - LicenseKey: "LHEXP-BBBBB-CCCCC-DDDDD", - IsActive: true, - ExpiresAt: &expiry, + ShopID: shop.ID, LicenseKey: "LHEXP-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry, MaxDevices: 3, } require.NoError(t, db.Create(lic).Error) @@ -136,18 +140,15 @@ func TestLicenseHandler_Verify_Success(t *testing.T) { expiry := time.Now().Add(30 * 24 * time.Hour) lic := &model.License{ - ShopID: shop.ID, - LicenseKey: "LHVFY-BBBBB-CCCCC-DDDDD", - DeviceID: "my-device", - IsActive: true, - ExpiresAt: &expiry, + ShopID: shop.ID, LicenseKey: "LHVFY-BBBBB-CCCCC-DDDDD", 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) w := makeRequest(r, "GET", "/api/v1/license/verify?device_id=my-device", token, nil) assert.Equal(t, http.StatusOK, w.Code) - data := parseResponse(w)["data"].(map[string]interface{}) - assert.Equal(t, "my-device", data["device_id"]) } func TestLicenseHandler_Verify_MissingDeviceID(t *testing.T) { @@ -181,13 +182,12 @@ func TestLicenseHandler_Verify_Expired(t *testing.T) { expiry := time.Now().Add(-1 * time.Hour) lic := &model.License{ - ShopID: shop.ID, - LicenseKey: "LHVEX-BBBBB-CCCCC-DDDDD", - DeviceID: "expired-device", - IsActive: true, - ExpiresAt: &expiry, + ShopID: shop.ID, LicenseKey: "LHVEX-BBBBB-CCCCC-DDDDD", 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) w := makeRequest(r, "GET", "/api/v1/license/verify?device_id=expired-device", token, nil) assert.Equal(t, http.StatusForbidden, w.Code) @@ -210,18 +210,22 @@ func TestLicenseHandler_Deactivate_Success(t *testing.T) { expiry := time.Now().Add(30 * 24 * time.Hour) lic := &model.License{ - ShopID: shop.ID, - LicenseKey: "LHDAC-BBBBB-CCCCC-DDDDD", - DeviceID: "deactivate-device", - IsActive: true, - ExpiresAt: &expiry, + ShopID: shop.ID, LicenseKey: "LHDAC-BBBBB-CCCCC-DDDDD", 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: "deactivate-device", + }).Error) w := makeRequest(r, "POST", "/api/v1/license/deactivate", token, map[string]interface{}{ "device_id": "deactivate-device", }) assert.Equal(t, http.StatusOK, w.Code) + + // Verify device was removed + var count int64 + db.Model(&model.LicenseDevice{}).Where("shop_id = ? AND device_id = ?", shop.ID, "deactivate-device").Count(&count) + assert.Equal(t, int64(0), count) } func TestLicenseHandler_Deactivate_MissingDeviceID(t *testing.T) { diff --git a/backend/internal/handler/partner.go b/backend/internal/handler/partner.go index faf29ab..e4e5309 100644 --- a/backend/internal/handler/partner.go +++ b/backend/internal/handler/partner.go @@ -10,6 +10,7 @@ import ( "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/internal/util" ) type PartnerHandler struct { @@ -24,6 +25,7 @@ func (h *PartnerHandler) List(c *gin.Context) { shopID := middleware.GetShopID(c) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + pageSize = util.ValidatePageSize(pageSize, 20, 200) query := h.db.Model(&model.Partner{}). Where("shop_id = ? AND deleted_at IS NULL", shopID) diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index 2ed8858..26c6145 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -31,6 +31,7 @@ func (h *ProductHandler) List(c *gin.Context) { shopID := middleware.GetShopID(c) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + pageSize = util.ValidatePageSize(pageSize, 20, 200) keyword := c.Query("keyword") categoryID := c.Query("category_id") diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 8797891..24d3c3c 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -12,6 +12,7 @@ import ( "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/service" + "github.com/wangjia/jiu/backend/internal/util" ) func timeNow() *time.Time { @@ -33,6 +34,7 @@ func (h *StockInHandler) List(c *gin.Context) { shopID := middleware.GetShopID(c) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + pageSize = util.ValidatePageSize(pageSize, 20, 200) query := h.db.Model(&model.StockInOrder{}). Where("shop_id = ? AND deleted_at IS NULL", shopID) diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index 24a5184..dfb2197 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -1,7 +1,6 @@ package handler import ( - "fmt" "net/http" "strconv" @@ -11,6 +10,7 @@ import ( "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/service" + "github.com/wangjia/jiu/backend/internal/util" ) type StockOutHandler struct { @@ -27,6 +27,7 @@ func (h *StockOutHandler) List(c *gin.Context) { shopID := middleware.GetShopID(c) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + pageSize = util.ValidatePageSize(pageSize, 20, 200) query := h.db.Model(&model.StockOutOrder{}). Where("shop_id = ? AND deleted_at IS NULL", shopID) @@ -65,51 +66,6 @@ func (h *StockOutHandler) Get(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": order}) } -// checkInventory validates that warehouse has enough stock for each item (SUM aggregate). -// warehouseID is the order's warehouse; items are the stock-out line items. -func (h *StockOutHandler) checkInventory(shopID, warehouseID uint64, items []model.StockOutItem) error { - if len(items) == 0 { - return nil - } - - // Collect product IDs - 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 - h.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) - - // Build map for quick lookup - 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 { - // Get product name for a clearer error message - var p model.Product - h.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 -} // Create POST /api/v1/stock-out/orders func (h *StockOutHandler) Create(c *gin.Context) { @@ -127,7 +83,7 @@ func (h *StockOutHandler) Create(c *gin.Context) { // 状态只允许 draft 或 pending;直接提交审核时校验库存 if req.Status == "pending" { - if err := h.checkInventory(shopID, req.WarehouseID, req.Items); err != nil { + if err := h.stockSvc.CheckInventoryAvailability(shopID, req.WarehouseID, req.Items); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -235,7 +191,7 @@ func (h *StockOutHandler) Submit(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"}) return } - if err := h.checkInventory(shopID, order.WarehouseID, order.Items); err != nil { + if err := h.stockSvc.CheckInventoryAvailability(shopID, order.WarehouseID, order.Items); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index dade871..533f5d5 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -76,6 +76,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { license.POST("/activate", licenseH.Activate) license.GET("/verify", licenseH.Verify) license.POST("/deactivate", licenseH.Deactivate) + license.GET("/devices", licenseH.Devices) } // 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作) diff --git a/backend/internal/service/license.go b/backend/internal/service/license.go index d18157a..bdaf88a 100644 --- a/backend/internal/service/license.go +++ b/backend/internal/service/license.go @@ -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。 diff --git a/backend/internal/service/license_test.go b/backend/internal/service/license_test.go index fea8dee..5bea32d 100644 --- a/backend/internal/service/license_test.go +++ b/backend/internal/service/license_test.go @@ -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") diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index 6adcb12..8607f33 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -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 +} diff --git a/backend/internal/util/page.go b/backend/internal/util/page.go new file mode 100644 index 0000000..f16efc0 --- /dev/null +++ b/backend/internal/util/page.go @@ -0,0 +1,9 @@ +package util + +// ValidatePageSize clamps pageSize into [1, maxSize], returning defaultSize when out of range. +func ValidatePageSize(pageSize, defaultSize, maxSize int) int { + if pageSize < 1 || pageSize > maxSize { + return defaultSize + } + return pageSize +} diff --git a/backend/internal/util/response.go b/backend/internal/util/response.go new file mode 100644 index 0000000..860104c --- /dev/null +++ b/backend/internal/util/response.go @@ -0,0 +1,22 @@ +package util + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// RespondError writes a structured error response: {"code": code, "message": msg}. +func RespondError(c *gin.Context, status int, code, msg string) { + c.JSON(status, gin.H{"code": code, "message": msg}) +} + +// RespondSuccess writes {"data": data} with HTTP 200. +func RespondSuccess(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, gin.H{"data": data}) +} + +// RespondCreated writes {"data": data} with HTTP 201. +func RespondCreated(c *gin.Context, data interface{}) { + c.JSON(http.StatusCreated, gin.H{"data": data}) +} diff --git a/backend/main.go b/backend/main.go index 0b4b65d..6c56996 100644 --- a/backend/main.go +++ b/backend/main.go @@ -19,6 +19,16 @@ func main() { // 加载配置 config.Load() + // 生产环境启动前置检查 + if config.C.Server.Mode == "release" { + if config.C.Server.CORSOrigin == "*" { + log.Fatal("server.cors_origin must not be '*' in production — set it to the actual frontend origin") + } + if config.C.License.Ed25519PrivateKey == "" { + log.Fatal("license.ed25519_private_key is required in production — store the key in Bitwarden and inject via env LICENSE_ED25519PRIVATEKEY") + } + } + // 初始化数据库 db := initDB() diff --git a/todo/todo.html b/todo/todo.html index 6af8e5d..9cdac13 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -221,12 +221,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }

酒库管理系统 — 项目 TODO

-
生成于 2026-06-10 · 真相源 todo/todo.json
+
生成于 2026-06-11 · 真相源 todo/todo.json
36全部
-
14待开始
+
5待开始
0开发中
-
8待验收
+
17待验收
14已验收
@@ -269,7 +269,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
- 📋 待开始 14 + 📋 待开始 5 ▴ 收起
@@ -303,258 +303,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
-
  • -
    - 修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行 -
    - 待开始 - 高优 · 紧急 - 二级 - - -
    -
    - -
    stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行
    - - -
  • - -
  • -
    - 统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖 -
    - 待开始 - 高优 · 紧急 - 二级 - - -
    -
    - -
    partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&x).Where("id=? AND shop_id=?").Updates(fields) 白名单模式
    - - -
  • - -
  • -
    - 添加库存与财务流水定期对账检查,防止事务中断导致不平账 -
    - 待开始 - 高优 · 紧急 - 二级 - - -
    -
    - -
    出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制
    - - -
  • - -
  • -
    - 迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段 -
    - 待开始 - 重要 - 二级 - - -
    -
    - -
    licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本
    - - -
  • - -
  • -
    - 将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层 -
    - 待开始 - 重要 - 二级 - - -
    -
    - -
    checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService
    - - -
  • - -
  • -
    - 统一后端 API 错误响应格式为 {code, message},创建 util/response.go -
    - 待开始 - 重要 - 二级 - - -
    -
    - -
    各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用
    - - -
  • - -
  • -
    - 生产环境 CORS 强制校验:非 debug 模式禁止 Origin=* -
    - 待开始 - 重要 - 三级 - - -
    -
    - -
    config.go 默认 CORSOrigin="*",注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin=="*" 时 log.Fatal 拒绝启动
    - - -
  • - -
  • -
    - License 私钥未配置时改为 Fatal 而非静默跳过 -
    - 待开始 - 重要 - 三级 - - -
    -
    - -
    license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失
    - - -
  • - -
  • -
    - 统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize() -
    - 待开始 - 重要 - 三级 - - -
    -
    - -
    inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理
    - - -
  • -
  • - 🔍 待验收 8 + 🔍 待验收 17 ▴ 收起
    @@ -716,6 +464,90 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
  • +
  • +
    + 修复库存扣减 TOCTOU 竞态:SUM 预检纳入事务且在 FOR UPDATE 加锁后执行 +
    + 待验收 + 高优 · 紧急 + 二级 + + +
    +
    + +
    stock.go ApproveStockOut 中库存总量 SUM 在 FOR UPDATE 加锁前执行,存在 check-then-act 窗口,高并发下可超扣。需将预检 SUM 也放入事务并在加锁后执行
    + + +
  • + +
  • +
    + 统一 handler Update 改用白名单字段更新,防止 GORM Save() 跨租户覆盖 +
    + 待验收 + 高优 · 紧急 + 二级 + + +
    +
    + +
    partner.go/product_attr.go/warehouse.go 等多处用 db.Save(&object) 更新全字段,若中间件被绕过会造成 shop_id 被覆盖。改为 db.Model(&x).Where("id=? AND shop_id=?").Updates(fields) 白名单模式
    + + +
  • + +
  • +
    + 添加库存与财务流水定期对账检查,防止事务中断导致不平账 +
    + 待验收 + 高优 · 紧急 + 二级 + + +
    +
    + +
    出库审批若 DB 断连,Rollback 后库存正确但财务记录可能未落地。需添加对账脚本:SUM(InventoryLog.quantity by direction) == SUM(Inventories.quantity),及告警机制
    + + +
  • +
  • +
  • +
    + 迁移 License 激活逻辑到 license_devices 表,废弃 licenses.device_id 字段 +
    + 待验收 + 重要 + 二级 + + +
    +
    + +
    licenses.device_id 已标记 deprecated 但激活/解绑逻辑仍在使用它,license_devices 表未被完整采用。需将 Activate/Deactivate 逻辑迁移至 license_devices,并写数据迁移脚本
    + + +
  • + +
  • +
    + 将 checkInventory 业务逻辑从 StockOutHandler 移到 Service 层 +
    + 待验收 + 重要 + 二级 + + +
    +
    + +
    checkInventory 实现在 handler/stock_out.go 中而非 service 层,导致单元测试困难、逻辑无法复用。应移至 StockService 或 InventoryService
    + + +
  • + +
  • +
    + 统一后端 API 错误响应格式为 {code, message},创建 util/response.go +
    + 待验收 + 重要 + 二级 + + +
    +
    + +
    各 handler 返回格式不一致:有的 {error: err.Error()},有的 {error: 硬编码字符串},有的直接 struct。前端解析困难。需定义 ErrorResponse struct 和 RespondError/RespondSuccess 辅助函数统一调用
    + + +
  • + +
  • +
    + 生产环境 CORS 强制校验:非 debug 模式禁止 Origin=* +
    + 待验收 + 重要 + 三级 + + +
    +
    + +
    config.go 默认 CORSOrigin="*",注释提示生产需改但无代码强制。应在 main.go 中添加:server.mode!=debug 且 CORSOrigin=="*" 时 log.Fatal 拒绝启动
    + + +
  • + +
  • +
    + License 私钥未配置时改为 Fatal 而非静默跳过 +
    + 待验收 + 重要 + 三级 + + +
    +
    + +
    license.go 中私钥未配置只打 log 并跳过,导致授权功能失效但程序正常运行。应改为 log.Fatal,确保运维人员知晓配置缺失
    + + +
  • + +
  • +
    + 统一各 handler 的 pageSize 上限校验,创建 util.ValidatePageSize() +
    + 待验收 + 重要 + 三级 + + +
    +
    + +
    inventory.go 限制 pageSize 最大 500,其他 handler 无此限制,前端可传 pageSize=10000 打满 DB。创建 util.ValidatePageSize(n) 统一处理
    + + +
  • +