feat(#40): add shopee product admin API
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user