diff --git a/internal/config/config.go b/internal/config/config.go index 01e3ec6..7c843f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -81,8 +81,11 @@ type InviteConfig struct { // RechargeRewardConfig 保存充值奖励活动配置。 type RechargeRewardConfig struct { - DefaultSysOrigin string - Timezone string + DefaultSysOrigin string + Timezone string + StorageTimezone string + StartDate string + CoinSellerGoldPerUSD int64 } // TaskCenterConfig 保存任务中心模块配置。 @@ -313,6 +316,18 @@ func Load() Config { []string{"CHATAPP_RECHARGE_REWARD_TIMEZONE", "RECHARGE_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, "Asia/Riyadh", ), + StorageTimezone: getEnvAny( + []string{"CHATAPP_RECHARGE_REWARD_STORAGE_TIMEZONE", "RECHARGE_REWARD_STORAGE_TIMEZONE"}, + "Asia/Shanghai", + ), + StartDate: getEnvAny( + []string{"CHATAPP_RECHARGE_REWARD_START_DATE", "RECHARGE_REWARD_START_DATE"}, + "2026-05-21", + ), + CoinSellerGoldPerUSD: int64(getEnvIntAny( + []string{"CHATAPP_RECHARGE_REWARD_COIN_SELLER_GOLD_PER_USD", "RECHARGE_REWARD_COIN_SELLER_GOLD_PER_USD"}, + 100000, + )), }, TaskCenter: TaskCenterConfig{ DefaultSysOrigin: strings.ToUpper(getEnvAny( diff --git a/internal/model/recharge_reward_models.go b/internal/model/recharge_reward_models.go index 430427b..1fadc20 100644 --- a/internal/model/recharge_reward_models.go +++ b/internal/model/recharge_reward_models.go @@ -29,3 +29,39 @@ type RechargeRewardLevel struct { } func (RechargeRewardLevel) TableName() string { return "recharge_reward_level" } + +// RechargeRewardGrantRecord 记录某个充值周期内用户各档位的实际发放状态。 +type RechargeRewardGrantRecord struct { + ID int64 `gorm:"column:id;primaryKey"` + ConfigID int64 `gorm:"column:config_id;index:idx_recharge_reward_grant_config_cycle,priority:1"` + LevelID int64 `gorm:"column:level_id"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_recharge_reward_grant_level,priority:1;index:idx_recharge_reward_grant_date,priority:1"` + CycleKey string `gorm:"column:cycle_key;size:32;uniqueIndex:uk_recharge_reward_grant_level,priority:2;index:idx_recharge_reward_grant_config_cycle,priority:2"` + RechargeDate int `gorm:"column:recharge_date;index:idx_recharge_reward_grant_user,priority:2;index:idx_recharge_reward_grant_date,priority:2"` + PeriodStartAt time.Time `gorm:"column:period_start_at"` + PeriodEndAt time.Time `gorm:"column:period_end_at"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_recharge_reward_grant_level,priority:3;index:idx_recharge_reward_grant_user,priority:1"` + Account string `gorm:"column:account;size:64"` + UserAvatar string `gorm:"column:user_avatar;size:1024"` + UserNickname string `gorm:"column:user_nickname;size:255"` + CountryCode string `gorm:"column:country_code;size:32"` + CountryName string `gorm:"column:country_name;size:128"` + RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"` + Level int `gorm:"column:level;uniqueIndex:uk_recharge_reward_grant_level,priority:4"` + RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"` + RewardGold int64 `gorm:"column:reward_gold"` + RewardGroupID *int64 `gorm:"column:reward_group_id"` + RewardGroupName string `gorm:"column:reward_group_name;size:255"` + RewardGroupTrackID int64 `gorm:"column:reward_group_track_id"` + GoldEventID string `gorm:"column:gold_event_id;size:128;index:idx_recharge_reward_gold_event"` + Status string `gorm:"column:status;size:32;index:idx_recharge_reward_grant_config_cycle,priority:3;index:idx_recharge_reward_grant_date,priority:3"` + RewardGroupStatus string `gorm:"column:reward_group_status;size:32"` + RewardGoldStatus string `gorm:"column:reward_gold_status;size:32"` + RetryCount int `gorm:"column:retry_count"` + LastError string `gorm:"column:last_error;size:1024"` + SentAt *time.Time `gorm:"column:sent_at"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (RechargeRewardGrantRecord) TableName() string { return "recharge_reward_grant_record" } diff --git a/internal/service/rechargereward/claim_records.go b/internal/service/rechargereward/claim_records.go index 5c66267..d084ab8 100644 --- a/internal/service/rechargereward/claim_records.go +++ b/internal/service/rechargereward/claim_records.go @@ -2,48 +2,33 @@ package rechargereward import ( "context" - "encoding/json" "fmt" - "math" "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"` - LastClaimTime string `gorm:"column:last_claim_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"` + UserID int64 `gorm:"column:user_id"` + RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"` + LastRechargeTime string `gorm:"column:last_recharge_time"` + LastClaimTime string `gorm:"column:last_claim_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"` } type rechargeRewardClaimDetailRow struct { - UserID int64 `gorm:"column:user_id"` - ActivityID int64 `gorm:"column:activity_id"` - Level int `gorm:"column:level"` - ResourceGroupID *int64 `gorm:"column:resource_group_id"` - RuleDescription string `gorm:"column:rule_description"` - JSONData string `gorm:"column:json_data"` - ClaimTime string `gorm:"column:claim_time"` + UserID int64 `gorm:"column:user_id"` + ActivityID int64 `gorm:"column:activity_id"` + Level int `gorm:"column:level"` + ResourceGroupID *int64 `gorm:"column:resource_group_id"` + RewardGroupName string `gorm:"column:reward_group_name"` + RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"` + RewardGold int64 `gorm:"column:reward_gold"` + ClaimTime string `gorm:"column:claim_time"` } // PageClaimRecords 分页返回真实领取过累计充值奖励的用户。 @@ -67,12 +52,10 @@ func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, month var total int64 countSQL := ` SELECT COUNT(DISTINCT receive.user_id) -FROM user_activity_reward_receive_record AS receive -INNER JOIN props_activity_rule_config AS rule ON rule.id = receive.activity_id +FROM recharge_reward_grant_record AS receive WHERE receive.recharge_date = ? - AND rule.sys_origin = ? - AND rule.activity_type = 'CUMULATIVE_RECHARGE' - AND COALESCE(rule.is_del, 0) = 0 + AND receive.sys_origin = ? + AND receive.status = 'SUCCESS' AND (? = 0 OR receive.user_id = ?)` if err := s.db.WithContext(ctx).Raw( countSQL, @@ -90,40 +73,34 @@ WHERE receive.recharge_date = ? listSQL := ` SELECT claims.user_id, - COALESCE(recharge.recharge_amount, 0) AS recharge_amount, - COALESCE(recharge.last_recharge_time, '') AS last_recharge_time, + claims.recharge_amount_cents, + claims.last_recharge_time, claims.last_claim_time, - user.account, - user.user_avatar, - user.user_nickname, - user.country_code, - user.country_name + claims.account, + claims.user_avatar, + claims.user_nickname, + claims.country_code, + claims.country_name FROM ( SELECT receive.user_id, - MAX(receive.create_time) AS last_claim_time - FROM user_activity_reward_receive_record AS receive - INNER JOIN props_activity_rule_config AS rule ON rule.id = receive.activity_id + MAX(receive.recharge_amount_cents) AS recharge_amount_cents, + COALESCE(MAX(receive.sent_at), MAX(receive.update_time), '') AS last_recharge_time, + COALESCE(MAX(receive.sent_at), MAX(receive.create_time), '') AS last_claim_time, + MAX(receive.account) AS account, + MAX(receive.user_avatar) AS user_avatar, + MAX(receive.user_nickname) AS user_nickname, + MAX(receive.country_code) AS country_code, + MAX(receive.country_name) AS country_name + FROM recharge_reward_grant_record AS receive WHERE receive.recharge_date = ? - AND rule.sys_origin = ? - AND rule.activity_type = 'CUMULATIVE_RECHARGE' - AND COALESCE(rule.is_del, 0) = 0 + AND receive.sys_origin = ? + AND receive.status = 'SUCCESS' AND (? = 0 OR receive.user_id = ?) GROUP BY receive.user_id - ORDER BY MAX(receive.create_time) DESC, receive.user_id ASC + ORDER BY COALESCE(MAX(receive.sent_at), MAX(receive.create_time)) DESC, receive.user_id ASC LIMIT ? OFFSET ? ) AS claims -INNER JOIN user_base_info AS user ON user.id = claims.user_id -LEFT JOIN ( - SELECT - recharge.user_id, - SUM(recharge.amount) AS recharge_amount, - MAX(recharge.update_time) AS last_recharge_time - FROM user_monthly_recharge_v2 AS recharge - WHERE recharge.recharge_date = ? - AND recharge.type IN ? - GROUP BY recharge.user_id -) AS recharge ON recharge.user_id = claims.user_id ORDER BY claims.last_claim_time DESC, claims.user_id ASC` if err := s.db.WithContext(ctx).Raw( listSQL, @@ -133,8 +110,6 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC` userID, limit, (cursor-1)*limit, - rechargeDate, - personalMonthlyRechargeTypes, ).Scan(&rows).Error; err != nil { return nil, err } @@ -207,7 +182,6 @@ func rechargeRewardClaimRecordView( details []rechargeRewardClaimDetailRow, rewardItems map[int64][]RechargeRewardRewardItem, ) RechargeRewardClaimRecordView { - rechargeCents := parseIntegrationAmountCents(integrationAmount(row.RechargeAmount)) reached := claimDetailPayloads(details, rewardItems) return RechargeRewardClaimRecordView{ RecordKey: fmt.Sprintf("%d-%d", row.UserID, rechargeDate), @@ -219,8 +193,8 @@ func rechargeRewardClaimRecordView( CountryName: strings.TrimSpace(row.CountryName), RechargeMonth: month, RechargeDate: rechargeDate, - RechargeAmount: formatAmountCents(rechargeCents), - RechargeAmountCents: rechargeCents, + RechargeAmount: formatAmountCents(row.RechargeAmountCents), + RechargeAmountCents: row.RechargeAmountCents, ReachedLevels: reached, ClaimedRewards: rechargeRewardClaimedRewards(reached), LastRechargeTime: formatRechargeRewardRecordTime(row.LastRechargeTime), @@ -235,20 +209,19 @@ func (s *Service) loadClaimDetails(ctx context.Context, sysOrigin string, rechar detailSQL := ` SELECT receive.user_id, - receive.activity_id, - COALESCE(rule.sort, 0) AS level, - rule.resource_group_id, - rule.rule_description, - rule.json_data, - receive.create_time AS claim_time -FROM user_activity_reward_receive_record AS receive -INNER JOIN props_activity_rule_config AS rule ON rule.id = receive.activity_id + receive.level_id AS activity_id, + receive.level, + receive.reward_group_id AS resource_group_id, + receive.reward_group_name, + receive.recharge_threshold_cents, + receive.reward_gold, + COALESCE(receive.sent_at, receive.create_time) AS claim_time +FROM recharge_reward_grant_record AS receive WHERE receive.recharge_date = ? - AND rule.sys_origin = ? - AND rule.activity_type = 'CUMULATIVE_RECHARGE' - AND COALESCE(rule.is_del, 0) = 0 + AND receive.sys_origin = ? + AND receive.status = 'SUCCESS' AND receive.user_id IN ? -ORDER BY receive.user_id ASC, COALESCE(rule.sort, 0) ASC, receive.create_time ASC` +ORDER BY receive.user_id ASC, receive.level ASC, receive.create_time ASC` if err := s.db.WithContext(ctx).Raw(detailSQL, rechargeDate, sysOrigin, userIDs).Scan(&rows).Error; err != nil { return nil, err } @@ -310,14 +283,14 @@ func claimDetailPayloads( if detail.ResourceGroupID != nil { items = rewardItems[*detail.ResourceGroupID] } - thresholdCents := rechargeRewardRuleQuantityCents(detail.JSONData) result = append(result, RechargeRewardLevelPayload{ ID: detail.ActivityID, Level: level, - RechargeAmount: formatAmountCents(thresholdCents), - RechargeAmountCents: thresholdCents, + RechargeAmount: formatAmountCents(detail.RechargeThresholdCents), + RechargeAmountCents: detail.RechargeThresholdCents, + RewardGold: detail.RewardGold, RewardGroupID: detail.ResourceGroupID, - RewardGroupName: strings.TrimSpace(detail.RuleDescription), + RewardGroupName: strings.TrimSpace(detail.RewardGroupName), RewardItems: items, Enabled: true, Reached: true, @@ -326,33 +299,6 @@ func claimDetailPayloads( return result } -func rechargeRewardRuleQuantityCents(raw string) int64 { - var payload map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { - return 0 - } - value, ok := payload["quantity"] - if !ok { - return 0 - } - switch typed := value.(type) { - case float64: - return int64(math.Round(typed * 100)) - case string: - cents, err := parseAmountCents(typed) - if err != nil { - return 0 - } - return cents - default: - cents, err := parseAmountCents(fmt.Sprint(typed)) - if err != nil { - return 0 - } - return cents - } -} - func reachedRechargeRewardLevels( levels []rechargeRewardLevelSnapshot, rewardItems map[int64][]RechargeRewardRewardItem, diff --git a/internal/service/rechargereward/config_test.go b/internal/service/rechargereward/config_test.go index 358211e..fa38d2b 100644 --- a/internal/service/rechargereward/config_test.go +++ b/internal/service/rechargereward/config_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "testing" + "time" "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" @@ -22,14 +23,21 @@ func newTestService(t *testing.T, java rechargeRewardJavaGateway) (*Service, *go if err := db.AutoMigrate( &model.RechargeRewardConfig{}, &model.RechargeRewardLevel{}, + &model.RechargeRewardGrantRecord{}, &model.UserBaseInfo{}, ); err != nil { t.Fatalf("auto migrate: %v", err) } + if err := db.Exec(`ALTER TABLE user_base_info ADD COLUMN is_del INTEGER DEFAULT 0`).Error; err != nil { + t.Fatalf("add is_del: %v", err) + } service := NewService(config.Config{ RechargeReward: config.RechargeRewardConfig{ - DefaultSysOrigin: "LIKEI", - Timezone: "Asia/Riyadh", + DefaultSysOrigin: "LIKEI", + Timezone: "Asia/Riyadh", + StorageTimezone: "Asia/Shanghai", + StartDate: "2020-01-01", + CoinSellerGoldPerUSD: 100000, }, }, db, java) return service, db @@ -107,7 +115,6 @@ func TestSaveConfigUpsertsBySysOrigin(t *testing.T) { func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) { groupID := int64(9001) java := rechargeRewardTestGateway{ - monthly: integration.DecimalString("150.50"), total: map[int64][]integration.UserTotalRecharge{ 42: { {UserID: integration.Int64Value(42), Type: "GOOGLE", Amount: integration.DecimalString("5000.00")}, @@ -130,7 +137,37 @@ func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) { }, }, } - service, _ := newTestService(t, java) + service, db := newTestService(t, java) + createRechargeRewardSourceTables(t, db) + 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) + } + rechargeAt := rechargeRewardStorageTime(time.Now().In(service.location), service.storageLocation) + if err := db.Exec(` +INSERT INTO order_purchase_history (user_id, sys_origin, evn, is_trial_period, status, pay_platform, unit_price, create_time) VALUES + (42, 'LIKEI', 'PROD', 0, 'COMPLETE', 'GOOGLE', 120.50, ?) +`, rechargeAt).Error; err != nil { + t.Fatalf("insert google recharge: %v", err) + } + if err := db.Exec(` +INSERT INTO order_user_purchase_pay (user_id, sys_origin, evn, factory_code, pay_status, receipt_type, refund_status, compute_usd_amount, create_time, update_time) VALUES + (42, 'LIKEI', 'PROD', 'MIFA_PAY', 'SUCCESSFUL', 'PAYMENT', 'NONE', 29.00, ?, ?) +`, rechargeAt, rechargeAt).Error; err != nil { + t.Fatalf("insert mifapay recharge: %v", err) + } + if err := db.Exec(` +INSERT INTO likei_wallet.user_freight_balance_running_water (accept_user_id, sys_origin, origin, type, quantity, create_time) VALUES + (42, 'LIKEI', 'SHIPMENT', 1, 100000, ?) +`, rechargeAt).Error; err != nil { + t.Fatalf("insert seller recharge: %v", err) + } if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{ SysOrigin: "LIKEI", Enabled: true, @@ -174,39 +211,6 @@ func TestPageClaimRecordsUsesActualReceiveRecords(t *testing.T) { }, }, }) - 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 := db.Exec(` -CREATE TABLE user_activity_reward_receive_record ( - id INTEGER PRIMARY KEY, - user_id INTEGER NOT NULL, - recharge_date INTEGER NOT NULL, - activity_id INTEGER NOT NULL, - create_time DATETIME NOT NULL -)`).Error; err != nil { - t.Fatalf("create receive record table: %v", err) - } - if err := db.Exec(` -CREATE TABLE props_activity_rule_config ( - id INTEGER PRIMARY KEY, - sys_origin TEXT NOT NULL, - activity_type TEXT NOT NULL, - resource_group_id INTEGER NOT NULL, - json_data TEXT NOT NULL, - sort INTEGER NOT NULL, - rule_description TEXT NOT NULL, - is_del INTEGER NOT NULL -)`).Error; err != nil { - t.Fatalf("create activity rule table: %v", err) - } if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{ SysOrigin: "LIKEI", Enabled: true, @@ -228,25 +232,33 @@ CREATE TABLE props_activity_rule_config ( }).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'), - (43, 'GOOGLE', 300.00, 202605, '2026-05-07 12:00:00') -`).Error; err != nil { - t.Fatalf("insert monthly recharge: %v", err) - } - if err := db.Exec(` -INSERT INTO props_activity_rule_config (id, sys_origin, activity_type, resource_group_id, json_data, sort, rule_description, is_del) VALUES - (7001, 'LIKEI', 'CUMULATIVE_RECHARGE', 9001, '{"quantity":100}', 1, 'Gift Box', 0) -`).Error; err != nil { - t.Fatalf("insert activity rule: %v", err) - } - if err := db.Exec(` -INSERT INTO user_activity_reward_receive_record (id, user_id, recharge_date, activity_id, create_time) VALUES - (8001, 42, 202605, 7001, '2026-05-07 10:30:00') -`).Error; err != nil { - t.Fatalf("insert receive record: %v", err) + if err := db.Create(&model.RechargeRewardGrantRecord{ + ID: 8001, + ConfigID: 1001, + LevelID: 7001, + SysOrigin: "LIKEI", + CycleKey: "20260521", + RechargeDate: 202605, + PeriodStartAt: time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC), + PeriodEndAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + UserID: 42, + Account: "1000042", + UserNickname: "Nina", + CountryCode: "SA", + CountryName: "Saudi Arabia", + RechargeAmountCents: 15050, + Level: 1, + RechargeThresholdCents: 10000, + RewardGroupID: &groupID, + RewardGroupName: "Gift Box", + Status: "SUCCESS", + RewardGroupStatus: "SUCCESS", + RewardGoldStatus: "SKIPPED", + SentAt: ptrTime(time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)), + CreateTime: time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC), + UpdateTime: time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC), + }).Error; err != nil { + t.Fatalf("insert grant record: %v", err) } resp, err := service.PageClaimRecords(context.Background(), "LIKEI", "2026-05", 0, 1, 20) @@ -302,6 +314,56 @@ func ptrFlexibleInt64(value int64) flexibleOptionalInt64 { return flexibleOptionalInt64{value: &value} } +func ptrTime(value time.Time) *time.Time { + return &value +} + +func createRechargeRewardSourceTables(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.Exec(`ATTACH DATABASE ':memory:' AS likei_wallet`).Error; err != nil { + t.Fatalf("attach wallet schema: %v", err) + } + if err := db.Exec(` +CREATE TABLE order_purchase_history ( + user_id INTEGER NOT NULL, + sys_origin TEXT NOT NULL, + evn TEXT NOT NULL, + is_trial_period INTEGER NOT NULL, + status TEXT NOT NULL, + pay_platform TEXT NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + create_time DATETIME NOT NULL +)`).Error; err != nil { + t.Fatalf("create order_purchase_history: %v", err) + } + if err := db.Exec(` +CREATE TABLE order_user_purchase_pay ( + user_id INTEGER NOT NULL, + sys_origin TEXT NOT NULL, + evn TEXT NOT NULL, + factory_code TEXT NOT NULL, + pay_status TEXT NOT NULL, + receipt_type TEXT NOT NULL, + refund_status TEXT NOT NULL, + compute_usd_amount DECIMAL(12,2) NOT NULL, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL +)`).Error; err != nil { + t.Fatalf("create order_user_purchase_pay: %v", err) + } + if err := db.Exec(` +CREATE TABLE likei_wallet.user_freight_balance_running_water ( + accept_user_id INTEGER NOT NULL, + sys_origin TEXT NOT NULL, + origin TEXT NOT NULL, + type INTEGER NOT NULL, + quantity DECIMAL(20,2) NOT NULL, + create_time DATETIME NOT NULL +)`).Error; err != nil { + t.Fatalf("create wallet freight water: %v", err) + } +} + type rechargeRewardTestGateway struct { monthly integration.DecimalString total map[int64][]integration.UserTotalRecharge diff --git a/internal/service/rechargereward/home.go b/internal/service/rechargereward/home.go index 9d987df..371d215 100644 --- a/internal/service/rechargereward/home.go +++ b/internal/service/rechargereward/home.go @@ -26,11 +26,15 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo location = resolveLocation(bundle.Config.Timezone, s.location) } now := time.Now().In(location) - periodStart, periodEnd := currentMonthBounds(now, location) + period := s.displayPeriod(now, location) + currentPeriod, periodActive := s.currentPeriod(now, location) - currentRechargeCents, err := s.loadCurrentRechargeCents(ctx, user.UserID) - if err != nil { - return nil, err + var currentRechargeCents int64 + if periodActive { + currentRechargeCents, err = s.loadCurrentRechargeCents(ctx, sysOrigin, user.UserID, currentPeriod) + if err != nil { + return nil, err + } } accumulatedRechargeCents, err := s.loadAccumulatedRechargeCents(ctx, user.UserID) if err != nil { @@ -43,9 +47,9 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo Enabled: bundle.Config != nil && bundle.Config.Enabled, SysOrigin: sysOrigin, Timezone: location.String(), - PeriodStartAt: formatDateTime(periodStart), - PeriodEndAt: formatDateTime(periodEnd), - CountdownMillis: maxInt64(0, periodEnd.UnixMilli()-now.UnixMilli()), + PeriodStartAt: formatDateTime(period.StartAt), + PeriodEndAt: formatDateTime(period.EndAt), + CountdownMillis: maxInt64(0, period.EndAt.UnixMilli()-now.UnixMilli()), UserID: user.UserID, CurrentRechargeAmount: formatAmountCents(currentRechargeCents), CurrentRechargeAmountCents: currentRechargeCents, @@ -88,14 +92,6 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo return resp, nil } -func (s *Service) loadCurrentRechargeCents(ctx context.Context, userID int64) (int64, error) { - amount, err := s.java.GetThisMonthTotalPersonalRecharge(ctx, userID) - if err != nil { - return 0, err - } - return parseIntegrationAmountCents(integrationAmount(amount)), nil -} - func (s *Service) loadAccumulatedRechargeCents(ctx context.Context, userID int64) (int64, error) { recharges, err := s.java.MapUserTotalRecharge(ctx, []int64{userID}) if err != nil { @@ -104,12 +100,6 @@ func (s *Service) loadAccumulatedRechargeCents(ctx context.Context, userID int64 return totalRechargeCents(recharges[userID]), nil } -func currentMonthBounds(now time.Time, location *time.Location) (time.Time, time.Time) { - local := now.In(location) - start := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location) - return start, start.AddDate(0, 1, 0) -} - func resolveLocation(timezone string, fallback *time.Location) *time.Location { location, err := time.LoadLocation(normalizeRechargeRewardTimezone(timezone)) if err == nil { diff --git a/internal/service/rechargereward/period.go b/internal/service/rechargereward/period.go new file mode 100644 index 0000000..944060c --- /dev/null +++ b/internal/service/rechargereward/period.go @@ -0,0 +1,75 @@ +package rechargereward + +import ( + "strings" + "time" +) + +type rechargeRewardPeriod struct { + CycleKey string + RechargeDate int + StartAt time.Time + EndAt time.Time +} + +func (s *Service) currentPeriod(now time.Time, location *time.Location) (rechargeRewardPeriod, bool) { + startAt := rechargeRewardStartAt(s.cfg.RechargeReward.StartDate, location) + return currentRechargeRewardPeriod(now, startAt, location) +} + +func (s *Service) displayPeriod(now time.Time, location *time.Location) rechargeRewardPeriod { + startAt := rechargeRewardStartAt(s.cfg.RechargeReward.StartDate, location) + period, ok := currentRechargeRewardPeriod(now, startAt, location) + if ok { + return period + } + monthStart := time.Date(startAt.Year(), startAt.Month(), 1, 0, 0, 0, 0, location) + return rechargeRewardPeriod{ + CycleKey: startAt.Format("20060102"), + RechargeDate: startAt.Year()*100 + int(startAt.Month()), + StartAt: startAt, + EndAt: monthStart.AddDate(0, 1, 0), + } +} + +func currentRechargeRewardPeriod(now time.Time, startAt time.Time, location *time.Location) (rechargeRewardPeriod, bool) { + local := now.In(location) + start := startAt.In(location) + if local.Before(start) { + return rechargeRewardPeriod{}, false + } + monthStart := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location) + periodStart := monthStart + if periodStart.Before(start) { + periodStart = start + } + periodEnd := monthStart.AddDate(0, 1, 0) + if !local.Before(periodEnd) { + return rechargeRewardPeriod{}, false + } + return rechargeRewardPeriod{ + CycleKey: periodStart.Format("20060102"), + RechargeDate: periodStart.Year()*100 + int(periodStart.Month()), + StartAt: periodStart, + EndAt: periodEnd, + }, true +} + +func rechargeRewardStartAt(raw string, location *time.Location) time.Time { + value := strings.TrimSpace(raw) + if value == "" { + value = "2026-05-21" + } + parsed, err := time.ParseInLocation("2006-01-02", value, location) + if err != nil { + parsed, _ = time.ParseInLocation("2006-01-02", "2026-05-21", location) + } + return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, location) +} + +func rechargeRewardStorageTime(t time.Time, storageLocation *time.Location) string { + if storageLocation == nil { + storageLocation = t.Location() + } + return t.In(storageLocation).Format("2006-01-02 15:04:05") +} diff --git a/internal/service/rechargereward/recharge_amount.go b/internal/service/rechargereward/recharge_amount.go new file mode 100644 index 0000000..0382ab9 --- /dev/null +++ b/internal/service/rechargereward/recharge_amount.go @@ -0,0 +1,102 @@ +package rechargereward + +import ( + "context" + "strings" +) + +var rechargeRewardOrderPlatforms = []string{ + "GOOGLE", + "PAYER_MAX", + "AIRWALLEX", + "PAYNICORN", + "STRIPE", + "PAY_PAL", + "CLIPSPAY", +} + +type rechargeRewardAmountRow struct { + RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"` +} + +func (s *Service) loadCurrentRechargeCents(ctx context.Context, sysOrigin string, userID int64, period rechargeRewardPeriod) (int64, error) { + if userID <= 0 { + return 0, nil + } + sysOrigin = strings.ToUpper(strings.TrimSpace(sysOrigin)) + startAt := rechargeRewardStorageTime(period.StartAt, s.storageLocation) + endAt := rechargeRewardStorageTime(period.EndAt, s.storageLocation) + coinSellerGoldPerUSD := s.cfg.RechargeReward.CoinSellerGoldPerUSD + if coinSellerGoldPerUSD <= 0 { + coinSellerGoldPerUSD = 100000 + } + + const query = ` +SELECT CAST(IFNULL(SUM(source.amount_cents), 0) AS SIGNED) AS recharge_amount_cents +FROM ( + SELECT CAST(ROUND(SUM(IFNULL(t.unit_price, 0)) * 100, 0) AS SIGNED) AS amount_cents + FROM order_purchase_history AS t + INNER JOIN user_base_info AS u ON u.id = t.user_id + WHERE t.evn = 'PROD' + AND t.is_trial_period = 0 + AND t.status = 'COMPLETE' + AND t.pay_platform IN ? + AND (u.is_del = 0 OR u.is_del IS NULL) + AND UPPER(TRIM(u.origin_sys)) = ? + AND t.user_id = ? + AND t.create_time >= ? + AND t.create_time < ? + UNION ALL + SELECT CAST(ROUND(SUM(IFNULL(t.compute_usd_amount, 0)) * 100, 0) AS SIGNED) AS amount_cents + FROM order_user_purchase_pay AS t + INNER JOIN user_base_info AS u ON u.id = t.user_id + WHERE t.evn = 'PROD' + AND t.factory_code = 'MIFA_PAY' + AND t.pay_status = 'SUCCESSFUL' + AND t.receipt_type = 'PAYMENT' + AND t.refund_status = 'NONE' + AND (u.is_del = 0 OR u.is_del IS NULL) + AND UPPER(TRIM(u.origin_sys)) = ? + AND t.user_id = ? + AND IFNULL(t.update_time, t.create_time) >= ? + AND IFNULL(t.update_time, t.create_time) < ? + UNION ALL + SELECT CAST(ROUND(SUM(IFNULL(t.quantity, 0) * 100 / ?), 0) AS SIGNED) AS amount_cents + FROM likei_wallet.user_freight_balance_running_water AS t + INNER JOIN user_base_info AS u ON u.id = t.accept_user_id + WHERE t.origin = 'SHIPMENT' + AND t.type = 1 + AND t.accept_user_id IS NOT NULL + AND IFNULL(t.quantity, 0) > 0 + AND (u.is_del = 0 OR u.is_del IS NULL) + AND UPPER(TRIM(u.origin_sys)) = ? + AND t.accept_user_id = ? + AND t.create_time >= ? + AND t.create_time < ? +) AS source` + + var row rechargeRewardAmountRow + if err := s.db.WithContext(ctx).Raw( + query, + rechargeRewardOrderPlatforms, + sysOrigin, + userID, + startAt, + endAt, + sysOrigin, + userID, + startAt, + endAt, + coinSellerGoldPerUSD, + sysOrigin, + userID, + startAt, + endAt, + ).Scan(&row).Error; err != nil { + return 0, err + } + if row.RechargeAmountCents < 0 { + return 0, nil + } + return row.RechargeAmountCents, nil +} diff --git a/internal/service/rechargereward/types.go b/internal/service/rechargereward/types.go index e5bb664..d5fa380 100644 --- a/internal/service/rechargereward/types.go +++ b/internal/service/rechargereward/types.go @@ -41,10 +41,11 @@ type rechargeRewardJavaGateway interface { // Service 负责充值奖励后台配置和 H5 用户态数据。 type Service struct { - cfg config.Config - db rechargeRewardDB - java rechargeRewardJavaGateway - location *time.Location + cfg config.Config + db rechargeRewardDB + java rechargeRewardJavaGateway + location *time.Location + storageLocation *time.Location } func NewService(cfg config.Config, db rechargeRewardDB, java rechargeRewardJavaGateway) *Service { @@ -56,7 +57,15 @@ func NewService(cfg config.Config, db rechargeRewardDB, java rechargeRewardJavaG if err != nil { location = time.UTC } - return &Service{cfg: cfg, db: db, java: java, location: location} + storageTimezone := strings.TrimSpace(cfg.RechargeReward.StorageTimezone) + if storageTimezone == "" { + storageTimezone = "Asia/Shanghai" + } + storageLocation, err := time.LoadLocation(storageTimezone) + if err != nil { + storageLocation = location + } + return &Service{cfg: cfg, db: db, java: java, location: location, storageLocation: storageLocation} } // RechargeRewardLevelPayload 是前后台共用的充值奖励档位读模型。 diff --git a/migrations/048_recharge_reward_grant_record.sql b/migrations/048_recharge_reward_grant_record.sql new file mode 100644 index 0000000..c11bbde --- /dev/null +++ b/migrations/048_recharge_reward_grant_record.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS `recharge_reward_grant_record` ( + `id` bigint NOT NULL COMMENT '主键ID', + `config_id` bigint NOT NULL COMMENT '充值奖励主配置ID', + `level_id` bigint NOT NULL COMMENT '充值奖励档位ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `cycle_key` varchar(32) NOT NULL COMMENT '充值周期标识,周期开始日yyyyMMdd', + `recharge_date` int NOT NULL COMMENT '充值年月yyyyMM,用于后台筛选', + `period_start_at` datetime(3) NOT NULL COMMENT '周期开始时间,活动时区墙上时间', + `period_end_at` datetime(3) NOT NULL COMMENT '周期结束时间,活动时区墙上时间', + `user_id` bigint NOT NULL COMMENT '用户ID', + `account` varchar(64) NOT NULL DEFAULT '' COMMENT '用户账号快照', + `user_avatar` varchar(1024) NOT NULL DEFAULT '' COMMENT '用户头像快照', + `user_nickname` varchar(255) NOT NULL DEFAULT '' COMMENT '用户昵称快照', + `country_code` varchar(32) NOT NULL DEFAULT '' COMMENT '国家编码快照', + `country_name` varchar(128) NOT NULL DEFAULT '' COMMENT '国家名称快照', + `recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '发放时周期累计充值金额,单位为分', + `level` int NOT NULL COMMENT '奖励档位', + `recharge_threshold_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分', + `reward_gold` bigint NOT NULL DEFAULT '0' COMMENT '金币奖励数量', + `reward_group_id` bigint DEFAULT NULL COMMENT '奖励组ID', + `reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励组名称快照', + `reward_group_track_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励组发放幂等追踪ID', + `gold_event_id` varchar(128) NOT NULL DEFAULT '' COMMENT '金币发放钱包事件ID', + `status` varchar(32) NOT NULL DEFAULT 'PENDING' COMMENT '整体发放状态:PENDING/SUCCESS/FAILED', + `reward_group_status` varchar(32) NOT NULL DEFAULT 'SKIPPED' COMMENT '奖励组发放状态:PENDING/SUCCESS/FAILED/SKIPPED', + `reward_gold_status` varchar(32) NOT NULL DEFAULT 'SKIPPED' COMMENT '金币发放状态:PENDING/SUCCESS/FAILED/SKIPPED', + `retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数', + `last_error` varchar(1024) NOT NULL DEFAULT '' COMMENT '最近一次失败原因', + `sent_at` datetime(3) DEFAULT NULL COMMENT '整体发放成功时间', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_recharge_reward_grant_level` (`sys_origin`, `cycle_key`, `user_id`, `level`), + KEY `idx_recharge_reward_grant_config_cycle` (`config_id`, `cycle_key`, `status`), + KEY `idx_recharge_reward_grant_user` (`user_id`, `recharge_date`), + KEY `idx_recharge_reward_grant_date` (`sys_origin`, `recharge_date`, `status`), + KEY `idx_recharge_reward_gold_event` (`gold_event_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值奖励周期发放记录';