package service import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "math" "strconv" "strings" "time" "gorm.io/gorm" "wails-app/internal/dao" vehicleRequest "wails-app/internal/model/vehicle/request" "wails-app/internal/modules/central-payment/model/request" centralRepo "wails-app/internal/modules/central-payment/repository" paymentService "wails-app/internal/modules/payment/service" utils "wails-app/internal/pkg" vehicleService "wails-app/internal/service/vehicle" ) const ( PaymentEntryCentral = "central_payment_machine" tokenLifetime = 30 * time.Minute ) type Service struct { repo *centralRepo.Repository vehicleSvc *vehicleService.VehicleService } func New() *Service { return &Service{repo: centralRepo.New(), vehicleSvc: &vehicleService.VehicleService{}} } func tokenHash(token string) string { digest := sha256.Sum256([]byte(token)) return hex.EncodeToString(digest[:]) } func randomToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } func (s *Service) Login(req request.LoginRequest) (string, error) { username := strings.TrimSpace(req.UserName) if username == "" || req.Password == "" { return "", errors.New("用户名或密码不能为空") } device, err := s.repo.FindDeviceByUsername(username) if err != nil || !device.Enabled || !utils.BcryptCheck(req.Password, device.PasswordHash) { return "", errors.New("用户名或密码错误") } token, err := randomToken() if err != nil { return "", fmt.Errorf("生成设备令牌失败: %w", err) } if err := s.repo.ReplaceTokens(device.DeviceID, &dao.CentralPaymentToken{ DeviceID: device.DeviceID, TokenHash: tokenHash(token), ExpireAt: time.Now().Add(tokenLifetime), }); err != nil { return "", fmt.Errorf("保存设备令牌失败: %w", err) } return token, nil } func (s *Service) Authenticate(token string) (*dao.CentralPaymentDevice, error) { if strings.TrimSpace(token) == "" { return nil, errors.New("Token不能为空") } device, err := s.repo.FindDeviceByTokenHash(tokenHash(token), time.Now()) if err != nil { return nil, errors.New("Token无效或已过期") } return device, nil } func stopTimeText(minutes int64) string { if minutes < 60 { return fmt.Sprintf("%d分钟", minutes) } return fmt.Sprintf("%d小时%d分钟", minutes/60, minutes%60) } func moneyString(amount float64) string { return strconv.FormatFloat(amount, 'f', 2, 64) } func parseMoney(raw json.RawMessage) (float64, error) { value := strings.TrimSpace(string(raw)) if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { if err := json.Unmarshal(raw, &value); err != nil { return 0, errors.New("Money格式错误") } } amount, err := strconv.ParseFloat(strings.TrimSpace(value), 64) if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 { return 0, errors.New("Money必须是非负金额") } return amount, nil } func moneyEqual(left, right float64) bool { return math.Abs(left-right) < 0.005 } func paymentMethod(payType int) (string, error) { switch payType { case 0: return paymentService.PaymentCash, nil case 1: return paymentService.PaymentPOS, nil default: return "", errors.New("PayType仅支持0(现金)或1(刷卡)") } } type Price struct { OrderID string CarNo string InTime time.Time OutTime time.Time StopTime string Price string } func (s *Service) QueryPrice(device *dao.CentralPaymentDevice, carNo string) (*Price, error) { carNo = strings.TrimSpace(carNo) if carNo == "" { return nil, errors.New("CarNo不能为空") } preview, err := s.vehicleSvc.ExitPreview(vehicleRequest.ExitPreviewRequest{PlateNumber: carNo}) if err != nil { return nil, err } if preview.TicketNo == "" || preview.TicketState != "pending_payment" || preview.PaymentStatus != "unpaid" { return nil, errors.New("当前订单不允许缴费") } if device != nil && device.ParkingLotID != 0 && device.ParkingLotID != preview.ParkingLotID { return nil, errors.New("设备无权查询该停车场订单") } now := time.Now() return &Price{ OrderID: preview.TicketNo, CarNo: preview.PlateNumber, InTime: time.Unix(preview.EntryTime, 0), OutTime: now, StopTime: stopTimeText(preview.StayTime), Price: moneyString(preview.Fee), }, nil } func (s *Service) PaySuccess(device *dao.CentralPaymentDevice, req request.PaySuccessRequest) error { if req.Data.PayDev != 2 { return errors.New("PayDev必须为2(POF)") } // Token authentication already binds the request to a central payment // device. DevId is retained as a protocol/audit field and is deliberately // non-blocking because vendor hardware identifiers may differ from the // management-system device_id. method, err := paymentMethod(req.Data.PayType) if err != nil { return err } money, err := parseMoney(req.Data.Money) if err != nil { return err } orderID := strings.TrimSpace(req.Data.OrderID) carNo := strings.TrimSpace(req.Data.CarNo) if orderID == "" || carNo == "" { return errors.New("OrderId和CarNo不能为空") } ticket, err := s.repo.FindTicket(orderID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("订单不存在") } return err } if ticket.PlateNumber != "" && ticket.PlateNumber != carNo { return errors.New("OrderId与CarNo不匹配") } entry := device.PaymentEntry if entry == "" { entry = PaymentEntryCentral } if ticket.State == "paid" { if !s.matchesExistingPayment(ticket, entry, method, money) { return errors.New("订单已支付,支付数据不一致") } return nil } if ticket.State != "pending_payment" { return fmt.Errorf("订单当前状态不允许支付: %s", ticket.State) } preview, err := s.vehicleSvc.ExitPreview(vehicleRequest.ExitPreviewRequest{SessionID: ticket.VehicleRecordID}) if err != nil { return fmt.Errorf("重新计算订单费用失败: %w", err) } if preview.TicketNo != orderID || preview.PlateNumber != carNo { return errors.New("订单对应的在场车辆不匹配") } if device.ParkingLotID != 0 && device.ParkingLotID != preview.ParkingLotID { return errors.New("设备无权支付该停车场订单") } if !moneyEqual(preview.Fee, money) { return errors.New("金额已变化,请重新查询") } if err := s.vehicleSvc.PayDigitalTicket(orderID, entry, method, "", money, 0); err != nil { // A retry can race with the first notification. Re-read the order so a // successful first payment is reported as an idempotent success. if current, findErr := s.repo.FindTicket(orderID); findErr == nil && current.State == "paid" && s.matchesExistingPayment(current, entry, method, money) { return nil } return fmt.Errorf("写入数字票支付状态失败: %w", err) } return nil } // PayStatus returns the persisted ticket state without recalculating or // mutating payment data. It is intended for payment-machine retry/reconcile // flows after a paysuccess response was lost. func (s *Service) PayStatus(device *dao.CentralPaymentDevice, req request.PayStatusRequest) (*dao.DigitalTicket, *dao.VehicleRecord, error) { orderID := strings.TrimSpace(req.Data.OrderID) carNo := strings.TrimSpace(req.Data.CarNo) if orderID == "" { return nil, nil, errors.New("OrderId不能为空") } ticket, err := s.repo.FindTicket(orderID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, errors.New("订单不存在") } return nil, nil, err } if carNo != "" && ticket.PlateNumber != "" && ticket.PlateNumber != carNo { return nil, nil, errors.New("OrderId与CarNo不匹配") } record, err := s.repo.FindVehicleRecord(ticket.VehicleRecordID) if err != nil { return nil, nil, errors.New("订单对应的停车记录不存在") } if device.ParkingLotID != 0 && device.ParkingLotID != record.ParkingLotID { return nil, nil, errors.New("设备无权查询该停车场订单") } return ticket, record, nil } func (s *Service) matchesExistingPayment(ticket *dao.DigitalTicket, entry, method string, money float64) bool { payment, err := s.repo.FindLatestPayment(ticket.VehicleRecordID, entry) return err == nil && payment.PaymentMethod == method && moneyEqual(payment.Amount, money) } // Log persists protocol metadata for field troubleshooting. The caller must // pass a redacted body: device tokens and login passwords must never be saved. func (s *Service) Log(endpoint, deviceID, sourceIP string, body interface{}, result int, remark string) { payload, err := json.Marshal(body) if err != nil { payload = []byte("{}") } s.repo.Log(&dao.CentralPaymentRequestLog{ Endpoint: endpoint, DeviceID: deviceID, SourceIP: sourceIP, RequestBody: string(payload), Result: result, Remark: remark, }) }