소스 검색

fix: 月卡办理/续费/退卡事务化,过期续费从今天起算

- 三操作均包入事务:月卡+白名单+支付流水任一步失败整体回滚并返回错误
- 修复 GetByVehicleID 数据库错误被静默吞掉的问题
- 过期卡续费从今天起算,避免续费后仍处于过期状态
- 新增 9 个 service 测试(成功路径/白名单失败回滚/流水失败回滚/续费起算/回滚)
lq 2 주 전
부모
커밋
84bc3d32dc
2개의 변경된 파일327개의 추가작업 그리고 63개의 파일을 삭제
  1. 94 63
      internal/modules/monthly/service/service.go
  2. 233 0
      internal/modules/monthly/service/service_test.go

+ 94 - 63
internal/modules/monthly/service/service.go

@@ -8,6 +8,8 @@ import (
 	"wails-app/internal/global"
 	"wails-app/internal/modules/monthly/repository"
 	paymentSvc "wails-app/internal/modules/payment/service"
+
+	"gorm.io/gorm"
 )
 
 type MonthlyCardService struct {
@@ -31,9 +33,12 @@ func cardDays(cardType string) int {
 	}
 }
 
-// Create 办理月卡:创建 monthly_card + shortlist 白名单 + payment_record
+// Create 办理月卡:月卡、白名单、支付流水在同一事务中,任一步失败整体回滚。
 func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64, operatorID uint, remark string) (*dao.MonthlyCard, error) {
-	existing, _ := s.repo.GetByVehicleID(vehicleID)
+	existing, err := s.repo.GetByVehicleID(vehicleID)
+	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+		return nil, fmt.Errorf("查询有效月卡失败: %w", err)
+	}
 	if existing != nil {
 		return nil, errors.New("该车辆已有有效月卡,请先退卡或等过期后续费")
 	}
@@ -51,76 +56,102 @@ func (s *MonthlyCardService) Create(vehicleID uint, cardType string, fee float64
 		StartDate: now, EndDate: endDate,
 		Fee: fee, PaymentStatus: "paid", OperatorID: operatorID, Remark: remark,
 	}
-	if err := s.repo.Create(card); err != nil {
-		return nil, fmt.Errorf("创建月卡失败: %w", err)
-	}
 
-	// 写入白名单
-	shortlist := dao.Shortlist{
-		VehicleId:      int(vehicleID),
-		ListType:       "白名单",
-		ExpirationTime: &endDate,
+	err = global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Create(card).Error; err != nil {
+			return fmt.Errorf("创建月卡失败: %w", err)
+		}
+
+		shortlist := dao.Shortlist{
+			VehicleId:      int(vehicleID),
+			ListType:       "白名单",
+			ExpirationTime: &endDate,
+		}
+		if err := tx.Create(&shortlist).Error; err != nil {
+			return fmt.Errorf("写入白名单失败: %w", err)
+		}
+
+		payRecord := dao.PaymentRecord{
+			RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
+			Amount: fee, PaidAmount: fee, ChangeAmount: 0,
+			OperatorID: operatorID, PaidAt: now,
+			Remark: fmt.Sprintf("包月卡办理(%s)", cardType),
+		}
+		if err := tx.Create(&payRecord).Error; err != nil {
+			return fmt.Errorf("创建支付流水失败: %w", err)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
 	}
-	global.GVA_DB.Create(&shortlist)
-
-	// 创建 payment_record
-	payRecord := dao.PaymentRecord{
-		RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
-		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
-		OperatorID: operatorID, PaidAt: now,
-		Remark: fmt.Sprintf("包月卡办理(%s)", cardType),
-	}
-	global.GVA_DB.Create(&payRecord)
-
 	return card, nil
 }
 
