fcm.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. package server
  2. import (
  3. "context"
  4. "encoding/json"
  5. "log"
  6. "time"
  7. firebase "firebase.google.com/go"
  8. "firebase.google.com/go/messaging"
  9. "github.com/robfig/cron/v3"
  10. "github.com/rotisserie/eris"
  11. "go.uber.org/zap"
  12. "google.golang.org/api/option"
  13. "sikey.com/websocket/models"
  14. "sikey.com/websocket/repositories"
  15. )
  16. const (
  17. iosFirebaseServiceAccountID = "firebase-adminsdk-uycdi@sikey-watch.iam.gserviceaccount.com"
  18. iosFirebaseCredentials = `{
  19. "type": "service_account",
  20. "project_id": "sikey-watch",
  21. "private_key_id": "ab460af1d589188d0a8ab6c597f7daa283317838",
  22. "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDKjxzFiEJkxMCw\nGhPaZDSFvOhBhYeiWpS1jKNf4lnkkQz/OlKQnK7wFBFGZJ8ZHJQGzKgkR35P9J8L\n7gpEkK3ar4yCtuZL4Vtr0CnvXeO8VJAoUwHkn0oDXTOvTWEWxmt7ROSz9qDNT1jn\n8m18+eJrP9bmqd/junm6RV0qErs0bqZKt2GN500jM67hFzhK7yrGevubr/qsQWS/\nFlpXaaGAvirOJ3tT/fRqVep0K1PUR66IOwsv510GZbvPWg6xG7NWvTOQBM5rvKKu\npHRXhe9N3HCI55wbFBTLsn/bIbxiLCvF4vpd3moFOzohstQKsAxZKcbk4NAxTxyU\nwnk1gNfzAgMBAAECggEABDoDQC3tMLwYRzRsGKEBpY7BC+j04RLhyn4CRLRs6sOK\nEWNOqLzUO6c3iw/7b+M0RYYMtiEVxiZVEUMozLB2KDuEg4+8c3Xi2feQgHGlmF7t\nWSIORo56VWKi+vPy5C5EvOA9o3Gwfkg7Ey/wT+zI6DL/UXXW+aIA+z2KSZ/1bw0c\nf2A4sUaS1OJ5yE9oTdPudO09mBGrBd4G2NigQJ9lgz+A9nh5+ECT/qZJs+Yc/gFT\nq6KhgJgVZydptbNaW9NgW0lKrtJPmxTZ3nO+u2iWpuGJGMb3tNGuR1P35OBK4UBk\nmwgrXK6LDPbuo1RMNVTBFeWf5yCAtekbgtxXXleZWQKBgQDxG3obkRwq02Wd+r/i\nbkScwGeEsnXbjIzeQsalb4FDOQX69J7GFnjAcG+23yjoiRPmA0gnGzXEsABocFoR\nJHgJGwiXN+sjFKbHOKKbLK1hGHSUZfCW+7KGx2nvUA8m4qXG3HP/XNFZQQaJrnb2\nLIE+w5GkAltR1uFVK3YCf19F2wKBgQDXEhaaaqG9+pHxTXRUmeJhruVooCs4w5eP\n9o9WUmX0w6UNgLe1AqLUFe0/zxOphD1MOUMs3FReWox6AdrRzdXlE/2gKR0NdGQG\nFiNSuhb58xnM6iMECg+im39HqCXRZRQKOeEZu7XJEPZU2+RvlM5ov00a+aif4Pfb\nEtarj9qtyQKBgQCazYeC95JcNMqDuiFFYoMPGcHdQy/EvOMdOUaNpaAa5xvd2v2u\nNXNmK0qu4W1Ej+6EugqzgRbuqAo7BBfv9bhUMFU9sht4tKO4Oba9ZtwTAT6ooSLt\n7cDcJGDx1DdGQjMqERUxGgkYA1YNREUBHeYFxE9YPGMhkpOuuW7Vf65ODQKBgCYb\nUl3x3s6mgw1aR+5lhbMBJiyvlHjuTwB3E1acKux/bdNCp0ovOWKSsALKUhWLFMFY\ntApSz7AYIyPLCFZ8PhXkwN+L6VXk9YQOkBusVT3cUQn5wlKI5dRN8PNlW64KVs6p\nCrVgiQkjNEI84/DRUPFGVAcfjT0mw0PRxq+HQZvhAoGBAIeRKHj/dceKR+pDw1vN\nTgSK61MfCAFMPSANLGYq+1lRoyDlc9ekFAZYaGXsSPy9ja3VSJkH/eu0rhUS6uYG\na7sVk/TwXoze/scHiR0JZNiKxoyoFaHJmblFdk0TC4+vwcpJFRSoQhh6gz0tZUL5\nAatOg4mA1vMm3Ll+xUY4ZzFe\n-----END PRIVATE KEY-----\n",
  23. "client_email": "firebase-adminsdk-uycdi@sikey-watch.iam.gserviceaccount.com",
  24. "client_id": "109500502903490303964",
  25. "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  26. "token_uri": "https://oauth2.googleapis.com/token",
  27. "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  28. "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-uycdi%40sikey-watch.iam.gserviceaccount.com",
  29. "universe_domain": "googleapis.com"
  30. }`
  31. androidFirebaseServiceAccountID = "firebase-adminsdk-oasvo@sik-veryfit.iam.gserviceaccount.com"
  32. androidFirebaseCredentials = `{
  33. "type": "service_account",
  34. "project_id": "sik-veryfit",
  35. "private_key_id": "268ebd274a04ebdd14375440a193ddb1e7acec05",
  36. "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCZsf9lfcZ1/5hK\nq1CV55VVCi3q8Fi1r3Qw1UPyOAuHsHhIMCYs7wOas2Vimft43T6nMhEMAVnuZkg8\nOnM86HcECsK9wIFZGzGu7sKJQW2mJ3VEh/1PAo2L9Cakcr4K70RWKMtNV6jly5+o\nVYoxOyxNlWYGMTAc41XcjBYOPAwJFXnhbpFv/XbZMj/cFXUvB/ORpJXnDZqyVcBH\nlSxfTHl1XSXdXuPzIulIQeA5+1ZGtBBrb3fdTFdoMb8xcpMKwqILzVAlIDuwGMg4\n2MZ7pctOm9Qvqww9s/Vwhmog43MfGNslN8m5j9w7rBvA3wWbnIZ5Dl8f2VGhgnat\nEcWUC97LAgMBAAECggEAAo6FrkajkL6GlUrGK3aI+fFIP/Lmi+8vXWsfpKfGBR5A\nnO6ZYXM9Dhxj+gDg6K5VhQJLeDJJxM0sC0su4ybrQI6yvgCABKlz4nI/ZcotKU4c\n1nIQeTYASuZZzBuUib4HUEFKvEl60Db1k2KPonnP8RGdy+uFV8uTSmStIUAfGXLt\nnVoWKXvJmuT+hpKfvq2xVvYJFSoVrnrvbm9RVj9oUc2jbO3DXiNW/b73qLBgUh+4\nP+aLkspLQbLkmaz0oX6MaWHwnvOHPvVJUSqWHOssv60qeSoD+irM414YVBXaRXce\nQDEUq3Fdv5ORJzlru2iLZQoq71BHI7Ye3btV/DV7kQKBgQDPNW5p4yTMRrEK4GVE\ntCkag4cfKuBs9+wxl699jUlJJbQ7dqjHewN0zwFJmTCedph3SCBOM0JR3PpcwhAQ\n9HTuB3HxZ+vkV9w3UjDhlqOe13UvBrhjcqiQ1FAax59FfEA1vZVWAk4XcK3v+Sz8\nwtVl66haEa8+2puNmAUJtWYepQKBgQC94sVRM4220gMC9SPXr2diLt1DEMIAlbXO\noY7hAA89xRQGZYrcJw5bbUx4Dxkh24SPagmO102v+SZVUymo77bftYyB/+HWR2C2\ncIdJr304xcwxbcuPXYLm4cJOvOKVaZIYO1MaomMH4KhtasuqIc2DcjaLEoB0KrAG\ngY/aWsF8rwKBgQC/hgOom+tHZY//Hap89omHmEss49TM49vNewcaZZ13nwIYdHVM\n7MclELF/9biav8PtfR1fKsICKN7BHh77jPkHiponKIdBaHSELdRAr5xNFZma/fsw\n8KoeNCBWoGz3LQGsqq998GN+Bwi+5vJOL0hQDKJvnij9T9K37eu6LZFWgQKBgG+Z\nOKiJqQ86jISPeHpJ33Pn4SiKT8qyMkD32JZKR6rhCezcfuj2l0yKhzfEf9vTPSxg\ngK+PwFvdDXd2QfTEtfDyrVq1/Y/HMYLnzcsztV/JeEHQqqNerRFuu5k1D+IKQs+1\nBhmQOK2njrWH35zk8vj/BilkPVSIxh18xgPp9O+HAoGBAMvhSFbM5RxAtWTFsNRP\nOqY+SI0uUtcJsSWJLZGKXuYRjaT6DbAbiLTkGm4RflPXN35YydKJsiIcQhGfTkz/\nN3p0aTGz7mpmomHrKgQQ2vJaEFk4xbFctmnyepfAf1c/Odby5oYzeXSr/oSrqD3A\nJYfxCeHcusIh5jHG6OGGK5wT\n-----END PRIVATE KEY-----\n",
  37. "client_email": "firebase-adminsdk-oasvo@sik-veryfit.iam.gserviceaccount.com",
  38. "client_id": "108604649099796268804",
  39. "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  40. "token_uri": "https://oauth2.googleapis.com/token",
  41. "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  42. "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-oasvo%40sik-veryfit.iam.gserviceaccount.com",
  43. "universe_domain": "googleapis.com"
  44. }`
  45. )
  46. const DefaultQueueStatus = -1
  47. const DefaultRemainingRetries = 3
  48. type FirebaseMessageServer struct {
  49. //iosApp *firebase.App
  50. iosClient *messaging.Client
  51. androidClient *messaging.Client
  52. repos *repositories.Repositories
  53. }
  54. func newIosFirebaseMessageClient(ctx context.Context) *messaging.Client {
  55. conf := &firebase.Config{ServiceAccountID: iosFirebaseServiceAccountID}
  56. app, err := firebase.NewApp(ctx, conf,
  57. // option.WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
  58. option.WithCredentialsJSON([]byte(iosFirebaseCredentials)))
  59. if err != nil {
  60. log.Fatalln(err)
  61. }
  62. // firebase messaging client
  63. client, err := app.Messaging(ctx)
  64. if err != nil {
  65. panic(eris.Wrap(err, "ios: unable to create fcm client connect"))
  66. }
  67. return client
  68. }
  69. func androidFirebaseMessageClient(ctx context.Context) *messaging.Client {
  70. conf := &firebase.Config{ServiceAccountID: androidFirebaseServiceAccountID}
  71. app, err := firebase.NewApp(ctx, conf,
  72. // option.WithHTTPClient(&http.Client{Timeout: 5 * time.Second}),
  73. option.WithCredentialsJSON([]byte(androidFirebaseCredentials)))
  74. if err != nil {
  75. log.Fatalln(err)
  76. }
  77. // firebase messaging client
  78. client, err := app.Messaging(ctx)
  79. if err != nil {
  80. panic(eris.Wrap(err, "android: unable to create fcm client connect"))
  81. }
  82. return client
  83. }
  84. func NewFirebaseMessageServer(repos *repositories.Repositories) *FirebaseMessageServer {
  85. ctx := context.Background()
  86. firebaseMessageServer := &FirebaseMessageServer{
  87. iosClient: newIosFirebaseMessageClient(ctx),
  88. androidClient: androidFirebaseMessageClient(ctx),
  89. repos: repos,
  90. }
  91. c := cron.New()
  92. c.AddFunc("@every 5s", func() { firebaseMessageServer.queueRun(ctx) })
  93. c.Start()
  94. return firebaseMessageServer
  95. }
  96. func (f *FirebaseMessageServer) Send(ctx context.Context, uid string, message Message) error {
  97. // "newMsg": { "title": "News", "body": "click to view" },
  98. // "chatting": { "title": "Chat Message", "body": "click to view" },
  99. // "location": { "title": "Location Changed", "body": "click to view" },
  100. // "fence": { "title": "Electronic Fence", "body": "click to view" },
  101. // "notification": { "title": "New Notification","body": "click to view" },
  102. // "videoCall": { "title": "Call Notification", "body": "click to view" }
  103. var title string
  104. var body = "click to view"
  105. remainingRetries := 1
  106. messageType := message.MessageType()
  107. switch messageType {
  108. case MessageTypeDownChating, MessageTypeUpChating:
  109. title = "Chat Message"
  110. case MessageTypeLocation:
  111. title = "Location Changed"
  112. // 定位消息不参与 fcm
  113. zap.L().Info("[firebase] 定位消息不参与fcm推送", zap.String("user_id", uid))
  114. return nil
  115. case MessageTypeNotification:
  116. title = "New Notification"
  117. case MessageTypeVideoCall:
  118. remainingRetries = DefaultRemainingRetries
  119. title = "Call Notification"
  120. // 视频通话里的 payload 会有接通和挂断
  121. // 这里需要过滤掉挂断的通知消息
  122. if videoCall, ok := message.(*VideoCall); ok {
  123. if videoCall.Content.Dial == -1 {
  124. zap.L().Info("[firebase] 视频通话挂断通知不参与fcm推送", zap.String("user_id", uid))
  125. return nil
  126. }
  127. }
  128. default:
  129. title = "News"
  130. }
  131. // 加入消息到 fcm 队列
  132. queue := &models.FirebaseMessagingQueue{
  133. Title: title,
  134. Body: body,
  135. Receiver: uid,
  136. Data: string(serializeMessage(message)),
  137. RemainingRetries: remainingRetries,
  138. }
  139. if err := f.repos.FirebaseMessageQueueRepository.Create(ctx, queue); err != nil {
  140. zap.L().Info("[firebase] 加入队列错误", zap.Error(err), zap.String("user_id", uid))
  141. return eris.Wrap(err, "uanble to add fcm queue")
  142. }
  143. zap.L().Info("[firebase] 已加入推送队列", zap.String("user_id", uid), zap.Any("messageType", messageType), zap.Int("remainingRetries", remainingRetries))
  144. return nil
  145. }
  146. func (f *FirebaseMessageServer) queueRun(ctx context.Context) {
  147. //zap.L().Info("[firebase] queue running")
  148. // 每秒运行fcm队列
  149. queues, err := f.repos.FirebaseMessageQueueRepository.FindRetrieableQueue(ctx)
  150. if err != nil {
  151. zap.L().Error("[firebase] FindRetrieableQueue error", zap.Error(err))
  152. return
  153. }
  154. zap.L().Info("[firebase] queue running len ", zap.Int("queues", len(queues)))
  155. for _, queue := range queues {
  156. receiver := queue.Receiver
  157. firebaseToken, err := f.repos.FirebaseMessageRepository.GetFirebaseToken(ctx, receiver)
  158. if err != nil {
  159. _ = f.repos.FirebaseMessageQueueRepository.Delete(ctx, queue.ID)
  160. if eris.Is(err, models.ErrRecordNotFound) {
  161. zap.L().Info("[firebase] GetFirebaseToken NotFound 删除离线消息", zap.String("user_id", receiver), zap.Int("queue.ID", queue.ID))
  162. continue
  163. }
  164. zap.L().Error("[firebase] unable to find firebase message database", zap.Error(err), zap.String("user_id", receiver), zap.Int("queue.ID", queue.ID))
  165. return
  166. }
  167. msg := &messaging.Message{
  168. Token: firebaseToken.Token,
  169. Data: map[string]string{
  170. "message": queue.Data,
  171. },
  172. Android: &messaging.AndroidConfig{
  173. Notification: &messaging.AndroidNotification{
  174. Sound: "default",
  175. Priority: messaging.PriorityHigh,
  176. },
  177. },
  178. APNS: &messaging.APNSConfig{
  179. Payload: &messaging.APNSPayload{
  180. Aps: &messaging.Aps{
  181. Sound: "default",
  182. },
  183. },
  184. },
  185. Notification: &messaging.Notification{
  186. Title: queue.Title,
  187. Body: queue.Body,
  188. },
  189. }
  190. var client *messaging.Client
  191. switch firebaseToken.System {
  192. case "android":
  193. client = f.androidClient
  194. // 将JSON字符串转换为NatsPubMessage对象
  195. var message NatsPubMessage[NatsPubVideoCallMessage]
  196. err := json.Unmarshal([]byte(queue.Data), &message)
  197. if err != nil {
  198. zap.L().Error("[firebase] Error unmarshaling JSON: ", zap.Error(err), zap.String("user_id", receiver), zap.Int("queue.ID", queue.ID))
  199. } else {
  200. if message.Type == MessageTypeVideoCall {
  201. msg.Android.Notification.ChannelID = "video_call"
  202. }
  203. }
  204. default:
  205. client = f.iosClient
  206. }
  207. zap.L().Info("[firebase] Send", zap.Any("msg", msg), zap.String("user_id", receiver), zap.String("system", firebaseToken.System))
  208. var resultName string
  209. if resultName, err = client.Send(ctx, msg); err != nil {
  210. zap.L().Error("[firebase] 发送错误", zap.Error(err), zap.String("user_id", receiver), zap.Int("queue.ID", queue.ID))
  211. remainingRetries := queue.RemainingRetries - 1
  212. if remainingRetries == 0 {
  213. _ = f.repos.FirebaseMessageQueueRepository.Delete(ctx, queue.ID)
  214. return
  215. }
  216. queue.LastRetryTime = Now(time.Now().UTC())
  217. queue.RemainingRetries = remainingRetries
  218. _ = f.repos.FirebaseMessageQueueRepository.UpdateRetries(ctx, &queue)
  219. return
  220. }
  221. zap.L().Info("[firebase] Successfully",
  222. zap.Any("msg", msg),
  223. zap.String("user_id", receiver),
  224. zap.String("result", resultName))
  225. remainingRetries := queue.RemainingRetries - 1
  226. if remainingRetries == 0 {
  227. _ = f.repos.FirebaseMessageQueueRepository.Delete(ctx, queue.ID)
  228. return
  229. }
  230. queue.LastRetryTime = Now(time.Now().UTC())
  231. queue.RemainingRetries = remainingRetries
  232. _ = f.repos.FirebaseMessageQueueRepository.UpdateRetries(ctx, &queue)
  233. }
  234. }
  235. func Now(n time.Time) *time.Time {
  236. return &n
  237. }
  238. type NatsPubVideoCallMessage struct {
  239. AccountId string `json:"accountId"`
  240. Dial int8 `json:"dial"` // -1: 挂断 1: 接通
  241. Receiver string `json:"receiver"`
  242. Sender string `json:"sender"`
  243. }
  244. type NatsPubMessage[T NatsPubVideoCallMessage] struct {
  245. RequestId string `json:"requestId"`
  246. Type int8 `json:"type"`
  247. Content *T `json:"content"`
  248. }