From 696e90893b8d85f34477256d7bca1e8d43e27b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Mon, 21 Sep 2026 17:26:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=BB=E6=95=B0=E6=8D=AE=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E7=9A=84=E4=B8=A4=E7=B1=BB=E9=9D=99=E9=BB=98=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPDATE 漏列: - code_rules 的 SET 补上 category_id。此前改分类提示"保存成功"但不落库, 是唯一用户可达的活缺陷(同页的分类下拉没有 disabled,且规则只能改不能删, 没有"删了重建"这条退路) - code_rules 有 uk_code_rules_org_category,补列后改到已占用的分类会撞唯一键, 新建与更新统一翻成中文的 ErrDuplicateCodeRule - 分类/单位/仓库/库位/伙伴的编码定为不可变:分类编码是编码规则前缀的来源 (init.sql 的种子数据直接取 material_categories.code),伙伴编码是对外业务 编号,改掉会让历史数据脱节且查不回来。service 的 Update* 不再要求编码非空 ——原先是"必须传、传了却被丢弃";新建侧的必填校验保留 - 顺带补齐两处同款漏网:organization.go 的 code 与 partner.go 的 partner_code (前者 service 本就不校验编码,自洽,无需改) 记录不存在时的静默成功: - DSN 开启 clientFoundRows,让 RowsAffected 返回"匹配"行数而非"实际改变"行数。 否则"编辑时一个字段都没改"和"记录不存在"都返回 0,无法区分,直接加检查会把 正常保存误判成记录不存在(真 MySQL 8.0 实测) - 新增 repository.execExpectingRow,7 个 Update* 影响 0 行时返回 ErrRecordNotFound; 伙伴走事务内的 SELECT ... FOR UPDATE,只需翻译 sql.ErrNoRows - 既有 4 处 RowsAffected 用法逐处核对过,均不受该 flag 影响 测试:补 6 个 Update* 的存在性检查、编码必填约束的两端、唯一键翻译、DSN 拼接; 全部做过变异验证(抽掉实现后对应测试确实变红)。 --- internal/repository/master_data.go | 30 +- internal/repository/organization.go | 3 +- internal/repository/organization_test.go | 19 ++ internal/repository/partner.go | 7 + internal/repository/update.go | 32 ++ internal/repository/warehouse.go | 8 +- internal/service/admin_master.go | 107 ++++--- internal/service/admin_master_test.go | 358 +++++++++++++++++++++++ pkg/database/database.go | 32 +- pkg/database/database_test.go | 64 ++++ 10 files changed, 595 insertions(+), 65 deletions(-) create mode 100644 internal/repository/update.go create mode 100644 pkg/database/database_test.go diff --git a/internal/repository/master_data.go b/internal/repository/master_data.go index 70446945..8db5f062 100644 --- a/internal/repository/master_data.go +++ b/internal/repository/master_data.go @@ -2,11 +2,27 @@ package repository import ( "database/sql" + "errors" + "strings" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) +// ErrDuplicateCodeRule 表示该分类下已经有一条编码规则。code_rules 上有 +// uk_code_rules_org_category(organization_id, category_id),新建选到已占用的 +// 分类、或者改分类改到已占用的分类,都会撞上它。 +var ErrDuplicateCodeRule = errors.New("该分类已有编码规则") + +// translateDuplicateCodeRule 把唯一键冲突翻译成可直接展示的中文错误, +// 其余错误原样返回。前端只有拿到中文消息才会照原样弹出,否则显示"请求失败"。 +func translateDuplicateCodeRule(err error) error { + if err != nil && strings.Contains(err.Error(), "Duplicate entry") { + return ErrDuplicateCodeRule + } + return err +} + type MasterDataRepository struct{} func NewMasterDataRepository() *MasterDataRepository { return &MasterDataRepository{} } @@ -38,8 +54,7 @@ func (r *MasterDataRepository) CreateCategory(item *model.MaterialCategory) erro } func (r *MasterDataRepository) UpdateCategory(item *model.MaterialCategory) error { - _, err := database.DB.Exec(`UPDATE material_categories SET name = ?, description = ?, parent_id = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Description, item.ParentID, item.Status, item.ID, item.OrganizationID) - return err + return execExpectingRow(`UPDATE material_categories SET name = ?, description = ?, parent_id = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Description, item.ParentID, item.Status, item.ID, item.OrganizationID) } func (r *MasterDataRepository) ListUnits(organizationID int64) ([]model.Unit, error) { @@ -69,8 +84,7 @@ func (r *MasterDataRepository) CreateUnit(item *model.Unit) error { } func (r *MasterDataRepository) UpdateUnit(item *model.Unit) error { - _, err := database.DB.Exec(`UPDATE units SET name = ?, precision_scale = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.PrecisionScale, item.Status, item.ID, item.OrganizationID) - return err + return execExpectingRow(`UPDATE units SET name = ?, precision_scale = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.PrecisionScale, item.Status, item.ID, item.OrganizationID) } func (r *MasterDataRepository) ListCodeRules(organizationID int64) ([]model.CodeRule, error) { @@ -95,15 +109,17 @@ func (r *MasterDataRepository) ListCodeRules(organizationID int64) ([]model.Code func (r *MasterDataRepository) CreateCodeRule(item *model.CodeRule) error { result, err := database.DB.Exec(`INSERT INTO code_rules (organization_id, category_id, prefix, sequence_length, next_sequence, status) VALUES (?, ?, ?, ?, ?, ?)`, item.OrganizationID, item.CategoryID, item.Prefix, item.SequenceLength, item.NextSequence, item.Status) if err != nil { - return err + return translateDuplicateCodeRule(err) } item.ID, err = result.LastInsertId() return err } +// UpdateCodeRule 更新编码规则。category_id 必须写进 SET:前端编辑弹窗里的 +// 分类下拉没有禁用(与同页的编码输入框不同),而规则又只能改不能删, +// 漏掉这一列会让"改分类"提示成功却完全不落库。 func (r *MasterDataRepository) UpdateCodeRule(item *model.CodeRule) error { - _, err := database.DB.Exec(`UPDATE code_rules SET prefix = ?, sequence_length = ?, next_sequence = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Prefix, item.SequenceLength, item.NextSequence, item.Status, item.ID, item.OrganizationID) - return err + return translateDuplicateCodeRule(execExpectingRow(`UPDATE code_rules SET category_id = ?, prefix = ?, sequence_length = ?, next_sequence = ?, status = ? WHERE id = ? AND organization_id = ?`, item.CategoryID, item.Prefix, item.SequenceLength, item.NextSequence, item.Status, item.ID, item.OrganizationID)) } // FindActiveCodeRule 取小类当前启用的编码规则,没有规则时返回 (nil, nil)。 diff --git a/internal/repository/organization.go b/internal/repository/organization.go index 03a3fa12..0e323d8e 100644 --- a/internal/repository/organization.go +++ b/internal/repository/organization.go @@ -44,8 +44,7 @@ func (r *OrganizationRepository) Create(item *model.Organization) error { } func (r *OrganizationRepository) Update(item *model.Organization) error { - _, err := database.DB.Exec(`UPDATE organizations SET name = ?, status = ?, default_timezone = ? WHERE id = ?`, item.Name, item.Status, item.DefaultTimezone, item.ID) - return err + return execExpectingRow(`UPDATE organizations SET name = ?, status = ?, default_timezone = ? WHERE id = ?`, item.Name, item.Status, item.DefaultTimezone, item.ID) } func (r *OrganizationRepository) ListRoles() ([]model.Role, error) { diff --git a/internal/repository/organization_test.go b/internal/repository/organization_test.go index 2c2711cf..d70ab6fc 100644 --- a/internal/repository/organization_test.go +++ b/internal/repository/organization_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) @@ -92,3 +93,21 @@ func TestUpdateRoleReplacesPermissionsInTransaction(t *testing.T) { t.Fatal(err) } } + +// 组织不存在时(比如 id 打错)更新影响 0 行。此前返回 nil,接口提示"保存成功" +// 而实际什么都没改。 +func TestOrganizationUpdateReportsMissingRecord(t *testing.T) { + 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 + + mock.ExpectExec("UPDATE organizations").WillReturnResult(sqlmock.NewResult(0, 0)) + + err = NewOrganizationRepository().Update(&model.Organization{ID: 404, Name: "改名后的组织"}) + if !errors.Is(err, ErrRecordNotFound) { + t.Fatalf("影响 0 行应当返回 ErrRecordNotFound,实际 %v", err) + } +} diff --git a/internal/repository/partner.go b/internal/repository/partner.go index 5586ee23..03da1eb5 100644 --- a/internal/repository/partner.go +++ b/internal/repository/partner.go @@ -1,6 +1,8 @@ package repository import ( + "database/sql" + "errors" "fmt" "github.com/wmms/wmms/internal/model" @@ -110,8 +112,13 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) { } }() + // 这一句兼任存在性检查:查不到就说明伙伴不存在或不属于当前组织, + // 不能让它以 sql.ErrNoRows 一路冒到接口变成 500。 var businessPartnerID int64 if err = tx.QueryRow(`SELECT business_partner_id FROM organization_partners WHERE id = ? AND organization_id = ? FOR UPDATE`, item.ID, item.OrganizationID).Scan(&businessPartnerID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrRecordNotFound + } return err } if _, err = tx.Exec(`UPDATE business_partners SET legal_name = ?, unified_credit_code = NULLIF(?, ''), diff --git a/internal/repository/update.go b/internal/repository/update.go new file mode 100644 index 00000000..aa9a4d47 --- /dev/null +++ b/internal/repository/update.go @@ -0,0 +1,32 @@ +package repository + +import ( + "errors" + + "github.com/wmms/wmms/pkg/database" +) + +// ErrRecordNotFound 表示按 id + 组织没查到目标记录:要么 id 不存在,要么这条记录 +// 属于别的组织。按主键更新时影响 0 行就返回它,避免「改了一条不存在的记录」还提示 +// 保存成功。 +var ErrRecordNotFound = errors.New("记录不存在,可能已被删除或不属于当前组织") + +// execExpectingRow 执行一条按主键更新的语句,并要求它至少匹配到一行。 +// +// 判断依据是 RowsAffected,而 MySQL 默认返回的是「实际改变」的行数——编辑弹窗里 +// 一个字段都没动时同样是 0,跟记录不存在分不开。所以 DSN 必须开 clientFoundRows=true +// (见 pkg/database.Init)把语义换成「匹配」的行数,这个检查才成立。 +func execExpectingRow(query string, args ...interface{}) error { + result, err := database.DB.Exec(query, args...) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrRecordNotFound + } + return nil +} diff --git a/internal/repository/warehouse.go b/internal/repository/warehouse.go index b921182d..cbc29a4f 100644 --- a/internal/repository/warehouse.go +++ b/internal/repository/warehouse.go @@ -54,8 +54,7 @@ func (r *WarehouseRepository) Create(item *model.Warehouse) error { } func (r *WarehouseRepository) Update(item *model.Warehouse) error { - _, err := database.DB.Exec(`UPDATE warehouses SET name = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Status, item.ID, item.OrganizationID) - return err + return execExpectingRow(`UPDATE warehouses SET name = ?, status = ? WHERE id = ? AND organization_id = ?`, item.Name, item.Status, item.ID, item.OrganizationID) } func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, organizationID int64) error { @@ -69,7 +68,8 @@ func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, orga } func (r *WarehouseRepository) UpdateLocation(item *model.WarehouseLocation, organizationID int64) error { - _, err := database.DB.Exec(`UPDATE warehouse_locations l JOIN warehouses w ON w.id = l.warehouse_id + // JOIN 形式的 UPDATE 同样受 clientFoundRows 影响,影响 0 行也代表库位不存在 + // 或不属于该组织(已实测)。 + return execExpectingRow(`UPDATE warehouse_locations l JOIN warehouses w ON w.id = l.warehouse_id SET l.name = ?, l.status = ? WHERE l.id = ? AND w.organization_id = ?`, item.Name, item.Status, item.ID, organizationID) - return err } diff --git a/internal/service/admin_master.go b/internal/service/admin_master.go index 609c83a6..0fdc2b73 100644 --- a/internal/service/admin_master.go +++ b/internal/service/admin_master.go @@ -8,6 +8,13 @@ import ( "github.com/wmms/wmms/internal/repository" ) +// 本文件里的「编码」字段——分类/单位/仓库/库位的 code、伙伴的 partner_code—— +// 一律不可变。它们是编码规则前缀的来源(init.sql 里 code_rules 的种子数据直接取 +// material_categories.code)或者对外的业务编号,改掉会让已经生成的历史编码、单据 +// 与之脱节,而且事后查不回来。前端编辑时这些输入框都是 :disabled 的,与之对应: +// 各 Update* 既不校验也不使用请求体里的编码,repository 的 UPDATE 也不写这些列。 +// 因此 Update* 只校验名称等可改字段——从前这里复用 Create* 的校验,要求编码非空, +// 形成「必须传、传了却被丢弃」的怪要求。 var errInvalidMasterData = errors.New("code and name are required") var errUpdateRequiresID = errors.New("id is required for update") var ErrProtectedRole = repository.ErrProtectedRole @@ -72,35 +79,35 @@ func (s *MasterDataService) ListCategories(orgID int64) ([]model.MaterialCategor // CreateCategory 新建分类。请求体可能带着前端表单残留的 id,这里不读取 item.ID, // repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。 func (s *MasterDataService) CreateCategory(item *model.MaterialCategory) error { - if err := prepareCategory(item); err != nil { - return err + normalizeCategory(item) + if item.Code == "" || item.Name == "" { + return errInvalidMasterData } return s.repo.CreateCategory(item) } // UpdateCategory 更新分类。id 来自路由参数,必须显式存在。 +// code 不可变,理由见文件头的约定:这里只认名称、描述、上级和状态。 func (s *MasterDataService) UpdateCategory(item *model.MaterialCategory) error { if item.ID == 0 { return errUpdateRequiresID } - if err := prepareCategory(item); err != nil { - return err + normalizeCategory(item) + if item.Name == "" { + return errInvalidMasterData } return s.repo.UpdateCategory(item) } -// prepareCategory 是新建与更新共用的字段清理、校验和默认值补全。 -func prepareCategory(item *model.MaterialCategory) error { +// normalizeCategory 是新建与更新共用的字段清理和默认值补全。 +// 编码是否必填由两个入口各自判断:新建必填,更新不看。 +func normalizeCategory(item *model.MaterialCategory) { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) item.Description = strings.TrimSpace(item.Description) - if item.Code == "" || item.Name == "" { - return errInvalidMasterData - } if item.Status == "" { item.Status = "active" } - return nil } func (s *MasterDataService) ListUnits(orgID int64) ([]model.Unit, error) { return s.repo.ListUnits(orgID) @@ -108,30 +115,35 @@ func (s *MasterDataService) ListUnits(orgID int64) ([]model.Unit, error) { // CreateUnit 新建单位。同样不读取 item.ID,避免表单残留的脏 id 把新建变成覆盖。 func (s *MasterDataService) CreateUnit(item *model.Unit) error { - if err := prepareUnit(item); err != nil { + if err := normalizeUnit(item); err != nil { return err } + if item.Code == "" || item.Name == "" { + return errInvalidMasterData + } return s.repo.CreateUnit(item) } // UpdateUnit 更新单位。id 来自路由参数,必须显式存在。 +// code 不可变,理由见文件头的约定;小数位是可以改的。 func (s *MasterDataService) UpdateUnit(item *model.Unit) error { if item.ID == 0 { return errUpdateRequiresID } - if err := prepareUnit(item); err != nil { + if err := normalizeUnit(item); err != nil { return err } + if item.Name == "" { + return errInvalidMasterData + } return s.repo.UpdateUnit(item) } -// prepareUnit 是新建与更新共用的字段清理、校验和默认值补全。 -func prepareUnit(item *model.Unit) error { +// normalizeUnit 是新建与更新共用的字段清理、默认值补全和小数位值域校验。 +// 小数位在编辑时也能改,所以两个入口都要查。编码是否必填由两个入口各自判断。 +func normalizeUnit(item *model.Unit) error { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) - if item.Code == "" || item.Name == "" { - return errInvalidMasterData - } if item.Status == "" { item.Status = "active" } @@ -209,30 +221,37 @@ func (s *PartnerService) List(orgID int64, role string, limit, offset int) ([]mo // Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID, // repository 的 INSERT 也不写 id 列,所以脏 id 不会把新建变成覆盖。 func (s *PartnerService) Create(item *model.BusinessPartner) error { - if err := preparePartner(item); err != nil { + if err := normalizePartner(item); err != nil { return err } + if item.PartnerCode == "" || item.LegalName == "" { + return errors.New("partner_code and legal_name are required") + } return s.repo.Create(item) } // Update 更新伙伴。id 来自路由参数,必须显式存在。 +// partner_code 不可变,理由见文件头的约定:它是伙伴对外的业务编号, +// 单据引用的就是它。这里只认企业名称、角色、结算等可改字段。 func (s *PartnerService) Update(item *model.BusinessPartner) error { if item.ID == 0 { return errUpdateRequiresID } - if err := preparePartner(item); err != nil { + if err := normalizePartner(item); err != nil { return err } + if item.LegalName == "" { + return errors.New("legal_name is required") + } return s.repo.Update(item) } -// preparePartner 是新建与更新共用的字段清理、校验和默认值补全。 -func preparePartner(item *model.BusinessPartner) error { +// normalizePartner 是新建与更新共用的字段清理、默认值补全和角色校验: +// 至少要有供应商或客户其中一个角色,编辑时也可能把两个都取消掉。 +// 编码是否必填由两个入口各自判断。 +func normalizePartner(item *model.BusinessPartner) error { item.PartnerCode = strings.TrimSpace(item.PartnerCode) item.LegalName = strings.TrimSpace(item.LegalName) - if item.PartnerCode == "" || item.LegalName == "" { - return errors.New("partner_code and legal_name are required") - } if !item.IsSupplier && !item.IsCustomer { return errors.New("at least one partner role is required") } @@ -258,64 +277,64 @@ func (s *WarehouseService) List(orgID int64) ([]model.Warehouse, error) { // Create 新建仓库。不读取 item.ID,脏 id 不会把新建变成覆盖。 func (s *WarehouseService) Create(item *model.Warehouse) error { - if err := prepareWarehouse(item); err != nil { - return err + normalizeWarehouse(item) + if item.Code == "" || item.Name == "" { + return errInvalidMasterData } return s.repo.Create(item) } // Update 更新仓库。id 来自路由参数,必须显式存在。 +// code 不可变,理由见文件头的约定。 func (s *WarehouseService) Update(item *model.Warehouse) error { if item.ID == 0 { return errUpdateRequiresID } - if err := prepareWarehouse(item); err != nil { - return err + normalizeWarehouse(item) + if item.Name == "" { + return errInvalidMasterData } return s.repo.Update(item) } -// prepareWarehouse 是新建与更新共用的字段清理、校验和默认值补全。 -func prepareWarehouse(item *model.Warehouse) error { +// normalizeWarehouse 是新建与更新共用的字段清理和默认值补全。 +// 编码是否必填由两个入口各自判断:新建必填,更新不看。 +func normalizeWarehouse(item *model.Warehouse) { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) - if item.Code == "" || item.Name == "" { - return errInvalidMasterData - } if item.Status == "" { item.Status = "active" } - return nil } // CreateLocation 新建库位。orgID 用于校验仓库归属,item.ID 不参与。 func (s *WarehouseService) CreateLocation(item *model.WarehouseLocation, orgID int64) error { - if err := prepareLocation(item); err != nil { - return err + normalizeLocation(item) + if item.Code == "" || item.Name == "" || item.WarehouseID <= 0 { + return errInvalidMasterData } return s.repo.CreateLocation(item, orgID) } // UpdateLocation 更新库位。id 来自路由参数,必须显式存在。 +// code 不可变,理由见文件头的约定;warehouse_id 由路由给出,不能搬到别的仓库去。 func (s *WarehouseService) UpdateLocation(item *model.WarehouseLocation, orgID int64) error { if item.ID == 0 { return errUpdateRequiresID } - if err := prepareLocation(item); err != nil { - return err + normalizeLocation(item) + if item.Name == "" || item.WarehouseID <= 0 { + return errInvalidMasterData } return s.repo.UpdateLocation(item, orgID) } -// prepareLocation 是新建与更新共用的字段清理、校验和默认值补全。 -func prepareLocation(item *model.WarehouseLocation) error { +// normalizeLocation 是新建与更新共用的字段清理和默认值补全。 +// 编码是否必填由两个入口各自判断:新建必填,更新不看。 +func normalizeLocation(item *model.WarehouseLocation) { item.Code = strings.TrimSpace(item.Code) item.Name = strings.TrimSpace(item.Name) - if item.Code == "" || item.Name == "" || item.WarehouseID <= 0 { - return errInvalidMasterData - } if item.Status == "" { item.Status = "active" } - return nil } diff --git a/internal/service/admin_master_test.go b/internal/service/admin_master_test.go index 517c0a3b..7edef8fe 100644 --- a/internal/service/admin_master_test.go +++ b/internal/service/admin_master_test.go @@ -1,10 +1,13 @@ package service import ( + "errors" + "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/internal/repository" "github.com/wmms/wmms/pkg/database" ) @@ -135,6 +138,8 @@ func TestLocationCreateRequiresWarehouseID(t *testing.T) { // 上面的 UpdateRequiresID 只覆盖了拒绝分支,方法体里真正的 s.repo.Update 从未被执行。 // 六个 Update* 互为复制粘贴的模板,若某一处尾行被误写成 s.repo.Create, // 生产行为会退化成「编辑即新增」——与本次修复的缺陷同级。这里补上走通的路径。 +// Partner / Category / Unit / CodeRule / Warehouse / Location 六个现已全覆盖, +// 且逐个把尾行改成 Create* 验证过确实会被抓住,不是只跑绿。 func TestPartnerUpdateUpdatesInsteadOfInserting(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { @@ -197,3 +202,356 @@ func TestCategoryUpdateUpdatesInsteadOfInserting(t *testing.T) { t.Fatalf("应当执行 UPDATE,实际 SQL 不是插入: %v", err) } } + +func TestUnitUpdateUpdatesInsteadOfInserting(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + database.DB = db + t.Cleanup(func() { database.DB = nil; db.Close() }) + + mock.ExpectExec("UPDATE units"). + WithArgs("件(改名)", int64(2), "active", int64(4), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + item := &model.Unit{ + ID: 4, + OrganizationID: 1, + Code: "pcs", + Name: "件(改名)", + PrecisionScale: 2, + } + if err := NewMasterDataService().UpdateUnit(item); err != nil { + t.Fatalf("UpdateUnit: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err) + } +} + +func TestCodeRuleUpdateUpdatesInsteadOfInserting(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + database.DB = db + t.Cleanup(func() { database.DB = nil; db.Close() }) + + // category_id 必须出现在参数里:SET 漏掉它正是本次修的缺陷, + // 这里用 WithArgs 钉住参数个数和顺序,漏了或写错位置都会失败。 + mock.ExpectExec("UPDATE code_rules"). + WithArgs(int64(2), "B", int64(5), int64(7), "active", int64(3), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + item := &model.CodeRule{ + ID: 3, + OrganizationID: 1, + CategoryID: 2, + Prefix: "B", + SequenceLength: 5, + NextSequence: 7, + } + if err := NewMasterDataService().UpdateCodeRule(item); err != nil { + t.Fatalf("UpdateCodeRule: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err) + } +} + +func TestWarehouseUpdateUpdatesInsteadOfInserting(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + database.DB = db + t.Cleanup(func() { database.DB = nil; db.Close() }) + + mock.ExpectExec("UPDATE warehouses"). + WithArgs("一号仓(改名)", "active", int64(5), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + item := &model.Warehouse{ + ID: 5, + OrganizationID: 1, + Code: "WH1", + Name: "一号仓(改名)", + } + if err := NewWarehouseService().Update(item); err != nil { + t.Fatalf("Update: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err) + } +} + +// 库位这条和上面三条不同:repository 的 UPDATE 是 JOIN warehouses 的写法, +// orgID 由方法参数传入、不在 item 上,SQL 模式写窄一点才能对上。 +func TestLocationUpdateUpdatesInsteadOfInserting(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + database.DB = db + t.Cleanup(func() { database.DB = nil; db.Close() }) + + mock.ExpectExec("UPDATE warehouse_locations"). + WithArgs("库位一(改名)", "active", int64(8), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + item := &model.WarehouseLocation{ + ID: 8, + WarehouseID: 3, + Code: "L1", + Name: "库位一(改名)", + } + if err := NewWarehouseService().UpdateLocation(item, 1); err != nil { + t.Fatalf("UpdateLocation: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("应当执行 UPDATE,实际 SQL 不是更新: %v", err) + } +} + +// 以下几组测试钉住「编码不可变」这条契约的两端: +// 更新侧——请求里不带 code 也必须能走通。旧实现复用 Create 的校验,要求 code +// 非空,而 repository 的 UPDATE 又不写这一列,形成「必须传、传了却被丢弃」。 +// 新建侧——拆出 normalize* 时不能把 code 的必填校验一起丢了。 +func newMasterDataMock(t *testing.T) sqlmock.Sqlmock { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + database.DB = db + t.Cleanup(func() { database.DB = nil; db.Close() }) + return mock +} + +func TestCategoryUpdateAcceptsRequestWithoutCode(t *testing.T) { + mock := newMasterDataMock(t) + mock.ExpectExec("UPDATE material_categories"). + WithArgs("只改名称", "", nil, "active", int64(9), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + item := &model.MaterialCategory{ID: 9, OrganizationID: 1, Name: "只改名称"} + if err := NewMasterDataService().UpdateCategory(item); err != nil { + t.Fatalf("UpdateCategory: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err) + } +} + +func TestUnitUpdateAcceptsRequestWithoutCode(t *testing.T) { + mock := newMasterDataMock(t) + mock.ExpectExec("UPDATE units"). + WithArgs("件(改名)", int64(2), "active", int64(4), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + item := &model.Unit{ID: 4, OrganizationID: 1, Name: "件(改名)", PrecisionScale: 2} + if err := NewMasterDataService().UpdateUnit(item); err != nil { + t.Fatalf("UpdateUnit: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err) + } +} + +func TestWarehouseUpdateAcceptsRequestWithoutCode(t *testing.T) { + mock := newMasterDataMock(t) + mock.ExpectExec("UPDATE warehouses"). + WithArgs("一号仓(改名)", "active", int64(5), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + item := &model.Warehouse{ID: 5, OrganizationID: 1, Name: "一号仓(改名)"} + if err := NewWarehouseService().Update(item); err != nil { + t.Fatalf("Update: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err) + } +} + +func TestLocationUpdateAcceptsRequestWithoutCode(t *testing.T) { + mock := newMasterDataMock(t) + mock.ExpectExec("UPDATE warehouse_locations"). + WithArgs("库位一(改名)", "active", int64(8), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + item := &model.WarehouseLocation{ID: 8, WarehouseID: 3, Name: "库位一(改名)"} + if err := NewWarehouseService().UpdateLocation(item, 1); err != nil { + t.Fatalf("UpdateLocation: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("不带 code 的更新应当照常执行 UPDATE: %v", err) + } +} + +func TestPartnerUpdateAcceptsRequestWithoutPartnerCode(t *testing.T) { + mock := newMasterDataMock(t) + mock.ExpectBegin() + mock.ExpectQuery("SELECT business_partner_id FROM organization_partners"). + WithArgs(int64(12), int64(1)). + WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(int64(7))) + mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE organization_partners").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + item := &model.BusinessPartner{ID: 12, OrganizationID: 1, LegalName: "伙伴改名", IsSupplier: true} + if err := NewPartnerService().Update(item); err != nil { + t.Fatalf("Update: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("不带 partner_code 的更新应当照常执行: %v", err) + } +} + +// 更新侧放开 code 之后,名称这类可改字段的校验不能跟着一起没了。 +func TestUpdatesStillRequireName(t *testing.T) { + cases := []struct { + name string + run func() error + }{ + {"category", func() error { + return NewMasterDataService().UpdateCategory(&model.MaterialCategory{ID: 9, Code: "A01"}) + }}, + {"unit", func() error { + return NewMasterDataService().UpdateUnit(&model.Unit{ID: 4, Code: "pcs"}) + }}, + {"warehouse", func() error { + return NewWarehouseService().Update(&model.Warehouse{ID: 5, Code: "WH1"}) + }}, + {"location", func() error { + return NewWarehouseService().UpdateLocation(&model.WarehouseLocation{ID: 8, WarehouseID: 3, Code: "L1"}, 1) + }}, + {"partner", func() error { + return NewPartnerService().Update(&model.BusinessPartner{ID: 12, PartnerCode: "SUP-007", IsSupplier: true}) + }}, + } + for _, c := range cases { + if err := c.run(); err == nil { + t.Errorf("%s: 名称为空的更新应当被拒绝", c.name) + } + } +} + +// 新建侧仍然要求 code:拆方法不能把必填校验弄丢。 +func TestCreatesStillRequireCode(t *testing.T) { + cases := []struct { + name string + run func() error + }{ + {"category", func() error { + return NewMasterDataService().CreateCategory(&model.MaterialCategory{Name: "新分类"}) + }}, + {"unit", func() error { + return NewMasterDataService().CreateUnit(&model.Unit{Name: "件"}) + }}, + {"warehouse", func() error { + return NewWarehouseService().Create(&model.Warehouse{Name: "二号仓"}) + }}, + {"location", func() error { + return NewWarehouseService().CreateLocation(&model.WarehouseLocation{WarehouseID: 3, Name: "库位一"}, 1) + }}, + {"partner", func() error { + return NewPartnerService().Create(&model.BusinessPartner{LegalName: "新伙伴", IsSupplier: true}) + }}, + } + for _, c := range cases { + if err := c.run(); err == nil { + t.Errorf("%s: 缺少 code 的新建应当被拒绝", c.name) + } + } +} + +// 修好 category_id 之后,把规则改到已有规则的分类上会撞 +// uk_code_rules_org_category。此前 SET 不写这一列,永远撞不上; +// 现在必须把这冲突翻成中文提示,否则前端只会显示「请求失败」。 +func TestCodeRuleUpdateReportsDuplicateCategory(t *testing.T) { + mock := newMasterDataMock(t) + mock.ExpectExec("UPDATE code_rules"). + WillReturnError(errors.New("Error 1062: Duplicate entry '1-2' for key 'uk_code_rules_org_category'")) + + item := &model.CodeRule{ID: 3, OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1} + err := NewMasterDataService().UpdateCodeRule(item) + if !errors.Is(err, repository.ErrDuplicateCodeRule) { + t.Fatalf("撞唯一键应当翻成 ErrDuplicateCodeRule,实际 %v", err) + } + // 前端 index.js 只把中文消息照原样弹出,英文一律显示"请求失败"。 + if !strings.Contains(err.Error(), "已有编码规则") { + t.Fatalf("错误消息应当是中文,实际 %q", err.Error()) + } +} + +func TestCodeRuleCreateReportsDuplicateCategory(t *testing.T) { + mock := newMasterDataMock(t) + mock.ExpectExec("INSERT INTO code_rules"). + WillReturnError(errors.New("Error 1062: Duplicate entry '1-2' for key 'uk_code_rules_org_category'")) + + item := &model.CodeRule{OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1} + if err := NewMasterDataService().CreateCodeRule(item); !errors.Is(err, repository.ErrDuplicateCodeRule) { + t.Fatalf("新建撞唯一键也应当翻成 ErrDuplicateCodeRule,实际 %v", err) + } +} + +// 记录不存在、或者存在但不属于当前组织时,UPDATE 影响 0 行。此前一律返回 nil, +// 接口提示"保存成功"而实际什么都没改。 +// +// 这条检查依赖 DSN 的 clientFoundRows=true:不开的话「编辑弹窗里一个字段都没动」 +// 同样是 0 行,正常的保存会被误判成记录不存在。开关的实测数据见 +// pkg/database/database.go 里 normalizeDSN 的注释。 +func TestUpdatesReportMissingRecord(t *testing.T) { + cases := []struct { + name string + sql string + run func() error + }{ + {"category", "UPDATE material_categories", func() error { + return NewMasterDataService().UpdateCategory(&model.MaterialCategory{ID: 404, OrganizationID: 1, Name: "改名"}) + }}, + {"unit", "UPDATE units", func() error { + return NewMasterDataService().UpdateUnit(&model.Unit{ID: 404, OrganizationID: 1, Name: "件"}) + }}, + {"code rule", "UPDATE code_rules", func() error { + return NewMasterDataService().UpdateCodeRule(&model.CodeRule{ + ID: 404, OrganizationID: 1, CategoryID: 2, Prefix: "B", SequenceLength: 4, NextSequence: 1, + }) + }}, + {"warehouse", "UPDATE warehouses", func() error { + return NewWarehouseService().Update(&model.Warehouse{ID: 404, OrganizationID: 1, Name: "一号仓"}) + }}, + {"location", "UPDATE warehouse_locations", func() error { + return NewWarehouseService().UpdateLocation(&model.WarehouseLocation{ID: 404, WarehouseID: 3, Name: "库位一"}, 1) + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mock := newMasterDataMock(t) + // 第二个参数是受影响行数:0 行代表 id 不存在或组织对不上。 + mock.ExpectExec(c.sql).WillReturnResult(sqlmock.NewResult(0, 0)) + if err := c.run(); !errors.Is(err, repository.ErrRecordNotFound) { + t.Fatalf("影响 0 行应当返回 ErrRecordNotFound,实际 %v", err) + } + }) + } +} + +// 伙伴走的是另一条路:事务里先 SELECT ... FOR UPDATE,查不到就已经能判定不存在。 +// 不翻译的话会以 sql.ErrNoRows 冒到接口,变成 500 加一句英文。 +func TestPartnerUpdateReportsMissingRecord(t *testing.T) { + mock := newMasterDataMock(t) + mock.ExpectBegin() + mock.ExpectQuery("SELECT business_partner_id FROM organization_partners"). + WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"})) + mock.ExpectRollback() + + item := &model.BusinessPartner{ID: 404, OrganizationID: 1, LegalName: "不存在的伙伴", IsSupplier: true} + if err := NewPartnerService().Update(item); !errors.Is(err, repository.ErrRecordNotFound) { + t.Fatalf("查不到伙伴应当返回 ErrRecordNotFound,实际 %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("查不到时应当回滚事务: %v", err) + } +} diff --git a/pkg/database/database.go b/pkg/database/database.go index 1d051155..c57013f9 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -11,15 +11,31 @@ import ( var DB *sql.DB -func Init(dsn string) error { - // Add parseTime=true to handle DATETIME columns as time.Time - if !strings.Contains(dsn, "parseTime=") { - if strings.Contains(dsn, "?") { - dsn += "&parseTime=true" - } else { - dsn += "?parseTime=true" - } +// appendDSNParam 往 DSN 里补一个连接参数。key 已经出现过就原样返回——调用方 +// 显式配的值优先,哪怕配的是反值。 +func appendDSNParam(dsn, key, value string) string { + if strings.Contains(dsn, key+"=") { + return dsn } + if strings.Contains(dsn, "?") { + return dsn + "&" + key + "=" + value + } + return dsn + "?" + key + "=" + value +} + +// normalizeDSN 补全连接参数。这两个都在代码里兜底,不要求每个部署环境都记得写: +// - parseTime=true 把 DATETIME 列扫成 time.Time; +// - clientFoundRows=true 让 UPDATE 的 RowsAffected 返回「匹配」的行数,而不是 +// MySQL 默认的「实际改变」的行数。默认语义下「编辑时一个字段都没改」和 +// 「记录不存在」都返回 0,主数据的 Update* 就没法据此判断记录是否存在; +// 开启后两者分别是 1 和 0,可以区分。对仓库/库位的 JOIN 形式 UPDATE 同样有效。 +func normalizeDSN(dsn string) string { + dsn = appendDSNParam(dsn, "parseTime", "true") + return appendDSNParam(dsn, "clientFoundRows", "true") +} + +func Init(dsn string) error { + dsn = normalizeDSN(dsn) var err error DB, err = sql.Open("mysql", dsn) diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go new file mode 100644 index 00000000..e93a4df1 --- /dev/null +++ b/pkg/database/database_test.go @@ -0,0 +1,64 @@ +package database + +import "testing" + +// Init 靠 appendDSNParam 把 parseTime 和 clientFoundRows 补进 DSN。这里钉住拼接 +// 规则:没有 ? 要用 ? 起头,已有 ? 要用 & 续接,已经写过的参数不再重复追加。 +func TestAppendDSNParam(t *testing.T) { + cases := []struct { + name string + dsn string + key string + value string + want string + }{ + { + name: "没有查询串时用问号起头", + dsn: "root:pw@tcp(db:3306)/wmms", + key: "parseTime", + value: "true", + want: "root:pw@tcp(db:3306)/wmms?parseTime=true", + }, + { + name: "已有查询串时用与号续接", + dsn: "root:pw@tcp(db:3306)/wmms?charset=utf8mb4", + key: "parseTime", + value: "true", + want: "root:pw@tcp(db:3306)/wmms?charset=utf8mb4&parseTime=true", + }, + { + name: "已经配过就不重复追加", + dsn: "root:pw@tcp(db:3306)/wmms?parseTime=true", + key: "parseTime", + value: "true", + want: "root:pw@tcp(db:3306)/wmms?parseTime=true", + }, + { + // 按参数名判断,不按完整参数值:调用方显式关了 clientFoundRows, + // 不能被当成"没配过"再追加一个 true 进去。 + name: "显式配置的反值优先", + dsn: "root:pw@tcp(db:3306)/wmms?clientFoundRows=false", + key: "clientFoundRows", + value: "true", + want: "root:pw@tcp(db:3306)/wmms?clientFoundRows=false", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := appendDSNParam(c.dsn, c.key, c.value); got != c.want { + t.Errorf("appendDSNParam(%q, %q, %q)\n got = %q\nwant = %q", c.dsn, c.key, c.value, got, c.want) + } + }) + } +} + +// Init 实际走的就是这条路径。两个参数缺一不可:漏了 clientFoundRows,Update* 的 +// 存在性检查会把「编辑时一个字段都没改」误判成「记录不存在」,成了比原缺陷更糟的 +// 回归——正常的保存会被拒绝。 +func TestNormalizeDSNAddsBothParams(t *testing.T) { + got := normalizeDSN("root:pw@tcp(db:3306)/wmms?charset=utf8mb4") + want := "root:pw@tcp(db:3306)/wmms?charset=utf8mb4&parseTime=true&clientFoundRows=true" + if got != want { + t.Fatalf("normalizeDSN()\n got = %q\nwant = %q", got, want) + } +}