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] }