feat(#40): add shopee product admin API

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
QiuSW
2026-08-19 10:12:03 +08:00
co-authored by Claude Opus 5
parent f9cb1945ab
commit 658ffd5d15
5 changed files with 1487 additions and 0 deletions
+2
View File
@@ -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)
}
+351
View File
@@ -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)
}
+26
View File
@@ -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)
}
+721
View File
@@ -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}
}
@@ -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)
}
}