Files
cmroubao/backend-api/internal/usecase/freight_service.go
T

756 lines
21 KiB
Go

package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
)
const (
maxFreightOrdersPerSync = 100
maxFreightItemsPerOrder = 1000
maxFreightWindowDays = 7
freightWatermarkOverlap = 10 * time.Minute
)
type FreightService struct {
repository FreightRepository
source FreightSource
clock Clock
ids IDGenerator
timeout time.Duration
}
type CreateFreightSyncCommand struct {
CreatorSubject string
ActorUserID string
IdempotencyKey string
OrderNumber string
}
type CreateFreightDateSyncCommand struct {
CreatorSubject string
ActorUserID string
IdempotencyKey string
CreatedFrom string
CreatedTo string
SyncToNow bool
}
type CreateFreightSyncResult struct {
Run domain.FreightSyncRun
Replayed bool
}
func NewFreightService(
repository FreightRepository,
source FreightSource,
clock Clock,
ids IDGenerator,
timeout time.Duration,
) (*FreightService, error) {
if repository == nil || source == nil || clock == nil || ids == nil ||
timeout <= 0 {
return nil, errors.New("freight service dependencies are required")
}
return &FreightService{
repository: repository,
source: source,
clock: clock,
ids: ids,
timeout: timeout,
}, nil
}
func (service *FreightService) CreateOrderSync(
ctx context.Context,
command CreateFreightSyncCommand,
) (CreateFreightSyncResult, error) {
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
command.OrderNumber = strings.TrimSpace(command.OrderNumber)
fields := map[string]string{}
if command.CreatorSubject == "" {
fields["creator_subject"] = "is required"
}
if command.ActorUserID == "" {
fields["actor_user_id"] = "is required"
}
if command.IdempotencyKey == "" || len([]byte(command.IdempotencyKey)) > 128 {
fields["idempotency_key"] = "must contain 1 to 128 bytes"
}
if command.OrderNumber == "" ||
len([]byte(command.OrderNumber)) > 128 ||
hasControl(command.OrderNumber) {
fields["order_number"] = "must contain 1 to 128 bytes without control characters"
}
if len(fields) > 0 {
return CreateFreightSyncResult{}, invalidError(
"FREIGHT_SYNC_INVALID",
"freight sync request is invalid",
fields,
)
}
runID, err := service.ids.NewID()
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
now := service.clock.Now().UTC()
queryHash := hashJSON(struct {
Mode string `json:"mode"`
OrderNumber string `json:"order_number"`
}{domain.FreightSyncOrderNumber, command.OrderNumber})
requestHash := hashJSON(struct {
OrderNumber string `json:"order_number"`
}{command.OrderNumber})
run, created, err := service.repository.CreateFreightSync(
ctx,
domain.FreightSyncRun{
ID: runID,
CreatorSubject: command.CreatorSubject,
CreatedByUserID: command.ActorUserID,
Mode: domain.FreightSyncOrderNumber,
OrderNumber: command.OrderNumber,
QuerySHA256: queryHash,
Status: domain.FreightSyncPending,
CreatedAt: now,
},
command.IdempotencyKey,
requestHash,
)
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
if created {
go service.execute(run)
}
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
}
func (service *FreightService) CreateDateSync(
ctx context.Context,
command CreateFreightDateSyncCommand,
) (CreateFreightSyncResult, error) {
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
command.CreatedFrom = strings.TrimSpace(command.CreatedFrom)
command.CreatedTo = strings.TrimSpace(command.CreatedTo)
fields := validateFreightActor(
command.CreatorSubject,
command.ActorUserID,
command.IdempotencyKey,
)
if command.SyncToNow &&
(command.CreatedFrom != "" || command.CreatedTo != "") {
fields["sync_to_now"] = "cannot be combined with a manual range"
}
if !command.SyncToNow &&
(command.CreatedFrom == "" || command.CreatedTo == "") {
fields["created_range"] = "created_from and created_to are required"
}
if len(fields) > 0 {
return CreateFreightSyncResult{}, invalidError(
"FREIGHT_SYNC_INVALID",
"freight sync request is invalid",
fields,
)
}
now := service.clock.Now().UTC()
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
var startDate, endDate time.Time
var watermarkThrough time.Time
if command.SyncToNow {
watermark, err := service.repository.GetFreightSyncWatermark(
ctx,
command.CreatorSubject,
)
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
localNow := now.In(location)
endDate = startOfLocalDay(localNow, location)
startDate = endDate
if watermark != nil {
overlap := watermark.LastSuccessfulTo.Add(
-freightWatermarkOverlap,
).In(location)
startDate = startOfLocalDay(overlap, location)
if startDate.After(endDate) {
startDate = endDate
}
}
watermarkThrough = now
} else {
startDate, err = parseFreightDate(command.CreatedFrom, location)
if err != nil {
fields["created_from"] = "must be YYYY-MM-DD"
}
endDate, err = parseFreightDate(command.CreatedTo, location)
if err != nil {
fields["created_to"] = "must be YYYY-MM-DD"
}
if len(fields) == 0 {
if endDate.Before(startDate) {
fields["created_to"] = "must not be before created_from"
} else if daysInclusive(startDate, endDate) >
maxFreightWindowDays {
fields["created_range"] = "must not exceed 7 inclusive days"
}
if endDate.After(startOfLocalDay(now.In(location), location)) {
fields["created_to"] = "must not be in the future"
}
}
if len(fields) > 0 {
return CreateFreightSyncResult{}, invalidError(
"FREIGHT_SYNC_INVALID",
"freight sync request is invalid",
fields,
)
}
watermarkThrough = endDate.AddDate(0, 0, 1).Add(-time.Nanosecond).UTC()
if watermarkThrough.After(now) {
watermarkThrough = now
}
}
createdFrom := startDate.Format(time.DateOnly)
createdTo := endDate.Format(time.DateOnly)
runID, err := service.ids.NewID()
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
queryHash := hashJSON(struct {
Mode string `json:"mode"`
CreatedFrom string `json:"created_from"`
CreatedTo string `json:"created_to"`
}{
domain.FreightSyncCreatedRange,
createdFrom,
createdTo,
})
requestHash := hashJSON(struct {
SyncToNow bool `json:"sync_to_now"`
CreatedFrom string `json:"created_from"`
CreatedTo string `json:"created_to"`
}{
command.SyncToNow,
command.CreatedFrom,
command.CreatedTo,
})
run, created, err := service.repository.CreateFreightSync(
ctx,
domain.FreightSyncRun{
ID: runID,
CreatorSubject: command.CreatorSubject,
CreatedByUserID: command.ActorUserID,
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
WatermarkThrough: &watermarkThrough,
QuerySHA256: queryHash,
Status: domain.FreightSyncPending,
CreatedAt: now,
},
command.IdempotencyKey,
requestHash,
)
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
}
if created {
go service.execute(run)
}
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
}
func (service *FreightService) execute(run domain.FreightSyncRun) {
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
defer cancel()
now := service.clock.Now().UTC()
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
return
}
var source domain.FreightSourceBatch
var err error
if run.Mode == domain.FreightSyncCreatedRange {
source, err = service.queryCreatedRange(ctx, run)
} else {
source, err = service.source.QueryOrder(ctx, run.OrderNumber)
}
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
run.ID,
freightSourceErrorCode(err),
service.clock.Now().UTC(),
)
return
}
batch, err := service.normalize(source)
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
run.ID,
"ERP_RESPONSE_INVALID",
service.clock.Now().UTC(),
)
return
}
finishedAt := service.clock.Now().UTC()
if run.Mode == domain.FreightSyncCreatedRange &&
run.WatermarkThrough != nil {
err = service.repository.CompleteFreightDateSync(
ctx,
run,
batch,
*run.WatermarkThrough,
finishedAt,
)
} else {
err = service.repository.CompleteFreightSync(
ctx,
run,
batch,
finishedAt,
)
}
if err != nil {
_ = service.repository.FailFreightSync(
ctx,
run.ID,
"STORAGE_UNAVAILABLE",
service.clock.Now().UTC(),
)
}
}
func (service *FreightService) queryCreatedRange(
ctx context.Context,
run domain.FreightSyncRun,
) (domain.FreightSourceBatch, error) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return domain.FreightSourceBatch{}, err
}
start, err := parseFreightDate(run.CreatedFrom, location)
if err != nil {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
end, err := parseFreightDate(run.CreatedTo, location)
if err != nil || end.Before(start) {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
orders := make([]domain.FreightSourceOrder, 0)
seen := make(map[string]domain.FreightSourceOrder)
for windowStart := start; !windowStart.After(end); {
windowEnd := windowStart.AddDate(0, 0, maxFreightWindowDays-1)
if windowEnd.After(end) {
windowEnd = end
}
fromValue := windowStart.Format(time.DateOnly)
toValue := windowEnd.Format(time.DateOnly)
batch, err := service.source.QueryCreatedRange(
ctx,
fromValue,
toValue,
)
if err != nil {
return domain.FreightSourceBatch{}, err
}
if batch.SchemaVersion != 1 ||
batch.Query.Mode != domain.FreightSyncCreatedRange ||
batch.Query.CreatedFrom == nil ||
batch.Query.CreatedTo == nil ||
*batch.Query.CreatedFrom != fromValue ||
*batch.Query.CreatedTo != toValue {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
for _, order := range batch.Orders {
existing, exists := seen[order.ExternalStockID]
if exists {
if hashJSON(existing) != hashJSON(order) {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
continue
}
seen[order.ExternalStockID] = order
orders = append(orders, order)
if len(orders) > maxFreightOrdersPerSync {
return domain.FreightSourceBatch{}, domain.ErrFreightSourceProtocol
}
}
windowStart = windowEnd.AddDate(0, 0, 1)
}
createdFrom := run.CreatedFrom
createdTo := run.CreatedTo
return domain.FreightSourceBatch{
SchemaVersion: 1,
Query: domain.FreightSourceQuery{
Mode: domain.FreightSyncCreatedRange,
CreatedFrom: &createdFrom,
CreatedTo: &createdTo,
},
Orders: orders,
}, nil
}
func (service *FreightService) RecoverInterrupted(
ctx context.Context,
) (int64, error) {
count, err := service.repository.RecoverFreightSyncs(
ctx,
service.clock.Now().UTC(),
)
if err != nil {
return 0, wrapRepositoryError(err)
}
return count, nil
}
func (service *FreightService) GetSync(
ctx context.Context,
creatorSubject, syncID string,
) (domain.FreightSyncRun, error) {
run, err := service.repository.GetFreightSync(
ctx,
strings.TrimSpace(creatorSubject),
strings.TrimSpace(syncID),
)
if err != nil {
return domain.FreightSyncRun{}, wrapRepositoryError(err)
}
return run, nil
}
func (service *FreightService) GetWatermark(
ctx context.Context,
creatorSubject string,
) (*domain.FreightSyncWatermark, error) {
watermark, err := service.repository.GetFreightSyncWatermark(
ctx,
strings.TrimSpace(creatorSubject),
)
if err != nil {
return nil, wrapRepositoryError(err)
}
return watermark, nil
}
func (service *FreightService) ListOrders(
ctx context.Context,
creatorSubject string,
limit int,
) ([]domain.FreightOrder, error) {
if limit == 0 {
limit = 50
}
if limit < 1 || limit > 100 {
return nil, invalidError(
"FREIGHT_LIST_INVALID",
"freight list filter is invalid",
map[string]string{"limit": "must be between 1 and 100"},
)
}
orders, err := service.repository.ListFreightOrders(
ctx,
strings.TrimSpace(creatorSubject),
limit,
)
if err != nil {
return nil, wrapRepositoryError(err)
}
return orders, nil
}
func (service *FreightService) GetOrder(
ctx context.Context,
creatorSubject, orderID string,
) (domain.FreightOrderDetail, error) {
detail, err := service.repository.GetFreightOrder(
ctx,
strings.TrimSpace(creatorSubject),
strings.TrimSpace(orderID),
)
if err != nil {
return domain.FreightOrderDetail{}, wrapRepositoryError(err)
}
return detail, nil
}
func (service *FreightService) normalize(
source domain.FreightSourceBatch,
) (domain.FreightImportBatch, error) {
if source.SchemaVersion != 1 ||
(source.Query.Mode != domain.FreightSyncOrderNumber &&
source.Query.Mode != domain.FreightSyncCreatedRange) ||
len(source.Orders) > maxFreightOrdersPerSync {
return domain.FreightImportBatch{}, errors.New("invalid source envelope")
}
seenOrders := map[string]struct{}{}
result := domain.FreightImportBatch{
Orders: make([]domain.FreightImportOrder, 0, len(source.Orders)),
}
for _, sourceOrder := range source.Orders {
externalID, ok := validExternalID(sourceOrder.ExternalStockID)
if !ok || len(sourceOrder.Items) > maxFreightItemsPerOrder {
return domain.FreightImportBatch{}, errors.New("invalid freight order")
}
if _, exists := seenOrders[externalID]; exists {
return domain.FreightImportBatch{}, errors.New("duplicate freight order")
}
seenOrders[externalID] = struct{}{}
if !validBytes(sourceOrder.SourceCode, 256) ||
!validOptional(sourceOrder.PlatformOrderNo, 256) ||
!validOptional(sourceOrder.ShopName, 512) ||
!validOptional(sourceOrder.OrderStatus, 128) ||
!validOptional(sourceOrder.PurchaseStatus, 128) {
return domain.FreightImportBatch{}, errors.New("invalid freight fields")
}
sourceCreatedAt, err := parseERPTime(sourceOrder.SourceCreatedAt)
if err != nil {
return domain.FreightImportBatch{}, err
}
orderID, err := service.ids.NewID()
if err != nil {
return domain.FreightImportBatch{}, err
}
order := domain.FreightImportOrder{
ID: orderID,
ExternalStockID: externalID,
SourceCode: sourceOrder.SourceCode,
PlatformOrderNo: cleanOptional(sourceOrder.PlatformOrderNo),
ShopName: cleanOptional(sourceOrder.ShopName),
SourceCreatedAt: sourceCreatedAt,
OrderStatus: cleanOptional(sourceOrder.OrderStatus),
PurchaseStatus: cleanOptional(sourceOrder.PurchaseStatus),
IsCanceled: sourceOrder.IsCanceled,
Items: make([]domain.FreightImportItem, 0, len(sourceOrder.Items)),
}
seenItems := map[string]struct{}{}
for _, sourceItem := range sourceOrder.Items {
itemExternalID, ok := validExternalID(sourceItem.ExternalItemID)
if !ok {
return domain.FreightImportBatch{}, errors.New("invalid freight item")
}
if _, exists := seenItems[itemExternalID]; exists {
return domain.FreightImportBatch{}, errors.New("duplicate freight item")
}
seenItems[itemExternalID] = struct{}{}
if !validBytes(sourceItem.Title, 2048) ||
!validBytes(sourceItem.ProductSpec, 1024) ||
!validBytes(sourceItem.SKU, 512) ||
!validOptional(sourceItem.ProductThumbRef, 512) ||
!validOptional(sourceItem.PurchaseStatus, 128) ||
(sourceItem.Quantity != nil && *sourceItem.Quantity <= 0) {
return domain.FreightImportBatch{}, errors.New("invalid freight item fields")
}
itemID, err := service.ids.NewID()
if err != nil {
return domain.FreightImportBatch{}, err
}
item := domain.FreightImportItem{
ID: itemID,
ExternalItemID: itemExternalID,
Title: sourceItem.Title,
ProductSpec: sourceItem.ProductSpec,
SKU: sourceItem.SKU,
Quantity: sourceItem.Quantity,
ProductThumbRef: cleanOptional(sourceItem.ProductThumbRef),
PurchaseStatus: cleanOptional(sourceItem.PurchaseStatus),
}
item.CanonicalSHA256 = hashJSON(struct {
ExternalItemID string `json:"external_item_id"`
Title string `json:"title"`
ProductSpec string `json:"product_spec"`
SKU string `json:"sku"`
Quantity *int `json:"quantity"`
ProductThumbRef *string `json:"product_thumb_ref"`
PurchaseStatus *string `json:"purchase_status"`
}{
item.ExternalItemID, item.Title, item.ProductSpec, item.SKU,
item.Quantity, item.ProductThumbRef, item.PurchaseStatus,
})
order.Items = append(order.Items, item)
}
sort.Slice(order.Items, func(i, j int) bool {
left, _ := strconv.ParseUint(order.Items[i].ExternalItemID, 10, 64)
right, _ := strconv.ParseUint(order.Items[j].ExternalItemID, 10, 64)
return left < right
})
type canonicalItem struct {
ExternalItemID string `json:"external_item_id"`
CanonicalSHA256 string `json:"canonical_sha256"`
}
canonicalItems := make([]canonicalItem, 0, len(order.Items))
for _, item := range order.Items {
canonicalItems = append(canonicalItems, canonicalItem{
ExternalItemID: item.ExternalItemID,
CanonicalSHA256: item.CanonicalSHA256,
})
}
order.CanonicalSHA256 = hashJSON(struct {
ExternalStockID string `json:"external_stock_id"`
SourceCode string `json:"source_code"`
PlatformOrderNo *string `json:"platform_order_no"`
ShopName *string `json:"shop_name"`
SourceCreatedAt *time.Time `json:"source_created_at"`
OrderStatus *string `json:"order_status"`
PurchaseStatus *string `json:"purchase_status"`
IsCanceled *bool `json:"is_canceled"`
Items []canonicalItem `json:"items"`
}{
order.ExternalStockID, order.SourceCode, order.PlatformOrderNo,
order.ShopName, order.SourceCreatedAt, order.OrderStatus,
order.PurchaseStatus, order.IsCanceled, canonicalItems,
})
result.Orders = append(result.Orders, order)
}
sort.Slice(result.Orders, func(i, j int) bool {
left, _ := strconv.ParseUint(result.Orders[i].ExternalStockID, 10, 64)
right, _ := strconv.ParseUint(result.Orders[j].ExternalStockID, 10, 64)
return left < right
})
return result, nil
}
func validateFreightActor(
creatorSubject, actorUserID, idempotencyKey string,
) map[string]string {
fields := map[string]string{}
if creatorSubject == "" {
fields["creator_subject"] = "is required"
}
if actorUserID == "" {
fields["actor_user_id"] = "is required"
}
if idempotencyKey == "" || len([]byte(idempotencyKey)) > 128 {
fields["idempotency_key"] = "must contain 1 to 128 bytes"
}
return fields
}
func parseFreightDate(value string, location *time.Location) (time.Time, error) {
parsed, err := time.ParseInLocation(time.DateOnly, value, location)
if err != nil || parsed.Format(time.DateOnly) != value {
return time.Time{}, errors.New("invalid freight date")
}
return parsed, nil
}
func startOfLocalDay(value time.Time, location *time.Location) time.Time {
return time.Date(
value.Year(),
value.Month(),
value.Day(),
0,
0,
0,
0,
location,
)
}
func daysInclusive(start, end time.Time) int {
return int(end.Sub(start)/(24*time.Hour)) + 1
}
func freightSourceErrorCode(err error) string {
switch {
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
return "ERP_CONNECTOR_NOT_CONFIGURED"
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
return "ERP_SESSION_REQUIRED"
case errors.Is(err, domain.ErrFreightSourceNotFound):
return "ERP_FREIGHT_NOT_FOUND"
case errors.Is(err, domain.ErrFreightSourceProtocol):
return "ERP_RESPONSE_INVALID"
default:
return "ERP_CONNECTOR_UNAVAILABLE"
}
}
func validExternalID(value string) (string, bool) {
value = strings.TrimSpace(value)
number, err := strconv.ParseUint(value, 10, 64)
return value, err == nil && number > 0 && strconv.FormatUint(number, 10) == value
}
func validBytes(value string, maximum int) bool {
return utf8.ValidString(value) && len([]byte(value)) <= maximum &&
!hasControl(value)
}
func validOptional(value *string, maximum int) bool {
return value == nil || validBytes(strings.TrimSpace(*value), maximum)
}
func cleanOptional(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func hasControl(value string) bool {
for _, character := range value {
if character < 0x20 || character == 0x7f {
return true
}
}
return false
}
func parseERPTime(value *string) (*time.Time, error) {
value = cleanOptional(value)
if value == nil {
return nil, nil
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return nil, err
}
layouts := []string{
time.RFC3339Nano,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
}
for _, layout := range layouts {
var parsed time.Time
if layout == time.RFC3339Nano {
parsed, err = time.Parse(layout, *value)
} else {
parsed, err = time.ParseInLocation(layout, *value, location)
}
if err == nil {
result := parsed.UTC()
return &result, nil
}
}
return nil, errors.New("invalid ERP source time")
}
func hashJSON(value any) string {
encoded, _ := json.Marshal(value)
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:])
}