package service import ( "errors" "fmt" "math" "regexp" "strings" "time" "wails-app/internal/dao" "wails-app/internal/global" parkingSessionService "wails-app/internal/modules/parking-session/service" "wails-app/internal/modules/payment/repository" "gorm.io/gorm" ) // Internal payment constants and configuration values. const ( PaymentCash = "cash" PaymentFree = "free" PaymentPOS = "pos" PaymentTicketQR = "ticket_qr" InputModeManualAmount = "manual_amount" InputModeConfirm = "confirm" InputModeExternal = "external" EntryCounter = "counter" EntryTicket = "ticket" // Scene* are kept as source-compatible aliases for older callers. New code // should pass an Entry* value and treat it as the payment entry, not a // property of the payment method. SceneCounter = EntryCounter SceneTicket = EntryTicket ) var paymentCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,31}$`) var ( ErrPaymentAlreadyProcessed = errors.New("payment already processed") ErrVehicleRecordNotFound = errors.New("vehicle record not found") ) type PaymentService struct { repo *repository.PaymentRepository } func NewPaymentService() *PaymentService { return &PaymentService{repo: &repository.PaymentRepository{}} } func (s *PaymentService) ListMethods() ([]dao.PaymentMethodConfig, error) { return s.repo.MethodList() } func (s *PaymentService) ListEntries() ([]dao.PaymentEntryConfig, error) { return s.repo.EntryList() } func (s *PaymentService) ListEnabledEntries() ([]dao.PaymentEntryConfig, error) { entries, err := s.repo.EntryList() if err != nil { return nil, err } result := make([]dao.PaymentEntryConfig, 0, len(entries)) for _, entry := range entries { if entry.Enabled { result = append(result, entry) } } return result, nil } func (s *PaymentService) ListEnabledMethods(entryCode string) ([]dao.PaymentMethodConfig, error) { if entryCode != "" && entryCode != EntryCounter && entryCode != EntryTicket { // Custom entries are valid too; only reject an empty value at the API // boundary where an entry is required. if _, err := s.repo.GetEntryByCode(nil, entryCode); err != nil { return nil, errors.New("无效的支付入口") } } methods, err := s.repo.EnabledMethodList(global.GVA_DB, entryCode) if err == nil { return methods, nil } // Existing installations created before the split may not have the // relation tables yet. Keep their old scene data usable until startup // migration has completed. if strings.Contains(strings.ToLower(err.Error()), "no such table") { return s.listLegacyEnabledMethods(entryCode), nil } return nil, err } func (s *PaymentService) listLegacyEnabledMethods(entryCode string) []dao.PaymentMethodConfig { methods, err := s.repo.MethodList() if err != nil { return nil } result := make([]dao.PaymentMethodConfig, 0, len(methods)) for _, method := range methods { if method.Enabled && containsScene(method.Scenes, entryCode) { result = append(result, method) } } return result } func normalizeCodes(codes []string) []string { seen := make(map[string]bool, len(codes)) result := make([]string, 0, len(codes)) for _, code := range codes { code = strings.ToLower(strings.TrimSpace(code)) if code != "" && !seen[code] { seen[code] = true result = append(result, code) } } return result } func normalizeMethod(method *dao.PaymentMethodConfig) error { method.Code = strings.ToLower(strings.TrimSpace(method.Code)) method.Name = strings.TrimSpace(method.Name) method.InputMode = strings.TrimSpace(method.InputMode) method.Description = strings.TrimSpace(method.Description) if !paymentCodePattern.MatchString(method.Code) { return errors.New("支付编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为2-32位") } if method.Name == "" || len([]rune(method.Name)) > 50 { return errors.New("支付方式名称不能为空且不能超过50个字符") } validModes := map[string]bool{InputModeManualAmount: true, InputModeConfirm: true, InputModeExternal: true} if !validModes[method.InputMode] { return errors.New("无效的金额处理方式") } method.EntryCodes = normalizeCodes(method.EntryCodes) // Scenes is only read during migration. Do not use it as a runtime // authorization source after payment entries have been configured. method.Scenes = normalizeCodes(method.Scenes) return nil } func (s *PaymentService) CreateMethod(method *dao.PaymentMethodConfig) error { if err := normalizeMethod(method); err != nil { return err } method.IsSystem = false err := global.GVA_DB.Transaction(func(tx *gorm.DB) error { if err := s.repo.CreateMethodTx(tx, method); err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { return errors.New("支付编码已存在") } return fmt.Errorf("新增支付方式失败: %w", err) } if len(method.EntryCodes) > 0 { if err := s.validateMethodEntryCodes(tx, method.EntryCodes); err != nil { return err } return s.repo.ReplaceMethodEntries(tx, method.Code, method.EntryCodes) } return nil }) return err } func (s *PaymentService) UpdateMethod(id uint, updates *dao.PaymentMethodConfig) error { current, err := s.repo.GetMethodByID(id) if err != nil { return errors.New("支付方式不存在") } updates.Code = current.Code updates.IsSystem = current.IsSystem updates.EntryCodes = nil if current.Code == PaymentCash { updates.InputMode = InputModeManualAmount updates.Enabled = true } if err := normalizeMethod(updates); err != nil { return err } updates.ID = current.ID return s.repo.UpdateMethod(updates) } func (s *PaymentService) SetMethodEnabled(id uint, enabled bool) error { method, err := s.repo.GetMethodByID(id) if err != nil { return errors.New("支付方式不存在") } if method.Code == PaymentCash && !enabled { return errors.New("人工支付是基础功能,不能停用") } return s.repo.SetMethodEnabled(id, enabled) } func (s *PaymentService) DeleteMethod(id uint) error { method, err := s.repo.GetMethodByID(id) if err != nil { return errors.New("支付方式不存在") } if method.IsSystem { return errors.New("系统内置支付方式不能删除") } return s.repo.DeleteMethod(method) } func normalizeEntry(entry *dao.PaymentEntryConfig) error { entry.Code = strings.ToLower(strings.TrimSpace(entry.Code)) entry.Name = strings.TrimSpace(entry.Name) entry.Description = strings.TrimSpace(entry.Description) if !paymentCodePattern.MatchString(entry.Code) { return errors.New("支付入口编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为2-32位") } if entry.Name == "" || len([]rune(entry.Name)) > 50 { return errors.New("支付入口名称不能为空且不能超过50个字符") } entry.MethodCodes = normalizeCodes(entry.MethodCodes) if !containsScene(entry.MethodCodes, PaymentCash) { entry.MethodCodes = append([]string{PaymentCash}, entry.MethodCodes...) } return nil } func (s *PaymentService) validateEntryCodes(db *gorm.DB, codes []string) error { for _, code := range codes { method, err := s.repo.GetMethodByCode(db, code) if err != nil || method.IsLegacy { return fmt.Errorf("支付方式“%s”不存在或不可用", code) } } return nil } func (s *PaymentService) validateMethodEntryCodes(db *gorm.DB, codes []string) error { for _, code := range codes { if _, err := s.repo.GetEntryByCode(db, code); err != nil { return fmt.Errorf("支付入口“%s”不存在", code) } } return nil } func (s *PaymentService) CreateEntry(entry *dao.PaymentEntryConfig) error { if err := normalizeEntry(entry); err != nil { return err } if err := s.validateEntryCodes(global.GVA_DB, entry.MethodCodes); err != nil { return err } entry.IsSystem = false return global.GVA_DB.Transaction(func(tx *gorm.DB) error { if err := s.repo.CreateEntryTx(tx, entry); err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { return errors.New("支付入口编码已存在") } return fmt.Errorf("新增支付入口失败: %w", err) } return s.repo.ReplaceEntryMethods(tx, entry.Code, entry.MethodCodes) }) } func (s *PaymentService) UpdateEntry(id uint, updates *dao.PaymentEntryConfig) error { current, err := s.repo.GetEntryByID(id) if err != nil { return errors.New("支付入口不存在") } updates.Code = current.Code updates.IsSystem = current.IsSystem if err := normalizeEntry(updates); err != nil { return err } if err := s.validateEntryCodes(global.GVA_DB, updates.MethodCodes); err != nil { return err } updates.ID = current.ID return global.GVA_DB.Transaction(func(tx *gorm.DB) error { if err := s.repo.UpdateEntryTx(tx, updates); err != nil { return err } return s.repo.ReplaceEntryMethods(tx, current.Code, updates.MethodCodes) }) } func (s *PaymentService) SetEntryEnabled(id uint, enabled bool) error { if _, err := s.repo.GetEntryByID(id); err != nil { return errors.New("支付入口不存在") } return s.repo.SetEntryEnabled(id, enabled) } func (s *PaymentService) DeleteEntry(id uint) error { entry, err := s.repo.GetEntryByID(id) if err != nil { return errors.New("支付入口不存在") } if entry.IsSystem { return errors.New("系统内置支付入口不能删除") } return s.repo.DeleteEntry(entry) } func containsScene(scenes []string, scene string) bool { for _, item := range scenes { if item == scene { return true } } return false } func (s *PaymentService) ValidateMethod(db *gorm.DB, code, entryCode string) (*dao.PaymentMethodConfig, error) { if code == PaymentFree { if entryCode != EntryCounter { return nil, errors.New("免密放行不能用于当前支付场景") } entry, err := s.repo.GetEntryByCode(db, entryCode) if err == nil && !entry.Enabled { return nil, fmt.Errorf("支付入口“%s”已停用", entry.Name) } if err != nil && !strings.Contains(strings.ToLower(err.Error()), "no such table") { return nil, errors.New("支付入口不存在") } return &dao.PaymentMethodConfig{Code: PaymentFree, Name: "免密放行", InputMode: InputModeConfirm, Enabled: true}, nil } entry, entryErr := s.repo.GetEntryByCode(db, entryCode) method, methodErr := s.repo.GetMethodByCode(db, code) if methodErr != nil { return nil, errors.New("支付方式不存在") } if !method.Enabled { return nil, fmt.Errorf("支付方式“%s”已停用", method.Name) } if entryErr != nil { if strings.Contains(strings.ToLower(entryErr.Error()), "no such table") && methodErr == nil { if containsScene(method.Scenes, entryCode) { return method, nil } return nil, fmt.Errorf("支付方式“%s”不适用于当前场景", method.Name) } return nil, errors.New("支付入口不存在") } if !entry.Enabled { return nil, fmt.Errorf("支付入口“%s”已停用", entry.Name) } allowed, err := s.repo.IsMethodAllowed(db, entryCode, code) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "no such table") && containsScene(method.Scenes, entryCode) { return method, nil } return nil, err } if !allowed { return nil, fmt.Errorf("支付方式“%s”未配置到当前支付入口", method.Name) } return method, nil } func (s *PaymentService) ValidatePayment(db *gorm.DB, code, entryCode string, amount, paidAmount float64) (*dao.PaymentMethodConfig, float64, error) { method, err := s.ValidateMethod(db, code, entryCode) if err != nil { return nil, 0, err } if math.IsNaN(amount) || math.IsInf(amount, 0) || math.IsNaN(paidAmount) || math.IsInf(paidAmount, 0) || amount < 0 || paidAmount < 0 { return nil, 0, errors.New("支付金额必须是有限的非负数") } if code == PaymentFree { return method, 0, nil } if method.InputMode == InputModeManualAmount { if paidAmount < amount { return nil, 0, errors.New("实收金额不足") } return method, paidAmount, nil } return method, amount, nil } // ProcessPayment creates a payment_record and updates vehicle_record payment status func (s *PaymentService) ProcessPayment(recordID uint, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) { if global.GVA_DB == nil { return nil, errors.New("database not initialized") } var result *dao.PaymentRecord err := global.GVA_DB.Transaction(func(tx *gorm.DB) error { var err error result, err = s.ProcessPaymentTxAtEntry(tx, recordID, EntryCounter, paymentMethod, amount, paidAmount, operatorID) return err }) if err != nil { return nil, err } return result, nil } // ProcessPaymentTx keeps the pre-split argument order for callers compiled // against the old service. The final argument is now interpreted as the // payment entry. func (s *PaymentService) ProcessPaymentTx(db *gorm.DB, recordID uint, paymentMethod string, amount float64, paidAmount float64, operatorID uint, paymentEntry string) (*dao.PaymentRecord, error) { return s.ProcessPaymentTxAtEntry(db, recordID, paymentEntry, paymentMethod, amount, paidAmount, operatorID) } func (s *PaymentService) ProcessPaymentTxAtEntry(db *gorm.DB, recordID uint, paymentEntry string, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) { if db == nil { return nil, errors.New("database not initialized") } method, paidAmount, err := s.ValidatePayment(db, paymentMethod, paymentEntry, amount, paidAmount) if err != nil { return nil, err } changeAmount := 0.0 if method.InputMode == InputModeManualAmount && paidAmount > amount { changeAmount = paidAmount - amount } if recordID != 0 { sessionSvc := parkingSessionService.NewParkingSessionService() if _, err := sessionSvc.MarkPaidTx(db, recordID, paymentEntry, paymentMethod, amount); err != nil { switch { case errors.Is(err, parkingSessionService.ErrSessionNotFound): return nil, ErrVehicleRecordNotFound case errors.Is(err, parkingSessionService.ErrSessionAlreadyPaid), errors.Is(err, parkingSessionService.ErrSessionAlreadyClosed): return nil, fmt.Errorf("payment already processed: %w", ErrPaymentAlreadyProcessed) default: return nil, fmt.Errorf("failed to update payment status: %w", err) } } } now := time.Now() pr := &dao.PaymentRecord{ RecordID: recordID, PaymentEntry: paymentEntry, PaymentMethod: paymentMethod, Amount: amount, PaidAmount: paidAmount, ChangeAmount: changeAmount, OperatorID: operatorID, PaidAt: now, } if err := db.Create(pr).Error; err != nil { return nil, fmt.Errorf("创建支付记录失败: %w", err) } return pr, nil } // QueryPayments 查询收费记录列表 func (s *PaymentService) QueryPayments(q repository.PaymentListQuery) ([]repository.PaymentListResult, int64, error) { return s.repo.List(q) } // GetTodaySummary 获取当日收费汇总 func (s *PaymentService) GetTodaySummary() (repository.TodaySummary, error) { return s.repo.GetTodaySummary() }