feat(backend): 出入库汇总近30天滚动窗口 + 跨租户安全加固

- summaryBounds:本月/近30天滚动双口径(stock-in/out Summary ?window=rolling30)
- security(SEC-001):新增 ownership.go ensureShopRef 写入侧防线(stock-in/out/finance/
  盘点建单的 warehouse/partner/product 外键归属校验);读取侧全部 Preload 补
  shop_id 作用域,finance Summary JOIN 补租户条件;回归测试 CrossTenantRefs
- security(SEC-002):release 模式 JWT 密钥为空/默认值时拒绝启动
- gofmt 对齐若干 model/cmd 文件

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 09:57:52 +08:00
parent 69890b9935
commit ca7595b113
22 changed files with 423 additions and 225 deletions
+47 -7
View File
@@ -75,7 +75,10 @@ func (h *StockInHandler) List(c *gin.Context) {
query.Count(&total)
orders := make([]model.StockInOrder, 0)
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
query.Preload("Warehouse", "shop_id = ?", shopID).
Preload("Partner", "shop_id = ?", shopID).
Preload("Operator", "shop_id = ?", shopID).
Preload("Reviewer", "shop_id = ?", shopID).
Offset((page - 1) * pageSize).Limit(pageSize).
Order("order_date DESC, id DESC").Find(&orders)
@@ -162,10 +165,27 @@ func monthBounds(now time.Time) (mStart, next, prev string) {
return s.Format(f), s.AddDate(0, 1, 0).Format(f), s.AddDate(0, -1, 0).Format(f)
}
// Summary GET /api/v1/stock-in/summary —— 全店入库 KPI(守多租户)
// summaryBounds 依 ?window= 选统计口径:
// - rolling30:近 30 天滚动窗 [今-29d, 明),对照窗为再往前 30 天——出入库列表
// KPI 用(月初自然月全 0 的观感问题,用户拍板改滚动窗);
// - 默认:自然月 + 上月(原型/财务屏口径)。
//
// 返回 YYYY-MM-DD 串,日期串比较跨 MySQL/SQLite 可移植。
func summaryBounds(c *gin.Context, now time.Time) (curFrom, curTo, prevFrom, prevTo string) {
const f = "2006-01-02"
if c.Query("window") == "rolling30" {
d := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return d.AddDate(0, 0, -29).Format(f), d.AddDate(0, 0, 1).Format(f),
d.AddDate(0, 0, -59).Format(f), d.AddDate(0, 0, -29).Format(f)
}
mStart, next, prev := monthBounds(now)
return mStart, next, prev, mStart
}
// Summary GET /api/v1/stock-in/summary —— 全店入库 KPI(守多租户;?window=rolling30 见 summaryBounds
func (h *StockInHandler) Summary(c *gin.Context) {
shopID := middleware.GetShopID(c)
mStart, next, prev := monthBounds(time.Now())
curFrom, curTo, prevFrom, prevTo := summaryBounds(c, time.Now())
agg := func(from, to string) (int64, float64) {
var r struct {
Cnt int64
@@ -177,8 +197,8 @@ func (h *StockInHandler) Summary(c *gin.Context) {
return r.Cnt, r.Amt
}
var s stockSummary
s.MonthCount, s.MonthAmount = agg(mStart, next)
s.LastMonthCount, s.LastMonthAmount = agg(prev, mStart)
s.MonthCount, s.MonthAmount = agg(curFrom, curTo)
s.LastMonthCount, s.LastMonthAmount = agg(prevFrom, prevTo)
h.db.Model(&model.StockInOrder{}).
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
c.JSON(http.StatusOK, s)
@@ -188,9 +208,10 @@ func (h *StockInHandler) Summary(c *gin.Context) {
func (h *StockInHandler) Get(c *gin.Context) {
shopID := middleware.GetShopID(c)
var order model.StockInOrder
if err := h.db.Preload("Items.Product").Preload("Items.Product.Origin").
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).Preload("Items.Product.Origin").
Preload("Items.Product.ShelfLife").Preload("Items.Product.Storage").
Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
Preload("Warehouse", "shop_id = ?", shopID).Preload("Partner", "shop_id = ?", shopID).
Preload("Operator", "shop_id = ?", shopID).Preload("Reviewer", "shop_id = ?", shopID).
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
@@ -210,6 +231,15 @@ func (h *StockInHandler) Create(c *gin.Context) {
return
}
// SEC-001:外键必须属于当前店(防跨租户引用泄露)
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.ShopID = shopID
req.OperatorID = operatorID
req.Status = "draft"
@@ -267,6 +297,16 @@ func (h *StockInHandler) Update(c *gin.Context) {
return
}
// SEC-001:外键必须属于当前店(防跨租户引用泄露)
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockInItem{}).Error; err != nil {
return err