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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user