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/invite"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
|
"chatapp3-golang/internal/service/signinreward"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@ -30,12 +31,14 @@ func main() {
|
|||||||
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
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)
|
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)
|
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)
|
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
|
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
|
||||||
Invite: inviteService,
|
Invite: inviteService,
|
||||||
Baishun: baishunService,
|
Baishun: baishunService,
|
||||||
LuckyGift: luckyGiftService,
|
LuckyGift: luckyGiftService,
|
||||||
RegisterReward: registerRewardService,
|
RegisterReward: registerRewardService,
|
||||||
|
SignInReward: signInRewardService,
|
||||||
WeekStar: weekStarService,
|
WeekStar: weekStarService,
|
||||||
})
|
})
|
||||||
server := &http.Server{
|
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/invite"
|
||||||
"chatapp3-golang/internal/service/luckygift"
|
"chatapp3-golang/internal/service/luckygift"
|
||||||
"chatapp3-golang/internal/service/registerreward"
|
"chatapp3-golang/internal/service/registerreward"
|
||||||
|
"chatapp3-golang/internal/service/signinreward"
|
||||||
"chatapp3-golang/internal/service/weekstar"
|
"chatapp3-golang/internal/service/weekstar"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -21,6 +22,7 @@ type Services struct {
|
|||||||
Baishun *baishun.BaishunService
|
Baishun *baishun.BaishunService
|
||||||
LuckyGift *luckygift.LuckyGiftService
|
LuckyGift *luckygift.LuckyGiftService
|
||||||
RegisterReward *registerreward.RegisterRewardService
|
RegisterReward *registerreward.RegisterRewardService
|
||||||
|
SignInReward *signinreward.SignInRewardService
|
||||||
WeekStar *weekstar.WeekStarService
|
WeekStar *weekstar.WeekStarService
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +39,7 @@ func NewRouter(
|
|||||||
registerHealthRoutes(engine, cfg, repository)
|
registerHealthRoutes(engine, cfg, repository)
|
||||||
registerInviteRoutes(engine, javaClient, services.Invite)
|
registerInviteRoutes(engine, javaClient, services.Invite)
|
||||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||||
|
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||||
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
|
||||||
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
|
||||||
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
|
||||||
@ -58,6 +61,6 @@ func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *rep
|
|||||||
return
|
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