package service import ( "errors" "fmt" "math" "strings" "time" "wails-app/internal/dao" "wails-app/internal/global" "wails-app/internal/modules/parking-session/model/request" "wails-app/internal/modules/parking-session/repository" "gorm.io/gorm" ) const ( PaymentStatusUnpaid = "unpaid" PaymentStatusPaid = "paid" ) var ( ErrSessionNotFound = errors.New("未找到在场停车会话") ErrSessionAlreadyOpen = errors.New("车辆已有在场停车会话") ErrSessionAlreadyPaid = errors.New("停车会话已完成支付") ErrSessionAlreadyClosed = errors.New("停车会话已关闭") ErrSessionIdentityConflict = errors.New("车牌与RFID对应不同的在场停车会话") ErrSessionAmbiguous = errors.New("存在多条匹配的在场停车会话") ErrSessionIdentifierEmpty = errors.New("停车会话必须提供车牌、RFID或会话编号") ErrParkingLotNotFound = errors.New("停车场不存在") ErrParkingLotFull = errors.New("停车场车位已满") ) // ParkingSessionService owns the parking-stay lifecycle. It deliberately // persists to vehicle_record so existing reports and historical data remain // compatible while callers stop treating the record as an unstructured table. type ParkingSessionService struct { repo *repository.ParkingSessionRepository } func NewParkingSessionService() *ParkingSessionService { return &ParkingSessionService{repo: &repository.ParkingSessionRepository{}} } // FindActiveSession is the domain-facing name used by adapters that do not // need to know whether the lookup is backed by a transaction. func (s *ParkingSessionService) FindActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) { return s.ResolveActiveSession(req) } func (s *ParkingSessionService) OpenSession(req request.OpenSessionRequest) (*dao.VehicleRecord, error) { if global.GVA_DB == nil { return nil, errors.New("数据库未初始化") } var session *dao.VehicleRecord err := global.GVA_DB.Transaction(func(tx *gorm.DB) error { var err error session, err = s.OpenSessionTx(tx, req) return err }) return session, err } func (s *ParkingSessionService) MarkPaid(sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) { if global.GVA_DB == nil { return nil, errors.New("数据库未初始化") } var session *dao.VehicleRecord err := global.GVA_DB.Transaction(func(tx *gorm.DB) error { var err error session, err = s.MarkPaidTx(tx, sessionID, paymentEntry, paymentMethod, fee) return err }) return session, err } func (s *ParkingSessionService) CloseSession(req request.CloseSessionRequest) (*dao.VehicleRecord, error) { if global.GVA_DB == nil { return nil, errors.New("数据库未初始化") } var session *dao.VehicleRecord err := global.GVA_DB.Transaction(func(tx *gorm.DB) error { var err error session, err = s.CloseSessionTx(tx, req) return err }) return session, err } func (s *ParkingSessionService) ResolveActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) { return s.ResolveActiveSessionTx(global.GVA_DB, req) } // ResolveActiveSessionTx resolves one active parking session deterministically. // When both identities are supplied, it refuses to pick an arbitrary session // if the plate and RFID point to different active records. func (s *ParkingSessionService) ResolveActiveSessionTx(db *gorm.DB, req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) { if db == nil { return nil, errors.New("数据库未初始化") } if req.SessionID != 0 { session, err := s.repo.FindActiveByIDTx(db, req.SessionID) if err == nil { return session, nil } return nil, sessionLookupError(db, req.SessionID, err) } plateNumber := strings.TrimSpace(req.PlateNumber) rfidTag := strings.TrimSpace(req.RFIDTag) if plateNumber == "" && rfidTag == "" { return nil, ErrSessionIdentifierEmpty } if plateNumber != "" && rfidTag != "" { if session, err := s.repo.FindActiveByExactIdentityTx(db, plateNumber, rfidTag); err == nil { return session, nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } var plateSessions, rfidSessions []dao.VehicleRecord var err error if plateNumber != "" { plateSessions, err = s.repo.FindActiveByPlateTx(db, plateNumber) if err != nil { return nil, err } } if rfidTag != "" { rfidSessions, err = s.repo.FindActiveByRFIDTx(db, rfidTag) if err != nil { return nil, err } } if len(plateSessions) > 1 || len(rfidSessions) > 1 { return nil, ErrSessionAmbiguous } if len(plateSessions) == 1 && len(rfidSessions) == 1 && plateSessions[0].ID != rfidSessions[0].ID { return nil, ErrSessionIdentityConflict } if len(plateSessions) == 1 { return &plateSessions[0], nil } if len(rfidSessions) == 1 { return &rfidSessions[0], nil } return nil, ErrSessionNotFound } func sessionLookupError(db *gorm.DB, id uint, err error) error { if !errors.Is(err, gorm.ErrRecordNotFound) { return err } var session dao.VehicleRecord if lookupErr := db.Unscoped().First(&session, id).Error; lookupErr == nil { if session.ExitTime != nil { return ErrSessionAlreadyClosed } if session.PaymentStatus == PaymentStatusPaid { return ErrSessionAlreadyPaid } return ErrSessionNotFound } else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) { return lookupErr } return ErrSessionNotFound } // OpenSessionTx 创建持久化停车会话。 // 在调用方事务中再次查询在场会话,确保校验和写入属于同一个原子操作。 func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessionRequest) (*dao.VehicleRecord, error) { if db == nil { return nil, errors.New("数据库未初始化") } req.PlateNumber = strings.TrimSpace(req.PlateNumber) req.RFIDTag = strings.TrimSpace(req.RFIDTag) if req.PlateNumber == "" && req.RFIDTag == "" { return nil, ErrSessionIdentifierEmpty } _, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{ PlateNumber: req.PlateNumber, RFIDTag: req.RFIDTag, }) if err == nil { return nil, ErrSessionAlreadyOpen } if !errors.Is(err, ErrSessionNotFound) { return nil, err } if err := reserveParkingSpaceTx(db, req.ParkingLotID, req.AccessProfile); err != nil { return nil, err } entryTime := req.EntryTime if entryTime.IsZero() { entryTime = time.Now() } session := &dao.VehicleRecord{ PlateNumber: req.PlateNumber, RFIDTag: req.RFIDTag, EntryTime: entryTime, ParkingLotID: req.ParkingLotID, ParkingSpaceID: req.ParkingSpaceID, EntryChannelID: req.EntryChannelID, EntryChannelCode: req.EntryChannelCode, EntryChannelName: req.EntryChannelName, EntryDeviceCode: req.EntryDeviceCode, EntryDeviceName: req.EntryDeviceName, EntryImage: req.EntryImage, PaymentStatus: PaymentStatusUnpaid, } if err := s.repo.CreateTx(db, session); err != nil { return nil, err } return session, nil } // reserveParkingSpaceTx 在创建会话前原子增加停车场占用数。 // 满位时只有停车场明确允许的车辆身份可以继续入场。 func reserveParkingSpaceTx(db *gorm.DB, parkingLotID uint, profile request.EntryAccessProfile) error { if parkingLotID == 0 { return nil } result := db.Model(&dao.ParkingLot{}). Where(`id = ? AND ( occupied < capacity OR (? AND allow_temporary_when_full) OR (? AND allow_monthly_when_full) OR (? AND allow_vip_when_full) )`, parkingLotID, profile.IsTemporary, profile.HasValidMonthlyCard, profile.IsVIP). UpdateColumns(map[string]interface{}{ "occupied": gorm.Expr("occupied + 1"), "available": gorm.Expr("CASE WHEN capacity > occupied + 1 THEN capacity - occupied - 1 ELSE 0 END"), }) if result.Error != nil { return fmt.Errorf("更新停车场占用数失败: %w", result.Error) } if result.RowsAffected == 1 { return nil } var lot dao.ParkingLot if err := db.First(&lot, parkingLotID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrParkingLotNotFound } return err } if lot.Occupied < lot.Capacity { return errors.New("停车场车位占用状态更新失败") } switch { case profile.IsVIP && !lot.AllowVIPWhenFull: return fmt.Errorf("%w,当前停车场不允许 VIP 车满位入场", ErrParkingLotFull) case profile.HasValidMonthlyCard && !lot.AllowMonthlyWhenFull: return fmt.Errorf("%w,当前停车场不允许月租车满位入场", ErrParkingLotFull) case profile.IsTemporary && !lot.AllowTemporaryWhenFull: return fmt.Errorf("%w,当前停车场不允许临时车满位入场", ErrParkingLotFull) default: return ErrParkingLotFull } } // releaseParkingSpaceTx 在会话关闭后释放占用,并根据实际占用数重新计算余位。 func releaseParkingSpaceTx(db *gorm.DB, parkingLotID uint) error { if parkingLotID == 0 { return nil } result := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID). UpdateColumn("occupied", gorm.Expr("CASE WHEN occupied > 0 THEN occupied - 1 ELSE 0 END")) if result.Error != nil { return fmt.Errorf("释放停车场占用数失败: %w", result.Error) } if result.RowsAffected != 1 { return ErrParkingLotNotFound } if err := db.Model(&dao.ParkingLot{}).Where("id = ?", parkingLotID). UpdateColumn("available", gorm.Expr("CASE WHEN capacity > occupied THEN capacity - occupied ELSE 0 END")).Error; err != nil { return fmt.Errorf("更新停车场余位失败: %w", err) } return nil } // MarkPaidTx 更新在场会话的当前结算摘要。 // 实际资金流水仍以支付模块创建的 payment_record 为准。 func (s *ParkingSessionService) MarkPaidTx(db *gorm.DB, sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) { if db == nil { return nil, errors.New("数据库未初始化") } if sessionID == 0 { return nil, ErrSessionNotFound } if math.IsNaN(fee) || math.IsInf(fee, 0) || fee < 0 { return nil, errors.New("停车费用必须是有限的非负数") } session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: sessionID}) if err != nil { return nil, err } if session.PaymentStatus == PaymentStatusPaid { return nil, ErrSessionAlreadyPaid } rows, err := s.repo.UpdateUnpaidActiveTx(db, sessionID, map[string]interface{}{ "payment_status": PaymentStatusPaid, "payment_entry": paymentEntry, "payment_method": paymentMethod, "fee": fee, }) if err != nil { return nil, fmt.Errorf("更新停车会话支付状态失败: %w", err) } if rows != 1 { return nil, sessionLookupError(db, sessionID, gorm.ErrRecordNotFound) } session.PaymentStatus = PaymentStatusPaid session.PaymentEntry = paymentEntry session.PaymentMethod = paymentMethod session.Fee = fee return session, nil } // CloseSessionTx 只允许将一个已支付的停车会话结算出场一次。 func (s *ParkingSessionService) CloseSessionTx(db *gorm.DB, req request.CloseSessionRequest) (*dao.VehicleRecord, error) { if db == nil { return nil, errors.New("数据库未初始化") } if req.SessionID == 0 { return nil, ErrSessionNotFound } if req.StayTime < 0 || math.IsNaN(req.Fee) || math.IsInf(req.Fee, 0) || req.Fee < 0 { return nil, errors.New("停车会话结算数据无效") } session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: req.SessionID}) if err != nil { return nil, err } exitTime := req.ExitTime if exitTime.IsZero() { exitTime = time.Now() } paymentStatus := req.PaymentStatus if paymentStatus == "" { paymentStatus = PaymentStatusPaid } updates := map[string]interface{}{ "exit_time": exitTime, "stay_time": req.StayTime, "fee": req.Fee, "payment_status": paymentStatus, "payment_entry": req.PaymentEntry, "payment_method": req.PaymentMethod, } if req.ExitImage != "" { updates["exit_image"] = req.ExitImage } if req.ExitChannelID != 0 { updates["exit_channel_id"] = req.ExitChannelID } if req.ExitChannelCode != "" { updates["exit_channel_code"] = req.ExitChannelCode } if req.ExitChannelName != "" { updates["exit_channel_name"] = req.ExitChannelName } if req.ExitDeviceCode != "" { updates["exit_device_code"] = req.ExitDeviceCode } if req.ExitDeviceName != "" { updates["exit_device_name"] = req.ExitDeviceName } rows, err := s.repo.UpdateActiveTx(db, req.SessionID, updates) if err != nil { return nil, fmt.Errorf("关闭停车会话失败: %w", err) } if rows != 1 { return nil, sessionLookupError(db, req.SessionID, gorm.ErrRecordNotFound) } if err := releaseParkingSpaceTx(db, session.ParkingLotID); err != nil { return nil, err } session.ExitTime = &exitTime session.StayTime = req.StayTime session.Fee = req.Fee session.PaymentStatus = paymentStatus session.PaymentEntry = req.PaymentEntry session.PaymentMethod = req.PaymentMethod if req.ExitImage != "" { session.ExitImage = req.ExitImage } if req.ExitChannelID != 0 { session.ExitChannelID = req.ExitChannelID } if req.ExitChannelCode != "" { session.ExitChannelCode = req.ExitChannelCode } if req.ExitChannelName != "" { session.ExitChannelName = req.ExitChannelName } if req.ExitDeviceCode != "" { session.ExitDeviceCode = req.ExitDeviceCode } if req.ExitDeviceName != "" { session.ExitDeviceName = req.ExitDeviceName } return session, nil }