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/luckygift"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/weekstar"
"context"
"errors"
@ -30,12 +31,14 @@ func main() {
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
weekStarService := weekstar.NewWeekStarService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
engine := router.NewRouter(app.Config, app.Repository, &app.Gateways, router.Services{
Invite: inviteService,
Baishun: baishunService,
LuckyGift: luckyGiftService,
RegisterReward: registerRewardService,
SignInReward: signInRewardService,
WeekStar: weekStarService,
})
server := &http.Server{

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/luckygift"
"chatapp3-golang/internal/service/registerreward"
"chatapp3-golang/internal/service/signinreward"
"chatapp3-golang/internal/service/weekstar"
"github.com/gin-gonic/gin"
@ -21,6 +22,7 @@ type Services struct {
Baishun *baishun.BaishunService
LuckyGift *luckygift.LuckyGiftService
RegisterReward *registerreward.RegisterRewardService
SignInReward *signinreward.SignInRewardService
WeekStar *weekstar.WeekStarService
}
@ -37,6 +39,7 @@ func NewRouter(
registerHealthRoutes(engine, cfg, repository)
registerInviteRoutes(engine, javaClient, services.Invite)
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
registerWeekStarRoutes(engine, javaClient, services.WeekStar)
registerBaishunRoutes(engine, cfg, javaClient, services.Baishun)
registerLuckyGiftRoutes(engine, cfg, services.LuckyGift)
@ -58,6 +61,6 @@ func registerHealthRoutes(engine *gin.Engine, cfg config.Config, repository *rep
return
}
c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success2"})
c.JSON(http.StatusOK, gin.H{"code": "ok", "message": "success3"})
})
}

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