fix: 明确拦截档口入库码唯一冲突 (#231)

This commit is contained in:
chengma
2026-08-15 09:12:23 +08:00
parent 9ca7f83289
commit c0eb957097
3 changed files with 44 additions and 2 deletions
+28 -2
View File
@@ -6,9 +6,14 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/go-sql-driver/mysql"
"cmautobuy/admin/model" "cmautobuy/admin/model"
) )
// ErrInnerCodeUniqueConflict 表示同一日期的入库码已属于另一个业务键。
var ErrInnerCodeUniqueConflict = errors.New("同一业务日期的档口入库码已被其他记录使用")
// InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。 // InnerCodeSybSnapshot 是本地顺运宝明细用于解析货运单 stock id 的最小快照。
type InnerCodeSybSnapshot struct { type InnerCodeSybSnapshot struct {
OrderNumber string OrderNumber string
@@ -40,6 +45,17 @@ const (
// UpsertInnerCodeImportRow 按已确认业务键写入一行。 // UpsertInnerCodeImportRow 按已确认业务键写入一行。
// 必须在事务中调用;先锁定业务键,避免另一个唯一键冲突时更新错行。 // 必须在事务中调用;先锁定业务键,避免另一个唯一键冲突时更新错行。
func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now string) (InnerCodeImportOutcome, error) { 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 var id int64
err := tx.QueryRow(` err := tx.QueryRow(`
SELECT id FROM syb_inner_code_records 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, nullableString(row.ShopName), row.Stall, row.SpecRaw, row.SpecKey, row.InnerCode,
row.SourceDuplicateCount, row.CreatedByUserID, now, now) row.SourceDuplicateCount, row.CreatedByUserID, now, now)
if err != nil { if err != nil {
return "", fmt.Errorf("新增档口入库码记录失败: %w", err) return "", innerCodeImportWriteError("新增档口入库码记录失败", err)
} }
return InnerCodeImportCreated, nil 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.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName), row.SpecRaw,
row.InnerCode, row.SourceDuplicateCount, now, id) row.InnerCode, row.SourceDuplicateCount, now, id)
if err != nil { if err != nil {
return "", fmt.Errorf("更新档口入库码导入记录失败: %w", err) return "", innerCodeImportWriteError("更新档口入库码导入记录失败", err)
} }
return InnerCodeImportUpdated, nil 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 返回某日允许重新规划的记录。 // ListInnerCodeRecordsForPlanning 返回某日允许重新规划的记录。
func ListInnerCodeRecordsForPlanning(q Execer, businessDate string) ([]model.InnerCodeRecord, error) { func ListInnerCodeRecordsForPlanning(q Execer, businessDate string) ([]model.InnerCodeRecord, error) {
rows, err := q.Query(` rows, err := q.Query(`
+12
View File
@@ -2,8 +2,10 @@ package repository
import ( import (
"database/sql" "database/sql"
"errors"
"testing" "testing"
"github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -38,3 +40,13 @@ func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
t.Fatalf("ready 记录不应变化 status=%s err=%v", status, err) 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)
}
}
+4
View File
@@ -125,6 +125,10 @@ func ImportInnerCodeExcel(db *sql.DB, path, businessDate, actorUserID string) (*
for _, row := range rows { for _, row := range rows {
outcome, err := repository.UpsertInnerCodeImportRow(tx, row, now) outcome, err := repository.UpsertInnerCodeImportRow(tx, row, now)
if err != nil { 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) return nil, fmt.Errorf("导入 Excel 第 %d 行失败: %w", row.SourceRow, err)
} }
if outcome == repository.InnerCodeImportCreated { if outcome == repository.InnerCodeImportCreated {