From 5d508cd2bb186ee2a7c3339b73d98c4ea43820af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Tue, 22 Sep 2026 18:21:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=99=E4=B8=8D=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E7=9A=84=E4=BB=93=E5=BA=93=E5=BB=BA=E5=BA=93=E4=BD=8D=E6=97=B6?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5=E5=88=9B=E5=BB=BA=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateLocation 的 INSERT...SELECT 匹配不到仓库时影响 0 行,MySQL 不报错: err 为 nil、LastInsertId() 返回 0。此前只看 err,接口回 201 "Location created successfully" 且 data.id 为 0,而库里一行都没写进去—— 刷新后库位消失,重试还是"成功"。 改为以 RowsAffected 为准(INSERT 的 RowsAffected 就是实际插入行数,不受 clientFoundRows 影响),影响 0 行返回 ErrWarehouseNotFound,接口回 400 "仓库不存在,可能已被删除或不属于当前组织"。与既有的 execExpectingRow、 role_permissions 插入保持同一套路。 实测(docker,重建后端镜像后): 不存在的仓库 999 -> HTTP 400,warehouse_locations 1 行 -> 1 行 真实仓库 1 -> HTTP 201 id=3,1 行 -> 2 行,库中确有此行 新增 warehouse_test.go 覆盖两个分支,变异验证:去掉 affected==0 判断后 "影响 0 行"用例失败(返回 ,正是线上症状)。 --- internal/repository/warehouse.go | 20 ++++++++ internal/repository/warehouse_test.go | 68 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 internal/repository/warehouse_test.go diff --git a/internal/repository/warehouse.go b/internal/repository/warehouse.go index cbc29a4f..58a74b44 100644 --- a/internal/repository/warehouse.go +++ b/internal/repository/warehouse.go @@ -2,11 +2,17 @@ package repository import ( "database/sql" + "errors" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) +// ErrWarehouseNotFound 表示仓库 id 不存在,或这条仓库不属于当前组织。 +// 建库位时的 INSERT...SELECT 影响 0 行就是这种情况,接口据此回错, +// 而不是把「没写成」报成"创建成功"。 +var ErrWarehouseNotFound = errors.New("仓库不存在,可能已被删除或不属于当前组织") + type WarehouseRepository struct{} func NewWarehouseRepository() *WarehouseRepository { return &WarehouseRepository{} } @@ -58,11 +64,25 @@ func (r *WarehouseRepository) Update(item *model.Warehouse) error { } func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, organizationID int64) error { + // 仓库归属条件写在 SELECT 里,避免「先查后插」之间的竞态。 + // + // 匹配不到仓库时这条语句影响 0 行,但 MySQL 不报错:err 为 nil,LastInsertId() + // 返回 0。此前只看 err,接口回 201「创建成功」而库里一行都没有(实测请求前后 + // warehouse_locations 行数都是 1,刷新后库位消失)。所以这里和按主键更新一样, + // 以 RowsAffected 为准——INSERT 的 RowsAffected 就是实际插入行数,不受 + // clientFoundRows 影响。 result, err := database.DB.Exec(`INSERT INTO warehouse_locations (warehouse_id, code, name, status) SELECT id, ?, ?, ? FROM warehouses WHERE id = ? AND organization_id = ?`, item.Code, item.Name, item.Status, item.WarehouseID, organizationID) if err != nil { return err } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrWarehouseNotFound + } item.ID, err = result.LastInsertId() return err } diff --git a/internal/repository/warehouse_test.go b/internal/repository/warehouse_test.go new file mode 100644 index 00000000..0dd97aa6 --- /dev/null +++ b/internal/repository/warehouse_test.go @@ -0,0 +1,68 @@ +package repository + +import ( + "errors" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/pkg/database" +) + +// 与 warehouse.go 里的语句逐字一致:换行和缩进都参与匹配。 +const createLocationSQL = `INSERT INTO warehouse_locations (warehouse_id, code, name, status) + SELECT id, ?, ?, ? FROM warehouses WHERE id = ? AND organization_id = ?` + +func newWarehouseMock(t *testing.T) sqlmock.Sqlmock { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("open sql mock: %v", err) + } + t.Cleanup(func() { database.DB = nil; db.Close() }) + database.DB = db + return mock +} + +// 仓库不存在时(id 打错、仓库已被删、或这条仓库属于别的组织)这条 INSERT...SELECT +// 影响 0 行,而 MySQL 既不报错、LastInsertId() 也只回 0。此前只看 err,接口会回 +// 201「Location created successfully」,库里却没有新行,刷新后库位消失、重试还是 +// "成功"(实测请求前后 warehouse_locations 行数都是 1)。 +func TestCreateLocationReportsMissingWarehouse(t *testing.T) { + mock := newWarehouseMock(t) + mock.ExpectExec(regexp.QuoteMeta(createLocationSQL)). + WithArgs("L1", "库位一", "active", int64(999), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 0)) + + item := &model.WarehouseLocation{WarehouseID: 999, Code: "L1", Name: "库位一", Status: "active"} + err := NewWarehouseRepository().CreateLocation(item, 1) + if !errors.Is(err, ErrWarehouseNotFound) { + t.Fatalf("影响 0 行应当返回 ErrWarehouseNotFound,实际 %v", err) + } + if item.ID != 0 { + t.Fatalf("没插进去就不该有 id,实际 %d", item.ID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// 反面:真的插进去了就必须回填 id,别把成功路径也一起判成失败。 +func TestCreateLocationReturnsInsertedID(t *testing.T) { + mock := newWarehouseMock(t) + mock.ExpectExec(regexp.QuoteMeta(createLocationSQL)). + WithArgs("L1", "库位一", "active", int64(3), int64(1)). + WillReturnResult(sqlmock.NewResult(7, 1)) + + item := &model.WarehouseLocation{WarehouseID: 3, Code: "L1", Name: "库位一", Status: "active"} + if err := NewWarehouseRepository().CreateLocation(item, 1); err != nil { + t.Fatalf("插入成功不应报错,实际 %v", err) + } + if item.ID != 7 { + t.Fatalf("应当回填自增 id 7,实际 %d", item.ID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +}