diff --git a/supply-api/internal/domain/account.go b/supply-api/internal/domain/account.go index 7e7cbece..0eb7a825 100644 --- a/supply-api/internal/domain/account.go +++ b/supply-api/internal/domain/account.go @@ -213,7 +213,7 @@ func (s *accountService) Activate(ctx context.Context, supplierID, accountID int } if account.Status != AccountStatusPending && account.Status != AccountStatusSuspended { - return nil, errors.New("SUP_ACC_4091: can only activate pending or suspended accounts") + return nil, ErrAccountCannotActivateState } account.Status = AccountStatusActive @@ -242,7 +242,7 @@ func (s *accountService) Suspend(ctx context.Context, supplierID, accountID int6 } if account.Status != AccountStatusActive { - return nil, errors.New("SUP_ACC_4091: can only suspend active accounts") + return nil, ErrAccountCannotSuspendState } account.Status = AccountStatusSuspended @@ -271,7 +271,7 @@ func (s *accountService) Delete(ctx context.Context, supplierID, accountID int64 } if account.Status == AccountStatusActive { - return errors.New("SUP_ACC_4092: cannot delete active accounts") + return ErrAccountCannotDeleteActive } s.emitAudit(ctx, audit.Event{ diff --git a/supply-api/internal/domain/invariants.go b/supply-api/internal/domain/invariants.go index e2b8501e..f46658e3 100644 --- a/supply-api/internal/domain/invariants.go +++ b/supply-api/internal/domain/invariants.go @@ -9,6 +9,12 @@ import ( // 领域不变量错误 var ( + // INV-ACC-000: 仅 pending/suspended 可激活 + ErrAccountCannotActivateState = errors.New("SUP_ACC_4091: can only activate pending or suspended accounts") + + // INV-ACC-000B: 仅 active 可暂停 + ErrAccountCannotSuspendState = errors.New("SUP_ACC_4091: can only suspend active accounts") + // INV-ACC-001: active账号不可删除 ErrAccountCannotDeleteActive = errors.New("SUP_ACC_4092: cannot delete active accounts") @@ -18,6 +24,12 @@ var ( // INV-PKG-001: sold_out只能系统迁移 ErrPackageSoldOutSystemOnly = errors.New("SUP_PKG_4092: sold_out status can only be changed by system") + // INV-PKG-001B: 仅 draft/paused 可发布 + ErrPackageCannotPublishState = errors.New("SUP_PKG_4092: can only publish draft or paused packages") + + // INV-PKG-001C: 仅 active 可暂停 + ErrPackageCannotPauseState = errors.New("SUP_PKG_4092: can only pause active packages") + // INV-PKG-002: expired套餐不可直接恢复 ErrPackageExpiredCannotRestore = errors.New("SUP_PKG_4093: expired package cannot be directly restored") diff --git a/supply-api/internal/domain/package.go b/supply-api/internal/domain/package.go index 440c2c54..19f0d28d 100644 --- a/supply-api/internal/domain/package.go +++ b/supply-api/internal/domain/package.go @@ -2,7 +2,6 @@ package domain import ( "context" - "errors" "net/netip" "time" @@ -186,7 +185,7 @@ func (s *packageService) Publish(ctx context.Context, supplierID, packageID int6 } if pkg.Status != PackageStatusDraft && pkg.Status != PackageStatusPaused { - return nil, errors.New("SUP_PKG_4092: can only publish draft or paused packages") + return nil, ErrPackageCannotPublishState } pkg.Status = PackageStatusActive @@ -215,7 +214,7 @@ func (s *packageService) Pause(ctx context.Context, supplierID, packageID int64) } if pkg.Status != PackageStatusActive { - return nil, errors.New("SUP_PKG_4092: can only pause active packages") + return nil, ErrPackageCannotPauseState } pkg.Status = PackageStatusPaused diff --git a/supply-api/internal/domain/settlement.go b/supply-api/internal/domain/settlement.go index 13d0f8ee..be32183f 100644 --- a/supply-api/internal/domain/settlement.go +++ b/supply-api/internal/domain/settlement.go @@ -294,7 +294,7 @@ func (s *settlementService) Cancel(ctx context.Context, supplierID, settlementID } if settlement.Status == SettlementStatusProcessing || settlement.Status == SettlementStatusCompleted { - return nil, errors.New("SUP_SET_4092: cannot cancel processing or completed settlements") + return nil, ErrSettlementCannotCancel } // 保存更新前的版本号用于乐观锁 diff --git a/supply-api/internal/httpapi/supply_api.go b/supply-api/internal/httpapi/supply_api.go index a1b8334a..31c199b8 100644 --- a/supply-api/internal/httpapi/supply_api.go +++ b/supply-api/internal/httpapi/supply_api.go @@ -135,6 +135,31 @@ func (a *SupplyAPI) requireIdempotencyMiddleware(w http.ResponseWriter) bool { return true } +func isSupplyActionConflict(err error) bool { + return errors.Is(err, repository.ErrConcurrencyConflict) || + errors.Is(err, domain.ErrAccountCannotActivateState) || + errors.Is(err, domain.ErrAccountCannotSuspendState) || + errors.Is(err, domain.ErrAccountCannotDeleteActive) || + errors.Is(err, domain.ErrPackageCannotPublishState) || + errors.Is(err, domain.ErrPackageCannotPauseState) || + errors.Is(err, domain.ErrSettlementCannotCancel) +} + +func isSupplyActionNotFound(err error) bool { + return errors.Is(err, repository.ErrNotFound) +} + +func writeSupplyActionError(w http.ResponseWriter, err error, unexpectedCode string) { + switch { + case isSupplyActionConflict(err): + writeError(w, http.StatusConflict, CodeConflict, err.Error()) + case isSupplyActionNotFound(err): + writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) + default: + writeError(w, http.StatusInternalServerError, unexpectedCode, err.Error()) + } +} + // ==================== Account Handlers ==================== type VerifyAccountRequest struct { @@ -318,11 +343,7 @@ func (a *SupplyAPI) handleActivateAccount(w http.ResponseWriter, r *http.Request account, err := a.accountService.Activate(r.Context(), supplierID, accountID) if err != nil { - if strings.Contains(err.Error(), "SUP_ACC") { - writeError(w, http.StatusConflict, CodeConflict, err.Error()) - } else { - writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) - } + writeSupplyActionError(w, err, CodeQueryFailed) return } @@ -344,11 +365,7 @@ func (a *SupplyAPI) handleSuspendAccount(w http.ResponseWriter, r *http.Request, account, err := a.accountService.Suspend(r.Context(), supplierID, accountID) if err != nil { - if strings.Contains(err.Error(), "SUP_ACC") { - writeError(w, http.StatusConflict, CodeConflict, err.Error()) - } else { - writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) - } + writeSupplyActionError(w, err, CodeQueryFailed) return } @@ -370,11 +387,7 @@ func (a *SupplyAPI) handleDeleteAccount(w http.ResponseWriter, r *http.Request, err := a.accountService.Delete(r.Context(), supplierID, accountID) if err != nil { - if strings.Contains(err.Error(), "SUP_ACC") { - writeError(w, http.StatusConflict, CodeConflict, err.Error()) - } else { - writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) - } + writeSupplyActionError(w, err, CodeQueryFailed) return } @@ -579,11 +592,7 @@ func (a *SupplyAPI) handlePublishPackage(w http.ResponseWriter, r *http.Request, pkg, err := a.packageService.Publish(r.Context(), supplierID, packageID) if err != nil { - if strings.Contains(err.Error(), "SUP_PKG") { - writeError(w, http.StatusConflict, CodeConflict, err.Error()) - } else { - writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) - } + writeSupplyActionError(w, err, CodeQueryFailed) return } @@ -605,11 +614,7 @@ func (a *SupplyAPI) handlePausePackage(w http.ResponseWriter, r *http.Request, p pkg, err := a.packageService.Pause(r.Context(), supplierID, packageID) if err != nil { - if strings.Contains(err.Error(), "SUP_PKG") { - writeError(w, http.StatusConflict, CodeConflict, err.Error()) - } else { - writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) - } + writeSupplyActionError(w, err, CodeQueryFailed) return } @@ -631,11 +636,7 @@ func (a *SupplyAPI) handleUnlistPackage(w http.ResponseWriter, r *http.Request, pkg, err := a.packageService.Unlist(r.Context(), supplierID, packageID) if err != nil { - if strings.Contains(err.Error(), "SUP_PKG") { - writeError(w, http.StatusConflict, CodeConflict, err.Error()) - } else { - writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) - } + writeSupplyActionError(w, err, CodeQueryFailed) return } @@ -657,7 +658,7 @@ func (a *SupplyAPI) handleClonePackage(w http.ResponseWriter, r *http.Request, p pkg, err := a.packageService.Clone(r.Context(), supplierID, packageID) if err != nil { - writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) + writeSupplyActionError(w, err, CodeCreateFailed) return } @@ -877,11 +878,7 @@ func (a *SupplyAPI) handleCancelSettlement(w http.ResponseWriter, r *http.Reques settlement, err := a.settlementService.Cancel(r.Context(), supplierID, settlementID) if err != nil { - if strings.Contains(err.Error(), "SUP_SET") { - writeError(w, http.StatusConflict, CodeConflict, err.Error()) - } else { - writeError(w, http.StatusNotFound, CodeNotFound, err.Error()) - } + writeSupplyActionError(w, err, CodeQueryFailed) return } diff --git a/supply-api/internal/httpapi/supply_api_test.go b/supply-api/internal/httpapi/supply_api_test.go index 9af0eee7..55d1b033 100644 --- a/supply-api/internal/httpapi/supply_api_test.go +++ b/supply-api/internal/httpapi/supply_api_test.go @@ -13,6 +13,7 @@ import ( "lijiaoqiao/supply-api/internal/audit" "lijiaoqiao/supply-api/internal/domain" "lijiaoqiao/supply-api/internal/middleware" + "lijiaoqiao/supply-api/internal/repository" ) // ==================== Mock Implementations ==================== @@ -532,7 +533,7 @@ func TestSupplyAPI_ActivateAccount_Success(t *testing.T) { func TestSupplyAPI_ActivateAccount_NotFound(t *testing.T) { api, accountSvc, _, _, _, _ := newTestAPI() - accountSvc.activateErr = errors.New("account not found") + accountSvc.activateErr = repository.ErrNotFound req := httptest.NewRequest("POST", "/api/v1/supply/accounts/1/activate", nil) w := httptest.NewRecorder() @@ -544,6 +545,20 @@ func TestSupplyAPI_ActivateAccount_NotFound(t *testing.T) { } } +func TestSupplyAPI_ActivateAccount_ConcurrencyConflict(t *testing.T) { + api, accountSvc, _, _, _, _ := newTestAPI() + accountSvc.activateErr = repository.ErrConcurrencyConflict + + req := httptest.NewRequest("POST", "/api/v1/supply/accounts/1/activate", nil) + w := httptest.NewRecorder() + + api.handleAccountActions(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409, got %d body=%s", w.Code, w.Body.String()) + } +} + func TestSupplyAPI_SuspendAccount_Success(t *testing.T) { api, _, _, _, _, _ := newTestAPI() @@ -559,7 +574,7 @@ func TestSupplyAPI_SuspendAccount_Success(t *testing.T) { func TestSupplyAPI_SuspendAccount_Conflict(t *testing.T) { api, accountSvc, _, _, _, _ := newTestAPI() - accountSvc.suspendErr = errors.New("SUP_ACC_4091: account state conflict") + accountSvc.suspendErr = domain.ErrAccountCannotSuspendState req := httptest.NewRequest("POST", "/api/v1/supply/accounts/1/suspend", nil) w := httptest.NewRecorder() @@ -599,7 +614,7 @@ func TestSupplyAPI_DeleteAccount_Success(t *testing.T) { func TestSupplyAPI_DeleteAccount_Conflict(t *testing.T) { api, accountSvc, _, _, _, _ := newTestAPI() - accountSvc.deleteErr = errors.New("SUP_ACC_4092: cannot delete account with active packages") + accountSvc.deleteErr = domain.ErrAccountCannotDeleteActive req := httptest.NewRequest("DELETE", "/api/v1/supply/accounts/1/delete", nil) w := httptest.NewRecorder() @@ -766,7 +781,7 @@ func TestSupplyAPI_PublishPackage_Success(t *testing.T) { func TestSupplyAPI_PublishPackage_NotFound(t *testing.T) { api, _, packageSvc, _, _, _ := newTestAPI() - packageSvc.publishErr = errors.New("package not found") + packageSvc.publishErr = repository.ErrNotFound req := httptest.NewRequest("POST", "/api/v1/supply/packages/1/publish", nil) w := httptest.NewRecorder() @@ -778,6 +793,20 @@ func TestSupplyAPI_PublishPackage_NotFound(t *testing.T) { } } +func TestSupplyAPI_PublishPackage_ConcurrencyConflict(t *testing.T) { + api, _, packageSvc, _, _, _ := newTestAPI() + packageSvc.publishErr = repository.ErrConcurrencyConflict + + req := httptest.NewRequest("POST", "/api/v1/supply/packages/1/publish", nil) + w := httptest.NewRecorder() + + api.handlePackageActions(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409, got %d body=%s", w.Code, w.Body.String()) + } +} + func TestSupplyAPI_PausePackage_Success(t *testing.T) { api, _, _, _, _, _ := newTestAPI() @@ -793,7 +822,7 @@ func TestSupplyAPI_PausePackage_Success(t *testing.T) { func TestSupplyAPI_PausePackage_Conflict(t *testing.T) { api, _, packageSvc, _, _, _ := newTestAPI() - packageSvc.pauseErr = errors.New("SUP_PKG_4092: cannot pause active package") + packageSvc.pauseErr = domain.ErrPackageCannotPauseState req := httptest.NewRequest("POST", "/api/v1/supply/packages/1/pause", nil) w := httptest.NewRecorder() @@ -833,7 +862,7 @@ func TestSupplyAPI_UnlistPackage_Success(t *testing.T) { func TestSupplyAPI_UnlistPackage_Conflict(t *testing.T) { api, _, packageSvc, _, _, _ := newTestAPI() - packageSvc.unlistErr = errors.New("SUP_PKG_4093: cannot unlist package") + packageSvc.unlistErr = repository.ErrConcurrencyConflict req := httptest.NewRequest("POST", "/api/v1/supply/packages/1/unlist", nil) w := httptest.NewRecorder() @@ -873,7 +902,7 @@ func TestSupplyAPI_ClonePackage_WrongMethod(t *testing.T) { func TestSupplyAPI_ClonePackage_NotFound(t *testing.T) { api, _, packageSvc, _, _, _ := newTestAPI() - packageSvc.cloneErr = errors.New("package not found") + packageSvc.cloneErr = repository.ErrNotFound req := httptest.NewRequest("POST", "/api/v1/supply/packages/1/clone", nil) w := httptest.NewRecorder() @@ -885,6 +914,20 @@ func TestSupplyAPI_ClonePackage_NotFound(t *testing.T) { } } +func TestSupplyAPI_ClonePackage_UnexpectedCreateFailureReturnsInternalServerError(t *testing.T) { + api, _, packageSvc, _, _, _ := newTestAPI() + packageSvc.cloneErr = errors.New("insert failed") + + req := httptest.NewRequest("POST", "/api/v1/supply/packages/1/clone", nil) + w := httptest.NewRecorder() + + api.handlePackageActions(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d body=%s", w.Code, w.Body.String()) + } +} + func TestSupplyAPI_PublishPackage_WrongMethod(t *testing.T) { api, _, _, _, _, _ := newTestAPI() @@ -1134,7 +1177,7 @@ func TestSupplyAPI_CancelSettlement_Success(t *testing.T) { func TestSupplyAPI_CancelSettlement_NotFound(t *testing.T) { api, _, _, settlementSvc, _, _ := newTestAPI() - settlementSvc.cancelErr = errors.New("settlement not found") + settlementSvc.cancelErr = repository.ErrNotFound req := httptest.NewRequest("POST", "/api/v1/supply/settlements/1/cancel", nil) w := httptest.NewRecorder() @@ -1146,6 +1189,20 @@ func TestSupplyAPI_CancelSettlement_NotFound(t *testing.T) { } } +func TestSupplyAPI_CancelSettlement_ConcurrencyConflict(t *testing.T) { + api, _, _, settlementSvc, _, _ := newTestAPI() + settlementSvc.cancelErr = repository.ErrConcurrencyConflict + + req := httptest.NewRequest("POST", "/api/v1/supply/settlements/1/cancel", nil) + w := httptest.NewRecorder() + + api.handleSettlementActions(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409, got %d body=%s", w.Code, w.Body.String()) + } +} + func TestSupplyAPI_GetStatement_Success(t *testing.T) { api, _, _, _, _, _ := newTestAPI()