service.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. package service
  2. import (
  3. "crypto/rand"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "math"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "gorm.io/gorm"
  14. "wails-app/internal/dao"
  15. vehicleRequest "wails-app/internal/model/vehicle/request"
  16. "wails-app/internal/modules/central-payment/model/request"
  17. centralRepo "wails-app/internal/modules/central-payment/repository"
  18. paymentService "wails-app/internal/modules/payment/service"
  19. utils "wails-app/internal/pkg"
  20. vehicleService "wails-app/internal/service/vehicle"
  21. )
  22. const (
  23. PaymentEntryCentral = "central_payment_machine"
  24. tokenLifetime = 30 * time.Minute
  25. )
  26. type Service struct {
  27. repo *centralRepo.Repository
  28. vehicleSvc *vehicleService.VehicleService
  29. }
  30. func New() *Service {
  31. return &Service{repo: centralRepo.New(), vehicleSvc: &vehicleService.VehicleService{}}
  32. }
  33. func tokenHash(token string) string {
  34. digest := sha256.Sum256([]byte(token))
  35. return hex.EncodeToString(digest[:])
  36. }
  37. func randomToken() (string, error) {
  38. bytes := make([]byte, 32)
  39. if _, err := rand.Read(bytes); err != nil {
  40. return "", err
  41. }
  42. return hex.EncodeToString(bytes), nil
  43. }
  44. func (s *Service) Login(req request.LoginRequest) (string, error) {
  45. username := strings.TrimSpace(req.UserName)
  46. if username == "" || req.Password == "" {
  47. return "", errors.New("用户名或密码不能为空")
  48. }
  49. device, err := s.repo.FindDeviceByUsername(username)
  50. if err != nil || !device.Enabled || !utils.BcryptCheck(req.Password, device.PasswordHash) {
  51. return "", errors.New("用户名或密码错误")
  52. }
  53. token, err := randomToken()
  54. if err != nil {
  55. return "", fmt.Errorf("生成设备令牌失败: %w", err)
  56. }
  57. if err := s.repo.ReplaceTokens(device.DeviceID, &dao.CentralPaymentToken{
  58. DeviceID: device.DeviceID, TokenHash: tokenHash(token), ExpireAt: time.Now().Add(tokenLifetime),
  59. }); err != nil {
  60. return "", fmt.Errorf("保存设备令牌失败: %w", err)
  61. }
  62. return token, nil
  63. }
  64. func (s *Service) Authenticate(token string) (*dao.CentralPaymentDevice, error) {
  65. if strings.TrimSpace(token) == "" {
  66. return nil, errors.New("Token不能为空")
  67. }
  68. device, err := s.repo.FindDeviceByTokenHash(tokenHash(token), time.Now())
  69. if err != nil {
  70. return nil, errors.New("Token无效或已过期")
  71. }
  72. return device, nil
  73. }
  74. func stopTimeText(minutes int64) string {
  75. if minutes < 60 {
  76. return fmt.Sprintf("%d分钟", minutes)
  77. }
  78. return fmt.Sprintf("%d小时%d分钟", minutes/60, minutes%60)
  79. }
  80. func moneyString(amount float64) string {
  81. return strconv.FormatFloat(amount, 'f', 2, 64)
  82. }
  83. func parseMoney(raw json.RawMessage) (float64, error) {
  84. value := strings.TrimSpace(string(raw))
  85. if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
  86. if err := json.Unmarshal(raw, &value); err != nil {
  87. return 0, errors.New("Money格式错误")
  88. }
  89. }
  90. amount, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
  91. if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
  92. return 0, errors.New("Money必须是非负金额")
  93. }
  94. return amount, nil
  95. }
  96. func moneyEqual(left, right float64) bool {
  97. return math.Abs(left-right) < 0.005
  98. }
  99. func paymentMethod(payType int) (string, error) {
  100. switch payType {
  101. case 0:
  102. return paymentService.PaymentCash, nil
  103. case 1:
  104. return paymentService.PaymentPOS, nil
  105. default:
  106. return "", errors.New("PayType仅支持0(现金)或1(刷卡)")
  107. }
  108. }
  109. type Price struct {
  110. OrderID string
  111. CarNo string
  112. InTime time.Time
  113. OutTime time.Time
  114. StopTime string
  115. Price string
  116. }
  117. func (s *Service) QueryPrice(device *dao.CentralPaymentDevice, carNo string) (*Price, error) {
  118. carNo = strings.TrimSpace(carNo)
  119. if carNo == "" {
  120. return nil, errors.New("CarNo不能为空")
  121. }
  122. preview, err := s.vehicleSvc.ExitPreview(vehicleRequest.ExitPreviewRequest{PlateNumber: carNo})
  123. if err != nil {
  124. return nil, err
  125. }
  126. if preview.TicketNo == "" || preview.TicketState != "pending_payment" || preview.PaymentStatus != "unpaid" {
  127. return nil, errors.New("当前订单不允许缴费")
  128. }
  129. if device != nil && device.ParkingLotID != 0 && device.ParkingLotID != preview.ParkingLotID {
  130. return nil, errors.New("设备无权查询该停车场订单")
  131. }
  132. now := time.Now()
  133. return &Price{
  134. OrderID: preview.TicketNo, CarNo: preview.PlateNumber,
  135. InTime: time.Unix(preview.EntryTime, 0), OutTime: now,
  136. StopTime: stopTimeText(preview.StayTime), Price: moneyString(preview.Fee),
  137. }, nil
  138. }
  139. func (s *Service) PaySuccess(device *dao.CentralPaymentDevice, req request.PaySuccessRequest) error {
  140. if req.Data.PayDev != 2 {
  141. return errors.New("PayDev必须为2(POF)")
  142. }
  143. // Token authentication already binds the request to a central payment
  144. // device. DevId is retained as a protocol/audit field and is deliberately
  145. // non-blocking because vendor hardware identifiers may differ from the
  146. // management-system device_id.
  147. method, err := paymentMethod(req.Data.PayType)
  148. if err != nil {
  149. return err
  150. }
  151. money, err := parseMoney(req.Data.Money)
  152. if err != nil {
  153. return err
  154. }
  155. orderID := strings.TrimSpace(req.Data.OrderID)
  156. carNo := strings.TrimSpace(req.Data.CarNo)
  157. if orderID == "" || carNo == "" {
  158. return errors.New("OrderId和CarNo不能为空")
  159. }
  160. ticket, err := s.repo.FindTicket(orderID)
  161. if err != nil {
  162. if errors.Is(err, gorm.ErrRecordNotFound) {
  163. return errors.New("订单不存在")
  164. }
  165. return err
  166. }
  167. if ticket.PlateNumber != "" && ticket.PlateNumber != carNo {
  168. return errors.New("OrderId与CarNo不匹配")
  169. }
  170. entry := device.PaymentEntry
  171. if entry == "" {
  172. entry = PaymentEntryCentral
  173. }
  174. if ticket.State == "paid" {
  175. if !s.matchesExistingPayment(ticket, entry, method, money) {
  176. return errors.New("订单已支付,支付数据不一致")
  177. }
  178. return nil
  179. }
  180. if ticket.State != "pending_payment" {
  181. return fmt.Errorf("订单当前状态不允许支付: %s", ticket.State)
  182. }
  183. preview, err := s.vehicleSvc.ExitPreview(vehicleRequest.ExitPreviewRequest{SessionID: ticket.VehicleRecordID})
  184. if err != nil {
  185. return fmt.Errorf("重新计算订单费用失败: %w", err)
  186. }
  187. if preview.TicketNo != orderID || preview.PlateNumber != carNo {
  188. return errors.New("订单对应的在场车辆不匹配")
  189. }
  190. if device.ParkingLotID != 0 && device.ParkingLotID != preview.ParkingLotID {
  191. return errors.New("设备无权支付该停车场订单")
  192. }
  193. if !moneyEqual(preview.Fee, money) {
  194. return errors.New("金额已变化,请重新查询")
  195. }
  196. if err := s.vehicleSvc.PayDigitalTicket(orderID, entry, method, "", money, 0); err != nil {
  197. // A retry can race with the first notification. Re-read the order so a
  198. // successful first payment is reported as an idempotent success.
  199. if current, findErr := s.repo.FindTicket(orderID); findErr == nil && current.State == "paid" && s.matchesExistingPayment(current, entry, method, money) {
  200. return nil
  201. }
  202. return fmt.Errorf("写入数字票支付状态失败: %w", err)
  203. }
  204. return nil
  205. }
  206. // PayStatus returns the persisted ticket state without recalculating or
  207. // mutating payment data. It is intended for payment-machine retry/reconcile
  208. // flows after a paysuccess response was lost.
  209. func (s *Service) PayStatus(device *dao.CentralPaymentDevice, req request.PayStatusRequest) (*dao.DigitalTicket, *dao.VehicleRecord, error) {
  210. orderID := strings.TrimSpace(req.Data.OrderID)
  211. carNo := strings.TrimSpace(req.Data.CarNo)
  212. if orderID == "" {
  213. return nil, nil, errors.New("OrderId不能为空")
  214. }
  215. ticket, err := s.repo.FindTicket(orderID)
  216. if err != nil {
  217. if errors.Is(err, gorm.ErrRecordNotFound) {
  218. return nil, nil, errors.New("订单不存在")
  219. }
  220. return nil, nil, err
  221. }
  222. if carNo != "" && ticket.PlateNumber != "" && ticket.PlateNumber != carNo {
  223. return nil, nil, errors.New("OrderId与CarNo不匹配")
  224. }
  225. record, err := s.repo.FindVehicleRecord(ticket.VehicleRecordID)
  226. if err != nil {
  227. return nil, nil, errors.New("订单对应的停车记录不存在")
  228. }
  229. if device.ParkingLotID != 0 && device.ParkingLotID != record.ParkingLotID {
  230. return nil, nil, errors.New("设备无权查询该停车场订单")
  231. }
  232. return ticket, record, nil
  233. }
  234. func (s *Service) matchesExistingPayment(ticket *dao.DigitalTicket, entry, method string, money float64) bool {
  235. payment, err := s.repo.FindLatestPayment(ticket.VehicleRecordID, entry)
  236. return err == nil && payment.PaymentMethod == method && moneyEqual(payment.Amount, money)
  237. }
  238. // Log persists protocol metadata for field troubleshooting. The caller must
  239. // pass a redacted body: device tokens and login passwords must never be saved.
  240. func (s *Service) Log(endpoint, deviceID, sourceIP string, body interface{}, result int, remark string) {
  241. payload, err := json.Marshal(body)
  242. if err != nil {
  243. payload = []byte("{}")
  244. }
  245. s.repo.Log(&dao.CentralPaymentRequestLog{
  246. Endpoint: endpoint, DeviceID: deviceID, SourceIP: sourceIP,
  247. RequestBody: string(payload), Result: result, Remark: remark,
  248. })
  249. }