-// Renew 续费:延长到期日 + 更新 shortlist
+// Renew 续费:未过期卡从原到期日顺延,已过期卡从今天起算;
+// 月卡、白名单、支付流水在同一事务中,任一步失败整体回滚。
 func (s *MonthlyCardService) Renew(cardID uint, cardType string, fee float64, operatorID uint) error {
-	var card dao.MonthlyCard
-	if err := global.GVA_DB.First(&card, cardID).Error; err != nil {
-		return errors.New("月卡记录不存在")
-	}
-	if card.PaymentStatus != "paid" {
-		return errors.New("月卡已退卡,无法续费")
-	}
-
-	newEnd := card.EndDate.AddDate(0, 0, cardDays(cardType))
-	card.EndDate = newEnd
-	card.Fee += fee
-	global.GVA_DB.Save(&card)
-
-	// 更新 shortlist 到期日
-	global.GVA_DB.Model(&dao.Shortlist{}).Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").
-		Update("expiration_time", newEnd)
-
-	// 创建 payment_record
-	now := time.Now()
-	payRecord := dao.PaymentRecord{
-		RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
-		Amount: fee, PaidAmount: fee, ChangeAmount: 0,
-		OperatorID: operatorID, PaidAt: now,
-		Remark: fmt.Sprintf("包月卡续费(%s)", cardType),
-	}
-	global.GVA_DB.Create(&payRecord)
-
-	return nil
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var card dao.MonthlyCard
+		if err := tx.First(&card, cardID).Error; err != nil {
+			return errors.New("月卡记录不存在")
+		}
+		if card.PaymentStatus != "paid" {
+			return errors.New("月卡已退卡,无法续费")
+		}
+
+		now := time.Now()
+		// 过期卡从今天起算,避免"续费后仍处于过期状态"
+		base := card.EndDate
+		if base.Before(now) {
+			base = now
+		}
+		newEnd := base.AddDate(0, 0, cardDays(cardType))
+
+		card.EndDate = newEnd
+		card.Fee += fee
+		if err := tx.Save(&card).Error; err != nil {
+			return fmt.Errorf("更新月卡失败: %w", err)
+		}
+
+		if err := tx.Model(&dao.Shortlist{}).
+			Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").
+			Update("expiration_time", newEnd).Error; err != nil {
+			return fmt.Errorf("更新白名单失败: %w", err)
+		}
+
+		payRecord := dao.PaymentRecord{
+			RecordID: 0, PaymentEntry: paymentSvc.EntryCounter, PaymentMethod: paymentSvc.PaymentCash,
+			Amount: fee, PaidAmount: fee, ChangeAmount: 0,
+			OperatorID: operatorID, PaidAt: now,
+			Remark: fmt.Sprintf("包月卡续费(%s)", cardType),
+		}
+		if err := tx.Create(&payRecord).Error; err != nil {
+			return fmt.Errorf("创建支付流水失败: %w", err)
+		}
+		return nil
+	})
 }
 
