feat: add week star reward retry worker
This commit is contained in:
parent
a311310180
commit
2fb3a1e5f5
3
.gitignore
vendored
3
.gitignore
vendored
@ -1 +1,4 @@
|
||||
target/
|
||||
.DS_Store
|
||||
/week-star-job
|
||||
/week-star-settle-once
|
||||
|
||||
27
README.md
27
README.md
@ -3,7 +3,7 @@
|
||||
Standalone cron workers for chatapp activities.
|
||||
|
||||
Current worker:
|
||||
- `week-star-job`: specified-gift weekly rank settlement and reward dispatch
|
||||
- `week-star-job`: specified-gift weekly rank settlement, reward dispatch, and failed reward retry
|
||||
|
||||
Local run:
|
||||
|
||||
@ -22,14 +22,23 @@ cd /Users/hy/Documents/hy/chatapp3/chatapp3-deploy
|
||||
|
||||
Required envs:
|
||||
|
||||
- `CRON_MYSQL_DSN`
|
||||
- `CRON_REDIS_ADDR`
|
||||
- `CRON_REDIS_PASSWORD`
|
||||
- `CRON_REDIS_DB`
|
||||
- `CRON_JAVA_OTHER_BASE_URL`
|
||||
- `WEEK_STAR_DEFAULT_SYS_ORIGIN`
|
||||
- `WEEK_STAR_TIMEZONE`
|
||||
- `WEEK_STAR_SETTLE_INTERVAL_SECONDS`
|
||||
- `CHATAPP_STORE_MYSQL_DSN`
|
||||
- `CHATAPP_STORE_REDIS_ADDR`
|
||||
- `CHATAPP_STORE_REDIS_PASSWORD`
|
||||
- `CHATAPP_STORE_REDIS_DB`
|
||||
- `CHATAPP_JAVA_OTHER_BASE_URL`
|
||||
- `CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN`
|
||||
- `CHATAPP_WEEK_STAR_TIMEZONE`
|
||||
- `CHATAPP_WEEK_STAR_SETTLE_INTERVAL_SECONDS`
|
||||
- `CHATAPP_WEEK_STAR_RETRY_STREAM_KEY`
|
||||
- `CHATAPP_WEEK_STAR_RETRY_CONSUMER_GROUP`
|
||||
- `CHATAPP_WEEK_STAR_RETRY_CONSUMER_NAME`
|
||||
- `CHATAPP_WEEK_STAR_RETRY_BATCH_SIZE`
|
||||
- `CHATAPP_WEEK_STAR_RETRY_BLOCK_MILLIS`
|
||||
- `CHATAPP_WEEK_STAR_RETRY_MIN_IDLE_MILLIS`
|
||||
- `CHATAPP_HTTP_TIMEOUT_SECONDS`
|
||||
|
||||
Legacy `CRON_*` / `WEEK_STAR_*` keys are still supported as fallback, but deployment should use the `CHATAPP_*` namespace as the canonical source.
|
||||
|
||||
Production topology:
|
||||
|
||||
|
||||
34
cmd/week-star-settle-once/main.go
Normal file
34
cmd/week-star-settle-once/main.go
Normal file
@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"chatapp-cron/internal/config"
|
||||
"chatapp-cron/internal/integration"
|
||||
"chatapp-cron/internal/repo"
|
||||
"chatapp-cron/internal/service"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
repository, err := repo.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("init repository failed: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := repository.Ping(ctx); err != nil {
|
||||
log.Fatalf("ping dependencies failed: %v", err)
|
||||
}
|
||||
|
||||
javaClient := integration.New(cfg)
|
||||
settlementService := service.NewWeekStarSettlementService(cfg, repository, javaClient)
|
||||
if err := settlementService.SettleDueCycles(ctx); err != nil {
|
||||
log.Fatalf("settle due cycles failed: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("week-star settle once finished")
|
||||
}
|
||||
@ -8,52 +8,79 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MySQLDSN string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
JavaOtherBaseURL string
|
||||
WeekStarDefaultSysOrigin string
|
||||
WeekStarTimezone string
|
||||
WeekStarSettleInterval time.Duration
|
||||
Timeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
AutoMigrate bool
|
||||
MySQLDSN string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
JavaOtherBaseURL string
|
||||
WeekStarDefaultSysOrigin string
|
||||
WeekStarTimezone string
|
||||
WeekStarSettleInterval time.Duration
|
||||
WeekStarRetryStreamKey string
|
||||
WeekStarRetryConsumerGroup string
|
||||
WeekStarRetryConsumerName string
|
||||
WeekStarRetryBatchSize int64
|
||||
WeekStarRetryBlock time.Duration
|
||||
WeekStarRetryMinIdle time.Duration
|
||||
Timeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
AutoMigrate bool
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
MySQLDSN: firstEnv("CRON_MYSQL_DSN", "INVITE_MYSQL_DSN", "root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local"),
|
||||
RedisAddr: firstEnv("CRON_REDIS_ADDR", "INVITE_REDIS_ADDR", "127.0.0.1:6379"),
|
||||
RedisPassword: firstEnv("CRON_REDIS_PASSWORD", "INVITE_REDIS_PASSWORD", ""),
|
||||
RedisDB: getEnvInt("CRON_REDIS_DB", getEnvInt("INVITE_REDIS_DB", 0)),
|
||||
JavaOtherBaseURL: firstEnv("CRON_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL", "http://127.0.0.1:2400"),
|
||||
WeekStarDefaultSysOrigin: strings.ToUpper(firstEnv("WEEK_STAR_DEFAULT_SYS_ORIGIN", "", "LIKEI")),
|
||||
WeekStarTimezone: firstEnv("WEEK_STAR_TIMEZONE", "", "Asia/Riyadh"),
|
||||
WeekStarSettleInterval: time.Duration(getEnvInt("WEEK_STAR_SETTLE_INTERVAL_SECONDS", 60)) * time.Second,
|
||||
Timeout: time.Duration(getEnvInt("CRON_HTTP_TIMEOUT_SECONDS", getEnvInt("INVITE_HTTP_TIMEOUT_SECONDS", 8))) * time.Second,
|
||||
ShutdownTimeout: time.Duration(getEnvInt("CRON_SHUTDOWN_TIMEOUT_SECONDS", getEnvInt("INVITE_HTTP_SHUTDOWN_TIMEOUT_SECONDS", 30))) * time.Second,
|
||||
AutoMigrate: getEnvBool("CRON_AUTO_MIGRATE", getEnvBool("INVITE_AUTO_MIGRATE", false)),
|
||||
MySQLDSN: getEnvAny(
|
||||
[]string{"CHATAPP_STORE_MYSQL_DSN", "CRON_MYSQL_DSN", "INVITE_MYSQL_DSN"},
|
||||
"root:root@tcp(127.0.0.1:3306)/chatapp3?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
),
|
||||
RedisAddr: getEnvAny(
|
||||
[]string{"CHATAPP_STORE_REDIS_ADDR", "CRON_REDIS_ADDR", "INVITE_REDIS_ADDR"},
|
||||
"127.0.0.1:6379",
|
||||
),
|
||||
RedisPassword: getEnvAny(
|
||||
[]string{"CHATAPP_STORE_REDIS_PASSWORD", "CRON_REDIS_PASSWORD", "INVITE_REDIS_PASSWORD"},
|
||||
"",
|
||||
),
|
||||
RedisDB: getEnvIntAny(
|
||||
[]string{"CHATAPP_STORE_REDIS_DB", "CRON_REDIS_DB", "INVITE_REDIS_DB"},
|
||||
0,
|
||||
),
|
||||
JavaOtherBaseURL: getEnvAny(
|
||||
[]string{"CHATAPP_JAVA_OTHER_BASE_URL", "CRON_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"},
|
||||
"http://127.0.0.1:2400",
|
||||
),
|
||||
WeekStarDefaultSysOrigin: strings.ToUpper(
|
||||
getEnvAny([]string{"CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"}, "LIKEI"),
|
||||
),
|
||||
WeekStarTimezone: getEnvAny([]string{"CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh"),
|
||||
WeekStarSettleInterval: time.Duration(getEnvIntAny([]string{"CHATAPP_WEEK_STAR_SETTLE_INTERVAL_SECONDS", "WEEK_STAR_SETTLE_INTERVAL_SECONDS"}, 60)) * time.Second,
|
||||
WeekStarRetryStreamKey: getEnvAny([]string{"CHATAPP_WEEK_STAR_RETRY_STREAM_KEY", "WEEK_STAR_RETRY_STREAM_KEY"}, "week-star:reward-retry"),
|
||||
WeekStarRetryConsumerGroup: getEnvAny([]string{"CHATAPP_WEEK_STAR_RETRY_CONSUMER_GROUP", "WEEK_STAR_RETRY_CONSUMER_GROUP"}, "week-star-retry"),
|
||||
WeekStarRetryConsumerName: getEnvAny([]string{"CHATAPP_WEEK_STAR_RETRY_CONSUMER_NAME", "WEEK_STAR_RETRY_CONSUMER_NAME"}, defaultConsumerName("week-star-retry")),
|
||||
WeekStarRetryBatchSize: int64(getEnvIntAny([]string{"CHATAPP_WEEK_STAR_RETRY_BATCH_SIZE", "WEEK_STAR_RETRY_BATCH_SIZE"}, 10)),
|
||||
WeekStarRetryBlock: time.Duration(getEnvIntAny([]string{"CHATAPP_WEEK_STAR_RETRY_BLOCK_MILLIS", "WEEK_STAR_RETRY_BLOCK_MILLIS"}, 5000)) * time.Millisecond,
|
||||
WeekStarRetryMinIdle: time.Duration(getEnvIntAny([]string{"CHATAPP_WEEK_STAR_RETRY_MIN_IDLE_MILLIS", "WEEK_STAR_RETRY_MIN_IDLE_MILLIS"}, 60000)) * time.Millisecond,
|
||||
Timeout: time.Duration(getEnvIntAny([]string{"CHATAPP_HTTP_TIMEOUT_SECONDS", "CRON_HTTP_TIMEOUT_SECONDS", "INVITE_HTTP_TIMEOUT_SECONDS"}, 8)) * time.Second,
|
||||
ShutdownTimeout: time.Duration(getEnvIntAny([]string{"CHATAPP_HTTP_SHUTDOWN_TIMEOUT_SECONDS", "CRON_SHUTDOWN_TIMEOUT_SECONDS", "INVITE_HTTP_SHUTDOWN_TIMEOUT_SECONDS"}, 30)) * time.Second,
|
||||
AutoMigrate: getEnvBoolAny([]string{"CHATAPP_AUTO_MIGRATE", "CRON_AUTO_MIGRATE", "INVITE_AUTO_MIGRATE"}, false),
|
||||
}
|
||||
}
|
||||
|
||||
func firstEnv(keys ...string) string {
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys[:len(keys)-1] {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
func getEnvAny(keys []string, fallback string) string {
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return keys[len(keys)-1]
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
func getEnvIntAny(keys []string, fallback int) int {
|
||||
value := getEnvAny(keys, "")
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@ -64,8 +91,8 @@ func getEnvInt(key string, fallback int) int {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
func getEnvBoolAny(keys []string, fallback bool) bool {
|
||||
value := getEnvAny(keys, "")
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
@ -75,3 +102,15 @@ func getEnvBool(key string, fallback bool) bool {
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func defaultConsumerName(prefix string) string {
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
return prefix
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return prefix
|
||||
}
|
||||
return prefix + "-" + host
|
||||
}
|
||||
|
||||
110
internal/config/config_test.go
Normal file
110
internal/config/config_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadPrefersChatappKeys(t *testing.T) {
|
||||
t.Setenv("CHATAPP_STORE_MYSQL_DSN", "chatapp-mysql")
|
||||
t.Setenv("CRON_MYSQL_DSN", "legacy-mysql")
|
||||
t.Setenv("CHATAPP_STORE_REDIS_ADDR", "chatapp-redis")
|
||||
t.Setenv("CHATAPP_STORE_REDIS_PASSWORD", "chatapp-password")
|
||||
t.Setenv("CHATAPP_STORE_REDIS_DB", "5")
|
||||
t.Setenv("CHATAPP_JAVA_OTHER_BASE_URL", "http://chatapp-java")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "likei")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_TIMEZONE", "Asia/Shanghai")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_SETTLE_INTERVAL_SECONDS", "120")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_RETRY_STREAM_KEY", "chatapp-stream")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_RETRY_CONSUMER_GROUP", "chatapp-group")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_RETRY_CONSUMER_NAME", "chatapp-consumer")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_RETRY_BATCH_SIZE", "11")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_RETRY_BLOCK_MILLIS", "2500")
|
||||
t.Setenv("CHATAPP_WEEK_STAR_RETRY_MIN_IDLE_MILLIS", "4500")
|
||||
t.Setenv("CHATAPP_HTTP_TIMEOUT_SECONDS", "9")
|
||||
t.Setenv("CHATAPP_HTTP_SHUTDOWN_TIMEOUT_SECONDS", "19")
|
||||
t.Setenv("CHATAPP_AUTO_MIGRATE", "true")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.MySQLDSN != "chatapp-mysql" {
|
||||
t.Fatalf("expected chatapp mysql dsn, got %q", cfg.MySQLDSN)
|
||||
}
|
||||
if cfg.RedisAddr != "chatapp-redis" || cfg.RedisPassword != "chatapp-password" || cfg.RedisDB != 5 {
|
||||
t.Fatalf("unexpected redis config: %+v", cfg)
|
||||
}
|
||||
if cfg.JavaOtherBaseURL != "http://chatapp-java" {
|
||||
t.Fatalf("expected chatapp java base url, got %q", cfg.JavaOtherBaseURL)
|
||||
}
|
||||
if cfg.WeekStarDefaultSysOrigin != "LIKEI" {
|
||||
t.Fatalf("expected normalized sys origin LIKEI, got %q", cfg.WeekStarDefaultSysOrigin)
|
||||
}
|
||||
if cfg.WeekStarTimezone != "Asia/Shanghai" {
|
||||
t.Fatalf("expected chatapp timezone, got %q", cfg.WeekStarTimezone)
|
||||
}
|
||||
if cfg.WeekStarSettleInterval != 120*time.Second {
|
||||
t.Fatalf("expected settle interval 120s, got %s", cfg.WeekStarSettleInterval)
|
||||
}
|
||||
if cfg.WeekStarRetryStreamKey != "chatapp-stream" || cfg.WeekStarRetryConsumerGroup != "chatapp-group" || cfg.WeekStarRetryConsumerName != "chatapp-consumer" {
|
||||
t.Fatalf("unexpected retry consumer config: %+v", cfg)
|
||||
}
|
||||
if cfg.WeekStarRetryBatchSize != 11 || cfg.WeekStarRetryBlock != 2500*time.Millisecond || cfg.WeekStarRetryMinIdle != 4500*time.Millisecond {
|
||||
t.Fatalf("unexpected retry timing config: %+v", cfg)
|
||||
}
|
||||
if cfg.Timeout != 9*time.Second || cfg.ShutdownTimeout != 19*time.Second {
|
||||
t.Fatalf("unexpected http timeout config: %+v", cfg)
|
||||
}
|
||||
if !cfg.AutoMigrate {
|
||||
t.Fatalf("expected auto migrate to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFallsBackToLegacyKeys(t *testing.T) {
|
||||
t.Setenv("CRON_MYSQL_DSN", "legacy-mysql")
|
||||
t.Setenv("CRON_REDIS_ADDR", "legacy-redis")
|
||||
t.Setenv("CRON_REDIS_PASSWORD", "legacy-password")
|
||||
t.Setenv("CRON_REDIS_DB", "3")
|
||||
t.Setenv("CRON_JAVA_OTHER_BASE_URL", "http://legacy-java")
|
||||
t.Setenv("WEEK_STAR_DEFAULT_SYS_ORIGIN", "tarab")
|
||||
t.Setenv("WEEK_STAR_TIMEZONE", "Asia/Riyadh")
|
||||
t.Setenv("WEEK_STAR_SETTLE_INTERVAL_SECONDS", "66")
|
||||
t.Setenv("WEEK_STAR_RETRY_STREAM_KEY", "legacy-stream")
|
||||
t.Setenv("WEEK_STAR_RETRY_CONSUMER_GROUP", "legacy-group")
|
||||
t.Setenv("WEEK_STAR_RETRY_CONSUMER_NAME", "legacy-consumer")
|
||||
t.Setenv("WEEK_STAR_RETRY_BATCH_SIZE", "7")
|
||||
t.Setenv("WEEK_STAR_RETRY_BLOCK_MILLIS", "1700")
|
||||
t.Setenv("WEEK_STAR_RETRY_MIN_IDLE_MILLIS", "2700")
|
||||
t.Setenv("CRON_HTTP_TIMEOUT_SECONDS", "4")
|
||||
t.Setenv("CRON_SHUTDOWN_TIMEOUT_SECONDS", "14")
|
||||
t.Setenv("CRON_AUTO_MIGRATE", "true")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.MySQLDSN != "legacy-mysql" {
|
||||
t.Fatalf("expected legacy mysql dsn, got %q", cfg.MySQLDSN)
|
||||
}
|
||||
if cfg.RedisAddr != "legacy-redis" || cfg.RedisPassword != "legacy-password" || cfg.RedisDB != 3 {
|
||||
t.Fatalf("unexpected redis config: %+v", cfg)
|
||||
}
|
||||
if cfg.JavaOtherBaseURL != "http://legacy-java" {
|
||||
t.Fatalf("expected legacy java base url, got %q", cfg.JavaOtherBaseURL)
|
||||
}
|
||||
if cfg.WeekStarDefaultSysOrigin != "TARAB" {
|
||||
t.Fatalf("expected normalized sys origin TARAB, got %q", cfg.WeekStarDefaultSysOrigin)
|
||||
}
|
||||
if cfg.WeekStarSettleInterval != 66*time.Second {
|
||||
t.Fatalf("expected settle interval 66s, got %s", cfg.WeekStarSettleInterval)
|
||||
}
|
||||
if cfg.WeekStarRetryStreamKey != "legacy-stream" || cfg.WeekStarRetryConsumerGroup != "legacy-group" || cfg.WeekStarRetryConsumerName != "legacy-consumer" {
|
||||
t.Fatalf("unexpected retry consumer config: %+v", cfg)
|
||||
}
|
||||
if cfg.WeekStarRetryBatchSize != 7 || cfg.WeekStarRetryBlock != 1700*time.Millisecond || cfg.WeekStarRetryMinIdle != 2700*time.Millisecond {
|
||||
t.Fatalf("unexpected retry timing config: %+v", cfg)
|
||||
}
|
||||
if cfg.Timeout != 4*time.Second || cfg.ShutdownTimeout != 14*time.Second {
|
||||
t.Fatalf("unexpected http timeout config: %+v", cfg)
|
||||
}
|
||||
if !cfg.AutoMigrate {
|
||||
t.Fatalf("expected auto migrate to be true")
|
||||
}
|
||||
}
|
||||
@ -17,16 +17,16 @@ type UserBaseInfo struct {
|
||||
// TableName 返回用户基础资料表名。
|
||||
func (UserBaseInfo) TableName() string { return "user_base_info" }
|
||||
|
||||
// WeekStarActivityConfig 对应周榜活动主配置,一条记录描述一个系统下的活动时间窗和开关状态。
|
||||
// WeekStarActivityConfig 对应周榜活动主配置,一条记录描述一个系统下的一个周榜活动时间窗。
|
||||
type WeekStarActivityConfig struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"` // 配置主键
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_week_star_sys_origin"` // 系统标识,例如 LIKEI
|
||||
Enabled bool `gorm:"column:enabled"` // 活动开关
|
||||
StartAt time.Time `gorm:"column:start_at"` // 活动开始时间
|
||||
EndAt time.Time `gorm:"column:end_at"` // 活动结束时间
|
||||
Timezone string `gorm:"column:timezone;size:64"` // 活动结算时区
|
||||
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
||||
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
|
||||
ID int64 `gorm:"column:id;primaryKey"` // 配置主键
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_week_star_sys_origin_window,priority:1;index:idx_week_star_active_lookup,priority:1"` // 系统标识,例如 LIKEI
|
||||
Enabled bool `gorm:"column:enabled;index:idx_week_star_active_lookup,priority:2"` // 活动开关
|
||||
StartAt time.Time `gorm:"column:start_at;uniqueIndex:uk_week_star_sys_origin_window,priority:2;index:idx_week_star_active_lookup,priority:3"` // 活动开始时间
|
||||
EndAt time.Time `gorm:"column:end_at;uniqueIndex:uk_week_star_sys_origin_window,priority:3;index:idx_week_star_active_lookup,priority:4"` // 活动结束时间
|
||||
Timezone string `gorm:"column:timezone;size:64"` // 活动结算时区
|
||||
CreateTime time.Time `gorm:"column:create_time"` // 创建时间
|
||||
UpdateTime time.Time `gorm:"column:update_time"` // 更新时间
|
||||
}
|
||||
|
||||
// TableName 返回周榜活动主配置表名。
|
||||
|
||||
241
internal/service/week_star_retry.go
Normal file
241
internal/service/week_star_retry.go
Normal file
@ -0,0 +1,241 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chatapp-cron/internal/model"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const weekStarRewardRetryPendingTTL = 30 * time.Minute
|
||||
|
||||
// startRetryWorker 启动失败补发任务消费协程,统一处理后台人工补发请求。
|
||||
func (s *WeekStarSettlementService) startRetryWorker(ctx context.Context) {
|
||||
if strings.TrimSpace(s.cfg.WeekStarRetryStreamKey) == "" {
|
||||
log.Printf("week star retry consumer disabled: stream key is blank")
|
||||
return
|
||||
}
|
||||
if err := s.ensureRetryConsumerGroup(ctx); err != nil {
|
||||
log.Printf("ensure week star retry consumer group failed before start: %v", err)
|
||||
}
|
||||
log.Printf(
|
||||
"week star retry consumer started. stream=%s group=%s consumer=%s",
|
||||
s.cfg.WeekStarRetryStreamKey,
|
||||
s.cfg.WeekStarRetryConsumerGroup,
|
||||
s.cfg.WeekStarRetryConsumerName,
|
||||
)
|
||||
go s.consumeRetryLoop(ctx)
|
||||
}
|
||||
|
||||
// consumeRetryLoop 先认领超时 pending 任务,再持续读取新补发任务。
|
||||
func (s *WeekStarSettlementService) consumeRetryLoop(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
reclaimed, err := s.reclaimPendingRetryTasks(ctx)
|
||||
if err != nil {
|
||||
s.handleRetryLoopError(ctx, "reclaim week star retry tasks", err)
|
||||
continue
|
||||
}
|
||||
if reclaimed > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
err = s.readNewRetryTasks(ctx)
|
||||
if err == nil || errors.Is(err, redis.Nil) {
|
||||
continue
|
||||
}
|
||||
s.handleRetryLoopError(ctx, "read week star retry tasks", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRetryLoopError 统一处理补发消费循环错误,并在必要时重建消费者组。
|
||||
func (s *WeekStarSettlementService) handleRetryLoopError(ctx context.Context, action string, err error) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if isWeekStarRetryNoGroupErr(err) {
|
||||
if ensureErr := s.ensureRetryConsumerGroup(ctx); ensureErr != nil {
|
||||
log.Printf("%s failed and recreate retry consumer group failed: %v", action, ensureErr)
|
||||
sleepWeekStarRetry(ctx, time.Second)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("%s failed: %v", action, err)
|
||||
sleepWeekStarRetry(ctx, time.Second)
|
||||
}
|
||||
|
||||
// ensureRetryConsumerGroup 确保失败补发 Redis Stream 消费组存在。
|
||||
func (s *WeekStarSettlementService) ensureRetryConsumerGroup(ctx context.Context) error {
|
||||
err := s.repo.Redis.XGroupCreateMkStream(
|
||||
ctx,
|
||||
s.cfg.WeekStarRetryStreamKey,
|
||||
s.cfg.WeekStarRetryConsumerGroup,
|
||||
"0",
|
||||
).Err()
|
||||
if err != nil && !strings.Contains(strings.ToUpper(err.Error()), "BUSYGROUP") {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reclaimPendingRetryTasks 认领长时间未确认的补发任务,避免 worker 中断后消息遗失。
|
||||
func (s *WeekStarSettlementService) reclaimPendingRetryTasks(ctx context.Context) (int, error) {
|
||||
messages, _, err := s.repo.Redis.XAutoClaim(ctx, &redis.XAutoClaimArgs{
|
||||
Stream: s.cfg.WeekStarRetryStreamKey,
|
||||
Group: s.cfg.WeekStarRetryConsumerGroup,
|
||||
Consumer: s.cfg.WeekStarRetryConsumerName,
|
||||
MinIdle: s.cfg.WeekStarRetryMinIdle,
|
||||
Start: "0-0",
|
||||
Count: s.cfg.WeekStarRetryBatchSize,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(messages), s.processRetryMessages(ctx, messages)
|
||||
}
|
||||
|
||||
// readNewRetryTasks 按消费者组协议读取新的补发任务。
|
||||
func (s *WeekStarSettlementService) readNewRetryTasks(ctx context.Context) error {
|
||||
streams, err := s.repo.Redis.XReadGroup(ctx, &redis.XReadGroupArgs{
|
||||
Group: s.cfg.WeekStarRetryConsumerGroup,
|
||||
Consumer: s.cfg.WeekStarRetryConsumerName,
|
||||
Streams: []string{s.cfg.WeekStarRetryStreamKey, ">"},
|
||||
Count: s.cfg.WeekStarRetryBatchSize,
|
||||
Block: s.cfg.WeekStarRetryBlock,
|
||||
}).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stream := range streams {
|
||||
if err := s.processRetryMessages(ctx, stream.Messages); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processRetryMessages 顺序处理补发任务,并在完成状态落库后 ACK + DEL。
|
||||
func (s *WeekStarSettlementService) processRetryMessages(ctx context.Context, messages []redis.XMessage) error {
|
||||
for _, message := range messages {
|
||||
ack, recordID, err := s.processRetryMessage(ctx, message)
|
||||
if ack {
|
||||
if ackErr := s.repo.Redis.XAck(
|
||||
context.Background(),
|
||||
s.cfg.WeekStarRetryStreamKey,
|
||||
s.cfg.WeekStarRetryConsumerGroup,
|
||||
message.ID,
|
||||
).Err(); ackErr != nil {
|
||||
log.Printf("ack week star retry task failed. streamId=%s err=%v", message.ID, ackErr)
|
||||
}
|
||||
if delErr := s.repo.Redis.XDel(context.Background(), s.cfg.WeekStarRetryStreamKey, message.ID).Err(); delErr != nil {
|
||||
log.Printf("delete week star retry task failed. streamId=%s err=%v", message.ID, delErr)
|
||||
}
|
||||
if recordID > 0 {
|
||||
_, _ = s.repo.Redis.Del(context.Background(), s.rewardRetryPendingKey(recordID)).Result()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processRetryMessage 处理单条补发任务;只有在状态已经稳定落库后才确认消息。
|
||||
func (s *WeekStarSettlementService) processRetryMessage(ctx context.Context, message redis.XMessage) (bool, int64, error) {
|
||||
recordID, err := parseWeekStarRetryRecordID(message)
|
||||
if err != nil {
|
||||
log.Printf("drop malformed week star retry task. streamId=%s err=%v values=%v", message.ID, err, message.Values)
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
var record model.WeekStarRewardRecord
|
||||
err = s.repo.DB.WithContext(ctx).Where("id = ?", recordID).First(&record).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Printf("skip missing week star retry record. recordId=%d", recordID)
|
||||
return true, recordID, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, recordID, err
|
||||
}
|
||||
if record.Status != weekStarRewardStatusFailed {
|
||||
return true, recordID, nil
|
||||
}
|
||||
|
||||
handled, err := s.dispatchRewardRecord(ctx, &record, true)
|
||||
if err != nil {
|
||||
if handled {
|
||||
log.Printf("week star manual retry finished with failed status. recordId=%d err=%v", recordID, err)
|
||||
return true, recordID, nil
|
||||
}
|
||||
return false, recordID, err
|
||||
}
|
||||
return true, recordID, nil
|
||||
}
|
||||
|
||||
// parseWeekStarRetryRecordID 把 Redis Stream 消息解析成补发记录主键。
|
||||
func parseWeekStarRetryRecordID(message redis.XMessage) (int64, error) {
|
||||
return toWeekStarRetryInt64(message.Values["recordId"])
|
||||
}
|
||||
|
||||
// toWeekStarRetryInt64 把 Stream 字段安全转换成 int64。
|
||||
func toWeekStarRetryInt64(value any) (int64, error) {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return 0, fmt.Errorf("value is nil")
|
||||
case int64:
|
||||
return typed, nil
|
||||
case int32:
|
||||
return int64(typed), nil
|
||||
case int:
|
||||
return int64(typed), nil
|
||||
case uint64:
|
||||
return int64(typed), nil
|
||||
case float64:
|
||||
return int64(typed), nil
|
||||
case string:
|
||||
return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
case []byte:
|
||||
return strconv.ParseInt(strings.TrimSpace(string(typed)), 10, 64)
|
||||
default:
|
||||
return strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// isWeekStarRetryNoGroupErr 判断是否是消费者组不存在错误。
|
||||
func isWeekStarRetryNoGroupErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToUpper(err.Error()), "NOGROUP")
|
||||
}
|
||||
|
||||
// rewardRetryPendingKey 返回补发任务去重 key,和 chatapp3-golang 保持一致。
|
||||
func (s *WeekStarSettlementService) rewardRetryPendingKey(recordID int64) string {
|
||||
return strings.TrimSpace(s.cfg.WeekStarRetryStreamKey) + ":pending:" + strconv.FormatInt(recordID, 10)
|
||||
}
|
||||
|
||||
// sleepWeekStarRetry 支持被上下文中断的短暂退避休眠。
|
||||
func sleepWeekStarRetry(ctx context.Context, duration time.Duration) {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
@ -82,8 +82,14 @@ func NewWeekStarSettlementService(cfg config.Config, repository *repo.Repository
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动定时扫描任务,按照配置周期持续检查是否存在待结算周榜。
|
||||
// Start 启动周榜结算扫描和失败补发消费任务。
|
||||
func (s *WeekStarSettlementService) Start(ctx context.Context) {
|
||||
s.startSettlementLoop(ctx)
|
||||
s.startRetryWorker(ctx)
|
||||
}
|
||||
|
||||
// startSettlementLoop 按配置周期持续检查是否存在待结算周榜。
|
||||
func (s *WeekStarSettlementService) startSettlementLoop(ctx context.Context) {
|
||||
interval := s.cfg.WeekStarSettleInterval
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
@ -195,7 +201,7 @@ func (s *WeekStarSettlementService) settleCycle(ctx context.Context, configRow *
|
||||
var rewardConfigs []model.WeekStarActivityRewardConfig
|
||||
if err := s.repo.DB.WithContext(ctx).
|
||||
Where("config_id = ?", configRow.ID).
|
||||
Order("rank asc").
|
||||
Order("`rank` asc").
|
||||
Find(&rewardConfigs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@ -282,14 +288,15 @@ func (s *WeekStarSettlementService) settleCycle(ctx context.Context, configRow *
|
||||
var pendingRecords []model.WeekStarRewardRecord
|
||||
if err := s.repo.DB.WithContext(ctx).
|
||||
Where("config_id = ? AND cycle_key = ? AND status = ?", configRow.ID, cycleKey, weekStarRewardStatusPending).
|
||||
Order("rank asc").
|
||||
Order("`rank` asc").
|
||||
Find(&pendingRecords).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var lastErr error
|
||||
for index := range pendingRecords {
|
||||
// 发奖逐条执行,单条失败不会阻塞同周期其他名次继续发放。
|
||||
if err := s.dispatchRewardRecord(ctx, &pendingRecords[index]); err != nil {
|
||||
_, err := s.dispatchRewardRecord(ctx, &pendingRecords[index], false)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
@ -297,16 +304,22 @@ func (s *WeekStarSettlementService) settleCycle(ctx context.Context, configRow *
|
||||
}
|
||||
|
||||
// dispatchRewardRecord 调用 Java 奖励接口发奖,并回写发奖成功或失败状态。
|
||||
func (s *WeekStarSettlementService) dispatchRewardRecord(ctx context.Context, record *model.WeekStarRewardRecord) error {
|
||||
// handled=true 表示本次任务已经完成状态落库,调用方可以安全确认消息;
|
||||
// handled=false 表示处理过程中断,调用方应保留消息供后续继续消费。
|
||||
func (s *WeekStarSettlementService) dispatchRewardRecord(ctx context.Context, record *model.WeekStarRewardRecord, isRetry bool) (bool, error) {
|
||||
if record == nil {
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
var configRow model.WeekStarActivityConfig
|
||||
if err := s.repo.DB.WithContext(ctx).Where("id = ?", record.ConfigID).First(&configRow).Error; err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
retryCount := record.RetryCount
|
||||
if isRetry {
|
||||
retryCount++
|
||||
}
|
||||
err := s.java.SendActivityReward(ctx, integration.SendActivityRewardRequest{
|
||||
TrackID: record.TrackID,
|
||||
Origin: weekStarRewardOrigin,
|
||||
@ -318,7 +331,7 @@ func (s *WeekStarSettlementService) dispatchRewardRecord(ctx context.Context, re
|
||||
// 发奖失败时保留错误信息,方便后台做失败补发。
|
||||
updates := map[string]any{
|
||||
"status": weekStarRewardStatusFailed,
|
||||
"retry_count": record.RetryCount,
|
||||
"retry_count": retryCount,
|
||||
"last_error": truncateString(err.Error(), 1024),
|
||||
"update_time": now,
|
||||
}
|
||||
@ -326,21 +339,24 @@ func (s *WeekStarSettlementService) dispatchRewardRecord(ctx context.Context, re
|
||||
Model(&model.WeekStarRewardRecord{}).
|
||||
Where("id = ?", record.ID).
|
||||
Updates(updates).Error; updateErr != nil {
|
||||
return updateErr
|
||||
return false, updateErr
|
||||
}
|
||||
return err
|
||||
return true, err
|
||||
}
|
||||
|
||||
return s.repo.DB.WithContext(ctx).
|
||||
if err := s.repo.DB.WithContext(ctx).
|
||||
Model(&model.WeekStarRewardRecord{}).
|
||||
Where("id = ?", record.ID).
|
||||
Updates(map[string]any{
|
||||
"status": weekStarRewardStatusSuccess,
|
||||
"retry_count": record.RetryCount,
|
||||
"retry_count": retryCount,
|
||||
"last_error": "",
|
||||
"sent_at": now,
|
||||
"update_time": now,
|
||||
}).Error
|
||||
}).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// loadLiveRanking 从 Redis 实时榜读取前 N 名,并补齐用户资料供快照/发奖使用。
|
||||
@ -464,7 +480,7 @@ func (s *WeekStarSettlementService) toStorageWallClock(t time.Time) time.Time {
|
||||
local.Minute(),
|
||||
local.Second(),
|
||||
local.Nanosecond(),
|
||||
time.Local,
|
||||
s.location,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user