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,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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user