Add Wildberries product fetching and rate limiting functionality
This commit is contained in:
		
							
								
								
									
										63
									
								
								internal/wb/products/adapter_grpc.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								internal/wb/products/adapter_grpc.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
package products
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"github.com/samber/lo"
 | 
			
		||||
	"google.golang.org/grpc"
 | 
			
		||||
	pb "sipro-mps/api/generated/v1/wb/products"
 | 
			
		||||
	"sipro-mps/internal/marketplace"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type AdapterGRPC struct {
 | 
			
		||||
	pb.UnimplementedProductsServiceServer
 | 
			
		||||
	repo Repository
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewAdapterGRPC(repo Repository) *AdapterGRPC {
 | 
			
		||||
	return &AdapterGRPC{
 | 
			
		||||
		repo: repo,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func RegisterAdapterGRPC(server *grpc.Server, marketplacesRepository marketplace.Repository) (*Repository, error) {
 | 
			
		||||
	repo := NewAPIRepository(marketplacesRepository)
 | 
			
		||||
	adapter := NewAdapterGRPC(repo)
 | 
			
		||||
	pb.RegisterProductsServiceServer(server, adapter)
 | 
			
		||||
	return &repo, nil
 | 
			
		||||
}
 | 
			
		||||
func (a *AdapterGRPC) GetProducts(req *pb.GetProductsRequest, stream pb.ProductsService_GetProductsServer) error {
 | 
			
		||||
	ctx := stream.Context()
 | 
			
		||||
	resultChan := make(chan []pb.Product)
 | 
			
		||||
	errChan := make(chan error)
 | 
			
		||||
	go a.repo.StreamAllProductsCache(ctx, int(req.MarketplaceId), resultChan, errChan)
 | 
			
		||||
	for {
 | 
			
		||||
		select {
 | 
			
		||||
		case <-ctx.Done():
 | 
			
		||||
			fmt.Println("context done")
 | 
			
		||||
			return ctx.Err()
 | 
			
		||||
		case products, ok := <-resultChan:
 | 
			
		||||
			if !ok {
 | 
			
		||||
				fmt.Println("result channel closed")
 | 
			
		||||
				return nil
 | 
			
		||||
			}
 | 
			
		||||
			resp := &pb.GetProductsResponse{
 | 
			
		||||
				Products: lo.Map(products, func(p pb.Product, _ int) *pb.Product {
 | 
			
		||||
					return &p
 | 
			
		||||
				}),
 | 
			
		||||
			}
 | 
			
		||||
			if err := stream.Send(resp); err != nil {
 | 
			
		||||
				fmt.Println("error sending response", err)
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
		case err, ok := <-errChan:
 | 
			
		||||
			if !ok {
 | 
			
		||||
				return nil
 | 
			
		||||
			}
 | 
			
		||||
			if ok && err != nil {
 | 
			
		||||
				fmt.Println("error in channel", err)
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								internal/wb/products/entities.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								internal/wb/products/entities.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
package products
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	pb "sipro-mps/api/generated/v1/wb/products"
 | 
			
		||||
	"sipro-mps/pkg/api/wb/client"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type WbProduct = api.ContentV2GetCardsListPostOKCardsItem
 | 
			
		||||
type PbProduct = pb.Product
 | 
			
		||||
							
								
								
									
										28
									
								
								internal/wb/products/mapping/converter.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								internal/wb/products/mapping/converter.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
package mapping
 | 
			
		||||
 | 
			
		||||
import wbclient "sipro-mps/pkg/api/wb/client"
 | 
			
		||||
 | 
			
		||||
// import (
 | 
			
		||||
//
 | 
			
		||||
//	proto "sipro-mps/api/generated/v1/wb/products"
 | 
			
		||||
//	internal "sipro-mps/internal/wb/products"
 | 
			
		||||
//	wbclient "sipro-mps/pkg/api/wb/client"
 | 
			
		||||
//
 | 
			
		||||
// )
 | 
			
		||||
//
 | 
			
		||||
// //go:generate go run github.com/jmattheis/goverter/cmd/goverter gen  -global "ignoreUnexported yes" .
 | 
			
		||||
//
 | 
			
		||||
// // goverter:converter
 | 
			
		||||
// // goverter:extend  OptIntToInt64 OptStringToString
 | 
			
		||||
//
 | 
			
		||||
//	type Converter interface {
 | 
			
		||||
//		// goverter:ignore state sizeCache unknownFields
 | 
			
		||||
//
 | 
			
		||||
//		ToProto(details *internal.WbProduct) *proto.Product
 | 
			
		||||
//	}
 | 
			
		||||
func OptIntToInt64(i wbclient.OptInt) int64 {
 | 
			
		||||
	return int64(i.Value)
 | 
			
		||||
}
 | 
			
		||||
func OptStringToString(s wbclient.OptString) string {
 | 
			
		||||
	return s.Value
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								internal/wb/products/mapping/generated/generated.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								internal/wb/products/mapping/generated/generated.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
// Code generated by github.com/jmattheis/goverter, DO NOT EDIT.
 | 
			
		||||
//go:build !goverter
 | 
			
		||||
 | 
			
		||||
package generated
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	products "sipro-mps/api/generated/v1/wb/products"
 | 
			
		||||
	mapping "sipro-mps/internal/wb/products/mapping"
 | 
			
		||||
	client "sipro-mps/pkg/api/wb/client"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type ConverterImpl struct{}
 | 
			
		||||
 | 
			
		||||
func (c *ConverterImpl) ToProto(source *client.ContentV2GetCardsListPostOKCardsItem) *products.Product {
 | 
			
		||||
	var pProductsProduct *products.Product
 | 
			
		||||
	if source != nil {
 | 
			
		||||
		var productsProduct products.Product
 | 
			
		||||
		productsProduct.NmID = mapping.OptIntToInt64((*source).NmID)
 | 
			
		||||
		productsProduct.SubjectID = mapping.OptIntToInt64((*source).SubjectID)
 | 
			
		||||
		productsProduct.VendorCode = mapping.OptStringToString((*source).VendorCode)
 | 
			
		||||
		if (*source).Sizes != nil {
 | 
			
		||||
			productsProduct.Sizes = make([]*products.Product_Size, len((*source).Sizes))
 | 
			
		||||
			for i := 0; i < len((*source).Sizes); i++ {
 | 
			
		||||
				productsProduct.Sizes[i] = c.apiContentV2GetCardsListPostOKCardsItemSizesItemToPProductsProduct_Size((*source).Sizes[i])
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		pProductsProduct = &productsProduct
 | 
			
		||||
	}
 | 
			
		||||
	return pProductsProduct
 | 
			
		||||
}
 | 
			
		||||
func (c *ConverterImpl) apiContentV2GetCardsListPostOKCardsItemSizesItemToPProductsProduct_Size(source client.ContentV2GetCardsListPostOKCardsItemSizesItem) *products.Product_Size {
 | 
			
		||||
	var productsProduct_Size products.Product_Size
 | 
			
		||||
	if source.Skus != nil {
 | 
			
		||||
		productsProduct_Size.Skus = make([]string, len(source.Skus))
 | 
			
		||||
		for i := 0; i < len(source.Skus); i++ {
 | 
			
		||||
			productsProduct_Size.Skus[i] = source.Skus[i]
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return &productsProduct_Size
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										13
									
								
								internal/wb/products/repository.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								internal/wb/products/repository.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
			
		||||
package products
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"sipro-mps/internal/marketplace"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Repository interface {
 | 
			
		||||
	GetAllProducts(ctx context.Context, marketplaceId int) ([]WbProduct, error)
 | 
			
		||||
	StreamAllProducts(ctx context.Context, marketplaceId int, resultChan chan<- []WbProduct, errChan chan<- error)
 | 
			
		||||
	StreamAllProductsCache(ctx context.Context, marketplaceId int, resultChan chan<- []PbProduct, errChan chan<- error)
 | 
			
		||||
	ParseMarketplace(ctx context.Context, marketplaceId int) (*marketplace.Marketplace, string, error)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										224
									
								
								internal/wb/products/repository_api.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								internal/wb/products/repository_api.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,224 @@
 | 
			
		||||
package products
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"github.com/go-faster/errors"
 | 
			
		||||
	"github.com/redis/rueidis"
 | 
			
		||||
	"github.com/samber/lo"
 | 
			
		||||
	pb "sipro-mps/api/generated/v1/wb/products"
 | 
			
		||||
	"sipro-mps/internal/marketplace"
 | 
			
		||||
	"sipro-mps/internal/redis"
 | 
			
		||||
	"sipro-mps/internal/tasks/client"
 | 
			
		||||
	"sipro-mps/internal/tasks/types"
 | 
			
		||||
	"sipro-mps/internal/wb"
 | 
			
		||||
	"sipro-mps/internal/wb/products/mapping/generated"
 | 
			
		||||
	wbapi "sipro-mps/pkg/api/wb/client"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	maxRetries            = 5
 | 
			
		||||
	maxProductsPerRequest = 100
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type apiRepository struct {
 | 
			
		||||
	marketplaceRepository marketplace.Repository
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a apiRepository) ParseMarketplace(ctx context.Context, marketplaceId int) (*marketplace.Marketplace, string, error) {
 | 
			
		||||
	marketplaceByID, err := a.marketplaceRepository.GetMarketplaceByID(ctx, marketplaceId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, "", err
 | 
			
		||||
	}
 | 
			
		||||
	_, claims, err := wb.DecodeWildberriesJwt(marketplaceByID.AuthDataJson)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, "", err
 | 
			
		||||
	}
 | 
			
		||||
	sellerId := claims["sid"].(string)
 | 
			
		||||
	return marketplaceByID, sellerId, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func fetchProducts(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	client *wbapi.Client,
 | 
			
		||||
	sellerId string,
 | 
			
		||||
	resultChan chan<- []WbProduct,
 | 
			
		||||
	errChan chan<- error,
 | 
			
		||||
) {
 | 
			
		||||
	defer close(resultChan)
 | 
			
		||||
	defer close(errChan)
 | 
			
		||||
	request := wbapi.ContentV2GetCardsListPostReq{}
 | 
			
		||||
	request.Settings.SetTo(wbapi.ContentV2GetCardsListPostReqSettings{})
 | 
			
		||||
 | 
			
		||||
	request.Settings.Value.Cursor.SetTo(wbapi.ContentV2GetCardsListPostReqSettingsCursor{})
 | 
			
		||||
	request.Settings.Value.Cursor.Value.Limit.SetTo(maxProductsPerRequest)
 | 
			
		||||
 | 
			
		||||
	request.Settings.Value.Filter.SetTo(wbapi.ContentV2GetCardsListPostReqSettingsFilter{})
 | 
			
		||||
	request.Settings.Value.Filter.Value.WithPhoto.SetTo(-1)
 | 
			
		||||
	currentRetry := 0
 | 
			
		||||
	for {
 | 
			
		||||
		response, err := client.ContentV2GetCardsListPost(ctx, &request, wbapi.ContentV2GetCardsListPostParams{Locale: wbapi.NewOptString("ru")})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			currentRetry++
 | 
			
		||||
			if currentRetry >= maxRetries {
 | 
			
		||||
				errChan <- fmt.Errorf("fetching product IDs: %w", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		currentRetry = 0
 | 
			
		||||
 | 
			
		||||
		switch r := response.(type) {
 | 
			
		||||
		case *wbapi.ContentV2GetCardsListPostOKHeaders:
 | 
			
		||||
			err = wb.SyncRateLimitRemaining(ctx, sellerId, r.XRatelimitRemaining.Value)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				errChan <- fmt.Errorf("syncing rate limit: %w", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			resultChan <- r.Response.Cards
 | 
			
		||||
			if r.Response.Cursor.Value.Total.Value < maxProductsPerRequest {
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			request.Settings.Value.Cursor.Value.UpdatedAt.SetTo(r.Response.Cursor.Value.UpdatedAt.Value)
 | 
			
		||||
			request.Settings.Value.Cursor.Value.NmID.SetTo(r.Response.Cursor.Value.NmID.Value)
 | 
			
		||||
		case *wbapi.R429Headers:
 | 
			
		||||
			err = wb.SetRateLimitRetry(ctx, sellerId, r.XRatelimitRetry.Value, r.XRatelimitLimit.Value, r.XRatelimitReset.Value)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				errChan <- fmt.Errorf("setting rate limit retry: %w", err)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		default:
 | 
			
		||||
			errChan <- fmt.Errorf("unexpected response type: %T", r)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a apiRepository) StreamAllProductsCache(ctx context.Context, marketplaceId int, resultChan chan<- []pb.Product, errChan chan<- error) {
 | 
			
		||||
	defer close(resultChan)
 | 
			
		||||
	defer close(errChan)
 | 
			
		||||
	_, sellerId, err := a.ParseMarketplace(ctx, marketplaceId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		errChan <- err
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	c := *redis.Client
 | 
			
		||||
	key := fmt.Sprintf("wb:products:%s", sellerId)
 | 
			
		||||
	jsonString, err := c.Do(ctx, c.B().Get().Key(key).Build()).ToString()
 | 
			
		||||
	if err == nil && jsonString != "null" {
 | 
			
		||||
		var result []pb.Product
 | 
			
		||||
		err = json.Unmarshal([]byte(jsonString), &result)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			errChan <- fmt.Errorf("unmarshalling products from cache: %w", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		task, err := types.NewFetchProductsTask(marketplaceId)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			errChan <- fmt.Errorf("creating fetch products task: %w", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		_, err = client.Client.Enqueue(task)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			errChan <- fmt.Errorf("enqueueing fetch products task: %w", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		resultChan <- result
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if !errors.As(err, &rueidis.Nil) && err != nil {
 | 
			
		||||
		errChan <- fmt.Errorf("fetching products from cache: %w", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	converter := generated.ConverterImpl{}
 | 
			
		||||
 | 
			
		||||
	innerResultChan := make(chan []WbProduct)
 | 
			
		||||
	innerErrChan := make(chan error)
 | 
			
		||||
	go a.StreamAllProducts(ctx, marketplaceId, innerResultChan, innerErrChan)
 | 
			
		||||
	var allProducts []pb.Product
 | 
			
		||||
	defer func() {
 | 
			
		||||
		jsonData, err := json.Marshal(allProducts)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			errChan <- fmt.Errorf("marshalling products to cache: %w", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		err = c.Do(ctx, c.B().Set().Key(key).Value(string(jsonData)).Build()).Error()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			errChan <- fmt.Errorf("setting products to cache: %w", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
	for {
 | 
			
		||||
		select {
 | 
			
		||||
		case err, ok := <-innerErrChan:
 | 
			
		||||
			if !ok {
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			errChan <- fmt.Errorf("streaming products: %w", err)
 | 
			
		||||
			return
 | 
			
		||||
		case products, ok := <-innerResultChan:
 | 
			
		||||
			if !ok {
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			pbProducts := lo.Map(products, func(p WbProduct, _ int) pb.Product {
 | 
			
		||||
				return *converter.ToProto(&p)
 | 
			
		||||
			})
 | 
			
		||||
			allProducts = append(allProducts, pbProducts...)
 | 
			
		||||
			resultChan <- pbProducts
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
func (a apiRepository) GetAllProducts(ctx context.Context, marketplaceId int) ([]WbProduct, error) {
 | 
			
		||||
	marketplaceByID, sellerId, err := a.ParseMarketplace(ctx, marketplaceId)
 | 
			
		||||
	fromMarketplace, err := wb.GetClientFromMarketplace(marketplaceByID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	resultChan := make(chan []WbProduct)
 | 
			
		||||
	errChan := make(chan error)
 | 
			
		||||
	go fetchProducts(ctx, fromMarketplace, sellerId, resultChan, errChan)
 | 
			
		||||
 | 
			
		||||
	var products []WbProduct
 | 
			
		||||
	isWaiting := true
 | 
			
		||||
	for {
 | 
			
		||||
		if !isWaiting {
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		select {
 | 
			
		||||
		case err, ok := <-errChan:
 | 
			
		||||
			if !ok {
 | 
			
		||||
				isWaiting = false
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
			return nil, err
 | 
			
		||||
		case newProducts, ok := <-resultChan:
 | 
			
		||||
			if !ok {
 | 
			
		||||
				isWaiting = false
 | 
			
		||||
			}
 | 
			
		||||
			products = append(products, newProducts...)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return products, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a apiRepository) StreamAllProducts(ctx context.Context, marketplaceId int, resultChan chan<- []WbProduct, errChan chan<- error) {
 | 
			
		||||
	marketplaceByID, sellerId, err := a.ParseMarketplace(ctx, marketplaceId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		errChan <- err
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	fromMarketplace, err := wb.GetClientFromMarketplace(marketplaceByID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		errChan <- err
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	go fetchProducts(ctx, fromMarketplace, sellerId, resultChan, errChan)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewAPIRepository(marketplaceRepository marketplace.Repository) Repository {
 | 
			
		||||
	return &apiRepository{
 | 
			
		||||
		marketplaceRepository: marketplaceRepository,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user