service.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. package service
  2. import (
  3. "errors"
  4. "fmt"
  5. "math"
  6. "strings"
  7. "time"
  8. "wails-app/internal/dao"
  9. "wails-app/internal/global"
  10. "wails-app/internal/modules/parking-session/model/request"
  11. "wails-app/internal/modules/parking-session/repository"
  12. "gorm.io/gorm"
  13. )
  14. const (
  15. PaymentStatusUnpaid = "unpaid"
  16. PaymentStatusPaid = "paid"
  17. )
  18. var (
  19. ErrSessionNotFound = errors.New("未找到在场停车会话")
  20. ErrSessionAlreadyOpen = errors.New("车辆已有在场停车会话")
  21. ErrSessionAlreadyPaid = errors.New("停车会话已完成支付")
  22. ErrSessionAlreadyClosed = errors.New("停车会话已关闭")
  23. ErrSessionIdentityConflict = errors.New("车牌与RFID对应不同的在场停车会话")
  24. ErrSessionAmbiguous = errors.New("存在多条匹配的在场停车会话")
  25. ErrSessionIdentifierEmpty = errors.New("停车会话必须提供车牌、RFID或会话编号")
  26. )
  27. // ParkingSessionService owns the parking-stay lifecycle. It deliberately
  28. // persists to vehicle_record so existing reports and historical data remain
  29. // compatible while callers stop treating the record as an unstructured table.
  30. type ParkingSessionService struct {
  31. repo *repository.ParkingSessionRepository
  32. }
  33. func NewParkingSessionService() *ParkingSessionService {
  34. return &ParkingSessionService{repo: &repository.ParkingSessionRepository{}}
  35. }
  36. // FindActiveSession is the domain-facing name used by adapters that do not
  37. // need to know whether the lookup is backed by a transaction.
  38. func (s *ParkingSessionService) FindActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  39. return s.ResolveActiveSession(req)
  40. }
  41. func (s *ParkingSessionService) OpenSession(req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
  42. if global.GVA_DB == nil {
  43. return nil, errors.New("数据库未初始化")
  44. }
  45. var session *dao.VehicleRecord
  46. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  47. var err error
  48. session, err = s.OpenSessionTx(tx, req)
  49. return err
  50. })
  51. return session, err
  52. }
  53. func (s *ParkingSessionService) MarkPaid(sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
  54. if global.GVA_DB == nil {
  55. return nil, errors.New("数据库未初始化")
  56. }
  57. var session *dao.VehicleRecord
  58. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  59. var err error
  60. session, err = s.MarkPaidTx(tx, sessionID, paymentEntry, paymentMethod, fee)
  61. return err
  62. })
  63. return session, err
  64. }
  65. func (s *ParkingSessionService) CloseSession(req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
  66. if global.GVA_DB == nil {
  67. return nil, errors.New("数据库未初始化")
  68. }
  69. var session *dao.VehicleRecord
  70. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  71. var err error
  72. session, err = s.CloseSessionTx(tx, req)
  73. return err
  74. })
  75. return session, err
  76. }
  77. func (s *ParkingSessionService) ResolveActiveSession(req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  78. return s.ResolveActiveSessionTx(global.GVA_DB, req)
  79. }
  80. // ResolveActiveSessionTx resolves one active parking session deterministically.
  81. // When both identities are supplied, it refuses to pick an arbitrary session
  82. // if the plate and RFID point to different active records.
  83. func (s *ParkingSessionService) ResolveActiveSessionTx(db *gorm.DB, req request.ResolveActiveSessionRequest) (*dao.VehicleRecord, error) {
  84. if db == nil {
  85. return nil, errors.New("数据库未初始化")
  86. }
  87. if req.SessionID != 0 {
  88. session, err := s.repo.FindActiveByIDTx(db, req.SessionID)
  89. if err == nil {
  90. return session, nil
  91. }
  92. return nil, sessionLookupError(db, req.SessionID, err)
  93. }
  94. plateNumber := strings.TrimSpace(req.PlateNumber)
  95. rfidTag := strings.TrimSpace(req.RFIDTag)
  96. if plateNumber == "" && rfidTag == "" {
  97. return nil, ErrSessionIdentifierEmpty
  98. }
  99. if plateNumber != "" && rfidTag != "" {
  100. if session, err := s.repo.FindActiveByExactIdentityTx(db, plateNumber, rfidTag); err == nil {
  101. return session, nil
  102. } else if !errors.Is(err, gorm.ErrRecordNotFound) {
  103. return nil, err
  104. }
  105. }
  106. var plateSessions, rfidSessions []dao.VehicleRecord
  107. var err error
  108. if plateNumber != "" {
  109. plateSessions, err = s.repo.FindActiveByPlateTx(db, plateNumber)
  110. if err != nil {
  111. return nil, err
  112. }
  113. }
  114. if rfidTag != "" {
  115. rfidSessions, err = s.repo.FindActiveByRFIDTx(db, rfidTag)
  116. if err != nil {
  117. return nil, err
  118. }
  119. }
  120. if len(plateSessions) > 1 || len(rfidSessions) > 1 {
  121. return nil, ErrSessionAmbiguous
  122. }
  123. if len(plateSessions) == 1 && len(rfidSessions) == 1 && plateSessions[0].ID != rfidSessions[0].ID {
  124. return nil, ErrSessionIdentityConflict
  125. }
  126. if len(plateSessions) == 1 {
  127. return &plateSessions[0], nil
  128. }
  129. if len(rfidSessions) == 1 {
  130. return &rfidSessions[0], nil
  131. }
  132. return nil, ErrSessionNotFound
  133. }
  134. func sessionLookupError(db *gorm.DB, id uint, err error) error {
  135. if !errors.Is(err, gorm.ErrRecordNotFound) {
  136. return err
  137. }
  138. var session dao.VehicleRecord
  139. if lookupErr := db.Unscoped().First(&session, id).Error; lookupErr == nil {
  140. if session.ExitTime != nil {
  141. return ErrSessionAlreadyClosed
  142. }
  143. if session.PaymentStatus == PaymentStatusPaid {
  144. return ErrSessionAlreadyPaid
  145. }
  146. return ErrSessionNotFound
  147. } else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
  148. return lookupErr
  149. }
  150. return ErrSessionNotFound
  151. }
  152. // OpenSessionTx creates a durable parking session. The active-session lookup
  153. // is repeated in the caller transaction to make the validation part of the
  154. // same atomic operation as the insert.
  155. func (s *ParkingSessionService) OpenSessionTx(db *gorm.DB, req request.OpenSessionRequest) (*dao.VehicleRecord, error) {
  156. if db == nil {
  157. return nil, errors.New("数据库未初始化")
  158. }
  159. req.PlateNumber = strings.TrimSpace(req.PlateNumber)
  160. req.RFIDTag = strings.TrimSpace(req.RFIDTag)
  161. if req.PlateNumber == "" && req.RFIDTag == "" {
  162. return nil, ErrSessionIdentifierEmpty
  163. }
  164. _, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{
  165. PlateNumber: req.PlateNumber,
  166. RFIDTag: req.RFIDTag,
  167. })
  168. if err == nil {
  169. return nil, ErrSessionAlreadyOpen
  170. }
  171. if !errors.Is(err, ErrSessionNotFound) {
  172. return nil, err
  173. }
  174. entryTime := req.EntryTime
  175. if entryTime.IsZero() {
  176. entryTime = time.Now()
  177. }
  178. session := &dao.VehicleRecord{
  179. PlateNumber: req.PlateNumber,
  180. RFIDTag: req.RFIDTag,
  181. EntryTime: entryTime,
  182. ParkingLotID: req.ParkingLotID,
  183. ParkingSpaceID: req.ParkingSpaceID,
  184. EntryImage: req.EntryImage,
  185. PaymentStatus: PaymentStatusUnpaid,
  186. }
  187. if err := s.repo.CreateTx(db, session); err != nil {
  188. return nil, err
  189. }
  190. return session, nil
  191. }
  192. // MarkPaidTx updates the current settlement summary on a still-active session.
  193. // The payment_record created by the payment module remains the source of truth
  194. // for the monetary transaction itself.
  195. func (s *ParkingSessionService) MarkPaidTx(db *gorm.DB, sessionID uint, paymentEntry, paymentMethod string, fee float64) (*dao.VehicleRecord, error) {
  196. if db == nil {
  197. return nil, errors.New("数据库未初始化")
  198. }
  199. if sessionID == 0 {
  200. return nil, ErrSessionNotFound
  201. }
  202. if math.IsNaN(fee) || math.IsInf(fee, 0) || fee < 0 {
  203. return nil, errors.New("停车费用必须是有限的非负数")
  204. }
  205. session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: sessionID})
  206. if err != nil {
  207. return nil, err
  208. }
  209. if session.PaymentStatus == PaymentStatusPaid {
  210. return nil, ErrSessionAlreadyPaid
  211. }
  212. rows, err := s.repo.UpdateUnpaidActiveTx(db, sessionID, map[string]interface{}{
  213. "payment_status": PaymentStatusPaid,
  214. "payment_entry": paymentEntry,
  215. "payment_method": paymentMethod,
  216. "fee": fee,
  217. })
  218. if err != nil {
  219. return nil, fmt.Errorf("更新停车会话支付状态失败: %w", err)
  220. }
  221. if rows != 1 {
  222. return nil, sessionLookupError(db, sessionID, gorm.ErrRecordNotFound)
  223. }
  224. session.PaymentStatus = PaymentStatusPaid
  225. session.PaymentEntry = paymentEntry
  226. session.PaymentMethod = paymentMethod
  227. session.Fee = fee
  228. return session, nil
  229. }
  230. // CloseSessionTx finalizes one paid parking session exactly once.
  231. func (s *ParkingSessionService) CloseSessionTx(db *gorm.DB, req request.CloseSessionRequest) (*dao.VehicleRecord, error) {
  232. if db == nil {
  233. return nil, errors.New("数据库未初始化")
  234. }
  235. if req.SessionID == 0 {
  236. return nil, ErrSessionNotFound
  237. }
  238. if req.StayTime < 0 || math.IsNaN(req.Fee) || math.IsInf(req.Fee, 0) || req.Fee < 0 {
  239. return nil, errors.New("停车会话结算数据无效")
  240. }
  241. session, err := s.ResolveActiveSessionTx(db, request.ResolveActiveSessionRequest{SessionID: req.SessionID})
  242. if err != nil {
  243. return nil, err
  244. }
  245. exitTime := req.ExitTime
  246. if exitTime.IsZero() {
  247. exitTime = time.Now()
  248. }
  249. paymentStatus := req.PaymentStatus
  250. if paymentStatus == "" {
  251. paymentStatus = PaymentStatusPaid
  252. }
  253. updates := map[string]interface{}{
  254. "exit_time": exitTime,
  255. "stay_time": req.StayTime,
  256. "fee": req.Fee,
  257. "payment_status": paymentStatus,
  258. "payment_entry": req.PaymentEntry,
  259. "payment_method": req.PaymentMethod,
  260. }
  261. if req.ExitImage != "" {
  262. updates["exit_image"] = req.ExitImage
  263. }
  264. rows, err := s.repo.UpdateActiveTx(db, req.SessionID, updates)
  265. if err != nil {
  266. return nil, fmt.Errorf("关闭停车会话失败: %w", err)
  267. }
  268. if rows != 1 {
  269. return nil, sessionLookupError(db, req.SessionID, gorm.ErrRecordNotFound)
  270. }
  271. session.ExitTime = &exitTime
  272. session.StayTime = req.StayTime
  273. session.Fee = req.Fee
  274. session.PaymentStatus = paymentStatus
  275. session.PaymentEntry = req.PaymentEntry
  276. session.PaymentMethod = req.PaymentMethod
  277. if req.ExitImage != "" {
  278. session.ExitImage = req.ExitImage
  279. }
  280. return session, nil
  281. }