fix(backend): 修复库存 TOCTOU 竞态 + handler 白名单更新 + 对账接口
#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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user