This commit is contained in:
zhx 2026-06-11 19:37:21 +08:00
parent 8628de3dbd
commit 88551cd96d
4 changed files with 300 additions and 1 deletions

View File

@ -334,6 +334,33 @@ func TestLocalSelfGameStatisticsFlow(t *testing.T) {
if err := repo.ConsumeSelfGamePoolAdjustment(appCtx, pool); err != nil {
t.Fatalf("consume self game pool failed: %v", err)
}
for _, dayOffset := range []int{1, 7} {
if err := repo.ConsumeSelfGameMatch(appCtx, mysqlstorage.SelfGameMatchEvent{
AppCode: appCode,
EventID: "SelfGameMatchSettled:dice_retention_" + string(rune('0'+dayOffset)),
EventType: gamemq.EventTypeSelfGameMatchSettled,
MatchID: "dice_retention",
GameID: "dice",
RegionID: regionID,
StakeCoin: 500,
UserParticipants: 1,
WinnerCount: 1,
UserStakeCoin: 500,
PayoutCoin: 900,
Participants: []mysqlstorage.SelfGameMatchParticipantEvent{{
UserID: userID,
ParticipantType: "user",
StakeCoin: 500,
DicePoints: []int64{5},
Result: "win",
PayoutCoin: 900,
NetWinCoin: 400,
}},
OccurredAtMS: time.UnixMilli(occurredAt).AddDate(0, 0, dayOffset).UnixMilli(),
}); err != nil {
t.Fatalf("consume retention self game match day+%d failed: %v", dayOffset, err)
}
}
overview, err := repo.QuerySelfGameOverview(ctx, mysqlstorage.SelfGameOverviewQuery{
AppCode: appCode,
@ -360,6 +387,12 @@ func TestLocalSelfGameStatisticsFlow(t *testing.T) {
if len(overview.UserRisk) != 1 || overview.UserRisk[0].UserID != userID || overview.UserRisk[0].WinCount != 1 || overview.UserRisk[0].NetWinCoin != 400 {
t.Fatalf("self game user risk mismatch: %+v", overview.UserRisk)
}
if overview.Retention.CohortUsers != 1 || overview.Retention.Day1Users != 1 || overview.Retention.Day7Users != 1 || overview.Retention.Day1Rate != 1 || overview.Retention.Day7Rate != 1 {
t.Fatalf("self game retention mismatch: %+v", overview.Retention)
}
if len(overview.MetricDefinitions) == 0 {
t.Fatalf("self game metric definitions missing")
}
}
func walletMessage(t *testing.T, message walletmq.WalletOutboxMessage) []byte {

View File

@ -177,6 +177,8 @@ type SelfGameOverview struct {
PoolStats []SelfGamePoolStat `json:"pool_stats"`
ParticipantDistribution []SelfGameParticipantStat `json:"participant_distribution"`
UserRisk []SelfGameUserRiskStat `json:"user_risk"`
Retention SelfGameRetention `json:"retention"`
MetricDefinitions []MetricDefinition `json:"metric_definitions"`
Funnel []SelfGameFunnelStep `json:"funnel"`
Conversion SelfGameConversion `json:"conversion"`
}
@ -279,6 +281,19 @@ type SelfGameUserRiskStat struct {
CancelRate float64 `json:"cancel_rate"`
}
type SelfGameRetention struct {
CohortUsers int64 `json:"cohort_users"`
Day1Users int64 `json:"day1_users"`
Day7Users int64 `json:"day7_users"`
Day1Rate float64 `json:"day1_rate"`
Day7Rate float64 `json:"day7_rate"`
}
type MetricDefinition struct {
Metric string `json:"metric"`
Definition string `json:"definition"`
}
type SelfGameFunnelStep struct {
EventName string `json:"event_name"`
EventCount int64 `json:"event_count"`
@ -419,6 +434,12 @@ func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOv
return SelfGameOverview{}, err
}
overview.UserRisk = risk
retention, err := r.querySelfGameRetention(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.Retention = retention
overview.MetricDefinitions = selfGameMetricDefinitions()
return overview, nil
}
@ -1062,6 +1083,63 @@ func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, startDay, e
return out, rows.Err()
}
func (r *Repository) querySelfGameRetention(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameRetention, error) {
cohortFilter, cohortArgs := selfGameUserDayFilter(app, startDay, endDay, gameID, countryID, regionID)
returnFilter, returnArgs := selfGameUserDayFilter(app, addStatDays(startDay, 1), addStatDays(endDay, 7), gameID, countryID, regionID)
args := make([]any, 0, len(cohortArgs)+len(returnArgs)*2)
args = append(args, cohortArgs...)
args = append(args, returnArgs...)
args = append(args, returnArgs...)
row := r.db.QueryRowContext(ctx, `
SELECT COUNT(1),
COALESCE(SUM(CASE WHEN d1.user_id IS NULL THEN 0 ELSE 1 END), 0),
COALESCE(SUM(CASE WHEN d7.user_id IS NULL THEN 0 ELSE 1 END), 0)
FROM (
SELECT DISTINCT stat_day, user_id
FROM stat_self_game_user_day
WHERE `+cohortFilter+`
) cohort
LEFT JOIN (
SELECT DISTINCT stat_day, user_id
FROM stat_self_game_user_day
WHERE `+returnFilter+`
) d1 ON d1.user_id = cohort.user_id AND d1.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 1 DAY)
LEFT JOIN (
SELECT DISTINCT stat_day, user_id
FROM stat_self_game_user_day
WHERE `+returnFilter+`
) d7 ON d7.user_id = cohort.user_id AND d7.stat_day = DATE_ADD(cohort.stat_day, INTERVAL 7 DAY)`, args...)
var retention SelfGameRetention
if err := row.Scan(&retention.CohortUsers, &retention.Day1Users, &retention.Day7Users); err != nil {
return SelfGameRetention{}, err
}
retention.Day1Rate = ratio(retention.Day1Users, retention.CohortUsers)
retention.Day7Rate = ratio(retention.Day7Users, retention.CohortUsers)
return retention, nil
}
func selfGameUserDayFilter(app, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
filter := "app_code = ? AND match_count > 0"
args := []any{app}
if startDay != "" && endDay != "" {
filter += " AND stat_day BETWEEN ? AND ?"
args = append(args, startDay, endDay)
}
if gameID != "" {
filter += " AND game_id = ?"
args = append(args, gameID)
}
if countryID > 0 {
filter += " AND country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND region_id = ?"
args = append(args, regionID)
}
return filter, args
}
func selfGameFilter(app, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
filter := "app_code = ? AND stat_day BETWEEN ? AND ?"
args := []any{app, startDay, endDay}
@ -1146,6 +1224,14 @@ func previousStatDayRange(startDay, endDay string) (string, string) {
return previousStart.Format("2006-01-02"), previousEnd.Format("2006-01-02")
}
func addStatDays(day string, offset int) string {
parsed, err := time.Parse("2006-01-02", day)
if err != nil {
return day
}
return parsed.AddDate(0, 0, offset).Format("2006-01-02")
}
func div(numerator, denominator int64) int64 {
if denominator <= 0 {
return 0
@ -1172,3 +1258,18 @@ func ratio(numerator, denominator int64) float64 {
}
return float64(numerator) / float64(denominator)
}
func selfGameMetricDefinitions() []MetricDefinition {
return []MetricDefinition{
{Metric: "game_pv", Definition: "H5 page_open event_count同一用户多次打开会重复计数。"},
{Metric: "game_uv", Definition: "H5 page_open user_count同一 UTC 日、同一 game_id、同一用户只计一次。"},
{Metric: "match_click_users", Definition: "H5 match_click user_count用户点击匹配按钮后上报。"},
{Metric: "match_success_users", Definition: "H5 match_success user_count匹配接口成功返回有效 match_id 后上报。"},
{Metric: "settlement_users", Definition: "H5 settlement_show user_count结算页展示时上报。"},
{Metric: "retention_cohort_users", Definition: "统计窗口内有已结算自研游戏对局的真实用户;只统计 stat_self_game_user_day.match_count > 0。"},
{Metric: "day1_retention", Definition: "cohort 用户在第 1 个 UTC 统计日再次有已结算自研游戏对局game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
{Metric: "day7_retention", Definition: "cohort 用户在第 7 个 UTC 统计日再次有已结算自研游戏对局game_id、country_id、region_id 筛选会同时作用于 cohort 和回访日。"},
{Metric: "platform_profit_coin", Definition: "结算事件快照的 user_stake_coin + robot_stake_coin - payout_coin - refund_coin与奖池入池/出池流水分表展示。"},
{Metric: "pool_stats", Definition: "game-service 奖池调整流水聚合in_coin/out_coin 是入池/出池金额balance_after_coin 是当天同方向同原因聚合行内最新余额快照。"},
}
}

View File

@ -10,6 +10,7 @@ import (
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
cpdomain "hyapp/services/user-service/internal/domain/cp"
)
@ -1144,7 +1145,8 @@ func insertOutboxTx(ctx context.Context, tx *sql.Tx, appCode string, eventType s
if err != nil {
return err
}
eventID := fmt.Sprintf("%s:%s:%d:%d", aggregateType, eventType, aggregateID, nowMs)
// event_id 是 user_outbox 的投递事实主键,必须按每条通知唯一生成;同一个目标用户同毫秒内可能收到多条 CP 申请/结果通知。
eventID := idgen.New("uout")
// outbox 与业务状态同事务提交RocketMQ 发布失败只影响异步 IM/统计,不回滚已经同意的关系。
_, err = tx.ExecContext(ctx, `
INSERT INTO user_outbox (

View File

@ -271,6 +271,169 @@ func TestConsumeGiftEventIgnoresNormalGiftWithoutRelationship(t *testing.T) {
}
}
func TestConsumeGiftEventCreatesNewApplicationAfterRejected(t *testing.T) {
schema := mysqlschema.New(t)
repo := New(schema.DB)
ctx := appcode.WithContext(context.Background(), appcode.Default)
nowMS := int64(1700000000000)
seedCPTestUser(t, schema.DB, 4401, "Invite Alice", nowMS)
seedCPTestUser(t, schema.DB, 4402, "Invite Bob", nowMS)
// 第一次 CP 礼物只生成 pending 申请;目标用户拒绝后,这条申请必须保持终态,不允许后续礼物回写成 pending。
first, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
AppCode: appcode.Default,
EventID: "room_evt_cp_invite_1",
RoomID: "room-cp",
RoomVersion: 3,
OccurredAtMS: nowMS + 1,
SenderUserID: 4401,
TargetUserID: 4402,
GiftID: "cp_gift",
GiftCount: 1,
GiftValue: 37,
BillingReceiptID: "receipt-cp-invite-1",
CommandID: "cmd-cp-invite-1",
CPRelationType: cpdomain.RelationTypeCP,
GiftName: "CP Gift",
}, nowMS+2)
if err != nil {
t.Fatalf("consume first cp gift failed: %v", err)
}
if !first.Consumed || first.Application.Status != cpdomain.ApplicationStatusPending {
t.Fatalf("first cp gift must create pending application: %+v", first)
}
if _, err := repo.RejectApplication(ctx, 4402, first.Application.ApplicationID, "declined", nowMS+3); err != nil {
t.Fatalf("reject first cp application failed: %v", err)
}
// 第二次送礼来自新的 room outbox event应创建新的 pending 申请,不能复用或覆盖旧 rejected 申请。
second, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
AppCode: appcode.Default,
EventID: "room_evt_cp_invite_2",
RoomID: "room-cp",
RoomVersion: 4,
OccurredAtMS: nowMS + 4,
SenderUserID: 4401,
TargetUserID: 4402,
GiftID: "cp_gift",
GiftCount: 1,
GiftValue: 37,
BillingReceiptID: "receipt-cp-invite-2",
CommandID: "cmd-cp-invite-2",
CPRelationType: cpdomain.RelationTypeCP,
GiftName: "CP Gift",
}, nowMS+5)
if err != nil {
t.Fatalf("consume second cp gift failed: %v", err)
}
if !second.Consumed || second.Application.Status != cpdomain.ApplicationStatusPending {
t.Fatalf("second cp gift must create pending application: %+v", second)
}
if second.Application.ApplicationID == first.Application.ApplicationID {
t.Fatalf("rejected application must not be reused: first=%s second=%s", first.Application.ApplicationID, second.Application.ApplicationID)
}
statuses := map[string]string{}
rows, err := schema.DB.QueryContext(ctx, `
SELECT application_id, status
FROM user_cp_applications
WHERE app_code = ? AND requester_user_id = ? AND target_user_id = ?
ORDER BY created_at_ms ASC`,
appcode.Default, 4401, 4402,
)
if err != nil {
t.Fatalf("list cp applications failed: %v", err)
}
defer rows.Close()
for rows.Next() {
var applicationID string
var status string
if err := rows.Scan(&applicationID, &status); err != nil {
t.Fatalf("scan cp application status failed: %v", err)
}
statuses[applicationID] = status
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate cp application statuses failed: %v", err)
}
if statuses[first.Application.ApplicationID] != cpdomain.ApplicationStatusRejected {
t.Fatalf("first application must remain rejected: %+v", statuses)
}
if statuses[second.Application.ApplicationID] != cpdomain.ApplicationStatusPending {
t.Fatalf("second application must remain pending: %+v", statuses)
}
}
func TestConsumeGiftEventWritesDistinctOutboxEventsInSameMillisecond(t *testing.T) {
schema := mysqlschema.New(t)
repo := New(schema.DB)
ctx := appcode.WithContext(context.Background(), appcode.Default)
nowMS := int64(1700000000000)
seedCPTestUser(t, schema.DB, 4501, "Outbox Alice", nowMS)
seedCPTestUser(t, schema.DB, 4502, "Outbox Bob", nowMS)
// 同一 pending 申请被后续礼物刷新时仍会产生新的通知事实event_id 不能只按目标用户和毫秒拼接,否则 IM 会被主键碰撞吞掉。
first, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
AppCode: appcode.Default,
EventID: "room_evt_cp_outbox_1",
RoomID: "room-cp-outbox",
RoomVersion: 3,
OccurredAtMS: nowMS + 1,
SenderUserID: 4501,
TargetUserID: 4502,
GiftID: "cp_gift",
GiftCount: 1,
GiftValue: 37,
BillingReceiptID: "receipt-cp-outbox-1",
CommandID: "cmd-cp-outbox-1",
CPRelationType: cpdomain.RelationTypeCP,
GiftName: "CP Gift",
}, nowMS+2)
if err != nil {
t.Fatalf("consume first cp outbox gift failed: %v", err)
}
second, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{
AppCode: appcode.Default,
EventID: "room_evt_cp_outbox_2",
RoomID: "room-cp-outbox",
RoomVersion: 4,
OccurredAtMS: nowMS + 2,
SenderUserID: 4501,
TargetUserID: 4502,
GiftID: "cp_gift",
GiftCount: 1,
GiftValue: 37,
BillingReceiptID: "receipt-cp-outbox-2",
CommandID: "cmd-cp-outbox-2",
CPRelationType: cpdomain.RelationTypeCP,
GiftName: "CP Gift",
}, nowMS+2)
if err != nil {
t.Fatalf("consume second cp outbox gift failed: %v", err)
}
if first.Application.ApplicationID != second.Application.ApplicationID {
t.Fatalf("same pending application should be refreshed before a decision: first=%s second=%s", first.Application.ApplicationID, second.Application.ApplicationID)
}
var outboxCount int64
if err := schema.DB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM user_outbox
WHERE app_code = ?
AND event_type = ?
AND aggregate_type = 'cp_application'
AND aggregate_id = ?`,
appcode.Default, cpdomain.EventTypeApplicationCreated, 4502,
).Scan(&outboxCount); err != nil {
t.Fatalf("count cp outbox events failed: %v", err)
}
if outboxCount != 2 {
t.Fatalf("same-millisecond cp notifications must keep two outbox rows, got %d", outboxCount)
}
}
func TestBreakRelationshipPrepareConfirmIsIdempotent(t *testing.T) {
schema := mysqlschema.New(t)
repo := New(schema.DB)