package service import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "gorm.io/gorm" "math" "time" "wails-app/internal/dao" "wails-app/internal/global" "wails-app/internal/modules/digital-ticket/repository" ) const ( StateCreated = "created" StatePendingPayment = "pending_payment" StatePaid = "paid" StateExited = "exited" StateExpired = "expired" ) var ( ErrTicketStateChanged = errors.New("digital ticket state changed") ErrInvalidTicketPayment = errors.New("invalid digital ticket payment amount") ) type TicketService struct { repo *repository.TicketRepository } func NewTicketService() *TicketService { return &TicketService{repo: &repository.TicketRepository{}} } func genTicketNo() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } func appendEvent(log string, event map[string]interface{}) string { var events []map[string]interface{} json.Unmarshal([]byte(log), &events) event["at"] = time.Now().Format(time.RFC3339) events = append(events, event) data, _ := json.Marshal(events) return string(data) } // Create 创建数字票(所有进场方式统一入口) func (s *TicketService) Create(plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) { return s.CreateTx(global.GVA_DB, plateNumber, triggerMode, recordID) } func (s *TicketService) CreateTx(db *gorm.DB, plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) { log := appendEvent("[]", map[string]interface{}{"event": "created", "trigger": triggerMode}) ticket := &dao.DigitalTicket{ TicketNo: genTicketNo(), PlateNumber: plateNumber, TriggerMode: triggerMode, State: StatePendingPayment, VehicleRecordID: recordID, EntryTime: time.Now(), EventLog: log, } if err := s.repo.CreateTx(db, ticket); err != nil { return nil, err } return ticket, nil } // Transition 状态转换(校验合法性,原子更新) func (s *TicketService) Transition(id uint, toState string, extra map[string]interface{}) error { return s.TransitionTx(global.GVA_DB, id, toState, extra) } func extraAmount(extra map[string]interface{}, key string) (float64, bool, error) { value, ok := extra[key] if !ok || value == nil { return 0, false, nil } switch number := value.(type) { case float64: return number, true, nil case float32: return float64(number), true, nil case int: return float64(number), true, nil case int8: return float64(number), true, nil case int16: return float64(number), true, nil case int32: return float64(number), true, nil case int64: return float64(number), true, nil case uint: return float64(number), true, nil case uint8: return float64(number), true, nil case uint16: return float64(number), true, nil case uint32: return float64(number), true, nil case uint64: return float64(number), true, nil case json.Number: parsed, err := number.Float64() return parsed, true, err default: return 0, true, fmt.Errorf("%s must be numeric", key) } } func validatePaymentExtra(extra map[string]interface{}) error { fee, hasFee, err := extraAmount(extra, "fee") if err != nil { return err } paid, hasPaid, err := extraAmount(extra, "paid_amount") if err != nil { return err } for _, amount := range []float64{fee, paid} { if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 { return ErrInvalidTicketPayment } } if hasFee && hasPaid && paid < fee { return errors.New("paid amount insufficient") } return nil } func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra map[string]interface{}) error { if db == nil { return errors.New("database not initialized") } ticket, err := s.repo.GetByIDTx(db, id) if err != nil { return errors.New("票不存在") } validTransitions := map[string][]string{ StateCreated: {StatePendingPayment}, StatePendingPayment: {StatePaid, StateExpired}, StatePaid: {StateExited, StateExpired}, } allowed := false for _, s := range validTransitions[ticket.State] { if s == toState { allowed = true break } } if !allowed { return fmt.Errorf("状态转换不合法: %s → %s", ticket.State, toState) } if toState == StatePaid { if err := validatePaymentExtra(extra); err != nil { return err } } updates := map[string]interface{}{} if toState == StatePaid { now := time.Now() updates["paid_at"] = now } if toState == StateExited { now := time.Now() updates["exit_time"] = now } // 只允许表中存在的字段写入数据库,其余仅记录到事件日志 dbFields := map[string]bool{"payment_entry": true, "payment_method": true, "payment_order_no": true, "paid_amount": true, "fee": true} for k, v := range extra { if dbFields[k] { updates[k] = v } } event := map[string]interface{}{"event": toState} for k, v := range extra { event[k] = v } updates["event_log"] = appendEvent(ticket.EventLog, event) updated, err := s.repo.UpdateStateIfCurrentTx(db, id, ticket.State, toState, updates) if err != nil { return err } if !updated { return ErrTicketStateChanged } return nil } // MarkPaidAndExitedTx updates the ticket lifecycle in the caller's // transaction. Payment entry and method are stored as payment metadata only; // they do not define the ticket itself. func (s *TicketService) MarkPaidAndExitedTx(db *gorm.DB, recordID uint, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount, fee float64) error { if err := validatePaymentExtra(map[string]interface{}{"paid_amount": paidAmount, "fee": fee}); err != nil { return err } ticket, err := s.repo.GetByVehicleRecordIDTx(db, recordID) if err != nil { return errors.New("车辆对应的数字票不存在") } if ticket.State == StatePendingPayment { extra := map[string]interface{}{ "payment_entry": paymentEntry, "payment_method": paymentMethod, "payment_order_no": paymentOrderNo, "paid_amount": paidAmount, "fee": fee, } if err := s.TransitionTx(db, ticket.ID, StatePaid, extra); err != nil { return err } ticket.State = StatePaid } if ticket.State == StatePaid { return s.TransitionTx(db, ticket.ID, StateExited, nil) } if ticket.State == StateExited { return nil } return fmt.Errorf("数字票状态不允许出场: %s", ticket.State) } func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) { ticket, err := s.repo.GetByTicketNo(no) if err != nil { return nil, err } s.enrichTicket(ticket) return ticket, nil } func (s *TicketService) GetByTicketNoTx(db *gorm.DB, no string) (*dao.DigitalTicket, error) { return s.repo.GetByTicketNoTx(db, no) } func (s *TicketService) GetByVehicleRecordIDTx(db *gorm.DB, recordID uint) (*dao.DigitalTicket, error) { return s.repo.GetByVehicleRecordIDTx(db, recordID) } func (s *TicketService) List(q repository.TicketListQuery) ([]dao.DigitalTicket, int64, error) { list, total, err := s.repo.List(q) if err != nil { return nil, 0, err } for i := range list { s.enrichTicket(&list[i]) } return list, total, nil } func (s *TicketService) Summary(q repository.TicketListQuery) (repository.TicketSummary, error) { return s.repo.Summary(q) } // enrichTicket keeps the ticket view useful before payment. A ticket is // created at vehicle entry, while the definitive fee is calculated at payment // or exit time. Pending tickets therefore need a live fee preview derived from // their vehicle record and current parking duration. func (s *TicketService) enrichTicket(ticket *dao.DigitalTicket) { if ticket == nil || ticket.VehicleRecordID == 0 || global.GVA_DB == nil { return } var record dao.VehicleRecord if err := global.GVA_DB.First(&record, ticket.VehicleRecordID).Error; err != nil { return } if record.PlateNumber != "" { ticket.PlateNumber = record.PlateNumber } var vehicle dao.Vehicle vehicleQuery := global.GVA_DB vehicleFound := false if record.PlateNumber != "" { vehicleQuery = vehicleQuery.Where("plate_number = ?", record.PlateNumber) vehicleFound = vehicleQuery.First(&vehicle).Error == nil } else if record.RFIDTag != "" { vehicleQuery = vehicleQuery.Where("rfid_tag = ?", record.RFIDTag) vehicleFound = vehicleQuery.First(&vehicle).Error == nil } if ticket.PlateNumber == "" && vehicleFound { ticket.PlateNumber = vehicle.PlateNumber } if ticket.State == StateCreated || ticket.State == StatePendingPayment { if !vehicleFound { return } stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes())) if stayTime < 0 { stayTime = 0 } // 预览保持宽容:未配置收费规则时费用显示 0,正式出场时由 CalculateFee 返回明确错误 if fee, calcErr := CalculateFee(&vehicle, stayTime); calcErr == nil { ticket.Fee = fee } return } // Older rows may have a zero digital-ticket fee even though the vehicle // record was already settled. Keep the historical record's fee visible. if ticket.Fee == 0 && record.Fee != 0 { ticket.Fee = record.Fee } }