topup.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. package model
  2. import (
  3. "errors"
  4. "fmt"
  5. "github.com/QuantumNous/new-api/common"
  6. "github.com/QuantumNous/new-api/logger"
  7. "github.com/shopspring/decimal"
  8. "gorm.io/gorm"
  9. )
  10. type TopUp struct {
  11. Id int `json:"id"`
  12. UserId int `json:"user_id" gorm:"index"`
  13. Amount int64 `json:"amount"`
  14. Money float64 `json:"money"`
  15. TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
  16. PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
  17. PaymentProvider string `json:"payment_provider" gorm:"type:varchar(50);default:''"`
  18. CreateTime int64 `json:"create_time"`
  19. CompleteTime int64 `json:"complete_time"`
  20. Status string `json:"status"`
  21. }
  22. const (
  23. PaymentMethodStripe = "stripe"
  24. PaymentMethodCreem = "creem"
  25. PaymentMethodWaffo = "waffo"
  26. PaymentMethodWaffoPancake = "waffo_pancake"
  27. )
  28. const (
  29. PaymentProviderEpay = "epay"
  30. PaymentProviderStripe = "stripe"
  31. PaymentProviderCreem = "creem"
  32. PaymentProviderWaffo = "waffo"
  33. PaymentProviderWaffoPancake = "waffo_pancake"
  34. )
  35. var (
  36. ErrPaymentMethodMismatch = errors.New("payment method mismatch")
  37. ErrTopUpNotFound = errors.New("topup not found")
  38. ErrTopUpStatusInvalid = errors.New("topup status invalid")
  39. )
  40. func (topUp *TopUp) Insert() error {
  41. var err error
  42. err = DB.Create(topUp).Error
  43. return err
  44. }
  45. func (topUp *TopUp) Update() error {
  46. var err error
  47. err = DB.Save(topUp).Error
  48. return err
  49. }
  50. func GetTopUpById(id int) *TopUp {
  51. var topUp *TopUp
  52. var err error
  53. err = DB.Where("id = ?", id).First(&topUp).Error
  54. if err != nil {
  55. return nil
  56. }
  57. return topUp
  58. }
  59. func GetTopUpByTradeNo(tradeNo string) *TopUp {
  60. var topUp *TopUp
  61. var err error
  62. err = DB.Where("trade_no = ?", tradeNo).First(&topUp).Error
  63. if err != nil {
  64. return nil
  65. }
  66. return topUp
  67. }
  68. func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, targetStatus string) error {
  69. if tradeNo == "" {
  70. return errors.New("未提供支付单号")
  71. }
  72. refCol := "`trade_no`"
  73. if common.UsingPostgreSQL {
  74. refCol = `"trade_no"`
  75. }
  76. return DB.Transaction(func(tx *gorm.DB) error {
  77. topUp := &TopUp{}
  78. if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
  79. return ErrTopUpNotFound
  80. }
  81. if expectedPaymentProvider != "" && topUp.PaymentProvider != expectedPaymentProvider {
  82. return ErrPaymentMethodMismatch
  83. }
  84. if topUp.Status != common.TopUpStatusPending {
  85. return ErrTopUpStatusInvalid
  86. }
  87. topUp.Status = targetStatus
  88. return tx.Save(topUp).Error
  89. })
  90. }
  91. func Recharge(referenceId string, customerId string, callerIp string) (err error) {
  92. if referenceId == "" {
  93. return errors.New("未提供支付单号")
  94. }
  95. var quota float64
  96. topUp := &TopUp{}
  97. refCol := "`trade_no`"
  98. if common.UsingPostgreSQL {
  99. refCol = `"trade_no"`
  100. }
  101. err = DB.Transaction(func(tx *gorm.DB) error {
  102. err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
  103. if err != nil {
  104. return errors.New("充值订单不存在")
  105. }
  106. if topUp.PaymentProvider != PaymentProviderStripe {
  107. return ErrPaymentMethodMismatch
  108. }
  109. if topUp.Status != common.TopUpStatusPending {
  110. return errors.New("充值订单状态错误")
  111. }
  112. topUp.CompleteTime = common.GetTimestamp()
  113. topUp.Status = common.TopUpStatusSuccess
  114. err = tx.Save(topUp).Error
  115. if err != nil {
  116. return err
  117. }
  118. quota = topUp.Money * common.QuotaPerUnit
  119. err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
  120. if err != nil {
  121. return err
  122. }
  123. return nil
  124. })
  125. if err != nil {
  126. common.SysError("topup failed: " + err.Error())
  127. return errors.New("充值失败,请稍后重试")
  128. }
  129. RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe)
  130. return nil
  131. }
  132. // topUpQueryWindowSeconds 限制充值记录查询的时间窗口(秒)。
  133. const topUpQueryWindowSeconds int64 = 30 * 24 * 60 * 60
  134. // topUpQueryCutoff 返回允许查询的最早 create_time(秒级 Unix 时间戳)。
  135. func topUpQueryCutoff() int64 {
  136. return common.GetTimestamp() - topUpQueryWindowSeconds
  137. }
  138. func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
  139. // Start transaction
  140. tx := DB.Begin()
  141. if tx.Error != nil {
  142. return nil, 0, tx.Error
  143. }
  144. defer func() {
  145. if r := recover(); r != nil {
  146. tx.Rollback()
  147. }
  148. }()
  149. cutoff := topUpQueryCutoff()
  150. // Get total count within transaction
  151. err = tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, cutoff).Count(&total).Error
  152. if err != nil {
  153. tx.Rollback()
  154. return nil, 0, err
  155. }
  156. // Get paginated topups within same transaction
  157. err = tx.Where("user_id = ? AND create_time >= ?", userId, cutoff).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
  158. if err != nil {
  159. tx.Rollback()
  160. return nil, 0, err
  161. }
  162. // Commit transaction
  163. if err = tx.Commit().Error; err != nil {
  164. return nil, 0, err
  165. }
  166. return topups, total, nil
  167. }
  168. // GetAllTopUps 获取全平台的充值记录(管理员使用,不限制时间窗口)
  169. func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
  170. tx := DB.Begin()
  171. if tx.Error != nil {
  172. return nil, 0, tx.Error
  173. }
  174. defer func() {
  175. if r := recover(); r != nil {
  176. tx.Rollback()
  177. }
  178. }()
  179. if err = tx.Model(&TopUp{}).Count(&total).Error; err != nil {
  180. tx.Rollback()
  181. return nil, 0, err
  182. }
  183. if err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
  184. tx.Rollback()
  185. return nil, 0, err
  186. }
  187. if err = tx.Commit().Error; err != nil {
  188. return nil, 0, err
  189. }
  190. return topups, total, nil
  191. }
  192. // searchTopUpCountHardLimit 搜索充值记录时 COUNT 的安全上限,
  193. // 防止对超大表执行无界 COUNT 触发 DoS。
  194. const searchTopUpCountHardLimit = 10000
  195. // SearchUserTopUps 按订单号搜索某用户的充值记录
  196. func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
  197. tx := DB.Begin()
  198. if tx.Error != nil {
  199. return nil, 0, tx.Error
  200. }
  201. defer func() {
  202. if r := recover(); r != nil {
  203. tx.Rollback()
  204. }
  205. }()
  206. query := tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, topUpQueryCutoff())
  207. if keyword != "" {
  208. pattern, perr := sanitizeLikePattern(keyword)
  209. if perr != nil {
  210. tx.Rollback()
  211. return nil, 0, perr
  212. }
  213. query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
  214. }
  215. if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
  216. tx.Rollback()
  217. common.SysError("failed to count search topups: " + err.Error())
  218. return nil, 0, errors.New("搜索充值记录失败")
  219. }
  220. if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
  221. tx.Rollback()
  222. common.SysError("failed to search topups: " + err.Error())
  223. return nil, 0, errors.New("搜索充值记录失败")
  224. }
  225. if err = tx.Commit().Error; err != nil {
  226. return nil, 0, err
  227. }
  228. return topups, total, nil
  229. }
  230. // SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用,不限制时间窗口)
  231. func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
  232. tx := DB.Begin()
  233. if tx.Error != nil {
  234. return nil, 0, tx.Error
  235. }
  236. defer func() {
  237. if r := recover(); r != nil {
  238. tx.Rollback()
  239. }
  240. }()
  241. query := tx.Model(&TopUp{})
  242. if keyword != "" {
  243. pattern, perr := sanitizeLikePattern(keyword)
  244. if perr != nil {
  245. tx.Rollback()
  246. return nil, 0, perr
  247. }
  248. query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
  249. }
  250. if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
  251. tx.Rollback()
  252. common.SysError("failed to count search topups: " + err.Error())
  253. return nil, 0, errors.New("搜索充值记录失败")
  254. }
  255. if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
  256. tx.Rollback()
  257. common.SysError("failed to search topups: " + err.Error())
  258. return nil, 0, errors.New("搜索充值记录失败")
  259. }
  260. if err = tx.Commit().Error; err != nil {
  261. return nil, 0, err
  262. }
  263. return topups, total, nil
  264. }
  265. // ManualCompleteTopUp 管理员手动完成订单并给用户充值
  266. func ManualCompleteTopUp(tradeNo string, callerIp string) error {
  267. if tradeNo == "" {
  268. return errors.New("未提供订单号")
  269. }
  270. refCol := "`trade_no`"
  271. if common.UsingPostgreSQL {
  272. refCol = `"trade_no"`
  273. }
  274. var userId int
  275. var quotaToAdd int
  276. var payMoney float64
  277. var paymentMethod string
  278. err := DB.Transaction(func(tx *gorm.DB) error {
  279. topUp := &TopUp{}
  280. // 行级锁,避免并发补单
  281. if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
  282. return errors.New("充值订单不存在")
  283. }
  284. // 幂等处理:已成功直接返回
  285. if topUp.Status == common.TopUpStatusSuccess {
  286. return nil
  287. }
  288. if topUp.Status != common.TopUpStatusPending {
  289. return errors.New("订单状态不是待支付,无法补单")
  290. }
  291. // 计算应充值额度:
  292. // - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
  293. // - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
  294. if topUp.PaymentProvider == PaymentProviderStripe {
  295. dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
  296. quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
  297. } else {
  298. dAmount := decimal.NewFromInt(topUp.Amount)
  299. dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
  300. quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
  301. }
  302. if quotaToAdd <= 0 {
  303. return errors.New("无效的充值额度")
  304. }
  305. // 标记完成
  306. topUp.CompleteTime = common.GetTimestamp()
  307. topUp.Status = common.TopUpStatusSuccess
  308. if err := tx.Save(topUp).Error; err != nil {
  309. return err
  310. }
  311. // 增加用户额度(立即写库,保持一致性)
  312. if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
  313. return err
  314. }
  315. userId = topUp.UserId
  316. payMoney = topUp.Money
  317. paymentMethod = topUp.PaymentMethod
  318. return nil
  319. })
  320. if err != nil {
  321. return err
  322. }
  323. // 事务外记录日志,避免阻塞
  324. RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin")
  325. return nil
  326. }
  327. func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) {
  328. if referenceId == "" {
  329. return errors.New("未提供支付单号")
  330. }
  331. var quota int64
  332. topUp := &TopUp{}
  333. refCol := "`trade_no`"
  334. if common.UsingPostgreSQL {
  335. refCol = `"trade_no"`
  336. }
  337. err = DB.Transaction(func(tx *gorm.DB) error {
  338. err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
  339. if err != nil {
  340. return errors.New("充值订单不存在")
  341. }
  342. if topUp.PaymentProvider != PaymentProviderCreem {
  343. return ErrPaymentMethodMismatch
  344. }
  345. if topUp.Status != common.TopUpStatusPending {
  346. return errors.New("充值订单状态错误")
  347. }
  348. topUp.CompleteTime = common.GetTimestamp()
  349. topUp.Status = common.TopUpStatusSuccess
  350. err = tx.Save(topUp).Error
  351. if err != nil {
  352. return err
  353. }
  354. // Creem 直接使用 Amount 作为充值额度(整数)
  355. quota = topUp.Amount
  356. // 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
  357. updateFields := map[string]interface{}{
  358. "quota": gorm.Expr("quota + ?", quota),
  359. }
  360. // 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
  361. if customerEmail != "" {
  362. // 先检查用户当前邮箱是否为空
  363. var user User
  364. err = tx.Where("id = ?", topUp.UserId).First(&user).Error
  365. if err != nil {
  366. return err
  367. }
  368. // 如果用户邮箱为空,则更新为支付时使用的邮箱
  369. if user.Email == "" {
  370. updateFields["email"] = customerEmail
  371. }
  372. }
  373. err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error
  374. if err != nil {
  375. return err
  376. }
  377. return nil
  378. })
  379. if err != nil {
  380. common.SysError("creem topup failed: " + err.Error())
  381. return errors.New("充值失败,请稍后重试")
  382. }
  383. RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem)
  384. return nil
  385. }
  386. func RechargeWaffo(tradeNo string, callerIp string) (err error) {
  387. if tradeNo == "" {
  388. return errors.New("未提供支付单号")
  389. }
  390. var quotaToAdd int
  391. topUp := &TopUp{}
  392. refCol := "`trade_no`"
  393. if common.UsingPostgreSQL {
  394. refCol = `"trade_no"`
  395. }
  396. err = DB.Transaction(func(tx *gorm.DB) error {
  397. err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error
  398. if err != nil {
  399. return errors.New("充值订单不存在")
  400. }
  401. if topUp.PaymentProvider != PaymentProviderWaffo {
  402. return ErrPaymentMethodMismatch
  403. }
  404. if topUp.Status == common.TopUpStatusSuccess {
  405. return nil // 幂等:已成功直接返回
  406. }
  407. if topUp.Status != common.TopUpStatusPending {
  408. return errors.New("充值订单状态错误")
  409. }
  410. dAmount := decimal.NewFromInt(topUp.Amount)
  411. dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
  412. quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
  413. if quotaToAdd <= 0 {
  414. return errors.New("无效的充值额度")
  415. }
  416. topUp.CompleteTime = common.GetTimestamp()
  417. topUp.Status = common.TopUpStatusSuccess
  418. if err := tx.Save(topUp).Error; err != nil {
  419. return err
  420. }
  421. if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
  422. return err
  423. }
  424. return nil
  425. })
  426. if err != nil {
  427. common.SysError("waffo topup failed: " + err.Error())
  428. return errors.New("充值失败,请稍后重试")
  429. }
  430. if quotaToAdd > 0 {
  431. RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffo)
  432. }
  433. return nil
  434. }
  435. func RechargeWaffoPancake(tradeNo string) (err error) {
  436. if tradeNo == "" {
  437. return errors.New("未提供支付单号")
  438. }
  439. var quotaToAdd int
  440. topUp := &TopUp{}
  441. refCol := "`trade_no`"
  442. if common.UsingPostgreSQL {
  443. refCol = `"trade_no"`
  444. }
  445. err = DB.Transaction(func(tx *gorm.DB) error {
  446. err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error
  447. if err != nil {
  448. return errors.New("充值订单不存在")
  449. }
  450. if topUp.PaymentProvider != PaymentProviderWaffoPancake {
  451. return ErrPaymentMethodMismatch
  452. }
  453. if topUp.Status == common.TopUpStatusSuccess {
  454. return nil
  455. }
  456. if topUp.Status != common.TopUpStatusPending {
  457. return errors.New("充值订单状态错误")
  458. }
  459. quotaToAdd = int(decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart())
  460. if quotaToAdd <= 0 {
  461. return errors.New("无效的充值额度")
  462. }
  463. topUp.CompleteTime = common.GetTimestamp()
  464. topUp.Status = common.TopUpStatusSuccess
  465. if err := tx.Save(topUp).Error; err != nil {
  466. return err
  467. }
  468. if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
  469. return err
  470. }
  471. return nil
  472. })
  473. if err != nil {
  474. common.SysError("waffo pancake topup failed: " + err.Error())
  475. return errors.New("充值失败,请稍后重试")
  476. }
  477. if quotaToAdd > 0 {
  478. RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
  479. }
  480. return nil
  481. }