service.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. package service
  2. import (
  3. "errors"
  4. "fmt"
  5. "math"
  6. "regexp"
  7. "strings"
  8. "time"
  9. "wails-app/internal/dao"
  10. "wails-app/internal/global"
  11. parkingSessionService "wails-app/internal/modules/parking-session/service"
  12. "wails-app/internal/modules/payment/repository"
  13. "gorm.io/gorm"
  14. )
  15. // Internal payment constants and configuration values.
  16. const (
  17. PaymentCash = "cash"
  18. PaymentFree = "free"
  19. PaymentPOS = "pos"
  20. PaymentTicketQR = "ticket_qr"
  21. InputModeManualAmount = "manual_amount"
  22. InputModeConfirm = "confirm"
  23. InputModeExternal = "external"
  24. EntryCounter = "counter"
  25. EntryTicket = "ticket"
  26. // Scene* are kept as source-compatible aliases for older callers. New code
  27. // should pass an Entry* value and treat it as the payment entry, not a
  28. // property of the payment method.
  29. SceneCounter = EntryCounter
  30. SceneTicket = EntryTicket
  31. )
  32. var paymentCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,31}$`)
  33. var (
  34. ErrPaymentAlreadyProcessed = errors.New("payment already processed")
  35. ErrVehicleRecordNotFound = errors.New("vehicle record not found")
  36. )
  37. type PaymentService struct {
  38. repo *repository.PaymentRepository
  39. }
  40. func NewPaymentService() *PaymentService {
  41. return &PaymentService{repo: &repository.PaymentRepository{}}
  42. }
  43. func (s *PaymentService) ListMethods() ([]dao.PaymentMethodConfig, error) {
  44. return s.repo.MethodList()
  45. }
  46. func (s *PaymentService) ListEntries() ([]dao.PaymentEntryConfig, error) {
  47. return s.repo.EntryList()
  48. }
  49. func (s *PaymentService) ListEnabledEntries() ([]dao.PaymentEntryConfig, error) {
  50. entries, err := s.repo.EntryList()
  51. if err != nil {
  52. return nil, err
  53. }
  54. result := make([]dao.PaymentEntryConfig, 0, len(entries))
  55. for _, entry := range entries {
  56. if entry.Enabled {
  57. result = append(result, entry)
  58. }
  59. }
  60. return result, nil
  61. }
  62. func (s *PaymentService) ListEnabledMethods(entryCode string) ([]dao.PaymentMethodConfig, error) {
  63. if entryCode != "" && entryCode != EntryCounter && entryCode != EntryTicket {
  64. // Custom entries are valid too; only reject an empty value at the API
  65. // boundary where an entry is required.
  66. if _, err := s.repo.GetEntryByCode(nil, entryCode); err != nil {
  67. return nil, errors.New("无效的支付入口")
  68. }
  69. }
  70. methods, err := s.repo.EnabledMethodList(global.GVA_DB, entryCode)
  71. if err == nil {
  72. return methods, nil
  73. }
  74. // Existing installations created before the split may not have the
  75. // relation tables yet. Keep their old scene data usable until startup
  76. // migration has completed.
  77. if strings.Contains(strings.ToLower(err.Error()), "no such table") {
  78. return s.listLegacyEnabledMethods(entryCode), nil
  79. }
  80. return nil, err
  81. }
  82. func (s *PaymentService) listLegacyEnabledMethods(entryCode string) []dao.PaymentMethodConfig {
  83. methods, err := s.repo.MethodList()
  84. if err != nil {
  85. return nil
  86. }
  87. result := make([]dao.PaymentMethodConfig, 0, len(methods))
  88. for _, method := range methods {
  89. if method.Enabled && containsScene(method.Scenes, entryCode) {
  90. result = append(result, method)
  91. }
  92. }
  93. return result
  94. }
  95. func normalizeCodes(codes []string) []string {
  96. seen := make(map[string]bool, len(codes))
  97. result := make([]string, 0, len(codes))
  98. for _, code := range codes {
  99. code = strings.ToLower(strings.TrimSpace(code))
  100. if code != "" && !seen[code] {
  101. seen[code] = true
  102. result = append(result, code)
  103. }
  104. }
  105. return result
  106. }
  107. func normalizeMethod(method *dao.PaymentMethodConfig) error {
  108. method.Code = strings.ToLower(strings.TrimSpace(method.Code))
  109. method.Name = strings.TrimSpace(method.Name)
  110. method.InputMode = strings.TrimSpace(method.InputMode)
  111. method.Description = strings.TrimSpace(method.Description)
  112. if !paymentCodePattern.MatchString(method.Code) {
  113. return errors.New("支付编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为2-32位")
  114. }
  115. if method.Name == "" || len([]rune(method.Name)) > 50 {
  116. return errors.New("支付方式名称不能为空且不能超过50个字符")
  117. }
  118. validModes := map[string]bool{InputModeManualAmount: true, InputModeConfirm: true, InputModeExternal: true}
  119. if !validModes[method.InputMode] {
  120. return errors.New("无效的金额处理方式")
  121. }
  122. method.EntryCodes = normalizeCodes(method.EntryCodes)
  123. // Scenes is only read during migration. Do not use it as a runtime
  124. // authorization source after payment entries have been configured.
  125. method.Scenes = normalizeCodes(method.Scenes)
  126. return nil
  127. }
  128. func (s *PaymentService) CreateMethod(method *dao.PaymentMethodConfig) error {
  129. if err := normalizeMethod(method); err != nil {
  130. return err
  131. }
  132. method.IsSystem = false
  133. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  134. if err := s.repo.CreateMethodTx(tx, method); err != nil {
  135. if strings.Contains(strings.ToLower(err.Error()), "unique") {
  136. return errors.New("支付编码已存在")
  137. }
  138. return fmt.Errorf("新增支付方式失败: %w", err)
  139. }
  140. if len(method.EntryCodes) > 0 {
  141. if err := s.validateMethodEntryCodes(tx, method.EntryCodes); err != nil {
  142. return err
  143. }
  144. return s.repo.ReplaceMethodEntries(tx, method.Code, method.EntryCodes)
  145. }
  146. return nil
  147. })
  148. return err
  149. }
  150. func (s *PaymentService) UpdateMethod(id uint, updates *dao.PaymentMethodConfig) error {
  151. current, err := s.repo.GetMethodByID(id)
  152. if err != nil {
  153. return errors.New("支付方式不存在")
  154. }
  155. updates.Code = current.Code
  156. updates.IsSystem = current.IsSystem
  157. updates.EntryCodes = nil
  158. if current.Code == PaymentCash {
  159. updates.InputMode = InputModeManualAmount
  160. updates.Enabled = true
  161. }
  162. if err := normalizeMethod(updates); err != nil {
  163. return err
  164. }
  165. updates.ID = current.ID
  166. return s.repo.UpdateMethod(updates)
  167. }
  168. func (s *PaymentService) SetMethodEnabled(id uint, enabled bool) error {
  169. method, err := s.repo.GetMethodByID(id)
  170. if err != nil {
  171. return errors.New("支付方式不存在")
  172. }
  173. if method.Code == PaymentCash && !enabled {
  174. return errors.New("人工支付是基础功能,不能停用")
  175. }
  176. return s.repo.SetMethodEnabled(id, enabled)
  177. }
  178. func (s *PaymentService) DeleteMethod(id uint) error {
  179. method, err := s.repo.GetMethodByID(id)
  180. if err != nil {
  181. return errors.New("支付方式不存在")
  182. }
  183. if method.IsSystem {
  184. return errors.New("系统内置支付方式不能删除")
  185. }
  186. return s.repo.DeleteMethod(method)
  187. }
  188. func normalizeEntry(entry *dao.PaymentEntryConfig) error {
  189. entry.Code = strings.ToLower(strings.TrimSpace(entry.Code))
  190. entry.Name = strings.TrimSpace(entry.Name)
  191. entry.Description = strings.TrimSpace(entry.Description)
  192. if !paymentCodePattern.MatchString(entry.Code) {
  193. return errors.New("支付入口编码必须以小写字母开头,只能包含小写字母、数字和下划线,长度为2-32位")
  194. }
  195. if entry.Name == "" || len([]rune(entry.Name)) > 50 {
  196. return errors.New("支付入口名称不能为空且不能超过50个字符")
  197. }
  198. entry.MethodCodes = normalizeCodes(entry.MethodCodes)
  199. if !containsScene(entry.MethodCodes, PaymentCash) {
  200. entry.MethodCodes = append([]string{PaymentCash}, entry.MethodCodes...)
  201. }
  202. return nil
  203. }
  204. func (s *PaymentService) validateEntryCodes(db *gorm.DB, codes []string) error {
  205. for _, code := range codes {
  206. method, err := s.repo.GetMethodByCode(db, code)
  207. if err != nil || method.IsLegacy {
  208. return fmt.Errorf("支付方式“%s”不存在或不可用", code)
  209. }
  210. }
  211. return nil
  212. }
  213. func (s *PaymentService) validateMethodEntryCodes(db *gorm.DB, codes []string) error {
  214. for _, code := range codes {
  215. if _, err := s.repo.GetEntryByCode(db, code); err != nil {
  216. return fmt.Errorf("支付入口“%s”不存在", code)
  217. }
  218. }
  219. return nil
  220. }
  221. func (s *PaymentService) CreateEntry(entry *dao.PaymentEntryConfig) error {
  222. if err := normalizeEntry(entry); err != nil {
  223. return err
  224. }
  225. if err := s.validateEntryCodes(global.GVA_DB, entry.MethodCodes); err != nil {
  226. return err
  227. }
  228. entry.IsSystem = false
  229. return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  230. if err := s.repo.CreateEntryTx(tx, entry); err != nil {
  231. if strings.Contains(strings.ToLower(err.Error()), "unique") {
  232. return errors.New("支付入口编码已存在")
  233. }
  234. return fmt.Errorf("新增支付入口失败: %w", err)
  235. }
  236. return s.repo.ReplaceEntryMethods(tx, entry.Code, entry.MethodCodes)
  237. })
  238. }
  239. func (s *PaymentService) UpdateEntry(id uint, updates *dao.PaymentEntryConfig) error {
  240. current, err := s.repo.GetEntryByID(id)
  241. if err != nil {
  242. return errors.New("支付入口不存在")
  243. }
  244. updates.Code = current.Code
  245. updates.IsSystem = current.IsSystem
  246. if err := normalizeEntry(updates); err != nil {
  247. return err
  248. }
  249. if err := s.validateEntryCodes(global.GVA_DB, updates.MethodCodes); err != nil {
  250. return err
  251. }
  252. updates.ID = current.ID
  253. return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  254. if err := s.repo.UpdateEntryTx(tx, updates); err != nil {
  255. return err
  256. }
  257. return s.repo.ReplaceEntryMethods(tx, current.Code, updates.MethodCodes)
  258. })
  259. }
  260. func (s *PaymentService) SetEntryEnabled(id uint, enabled bool) error {
  261. if _, err := s.repo.GetEntryByID(id); err != nil {
  262. return errors.New("支付入口不存在")
  263. }
  264. return s.repo.SetEntryEnabled(id, enabled)
  265. }
  266. func (s *PaymentService) DeleteEntry(id uint) error {
  267. entry, err := s.repo.GetEntryByID(id)
  268. if err != nil {
  269. return errors.New("支付入口不存在")
  270. }
  271. if entry.IsSystem {
  272. return errors.New("系统内置支付入口不能删除")
  273. }
  274. return s.repo.DeleteEntry(entry)
  275. }
  276. func containsScene(scenes []string, scene string) bool {
  277. for _, item := range scenes {
  278. if item == scene {
  279. return true
  280. }
  281. }
  282. return false
  283. }
  284. func (s *PaymentService) ValidateMethod(db *gorm.DB, code, entryCode string) (*dao.PaymentMethodConfig, error) {
  285. if code == PaymentFree {
  286. if entryCode != EntryCounter {
  287. return nil, errors.New("免密放行不能用于当前支付场景")
  288. }
  289. entry, err := s.repo.GetEntryByCode(db, entryCode)
  290. if err == nil && !entry.Enabled {
  291. return nil, fmt.Errorf("支付入口“%s”已停用", entry.Name)
  292. }
  293. if err != nil && !strings.Contains(strings.ToLower(err.Error()), "no such table") {
  294. return nil, errors.New("支付入口不存在")
  295. }
  296. return &dao.PaymentMethodConfig{Code: PaymentFree, Name: "免密放行", InputMode: InputModeConfirm, Enabled: true}, nil
  297. }
  298. entry, entryErr := s.repo.GetEntryByCode(db, entryCode)
  299. method, methodErr := s.repo.GetMethodByCode(db, code)
  300. if methodErr != nil {
  301. return nil, errors.New("支付方式不存在")
  302. }
  303. if !method.Enabled {
  304. return nil, fmt.Errorf("支付方式“%s”已停用", method.Name)
  305. }
  306. if entryErr != nil {
  307. if strings.Contains(strings.ToLower(entryErr.Error()), "no such table") && methodErr == nil {
  308. if containsScene(method.Scenes, entryCode) {
  309. return method, nil
  310. }
  311. return nil, fmt.Errorf("支付方式“%s”不适用于当前场景", method.Name)
  312. }
  313. return nil, errors.New("支付入口不存在")
  314. }
  315. if !entry.Enabled {
  316. return nil, fmt.Errorf("支付入口“%s”已停用", entry.Name)
  317. }
  318. allowed, err := s.repo.IsMethodAllowed(db, entryCode, code)
  319. if err != nil {
  320. if strings.Contains(strings.ToLower(err.Error()), "no such table") && containsScene(method.Scenes, entryCode) {
  321. return method, nil
  322. }
  323. return nil, err
  324. }
  325. if !allowed {
  326. return nil, fmt.Errorf("支付方式“%s”未配置到当前支付入口", method.Name)
  327. }
  328. return method, nil
  329. }
  330. func (s *PaymentService) ValidatePayment(db *gorm.DB, code, entryCode string, amount, paidAmount float64) (*dao.PaymentMethodConfig, float64, error) {
  331. method, err := s.ValidateMethod(db, code, entryCode)
  332. if err != nil {
  333. return nil, 0, err
  334. }
  335. if math.IsNaN(amount) || math.IsInf(amount, 0) || math.IsNaN(paidAmount) || math.IsInf(paidAmount, 0) || amount < 0 || paidAmount < 0 {
  336. return nil, 0, errors.New("支付金额必须是有限的非负数")
  337. }
  338. if code == PaymentFree {
  339. return method, 0, nil
  340. }
  341. if method.InputMode == InputModeManualAmount {
  342. if paidAmount < amount {
  343. return nil, 0, errors.New("实收金额不足")
  344. }
  345. return method, paidAmount, nil
  346. }
  347. return method, amount, nil
  348. }
  349. // ProcessPayment creates a payment_record and updates vehicle_record payment status
  350. func (s *PaymentService) ProcessPayment(recordID uint, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) {
  351. if global.GVA_DB == nil {
  352. return nil, errors.New("database not initialized")
  353. }
  354. var result *dao.PaymentRecord
  355. err := global.GVA_DB.Transaction(func(tx *gorm.DB) error {
  356. var err error
  357. result, err = s.ProcessPaymentTxAtEntry(tx, recordID, EntryCounter, paymentMethod, amount, paidAmount, operatorID)
  358. return err
  359. })
  360. if err != nil {
  361. return nil, err
  362. }
  363. return result, nil
  364. }
  365. // ProcessPaymentTx keeps the pre-split argument order for callers compiled
  366. // against the old service. The final argument is now interpreted as the
  367. // payment entry.
  368. func (s *PaymentService) ProcessPaymentTx(db *gorm.DB, recordID uint, paymentMethod string, amount float64, paidAmount float64, operatorID uint, paymentEntry string) (*dao.PaymentRecord, error) {
  369. return s.ProcessPaymentTxAtEntry(db, recordID, paymentEntry, paymentMethod, amount, paidAmount, operatorID)
  370. }
  371. func (s *PaymentService) ProcessPaymentTxAtEntry(db *gorm.DB, recordID uint, paymentEntry string, paymentMethod string, amount float64, paidAmount float64, operatorID uint) (*dao.PaymentRecord, error) {
  372. if db == nil {
  373. return nil, errors.New("database not initialized")
  374. }
  375. method, paidAmount, err := s.ValidatePayment(db, paymentMethod, paymentEntry, amount, paidAmount)
  376. if err != nil {
  377. return nil, err
  378. }
  379. changeAmount := 0.0
  380. if method.InputMode == InputModeManualAmount && paidAmount > amount {
  381. changeAmount = paidAmount - amount
  382. }
  383. if recordID != 0 {
  384. sessionSvc := parkingSessionService.NewParkingSessionService()
  385. if _, err := sessionSvc.MarkPaidTx(db, recordID, paymentEntry, paymentMethod, amount); err != nil {
  386. switch {
  387. case errors.Is(err, parkingSessionService.ErrSessionNotFound):
  388. return nil, ErrVehicleRecordNotFound
  389. case errors.Is(err, parkingSessionService.ErrSessionAlreadyPaid),
  390. errors.Is(err, parkingSessionService.ErrSessionAlreadyClosed):
  391. return nil, fmt.Errorf("payment already processed: %w", ErrPaymentAlreadyProcessed)
  392. default:
  393. return nil, fmt.Errorf("failed to update payment status: %w", err)
  394. }
  395. }
  396. }
  397. now := time.Now()
  398. pr := &dao.PaymentRecord{
  399. RecordID: recordID,
  400. PaymentEntry: paymentEntry,
  401. PaymentMethod: paymentMethod,
  402. Amount: amount,
  403. PaidAmount: paidAmount,
  404. ChangeAmount: changeAmount,
  405. OperatorID: operatorID,
  406. PaidAt: now,
  407. }
  408. if err := db.Create(pr).Error; err != nil {
  409. return nil, fmt.Errorf("创建支付记录失败: %w", err)
  410. }
  411. return pr, nil
  412. }
  413. // QueryPayments 查询收费记录列表
  414. func (s *PaymentService) QueryPayments(q repository.PaymentListQuery) ([]repository.PaymentListResult, int64, error) {
  415. return s.repo.List(q)
  416. }
  417. // GetTodaySummary 获取当日收费汇总
  418. func (s *PaymentService) GetTodaySummary() (repository.TodaySummary, error) {
  419. return s.repo.GetTodaySummary()
  420. }