充值奖励结算

This commit is contained in:
hy001 2026-05-21 11:14:55 +08:00
parent 810dc0345b
commit 74063d0908
9 changed files with 467 additions and 194 deletions

View File

@ -81,8 +81,11 @@ type InviteConfig struct {
// RechargeRewardConfig 保存充值奖励活动配置。 // RechargeRewardConfig 保存充值奖励活动配置。
type RechargeRewardConfig struct { type RechargeRewardConfig struct {
DefaultSysOrigin string DefaultSysOrigin string
Timezone string Timezone string
StorageTimezone string
StartDate string
CoinSellerGoldPerUSD int64
} }
// TaskCenterConfig 保存任务中心模块配置。 // TaskCenterConfig 保存任务中心模块配置。
@ -313,6 +316,18 @@ func Load() Config {
[]string{"CHATAPP_RECHARGE_REWARD_TIMEZONE", "RECHARGE_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"}, []string{"CHATAPP_RECHARGE_REWARD_TIMEZONE", "RECHARGE_REWARD_TIMEZONE", "CHATAPP_WEEK_STAR_TIMEZONE", "WEEK_STAR_TIMEZONE"},
"Asia/Riyadh", "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{ TaskCenter: TaskCenterConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny( DefaultSysOrigin: strings.ToUpper(getEnvAny(

View File

@ -29,3 +29,39 @@ type RechargeRewardLevel struct {
} }
func (RechargeRewardLevel) TableName() string { return "recharge_reward_level" } 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" }

View File

@ -2,48 +2,33 @@ package rechargereward
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"math"
"net/http" "net/http"
"strings" "strings"
"time" "time"
) )
var personalMonthlyRechargeTypes = []string{
"UNDEFINED",
"HUAWEI",
"GOOGLE",
"APPLE",
"PAYER_MAX",
"SHIPPING_AGENT",
"STRIPE",
"SALARY_EXCHANGE",
"PAY_PAL",
"CLIPSPAY",
"SELLER_AGENT",
}
type rechargeRewardClaimRecordRow struct { type rechargeRewardClaimRecordRow struct {
UserID int64 `gorm:"column:user_id"` UserID int64 `gorm:"column:user_id"`
RechargeAmount string `gorm:"column:recharge_amount"` RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"`
LastRechargeTime string `gorm:"column:last_recharge_time"` LastRechargeTime string `gorm:"column:last_recharge_time"`
LastClaimTime string `gorm:"column:last_claim_time"` LastClaimTime string `gorm:"column:last_claim_time"`
Account string `gorm:"column:account"` Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"` UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"` UserNickname string `gorm:"column:user_nickname"`
CountryCode string `gorm:"column:country_code"` CountryCode string `gorm:"column:country_code"`
CountryName string `gorm:"column:country_name"` CountryName string `gorm:"column:country_name"`
} }
type rechargeRewardClaimDetailRow struct { type rechargeRewardClaimDetailRow struct {
UserID int64 `gorm:"column:user_id"` UserID int64 `gorm:"column:user_id"`
ActivityID int64 `gorm:"column:activity_id"` ActivityID int64 `gorm:"column:activity_id"`
Level int `gorm:"column:level"` Level int `gorm:"column:level"`
ResourceGroupID *int64 `gorm:"column:resource_group_id"` ResourceGroupID *int64 `gorm:"column:resource_group_id"`
RuleDescription string `gorm:"column:rule_description"` RewardGroupName string `gorm:"column:reward_group_name"`
JSONData string `gorm:"column:json_data"` RechargeThresholdCents int64 `gorm:"column:recharge_threshold_cents"`
ClaimTime string `gorm:"column:claim_time"` RewardGold int64 `gorm:"column:reward_gold"`
ClaimTime string `gorm:"column:claim_time"`
} }
// PageClaimRecords 分页返回真实领取过累计充值奖励的用户。 // PageClaimRecords 分页返回真实领取过累计充值奖励的用户。
@ -67,12 +52,10 @@ func (s *Service) PageClaimRecords(ctx context.Context, sysOrigin string, month
var total int64 var total int64
countSQL := ` countSQL := `
SELECT COUNT(DISTINCT receive.user_id) SELECT COUNT(DISTINCT receive.user_id)
FROM user_activity_reward_receive_record AS receive FROM recharge_reward_grant_record AS receive
INNER JOIN props_activity_rule_config AS rule ON rule.id = receive.activity_id
WHERE receive.recharge_date = ? WHERE receive.recharge_date = ?
AND rule.sys_origin = ? AND receive.sys_origin = ?
AND rule.activity_type = 'CUMULATIVE_RECHARGE' AND receive.status = 'SUCCESS'
AND COALESCE(rule.is_del, 0) = 0
AND (? = 0 OR receive.user_id = ?)` AND (? = 0 OR receive.user_id = ?)`
if err := s.db.WithContext(ctx).Raw( if err := s.db.WithContext(ctx).Raw(
countSQL, countSQL,
@ -90,40 +73,34 @@ WHERE receive.recharge_date = ?
listSQL := ` listSQL := `
SELECT SELECT
claims.user_id, claims.user_id,
COALESCE(recharge.recharge_amount, 0) AS recharge_amount, claims.recharge_amount_cents,
COALESCE(recharge.last_recharge_time, '') AS last_recharge_time, claims.last_recharge_time,
claims.last_claim_time, claims.last_claim_time,
user.account, claims.account,
user.user_avatar, claims.user_avatar,
user.user_nickname, claims.user_nickname,
user.country_code, claims.country_code,
user.country_name claims.country_name
FROM ( FROM (
SELECT SELECT
receive.user_id, receive.user_id,
MAX(receive.create_time) AS last_claim_time MAX(receive.recharge_amount_cents) AS recharge_amount_cents,
FROM user_activity_reward_receive_record AS receive COALESCE(MAX(receive.sent_at), MAX(receive.update_time), '') AS last_recharge_time,
INNER JOIN props_activity_rule_config AS rule ON rule.id = receive.activity_id 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 = ? WHERE receive.recharge_date = ?
AND rule.sys_origin = ? AND receive.sys_origin = ?
AND rule.activity_type = 'CUMULATIVE_RECHARGE' AND receive.status = 'SUCCESS'
AND COALESCE(rule.is_del, 0) = 0
AND (? = 0 OR receive.user_id = ?) AND (? = 0 OR receive.user_id = ?)
GROUP BY 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 ? LIMIT ? OFFSET ?
) AS claims ) 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` ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
if err := s.db.WithContext(ctx).Raw( if err := s.db.WithContext(ctx).Raw(
listSQL, listSQL,
@ -133,8 +110,6 @@ ORDER BY claims.last_claim_time DESC, claims.user_id ASC`
userID, userID,
limit, limit,
(cursor-1)*limit, (cursor-1)*limit,
rechargeDate,
personalMonthlyRechargeTypes,
).Scan(&rows).Error; err != nil { ).Scan(&rows).Error; err != nil {
return nil, err return nil, err
} }
@ -207,7 +182,6 @@ func rechargeRewardClaimRecordView(
details []rechargeRewardClaimDetailRow, details []rechargeRewardClaimDetailRow,
rewardItems map[int64][]RechargeRewardRewardItem, rewardItems map[int64][]RechargeRewardRewardItem,
) RechargeRewardClaimRecordView { ) RechargeRewardClaimRecordView {
rechargeCents := parseIntegrationAmountCents(integrationAmount(row.RechargeAmount))
reached := claimDetailPayloads(details, rewardItems) reached := claimDetailPayloads(details, rewardItems)
return RechargeRewardClaimRecordView{ return RechargeRewardClaimRecordView{
RecordKey: fmt.Sprintf("%d-%d", row.UserID, rechargeDate), RecordKey: fmt.Sprintf("%d-%d", row.UserID, rechargeDate),
@ -219,8 +193,8 @@ func rechargeRewardClaimRecordView(
CountryName: strings.TrimSpace(row.CountryName), CountryName: strings.TrimSpace(row.CountryName),
RechargeMonth: month, RechargeMonth: month,
RechargeDate: rechargeDate, RechargeDate: rechargeDate,
RechargeAmount: formatAmountCents(rechargeCents), RechargeAmount: formatAmountCents(row.RechargeAmountCents),
RechargeAmountCents: rechargeCents, RechargeAmountCents: row.RechargeAmountCents,
ReachedLevels: reached, ReachedLevels: reached,
ClaimedRewards: rechargeRewardClaimedRewards(reached), ClaimedRewards: rechargeRewardClaimedRewards(reached),
LastRechargeTime: formatRechargeRewardRecordTime(row.LastRechargeTime), LastRechargeTime: formatRechargeRewardRecordTime(row.LastRechargeTime),
@ -235,20 +209,19 @@ func (s *Service) loadClaimDetails(ctx context.Context, sysOrigin string, rechar
detailSQL := ` detailSQL := `
SELECT SELECT
receive.user_id, receive.user_id,
receive.activity_id, receive.level_id AS activity_id,
COALESCE(rule.sort, 0) AS level, receive.level,
rule.resource_group_id, receive.reward_group_id AS resource_group_id,
rule.rule_description, receive.reward_group_name,
rule.json_data, receive.recharge_threshold_cents,
receive.create_time AS claim_time receive.reward_gold,
FROM user_activity_reward_receive_record AS receive COALESCE(receive.sent_at, receive.create_time) AS claim_time
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 = ? WHERE receive.recharge_date = ?
AND rule.sys_origin = ? AND receive.sys_origin = ?
AND rule.activity_type = 'CUMULATIVE_RECHARGE' AND receive.status = 'SUCCESS'
AND COALESCE(rule.is_del, 0) = 0
AND receive.user_id IN ? 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 { if err := s.db.WithContext(ctx).Raw(detailSQL, rechargeDate, sysOrigin, userIDs).Scan(&rows).Error; err != nil {
return nil, err return nil, err
} }
@ -310,14 +283,14 @@ func claimDetailPayloads(
if detail.ResourceGroupID != nil { if detail.ResourceGroupID != nil {
items = rewardItems[*detail.ResourceGroupID] items = rewardItems[*detail.ResourceGroupID]
} }
thresholdCents := rechargeRewardRuleQuantityCents(detail.JSONData)
result = append(result, RechargeRewardLevelPayload{ result = append(result, RechargeRewardLevelPayload{
ID: detail.ActivityID, ID: detail.ActivityID,
Level: level, Level: level,
RechargeAmount: formatAmountCents(thresholdCents), RechargeAmount: formatAmountCents(detail.RechargeThresholdCents),
RechargeAmountCents: thresholdCents, RechargeAmountCents: detail.RechargeThresholdCents,
RewardGold: detail.RewardGold,
RewardGroupID: detail.ResourceGroupID, RewardGroupID: detail.ResourceGroupID,
RewardGroupName: strings.TrimSpace(detail.RuleDescription), RewardGroupName: strings.TrimSpace(detail.RewardGroupName),
RewardItems: items, RewardItems: items,
Enabled: true, Enabled: true,
Reached: true, Reached: true,
@ -326,33 +299,6 @@ func claimDetailPayloads(
return result 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( func reachedRechargeRewardLevels(
levels []rechargeRewardLevelSnapshot, levels []rechargeRewardLevelSnapshot,
rewardItems map[int64][]RechargeRewardRewardItem, rewardItems map[int64][]RechargeRewardRewardItem,

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"testing" "testing"
"time"
"chatapp3-golang/internal/config" "chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration" "chatapp3-golang/internal/integration"
@ -22,14 +23,21 @@ func newTestService(t *testing.T, java rechargeRewardJavaGateway) (*Service, *go
if err := db.AutoMigrate( if err := db.AutoMigrate(
&model.RechargeRewardConfig{}, &model.RechargeRewardConfig{},
&model.RechargeRewardLevel{}, &model.RechargeRewardLevel{},
&model.RechargeRewardGrantRecord{},
&model.UserBaseInfo{}, &model.UserBaseInfo{},
); err != nil { ); err != nil {
t.Fatalf("auto migrate: %v", err) 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{ service := NewService(config.Config{
RechargeReward: config.RechargeRewardConfig{ RechargeReward: config.RechargeRewardConfig{
DefaultSysOrigin: "LIKEI", DefaultSysOrigin: "LIKEI",
Timezone: "Asia/Riyadh", Timezone: "Asia/Riyadh",
StorageTimezone: "Asia/Shanghai",
StartDate: "2020-01-01",
CoinSellerGoldPerUSD: 100000,
}, },
}, db, java) }, db, java)
return service, db return service, db
@ -107,7 +115,6 @@ func TestSaveConfigUpsertsBySysOrigin(t *testing.T) {
func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) { func TestGetHomeUsesJavaRechargeAndMarksLevels(t *testing.T) {
groupID := int64(9001) groupID := int64(9001)
java := rechargeRewardTestGateway{ java := rechargeRewardTestGateway{
monthly: integration.DecimalString("150.50"),
total: map[int64][]integration.UserTotalRecharge{ total: map[int64][]integration.UserTotalRecharge{
42: { 42: {
{UserID: integration.Int64Value(42), Type: "GOOGLE", Amount: integration.DecimalString("5000.00")}, {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{ if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
Enabled: true, 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{ if _, err := service.SaveConfig(context.Background(), SaveRechargeRewardConfigRequest{
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
Enabled: true, Enabled: true,
@ -228,25 +232,33 @@ CREATE TABLE props_activity_rule_config (
}).Error; err != nil { }).Error; err != nil {
t.Fatalf("create user: %v", err) t.Fatalf("create user: %v", err)
} }
if err := db.Exec(` if err := db.Create(&model.RechargeRewardGrantRecord{
INSERT INTO user_monthly_recharge_v2 (user_id, type, amount, recharge_date, update_time) VALUES ID: 8001,
(42, 'GOOGLE', 150.50, 202605, '2026-05-07 10:00:00'), ConfigID: 1001,
(42, 'MIFA_PAY', 70.00, 202605, '2026-05-07 11:00:00'), LevelID: 7001,
(43, 'GOOGLE', 300.00, 202605, '2026-05-07 12:00:00') SysOrigin: "LIKEI",
`).Error; err != nil { CycleKey: "20260521",
t.Fatalf("insert monthly recharge: %v", err) RechargeDate: 202605,
} PeriodStartAt: time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC),
if err := db.Exec(` PeriodEndAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
INSERT INTO props_activity_rule_config (id, sys_origin, activity_type, resource_group_id, json_data, sort, rule_description, is_del) VALUES UserID: 42,
(7001, 'LIKEI', 'CUMULATIVE_RECHARGE', 9001, '{"quantity":100}', 1, 'Gift Box', 0) Account: "1000042",
`).Error; err != nil { UserNickname: "Nina",
t.Fatalf("insert activity rule: %v", err) CountryCode: "SA",
} CountryName: "Saudi Arabia",
if err := db.Exec(` RechargeAmountCents: 15050,
INSERT INTO user_activity_reward_receive_record (id, user_id, recharge_date, activity_id, create_time) VALUES Level: 1,
(8001, 42, 202605, 7001, '2026-05-07 10:30:00') RechargeThresholdCents: 10000,
`).Error; err != nil { RewardGroupID: &groupID,
t.Fatalf("insert receive record: %v", err) 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) 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} 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 { type rechargeRewardTestGateway struct {
monthly integration.DecimalString monthly integration.DecimalString
total map[int64][]integration.UserTotalRecharge total map[int64][]integration.UserTotalRecharge

View File

@ -26,11 +26,15 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
location = resolveLocation(bundle.Config.Timezone, s.location) location = resolveLocation(bundle.Config.Timezone, s.location)
} }
now := time.Now().In(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) var currentRechargeCents int64
if err != nil { if periodActive {
return nil, err currentRechargeCents, err = s.loadCurrentRechargeCents(ctx, sysOrigin, user.UserID, currentPeriod)
if err != nil {
return nil, err
}
} }
accumulatedRechargeCents, err := s.loadAccumulatedRechargeCents(ctx, user.UserID) accumulatedRechargeCents, err := s.loadAccumulatedRechargeCents(ctx, user.UserID)
if err != nil { if err != nil {
@ -43,9 +47,9 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
Enabled: bundle.Config != nil && bundle.Config.Enabled, Enabled: bundle.Config != nil && bundle.Config.Enabled,
SysOrigin: sysOrigin, SysOrigin: sysOrigin,
Timezone: location.String(), Timezone: location.String(),
PeriodStartAt: formatDateTime(periodStart), PeriodStartAt: formatDateTime(period.StartAt),
PeriodEndAt: formatDateTime(periodEnd), PeriodEndAt: formatDateTime(period.EndAt),
CountdownMillis: maxInt64(0, periodEnd.UnixMilli()-now.UnixMilli()), CountdownMillis: maxInt64(0, period.EndAt.UnixMilli()-now.UnixMilli()),
UserID: user.UserID, UserID: user.UserID,
CurrentRechargeAmount: formatAmountCents(currentRechargeCents), CurrentRechargeAmount: formatAmountCents(currentRechargeCents),
CurrentRechargeAmountCents: currentRechargeCents, CurrentRechargeAmountCents: currentRechargeCents,
@ -88,14 +92,6 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*RechargeRewardHo
return resp, nil 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) { func (s *Service) loadAccumulatedRechargeCents(ctx context.Context, userID int64) (int64, error) {
recharges, err := s.java.MapUserTotalRecharge(ctx, []int64{userID}) recharges, err := s.java.MapUserTotalRecharge(ctx, []int64{userID})
if err != nil { if err != nil {
@ -104,12 +100,6 @@ func (s *Service) loadAccumulatedRechargeCents(ctx context.Context, userID int64
return totalRechargeCents(recharges[userID]), nil 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 { func resolveLocation(timezone string, fallback *time.Location) *time.Location {
location, err := time.LoadLocation(normalizeRechargeRewardTimezone(timezone)) location, err := time.LoadLocation(normalizeRechargeRewardTimezone(timezone))
if err == nil { if err == nil {

View File

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

View File

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

View File

@ -41,10 +41,11 @@ type rechargeRewardJavaGateway interface {
// Service 负责充值奖励后台配置和 H5 用户态数据。 // Service 负责充值奖励后台配置和 H5 用户态数据。
type Service struct { type Service struct {
cfg config.Config cfg config.Config
db rechargeRewardDB db rechargeRewardDB
java rechargeRewardJavaGateway java rechargeRewardJavaGateway
location *time.Location location *time.Location
storageLocation *time.Location
} }
func NewService(cfg config.Config, db rechargeRewardDB, java rechargeRewardJavaGateway) *Service { 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 { if err != nil {
location = time.UTC 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 是前后台共用的充值奖励档位读模型。 // RechargeRewardLevelPayload 是前后台共用的充值奖励档位读模型。

View File

@ -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='充值奖励周期发放记录';