2026-06-06 13:50:46 +08:00

474 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/credentials/insecure"
"google.golang.org/protobuf/proto"
activityv1 "hyapp.local/api/proto/activity/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
)
const (
defaultAppCode = "lalu"
localTitlePrefix = "Local Weekly Star Flow"
activityCode = "weekly_star"
cycleStatusActive = "active"
rewardGroupTop1ID = int64(930001)
rewardGroupTop2ID = int64(930002)
rewardGroupTop3ID = int64(930003)
testOperatorAdmin = int64(900001)
testTargetAnchorID = int64(990001)
)
type flowConfig struct {
activityAddr string
activityDSN string
walletDSN string
appCode string
regionID int64
}
type flowSummary struct {
RunID string `json:"run_id"`
RegionID int64 `json:"region_id"`
FinishedCycleID string `json:"finished_cycle_id"`
CurrentCycleID string `json:"current_cycle_id"`
SentEvents []string `json:"sent_events"`
Leaderboard []leaderboardSummary `json:"leaderboard"`
Cron cronSummary `json:"cron"`
Settlements []settlementSummary `json:"settlements"`
WalletGrants []walletGrantSummary `json:"wallet_grants"`
CurrentCycleAfter string `json:"current_cycle_after"`
ExpectedAssertions map[string]string `json:"expected_assertions"`
}
type leaderboardSummary struct {
RankNo int32 `json:"rank_no"`
UserID int64 `json:"user_id"`
Score int64 `json:"score"`
}
type cronSummary struct {
Claimed int32 `json:"claimed"`
Processed int32 `json:"processed"`
Success int32 `json:"success"`
Failure int32 `json:"failure"`
}
type settlementSummary struct {
RankNo int32 `json:"rank_no"`
UserID int64 `json:"user_id"`
Score int64 `json:"score"`
ResourceGroupID int64 `json:"resource_group_id"`
WalletCommandID string `json:"wallet_command_id"`
WalletGrantID string `json:"wallet_grant_id"`
Status string `json:"status"`
}
type walletGrantSummary struct {
CommandID string `json:"command_id"`
GrantID string `json:"grant_id"`
TargetUserID int64 `json:"target_user_id"`
GrantSubjectID string `json:"grant_subject_id"`
Status string `json:"status"`
}
func main() {
cfg := flowConfig{}
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", 990686, "local test region id")
flag.Parse()
if strings.TrimSpace(cfg.appCode) == "" {
log.Fatal("app-code is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
// activityDB 只用于准备和交叉验证测试夹具不能直接写积分、settlement 或周期结果。
// 真正的周期创建、送礼消费、榜单读取和结算都必须走 activity-service gRPC才能覆盖线上同一套业务逻辑。
activityDB := mustOpenDB(ctx, cfg.activityDSN)
defer activityDB.Close()
// walletDB 只用于准备本地资源组和最终审计 wallet grant奖励发放必须通过 wallet-service GrantResourceGroup。
walletDB := mustOpenDB(ctx, cfg.walletDSN)
defer walletDB.Close()
// 只清理本工具创建的本地区域未完成周期,避免旧失败周期被本轮 cron 一起重试。
mustExec(ctx, activityDB, `
UPDATE weekly_star_cycles
SET status = 'disabled', locked_by = '', lock_until_ms = 0, updated_at_ms = ?
WHERE app_code = ? AND activity_code = ? AND region_id = ? AND title LIKE ? AND status IN ('active', 'settling')`,
time.Now().UTC().UnixMilli(), cfg.appCode, activityCode, cfg.regionID, localTitlePrefix+"%",
)
// 奖励资源组由 wallet-service 拥有;这里仅准备本地验证用的 COIN 资源组配置。
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()
adminClient := activityv1.NewAdminWeeklyStarServiceClient(conn)
appClient := activityv1.NewWeeklyStarServiceClient(conn)
roomClient := activityv1.NewRoomEventConsumerServiceClient(conn)
cronClient := activityv1.NewActivityCronServiceClient(conn)
runID := fmt.Sprintf("%d", time.Now().UTC().UnixMilli())
now := time.Now().UTC()
// 第一个周期刻意设置为已经结束,事件 occurred_at_ms 落在它的 [start,end) 内cron 可以立即结算。
// 第二个周期从第一个周期 end_ms 开始覆盖当前时间,用来验证旧周期结算后 current 自动进入下一周期。
finishedStart := now.Add(-10 * time.Minute).UnixMilli()
finishedEnd := now.Add(-1 * time.Minute).UnixMilli()
currentStart := finishedEnd
currentEnd := now.Add(50 * time.Minute).UnixMilli()
gifts := []string{"weekly_star_flow_gift_a", "weekly_star_flow_gift_b", "weekly_star_flow_gift_c"}
// 后台配置两个不重叠周期:第一个已经到期用于结算,第二个覆盖当前时间用于验证自动进入下一周期。
finishedCycle := createCycle(ctx, adminClient, cfg, fmt.Sprintf("%s finished %s", localTitlePrefix, runID), finishedStart, finishedEnd, gifts)
currentCycle := createCycle(ctx, adminClient, cfg, fmt.Sprintf("%s current %s", localTitlePrefix, runID), currentStart, currentEnd, gifts)
eventTime := finishedEnd - 10_000
events := []struct {
userID int64
giftID string
score int64
}{
{userID: 701001, giftID: gifts[0], score: 5_000},
{userID: 701002, giftID: gifts[1], score: 8_000},
{userID: 701003, giftID: gifts[2], score: 6_500},
}
sentEventIDs := make([]string, 0, len(events)+1)
for index, event := range events {
eventID := fmt.Sprintf("weekly-star-flow-%s-%d", runID, index+1)
// consumeGiftEvent 调的是 RoomEventConsumerService不直接调用 weekly-star service。
// 这样可以同时验证 activity-service 的房间事件 fanout wiring 是否把周星模块挂上。
consumeGiftEvent(ctx, roomClient, cfg, eventID, event.userID, event.giftID, event.score, eventTime, int64(index+1))
sentEventIDs = append(sentEventIDs, eventID)
}
// 同一个 source_event_id 再投一次,验证 weekly_star_score_events 的幂等约束不会让榜单重复加分。
consumeGiftEvent(ctx, roomClient, cfg, sentEventIDs[0], events[0].userID, events[0].giftID, events[0].score, eventTime, 99)
sentEventIDs = append(sentEventIDs, sentEventIDs[0]+" (duplicate)")
leaderboard := listLeaderboard(ctx, adminClient, cfg, finishedCycle.GetCycleId())
// 先查榜再结算,验证积分累计发生在送礼事件消费阶段,而不是结算时临时计算。
assertLeaderboard(leaderboard, []leaderboardSummary{
{RankNo: 1, UserID: 701002, Score: 8_000},
{RankNo: 2, UserID: 701003, Score: 6_500},
{RankNo: 3, UserID: 701001, Score: 5_000},
})
cronResp, err := cronClient.ProcessWeeklyStarSettlementBatch(ctx, &activityv1.CronBatchRequest{
Meta: meta(cfg, "weekly-star-local-flow-cron-"+runID),
RunId: "weekly-star-local-flow-" + runID,
WorkerId: "weekly-star-local-flow",
BatchSize: 10,
LockTtlMs: int64((30 * time.Second) / time.Millisecond),
})
if err != nil {
log.Fatalf("process weekly star settlement: %v", err)
}
if cronResp.GetClaimedCount() != 1 || cronResp.GetProcessedCount() != 3 || cronResp.GetSuccessCount() != 3 || cronResp.GetFailureCount() != 0 {
log.Fatalf("unexpected settlement cron result: claimed=%d processed=%d success=%d failure=%d", cronResp.GetClaimedCount(), cronResp.GetProcessedCount(), cronResp.GetSuccessCount(), cronResp.GetFailureCount())
}
settlements := listSettlements(ctx, adminClient, cfg, finishedCycle.GetCycleId())
assertSettlements(settlements)
// activity 侧 granted 只能说明它收到了 wallet 返回;这里再查 wallet.resource_grants确认奖励确实落到 wallet owner 库。
grants := listWalletGrants(ctx, walletDB, cfg.appCode, walletCommandIDs(settlements))
if len(grants) != 3 {
log.Fatalf("expected 3 wallet grants, got %d", len(grants))
}
currentResp, err := appClient.GetWeeklyStarCurrent(ctx, &activityv1.GetWeeklyStarCurrentRequest{
Meta: meta(cfg, "weekly-star-local-flow-current-"+runID),
UserId: 701001,
RegionId: cfg.regionID,
})
if err != nil {
log.Fatalf("get current weekly star cycle: %v", err)
}
if currentResp.GetCycle().GetCycleId() != currentCycle.GetCycleId() {
log.Fatalf("expected current cycle %s, got %s", currentCycle.GetCycleId(), currentResp.GetCycle().GetCycleId())
}
summary := flowSummary{
RunID: runID,
RegionID: cfg.regionID,
FinishedCycleID: finishedCycle.GetCycleId(),
CurrentCycleID: currentCycle.GetCycleId(),
SentEvents: sentEventIDs,
Leaderboard: leaderboard,
Cron: cronSummary{
Claimed: cronResp.GetClaimedCount(),
Processed: cronResp.GetProcessedCount(),
Success: cronResp.GetSuccessCount(),
Failure: cronResp.GetFailureCount(),
},
Settlements: settlements,
WalletGrants: grants,
CurrentCycleAfter: currentResp.GetCycle().GetCycleId(),
ExpectedAssertions: map[string]string{
"multiple_cycles": "created one ended active cycle and one current active cycle in UTC epoch ms",
"gift_score": "configured gift score equals RoomGiftSent.coin_spent",
"duplicate_event": "same source_event_id did not double-count leaderboard score",
"settlement": "ended cycle settled top1/top2/top3 through wallet GrantResourceGroup",
"next_cycle": "GetWeeklyStarCurrent returned the second cycle after settlement",
},
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(summary); err != nil {
log.Fatalf("encode summary: %v", err)
}
}
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 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 {
// 固定 group_id 让 activity 周期配置可以稳定引用ON DUPLICATE 只更新本工具自己的本地资源组。
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 flow 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, `
-- wallet_asset item 不依赖具体 resource_idwallet-service 会把它原子展开成 COIN 入账和 resource_grant_item。
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 flowConfig, title string, startMS int64, endMS int64, gifts []string) *activityv1.WeeklyStarCycle {
resp, err := client.CreateWeeklyStarCycle(ctx, &activityv1.UpsertWeeklyStarCycleRequest{
Meta: meta(cfg, "weekly-star-local-flow-create-"+strings.ReplaceAll(title, " ", "-")),
Cycle: &activityv1.WeeklyStarCycle{
ActivityCode: activityCode,
RegionId: cfg.regionID,
Title: title,
Status: cycleStatusActive,
StartMs: startMS,
EndMs: endMS,
Gifts: []*activityv1.WeeklyStarGift{
{GiftId: gifts[0], SortOrder: 1},
{GiftId: gifts[1], SortOrder: 2},
{GiftId: gifts[2], SortOrder: 3},
},
Rewards: []*activityv1.WeeklyStarReward{
{RankNo: 1, ResourceGroupId: rewardGroupTop1ID},
{RankNo: 2, ResourceGroupId: rewardGroupTop2ID},
{RankNo: 3, ResourceGroupId: rewardGroupTop3ID},
},
},
OperatorAdminId: testOperatorAdmin,
})
if err != nil {
log.Fatalf("create cycle %q: %v", title, err)
}
if resp.GetCycle().GetCycleId() == "" {
log.Fatalf("create cycle %q returned empty cycle_id", title)
}
return resp.GetCycle()
}
func consumeGiftEvent(ctx context.Context, client activityv1.RoomEventConsumerServiceClient, cfg flowConfig, 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: cfg.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-local-flow-event-"+eventID),
Envelope: &roomeventsv1.EventEnvelope{
EventId: eventID,
RoomId: "weekly-star-local-room",
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 listLeaderboard(ctx context.Context, client activityv1.AdminWeeklyStarServiceClient, cfg flowConfig, cycleID string) []leaderboardSummary {
resp, err := client.ListWeeklyStarLeaderboard(ctx, &activityv1.ListWeeklyStarLeaderboardRequest{
Meta: meta(cfg, "weekly-star-local-flow-leaderboard-"+cycleID),
CycleId: cycleID,
RegionId: cfg.regionID,
PageSize: 10,
})
if err != nil {
log.Fatalf("list leaderboard: %v", err)
}
out := make([]leaderboardSummary, 0, len(resp.GetEntries()))
for _, entry := range resp.GetEntries() {
out = append(out, leaderboardSummary{RankNo: entry.GetRankNo(), UserID: entry.GetUserId(), Score: entry.GetScore()})
}
return out
}
func assertLeaderboard(actual []leaderboardSummary, expected []leaderboardSummary) {
if len(actual) < len(expected) {
log.Fatalf("expected at least %d leaderboard entries, got %d", len(expected), len(actual))
}
for index, want := range expected {
got := actual[index]
if got != want {
log.Fatalf("leaderboard row %d mismatch: got %+v want %+v", index+1, got, want)
}
}
}
func listSettlements(ctx context.Context, client activityv1.AdminWeeklyStarServiceClient, cfg flowConfig, cycleID string) []settlementSummary {
resp, err := client.ListWeeklyStarSettlements(ctx, &activityv1.ListWeeklyStarSettlementsRequest{
Meta: meta(cfg, "weekly-star-local-flow-settlements-"+cycleID),
CycleId: cycleID,
})
if err != nil {
log.Fatalf("list settlements: %v", err)
}
out := make([]settlementSummary, 0, len(resp.GetSettlements()))
for _, settlement := range resp.GetSettlements() {
out = append(out, settlementSummary{
RankNo: settlement.GetRankNo(),
UserID: settlement.GetUserId(),
Score: settlement.GetScore(),
ResourceGroupID: settlement.GetResourceGroupId(),
WalletCommandID: settlement.GetWalletCommandId(),
WalletGrantID: settlement.GetWalletGrantId(),
Status: settlement.GetStatus(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].RankNo < out[j].RankNo })
return out
}
func assertSettlements(items []settlementSummary) {
if len(items) != 3 {
log.Fatalf("expected 3 settlements, got %d", len(items))
}
for _, item := range items {
if item.Status != "granted" || item.WalletCommandID == "" || item.WalletGrantID == "" {
log.Fatalf("settlement was not granted: %+v", item)
}
}
}
func walletCommandIDs(items []settlementSummary) []string {
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.WalletCommandID)
}
return ids
}
func listWalletGrants(ctx context.Context, db *sql.DB, appCode string, commandIDs []string) []walletGrantSummary {
if len(commandIDs) == 0 {
return nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(commandIDs)), ",")
args := make([]any, 0, len(commandIDs)+1)
args = append(args, appCode)
for _, id := range commandIDs {
args = append(args, id)
}
rows, err := db.QueryContext(ctx, `
SELECT command_id, grant_id, target_user_id, grant_subject_id, status
FROM resource_grants
WHERE app_code = ? AND command_id IN (`+placeholders+`)
ORDER BY target_user_id ASC`, args...)
if err != nil {
log.Fatalf("query wallet grants: %v", err)
}
defer rows.Close()
out := make([]walletGrantSummary, 0, len(commandIDs))
for rows.Next() {
var item walletGrantSummary
if err := rows.Scan(&item.CommandID, &item.GrantID, &item.TargetUserID, &item.GrantSubjectID, &item.Status); err != nil {
log.Fatalf("scan wallet grant: %v", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
log.Fatalf("iterate wallet grants: %v", err)
}
return out
}
func meta(cfg flowConfig, requestID string) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: requestID,
Caller: "weekly-star-local-flow",
SentAtMs: time.Now().UTC().UnixMilli(),
AppCode: cfg.appCode,
}
}