fix: 主数据编辑的两类静默失败
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 拼接; 全部做过变异验证(抽掉实现后对应测试确实变红)。
This commit is contained in:
parent
837cc5787b
commit
696e90893b
|
|
@ -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)。
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(?, ''),
|
||||
|
|
|
|||
32
internal/repository/update.go
Normal file
32
internal/repository/update.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=") {
|
||||
// appendDSNParam 往 DSN 里补一个连接参数。key 已经出现过就原样返回——调用方
|
||||
// 显式配的值优先,哪怕配的是反值。
|
||||
func appendDSNParam(dsn, key, value string) string {
|
||||
if strings.Contains(dsn, key+"=") {
|
||||
return dsn
|
||||
}
|
||||
if strings.Contains(dsn, "?") {
|
||||
dsn += "&parseTime=true"
|
||||
} else {
|
||||
dsn += "?parseTime=true"
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
64
pkg/database/database_test.go
Normal file
64
pkg/database/database_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user