diff --git a/cmd/common/main.go b/cmd/common/main.go index ba1fccb4..d739f1e7 100644 --- a/cmd/common/main.go +++ b/cmd/common/main.go @@ -172,26 +172,22 @@ func syncNoticeTask() error { return fmt.Errorf("notice sync task: failed to create notice: %w", err) } - channelProperties := map[string]string{ - "channel_activity": "com.west2online.umeng.MfrMessageActivity", - "huawei_channel_importance": "NORMAL", - "xiaomi_channel_id": config.Vendors.Xiaomi.JwchNotice, - } // 进行消息推送 - err = umeng.SendAndroidGroupcastWithUrl(config.Umeng.Android.AppKey, config.Umeng.Android.AppMasterSecret, - "", "教务处通知", info.Title, constants.UmengJwchNoticeTag, info.URL, channelProperties) - if err != nil { - logger.Errorf("notice sync task: failed to send notice to Android: %v", err) - } + if ok := umeng.EnqueueAsync(func() error { + err = umeng.SendAndroidGroupcastWithUrl("教务处通知", info.Title, "", info.URL, constants.UmengJwchNoticeTag, "教务处") + if err != nil { + logger.Errorf("notice sync task: failed to send notice to Android: %v", err) + } - err = umeng.SendIOSGroupcast(config.Umeng.IOS.AppKey, config.Umeng.IOS.AppMasterSecret, - "教务处通知", "", info.Title, constants.UmengJwchNoticeTag) - if err != nil { - logger.Errorf("notice sync task: failed to send notice to IOS: %v", err) + err = umeng.SendIOSGroupcast("教务处通知", "", info.Title, constants.UmengJwchNoticeTag, "教务处") + if err != nil { + logger.Errorf("notice sync task: failed to send notice to IOS: %v", err) + } + logger.Infof("notice sync task: notice send success") + return nil + }); !ok { + logger.Errorf("umeng async queue full, drop notice notification") } - logger.Infof("notice sync task: notice send success") - - time.Sleep(constants.UmengRateLimitDelay) } return nil } diff --git a/config/types.go b/config/types.go index 25d1e504..e01bcc15 100644 --- a/config/types.go +++ b/config/types.go @@ -136,15 +136,32 @@ type umeng struct { IOS IOSUmeng `mapstructure:"ios"` } -type vendor struct { - ExamNotifications string `mapstructure:"ExamNotifications"` - ExamResultsNotifications string `mapstructure:"ExamResultsNotifications"` - JwchNotice string `mapstructure:"JwchNotice"` +type oppo struct { + ChannelID string `mapstructure:"channel_id"` + Category string `mapstructure:"category"` + NotifyLevel string `mapstructure:"notify_level"` + PrivateMsgTemplate struct { + PrivateMsgTemplateID string `mapstructure:"private_msg_template_id"` + } `mapstructure:"private_msg_template"` +} + +type huawei struct { + ChannelImportance string `mapstructure:"channel_importance"` + ChannelCategory string `mapstructure:"channel_category"` +} + +type localProperties struct { + ChannelID string `mapstructure:"channel_id"` + ChannelName string `mapstructure:"channel_name"` } type vendors struct { - Xiaomi vendor `mapstructure:"xiaomi"` - Huawei vendor `mapstructure:"huawei"` + ChannelActivity string `mapstructure:"channel_activity"` + XiaoMiChannelID string `mapstructure:"xiaomi_channel_id"` + VivoCategory string `mapstructure:"vivo_category"` + Oppo oppo `mapstructure:"oppo"` + Huawei huawei `mapstructure:"huawei"` + LocalProperties localProperties `mapstructure:"local_properties"` } type mcp struct { diff --git a/internal/academic/service/get_scores.go b/internal/academic/service/get_scores.go index 348ab3bf..f71ee3a5 100644 --- a/internal/academic/service/get_scores.go +++ b/internal/academic/service/get_scores.go @@ -19,11 +19,9 @@ package service import ( "fmt" "strings" - "time" "github.com/bytedance/sonic" - "github.com/west2-online/fzuhelper-server/config" loginmodel "github.com/west2-online/fzuhelper-server/kitex_gen/model" "github.com/west2-online/fzuhelper-server/pkg/base" "github.com/west2-online/fzuhelper-server/pkg/base/context" @@ -179,9 +177,10 @@ func (s *AcademicService) handleScoreChange(stuID string, scores []*jwch.Mark) ( scores[i].Name, scores[i].Semester, scores[i].Teacher, scores[i].ElectiveType, }, "|")) - err = s.sendNotifications(scores[i].Name, tag) - if err != nil { - return err + if ok := umeng.EnqueueAsync(func() error { + return s.sendNotifications(scores[i].Name, tag) + }); !ok { + logger.Errorf("umeng async queue full, drop score notification, tag:%v", tag) } // 写入课程信息,代表发送过通知 _, err = s.db.Academic.CreateCourseOffering(s.ctx, &model.CourseOffering{ @@ -202,21 +201,15 @@ func (s *AcademicService) handleScoreChange(stuID string, scores []*jwch.Mark) ( } func (s *AcademicService) sendNotifications(courseName, tag string) (err error) { - err = umeng.SendAndroidGroupcastWithGoApp(config.Umeng.Android.AppKey, config.Umeng.Android.AppMasterSecret, - "", fmt.Sprintf("%v成绩更新啦", courseName), "", - tag) + err = umeng.SendAndroidGroupcastWithGoApp(fmt.Sprintf("%v成绩更新啦", courseName), "", "", tag, fmt.Sprintf("成绩更新%v", tag[:12])) if err != nil { logger.Errorf("task queue: failed to send notice to Android: %v", err) } - err = umeng.SendIOSGroupcast(config.Umeng.IOS.AppKey, config.Umeng.IOS.AppMasterSecret, - fmt.Sprintf("%v成绩更新啦", courseName), "", "", - tag) + err = umeng.SendIOSGroupcast(fmt.Sprintf("%v成绩更新啦", courseName), "", "", tag, fmt.Sprintf("成绩更新%v", tag[:12])) if err != nil { logger.Errorf("task queue: failed to send notice to IOS: %v", err) } logger.Infof("task queue: send notice to app, tag:%v", tag) - // 停止 30 秒防止 umeng 限流 - time.Sleep(constants.UmengRateLimitDelay) return nil } diff --git a/internal/academic/service/get_scores_test.go b/internal/academic/service/get_scores_test.go index cc2b2904..0103fcf5 100644 --- a/internal/academic/service/get_scores_test.go +++ b/internal/academic/service/get_scores_test.go @@ -613,7 +613,7 @@ func TestAcademicService_sendNotifications(t *testing.T) { Convey("should send notifications to both Android and iOS", func() { // Given: 准备发送推送的课程信息 courseName := "数据结构" - tag := "test_tag" + tag := "abcdefghijklmnopqrstuvwxyz123456" // Mock umeng 推送成功 umengAndroidPatch := mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(nil).Build() @@ -636,7 +636,7 @@ func TestAcademicService_sendNotifications(t *testing.T) { Convey("should handle notification errors gracefully", func() { // Given: 准备发送推送但可能出错 courseName := "数据结构" - tag := "test_tag" + tag := "abcdefghijklmnopqrstuvwxyz123456" // Mock umeng 推送失败 umengAndroidPatch := mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(fmt.Errorf("android push failed")).Build() diff --git a/internal/course/service/get_course_list.go b/internal/course/service/get_course_list.go index 9c2bb2d6..3cdedc8c 100644 --- a/internal/course/service/get_course_list.go +++ b/internal/course/service/get_course_list.go @@ -22,11 +22,9 @@ import ( "slices" "sort" "strings" - "time" "github.com/bytedance/sonic" - "github.com/west2-online/fzuhelper-server/config" "github.com/west2-online/fzuhelper-server/internal/course/pack" "github.com/west2-online/fzuhelper-server/kitex_gen/course" kitexModel "github.com/west2-online/fzuhelper-server/kitex_gen/model" @@ -36,9 +34,7 @@ import ( "github.com/west2-online/fzuhelper-server/pkg/constants" "github.com/west2-online/fzuhelper-server/pkg/db/model" "github.com/west2-online/fzuhelper-server/pkg/errno" - "github.com/west2-online/fzuhelper-server/pkg/logger" "github.com/west2-online/fzuhelper-server/pkg/taskqueue" - "github.com/west2-online/fzuhelper-server/pkg/umeng" "github.com/west2-online/fzuhelper-server/pkg/utils" "github.com/west2-online/jwch" "github.com/west2-online/yjsy" @@ -109,7 +105,6 @@ func (s *CourseService) GetCourseList(req *course.CourseListRequest, loginData * return s.removeDuplicateCourses(pack.BuildCourse(courses)), nil } -// putCourseToDatabase 将课程表存入数据库,如果与数据库数据不同,进行 umeng 推送 func (s *CourseService) putCourseToDatabase(stuId string, term string, courses []*kitexModel.Course) error { old, err := s.db.Course.GetUserTermCourseSha256ByStuIdAndTerm(s.ctx, stuId, term) if err != nil { @@ -148,70 +143,11 @@ func (s *CourseService) putCourseToDatabase(stuId string, term string, courses [ if err != nil { return err } - // 异步处理调课通知逻辑 - s.taskQueue.Add(stuId, taskqueue.QueueTask{Execute: func() error { - return s.handleCourseUpdate(term, courses, old) - }}) } return nil } -// 当发现课程有调课时,对具体的字段进行一一对比,找出调课的课程 -func (s *CourseService) handleCourseUpdate(term string, newCourses []*kitexModel.Course, oldCourses *model.UserCourse) (err error) { - // 将 old 的课程进行解析,变成同一个格式 - olds := make([]*kitexModel.Course, 0) - - if oldCourses.TermCourses != "" { - if err = sonic.Unmarshal([]byte(oldCourses.TermCourses), &olds); err != nil { - return fmt.Errorf("service.GetCourseList: Unmarshal old courses failed: %w", err) - } - } - - // 构建 hash 映射表,方便对比 - hashToAdjust := make(map[string]string) - for _, c := range olds { - hash := utils.GenerateCourseHash(c.Name, term, c.Teacher, c.ElectiveType, c.RawScheduleRules) - if c.ElectiveType != "" { - // 旧数据没有这个字段,防止错误发送通知 - hashToAdjust[hash] = c.RawAdjust - } - } - - // 对比新课程和旧课程的调课规则,有变化则发送通知 - for _, c := range newCourses { - hash := utils.GenerateCourseHash(c.Name, term, c.Teacher, c.ElectiveType, c.RawScheduleRules) - if oldAdjust, exists := hashToAdjust[hash]; exists { - if oldAdjust != c.RawAdjust { - err = s.sendNotifications(c.Name, hash) - if err != nil { - return fmt.Errorf("service.GetCourseList: Send notifications failed: %w", err) - } - } - } - } - - return nil -} - -func (s *CourseService) sendNotifications(courseName, tag string) (err error) { - err = umeng.SendAndroidGroupcastWithGoApp(config.Umeng.Android.AppKey, config.Umeng.Android.AppMasterSecret, - "", fmt.Sprintf("[调课] %v", courseName), "", tag) - if err != nil { - logger.Errorf("service.sendNotifications: Send course updated message to Android failed: %v", err) - return err - } - - err = umeng.SendIOSGroupcast(config.Umeng.Android.AppKey, config.Umeng.Android.AppMasterSecret, - "", fmt.Sprintf("[调课] %v", courseName), "", tag) - if err != nil { - logger.Errorf("service.sendNotifications: Send course updated message to IOS failed: %v", err) - return err - } - time.Sleep(constants.UmengRateLimitDelay) - return nil -} - func (s *CourseService) GetCourseListYjsy(req *course.CourseListRequest, loginData *kitexModel.LoginData) ([]*kitexModel.Course, error) { var err error diff --git a/internal/course/service/get_course_list_test.go b/internal/course/service/get_course_list_test.go index 2d60e850..60195a2c 100644 --- a/internal/course/service/get_course_list_test.go +++ b/internal/course/service/get_course_list_test.go @@ -19,12 +19,10 @@ package service import ( "context" "testing" - "time" "github.com/bytedance/mockey" "github.com/stretchr/testify/assert" - "github.com/west2-online/fzuhelper-server/config" "github.com/west2-online/fzuhelper-server/internal/course/pack" "github.com/west2-online/fzuhelper-server/kitex_gen/course" "github.com/west2-online/fzuhelper-server/kitex_gen/model" @@ -36,7 +34,6 @@ import ( dbcourse "github.com/west2-online/fzuhelper-server/pkg/db/course" dbmodel "github.com/west2-online/fzuhelper-server/pkg/db/model" "github.com/west2-online/fzuhelper-server/pkg/taskqueue" - "github.com/west2-online/fzuhelper-server/pkg/umeng" "github.com/west2-online/fzuhelper-server/pkg/utils" "github.com/west2-online/jwch" "github.com/west2-online/yjsy" @@ -619,136 +616,3 @@ func TestCourseToDatabase(t *testing.T) { }) } } - -func TestHandleCourseUpdate(t *testing.T) { - type testCase struct { - name string - newList []*model.Course - old *dbmodel.UserCourse - sendError error - expectNotifyCount int - expectError string - } - term := "202401" - // 旧数据:RawAdjust = "" - oldList := []*model.Course{{ - Name: "C", - Teacher: "T", - ElectiveType: "elective", - RawScheduleRules: "[]", - RawAdjust: "", - }} - oldJSON, _ := utils.JSONEncode(oldList) - old := &dbmodel.UserCourse{TermCourses: oldJSON} - - // 新数据:RawAdjust 改变 - newList := []*model.Course{{ - Name: "C", - Teacher: "T", - ElectiveType: "elective", - RawScheduleRules: "[]", - RawAdjust: "1", - }} - - testCases := []testCase{ - { - name: "adjust changed -> notify once", - newList: newList, - old: old, - expectNotifyCount: 1, - }, - { - name: "sendNotifications error", - newList: newList, - old: old, - sendError: assert.AnError, - expectError: "Send notifications failed", - }, - { - name: "no change -> no notify", - newList: oldList, - old: old, - expectNotifyCount: 0, - }, - { - name: "old json invalid -> error", - newList: oldList, - old: &dbmodel.UserCourse{TermCourses: "{"}, - expectError: "Unmarshal old courses failed", - }, - } - - defer mockey.UnPatchAll() - for _, tc := range testCases { - mockey.PatchConvey(tc.name, t, func() { - mockClientSet := &base.ClientSet{ - SFClient: new(utils.Snowflake), - DBClient: new(db.Database), - CacheClient: new(cache.Cache), - } - - mockey.Mock((*CourseService).sendNotifications).Return(tc.sendError).Build() - - courseService := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)) - err := courseService.handleCourseUpdate(term, tc.newList, tc.old) - if tc.expectError != "" { - assert.ErrorContains(t, err, tc.expectError) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestSendNotifications(t *testing.T) { - type testCase struct { - name string - androidError error - iosError error - expectError bool - } - - courseName := "CourseX" - tag := "tag-1" - - testCases := []testCase{ - { - name: "both platform success", - }, - { - name: "android fail", - androidError: assert.AnError, - expectError: true, - }, - { - name: "ios fail after android ok", - iosError: assert.AnError, - expectError: true, - }, - } - - defer mockey.UnPatchAll() - for _, tc := range testCases { - mockey.PatchConvey(tc.name, t, func() { - mockClientSet := &base.ClientSet{ - SFClient: new(utils.Snowflake), - DBClient: new(db.Database), - CacheClient: new(cache.Cache), - } - - _ = config.InitForTest("course") - - mockey.Mock(time.Sleep).To(func(d time.Duration) {}).Build() - mockey.Mock(umeng.SendAndroidGroupcastWithGoApp).Return(tc.androidError).Build() - mockey.Mock(umeng.SendIOSGroupcast).Return(tc.iosError).Build() - - courseService := NewCourseService(context.Background(), mockClientSet, new(taskqueue.BaseTaskQueue)) - err := courseService.sendNotifications(courseName, tag) - if tc.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/pkg/constants/umeng.go b/pkg/constants/umeng.go index 12ac2dce..0f6c8128 100644 --- a/pkg/constants/umeng.go +++ b/pkg/constants/umeng.go @@ -21,7 +21,9 @@ import "time" const ( UmengURL = "https://msgapi.umeng.com/api/send" // 推送 API UmengMessageExpireTime = 3 * ONE_DAY // 推送消息过期时间 - UmengRateLimitDelay = 30 * time.Second // 用于在发送通知循环中等待,防止被友盟限流 + UmengRateLimitDelay = 1 * time.Minute // 用于在发送通知中等待,防止被友盟限流 + UmengAsyncQueueSize = 500 // 异步发送通知的队列大小 + UmengDailyLimit = 500 // 每日最大请求数 ) // Tag diff --git a/pkg/umeng/dispatcher.go b/pkg/umeng/dispatcher.go new file mode 100644 index 00000000..70fd8e7f --- /dev/null +++ b/pkg/umeng/dispatcher.go @@ -0,0 +1,159 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package umeng + +import ( + "sync" + "time" + + "github.com/west2-online/fzuhelper-server/pkg/constants" + "github.com/west2-online/fzuhelper-server/pkg/logger" +) + +// asyncDispatcher 负责异步消费 Umeng 发送任务并执行限流。 +// 设计目标: +// 1) 异步:发送端仅入队,不阻塞业务线程或主任务队列。 +// 2) 限流:支持最小间隔控制 + 每日配额控制。 +// 3) 单例:进程内仅启动一个 dispatcher,确保全局限流一致性。 +type asyncDispatcher struct { + // ch 为任务队列通道,元素是“发送函数”。 + // 发送函数返回 error,便于统一记录失败日志。 + ch chan func() error + // interval 为相邻两次发送的最小间隔。 + interval time.Duration + // dailyLimit 为每日最大允许发送次数。 + dailyLimit int + // dailyCount 为当天已发送次数。 + dailyCount int + // lastResetDate 记录上次重置日期,用于跨日清零计数。 + lastResetDate time.Time + // lastRequestTime 记录上一次实际发送的时间,用于间隔限流。 + lastRequestTime time.Time +} + +var ( + // dispatcherOnce 确保 dispatcher 只被初始化一次。 + dispatcherOnce sync.Once + // dispatcher 为全局单例实例。 + dispatcher *asyncDispatcher +) + +// getDispatcher 获取全局 dispatcher 单例。 +// 首次调用会完成初始化并启动后台消费协程。 +// 该方法为内部使用,外部通过 EnqueueAsync 入队即可。 +func getDispatcher() *asyncDispatcher { + dispatcherOnce.Do(func() { + dispatcher = newAsyncDispatcher() + // 后台消费协程:串行处理任务,确保限流语义正确。 + go dispatcher.run() + }) + return dispatcher +} + +// newAsyncDispatcher 创建一个新的 dispatcher 实例。 +// 参数: +// - queueSize:队列缓冲长度。 +// - interval:发送最小间隔。 +// - dailyLimit:每日最大发送次数。 +// 返回值仅在 getDispatcher 中使用,避免重复创建。 +func newAsyncDispatcher() *asyncDispatcher { + queueSize := constants.UmengAsyncQueueSize + interval := constants.UmengRateLimitDelay + dailyLimit := constants.UmengDailyLimit + return &asyncDispatcher{ + ch: make(chan func() error, queueSize), + interval: interval, + dailyLimit: dailyLimit, + dailyCount: 0, + lastResetDate: time.Now(), + lastRequestTime: time.Now().Add(-interval), + } +} + +// EnqueueAsync 将 Umeng 发送任务放入异步队列。 +// 特性: +// - 非阻塞:队列满时立即返回 false,不阻塞业务线程。 +// - 安全:task 为空时直接返回 false。 +// - 单例:内部确保 dispatcher 只初始化一次。 +// 用法:将实际发送逻辑包装成闭包传入,例如: +// +// umeng.EnqueueAsync(func() error { return umeng.SendAndroidGroupcastWithGoApp(...) }) +func EnqueueAsync(task func() error) bool { + if task == nil { + return false + } + d := getDispatcher() + select { + case d.ch <- task: + return true + default: + return false + } +} + +// run 后台消费循环。 +// 该循环串行读取队列并执行任务,先限流再发送,保证顺序与配额一致。 +// 注意:此方法应仅在后台协程中运行。 +func (d *asyncDispatcher) run() { + for task := range d.ch { + d.wait() + if err := task(); err != nil { + logger.Errorf("umeng async task failed: %v", err) + } + } +} + +// wait 执行限流等待。 +// 逻辑顺序: +// 1) 跨日判断:新的一天重置 dailyCount。 +// 2) 每日配额:超过 dailyLimit 则等待到次日零点并重置。 +// 3) 间隔限制:确保相邻发送间隔不小于 interval。 +// 该方法在后台消费协程中调用,因此可以阻塞而不影响业务线程。 +func (d *asyncDispatcher) wait() { + now := time.Now() + if !sameDay(now, d.lastResetDate) { + d.dailyCount = 0 + d.lastResetDate = now + } + + if d.dailyCount >= d.dailyLimit { + nextDay := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) + time.Sleep(time.Until(nextDay)) + d.dailyCount = 0 + d.lastResetDate = time.Now() + // 当前 wait() 调用已在此手动完成跨日重置,后续直接进入间隔检查即可,无需再次执行 sameDay 判断。 + d.lastRequestTime = time.Now().Add(-d.interval) + } + + now = time.Now() + elapsed := now.Sub(d.lastRequestTime) + if elapsed < d.interval { + time.Sleep(d.interval - elapsed) + now = time.Now() + } + + d.lastRequestTime = now + d.dailyCount++ +} + +// sameDay 判断两个时间是否在同一天(按本地时区)。 +// 用于每日配额的跨日判断。 +func sameDay(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} diff --git a/pkg/umeng/dispatcher_test.go b/pkg/umeng/dispatcher_test.go new file mode 100644 index 00000000..c0b57886 --- /dev/null +++ b/pkg/umeng/dispatcher_test.go @@ -0,0 +1,298 @@ +/* +Copyright 2024 The west2-online Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package umeng + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + + "github.com/west2-online/fzuhelper-server/pkg/constants" +) + +func setMockDispatcherForTest(d *asyncDispatcher) { + dispatcher = d + dispatcherOnce = sync.Once{} + dispatcherOnce.Do(func() {}) +} + +func resetDispatcherForTest() { + dispatcher = nil + dispatcherOnce = sync.Once{} +} + +func TestNewAsyncDispatcher(t *testing.T) { + type testCase struct { + name string + expectQueueSize int + expectInterval time.Duration + expectDaily int + } + + testCases := []testCase{ + { + name: "UseConstants", + expectQueueSize: constants.UmengAsyncQueueSize, + expectInterval: constants.UmengRateLimitDelay, + expectDaily: constants.UmengDailyLimit, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + d := newAsyncDispatcher() + + assert.NotNil(t, d) + assert.Equal(t, tc.expectQueueSize, cap(d.ch)) + assert.Equal(t, tc.expectInterval, d.interval) + assert.Equal(t, tc.expectDaily, d.dailyLimit) + assert.Equal(t, 0, d.dailyCount) + }) + } +} + +func TestGetDispatcher(t *testing.T) { + resetDispatcherForTest() + t.Cleanup(resetDispatcherForTest) + + d1 := getDispatcher() + d2 := getDispatcher() + + assert.NotNil(t, d1) + assert.Same(t, d1, d2) +} + +func TestEnqueueAsync(t *testing.T) { + type testCase struct { + name string + task func() error + prepare func() + expectEnq bool + expectRead bool + } + + testCases := []testCase{ + { + name: "NilTask", + task: nil, + prepare: resetDispatcherForTest, + expectEnq: false, + }, + { + name: "EnqueueSuccess", + task: func() error { return nil }, + prepare: func() { + setMockDispatcherForTest(&asyncDispatcher{ch: make(chan func() error, 1)}) + }, + expectEnq: true, + expectRead: true, + }, + { + name: "QueueFull", + task: func() error { return nil }, + prepare: func() { + d := &asyncDispatcher{ch: make(chan func() error, 1)} + d.ch <- func() error { return nil } + setMockDispatcherForTest(d) + }, + expectEnq: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resetDispatcherForTest() + t.Cleanup(resetDispatcherForTest) + if tc.prepare != nil { + tc.prepare() + } + + ok := EnqueueAsync(tc.task) + assert.Equal(t, tc.expectEnq, ok) + + if tc.expectRead { + select { + case <-dispatcher.ch: + default: + t.Fatalf("expected task enqueued but channel is empty") + } + } + }) + } +} + +func TestSameDay(t *testing.T) { + type testCase struct { + name string + a time.Time + b time.Time + expect bool + } + + now := time.Now() + testCases := []testCase{ + { + name: "SameDay", + a: now, + b: now.Add(2 * time.Hour), + expect: true, + }, + { + name: "DifferentDay", + a: now, + b: now.Add(24 * time.Hour), + expect: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, sameDay(tc.a, tc.b)) + }) + } +} + +func TestAsyncDispatcherWait(t *testing.T) { + type testCase struct { + name string + dispatcher *asyncDispatcher + beforeWait func(t *testing.T) + afterAssert func(t *testing.T, d *asyncDispatcher, start time.Time) + } + + testCases := []testCase{ + { + name: "ResetOnNextDay", + dispatcher: &asyncDispatcher{ + ch: make(chan func() error, 1), + interval: 0, + dailyLimit: 10, + dailyCount: 5, + lastResetDate: time.Now().AddDate(0, 0, -1), + lastRequestTime: time.Now(), + }, + afterAssert: func(t *testing.T, d *asyncDispatcher, _ time.Time) { + assert.Equal(t, 1, d.dailyCount) + assert.True(t, sameDay(time.Now(), d.lastResetDate)) + assert.False(t, d.lastRequestTime.IsZero()) + }, + }, + { + name: "RespectInterval", + dispatcher: &asyncDispatcher{ + ch: make(chan func() error, 1), + interval: 20 * time.Millisecond, + dailyLimit: 10, + dailyCount: 0, + lastResetDate: time.Now(), + lastRequestTime: time.Now(), + }, + afterAssert: func(t *testing.T, d *asyncDispatcher, start time.Time) { + elapsed := time.Since(start) + assert.True(t, elapsed >= 15*time.Millisecond) + assert.Equal(t, 1, d.dailyCount) + }, + }, + { + name: "DailyLimitReached", + dispatcher: &asyncDispatcher{ + ch: make(chan func() error, 1), + interval: 0, + dailyLimit: 1, + dailyCount: 1, + lastResetDate: time.Now(), + lastRequestTime: time.Now(), + }, + beforeWait: func(t *testing.T) { + mockey.Mock(time.Sleep).To(func(d time.Duration) { + assert.GreaterOrEqual(t, d, time.Millisecond) + }).Build() + }, + afterAssert: func(t *testing.T, d *asyncDispatcher, _ time.Time) { + assert.Equal(t, 1, d.dailyCount) + assert.True(t, sameDay(time.Now(), d.lastResetDate)) + assert.False(t, d.lastRequestTime.IsZero()) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer mockey.UnPatchAll() + if tc.beforeWait != nil { + tc.beforeWait(t) + } + start := time.Now() + tc.dispatcher.wait() + tc.afterAssert(t, tc.dispatcher, start) + }) + } +} + +func TestAsyncDispatcherRun(t *testing.T) { + d := &asyncDispatcher{ + ch: make(chan func() error, 2), + interval: 0, + dailyLimit: 10, + dailyCount: 0, + lastResetDate: time.Now(), + lastRequestTime: time.Now(), + } + + var mu sync.Mutex + called := make([]int, 0, 2) + var wg sync.WaitGroup + wg.Add(2) + + go d.run() + + d.ch <- func() error { + mu.Lock() + called = append(called, 1) + mu.Unlock() + wg.Done() + return nil + } + d.ch <- func() error { + mu.Lock() + called = append(called, 2) + mu.Unlock() + wg.Done() + return errors.New("mock error") + } + close(d.ch) + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + mu.Lock() + defer mu.Unlock() + assert.Equal(t, []int{1, 2}, called) + assert.Equal(t, 2, d.dailyCount) + case <-time.After(time.Second): + t.Fatal("dispatcher run did not finish in time") + } +} diff --git a/pkg/umeng/groupcast.go b/pkg/umeng/groupcast.go index 23d0c67a..47a99b3e 100644 --- a/pkg/umeng/groupcast.go +++ b/pkg/umeng/groupcast.go @@ -26,14 +26,41 @@ import ( "net/http" "time" + "github.com/west2-online/fzuhelper-server/config" "github.com/west2-online/fzuhelper-server/pkg/constants" "github.com/west2-online/fzuhelper-server/pkg/errno" "github.com/west2-online/fzuhelper-server/pkg/logger" ) -func SendAndroidGroupcastWithGoApp(appKey, appMasterSecret, ticker, title, text, tag string) error { +func getChannelProperties(title, content string) AndroidChannelProperties { + return AndroidChannelProperties{ + ChannelActivity: config.Vendors.ChannelActivity, + XiaoMiChannelID: config.Vendors.XiaoMiChannelID, + VivoCategory: config.Vendors.VivoCategory, + OppoChannelID: config.Vendors.Oppo.ChannelID, + OppoCategory: config.Vendors.Oppo.Category, + OppoNotifyLevel: config.Vendors.Oppo.NotifyLevel, + HuaweiChannelImportance: config.Vendors.Huawei.ChannelImportance, + HuaweiChannelCategory: config.Vendors.Huawei.ChannelCategory, + OppoPrivateMsgTemplate: OppoPrivateMsgTemplate{ + PrivateMsgTemplateID: config.Vendors.Oppo.PrivateMsgTemplate.PrivateMsgTemplateID, + PrivateTitleParameters: OppoPrivateTitleParameters{ + Title: title, + }, + PrivateContentParameters: OppoPrivateContentParameters{ + Content: content, + }, + }, + LocalProperties: LocalProperties{ + ChannelID: config.Vendors.LocalProperties.ChannelID, + ChannelName: config.Vendors.LocalProperties.ChannelName, + }, + } +} + +func SendAndroidGroupcastWithGoApp(title, text, ticker, tag, description string) error { message := AndroidGroupcastMessage{ - AppKey: appKey, + AppKey: config.Umeng.Android.AppKey, Timestamp: fmt.Sprintf("%d", time.Now().Unix()), Type: "groupcast", Filter: Filter{ @@ -46,25 +73,31 @@ func SendAndroidGroupcastWithGoApp(appKey, appMasterSecret, ticker, title, text, Payload: AndroidPayload{ DisplayType: "notification", Body: AndroidBody{ - Ticker: ticker, - Title: title, - Text: text, - AfterOpen: "go_app", + Title: title, + Text: text, + Ticker: ticker, + PlaySound: "true", + PlayVibrate: "true", + PlayLights: "true", + AfterOpen: "go_app", }, }, - Policy: Policy{ - ExpireTime: time.Now().Add(constants.UmengMessageExpireTime).Format("2006-01-02 15:04:05"), + Policy: AndroidPolicy{ + ExpireTime: time.Now().Add(constants.UmengMessageExpireTime).Format("2006-01-02 15:04:05"), + NotificationClosedFilter: true, }, - Description: "Android-广播通知", + Description: description, + Category: 1, // 系统消息 + ChannelProperties: getChannelProperties(title, text), } - return sendGroupcast(appMasterSecret, message) + return sendGroupcast(config.Umeng.Android.AppMasterSecret, message) } // Android广播函数 -func SendAndroidGroupcastWithUrl(appKey, appMasterSecret, ticker, title, text, tag, url string, channelProperties map[string]string) error { +func SendAndroidGroupcastWithUrl(title, text, ticker, url, tag, description string) error { message := AndroidGroupcastMessage{ - AppKey: appKey, + AppKey: config.Umeng.Android.AppKey, Timestamp: fmt.Sprintf("%d", time.Now().Unix()), Type: "groupcast", Filter: Filter{ @@ -77,27 +110,32 @@ func SendAndroidGroupcastWithUrl(appKey, appMasterSecret, ticker, title, text, t Payload: AndroidPayload{ DisplayType: "notification", Body: AndroidBody{ - Ticker: ticker, - Title: title, - Text: text, - AfterOpen: "go_url", - URL: url, + Title: title, + Text: text, + Ticker: ticker, + PlaySound: "true", + PlayVibrate: "true", + PlayLights: "true", + AfterOpen: "go_url", + URL: url, }, }, - Policy: Policy{ - ExpireTime: time.Now().Add(constants.UmengMessageExpireTime).Format("2006-01-02 15:04:05"), + Policy: AndroidPolicy{ + ExpireTime: time.Now().Add(constants.UmengMessageExpireTime).Format("2006-01-02 15:04:05"), + NotificationClosedFilter: true, }, - ChannelProperties: channelProperties, - Description: "Android-广播通知", + Description: description, + Category: 1, // 系统消息 + ChannelProperties: getChannelProperties(title, text), } - return sendGroupcast(appMasterSecret, message) + return sendGroupcast(config.Umeng.Android.AppMasterSecret, message) } // iOS广播函数 -func SendIOSGroupcast(appKey, appMasterSecret, title, subtitle, body, tag string) error { +func SendIOSGroupcast(title, subtitle, body, tag, description string) error { message := IOSGroupcastMessage{ - AppKey: appKey, + AppKey: config.Umeng.IOS.AppKey, Timestamp: fmt.Sprintf("%d", time.Now().Unix()), Type: "groupcast", Filter: Filter{ @@ -114,15 +152,17 @@ func SendIOSGroupcast(appKey, appMasterSecret, title, subtitle, body, tag string Subtitle: subtitle, Body: body, }, + Sound: "default", + InterruptionLevel: "active", }, }, - Policy: Policy{ + Policy: IOSPolicy{ ExpireTime: time.Now().Add(constants.UmengMessageExpireTime).Format("2006-01-02 15:04:05"), }, - Description: "iOS-广播通知", + Description: description, } - return sendGroupcast(appMasterSecret, message) + return sendGroupcast(config.Umeng.IOS.AppMasterSecret, message) } // 通用广播发送逻辑 @@ -162,7 +202,6 @@ func sendGroupcast(appMasterSecret string, message interface{}) error { return errno.Errorf(errno.InternalServiceErrorCode, "umeng.sendGroupcast : Groupcast failed: %s (%s)", response.Data.ErrorMsg, response.Data.ErrorCode) } - logger.Infof("Groupcast sent successfully! MsgID: %s\n", response.Data.MsgID) return nil } diff --git a/pkg/umeng/model.go b/pkg/umeng/model.go index 170aab3f..94ad97e2 100644 --- a/pkg/umeng/model.go +++ b/pkg/umeng/model.go @@ -16,6 +16,7 @@ limitations under the License. package umeng +// 具体定义查看友盟官方文档:https://developer.umeng.com/docs/67966/detail/68343 // UmengResponse 公共返回结构 type UmengResponse struct { Ret string `json:"ret"` @@ -29,14 +30,15 @@ type UmengResponse struct { // AndroidGroupcastMessage Android广播消息结构 type AndroidGroupcastMessage struct { - AppKey string `json:"appkey"` - Timestamp string `json:"timestamp"` - Type string `json:"type"` - Filter Filter `json:"filter"` - Payload AndroidPayload `json:"payload"` - Policy Policy `json:"policy"` - ChannelProperties map[string]string `json:"channel_properties"` - Description string `json:"description"` + AppKey string `json:"appkey"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Filter Filter `json:"filter"` + Payload AndroidPayload `json:"payload"` + Policy AndroidPolicy `json:"policy"` + Description string `json:"description"` + Category int `json:"category"` + ChannelProperties AndroidChannelProperties `json:"channel_properties"` } type AndroidPayload struct { @@ -45,11 +47,46 @@ type AndroidPayload struct { } type AndroidBody struct { - Ticker string `json:"ticker"` - Title string `json:"title"` - Text string `json:"text"` - AfterOpen string `json:"after_open"` - URL string `json:"url"` + Title string `json:"title"` + Text string `json:"text"` + Ticker string `json:"ticker"` + PlaySound string `json:"play_sound"` + PlayVibrate string `json:"play_vibrate"` + PlayLights string `json:"play_lights"` + AfterOpen string `json:"after_open"` + URL string `json:"url"` +} + +type AndroidChannelProperties struct { + ChannelActivity string `json:"channel_activity"` + XiaoMiChannelID string `json:"xiaomi_channel_id"` + VivoCategory string `json:"vivo_category"` + OppoChannelID string `json:"oppo_channel_id"` + OppoCategory string `json:"oppo_category"` + OppoNotifyLevel string `json:"oppo_notify_level"` + HuaweiChannelImportance string `json:"huawei_channel_importance"` + HuaweiChannelCategory string `json:"huawei_channel_category"` + OppoPrivateMsgTemplate OppoPrivateMsgTemplate `json:"oppo_private_msg_template"` + LocalProperties LocalProperties `json:"local_properties"` +} + +type OppoPrivateMsgTemplate struct { + PrivateMsgTemplateID string `json:"private_msg_template_id"` + PrivateTitleParameters OppoPrivateTitleParameters `json:"private_title_parameters"` + PrivateContentParameters OppoPrivateContentParameters `json:"private_content_parameters"` +} + +type OppoPrivateTitleParameters struct { + Title string `json:"title"` +} + +type OppoPrivateContentParameters struct { + Content string `json:"content"` +} + +type LocalProperties struct { + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` } // IOSGroupcastMessage iOS广播消息结构 @@ -59,7 +96,7 @@ type IOSGroupcastMessage struct { Type string `json:"type"` Filter Filter `json:"filter"` Payload IOSPayload `json:"payload"` - Policy Policy `json:"policy"` + Policy IOSPolicy `json:"policy"` Description string `json:"description"` } @@ -68,7 +105,9 @@ type IOSPayload struct { } type IOSAps struct { - Alert IOSAlert `json:"alert"` + Alert IOSAlert `json:"alert"` + Sound string `json:"sound"` + InterruptionLevel string `json:"interruption-level"` } type IOSAlert struct { @@ -78,7 +117,12 @@ type IOSAlert struct { } // Policy 公共策略结构 -type Policy struct { +type AndroidPolicy struct { + ExpireTime string `json:"expire_time"` + NotificationClosedFilter bool `json:"notification_closed_filter"` +} + +type IOSPolicy struct { ExpireTime string `json:"expire_time"` }