-// Refund 退卡:标记 refunded + 移除 shortlist
+// Refund 退卡:标记退款状态与移除白名单在同一事务中,任一步失败整体回滚。
 func (s *MonthlyCardService) Refund(cardID uint) error {
-	var card dao.MonthlyCard
-	if err := global.GVA_DB.First(&card, cardID).Error; err != nil {
-		return errors.New("月卡记录不存在")
-	}
-
-	card.PaymentStatus = "refunded"
-	global.GVA_DB.Save(&card)
-
-	// 移除白名单
-	global.GVA_DB.Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").Unscoped().Delete(&dao.Shortlist{})
-
-	return nil
+	return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
+		var card dao.MonthlyCard
+		if err := tx.First(&card, cardID).Error; err != nil {
+			return errors.New("月卡记录不存在")
+		}
+
+		card.PaymentStatus = "refunded"
+		if err := tx.Save(&card).Error; err != nil {
+			return fmt.Errorf("更新月卡状态失败: %w", err)
+		}
+
+		if err := tx.Where("vehicle_id = ? AND list_type = ?", card.VehicleID, "白名单").
+			Unscoped().Delete(&dao.Shortlist{}).Error; err != nil {
+			return fmt.Errorf("移除白名单失败: %w", err)
+		}
+		return nil
+	})
 }
 
 func (s *MonthlyCardService) List(q repository.MonthlyCardQuery) ([]repository.MonthlyCardResult, int64, error) {

+ 233 - 0
internal/modules/monthly/service/service_test.go

@@ -0,0 +1,233 @@
+package service
+
+import (
+	"testing"
+	"time"
+
+	"github.com/glebarez/sqlite"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+	"wails-app/internal/dao"
+	"wails-app/internal/global"
+)
+
+func setupMonthlyServiceTest(t *testing.T) *MonthlyCardService {
+	t.Helper()
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+	require.NoError(t, err)
+	require.NoError(t, db.AutoMigrate(&dao.Vehicle{}, &dao.MonthlyCard{}, &dao.Shortlist{}, &dao.PaymentRecord{}))
+	global.GVA_DB = db
+	t.Cleanup(func() { global.GVA_DB = nil })
+	return NewMonthlyCardService()
+}
+
+func createVehicle(t *testing.T, plate string) *dao.Vehicle {
+	t.Helper()
+	vehicle := dao.Vehicle{PlateNumber: plate, VehicleTypeID: 1}
+	require.NoError(t, global.GVA_DB.Create(&vehicle).Error)
+	return &vehicle
+}
+
+func countTable(t *testing.T, model interface{}) int64 {
+	t.Helper()
+	var count int64
+	require.NoError(t, global.GVA_DB.Model(model).Count(&count).Error)
+	return count
+}
+
+func TestCreateWritesCardWhitelistAndPayment(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-001")
+
+	card, err := svc.Create(vehicle.ID, "month", 300, 7, "测试办理")
+	require.NoError(t, err)
+	require.NotZero(t, card.ID)
+	require.Equal(t, "paid", card.PaymentStatus)
+	require.True(t, card.EndDate.After(time.Now().Add(29*24*time.Hour)))
+
+	// 三张表全部写入
+	require.Equal(t, int64(1), countTable(t, &dao.MonthlyCard{}))
+	require.Equal(t, int64(1), countTable(t, &dao.Shortlist{}))
+	require.Equal(t, int64(1), countTable(t, &dao.PaymentRecord{}))
+
+	// 白名单到期日与月卡一致
+	var shortlist dao.Shortlist
+	require.NoError(t, global.GVA_DB.First(&shortlist).Error)
+	require.Equal(t, "白名单", shortlist.ListType)
+	require.NotNil(t, shortlist.ExpirationTime)
+	require.True(t, shortlist.ExpirationTime.Equal(card.EndDate))
+
+	// 支付流水
+	var pay dao.PaymentRecord
+	require.NoError(t, global.GVA_DB.First(&pay).Error)
+	require.Equal(t, 300.0, pay.Amount)
+	require.Equal(t, uint(7), pay.OperatorID)
+}
+
+func TestCreateRollsBackWhenWhitelistWriteFails(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-002")
+
+	// 模拟白名单写入失败:删除 shortlist 表
+	require.NoError(t, global.GVA_DB.Exec("DROP TABLE shortlist").Error)
+
+	_, err := svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "白名单")
+
+	// 月卡与流水均回滚
+	require.Equal(t, int64(0), countTable(t, &dao.MonthlyCard{}))
+	require.Equal(t, int64(0), countTable(t, &dao.PaymentRecord{}))
+}
+
+func TestCreateRollsBackWhenPaymentWriteFails(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-003")
+
+	// 模拟支付流水写入失败:删除 payment_record 表
+	require.NoError(t, global.GVA_DB.Exec("DROP TABLE payment_record").Error)
+
+	_, err := svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "支付流水")
+
+	// 月卡与白名单均回滚
+	require.Equal(t, int64(0), countTable(t, &dao.MonthlyCard{}))
+	require.Equal(t, int64(0), countTable(t, &dao.Shortlist{}))
+}
+
+func TestCreateRejectsDuplicateActiveCard(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-004")
+
+	_, err := svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.NoError(t, err)
+
+	_, err = svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "已有有效月卡")
+	require.Equal(t, int64(1), countTable(t, &dao.MonthlyCard{}))
+}
+
+func TestCreateRejectsUnknownVehicle(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	_, err := svc.Create(99999, "month", 300, 7, "")
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "车辆不存在")
+}
+
+func TestRenewExtendsFromEndDateWhenActive(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-005")
+
+	now := time.Now()
+	card := &dao.MonthlyCard{
+		VehicleID: vehicle.ID, CardType: "month",
+		StartDate: now.AddDate(0, -1, 0), EndDate: now.AddDate(0, 0, 10),
+		Fee: 300, PaymentStatus: "paid",
+	}
+	require.NoError(t, global.GVA_DB.Create(card).Error)
+	originalEnd := card.EndDate
+	require.NoError(t, global.GVA_DB.Create(&dao.Shortlist{
+		VehicleId: int(vehicle.ID), ListType: "白名单", ExpirationTime: &originalEnd,
+	}).Error)
+
+	err := svc.Renew(card.ID, "month", 300, 7)
+	require.NoError(t, err)
+
+	var renewed dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&renewed, card.ID).Error)
+	// 未过期:从原到期日顺延 30 天
+	require.True(t, renewed.EndDate.After(now.AddDate(0, 0, 39)), "到期日应约为原到期日+30天")
+	require.True(t, renewed.EndDate.Before(now.AddDate(0, 0, 41)))
+	require.Equal(t, 600.0, renewed.Fee)
+
+	// 白名单到期日同步
+	var shortlist dao.Shortlist
+	require.NoError(t, global.GVA_DB.Where("vehicle_id = ?", vehicle.ID).First(&shortlist).Error)
+	require.True(t, shortlist.ExpirationTime.Equal(renewed.EndDate))
+}
+
+func TestRenewStartsFromNowWhenCardExpired(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-006")
+
+	now := time.Now()
+	card := &dao.MonthlyCard{
+		VehicleID: vehicle.ID, CardType: "month",
+		StartDate: now.AddDate(0, -3, 0), EndDate: now.AddDate(0, -2, 0), // 已过期 2 个月
+		Fee: 300, PaymentStatus: "paid",
+	}
+	require.NoError(t, global.GVA_DB.Create(card).Error)
+	expiredEnd := card.EndDate
+	require.NoError(t, global.GVA_DB.Create(&dao.Shortlist{
+		VehicleId: int(vehicle.ID), ListType: "白名单", ExpirationTime: &expiredEnd,
+	}).Error)
+
+	err := svc.Renew(card.ID, "month", 300, 7)
+	require.NoError(t, err)
+
+	var renewed dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&renewed, card.ID).Error)
+	// 已过期:从今天起算约 30 天,而不是从两个月前的到期日续期
+	require.True(t, renewed.EndDate.After(now.AddDate(0, 0, 29)), "过期卡应从今天起算")
+	require.True(t, renewed.EndDate.Before(now.AddDate(0, 0, 31)))
+
+	// 白名单到期日同步为新到期日(未过期卡同样适用)
+	var shortlist dao.Shortlist
+	require.NoError(t, global.GVA_DB.Where("vehicle_id = ?", vehicle.ID).First(&shortlist).Error)
+	require.True(t, shortlist.ExpirationTime.Equal(renewed.EndDate))
+}
+
+func TestRenewRollsBackWhenPaymentWriteFails(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-007")
+
+	now := time.Now()
+	card := &dao.MonthlyCard{
+		VehicleID: vehicle.ID, CardType: "month",
+		StartDate: now, EndDate: now.AddDate(0, 0, 10),
+		Fee: 300, PaymentStatus: "paid",
+	}
+	require.NoError(t, global.GVA_DB.Create(card).Error)
+	originalEnd := card.EndDate
+
+	require.NoError(t, global.GVA_DB.Exec("DROP TABLE payment_record").Error)
+
+	err := svc.Renew(card.ID, "month", 300, 7)
+	require.Error(t, err)
+
+	// 到期日与费用均未变(回滚)
+	var after dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&after, card.ID).Error)
+	require.True(t, after.EndDate.Equal(originalEnd))
+	require.Equal(t, 300.0, after.Fee)
+}
+
+func TestRefundMarksRefundedAndRemovesWhitelist(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	vehicle := createVehicle(t, "MC-008")
+
+	card, err := svc.Create(vehicle.ID, "month", 300, 7, "")
+	require.NoError(t, err)
+	require.Equal(t, int64(1), countTable(t, &dao.Shortlist{}))
+
+	err = svc.Refund(card.ID)
+	require.NoError(t, err)
+
+	var refunded dao.MonthlyCard
+	require.NoError(t, global.GVA_DB.First(&refunded, card.ID).Error)
+	require.Equal(t, "refunded", refunded.PaymentStatus)
+
+	var whitelistCount int64
+	require.NoError(t, global.GVA_DB.Model(&dao.Shortlist{}).
+		Where("vehicle_id = ? AND list_type = ?", vehicle.ID, "白名单").Count(&whitelistCount).Error)
+	require.Equal(t, int64(0), whitelistCount)
+}
+
+func TestRefundRejectsUnknownCard(t *testing.T) {
+	svc := setupMonthlyServiceTest(t)
+	err := svc.Refund(99999)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "月卡记录不存在")
+}