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