diff --git a/internal/router/recharge_reward_routes.go b/internal/router/recharge_reward_routes.go index 965982d..950270e 100644 --- a/internal/router/recharge_reward_routes.go +++ b/internal/router/recharge_reward_routes.go @@ -50,4 +50,20 @@ func registerRechargeRewardRoutes(engine *gin.Engine, javaClient authGateway, se } writeOK(c, resp) }) + + group.GET("/claim-record/page", func(c *gin.Context) { + resp, err := service.PageClaimRecords( + c.Request.Context(), + c.Query("sysOrigin"), + c.Query("month"), + rechargereward.ParseInt64(c.Query("userId")), + int(rechargereward.ParseInt64(c.Query("cursor"))), + int(rechargereward.ParseInt64(c.Query("limit"))), + ) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) } diff --git a/internal/service/rechargereward/claim_records.go b/internal/service/rechargereward/claim_records.go new file mode 100644 index 0000000..3ce5d43 --- /dev/null +++ b/internal/service/rechargereward/claim_records.go @@ -0,0 +1,274 @@ +package rechargereward + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" +) + +var personalMonthlyRechargeTypes = []string{ + "UNDEFINED", + "HUAWEI", + "GOOGLE", + "APPLE", + "PAYER_MAX", + "SHIPPING_AGENT", + "STRIPE", + "SALARY_EXCHANGE", + "PAY_PAL", + "CLIPSPAY", + "SELLER_AGENT", +} + +type rechargeRewardClaimRecordRow struct { + UserID int64 `gorm:"column:user_id"` + RechargeAmount string `gorm:"column:recharge_amount"` + LastRechargeTime string `gorm:"column:last_recharge_time"` + 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"` +} + +// PageClaimRecords 分页返回当前配置下已达到充值奖励档位的用户。 +func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, month string, userID int64, cursor int, limit int) (*RechargeRewardClaimRecordPageResponse, error) { + sysOrigin = s.normalizeSysOrigin(sysOrigin) + cursor, limit = NormalizePage(cursor, limit) + + bundle, err := s.loadConfigBundle(ctx, sysOrigin) + if err != nil { + return nil, err + } + location := s.location + if bundle.Config != nil { + location = resolveLocation(bundle.Config.Timezone, s.location) + } + rechargeDate, displayMonth, err := normalizeRechargeRewardRecordMonth(month, time.Now().In(location)) + if err != nil { + return nil, err + } + + levels := enabledRechargeRewardLevels(bundle.Levels) + if len(levels) == 0 { + return emptyRechargeRewardClaimRecordPage(displayMonth, rechargeDate, location.String(), cursor, limit), nil + } + minThresholdCents := levels[0].RechargeAmountCents + minThresholdAmount := float64(minThresholdCents) / 100 + + var total int64 + countSQL := ` +SELECT COUNT(1) +FROM ( + SELECT recharge.user_id + FROM user_monthly_recharge_v2 AS recharge + INNER JOIN user_base_info AS user ON user.id = recharge.user_id + WHERE recharge.recharge_date = ? + AND recharge.type IN ? + AND user.origin_sys = ? + AND (? = 0 OR recharge.user_id = ?) + GROUP BY recharge.user_id + HAVING SUM(recharge.amount) >= ? +) AS qualified` + if err := s.db.WithContext(ctx).Raw( + countSQL, + rechargeDate, + personalMonthlyRechargeTypes, + sysOrigin, + userID, + userID, + minThresholdAmount, + ).Scan(&total).Error; err != nil { + return nil, err + } + + records := make([]RechargeRewardClaimRecordView, 0) + if total > 0 { + var rows []rechargeRewardClaimRecordRow + listSQL := ` +SELECT + recharge.user_id, + SUM(recharge.amount) AS recharge_amount, + MAX(recharge.update_time) AS last_recharge_time, + user.account, + user.user_avatar, + user.user_nickname, + user.country_code, + user.country_name +FROM user_monthly_recharge_v2 AS recharge +INNER JOIN user_base_info AS user ON user.id = recharge.user_id +WHERE recharge.recharge_date = ? + AND recharge.type IN ? + AND user.origin_sys = ? + AND (? = 0 OR recharge.user_id = ?) +GROUP BY recharge.user_id, user.account, user.user_avatar, user.user_nickname, user.country_code, user.country_name +HAVING SUM(recharge.amount) >= ? +ORDER BY SUM(recharge.amount) DESC, recharge.user_id ASC +LIMIT ? OFFSET ?` + if err := s.db.WithContext(ctx).Raw( + listSQL, + rechargeDate, + personalMonthlyRechargeTypes, + sysOrigin, + userID, + userID, + minThresholdAmount, + limit, + (cursor-1)*limit, + ).Scan(&rows).Error; err != nil { + return nil, err + } + rewardItems := s.loadRewardItemsMap(ctx, levels) + records = make([]RechargeRewardClaimRecordView, 0, len(rows)) + for _, row := range rows { + records = append(records, rechargeRewardClaimRecordView(row, displayMonth, rechargeDate, levels, rewardItems)) + } + } + + return &RechargeRewardClaimRecordPageResponse{ + Month: displayMonth, + RechargeDate: rechargeDate, + Timezone: location.String(), + Records: records, + Total: total, + Current: cursor, + Size: limit, + }, nil +} + +func emptyRechargeRewardClaimRecordPage(month string, rechargeDate int, timezone string, cursor int, limit int) *RechargeRewardClaimRecordPageResponse { + return &RechargeRewardClaimRecordPageResponse{ + Month: month, + RechargeDate: rechargeDate, + Timezone: timezone, + Records: []RechargeRewardClaimRecordView{}, + Total: 0, + Current: cursor, + Size: limit, + } +} + +func normalizeRechargeRewardRecordMonth(raw string, now time.Time) (int, string, error) { + value := strings.TrimSpace(raw) + if value == "" { + value = now.Format("2006-01") + } + if len(value) == 6 && !strings.Contains(value, "-") { + value = value[:4] + "-" + value[4:] + } + parsed, err := time.Parse("2006-01", value) + if err != nil { + return 0, "", NewAppError(http.StatusBadRequest, "invalid_month", "month must be YYYY-MM") + } + rechargeDate := parsed.Year()*100 + int(parsed.Month()) + return rechargeDate, parsed.Format("2006-01"), nil +} + +func enabledRechargeRewardLevels(levels []rechargeRewardLevelSnapshot) []rechargeRewardLevelSnapshot { + result := make([]rechargeRewardLevelSnapshot, 0, len(levels)) + for _, level := range levels { + if !level.Enabled || level.RechargeAmountCents <= 0 { + continue + } + result = append(result, level) + } + sortLevels(result) + return result +} + +func rechargeRewardClaimRecordView( + row rechargeRewardClaimRecordRow, + month string, + rechargeDate int, + levels []rechargeRewardLevelSnapshot, + rewardItems map[int64][]RechargeRewardRewardItem, +) RechargeRewardClaimRecordView { + rechargeCents := parseIntegrationAmountCents(integrationAmount(row.RechargeAmount)) + reached := reachedRechargeRewardLevels(levels, rewardItems, rechargeCents) + return RechargeRewardClaimRecordView{ + RecordKey: fmt.Sprintf("%d-%d", row.UserID, rechargeDate), + UserID: row.UserID, + Account: strings.TrimSpace(row.Account), + UserAvatar: strings.TrimSpace(row.UserAvatar), + UserNickname: strings.TrimSpace(row.UserNickname), + CountryCode: strings.TrimSpace(row.CountryCode), + CountryName: strings.TrimSpace(row.CountryName), + RechargeMonth: month, + RechargeDate: rechargeDate, + RechargeAmount: formatAmountCents(rechargeCents), + RechargeAmountCents: rechargeCents, + ReachedLevels: reached, + ClaimedRewards: rechargeRewardClaimedRewards(reached), + LastRechargeTime: formatRechargeRewardRecordTime(row.LastRechargeTime), + } +} + +func reachedRechargeRewardLevels( + levels []rechargeRewardLevelSnapshot, + rewardItems map[int64][]RechargeRewardRewardItem, + rechargeCents int64, +) []RechargeRewardLevelPayload { + result := make([]RechargeRewardLevelPayload, 0) + for _, level := range levels { + if rechargeCents < level.RechargeAmountCents { + continue + } + var items []RechargeRewardRewardItem + if level.RewardGroupID != nil { + items = rewardItems[*level.RewardGroupID] + } + result = append(result, RechargeRewardLevelPayload{ + ID: level.ID, + Level: level.Level, + RechargeAmount: formatAmountCents(level.RechargeAmountCents), + RechargeAmountCents: level.RechargeAmountCents, + RewardGold: level.RewardGold, + RewardGroupID: level.RewardGroupID, + RewardGroupName: level.RewardGroupName, + RewardItems: items, + Enabled: level.Enabled, + Reached: true, + }) + } + return result +} + +func rechargeRewardClaimedRewards(levels []RechargeRewardLevelPayload) []RechargeRewardClaimedRewardPayload { + result := make([]RechargeRewardClaimedRewardPayload, 0, len(levels)) + for _, level := range levels { + result = append(result, RechargeRewardClaimedRewardPayload{ + Level: level.Level, + RechargeAmount: level.RechargeAmount, + RechargeAmountCents: level.RechargeAmountCents, + RewardGold: level.RewardGold, + RewardGroupID: level.RewardGroupID, + RewardGroupName: level.RewardGroupName, + RewardItems: level.RewardItems, + }) + } + return result +} + +func formatRechargeRewardRecordTime(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + for _, layout := range []string{ + "2006-01-02 15:04:05.999999999-07:00", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + time.RFC3339Nano, + time.RFC3339, + } { + if parsed, err := time.Parse(layout, value); err == nil { + return formatDateTime(parsed) + } + } + if len(value) > len("2006-01-02 15:04:05") { + return value[:len("2006-01-02 15:04:05")] + } + return value +} diff --git a/internal/service/rechargereward/config_test.go b/internal/service/rechargereward/config_test.go index 0d3c765..6d94a13 100644 --- a/internal/service/rechargereward/config_test.go +++ b/internal/service/rechargereward/config_test.go @@ -22,6 +22,7 @@ func newTestService(t *testing.T, java rechargeRewardJavaGateway) (*Service, *go if err := db.AutoMigrate( &model.RechargeRewardConfig{}, &model.RechargeRewardLevel{}, + &model.UserBaseInfo{}, ); err != nil { t.Fatalf("auto migrate: %v", err) } @@ -160,6 +161,77 @@ func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) { } } +func TestPageClaimRecordsUsesMonthlyPersonalRechargeAndReachedLevels(t *testing.T) { + groupID := int64(9001) + service, db := newTestService(t, rechargeRewardTestGateway{ + groups: map[int64]integration.RewardGroupDetail{ + groupID: { + ID: integration.Int64Value(groupID), + Name: "Gift Box", + RewardConfigList: []integration.RewardGroupItem{ + {ID: integration.Int64Value(1), Type: "PROP", Name: "Frame", Quantity: integration.Int64Value(1)}, + }, + }, + }, + }) + if err := db.Exec(` +CREATE TABLE user_monthly_recharge_v2 ( + user_id INTEGER NOT NULL, + type TEXT NOT NULL, + amount DECIMAL(12,2) NOT NULL, + recharge_date INTEGER NOT NULL, + update_time DATETIME NOT NULL +)`).Error; err != nil { + t.Fatalf("create monthly recharge table: %v", err) + } + if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{ + SysOrigin: "LIKEI", + Enabled: true, + Timezone: "Asia/Riyadh", + LevelConfigs: []RechargeRewardLevelInput{ + {Level: 1, RechargeAmount: mustAmount(t, "100"), RewardGroupID: ptrFlexibleInt64(groupID), Enabled: true}, + {Level: 2, RechargeAmount: mustAmount(t, "200"), RewardGold: 3000, Enabled: true}, + }, + }); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + if err := db.Create(&model.UserBaseInfo{ + ID: 42, + Account: "1000042", + UserNickname: "Nina", + OriginSys: "LIKEI", + CountryCode: "SA", + CountryName: "Saudi Arabia", + }).Error; err != nil { + t.Fatalf("create user: %v", err) + } + if err := db.Exec(` +INSERT INTO user_monthly_recharge_v2 (user_id, type, amount, recharge_date, update_time) VALUES + (42, 'GOOGLE', 150.50, 202605, '2026-05-07 10:00:00'), + (42, 'MIFA_PAY', 70.00, 202605, '2026-05-07 11:00:00') +`).Error; err != nil { + t.Fatalf("insert monthly recharge: %v", err) + } + + resp, err := service.PageClaimRecords(context.Background(), "LIKEI", "2026-05", 0, 1, 20) + if err != nil { + t.Fatalf("PageClaimRecords() error = %v", err) + } + if resp.Total != 1 || len(resp.Records) != 1 { + t.Fatalf("page result total=%d records=%d", resp.Total, len(resp.Records)) + } + record := resp.Records[0] + if record.RechargeAmount != "150.50" { + t.Fatalf("RechargeAmount = %q, want 150.50", record.RechargeAmount) + } + if len(record.ReachedLevels) != 1 || record.ReachedLevels[0].Level != 1 { + t.Fatalf("ReachedLevels = %+v, want only level 1", record.ReachedLevels) + } + if len(record.ClaimedRewards) != 1 || len(record.ClaimedRewards[0].RewardItems) != 1 { + t.Fatalf("ClaimedRewards = %+v, want level reward item", record.ClaimedRewards) + } +} + func TestParseAmountCents(t *testing.T) { tests := map[string]int64{ "100": 10000, diff --git a/internal/service/rechargereward/types.go b/internal/service/rechargereward/types.go index d2aa969..e5bb664 100644 --- a/internal/service/rechargereward/types.go +++ b/internal/service/rechargereward/types.go @@ -131,6 +131,46 @@ type RechargeRewardHomeResponse struct { LevelConfigs []RechargeRewardLevelPayload `json:"levelConfigs"` } +// RechargeRewardClaimRecordPageResponse 是后台领取人员列表分页响应。 +type RechargeRewardClaimRecordPageResponse struct { + Month string `json:"month"` + RechargeDate int `json:"rechargeDate"` + Timezone string `json:"timezone"` + Records []RechargeRewardClaimRecordView `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` +} + +// RechargeRewardClaimRecordView 展示一个达标用户在当前充值奖励配置下可领取/已领取的奖励。 +type RechargeRewardClaimRecordView struct { + RecordKey string `json:"recordKey"` + UserID int64 `json:"userId,string"` + 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"` + RechargeMonth string `json:"rechargeMonth"` + RechargeDate int `json:"rechargeDate"` + RechargeAmount string `json:"rechargeAmount"` + RechargeAmountCents int64 `json:"rechargeAmountCents"` + ReachedLevels []RechargeRewardLevelPayload `json:"reachedLevels"` + ClaimedRewards []RechargeRewardClaimedRewardPayload `json:"claimedRewards"` + LastRechargeTime string `json:"lastRechargeTime,omitempty"` +} + +// RechargeRewardClaimedRewardPayload 是人员列表里按档位聚合后的奖励展示。 +type RechargeRewardClaimedRewardPayload struct { + Level int `json:"level"` + RechargeAmount string `json:"rechargeAmount"` + RechargeAmountCents int64 `json:"rechargeAmountCents"` + RewardGold int64 `json:"rewardGold"` + RewardGroupID *int64 `json:"rewardGroupId,omitempty"` + RewardGroupName string `json:"rewardGroupName,omitempty"` + RewardItems []RechargeRewardRewardItem `json:"rewardItems"` +} + // RechargeRewardRewardItem 是奖励组内的单个物品展示项。 type RechargeRewardRewardItem struct { ID int64 `json:"id"` @@ -263,6 +303,24 @@ func (v flexibleAmount) Cents() int64 { return int64(v) } +func ParseInt64(raw string) int64 { + value, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + return value +} + +func NormalizePage(cursor int, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + func normalizeRechargeRewardTimezone(timezone string) string { timezone = strings.TrimSpace(timezone) if timezone == "" {