From 51cfe5fc6d5026f84311ae597621a45c3165b1cb Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 11 Jun 2026 00:21:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E4=BF=AE=E5=A4=8D=E5=BA=93?= =?UTF-8?q?=E5=AD=98=20TOCTOU=20=E7=AB=9E=E6=80=81=20+=20handler=20?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E6=9B=B4=E6=96=B0=20+=20=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #29 ApproveStockOut:FOR UPDATE 锁定批次后再内存汇总总量, 消除预检 SUM 与加锁之间的竞态窗口,减少一次 DB 往返 #30 partner/warehouse/user/product_attr/product_option Update 方法: - 绑定到独立 req struct,防止请求体覆盖记录 ID - 改用 db.Model.Where("shop_id=?").Updates(map) 白名单更新, 数据库层强制 shop_id 隔离约束 #31 新增 GET /api/v1/admin/reconcile:对比 inventories 当前库存 与 inventory_logs 流水净量,返回差异行,用于发现不平账异常 Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/handler/admin.go | 89 ++++++++++++++++++++++ backend/internal/handler/partner.go | 32 +++++++- backend/internal/handler/product_attr.go | 28 +++---- backend/internal/handler/product_option.go | 22 +++--- backend/internal/handler/user.go | 14 ++-- backend/internal/handler/warehouse.go | 21 ++++- backend/internal/router/router.go | 1 + backend/internal/service/stock.go | 23 +++--- 8 files changed, 178 insertions(+), 52 deletions(-) diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 1d858a1..882102d 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -58,3 +59,91 @@ func (h *AdminHandler) ClearData(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"cleared": cleared}) } + +// ReconcileInventory 对账:检查 inventories 表当前库存与 inventory_logs 流水之和是否吻合。 +// 返回所有差异行,正常情况下 data 为空数组。 +// 支持可选 ?shop_id=N 参数(superadmin 可指定任意门店)。 +func (h *AdminHandler) ReconcileInventory(c *gin.Context) { + // superadmin 路由,可通过参数指定 shop;不指定则对所有门店执行 + var shopFilter *uint64 + if s := c.Query("shop_id"); s != "" { + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid shop_id"}) + return + } + shopFilter = &v + } + _ = middleware.GetShopID // superadmin 不依赖 JWT shop_id + + type row struct { + ShopID uint64 `json:"shop_id"` + WarehouseID uint64 `json:"warehouse_id"` + ProductID uint64 `json:"product_id"` + InvQty float64 `json:"inv_qty"` + LogQty float64 `json:"log_qty"` + Diff float64 `json:"diff"` + } + + // inventories 侧:当前各(shop,warehouse,product)库存总量 + invQuery := h.db.Table("inventories"). + Select("shop_id, warehouse_id, product_id, COALESCE(SUM(quantity),0) AS inv_qty"). + Where("deleted_at IS NULL"). + Group("shop_id, warehouse_id, product_id") + if shopFilter != nil { + invQuery = invQuery.Where("shop_id = ?", *shopFilter) + } + + // inventory_logs 侧:同维度流水净量(in 为正,out 为负) + logQuery := h.db.Table("inventory_logs"). + Select("shop_id, warehouse_id, product_id, " + + "COALESCE(SUM(CASE WHEN direction='in' THEN quantity ELSE -quantity END),0) AS log_qty"). + Group("shop_id, warehouse_id, product_id") + if shopFilter != nil { + logQuery = logQuery.Where("shop_id = ?", *shopFilter) + } + + // 全外连接找差异 + type invRow struct { + ShopID uint64 `gorm:"column:shop_id"` + WarehouseID uint64 `gorm:"column:warehouse_id"` + ProductID uint64 `gorm:"column:product_id"` + InvQty float64 `gorm:"column:inv_qty"` + } + type logRow struct { + ShopID uint64 `gorm:"column:shop_id"` + WarehouseID uint64 `gorm:"column:warehouse_id"` + ProductID uint64 `gorm:"column:product_id"` + LogQty float64 `gorm:"column:log_qty"` + } + + var invRows []invRow + var logRows []logRow + invQuery.Scan(&invRows) + logQuery.Scan(&logRows) + + // 合并为 map 再求差 + type key struct{ S, W, P uint64 } + m := map[key]*row{} + for _, r := range invRows { + k := key{r.ShopID, r.WarehouseID, r.ProductID} + m[k] = &row{ShopID: r.ShopID, WarehouseID: r.WarehouseID, ProductID: r.ProductID, InvQty: r.InvQty} + } + for _, r := range logRows { + k := key{r.ShopID, r.WarehouseID, r.ProductID} + if _, ok := m[k]; !ok { + m[k] = &row{ShopID: r.ShopID, WarehouseID: r.WarehouseID, ProductID: r.ProductID} + } + m[k].LogQty = r.LogQty + } + + diffs := make([]row, 0) + for _, v := range m { + v.Diff = v.InvQty - v.LogQty + if v.Diff < -0.001 || v.Diff > 0.001 { + diffs = append(diffs, *v) + } + } + + c.JSON(http.StatusOK, gin.H{"data": diffs, "total": len(diffs)}) +} diff --git a/backend/internal/handler/partner.go b/backend/internal/handler/partner.go index c77f44d..faf29ab 100644 --- a/backend/internal/handler/partner.go +++ b/backend/internal/handler/partner.go @@ -79,12 +79,38 @@ func (h *PartnerHandler) Update(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - if err := c.ShouldBindJSON(&p); err != nil { + var req struct { + Name string `json:"name"` + Type string `json:"type"` + Code string `json:"code"` + Contact string `json:"contact"` + Phone string `json:"phone"` + Address string `json:"address"` + BankAccount string `json:"bank_account"` + CreditLimit float64 `json:"credit_limit"` + Status string `json:"status"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - p.ShopID = shopID - h.db.Save(&p) + if err := h.db.Model(&p).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "name": req.Name, + "type": req.Type, + "code": req.Code, + "contact": req.Contact, + "phone": req.Phone, + "address": req.Address, + "bank_account": req.BankAccount, + "credit_limit": req.CreditLimit, + "status": req.Status, + "remark": req.Remark, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + h.db.Where("id = ? AND shop_id = ?", p.ID, shopID).First(&p) c.JSON(http.StatusOK, gin.H{"data": p}) } diff --git a/backend/internal/handler/product_attr.go b/backend/internal/handler/product_attr.go index 46395b3..bce31ad 100644 --- a/backend/internal/handler/product_attr.go +++ b/backend/internal/handler/product_attr.go @@ -68,10 +68,9 @@ func (h *ProductAttrHandler) UpdateOrigin(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - item.Code = req.Code - item.Name = req.Name - item.Remark = req.Remark - h.db.Save(&item) + h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "code": req.Code, "name": req.Name, "remark": req.Remark, + }) c.JSON(http.StatusOK, gin.H{"data": item}) } @@ -130,10 +129,9 @@ func (h *ProductAttrHandler) UpdateShelfLife(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - item.Code = req.Code - item.Name = req.Name - item.Remark = req.Remark - h.db.Save(&item) + h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "code": req.Code, "name": req.Name, "remark": req.Remark, + }) c.JSON(http.StatusOK, gin.H{"data": item}) } @@ -192,10 +190,9 @@ func (h *ProductAttrHandler) UpdateStorage(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - item.Code = req.Code - item.Name = req.Name - item.Remark = req.Remark - h.db.Save(&item) + h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "code": req.Code, "name": req.Name, "remark": req.Remark, + }) c.JSON(http.StatusOK, gin.H{"data": item}) } @@ -254,10 +251,9 @@ func (h *ProductAttrHandler) UpdateDescriptionDoc(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - item.Title = req.Title - item.Content = req.Content - item.Remark = req.Remark - h.db.Save(&item) + h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "title": req.Title, "content": req.Content, "remark": req.Remark, + }) c.JSON(http.StatusOK, gin.H{"data": item}) } diff --git a/backend/internal/handler/product_option.go b/backend/internal/handler/product_option.go index 7d39467..2d564f3 100644 --- a/backend/internal/handler/product_option.go +++ b/backend/internal/handler/product_option.go @@ -67,10 +67,9 @@ func (h *ProductOptionHandler) UpdateName(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - item.Code = req.Code - item.Name = req.Name - item.Remark = req.Remark - h.db.Save(&item) + h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "code": req.Code, "name": req.Name, "remark": req.Remark, + }) c.JSON(http.StatusOK, gin.H{"data": item}) } @@ -129,10 +128,9 @@ func (h *ProductOptionHandler) UpdateSeries(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - item.Code = req.Code - item.Name = req.Name - item.Remark = req.Remark - h.db.Save(&item) + h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "code": req.Code, "name": req.Name, "remark": req.Remark, + }) c.JSON(http.StatusOK, gin.H{"data": item}) } @@ -194,11 +192,9 @@ func (h *ProductOptionHandler) UpdateSpec(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - item.Code = req.Code - item.Name = req.Name - item.Quantity = req.Quantity - item.Remark = req.Remark - h.db.Save(&item) + h.db.Model(&item).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "code": req.Code, "name": req.Name, "quantity": req.Quantity, "remark": req.Remark, + }) c.JSON(http.StatusOK, gin.H{"data": item}) } diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index 00a80cb..211d801 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -83,19 +83,23 @@ func (h *UserHandler) Update(c *gin.Context) { IsActive *bool `json:"is_active"` } c.ShouldBindJSON(&req) + updates := map[string]interface{}{} if req.RealName != "" { - u.RealName = req.RealName + updates["real_name"] = req.RealName } if req.Phone != "" { - u.Phone = req.Phone + updates["phone"] = req.Phone } if req.Role != "" { - u.Role = req.Role + updates["role"] = req.Role } if req.IsActive != nil { - u.IsActive = *req.IsActive + updates["is_active"] = *req.IsActive } - h.db.Save(&u) + if len(updates) > 0 { + h.db.Model(&u).Where("shop_id = ?", shopID).Updates(updates) + } + h.db.Where("id = ? AND shop_id = ?", u.ID, shopID).First(&u) c.JSON(http.StatusOK, gin.H{"data": u}) } diff --git a/backend/internal/handler/warehouse.go b/backend/internal/handler/warehouse.go index 97057b6..b0b739e 100644 --- a/backend/internal/handler/warehouse.go +++ b/backend/internal/handler/warehouse.go @@ -45,9 +45,24 @@ func (h *WarehouseHandler) Update(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - c.ShouldBindJSON(&w) - w.ShopID = shopID - h.db.Save(&w) + var req struct { + Name string `json:"name"` + Location string `json:"location"` + IsDefault bool `json:"is_default"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := h.db.Model(&w).Where("shop_id = ?", shopID).Updates(map[string]interface{}{ + "name": req.Name, + "location": req.Location, + "is_default": req.IsDefault, + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + h.db.Where("id = ? AND shop_id = ?", w.ID, shopID).First(&w) c.JSON(http.StatusOK, gin.H{"data": w}) } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c5368a5..dade871 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -252,6 +252,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { superAdmin.Use(middleware.SuperAdminOnly()) { superAdmin.POST("/clear-data", adminH.ClearData) + superAdmin.GET("/reconcile", adminH.ReconcileInventory) superAdmin.GET("/errors", errorReportH.List) superAdmin.GET("/feedback", feedbackH.List) superAdmin.PATCH("/feedback/:id", feedbackH.UpdateStatus) diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index 436f022..6adcb12 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -163,12 +163,17 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error productID := itemCopy.ProductID needed := itemCopy.Quantity - // 1. 预检:SUM 是否充足 - var totalQty float64 - tx.Model(&model.Inventory{}). - Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", + // 1. FOR UPDATE 锁定批次后再汇总,消除 TOCTOU 窗口 + var batches []model.Inventory + tx.Set("gorm:query_option", "FOR UPDATE"). + Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL", shopID, warehouseID, productID). - Select("COALESCE(SUM(quantity), 0)").Scan(&totalQty) + Order("created_at ASC").Find(&batches) + + var totalQty float64 + for _, b := range batches { + totalQty += b.Quantity + } if totalQty < needed { return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f", ErrInsufficientStock, productID, totalQty, needed) @@ -176,13 +181,7 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error qtyBefore := totalQty - // 2. FIFO 扣减批次 - var batches []model.Inventory - tx.Set("gorm:query_option", "FOR UPDATE"). - Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL", - shopID, warehouseID, productID). - Order("created_at ASC").Find(&batches) - + // 2. FIFO 扣减 remaining := needed for i := range batches { if remaining <= 0 {