fix(supply-api): classify handler failures by error type

This commit is contained in:
Your Name
2026-04-20 16:24:24 +08:00
parent a1555c0127
commit eab029a05c
6 changed files with 116 additions and 51 deletions

View File

@@ -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{

View File

@@ -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")

View File

@@ -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

View File

@@ -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
}
// 保存更新前的版本号用于乐观锁

View File

@@ -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
}

View File

@@ -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()