fix: 给不存在的仓库建库位时误报创建成功

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 行"用例失败(返回 <nil>,正是线上症状)。
This commit is contained in:
文睿 张 2026-09-22 18:21:31 +08:00
parent aa017a32b4
commit 5d508cd2bb
2 changed files with 88 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
}
}