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
+8 -3
View File
@@ -27,7 +27,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
var q struct {
Type string `form:"type"`
Month string `form:"month"` // e.g. "2026-04"
Month string `form:"month"` // e.g. "2026-04"
StartDate string `form:"start_date"` // YYYY-MM-DD(区间过滤,优先于 month)
EndDate string `form:"end_date"`
PartnerID uint64 `form:"partner_id"`
@@ -67,7 +67,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
records := make([]model.FinanceRecord, 0)
offset := (q.Page - 1) * q.PageSize
base.Preload("Partner").Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records)
base.Preload("Partner", "shop_id = ?", shopID).Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records)
c.JSON(http.StatusOK, gin.H{
"data": records,
@@ -93,6 +93,11 @@ func (h *FinanceHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// SEC-001:往来单位必须属于当前店(防跨租户引用泄露)
if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Type != "payment" && req.Type != "receipt" {
c.JSON(http.StatusBadRequest, gin.H{"error": "type must be payment or receipt"})
return
@@ -236,7 +241,7 @@ func (h *FinanceHandler) Summary(c *gin.Context) {
COUNT(*) AS record_count,
SUM(f.amount) AS total_amount
FROM finance_records f
LEFT JOIN partners p ON p.id = f.partner_id
LEFT JOIN partners p ON p.id = f.partner_id AND p.shop_id = f.shop_id
WHERE f.shop_id = ? AND f.deleted_at IS NULL
AND f.type IN ('payable','receivable')
AND f.status = 'open'
+10 -10
View File
@@ -8,9 +8,9 @@ import (
"strings"
"time"
"github.com/shakinm/xlsReader/xls"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/shakinm/xlsReader/xls"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
@@ -336,12 +336,12 @@ func (h *ImportHandler) ImportStockIn(c *gin.Context) {
total := qty * price
totalAmount += total
items = append(items, model.StockInItem{
ShopID: shopID,
ProductID: prod.ID,
Quantity: qty,
UnitPrice: price,
ShopID: shopID,
ProductID: prod.ID,
Quantity: qty,
UnitPrice: price,
TotalPrice: total,
BatchNo: batchNo,
BatchNo: batchNo,
})
}
@@ -439,10 +439,10 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) {
total := qty * price
totalAmount += total
items = append(items, model.StockOutItem{
ShopID: shopID,
ProductID: prod.ID,
Quantity: qty,
UnitPrice: price,
ShopID: shopID,
ProductID: prod.ID,
Quantity: qty,
UnitPrice: price,
TotalPrice: total,
})
}
+16 -3
View File
@@ -230,7 +230,8 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
query.Count(&total)
logs := make([]model.InventoryLog, 0)
query.Preload("Product").Preload("Warehouse").Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
query.Preload("Product", "shop_id = ?", shopID).
Preload("Warehouse", "shop_id = ?", shopID).Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
}
@@ -250,6 +251,18 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
req.OperatorID = operatorID
req.Status = "draft"
// SEC-P01:外键归属校验——仓库与明细商品必须属于本店,拒绝跨租户脏引用
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for i := range req.Items {
if err := ensureShopRef(h.db, "products", req.Items[i].ProductID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
// 自动填入系统库存数量(SUM 聚合)
for i := range req.Items {
req.Items[i].ShopID = shopID
@@ -274,7 +287,7 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
func (h *InventoryHandler) GetCheck(c *gin.Context) {
shopID := middleware.GetShopID(c)
var check model.InventoryCheck
if err := h.db.Preload("Items.Product").
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).
Where("id = ? AND shop_id = ?", c.Param("id"), shopID).
First(&check).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
@@ -289,7 +302,7 @@ func (h *InventoryHandler) CompleteCheck(c *gin.Context) {
checkID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var check model.InventoryCheck
if err := h.db.Preload("Items.Product").
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).
Where("id = ? AND shop_id = ?", checkID, shopID).
First(&check).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+3 -3
View File
@@ -24,7 +24,7 @@ func TestInventoryHandler_List(t *testing.T) {
// 直接插入库存记录
inv := model.Inventory{
ShopID: shop.ID,
ShopID: shop.ID,
WarehouseID: &warehouse.ID,
ProductID: &product.ID,
Quantity: 100,
@@ -113,7 +113,7 @@ func TestInventoryHandler_Logs(t *testing.T) {
// 创建库存流水
opID := user.ID
db.Create(&model.InventoryLog{
ShopID: shop.ID,
ShopID: shop.ID,
WarehouseID: warehouse.ID,
ProductID: product.ID,
Direction: "in",
@@ -142,7 +142,7 @@ func TestInventoryHandler_CreateCheck(t *testing.T) {
// 先创建库存
db.Create(&model.Inventory{
ShopID: shop.ID,
ShopID: shop.ID,
WarehouseID: &warehouse.ID,
ProductID: &product.ID,
Quantity: 50,
+51
View File
@@ -0,0 +1,51 @@
package handler
import (
"fmt"
"gorm.io/gorm"
)
// SEC-001(跨租户信息泄露)防线:
//
// 关联对象(Partner/Warehouse/Product…)若只按外键回连、不再按 shop_id 过滤,
// 攻击者可在本店单据里塞他店 id,再从 GET 接口把他店对象(含 PII)读出来。
// 两道防线缺一不可:
// 1. 写入侧 ensureShopRef:拒绝引用不属于当前店的外键;
// 2. 读取侧 Preload/JOIN 一律附加 shop_id 条件(存量脏引用也带不出数据)。
// ensureShopRef 校验外键对象存在且属于当前店(table 为调用点硬编码常量,非用户输入)。
func ensureShopRef(db *gorm.DB, table string, id uint64, shopID uint64) error {
if id == 0 {
return fmt.Errorf("引用对象 id 无效")
}
var n int64
db.Table(table).
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
Count(&n)
if n == 0 {
return fmt.Errorf("引用的%s不存在", refName(table))
}
return nil
}
// ensureShopRefOpt 可选外键(如 partner_id 指针):nil/0 视为未引用,直接通过。
func ensureShopRefOpt(db *gorm.DB, table string, id *uint64, shopID uint64) error {
if id == nil || *id == 0 {
return nil
}
return ensureShopRef(db, table, *id, shopID)
}
func refName(table string) string {
switch table {
case "partners":
return "往来单位"
case "warehouses":
return "仓库"
case "products":
return "商品"
default:
return "对象"
}
}
+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
+61
View File
@@ -164,6 +164,20 @@ func TestStockInHandler_Summary(t *testing.T) {
assert.Equal(t, float64(2), s["month_count"])
assert.Equal(t, float64(400), s["month_amount"])
assert.Equal(t, float64(1), s["pending_count"])
// rolling30 口径:40 天前的单落「前一窗」,今天的 2 笔在近 30 天窗
makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": wh.ID,
"order_date": time.Now().AddDate(0, 0, -40).Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "B", "quantity": 1.0, "unit_price": 50.0}},
})
w = makeRequest(r, "GET", "/api/v1/stock-in/summary?window=rolling30", token, nil)
require.Equal(t, http.StatusOK, w.Code)
s = parseResponse(w)
assert.Equal(t, float64(2), s["month_count"])
assert.Equal(t, float64(400), s["month_amount"])
assert.Equal(t, float64(1), s["last_month_count"])
assert.Equal(t, float64(50), s["last_month_amount"])
}
func TestStockInHandler_DetailFilter(t *testing.T) {
@@ -801,3 +815,50 @@ func TestStockInHandler_List_FilterByStatus(t *testing.T) {
resp = parseResponse(w)
assert.Equal(t, float64(2), resp["total"].(float64))
}
// SEC-001 回归:跨租户外键引用必须被拒绝;存量脏引用(他店 partner_id)
// 读取时 Preload 不得把他店对象带出。
func TestStockInHandler_CrossTenantRefs(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "SEC1A")
shopB := testutil.CreateTestShop(db, "SEC1B")
userA := testutil.CreateTestUser(db, shopA.ID, "admin", "pass", "admin")
whA := testutil.CreateTestWarehouse(db, shopA.ID, "WA")
whB := testutil.CreateTestWarehouse(db, shopB.ID, "WB")
partnerB := &model.Partner{
TenantBase: model.TenantBase{ShopID: shopB.ID},
Name: "B店机密供应商", Type: "supplier", Phone: "13800000000"}
require.NoError(t, db.Create(partnerB).Error)
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
r := setupProtectedRouter(db)
// 1) 引用他店仓库 → 400
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", tokenA, map[string]interface{}{
"warehouse_id": whB.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "A", "quantity": 1.0, "unit_price": 10.0}},
})
require.Equal(t, http.StatusBadRequest, w.Code, "cross-shop warehouse must be rejected")
// 2) 引用他店往来单位 → 400
w = makeRequest(r, "POST", "/api/v1/stock-in/orders", tokenA, map[string]interface{}{
"warehouse_id": whA.ID,
"partner_id": partnerB.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "A", "quantity": 1.0, "unit_price": 10.0}},
})
require.Equal(t, http.StatusBadRequest, w.Code, "cross-shop partner must be rejected")
// 3) 存量脏引用:直接落库一张 A 店单指向 B 店 partner,GET 不得带出 B 店对象
dirty := &model.StockInOrder{
TenantBase: model.TenantBase{ShopID: shopA.ID},
WarehouseID: whA.ID, PartnerID: &partnerB.ID,
OrderNo: "SEC1-DIRTY", Status: "draft", OperatorID: userA.ID,
}
require.NoError(t, db.Create(dirty).Error)
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", dirty.ID), tokenA, nil)
require.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
assert.NotContains(t, body, "B店机密供应商", "cross-shop partner PII must not leak via preload")
assert.NotContains(t, body, "13800000000")
}
+30 -7
View File
@@ -71,17 +71,20 @@ func (h *StockOutHandler) List(c *gin.Context) {
query.Count(&total)
orders := make([]model.StockOutOrder, 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)
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
}
// Summary GET /api/v1/stock-out/summary —— 全店出库 KPI(守多租户)
// Summary GET /api/v1/stock-out/summary —— 全店出库 KPI(守多租户?window=rolling30 见 summaryBounds
func (h *StockOutHandler) 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
@@ -93,8 +96,8 @@ func (h *StockOutHandler) 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.StockOutOrder{}).
Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount)
c.JSON(http.StatusOK, s)
@@ -139,7 +142,9 @@ func (h *StockOutHandler) ConfirmSale(c *gin.Context) {
func (h *StockOutHandler) Get(c *gin.Context) {
shopID := middleware.GetShopID(c)
var order model.StockOutOrder
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).
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"})
@@ -148,7 +153,6 @@ func (h *StockOutHandler) Get(c *gin.Context) {
util.RespondSuccess(c, order)
}
// Create POST /api/v1/stock-out/orders
// fillStockOutItemSnapshots 按 product_id 从商品主数据拷明细快照列(编码/名称/系列/规格/批次/生产日期)。
// 明细 = product 引用 + 快照(历史保真:商品日后改名/删除,单据仍能还原当时信息;搜索/退单提示读快照)。
@@ -198,6 +202,15 @@ func (h *StockOutHandler) 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
@@ -256,6 +269,16 @@ func (h *StockOutHandler) 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.StockOutItem{}).Error; err != nil {
return err
@@ -57,7 +57,7 @@ func TestWriteAccessMatrix(t *testing.T) {
type tc struct {
name string
role string
daysExpired int // >0 已过期天数;-30 表示未过期
daysExpired int // >0 已过期天数;-30 表示未过期
wantPostFwd bool // POST 是否应放行(非 403)
wantGetFwd bool // GET 是否应放行
wantCode string // 期望 403 body 的 code(空则不校验)