Files
jiu/backend/internal/handler/admin.go
T
wangjia 51cfe5fc6d 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>
2026-06-11 00:21:48 +08:00

150 lines
4.4 KiB
Go

package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
)
type AdminHandler struct{ db *gorm.DB }
func NewAdminHandler(db *gorm.DB) *AdminHandler { return &AdminHandler{db: db} }
// ClearData 清空指定表数据(仅限当前 shop,不影响其他租户)
func (h *AdminHandler) ClearData(c *gin.Context) {
shopID := middleware.GetShopID(c)
var body struct {
Tables []string `json:"tables"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Tables) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "tables is required"})
return
}
// 允许清空的表名白名单(key=前端传入标识,value=DB表名列表)
allowed := map[string][]string{
"stock_in": {"stock_in_items", "stock_in_orders"},
"stock_out": {"stock_out_items", "stock_out_orders"},
"inventory": {"inventory_logs", "inventories"},
"products": {"products"},
"partners": {"partners"},
}
cleared := []string{}
err := h.db.Transaction(func(tx *gorm.DB) error {
for _, t := range body.Tables {
tables, ok := allowed[t]
if !ok {
continue
}
for _, tbl := range tables {
if err := tx.Exec("DELETE FROM "+tbl+" WHERE shop_id = ?", shopID).Error; err != nil {
return err
}
}
cleared = append(cleared, t)
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
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)})
}