修复数据以及上麦问题

This commit is contained in:
zhx 2026-07-04 22:02:28 +08:00
parent 7a2d292c36
commit e5cd2a5ce7
15 changed files with 918 additions and 168 deletions

View File

@ -917,7 +917,7 @@ func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate
}
// applyRetention 用注册 cohort × user_daily_active_log 推 D1/D7/D30目标观察日未到时基数保持 0
// 上层比例展示为 0%,与 cdc 源口径一致
// 上层会把 0 基数转成 nil避免把“还不能观察”展示成真实 0% 留存
func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error {
location, err := time.LoadLocation(s.statTimezone)
if err != nil {

View File

@ -489,15 +489,15 @@ func (m externalDashboardMetric) toMap() map[string]any {
"consumed_coin": nullableInt64(m.ConsumedCoin),
"consume_output_ratio": consumeOutputRatioValue(m.ConsumedCoin, m.OutputCoin),
"daily_active_user": m.ActiveUsers,
"d1_retention_base_users": m.D1RetentionBaseUsers,
"d1_retention_rate": m.D1RetentionRate,
"d1_retention_users": m.D1RetentionUsers,
"d7_retention_base_users": m.D7RetentionBaseUsers,
"d7_retention_rate": m.D7RetentionRate,
"d7_retention_users": m.D7RetentionUsers,
"d30_retention_base_users": m.D30RetentionBaseUsers,
"d30_retention_rate": m.D30RetentionRate,
"d30_retention_users": m.D30RetentionUsers,
"d1_retention_base_users": retentionMetricValue(m.D1RetentionBaseUsers, m.D1RetentionBaseUsers),
"d1_retention_rate": retentionRateValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"d1_retention_users": retentionMetricValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"d7_retention_base_users": retentionMetricValue(m.D7RetentionBaseUsers, m.D7RetentionBaseUsers),
"d7_retention_rate": retentionRateValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"d7_retention_users": retentionMetricValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"d30_retention_base_users": retentionMetricValue(m.D30RetentionBaseUsers, m.D30RetentionBaseUsers),
"d30_retention_rate": retentionRateValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"d30_retention_users": retentionMetricValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"game_payout": m.GamePayout,
"game_players": m.GamePlayers,
"game_profit": m.GameProfit,
@ -546,15 +546,15 @@ func (m externalDashboardMetric) toMap() map[string]any {
func (m externalDashboardMetric) retentionMap() map[string]any {
return map[string]any{
"day1_base_users": m.D1RetentionBaseUsers,
"day1_rate": m.D1RetentionRate,
"day1_users": m.D1RetentionUsers,
"day7_base_users": m.D7RetentionBaseUsers,
"day7_rate": m.D7RetentionRate,
"day7_users": m.D7RetentionUsers,
"day30_base_users": m.D30RetentionBaseUsers,
"day30_rate": m.D30RetentionRate,
"day30_users": m.D30RetentionUsers,
"day1_base_users": retentionMetricValue(m.D1RetentionBaseUsers, m.D1RetentionBaseUsers),
"day1_rate": retentionRateValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"day1_users": retentionMetricValue(m.D1RetentionUsers, m.D1RetentionBaseUsers),
"day7_base_users": retentionMetricValue(m.D7RetentionBaseUsers, m.D7RetentionBaseUsers),
"day7_rate": retentionRateValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"day7_users": retentionMetricValue(m.D7RetentionUsers, m.D7RetentionBaseUsers),
"day30_base_users": retentionMetricValue(m.D30RetentionBaseUsers, m.D30RetentionBaseUsers),
"day30_rate": retentionRateValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"day30_users": retentionMetricValue(m.D30RetentionUsers, m.D30RetentionBaseUsers),
"registered_users": m.NewUsers,
}
}
@ -757,6 +757,20 @@ func ratioFloat64(numerator int64, denominator int64) float64 {
return float64(numerator) / float64(denominator)
}
func retentionMetricValue(value int64, base int64) any {
if base <= 0 {
return nil
}
return value
}
func retentionRateValue(users int64, base int64) any {
if base <= 0 {
return nil
}
return ratioFloat64(users, base)
}
func deltaRate(current int64, previous int64) float64 {
if previous == 0 {
if current == 0 {

View File

@ -120,6 +120,28 @@ func TestMySQLExternalDashboardSourceStatisticsOverview(t *testing.T) {
}
}
func TestExternalDashboardRetentionUsesNilWhenBaseMissing(t *testing.T) {
metric := externalDashboardMetric{
D1RetentionUsers: 0,
D1RetentionBaseUsers: 0,
D7RetentionUsers: 0,
D7RetentionBaseUsers: 5,
D30RetentionUsers: 0,
D30RetentionBaseUsers: 0,
}
row := metric.toMap()
if row["d1_retention_rate"] != nil || row["d1_retention_users"] != nil || row["d1_retention_base_users"] != nil {
t.Fatalf("d1 retention without base should be nil, got %#v", row)
}
if row["d7_retention_rate"] != float64(0) || row["d7_retention_users"] != int64(0) || row["d7_retention_base_users"] != int64(5) {
t.Fatalf("d7 observed zero retention should stay visible, got %#v", row)
}
retention := metric.retentionMap()
if retention["day30_rate"] != nil || retention["day30_users"] != nil || retention["day30_base_users"] != nil {
t.Fatalf("d30 retention without base should be nil, got %#v", retention)
}
}
func TestMySQLExternalDashboardSourceFiltersLegacyRegionCountries(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {

View File

@ -1345,9 +1345,33 @@ func topLevelMetrics(overview map[string]any) map[string]any {
}
out[key] = value
}
flattenRetentionMetrics(out, overview["retention"])
return out
}
func flattenRetentionMetrics(out map[string]any, retention any) {
retentionRow, ok := retention.(map[string]any)
if !ok {
return
}
for _, item := range []struct {
day string
rateKey string
usersKey string
baseKey string
}{
{day: "day1", rateKey: "d1_retention_rate", usersKey: "d1_retention_users", baseKey: "d1_retention_base_users"},
{day: "day7", rateKey: "d7_retention_rate", usersKey: "d7_retention_users", baseKey: "d7_retention_base_users"},
{day: "day30", rateKey: "d30_retention_rate", usersKey: "d30_retention_users", baseKey: "d30_retention_base_users"},
} {
// statistics-service 的历史契约把整段留存放在 retention 子对象里;
// Databi v2 的 App 行统一读取扁平字段,这里只做字段搬运,不重算口径。
out[item.rateKey] = retentionRow[item.day+"_rate"]
out[item.usersKey] = retentionRow[item.day+"_users"]
out[item.baseKey] = retentionRow[item.day+"_base_users"]
}
}
func scopeInfos(scopes []model.UserMoneyScope) []ScopeInfo {
out := make([]ScopeInfo, 0, len(scopes))
for _, scope := range scopes {

View File

@ -42,12 +42,18 @@ func TestRegionDisplay(t *testing.T) {
}
func TestTopLevelMetricsStripsStructuralKeys(t *testing.T) {
day1Rate := 0.5
out := topLevelMetrics(map[string]any{
"recharge_usd_minor": int64(5),
"daily_series": []any{"x"},
"country_breakdown": []any{"y"},
"daily_country_breakdown": []any{"z"},
"report_metric_sources": []any{},
"retention": map[string]any{
"day1_base_users": int64(6),
"day1_rate": day1Rate,
"day1_users": int64(3),
},
})
if _, ok := out["daily_series"]; ok {
t.Fatalf("expected daily_series stripped")
@ -55,6 +61,9 @@ func TestTopLevelMetricsStripsStructuralKeys(t *testing.T) {
if out["recharge_usd_minor"] != int64(5) {
t.Fatalf("expected metric kept, got %v", out["recharge_usd_minor"])
}
if out["d1_retention_rate"] != day1Rate || out["d1_retention_users"] != int64(3) || out["d1_retention_base_users"] != int64(6) {
t.Fatalf("expected nested retention flattened, got %#v", out)
}
}
// legacy 雪花区域 ID 超出 JS 安全整数,历史 scope 里存的是 float64 圆整值;

View File

@ -248,6 +248,11 @@ type ConfirmMicPublishing struct {
RoomVersion int64 `json:"room_version"`
// EventTimeMS 是客户端 SDK 或 RTC webhook 的事件时间,必须晚于已接受事件。
EventTimeMS int64 `json:"event_time_ms"`
// AcceptedEventTimeMS 是 room-service 接受本次客户端确认时使用的服务端时间。
//
// 客户端 event_time_ms 可能受设备时钟漂移影响,只能保留为原始观测时间;恢复回放和后续媒体事件
// 排序必须使用已经被服务端 deadline 校验过的时间,避免重启后把偏快客户端时间写回状态。
AcceptedEventTimeMS int64 `json:"accepted_event_time_ms,omitempty"`
// Source 记录确认来源,例如 client 或 rtc_webhook。
Source string `json:"source,omitempty"`
// TargetDisplayProfile 是发流确认目标用户展示快照,只用于 IM/UI 兜底。
@ -686,6 +691,8 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "password_hash")
// MicUp 的发流确认 deadline 由 room-service 生成,客户端重试同一 command_id 时不能因此冲突。
delete(values, "publish_deadline_ms")
// ConfirmMicPublishing 的接受时间由 room-service 生成,不能参与客户端请求幂等语义。
delete(values, "accepted_event_time_ms")
return json.Marshal(values)
}

View File

@ -379,10 +379,8 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
// RTC webhook 可能晚于用户下麦到达;旧事件只忽略,不返回错误清掉新状态。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if seat.MicSessionID != cmd.MicSessionID ||
cmd.RoomVersion < seat.MicSessionRoomVersion ||
cmd.EventTimeMS <= seat.LastPublishEventTimeMS ||
cmd.EventTimeMS > seat.PublishDeadlineMS {
receivedAtMS := micPublishConfirmReceivedAtMS(seat, cmd, s.micPublishTimeout, now)
if !micPublishConfirmationMatchesCurrentSession(seat, cmd, receivedAtMS) {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
if seat.PublishState == state.MicPublishPublishing {
@ -392,11 +390,18 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
return mutationResult{}, nil, xerr.New(xerr.Conflict, "mic is not waiting for publish confirmation")
}
acceptedCmd := cmd
acceptedCmd.AcceptedEventTimeMS = receivedAtMS
commandPayload, err := command.Serialize(acceptedCmd)
if err != nil {
return mutationResult{}, nil, err
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].LastPublishEventTimeMS = cmd.EventTimeMS
// 确认发流本身代表当前会话刚被服务端接受,先初始化心跳时间,后续 MicHeartbeat 只负责刷新这个独立字段。
current.MicSeats[index].MicHeartbeatAtMS = now.UnixMilli()
current.MicSeats[index].LastPublishEventTimeMS = receivedAtMS
// 确认发流本身代表当前会话刚被服务端接受,心跳起点使用同一个 accepted time保证 command log 回放一致
current.MicSeats[index].MicHeartbeatAtMS = receivedAtMS
if micPublishConfirmSourceIsMuted(cmd.Source) {
current.MicSeats[index].MicMuted = true
}
@ -413,7 +418,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
MicSessionId: cmd.MicSessionID,
PublishState: state.MicPublishPublishing,
PublishDeadlineMs: seat.PublishDeadlineMS,
PublishEventTimeMs: cmd.EventTimeMS,
PublishEventTimeMs: receivedAtMS,
MicMuted: current.MicSeats[index].MicMuted,
SeatStatus: seatStatus,
TargetGiftValue: targetGiftValue,
@ -433,6 +438,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
micSessionID: cmd.MicSessionID,
publishDeadlineMS: seat.PublishDeadlineMS,
micHeartbeatAtMS: current.MicSeats[index].MicHeartbeatAtMS,
commandPayload: commandPayload,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
@ -456,6 +462,46 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
}, nil
}
func micPublishConfirmationMatchesCurrentSession(seat state.MicSeat, cmd command.ConfirmMicPublishing, arrivedAtMS int64) bool {
if seat.MicSessionID != cmd.MicSessionID || cmd.RoomVersion < seat.MicSessionRoomVersion {
// session_id 和创建 session 的房间版本共同防止快速下麦/重上麦后旧确认误推进新会话。
return false
}
if cmd.EventTimeMS <= seat.LastPublishEventTimeMS {
// event_time_ms 只负责同一会话内的事件顺序,不能拿它判断客户端请求是否真的超时。
return false
}
if seat.PublishDeadlineMS <= 0 || arrivedAtMS >= seat.PublishDeadlineMS {
// deadline 是服务端创建的等待窗口:客户端时钟可能快于服务端,只有服务端到达时间越界才算真实超时。
return false
}
return true
}
func micPublishConfirmReceivedAtMS(seat state.MicSeat, cmd command.ConfirmMicPublishing, timeout time.Duration, now time.Time) int64 {
receivedAtMS := cmd.SentAtMS
if receivedAtMS <= 0 {
return now.UnixMilli()
}
if createdAtMS := rtcMicSessionCreatedAtMS(seat, timeout); createdAtMS > 0 && receivedAtMS < createdAtMS {
// sent_at_ms 正常来自 gateway 服务端时间;若测试夹具或异常入口给了早于会话创建的时间,
// 不能把它当成本次确认时间,否则恢复后 LastPublishEventTimeMS 会落到会话创建前。
return now.UnixMilli()
}
return receivedAtMS
}
func confirmMicPublishingAcceptedEventTimeMS(cmd *command.ConfirmMicPublishing) int64 {
if cmd == nil {
return 0
}
if cmd.AcceptedEventTimeMS > 0 {
return cmd.AcceptedEventTimeMS
}
return cmd.EventTimeMS
}
// MicHeartbeat 刷新当前 publishing 麦位会话的服务端心跳时间。
func (s *Service) MicHeartbeat(ctx context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())

View File

@ -2,10 +2,13 @@ package service_test
import (
"context"
"fmt"
"testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
@ -54,6 +57,161 @@ func TestMicUpReturnsExistingSeatForDuplicateUserCommand(t *testing.T) {
}
}
func TestConfirmMicPublishingAcceptsFastClientClockBeforeServerDeadline(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-mic-confirm-fast-client-clock"
ownerID := int64(8551)
speakerID := int64(8552)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-fast-clock-up"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
arrivedAtMS := upResp.GetPublishDeadlineMs() - 1
fastClientEventMS := upResp.GetPublishDeadlineMs() + int64((5 * time.Second).Milliseconds())
now.now = time.UnixMilli(arrivedAtMS)
confirmMeta := rocketMeta(roomID, speakerID, "mic-confirm-fast-clock-confirm")
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: confirmMeta,
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: fastClientEventMS,
Source: "client",
})
if err != nil {
t.Fatalf("confirm mic publishing failed: %v", err)
}
if !confirmResp.GetResult().GetApplied() {
t.Fatalf("fast client event_time_ms must not be treated as timeout before server deadline: %+v", confirmResp.GetResult())
}
seat := seatByNo(confirmResp.GetRoom(), 2)
if seat == nil || seat.GetPublishState() != "publishing" {
t.Fatalf("seat must be confirmed as publishing: %+v", seat)
}
if seat.GetLastPublishEventTimeMs() != arrivedAtMS || seat.GetMicHeartbeatAtMs() != arrivedAtMS {
t.Fatalf("confirmation must store server accepted time, not fast client event time: seat=%+v accepted=%d client_event=%d", seat, arrivedAtMS, fastClientEventMS)
}
record, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, confirmMeta.GetCommandId())
if err != nil {
t.Fatalf("get confirm command log failed: %v", err)
}
if !exists {
t.Fatalf("confirm command log must be persisted")
}
decoded, err := command.Deserialize(command.ConfirmMicPublishing{}.Type(), record.Payload)
if err != nil {
t.Fatalf("decode confirm command log failed: %v", err)
}
confirmCmd, ok := decoded.(*command.ConfirmMicPublishing)
if !ok {
t.Fatalf("confirm command log has unexpected type: %T", decoded)
}
if confirmCmd.EventTimeMS != fastClientEventMS || confirmCmd.AcceptedEventTimeMS != arrivedAtMS {
t.Fatalf("confirm command log must keep raw client time and accepted server time: %+v raw=%d accepted=%d", confirmCmd, fastClientEventMS, arrivedAtMS)
}
}
func TestConfirmMicPublishingIgnoresStaleSessionVersionAndTimeout(t *testing.T) {
tests := []struct {
name string
adjust func(clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse)
request func(roomID string, speakerID int64, clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) *roomv1.ConfirmMicPublishingRequest
}{
{
name: "old_session",
adjust: func(clock *fixedRoomRocketClock, _ *roomv1.MicUpResponse) {
clock.now = clock.now.Add(500 * time.Millisecond)
},
request: func(roomID string, speakerID int64, clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) *roomv1.ConfirmMicPublishingRequest {
return &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-old-session"),
MicSessionId: upResp.GetMicSessionId() + "-old",
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: clock.Now().UnixMilli(),
Source: "client",
}
},
},
{
name: "old_room_version",
adjust: func(clock *fixedRoomRocketClock, _ *roomv1.MicUpResponse) {
clock.now = clock.now.Add(500 * time.Millisecond)
},
request: func(roomID string, speakerID int64, clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) *roomv1.ConfirmMicPublishingRequest {
return &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-old-room-version"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion() - 1,
EventTimeMs: clock.Now().UnixMilli(),
Source: "client",
}
},
},
{
name: "deadline_reached",
adjust: func(clock *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) {
clock.now = time.UnixMilli(upResp.GetPublishDeadlineMs())
},
request: func(roomID string, speakerID int64, _ *fixedRoomRocketClock, upResp *roomv1.MicUpResponse) *roomv1.ConfirmMicPublishingRequest {
return &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-deadline-reached"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: upResp.GetPublishDeadlineMs() - 1,
Source: "client",
}
},
},
}
for index, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := fmt.Sprintf("room-mic-confirm-guard-%d", index)
ownerID := int64(8561 + index*10)
speakerID := int64(8562 + index*10)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, fmt.Sprintf("mic-confirm-guard-up-%d", index)),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
tt.adjust(now, upResp)
confirmResp, err := svc.ConfirmMicPublishing(ctx, tt.request(roomID, speakerID, now, upResp))
if err != nil {
t.Fatalf("stale confirm must be ignored without error: %v", err)
}
if confirmResp.GetResult().GetApplied() || confirmResp.GetRoom().GetVersion() != upResp.GetRoom().GetVersion() {
t.Fatalf("stale confirm must not advance room state: confirm=%+v up_version=%d", confirmResp.GetResult(), upResp.GetRoom().GetVersion())
}
seat := seatByNo(confirmResp.GetRoom(), 2)
if seat == nil || seat.GetPublishState() != "pending_publish" || seat.GetLastPublishEventTimeMs() != 0 || seat.GetMicHeartbeatAtMs() != 0 {
t.Fatalf("stale confirm must leave pending session untouched: %+v", seat)
}
})
}
}
func TestMicHeartbeatRefreshesPublishingSessionAndPresence(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -111,6 +269,120 @@ func TestMicHeartbeatRefreshesPublishingSessionAndPresence(t *testing.T) {
}
}
func TestConfirmMicPublishingIgnoresBackwardEventTime(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-mic-confirm-backward-event"
ownerID := int64(8651)
speakerID := int64(8652)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-backward-up"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
now.now = now.now.Add(time.Second)
acceptedEventMS := now.Now().UnixMilli()
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-backward-first"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: acceptedEventMS,
Source: "client",
})
if err != nil {
t.Fatalf("first confirm failed: %v", err)
}
if !confirmResp.GetResult().GetApplied() {
t.Fatalf("first confirm must apply: %+v", confirmResp.GetResult())
}
now.now = now.now.Add(time.Second)
backwardResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "mic-confirm-backward-retry"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: confirmResp.GetRoom().GetVersion(),
EventTimeMs: acceptedEventMS - 1,
Source: "client",
})
if err != nil {
t.Fatalf("backward confirm must be ignored without error: %v", err)
}
if backwardResp.GetResult().GetApplied() || backwardResp.GetRoom().GetVersion() != confirmResp.GetRoom().GetVersion() {
t.Fatalf("backward event must not advance room state: backward=%+v confirmed_version=%d", backwardResp.GetResult(), confirmResp.GetRoom().GetVersion())
}
seat := seatByNo(backwardResp.GetRoom(), 2)
if seat == nil || seat.GetPublishState() != "publishing" || seat.GetLastPublishEventTimeMs() != acceptedEventMS {
t.Fatalf("backward event must not lower accepted event time: %+v want_event=%d", seat, acceptedEventMS)
}
}
func TestRTCAudioStoppedReleasesAfterFastClientConfirmWatermark(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 4, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now)
roomID := "room-rtc-stop-after-fast-confirm"
ownerID := int64(8661)
speakerID := int64(8662)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, speakerID)
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, speakerID, "rtc-stop-after-fast-confirm-up"),
SeatNo: 2,
})
if err != nil {
t.Fatalf("mic up failed: %v", err)
}
createdAtMS := upResp.GetPublishDeadlineMs() - int64((15 * time.Second).Milliseconds())
stopEventMS := createdAtMS + int64((2 * time.Second).Milliseconds())
acceptedAtMS := upResp.GetPublishDeadlineMs() - 1
now.now = time.UnixMilli(acceptedAtMS)
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: rocketMeta(roomID, speakerID, "rtc-stop-after-fast-confirm-confirm"),
MicSessionId: upResp.GetMicSessionId(),
RoomVersion: upResp.GetRoom().GetVersion(),
EventTimeMs: upResp.GetPublishDeadlineMs() + 5000,
Source: "client",
})
if err != nil {
t.Fatalf("confirm mic publishing failed: %v", err)
}
confirmedSeat := seatByNo(confirmResp.GetRoom(), 2)
if confirmedSeat == nil || confirmedSeat.GetLastPublishEventTimeMs() != acceptedAtMS {
t.Fatalf("confirm must store accepted watermark before RTC stop test: %+v accepted=%d", confirmedSeat, acceptedAtMS)
}
now.now = now.now.Add(time.Second)
stopResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: rocketMeta(roomID, speakerID, "rtc-stop-after-fast-confirm-stop"),
TargetUserId: speakerID,
EventType: "audio_stopped",
EventTimeMs: stopEventMS,
Reason: "rtc_audio_stopped:0",
Source: "tencent_rtc_callback",
})
if err != nil {
t.Fatalf("rtc audio stopped failed: %v", err)
}
if !stopResp.GetResult().GetApplied() {
t.Fatalf("rtc stop must release current mic session even when provider event time is below client confirm accepted time: %+v", stopResp.GetResult())
}
seat := seatByNo(stopResp.GetRoom(), 2)
if seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
t.Fatalf("rtc stop must clear current mic seat: %+v", seat)
}
}
func TestMicHeartbeatRejectsPendingSession(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)

