service.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  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. // RFID 自动免密结算不产生实收金额。费用仍需保留在数字票中用于收费统计,
  116. // 因此仅在调用方显式授权时允许 paid_amount 为 0;其他支付方式必须足额支付。
  117. paymentMethod, _ := extra["payment_method"].(string)
  118. allowUnpaidFree, _ := extra["allow_unpaid_free"].(bool)
  119. if hasFee && hasPaid && paid < fee && !(paymentMethod == "free" && allowUnpaidFree) {
  120. return errors.New("paid amount insufficient")
  121. }
  122. return nil
  123. }
  124. func (s *TicketService) TransitionTx(db *gorm.DB, id uint, toState string, extra map[string]interface{}) error {
  125. if db == nil {
  126. return errors.New("database not initialized")
  127. }
  128. ticket, err := s.repo.GetByIDTx(db, id)
  129. if err != nil {
  130. return errors.New("票不存在")
  131. }
  132. validTransitions := map[string][]string{
  133. StateCreated: {StatePendingPayment},
  134. StatePendingPayment: {StatePaid, StateExpired},
  135. StatePaid: {StateExited, StateExpired},
  136. }
  137. allowed := false
  138. for _, s := range validTransitions[ticket.State] {
  139. if s == toState {
  140. allowed = true
  141. break
  142. }
  143. }
  144. if !allowed {
  145. return fmt.Errorf("状态转换不合法: %s → %s", ticket.State, toState)
  146. }
  147. if toState == StatePaid {
  148. if err := validatePaymentExtra(extra); err != nil {
  149. return err
  150. }
  151. }
  152. updates := map[string]interface{}{}
  153. if toState == StatePaid {
  154. now := time.Now()
  155. updates["paid_at"] = now
  156. }
  157. if toState == StateExited {
  158. now := time.Now()
  159. updates["exit_time"] = now
  160. }
  161. // 只允许表中存在的字段写入数据库,其余仅记录到事件日志
  162. dbFields := map[string]bool{"payment_entry": true, "payment_method": true, "payment_order_no": true, "paid_amount": true, "fee": true}
  163. for k, v := range extra {
  164. if dbFields[k] {
  165. updates[k] = v
  166. }
  167. }
  168. event := map[string]interface{}{"event": toState}
  169. for k, v := range extra {
  170. event[k] = v
  171. }
  172. updates["event_log"] = appendEvent(ticket.EventLog, event)
  173. updated, err := s.repo.UpdateStateIfCurrentTx(db, id, ticket.State, toState, updates)
  174. if err != nil {
  175. return err
  176. }
  177. if !updated {
  178. return ErrTicketStateChanged
  179. }
  180. return nil
  181. }
  182. // MarkPaidAndExitedTx updates the ticket lifecycle in the caller's
  183. // transaction. Payment entry and method are stored as payment metadata only;
  184. // they do not define the ticket itself.
  185. func (s *TicketService) MarkPaidAndExitedTx(db *gorm.DB, recordID uint, paymentEntry, paymentMethod, paymentOrderNo string, paidAmount, fee float64, allowUnpaidFreeOption ...bool) error {
  186. allowUnpaidFree := len(allowUnpaidFreeOption) > 0 && allowUnpaidFreeOption[0]
  187. if err := validatePaymentExtra(map[string]interface{}{
  188. "paid_amount": paidAmount,
  189. "fee": fee,
  190. "payment_method": paymentMethod,
  191. "allow_unpaid_free": allowUnpaidFree,
  192. }); err != nil {
  193. return err
  194. }
  195. ticket, err := s.repo.GetByVehicleRecordIDTx(db, recordID)
  196. if err != nil {
  197. return errors.New("车辆对应的数字票不存在")
  198. }
  199. if ticket.State == StatePendingPayment {
  200. extra := map[string]interface{}{
  201. "payment_entry": paymentEntry,
  202. "payment_method": paymentMethod,
  203. "payment_order_no": paymentOrderNo,
  204. "paid_amount": paidAmount,
  205. "fee": fee,
  206. "allow_unpaid_free": allowUnpaidFree,
  207. }
  208. if err := s.TransitionTx(db, ticket.ID, StatePaid, extra); err != nil {
  209. return err
  210. }
  211. ticket.State = StatePaid
  212. }
  213. if ticket.State == StatePaid {
  214. return s.TransitionTx(db, ticket.ID, StateExited, nil)
  215. }
  216. if ticket.State == StateExited {
  217. return nil
  218. }
  219. return fmt.Errorf("数字票状态不允许出场: %s", ticket.State)
  220. }
  221. func (s *TicketService) GetByTicketNo(no string) (*dao.DigitalTicket, error) {
  222. ticket, err := s.repo.GetByTicketNo(no)
  223. if err != nil {
  224. return nil, err
  225. }
  226. s.enrichTicket(ticket)
  227. return ticket, nil
  228. }
  229. func (s *TicketService) GetByTicketNoTx(db *gorm.DB, no string) (*dao.DigitalTicket, error) {
  230. return s.repo.GetByTicketNoTx(db, no)
  231. }
  232. func (s *TicketService) GetByVehicleRecordIDTx(db *gorm.DB, recordID uint) (*dao.DigitalTicket, error) {
  233. return s.repo.GetByVehicleRecordIDTx(db, recordID)
  234. }
  235. func (s *TicketService) List(q repository.TicketListQuery) ([]dao.DigitalTicket, int64, error) {
  236. list, total, err := s.repo.List(q)
  237. if err != nil {
  238. return nil, 0, err
  239. }
  240. for i := range list {
  241. s.enrichTicket(&list[i])
  242. }
  243. return list, total, nil
  244. }
  245. func (s *TicketService) Summary(q repository.TicketListQuery) (repository.TicketSummary, error) {
  246. return s.repo.Summary(q)
  247. }
  248. // enrichTicket keeps the ticket view useful before payment. A ticket is
  249. // created at vehicle entry, while the definitive fee is calculated at payment
  250. // or exit time. Pending tickets therefore need a live fee preview derived from
  251. // their vehicle record and current parking duration.
  252. func (s *TicketService) enrichTicket(ticket *dao.DigitalTicket) {
  253. if ticket == nil || ticket.VehicleRecordID == 0 || global.GVA_DB == nil {
  254. return
  255. }
  256. var record dao.VehicleRecord
  257. if err := global.GVA_DB.First(&record, ticket.VehicleRecordID).Error; err != nil {
  258. return
  259. }
  260. if record.PlateNumber != "" {
  261. ticket.PlateNumber = record.PlateNumber
  262. }
  263. var vehicle dao.Vehicle
  264. vehicleQuery := global.GVA_DB
  265. vehicleFound := false
  266. if record.PlateNumber != "" {
  267. vehicleQuery = vehicleQuery.Where("plate_number = ?", record.PlateNumber)
  268. vehicleFound = vehicleQuery.First(&vehicle).Error == nil
  269. } else if record.RFIDTag != "" {
  270. vehicleQuery = vehicleQuery.Where("rfid_tag = ?", record.RFIDTag)
  271. vehicleFound = vehicleQuery.First(&vehicle).Error == nil
  272. }
  273. if ticket.PlateNumber == "" && vehicleFound {
  274. ticket.PlateNumber = vehicle.PlateNumber
  275. }
  276. if ticket.State == StateCreated || ticket.State == StatePendingPayment {
  277. if !vehicleFound {
  278. return
  279. }
  280. stayTime := int64(math.Ceil(time.Since(record.EntryTime).Minutes()))
  281. if stayTime < 0 {
  282. stayTime = 0
  283. }
  284. // 预览保持宽容:未配置收费规则时费用显示 0,正式出场时由 CalculateFee 返回明确错误
  285. if fee, calcErr := CalculateFee(&vehicle, stayTime); calcErr == nil {
  286. ticket.Fee = fee
  287. }
  288. return
  289. }
  290. // Older rows may have a zero digital-ticket fee even though the vehicle
  291. // record was already settled. Keep the historical record's fee visible.
  292. if ticket.Fee == 0 && record.Fee != 0 {
  293. ticket.Fee = record.Fee
  294. }
  295. }