redpacket_service.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. package services
  2. import (
  3. "app/commons/constant"
  4. "app/commons/core"
  5. "app/commons/core/redisclient"
  6. "app/commons/i18n"
  7. "app/commons/model/entity"
  8. "crypto/rand"
  9. "encoding/hex"
  10. "errors"
  11. "fmt"
  12. "github.com/shopspring/decimal"
  13. "gorm.io/gorm"
  14. "math/big"
  15. "time"
  16. )
  17. // RedPacketService 红包服务
  18. type RedPacketService struct {
  19. CommonService
  20. }
  21. // GeneratePacketNo 生成红包编号
  22. func (s *RedPacketService) GeneratePacketNo() string {
  23. b := make([]byte, 16)
  24. rand.Read(b)
  25. return fmt.Sprintf("RP%d%s", time.Now().Unix(), hex.EncodeToString(b)[:16])
  26. }
  27. // CreateRedPacket 创建红包
  28. func (s *RedPacketService) CreateRedPacket(
  29. userId int64,
  30. groupId string,
  31. packetType int,
  32. totalAmount decimal.Decimal,
  33. totalCount int,
  34. symbol string,
  35. blessingWords string,
  36. expireMinutes int,
  37. maxGrabAmount decimal.Decimal,
  38. lang string,
  39. ) (*entity.TgRedPacket, error) {
  40. // 参数校验
  41. if totalAmount.LessThanOrEqual(decimal.Zero) {
  42. return nil, errors.New("红包金额必须大于0")
  43. }
  44. if totalCount <= 0 || totalCount > 100 {
  45. return nil, errors.New("红包个数必须在1-100之间")
  46. }
  47. if packetType != 1 && packetType != 2 {
  48. return nil, errors.New("红包类型错误")
  49. }
  50. // 管理员发红包(userId=0)跳过资产检查和扣款
  51. isAdmin := userId == 0
  52. if !isAdmin {
  53. // 检查用户资产
  54. userAsset, err := s.GetAssetBySymbol(userId, symbol)
  55. if err != nil {
  56. return nil, errors.New("获取资产失败")
  57. }
  58. if userAsset.Balance.LessThan(totalAmount) {
  59. return nil, errors.New("余额不足")
  60. }
  61. }
  62. // 开始事务
  63. var packet *entity.TgRedPacket
  64. err := s.DB().Transaction(func(tx *gorm.DB) error {
  65. // 扣除用户余额(转为冻结),管理员跳过
  66. if !isAdmin {
  67. if err := s.ActionAsset(tx, userId, symbol, totalAmount.Neg(), totalAmount); err != nil {
  68. return fmt.Errorf("扣除余额失败: %w", err)
  69. }
  70. }
  71. // 计算过期时间
  72. expireDuration := 10 * time.Minute // 默认10分钟
  73. if expireMinutes > 0 {
  74. expireDuration = time.Duration(expireMinutes) * time.Minute
  75. }
  76. // 默认语言
  77. if lang == "" {
  78. lang = "vi"
  79. }
  80. // 创建红包记录
  81. packet = &entity.TgRedPacket{
  82. PacketNo: s.GeneratePacketNo(),
  83. UserId: userId,
  84. GroupId: groupId,
  85. PacketType: packetType,
  86. TotalAmount: totalAmount,
  87. TotalCount: totalCount,
  88. GrabbedCount: 0,
  89. GrabbedAmount: decimal.Zero,
  90. RemainCount: totalCount,
  91. RemainAmount: totalAmount,
  92. Symbol: symbol,
  93. MaxGrabAmount: maxGrabAmount,
  94. Lang: lang,
  95. BlessingWords: blessingWords,
  96. Status: 1, // 进行中
  97. ExpireAt: time.Now().Add(expireDuration),
  98. }
  99. if err := tx.Create(packet).Error; err != nil {
  100. return fmt.Errorf("创建红包失败: %w", err)
  101. }
  102. return nil
  103. })
  104. if err != nil {
  105. return nil, err
  106. }
  107. return packet, nil
  108. }
  109. // GrabRedPacket 抢红包
  110. func (s *RedPacketService) GrabRedPacket(
  111. packetNo string,
  112. userId int64,
  113. telegramId int64,
  114. telegramUsername string,
  115. ) (*entity.TgRedPacketRecord, error) {
  116. // 分布式锁(防止并发抢同一个红包)
  117. lockKey := fmt.Sprintf("grab_redpacket:%s", packetNo)
  118. lock, err := redisclient.LockWithTime(lockKey, 5)
  119. if err != nil {
  120. return nil, errors.New("系统繁忙,请稍后重试")
  121. }
  122. defer redisclient.UnlockSafe(lock)
  123. // 获取红包信息
  124. var packet entity.TgRedPacket
  125. if err := s.DB().Where("packet_no = ?", packetNo).First(&packet).Error; err != nil {
  126. if errors.Is(err, gorm.ErrRecordNotFound) {
  127. return nil, errors.New("红包不存在")
  128. }
  129. return nil, err
  130. }
  131. // 检查红包状态
  132. if packet.Status != 1 {
  133. return nil, errors.New("红包已结束")
  134. }
  135. if time.Now().After(packet.ExpireAt) {
  136. return nil, errors.New("红包已过期")
  137. }
  138. if packet.RemainCount <= 0 {
  139. return nil, errors.New("红包已抢完")
  140. }
  141. // 检查是否已经抢过(优先用telegramId判断,兼容未绑定平台账号的用户)
  142. var count int64
  143. if telegramId > 0 {
  144. s.DB().Model(&entity.TgRedPacketRecord{}).
  145. Where("packet_id = ? AND telegram_id = ?", packet.Id, telegramId).
  146. Count(&count)
  147. } else {
  148. s.DB().Model(&entity.TgRedPacketRecord{}).
  149. Where("packet_id = ? AND user_id = ?", packet.Id, userId).
  150. Count(&count)
  151. }
  152. if count > 0 {
  153. return nil, errors.New("您已经抢过这个红包了")
  154. }
  155. // 开始事务
  156. var record *entity.TgRedPacketRecord
  157. err = s.DB().Transaction(func(tx *gorm.DB) error {
  158. // 计算抢到的金额
  159. amount := s.CalculateGrabAmount(&packet)
  160. // 先保存原始值(GORM Updates 会回写到 packet 对象,导致后续判断用到更新后的值)
  161. origGrabbedCount := packet.GrabbedCount
  162. origRemainCount := packet.RemainCount
  163. // 更新红包信息
  164. updates := map[string]interface{}{
  165. "grabbed_count": origGrabbedCount + 1,
  166. "grabbed_amount": packet.GrabbedAmount.Add(amount),
  167. "remain_count": origRemainCount - 1,
  168. "remain_amount": packet.RemainAmount.Sub(amount),
  169. }
  170. if err := tx.Model(&packet).Updates(updates).Error; err != nil {
  171. return fmt.Errorf("更新红包失败: %w", err)
  172. }
  173. // 创建抢红包记录
  174. record = &entity.TgRedPacketRecord{
  175. PacketId: packet.Id,
  176. PacketNo: packet.PacketNo,
  177. UserId: userId,
  178. TelegramId: telegramId,
  179. TelegramUsername: telegramUsername,
  180. Amount: amount,
  181. Sequence: int(origGrabbedCount) + 1,
  182. GrabbedAt: time.Now(),
  183. }
  184. if err := tx.Create(record).Error; err != nil {
  185. return fmt.Errorf("创建记录失败: %w", err)
  186. }
  187. // 增加用户余额,userId=0 时跳过(Telegram用户未绑定平台账号)
  188. if userId > 0 {
  189. // 确保用户有资产记录(不存在则创建)
  190. var assetCount int64
  191. tx.Model(&entity.Asset{}).Where("user_id = ? AND symbol = ?", userId, packet.Symbol).Count(&assetCount)
  192. if assetCount == 0 {
  193. newAsset := &entity.Asset{
  194. UserId: userId,
  195. Symbol: packet.Symbol,
  196. Balance: decimal.Zero,
  197. Frozen: decimal.Zero,
  198. }
  199. if err := tx.Create(newAsset).Error; err != nil {
  200. return fmt.Errorf("创建资产记录失败: %w", err)
  201. }
  202. }
  203. // 写流水 + 改资产
  204. bs := constant.BsById(constant.BsRedPacketGrab)
  205. bs.ContextName = "红包编号"
  206. bs.ContextValue = packet.PacketNo
  207. bs.Desc = fmt.Sprintf("抢红包收入: %s %s, 红包: %s", amount.String(), packet.Symbol, packet.PacketNo)
  208. if err := s.GenBillAndActionAsset(tx, userId, packet.Symbol, amount, decimal.Zero, bs); err != nil {
  209. return fmt.Errorf("增加余额失败: %w", err)
  210. }
  211. }
  212. // 如果抢完了,更新红包状态(使用原始值判断,因为 GORM Updates 已回写 packet)
  213. if origRemainCount == 1 {
  214. now := time.Now()
  215. if err := tx.Model(&packet).Updates(map[string]interface{}{
  216. "status": 2, // 已抢完
  217. "completed_at": &now,
  218. }).Error; err != nil {
  219. return fmt.Errorf("更新完成状态失败: %w", err)
  220. }
  221. // 异步计算手气最佳
  222. go s.CalculateBestLuck(packet.Id)
  223. }
  224. return nil
  225. })
  226. if err != nil {
  227. return nil, err
  228. }
  229. return record, nil
  230. }
  231. // CalculateGrabAmount 计算抢红包金额
  232. func (s *RedPacketService) CalculateGrabAmount(packet *entity.TgRedPacket) decimal.Decimal {
  233. var amount decimal.Decimal
  234. // 最后一个红包:剩余所有金额
  235. if packet.RemainCount == 1 {
  236. amount = packet.RemainAmount
  237. // 应用单人上限(即使是最后一个也要限制,超出部分由过期结算退回)
  238. if packet.MaxGrabAmount.GreaterThan(decimal.Zero) && amount.GreaterThan(packet.MaxGrabAmount) {
  239. amount = packet.MaxGrabAmount
  240. }
  241. return amount
  242. }
  243. // 普通红包:平均分配
  244. if packet.PacketType == 1 {
  245. amount = packet.RemainAmount.Div(decimal.NewFromInt(int64(packet.RemainCount)))
  246. } else if packet.PacketType == 2 {
  247. // 手气红包:二倍均值算法
  248. avg := packet.RemainAmount.Div(decimal.NewFromInt(int64(packet.RemainCount)))
  249. // 最大值 = 平均值 * 2
  250. maxAmount := avg.Mul(decimal.NewFromInt(2))
  251. // 最小值:0.01
  252. minAmount := decimal.NewFromFloat(0.01)
  253. // 随机金额:minAmount ~ maxAmount
  254. amount = s.randomDecimal(minAmount, maxAmount)
  255. // 确保不超过剩余金额
  256. if amount.GreaterThan(packet.RemainAmount) {
  257. amount = packet.RemainAmount
  258. }
  259. } else {
  260. // 默认平均分配
  261. amount = packet.RemainAmount.Div(decimal.NewFromInt(int64(packet.RemainCount)))
  262. }
  263. // 应用单人上限
  264. if packet.MaxGrabAmount.GreaterThan(decimal.Zero) && amount.GreaterThan(packet.MaxGrabAmount) {
  265. amount = packet.MaxGrabAmount
  266. }
  267. return amount
  268. }
  269. // randomDecimal 生成随机金额
  270. func (s *RedPacketService) randomDecimal(min, max decimal.Decimal) decimal.Decimal {
  271. // 计算范围
  272. diff := max.Sub(min)
  273. // 生成随机数 0-1
  274. randomBig, _ := rand.Int(rand.Reader, big.NewInt(10000))
  275. randomFloat := decimal.NewFromBigInt(randomBig, -4) // 0.0000 - 0.9999
  276. // 计算随机金额
  277. amount := min.Add(diff.Mul(randomFloat))
  278. // 保留2位小数
  279. return amount.Round(2)
  280. }
  281. // CalculateBestLuck 计算手气最佳
  282. func (s *RedPacketService) CalculateBestLuck(packetId int64) {
  283. // 查询所有记录
  284. var records []entity.TgRedPacketRecord
  285. s.DB().Where("packet_id = ?", packetId).Order("amount DESC").Find(&records)
  286. if len(records) == 0 {
  287. return
  288. }
  289. // 标记手气最佳(金额最大的)
  290. bestRecord := records[0]
  291. s.DB().Model(&entity.TgRedPacketRecord{}).
  292. Where("id = ?", bestRecord.Id).
  293. Update("is_best", 1)
  294. }
  295. // GetRedPacketDetail 获取红包详情
  296. func (s *RedPacketService) GetRedPacketDetail(packetNo string) (*entity.TgRedPacket, []entity.TgRedPacketRecord, error) {
  297. var packet entity.TgRedPacket
  298. if err := s.DB().Where("packet_no = ?", packetNo).First(&packet).Error; err != nil {
  299. return nil, nil, err
  300. }
  301. var records []entity.TgRedPacketRecord
  302. s.DB().Where("packet_id = ?", packet.Id).Order("grabbed_at ASC").Find(&records)
  303. return &packet, records, nil
  304. }
  305. // GetUserRedPacketRecords 获取用户的红包记录
  306. func (s *RedPacketService) GetUserRedPacketRecords(userId int64, page, pageSize int) ([]entity.TgRedPacketRecord, int64, error) {
  307. var records []entity.TgRedPacketRecord
  308. var total int64
  309. db := s.DB().Model(&entity.TgRedPacketRecord{}).Where("user_id = ?", userId)
  310. db.Count(&total)
  311. offset := (page - 1) * pageSize
  312. if err := db.Order("grabbed_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
  313. return nil, 0, err
  314. }
  315. return records, total, nil
  316. }
  317. // ExpiredPacketInfo 过期红包信息(用于 tasks 层编辑 Telegram 消息)
  318. type ExpiredPacketInfo struct {
  319. ChatID int64
  320. MessageID int
  321. Text string
  322. }
  323. // ProcessExpiredPackets 处理所有已过期但状态仍为进行中的红包
  324. // 返回需要编辑 Telegram 消息的红包信息列表(由 tasks 层调用 telegram 包避免循环依赖)
  325. func (s *RedPacketService) ProcessExpiredPackets() []ExpiredPacketInfo {
  326. var packets []entity.TgRedPacket
  327. s.DB().Where("status = 1 AND expire_at < ?", time.Now()).Find(&packets)
  328. if len(packets) == 0 {
  329. return nil
  330. }
  331. core.Log.Infof("发现 %d 个过期红包,开始结算...", len(packets))
  332. var editList []ExpiredPacketInfo
  333. for _, packet := range packets {
  334. info := s.settleExpiredPacket(&packet)
  335. if info != nil {
  336. editList = append(editList, *info)
  337. }
  338. }
  339. return editList
  340. }
  341. // settleExpiredPacket 结算单个过期红包
  342. func (s *RedPacketService) settleExpiredPacket(packet *entity.TgRedPacket) *ExpiredPacketInfo {
  343. now := time.Now()
  344. err := s.DB().Transaction(func(tx *gorm.DB) error {
  345. // 1. 更新红包状态为已过期
  346. if err := tx.Model(packet).Updates(map[string]interface{}{
  347. "status": 3, // 已过期
  348. "completed_at": &now,
  349. }).Error; err != nil {
  350. return fmt.Errorf("更新状态失败: %w", err)
  351. }
  352. // 2. 退还未领取金额给发送者(管理员发的 userId=0 不退)
  353. if packet.UserId > 0 && packet.RemainAmount.GreaterThan(decimal.Zero) {
  354. bs := constant.BsById(constant.BsRedPacketRefund)
  355. bs.Desc = fmt.Sprintf("红包过期退回: %s %s, 红包: %s", packet.RemainAmount.String(), packet.Symbol, packet.PacketNo)
  356. bs.ContextName = "红包编号"
  357. bs.ContextValue = packet.PacketNo
  358. // balance += remainAmount (退回可用余额), frozen -= remainAmount (解冻)
  359. if err := s.GenBillAndActionAsset(tx, packet.UserId, packet.Symbol,
  360. packet.RemainAmount, packet.RemainAmount.Neg(), bs); err != nil {
  361. return fmt.Errorf("退还余额失败: %w", err)
  362. }
  363. }
  364. return nil
  365. })
  366. if err != nil {
  367. core.Log.Errorf("结算过期红包失败 [%s]: %v", packet.PacketNo, err)
  368. return nil
  369. }
  370. // 3. 如果有人领取过,计算手气最佳
  371. if packet.GrabbedCount > 0 {
  372. go s.CalculateBestLuck(packet.Id)
  373. }
  374. core.Log.Infof("红包过期结算完成 [%s]: 退回 %s %s, 已领 %d/%d",
  375. packet.PacketNo, packet.RemainAmount.String(), packet.Symbol,
  376. packet.GrabbedCount, packet.TotalCount)
  377. // 4. 如果有 Telegram 消息ID,构建编辑文本返回给 tasks 层
  378. if packet.MessageId > 0 {
  379. text := s.buildExpiredMessageText(packet)
  380. var chatID int64
  381. fmt.Sscanf(packet.GroupId, "%d", &chatID)
  382. return &ExpiredPacketInfo{
  383. ChatID: chatID,
  384. MessageID: int(packet.MessageId),
  385. Text: text,
  386. }
  387. }
  388. return nil
  389. }
  390. // buildExpiredMessageText 构建过期红包的 Telegram 消息文本
  391. func (s *RedPacketService) buildExpiredMessageText(packet *entity.TgRedPacket) string {
  392. // 查询已领取记录
  393. _, records, _ := s.GetRedPacketDetail(packet.PacketNo)
  394. lang := packet.Lang
  395. if lang == "" {
  396. lang = "vi"
  397. }
  398. m := i18n.GetLang(lang)
  399. packetTypeText := i18n.PacketTypeText(packet.PacketType, lang)
  400. editText := fmt.Sprintf("%s\n💰 %s\n\n%s: %.2f %s | %s: %d | %s",
  401. m.RedPacketTitle,
  402. packet.BlessingWords,
  403. m.TotalAmount, packet.TotalAmount.InexactFloat64(), packet.Symbol,
  404. m.Count, packet.TotalCount,
  405. packetTypeText)
  406. if len(records) > 0 {
  407. editText += fmt.Sprintf("\n\n"+m.GrabDetail, packet.GrabbedCount, packet.TotalCount)
  408. for _, r := range records {
  409. displayName := r.TelegramUsername
  410. if displayName != "" {
  411. displayName = "@" + displayName
  412. } else {
  413. displayName = fmt.Sprintf(m.UserPrefix, r.TelegramId)
  414. }
  415. editText += fmt.Sprintf("\n"+m.GrabRecord, displayName, r.Amount.InexactFloat64(), packet.Symbol)
  416. }
  417. }
  418. editText += "\n\n" + m.ExpiredMsg
  419. return editText
  420. }