View File

@ -251,9 +251,10 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
return fmt.Errorf("confirm_mic_publishing replay target is not on seat: %d", typed.TargetUserID)
}
index := current.SeatIndex(seat.SeatNo)
acceptedEventTimeMS := confirmMicPublishingAcceptedEventTimeMS(typed)
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
current.MicSeats[index].MicHeartbeatAtMS = typed.SentAtMS
current.MicSeats[index].LastPublishEventTimeMS = acceptedEventTimeMS
current.MicSeats[index].MicHeartbeatAtMS = acceptedEventTimeMS
current.Version++
case *command.MicHeartbeat:
seat, exists := current.SeatByUser(typed.TargetUserID)

View File

@ -236,6 +236,10 @@ func rtcEventMatchesCurrentMicSession(seat state.MicSeat, eventTimeMS int64, tim
if !rtcStopEventMatchesCurrentMicSession(seat, eventTimeMS, timeout) {
return false
}
if eventTimeMS <= seat.LastPublishEventTimeMS {
// audio_started 必须晚于已接受发布水位,防止腾讯重试或乱序开始事件反复推进状态。
return false
}
if seat.PublishDeadlineMS > 0 && eventTimeMS > seat.PublishDeadlineMS {
// audio_started 过了确认 deadline 不再接受,超时 worker 会按 mic_session_id 释放麦位。
return false
@ -246,8 +250,8 @@ func rtcEventMatchesCurrentMicSession(seat state.MicSeat, eventTimeMS int64, tim
// rtcStopEventMatchesCurrentMicSession 校验停止/退房事件是否能作用于当前 mic_session。
func rtcStopEventMatchesCurrentMicSession(seat state.MicSeat, eventTimeMS int64, timeout time.Duration) bool {
if seat.MicSessionID == "" || eventTimeMS <= seat.LastPublishEventTimeMS {
// 没有会话或事件时间不递增时,事件不能改变当前麦位
if seat.MicSessionID == "" {
// 没有会话时,停止/退房事件只能是旧回调
return false
}
if createdAtMS := rtcMicSessionCreatedAtMS(seat, timeout); createdAtMS > 0 && eventTimeMS < createdAtMS {

View File

@ -109,7 +109,9 @@ CREATE TABLE IF NOT EXISTS stat_user_registration (
registered_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_tz, user_id),
KEY idx_stat_user_registration_day (app_code, stat_tz, registered_day, country_id),
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id),
KEY idx_stat_user_registration_country_day (app_code, stat_tz, country_id, registered_day, user_id),
KEY idx_stat_user_registration_region_day (app_code, stat_tz, region_id, registered_day, country_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户注册日留存 cohort 表';
CREATE TABLE IF NOT EXISTS stat_user_dimension (

View File

@ -0,0 +1,47 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Databi 留存查询会按注册日 cohort 读取 stat_user_registration并可叠加国家或区域筛选。
-- 这两个索引只用于老库受控补齐:请在低峰执行,并在执行后对留存 SQL 做 EXPLAIN。
-- 不放进服务启动自动迁移,避免大表普通 ALTER 在生产启动时造成元数据锁或 IO 峰值。
SET @add_retention_country_day := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE stat_user_registration ADD INDEX idx_stat_user_registration_country_day (app_code, stat_tz, country_id, registered_day, user_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'stat_user_registration'
AND INDEX_NAME = 'idx_stat_user_registration_country_day'
);
PREPARE stmt FROM @add_retention_country_day;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_retention_region_day := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE stat_user_registration ADD INDEX idx_stat_user_registration_region_day (app_code, stat_tz, region_id, registered_day, country_id, user_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'stat_user_registration'
AND INDEX_NAME = 'idx_stat_user_registration_region_day'
);
PREPARE stmt FROM @add_retention_region_day;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT
COUNT(*) AS retention_registration_filter_index_count
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'stat_user_registration'
AND INDEX_NAME IN (
'idx_stat_user_registration_country_day',
'idx_stat_user_registration_region_day'
);

View File

@ -111,59 +111,71 @@ type Overview struct {
}
type DailySeriesItem struct {
StatDay string `json:"stat_day"`
Label string `json:"label"`
NewUsers int64 `json:"new_users"`
RegisteredUsers int64 `json:"registered_users"`
Registrations int64 `json:"registrations"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUsers int64 `json:"recharge_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
CoinTotal int64 `json:"coin_total"`
ConsumedCoin int64 `json:"consumed_coin"`
OutputCoin int64 `json:"output_coin"`
ConsumeOutputRatio float64 `json:"consume_output_ratio"`
ConsumeOutputDelta int64 `json:"consume_output_delta"`
PlatformGrantCoin int64 `json:"platform_grant_coin"`
ManualGrantCoin int64 `json:"manual_grant_coin"`
SalaryUSDMinor int64 `json:"salary_usd_minor"`
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
MicOnlineMS int64 `json:"mic_online_ms"`
MicOnlineUsers int64 `json:"mic_online_users"`
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
GiftCoinSpent int64 `json:"gift_coin_spent"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
GameTurnover int64 `json:"game_turnover"`
GameProfit int64 `json:"game_profit"`
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
PayerRate float64 `json:"payer_rate"`
PaidConversionRate float64 `json:"paid_conversion_rate"`
RechargeConversionRate float64 `json:"recharge_conversion_rate"`
StatDay string `json:"stat_day"`
Label string `json:"label"`
NewUsers int64 `json:"new_users"`
RegisteredUsers int64 `json:"registered_users"`
Registrations int64 `json:"registrations"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUsers int64 `json:"recharge_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
CoinTotal int64 `json:"coin_total"`
ConsumedCoin int64 `json:"consumed_coin"`
OutputCoin int64 `json:"output_coin"`
ConsumeOutputRatio float64 `json:"consume_output_ratio"`
ConsumeOutputDelta int64 `json:"consume_output_delta"`
PlatformGrantCoin int64 `json:"platform_grant_coin"`
ManualGrantCoin int64 `json:"manual_grant_coin"`
SalaryUSDMinor int64 `json:"salary_usd_minor"`
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
MicOnlineMS int64 `json:"mic_online_ms"`
MicOnlineUsers int64 `json:"mic_online_users"`
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
GiftCoinSpent int64 `json:"gift_coin_spent"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
GameTurnover int64 `json:"game_turnover"`
GameProfit int64 `json:"game_profit"`
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
PayerRate float64 `json:"payer_rate"`
PaidConversionRate float64 `json:"paid_conversion_rate"`
RechargeConversionRate float64 `json:"recharge_conversion_rate"`
D1RetentionUsers *int64 `json:"d1_retention_users"`
D1RetentionBaseUsers *int64 `json:"d1_retention_base_users"`
D1RetentionRate *float64 `json:"d1_retention_rate"`
D7RetentionUsers *int64 `json:"d7_retention_users"`
D7RetentionBaseUsers *int64 `json:"d7_retention_base_users"`
D7RetentionRate *float64 `json:"d7_retention_rate"`
D30RetentionUsers *int64 `json:"d30_retention_users"`
D30RetentionBaseUsers *int64 `json:"d30_retention_base_users"`
D30RetentionRate *float64 `json:"d30_retention_rate"`
}
type Retention struct {
RegisteredUsers int64 `json:"registered_users"`
Day1Users int64 `json:"day1_users"`
Day7Users int64 `json:"day7_users"`
Day30Users int64 `json:"day30_users"`
Day1Rate float64 `json:"day1_rate"`
Day7Rate float64 `json:"day7_rate"`
Day30Rate float64 `json:"day30_rate"`
RegisteredUsers int64 `json:"registered_users"`
Day1Users *int64 `json:"day1_users"`
Day1BaseUsers *int64 `json:"day1_base_users"`
Day1Rate *float64 `json:"day1_rate"`
Day7Users *int64 `json:"day7_users"`
Day7BaseUsers *int64 `json:"day7_base_users"`
Day7Rate *float64 `json:"day7_rate"`
Day30Users *int64 `json:"day30_users"`
Day30BaseUsers *int64 `json:"day30_base_users"`
Day30Rate *float64 `json:"day30_rate"`
}
type GameRank struct {
@ -192,55 +204,64 @@ type superLuckyAggregate struct {
}
type CountryBreakdown struct {
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
NewUsers int64 `json:"new_users"`
RegisteredUsers int64 `json:"registered_users"`
Registrations int64 `json:"registrations"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
RechargeUsers int64 `json:"recharge_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
CoinTotal int64 `json:"coin_total"`
ConsumedCoin int64 `json:"consumed_coin"`
OutputCoin int64 `json:"output_coin"`
ConsumeOutputRatio float64 `json:"consume_output_ratio"`
ConsumeOutputDelta int64 `json:"consume_output_delta"`
PlatformGrantCoin int64 `json:"platform_grant_coin"`
ManualGrantCoin int64 `json:"manual_grant_coin"`
SalaryUSDMinor int64 `json:"salary_usd_minor"`
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
MicOnlineMS int64 `json:"mic_online_ms"`
MicOnlineUsers int64 `json:"mic_online_users"`
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
GiftCoinSpent int64 `json:"gift_coin_spent"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
GameTurnover int64 `json:"game_turnover"`
GamePayout int64 `json:"game_payout"`
GameRefund int64 `json:"game_refund"`
GamePlayers int64 `json:"game_players"`
GameProfit int64 `json:"game_profit"`
GameProfitRate float64 `json:"game_profit_rate"`
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
PayerRate float64 `json:"payer_rate"`
PaidConversionRate float64 `json:"paid_conversion_rate"`
RechargeConversionRate float64 `json:"recharge_conversion_rate"`
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
NewUsers int64 `json:"new_users"`
RegisteredUsers int64 `json:"registered_users"`
Registrations int64 `json:"registrations"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
RechargeUsers int64 `json:"recharge_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
CoinTotal int64 `json:"coin_total"`
ConsumedCoin int64 `json:"consumed_coin"`
OutputCoin int64 `json:"output_coin"`
ConsumeOutputRatio float64 `json:"consume_output_ratio"`
ConsumeOutputDelta int64 `json:"consume_output_delta"`
PlatformGrantCoin int64 `json:"platform_grant_coin"`
ManualGrantCoin int64 `json:"manual_grant_coin"`
SalaryUSDMinor int64 `json:"salary_usd_minor"`
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
MicOnlineMS int64 `json:"mic_online_ms"`
MicOnlineUsers int64 `json:"mic_online_users"`
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
GiftCoinSpent int64 `json:"gift_coin_spent"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
GameTurnover int64 `json:"game_turnover"`
GamePayout int64 `json:"game_payout"`
GameRefund int64 `json:"game_refund"`
GamePlayers int64 `json:"game_players"`
GameProfit int64 `json:"game_profit"`
GameProfitRate float64 `json:"game_profit_rate"`
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
PayerRate float64 `json:"payer_rate"`
PaidConversionRate float64 `json:"paid_conversion_rate"`
RechargeConversionRate float64 `json:"recharge_conversion_rate"`
D1RetentionUsers *int64 `json:"d1_retention_users"`
D1RetentionBaseUsers *int64 `json:"d1_retention_base_users"`
D1RetentionRate *float64 `json:"d1_retention_rate"`
D7RetentionUsers *int64 `json:"d7_retention_users"`
D7RetentionBaseUsers *int64 `json:"d7_retention_base_users"`
D7RetentionRate *float64 `json:"d7_retention_rate"`
D30RetentionUsers *int64 `json:"d30_retention_users"`
D30RetentionBaseUsers *int64 `json:"d30_retention_base_users"`
D30RetentionRate *float64 `json:"d30_retention_rate"`
}
type DailyCountryStat struct {
@ -529,23 +550,23 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
applyOverviewDerivedMetrics(&overview)
applyOverviewDerivedMetrics(&previous)
overview.applyDeltaRates(previous)
retention, err := r.queryRetention(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
retentionCube, err := r.queryRetentionCube(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.Retention = retention
overview.Retention = retentionCube.Total.toRetention()
ranking, err := r.queryGameRanking(ctx, app, statTZ, startDay, endDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
overview.GameRanking = ranking
if query.CountryID <= 0 {
breakdown, err := r.queryCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID)
breakdown, err := r.queryCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID, retentionCube.ByCountry)
if err != nil {
return Overview{}, err
}
overview.CountryBreakdown = breakdown
dailyCountryBreakdown, err := r.queryDailyCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID)
dailyCountryBreakdown, err := r.queryDailyCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID, retentionCube.ByDayCountry)
if err != nil {
return Overview{}, err
}
@ -556,8 +577,16 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
seriesStartDay = statDayIn(query.SeriesStartMS, statTZ)
seriesEndDay = statDayIn(query.SeriesEndMS-1, statTZ)
}
seriesRetentionByDay := retentionCube.ByDay
if seriesStartDay != startDay || seriesEndDay != endDay {
seriesRetentionCube, err := r.queryRetentionCube(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
if err != nil {
return Overview{}, err
}
seriesRetentionByDay = seriesRetentionCube.ByDay
}
// daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。
dailySeries, err := r.queryDailySeries(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
dailySeries, err := r.queryDailySeries(ctx, app, statTZ, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID, seriesRetentionByDay)
if err != nil {
return Overview{}, err
}
@ -787,7 +816,7 @@ func (r *Repository) queryRealRoomRobotGiftOverview(ctx context.Context, app, st
return overview, nil
}
func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]DailySeriesItem, error) {
func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64, retentionByDay map[string]retentionCount) ([]DailySeriesItem, error) {
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
if countryID > 0 {
@ -840,6 +869,13 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay
for index := range out {
out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay]
}
for index := range out {
if retention, ok := retentionByDay[out[index].StatDay]; ok {
out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
}
}
superByDay, err := r.querySuperLuckyGiftDaily(ctx, app, statTZ, startDay, endDay, countryID, regionID)
if err != nil {
return nil, err
@ -895,6 +931,118 @@ func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, statTZ, st
return out, rows.Err()
}
type retentionCount struct {
RegisteredUsers int64
D1Users int64
D1Base int64
D7Users int64
D7Base int64
D30Users int64
D30Base int64
}
func (count *retentionCount) add(next retentionCount) {
count.RegisteredUsers += next.RegisteredUsers
count.D1Users += next.D1Users
count.D1Base += next.D1Base
count.D7Users += next.D7Users
count.D7Base += next.D7Base
count.D30Users += next.D30Users
count.D30Base += next.D30Base
}
func (count retentionCount) toRetention() Retention {
out := Retention{RegisteredUsers: count.RegisteredUsers}
out.Day1Users, out.Day1BaseUsers, out.Day1Rate = retentionPointers(count.D1Users, count.D1Base)
out.Day7Users, out.Day7BaseUsers, out.Day7Rate = retentionPointers(count.D7Users, count.D7Base)
out.Day30Users, out.Day30BaseUsers, out.Day30Rate = retentionPointers(count.D30Users, count.D30Base)
return out
}
type retentionCube struct {
Total retentionCount
ByDay map[string]retentionCount
ByCountry map[int64]retentionCount
ByDayCountry map[string]retentionCount
}
func newRetentionCube() retentionCube {
return retentionCube{
ByDay: map[string]retentionCount{},
ByCountry: map[int64]retentionCount{},
ByDayCountry: map[string]retentionCount{},
}
}
func (cube *retentionCube) add(day string, countryID int64, count retentionCount) {
cube.Total.add(count)
dayCount := cube.ByDay[day]
dayCount.add(count)
cube.ByDay[day] = dayCount
countryCount := cube.ByCountry[countryID]
countryCount.add(count)
cube.ByCountry[countryID] = countryCount
dayCountryKey := dailyCountryKey(day, countryID)
dayCountryCount := cube.ByDayCountry[dayCountryKey]
dayCountryCount.add(count)
cube.ByDayCountry[dayCountryKey] = dayCountryCount
}
func (r *Repository) queryRetentionCube(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (retentionCube, error) {
tz := normalizeStatTZ(statTZ)
today := statDayIn(time.Now().UnixMilli(), tz)
args := []any{
today, today,
today, today,
today, today,
app, tz, startDay, endDay,
}
filter := ""
if countryID > 0 {
filter += " AND r.country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND r.region_id = ?"
args = append(args, regionID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT DATE_FORMAT(r.registered_day, '%Y-%m-%d'), r.country_id, COUNT(*),
COALESCE(SUM(CASE WHEN d1.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 1 DAY) <= ? THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN d7.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 7 DAY) <= ? THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN d30.user_id IS NOT NULL THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN DATE_ADD(r.registered_day, INTERVAL 30 DAY) <= ? THEN 1 ELSE 0 END),0)
FROM stat_user_registration r
LEFT JOIN stat_user_day_activity d1 ON DATE_ADD(r.registered_day, INTERVAL 1 DAY) <= ? AND d1.app_code = r.app_code AND d1.stat_tz = r.stat_tz AND d1.stat_day = DATE_ADD(r.registered_day, INTERVAL 1 DAY) AND d1.user_id = r.user_id
LEFT JOIN stat_user_day_activity d7 ON DATE_ADD(r.registered_day, INTERVAL 7 DAY) <= ? AND d7.app_code = r.app_code AND d7.stat_tz = r.stat_tz AND d7.stat_day = DATE_ADD(r.registered_day, INTERVAL 7 DAY) AND d7.user_id = r.user_id
LEFT JOIN stat_user_day_activity d30 ON DATE_ADD(r.registered_day, INTERVAL 30 DAY) <= ? AND d30.app_code = r.app_code AND d30.stat_tz = r.stat_tz AND d30.stat_day = DATE_ADD(r.registered_day, INTERVAL 30 DAY) AND d30.user_id = r.user_id
WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter+`
GROUP BY r.registered_day, r.country_id`, args...)
if err != nil {
return retentionCube{}, err
}
defer rows.Close()
out := newRetentionCube()
for rows.Next() {
var day string
var countryID int64
var count retentionCount
if err := rows.Scan(&day, &countryID, &count.RegisteredUsers, &count.D1Users, &count.D1Base, &count.D7Users, &count.D7Base, &count.D30Users, &count.D30Base); err != nil {
return retentionCube{}, err
}
out.add(day, countryID, count)
}
if err := rows.Err(); err != nil {
return retentionCube{}, err
}
return out, nil
}
func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
if item == nil {
return
@ -975,34 +1123,6 @@ func (overview *Overview) applyDeltaRates(previous Overview) {
overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit)
}
func (r *Repository) queryRetention(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (Retention, error) {
filter := ""
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
if countryID > 0 {
filter = " AND r.country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND r.region_id = ?"
args = append(args, regionID)
}
row := r.db.QueryRowContext(ctx, `
SELECT COUNT(*),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 7 DAY))),0),
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.stat_tz=r.stat_tz AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 30 DAY))),0)
FROM stat_user_registration r
WHERE r.app_code = ? AND r.stat_tz = ? AND r.registered_day BETWEEN ? AND ?`+filter, args...)
var out Retention
if err := row.Scan(&out.RegisteredUsers, &out.Day1Users, &out.Day7Users, &out.Day30Users); err != nil && err != sql.ErrNoRows {
return Retention{}, err
}
out.Day1Rate = ratio(out.Day1Users, out.RegisteredUsers)
out.Day7Rate = ratio(out.Day7Users, out.RegisteredUsers)
out.Day30Rate = ratio(out.Day30Users, out.RegisteredUsers)
return out, nil
}
func (r *Repository) queryGameRanking(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) {
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
@ -1039,7 +1159,7 @@ func (r *Repository) queryGameRanking(ctx context.Context, app, statTZ, startDay
return out, rows.Err()
}
func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) {
func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64, retentionByCountry map[int64]retentionCount) ([]CountryBreakdown, error) {
args := []any{endDay, endDay, app, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
if regionID > 0 {
@ -1081,6 +1201,13 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, sta
if err := rows.Err(); err != nil {
return nil, err
}
for index := range out {
if retention, ok := retentionByCountry[out[index].CountryID]; ok {
out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
}
}
superByCountry, err := r.querySuperLuckyGiftCountry(ctx, app, statTZ, startDay, endDay, regionID)
if err != nil {
return nil, err
@ -1095,7 +1222,7 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, sta
return out, nil
}
func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) ([]DailyCountryStat, error) {
func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64, retentionByDayCountry map[string]retentionCount) ([]DailyCountryStat, error) {
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
filter := ""
if regionID > 0 {
@ -1137,6 +1264,13 @@ func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ
if err := rows.Err(); err != nil {
return nil, err
}
for index := range out {
if retention, ok := retentionByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok {
out[index].D1RetentionUsers, out[index].D1RetentionBaseUsers, out[index].D1RetentionRate = retentionPointers(retention.D1Users, retention.D1Base)
out[index].D7RetentionUsers, out[index].D7RetentionBaseUsers, out[index].D7RetentionRate = retentionPointers(retention.D7Users, retention.D7Base)
out[index].D30RetentionUsers, out[index].D30RetentionBaseUsers, out[index].D30RetentionRate = retentionPointers(retention.D30Users, retention.D30Base)
}
}
superByDayCountry, err := r.querySuperLuckyGiftDailyCountry(ctx, app, statTZ, startDay, endDay, regionID)
if err != nil {
return nil, err
@ -1912,6 +2046,16 @@ func ratio(numerator, denominator int64) float64 {
return float64(numerator) / float64(denominator)
}
func retentionPointers(users, base int64) (*int64, *int64, *float64) {
if base <= 0 {
return nil, nil, nil
}
usersValue := users
baseValue := base
rateValue := ratio(users, base)
return &usersValue, &baseValue, &rateValue
}
func selfGameMetricDefinitions() []MetricDefinition {
return []MetricDefinition{
{Metric: "game_pv", Definition: "H5 page_open event_count同一用户多次打开会重复计数。"},

View File

@ -3,11 +3,13 @@ package mysql
import (
"context"
"encoding/json"
"math"
"runtime"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
)
@ -615,6 +617,139 @@ func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) {
}
}
func TestQueryOverviewReturnsRetentionForAllBreakdowns(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_query_retention_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
today, err := time.Parse("2006-01-02", statDayIn(time.Now().UnixMilli(), StatTZUTC))
if err != nil {
t.Fatalf("parse today: %v", err)
}
registeredAt := today.AddDate(0, 0, -1)
registeredDay := registeredAt.Format("2006-01-02")
retainedDay := today.Format("2006-01-02")
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_app_day_country (
app_code, stat_day, country_id, region_id, new_users, active_users, updated_at_ms
) VALUES ('lalu', ?, 86, 210, 2, 2, 1)`, registeredDay); err != nil {
t.Fatalf("seed day country stats: %v", err)
}
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_user_registration (
app_code, stat_tz, user_id, registered_day, country_id, region_id, registered_at_ms
) VALUES
('lalu', 'UTC', 9001, ?, 86, 210, 1),
('lalu', 'UTC', 9002, ?, 86, 210, 2)`, registeredDay, registeredDay); err != nil {
t.Fatalf("seed registrations: %v", err)
}
if _, err := repository.db.ExecContext(ctx, `
INSERT INTO stat_user_day_activity (
app_code, stat_tz, stat_day, country_id, region_id, user_id, first_active_at_ms
) VALUES ('lalu', 'UTC', ?, 86, 210, 9001, 3)`, retainedDay); err != nil {
t.Fatalf("seed retained activity: %v", err)
}
overview, err := repository.QueryOverview(ctx, OverviewQuery{
AppCode: "lalu",
StartMS: registeredAt.UnixMilli(),
EndMS: registeredAt.AddDate(0, 0, 1).UnixMilli(),
RegionID: 210,
})
if err != nil {
t.Fatalf("query overview: %v", err)
}
if overview.Retention.RegisteredUsers != 2 {
t.Fatalf("retention registered users mismatch: %+v", overview.Retention)
}
requireInt64Ptr(t, "overview d1 users", overview.Retention.Day1Users, 1)
requireInt64Ptr(t, "overview d1 base", overview.Retention.Day1BaseUsers, 2)
requireFloat64Ptr(t, "overview d1 rate", overview.Retention.Day1Rate, 0.5)
requireNilRetention(t, "overview d7", overview.Retention.Day7Users, overview.Retention.Day7BaseUsers, overview.Retention.Day7Rate)
requireNilRetention(t, "overview d30", overview.Retention.Day30Users, overview.Retention.Day30BaseUsers, overview.Retention.Day30Rate)
if len(overview.DailySeries) != 1 {
t.Fatalf("daily series missing retention day: %+v", overview.DailySeries)
}
requireInt64Ptr(t, "daily d1 users", overview.DailySeries[0].D1RetentionUsers, 1)
requireInt64Ptr(t, "daily d1 base", overview.DailySeries[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "daily d1 rate", overview.DailySeries[0].D1RetentionRate, 0.5)
requireNilRetention(t, "daily d7", overview.DailySeries[0].D7RetentionUsers, overview.DailySeries[0].D7RetentionBaseUsers, overview.DailySeries[0].D7RetentionRate)
if len(overview.CountryBreakdown) != 1 {
t.Fatalf("country breakdown missing retention country: %+v", overview.CountryBreakdown)
}
requireInt64Ptr(t, "country d1 users", overview.CountryBreakdown[0].D1RetentionUsers, 1)
requireInt64Ptr(t, "country d1 base", overview.CountryBreakdown[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "country d1 rate", overview.CountryBreakdown[0].D1RetentionRate, 0.5)
if len(overview.DailyCountryBreakdown) != 1 {
t.Fatalf("daily country breakdown missing retention country: %+v", overview.DailyCountryBreakdown)
}
requireInt64Ptr(t, "daily country d1 users", overview.DailyCountryBreakdown[0].D1RetentionUsers, 1)
requireInt64Ptr(t, "daily country d1 base", overview.DailyCountryBreakdown[0].D1RetentionBaseUsers, 2)
requireFloat64Ptr(t, "daily country d1 rate", overview.DailyCountryBreakdown[0].D1RetentionRate, 0.5)
}
func TestQueryRetentionCubeRollsUpDailyCountryRows(t *testing.T) {
ctx := context.Background()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
repository := &Repository{db: db}
today := statDayIn(time.Now().UnixMilli(), StatTZUTC)
rows := sqlmock.NewRows([]string{
"registered_day", "country_id", "registered_users",
"d1_users", "d1_base", "d7_users", "d7_base", "d30_users", "d30_base",
}).
AddRow("2026-06-01", int64(86), int64(2), int64(1), int64(2), int64(0), int64(0), int64(0), int64(0)).
AddRow("2026-06-01", int64(66), int64(3), int64(0), int64(3), int64(1), int64(3), int64(0), int64(0)).
AddRow("2026-06-02", int64(86), int64(4), int64(2), int64(4), int64(0), int64(0), int64(0), int64(0))
mock.ExpectQuery("SELECT DATE_FORMAT").
WithArgs(today, today, today, today, today, today, "lalu", StatTZUTC, "2026-06-01", "2026-06-02", int64(210)).
WillReturnRows(rows)
cube, err := repository.queryRetentionCube(ctx, "lalu", StatTZUTC, "2026-06-01", "2026-06-02", 0, 210)
if err != nil {
t.Fatalf("query retention cube: %v", err)
}
if cube.Total.RegisteredUsers != 9 || cube.Total.D1Users != 3 || cube.Total.D1Base != 9 || cube.Total.D7Users != 1 || cube.Total.D7Base != 3 {
t.Fatalf("total retention rollup mismatch: %+v", cube.Total)
}
retention := cube.Total.toRetention()
requireInt64Ptr(t, "total d1 users", retention.Day1Users, 3)
requireFloat64Ptr(t, "total d1 rate", retention.Day1Rate, 1.0/3.0)
requireNilRetention(t, "total d30", retention.Day30Users, retention.Day30BaseUsers, retention.Day30Rate)
day := cube.ByDay["2026-06-01"]
if day.RegisteredUsers != 5 || day.D1Users != 1 || day.D1Base != 5 || day.D7Users != 1 || day.D7Base != 3 {
t.Fatalf("day retention rollup mismatch: %+v", day)
}
country := cube.ByCountry[86]
if country.RegisteredUsers != 6 || country.D1Users != 3 || country.D1Base != 6 {
t.Fatalf("country retention rollup mismatch: %+v", country)
}
dayCountry := cube.ByDayCountry[dailyCountryKey("2026-06-01", 66)]
if dayCountry.RegisteredUsers != 3 || dayCountry.D1Users != 0 || dayCountry.D1Base != 3 || dayCountry.D7Users != 1 || dayCountry.D7Base != 3 {
t.Fatalf("daily country retention rollup mismatch: %+v", dayCountry)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestQueryOverviewReturnsRealRoomRobotGiftStats(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
@ -860,3 +995,24 @@ func TestQuerySelfGameOverviewUsesLatestPoolBalanceSnapshot(t *testing.T) {
t.Fatalf("pool balance should be latest snapshot, not max balance: %+v", overview.PoolStats)
}
}
func requireInt64Ptr(t *testing.T, name string, got *int64, want int64) {
t.Helper()
if got == nil || *got != want {
t.Fatalf("%s mismatch: got=%v want=%d", name, got, want)
}
}
func requireFloat64Ptr(t *testing.T, name string, got *float64, want float64) {
t.Helper()
if got == nil || math.Abs(*got-want) > 0.000001 {
t.Fatalf("%s mismatch: got=%v want=%f", name, got, want)
}
}
func requireNilRetention(t *testing.T, name string, users *int64, base *int64, rate *float64) {
t.Helper()
if users != nil || base != nil || rate != nil {
t.Fatalf("%s should be nil before cohort matures: users=%v base=%v rate=%v", name, users, base, rate)
}
}

View File

@ -168,7 +168,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', user_id BIGINT NOT NULL, registered_day DATE NOT NULL,
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, registered_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_tz, user_id), KEY idx_stat_user_registration_day (app_code, stat_tz, registered_day, country_id),
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id),
KEY idx_stat_user_registration_country_day (app_code, stat_tz, country_id, registered_day, user_id),
KEY idx_stat_user_registration_region_day (app_code, stat_tz, region_id, registered_day, country_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_user_dimension (
app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,