feat(returnmatch): record every batch-match submission and its outcomes (#338)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-24 14:22:06 +08:00
co-authored by Claude Opus 5.5
parent 20a8144be0
commit 1e944b9d2e
7 changed files with 262 additions and 0 deletions
+1
View File
@@ -81,6 +81,7 @@ func MigratedModels() []any {
&models.PDDProductReplacementWorkerLease{},
&models.ReturnMatch{},
&models.ReturnMatchLog{},
&models.ReturnMatchBatch{},
}
}
@@ -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" }
@@ -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)
}
}
+39
View File
@@ -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})
}
+3
View File
@@ -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)
+89
View File
@@ -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
@@ -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
})
}