feat: add sign in reward flow

This commit is contained in:
hy001 2026-04-20 18:31:47 +08:00
parent 0f469f1cd7
commit 27c14d5960
9 changed files with 1207 additions and 1 deletions

View File

@ -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{

View 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" }

View File

@ -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"})
}) })
} }

View 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)
})
}

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

View 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")
}

View 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)
}
}
}

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

View 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='签到奖励发放日志';