2026-04-20 22:07:10 +08:00

497 lines
15 KiB
Go

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,
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.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.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": 0,
"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: 0,
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.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,
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]
}