661 lines
29 KiB
Go
661 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
|
)
|
|
|
|
const (
|
|
defaultAppCode = "lalu"
|
|
boundaryTitlePrefix = "Local Weekly Star Boundary"
|
|
activityCode = "weekly_star"
|
|
statusActive = "active"
|
|
statusDisabled = "disabled"
|
|
rewardGroupTop1ID = int64(930001)
|
|
rewardGroupTop2ID = int64(930002)
|
|
rewardGroupTop3ID = int64(930003)
|
|
testOperatorAdmin = int64(900001)
|
|
testTargetAnchorID = int64(990001)
|
|
localBoundaryRoomID = "weekly-star-boundary-room"
|
|
)
|
|
|
|
type config struct {
|
|
activityAddr string
|
|
activityDSN string
|
|
walletDSN string
|
|
appCode string
|
|
regionID int64
|
|
}
|
|
|
|
type clients struct {
|
|
admin activityv1.AdminWeeklyStarServiceClient
|
|
app activityv1.WeeklyStarServiceClient
|
|
room activityv1.RoomEventConsumerServiceClient
|
|
cron activityv1.ActivityCronServiceClient
|
|
}
|
|
|
|
type boundarySummary struct {
|
|
RunID string `json:"run_id"`
|
|
RegionID int64 `json:"region_id"`
|
|
Passed []string `json:"passed"`
|
|
Skipped []string `json:"skipped"`
|
|
Diagnostics map[string]string `json:"diagnostics"`
|
|
}
|
|
|
|
type leaderboardEntry struct {
|
|
RankNo int32 `json:"rank_no"`
|
|
UserID int64 `json:"user_id"`
|
|
Score int64 `json:"score"`
|
|
}
|
|
|
|
type settlementRow struct {
|
|
RankNo int32
|
|
UserID int64
|
|
Score int64
|
|
ResourceGroupID int64
|
|
Status string
|
|
}
|
|
|
|
type cycleWindow struct {
|
|
startMS int64
|
|
endMS int64
|
|
}
|
|
|
|
func main() {
|
|
cfg := config{}
|
|
flag.StringVar(&cfg.activityAddr, "activity-addr", "127.0.0.1:13006", "activity-service gRPC address")
|
|
flag.StringVar(&cfg.activityDSN, "activity-dsn", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC", "activity MySQL DSN")
|
|
flag.StringVar(&cfg.walletDSN, "wallet-dsn", "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", "wallet MySQL DSN")
|
|
flag.StringVar(&cfg.appCode, "app-code", defaultAppCode, "app code")
|
|
flag.Int64Var(&cfg.regionID, "region-id", 991686, "local boundary-test region id")
|
|
flag.Parse()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
activityDB := mustOpenDB(ctx, cfg.activityDSN)
|
|
defer activityDB.Close()
|
|
walletDB := mustOpenDB(ctx, cfg.walletDSN)
|
|
defer walletDB.Close()
|
|
|
|
runID := fmt.Sprintf("%d", time.Now().UTC().UnixMilli())
|
|
cleanupBoundaryCycles(ctx, activityDB, cfg)
|
|
seedWalletRewardGroups(ctx, walletDB, cfg.appCode)
|
|
|
|
conn, err := grpc.DialContext(ctx, cfg.activityAddr, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
|
|
if err != nil {
|
|
log.Fatalf("dial activity-service: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
clients := clients{
|
|
admin: activityv1.NewAdminWeeklyStarServiceClient(conn),
|
|
app: activityv1.NewWeeklyStarServiceClient(conn),
|
|
room: activityv1.NewRoomEventConsumerServiceClient(conn),
|
|
cron: activityv1.NewActivityCronServiceClient(conn),
|
|
}
|
|
|
|
summary := boundarySummary{
|
|
RunID: runID,
|
|
RegionID: cfg.regionID,
|
|
Passed: make([]string, 0),
|
|
Skipped: make([]string, 0),
|
|
Diagnostics: make(map[string]string),
|
|
}
|
|
|
|
runOverlapRejected(ctx, clients, cfg, runID, &summary)
|
|
runValidationRejected(ctx, clients, cfg, runID, &summary)
|
|
runRegionFallback(ctx, activityDB, clients, cfg, runID, &summary)
|
|
runTimeBoundaryAndIgnoredGift(ctx, clients, cfg, runID, &summary)
|
|
runTieBreaking(ctx, clients, cfg, runID, &summary)
|
|
runDisabledCycleIgnored(ctx, clients, cfg, runID, &summary)
|
|
runIdempotentAndBeforeStartIgnored(ctx, clients, cfg, runID, &summary)
|
|
runTop3SettlementGrantsOnlyWinners(ctx, activityDB, walletDB, clients, cfg, runID, &summary)
|
|
runNoScoreSettlement(ctx, activityDB, walletDB, clients, cfg, runID, &summary)
|
|
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(summary); err != nil {
|
|
log.Fatalf("encode boundary summary: %v", err)
|
|
}
|
|
}
|
|
|
|
func runOverlapRejected(ctx context.Context, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
window := currentWindow()
|
|
gifts := giftsFor("overlap", runID)
|
|
baseCycle := createCycle(ctx, clients.admin, cfg, cfg.regionID+10, fmt.Sprintf("%s overlap base %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, gifts)
|
|
_, err := createCycleAllowError(ctx, clients.admin, cfg, cfg.regionID+10, fmt.Sprintf("%s overlap conflict %s", boundaryTitlePrefix, runID), statusActive, window.startMS+1_000, window.endMS-1_000, gifts)
|
|
if status.Code(err) != codes.FailedPrecondition {
|
|
log.Fatalf("overlap should be rejected with FailedPrecondition, got %v", err)
|
|
}
|
|
summary.Diagnostics["overlap_base_cycle"] = baseCycle.GetCycleId()
|
|
pass(summary, "overlap_active_cycle_rejected")
|
|
}
|
|
|
|
func runValidationRejected(ctx context.Context, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
window := currentWindow()
|
|
twoGifts := []string{"weekly_star_boundary_invalid_a_" + runID, "weekly_star_boundary_invalid_b_" + runID}
|
|
_, giftErr := createCycleAllowError(ctx, clients.admin, cfg, cfg.regionID+20, fmt.Sprintf("%s invalid gift count %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, twoGifts)
|
|
if status.Code(giftErr) != codes.InvalidArgument {
|
|
log.Fatalf("cycle with two gifts should be InvalidArgument, got %v", giftErr)
|
|
}
|
|
_, rewardErr := createCycleWithRewardsAllowError(ctx, clients.admin, cfg, cfg.regionID+21, fmt.Sprintf("%s missing reward %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, giftsFor("missing_reward", runID), []*activityv1.WeeklyStarReward{
|
|
{RankNo: 1, ResourceGroupId: rewardGroupTop1ID},
|
|
{RankNo: 2, ResourceGroupId: rewardGroupTop2ID},
|
|
})
|
|
if status.Code(rewardErr) != codes.InvalidArgument {
|
|
log.Fatalf("cycle missing top3 reward should be InvalidArgument, got %v", rewardErr)
|
|
}
|
|
pass(summary, "exactly_three_gifts_and_top3_rewards_required")
|
|
}
|
|
|
|
func runRegionFallback(ctx context.Context, db *sql.DB, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
window := currentWindow()
|
|
defaultGifts := giftsFor("default_region", runID)
|
|
concreteGifts := giftsFor("concrete_region", runID)
|
|
defaultCycle, err := createCycleAllowError(ctx, clients.admin, cfg, 0, fmt.Sprintf("%s default region %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, defaultGifts)
|
|
if err != nil {
|
|
existingID, ok := currentCycleIDFromDB(ctx, db, cfg, 0, time.Now().UTC().UnixMilli())
|
|
if !ok {
|
|
log.Fatalf("create default cycle failed and no existing default current cycle is available: %v", err)
|
|
}
|
|
summary.Skipped = append(summary.Skipped, "controlled_default_cycle_create: existing active default cycle "+existingID)
|
|
defaultCycle = &activityv1.WeeklyStarCycle{CycleId: existingID, RegionId: 0}
|
|
}
|
|
concreteCycle := createCycle(ctx, clients.admin, cfg, cfg.regionID+30, fmt.Sprintf("%s concrete region %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, concreteGifts)
|
|
|
|
concreteCurrent := getCurrent(ctx, clients.app, cfg, cfg.regionID+30, 710001)
|
|
if concreteCurrent.GetCycle().GetCycleId() != concreteCycle.GetCycleId() {
|
|
log.Fatalf("concrete region should preempt default, got %s want %s", concreteCurrent.GetCycle().GetCycleId(), concreteCycle.GetCycleId())
|
|
}
|
|
defaultCurrent := getCurrent(ctx, clients.app, cfg, cfg.regionID+31, 710001)
|
|
if defaultCurrent.GetCycle().GetRegionId() != 0 || defaultCurrent.GetCycle().GetCycleId() == concreteCycle.GetCycleId() || defaultCurrent.GetCycle().GetCycleId() == "" {
|
|
log.Fatalf("region without concrete cycle should fall back to default, got cycle=%s region=%d", defaultCurrent.GetCycle().GetCycleId(), defaultCurrent.GetCycle().GetRegionId())
|
|
}
|
|
summary.Diagnostics["default_cycle_used"] = defaultCycle.GetCycleId()
|
|
summary.Diagnostics["concrete_cycle_used"] = concreteCycle.GetCycleId()
|
|
pass(summary, "region_specific_cycle_preempts_default_and_default_fallback_works")
|
|
}
|
|
|
|
func runTimeBoundaryAndIgnoredGift(ctx context.Context, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
window := currentWindow()
|
|
regionID := cfg.regionID + 40
|
|
gifts := giftsFor("time_boundary", runID)
|
|
cycle := createCycle(ctx, clients.admin, cfg, regionID, fmt.Sprintf("%s time boundary %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, gifts)
|
|
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "time-start"), 720001, gifts[0], 100, window.startMS, 1)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "time-end"), 720002, gifts[0], 200, window.endMS, 2)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "time-non-config"), 720003, "weekly_star_boundary_not_configured_"+runID, 300, window.startMS+1_000, 3)
|
|
|
|
entries := listLeaderboard(ctx, clients.admin, cfg, regionID, cycle.GetCycleId())
|
|
assertLeaderboardExact("time boundary", entries, []leaderboardEntry{{RankNo: 1, UserID: 720001, Score: 100}})
|
|
summary.Diagnostics["time_boundary_cycle"] = cycle.GetCycleId()
|
|
pass(summary, "start_inclusive_end_exclusive_and_non_configured_gift_ignored")
|
|
}
|
|
|
|
func runTieBreaking(ctx context.Context, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
window := currentWindow()
|
|
regionID := cfg.regionID + 50
|
|
gifts := giftsFor("tie_breaking", runID)
|
|
cycle := createCycle(ctx, clients.admin, cfg, regionID, fmt.Sprintf("%s tie breaking %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, gifts)
|
|
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "tie-later-small-id"), 730001, gifts[0], 1000, window.startMS+2_000, 1)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "tie-later-large-id"), 730002, gifts[0], 1000, window.startMS+2_000, 2)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "tie-earliest"), 730003, gifts[0], 1000, window.startMS+1_000, 3)
|
|
|
|
entries := listLeaderboard(ctx, clients.admin, cfg, regionID, cycle.GetCycleId())
|
|
assertLeaderboardExact("tie breaking", entries, []leaderboardEntry{
|
|
{RankNo: 1, UserID: 730003, Score: 1000},
|
|
{RankNo: 2, UserID: 730001, Score: 1000},
|
|
{RankNo: 3, UserID: 730002, Score: 1000},
|
|
})
|
|
summary.Diagnostics["tie_breaking_cycle"] = cycle.GetCycleId()
|
|
pass(summary, "tie_breaking_score_first_scored_at_user_id")
|
|
}
|
|
|
|
func runDisabledCycleIgnored(ctx context.Context, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
window := currentWindow()
|
|
regionID := cfg.regionID + 60
|
|
gifts := giftsFor("disabled", runID)
|
|
cycle := createCycle(ctx, clients.admin, cfg, regionID, fmt.Sprintf("%s disabled ignored %s", boundaryTitlePrefix, runID), statusDisabled, window.startMS, window.endMS, gifts)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "disabled-gift"), 740001, gifts[0], 777, window.startMS+1_000, 1)
|
|
entries := listLeaderboard(ctx, clients.admin, cfg, regionID, cycle.GetCycleId())
|
|
if len(entries) != 0 {
|
|
log.Fatalf("disabled cycle should ignore gift events, got leaderboard %+v", entries)
|
|
}
|
|
summary.Diagnostics["disabled_cycle"] = cycle.GetCycleId()
|
|
pass(summary, "disabled_cycle_ignored")
|
|
}
|
|
|
|
func runIdempotentAndBeforeStartIgnored(ctx context.Context, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
window := currentWindow()
|
|
regionID := cfg.regionID + 65
|
|
gifts := giftsFor("idempotency", runID)
|
|
cycle := createCycle(ctx, clients.admin, cfg, regionID, fmt.Sprintf("%s idempotency %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, gifts)
|
|
|
|
// A gift before the configured UTC window must not create score, even when the
|
|
// gift id is configured; otherwise delayed or wrongly timestamped events can
|
|
// leak into the leaderboard before the cycle actually begins.
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "before-start"), 750001, gifts[0], 900, window.startMS-1, 1)
|
|
// The first in-window event should score once, and the second delivery with
|
|
// the same source_event_id must be treated as replay protection rather than a
|
|
// second gift spend. This is the room event idempotency boundary that protects
|
|
// RocketMQ retries and local replay tooling.
|
|
duplicateID := eventID(runID, "duplicate-score")
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, duplicateID, 750002, gifts[0], 600, window.startMS+1_000, 2)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, duplicateID, 750002, gifts[0], 600, window.startMS+1_000, 2)
|
|
|
|
entries := listLeaderboard(ctx, clients.admin, cfg, regionID, cycle.GetCycleId())
|
|
assertLeaderboardExact("idempotency", entries, []leaderboardEntry{{RankNo: 1, UserID: 750002, Score: 600}})
|
|
summary.Diagnostics["idempotency_cycle"] = cycle.GetCycleId()
|
|
pass(summary, "before_start_ignored_and_duplicate_event_id_idempotent")
|
|
}
|
|
|
|
func runTop3SettlementGrantsOnlyWinners(ctx context.Context, activityDB *sql.DB, walletDB *sql.DB, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
window := currentWindow()
|
|
regionID := cfg.regionID + 68
|
|
gifts := giftsFor("top3_settlement", runID)
|
|
cycle := createCycle(ctx, clients.admin, cfg, regionID, fmt.Sprintf("%s top3 settlement %s", boundaryTitlePrefix, runID), statusActive, window.startMS, window.endMS, gifts)
|
|
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "settle-top1"), 760001, gifts[0], 1_200, window.startMS+1_000, 1)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "settle-top2"), 760002, gifts[0], 900, window.startMS+2_000, 2)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "settle-top3"), 760003, gifts[0], 800, window.startMS+3_000, 3)
|
|
consumeGiftEvent(ctx, clients.room, cfg, regionID, eventID(runID, "settle-fourth"), 760004, gifts[0], 700, window.startMS+4_000, 4)
|
|
|
|
// The test first records score in a normal current cycle, then moves only this
|
|
// cycle's end time into the past. This keeps the event-consume path realistic
|
|
// while making settlement deterministic without waiting for wall-clock time.
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
mustExec(ctx, activityDB, `
|
|
UPDATE weekly_star_cycles
|
|
SET end_ms = ?, updated_at_ms = ?
|
|
WHERE app_code = ? AND cycle_id = ?`,
|
|
nowMS-1, nowMS, cfg.appCode, cycle.GetCycleId(),
|
|
)
|
|
triggerWeeklyStarSettlementUntilSettled(ctx, activityDB, clients, cfg, runID, "top3-settlement", cycle.GetCycleId())
|
|
|
|
rows := settlementRowsFromDB(ctx, activityDB, cfg, cycle.GetCycleId())
|
|
expected := []settlementRow{
|
|
{RankNo: 1, UserID: 760001, Score: 1_200, ResourceGroupID: rewardGroupTop1ID, Status: "granted"},
|
|
{RankNo: 2, UserID: 760002, Score: 900, ResourceGroupID: rewardGroupTop2ID, Status: "granted"},
|
|
{RankNo: 3, UserID: 760003, Score: 800, ResourceGroupID: rewardGroupTop3ID, Status: "granted"},
|
|
}
|
|
if len(rows) != len(expected) {
|
|
log.Fatalf("top3 settlement row count mismatch: got %d want %d rows=%+v", len(rows), len(expected), rows)
|
|
}
|
|
for index, want := range expected {
|
|
if rows[index] != want {
|
|
log.Fatalf("top3 settlement row %d mismatch: got %+v want %+v", index+1, rows[index], want)
|
|
}
|
|
}
|
|
if countWalletGrantsForCycle(ctx, walletDB, cfg, cycle.GetCycleId()) != 3 {
|
|
log.Fatalf("top3 settlement should create exactly 3 wallet grants")
|
|
}
|
|
summary.Diagnostics["top3_settlement_cycle"] = cycle.GetCycleId()
|
|
pass(summary, "settlement_grants_top3_only")
|
|
}
|
|
|
|
func runNoScoreSettlement(ctx context.Context, activityDB *sql.DB, walletDB *sql.DB, clients clients, cfg config, runID string, summary *boundarySummary) {
|
|
// Use an old, isolated window and a single-cycle batch so this boundary check
|
|
// settles the cycle created by this run instead of depending on whatever old
|
|
// local test data may already be due in the developer database.
|
|
oldWindowStart := time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(time.Now().UTC().UnixNano()%int64(time.Hour)) * time.Nanosecond)
|
|
oldWindowEnd := oldWindowStart.Add(time.Hour)
|
|
regionID := cfg.regionID + 70
|
|
gifts := giftsFor("no_score", runID)
|
|
cycle := createCycle(ctx, clients.admin, cfg, regionID, fmt.Sprintf("%s no score settlement %s", boundaryTitlePrefix, runID), statusActive, oldWindowStart.UnixMilli(), oldWindowEnd.UnixMilli(), gifts)
|
|
|
|
resp, err := clients.cron.ProcessWeeklyStarSettlementBatch(ctx, &activityv1.CronBatchRequest{
|
|
Meta: meta(cfg, "weekly-star-boundary-cron-"+runID),
|
|
RunId: "weekly-star-boundary-" + runID,
|
|
WorkerId: "weekly-star-boundary",
|
|
BatchSize: 1,
|
|
LockTtlMs: int64((30 * time.Second) / time.Millisecond),
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("no-score settlement cron failed: %v", err)
|
|
}
|
|
if resp.GetClaimedCount() != 1 || resp.GetProcessedCount() != 1 || resp.GetSuccessCount() != 1 || resp.GetFailureCount() != 0 {
|
|
log.Fatalf("unexpected no-score cron result: claimed=%d processed=%d success=%d failure=%d", resp.GetClaimedCount(), resp.GetProcessedCount(), resp.GetSuccessCount(), resp.GetFailureCount())
|
|
}
|
|
statusValue := cycleStatusFromDB(ctx, activityDB, cfg, cycle.GetCycleId())
|
|
if statusValue != "settled" {
|
|
log.Fatalf("no-score cycle should be settled, got %s", statusValue)
|
|
}
|
|
if countSettlements(ctx, activityDB, cfg, cycle.GetCycleId()) != 0 {
|
|
log.Fatalf("no-score cycle should not create settlement rows")
|
|
}
|
|
if countWalletGrantsForCycle(ctx, walletDB, cfg, cycle.GetCycleId()) != 0 {
|
|
log.Fatalf("no-score cycle should not create wallet grants")
|
|
}
|
|
summary.Diagnostics["no_score_cycle"] = cycle.GetCycleId()
|
|
pass(summary, "no_score_cycle_settles_without_grants")
|
|
}
|
|
|
|
func triggerWeeklyStarSettlementUntilSettled(ctx context.Context, db *sql.DB, clients clients, cfg config, runID string, label string, cycleID string) {
|
|
for attempt := 1; attempt <= 8; attempt++ {
|
|
_, err := clients.cron.ProcessWeeklyStarSettlementBatch(ctx, &activityv1.CronBatchRequest{
|
|
Meta: meta(cfg, fmt.Sprintf("weekly-star-boundary-cron-%s-%s-%d", label, runID, attempt)),
|
|
RunId: fmt.Sprintf("weekly-star-boundary-%s-%s-%d", label, runID, attempt),
|
|
WorkerId: "weekly-star-boundary",
|
|
BatchSize: 5,
|
|
LockTtlMs: int64((30 * time.Second) / time.Millisecond),
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("trigger weekly star settlement %s: %v", cycleID, err)
|
|
}
|
|
if cycleStatusFromDB(ctx, db, cfg, cycleID) == "settled" {
|
|
return
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
log.Fatalf("cycle %s was not settled after repeated cron attempts", cycleID)
|
|
}
|
|
|
|
func currentWindow() cycleWindow {
|
|
now := time.Now().UTC()
|
|
return cycleWindow{startMS: now.Add(-2 * time.Minute).UnixMilli(), endMS: now.Add(30 * time.Minute).UnixMilli()}
|
|
}
|
|
|
|
func giftsFor(label string, runID string) []string {
|
|
return []string{
|
|
"weekly_star_boundary_" + label + "_a_" + runID,
|
|
"weekly_star_boundary_" + label + "_b_" + runID,
|
|
"weekly_star_boundary_" + label + "_c_" + runID,
|
|
}
|
|
}
|
|
|
|
func cleanupBoundaryCycles(ctx context.Context, db *sql.DB, cfg config) {
|
|
mustExec(ctx, db, `
|
|
UPDATE weekly_star_cycles
|
|
SET status = 'disabled', locked_by = '', lock_until_ms = 0, updated_at_ms = ?
|
|
WHERE app_code = ? AND activity_code = ? AND title LIKE ? AND status IN ('active', 'settling')`,
|
|
time.Now().UTC().UnixMilli(), cfg.appCode, activityCode, boundaryTitlePrefix+"%",
|
|
)
|
|
}
|
|
|
|
func seedWalletRewardGroups(ctx context.Context, db *sql.DB, appCode string) {
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
type groupSeed struct {
|
|
groupID int64
|
|
code string
|
|
name string
|
|
coins int64
|
|
}
|
|
seeds := []groupSeed{
|
|
{groupID: rewardGroupTop1ID, code: "weekly_star_local_top1", name: "Weekly Star Local Top 1", coins: 111_000},
|
|
{groupID: rewardGroupTop2ID, code: "weekly_star_local_top2", name: "Weekly Star Local Top 2", coins: 66_000},
|
|
{groupID: rewardGroupTop3ID, code: "weekly_star_local_top3", name: "Weekly Star Local Top 3", coins: 33_000},
|
|
}
|
|
for index, seed := range seeds {
|
|
mustExec(ctx, db, `
|
|
INSERT INTO resource_groups (
|
|
app_code, group_id, group_code, name, status, description, sort_order,
|
|
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
|
) VALUES (?, ?, ?, ?, 'active', 'local weekly star boundary reward group', ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
name = VALUES(name), status = 'active', description = VALUES(description),
|
|
sort_order = VALUES(sort_order), updated_by_user_id = VALUES(updated_by_user_id), updated_at_ms = VALUES(updated_at_ms)`,
|
|
appCode, seed.groupID, seed.code, seed.name, index+1, testOperatorAdmin, testOperatorAdmin, nowMS, nowMS,
|
|
)
|
|
mustExec(ctx, db, `
|
|
INSERT INTO resource_group_items (
|
|
app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
|
|
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
|
|
) VALUES (?, ?, 'wallet_asset', 0, 'COIN', ?, 1, 0, 1, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
wallet_asset_amount = VALUES(wallet_asset_amount), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`,
|
|
appCode, seed.groupID, seed.coins, nowMS, nowMS,
|
|
)
|
|
}
|
|
}
|
|
|
|
func createCycle(ctx context.Context, client activityv1.AdminWeeklyStarServiceClient, cfg config, regionID int64, title string, cycleStatus string, startMS int64, endMS int64, gifts []string) *activityv1.WeeklyStarCycle {
|
|
cycle, err := createCycleAllowError(ctx, client, cfg, regionID, title, cycleStatus, startMS, endMS, gifts)
|
|
if err != nil {
|
|
log.Fatalf("create cycle %q: %v", title, err)
|
|
}
|
|
return cycle
|
|
}
|
|
|
|
func createCycleAllowError(ctx context.Context, client activityv1.AdminWeeklyStarServiceClient, cfg config, regionID int64, title string, cycleStatus string, startMS int64, endMS int64, gifts []string) (*activityv1.WeeklyStarCycle, error) {
|
|
return createCycleWithRewardsAllowError(ctx, client, cfg, regionID, title, cycleStatus, startMS, endMS, gifts, []*activityv1.WeeklyStarReward{
|
|
{RankNo: 1, ResourceGroupId: rewardGroupTop1ID},
|
|
{RankNo: 2, ResourceGroupId: rewardGroupTop2ID},
|
|
{RankNo: 3, ResourceGroupId: rewardGroupTop3ID},
|
|
})
|
|
}
|
|
|
|
func createCycleWithRewardsAllowError(ctx context.Context, client activityv1.AdminWeeklyStarServiceClient, cfg config, regionID int64, title string, cycleStatus string, startMS int64, endMS int64, gifts []string, rewards []*activityv1.WeeklyStarReward) (*activityv1.WeeklyStarCycle, error) {
|
|
giftItems := make([]*activityv1.WeeklyStarGift, 0, len(gifts))
|
|
for index, gift := range gifts {
|
|
giftItems = append(giftItems, &activityv1.WeeklyStarGift{GiftId: gift, SortOrder: int32(index + 1)})
|
|
}
|
|
resp, err := client.CreateWeeklyStarCycle(ctx, &activityv1.UpsertWeeklyStarCycleRequest{
|
|
Meta: meta(cfg, "weekly-star-boundary-create-"+sanitize(title)),
|
|
Cycle: &activityv1.WeeklyStarCycle{
|
|
ActivityCode: activityCode,
|
|
RegionId: regionID,
|
|
Title: title,
|
|
Status: cycleStatus,
|
|
StartMs: startMS,
|
|
EndMs: endMS,
|
|
Gifts: giftItems,
|
|
Rewards: rewards,
|
|
},
|
|
OperatorAdminId: testOperatorAdmin,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.GetCycle().GetCycleId() == "" {
|
|
return nil, fmt.Errorf("empty cycle_id")
|
|
}
|
|
return resp.GetCycle(), nil
|
|
}
|
|
|
|
func consumeGiftEvent(ctx context.Context, client activityv1.RoomEventConsumerServiceClient, cfg config, regionID int64, eventID string, userID int64, giftID string, score int64, occurredAtMS int64, version int64) {
|
|
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
|
|
SenderUserId: userID,
|
|
TargetUserId: testTargetAnchorID,
|
|
GiftId: giftID,
|
|
GiftCount: 1,
|
|
GiftValue: 1,
|
|
BillingReceiptId: eventID + "-receipt",
|
|
VisibleRegionId: regionID,
|
|
CommandId: eventID + "-command",
|
|
CoinSpent: score,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("marshal room gift sent: %v", err)
|
|
}
|
|
_, err = client.ConsumeRoomEvent(ctx, &activityv1.ConsumeRoomEventRequest{
|
|
Meta: meta(cfg, "weekly-star-boundary-event-"+eventID),
|
|
Envelope: &roomeventsv1.EventEnvelope{
|
|
EventId: eventID,
|
|
RoomId: localBoundaryRoomID,
|
|
EventType: "RoomGiftSent",
|
|
RoomVersion: version,
|
|
OccurredAtMs: occurredAtMS,
|
|
Body: body,
|
|
AppCode: cfg.appCode,
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("consume room gift event %s: %v", eventID, err)
|
|
}
|
|
}
|
|
|
|
func getCurrent(ctx context.Context, client activityv1.WeeklyStarServiceClient, cfg config, regionID int64, userID int64) *activityv1.GetWeeklyStarCurrentResponse {
|
|
resp, err := client.GetWeeklyStarCurrent(ctx, &activityv1.GetWeeklyStarCurrentRequest{
|
|
Meta: meta(cfg, "weekly-star-boundary-current-"+fmt.Sprint(regionID)),
|
|
UserId: userID,
|
|
RegionId: regionID,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("get current region %d: %v", regionID, err)
|
|
}
|
|
if resp.GetCycle() == nil {
|
|
log.Fatalf("get current region %d returned nil cycle", regionID)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func listLeaderboard(ctx context.Context, client activityv1.AdminWeeklyStarServiceClient, cfg config, regionID int64, cycleID string) []leaderboardEntry {
|
|
resp, err := client.ListWeeklyStarLeaderboard(ctx, &activityv1.ListWeeklyStarLeaderboardRequest{
|
|
Meta: meta(cfg, "weekly-star-boundary-leaderboard-"+cycleID),
|
|
CycleId: cycleID,
|
|
RegionId: regionID,
|
|
PageSize: 10,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("list leaderboard %s: %v", cycleID, err)
|
|
}
|
|
out := make([]leaderboardEntry, 0, len(resp.GetEntries()))
|
|
for _, entry := range resp.GetEntries() {
|
|
out = append(out, leaderboardEntry{RankNo: entry.GetRankNo(), UserID: entry.GetUserId(), Score: entry.GetScore()})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func assertLeaderboardExact(label string, actual []leaderboardEntry, expected []leaderboardEntry) {
|
|
if len(actual) != len(expected) {
|
|
log.Fatalf("%s leaderboard length mismatch: got %d want %d entries=%+v", label, len(actual), len(expected), actual)
|
|
}
|
|
for index, want := range expected {
|
|
if actual[index] != want {
|
|
log.Fatalf("%s leaderboard row %d mismatch: got %+v want %+v", label, index+1, actual[index], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func currentCycleIDFromDB(ctx context.Context, db *sql.DB, cfg config, regionID int64, nowMS int64) (string, bool) {
|
|
var cycleID string
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT cycle_id
|
|
FROM weekly_star_cycles
|
|
WHERE app_code = ? AND activity_code = ? AND region_id = ? AND status = 'active' AND start_ms <= ? AND end_ms > ?
|
|
ORDER BY start_ms DESC, updated_at_ms DESC
|
|
LIMIT 1`, cfg.appCode, activityCode, regionID, nowMS, nowMS,
|
|
).Scan(&cycleID)
|
|
if err == sql.ErrNoRows {
|
|
return "", false
|
|
}
|
|
if err != nil {
|
|
log.Fatalf("query current cycle from db: %v", err)
|
|
}
|
|
return cycleID, true
|
|
}
|
|
|
|
func cycleStatusFromDB(ctx context.Context, db *sql.DB, cfg config, cycleID string) string {
|
|
var statusValue string
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT status FROM weekly_star_cycles WHERE app_code = ? AND cycle_id = ?`, cfg.appCode, cycleID,
|
|
).Scan(&statusValue)
|
|
if err != nil {
|
|
log.Fatalf("query cycle status %s: %v", cycleID, err)
|
|
}
|
|
return statusValue
|
|
}
|
|
|
|
func countSettlements(ctx context.Context, db *sql.DB, cfg config, cycleID string) int {
|
|
var count int
|
|
if err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM weekly_star_settlements WHERE app_code = ? AND cycle_id = ?`, cfg.appCode, cycleID,
|
|
).Scan(&count); err != nil {
|
|
log.Fatalf("count settlements: %v", err)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func countWalletGrantsForCycle(ctx context.Context, db *sql.DB, cfg config, cycleID string) int {
|
|
var count int
|
|
if err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM resource_grants WHERE app_code = ? AND command_id LIKE ?`, cfg.appCode, "weekly_star:"+cycleID+":%",
|
|
).Scan(&count); err != nil {
|
|
log.Fatalf("count wallet grants: %v", err)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func settlementRowsFromDB(ctx context.Context, db *sql.DB, cfg config, cycleID string) []settlementRow {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT rank_no, user_id, score, resource_group_id, status
|
|
FROM weekly_star_settlements
|
|
WHERE app_code = ? AND cycle_id = ?
|
|
ORDER BY rank_no ASC`, cfg.appCode, cycleID,
|
|
)
|
|
if err != nil {
|
|
log.Fatalf("query settlement rows: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]settlementRow, 0, 3)
|
|
for rows.Next() {
|
|
var row settlementRow
|
|
if err := rows.Scan(&row.RankNo, &row.UserID, &row.Score, &row.ResourceGroupID, &row.Status); err != nil {
|
|
log.Fatalf("scan settlement row: %v", err)
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
log.Fatalf("iterate settlement rows: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mustOpenDB(ctx context.Context, dsn string) *sql.DB {
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
log.Fatalf("open mysql: %v", err)
|
|
}
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
log.Fatalf("ping mysql: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func mustExec(ctx context.Context, db *sql.DB, query string, args ...any) {
|
|
if _, err := db.ExecContext(ctx, query, args...); err != nil {
|
|
log.Fatalf("exec mysql setup: %v", err)
|
|
}
|
|
}
|
|
|
|
func meta(cfg config, requestID string) *activityv1.RequestMeta {
|
|
return &activityv1.RequestMeta{
|
|
RequestId: requestID,
|
|
Caller: "weekly-star-local-boundary",
|
|
SentAtMs: time.Now().UTC().UnixMilli(),
|
|
AppCode: cfg.appCode,
|
|
}
|
|
}
|
|
|
|
func eventID(runID string, label string) string {
|
|
return "weekly-star-boundary-" + runID + "-" + label
|
|
}
|
|
|
|
func sanitize(value string) string {
|
|
return strings.NewReplacer(" ", "-", ":", "-", "/", "-").Replace(value)
|
|
}
|
|
|
|
func pass(summary *boundarySummary, name string) {
|
|
summary.Passed = append(summary.Passed, name)
|
|
sort.Strings(summary.Passed)
|
|
}
|