|
|
@@ -1,22 +1,41 @@
|
|
|
package service
|
|
|
|
|
|
import (
|
|
|
+ "errors"
|
|
|
"fmt"
|
|
|
+ "regexp"
|
|
|
+ "strings"
|
|
|
"time"
|
|
|
"wails-app/internal/dao"
|
|
|
"wails-app/internal/global"
|
|
|
"wails-app/internal/modules/payment/repository"
|
|
|
+
|
|
|
+ "gorm.io/gorm"
|
|
|
)
|
|
|
|
|
|
-// Payment method constants — add new methods here for POS/Wechat/Alipay
|
|
|
+// Internal payment constants and configuration values.
|
|
|
const (
|
|
|
- PaymentCash = "cash"
|
|
|
- PaymentFree = "free"
|
|
|
- PaymentPOS = "pos"
|
|
|
- PaymentWechat = "wechat"
|
|
|
- PaymentAlipay = "alipay"
|
|
|
+ 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}$`)
|
|
|
+
|
|
|
type PaymentService struct {
|
|
|
repo *repository.PaymentRepository
|
|
|
}
|
|
|
@@ -25,16 +44,359 @@ 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 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) {
|
|
|
+ return s.ProcessPaymentTx(global.GVA_DB, recordID, paymentMethod, amount, paidAmount, operatorID, EntryCounter)
|
|
|
+}
|
|
|
+
|
|
|
+// 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) {
|
|
|
+ method, paidAmount, err := s.ValidatePayment(db, paymentMethod, paymentEntry, amount, paidAmount)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
changeAmount := 0.0
|
|
|
- if paymentMethod == PaymentCash && paidAmount > amount {
|
|
|
+ if method.InputMode == InputModeManualAmount && paidAmount > amount {
|
|
|
changeAmount = paidAmount - amount
|
|
|
}
|
|
|
|
|
|
now := time.Now()
|
|
|
pr := &dao.PaymentRecord{
|
|
|
RecordID: recordID,
|
|
|
+ PaymentEntry: paymentEntry,
|
|
|
PaymentMethod: paymentMethod,
|
|
|
Amount: amount,
|
|
|
PaidAmount: paidAmount,
|
|
|
@@ -43,15 +405,18 @@ func (s *PaymentService) ProcessPayment(recordID uint, paymentMethod string, amo
|
|
|
PaidAt: now,
|
|
|
}
|
|
|
|
|
|
- if err := s.repo.Create(pr); err != nil {
|
|
|
+ if err := db.Create(pr).Error; err != nil {
|
|
|
return nil, fmt.Errorf("创建支付记录失败: %w", err)
|
|
|
}
|
|
|
|
|
|
// Update vehicle_record payment status
|
|
|
- global.GVA_DB.Model(&dao.VehicleRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
|
|
|
+ if err := db.Model(&dao.VehicleRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
|
|
|
"payment_status": "paid",
|
|
|
+ "payment_entry": paymentEntry,
|
|
|
"payment_method": paymentMethod,
|
|
|
- })
|
|
|
+ }).Error; err != nil {
|
|
|
+ return nil, fmt.Errorf("更新支付状态失败: %w", err)
|
|
|
+ }
|
|
|
|
|
|
return pr, nil
|
|
|
}
|