feat(t242): delete unused freight orders

This commit is contained in:
QiuSW
2026-07-29 16:10:45 +08:00
parent c80a5c26ed
commit e5f403ff7d
20 changed files with 915 additions and 27 deletions
+8
View File
@@ -65,6 +65,7 @@ var (
ErrSourceReferenceConflict = errors.New("source reference conflict")
ErrAssetUnavailable = errors.New("asset is unavailable")
ErrTaskStateConflict = errors.New("task state conflict")
ErrFreightOrderInUse = errors.New("freight order is in use")
ErrRepositoryUnavailable = errors.New("repository unavailable")
ErrRepositoryInvariant = errors.New("repository invariant failed")
)
@@ -106,6 +107,13 @@ func wrapRepositoryError(err error) error {
"task state does not allow this operation",
err,
)
case errors.Is(err, ErrFreightOrderInUse):
return newError(
ErrorKindConflict,
"FREIGHT_ORDER_IN_USE",
"freight order is already used by procurement",
err,
)
case errors.Is(err, ErrRepositoryUnavailable):
result := newError(
ErrorKindUnavailable,
@@ -39,4 +39,5 @@ type FreightImageRepository interface {
type FreightImageCache interface {
CacheRun(context.Context, string, string) error
DeleteStoredImages([]string)
}
@@ -169,6 +169,20 @@ func (service *FreightImageService) deleteStoredImage(storageKey string) {
_ = service.store.Delete(ctx, storageKey)
}
func (service *FreightImageService) DeleteStoredImages(storageKeys []string) {
if len(storageKeys) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), freightImageCleanup)
defer cancel()
for _, storageKey := range storageKeys {
if ctx.Err() != nil {
return
}
_ = service.store.Delete(ctx, storageKey)
}
}
func (service *FreightImageService) OpenItemImage(
ctx context.Context,
creatorSubject, itemID string,
@@ -118,6 +118,30 @@ func TestFreightImageOpenDoesNotRevealUnavailableItems(t *testing.T) {
}
}
func TestFreightImageDeleteStoredImagesUsesIdempotentStoreCleanup(t *testing.T) {
store := &freightImageStoreFake{}
service, err := NewFreightImageService(
&freightImageRepositoryFake{},
&freightImageSourceFake{},
store,
fakeClock{},
)
if err != nil {
t.Fatalf("NewFreightImageService() error = %v", err)
}
service.DeleteStoredImages([]string{
"first/image.jpg",
"second/image.jpg",
})
store.mu.Lock()
defer store.mu.Unlock()
if len(store.deleted) != 2 ||
store.deleted[0] != "first/image.jpg" ||
store.deleted[1] != "second/image.jpg" {
t.Fatalf("deleted storage keys = %#v", store.deleted)
}
}
type freightImageRepositoryFake struct {
mu sync.Mutex
jobs []domain.FreightItemImageJob
@@ -58,4 +58,9 @@ type FreightRepository interface {
string,
string,
) (domain.FreightOrderDetail, error)
DeleteFreightOrder(
context.Context,
string,
string,
) ([]string, error)
}
@@ -69,6 +69,12 @@ type CreateFreightDateSyncCommand struct {
SyncToNow bool
}
type DeleteFreightOrderCommand struct {
CreatorSubject string
ActorUserID string
OrderID string
}
type CreateFreightSyncResult struct {
Run domain.FreightSyncRun
Replayed bool
@@ -701,6 +707,44 @@ func (service *FreightService) GetOrder(
return detail, nil
}
func (service *FreightService) DeleteOrder(
ctx context.Context,
command DeleteFreightOrderCommand,
) error {
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
command.OrderID = strings.TrimSpace(command.OrderID)
fields := map[string]string{}
if command.CreatorSubject == "" {
fields["creator_subject"] = "is required"
}
if !isUUID(command.ActorUserID) {
fields["actor_user_id"] = "must be a UUID"
}
if !isUUID(command.OrderID) {
fields["order_id"] = "must be a UUID"
}
if len(fields) > 0 {
return invalidError(
"FREIGHT_DELETE_INVALID",
"freight order deletion is invalid",
fields,
)
}
storageKeys, err := service.repository.DeleteFreightOrder(
ctx,
command.CreatorSubject,
command.OrderID,
)
if err != nil {
return wrapRepositoryError(err)
}
if service.imageCache != nil {
service.imageCache.DeleteStoredImages(storageKeys)
}
return nil
}
func (service *FreightService) normalize(
source domain.FreightSourceBatch,
) (domain.FreightImportBatch, error) {
@@ -236,6 +236,7 @@ type recordingFreightImageCache struct {
creatorSubject string
runID string
err error
deleted []string
}
func (cache *recordingFreightImageCache) CacheRun(
@@ -248,6 +249,12 @@ func (cache *recordingFreightImageCache) CacheRun(
return cache.err
}
func (cache *recordingFreightImageCache) DeleteStoredImages(
storageKeys []string,
) {
cache.deleted = append(cache.deleted, storageKeys...)
}
func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) {
repository := &syncTrackingRepository{}
source := &blockingOrderSource{}
@@ -378,6 +385,56 @@ func validFreightOrderSyncCommand(idempotencyKey string) CreateFreightSyncComman
}
}
func TestDeleteFreightOrderValidatesConflictAndCleansImages(t *testing.T) {
repository := &syncTrackingRepository{
deleteStorageKeys: []string{"first/image.jpg", "second/image.jpg"},
}
imageCache := &recordingFreightImageCache{}
service, err := NewFreightService(
repository,
&recordingDateSource{},
fakeClock{},
&sequenceIDs{},
time.Minute,
WithFreightImageCache(imageCache),
)
if err != nil {
t.Fatalf("NewFreightService() error = %v", err)
}
command := DeleteFreightOrderCommand{
CreatorSubject: " local-admin ",
ActorUserID: "00000000-0000-4000-8000-000000000099",
OrderID: "00000000-0000-4000-8000-000000000100",
}
if err := service.DeleteOrder(context.Background(), command); err != nil {
t.Fatalf("DeleteOrder() error = %v", err)
}
if repository.deleteCalls != 1 ||
repository.deleteCreatorSubject != "local-admin" ||
repository.deleteOrderID != command.OrderID {
t.Fatalf("delete repository call = %+v", repository)
}
if len(imageCache.deleted) != 2 ||
imageCache.deleted[0] != "first/image.jpg" ||
imageCache.deleted[1] != "second/image.jpg" {
t.Fatalf("deleted image keys = %#v", imageCache.deleted)
}
repository.deleteErr = ErrFreightOrderInUse
err = service.DeleteOrder(context.Background(), command)
assertUsecaseError(t, err, ErrorKindConflict, "FREIGHT_ORDER_IN_USE")
if len(imageCache.deleted) != 2 {
t.Fatalf("conflicting delete cleaned images = %#v", imageCache.deleted)
}
command.OrderID = "invalid"
err = service.DeleteOrder(context.Background(), command)
assertUsecaseError(t, err, ErrorKindInvalid, "FREIGHT_DELETE_INVALID")
if repository.deleteCalls != 2 {
t.Fatalf("invalid delete reached repository %d times", repository.deleteCalls)
}
}
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
source := &recordingDateSource{}
service := &FreightService{source: source}
@@ -581,12 +638,17 @@ func (*blockingOrderSource) QueryCreatedRange(
}
type syncTrackingRepository struct {
mu sync.Mutex
startCalls int
completeCalls int
failCalls int
failCode string
failContextErr error
mu sync.Mutex
startCalls int
completeCalls int
failCalls int
failCode string
failContextErr error
deleteCalls int
deleteCreatorSubject string
deleteOrderID string
deleteStorageKeys []string
deleteErr error
}
func (*syncTrackingRepository) CreateFreightSync(
@@ -682,6 +744,17 @@ func (*syncTrackingRepository) GetFreightOrder(
return domain.FreightOrderDetail{}, nil
}
func (repository *syncTrackingRepository) DeleteFreightOrder(
_ context.Context,
creatorSubject, orderID string,
) ([]string, error) {
repository.deleteCalls++
repository.deleteCreatorSubject = creatorSubject
repository.deleteOrderID = orderID
return append([]string(nil), repository.deleteStorageKeys...),
repository.deleteErr
}
func (source *recordingDateSource) EnsureAuthenticated(context.Context) error {
source.ensureCalls++
return source.ensureErr
@@ -800,3 +873,11 @@ func (*dateCaptureRepository) GetFreightOrder(
) (domain.FreightOrderDetail, error) {
return domain.FreightOrderDetail{}, nil
}
func (*dateCaptureRepository) DeleteFreightOrder(
context.Context,
string,
string,
) ([]string, error) {
return nil, nil
}