From 658ffd5d158c78d1749d9da06ba984924def073f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 19 Aug 2026 10:12:03 +0800 Subject: [PATCH] feat(#40): add shopee product admin API Co-Authored-By: Claude Opus 5 --- server/app/admin/router/init_router.go | 2 + server/app/goauto/shopeeproduct/handler.go | 351 +++++++++ server/app/goauto/shopeeproduct/router.go | 26 + server/app/goauto/shopeeproduct/service.go | 721 ++++++++++++++++++ .../app/goauto/shopeeproduct/service_test.go | 387 ++++++++++ 5 files changed, 1487 insertions(+) create mode 100644 server/app/goauto/shopeeproduct/handler.go create mode 100644 server/app/goauto/shopeeproduct/router.go create mode 100644 server/app/goauto/shopeeproduct/service.go create mode 100644 server/app/goauto/shopeeproduct/service_test.go diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 9f66cab..fd43211 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -9,6 +9,7 @@ import ( goautodevice "go-admin/app/goauto/device" goautoproduct "go-admin/app/goauto/product" goautorule "go-admin/app/goauto/rule" + goautoshopeeproduct "go-admin/app/goauto/shopeeproduct" goautotask "go-admin/app/goauto/task" common "go-admin/common/middleware" ) @@ -47,4 +48,5 @@ func InitRouter() { goautotask.InitRouter(r, authMiddleware) goautoproduct.InitRouter(r, authMiddleware) goautorule.InitRouter(r, authMiddleware) + goautoshopeeproduct.InitRouter(r, authMiddleware) } diff --git a/server/app/goauto/shopeeproduct/handler.go b/server/app/goauto/shopeeproduct/handler.go new file mode 100644 index 0000000..0d9ca91 --- /dev/null +++ b/server/app/goauto/shopeeproduct/handler.go @@ -0,0 +1,351 @@ +package shopeeproduct + +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"), Status: strings.TrimSpace(c.Query("status")), + }) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) +} + +func (handler Handler) Create(c *gin.Context) { + var request CreateRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.Create(c.Request.Context(), request) + respond(c, response, err) +} + +func (handler Handler) Detail(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.Detail(c.Request.Context(), id) + respond(c, response, err) +} + +func (handler Handler) Update(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request UpdateRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.Update(c.Request.Context(), id, request) + respond(c, response, err) +} + +func (handler Handler) LinkPDD(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request LinkPDDRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.LinkPDD(c.Request.Context(), id, request) + respond(c, response, err) +} + +func (handler Handler) AddSpecValue(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request AddSpecValueRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.AddSpecValue(c.Request.Context(), id, request) + respond(c, response, err) +} + +func (handler Handler) RemoveSpecValue(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request RemoveSpecValueRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.RemoveSpecValue(c.Request.Context(), id, request) + respond(c, response, err) +} + +func (handler Handler) SetMapping(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request SetMappingRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.SetMapping(c.Request.Context(), id, request) + respond(c, response, err) +} + +func (handler Handler) ClearMapping(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request ClearMappingRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.ClearMapping(c.Request.Context(), id, request) + respond(c, response, err) +} + +func (handler Handler) ConfirmMapping(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request ConfirmMappingRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.ConfirmMapping(c.Request.Context(), id, request) + respond(c, response, err) +} + +func (handler Handler) ConfirmExactMatches(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request struct { + RequestID string `json:"requestId"` + } + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.ConfirmAllExactMatches(c.Request.Context(), id, request.RequestID) + respond(c, response, err) +} + +func (handler Handler) BatchDelete(c *gin.Context) { + var request BatchDeleteRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + operatorID := currentUserID(c) + results, err := service.BatchSoftDelete(c.Request.Context(), operatorID, request) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"results": results}}) +} + +func (handler Handler) Restore(c *gin.Context) { + id, ok := handler.pathID(c) + if !ok { + return + } + var request RestoreRequest + if err := decodeJSON(c, &request); err != nil { + writeError(c, invalidRequest("请求 JSON 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.Restore(c.Request.Context(), id, request) + respond(c, response, err) +} + +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 +} + +// currentUserID reads the authenticated admin user id for delete audit +// (deleted_by). Batch soft delete and restore are open to both admin and +// purchasing roles per #40; role membership itself is enforced by the router +// middleware, not here. +func currentUserID(c *gin.Context) uint64 { + value, exists := c.Get("userId") + if !exists { + return 0 + } + switch id := value.(type) { + case int: + return uint64(id) + case int64: + return uint64(id) + case uint64: + return id + case float64: + return uint64(id) + default: + return 0 + } +} + +func respond(c *gin.Context, response SaveResponse, err error) { + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) +} + +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, CodeValueNotManual: + status = http.StatusUnprocessableEntity + case CodeItemIDExists: + status = http.StatusConflict + case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound: + status = http.StatusNotFound + case CodePDDProductDisabled: + status = http.StatusConflict + } + response := gin.H{"code": target.Code, "message": target.Message, "retryable": target.Retryable} + if target.ExistingItemID > 0 { + response["existingProductId"] = target.ExistingItemID + } + c.JSON(status, response) +} diff --git a/server/app/goauto/shopeeproduct/router.go b/server/app/goauto/shopeeproduct/router.go new file mode 100644 index 0000000..797792a --- /dev/null +++ b/server/app/goauto/shopeeproduct/router.go @@ -0,0 +1,26 @@ +package shopeeproduct + +import ( + "go-admin/common/middleware" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { + handler := Handler{} + admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + admin.GET("", handler.List) + admin.POST("", handler.Create) + admin.POST("/batch-delete", handler.BatchDelete) + admin.GET("/:productId", handler.Detail) + admin.PATCH("/:productId", handler.Update) + admin.POST("/:productId/link-pdd", handler.LinkPDD) + admin.POST("/:productId/restore", handler.Restore) + admin.POST("/:productId/specs/values", handler.AddSpecValue) + admin.DELETE("/:productId/specs/values", handler.RemoveSpecValue) + admin.PUT("/:productId/specs/mapping", handler.SetMapping) + admin.DELETE("/:productId/specs/mapping", handler.ClearMapping) + admin.POST("/:productId/specs/mapping/confirm", handler.ConfirmMapping) + admin.POST("/:productId/specs/mapping/confirm-exact-matches", handler.ConfirmExactMatches) +} diff --git a/server/app/goauto/shopeeproduct/service.go b/server/app/goauto/shopeeproduct/service.go new file mode 100644 index 0000000..bd33872 --- /dev/null +++ b/server/app/goauto/shopeeproduct/service.go @@ -0,0 +1,721 @@ +package shopeeproduct + +import ( + "context" + "errors" + "fmt" + "strings" + + adminmodels "go-admin/app/admin/models" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + CodeInvalidRequest = "INVALID_REQUEST" + CodeItemIDExists = "SHOPEE_ITEM_ID_EXISTS" + CodeProductNotFound = "SHOPEE_PRODUCT_NOT_FOUND" + CodePDDProductNotFound = "PDD_PRODUCT_NOT_FOUND" + CodePDDProductDisabled = "PDD_PRODUCT_DISABLED" + CodeMappingNotFound = "SPEC_MAPPING_NOT_FOUND" + CodeValueNotFound = "SPEC_VALUE_NOT_FOUND" + CodeValueNotManual = "SPEC_VALUE_NOT_MANUAL" + CodeInternal = "INTERNAL_ERROR" +) + +// DefaultCurrencyConfigKey is the sys_config key holding the ISO 4217 code +// used for every Shopee product's reference price. #40: currency is a system +// default, never chosen per product, because SYB never returns a currency +// field and a Shopee shop belongs to exactly one site. +const DefaultCurrencyConfigKey = "shopee_default_currency" + +const fallbackCurrency = "TWD" + +// allowedCurrencies is the whitelist the server intersects the sys_config +// value against (#40: "服务端校验白名单 = 配置值 ∩ ISO 4217"). Kept to the +// currencies actually seen in SYB samples; extend when a new site is onboarded. +var allowedCurrencies = map[string]bool{ + "TWD": true, "MYR": true, "SGD": true, "THB": true, "PHP": true, "IDR": true, +} + +type ServiceError struct { + Code string + Message string + Retryable bool + ExistingItemID uint64 + 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} } + +// ---------------------------------------------------------------- views + +type ProductView struct { + models.ShopeeProduct + Specs []SpecDimension `json:"specs"` + Deleted bool `json:"deleted"` + SharedByPDD int64 `json:"sharedByPddCount,omitempty"` +} + +type SaveResponse struct { + Product ProductView `json:"product"` + Replayed bool `json:"replayed,omitempty"` +} + +type ListItemView struct { + ProductView +} + +type ListResponse struct { + Items []ListItemView `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// ---------------------------------------------------------------- create + +type CreateRequest struct { + RequestID string `json:"requestId"` + ShopeeItemID string `json:"shopeeItemId"` + Title string `json:"title"` + ShopName string `json:"shopName"` + PDDProductID *uint64 `json:"pddProductId"` + Specs []SpecDimension `json:"specs"` +} + +func (service *Service) Create(ctx context.Context, request CreateRequest) (SaveResponse, error) { + if err := validateCreateRequest(request); err != nil { + return SaveResponse{}, err + } + db := service.DB.WithContext(ctx) + + var replay models.ShopeeProduct + if err := db.Where("last_create_request_id = ?", request.RequestID).First(&replay).Error; err == nil { + view, viewErr := service.makeView(ctx, replay) + return SaveResponse{Product: view, Replayed: true}, viewErr + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return SaveResponse{}, internalError(err) + } + + var existing models.ShopeeProduct + if err := db.Where("shopee_item_id = ?", request.ShopeeItemID).First(&existing).Error; err == nil { + return SaveResponse{}, itemIDExists(existing.ID) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return SaveResponse{}, internalError(err) + } + + if request.PDDProductID != nil { + if err := service.validatePDDLink(ctx, *request.PDDProductID); err != nil { + return SaveResponse{}, err + } + } + + specsJSON, err := Marshal(request.Specs) + if err != nil { + return SaveResponse{}, internalError(err) + } + currency, err := service.resolveCurrency(ctx) + if err != nil { + return SaveResponse{}, err + } + + record := models.ShopeeProduct{ + ShopeeItemID: request.ShopeeItemID, Title: strings.TrimSpace(request.Title), ShopName: strings.TrimSpace(request.ShopName), + PDDProductID: request.PDDProductID, Currency: currency, SpecsJSON: specsJSON, LastCreateRequestID: &request.RequestID, + } + if err := db.Create(&record).Error; err != nil { + if findErr := db.Where("last_create_request_id = ?", request.RequestID).First(&replay).Error; findErr == nil { + view, viewErr := service.makeView(ctx, replay) + return SaveResponse{Product: view, Replayed: true}, viewErr + } + if findErr := db.Where("shopee_item_id = ?", request.ShopeeItemID).First(&existing).Error; findErr == nil { + return SaveResponse{}, itemIDExists(existing.ID) + } + return SaveResponse{}, internalError(err) + } + view, err := service.makeView(ctx, record) + return SaveResponse{Product: view}, err +} + +// ---------------------------------------------------------------- update (archive fields only) + +type UpdateRequest struct { + RequestID string `json:"requestId"` + Title string `json:"title"` + ShopName string `json:"shopName"` + ImageURL string `json:"imageUrl"` + SalePriceCent *int64 `json:"salePriceCent"` +} + +func (service *Service) Update(ctx context.Context, id uint64, request UpdateRequest) (SaveResponse, error) { + if err := validateUpdateRequest(request); err != nil { + return SaveResponse{}, err + } + db := service.DB.WithContext(ctx) + + var replay models.ShopeeProduct + if err := db.Where("id = ? AND last_update_request_id = ?", id, request.RequestID).First(&replay).Error; err == nil { + view, viewErr := service.makeView(ctx, replay) + return SaveResponse{Product: view, Replayed: true}, viewErr + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return SaveResponse{}, internalError(err) + } + + result := db.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{ + "title": strings.TrimSpace(request.Title), "shop_name": strings.TrimSpace(request.ShopName), + "image_url": strings.TrimSpace(request.ImageURL), "sale_price_cent": request.SalePriceCent, + "last_update_request_id": request.RequestID, + }) + if result.Error != nil { + return SaveResponse{}, internalError(result.Error) + } + if result.RowsAffected == 0 { + return SaveResponse{}, productNotFound() + } + return service.Detail(ctx, id) +} + +// ---------------------------------------------------------------- detail / list + +func (service *Service) Detail(ctx context.Context, id uint64) (SaveResponse, error) { + var record models.ShopeeProduct + if err := service.DB.WithContext(ctx).First(&record, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return SaveResponse{}, productNotFound() + } + return SaveResponse{}, internalError(err) + } + view, err := service.makeView(ctx, record) + return SaveResponse{Product: view}, err +} + +type ListRequest struct { + Page, PageSize int + Keyword string + // Status filters by lifecycle: "" (default) live only, "deleted" soft-deleted + // only. There is no "all" value: mixing live and deleted rows in one page + // would defeat the point of the filter (#40 prototype: 全部(不含已删除)). + Status string +} + +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.ShopeeProduct{}) + switch request.Status { + case "", "live": + // default GORM scope already excludes deleted_at IS NOT NULL rows + case "deleted": + query = query.Unscoped().Where("deleted_at IS NOT NULL") + default: + return ListResponse{}, invalidRequest("status 无效") + } + if request.Keyword = strings.TrimSpace(request.Keyword); request.Keyword != "" { + like := "%" + request.Keyword + "%" + query = query.Where("shopee_item_id LIKE ? OR title LIKE ? OR shop_name LIKE ?", like, like, like) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return ListResponse{}, internalError(err) + } + var records []models.ShopeeProduct + if err := query.Order("updated_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&records).Error; err != nil { + return ListResponse{}, internalError(err) + } + items := make([]ListItemView, 0, len(records)) + for _, record := range records { + view, err := service.makeView(ctx, record) + if err != nil { + return ListResponse{}, err + } + items = append(items, ListItemView{ProductView: view}) + } + return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil +} + +// ---------------------------------------------------------------- link PDD + +type LinkPDDRequest struct { + RequestID string `json:"requestId"` + PDDProductID uint64 `json:"pddProductId"` +} + +// LinkPDD sets or changes the linked PDD product via the search-and-select +// flow. #40 forbids a manual id text entry, and this method is the only write +// path for the association, so that rule holds regardless of which page calls +// it (create page, associate page, or the "更换" button on the mapping editor). +func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDRequest) (SaveResponse, error) { + if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil { + return SaveResponse{}, invalidRequest("requestId 必须是 UUID") + } + if request.PDDProductID == 0 { + return SaveResponse{}, invalidRequest("pddProductId 必填") + } + db := service.DB.WithContext(ctx) + if err := service.validatePDDLink(ctx, request.PDDProductID); err != nil { + return SaveResponse{}, err + } + result := db.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{ + "pdd_product_id": request.PDDProductID, "last_update_request_id": request.RequestID, + }) + if result.Error != nil { + return SaveResponse{}, internalError(result.Error) + } + if result.RowsAffected == 0 { + return SaveResponse{}, productNotFound() + } + return service.Detail(ctx, id) +} + +func (service *Service) validatePDDLink(ctx context.Context, pddProductID uint64) error { + var pddProduct models.PDDProduct + if err := service.DB.WithContext(ctx).First(&pddProduct, pddProductID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &ServiceError{Code: CodePDDProductNotFound, Message: "PDD 商品不存在"} + } + return internalError(err) + } + if pddProduct.Status == "disabled" { + return &ServiceError{Code: CodePDDProductDisabled, Message: "PDD 商品已停用,不能关联"} + } + return nil +} + +// ---------------------------------------------------------------- spec values & mapping + +type AddSpecValueRequest struct { + RequestID string `json:"requestId"` + Dimension string `json:"dimension"` + Role string `json:"role"` + Name string `json:"name"` +} + +// AddSpecValue is the "+ 添加颜色值 / 添加尺码值" action, usable from the create +// page, the detail page or the mapping editor's "就地补录" entry (#40). The +// added value always carries ValueSourceManual; a same-named import value that +// arrives later via #41 folds into it through Merge without duplicating. +func (service *Service) AddSpecValue(ctx context.Context, id uint64, request AddSpecValueRequest) (SaveResponse, error) { + name := strings.TrimSpace(request.Name) + dimensionName := strings.TrimSpace(request.Dimension) + if name == "" || dimensionName == "" { + return SaveResponse{}, invalidRequest("dimension 和 name 必填") + } + if request.Role != RoleColor && request.Role != RoleSize && request.Role != RoleOther { + return SaveResponse{}, invalidRequest("规格维度角色无效") + } + return service.mutateSpecs(ctx, id, request.RequestID, func(specs []SpecDimension) ([]SpecDimension, error) { + return Merge(specs, []SpecDimension{{Name: dimensionName, Role: request.Role, Values: []SpecValue{{Name: name, Source: ValueSourceManual}}}}), nil + }) +} + +type RemoveSpecValueRequest struct { + RequestID string `json:"requestId"` + Dimension string `json:"dimension"` + Name string `json:"name"` +} + +// RemoveSpecValue only ever removes a manually added value. #40: "导入值不可 +// 人工删除,只能清除其映射" — an import-sourced value is structurally +// protected here, not merely hidden by the UI. +func (service *Service) RemoveSpecValue(ctx context.Context, id uint64, request RemoveSpecValueRequest) (SaveResponse, error) { + return service.mutateSpecs(ctx, id, request.RequestID, func(specs []SpecDimension) ([]SpecDimension, error) { + for di, dimension := range specs { + if dimension.Name != request.Dimension { + continue + } + for vi, value := range dimension.Values { + if value.Name != request.Name { + continue + } + if value.Source != ValueSourceManual { + return nil, &ServiceError{Code: CodeValueNotManual, Message: "导入值不可人工删除,只能清除映射"} + } + specs[di].Values = append(dimension.Values[:vi], dimension.Values[vi+1:]...) + return specs, nil + } + } + return nil, valueNotFound() + }) +} + +type SetMappingRequest struct { + RequestID string `json:"requestId"` + Dimension string `json:"dimension"` + ValueName string `json:"valueName"` + PDDValue string `json:"pddValue"` + Source string `json:"source"` + Confidence *float64 `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// SetMapping records a mapping for one spec value. An exact-name match or an +// AI suggestion is always written as pending regardless of what the caller +// sends; only a manual mapping may start confirmed (#40, #46: AI 匹配必须人工 +// 确认后生效,名称相同不代表实物尺寸相同). +func (service *Service) SetMapping(ctx context.Context, id uint64, request SetMappingRequest) (SaveResponse, error) { + pddValue := strings.TrimSpace(request.PDDValue) + if pddValue == "" { + return SaveResponse{}, invalidRequest("pddValue 必填") + } + status := MappingStatusPending + if request.Source == MappingSourceManual { + status = MappingStatusConfirmed + } + return service.mutateSpecs(ctx, id, request.RequestID, func(specs []SpecDimension) ([]SpecDimension, error) { + for di, dimension := range specs { + if dimension.Name != request.Dimension { + continue + } + for vi, value := range dimension.Values { + if value.Name != request.ValueName { + continue + } + specs[di].Values[vi].Mapping = &Mapping{PDDValue: pddValue, Source: request.Source, Status: status, Confidence: request.Confidence, Reason: request.Reason} + return specs, nil + } + return nil, valueNotFound() + } + return nil, valueNotFound() + }) +} + +type ClearMappingRequest struct { + RequestID string `json:"requestId"` + Dimension string `json:"dimension"` + ValueName string `json:"valueName"` +} + +func (service *Service) ClearMapping(ctx context.Context, id uint64, request ClearMappingRequest) (SaveResponse, error) { + return service.mutateSpecs(ctx, id, request.RequestID, func(specs []SpecDimension) ([]SpecDimension, error) { + for di, dimension := range specs { + if dimension.Name != request.Dimension { + continue + } + for vi, value := range dimension.Values { + if value.Name != request.ValueName { + continue + } + specs[di].Values[vi].Mapping = nil + return specs, nil + } + return nil, valueNotFound() + } + return nil, valueNotFound() + }) +} + +type ConfirmMappingRequest struct { + RequestID string `json:"requestId"` + Dimension string `json:"dimension"` + ValueName string `json:"valueName"` +} + +// ConfirmMapping is the per-row "确认" action: it moves a pending mapping +// (exact match or AI suggestion) to confirmed without changing its source or +// its target value, so the confirmation is auditable after the fact. +func (service *Service) ConfirmMapping(ctx context.Context, id uint64, request ConfirmMappingRequest) (SaveResponse, error) { + return service.mutateSpecs(ctx, id, request.RequestID, func(specs []SpecDimension) ([]SpecDimension, error) { + for di, dimension := range specs { + if dimension.Name != request.Dimension { + continue + } + for vi, value := range dimension.Values { + if value.Name != request.ValueName { + continue + } + if value.Mapping == nil { + return nil, mappingNotFound() + } + specs[di].Values[vi].Mapping.Status = MappingStatusConfirmed + return specs, nil + } + return nil, valueNotFound() + } + return nil, valueNotFound() + }) +} + +// ConfirmAllExactMatches implements the "一键确认全部精确匹配" button (#40). +// It only ever touches pending mappings whose source is exact_match; AI +// suggestions are deliberately excluded and must be confirmed one at a time. +func (service *Service) ConfirmAllExactMatches(ctx context.Context, id uint64, requestID string) (SaveResponse, error) { + return service.mutateSpecs(ctx, id, requestID, func(specs []SpecDimension) ([]SpecDimension, error) { + for di, dimension := range specs { + for vi, value := range dimension.Values { + mapping := value.Mapping + if mapping != nil && mapping.Source == MappingSourceExactMatch && mapping.Status == MappingStatusPending { + specs[di].Values[vi].Mapping.Status = MappingStatusConfirmed + } + } + } + return specs, nil + }) +} + +// mutateSpecs centralises the read-modify-validate-write cycle every spec +// mutation needs, including request-id idempotency so a retried click cannot +// double-apply an edit. +func (service *Service) mutateSpecs(ctx context.Context, id uint64, requestID string, mutate func([]SpecDimension) ([]SpecDimension, error)) (SaveResponse, error) { + if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil { + return SaveResponse{}, invalidRequest("requestId 必须是 UUID") + } + db := service.DB.WithContext(ctx) + + var replay models.ShopeeProduct + if err := db.Where("id = ? AND last_update_request_id = ?", id, requestID).First(&replay).Error; err == nil { + view, viewErr := service.makeView(ctx, replay) + return SaveResponse{Product: view, Replayed: true}, viewErr + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return SaveResponse{}, internalError(err) + } + + var record models.ShopeeProduct + if err := db.First(&record, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return SaveResponse{}, productNotFound() + } + return SaveResponse{}, internalError(err) + } + specs, err := Unmarshal(record.SpecsJSON) + if err != nil { + return SaveResponse{}, internalError(err) + } + updated, err := mutate(specs) + if err != nil { + return SaveResponse{}, err + } + if err := Validate(updated); err != nil { + return SaveResponse{}, invalidRequest(err.Error()) + } + specsJSON, err := Marshal(updated) + if err != nil { + return SaveResponse{}, internalError(err) + } + result := db.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{ + "specs_json": specsJSON, "last_update_request_id": requestID, + }) + if result.Error != nil { + return SaveResponse{}, internalError(result.Error) + } + return service.Detail(ctx, id) +} + +// ---------------------------------------------------------------- batch soft delete / restore + +type BatchDeleteRequest struct { + RequestID string `json:"requestId"` + IDs []uint64 `json:"ids"` +} + +type BatchItemResult struct { + ID uint64 `json:"id"` + Status string `json:"status"` // deleted | skipped + Reason string `json:"reason,omitempty"` +} + +// BatchSoftDelete checks references before deleting each row and never rolls +// back the whole batch for one bad item (#40: 批量软删除支持部分成功并逐条反 +// 馈). Reference tables from #41 (syb_product) and #33/#34 (purchase_task) may +// not exist yet on an older schema; referencedBy tolerates that by checking +// table existence first, so this method keeps working before and after those +// migrations land. +func (service *Service) BatchSoftDelete(ctx context.Context, deletedBy uint64, request BatchDeleteRequest) ([]BatchItemResult, error) { + if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil { + return nil, invalidRequest("requestId 必须是 UUID") + } + if len(request.IDs) == 0 || len(request.IDs) > 500 { + return nil, invalidRequest("ids 必须包含 1 至 500 个元素") + } + db := service.DB.WithContext(ctx) + results := make([]BatchItemResult, 0, len(request.IDs)) + for _, id := range request.IDs { + var record models.ShopeeProduct + if err := db.First(&record, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + results = append(results, BatchItemResult{ID: id, Status: "skipped", Reason: "商品不存在或已删除"}) + continue + } + return nil, internalError(err) + } + if reason, err := referencedBy(db, id); err != nil { + return nil, internalError(err) + } else if reason != "" { + results = append(results, BatchItemResult{ID: id, Status: "skipped", Reason: reason}) + continue + } + affected, err := SoftDelete(db, id, deletedBy) + if err != nil { + return nil, internalError(err) + } + if affected == 0 { + results = append(results, BatchItemResult{ID: id, Status: "skipped", Reason: "商品不存在或已删除"}) + continue + } + results = append(results, BatchItemResult{ID: id, Status: "deleted"}) + } + return results, nil +} + +// referencedBy blocks deletion when a live syb_product or purchase_task points +// at this Shopee product. Both tables belong to other units (#41, #33/#34) and +// may not exist yet, so their absence is not an error — it just means nothing +// references this product yet. +func referencedBy(db *gorm.DB, id uint64) (string, error) { + if db.Migrator().HasTable("syb_product") { + var count int64 + if err := db.Table("syb_product").Where("shopee_product_id = ? AND deleted_at IS NULL", id).Count(&count).Error; err != nil { + return "", err + } + if count > 0 { + return fmt.Sprintf("被 %d 条 SYB 货运单明细引用", count), nil + } + } + if db.Migrator().HasTable("purchase_task") { + var count int64 + if err := db.Table("purchase_task").Where("shopee_product_id = ?", id).Count(&count).Error; err != nil { + return "", err + } + if count > 0 { + return fmt.Sprintf("被 %d 条采购任务引用", count), nil + } + } + return "", nil +} + +type RestoreRequest struct { + RequestID string `json:"requestId"` +} + +// Restore is the manual "恢复" action from the deleted filter (#40); #41's +// re-import revival calls shopeeproduct.Revive directly rather than through +// this HTTP-facing method, since the import runs in its own request. +func (service *Service) Restore(ctx context.Context, id uint64, request RestoreRequest) (SaveResponse, error) { + if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil { + return SaveResponse{}, invalidRequest("requestId 必须是 UUID") + } + db := service.DB.WithContext(ctx) + affected, err := Revive(db, id) + if err != nil { + return SaveResponse{}, internalError(err) + } + if affected == 0 { + return SaveResponse{}, productNotFound() + } + return service.Detail(ctx, id) +} + +// ---------------------------------------------------------------- currency + +// resolveCurrency reads the system default from sys_config and intersects it +// with the ISO 4217 whitelist. #40: SYB never returns a currency field, so +// per-product selection has nothing real to validate against; a bad config +// value falls back to fallbackCurrency rather than corrupting new records. +func (service *Service) resolveCurrency(ctx context.Context) (string, error) { + db := service.DB.WithContext(ctx) + // sys_config belongs to the go-admin core schema, not the goauto module + // migration; it may be absent in a minimal test database. Its absence is + // not an error, just "no override configured". + if !db.Migrator().HasTable(&adminmodels.SysConfig{}) { + return fallbackCurrency, nil + } + var config adminmodels.SysConfig + err := db.Where("config_key = ?", DefaultCurrencyConfigKey).First(&config).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", internalError(err) + } + value := strings.ToUpper(strings.TrimSpace(config.ConfigValue)) + if allowedCurrencies[value] { + return value, nil + } + return fallbackCurrency, nil +} + +// ---------------------------------------------------------------- helpers + +func (service *Service) makeView(ctx context.Context, record models.ShopeeProduct) (ProductView, error) { + specs, err := Unmarshal(record.SpecsJSON) + if err != nil { + return ProductView{}, internalError(fmt.Errorf("invalid specs_json for shopee product %d: %w", record.ID, err)) + } + view := ProductView{ShopeeProduct: record, Specs: specs, Deleted: record.DeletedAt.Valid} + if record.PDDProductID != nil { + var shared int64 + if err := service.DB.WithContext(ctx).Model(&models.ShopeeProduct{}).Where("pdd_product_id = ?", *record.PDDProductID).Count(&shared).Error; err != nil { + return ProductView{}, internalError(err) + } + view.SharedByPDD = shared + } + return view, nil +} + +func validateCreateRequest(request CreateRequest) error { + if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil { + return invalidRequest("requestId 必须是 UUID") + } + itemID := strings.TrimSpace(request.ShopeeItemID) + if itemID == "" || len([]rune(itemID)) > 64 { + return invalidRequest("shopeeItemId 必填且长度不能超过 64") + } + if len([]rune(strings.TrimSpace(request.Title))) > 500 || len([]rune(strings.TrimSpace(request.ShopName))) > 255 { + return invalidRequest("标题或店铺名称过长") + } + if err := Validate(request.Specs); err != nil { + return invalidRequest(err.Error()) + } + return nil +} + +func validateUpdateRequest(request UpdateRequest) error { + if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil { + return invalidRequest("requestId 必须是 UUID") + } + if len([]rune(strings.TrimSpace(request.Title))) > 500 || len([]rune(strings.TrimSpace(request.ShopName))) > 255 { + return invalidRequest("标题或店铺名称过长") + } + if len([]rune(strings.TrimSpace(request.ImageURL))) > 2048 { + return invalidRequest("参考图 URL 过长") + } + if request.SalePriceCent != nil && *request.SalePriceCent < 0 { + return invalidRequest("售价不能小于 0") + } + return nil +} + +func invalidRequest(message string) error { + return &ServiceError{Code: CodeInvalidRequest, Message: message} +} +func itemIDExists(id uint64) error { + return &ServiceError{Code: CodeItemIDExists, Message: "shopee_item_id 已存在", ExistingItemID: id} +} +func productNotFound() error { + return &ServiceError{Code: CodeProductNotFound, Message: "虾皮商品不存在"} +} +func mappingNotFound() error { + return &ServiceError{Code: CodeMappingNotFound, Message: "映射不存在"} +} +func valueNotFound() error { + return &ServiceError{Code: CodeValueNotFound, Message: "规格值不存在"} +} +func internalError(err error) error { + return &ServiceError{Code: CodeInternal, Message: "服务端处理失败", Retryable: true, Cause: err} +} diff --git a/server/app/goauto/shopeeproduct/service_test.go b/server/app/goauto/shopeeproduct/service_test.go new file mode 100644 index 0000000..355c063 --- /dev/null +++ b/server/app/goauto/shopeeproduct/service_test.go @@ -0,0 +1,387 @@ +package shopeeproduct + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func openTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open database: %v", err) + } + if err := migrations.Migrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func floatPtr(v float64) *float64 { return &v } + +func errCode(t *testing.T, err error) string { + t.Helper() + var target *ServiceError + if !errors.As(err, &target) { + t.Fatalf("expected ServiceError, got %v (%T)", err, err) + } + return target.Code +} + +func seedPDDProduct(t *testing.T, db *gorm.DB, status string) models.PDDProduct { + t.Helper() + product := models.PDDProduct{GoodsID: uuid.NewString()[:20], URL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", Status: status, SpecsJSON: "[]"} + if err := db.Create(&product).Error; err != nil { + t.Fatalf("seed pdd product: %v", err) + } + return product +} + +// ---------------------------------------------------------------- create + +func TestCreateRejectsDuplicateShopeeItemID(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + req := CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-100231", Title: "夏季纯棉短袖T恤"} + if _, err := service.Create(context.Background(), req); err != nil { + t.Fatalf("first create: %v", err) + } + _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-100231", Title: "重复"}) + if code := errCode(t, err); code != CodeItemIDExists { + t.Fatalf("expected %s, got %s", CodeItemIDExists, code) + } +} + +func TestCreateIsIdempotentByRequestID(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + req := CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-100231", Title: "夏季纯棉短袖T恤"} + first, err := service.Create(context.Background(), req) + if err != nil { + t.Fatalf("first create: %v", err) + } + second, err := service.Create(context.Background(), req) + if err != nil { + t.Fatalf("replay create: %v", err) + } + if !second.Replayed || second.Product.ID != first.Product.ID { + t.Fatalf("expected replayed response for same id, got %+v", second) + } +} + +func TestCreateAllowsSpecValuesWithoutPDDLink(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + req := CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-100232", Title: "宽松阔腿牛仔裤", + Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "浅蓝色", Source: ValueSourceManual}}}}, + } + response, err := service.Create(context.Background(), req) + if err != nil { + t.Fatalf("create without pdd link: %v", err) + } + if response.Product.PDDProductID != nil { + t.Fatalf("expected nil pdd link, got %v", *response.Product.PDDProductID) + } + if len(response.Product.Specs) != 1 || response.Product.Specs[0].Values[0].Name != "浅蓝色" { + t.Fatalf("spec values should be saved: %+v", response.Product.Specs) + } +} + +func TestCreateWithDisabledPDDProductFails(t *testing.T) { + db := openTestDB(t) + disabled := seedPDDProduct(t, db, "disabled") + service := NewService(db) + _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-X", PDDProductID: &disabled.ID}) + if code := errCode(t, err); code != CodePDDProductDisabled { + t.Fatalf("expected %s, got %s", CodePDDProductDisabled, code) + } +} + +// ---------------------------------------------------------------- link PDD + +func TestLinkPDDAllowsMultipleShopeeProductsSharingOnePDDProduct(t *testing.T) { + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + service := NewService(db) + a, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-A"}) + if err != nil { + t.Fatalf("create a: %v", err) + } + b, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-B"}) + if err != nil { + t.Fatalf("create b: %v", err) + } + if _, err := service.LinkPDD(context.Background(), a.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: pdd.ID}); err != nil { + t.Fatalf("link a: %v", err) + } + linked, err := service.LinkPDD(context.Background(), b.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: pdd.ID}) + if err != nil { + t.Fatalf("two shopee products should be able to share one pdd product: %v", err) + } + if linked.Product.SharedByPDD != 2 { + t.Fatalf("expected 2 shopee products sharing pdd product, got %d", linked.Product.SharedByPDD) + } +} + +func TestLinkPDDRejectsMissingPDDProduct(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-A"}) + if err != nil { + t.Fatalf("create: %v", err) + } + _, err = service.LinkPDD(context.Background(), created.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: 99999}) + if code := errCode(t, err); code != CodePDDProductNotFound { + t.Fatalf("expected %s, got %s", CodePDDProductNotFound, code) + } +} + +// ---------------------------------------------------------------- spec values + +func TestAddSpecValueThenRemoveManualValue(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-A"}) + if err != nil { + t.Fatalf("create: %v", err) + } + added, err := service.AddSpecValue(context.Background(), created.Product.ID, AddSpecValueRequest{RequestID: uuid.NewString(), Dimension: "尺码", Role: RoleSize, Name: "2XL"}) + if err != nil { + t.Fatalf("add spec value: %v", err) + } + if len(added.Product.Specs) != 1 || added.Product.Specs[0].Values[0].Source != ValueSourceManual { + t.Fatalf("expected one manual spec value, got %+v", added.Product.Specs) + } + removed, err := service.RemoveSpecValue(context.Background(), created.Product.ID, RemoveSpecValueRequest{RequestID: uuid.NewString(), Dimension: "尺码", Name: "2XL"}) + if err != nil { + t.Fatalf("remove manual spec value: %v", err) + } + if len(removed.Product.Specs[0].Values) != 0 { + t.Fatalf("expected value removed, got %+v", removed.Product.Specs) + } +} + +// #40: 导入值不可人工删除,只能清除其映射。 +func TestRemoveSpecValueRejectsImportSourcedValue(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-A", + Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "白色", Source: ValueSourceImport}}}}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + _, err = service.RemoveSpecValue(context.Background(), created.Product.ID, RemoveSpecValueRequest{RequestID: uuid.NewString(), Dimension: "颜色", Name: "白色"}) + if code := errCode(t, err); code != CodeValueNotManual { + t.Fatalf("expected %s, got %s", CodeValueNotManual, code) + } +} + +// ---------------------------------------------------------------- mapping + +// #40 + #46: 名称完全相等时自动预填映射,标记来源为「自动匹配」并置于「待确认」状态; +// 必须人工确认后才生效,不得自动生效。 +func TestSetMappingExactMatchStartsPending(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-A", + Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "L", Source: ValueSourceImport}}}}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + mapped, err := service.SetMapping(context.Background(), created.Product.ID, SetMappingRequest{ + RequestID: uuid.NewString(), Dimension: "尺码", ValueName: "L", PDDValue: "L", Source: MappingSourceExactMatch, + }) + if err != nil { + t.Fatalf("set mapping: %v", err) + } + mapping := mapped.Product.Specs[0].Values[0].Mapping + if mapping == nil || mapping.Status != MappingStatusPending { + t.Fatalf("exact match mapping must start pending, got %+v", mapping) + } +} + +func TestSetMappingAIMatchAlsoStartsPendingEvenIfClientLies(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-A", + Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "XL", Source: ValueSourceImport}}}}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + // Source is ai_match; the service must force pending regardless of what a + // (hypothetical, malicious or buggy) caller intends. + mapped, err := service.SetMapping(context.Background(), created.Product.ID, SetMappingRequest{ + RequestID: uuid.NewString(), Dimension: "尺码", ValueName: "XL", PDDValue: "XL", Source: MappingSourceAIMatch, + }) + if err != nil { + t.Fatalf("set mapping: %v", err) + } + if mapped.Product.Specs[0].Values[0].Mapping.Status != MappingStatusPending { + t.Fatal("AI match mapping must start pending") + } +} + +func TestConfirmMappingMovesToConfirmed(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-A", + Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "L", Source: ValueSourceImport}}}}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if _, err := service.SetMapping(context.Background(), created.Product.ID, SetMappingRequest{ + RequestID: uuid.NewString(), Dimension: "尺码", ValueName: "L", PDDValue: "L", Source: MappingSourceExactMatch, + }); err != nil { + t.Fatalf("set mapping: %v", err) + } + confirmed, err := service.ConfirmMapping(context.Background(), created.Product.ID, ConfirmMappingRequest{RequestID: uuid.NewString(), Dimension: "尺码", ValueName: "L"}) + if err != nil { + t.Fatalf("confirm mapping: %v", err) + } + if confirmed.Product.Specs[0].Values[0].Mapping.Status != MappingStatusConfirmed { + t.Fatal("mapping should be confirmed") + } +} + +// #40: 一键确认全部精确匹配仅对精确匹配生效,AI 建议不在此列,必须逐条确认。 +func TestConfirmAllExactMatchesSkipsAISuggestions(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ShopeeItemID: "SP-A", + Specs: []SpecDimension{ + {Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "M", Source: ValueSourceImport}, {Name: "XL", Source: ValueSourceImport}}}, + }, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + requestID := uuid.NewString() + if _, err := service.SetMapping(context.Background(), created.Product.ID, SetMappingRequest{ + RequestID: requestID, Dimension: "尺码", ValueName: "M", PDDValue: "M", Source: MappingSourceExactMatch, + }); err != nil { + t.Fatalf("set exact match: %v", err) + } + if _, err := service.SetMapping(context.Background(), created.Product.ID, SetMappingRequest{ + RequestID: uuid.NewString(), Dimension: "尺码", ValueName: "XL", PDDValue: "XL", Source: MappingSourceAIMatch, Confidence: floatPtr(0.86), + }); err != nil { + t.Fatalf("set ai match: %v", err) + } + confirmed, err := service.ConfirmAllExactMatches(context.Background(), created.Product.ID, uuid.NewString()) + if err != nil { + t.Fatalf("confirm all exact matches: %v", err) + } + values := map[string]*Mapping{} + for _, value := range confirmed.Product.Specs[0].Values { + values[value.Name] = value.Mapping + } + if values["M"].Status != MappingStatusConfirmed { + t.Fatal("exact match should be confirmed by one-click action") + } + if values["XL"].Status != MappingStatusPending { + t.Fatal("AI suggestion must remain pending after one-click confirm of exact matches") + } +} + +// ---------------------------------------------------------------- batch delete / restore + +func TestBatchSoftDeletePartialSuccessWithMissingID(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-A"}) + if err != nil { + t.Fatalf("create: %v", err) + } + results, err := service.BatchSoftDelete(context.Background(), 1, BatchDeleteRequest{RequestID: uuid.NewString(), IDs: []uint64{created.Product.ID, 999999}}) + if err != nil { + t.Fatalf("batch delete: %v", err) + } + if len(results) != 2 || results[0].Status != "deleted" || results[1].Status != "skipped" { + t.Fatalf("expected partial success, got %+v", results) + } +} + +func TestBatchSoftDeleteThenRecreateSameItemIDSucceeds(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-DUP"}) + if err != nil { + t.Fatalf("create: %v", err) + } + if _, err := service.BatchSoftDelete(context.Background(), 1, BatchDeleteRequest{RequestID: uuid.NewString(), IDs: []uint64{created.Product.ID}}); err != nil { + t.Fatalf("batch delete: %v", err) + } + if _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-DUP"}); err != nil { + t.Fatalf("recreate after soft delete should succeed: %v", err) + } +} + +func TestRestoreRevivesAndListVisibility(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-A"}) + if err != nil { + t.Fatalf("create: %v", err) + } + if _, err := service.BatchSoftDelete(context.Background(), 1, BatchDeleteRequest{RequestID: uuid.NewString(), IDs: []uint64{created.Product.ID}}); err != nil { + t.Fatalf("delete: %v", err) + } + live, err := service.List(context.Background(), ListRequest{}) + if err != nil { + t.Fatalf("list live: %v", err) + } + if live.Total != 0 { + t.Fatalf("deleted product should not appear in default (live) list, got total=%d", live.Total) + } + deleted, err := service.List(context.Background(), ListRequest{Status: "deleted"}) + if err != nil { + t.Fatalf("list deleted: %v", err) + } + if deleted.Total != 1 { + t.Fatalf("expected 1 deleted product, got %d", deleted.Total) + } + if _, err := service.Restore(context.Background(), created.Product.ID, RestoreRequest{RequestID: uuid.NewString()}); err != nil { + t.Fatalf("restore: %v", err) + } + liveAfter, err := service.List(context.Background(), ListRequest{}) + if err != nil { + t.Fatalf("list live after restore: %v", err) + } + if liveAfter.Total != 1 { + t.Fatalf("restored product should reappear in live list, got total=%d", liveAfter.Total) + } +} + +// ---------------------------------------------------------------- currency + +func TestResolveCurrencyFallsBackWhenUnconfigured(t *testing.T) { + db := openTestDB(t) + service := NewService(db) + response, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-A"}) + if err != nil { + t.Fatalf("create: %v", err) + } + if response.Product.Currency != fallbackCurrency { + t.Fatalf("expected fallback currency %s, got %s", fallbackCurrency, response.Product.Currency) + } +}