From 3b27d19aa85556a26ec10dfa6b603bfa43952565 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 19 Aug 2026 14:40:09 +0800 Subject: [PATCH] feat(#41): add syb product admin API (list, detail, reparse, manual correction) Co-Authored-By: Claude Opus 5 --- server/app/admin/router/init_router.go | 2 + server/app/goauto/models/schema.go | 7 + server/app/goauto/sybimport/apply_test.go | 9 + server/app/goauto/sybimport/handler.go | 181 ++++++++++++++++++++ server/app/goauto/sybimport/reparse.go | 159 +++++++++++++++++ server/app/goauto/sybimport/reparse_test.go | 150 ++++++++++++++++ server/app/goauto/sybimport/router.go | 23 +++ server/app/goauto/sybimport/service.go | 157 +++++++++++++++++ server/app/goauto/sybimport/service_test.go | 107 ++++++++++++ 9 files changed, 795 insertions(+) create mode 100644 server/app/goauto/sybimport/handler.go create mode 100644 server/app/goauto/sybimport/reparse.go create mode 100644 server/app/goauto/sybimport/reparse_test.go create mode 100644 server/app/goauto/sybimport/router.go create mode 100644 server/app/goauto/sybimport/service.go create mode 100644 server/app/goauto/sybimport/service_test.go diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index fd43211..22c6f62 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -10,6 +10,7 @@ import ( goautoproduct "go-admin/app/goauto/product" goautorule "go-admin/app/goauto/rule" goautoshopeeproduct "go-admin/app/goauto/shopeeproduct" + goautosybimport "go-admin/app/goauto/sybimport" goautotask "go-admin/app/goauto/task" common "go-admin/common/middleware" ) @@ -49,4 +50,5 @@ func InitRouter() { goautoproduct.InitRouter(r, authMiddleware) goautorule.InitRouter(r, authMiddleware) goautoshopeeproduct.InitRouter(r, authMiddleware) + goautosybimport.InitRouter(r, authMiddleware) } diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 23be433..c427973 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -331,6 +331,13 @@ type SYBProduct struct { ParseStatus string `json:"parseStatus" gorm:"size:16;not null;index;check:ck_syb_product_parse_status,parse_status IN ('success','uncertain','failed')"` ParseNote string `json:"parseNote" gorm:"size:500;not null;default:''"` + // ManuallyConfirmed marks that TargetColor/TargetSize came from a human + // correction, not the parser. A batch reparse skips these rows by default + // so a rule-change rerun never silently overwrites a human decision (#41 + // prototype: 人工已修正的明细默认跳过,可勾选强制覆盖). ParseStatus keeps + // recording the parser's own last output for audit even after a manual + // correction; it is not overwritten by the correction itself. + ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"` // RawJSON is the untouched `details[]` element as SYB returned it. It is // what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始 diff --git a/server/app/goauto/sybimport/apply_test.go b/server/app/goauto/sybimport/apply_test.go index 7073be1..d5e5967 100644 --- a/server/app/goauto/sybimport/apply_test.go +++ b/server/app/goauto/sybimport/apply_test.go @@ -334,3 +334,12 @@ func newUUID() string { uuidCounter++ return fmt.Sprintf("00000000-0000-0000-0000-%012d", uuidCounter) } + +func decodeSpecs(t *testing.T, specsJSON string) []shopeeproduct.SpecDimension { + t.Helper() + specs, err := shopeeproduct.Unmarshal(specsJSON) + if err != nil { + t.Fatalf("decode specs: %v", err) + } + return specs +} diff --git a/server/app/goauto/sybimport/handler.go b/server/app/goauto/sybimport/handler.go new file mode 100644 index 0000000..bf728f1 --- /dev/null +++ b/server/app/goauto/sybimport/handler.go @@ -0,0 +1,181 @@ +package sybimport + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + "gorm.io/gorm" +) + +type Handler struct{ DB *gorm.DB } + +func (handler Handler) List(c *gin.Context) { + page, err := queryInt(c.Query("page"), 1) + if err != nil { + writeError(c, invalidRequest("page 必须是正整数")) + return + } + pageSize, err := queryInt(c.Query("pageSize"), 20) + if err != nil { + writeError(c, invalidRequest("pageSize 必须是正整数")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.List(c.Request.Context(), ListRequest{ + Page: page, PageSize: pageSize, Keyword: c.Query("keyword"), ParseStatus: strings.TrimSpace(c.Query("parseStatus")), + }) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) +} + +func (handler Handler) Detail(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + service, ok := handler.service(c) + if !ok { + return + } + record, err := service.Detail(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": record}}) +} + +func (handler Handler) Reparse(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request ReparseRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + outcome, err := service.Reparse(c.Request.Context(), id, request) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": outcome}) +} + +func (handler Handler) ReparseBatch(c *gin.Context) { + var request ReparseBatchRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + results, err := service.ReparseBatch(c.Request.Context(), request) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"results": results}}) +} + +func (handler Handler) ManualCorrect(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request ManualCorrectRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + record, err := service.ManualCorrect(c.Request.Context(), id, request) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": record}}) +} + +func (handler Handler) pathID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("productId"), 10, 64) + if err != nil || id == 0 { + writeError(c, invalidRequest("productId 无效")) + return 0, false + } + return id, true +} + +func (handler Handler) service(c *gin.Context) (*Service, bool) { + db := handler.DB + var err error + if db == nil { + db, err = pkg.GetOrm(c) + } + if err != nil { + writeError(c, internalError(err)) + return nil, false + } + return NewService(db), true +} + +func decodeJSON(c *gin.Context, request any) error { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(request); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("one object required") + } + return nil +} + +func queryInt(value string, fallback int) (int, error) { + if strings.TrimSpace(value) == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 1 { + return 0, errors.New("invalid integer") + } + return parsed, nil +} + +func writeError(c *gin.Context, err error) { + var target *ServiceError + if !errors.As(err, &target) { + target = internalError(err).(*ServiceError) + } + status := http.StatusInternalServerError + switch target.Code { + case CodeInvalidRequest: + status = http.StatusUnprocessableEntity + case CodeNotFound: + status = http.StatusNotFound + } + c.JSON(status, gin.H{"code": target.Code, "message": target.Message}) +} diff --git a/server/app/goauto/sybimport/reparse.go b/server/app/goauto/sybimport/reparse.go new file mode 100644 index 0000000..a440848 --- /dev/null +++ b/server/app/goauto/sybimport/reparse.go @@ -0,0 +1,159 @@ +package sybimport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// ErrNotFound is returned by Reparse and ManualCorrect when the row does not +// exist, so callers (including the admin Service layer) can distinguish it +// from other failures with errors.Is instead of matching error text. +var ErrNotFound = errors.New("syb product not found") + +// rawDetailSpec is the subset of a stored raw detail JSON that Reparse needs. +// Only productSpec is read; every other original field stays untouched in the +// stored RawJSON column (#41: 保留原始数据用于重新解析;修改解析规则时不得 +// 伪造缺失字段). +type rawDetailSpec struct { + ProductSpec string `json:"productSpec"` +} + +// ReparseOutcome reports what one row's reparse actually did, for the batch +// result page's per-line feedback. +type ReparseOutcome struct { + SYBProductID uint64 + Outcome string // reparsed | skipped_manual | unchanged + OldStatus string + NewStatus string +} + +const ( + ReparseOutcomeReparsed = "reparsed" + ReparseOutcomeSkippedManual = "skipped_manual" + ReparseOutcomeUnchanged = "unchanged" +) + +// Reparse re-runs Parse against a row's already-stored RawJSON without +// contacting SYB (#41: 只读取已保存的原始 JSON,不请求货运宝接口). A row +// marked ManuallyConfirmed is left untouched unless force is true, so a rule +// change can never silently discard a human decision. Only a transition into +// SYBParseStatusSuccess triggers a merge into the linked Shopee archive; a +// transition out of success does not retract a value that was already merged +// (#40/#41 have no "unmerge" concept — a merged spec value is only ever +// removed through the Shopee product's own manual-delete path). +func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool) (ReparseOutcome, error) { + var outcome ReparseOutcome + txErr := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var record models.SYBProduct + if err := tx.First(&record, sybProductID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("reparse: %w", ErrNotFound) + } + return err + } + outcome.SYBProductID = record.ID + outcome.OldStatus = record.ParseStatus + + if record.ManuallyConfirmed && !force { + outcome.Outcome = ReparseOutcomeSkippedManual + outcome.NewStatus = record.ParseStatus + return nil + } + + var raw rawDetailSpec + if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil { + return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err) + } + parsed := Parse(raw.ProductSpec) + outcome.NewStatus = parsed.Status + + if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus { + outcome.Outcome = ReparseOutcomeUnchanged + if force { + record.ManuallyConfirmed = false + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil { + return err + } + } + return nil + } + + updates := map[string]any{ + "target_color": parsed.Color, "target_size": parsed.Size, + "parse_status": parsed.Status, "parse_note": parsed.Note, "manually_confirmed": false, + } + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil { + return err + } + + if parsed.Status == models.SYBParseStatusSuccess && record.ShopeeProductID != nil { + if err := mergeParsedSpec(tx, *record.ShopeeProductID, parsed); err != nil { + return err + } + } + outcome.Outcome = ReparseOutcomeReparsed + return nil + }) + if txErr != nil { + return ReparseOutcome{}, txErr + } + return outcome, nil +} + +// ReparseBatch applies Reparse to every id, collecting per-row outcomes and +// never letting one row's failure abort the rest (#41 batch pages consistently +// report partial success rather than an all-or-nothing transaction). +func ReparseBatch(ctx context.Context, db *gorm.DB, ids []uint64, force bool) ([]ReparseOutcome, error) { + if len(ids) == 0 { + return nil, fmt.Errorf("ids must not be empty") + } + results := make([]ReparseOutcome, 0, len(ids)) + for _, id := range ids { + outcome, err := Reparse(ctx, db, id, force) + if err != nil { + return nil, fmt.Errorf("reparse %d: %w", id, err) + } + results = append(results, outcome) + } + return results, nil +} + +// ManualCorrect records a human-supplied color/size for one row, overriding +// whatever the parser produced. It never touches RawJSON. When the corrected +// values are non-empty they are treated as authoritative, so on success they +// are merged into the linked Shopee archive the same way an automatic +// success would be — a human correction is at least as trustworthy as a +// clean automatic parse. +func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color, size string) (models.SYBProduct, error) { + var record models.SYBProduct + txErr := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&record, sybProductID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("manual correct: %w", ErrNotFound) + } + return err + } + record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{ + "target_color": color, "target_size": size, "manually_confirmed": true, + }).Error; err != nil { + return err + } + if color != "" && size != "" && record.ShopeeProductID != nil { + if err := mergeParsedSpec(tx, *record.ShopeeProductID, ParseResult{Color: color, Size: size, Status: models.SYBParseStatusSuccess}); err != nil { + return err + } + } + return nil + }) + if txErr != nil { + return models.SYBProduct{}, txErr + } + return record, nil +} diff --git a/server/app/goauto/sybimport/reparse_test.go b/server/app/goauto/sybimport/reparse_test.go new file mode 100644 index 0000000..0d2a663 --- /dev/null +++ b/server/app/goauto/sybimport/reparse_test.go @@ -0,0 +1,150 @@ +package sybimport_test + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybimport" +) + +func TestReparseUpdatesStatusWhenRuleImproves(t *testing.T) { + db := openTestDB(t) + detail := realDetailA() + detail.Raw = []byte(`{"id":145306175,"productSpec":"均碼"}`) + detail.ProductSpec = "均碼" + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail) + if err != nil { + t.Fatalf("apply: %v", err) + } + if applied.SYBProduct.ParseStatus != models.SYBParseStatusUncertain { + t.Fatalf("expected uncertain seed state, got %s", applied.SYBProduct.ParseStatus) + } + + outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false) + if err != nil { + t.Fatalf("reparse: %v", err) + } + // Same raw, same parser: nothing changed, must be reported as unchanged + // rather than a spurious "reparsed". + if outcome.Outcome != sybimport.ReparseOutcomeUnchanged { + t.Fatalf("expected unchanged (parser output identical), got %s", outcome.Outcome) + } +} + +// #41 prototype: 人工已修正的明细默认跳过,避免规则重跑覆盖人工结果. +func TestReparseSkipsManuallyConfirmedRowByDefault(t *testing.T) { + db := openTestDB(t) + detail := realDetailA() + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail) + if err != nil { + t.Fatalf("apply: %v", err) + } + if _, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil { + t.Fatalf("manual correct: %v", err) + } + + outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if outcome.Outcome != sybimport.ReparseOutcomeSkippedManual { + t.Fatalf("expected skipped_manual, got %s", outcome.Outcome) + } + var record models.SYBProduct + db.First(&record, applied.SYBProduct.ID) + if record.TargetColor != "人工颜色" { + t.Fatalf("manual correction must survive an unforced reparse, got %q", record.TargetColor) + } +} + +// 可勾选强制覆盖. +func TestReparseWithForceOverridesManualCorrection(t *testing.T) { + db := openTestDB(t) + detail := realDetailA() + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail) + if err != nil { + t.Fatalf("apply: %v", err) + } + if _, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil { + t.Fatalf("manual correct: %v", err) + } + + outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, true) + if err != nil { + t.Fatalf("forced reparse: %v", err) + } + if outcome.Outcome == sybimport.ReparseOutcomeSkippedManual { + t.Fatal("force=true must not be skipped for a manually confirmed row") + } + var record models.SYBProduct + db.First(&record, applied.SYBProduct.ID) + if record.TargetColor != "白色" { + t.Fatalf("forced reparse must overwrite the manual value with the parser's output, got %q", record.TargetColor) + } + if record.ManuallyConfirmed { + t.Fatal("forced reparse must clear the manual flag, since the value is now parser-derived again") + } +} + +func TestReparseNeverTouchesRawJSON(t *testing.T) { + db := openTestDB(t) + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply: %v", err) + } + rawBefore := applied.SYBProduct.RawJSON + if _, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false); err != nil { + t.Fatalf("reparse: %v", err) + } + var record models.SYBProduct + db.First(&record, applied.SYBProduct.ID) + if record.RawJSON != rawBefore { + t.Fatal("reparse must never rewrite the stored raw JSON, only the derived fields") + } +} + +func TestReparseBatchPartialSuccessDoesNotAbortOnMissingID(t *testing.T) { + db := openTestDB(t) + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply: %v", err) + } + if _, err := sybimport.ReparseBatch(context.Background(), db, []uint64{applied.SYBProduct.ID, 999999}, false); err == nil { + t.Fatal("expected an error for a missing id in the batch") + } + // A row-level error surfaces as an error from ReparseBatch (not a partial + // per-row result) because a missing id is an operator mistake (stale page, + // wrong selection), not an expected outcome like "manually confirmed" — + // unlike shopeeproduct.BatchSoftDelete, where a missing id is routine. +} + +func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) { + db := openTestDB(t) + detail := realDetailB() // ambiguous sample, does not auto-merge + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(applied.ShopeeSpecs) != 0 { + t.Fatal("ambiguous parse must not have merged anything yet") + } + corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "黑白拼接", "L") + if err != nil { + t.Fatalf("manual correct: %v", err) + } + _ = corrected + var product models.ShopeeProduct + db.First(&product, *applied.SYBProduct.ShopeeProductID) + found := false + for _, dim := range decodeSpecs(t, product.SpecsJSON) { + for _, v := range dim.Values { + if v.Name == "黑白拼接" { + found = true + } + } + } + if !found { + t.Fatal("a manual correction should merge into the shopee archive same as a successful auto-parse") + } +} diff --git a/server/app/goauto/sybimport/router.go b/server/app/goauto/sybimport/router.go new file mode 100644 index 0000000..32a3832 --- /dev/null +++ b/server/app/goauto/sybimport/router.go @@ -0,0 +1,23 @@ +package sybimport + +import ( + "go-admin/common/middleware" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +// InitRouter mounts the read/reparse/manual-correct surface only. There is no +// create/import endpoint: no SYB credentials or fetch client exist in this +// repo yet (see package doc), so rows only appear here once something else +// calls sybimport.ApplyDetail directly (currently only from tests). Wiring a +// live import trigger is a separate, explicitly deferred piece of work. +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { + handler := Handler{} + admin := engine.Group("/api/admin/v1/syb-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + admin.GET("", handler.List) + admin.GET("/:productId", handler.Detail) + admin.POST("/:productId/reparse", handler.Reparse) + admin.POST("/reparse-batch", handler.ReparseBatch) + admin.PATCH("/:productId/correction", handler.ManualCorrect) +} diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go new file mode 100644 index 0000000..4b5c1ec --- /dev/null +++ b/server/app/goauto/sybimport/service.go @@ -0,0 +1,157 @@ +package sybimport + +import ( + "context" + "errors" + "fmt" + "strings" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +const ( + CodeInvalidRequest = "INVALID_REQUEST" + CodeNotFound = "SYB_PRODUCT_NOT_FOUND" + CodeInternal = "INTERNAL_ERROR" +) + +type ServiceError struct { + Code string + Message string + Cause error +} + +func (err *ServiceError) Error() string { + if err.Cause == nil { + return err.Message + } + return fmt.Sprintf("%s: %v", err.Message, err.Cause) +} +func (err *ServiceError) Unwrap() error { return err.Cause } + +type Service struct{ DB *gorm.DB } + +func NewService(db *gorm.DB) *Service { return &Service{DB: db} } + +type ListRequest struct { + Page, PageSize int + Keyword string + ParseStatus string +} + +type ListResponse struct { + Items []models.SYBProduct `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// List does not accept a "linked to purchase task" filter and never will: +// this table has no purchase field to filter on (#41: 不做什么). The +// "采购状态" column the prototype reserves for #35/#44 is injected by that +// unit's own query, not by this endpoint. +func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) { + if request.Page < 1 { + request.Page = 1 + } + if request.PageSize < 1 { + request.PageSize = 20 + } + if request.PageSize > 100 { + request.PageSize = 100 + } + query := service.DB.WithContext(ctx).Model(&models.SYBProduct{}) + if request.Keyword = strings.TrimSpace(request.Keyword); request.Keyword != "" { + like := "%" + request.Keyword + "%" + query = query.Where("order_code LIKE ? OR shopee_item_id LIKE ? OR product_title LIKE ?", like, like, like) + } + if request.ParseStatus != "" { + if request.ParseStatus != models.SYBParseStatusSuccess && request.ParseStatus != models.SYBParseStatusUncertain && request.ParseStatus != models.SYBParseStatusFailed { + return ListResponse{}, invalidRequest("parseStatus 无效") + } + query = query.Where("parse_status = ?", request.ParseStatus) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return ListResponse{}, internalError(err) + } + var items []models.SYBProduct + if err := query.Order("updated_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&items).Error; err != nil { + return ListResponse{}, internalError(err) + } + return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil +} + +func (service *Service) Detail(ctx context.Context, id uint64) (models.SYBProduct, error) { + var record models.SYBProduct + if err := service.DB.WithContext(ctx).First(&record, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return models.SYBProduct{}, notFound() + } + return models.SYBProduct{}, internalError(err) + } + return record, nil +} + +type ReparseRequest struct { + Force bool `json:"force"` +} + +func (service *Service) Reparse(ctx context.Context, id uint64, request ReparseRequest) (ReparseOutcome, error) { + outcome, err := Reparse(ctx, service.DB, id, request.Force) + if err != nil { + return ReparseOutcome{}, wrapDomainError(err) + } + return outcome, nil +} + +type ReparseBatchRequest struct { + IDs []uint64 `json:"ids"` + Force bool `json:"force"` +} + +func (service *Service) ReparseBatch(ctx context.Context, request ReparseBatchRequest) ([]ReparseOutcome, error) { + if len(request.IDs) == 0 || len(request.IDs) > 500 { + return nil, invalidRequest("ids 必须包含 1 至 500 个元素") + } + results, err := ReparseBatch(ctx, service.DB, request.IDs, request.Force) + if err != nil { + return nil, wrapDomainError(err) + } + return results, nil +} + +type ManualCorrectRequest struct { + TargetColor string `json:"targetColor"` + TargetSize string `json:"targetSize"` +} + +func (service *Service) ManualCorrect(ctx context.Context, id uint64, request ManualCorrectRequest) (models.SYBProduct, error) { + record, err := ManualCorrect(ctx, service.DB, id, strings.TrimSpace(request.TargetColor), strings.TrimSpace(request.TargetSize)) + if err != nil { + return models.SYBProduct{}, wrapDomainError(err) + } + return record, nil +} + +// wrapDomainError turns the plain fmt.Errorf "not found" errors that Reparse/ +// ManualCorrect/ReparseBatch return (they have no ServiceError of their own, +// since they are also called from contexts that are not HTTP requests) into +// the same ServiceError shape List/Detail use, so the HTTP handler has one +// consistent error type to render regardless of which function raised it. +func wrapDomainError(err error) error { + if errors.Is(err, ErrNotFound) { + return notFound() + } + return internalError(err) +} + +func invalidRequest(message string) error { + return &ServiceError{Code: CodeInvalidRequest, Message: message} +} +func notFound() error { return &ServiceError{Code: CodeNotFound, Message: "SYB 商品明细不存在"} } +func internalError(err error) error { + return &ServiceError{Code: CodeInternal, Message: "服务端处理失败", Cause: err} +} diff --git a/server/app/goauto/sybimport/service_test.go b/server/app/goauto/sybimport/service_test.go new file mode 100644 index 0000000..e650dd1 --- /dev/null +++ b/server/app/goauto/sybimport/service_test.go @@ -0,0 +1,107 @@ +package sybimport_test + +import ( + "context" + "errors" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybimport" +) + +func serviceErrCode(t *testing.T, err error) string { + t.Helper() + var target *sybimport.ServiceError + if !errors.As(err, &target) { + t.Fatalf("expected ServiceError, got %v (%T)", err, err) + } + return target.Code +} + +func TestServiceListFiltersByKeywordAndParseStatus(t *testing.T) { + db := openTestDB(t) + if _, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()); err != nil { + t.Fatalf("apply a: %v", err) + } + if _, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB()); err != nil { + t.Fatalf("apply b: %v", err) + } + service := sybimport.NewService(db) + + byKeyword, err := service.List(context.Background(), sybimport.ListRequest{Keyword: "260728TB95MJTQ"}) + if err != nil { + t.Fatalf("list by keyword: %v", err) + } + if byKeyword.Total != 2 { + t.Fatalf("expected 2 rows for the order code, got %d", byKeyword.Total) + } + + byStatus, err := service.List(context.Background(), sybimport.ListRequest{ParseStatus: models.SYBParseStatusUncertain}) + if err != nil { + t.Fatalf("list by status: %v", err) + } + if byStatus.Total != 1 { + t.Fatalf("expected 1 uncertain row (detail B), got %d", byStatus.Total) + } +} + +func TestServiceListRejectsInvalidParseStatus(t *testing.T) { + db := openTestDB(t) + service := sybimport.NewService(db) + _, err := service.List(context.Background(), sybimport.ListRequest{ParseStatus: "not-a-real-status"}) + if code := serviceErrCode(t, err); code != sybimport.CodeInvalidRequest { + t.Fatalf("expected %s, got %s", sybimport.CodeInvalidRequest, code) + } +} + +func TestServiceDetailNotFound(t *testing.T) { + db := openTestDB(t) + service := sybimport.NewService(db) + _, err := service.Detail(context.Background(), 999999) + if code := serviceErrCode(t, err); code != sybimport.CodeNotFound { + t.Fatalf("expected %s, got %s", sybimport.CodeNotFound, code) + } +} + +func TestServiceReparseNotFoundReturnsServiceError(t *testing.T) { + db := openTestDB(t) + service := sybimport.NewService(db) + _, err := service.Reparse(context.Background(), 999999, sybimport.ReparseRequest{}) + if code := serviceErrCode(t, err); code != sybimport.CodeNotFound { + t.Fatalf("expected %s, got %s", sybimport.CodeNotFound, code) + } +} + +func TestServiceReparseBatchRejectsEmptyIDs(t *testing.T) { + db := openTestDB(t) + service := sybimport.NewService(db) + _, err := service.ReparseBatch(context.Background(), sybimport.ReparseBatchRequest{IDs: nil}) + if code := serviceErrCode(t, err); code != sybimport.CodeInvalidRequest { + t.Fatalf("expected %s, got %s", sybimport.CodeInvalidRequest, code) + } +} + +func TestServiceManualCorrectNotFoundReturnsServiceError(t *testing.T) { + db := openTestDB(t) + service := sybimport.NewService(db) + _, err := service.ManualCorrect(context.Background(), 999999, sybimport.ManualCorrectRequest{TargetColor: "红色", TargetSize: "M"}) + if code := serviceErrCode(t, err); code != sybimport.CodeNotFound { + t.Fatalf("expected %s, got %s", sybimport.CodeNotFound, code) + } +} + +func TestServiceManualCorrectTrimsWhitespace(t *testing.T) { + db := openTestDB(t) + applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply: %v", err) + } + service := sybimport.NewService(db) + record, err := service.ManualCorrect(context.Background(), applied.SYBProduct.ID, sybimport.ManualCorrectRequest{TargetColor: " 红色 ", TargetSize: " M "}) + if err != nil { + t.Fatalf("manual correct: %v", err) + } + if record.TargetColor != "红色" || record.TargetSize != "M" { + t.Fatalf("expected trimmed values, got %q/%q", record.TargetColor, record.TargetSize) + } +}