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