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:
wangjia
2026-06-11 00:38:49 +08:00
parent 51cfe5fc6d
commit 3ab78dbf7a
15 changed files with 554 additions and 429 deletions
+4 -48
View File
@@ -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
}