From 1e944b9d2eee0622c6f39a5e1ee484dab0281fe2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 14:22:06 +0800 Subject: [PATCH] feat(returnmatch): record every batch-match submission and its outcomes (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each click of 「匹配退货」 now writes a return_match_batch row: operator, time, submitted/matched/skipped counts and the per-SYB-product result (in submission order), plus the error if the batch aborted. Recording happens after the per-row transactions commit, so a logging failure is logged server-side instead of failing an already-applied match. GET /return-matches/batches and /return-matches/batches/:batchId expose the records. Migration version 1789800800000 creates the table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/migrations/migrate.go | 1 + .../app/goauto/models/return_match_batch.go | 25 ++++++ .../goauto/returnmatch/batch_record_test.go | 80 +++++++++++++++++ server/app/goauto/returnmatch/handler.go | 39 ++++++++ server/app/goauto/returnmatch/router.go | 3 + server/app/goauto/returnmatch/service.go | 89 +++++++++++++++++++ .../1789800800000_return_match_batch.go | 25 ++++++ 7 files changed, 262 insertions(+) create mode 100644 server/app/goauto/models/return_match_batch.go create mode 100644 server/app/goauto/returnmatch/batch_record_test.go create mode 100644 server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 93cfaec..443a094 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -81,6 +81,7 @@ func MigratedModels() []any { &models.PDDProductReplacementWorkerLease{}, &models.ReturnMatch{}, &models.ReturnMatchLog{}, + &models.ReturnMatchBatch{}, } } diff --git a/server/app/goauto/models/return_match_batch.go b/server/app/goauto/models/return_match_batch.go new file mode 100644 index 0000000..e0ea492 --- /dev/null +++ b/server/app/goauto/models/return_match_batch.go @@ -0,0 +1,25 @@ +package models + +import "time" + +// ReturnMatchBatch records one click of 「匹配退货」 (#338): who submitted it, +// which SYB products were submitted and the per-product outcome, so a later +// question like "why did only N of my selection match" can be answered from +// data instead of reconstructing the page selection. ItemsJSON holds the +// []BatchMatchItem result array (sybProductId/matched/reasonCode/reason/ +// matchId) in submission order. +type ReturnMatchBatch struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Operator string `json:"operator" gorm:"size:64;not null;default:'';index"` + SubmittedCount int `json:"submittedCount" gorm:"not null;default:0"` + MatchedCount int `json:"matchedCount" gorm:"not null;default:0"` + SkippedCount int `json:"skippedCount" gorm:"not null;default:0"` + ItemsJSON string `json:"-" gorm:"type:longtext;not null"` + // Error is non-empty when the batch aborted part-way; ItemsJSON then + // holds only the rows processed before the failure. + Error string `json:"error" gorm:"size:500;not null;default:''"` + + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} + +func (ReturnMatchBatch) TableName() string { return "return_match_batch" } diff --git a/server/app/goauto/returnmatch/batch_record_test.go b/server/app/goauto/returnmatch/batch_record_test.go new file mode 100644 index 0000000..72cafc8 --- /dev/null +++ b/server/app/goauto/returnmatch/batch_record_test.go @@ -0,0 +1,80 @@ +package returnmatch + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +// #338: every 「匹配退货」 click is recorded with the submitted SYB products in +// order and each one's outcome, so "why did only some of my selection match" +// can be answered from data. +func TestBatchMatch_RecordsSubmittedProductsAndOutcomes(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + hit := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC)) + miss := seedSYB(t, db, "SYB-2", 2, "黑色", "XL", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + seedReturn(t, db, "白色,L", &deadline) + missingID := uint64(999999) + + submitted := []uint64{miss.ID, hit.ID, missingID} + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: submitted, Operator: "caigou1"}) + if err != nil { + t.Fatal(err) + } + + batches, err := s.ListBatches(context.Background(), 10, true) + if err != nil { + t.Fatal(err) + } + if len(batches) != 1 { + t.Fatalf("want exactly one batch record, got %d", len(batches)) + } + b := batches[0] + if b.Operator != "caigou1" || b.SubmittedCount != 3 || b.MatchedCount != 1 || b.SkippedCount != 2 || b.Error != "" { + t.Fatalf("unexpected batch header: %+v", b.ReturnMatchBatch) + } + if len(b.Items) != len(submitted) { + t.Fatalf("want %d items, got %+v", len(submitted), b.Items) + } + for i, id := range submitted { + if b.Items[i].SYBProductID != id { + t.Fatalf("item %d: want submitted order id %d, got %d", i, id, b.Items[i].SYBProductID) + } + } + if b.Items[0].ReasonCode != ReasonNoCandidate || !b.Items[1].Matched || b.Items[1].MatchID != resp.Items[1].MatchID || b.Items[2].ReasonCode != ReasonStageIneligible { + t.Fatalf("unexpected recorded outcomes: %+v", b.Items) + } + + got, err := s.GetBatch(context.Background(), b.ID) + if err != nil { + t.Fatal(err) + } + if len(got.Items) != 3 { + t.Fatalf("GetBatch items: %+v", got.Items) + } + + // The list without items stays small. + light, err := s.ListBatches(context.Background(), 10, false) + if err != nil || len(light) != 1 || light[0].Items != nil { + t.Fatalf("list without items: %+v err=%v", light, err) + } +} + +// An empty submission is rejected before matching and leaves no record. +func TestBatchMatch_EmptySubmissionNotRecorded(t *testing.T) { + db := testDB(t) + if _, err := NewService(db).BatchMatch(context.Background(), BatchMatchRequest{Operator: "x"}); err != nil { + t.Fatal(err) + } + var n int64 + db.Model(&models.ReturnMatchBatch{}).Count(&n) + if n != 0 { + t.Fatalf("empty submission recorded %d batches", n) + } +} diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go index 8931ccb..1968c7e 100644 --- a/server/app/goauto/returnmatch/handler.go +++ b/server/app/goauto/returnmatch/handler.go @@ -218,3 +218,42 @@ func parseUint64List(raw []string) []uint64 { } return out } + +// ListBatches returns recent 「匹配退货」 batch records (who, when, submitted +// and matched counts); pass withItems=1 to include each row's outcome. +func (h Handler) ListBatches(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + limit, _ := strconv.Atoi(c.Query("limit")) + rows, err := NewService(db).ListBatches(c.Request.Context(), limit, c.Query("withItems") == "1") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}}) +} + +// GetBatch returns one batch record with every submitted SYB product's outcome. +func (h Handler) GetBatch(c *gin.Context) { + db, ok := h.db(c) + if !ok { + return + } + id, err := strconv.ParseUint(c.Param("batchId"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "batchId 无效"}) + return + } + rec, err := NewService(db).GetBatch(c.Request.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "批量匹配记录不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": rec}) +} diff --git a/server/app/goauto/returnmatch/router.go b/server/app/goauto/returnmatch/router.go index 5f84255..899fc72 100644 --- a/server/app/goauto/returnmatch/router.go +++ b/server/app/goauto/returnmatch/router.go @@ -13,6 +13,9 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { handler := Handler{} group := engine.Group("/api/admin/v1/return-matches").Use(auth.MiddlewareFunc()) group.GET("", handler.List) + // Static batch routes are registered before /:id so they never parse as a match id. + group.GET("/batches", handler.ListBatches) + group.GET("/batches/:batchId", handler.GetBatch) group.GET("/:id", handler.Detail) group.POST("/batch-match", handler.BatchMatch) group.POST("/:id/confirm", handler.Confirm) diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index ecbc906..d12a1be 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -2,8 +2,10 @@ package returnmatch import ( "context" + "encoding/json" "errors" "fmt" + "log" "strings" "time" "unicode/utf8" @@ -70,6 +72,93 @@ const ( // ever called from the batch-match button (ticked rows) — no scheduler, no // yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务). func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) { + resp, err := s.batchMatch(ctx, req) + if len(req.SYBProductIDs) > 0 { + // The batch record is written after the per-row transactions have + // committed, so a failure here must not turn an already-applied + // match into an error response; it is logged instead. + if logErr := s.recordBatch(ctx, req, resp, err); logErr != nil { + log.Printf("return match batch record failed: operator=%s submitted=%d: %v", req.Operator, len(req.SYBProductIDs), logErr) + } + } + return resp, err +} + +// recordBatch stores one ReturnMatchBatch row describing this click of +// 「匹配退货」: submitted SYB products in order and each one's outcome. +func (s *Service) recordBatch(ctx context.Context, req BatchMatchRequest, resp BatchMatchResponse, batchErr error) error { + items := resp.Items + if items == nil { + items = []BatchMatchItem{} + } + raw, err := json.Marshal(items) + if err != nil { + return err + } + row := models.ReturnMatchBatch{ + Operator: req.Operator, + SubmittedCount: len(req.SYBProductIDs), + MatchedCount: resp.MatchedCount, + SkippedCount: resp.SkippedCount, + ItemsJSON: string(raw), + } + if batchErr != nil { + row.Error = truncateRunes(batchErr.Error(), 500) + } + return s.DB.WithContext(ctx).Create(&row).Error +} + +func truncateRunes(v string, max int) string { + r := []rune(v) + if len(r) <= max { + return v + } + return string(r[:max]) +} + +// BatchRecord is the API view of one ReturnMatchBatch row. +type BatchRecord struct { + models.ReturnMatchBatch + Items []BatchMatchItem `json:"items,omitempty"` +} + +// ListBatches returns the most recent batch records, newest first; items are +// only expanded when withItems is true to keep the list response small. +func (s *Service) ListBatches(ctx context.Context, limit int, withItems bool) ([]BatchRecord, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + var rows []models.ReturnMatchBatch + if err := s.DB.WithContext(ctx).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + out := make([]BatchRecord, 0, len(rows)) + for _, row := range rows { + rec := BatchRecord{ReturnMatchBatch: row} + if withItems { + if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil { + return nil, err + } + } + out = append(out, rec) + } + return out, nil +} + +// GetBatch returns one batch record with its items expanded. +func (s *Service) GetBatch(ctx context.Context, id uint64) (BatchRecord, error) { + var row models.ReturnMatchBatch + if err := s.DB.WithContext(ctx).First(&row, id).Error; err != nil { + return BatchRecord{}, err + } + rec := BatchRecord{ReturnMatchBatch: row} + if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil { + return BatchRecord{}, err + } + return rec, nil +} + +func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) { resp := BatchMatchResponse{Items: make([]BatchMatchItem, 0, len(req.SYBProductIDs))} if len(req.SYBProductIDs) == 0 { return resp, nil diff --git a/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go b/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go new file mode 100644 index 0000000..91f3824 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789800800000_return_match_batch.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #338: creates return_match_batch (registered in +// migrations.MigratedModels) on databases whose earlier versions are already +// recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatchBatch) +} +func migrateReturnMatchBatch(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}