feat(#41): add syb product admin API (list, detail, reparse, manual correction)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
QiuSW
2026-08-19 14:40:09 +08:00
co-authored by Claude Opus 5
parent d7f7420431
commit 3b27d19aa8
9 changed files with 795 additions and 0 deletions
+2
View File
@@ -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)
}
+7
View File
@@ -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: "适用于解析规则更新后批量重跑,只读取已保存的原始
@@ -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
}
+181
View File
@@ -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})
}
+159
View File
@@ -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
}
+150
View File
@@ -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")
}
}
+23
View File
@@ -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)
}
+157
View File
@@ -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}
}
+107
View File
@@ -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)
}
}