| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- 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,
- })
- }
|