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:
parent
aa017a32b4
commit
5d508cd2bb
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
68
internal/repository/warehouse_test.go
Normal file
68
internal/repository/warehouse_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user