service.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. package service
  2. import (
  3. "crypto/rand"
  4. "encoding/hex"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "gorm.io/gorm"
  9. "math"
  10. "time"
  11. "wails-app/internal/dao"
  12. "wails-app/internal/global"
  13. "wails-app/internal/modules/digital-ticket/repository"
  14. )
  15. const (
  16. StateCreated = "created"
  17. StatePendingPayment = "pending_payment"
  18. StatePaid = "paid"
  19. StateExited = "exited"
  20. StateExpired = "expired"
  21. )
  22. var (
  23. ErrTicketStateChanged = errors.New("digital ticket state changed")
  24. ErrInvalidTicketPayment = errors.New("invalid digital ticket payment amount")
  25. )
  26. type TicketService struct {
  27. repo *repository.TicketRepository
  28. }
  29. func NewTicketService() *TicketService {
  30. return &TicketService{repo: &repository.TicketRepository{}}
  31. }
  32. func genTicketNo() string {
  33. b := make([]byte, 16)
  34. rand.Read(b)
  35. return hex.EncodeToString(b)
  36. }
  37. func appendEvent(log string, event map[string]interface{}) string {
  38. var events []map[string]interface{}
  39. json.Unmarshal([]byte(log), &events)
  40. event["at"] = time.Now().Format(time.RFC3339)
  41. events = append(events, event)
  42. data, _ := json.Marshal(events)
  43. return string(data)
  44. }
  45. // Create 创建数字票(所有进场方式统一入口)
  46. func (s *TicketService) Create(plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
  47. return s.CreateTx(global.GVA_DB, plateNumber, triggerMode, recordID)
  48. }
  49. func (s *TicketService) CreateTx(db *gorm.DB, plateNumber, triggerMode string, recordID uint) (*dao.DigitalTicket, error) {
  50. log := appendEvent("[]", map[string]interface{}{"event": "created", "trigger": triggerMode})
  51. ticket := &dao.DigitalTicket{
  52. TicketNo: genTicketNo(), PlateNumber: plateNumber, TriggerMode: triggerMode,
  53. State: StatePendingPayment, VehicleRecordID: recordID, EntryTime: time.Now(), EventLog: log,
  54. }
  55. if err := s.repo.CreateTx(db, ticket); err != nil {
  56. return nil, err
  57. }
  58. return ticket, nil
  59. }
  60. // Transition 状态转换(校验合法性,原子更新)
  61. func (s *TicketService) Transition(id uint, toState string, extra map[string]interface{}) error {
  62. return s.TransitionTx(global.GVA_DB, id, toState, extra)
  63. }
  64. func extraAmount(extra map[string]interface{}, key string) (float64, bool, error) {
  65. value, ok := extra[key]
  66. if !ok || value == nil {
  67. return 0, false, nil
  68. }
  69. switch number := value.(type) {
  70. case float64:
  71. return number, true, nil
  72. case float32:
  73. return float64(number), true, nil
  74. case int:
  75. return float64(number), true, nil
  76. case int8:
  77. return float64(number), true, nil
  78. case int16:
  79. return float64(number), true, nil
  80. case int32:
  81. return float64(number), true, nil
  82. case int64:
  83. return float64(number), true, nil
  84. case uint:
  85. return float64(number), true, nil
  86. case uint8:
  87. return float64(number), true, nil
  88. case uint16:
  89. return float64(number), true, nil
  90. case uint32:
  91. return float64(number), true, nil
  92. case uint64:
  93. return float64(number), true, nil
  94. case json.Number:
  95. parsed, err := number.Float64()
  96. return parsed, true, err
  97. default:
  98. return 0, true, fmt.Errorf("%s must be numeric", key)
  99. }
  100. }
  101. func validatePaymentExtra(extra map[string]interface{}) error {
  102. fee, hasFee, err := extraAmount(extra, "fee")
  103. if err != nil {
  104. return err
  105. }
  106. paid, hasPaid, err := extraAmount(extra, "paid_amount")
  107. if err != nil {
  108. return err
  109. }
  110. for _, amount := range []float64{fee, paid} {
  111. if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
  112. return ErrInvalidTicketPayment
  113. }
  114. }
  115. if hasFee && hasPaid && paid < fee {
  116. return errors.New("paid amount insufficient")
  117. }
  118. return nil
  119. }
  120. func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra map[string]interface{}) error {
  121. if db == nil {
  122. return errors.New("database not initialized")
  123. }
  124. ticket, err := s.repo.GetByIDTx(db, id)
  125. if err != nil {
  126. return errors.New("票不存在")
  127. }
  128. validTransitions := map[string][]string{
  129. StateCreated: {StatePendingPayment},
  130. StatePendingPayment: {StatePaid, StateExpired},
  131. StatePaid: {StateExited, StateExpired},
  132. }
  133. allowed := false
  134. for _, s := range validTransitions[ticket.State] {
  135. if s == toState {
  136. allowed = true
  137. break
  138. }
  139. }
  140. if !allowed {
  141. return fmt.Errorf("状态转换不合法: %s → %s", ticket.State, toState)
  142. }
  143. if toState == StatePaid {
  144. if err := validatePaymentExtra(extra); err != nil {
  145. return err
  146. }
  147. }
  148. updates := map[string]interface{}{}
  149. if toState == StatePaid {
  150. now := time.Now()
  151. updates["paid_at"] = now
  152. }
  153. if toState == StateExited {
  154. now := time.Now()
  155. updates["exit_time"] = now
  156. }
  157. // 只允许表中存在的字段写入数据库,其余仅记录到事件日志
  158. dbFields := map[string]bool{"payment_entry": true, "payment_method": true, "payment_order_no": true, "paid_amount": true, "fee": true}
  159. for k, v := range extra {
  160. if dbFields[k] {
  161. updates[k] = v
  162. }
  163. }
  164. event := map[string]interface{}{"event": toState}
  165. for k, v := range extra {
  166. event[k] = v
  167. }
  168. updates["event_log"] = appendEvent(ticket.EventLog, event)
  169. updated, err := s.repo.UpdateStateIfCurrentTx(db, id, ticket.State, toState, updates)
  170. if err != nil {
  171. return err
  172. }
  173. if !updated {
  174. return ErrTicketStateChanged
  175. }
  176. return nil
  177. }
  178. // MarkPaidAndExitedTx updates the ticket lifecycle in the caller's
  179. // transaction. Payment entry and method are stored as payment metadata only;
  180. // they do not define the ticket itself.
  181. func (s *TicketService) MarkPaidAndExitedTx(db *gorm.DB, recordID uint, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount, fee float64) error {
  182. if err := validatePaymentExtra(map[string]interface{}{"paid_amount": paidAmount, "fee": fee}); err != nil {
  183. return err
  184. }
  185. ticket, err := s.repo.GetByVehicleRecordIDTx(db, recordID)
  186. if err != nil {
  187. return errors.New("车辆对应的数字票不存在")
  188. }
  189. if ticket.State == StatePendingPayment {
  190. extra := map[string]interface{}{
  191. "payment_entry": paymentEntry,
  192. "payment_method": paymentMethod,
  193. "payment_order_no": paymentOrderNo,
  194. "paid_amount": paidAmount,
  195. "fee": fee,
  196. }
  197. if err := s.TransitionTx(db, ticket.ID, StatePaid, extra); err != nil {
  198. return err
  199. }
  200. ticket.State = StatePaid
  201. }
  202. if ticket.State == StatePaid {
  203. return s.TransitionTx(db, ticket.ID, StateExited, nil)
  204. }
  205. if ticket.State == StateExited {
  206. return nil
  207. }
  208. return fmt.Errorf("数字票状态不允许出场: %s", ticket.State)
  209. }
  210. func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) {
  211. ticket, err := s.repo.GetByTicketNo(no)
  212. if err != nil {
  213. return nil, err
  214. }
  215. s.enrichTicket(ticket)
  216. return ticket, nil
  217. }
  218. func (s *TicketService) GetByTicketNoTx(db *gorm.DB, no string) (*dao.DigitalTicket, error) {
  219. return s.repo.GetByTicketNoTx(db, no)
  220. }
  221. func (s *TicketService) GetByVehicleRecordIDTx(db *gorm.DB, recordID uint) (*dao.DigitalTicket, error) {
  222. return s.repo.GetByVehicleRecordIDTx(db, recordID)
  223. }
  224. func (s *TicketService) List(q repository.TicketListQuery) ([]dao.DigitalTicket, int64, error) {
  225. list, total, err := s.repo.List(q)
  226. if err != nil {
  227. return nil, 0, err
  228. }
  229. for i := range list {
  230. s.enrichTicket(&list[i])
  231. }
  232. return list, total, nil
  233. }
  234. func (s *TicketService) Summary(q repository.TicketListQuery) (repository.TicketSummary, error) {
  235. return s.repo.Summary(q)
  236. }
  237. // enrichTicket keeps the ticket view useful before payment. A ticket is
  238. // created at vehicle entry, while the definitive fee is calculated at payment
  239. // or exit time. Pending tickets therefore need a live fee preview derived from
  240. // their vehicle record and current parking duration.
  241. func (s *TicketService) enrichTicket(ticket *dao.DigitalTicket) {
  242. if ticket == nil || ticket.VehicleRecordID == 0 || global.GVA_DB == nil {
  243. return
  244. }
  245. var record dao.VehicleRecord
  246. if err := global.GVA_DB.First(&record, ticket.VehicleRecordID).Error; err != nil {
  247. return
  248. }
  249. if record.PlateNumber != "" {
  250. ticket.PlateNumber = record.PlateNumber
  251. }
  252. var vehicle dao.Vehicle
  253. vehicleQuery := global.GVA_DB
  254. vehicleFound := false
  255. if record.PlateNumber != "" {
  256. vehicleQuery = vehicleQuery.Where("plate_number = ?", record.PlateNumber)
  257. vehicleFound = vehicleQuery.First(&vehicle).Error == nil
  258. } else if record.RFIDTag != "" {
  259. vehicleQuery = vehicleQuery.Where("rfid_tag = ?", record.RFIDTag)
  260. vehicleFound = vehicleQuery.First(&vehicle).Error == nil
  261. }
  262. if ticket.PlateNumber == "" && vehicleFound {
  263. ticket.PlateNumber = vehicle.PlateNumber
  264. }
  265. if ticket.State == StateCreated || ticket.State == StatePendingPayment {
  266. if !vehicleFound {
  267. return
  268. }
  269. stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
  270. if stayTime < 0 {
  271. stayTime = 0
  272. }
  273. // 预览保持宽容:未配置收费规则时费用显示 0,正式出场时由 CalculateFee 返回明确错误
  274. if fee, calcErr := CalculateFee(&vehicle, stayTime); calcErr == nil {
  275. ticket.Fee = fee
  276. }
  277. return
  278. }
  279. // Older rows may have a zero digital-ticket fee even though the vehicle
  280. // record was already settled. Keep the historical record's fee visible.
  281. if ticket.Fee == 0 && record.Fee != 0 {
  282. ticket.Fee = record.Fee
  283. }
  284. }