diff --git a/backend/cmd/fix-inventory-products/main.go b/backend/cmd/fix-inventory-products/main.go index 2380862..c00d28f 100644 --- a/backend/cmd/fix-inventory-products/main.go +++ b/backend/cmd/fix-inventory-products/main.go @@ -7,9 +7,9 @@ // product_id 指错了。 // // 修复(就地、不删数据、幂等):遍历目标门店活跃库存行,对每行: -// 1. 按 (shop_id, code=inv.product_code) 找 product; -// 2. 找不到就用该行快照新建一个独立 product(保留原始编号); -// 3. 把 inv.product_id 重指到它。 +// 1. 按 (shop_id, code=inv.product_code) 找 product; +// 2. 找不到就用该行快照新建一个独立 product(保留原始编号); +// 3. 把 inv.product_id 重指到它。 // // 用法(在 backend/ 目录下): // diff --git a/backend/cmd/import-history/main_test.go b/backend/cmd/import-history/main_test.go index 501b857..a8c7f42 100644 --- a/backend/cmd/import-history/main_test.go +++ b/backend/cmd/import-history/main_test.go @@ -4,12 +4,12 @@ import "testing" func TestMapStatus(t *testing.T) { cases := map[string]string{ - "已审核": "approved", - "待审核": "pending", - "被驳回": "rejected", - "作废": "rejected", - "": "draft", - "未知": "draft", + "已审核": "approved", + "待审核": "pending", + "被驳回": "rejected", + "作废": "rejected", + "": "draft", + "未知": "draft", " 已审核 ": "approved", } for in, want := range cases { @@ -21,12 +21,12 @@ func TestMapStatus(t *testing.T) { func TestParseFloatLoose(t *testing.T) { cases := map[string]float64{ - "": 0, - " ": 0, - "123": 123, - "1,234.50": 1234.5, - "abc": 0, - "-9.9": -9.9, + "": 0, + " ": 0, + "123": 123, + "1,234.50": 1234.5, + "abc": 0, + "-9.9": -9.9, } for in, want := range cases { if got := parseFloatLoose(in); got != want { diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index 473f7be..a71b435 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -142,21 +142,21 @@ func main() { hash := mustHash("password123") admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any { return &model.User{ - ShopID: shop.ID, + ShopID: shop.ID, Username: "admin", PasswordHash: hash, RealName: "张三(管理员)", Phone: "13800000001", Role: "admin", IsActive: true, } }).(model.User) operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any { return &model.User{ - ShopID: shop.ID, + ShopID: shop.ID, Username: "operator", PasswordHash: hash, RealName: "李四(操作员)", Phone: "13800000002", Role: "operator", IsActive: true, } }).(model.User) upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any { return &model.User{ - ShopID: shop.ID, + ShopID: shop.ID, Username: "test", PasswordHash: hash, RealName: "王五(只读)", Phone: "13800000003", Role: "readonly", IsActive: true, } @@ -168,13 +168,13 @@ func main() { wh1 := upsert(db, &model.Warehouse{}, "shop_id = ? AND name = ?", shop.ID, "主仓库", func() any { return &model.Warehouse{ TenantBase: model.TenantBase{ShopID: shop.ID}, - Name: "主仓库", Location: "A栋1层东侧", IsDefault: true, + Name: "主仓库", Location: "A栋1层东侧", IsDefault: true, } }).(model.Warehouse) wh2 := upsert(db, &model.Warehouse{}, "shop_id = ? AND name = ?", shop.ID, "进口酒专库", func() any { return &model.Warehouse{ TenantBase: model.TenantBase{ShopID: shop.ID}, - Name: "进口酒专库", Location: "B栋2层恒温区", IsDefault: false, + Name: "进口酒专库", Location: "B栋2层恒温区", IsDefault: false, } }).(model.Warehouse) @@ -259,8 +259,8 @@ func main() { // ═══════════════════════════════════════════════════ type partnerSeed struct { code, name, ptype, contact, phone, address, bank string - creditLimit float64 - remark string + creditLimit float64 + remark string } partnerSeeds := []partnerSeed{ {"SUP001", "贵州茅台酒股份有限公司", "supplier", "张经理", "0851-22222001", @@ -528,22 +528,22 @@ func (s *seeder) createStockIn( full, initials := util.ToPinyin(g.name) // 铁律:入库每行 = 新建一个独立 product(唯一编号),不按名称复用 p := model.Product{ - TenantBase: model.TenantBase{ShopID: s.shopID}, - PublicID: uuid.New().String(), - Code: code, - Name: g.name, - Series: g.series, - Spec: g.spec, - Unit: g.unit, - Brand: g.brand, - CategoryID: g.cat, - PurchasePrice: ln.price, - SalePrice: g.sale, - MinStock: g.minStock, - BatchNo: ln.batch, + TenantBase: model.TenantBase{ShopID: s.shopID}, + PublicID: uuid.New().String(), + Code: code, + Name: g.name, + Series: g.series, + Spec: g.spec, + Unit: g.unit, + Brand: g.brand, + CategoryID: g.cat, + PurchasePrice: ln.price, + SalePrice: g.sale, + MinStock: g.minStock, + BatchNo: ln.batch, ProductionDate: ln.prodDate, - NamePinyin: full, - NameInitials: initials, + NamePinyin: full, + NameInitials: initials, } s.db.Create(&p) @@ -678,8 +678,8 @@ func (s *seeder) createStockOut( Series: p.Series, Spec: p.Spec, Quantity: ln.qty, - UnitPrice: p.PurchasePrice, // 成本单价(快照) - SalePrice: ln.sale, // 售价 + UnitPrice: p.PurchasePrice, // 成本单价(快照) + SalePrice: ln.sale, // 售价 TotalPrice: ln.qty * p.PurchasePrice, // 成本小计 BatchNo: p.BatchNo, Remark: ln.remark, diff --git a/backend/config/config.go b/backend/config/config.go index f6df138..f20f1f0 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -93,8 +93,8 @@ func Load() { viper.SetDefault("server.cors_origin", "*") viper.SetDefault("jwt.access_expire_min", 60) viper.SetDefault("jwt.refresh_expire_h", 168) // 7天 - viper.SetDefault("session.limit_total", 5) // 单用户跨全部平台合计并发上限 - viper.SetDefault("session.limit_desktop", 2) // 平台闸门:>0=允许,0=禁止该平台(数值不再作分平台上限) + viper.SetDefault("session.limit_total", 5) // 单用户跨全部平台合计并发上限 + viper.SetDefault("session.limit_desktop", 2) // 平台闸门:>0=允许,0=禁止该平台(数值不再作分平台上限) viper.SetDefault("session.limit_mobile", 2) viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁 viper.SetDefault("session.max_failures", 5) diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index 0a8814a..dac317d 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -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' diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index 1ae39d0..a689bb4 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -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, }) } diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index 3472ed2..ae4d0d8 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -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"}) diff --git a/backend/internal/handler/inventory_test.go b/backend/internal/handler/inventory_test.go index 1d15adf..b09c725 100644 --- a/backend/internal/handler/inventory_test.go +++ b/backend/internal/handler/inventory_test.go @@ -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, diff --git a/backend/internal/handler/ownership.go b/backend/internal/handler/ownership.go new file mode 100644 index 0000000..f57a09d --- /dev/null +++ b/backend/internal/handler/ownership.go @@ -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 "对象" + } +} diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 50ca3fa..0b0ade5 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -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 diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index e80fbed..df0d2dd 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -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") +} diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index d9bad6c..c56d511 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -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 diff --git a/backend/internal/handler/write_access_test.go b/backend/internal/handler/write_access_test.go index 283bb2d..386f7ad 100644 --- a/backend/internal/handler/write_access_test.go +++ b/backend/internal/handler/write_access_test.go @@ -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(空则不校验) diff --git a/backend/internal/model/error_report.go b/backend/internal/model/error_report.go index 4569cd4..c24eb6e 100644 --- a/backend/internal/model/error_report.go +++ b/backend/internal/model/error_report.go @@ -4,18 +4,18 @@ import "time" // ErrorReport 客户端错误上报记录(系统级,不做多租户隔离) type ErrorReport struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - CreatedAt time.Time `json:"created_at"` - ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception - AppVersion string `gorm:"size:30" json:"app_version"` - Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos - Username string `gorm:"size:50;index" json:"username"` - ShopID uint64 `gorm:"index" json:"shop_id"` - ShopNo string `gorm:"size:50" json:"shop_no"` - Role string `gorm:"size:20" json:"role"` - ProductInfo string `gorm:"size:200" json:"product_info"` - ClientIP string `gorm:"size:60" json:"client_ip"` - ErrorMsg string `gorm:"type:text;not null" json:"error_msg"` - StackTrace string `gorm:"type:text" json:"stack_trace"` - OccurredAt time.Time `json:"occurred_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + CreatedAt time.Time `json:"created_at"` + ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception + AppVersion string `gorm:"size:30" json:"app_version"` + Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos + Username string `gorm:"size:50;index" json:"username"` + ShopID uint64 `gorm:"index" json:"shop_id"` + ShopNo string `gorm:"size:50" json:"shop_no"` + Role string `gorm:"size:20" json:"role"` + ProductInfo string `gorm:"size:200" json:"product_info"` + ClientIP string `gorm:"size:60" json:"client_ip"` + ErrorMsg string `gorm:"type:text;not null" json:"error_msg"` + StackTrace string `gorm:"type:text" json:"stack_trace"` + OccurredAt time.Time `json:"occurred_at"` } diff --git a/backend/internal/model/license.go b/backend/internal/model/license.go index 318d2c3..cbb57fa 100644 --- a/backend/internal/model/license.go +++ b/backend/internal/model/license.go @@ -4,12 +4,12 @@ import "time" type License struct { Base - ShopID uint64 `gorm:"not null;index" json:"shop_id"` - LicenseKey string `gorm:"size:768;uniqueIndex" json:"license_key"` - Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"` + ShopID uint64 `gorm:"not null;index" json:"shop_id"` + LicenseKey string `gorm:"size:768;uniqueIndex" json:"license_key"` + Type string `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"` // Tier 当前权益档位(标准/pro/max…),随兑换码写入;当前默认 'standard',暂不据此做能力差异。 - Tier string `gorm:"size:32;not null;default:'standard'" json:"tier"` - ExpiresAt *time.Time `json:"expires_at"` + Tier string `gorm:"size:32;not null;default:'standard'" json:"tier"` + ExpiresAt *time.Time `json:"expires_at"` IsActive bool `gorm:"default:true" json:"is_active"` MaxDevices int `gorm:"default:3" json:"max_devices"` Features JSON `gorm:"type:json" json:"features,omitempty"` diff --git a/backend/internal/model/license_device.go b/backend/internal/model/license_device.go index 138ffea..156deea 100644 --- a/backend/internal/model/license_device.go +++ b/backend/internal/model/license_device.go @@ -3,14 +3,14 @@ package model import "time" type LicenseDevice struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - LicenseID uint64 `gorm:"not null;index" json:"license_id"` - ShopID uint64 `gorm:"not null;index" json:"shop_id"` - DeviceID string `gorm:"size:255;not null" json:"device_id"` - DeviceName string `gorm:"size:255" json:"device_name"` - Platform string `gorm:"size:50" json:"platform"` // windows|macos|android|ios|web - ActivatedAt time.Time `gorm:"not null;autoCreateTime" json:"activated_at"` - LastSeenAt time.Time `gorm:"not null;autoUpdateTime" json:"last_seen_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + LicenseID uint64 `gorm:"not null;index" json:"license_id"` + ShopID uint64 `gorm:"not null;index" json:"shop_id"` + DeviceID string `gorm:"size:255;not null" json:"device_id"` + DeviceName string `gorm:"size:255" json:"device_name"` + Platform string `gorm:"size:50" json:"platform"` // windows|macos|android|ios|web + ActivatedAt time.Time `gorm:"not null;autoCreateTime" json:"activated_at"` + LastSeenAt time.Time `gorm:"not null;autoUpdateTime" json:"last_seen_at"` } func (LicenseDevice) TableName() string { return "license_devices" } diff --git a/backend/internal/model/partner.go b/backend/internal/model/partner.go index fc7fdd6..dea09c1 100644 --- a/backend/internal/model/partner.go +++ b/backend/internal/model/partner.go @@ -2,8 +2,8 @@ package model type Partner struct { TenantBase - Code string `gorm:"size:50" json:"code"` - Name string `gorm:"size:200;not null" json:"name" binding:"required"` + Code string `gorm:"size:50" json:"code"` + Name string `gorm:"size:200;not null" json:"name" binding:"required"` // 拼音搜索列(同 products):Create/Update 时由 util.ToPinyin 生成,启动回填存量 NamePinyin string `gorm:"size:400;index" json:"-"` NameInitials string `gorm:"size:100;index" json:"-"` diff --git a/backend/internal/model/product.go b/backend/internal/model/product.go index 2d7c5e5..7f0feb3 100644 --- a/backend/internal/model/product.go +++ b/backend/internal/model/product.go @@ -2,14 +2,14 @@ package model type ProductCategory struct { TenantBase - Name string `gorm:"size:100;not null" json:"name"` - ParentID *uint64 `json:"parent_id"` - SortOrder int `gorm:"default:0" json:"sort_order"` + Name string `gorm:"size:100;not null" json:"name"` + ParentID *uint64 `json:"parent_id"` + SortOrder int `gorm:"default:0" json:"sort_order"` } type Product struct { TenantBase - PublicID string `gorm:"size:36;uniqueIndex" json:"public_id"` + PublicID string `gorm:"size:36;uniqueIndex" json:"public_id"` // Code 商品编码:同店内唯一。(shop_id, code) 联合唯一索引 uk_shop_code 由 autoMigrate 显式建(见 main.go), // 不在此用 tag 声明——ShopID 在共用 TenantBase 上,tag 只能建单列索引会破坏多租户隔离。 Code string `gorm:"size:50" json:"code"` @@ -25,12 +25,12 @@ type Product struct { // 特有产品的批次属性:每个 product = 一个特有产品/序列号,生产日期/批次归此(单一来源) ProductionDate *Date `gorm:"type:date" json:"production_date"` BatchNo string `gorm:"size:50" json:"batch_no"` - MinStock int `gorm:"default:0" json:"min_stock"` - Description string `gorm:"type:text" json:"description"` - CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` - Remark string `gorm:"size:500" json:"remark"` - NamePinyin string `gorm:"size:400;index" json:"-"` - NameInitials string `gorm:"size:100;index" json:"-"` + MinStock int `gorm:"default:0" json:"min_stock"` + Description string `gorm:"type:text" json:"description"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` + NamePinyin string `gorm:"size:400;index" json:"-"` + NameInitials string `gorm:"size:100;index" json:"-"` // 商品属性字典外键(可空,公开页展示用) OriginID *uint64 `json:"origin_id"` diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go index 5939e5b..53e9027 100644 --- a/backend/internal/model/stock.go +++ b/backend/internal/model/stock.go @@ -6,21 +6,21 @@ import "time" type StockInOrder struct { TenantBase - OrderNo string `gorm:"size:50;uniqueIndex:uk_shop_order_no" json:"order_no"` - Type string `gorm:"size:30;default:'purchase'" json:"type"` - WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"` - PartnerID *uint64 `json:"partner_id"` - OperatorID uint64 `gorm:"not null" json:"operator_id"` - CreatorID *uint64 `json:"creator_id"` - ReviewerID *uint64 `json:"reviewer_id"` - Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` - OrderDate Date `gorm:"type:date" json:"order_date"` - TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"` - ReviewedAt *time.Time `json:"reviewed_at"` + OrderNo string `gorm:"size:50;uniqueIndex:uk_shop_order_no" json:"order_no"` + Type string `gorm:"size:30;default:'purchase'" json:"type"` + WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"` + PartnerID *uint64 `json:"partner_id"` + OperatorID uint64 `gorm:"not null" json:"operator_id"` + CreatorID *uint64 `json:"creator_id"` + ReviewerID *uint64 `json:"reviewer_id"` + Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` + OrderDate Date `gorm:"type:date" json:"order_date"` + TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"` + ReviewedAt *time.Time `json:"reviewed_at"` // 退单状态:none=无 / partial=部分退单 / full=已全退(仅 approved 单可退) - ReturnState string `gorm:"size:20;default:'none'" json:"return_state"` - CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` - Remark string `gorm:"size:500" json:"remark"` + ReturnState string `gorm:"size:20;default:'none'" json:"return_state"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` Items []StockInItem `gorm:"foreignKey:OrderID" json:"items,omitempty"` Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"` @@ -32,25 +32,25 @@ type StockInOrder struct { type StockInItem struct { Base - OrderID uint64 `gorm:"not null;index" json:"order_id"` - ShopID uint64 `gorm:"not null" json:"shop_id"` - ProductID uint64 `gorm:"not null" json:"product_id"` + OrderID uint64 `gorm:"not null;index" json:"order_id"` + ShopID uint64 `gorm:"not null" json:"shop_id"` + ProductID uint64 `gorm:"not null" json:"product_id"` // 历史导入快照列:源明细自带的商品信息,不依赖 product 主数据(product_id 可为 0)。 - ProductCode string `gorm:"size:50" json:"product_code"` - ProductName string `gorm:"size:255" json:"product_name"` - Series string `gorm:"size:100" json:"series"` - Spec string `gorm:"size:100" json:"spec"` - Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` - UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"` + ProductCode string `gorm:"size:50" json:"product_code"` + ProductName string `gorm:"size:255" json:"product_name"` + Series string `gorm:"size:100" json:"series"` + Spec string `gorm:"size:100" json:"spec"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` + UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"` // 建议售价:仅用于入库建产品时写入 product.SalePrice,不落 stock_in_items 表(gorm:"-")。 - SalePrice float64 `gorm:"-" json:"sale_price"` - TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"` + SalePrice float64 `gorm:"-" json:"sale_price"` + TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"` // 已退数量:0=未退,=Quantity 表示整行已退单(整行退,不做部分数量) ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"` - BatchNo string `gorm:"size:50" json:"batch_no"` - ProductionDate *Date `gorm:"type:date" json:"production_date"` - CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` - Remark string `gorm:"size:255" json:"remark"` + BatchNo string `gorm:"size:50" json:"batch_no"` + ProductionDate *Date `gorm:"type:date" json:"production_date"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:255" json:"remark"` Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` Order *StockInOrder `gorm:"foreignKey:OrderID" json:"order,omitempty"` @@ -60,21 +60,21 @@ type StockInItem struct { type StockOutOrder struct { TenantBase - OrderNo string `gorm:"size:50;uniqueIndex:uk_shop_order_no" json:"order_no"` - Type string `gorm:"size:30;default:'sale'" json:"type"` - WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"` - PartnerID *uint64 `json:"partner_id"` - OperatorID uint64 `gorm:"not null" json:"operator_id"` - CreatorID *uint64 `json:"creator_id"` - ReviewerID *uint64 `json:"reviewer_id"` - Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` - OrderDate Date `gorm:"type:date" json:"order_date"` - TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"` - ReviewedAt *time.Time `json:"reviewed_at"` + OrderNo string `gorm:"size:50;uniqueIndex:uk_shop_order_no" json:"order_no"` + Type string `gorm:"size:30;default:'sale'" json:"type"` + WarehouseID uint64 `gorm:"not null" json:"warehouse_id" binding:"required,min=1"` + PartnerID *uint64 `json:"partner_id"` + OperatorID uint64 `gorm:"not null" json:"operator_id"` + CreatorID *uint64 `json:"creator_id"` + ReviewerID *uint64 `json:"reviewer_id"` + Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` + OrderDate Date `gorm:"type:date" json:"order_date"` + TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"` + ReviewedAt *time.Time `json:"reviewed_at"` // 退单状态:none / partial / full - ReturnState string `gorm:"size:20;default:'none'" json:"return_state"` - CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` - Remark string `gorm:"size:500" json:"remark"` + ReturnState string `gorm:"size:20;default:'none'" json:"return_state"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:500" json:"remark"` Items []StockOutItem `gorm:"foreignKey:OrderID" json:"items,omitempty"` Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"` @@ -86,25 +86,25 @@ type StockOutOrder struct { type StockOutItem struct { Base - OrderID uint64 `gorm:"not null;index" json:"order_id"` - ShopID uint64 `gorm:"not null" json:"shop_id"` - ProductID uint64 `gorm:"not null" json:"product_id"` + OrderID uint64 `gorm:"not null;index" json:"order_id"` + ShopID uint64 `gorm:"not null" json:"shop_id"` + ProductID uint64 `gorm:"not null" json:"product_id"` // 历史导入快照列:源明细自带的商品信息,不依赖 product 主数据(product_id 可为 0)。 - ProductCode string `gorm:"size:50" json:"product_code"` - ProductName string `gorm:"size:255" json:"product_name"` - Series string `gorm:"size:100" json:"series"` - Spec string `gorm:"size:100" json:"spec"` - Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` - UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"` + ProductCode string `gorm:"size:50" json:"product_code"` + ProductName string `gorm:"size:255" json:"product_name"` + Series string `gorm:"size:100" json:"series"` + Spec string `gorm:"size:100" json:"spec"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` + UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"` // 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 UnitPrice(入库成本) 区分 - SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"` - TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"` + SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"` + TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"` // 已退数量:0=未退,=Quantity 表示整行已退单 ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"` - BatchNo string `gorm:"size:50" json:"batch_no"` - ProductionDate *Date `gorm:"type:date" json:"production_date"` - CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` - Remark string `gorm:"size:255" json:"remark"` + BatchNo string `gorm:"size:50" json:"batch_no"` + ProductionDate *Date `gorm:"type:date" json:"production_date"` + CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` + Remark string `gorm:"size:255" json:"remark"` Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` } @@ -112,30 +112,30 @@ type StockOutItem struct { // -------- 实时库存(批次模式:每条记录代表一个批次/批次) -------- type Inventory struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - ShopID uint64 `gorm:"not null" json:"shop_id"` - WarehouseID *uint64 `json:"warehouse_id"` - ProductID *uint64 `json:"product_id"` - StockInItemID *uint64 `json:"stock_in_item_id"` - InventoryCheckID *uint64 `json:"inventory_check_id"` - Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + ShopID uint64 `gorm:"not null" json:"shop_id"` + WarehouseID *uint64 `json:"warehouse_id"` + ProductID *uint64 `json:"product_id"` + StockInItemID *uint64 `json:"stock_in_item_id"` + InventoryCheckID *uint64 `json:"inventory_check_id"` + Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"` // Snapshot fields: copied from product/warehouse/stock-in at approval time. // They reflect the state at the moment of stock-in and are NOT updated when the // referenced product or warehouse record is later modified. - ProductCode string `gorm:"size:50" json:"product_code"` - ProductName string `gorm:"size:200" json:"product_name"` - Series string `gorm:"size:100" json:"series"` - Spec string `gorm:"size:100" json:"spec"` - Unit string `gorm:"size:20" json:"unit"` - WarehouseName string `gorm:"size:100" json:"warehouse_name"` - UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"` - ProductionDate *Date `gorm:"type:date" json:"production_date"` - BatchNo string `gorm:"size:50" json:"batch_no"` - SupplierName string `gorm:"size:200" json:"supplier_name"` - Remark string `gorm:"size:500" json:"remark"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `gorm:"index" json:"-"` + ProductCode string `gorm:"size:50" json:"product_code"` + ProductName string `gorm:"size:200" json:"product_name"` + Series string `gorm:"size:100" json:"series"` + Spec string `gorm:"size:100" json:"spec"` + Unit string `gorm:"size:20" json:"unit"` + WarehouseName string `gorm:"size:100" json:"warehouse_name"` + UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"` + ProductionDate *Date `gorm:"type:date" json:"production_date"` + BatchNo string `gorm:"size:50" json:"batch_no"` + SupplierName string `gorm:"size:200" json:"supplier_name"` + Remark string `gorm:"size:500" json:"remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `gorm:"index" json:"-"` StockInItem *StockInItem `gorm:"foreignKey:StockInItemID" json:"stock_in_item,omitempty"` Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` @@ -166,12 +166,12 @@ type InventoryLog struct { type InventoryCheck struct { TenantBase - CheckNo string `gorm:"size:50;uniqueIndex:uk_shop_check_no" json:"check_no"` - WarehouseID uint64 `gorm:"not null" json:"warehouse_id"` - OperatorID uint64 `gorm:"not null" json:"operator_id"` - Status string `gorm:"type:enum('draft','completed');default:'draft'" json:"status"` - CheckDate Date `gorm:"type:date" json:"check_date"` - Remark string `gorm:"size:500" json:"remark"` + CheckNo string `gorm:"size:50;uniqueIndex:uk_shop_check_no" json:"check_no"` + WarehouseID uint64 `gorm:"not null" json:"warehouse_id"` + OperatorID uint64 `gorm:"not null" json:"operator_id"` + Status string `gorm:"type:enum('draft','completed');default:'draft'" json:"status"` + CheckDate Date `gorm:"type:date" json:"check_date"` + Remark string `gorm:"size:500" json:"remark"` Items []InventoryCheckItem `gorm:"foreignKey:CheckID" json:"items,omitempty"` } diff --git a/backend/internal/model/user_session.go b/backend/internal/model/user_session.go index c22af55..138f69a 100644 --- a/backend/internal/model/user_session.go +++ b/backend/internal/model/user_session.go @@ -5,26 +5,26 @@ import "time" // UserSession 服务端登录会话。JWT 的 sid claim 指向此表一行, // 用于支持登出/踢人/在线状态监控(JWT 本身无状态,无法撤销)。 type UserSession struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - ShopID uint64 `gorm:"not null;index:idx_session_shop_user" json:"shop_id"` - UserID uint64 `gorm:"not null;index:idx_session_shop_user" json:"user_id"` - SID string `gorm:"column:sid;size:64;not null;uniqueIndex" json:"sid"` // 嵌入 JWT - DeviceID string `gorm:"size:255" json:"device_id"` - DeviceName string `gorm:"size:255" json:"device_name"` - Platform string `gorm:"size:50" json:"platform"` // windows|macos|linux|android|ios|web - PlatformClass string `gorm:"size:20;index" json:"platform_class"` // desktop|mobile|web - IP string `gorm:"size:64" json:"ip"` - UserAgent string `gorm:"size:512" json:"user_agent"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + ShopID uint64 `gorm:"not null;index:idx_session_shop_user" json:"shop_id"` + UserID uint64 `gorm:"not null;index:idx_session_shop_user" json:"user_id"` + SID string `gorm:"column:sid;size:64;not null;uniqueIndex" json:"sid"` // 嵌入 JWT + DeviceID string `gorm:"size:255" json:"device_id"` + DeviceName string `gorm:"size:255" json:"device_name"` + Platform string `gorm:"size:50" json:"platform"` // windows|macos|linux|android|ios|web + PlatformClass string `gorm:"size:20;index" json:"platform_class"` // desktop|mobile|web + IP string `gorm:"size:64" json:"ip"` + UserAgent string `gorm:"size:512" json:"user_agent"` // RefreshJTI 当前有效 refresh token 的 jti,用于「轮换 + 重用检测」: // 每次续期轮换此值,若 refresh 携带的 jti 与之不符即判定为旧 token 重放(盗用),吊销整条会话。 - RefreshJTI string `gorm:"column:refresh_jti;size:64" json:"-"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + RefreshJTI string `gorm:"column:refresh_jti;size:64" json:"-"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` // LastSeenAt 手工维护(Login 建行赋值、心跳/refresh 显式 Update)。 // 故意用 autoCreateTime(建行给默认、之后不被 ORM 自动改);切勿改成 autoUpdateTime, // 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。 LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"` RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"` - RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset + RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL RefreshExpAt time.Time `json:"refresh_exp_at"` } diff --git a/backend/internal/service/stock_test.go b/backend/internal/service/stock_test.go index de228b0..ef0ed27 100644 --- a/backend/internal/service/stock_test.go +++ b/backend/internal/service/stock_test.go @@ -12,7 +12,6 @@ import ( "github.com/wangjia/jiu/backend/testutil" ) - func TestStockService_ApproveStockIn_Success(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "STOCK001") @@ -30,10 +29,10 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) { OrderDate: model.Date{Time: time.Now()}, Items: []model.StockInItem{ { - ShopID: shop.ID, - ProductID: product.ID, - Quantity: 10, - UnitPrice: 5.0, + ShopID: shop.ID, + ProductID: product.ID, + Quantity: 10, + UnitPrice: 5.0, TotalPrice: 50.0, }, }, @@ -138,7 +137,7 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) { // 先入库 inv := model.Inventory{ - ShopID: shop.ID, + ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 20, @@ -155,10 +154,10 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) { OrderDate: model.Date{Time: time.Now()}, Items: []model.StockOutItem{ { - ShopID: shop.ID, - ProductID: product.ID, - Quantity: 5, - UnitPrice: 10.0, + ShopID: shop.ID, + ProductID: product.ID, + Quantity: 5, + UnitPrice: 10.0, TotalPrice: 50.0, }, }, @@ -189,7 +188,7 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) { // 库存只有 3 inv := model.Inventory{ - ShopID: shop.ID, + ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 3, @@ -206,7 +205,7 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) { OrderDate: model.Date{Time: time.Now()}, Items: []model.StockOutItem{ { - ShopID: shop.ID, + ShopID: shop.ID, ProductID: product.ID, Quantity: 10, }, @@ -237,7 +236,7 @@ func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) { OrderDate: model.Date{Time: time.Now()}, Items: []model.StockOutItem{ { - ShopID: shop.ID, + ShopID: shop.ID, ProductID: product.ID, Quantity: 5, }, diff --git a/backend/main.go b/backend/main.go index b7c5168..c39e1f4 100644 --- a/backend/main.go +++ b/backend/main.go @@ -25,6 +25,12 @@ func main() { if config.C.Server.CORSOrigin == "*" { log.Fatal("server.cors_origin must not be '*' in production — set it to the actual frontend origin") } + // SEC-002:默认/空 JWT 密钥随仓库公开,任何人可自签 superadmin token。 + // 漏配 JWT_SECRET 时必须拒绝启动,而不是带公开密钥上线。 + if config.C.JWT.Secret == "" || + config.C.JWT.Secret == "change-this-to-a-random-secret-in-production" { + log.Fatal("jwt.secret is empty or still the repo default — set JWT_SECRET in production") + } } // 初始化数据库