From c0eb957097758eeeb776f81f06d495fea8903226 Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 15 Aug 2026 09:12:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=98=8E=E7=A1=AE=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E6=A1=A3=E5=8F=A3=E5=85=A5=E5=BA=93=E7=A0=81=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=86=B2=E7=AA=81=20(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/repository/inner_code.go | 30 +++++++++++++++++++++++++++-- admin/repository/inner_code_test.go | 12 ++++++++++++ admin/service/inner_code_import.go | 4 ++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index ab9850a..1a6c2ee 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -6,9 +6,14 @@ import ( "fmt" "strings" + "github.com/go-sql-driver/mysql" + "cmautobuy/admin/model" ) +// ErrInnerCodeUniqueConflict 表示同一日期的入库码已属于另一个业务键。 +var ErrInnerCodeUniqueConflict = errors.New("同一业务日期的档口入库码已被其他记录使用") + // InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。 type InnerCodeSybSnapshot struct { OrderNumber string @@ -40,6 +45,17 @@ const ( // UpsertInnerCodeImportRow 按已确认业务键写入一行。 // 必须在事务中调用;先锁定业务键,避免另一个唯一键冲突时更新错行。 func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now string) (InnerCodeImportOutcome, error) { + var codeRecordID int64 + var codeOrder, codeStall, codeSpecKey string + codeErr := tx.QueryRow(`SELECT id,order_number,stall,spec_key FROM syb_inner_code_records + WHERE business_date=? AND inner_code=? FOR UPDATE`, row.BusinessDate, row.InnerCode). + Scan(&codeRecordID, &codeOrder, &codeStall, &codeSpecKey) + if codeErr != nil && !errors.Is(codeErr, sql.ErrNoRows) { + return "", fmt.Errorf("核对档口入库码唯一性失败: %w", codeErr) + } + if codeErr == nil && (codeOrder != row.OrderNumber || codeStall != row.Stall || codeSpecKey != row.SpecKey) { + return "", fmt.Errorf("%w(记录 %d)", ErrInnerCodeUniqueConflict, codeRecordID) + } var id int64 err := tx.QueryRow(` SELECT id FROM syb_inner_code_records @@ -59,7 +75,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri nullableString(row.ShopName), row.Stall, row.SpecRaw, row.SpecKey, row.InnerCode, row.SourceDuplicateCount, row.CreatedByUserID, now, now) if err != nil { - return "", fmt.Errorf("新增档口入库码记录失败: %w", err) + return "", innerCodeImportWriteError("新增档口入库码记录失败", err) } return InnerCodeImportCreated, nil } @@ -85,11 +101,21 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName), row.SpecRaw, row.InnerCode, row.SourceDuplicateCount, now, id) if err != nil { - return "", fmt.Errorf("更新档口入库码导入记录失败: %w", err) + return "", innerCodeImportWriteError("更新档口入库码导入记录失败", err) } return InnerCodeImportUpdated, nil } +func innerCodeImportWriteError(action string, err error) error { + // 并发导入可能都在 SELECT 时看不到对方,最终仍由唯一索引裁决。 + // 不把 MySQL 索引名或 SQL 原文显示给操作员。 + var mysqlError *mysql.MySQLError + if errors.As(err, &mysqlError) && mysqlError.Number == 1062 { + return fmt.Errorf("%s: %w", action, ErrInnerCodeUniqueConflict) + } + return fmt.Errorf("%s: %w", action, err) +} + // ListInnerCodeRecordsForPlanning 返回某日允许重新规划的记录。 func ListInnerCodeRecordsForPlanning(q Execer, businessDate string) ([]model.InnerCodeRecord, error) { rows, err := q.Query(` diff --git a/admin/repository/inner_code_test.go b/admin/repository/inner_code_test.go index 58663be..ecbfca0 100644 --- a/admin/repository/inner_code_test.go +++ b/admin/repository/inner_code_test.go @@ -2,8 +2,10 @@ package repository import ( "database/sql" + "errors" "testing" + "github.com/go-sql-driver/mysql" _ "modernc.org/sqlite" ) @@ -38,3 +40,13 @@ func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) { t.Fatalf("ready 记录不应变化 status=%s err=%v", status, err) } } + +func TestInnerCodeImportWriteError_唯一冲突不泄漏索引细节(t *testing.T) { + err := innerCodeImportWriteError("新增失败", &mysql.MySQLError{Number: 1062, Message: "Duplicate entry for uq-secret"}) + if !errors.Is(err, ErrInnerCodeUniqueConflict) { + t.Fatalf("应识别 MySQL 1062: %v", err) + } + if err.Error() != "新增失败: "+ErrInnerCodeUniqueConflict.Error() { + t.Fatalf("不应回显索引细节: %v", err) + } +} diff --git a/admin/service/inner_code_import.go b/admin/service/inner_code_import.go index 9e3cf0a..6c54860 100644 --- a/admin/service/inner_code_import.go +++ b/admin/service/inner_code_import.go @@ -125,6 +125,10 @@ func ImportInnerCodeExcel(db *sql.DB, path, businessDate, actorUserID string) (* for _, row := range rows { outcome, err := repository.UpsertInnerCodeImportRow(tx, row, now) if err != nil { + if errors.Is(err, repository.ErrInnerCodeUniqueConflict) { + return nil, fmt.Errorf("%w:Excel 第 %d 行的入库码 %q 在该业务日期已属于另一条记录", + ErrInvalidInnerCodeImport, row.SourceRow, row.InnerCode) + } return nil, fmt.Errorf("导入 Excel 第 %d 行失败: %w", row.SourceRow, err) } if outcome == repository.InnerCodeImportCreated {