feat: add sign in reward admin flow

This commit is contained in:
hy001 2026-04-20 22:07:10 +08:00
parent 2ebdd680f5
commit df9a779b62
9 changed files with 317 additions and 67 deletions

View File

@ -118,6 +118,7 @@ type LuckyGiftProviderConfig struct {
func Load() Config { func Load() Config {
javaAppBaseURL := getEnvAny([]string{"CHATAPP_JAVA_APP_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, "http://127.0.0.1:2400") javaAppBaseURL := getEnvAny([]string{"CHATAPP_JAVA_APP_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, "http://127.0.0.1:2400")
consoleBaseURL := getEnvAny([]string{"CHATAPP_JAVA_CONSOLE_BASE_URL", "JAVA_CONSOLE_BASE_URL"}, "http://127.0.0.1:2700/console")
return Config{ return Config{
HTTP: HTTPConfig{ HTTP: HTTPConfig{
@ -134,10 +135,10 @@ func Load() Config {
}, },
Java: JavaConfig{ Java: JavaConfig{
AuthBaseURL: getEnvAny([]string{"CHATAPP_JAVA_AUTH_BASE_URL", "INVITE_JAVA_AUTH_BASE_URL"}, "http://127.0.0.1:2100"), AuthBaseURL: getEnvAny([]string{"CHATAPP_JAVA_AUTH_BASE_URL", "INVITE_JAVA_AUTH_BASE_URL"}, "http://127.0.0.1:2100"),
ConsoleBaseURL: getEnvAny([]string{"CHATAPP_JAVA_CONSOLE_BASE_URL", "JAVA_CONSOLE_BASE_URL"}, "http://127.0.0.1:2700/console"), ConsoleBaseURL: consoleBaseURL,
DeviceBaseURL: getEnvAny([]string{"CHATAPP_JAVA_DEVICE_BASE_URL", "INVITE_JAVA_DEVICE_BASE_URL"}, "http://127.0.0.1:2400"), DeviceBaseURL: getEnvAny([]string{"CHATAPP_JAVA_DEVICE_BASE_URL", "INVITE_JAVA_DEVICE_BASE_URL"}, "http://127.0.0.1:2400"),
AppBaseURL: javaAppBaseURL, AppBaseURL: javaAppBaseURL,
BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, "http://127.0.0.1:2200"), BridgeBaseURL: getEnvAny([]string{"CHATAPP_JAVA_BRIDGE_BASE_URL", "INVITE_JAVA_BRIDGE_BASE_URL"}, consoleBaseURL),
ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"), ExternalBaseURL: getEnvAny([]string{"CHATAPP_JAVA_EXTERNAL_BASE_URL", "INVITE_JAVA_EXTERNAL_BASE_URL"}, "http://127.0.0.1:3000"),
OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL), OtherBaseURL: getEnvAny([]string{"CHATAPP_JAVA_OTHER_BASE_URL", "GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"}, javaAppBaseURL),
WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"), WalletBaseURL: getEnvAny([]string{"CHATAPP_JAVA_WALLET_BASE_URL", "GAME_JAVA_WALLET_BASE_URL"}, "http://127.0.0.1:2300"),

View File

@ -296,11 +296,11 @@ func (c *Client) EnsureInviteCode(ctx context.Context, authorization string) (In
} }
func (c *Client) GrantGold(ctx context.Context, req GrantGoldRequest) error { func (c *Client) GrantGold(ctx context.Context, req GrantGoldRequest) error {
return c.postJSON(ctx, c.cfg.Java.BridgeBaseURL+"/internal/resident-activity/invite/grant-gold", req, nil, nil) return c.postJSON(ctx, c.rewardBridgeBaseURL()+"/internal/resident-activity/invite/grant-gold", req, nil, nil)
} }
func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error { func (c *Client) GrantProps(ctx context.Context, req GrantPropsRequest) error {
return c.postJSON(ctx, c.cfg.Java.BridgeBaseURL+"/internal/resident-activity/invite/grant-props", req, nil, nil) return c.postJSON(ctx, c.rewardBridgeBaseURL()+"/internal/resident-activity/invite/grant-props", req, nil, nil)
} }
func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error { func (c *Client) SendActivityReward(ctx context.Context, req SendActivityRewardRequest) error {
@ -336,6 +336,13 @@ func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile,
return resp.Body, nil return resp.Body, nil
} }
func (c *Client) rewardBridgeBaseURL() string {
if baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.Java.ConsoleBaseURL), "/"); baseURL != "" {
return baseURL
}
return strings.TrimRight(strings.TrimSpace(c.cfg.Java.BridgeBaseURL), "/")
}
func (c *Client) GetUserLanguage(ctx context.Context, userID int64) (string, error) { func (c *Client) GetUserLanguage(ctx context.Context, userID int64) (string, error) {
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getLanguage?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getLanguage?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
var resp resultResponse[string] var resp resultResponse[string]

View File

@ -3,6 +3,8 @@ package router
import ( import (
"chatapp3-golang/internal/service/signinreward" "chatapp3-golang/internal/service/signinreward"
"net/http" "net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -54,6 +56,24 @@ func registerSignInRewardRoutes(
} }
writeOK(c, resp) writeOK(c, resp)
}) })
residentGroup.GET("/record/page", func(c *gin.Context) {
cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20")))
resp, err := signInRewardService.PageRecords(
c.Request.Context(),
c.Query("sysOrigin"),
c.Query("claimDate"),
c.Query("status"),
signinreward.ParseSignInRewardUserID(c.Query("userId")),
cursor,
limit,
)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
residentGroup.POST("/config/save", func(c *gin.Context) { residentGroup.POST("/config/save", func(c *gin.Context) {
var req signinreward.SaveSignInRewardConfigRequest var req signinreward.SaveSignInRewardConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -0,0 +1,159 @@
package signinreward
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
)
type signInRewardRecordRow struct {
model.SignInRewardLog
Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"`
CountryCode string `gorm:"column:country_code"`
CountryName string `gorm:"column:country_name"`
}
// PageRecords 返回后台签到记录分页数据。
func (s *SignInRewardService) PageRecords(
ctx context.Context,
sysOrigin string,
claimDate string,
status string,
userID int64,
cursor int,
limit int,
) (*SignInRewardRecordPageResponse, error) {
sysOrigin = normalizeSignInRewardSysOrigin(sysOrigin)
if sysOrigin == "" {
return nil, NewAppError(http.StatusBadRequest, "bad_request", "sysOrigin is required")
}
bundle, err := s.loadConfigBundle(ctx, sysOrigin)
if err != nil {
return nil, err
}
location := resolveSignInRewardLocation(signInRewardDefaultTimezone)
if bundle.Config != nil {
location = resolveSignInRewardLocation(bundle.Config.Timezone)
}
resolvedDate := normalizeSignInRewardClaimDate(claimDate, location)
page, size := normalizeSignInRewardPage(cursor, limit)
query := s.newRecordBaseQuery(ctx, sysOrigin, resolvedDate)
if strings.TrimSpace(status) != "" {
query = query.Where("log.status = ?", strings.ToUpper(strings.TrimSpace(status)))
}
if userID > 0 {
query = query.Where("log.user_id = ?", userID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var signCount int64
if err := s.newRecordBaseQuery(ctx, sysOrigin, resolvedDate).Count(&signCount).Error; err != nil {
return nil, err
}
var successCount int64
if err := s.newRecordBaseQuery(ctx, sysOrigin, resolvedDate).
Where("log.status = ?", signInRewardStatusSuccess).
Count(&successCount).Error; err != nil {
return nil, err
}
records := make([]SignInRewardRecordView, 0)
if total > 0 {
var rows []signInRewardRecordRow
if err := query.
Select("log.*, user.account, user.user_avatar, user.user_nickname, user.country_code, user.country_name").
Joins("LEFT JOIN user_base_info AS user ON user.id = log.user_id").
Order("log.create_time desc").
Order("log.id desc").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
return nil, err
}
records = make([]SignInRewardRecordView, 0, len(rows))
for _, row := range rows {
records = append(records, SignInRewardRecordView{
ID: row.ID,
UserID: row.UserID,
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
CountryCode: row.CountryCode,
CountryName: row.CountryName,
ClaimDate: row.ClaimDate,
DayIndex: row.DayIndex,
StreakCount: row.StreakCount,
RewardGroupID: row.RewardGroupID,
RewardGroupName: row.RewardGroupName,
Status: strings.ToUpper(strings.TrimSpace(row.Status)),
ErrorMessage: strings.TrimSpace(row.ErrorMessage),
CreateTime: formatSignInRewardDateTime(row.CreateTime),
UpdateTime: formatSignInRewardDateTime(row.UpdateTime),
})
}
}
return &SignInRewardRecordPageResponse{
Date: resolvedDate,
Timezone: location.String(),
SignCount: signCount,
SuccessCount: successCount,
Records: records,
Total: total,
}, nil
}
func (s *SignInRewardService) newRecordBaseQuery(ctx context.Context, sysOrigin string, claimDate string) *gorm.DB {
return s.repo.DB.WithContext(ctx).
Table("sign_in_reward_log AS log").
Where("log.sys_origin = ? AND log.claim_date = ?", sysOrigin, claimDate)
}
func normalizeSignInRewardClaimDate(claimDate string, location *time.Location) string {
claimDate = strings.TrimSpace(claimDate)
if claimDate != "" {
return claimDate
}
return time.Now().In(location).Format("2006-01-02")
}
func normalizeSignInRewardPage(cursor int, limit int) (int, int) {
if cursor <= 0 {
cursor = 1
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
return cursor, limit
}
// ParseSignInRewardUserID 解析后台分页查询里传入的 userId。
func ParseSignInRewardUserID(raw string) int64 {
value := strings.TrimSpace(raw)
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0
}
return parsed
}

View File

@ -42,7 +42,6 @@ func (s *SignInRewardService) GetStatus(ctx context.Context, user AuthUser) (*Si
for _, day := range bundle.Days { for _, day := range bundle.Days {
days = append(days, SignInRewardDayPayload{ days = append(days, SignInRewardDayPayload{
DayIndex: day.DayIndex, DayIndex: day.DayIndex,
GoldAmount: day.GoldAmount,
RewardGroupID: day.RewardGroupID, RewardGroupID: day.RewardGroupID,
RewardGroupName: day.RewardGroupName, RewardGroupName: day.RewardGroupName,
RewardItems: itemsMap[rewardGroupKey(day.RewardGroupID)], RewardItems: itemsMap[rewardGroupKey(day.RewardGroupID)],
@ -115,7 +114,7 @@ func (s *SignInRewardService) CheckIn(ctx context.Context, user AuthUser) (*Sign
if err != nil { if err != nil {
return nil, err return nil, err
} }
if dayConfig.GoldAmount <= 0 && dayConfig.RewardGroupID == nil { if dayConfig.RewardGroupID == nil {
return nil, NewAppError(http.StatusBadRequest, "sign_in_reward_empty", "reward for current day is not configured") return nil, NewAppError(http.StatusBadRequest, "sign_in_reward_empty", "reward for current day is not configured")
} }
@ -263,9 +262,6 @@ func (s *SignInRewardService) ensureTodayLog(
if existing.StreakCount <= 0 { if existing.StreakCount <= 0 {
existing.StreakCount = state.CurrentStreak + 1 existing.StreakCount = state.CurrentStreak + 1
} }
if existing.GoldAmount <= 0 {
existing.GoldAmount = day.GoldAmount
}
if existing.RewardGroupID == nil { if existing.RewardGroupID == nil {
existing.RewardGroupID = day.RewardGroupID existing.RewardGroupID = day.RewardGroupID
existing.RewardGroupName = day.RewardGroupName existing.RewardGroupName = day.RewardGroupName
@ -276,7 +272,7 @@ func (s *SignInRewardService) ensureTodayLog(
Updates(map[string]any{ Updates(map[string]any{
"day_index": existing.DayIndex, "day_index": existing.DayIndex,
"streak_count": existing.StreakCount, "streak_count": existing.StreakCount,
"gold_amount": existing.GoldAmount, "gold_amount": 0,
"reward_group_id": existing.RewardGroupID, "reward_group_id": existing.RewardGroupID,
"reward_group_name": strings.TrimSpace(existing.RewardGroupName), "reward_group_name": strings.TrimSpace(existing.RewardGroupName),
"status": signInRewardStatusPending, "status": signInRewardStatusPending,
@ -303,7 +299,7 @@ func (s *SignInRewardService) ensureTodayLog(
ClaimDate: state.Today, ClaimDate: state.Today,
DayIndex: day.DayIndex, DayIndex: day.DayIndex,
StreakCount: state.CurrentStreak + 1, StreakCount: state.CurrentStreak + 1,
GoldAmount: day.GoldAmount, GoldAmount: 0,
RewardGroupID: day.RewardGroupID, RewardGroupID: day.RewardGroupID,
RewardGroupName: day.RewardGroupName, RewardGroupName: day.RewardGroupName,
Status: signInRewardStatusPending, Status: signInRewardStatusPending,
@ -325,29 +321,6 @@ func (s *SignInRewardService) dispatchCheckInReward(
user AuthUser, user AuthUser,
logRecord *model.SignInRewardLog, logRecord *model.SignInRewardLog,
) error { ) 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 logRecord.RewardGroupID != nil {
if err := s.java.GrantProps(ctx, integration.GrantPropsRequest{ if err := s.java.GrantProps(ctx, integration.GrantPropsRequest{
TrackID: logRecord.ID, TrackID: logRecord.ID,
@ -422,7 +395,6 @@ func (s *SignInRewardService) buildCheckInResponse(
ClaimDate: logRecord.ClaimDate, ClaimDate: logRecord.ClaimDate,
DayIndex: logRecord.DayIndex, DayIndex: logRecord.DayIndex,
StreakCount: logRecord.StreakCount, StreakCount: logRecord.StreakCount,
GoldAmount: logRecord.GoldAmount,
RewardGroupID: logRecord.RewardGroupID, RewardGroupID: logRecord.RewardGroupID,
RewardGroupName: logRecord.RewardGroupName, RewardGroupName: logRecord.RewardGroupName,
RewardItems: rewardItems, RewardItems: rewardItems,

View File

@ -102,8 +102,8 @@ func (s *SignInRewardService) SaveConfig(ctx context.Context, req SaveSignInRewa
ID: dayID, ID: dayID,
ConfigID: configRow.ID, ConfigID: configRow.ID,
DayIndex: item.DayIndex, DayIndex: item.DayIndex,
GoldAmount: item.GoldAmount, GoldAmount: 0,
RewardGroupID: item.RewardGroupID, RewardGroupID: item.RewardGroupID.Int64Ptr(),
RewardGroupName: strings.TrimSpace(item.RewardGroupName), RewardGroupName: strings.TrimSpace(item.RewardGroupName),
CreateTime: now, CreateTime: now,
UpdateTime: now, UpdateTime: now,
@ -139,15 +139,11 @@ func normalizeSaveDays(days []SignInRewardDayConfigInput, enabled bool) ([]SignI
return nil, NewAppError(http.StatusBadRequest, "duplicate_day_index", "dayIndex must be unique") return nil, NewAppError(http.StatusBadRequest, "duplicate_day_index", "dayIndex must be unique")
} }
seen[item.DayIndex] = struct{}{} seen[item.DayIndex] = struct{}{}
if item.GoldAmount < 0 { if enabled && item.RewardGroupID.Int64Ptr() == nil {
return nil, NewAppError(http.StatusBadRequest, "invalid_gold_amount", "goldAmount must be greater than or equal to 0") return nil, NewAppError(http.StatusBadRequest, "empty_day_reward", "each enabled day must configure rewardGroupId")
}
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{ normalized = append(normalized, SignInRewardDayConfigInput{
DayIndex: item.DayIndex, DayIndex: item.DayIndex,
GoldAmount: item.GoldAmount,
RewardGroupID: item.RewardGroupID, RewardGroupID: item.RewardGroupID,
RewardGroupName: strings.TrimSpace(item.RewardGroupName), RewardGroupName: strings.TrimSpace(item.RewardGroupName),
}) })
@ -195,7 +191,6 @@ func (s *SignInRewardService) loadConfigBundle(ctx context.Context, sysOrigin st
result.Days = append(result.Days, signInRewardDaySnapshot{ result.Days = append(result.Days, signInRewardDaySnapshot{
ID: row.ID, ID: row.ID,
DayIndex: row.DayIndex, DayIndex: row.DayIndex,
GoldAmount: row.GoldAmount,
RewardGroupID: row.RewardGroupID, RewardGroupID: row.RewardGroupID,
RewardGroupName: strings.TrimSpace(row.RewardGroupName), RewardGroupName: strings.TrimSpace(row.RewardGroupName),
}) })
@ -220,7 +215,6 @@ func buildAdminDayPayloads(days []signInRewardDaySnapshot) []SignInRewardDayPayl
for _, item := range days { for _, item := range days {
payloads = append(payloads, SignInRewardDayPayload{ payloads = append(payloads, SignInRewardDayPayload{
DayIndex: item.DayIndex, DayIndex: item.DayIndex,
GoldAmount: item.GoldAmount,
RewardGroupID: item.RewardGroupID, RewardGroupID: item.RewardGroupID,
RewardGroupName: item.RewardGroupName, RewardGroupName: item.RewardGroupName,
RewardItems: []SignInRewardRewardItem{}, RewardItems: []SignInRewardRewardItem{},

View File

@ -4,13 +4,13 @@ import "testing"
func TestNormalizeSaveDays(t *testing.T) { func TestNormalizeSaveDays(t *testing.T) {
validDays := []SignInRewardDayConfigInput{ validDays := []SignInRewardDayConfigInput{
{DayIndex: 7, GoldAmount: 70}, {DayIndex: 7, RewardGroupID: mustFlexibleOptionalInt64(70)},
{DayIndex: 1, GoldAmount: 10}, {DayIndex: 1, RewardGroupID: mustFlexibleOptionalInt64(10)},
{DayIndex: 3, GoldAmount: 30}, {DayIndex: 3, RewardGroupID: mustFlexibleOptionalInt64(30)},
{DayIndex: 2, GoldAmount: 20}, {DayIndex: 2, RewardGroupID: mustFlexibleOptionalInt64(20)},
{DayIndex: 4, GoldAmount: 40}, {DayIndex: 4, RewardGroupID: mustFlexibleOptionalInt64(40)},
{DayIndex: 5, GoldAmount: 50}, {DayIndex: 5, RewardGroupID: mustFlexibleOptionalInt64(50)},
{DayIndex: 6, GoldAmount: 60}, {DayIndex: 6, RewardGroupID: mustFlexibleOptionalInt64(60)},
} }
normalized, err := normalizeSaveDays(validDays, true) normalized, err := normalizeSaveDays(validDays, true)
@ -26,7 +26,7 @@ func TestNormalizeSaveDays(t *testing.T) {
t.Run("enabled config rejects empty reward day", func(t *testing.T) { t.Run("enabled config rejects empty reward day", func(t *testing.T) {
days := append([]SignInRewardDayConfigInput(nil), validDays...) days := append([]SignInRewardDayConfigInput(nil), validDays...)
days[0].GoldAmount = 0 days[0].RewardGroupID = flexibleOptionalInt64{}
if _, err := normalizeSaveDays(days, true); err == nil { if _, err := normalizeSaveDays(days, true); err == nil {
t.Fatalf("normalizeSaveDays() error = nil, want error") t.Fatalf("normalizeSaveDays() error = nil, want error")
} }
@ -34,13 +34,47 @@ func TestNormalizeSaveDays(t *testing.T) {
t.Run("disabled config allows empty reward day", func(t *testing.T) { t.Run("disabled config allows empty reward day", func(t *testing.T) {
days := append([]SignInRewardDayConfigInput(nil), validDays...) days := append([]SignInRewardDayConfigInput(nil), validDays...)
days[0].GoldAmount = 0 days[0].RewardGroupID = flexibleOptionalInt64{}
if _, err := normalizeSaveDays(days, false); err != nil { if _, err := normalizeSaveDays(days, false); err != nil {
t.Fatalf("normalizeSaveDays() error = %v, want nil", err) t.Fatalf("normalizeSaveDays() error = %v, want nil", err)
} }
}) })
} }
func TestFlexibleOptionalInt64(t *testing.T) {
testCases := []struct {
name string
input string
wantNil bool
want int64
}{
{name: "null", input: "null", wantNil: true},
{name: "empty string", input: `""`, wantNil: true},
{name: "number", input: "123", want: 123},
{name: "string number", input: `"456"`, want: 456},
}
for _, testCase := range testCases {
var value flexibleOptionalInt64
if err := value.UnmarshalJSON([]byte(testCase.input)); err != nil {
t.Fatalf("%s: UnmarshalJSON() error = %v", testCase.name, err)
}
if testCase.wantNil {
if value.Int64Ptr() != nil {
t.Fatalf("%s: Int64Ptr() = %v, want nil", testCase.name, *value.Int64Ptr())
}
continue
}
if value.Int64Ptr() == nil || *value.Int64Ptr() != testCase.want {
t.Fatalf("%s: Int64Ptr() = %v, want %d", testCase.name, value.Int64Ptr(), testCase.want)
}
}
}
func mustFlexibleOptionalInt64(value int64) flexibleOptionalInt64 {
return flexibleOptionalInt64{value: &value}
}
func TestSignInRewardCycleHelpers(t *testing.T) { func TestSignInRewardCycleHelpers(t *testing.T) {
testCases := []struct { testCases := []struct {
streak int streak int

View File

@ -2,6 +2,9 @@ package signinreward
import ( import (
"context" "context"
"encoding/json"
"fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -20,11 +23,9 @@ const (
signInRewardStatusSuccess = "SUCCESS" signInRewardStatusSuccess = "SUCCESS"
signInRewardStatusFailed = "FAILED" signInRewardStatusFailed = "FAILED"
signInRewardGoldOrigin = "SIGN_IN_REWARD"
signInRewardGoldDesc = "Sign-in reward"
signInRewardPropsOrigin = "SIGN_IN_REWARD" signInRewardPropsOrigin = "SIGN_IN_REWARD"
signInRewardDefaultTimezone = "Asia/Shanghai" signInRewardDefaultTimezone = "Asia/Riyadh"
) )
type AppError = common.AppError type AppError = common.AppError
@ -37,8 +38,6 @@ type signInRewardDB interface {
} }
type signInRewardGateway interface { 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 GrantProps(ctx context.Context, req integration.GrantPropsRequest) error
GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error) GetRewardGroupDetail(ctx context.Context, groupID int64) (integration.RewardGroupDetail, error)
} }
@ -74,10 +73,9 @@ type SaveSignInRewardConfigRequest struct {
// SignInRewardDayConfigInput 是单日配置的入参。 // SignInRewardDayConfigInput 是单日配置的入参。
type SignInRewardDayConfigInput struct { type SignInRewardDayConfigInput struct {
DayIndex int `json:"dayIndex"` DayIndex int `json:"dayIndex"`
GoldAmount int64 `json:"goldAmount"` RewardGroupID flexibleOptionalInt64 `json:"rewardGroupId"`
RewardGroupID *int64 `json:"rewardGroupId"` RewardGroupName string `json:"rewardGroupName"`
RewardGroupName string `json:"rewardGroupName"`
} }
// SignInRewardConfigResponse 是后台配置页读取结果。 // SignInRewardConfigResponse 是后台配置页读取结果。
@ -117,17 +115,45 @@ type SignInRewardCheckInResponse struct {
ClaimDate string `json:"claimDate"` ClaimDate string `json:"claimDate"`
DayIndex int `json:"dayIndex"` DayIndex int `json:"dayIndex"`
StreakCount int `json:"streakCount"` StreakCount int `json:"streakCount"`
GoldAmount int64 `json:"goldAmount"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"` RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"` RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []SignInRewardRewardItem `json:"rewardItems"` RewardItems []SignInRewardRewardItem `json:"rewardItems"`
Status string `json:"status"` Status string `json:"status"`
} }
// SignInRewardRecordPageResponse 是后台签到记录分页返回体。
type SignInRewardRecordPageResponse struct {
Date string `json:"date"`
Timezone string `json:"timezone"`
SignCount int64 `json:"signCount"`
SuccessCount int64 `json:"successCount"`
Records []SignInRewardRecordView `json:"records"`
Total int64 `json:"total"`
}
// SignInRewardRecordView 是后台签到记录单条展示结构。
type SignInRewardRecordView struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
Account string `json:"account,omitempty"`
UserAvatar string `json:"userAvatar,omitempty"`
UserNickname string `json:"userNickname,omitempty"`
CountryCode string `json:"countryCode,omitempty"`
CountryName string `json:"countryName,omitempty"`
ClaimDate string `json:"claimDate"`
DayIndex int `json:"dayIndex"`
StreakCount int `json:"streakCount"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"`
Status string `json:"status"`
ErrorMessage string `json:"errorMessage,omitempty"`
CreateTime string `json:"createTime,omitempty"`
UpdateTime string `json:"updateTime,omitempty"`
}
// SignInRewardDayPayload 是单日配置对外展示结构。 // SignInRewardDayPayload 是单日配置对外展示结构。
type SignInRewardDayPayload struct { type SignInRewardDayPayload struct {
DayIndex int `json:"dayIndex"` DayIndex int `json:"dayIndex"`
GoldAmount int64 `json:"goldAmount"`
RewardGroupID *int64 `json:"rewardGroupId,omitempty"` RewardGroupID *int64 `json:"rewardGroupId,omitempty"`
RewardGroupName string `json:"rewardGroupName,omitempty"` RewardGroupName string `json:"rewardGroupName,omitempty"`
RewardItems []SignInRewardRewardItem `json:"rewardItems"` RewardItems []SignInRewardRewardItem `json:"rewardItems"`
@ -162,7 +188,6 @@ type signInRewardConfigSnapshot struct {
type signInRewardDaySnapshot struct { type signInRewardDaySnapshot struct {
ID int64 ID int64
DayIndex int DayIndex int
GoldAmount int64
RewardGroupID *int64 RewardGroupID *int64
RewardGroupName string RewardGroupName string
} }
@ -187,3 +212,41 @@ func normalizeSignInRewardTimezone(timezone string) string {
} }
return timezone return timezone
} }
type flexibleOptionalInt64 struct {
value *int64
}
func (v *flexibleOptionalInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
switch raw {
case "", "null", `""`:
v.value = nil
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
raw = strings.Trim(raw, `"`)
raw = strings.TrimSpace(raw)
if raw == "" {
v.value = nil
return nil
}
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid int64 value: %w", err)
}
v.value = &parsed
return nil
}
func (v flexibleOptionalInt64) MarshalJSON() ([]byte, error) {
if v.value == nil {
return []byte("null"), nil
}
return json.Marshal(*v.value)
}
func (v flexibleOptionalInt64) Int64Ptr() *int64 {
return v.value
}

View File

@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS `sign_in_reward_config` (
`id` bigint NOT NULL COMMENT '主键ID', `id` bigint NOT NULL COMMENT '主键ID',
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统', `sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用', `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
`timezone` varchar(64) NOT NULL DEFAULT 'Asia/Shanghai' COMMENT '签到时区', `timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '签到时区',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),