diff --git a/cmd/api/main.go b/cmd/api/main.go index 9c37019..ba212a6 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -7,6 +7,7 @@ import ( "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/registerreward" + "chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/weekstar" "context" "errors" @@ -30,12 +31,14 @@ func main() { baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) + signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{ Invite: inviteService, Baishun: baishunService, LuckyGift: luckyGiftService, RegisterReward: registerRewardService, + SignInReward: signInRewardService, WeekStar: weekStarService, }) server := &http.Server{ diff --git a/internal/model/signinreward_models.go b/internal/model/signinreward_models.go new file mode 100644 index 0000000..34429e0 --- /dev/null +++ b/internal/model/signinreward_models.go @@ -0,0 +1,52 @@ +package model + +import "time" + +// SignInRewardConfig 保存签到奖励主配置。 +type SignInRewardConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_sign_in_reward_sys_origin"` + Enabled bool `gorm:"column:enabled"` + Timezone string `gorm:"column:timezone;size:64"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName 返回签到奖励主配置表名。 +func (SignInRewardConfig) TableName() string { return "sign_in_reward_config" } + +// SignInRewardDayConfig 保存签到奖励 7 天循环子配置。 +type SignInRewardDayConfig struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;index:idx_sign_in_reward_day_config,priority:1"` + DayIndex int `gorm:"column:day_index;index:idx_sign_in_reward_day_config,priority:2"` + GoldAmount int64 `gorm:"column:gold_amount"` + RewardGroupID *int64 `gorm:"column:reward_group_id"` + RewardGroupName string `gorm:"column:reward_group_name;size:255"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName 返回签到奖励日配置表名。 +func (SignInRewardDayConfig) TableName() string { return "sign_in_reward_day_config" } + +// SignInRewardLog 记录签到奖励发放结果,兼作幂等审计日志。 +type SignInRewardLog struct { + ID int64 `gorm:"column:id;primaryKey"` + EventID string `gorm:"column:event_id;size:128;uniqueIndex:uk_sign_in_reward_event"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_sign_in_reward_user_day,priority:1;index:idx_sign_in_reward_user_time,priority:1"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_sign_in_reward_user_day,priority:2;index:idx_sign_in_reward_user_time,priority:2"` + ClaimDate string `gorm:"column:claim_date;size:10;uniqueIndex:uk_sign_in_reward_user_day,priority:3;index:idx_sign_in_reward_claim,priority:2"` + DayIndex int `gorm:"column:day_index"` + StreakCount int `gorm:"column:streak_count"` + GoldAmount int64 `gorm:"column:gold_amount"` + RewardGroupID *int64 `gorm:"column:reward_group_id"` + RewardGroupName string `gorm:"column:reward_group_name;size:255"` + Status string `gorm:"column:status;size:32;index:idx_sign_in_reward_claim,priority:3"` + ErrorMessage string `gorm:"column:error_message;size:255"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +// TableName 返回签到奖励日志表名。 +func (SignInRewardLog) TableName() string { return "sign_in_reward_log" } diff --git a/internal/router/router.go b/internal/router/router.go index 29ccf99..3265a1c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -10,6 +10,7 @@ import ( "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/luckygift" "chatapp3-golang/internal/service/registerreward" + "chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/weekstar" "github.com/gin-gonic/gin" @@ -21,6 +22,7 @@ type Services struct { Baishun *baishun.BaishunService LuckyGift *luckygift.LuckyGiftService RegisterReward *registerreward.RegisterRewardService + SignInReward *signinreward.SignInRewardService WeekStar *weekstar.WeekStarService } @@ -37,6 +39,7 @@ func NewRouter( registerHealthRoutes(engine, cfg, repository) registerInviteRoutes(engine, javaClient, services.Invite) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) + registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerWeekStarRoutes(engine, javaClient, services.WeekStar) registerBaishunRoutes(engine, cfg, javaClient, services.Baishun) registerLuckyGiftRoutes(engine, cfg, services.LuckyGift) @@ -58,6 +61,6 @@ func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *rep return } - c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success2"}) + c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success3"}) }) } diff --git a/internal/router/sign_in_reward_routes.go b/internal/router/sign_in_reward_routes.go new file mode 100644 index 0000000..77e05eb --- /dev/null +++ b/internal/router/sign_in_reward_routes.go @@ -0,0 +1,70 @@ +package router + +import ( + "chatapp3-golang/internal/service/signinreward" + "net/http" + + "github.com/gin-gonic/gin" +) + +// registerSignInRewardRoutes 注册签到奖励 app 与后台配置接口。 +func registerSignInRewardRoutes( + engine *gin.Engine, + javaClient authGateway, + signInRewardService *signinreward.SignInRewardService, +) { + if signInRewardService == nil { + return + } + + appGroup := engine.Group("/app/sign-in-reward") + appGroup.Use(authMiddleware(javaClient)) + appGroup.GET("/config", func(c *gin.Context) { + resp, err := signInRewardService.GetStatus(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.GET("/status", func(c *gin.Context) { + resp, err := signInRewardService.GetStatus(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + appGroup.POST("/check-in", func(c *gin.Context) { + resp, err := signInRewardService.CheckIn(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + + residentGroup := engine.Group("/resident-activity/sign-in-reward") + residentGroup.Use(consoleAuthMiddleware(javaClient)) + residentGroup.GET("/config", func(c *gin.Context) { + resp, err := signInRewardService.GetConfig(c.Request.Context(), c.Query("sysOrigin")) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + residentGroup.POST("/config/save", func(c *gin.Context) { + var req signinreward.SaveSignInRewardConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, signinreward.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := signInRewardService.SaveConfig(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/service/signinreward/app.go b/internal/service/signinreward/app.go new file mode 100644 index 0000000..3f783ec --- /dev/null +++ b/internal/service/signinreward/app.go @@ -0,0 +1,524 @@ +package signinreward + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetStatus 返回签到奖励配置和当前用户签到状态。 +func (s *SignInRewardService) GetStatus(ctx context.Context, user AuthUser) (*SignInRewardStatusResponse, error) { + bundle, location, state, err := s.loadStatusContext(ctx, user) + if err != nil { + return nil, err + } + if bundle.Config == nil { + return &SignInRewardStatusResponse{ + UserID: user.UserID, + SysOrigin: normalizeSignInRewardSysOrigin(user.SysOrigin), + Configured: false, + Enabled: false, + Available: false, + Timezone: signInRewardDefaultTimezone, + CycleDays: signInRewardCycleLength, + CurrentDate: time.Now().In(location).Format("2006-01-02"), + NextDayIndex: 1, + Days: buildDefaultSignInRewardDays(), + }, nil + } + + itemsMap := s.loadRewardItemsMap(ctx, bundle.Days) + days := make([]SignInRewardDayPayload, 0, len(bundle.Days)) + for _, day := range bundle.Days { + days = append(days, SignInRewardDayPayload{ + DayIndex: day.DayIndex, + GoldAmount: day.GoldAmount, + RewardGroupID: day.RewardGroupID, + RewardGroupName: day.RewardGroupName, + RewardItems: itemsMap[rewardGroupKey(day.RewardGroupID)], + Signed: day.DayIndex <= state.CycleProgress, + TodayTarget: !state.SignedToday && day.DayIndex == state.NextDayIndex, + }) + } + + return &SignInRewardStatusResponse{ + UserID: user.UserID, + SysOrigin: bundle.Config.SysOrigin, + Configured: true, + Enabled: bundle.Config.Enabled, + Available: bundle.Config.Enabled, + Timezone: bundle.Config.Timezone, + CycleDays: signInRewardCycleLength, + CurrentDate: state.Today, + SignedToday: state.SignedToday, + CurrentStreak: state.CurrentStreak, + NextDayIndex: state.NextDayIndex, + Days: days, + }, nil +} + +// CheckIn 执行签到并发放当天奖励。 +func (s *SignInRewardService) CheckIn(ctx context.Context, user AuthUser) (*SignInRewardCheckInResponse, error) { + bundle, location, state, err := s.loadStatusContext(ctx, user) + if err != nil { + return nil, err + } + if bundle.Config == nil || !bundle.Config.Enabled { + return nil, NewAppError(http.StatusBadRequest, "sign_in_reward_unavailable", "sign-in reward is not available") + } + + if state.SignedToday { + logRecord, err := s.loadTodayLog(ctx, bundle.Config.SysOrigin, user.UserID, state.Today) + if err != nil { + return nil, err + } + return s.buildCheckInResponse(ctx, bundle.Days, logRecord, true), nil + } + + lockKey := fmt.Sprintf("signinreward:checkin:%s:%d:%s", bundle.Config.SysOrigin, user.UserID, state.Today) + lockOK, err := s.repo.Redis.SetNX(ctx, lockKey, "1", 10*time.Second).Result() + if err != nil { + return nil, NewAppError(http.StatusServiceUnavailable, "sign_in_reward_lock_failed", err.Error()) + } + if !lockOK { + return nil, NewAppError(http.StatusConflict, "sign_in_reward_in_progress", "sign-in reward is in progress") + } + defer s.repo.Redis.Del(context.Background(), lockKey) + + // 锁内再查一次,避免并发请求重复签到。 + bundle, location, state, err = s.loadStatusContext(ctx, user) + if err != nil { + return nil, err + } + if bundle.Config == nil || !bundle.Config.Enabled { + return nil, NewAppError(http.StatusBadRequest, "sign_in_reward_unavailable", "sign-in reward is not available") + } + if state.SignedToday { + logRecord, err := s.loadTodayLog(ctx, bundle.Config.SysOrigin, user.UserID, state.Today) + if err != nil { + return nil, err + } + return s.buildCheckInResponse(ctx, bundle.Days, logRecord, true), nil + } + + dayConfig, err := resolveSignInRewardDay(bundle.Days, state.NextDayIndex) + if err != nil { + return nil, err + } + if dayConfig.GoldAmount <= 0 && dayConfig.RewardGroupID == nil { + return nil, NewAppError(http.StatusBadRequest, "sign_in_reward_empty", "reward for current day is not configured") + } + + logRecord, err := s.ensureTodayLog(ctx, bundle.Config, user, state, dayConfig) + if err != nil { + return nil, err + } + + if err := s.dispatchCheckInReward(ctx, bundle.Config, user, logRecord); err != nil { + _ = s.markLogFailed(ctx, logRecord.ID, err.Error()) + return nil, err + } + if err := s.markLogSuccess(ctx, logRecord.ID); err != nil { + return nil, err + } + logRecord.Status = signInRewardStatusSuccess + logRecord.ErrorMessage = "" + logRecord.UpdateTime = time.Now().In(location) + + return s.buildCheckInResponse(ctx, bundle.Days, logRecord, false), nil +} + +func (s *SignInRewardService) loadStatusContext( + ctx context.Context, + user AuthUser, +) (*signInRewardConfigBundle, *time.Location, signInRewardRuntimeState, error) { + bundle, err := s.loadConfigBundle(ctx, user.SysOrigin) + if err != nil { + return nil, time.Local, signInRewardRuntimeState{}, err + } + + location := resolveSignInRewardLocation(signInRewardDefaultTimezone) + if bundle.Config != nil { + location = resolveSignInRewardLocation(bundle.Config.Timezone) + bundle.Config.Timezone = location.String() + } + + state, err := s.resolveRuntimeState(ctx, user.UserID, normalizeSignInRewardSysOrigin(user.SysOrigin), location) + if err != nil { + return nil, location, signInRewardRuntimeState{}, err + } + return bundle, location, state, nil +} + +func (s *SignInRewardService) resolveRuntimeState( + ctx context.Context, + userID int64, + sysOrigin string, + location *time.Location, +) (signInRewardRuntimeState, error) { + now := time.Now().In(location) + today := now.Format("2006-01-02") + latestLog, err := s.loadLatestSuccessLog(ctx, sysOrigin, userID) + if err != nil { + return signInRewardRuntimeState{}, err + } + if latestLog == nil { + return signInRewardRuntimeState{ + Today: today, + CurrentStreak: 0, + NextDayIndex: 1, + }, nil + } + + latestDate, err := time.ParseInLocation("2006-01-02", latestLog.ClaimDate, location) + if err != nil { + return signInRewardRuntimeState{}, err + } + yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") + if latestLog.ClaimDate == today { + cycleProgress := streakToCycleProgress(latestLog.StreakCount) + return signInRewardRuntimeState{ + Today: today, + SignedToday: true, + CurrentStreak: latestLog.StreakCount, + NextDayIndex: nextSignInRewardDayIndex(latestLog.StreakCount), + CycleProgress: cycleProgress, + LatestSuccessID: latestLog.ID, + }, nil + } + if latestLog.ClaimDate == yesterday || latestDate.AddDate(0, 0, 1).Format("2006-01-02") == today { + return signInRewardRuntimeState{ + Today: today, + CurrentStreak: latestLog.StreakCount, + NextDayIndex: nextSignInRewardDayIndex(latestLog.StreakCount), + CycleProgress: streakToCycleProgress(latestLog.StreakCount), + LatestSuccessID: latestLog.ID, + }, nil + } + return signInRewardRuntimeState{ + Today: today, + CurrentStreak: 0, + NextDayIndex: 1, + }, nil +} + +func (s *SignInRewardService) loadLatestSuccessLog(ctx context.Context, sysOrigin string, userID int64) (*model.SignInRewardLog, error) { + var row model.SignInRewardLog + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND user_id = ? AND status = ?", sysOrigin, userID, signInRewardStatusSuccess). + Order("claim_date desc"). + Order("id desc"). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *SignInRewardService) loadTodayLog(ctx context.Context, sysOrigin string, userID int64, claimDate string) (*model.SignInRewardLog, error) { + var row model.SignInRewardLog + err := s.repo.DB.WithContext(ctx). + Where("sys_origin = ? AND user_id = ? AND claim_date = ?", sysOrigin, userID, claimDate). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (s *SignInRewardService) ensureTodayLog( + ctx context.Context, + config *signInRewardConfigSnapshot, + user AuthUser, + state signInRewardRuntimeState, + day signInRewardDaySnapshot, +) (*model.SignInRewardLog, error) { + existing, err := s.loadTodayLog(ctx, config.SysOrigin, user.UserID, state.Today) + if err != nil { + return nil, err + } + if existing != nil { + if existing.Status == signInRewardStatusSuccess { + return existing, nil + } + if existing.DayIndex <= 0 { + existing.DayIndex = day.DayIndex + } + if existing.StreakCount <= 0 { + existing.StreakCount = state.CurrentStreak + 1 + } + if existing.GoldAmount <= 0 { + existing.GoldAmount = day.GoldAmount + } + if existing.RewardGroupID == nil { + existing.RewardGroupID = day.RewardGroupID + existing.RewardGroupName = day.RewardGroupName + } + if err := s.repo.DB.WithContext(ctx). + Model(&model.SignInRewardLog{}). + Where("id = ?", existing.ID). + Updates(map[string]any{ + "day_index": existing.DayIndex, + "streak_count": existing.StreakCount, + "gold_amount": existing.GoldAmount, + "reward_group_id": existing.RewardGroupID, + "reward_group_name": strings.TrimSpace(existing.RewardGroupName), + "status": signInRewardStatusPending, + "error_message": "", + "update_time": time.Now(), + }).Error; err != nil { + return nil, err + } + existing.Status = signInRewardStatusPending + existing.ErrorMessage = "" + return existing, nil + } + + id, err := utils.NextID() + if err != nil { + return nil, err + } + now := time.Now() + record := &model.SignInRewardLog{ + ID: id, + EventID: buildSignInRewardEventID(config.SysOrigin, user.UserID, state.Today), + SysOrigin: config.SysOrigin, + UserID: user.UserID, + ClaimDate: state.Today, + DayIndex: day.DayIndex, + StreakCount: state.CurrentStreak + 1, + GoldAmount: day.GoldAmount, + RewardGroupID: day.RewardGroupID, + RewardGroupName: day.RewardGroupName, + Status: signInRewardStatusPending, + CreateTime: now, + UpdateTime: now, + } + if err := s.repo.DB.WithContext(ctx).Create(record).Error; err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate") { + return s.loadTodayLog(ctx, config.SysOrigin, user.UserID, state.Today) + } + return nil, err + } + return record, nil +} + +func (s *SignInRewardService) dispatchCheckInReward( + ctx context.Context, + config *signInRewardConfigSnapshot, + user AuthUser, + logRecord *model.SignInRewardLog, +) error { + if logRecord.GoldAmount > 0 { + goldEventID := logRecord.EventID + ":gold" + exists, err := s.java.ExistsGoldEvent(ctx, goldEventID) + if err != nil { + return NewAppError(http.StatusBadGateway, "sign_in_reward_wallet_exists_failed", err.Error()) + } + if !exists { + if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ + ReceiptType: "INCOME", + UserID: user.UserID, + SysOrigin: config.SysOrigin, + EventID: goldEventID, + Remark: fmt.Sprintf("signInDate=%s dayIndex=%d", logRecord.ClaimDate, logRecord.DayIndex), + Amount: integration.NewPennyAmountPayloadFromDollar(logRecord.GoldAmount), + CloseDelayAsset: false, + OpUserType: "APP", + CustomizeOrigin: signInRewardGoldOrigin, + CustomizeOriginDesc: signInRewardGoldDesc, + }); err != nil { + return NewAppError(http.StatusBadGateway, "sign_in_reward_wallet_failed", err.Error()) + } + } + } + if logRecord.RewardGroupID != nil { + if err := s.java.GrantProps(ctx, integration.GrantPropsRequest{ + TrackID: logRecord.ID, + AcceptUserID: user.UserID, + SysOrigin: config.SysOrigin, + Origin: signInRewardPropsOrigin, + SourceGroupID: *logRecord.RewardGroupID, + }); err != nil { + return NewAppError(http.StatusBadGateway, "sign_in_reward_props_failed", err.Error()) + } + } + return nil +} + +func (s *SignInRewardService) markLogSuccess(ctx context.Context, logID int64) error { + return s.repo.DB.WithContext(ctx). + Model(&model.SignInRewardLog{}). + Where("id = ?", logID). + Updates(map[string]any{ + "status": signInRewardStatusSuccess, + "error_message": "", + "update_time": time.Now(), + }).Error +} + +func (s *SignInRewardService) markLogFailed(ctx context.Context, logID int64, errorMessage string) error { + return s.repo.DB.WithContext(ctx). + Model(&model.SignInRewardLog{}). + Where("id = ?", logID). + Updates(map[string]any{ + "status": signInRewardStatusFailed, + "error_message": truncateSignInRewardMessage(errorMessage), + "update_time": time.Now(), + }).Error +} + +func (s *SignInRewardService) buildCheckInResponse( + ctx context.Context, + days []signInRewardDaySnapshot, + logRecord *model.SignInRewardLog, + alreadySigned bool, +) *SignInRewardCheckInResponse { + if logRecord == nil { + return &SignInRewardCheckInResponse{ + Success: false, + AlreadySigned: alreadySigned, + RewardItems: []SignInRewardRewardItem{}, + } + } + + dayRewardMap := s.loadRewardItemsMap(ctx, []signInRewardDaySnapshot{{ + RewardGroupID: logRecord.RewardGroupID, + }}) + rewardItems := dayRewardMap[rewardGroupKey(logRecord.RewardGroupID)] + if len(rewardItems) == 0 { + for _, day := range days { + if day.DayIndex == logRecord.DayIndex { + rewardItems = s.loadRewardItemsMap(ctx, []signInRewardDaySnapshot{day})[rewardGroupKey(day.RewardGroupID)] + if strings.TrimSpace(logRecord.RewardGroupName) == "" { + logRecord.RewardGroupName = day.RewardGroupName + } + break + } + } + } + + return &SignInRewardCheckInResponse{ + Success: strings.EqualFold(logRecord.Status, signInRewardStatusSuccess), + AlreadySigned: alreadySigned, + UserID: logRecord.UserID, + SysOrigin: logRecord.SysOrigin, + ClaimDate: logRecord.ClaimDate, + DayIndex: logRecord.DayIndex, + StreakCount: logRecord.StreakCount, + GoldAmount: logRecord.GoldAmount, + RewardGroupID: logRecord.RewardGroupID, + RewardGroupName: logRecord.RewardGroupName, + RewardItems: rewardItems, + Status: logRecord.Status, + } +} + +func (s *SignInRewardService) loadRewardItemsMap( + ctx context.Context, + days []signInRewardDaySnapshot, +) map[string][]SignInRewardRewardItem { + groupIDs := make([]int64, 0) + seen := make(map[int64]struct{}) + for _, day := range days { + if day.RewardGroupID == nil || *day.RewardGroupID <= 0 { + continue + } + if _, ok := seen[*day.RewardGroupID]; ok { + continue + } + seen[*day.RewardGroupID] = struct{}{} + groupIDs = append(groupIDs, *day.RewardGroupID) + } + sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] }) + + result := make(map[string][]SignInRewardRewardItem, len(groupIDs)) + for _, groupID := range groupIDs { + detail, err := s.java.GetRewardGroupDetail(ctx, groupID) + if err != nil { + continue + } + items := make([]SignInRewardRewardItem, 0, len(detail.RewardConfigList)) + for _, item := range detail.RewardConfigList { + items = append(items, SignInRewardRewardItem{ + ID: int64(item.ID), + Type: item.Type, + Name: item.Name, + Content: item.Content, + Quantity: int64(item.Quantity), + Cover: item.Cover, + Remark: item.Remark, + }) + } + result[rewardGroupKey(&groupID)] = items + } + return result +} + +func resolveSignInRewardDay(days []signInRewardDaySnapshot, dayIndex int) (signInRewardDaySnapshot, error) { + for _, item := range days { + if item.DayIndex == dayIndex { + return item, nil + } + } + return signInRewardDaySnapshot{}, NewAppError(http.StatusBadRequest, "sign_in_reward_day_missing", "reward config for current day is missing") +} + +func nextSignInRewardDayIndex(currentStreak int) int { + return ((currentStreak % signInRewardCycleLength) + 1) +} + +func streakToCycleProgress(streak int) int { + if streak <= 0 { + return 0 + } + progress := streak % signInRewardCycleLength + if progress == 0 { + return signInRewardCycleLength + } + return progress +} + +func buildSignInRewardEventID(sysOrigin string, userID int64, claimDate string) string { + return fmt.Sprintf("SIGN_IN_REWARD:%s:%d:%s", normalizeSignInRewardSysOrigin(sysOrigin), userID, strings.TrimSpace(claimDate)) +} + +func resolveSignInRewardLocation(timezone string) *time.Location { + timezone = normalizeSignInRewardTimezone(timezone) + location, err := time.LoadLocation(timezone) + if err != nil { + location, _ = time.LoadLocation(signInRewardDefaultTimezone) + } + return location +} + +func rewardGroupKey(groupID *int64) string { + if groupID == nil || *groupID <= 0 { + return "" + } + return fmt.Sprintf("%d", *groupID) +} + +func truncateSignInRewardMessage(message string) string { + message = strings.TrimSpace(message) + if len(message) <= 255 { + return message + } + return message[:255] +} diff --git a/internal/service/signinreward/config.go b/internal/service/signinreward/config.go new file mode 100644 index 0000000..05b5152 --- /dev/null +++ b/internal/service/signinreward/config.go @@ -0,0 +1,253 @@ +package signinreward + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "time" + + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/utils" + + "gorm.io/gorm" +) + +// GetConfig 返回后台配置页使用的签到奖励配置。 +func (s *SignInRewardService) GetConfig(ctx context.Context, sysOrigin string) (*SignInRewardConfigResponse, error) { + bundle, err := s.loadConfigBundle(ctx, sysOrigin) + if err != nil { + return nil, err + } + if bundle.Config == nil { + return &SignInRewardConfigResponse{ + Configured: false, + SysOrigin: normalizeSignInRewardSysOrigin(sysOrigin), + Timezone: signInRewardDefaultTimezone, + CycleDays: signInRewardCycleLength, + Days: buildDefaultSignInRewardDays(), + }, nil + } + return &SignInRewardConfigResponse{ + Configured: true, + ID: bundle.Config.ID, + SysOrigin: bundle.Config.SysOrigin, + Enabled: bundle.Config.Enabled, + Timezone: bundle.Config.Timezone, + CycleDays: signInRewardCycleLength, + Days: buildAdminDayPayloads(bundle.Days), + UpdateTime: formatSignInRewardDateTime(bundle.Config.UpdateTime), + }, nil +} + +// SaveConfig 保存签到奖励后台配置。 +func (s *SignInRewardService) SaveConfig(ctx context.Context, req SaveSignInRewardConfigRequest) (*SignInRewardConfigResponse, error) { + sysOrigin := normalizeSignInRewardSysOrigin(req.SysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + + timezone := normalizeSignInRewardTimezone(req.Timezone) + if _, err := time.LoadLocation(timezone); err != nil { + return nil, NewAppError(http.StatusBadRequest, "invalid_timezone", err.Error()) + } + + normalizedDays, err := normalizeSaveDays(req.Days, req.Enabled) + if err != nil { + return nil, err + } + + now := time.Now() + var configID int64 + if err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var configRow model.SignInRewardConfig + if err := tx.Where("sys_origin = ?", sysOrigin).First(&configRow).Error; errors.Is(err, gorm.ErrRecordNotFound) { + nextID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + configRow = model.SignInRewardConfig{ + ID: nextID, + SysOrigin: sysOrigin, + CreateTime: now, + } + } else if err != nil { + return err + } + + configRow.Enabled = req.Enabled + configRow.Timezone = timezone + configRow.UpdateTime = now + + if configRow.CreateTime.IsZero() { + configRow.CreateTime = now + } + + if err := tx.Save(&configRow).Error; err != nil { + return err + } + + if err := tx.Where("config_id = ?", configRow.ID).Delete(&model.SignInRewardDayConfig{}).Error; err != nil { + return err + } + + rows := make([]model.SignInRewardDayConfig, 0, len(normalizedDays)) + for _, item := range normalizedDays { + dayID, idErr := utils.NextID() + if idErr != nil { + return idErr + } + rows = append(rows, model.SignInRewardDayConfig{ + ID: dayID, + ConfigID: configRow.ID, + DayIndex: item.DayIndex, + GoldAmount: item.GoldAmount, + RewardGroupID: item.RewardGroupID, + RewardGroupName: strings.TrimSpace(item.RewardGroupName), + CreateTime: now, + UpdateTime: now, + }) + } + if len(rows) > 0 { + if err := tx.Create(&rows).Error; err != nil { + return err + } + } + configID = configRow.ID + return nil + }); err != nil { + return nil, err + } + + _ = configID + return s.GetConfig(ctx, sysOrigin) +} + +func normalizeSaveDays(days []SignInRewardDayConfigInput, enabled bool) ([]SignInRewardDayConfigInput, error) { + if len(days) != signInRewardCycleLength { + return nil, NewAppError(http.StatusBadRequest, "invalid_days", "days must contain exactly 7 items") + } + + seen := make(map[int]struct{}, len(days)) + normalized := make([]SignInRewardDayConfigInput, 0, len(days)) + for _, item := range days { + if item.DayIndex < 1 || item.DayIndex > signInRewardCycleLength { + return nil, NewAppError(http.StatusBadRequest, "invalid_day_index", "dayIndex must be between 1 and 7") + } + if _, exists := seen[item.DayIndex]; exists { + return nil, NewAppError(http.StatusBadRequest, "duplicate_day_index", "dayIndex must be unique") + } + seen[item.DayIndex] = struct{}{} + if item.GoldAmount < 0 { + return nil, NewAppError(http.StatusBadRequest, "invalid_gold_amount", "goldAmount must be greater than or equal to 0") + } + if enabled && item.GoldAmount <= 0 && item.RewardGroupID == nil { + return nil, NewAppError(http.StatusBadRequest, "empty_day_reward", "each enabled day must configure goldAmount or rewardGroupId") + } + normalized = append(normalized, SignInRewardDayConfigInput{ + DayIndex: item.DayIndex, + GoldAmount: item.GoldAmount, + RewardGroupID: item.RewardGroupID, + RewardGroupName: strings.TrimSpace(item.RewardGroupName), + }) + } + sort.Slice(normalized, func(i, j int) bool { + return normalized[i].DayIndex < normalized[j].DayIndex + }) + return normalized, nil +} + +func (s *SignInRewardService) loadConfigBundle(ctx context.Context, sysOrigin string) (*signInRewardConfigBundle, error) { + sysOrigin = normalizeSignInRewardSysOrigin(sysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required") + } + + var configRow model.SignInRewardConfig + err := s.repo.DB.WithContext(ctx).Where("sys_origin = ?", sysOrigin).First(&configRow).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &signInRewardConfigBundle{}, nil + } + if err != nil { + return nil, err + } + + var dayRows []model.SignInRewardDayConfig + if err := s.repo.DB.WithContext(ctx). + Where("config_id = ?", configRow.ID). + Order("day_index asc"). + Find(&dayRows).Error; err != nil { + return nil, err + } + + result := &signInRewardConfigBundle{ + Config: &signInRewardConfigSnapshot{ + ID: configRow.ID, + SysOrigin: configRow.SysOrigin, + Enabled: configRow.Enabled, + Timezone: normalizeSignInRewardTimezone(configRow.Timezone), + UpdateTime: configRow.UpdateTime, + }, + Days: make([]signInRewardDaySnapshot, 0, len(dayRows)), + } + for _, row := range dayRows { + result.Days = append(result.Days, signInRewardDaySnapshot{ + ID: row.ID, + DayIndex: row.DayIndex, + GoldAmount: row.GoldAmount, + RewardGroupID: row.RewardGroupID, + RewardGroupName: strings.TrimSpace(row.RewardGroupName), + }) + } + fillMissingSignInRewardDays(&result.Days) + return result, nil +} + +func buildDefaultSignInRewardDays() []SignInRewardDayPayload { + days := make([]SignInRewardDayPayload, 0, signInRewardCycleLength) + for dayIndex := 1; dayIndex <= signInRewardCycleLength; dayIndex++ { + days = append(days, SignInRewardDayPayload{ + DayIndex: dayIndex, + RewardItems: []SignInRewardRewardItem{}, + }) + } + return days +} + +func buildAdminDayPayloads(days []signInRewardDaySnapshot) []SignInRewardDayPayload { + payloads := make([]SignInRewardDayPayload, 0, len(days)) + for _, item := range days { + payloads = append(payloads, SignInRewardDayPayload{ + DayIndex: item.DayIndex, + GoldAmount: item.GoldAmount, + RewardGroupID: item.RewardGroupID, + RewardGroupName: item.RewardGroupName, + RewardItems: []SignInRewardRewardItem{}, + }) + } + return payloads +} + +func fillMissingSignInRewardDays(days *[]signInRewardDaySnapshot) { + dayMap := make(map[int]signInRewardDaySnapshot, len(*days)) + for _, item := range *days { + dayMap[item.DayIndex] = item + } + filled := make([]signInRewardDaySnapshot, 0, signInRewardCycleLength) + for dayIndex := 1; dayIndex <= signInRewardCycleLength; dayIndex++ { + if item, ok := dayMap[dayIndex]; ok { + filled = append(filled, item) + continue + } + filled = append(filled, signInRewardDaySnapshot{DayIndex: dayIndex}) + } + *days = filled +} + +func formatSignInRewardDateTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} diff --git a/internal/service/signinreward/signinreward_test.go b/internal/service/signinreward/signinreward_test.go new file mode 100644 index 0000000..fb4b930 --- /dev/null +++ b/internal/service/signinreward/signinreward_test.go @@ -0,0 +1,66 @@ +package signinreward + +import "testing" + +func TestNormalizeSaveDays(t *testing.T) { + validDays := []SignInRewardDayConfigInput{ + {DayIndex: 7, GoldAmount: 70}, + {DayIndex: 1, GoldAmount: 10}, + {DayIndex: 3, GoldAmount: 30}, + {DayIndex: 2, GoldAmount: 20}, + {DayIndex: 4, GoldAmount: 40}, + {DayIndex: 5, GoldAmount: 50}, + {DayIndex: 6, GoldAmount: 60}, + } + + normalized, err := normalizeSaveDays(validDays, true) + if err != nil { + t.Fatalf("normalizeSaveDays() error = %v", err) + } + for index, item := range normalized { + expectedDay := index + 1 + if item.DayIndex != expectedDay { + t.Fatalf("normalized[%d].DayIndex = %d, want %d", index, item.DayIndex, expectedDay) + } + } + + t.Run("enabled config rejects empty reward day", func(t *testing.T) { + days := append([]SignInRewardDayConfigInput(nil), validDays...) + days[0].GoldAmount = 0 + if _, err := normalizeSaveDays(days, true); err == nil { + t.Fatalf("normalizeSaveDays() error = nil, want error") + } + }) + + t.Run("disabled config allows empty reward day", func(t *testing.T) { + days := append([]SignInRewardDayConfigInput(nil), validDays...) + days[0].GoldAmount = 0 + if _, err := normalizeSaveDays(days, false); err != nil { + t.Fatalf("normalizeSaveDays() error = %v, want nil", err) + } + }) +} + +func TestSignInRewardCycleHelpers(t *testing.T) { + testCases := []struct { + streak int + nextDayIndex int + cycleProgress int + }{ + {streak: 0, nextDayIndex: 1, cycleProgress: 0}, + {streak: 1, nextDayIndex: 2, cycleProgress: 1}, + {streak: 6, nextDayIndex: 7, cycleProgress: 6}, + {streak: 7, nextDayIndex: 1, cycleProgress: 7}, + {streak: 8, nextDayIndex: 2, cycleProgress: 1}, + {streak: 14, nextDayIndex: 1, cycleProgress: 7}, + } + + for _, testCase := range testCases { + if got := nextSignInRewardDayIndex(testCase.streak); got != testCase.nextDayIndex { + t.Fatalf("nextSignInRewardDayIndex(%d) = %d, want %d", testCase.streak, got, testCase.nextDayIndex) + } + if got := streakToCycleProgress(testCase.streak); got != testCase.cycleProgress { + t.Fatalf("streakToCycleProgress(%d) = %d, want %d", testCase.streak, got, testCase.cycleProgress) + } + } +} diff --git a/internal/service/signinreward/types.go b/internal/service/signinreward/types.go new file mode 100644 index 0000000..da5dde4 --- /dev/null +++ b/internal/service/signinreward/types.go @@ -0,0 +1,189 @@ +package signinreward + +import ( + "context" + "strings" + "time" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + signInRewardCycleLength = 7 + + signInRewardStatusPending = "PENDING" + signInRewardStatusSuccess = "SUCCESS" + signInRewardStatusFailed = "FAILED" + + signInRewardGoldOrigin = "SIGN_IN_REWARD" + signInRewardGoldDesc = "Sign-in reward" + signInRewardPropsOrigin = "SIGN_IN_REWARD" + + signInRewardDefaultTimezone = "Asia/Shanghai" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type signInRewardDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +type signInRewardGateway interface { + ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error + ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) + GrantProps(ctx context.Context, req integration.GrantPropsRequest) error + GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error) +} + +type signInRewardPorts struct { + DB signInRewardDB + Redis redis.Cmdable +} + +// SignInRewardService 负责签到奖励配置管理、签到状态查询和奖励发放。 +type SignInRewardService struct { + cfg config.Config + repo signInRewardPorts + java signInRewardGateway +} + +// NewSignInRewardService 创建签到奖励服务实例。 +func NewSignInRewardService(cfg config.Config, db signInRewardDB, cache redis.Cmdable, javaGateway signInRewardGateway) *SignInRewardService { + return &SignInRewardService{ + cfg: cfg, + repo: signInRewardPorts{DB: db, Redis: cache}, + java: javaGateway, + } +} + +// SaveSignInRewardConfigRequest 是后台保存签到奖励配置的入参。 +type SaveSignInRewardConfigRequest struct { + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + Days []SignInRewardDayConfigInput `json:"days"` +} + +// SignInRewardDayConfigInput 是单日配置的入参。 +type SignInRewardDayConfigInput struct { + DayIndex int `json:"dayIndex"` + GoldAmount int64 `json:"goldAmount"` + RewardGroupID *int64 `json:"rewardGroupId"` + RewardGroupName string `json:"rewardGroupName"` +} + +// SignInRewardConfigResponse 是后台配置页读取结果。 +type SignInRewardConfigResponse struct { + Configured bool `json:"configured"` + ID int64 `json:"id"` + SysOrigin string `json:"sysOrigin"` + Enabled bool `json:"enabled"` + Timezone string `json:"timezone"` + CycleDays int `json:"cycleDays"` + Days []SignInRewardDayPayload `json:"days"` + UpdateTime string `json:"updateTime,omitempty"` +} + +// SignInRewardStatusResponse 是 app 侧查询配置和状态的返回体。 +type SignInRewardStatusResponse struct { + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + Configured bool `json:"configured"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Timezone string `json:"timezone"` + CycleDays int `json:"cycleDays"` + CurrentDate string `json:"currentDate"` + SignedToday bool `json:"signedToday"` + CurrentStreak int `json:"currentStreak"` + NextDayIndex int `json:"nextDayIndex"` + Days []SignInRewardDayPayload `json:"days"` +} + +// SignInRewardCheckInResponse 是签到接口返回体。 +type SignInRewardCheckInResponse struct { + Success bool `json:"success"` + AlreadySigned bool `json:"alreadySigned"` + UserID int64 `json:"userId"` + SysOrigin string `json:"sysOrigin"` + ClaimDate string `json:"claimDate"` + DayIndex int `json:"dayIndex"` + StreakCount int `json:"streakCount"` + GoldAmount int64 `json:"goldAmount"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + RewardGroupName string `json:"rewardGroupName,omitempty"` + RewardItems []SignInRewardRewardItem `json:"rewardItems"` + Status string `json:"status"` +} + +// SignInRewardDayPayload 是单日配置对外展示结构。 +type SignInRewardDayPayload struct { + DayIndex int `json:"dayIndex"` + GoldAmount int64 `json:"goldAmount"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + RewardGroupName string `json:"rewardGroupName,omitempty"` + RewardItems []SignInRewardRewardItem `json:"rewardItems"` + Signed bool `json:"signed"` + TodayTarget bool `json:"todayTarget"` +} + +// SignInRewardRewardItem 是奖励组内的单个物品展示项。 +type SignInRewardRewardItem struct { + ID int64 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Quantity int64 `json:"quantity"` + Cover string `json:"cover"` + Remark string `json:"remark,omitempty"` +} + +type signInRewardConfigBundle struct { + Config *signInRewardConfigSnapshot + Days []signInRewardDaySnapshot +} + +type signInRewardConfigSnapshot struct { + ID int64 + SysOrigin string + Enabled bool + Timezone string + UpdateTime time.Time +} + +type signInRewardDaySnapshot struct { + ID int64 + DayIndex int + GoldAmount int64 + RewardGroupID *int64 + RewardGroupName string +} + +type signInRewardRuntimeState struct { + Today string + SignedToday bool + CurrentStreak int + NextDayIndex int + CycleProgress int + LatestSuccessID int64 +} + +func normalizeSignInRewardSysOrigin(sysOrigin string) string { + return strings.ToUpper(strings.TrimSpace(sysOrigin)) +} + +func normalizeSignInRewardTimezone(timezone string) string { + timezone = strings.TrimSpace(timezone) + if timezone == "" { + return signInRewardDefaultTimezone + } + return timezone +} diff --git a/migrations/006_sign_in_reward.sql b/migrations/006_sign_in_reward.sql new file mode 100644 index 0000000..0eda7e6 --- /dev/null +++ b/migrations/006_sign_in_reward.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS `sign_in_reward_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', + `timezone` varchar(64) NOT NULL DEFAULT 'Asia/Shanghai' COMMENT '签到时区', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sign_in_reward_sys_origin` (`sys_origin`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='签到奖励主配置'; + +CREATE TABLE IF NOT EXISTS `sign_in_reward_day_config` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '主配置ID', + `day_index` int NOT NULL COMMENT '循环日序号 1-7', + `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币奖励数量', + `reward_group_id` bigint DEFAULT NULL COMMENT '奖励组ID', + `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励组名称快照', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sign_in_reward_day_config` (`config_id`, `day_index`), + KEY `idx_sign_in_reward_day_config` (`config_id`, `day_index`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='签到奖励7天循环配置'; + +CREATE TABLE IF NOT EXISTS `sign_in_reward_log` ( + `id` bigint NOT NULL COMMENT '主键ID', + `event_id` varchar(128) NOT NULL COMMENT '幂等事件ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `user_id` bigint NOT NULL COMMENT '用户ID', + `claim_date` varchar(10) NOT NULL DEFAULT '' COMMENT '签到日期 YYYY-MM-DD', + `day_index` int NOT NULL DEFAULT '0' COMMENT '本次命中的循环日序号', + `streak_count` int NOT NULL DEFAULT '0' COMMENT '连续签到天数', + `gold_amount` bigint NOT NULL DEFAULT '0' COMMENT '金币奖励数量', + `reward_group_id` bigint DEFAULT NULL COMMENT '奖励组ID', + `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励组名称快照', + `status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '处理状态', + `error_message` varchar(255) NOT NULL DEFAULT '' COMMENT '错误信息', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sign_in_reward_event` (`event_id`), + UNIQUE KEY `uk_sign_in_reward_user_day` (`sys_origin`, `user_id`, `claim_date`), + KEY `idx_sign_in_reward_user_time` (`sys_origin`, `user_id`), + KEY `idx_sign_in_reward_claim` (`sys_origin`, `claim_date`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='签到奖励发放日志';