fix(supply-api): classify handler failures by error type
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 保存更新前的版本号用于乐观锁
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user