506 lines
26 KiB
Go
506 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
)
|
|
|
|
const (
|
|
appCode = "lalu"
|
|
operatorAdminID = int64(900100100)
|
|
rechargeSource = "cumulative_recharge_reward"
|
|
groupItemWalletAsset = "wallet_asset"
|
|
assetCoin = "COIN"
|
|
statusActive = "active"
|
|
statusDisabled = "disabled"
|
|
tierStatusInactive = "inactive"
|
|
)
|
|
|
|
type walletOutboxFact struct {
|
|
EventID string
|
|
TransactionID string
|
|
CommandID string
|
|
PayloadJSON string
|
|
CreatedAtMS int64
|
|
AvailableDelta int64
|
|
}
|
|
|
|
type cycle struct {
|
|
Key string
|
|
StartMS int64
|
|
EndMS int64
|
|
}
|
|
|
|
func main() {
|
|
var (
|
|
walletAddr = flag.String("wallet_addr", "127.0.0.1:13004", "wallet-service gRPC address")
|
|
activityAddr = flag.String("activity_addr", "127.0.0.1:13006", "activity-service gRPC address")
|
|
activityDSN = flag.String("activity_dsn", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC", "activity MySQL DSN")
|
|
walletDSN = flag.String("wallet_dsn", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", "wallet MySQL DSN")
|
|
mode = flag.String("mode", "happy", "flow mode: happy or boundary")
|
|
)
|
|
flag.Parse()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
walletConn, err := grpc.DialContext(ctx, *walletAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
must("dial wallet-service", err)
|
|
defer walletConn.Close()
|
|
activityConn, err := grpc.DialContext(ctx, *activityAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
must("dial activity-service", err)
|
|
defer activityConn.Close()
|
|
|
|
activityDB, err := sql.Open("mysql", *activityDSN)
|
|
must("open activity mysql", err)
|
|
defer activityDB.Close()
|
|
must("ping activity mysql", activityDB.PingContext(ctx))
|
|
walletDB, err := sql.Open("mysql", *walletDSN)
|
|
must("open wallet mysql", err)
|
|
defer walletDB.Close()
|
|
must("ping wallet mysql", walletDB.PingContext(ctx))
|
|
|
|
walletClient := walletv1.NewWalletServiceClient(walletConn)
|
|
rewardClient := activityv1.NewCumulativeRechargeRewardServiceClient(activityConn)
|
|
adminClient := activityv1.NewAdminCumulativeRechargeRewardServiceClient(activityConn)
|
|
|
|
if *mode == "boundary" {
|
|
runBoundaryFlow(ctx, walletClient, rewardClient, adminClient, activityDB, walletDB)
|
|
return
|
|
}
|
|
|
|
runID := fmt.Sprintf("crr_%d", time.Now().UnixMilli())
|
|
targetUserID := int64(760000000000) + time.Now().Unix()%10000000
|
|
sellerUserID := targetUserID + 100000000
|
|
now := time.Now().UTC()
|
|
currentCycle := cycleForTime(now)
|
|
nextCycle := cycleForTime(time.UnixMilli(currentCycle.EndMS).UTC().Add(2 * time.Hour))
|
|
|
|
group100 := createCoinRewardGroup(ctx, walletClient, runID, "100", 1000)
|
|
group200 := createCoinRewardGroup(ctx, walletClient, runID, "200", 2000)
|
|
updateConfig(ctx, adminClient, runID, group100, group200)
|
|
|
|
google := consumeRecharge(ctx, rewardClient, runID, "google", targetUserID, "google_play", 5_400_000, 6_000, now.Add(1*time.Minute))
|
|
assert(len(google.Grants) == 0 && google.GetReason() == "no_tier_matched", "google $60 should not grant a tier")
|
|
statusAfterGoogle := getStatus(ctx, rewardClient, targetUserID)
|
|
assert(statusAfterGoogle.GetProgress().GetTotalUsdMinor() == 6_000, "google progress should be $60.00")
|
|
|
|
mifapay := consumeRecharge(ctx, rewardClient, runID, "mifapay", targetUserID, "mifapay", 4_500_000, 5_000, now.Add(2*time.Minute))
|
|
assertGrantThresholds("mifapay should unlock only $100 tier", mifapay.GetGrants(), []int64{10_000})
|
|
statusAfterMifapay := getStatus(ctx, rewardClient, targetUserID)
|
|
assert(statusAfterMifapay.GetProgress().GetTotalUsdMinor() == 11_000, "google+mifapay progress should be $110.00")
|
|
|
|
transfer := runCoinSellerTransfer(ctx, walletClient, walletDB, runID, sellerUserID, targetUserID)
|
|
coinSellerUSDMinor := transfer.AvailableDelta * 100 / 90_000
|
|
assert(transfer.AvailableDelta == 9_000_000 && coinSellerUSDMinor == 10_000, "coin seller transfer should convert 9,000,000 coins to $100.00")
|
|
coinSeller := consumeRechargeFromWalletFact(ctx, rewardClient, targetUserID, transfer, coinSellerUSDMinor)
|
|
assertGrantThresholds("coin seller transfer should unlock only $200 tier", coinSeller.GetGrants(), []int64{20_000})
|
|
statusAfterCoinSeller := getStatus(ctx, rewardClient, targetUserID)
|
|
assert(statusAfterCoinSeller.GetProgress().GetTotalUsdMinor() == 21_000, "current cycle progress should be $210.00")
|
|
|
|
nextWeek := consumeRecharge(ctx, rewardClient, runID, "nextweek-google", targetUserID, "google_play", 9_000_000, 10_000, time.UnixMilli(nextCycle.StartMS).UTC().Add(time.Hour))
|
|
assertGrantThresholds("next week $100 should unlock only new $100 tier", nextWeek.GetGrants(), []int64{10_000})
|
|
|
|
currentProgress := queryProgress(ctx, activityDB, targetUserID, currentCycle.Key)
|
|
nextProgress := queryProgress(ctx, activityDB, targetUserID, nextCycle.Key)
|
|
assert(currentProgress == 21_000, "current cycle DB progress should stay $210.00")
|
|
assert(nextProgress == 10_000, "next cycle DB progress should reset to $100.00")
|
|
|
|
currentGrants := queryGrantSummary(ctx, activityDB, targetUserID, currentCycle.Key)
|
|
nextGrants := queryGrantSummary(ctx, activityDB, targetUserID, nextCycle.Key)
|
|
assert(currentGrants.Count == 2 && currentGrants.Granted == 2, "current cycle should have two granted tiers")
|
|
assert(nextGrants.Count == 1 && nextGrants.Granted == 1, "next cycle should have one granted tier")
|
|
|
|
walletReward := queryWalletRewardSummary(ctx, walletDB, targetUserID)
|
|
assert(walletReward.Count == 3 && walletReward.CoinDelta == 4_000, "wallet should receive three reward group grants totaling 4,000 COIN")
|
|
|
|
out := map[string]any{
|
|
"run_id": runID,
|
|
"target_user_id": targetUserID,
|
|
"seller_user_id": sellerUserID,
|
|
"current_cycle": currentCycle.Key,
|
|
"next_cycle": nextCycle.Key,
|
|
"events": []map[string]any{
|
|
{"type": "google_play", "usd_minor": 6000, "grant_thresholds": thresholds(google.GetGrants())},
|
|
{"type": "mifapay", "usd_minor": 5000, "grant_thresholds": thresholds(mifapay.GetGrants())},
|
|
{"type": "coin_seller_transfer", "coins": transfer.AvailableDelta, "usd_minor": coinSellerUSDMinor, "grant_thresholds": thresholds(coinSeller.GetGrants())},
|
|
{"type": "next_week_google_play", "usd_minor": 10000, "grant_thresholds": thresholds(nextWeek.GetGrants())},
|
|
},
|
|
"progress_usd_minor": map[string]int64{currentCycle.Key: currentProgress, nextCycle.Key: nextProgress},
|
|
"activity_grants": map[string]grantSummary{currentCycle.Key: currentGrants, nextCycle.Key: nextGrants},
|
|
"wallet_reward": walletReward,
|
|
}
|
|
encoded, _ := json.MarshalIndent(out, "", " ")
|
|
fmt.Println(string(encoded))
|
|
}
|
|
|
|
func createCoinRewardGroup(ctx context.Context, client walletv1.WalletServiceClient, runID string, suffix string, coinAmount int64) int64 {
|
|
resp, err := client.CreateResourceGroup(ctx, &walletv1.CreateResourceGroupRequest{
|
|
RequestId: fmt.Sprintf("%s_create_group_%s", runID, suffix),
|
|
AppCode: appCode,
|
|
GroupCode: fmt.Sprintf("%s_group_%s", runID, suffix),
|
|
Name: fmt.Sprintf("CRR local reward %s", suffix),
|
|
Status: statusActive,
|
|
Description: "local cumulative recharge flow verification",
|
|
SortOrder: 1,
|
|
OperatorUserId: operatorAdminID,
|
|
Items: []*walletv1.ResourceGroupItemInput{{
|
|
ItemType: groupItemWalletAsset,
|
|
WalletAssetType: assetCoin,
|
|
WalletAssetAmount: coinAmount,
|
|
SortOrder: 1,
|
|
}},
|
|
})
|
|
must("create wallet reward group "+suffix, err)
|
|
assert(resp.GetGroup().GetGroupId() > 0, "created resource group id should be positive")
|
|
return resp.GetGroup().GetGroupId()
|
|
}
|
|
|
|
func updateConfig(ctx context.Context, client activityv1.AdminCumulativeRechargeRewardServiceClient, runID string, group100 int64, group200 int64) {
|
|
_, err := updateConfigWithTiers(ctx, client, runID, true, []*activityv1.CumulativeRechargeRewardTier{
|
|
{TierCode: runID + "_100", TierName: "Local $100", ThresholdUsdMinor: 10_000, ResourceGroupId: group100, Status: statusActive, SortOrder: 1},
|
|
{TierCode: runID + "_200", TierName: "Local $200", ThresholdUsdMinor: 20_000, ResourceGroupId: group200, Status: statusActive, SortOrder: 2},
|
|
})
|
|
must("update cumulative recharge config", err)
|
|
}
|
|
|
|
func updateConfigWithTiers(ctx context.Context, client activityv1.AdminCumulativeRechargeRewardServiceClient, runID string, enabled bool, tiers []*activityv1.CumulativeRechargeRewardTier) (*activityv1.UpdateCumulativeRechargeRewardConfigResponse, error) {
|
|
return client.UpdateCumulativeRechargeRewardConfig(ctx, &activityv1.UpdateCumulativeRechargeRewardConfigRequest{
|
|
Meta: meta(runID + "_update_config"),
|
|
Enabled: enabled,
|
|
OperatorAdminId: operatorAdminID,
|
|
Tiers: tiers,
|
|
})
|
|
}
|
|
|
|
func consumeRecharge(ctx context.Context, client activityv1.CumulativeRechargeRewardServiceClient, runID string, name string, userID int64, rechargeType string, coinAmount int64, usdMinor int64, occurredAt time.Time) *activityv1.ConsumeCumulativeRechargeRewardResponse {
|
|
resp, err := consumeRechargeAllowError(ctx, client, runID, name, userID, rechargeType, coinAmount, usdMinor, occurredAt)
|
|
must("consume recharge "+name, err)
|
|
return resp
|
|
}
|
|
|
|
func consumeRechargeAllowError(ctx context.Context, client activityv1.CumulativeRechargeRewardServiceClient, runID string, name string, userID int64, rechargeType string, coinAmount int64, usdMinor int64, occurredAt time.Time) (*activityv1.ConsumeCumulativeRechargeRewardResponse, error) {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"recharge_type": rechargeType,
|
|
"coin_amount": coinAmount,
|
|
"recharge_usd_minor": usdMinor,
|
|
})
|
|
resp, err := client.ConsumeCumulativeRechargeReward(ctx, &activityv1.ConsumeCumulativeRechargeRewardRequest{
|
|
Meta: meta(runID + "_" + name),
|
|
EventId: fmt.Sprintf("%s_event_%s", runID, name),
|
|
TransactionId: fmt.Sprintf("%s_tx_%s", runID, name),
|
|
CommandId: fmt.Sprintf("%s_cmd_%s", runID, name),
|
|
UserId: userID,
|
|
RechargeCoinAmount: coinAmount,
|
|
RechargeSequence: time.Now().UnixNano(),
|
|
RechargeType: rechargeType,
|
|
QualifyingUsdMinor: usdMinor,
|
|
PayloadJson: string(payload),
|
|
OccurredAtMs: occurredAt.UnixMilli(),
|
|
})
|
|
return resp, err
|
|
}
|
|
|
|
func consumeRechargeFromWalletFact(ctx context.Context, client activityv1.CumulativeRechargeRewardServiceClient, userID int64, fact walletOutboxFact, usdMinor int64) *activityv1.ConsumeCumulativeRechargeRewardResponse {
|
|
resp, err := client.ConsumeCumulativeRechargeReward(ctx, &activityv1.ConsumeCumulativeRechargeRewardRequest{
|
|
Meta: meta("coin_seller_wallet_fact"),
|
|
EventId: fact.EventID,
|
|
TransactionId: fact.TransactionID,
|
|
CommandId: fact.CommandID,
|
|
UserId: userID,
|
|
RechargeCoinAmount: fact.AvailableDelta,
|
|
RechargeType: "coin_seller_transfer",
|
|
QualifyingUsdMinor: usdMinor,
|
|
PayloadJson: fact.PayloadJSON,
|
|
OccurredAtMs: fact.CreatedAtMS,
|
|
})
|
|
must("consume coin seller wallet fact", err)
|
|
return resp
|
|
}
|
|
|
|
func runCoinSellerTransfer(ctx context.Context, client walletv1.WalletServiceClient, db *sql.DB, runID string, sellerUserID int64, targetUserID int64) walletOutboxFact {
|
|
_, err := client.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
|
|
CommandId: fmt.Sprintf("%s_stock", runID),
|
|
SellerUserId: sellerUserID,
|
|
StockType: "usdt_purchase",
|
|
CoinAmount: 9_000_000,
|
|
PaidCurrencyCode: "USDT",
|
|
PaidAmountMicro: 100_000_000,
|
|
PaymentRef: fmt.Sprintf("%s_payment", runID),
|
|
EvidenceRef: "local-flow",
|
|
OperatorUserId: operatorAdminID,
|
|
Reason: "local cumulative recharge flow",
|
|
AppCode: appCode,
|
|
})
|
|
must("credit coin seller stock", err)
|
|
transfer, err := client.TransferCoinFromSeller(ctx, &walletv1.TransferCoinFromSellerRequest{
|
|
CommandId: fmt.Sprintf("%s_seller_transfer", runID),
|
|
SellerUserId: sellerUserID,
|
|
TargetUserId: targetUserID,
|
|
Amount: 9_000_000,
|
|
Reason: "local cumulative recharge flow",
|
|
AppCode: appCode,
|
|
SellerRegionId: 1,
|
|
TargetRegionId: 1,
|
|
})
|
|
must("transfer coin from seller", err)
|
|
|
|
var fact walletOutboxFact
|
|
err = db.QueryRowContext(ctx, `
|
|
SELECT event_id, transaction_id, command_id, COALESCE(payload, '{}'), created_at_ms, available_delta
|
|
FROM wallet_outbox
|
|
WHERE app_code = ? AND transaction_id = ? AND user_id = ? AND event_type = 'WalletRechargeRecorded'
|
|
LIMIT 1`,
|
|
appCode, transfer.GetTransactionId(), targetUserID,
|
|
).Scan(&fact.EventID, &fact.TransactionID, &fact.CommandID, &fact.PayloadJSON, &fact.CreatedAtMS, &fact.AvailableDelta)
|
|
must("read coin seller WalletRechargeRecorded outbox", err)
|
|
return fact
|
|
}
|
|
|
|
func setResourceGroupStatus(ctx context.Context, client walletv1.WalletServiceClient, runID string, groupID int64, status string) {
|
|
_, err := client.SetResourceGroupStatus(ctx, &walletv1.SetResourceGroupStatusRequest{
|
|
RequestId: fmt.Sprintf("%s_group_status_%d_%s", runID, groupID, status),
|
|
AppCode: appCode,
|
|
GroupId: groupID,
|
|
Status: status,
|
|
OperatorUserId: operatorAdminID,
|
|
})
|
|
must("set resource group status "+status, err)
|
|
}
|
|
|
|
func getStatus(ctx context.Context, client activityv1.CumulativeRechargeRewardServiceClient, userID int64) *activityv1.CumulativeRechargeRewardStatus {
|
|
resp, err := client.GetCumulativeRechargeRewardStatus(ctx, &activityv1.GetCumulativeRechargeRewardStatusRequest{Meta: meta("get_status"), UserId: userID})
|
|
must("get cumulative recharge status", err)
|
|
return resp.GetStatus()
|
|
}
|
|
|
|
func runBoundaryFlow(ctx context.Context, walletClient walletv1.WalletServiceClient, rewardClient activityv1.CumulativeRechargeRewardServiceClient, adminClient activityv1.AdminCumulativeRechargeRewardServiceClient, activityDB *sql.DB, walletDB *sql.DB) {
|
|
runID := fmt.Sprintf("crr_boundary_%d", time.Now().UnixMilli())
|
|
baseUserID := int64(761000000000) + time.Now().Unix()%10000000
|
|
now := time.Now().UTC()
|
|
currentCycle := cycleForTime(now)
|
|
nextCycle := cycleForTime(time.UnixMilli(currentCycle.EndMS).UTC().Add(time.Millisecond))
|
|
|
|
group100 := createCoinRewardGroup(ctx, walletClient, runID, "100", 1000)
|
|
group200 := createCoinRewardGroup(ctx, walletClient, runID, "200", 2000)
|
|
|
|
updateConfig(ctx, adminClient, runID, group100, group200)
|
|
duplicate := consumeRecharge(ctx, rewardClient, runID, "duplicate", baseUserID+1, "google_play", 13_500_000, 15_000, now.Add(10*time.Minute))
|
|
assertGrantThresholds("duplicate first event should grant $100", duplicate.GetGrants(), []int64{10_000})
|
|
duplicateAgain := consumeRecharge(ctx, rewardClient, runID, "duplicate", baseUserID+1, "google_play", 13_500_000, 15_000, now.Add(10*time.Minute))
|
|
assert(duplicateAgain.GetReason() == "already_consumed" && len(duplicateAgain.GetGrants()) == 0, "duplicate event should be consumed without new grants")
|
|
assert(queryProgress(ctx, activityDB, baseUserID+1, currentCycle.Key) == 15_000, "duplicate event must not double progress")
|
|
assert(queryGrantSummary(ctx, activityDB, baseUserID+1, currentCycle.Key).Count == 1, "duplicate event must not double grant rows")
|
|
|
|
cross := consumeRecharge(ctx, rewardClient, runID, "cross-multiple", baseUserID+2, "mifapay", 22_500_000, 25_000, now.Add(11*time.Minute))
|
|
assertGrantThresholds("single large recharge should cross both tiers", cross.GetGrants(), []int64{10_000, 20_000})
|
|
assert(queryProgress(ctx, activityDB, baseUserID+2, currentCycle.Key) == 25_000, "cross-tier progress should be $250.00")
|
|
assert(queryWalletRewardSummary(ctx, walletDB, baseUserID+2).Count == 2, "cross-tier wallet should receive two reward grants")
|
|
|
|
_, err := updateConfigWithTiers(ctx, adminClient, runID+"_duplicate_threshold", true, []*activityv1.CumulativeRechargeRewardTier{
|
|
{TierCode: runID + "_dup_a", TierName: "Duplicate A", ThresholdUsdMinor: 10_000, ResourceGroupId: group100, Status: statusActive, SortOrder: 1},
|
|
{TierCode: runID + "_dup_b", TierName: "Duplicate B", ThresholdUsdMinor: 10_000, ResourceGroupId: group200, Status: statusActive, SortOrder: 2},
|
|
})
|
|
assert(err != nil, "duplicate active thresholds must be rejected")
|
|
|
|
_, err = updateConfigWithTiers(ctx, adminClient, runID+"_disabled", false, []*activityv1.CumulativeRechargeRewardTier{
|
|
{TierCode: runID + "_disabled_100", TierName: "Disabled $100", ThresholdUsdMinor: 10_000, ResourceGroupId: group100, Status: statusActive, SortOrder: 1},
|
|
{TierCode: runID + "_disabled_200", TierName: "Disabled $200", ThresholdUsdMinor: 20_000, ResourceGroupId: group200, Status: statusActive, SortOrder: 2},
|
|
})
|
|
must("disable cumulative recharge config", err)
|
|
disabled := consumeRecharge(ctx, rewardClient, runID, "disabled-event", baseUserID+3, "google_play", 22_500_000, 25_000, now.Add(12*time.Minute))
|
|
assert(disabled.GetReason() == "disabled" && len(disabled.GetGrants()) == 0, "disabled config should consume event without grants")
|
|
assert(queryProgress(ctx, activityDB, baseUserID+3, currentCycle.Key) == 0, "disabled config should not update progress")
|
|
updateConfig(ctx, adminClient, runID+"_reenable_after_disabled", group100, group200)
|
|
disabledAgain := consumeRecharge(ctx, rewardClient, runID, "disabled-event", baseUserID+3, "google_play", 22_500_000, 25_000, now.Add(12*time.Minute))
|
|
assert(disabledAgain.GetReason() == "already_consumed" && queryProgress(ctx, activityDB, baseUserID+3, currentCycle.Key) == 0, "disabled-period event must not backfill after re-enable")
|
|
|
|
_, err = updateConfigWithTiers(ctx, adminClient, runID+"_inactive", true, []*activityv1.CumulativeRechargeRewardTier{
|
|
{TierCode: runID + "_inactive_100", TierName: "Inactive $100", ThresholdUsdMinor: 10_000, ResourceGroupId: group100, Status: tierStatusInactive, SortOrder: 1},
|
|
{TierCode: runID + "_active_200", TierName: "Active $200", ThresholdUsdMinor: 20_000, ResourceGroupId: group200, Status: statusActive, SortOrder: 2},
|
|
})
|
|
must("set inactive tier config", err)
|
|
inactive := consumeRecharge(ctx, rewardClient, runID, "inactive-tier", baseUserID+4, "mifapay", 22_500_000, 25_000, now.Add(13*time.Minute))
|
|
assertGrantThresholds("inactive $100 tier should be ignored", inactive.GetGrants(), []int64{20_000})
|
|
assert(queryGrantSummary(ctx, activityDB, baseUserID+4, currentCycle.Key).Count == 1, "inactive tier should not create a grant row")
|
|
|
|
failingGroup := createCoinRewardGroup(ctx, walletClient, runID, "disabled_group", 1000)
|
|
setResourceGroupStatus(ctx, walletClient, runID, failingGroup, statusDisabled)
|
|
_, err = updateConfigWithTiers(ctx, adminClient, runID+"_failed_retry", true, []*activityv1.CumulativeRechargeRewardTier{
|
|
{TierCode: runID + "_failed_100", TierName: "Failed then retry", ThresholdUsdMinor: 10_000, ResourceGroupId: failingGroup, Status: statusActive, SortOrder: 1},
|
|
})
|
|
must("set disabled resource group tier", err)
|
|
_, err = consumeRechargeAllowError(ctx, rewardClient, runID, "failed-wallet", baseUserID+5, "google_play", 13_500_000, 15_000, now.Add(14*time.Minute))
|
|
assert(err != nil, "disabled wallet resource group should fail grant")
|
|
assert(queryGrantStatus(ctx, activityDB, baseUserID+5, currentCycle.Key, 10_000) == "failed", "failed wallet grant should persist failed status")
|
|
setResourceGroupStatus(ctx, walletClient, runID, failingGroup, statusActive)
|
|
retried := consumeRecharge(ctx, rewardClient, runID, "failed-wallet", baseUserID+5, "google_play", 13_500_000, 15_000, now.Add(14*time.Minute))
|
|
assertGrantThresholds("same event retry should grant after group enabled", retried.GetGrants(), []int64{10_000})
|
|
assert(queryProgress(ctx, activityDB, baseUserID+5, currentCycle.Key) == 15_000, "failed retry must not double progress")
|
|
assert(queryGrantStatus(ctx, activityDB, baseUserID+5, currentCycle.Key, 10_000) == "granted", "retried wallet grant should be granted")
|
|
|
|
updateConfig(ctx, adminClient, runID+"_week_boundary", group100, group200)
|
|
endBoundary := consumeRecharge(ctx, rewardClient, runID, "week-end", baseUserID+6, "google_play", 9_000_000, 10_000, time.UnixMilli(currentCycle.EndMS).UTC())
|
|
assertGrantThresholds("event at cycle end should belong current cycle", endBoundary.GetGrants(), []int64{10_000})
|
|
nextBoundary := consumeRecharge(ctx, rewardClient, runID, "week-next", baseUserID+6, "google_play", 9_000_000, 10_000, time.UnixMilli(currentCycle.EndMS+1).UTC())
|
|
assertGrantThresholds("event after cycle end should belong next cycle", nextBoundary.GetGrants(), []int64{10_000})
|
|
assert(queryProgress(ctx, activityDB, baseUserID+6, currentCycle.Key) == 10_000, "cycle end progress should stay current week")
|
|
assert(queryProgress(ctx, activityDB, baseUserID+6, nextCycle.Key) == 10_000, "cycle start progress should reset next week")
|
|
|
|
_, err = consumeRechargeAllowError(ctx, rewardClient, runID, "zero-usd", baseUserID+7, "google_play", 10_000, 0, now.Add(15*time.Minute))
|
|
assert(err != nil, "zero qualifying USD event must be rejected by direct consume entry")
|
|
assert(queryProgress(ctx, activityDB, baseUserID+7, currentCycle.Key) == 0, "zero qualifying USD event must not write progress")
|
|
|
|
updateConfig(ctx, adminClient, runID+"_restore", group100, group200)
|
|
out := map[string]any{
|
|
"mode": "boundary",
|
|
"run_id": runID,
|
|
"base_user_id": baseUserID,
|
|
"current_cycle": currentCycle.Key,
|
|
"next_cycle": nextCycle.Key,
|
|
"cases": map[string]any{
|
|
"duplicate_event": map[string]any{"progress_usd_minor": queryProgress(ctx, activityDB, baseUserID+1, currentCycle.Key), "grant_count": queryGrantSummary(ctx, activityDB, baseUserID+1, currentCycle.Key).Count},
|
|
"cross_multiple_tiers": map[string]any{"grant_thresholds": thresholds(cross.GetGrants()), "wallet_grants": queryWalletRewardSummary(ctx, walletDB, baseUserID+2).Count},
|
|
"disabled_no_backfill": map[string]any{"first_reason": disabled.GetReason(), "retry_reason": disabledAgain.GetReason(), "progress_usd_minor": queryProgress(ctx, activityDB, baseUserID+3, currentCycle.Key)},
|
|
"inactive_tier_ignored": map[string]any{"grant_thresholds": thresholds(inactive.GetGrants())},
|
|
"failed_retry": map[string]any{"final_status": queryGrantStatus(ctx, activityDB, baseUserID+5, currentCycle.Key, 10_000), "progress_usd_minor": queryProgress(ctx, activityDB, baseUserID+5, currentCycle.Key)},
|
|
"week_boundary": map[string]any{"current_progress": queryProgress(ctx, activityDB, baseUserID+6, currentCycle.Key), "next_progress": queryProgress(ctx, activityDB, baseUserID+6, nextCycle.Key)},
|
|
"zero_usd_rejected": map[string]any{"progress_usd_minor": queryProgress(ctx, activityDB, baseUserID+7, currentCycle.Key)},
|
|
},
|
|
}
|
|
encoded, _ := json.MarshalIndent(out, "", " ")
|
|
fmt.Println(string(encoded))
|
|
}
|
|
|
|
func queryProgress(ctx context.Context, db *sql.DB, userID int64, cycleKey string) int64 {
|
|
var total sql.NullInt64
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT total_usd_minor
|
|
FROM cumulative_recharge_reward_progress
|
|
WHERE app_code = ? AND user_id = ? AND cycle_key = ?`,
|
|
appCode, userID, cycleKey,
|
|
).Scan(&total)
|
|
if err == sql.ErrNoRows {
|
|
return 0
|
|
}
|
|
must("query cumulative progress "+cycleKey, err)
|
|
return total.Int64
|
|
}
|
|
|
|
type grantSummary struct {
|
|
Count int64 `json:"count"`
|
|
Granted int64 `json:"granted"`
|
|
}
|
|
|
|
func queryGrantSummary(ctx context.Context, db *sql.DB, userID int64, cycleKey string) grantSummary {
|
|
var summary grantSummary
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'granted' THEN 1 ELSE 0 END), 0)
|
|
FROM cumulative_recharge_reward_grants
|
|
WHERE app_code = ? AND user_id = ? AND cycle_key = ?`,
|
|
appCode, userID, cycleKey,
|
|
).Scan(&summary.Count, &summary.Granted)
|
|
must("query cumulative grants "+cycleKey, err)
|
|
return summary
|
|
}
|
|
|
|
func queryGrantStatus(ctx context.Context, db *sql.DB, userID int64, cycleKey string, thresholdUSDMinor int64) string {
|
|
var status string
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT status
|
|
FROM cumulative_recharge_reward_grants
|
|
WHERE app_code = ? AND user_id = ? AND cycle_key = ? AND threshold_usd_minor = ?
|
|
ORDER BY created_at_ms DESC
|
|
LIMIT 1`,
|
|
appCode, userID, cycleKey, thresholdUSDMinor,
|
|
).Scan(&status)
|
|
must("query cumulative grant status", err)
|
|
return status
|
|
}
|
|
|
|
type walletRewardSummary struct {
|
|
Count int64 `json:"count"`
|
|
CoinDelta int64 `json:"coin_delta"`
|
|
}
|
|
|
|
func queryWalletRewardSummary(ctx context.Context, db *sql.DB, userID int64) walletRewardSummary {
|
|
var summary walletRewardSummary
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(DISTINCT rg.grant_id), COALESCE(SUM(we.available_delta), 0)
|
|
FROM resource_grants rg
|
|
JOIN resource_grant_items rgi ON rgi.app_code = rg.app_code AND rgi.grant_id = rg.grant_id
|
|
JOIN wallet_entries we ON we.app_code = rgi.app_code AND we.transaction_id = rgi.wallet_transaction_id AND we.user_id = rg.target_user_id
|
|
WHERE rg.app_code = ? AND rg.target_user_id = ? AND rg.grant_source = ? AND we.asset_type = ?`,
|
|
appCode, userID, rechargeSource, assetCoin,
|
|
).Scan(&summary.Count, &summary.CoinDelta)
|
|
must("query wallet reward grants", err)
|
|
return summary
|
|
}
|
|
|
|
func meta(requestID string) *activityv1.RequestMeta {
|
|
return &activityv1.RequestMeta{
|
|
RequestId: requestID,
|
|
Caller: "local-cumulative-recharge-flow",
|
|
AppCode: appCode,
|
|
SentAtMs: time.Now().UnixMilli(),
|
|
}
|
|
}
|
|
|
|
func cycleForTime(value time.Time) cycle {
|
|
utc := value.UTC()
|
|
dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
|
daysSinceMonday := (int(dayStart.Weekday()) + 6) % 7
|
|
start := dayStart.AddDate(0, 0, -daysSinceMonday)
|
|
end := start.AddDate(0, 0, 7)
|
|
isoYear, isoWeek := start.ISOWeek()
|
|
return cycle{Key: fmt.Sprintf("%04d-W%02d", isoYear, isoWeek), StartMS: start.UnixMilli(), EndMS: end.UnixMilli() - 1}
|
|
}
|
|
|
|
func assertGrantThresholds(message string, grants []*activityv1.CumulativeRechargeRewardGrant, want []int64) {
|
|
got := thresholds(grants)
|
|
if len(got) != len(want) {
|
|
log.Fatalf("%s: got %v want %v", message, got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
log.Fatalf("%s: got %v want %v", message, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func thresholds(grants []*activityv1.CumulativeRechargeRewardGrant) []int64 {
|
|
items := make([]int64, 0, len(grants))
|
|
for _, grant := range grants {
|
|
if grant == nil {
|
|
continue
|
|
}
|
|
items = append(items, grant.GetThresholdUsdMinor())
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i] < items[j] })
|
|
return items
|
|
}
|
|
|
|
func assert(ok bool, message string) {
|
|
if !ok {
|
|
log.Fatal(message)
|
|
}
|
|
}
|
|
|
|
func must(action string, err error) {
|
|
if err != nil {
|
|
log.Fatalf("%s: %v", action, err)
|
|
}
|
|
}
|