feat: add sign in reward flow
This commit is contained in:
parent
0f469f1cd7
commit
27c14d5960
@ -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{
|
||||
|
||||
52
internal/model/signinreward_models.go
Normal file
52
internal/model/signinreward_models.go
Normal file
@ -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" }
|
||||
@ -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"})
|
||||
})
|
||||
}
|
||||
|
||||
70
internal/router/sign_in_reward_routes.go
Normal file
70
internal/router/sign_in_reward_routes.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
524
internal/service/signinreward/app.go
Normal file
524
internal/service/signinreward/app.go
Normal file
@ -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]
|
||||
}
|
||||
253
internal/service/signinreward/config.go
Normal file
253
internal/service/signinreward/config.go
Normal file
@ -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")
|
||||
}
|
||||
66
internal/service/signinreward/signinreward_test.go
Normal file
66
internal/service/signinreward/signinreward_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
189
internal/service/signinreward/types.go
Normal file
189
internal/service/signinreward/types.go
Normal file
@ -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
|
||||
}
|
||||
46
migrations/006_sign_in_reward.sql
Normal file
46
migrations/006_sign_in_reward.sql
Normal file
@ -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='签到奖励发放日志';
|
||||
Loading…
x
Reference in New Issue
Block a user