feat: 安全回写档口入库码并支持核验恢复 (#234)

This commit is contained in:
chengma
2026-08-15 09:10:10 +08:00
parent 15ebda23e3
commit 2778eed29f
11 changed files with 782 additions and 4 deletions
+64
View File
@@ -155,6 +155,70 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
}
// InnerCodeApply 在操作员确认后逐条执行安全回写。
func (h *Handler) InnerCodeApply(c *gin.Context) {
businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q")
pageNumber := service.ParsePage(c.PostForm("page"))
ids := make([]int64, 0)
for _, raw := range c.PostFormArray("ids") {
if id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64); err == nil && id > 0 {
ids = append(ids, id)
}
}
client, message := h.innerCodeSybClient()
if message != "" {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
return
}
result, err := service.ApplyInnerCodes(c.Request.Context(), h.db, client, ids, currentUser(c).UserID)
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "回写未完成:"+err.Error())
return
}
message = fmt.Sprintf("回写完成:选择 %d 条,成功 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条,需核对 %d 条。",
result.Requested, result.Updated, result.AlreadyFilled, result.Skipped, result.Failed, result.NeedsCheck)
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
}
// InnerCodeRecheck 只重新读取未知结果,绝不再次发送删除或写入。
func (h *Handler) InnerCodeRecheck(c *gin.Context) {
businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q")
pageNumber := service.ParsePage(c.PostForm("page"))
id, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("id")), 10, 64)
if err != nil || id <= 0 {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "记录编号无效,没有执行核对。")
return
}
client, message := h.innerCodeSybClient()
if message != "" {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
return
}
_, message, err = service.RecheckInnerCode(c.Request.Context(), h.db, client, id)
if err != nil {
message = "重新核对失败:" + err.Error()
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
}
func (h *Handler) innerCodeSybClient() (*syb.Client, string) {
cfg, err := config.Load()
if err != nil {
return nil, "顺运宝配置不可用,没有发送远端请求。"
}
client, err := syb.New(cfg.Syb.BaseURL)
if err != nil {
return nil, "顺运宝地址配置有误,没有发送远端请求。"
}
if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil {
if errors.Is(err, service.ErrSybLoginRequired) {
return nil, "顺运宝会话已过期,请先到“顺运宝数据”页面登录。"
}
return nil, "校验顺运宝会话失败,没有发送远端请求。"
}
return client, ""
}
func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, message string) {
values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}, "message": {message}}
if strings.TrimSpace(status) != "" {
+2
View File
@@ -120,6 +120,8 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
innerCodes := pages.Group("/inner-codes")
innerCodes.POST("/import", h.InnerCodeImport)
innerCodes.POST("/match", h.InnerCodeMatch)
innerCodes.POST("/apply", h.InnerCodeApply)
innerCodes.POST("/recheck", h.InnerCodeRecheck)
// 7. 用户管理:先经过网页登录,再叠加管理员角色校验。
users := pages.Group("/users", AdminRequired())
+7
View File
@@ -78,6 +78,13 @@ func main() {
if aiInterrupted > 0 {
log.Printf("已把 %d 个上次进程遗留的 AI 匹配批次标记为中断", aiInterrupted)
}
innerCodeInterrupted, err := service.InterruptApplyingInnerCodes(db, time.Now())
if err != nil {
log.Fatalf("恢复中断的档口入库码回写失败: %v", err)
}
if innerCodeInterrupted > 0 {
log.Printf("已把 %d 条上次进程遗留的档口入库码回写标记为需核对", innerCodeInterrupted)
}
log.Printf("数据库已就绪")
// 3. Web 引擎
+96
View File
@@ -274,6 +274,102 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er
return row, nil
}
// ClaimInnerCodeForApply 原子领取一条 ready 记录。返回 claimed=false 表示状态已变化。
func ClaimInnerCodeForApply(db *sql.DB, id int64, actorUserID, now string) (*model.InnerCodeRecord, bool, error) {
tx, err := db.Begin()
if err != nil {
return nil, false, fmt.Errorf("开始领取档口入库码事务失败: %w", err)
}
defer tx.Rollback()
record, err := scanInnerCodeRecord(tx.QueryRow(`SELECT `+innerCodeListColumns+
` FROM syb_inner_code_records WHERE id=? FOR UPDATE`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
if record.Status != model.InnerCodeReady {
return &record, false, nil
}
result, err := tx.Exec(`UPDATE syb_inner_code_records
SET status='applying',applied_by_user_id=?,apply_started_at=?,updated_at=?
WHERE id=? AND status='ready'`, actorUserID, now, now, id)
if err != nil {
return nil, false, fmt.Errorf("领取档口入库码记录失败: %w", err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return &record, false, nil
}
if err := tx.Commit(); err != nil {
return nil, false, fmt.Errorf("提交档口入库码领取失败: %w", err)
}
record.Status = model.InnerCodeApplying
record.AppliedByUserID = actorUserID
record.ApplyStartedAt = now
record.UpdatedAt = now
return &record, true, nil
}
// FinishInnerCodeApply 保存一条已领取记录的最终结果。
func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, finishedAt string) error {
result, err := q.Exec(`UPDATE syb_inner_code_records
SET status=?,result_message=?,remote_inner_code=?,
applied_at=CASE WHEN ? IN ('updated','already_filled') THEN ? ELSE applied_at END,
updated_at=?
WHERE id=? AND status='applying'`, status, message, nullableString(remoteCode), status, finishedAt, finishedAt, id)
if err != nil {
return fmt.Errorf("保存档口入库码回写结果失败: %w", err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return fmt.Errorf("档口入库码记录 %d 已不在回写中,拒绝覆盖结果", id)
}
return nil
}
// GetInnerCodeForRecheck 读取一条需核对记录。
func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) {
record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+
` FROM syb_inner_code_records WHERE id=?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &record, nil
}
// SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。
func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error {
result, err := q.Exec(`UPDATE syb_inner_code_records
SET status=?,result_message=?,remote_inner_code=?,
applied_at=CASE WHEN ?='updated' THEN ? ELSE applied_at END,updated_at=?
WHERE id=? AND status='needs_check'`, status, message, nullableString(remoteCode), status, checkedAt, checkedAt, id)
if err != nil {
return fmt.Errorf("保存档口入库码核对结果失败: %w", err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return fmt.Errorf("档口入库码记录 %d 已不需要核对", id)
}
return nil
}
// InterruptApplyingInnerCodes 在启动时把未知结果的 applying 收敛为 needs_check。
func InterruptApplyingInnerCodes(q Execer, interruptedAt string) (int, error) {
result, err := q.Exec(`UPDATE syb_inner_code_records
SET status='needs_check',result_message='Admin 在回写完成前退出,请重新核对远端结果;系统不会自动重写',
updated_at=? WHERE status='applying'`, interruptedAt)
if err != nil {
return 0, fmt.Errorf("恢复中断的档口入库码回写失败: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("读取中断档口入库码数量失败: %w", err)
}
return int(affected), nil
}
func nullablePositiveInt(value int) any {
if value <= 0 {
return nil
+40
View File
@@ -0,0 +1,40 @@
package repository
import (
"database/sql"
"testing"
_ "modernc.org/sqlite"
)
func TestInterruptApplyingInnerCodes_只收敛回写中记录(t *testing.T) {
db, err := sql.Open("sqlite", "file:inner_code_interrupt?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE syb_inner_code_records (
id INTEGER PRIMARY KEY,status TEXT NOT NULL,result_message TEXT,updated_at TEXT NOT NULL
)`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO syb_inner_code_records(id,status,updated_at) VALUES
(1,'applying','old'),(2,'ready','old')`); err != nil {
t.Fatal(err)
}
count, err := InterruptApplyingInnerCodes(db, "2026-08-15T01:00:00Z")
if err != nil || count != 1 {
t.Fatalf("count=%d err=%v", count, err)
}
var status, message, updatedAt string
if err := db.QueryRow(`SELECT status,result_message,updated_at FROM syb_inner_code_records WHERE id=1`).
Scan(&status, &message, &updatedAt); err != nil {
t.Fatal(err)
}
if status != "needs_check" || message == "" || updatedAt != "2026-08-15T01:00:00Z" {
t.Fatalf("status=%s message=%q updated=%s", status, message, updatedAt)
}
if err := db.QueryRow(`SELECT status FROM syb_inner_code_records WHERE id=2`).Scan(&status); err != nil || status != "ready" {
t.Fatalf("ready 记录不应变化 status=%s err=%v", status, err)
}
}
@@ -1083,6 +1083,40 @@ func prepareMySQLV8(t *testing.T, db *sql.DB) {
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES(8,'2026-08-11T00:00:00Z')`)
}
func TestMySQLMigrate_V22升级V23且唯一约束生效(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, `SET FOREIGN_KEY_CHECKS=0`)
mustExec(t, db, `DROP TABLE syb_inner_code_records`)
mustExec(t, db, `DELETE FROM schema_migrations WHERE version=23`)
mustExec(t, db, `SET FOREIGN_KEY_CHECKS=1`)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v22 升级 v23 失败: %v", err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v23 重复迁移失败: %v", err)
}
if err := checkMySQLV23Shape(db); err != nil {
t.Fatal(err)
}
now := "2026-08-15T00:00:00Z"
mustExec(t, db, `INSERT INTO users(user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at)
VALUES('inner-user','inner-user','hash','purchaser','active',?,?,?)`, now, now, now)
mustExec(t, db, `INSERT INTO syb_inner_code_records
(business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,status,created_by_user_id,created_at,updated_at)
VALUES('2026-08-15',2,'ORDER-1','A#1','黑色,M','黑色,M','DK-1','pending','inner-user',?,?)`, now, now)
if _, err := db.Exec(`INSERT INTO syb_inner_code_records
(business_date,source_row,order_number,stall,spec_raw,spec_key,inner_code,status,created_by_user_id,created_at,updated_at)
VALUES('2026-08-15',3,'ORDER-2','B#2','白色,L','白色,L','DK-1','pending','inner-user',?,?)`, now, now); err == nil {
t.Fatal("同一业务日期的 inner_code 唯一约束必须拒绝冲突")
}
}
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
+243
View File
@@ -0,0 +1,243 @@
package service
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/syb"
)
// InnerCodeWriter 是安全回写所需的最小顺运宝接口。
type InnerCodeWriter interface {
InnerCodeDetailReader
DeleteInnerCode(context.Context, int64) error
UpdateDetailCode(context.Context, int64, int64, string) error
}
// InnerCodeApplyResult 是一次用户确认操作的统计。
type InnerCodeApplyResult struct {
Requested int
Updated int
AlreadyFilled int
Skipped int
Failed int
NeedsCheck int
}
type innerCodeApplyOutcome struct {
Status model.InnerCodeStatus
Message string
RemoteCode string
}
// ApplyInnerCodes 逐条原子领取并回写。每条写请求只发送一次,单条失败不阻断后续行。
func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, ids []int64, actorUserID string) (*InnerCodeApplyResult, error) {
ids = uniquePositiveInnerCodeIDs(ids)
if len(ids) == 0 {
return nil, fmt.Errorf("没有选择可回写记录")
}
if len(ids) > PageSize {
return nil, fmt.Errorf("单次最多回写当前页 %d 条记录", PageSize)
}
result := &InnerCodeApplyResult{Requested: len(ids)}
for _, id := range ids {
now := model.NowISO()
record, claimed, err := repository.ClaimInnerCodeForApply(db, id, actorUserID, now)
if err != nil {
return nil, err
}
if !claimed {
result.Skipped++
continue
}
outcome := applyClaimedInnerCode(ctx, writer, *record)
finishedAt := model.NowISO()
if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil {
return nil, err
}
switch outcome.Status {
case model.InnerCodeUpdated:
result.Updated++
case model.InnerCodeAlreadyFilled:
result.AlreadyFilled++
case model.InnerCodeNeedsCheck:
result.NeedsCheck++
case model.InnerCodeFailed:
result.Failed++
default:
result.Skipped++
}
}
return result, nil
}
func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome {
item, err := readCurrentInnerCodeDetail(ctx, writer, record)
if err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入前重新读取失败,没有发送删除或写入请求:" + err.Error(), RemoteCode: record.RemoteInnerCode}
}
currentCode := innerCodeRawText(item.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(record, *item) {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: currentCode}
}
platform := innerCodeRawText(item.Raw["purchasePlatform"])
purchaseCode := innerCodeRawText(item.Raw["purchaseCode"])
if platform != "" || purchaseCode != "" {
return innerCodeApplyOutcome{Status: model.InnerCodeSkipped,
Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: currentCode}
}
if currentCode == record.InnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeAlreadyFilled,
Message: "写入前核验发现远端已是目标入库码,无需重复写入", RemoteCode: currentCode}
}
if currentCode != record.RemoteInnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "远端快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode}
}
if currentCode != "" {
if err := writer.DeleteInnerCode(ctx, record.DetailID); err != nil {
status := model.InnerCodeFailed
message := "删除旧快递单号失败,未发送新值写入请求:" + err.Error()
if errors.Is(err, syb.ErrWriteResultUnknown) {
status = model.InnerCodeNeedsCheck
message = "删除旧快递单号的结果未知,禁止自动继续写入,请重新核对"
}
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode}
}
currentCode = ""
}
if err := writer.UpdateDetailCode(ctx, record.StockID, record.DetailID, record.InnerCode); err != nil {
status := model.InnerCodeFailed
message := "写入档口入库码失败,系统不会自动重试:" + err.Error()
if errors.Is(err, syb.ErrWriteResultUnknown) {
status = model.InnerCodeNeedsCheck
message = "写入结果未知,禁止自动重试,请重新核对"
}
return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode}
}
verified, err := readCurrentInnerCodeDetail(ctx, writer, record)
if err != nil {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入请求已成功响应,但重新读取失败,请核对远端结果", RemoteCode: currentCode}
}
verifiedCode := innerCodeRawText(verified.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(record, *verified) {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入后商品规格或档口身份发生变化,请人工核对", RemoteCode: verifiedCode}
}
if verifiedCode != record.InnerCode {
return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck,
Message: "写入后远端值与目标入库码不一致,禁止自动重试,请人工核对", RemoteCode: verifiedCode}
}
return innerCodeApplyOutcome{Status: model.InnerCodeUpdated,
Message: "回写完成,远端再次读取结果一致", RemoteCode: verifiedCode}
}
func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (*syb.DetailItem, error) {
if record.StockID <= 0 || record.DetailID <= 0 {
return nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID")
}
stocks, err := reader.DetailListByStock(ctx, []int64{record.StockID})
if err != nil {
return nil, err
}
if len(stocks) != 1 || stocks[0].ID != record.StockID {
return nil, fmt.Errorf("顺运宝没有唯一返回货运单 id=%d", record.StockID)
}
var found *syb.DetailItem
for index := range stocks[0].Details {
if stocks[0].Details[index].ID != record.DetailID {
continue
}
if found != nil {
return nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", record.DetailID)
}
item := stocks[0].Details[index]
found = &item
}
if found == nil {
return nil, fmt.Errorf("顺运宝未返回商品明细 id=%d", record.DetailID)
}
return found, nil
}
// RecheckInnerCode 只重新读取一条 needs_check 记录,不发送任何写请求。
func RecheckInnerCode(ctx context.Context, db *sql.DB, reader InnerCodeDetailReader, id int64) (model.InnerCodeStatus, string, error) {
record, err := repository.GetInnerCodeForRecheck(db, id)
if err != nil {
return "", "", err
}
if record == nil {
return "", "", fmt.Errorf("档口入库码记录不存在")
}
if record.Status != model.InnerCodeNeedsCheck {
return record.Status, "当前记录不需要核对", nil
}
item, readErr := readCurrentInnerCodeDetail(ctx, reader, *record)
status := model.InnerCodeNeedsCheck
remoteCode := record.RemoteInnerCode
message := "重新读取失败,仍需人工核对:" + errorText(readErr)
if readErr == nil {
remoteCode = innerCodeRawText(item.Raw["innerExpCode"])
if !innerCodeDetailIdentityMatches(*record, *item) {
message = "重新读取到的商品身份与规划不一致;保持需核对,系统没有写入"
} else if remoteCode == record.InnerCode {
status = model.InnerCodeUpdated
message = "重新读取确认远端已是目标入库码;没有重复写入"
} else {
message = "重新读取后远端仍不是目标入库码;保持需核对,系统没有写入"
}
}
checkedAt := model.NowISO()
message = compactInnerCodeMessage(message)
if err := repository.SaveInnerCodeRecheck(db, id, status, message, remoteCode, checkedAt); err != nil {
return "", "", err
}
return status, message, nil
}
func innerCodeDetailIdentityMatches(record model.InnerCodeRecord, item syb.DetailItem) bool {
return item.ProductSpec == record.SybSpec &&
innerCodeRawText(item.Raw["sku"]) == record.SybSKU &&
innerCodeRawText(item.Raw["variationSku"]) == record.SybVariationSKU
}
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func uniquePositiveInnerCodeIDs(ids []int64) []int64 {
seen := make(map[int64]bool, len(ids))
result := make([]int64, 0, len(ids))
for _, id := range ids {
if id > 0 && !seen[id] {
seen[id] = true
result = append(result, id)
}
}
return result
}
// InterruptApplyingInnerCodes 在 Admin 启动时收敛未确认的远端写结果。
func InterruptApplyingInnerCodes(db *sql.DB, now time.Time) (int, error) {
return repository.InterruptApplyingInnerCodes(db, now.UTC().Format(model.TimeLayout))
}
func compactInnerCodeMessage(message string) string {
message = strings.TrimSpace(message)
if len([]rune(message)) <= 500 {
return message
}
return string([]rune(message)[:500])
}
+139
View File
@@ -0,0 +1,139 @@
package service
import (
"context"
"errors"
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/syb"
)
type fakeInnerCodeWriter struct {
stock syb.StockDetail
readErr error
deleteErr error
updateErr error
readCount int
deleteCount int
updateCount int
}
func (f *fakeInnerCodeWriter) DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error) {
f.readCount++
if f.readErr != nil {
return nil, f.readErr
}
return []syb.StockDetail{f.stock}, nil
}
func (f *fakeInnerCodeWriter) DeleteInnerCode(_ context.Context, detailID int64) error {
f.deleteCount++
if f.deleteErr != nil {
return f.deleteErr
}
for index := range f.stock.Details {
if f.stock.Details[index].ID == detailID {
f.stock.Details[index].Raw["innerExpCode"] = ""
}
}
return nil
}
func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID int64, code string) error {
f.updateCount++
if f.updateErr != nil {
return f.updateErr
}
for index := range f.stock.Details {
if f.stock.Details[index].ID == detailID {
f.stock.Details[index].Raw["innerExpCode"] = code
}
}
return nil
}
func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) {
record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "")
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 0 || writer.updateCount != 1 || writer.readCount != 2 {
t.Fatalf("outcome=%+v counts read=%d delete=%d update=%d", outcome, writer.readCount, writer.deleteCount, writer.updateCount)
}
}
func TestApplyClaimedInnerCode_有旧值先删后写(t *testing.T) {
record := innerCodeApplyTestRecord("OLD")
writer := innerCodeApplyTestWriter(record, "OLD")
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 1 || writer.updateCount != 1 {
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
}
}
func TestApplyClaimedInnerCode_规划后远端值变化不写入(t *testing.T) {
record := innerCodeApplyTestRecord("OLD")
writer := innerCodeApplyTestWriter(record, "OTHER")
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeNeedsCheck || writer.deleteCount != 0 || writer.updateCount != 0 {
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
}
}
func TestApplyClaimedInnerCode_删除明确失败不继续写(t *testing.T) {
record := innerCodeApplyTestRecord("OLD")
writer := innerCodeApplyTestWriter(record, "OLD")
writer.deleteErr = errors.New("已打单数据不能清除")
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeFailed || writer.deleteCount != 1 || writer.updateCount != 0 {
t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount)
}
}
func TestApplyClaimedInnerCode_写入未知结果不复读也不重试(t *testing.T) {
record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "")
writer.updateErr = fmtUnknownInnerCodeWriteError()
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeNeedsCheck || writer.updateCount != 1 || writer.readCount != 1 {
t.Fatalf("outcome=%+v update=%d read=%d", outcome, writer.updateCount, writer.readCount)
}
}
func TestApplyClaimedInnerCode_采购字段出现后停止(t *testing.T) {
record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details[0].Raw["purchaseCode"] = "CG-1"
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 {
t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount)
}
}
func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.SybSKU = "A#1"
writer := innerCodeApplyTestWriter(record, "")
writer.stock.Details[0].Raw["sku"] = "B#2"
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 {
t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount)
}
}
func innerCodeApplyTestRecord(oldCode string) model.InnerCodeRecord {
return model.InnerCodeRecord{ID: 1, StockID: 10, DetailID: 20, SybSpec: "黑色,M",
InnerCode: "DK-001", RemoteInnerCode: oldCode, Status: model.InnerCodeApplying}
}
func innerCodeApplyTestWriter(record model.InnerCodeRecord, currentCode string) *fakeInnerCodeWriter {
item := syb.DetailItem{ID: record.DetailID, ProductSpec: record.SybSpec, Raw: map[string]any{
"id": record.DetailID, "productSpec": record.SybSpec, "innerExpCode": currentCode,
"purchasePlatform": "", "purchaseCode": "",
}}
return &fakeInnerCodeWriter{stock: syb.StockDetail{ID: record.StockID, Details: []syb.DetailItem{item}}}
}
func fmtUnknownInnerCodeWriteError() error {
return errors.Join(syb.ErrWriteResultUnknown, errors.New("timeout"))
}
+61 -4
View File
@@ -24,6 +24,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
)
// ErrSessionInvalid 表示服务端**明确**判定当前会话未登录或已过期
@@ -37,6 +38,15 @@ import (
// 还可能把本来有效的会话丢掉,见 §3.5 的理由和工单 #46。
var ErrSessionInvalid = errors.New("顺运宝会话未登录或已过期")
// ErrWriteResultUnknown 表示写请求可能已经到达顺运宝,但客户端无法确认结果。
// 调用方只能重新读取核对,绝不能自动重发同一个写请求。
var ErrWriteResultUnknown = errors.New("顺运宝写入结果未知")
type requestOutcomeUnknownError struct{ err error }
func (e requestOutcomeUnknownError) Error() string { return e.err.Error() }
func (e requestOutcomeUnknownError) Unwrap() error { return e.err }
// Client 是一个顺运宝 ERP 会话:验证码、登录、货运单查询共用同一个
// http.Client(同一个 Cookie Jar)。
//
@@ -177,20 +187,20 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
if err != nil {
// 网络故障(超时、连不上、DNS 失败……)——`[必须]` 不能当成"未登录",
// 见 ErrSessionInvalid 的注释和 08 §3.5。
return nil, fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)
return nil, requestOutcomeUnknownError{fmt.Errorf("请求顺运宝接口 %s 失败(网络问题,不代表未登录): %w", path, err)}
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err)
return nil, requestOutcomeUnknownError{fmt.Errorf("读取顺运宝接口 %s 响应失败: %w", path, err)}
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d: %w", path, resp.StatusCode, ErrSessionInvalid)
}
if resp.StatusCode >= http.StatusInternalServerError {
return nil, fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)
return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 返回 %d(服务端故障,不代表未登录)", path, resp.StatusCode)}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("顺运宝接口 %s 返回意外状态码 %d", path, resp.StatusCode)
@@ -198,7 +208,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)
return nil, requestOutcomeUnknownError{fmt.Errorf("顺运宝接口 %s 响应不是合法 JSON(格式错误,不代表未登录): %w", path, err)}
}
if !env.Status {
if isSessionInvalidMessage(env.Msg, env.Code) {
@@ -631,6 +641,53 @@ func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDet
return out, nil
}
// DeleteInnerCode 清除一个货运明细的 innerExpCode。
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
func (c *Client) DeleteInnerCode(ctx context.Context, detailID int64) error {
if detailID <= 0 {
return fmt.Errorf("detailId 必须是正整数")
}
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/deleteInnerCode",
url.Values{"detailId": {strconv.FormatInt(detailID, 10)}}, nil)
return classifyInnerCodeWriteError(err)
}
// UpdateDetailCode 把档口入库码写入货运明细的 innerExpCode。
// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。
func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, code string) error {
code = strings.TrimSpace(code)
if stockID <= 0 || detailID <= 0 {
return fmt.Errorf("货运单 id 和 detailId 必须是正整数")
}
if code == "" {
return fmt.Errorf("code 不能为空")
}
if utf8.RuneCountInString(code) > 128 {
return fmt.Errorf("code 不能超过 128 个字符")
}
for _, character := range code {
if character < 32 || character == 127 {
return fmt.Errorf("code 不能包含控制字符")
}
}
_, err := c.do(ctx, http.MethodGet, "/am/stock/detail/updateDetailCode", url.Values{
"t": {"0"}, "id": {strconv.FormatInt(stockID, 10)},
"detailId": {strconv.FormatInt(detailID, 10)}, "code": {code},
}, nil)
return classifyInnerCodeWriteError(err)
}
func classifyInnerCodeWriteError(err error) error {
if err == nil {
return nil
}
var unknown requestOutcomeUnknownError
if errors.As(err, &unknown) {
return fmt.Errorf("%w:%v", ErrWriteResultUnknown, err)
}
return err
}
// ---------- 类型转换:JSON 数字/字符串统一转 ----------
// toInt64 兼容 JSON 数字被 encoding/json 解成 float64、以及顺运宝个别
+79
View File
@@ -431,6 +431,85 @@ func TestClient_DetailListByStock_超过100个id报错(t *testing.T) {
}
}
func TestClient_InnerCodeWrite_参数和路径正确且只发送一次(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/am/stock/detail/deleteInnerCode":
if r.URL.Query().Get("detailId") != "22" {
t.Errorf("delete query=%v", r.URL.Query())
}
case "/am/stock/detail/updateDetailCode":
query := r.URL.Query()
if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != "DK-001" {
t.Errorf("update query=%v", query)
}
default:
t.Errorf("意外路径 %s", r.URL.Path)
}
io.WriteString(w, `{"status":true,"msg":"成功","data":null}`)
}))
defer server.Close()
client, err := New(server.URL)
if err != nil {
t.Fatal(err)
}
if err := client.DeleteInnerCode(context.Background(), 22); err != nil {
t.Fatal(err)
}
if err := client.UpdateDetailCode(context.Background(), 11, 22, " DK-001 "); err != nil {
t.Fatal(err)
}
if requests.Load() != 2 {
t.Fatalf("每个写动作只能发一次请求,实际总请求 %d", requests.Load())
}
}
func TestClient_InnerCodeWrite_未知结果与明确业务失败分开(t *testing.T) {
for _, tc := range []struct {
name string
status int
body string
wantUnknown bool
}{
{"服务端故障", http.StatusInternalServerError, `oops`, true},
{"成功响应损坏", http.StatusOK, `not-json`, true},
{"明确业务失败", http.StatusOK, `{"status":false,"msg":"已打单数据不能清除","code":1}`, false},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
io.WriteString(w, tc.body)
}))
defer server.Close()
client, _ := New(server.URL)
err := client.DeleteInnerCode(context.Background(), 22)
if err == nil || errors.Is(err, ErrWriteResultUnknown) != tc.wantUnknown {
t.Fatalf("err=%v unknown=%v want=%v", err, errors.Is(err, ErrWriteResultUnknown), tc.wantUnknown)
}
})
}
}
func TestClient_UpdateDetailCode_本地校验失败不发送请求(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
}))
defer server.Close()
client, _ := New(server.URL)
for _, code := range []string{"", "bad\ncode", strings.Repeat("长", 129)} {
if err := client.UpdateDetailCode(context.Background(), 1, 2, code); err == nil {
t.Errorf("code=%q 应被拒绝", code)
}
}
if requests.Load() != 0 {
t.Fatalf("本地校验失败不应发请求,实际 %d", requests.Load())
}
}
// ── 业务失败但不是登录问题 ──────────────────────────────
func TestClient_业务失败但不是登录问题时返回普通错误(t *testing.T) {
+17
View File
@@ -410,6 +410,23 @@ SKU(平均每商品 1.17 个),**查无此 SKU 是常态**,不是异常
`[建议]` 登录后**只调 `/am/user/get` 验证会话**,其余几个是网页自己的初始化请求,
Go 侧不用跟着调。
### 7.1 档口入库码写入(工单 #234)
档口入库码最终写入货运明细的 `innerExpCode`(页面名称“快递单号”)。两个写接口来自
现有 Python 流程和线上响应样本:
```text
GET /am/stock/detail/deleteInnerCode?detailId={detailID}
GET /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code={innerCode}
```
- 旧值为空时不调用删除;旧值非空时,只有删除明确成功才发送写入。
- 删除和写入请求都只允许发送一次,不使用自动重试。超时、5xx、响应读取失败或成功响应
无法解析时,结果可能已经在远端生效,必须标为“需核对”。
- 每条写入前重新调用 `listByStock`,核对 stock/detail、规格、采购平台、采购单号和规划时
的旧值;写入后再次读取,只有 `innerExpCode` 与目标一致才标为已回写。
- “重新核对”只调用 `listByStock`,绝不能再次调用上述两个写接口。
---
## 8. 实现时的固定约束