修复国家维度展示与跨应用房间事件

This commit is contained in:
zhx 2026-07-16 16:14:49 +08:00
parent f79abb496b
commit adc355caa9
15 changed files with 622 additions and 28 deletions

View File

@ -20,8 +20,9 @@ import (
)
const (
appKindHyapp = "hyapp"
appKindLegacy = "legacy"
appKindHyapp = "hyapp"
appKindLegacy = "legacy"
unknownCountryName = "未知国家"
overviewConcurrency = 4
)
@ -341,14 +342,18 @@ type RequirementsResult struct {
// Overview 服务端并发聚合各 App 的统计总览hyapp App 走 statistics-service
// legacy App 走 dashboard 外部源;国家行按区域目录卷积出大区行,并按用户数据范围裁剪。
func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, query OverviewQuery) (*OverviewResult, error) {
apps, err := s.listAllowedApps(ctx, query.AppCodes, access)
registeredApps, err := s.listApps(ctx)
if err != nil {
return nil, err
}
apps := filterAllowedApps(registeredApps, query.AppCodes, access)
catalog, err := s.regionCatalog(ctx, apps)
if err != nil {
return nil, err
}
// 国家主数据属于 App 业务域;即使 country_id 是物理全局自增,也必须按当前可查询 App 建目录,
// 否则跨 App 污染会被另一个 App 的主数据包装成合法国家,掩盖上游事实错误。
countryDirectories := s.countryDirectories(ctx, apps)
results := make([]AppOverview, len(apps))
var wg sync.WaitGroup
@ -359,7 +364,7 @@ func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, q
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
results[index] = s.appOverview(ctx, app, catalog[app.AppCode], access, query)
results[index] = s.appOverview(ctx, app, catalog[app.AppCode], countryDirectories[app.AppCode], access, query)
}(index, app)
}
wg.Wait()
@ -600,7 +605,7 @@ func supportsAppTrackingFunnel(appCode string) bool {
return ok
}
func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []RegionInfo, access repository.MoneyAccess, query OverviewQuery) AppOverview {
func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []RegionInfo, countries map[int64]countryDirectoryEntry, access repository.MoneyAccess, query OverviewQuery) AppOverview {
out := AppOverview{
AppCode: app.AppCode,
AppName: app.AppName,
@ -652,8 +657,10 @@ func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []Region
return out
}
countryRows := anyRowSlice(overview["country_breakdown"])
dailyCountryRows := anyRowSlice(overview["daily_country_breakdown"])
// statisticsOverview 会短时缓存并共享底层 map补展示字段时必须复制行避免并发请求互相改写缓存。
preserveSourceNames := app.Kind == appKindLegacy
countryRows := enrichCountryRows(anyRowSlice(overview["country_breakdown"]), countries, preserveSourceNames)
dailyCountryRows := enrichCountryRows(anyRowSlice(overview["daily_country_breakdown"]), countries, preserveSourceNames)
resolve := regionResolver(regions)
restricted := !allowAll && query.RegionID == 0 && len(requestRegionIDs) == 0
@ -1199,6 +1206,12 @@ func (s *Service) listAllowedApps(ctx context.Context, requested []string, acces
if err != nil {
return nil, err
}
return filterAllowedApps(apps, requested, access), nil
}
// filterAllowedApps 只裁剪可查询的 App不改变 appregistry 给出的稳定顺序。
// Overview 会保留裁剪前的注册 App 集合,供全局 country_id 展示目录使用。
func filterAllowedApps(apps []AppInfo, requested []string, access repository.MoneyAccess) []AppInfo {
requestedSet := map[string]struct{}{}
for _, appCode := range requested {
appCode = appctx.Normalize(appCode)
@ -1221,7 +1234,145 @@ func (s *Service) listAllowedApps(ctx context.Context, requested []string, acces
}
out = append(out, app)
}
return out, nil
return out
}
type countryDirectoryEntry struct {
AppCode string
ID int64
Code string
Name string
DisplayName string
Flag string
Enabled bool
}
// countryDirectories 按 app_code + country_id 汇总当前可查询 HyApp 的国家主数据。
// country_id 虽是全局自增主键,但国家启停和归属仍受 app_code 隔离;目录不能跨 App 复用。
func (s *Service) countryDirectories(ctx context.Context, apps []AppInfo) map[string]map[int64]countryDirectoryEntry {
byApp := make(map[string]map[int64]countryDirectoryEntry)
if s.user == nil {
return byApp
}
// appregistry 当前按名称排序;改按 app_code 查询让下游调用顺序和日志保持稳定。
hyappApps := make([]AppInfo, 0, len(apps))
for _, app := range apps {
if app.Kind == appKindHyapp {
hyappApps = append(hyappApps, app)
}
}
sort.Slice(hyappApps, func(i, j int) bool {
return hyappApps[i].AppCode < hyappApps[j].AppCode
})
for _, app := range hyappApps {
// user-service 依赖 RequestMeta.app_code 选择 App 数据域;每次调用都必须显式覆盖请求上下文。
appContext := appctx.WithContext(ctx, app.AppCode)
countries, err := s.user.ListCountries(appContext, userclient.ListCountriesRequest{
Caller: "admin-server",
// nil 表示同时读取 enabled 与 disabled。已停用国家仍可能存在于历史统计事实中不能从展示目录丢失。
Enabled: nil,
})
if err != nil {
// 国家目录只用于展示;单 App 故障时该 App 降级为未知,不能拖垮整个 Social BI Overview 接口。
slog.Warn("databi_list_hyapp_countries_failed", "app_code", app.AppCode, "error", err)
continue
}
byID := make(map[int64]countryDirectoryEntry, len(countries))
mergeCountryDirectory(byID, app.AppCode, countries)
byApp[app.AppCode] = byID
}
return byApp
}
// mergeCountryDirectory 将一次 App 查询投影为 BI 展示字段ID=0 是统计未知值,不伪造国家主数据。
// 同一 App 返回重复 ID 时保留第一项,避免异常目录响应在同一次请求内反复覆盖展示语义。
func mergeCountryDirectory(byID map[int64]countryDirectoryEntry, appCode string, countries []*userclient.Country) {
for _, country := range countries {
if country == nil || country.CountryID <= 0 {
continue
}
if _, exists := byID[country.CountryID]; exists {
continue
}
byID[country.CountryID] = countryDirectoryEntry{
AppCode: appctx.Normalize(appCode),
ID: country.CountryID,
Code: strings.TrimSpace(country.CountryCode),
Name: strings.TrimSpace(country.CountryName),
DisplayName: strings.TrimSpace(country.CountryDisplayName),
Flag: strings.TrimSpace(country.Flag),
Enabled: country.Enabled,
}
}
}
// enrichCountryRows 复制统计行后补展示字段,不能原地修改 statisticsOverview 的共享缓存。
func enrichCountryRows(rows []map[string]any, countries map[int64]countryDirectoryEntry, preserveSourceNames bool) []map[string]any {
out := make([]map[string]any, 0, len(rows))
for _, source := range rows {
row := make(map[string]any, len(source)+4)
for key, value := range source {
row[key] = value
}
enrichCountryRow(row, countries, preserveSourceNames)
out = append(out, row)
}
return out
}
func enrichCountryRow(row map[string]any, countries map[int64]countryDirectoryEntry, preserveSourceNames bool) {
// 外接 legacy 源可能使用 country_name、country 或 country_code 标识国家;任一字段存在时都以源数据为准,
// 避免它携带的本地数字 ID 碰巧等于某个 HyApp 全局 ID 后被错误覆盖。
if preserveSourceNames {
sourceDisplayName := rowString(row, "country_display_name")
sourceName := rowString(row, "country_name", "country")
sourceCode := rowString(row, "country_code")
if firstNonEmptyString(sourceDisplayName, sourceName, sourceCode) != "" {
row["country_name"] = firstNonEmptyString(sourceDisplayName, sourceName, sourceCode)
setCountryFieldIfEmpty(row, "country_code", "")
// 字段必须存在供前端统一读取,但不把 legacy 的英文名伪装成原始中文 display_name。
setCountryFieldIfEmpty(row, "country_display_name", "")
setCountryFieldIfEmpty(row, "flag", "")
return
}
// legacy 的数字 ID 由外部数据源定义,不能碰巧命中 HyApp ID 后借用其名称;
// 源没有返回名称/国家码时明确保持未知,等待该源补齐自己的展示契约。
row["country_name"] = unknownCountryName
setCountryFieldIfEmpty(row, "country_code", "")
setCountryFieldIfEmpty(row, "country_display_name", unknownCountryName)
setCountryFieldIfEmpty(row, "flag", "")
return
}
countryID, hasCountryID := rowInt64(row, "country_id")
country, found := countries[countryID]
if !hasCountryID || countryID <= 0 || !found {
// 0 和目录未命中都属于未知维度;保留原 country_id 便于继续追查事实来源,不伪造 ID 或国家码。
setCountryFieldIfEmpty(row, "country_code", "")
row["country_name"] = unknownCountryName
setCountryFieldIfEmpty(row, "country_display_name", unknownCountryName)
setCountryFieldIfEmpty(row, "flag", "")
return
}
row["country_code"] = country.Code
// HyApp 数字统计行以 country_id 对应的主数据为准country_display_name 保持原始字段,
// country_name 则按中文展示名、英文名、国家码依次 fallback供现有前端直接渲染。
row["country_display_name"] = country.DisplayName
row["flag"] = country.Flag
row["country_name"] = firstNonEmptyString(
country.DisplayName,
country.Name,
country.Code,
unknownCountryName,
)
}
func setCountryFieldIfEmpty(row map[string]any, key string, fallback string) {
if rowString(row, key) == "" {
row[key] = fallback
}
}
// regionCatalog 汇总每个 App 的区域目录hyapp App 共用 user-service 的区域主数据,

View File

@ -2,9 +2,13 @@ package databi
import (
"context"
"errors"
"reflect"
"testing"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
)
@ -102,3 +106,143 @@ func TestRegionDisplayToleratesRoundedIDs(t *testing.T) {
t.Fatalf("expected rounded id to match catalog")
}
}
func TestMergeAndEnrichCountryDirectory(t *testing.T) {
laluDirectory := map[int64]countryDirectoryEntry{}
mergeCountryDirectory(laluDirectory, "lalu", []*userclient.Country{
{CountryID: 158, CountryCode: "PK", CountryName: "Pakistan", CountryDisplayName: "巴基斯坦", Flag: "🇵🇰", Enabled: true},
// disabled 国家仍可能出现在历史统计中,必须进入目录。
{CountryID: 157, CountryCode: "BS", CountryName: "Philippines", CountryDisplayName: "菲律宾", Enabled: false},
// country_id=0 是未知维度,不允许目录把它伪装成真实国家。
{CountryID: 0, CountryCode: "UNKNOWN", CountryName: "Unknown", Enabled: true},
})
famiDirectory := map[int64]countryDirectoryEntry{}
mergeCountryDirectory(famiDirectory, "fami", []*userclient.Country{
{CountryID: 1163, CountryCode: "BD", CountryName: "Bangladesh", CountryDisplayName: "孟加拉国", Enabled: true},
})
huwaaDirectory := map[int64]countryDirectoryEntry{}
mergeCountryDirectory(huwaaDirectory, "huwaa", []*userclient.Country{
{CountryID: 953, CountryCode: "IN", CountryName: "India", CountryDisplayName: "印度", Enabled: true},
})
if len(laluDirectory) != 2 {
t.Fatalf("expected two lalu country ids, got %#v", laluDirectory)
}
if country := laluDirectory[158]; country.AppCode != "lalu" || country.Name != "Pakistan" {
t.Fatalf("expected lalu country retained, got %+v", country)
}
if country := laluDirectory[157]; country.Enabled {
t.Fatalf("expected disabled country retained, got %+v", country)
}
sourceRows := []map[string]any{
{"country_id": int64(158)},
{"country_id": int64(1163)},
{"country_id": int64(953)},
{"country_id": int64(0)},
{"country_id": int64(9999)},
}
rows := enrichCountryRows(sourceRows, laluDirectory, false)
if got := rowString(rows[0], "country_name"); got != "巴基斯坦" {
t.Fatalf("expected display name to win for country 158, got %q", got)
}
for _, index := range []int{1, 2, 3, 4} {
if got := rowString(rows[index], "country_name"); got != unknownCountryName {
t.Fatalf("expected row %d to remain unknown in lalu scope, got %q", index, got)
}
if got := rowString(rows[index], "country_display_name"); got != unknownCountryName {
t.Fatalf("expected row %d unknown display name, got %q", index, got)
}
}
if got := rowString(enrichCountryRows([]map[string]any{{"country_id": int64(1163)}}, famiDirectory, false)[0], "country_name"); got != "孟加拉国" {
t.Fatalf("expected fami country resolved only in fami scope, got %q", got)
}
if got := rowString(enrichCountryRows([]map[string]any{{"country_id": int64(953)}}, huwaaDirectory, false)[0], "country_name"); got != "印度" {
t.Fatalf("expected huwaa country resolved only in huwaa scope, got %q", got)
}
if _, mutated := sourceRows[0]["country_name"]; mutated {
t.Fatalf("expected enrichment to copy shared statistics rows")
}
legacyRows := enrichCountryRows([]map[string]any{
{
"country_id": int64(158),
"country_code": "LEGACY-PK",
"country_name": "Legacy Pakistan",
},
{"country_id": int64(158), "country": "Legacy country field"},
{"country_id": int64(158), "country_code": "LEGACY-CODE"},
{"country_id": int64(158)},
}, laluDirectory, true)
if got := rowString(legacyRows[0], "country_name"); got != "Legacy Pakistan" {
t.Fatalf("expected legacy source name preserved, got %q", got)
}
if got := rowString(legacyRows[0], "country_code"); got != "LEGACY-PK" {
t.Fatalf("expected legacy source code preserved, got %q", got)
}
if got := rowString(legacyRows[1], "country_name"); got != "Legacy country field" {
t.Fatalf("expected legacy country field preserved, got %q", got)
}
if got := rowString(legacyRows[2], "country_name"); got != "LEGACY-CODE" {
t.Fatalf("expected legacy code used instead of colliding HyApp id, got %q", got)
}
if got := rowString(legacyRows[3], "country_name"); got != unknownCountryName {
t.Fatalf("legacy numeric id must not borrow HyApp country name, got %q", got)
}
}
type countryDirectoryClientStub struct {
userclient.Client
countries map[string][]*userclient.Country
errors map[string]error
calls []string
filtered bool
}
func (c *countryDirectoryClientStub) ListCountries(ctx context.Context, req userclient.ListCountriesRequest) ([]*userclient.Country, error) {
appCode := appctx.FromContext(ctx)
c.calls = append(c.calls, appCode)
if req.Enabled != nil {
c.filtered = true
}
if err := c.errors[appCode]; err != nil {
return nil, err
}
return c.countries[appCode], nil
}
func TestCountryDirectoriesQueryEachAllowedHyappAndContinueAfterFailure(t *testing.T) {
client := &countryDirectoryClientStub{
countries: map[string][]*userclient.Country{
"fami": {{CountryID: 1163, CountryCode: "BD", CountryName: "Bangladesh", CountryDisplayName: "孟加拉国", Enabled: true}},
"lalu": {{CountryID: 158, CountryCode: "PK", CountryName: "Pakistan", CountryDisplayName: "巴基斯坦", Enabled: true}},
},
errors: map[string]error{"huwaa": errors.New("temporary user-service failure")},
}
service := NewService(nil, nil, nil, client)
directories := service.countryDirectories(context.Background(), []AppInfo{
{AppCode: "lalu", Kind: appKindHyapp},
{AppCode: "aslan", Kind: appKindLegacy},
{AppCode: "huwaa", Kind: appKindHyapp},
{AppCode: "fami", Kind: appKindHyapp},
})
if want := []string{"fami", "huwaa", "lalu"}; !reflect.DeepEqual(client.calls, want) {
t.Fatalf("expected each HyApp queried with its own context, got %v want %v", client.calls, want)
}
if client.filtered {
t.Fatalf("expected enabled filter omitted so disabled history can resolve")
}
if _, ok := directories["fami"][1163]; !ok {
t.Fatalf("expected successful app before failure retained")
}
if _, ok := directories["lalu"][158]; !ok {
t.Fatalf("expected apps after failure still queried")
}
if _, ok := directories["huwaa"]; ok {
t.Fatalf("failed app must not receive another app's directory: %#v", directories)
}
if len(directories) != 2 {
t.Fatalf("unexpected directories after one app failure: %#v", directories)
}
}

View File

@ -6,7 +6,6 @@ import (
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
)
@ -62,14 +61,14 @@ func Build(roomID string, eventType string, roomVersion int64, occurredAt time.T
// EventID 在 room-service 生成,后续同步广播和异步补偿都用同一个 ID 去重。
eventID := idgen.New("evt")
// Build 没有请求上下文,不能在多 App 环境猜租户。SaveMutation/SaveOutbox 会在持久化前用
// 当前请求的 app_code 同时补齐 Record 和 Envelope直发 IM/机器人展示也在发布边界做同样处理。
return Record{
AppCode: appcode.Default,
EventID: eventID,
EventType: eventType,
RoomID: roomID,
Status: StatusPending,
Envelope: &roomeventsv1.EventEnvelope{
AppCode: appcode.Default,
EventId: eventID,
RoomId: roomID,
EventType: eventType,

View File

@ -235,6 +235,8 @@ func (s *Service) bootstrapRobotRoom(ctx context.Context, config RobotRoomConfig
if _, err := s.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: robotRoomCommandMeta(config, userID, fmt.Sprintf("join:%d", userID)),
Role: "audience",
// 首批机器人与运行期替换机器人必须使用同一事实标记;否则 RoomUserJoined 会被统计成缺国家的真人 DAU。
ActorIsRobot: true,
}); err != nil {
return err
}

View File

@ -139,16 +139,21 @@ func TestAdminCreateRobotRoomMarksRobotJoinOutbox(t *testing.T) {
_, err := svc.AdminCreateRobotRoom(ctx, &roomv1.AdminCreateRobotRoomRequest{
Meta: roomservice.NewRequestMeta("", 9001),
OwnerRobotUserId: 2001,
CandidateRobotUserIds: []int64{2001},
MinRobotCount: 1,
MaxRobotCount: 1,
CandidateRobotUserIds: []int64{2001, 2002},
MinRobotCount: 2,
MaxRobotCount: 2,
RoomName: "Robot Join Outbox",
RoomAvatar: testRoomCoverURL,
VisibleRegionId: 686,
OwnerCountryCode: "ae",
GiftRule: &roomv1.AdminRobotRoomGiftRule{
GiftIds: []string{"84"},
LuckyGiftIds: []string{"28"},
NormalGiftIntervalMs: 10000,
LuckyComboMin: 100,
LuckyComboMax: 10000,
LuckyPauseMinMs: 5000,
LuckyPauseMaxMs: 20000,
},
AdminId: 9001,
})
@ -161,6 +166,7 @@ func TestAdminCreateRobotRoomMarksRobotJoinOutbox(t *testing.T) {
t.Fatalf("list pending outbox failed: %v", err)
}
joinEvents := 0
bootstrapRobotJoined := false
for _, record := range records {
if record.EventType != "RoomUserJoined" {
continue
@ -170,13 +176,17 @@ func TestAdminCreateRobotRoomMarksRobotJoinOutbox(t *testing.T) {
if err := proto.Unmarshal(record.Envelope.GetBody(), &joined); err != nil {
t.Fatalf("decode room join event failed: %v", err)
}
if joined.GetUserId() == 2001 && !joined.GetIsRobot() {
if !joined.GetIsRobot() {
t.Fatalf("robot join outbox must mark is_robot=true: %+v", &joined)
}
bootstrapRobotJoined = bootstrapRobotJoined || joined.GetUserId() == 2002
}
if joinEvents == 0 {
t.Fatalf("robot room creation should write RoomUserJoined outbox")
}
if !bootstrapRobotJoined {
t.Fatalf("bootstrap robot should write RoomUserJoined outbox")
}
}
func occupiedSeatUserCount(snapshot *roomv1.RoomSnapshot) int {

View File

@ -14,6 +14,24 @@ type countingOutboxPublisher struct {
events []*roomeventsv1.EventEnvelope
}
type scopedOutboxPublication struct {
contextApp string
envelopeApp string
}
type scopedOutboxPublisher struct {
published chan scopedOutboxPublication
}
func newScopedOutboxPublisher() *scopedOutboxPublisher {
return &scopedOutboxPublisher{published: make(chan scopedOutboxPublication, 2)}
}
func (p *scopedOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
p.published <- scopedOutboxPublication{contextApp: appcode.FromContext(ctx), envelopeApp: envelope.GetAppCode()}
return nil
}
type crossAppOutboxRepository struct {
Repository
claims []string
@ -46,6 +64,51 @@ func TestOutboxSingleRecordLeaseCoversPublishAndStateWrite(t *testing.T) {
}
}
func TestDirectAndRobotPublishersScopeBuiltRecordsFromContext(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "fami")
directPublisher := newScopedOutboxPublisher()
robotPublisher := newScopedOutboxPublisher()
svc := &Service{roomDisplayPublisher: directPublisher, robotDisplayPublisher: robotPublisher}
build := func(eventType string) outbox.Record {
record, err := outbox.Build("fami-room-publish", eventType, 3, time.UnixMilli(3000), &roomeventsv1.RoomUserJoined{UserId: 1001})
if err != nil {
t.Fatalf("build %s outbox: %v", eventType, err)
}
return record
}
svc.publishDirectIMRecordsBestEffort(ctx, []outbox.Record{build("RoomUserJoined")})
svc.publishRobotDisplayRecordsBestEffort(ctx, []outbox.Record{build("RoomRobotUserJoined")})
for name, publisher := range map[string]*scopedOutboxPublisher{"direct": directPublisher, "robot": robotPublisher} {
select {
case publication := <-publisher.published:
if publication.contextApp != "fami" || publication.envelopeApp != "fami" {
t.Fatalf("%s publisher lost scoped app: context=%q envelope=%q", name, publication.contextApp, publication.envelopeApp)
}
case <-time.After(time.Second):
t.Fatalf("%s publisher did not receive built record", name)
}
}
}
func TestRobotDisplaySamplerUsesCommandAppForContextFreeRecords(t *testing.T) {
svc := &Service{robotDisplaySampler: newRobotDisplaySampler()}
build := func() outbox.Record {
record, err := outbox.Build("shared-room-id", "RoomRobotLuckyGiftDrawn", 3, time.UnixMilli(3000), &roomeventsv1.RoomRobotLuckyGiftDrawn{})
if err != nil {
t.Fatalf("build robot display outbox: %v", err)
}
return record
}
// 同房间标识、事件类型和窗口在不同 App 下必须各保留一条Build 尚未补 App采样器只能从命令上下文取租户。
if got := svc.sampleRobotDisplayRecords(appcode.WithContext(context.Background(), "fami"), []outbox.Record{build()}); len(got) != 1 {
t.Fatalf("fami robot display unexpectedly sampled: %+v", got)
}
if got := svc.sampleRobotDisplayRecords(appcode.WithContext(context.Background(), "huwaa"), []outbox.Record{build()}); len(got) != 1 {
t.Fatalf("huwaa robot display shared fami sample window: %+v", got)
}
}
func TestClaimPendingOutboxFindsNonDefaultApp(t *testing.T) {
repository := &crossAppOutboxRepository{}
svc := &Service{nodeID: "room-cross-app", repository: repository}

View File

@ -107,7 +107,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
return nil, err
}
// 机器人展示只是 App 视觉补偿,提交前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
robotDisplayRecords := s.sampleRobotDisplayRecords(result.robotDisplayRecords)
robotDisplayRecords := s.sampleRobotDisplayRecords(ctx, result.robotDisplayRecords)
durableOutboxRecords, directIMRecords := splitRoomDirectIMRecords(outboxRecords, result.suppressDirectIMEventTypes)
// mutate 可额外返回只服务客户端展示的直发 IM典型场景是重复 JoinRoom 的入房飘窗;
// 这类事件不能进入 durable outbox否则会污染活跃统计和补偿消费。

View File

@ -1,6 +1,7 @@
package service
import (
"context"
"sync"
"time"
@ -28,14 +29,14 @@ func newRobotDisplaySampler() *robotDisplaySampler {
return &robotDisplaySampler{lastWindow: make(map[robotDisplaySampleKey]int64)}
}
func (s *Service) sampleRobotDisplayRecords(records []outbox.Record) []outbox.Record {
func (s *Service) sampleRobotDisplayRecords(ctx context.Context, records []outbox.Record) []outbox.Record {
if len(records) == 0 || s == nil || s.robotDisplaySampler == nil {
return records
}
return s.robotDisplaySampler.filter(records, robotDisplaySampleWindow)
return s.robotDisplaySampler.filter(records, robotDisplaySampleWindow, appcode.FromContext(ctx))
}
func (s *robotDisplaySampler) filter(records []outbox.Record, window time.Duration) []outbox.Record {
func (s *robotDisplaySampler) filter(records []outbox.Record, window time.Duration, scopedApp string) []outbox.Record {
if s == nil || window <= 0 || len(records) == 0 {
return records
}
@ -48,7 +49,9 @@ func (s *robotDisplaySampler) filter(records []outbox.Record, window time.Durati
var newestWindow int64
for _, record := range records {
key := robotDisplaySampleKey{
appCode: appcode.Normalize(record.AppCode),
// Build 阶段故意不猜 App降采样发生在发布边界补值之前因此必须使用当前命令上下文兜底
// 否则不同 App 的同房间标识会共用默认 Lalu 窗口并误丢展示事件。
appCode: appcode.Normalize(firstNonEmpty(record.AppCode, scopedApp)),
roomID: record.RoomID,
eventType: record.EventType,
}

View File

@ -48,6 +48,9 @@ func TestJoinRoomCarriesEffectiveVIPAndQueriesWalletOnce(t *testing.T) {
if len(records) != 1 {
t.Fatalf("joined direct IM record mismatch: %+v", records)
}
if records[0].GetAppCode() != vipTestAppCode {
t.Fatalf("joined direct IM must inherit request app: got=%q want=%q", records[0].GetAppCode(), vipTestAppCode)
}
var joined roomeventsv1.RoomUserJoined
if err := proto.Unmarshal(records[0].GetBody(), &joined); err != nil {
t.Fatalf("decode joined event failed: %v", err)

View File

@ -6,12 +6,61 @@ import (
"errors"
"sync"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/outbox"
roomservice "hyapp/services/room-service/internal/room/service"
)
func TestSaveMutationScopesBuiltOutboxToContextApp(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "fami")
repo := newCommandLogTestRepository(t, ctx)
record, err := outbox.Build("fami-room-outbox", "RoomCreated", 1, time.UnixMilli(1000), &roomeventsv1.RoomCreated{})
if err != nil {
t.Fatalf("build room outbox: %v", err)
}
if record.AppCode != "" || record.Envelope.GetAppCode() != "" {
t.Fatalf("context-free Build must not guess an app: record=%q envelope=%q", record.AppCode, record.Envelope.GetAppCode())
}
if err := repo.SaveMutation(ctx, roomservice.MutationCommit{
Command: roomservice.CommandRecord{
RoomID: record.RoomID,
RoomVersion: 1,
CommandID: "cmd-fami-room-outbox",
CommandType: "create_room",
Payload: []byte(`{"command_id":"cmd-fami-room-outbox"}`),
Replayable: true,
OwnerNodeID: "node-fami",
LeaseToken: "lease-fami",
CreatedAtMS: record.CreatedAtMS,
},
OutboxRecords: []outbox.Record{record},
}); err != nil {
t.Fatalf("save fami mutation: %v", err)
}
var storedApp string
var envelopeBytes []byte
if err := repo.db.QueryRowContext(ctx,
`SELECT app_code, envelope FROM room_outbox WHERE app_code = ? AND event_id = ?`,
"fami", record.EventID,
).Scan(&storedApp, &envelopeBytes); err != nil {
t.Fatalf("read persisted fami outbox: %v", err)
}
var envelope roomeventsv1.EventEnvelope
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
t.Fatalf("decode persisted fami envelope: %v", err)
}
if storedApp != "fami" || envelope.GetAppCode() != "fami" {
t.Fatalf("persistence boundary lost scoped app: row=%q envelope=%q", storedApp, envelope.GetAppCode())
}
}
func TestSaveMutationRejectsSameCommandInsteadOfContinuingSideEffects(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repo := newCommandLogTestRepository(t, ctx)

View File

@ -3,13 +3,53 @@ package mysql
import (
"context"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/outbox"
)
func TestSaveOutboxScopesBuiltRecordToContextApp(t *testing.T) {
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "ROOM_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_room_service.sql"),
DatabasePrefix: "hyapp_room_outbox_scope_test",
})
ctx := appcode.WithContext(context.Background(), "huwaa")
repo, err := Open(ctx, schema.DSN, 4, 4)
if err != nil {
t.Fatalf("open repository: %v", err)
}
defer repo.Close()
record, err := outbox.Build("huwaa-room-outbox", "RoomUserJoined", 9, time.UnixMilli(2000), &roomeventsv1.RoomUserJoined{UserId: 99})
if err != nil {
t.Fatalf("build room outbox: %v", err)
}
if err := repo.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
t.Fatalf("save scoped outbox: %v", err)
}
var storedApp string
var envelopeBytes []byte
if err := schema.DB.QueryRowContext(ctx,
`SELECT app_code, envelope FROM room_outbox WHERE app_code = ? AND event_id = ?`,
"huwaa", record.EventID,
).Scan(&storedApp, &envelopeBytes); err != nil {
t.Fatalf("read scoped outbox: %v", err)
}
var envelope roomeventsv1.EventEnvelope
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
t.Fatalf("decode scoped outbox envelope: %v", err)
}
if storedApp != "huwaa" || envelope.GetAppCode() != "huwaa" {
t.Fatalf("SaveOutbox lost scoped app: row=%q envelope=%q", storedApp, envelope.GetAppCode())
}
}
func retentionTestRecord(eventID string, createdAtMS int64) outbox.Record {
return outbox.Record{
AppCode: appcode.Default,

View File

@ -1582,9 +1582,19 @@ func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
// overlayCanonicalCountryActive preserves monetary and business facts from the
// legacy aggregate while replacing its incomplete activity counter. A
// canonical-only country is added so country and region rollups still reconcile
// with the top-level canonical user-day total.
// canonical-only valid countries are added to preserve resolved detail. Unknown-country
// DAU intentionally remains only in the top-level total, so country rows need not sum to it.
func overlayCanonicalCountryActive(items []CountryBreakdown, cube canonicalOverviewActiveCube) []CountryBreakdown {
// 旧聚合表可能仍保存 country_id=0 的金额或历史活跃事实;总览总计会继续包含这些真实事实,
// 但国家明细不能把“未知维度”渲染成一个国家。先过滤旧行,避免 canonical cube 不再产出 0 后仍残留空壳行。
validItems := items[:0]
for _, item := range items {
if item.CountryID > 0 {
validItems = append(validItems, item)
}
}
items = validItems
indexByCountry := make(map[int64]int, len(items))
for index := range items {
indexByCountry[items[index].CountryID] = index
@ -1629,6 +1639,15 @@ func overlayCanonicalCountryActive(items []CountryBreakdown, cube canonicalOverv
// country overlay. It prevents stale legacy DAU from surviving on rows with no
// canonical activity and fills client-only detail rows before final ordering.
func overlayCanonicalDailyCountryActive(items []DailyCountryStat, cube canonicalOverviewActiveCube) []DailyCountryStat {
// 与汇总国家行保持同一边界:未知国家事实只进入 App/日期总计,不作为 day×country 明细返回。
validItems := items[:0]
for _, item := range items {
if item.CountryID > 0 {
validItems = append(validItems, item)
}
}
items = validItems
indexByDayCountry := make(map[string]int, len(items))
for index := range items {
key := dailyCountryKey(items[index].StatDay, items[index].CountryID)

View File

@ -57,6 +57,63 @@ func TestCanonicalizeSocialUserDaysMergesIdentityWithoutLosingDeviceCount(t *tes
}
}
func TestCanonicalizeSocialUserDaysDirectTupleWithoutCountryDoesNotEraseKnownDimension(t *testing.T) {
rows := []socialRequirementUserDay{
{StatDay: "2026-07-15", Subject: "d:device-A", DeviceID: "device-A", CountryID: 158, RegionID: 19, UserRole: "user", Active: 1},
{StatDay: "2026-07-15", Subject: "u:123", UserID: 123, DirectUser: true, DeviceID: "device-A", CountryID: 0, RegionID: 999, UserRole: "host", Active: 1},
}
resolved := map[socialIdentityDeviceDayKey]int64{
{StatDay: "2026-07-15", DeviceID: "device-A"}: 123,
}
got := canonicalizeSocialUserDays(rows, resolved, 0, 0, nil)
if len(got) != 1 {
t.Fatalf("expected one canonical user-day, got %+v", got)
}
if got[0].CountryID != 158 || got[0].RegionID != 19 || !got[0].DirectUser || got[0].UserRole != "host" {
t.Fatalf("direct tuple without country must preserve known device dimension: %+v", got[0])
}
}
func TestCanonicalOverviewActiveCubeKeepsUnknownCountryOnlyInTotals(t *testing.T) {
day := "2026-07-15"
cube := buildCanonicalOverviewActiveCube([]socialRequirementUserDay{
{StatDay: day, Subject: "u:1", UserID: 1, CountryID: 158, RegionID: 19, Active: 1},
{StatDay: day, Subject: "d:anonymous", DeviceID: "anonymous", CountryID: 0, RegionID: 0, Active: 1},
{StatDay: day, Subject: "d:inactive", DeviceID: "inactive", CountryID: 0, RegionID: 0, Active: 0},
})
if cube.Total != 2 || cube.ByDay[day] != 2 {
t.Fatalf("unknown-country DAU must remain in total counters: %+v", cube)
}
if cube.ByCountry[158].ActiveUsers != 1 || cube.ByDayCountry[dailyCountryKey(day, 158)].ActiveUsers != 1 {
t.Fatalf("known-country DAU mismatch: %+v", cube)
}
if _, ok := cube.ByCountry[0]; ok {
t.Fatalf("country breakdown must not expose country_id=0: %+v", cube.ByCountry)
}
if _, ok := cube.ByDayCountry[dailyCountryKey(day, 0)]; ok {
t.Fatalf("daily country breakdown must not expose country_id=0: %+v", cube.ByDayCountry)
}
// 旧 stat_app_day_country 里的 0 维度可能先于 canonical Social 逻辑存在overlay 也必须删掉旧行,
// 否则页面仍会看到 active_users=0 的“国家 0”空壳金额字段还可能让它继续参与排序。
countryRows := overlayCanonicalCountryActive([]CountryBreakdown{
{CountryID: 0, RechargeUSDMinor: 100},
{CountryID: 158, RechargeUSDMinor: 200},
}, cube)
if len(countryRows) != 1 || countryRows[0].CountryID != 158 || countryRows[0].ActiveUsers != 1 {
t.Fatalf("country overlay must remove unknown legacy row: %+v", countryRows)
}
dailyRows := overlayCanonicalDailyCountryActive([]DailyCountryStat{
{StatDay: day, CountryBreakdown: CountryBreakdown{CountryID: 0, RechargeUSDMinor: 100}},
{StatDay: day, CountryBreakdown: CountryBreakdown{CountryID: 158, RechargeUSDMinor: 200}},
}, cube)
if len(dailyRows) != 1 || dailyRows[0].CountryID != 158 || dailyRows[0].ActiveUsers != 1 {
t.Fatalf("daily country overlay must remove unknown legacy row: %+v", dailyRows)
}
}
func TestSocialRetentionUserDayProjectionStaysNarrow(t *testing.T) {
for _, required := range []string{
"stat_day", "subject_key", "user_id", "device_id", "country_id", "region_id", "user_role",
@ -1079,6 +1136,45 @@ func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) {
}
}
func TestConsumeUserActiveFallsBackToStatisticsUserDimension(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_active_dimension_fallback_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
userID := int64(327300172394536960)
if err := repository.ConsumeUserRegionChanged(ctx, UserRegionChangedEvent{
AppCode: "lalu", EventID: "region:known-before-room", UserID: userID,
CountryID: 158, RegionID: 19, OccurredAtMS: time.Date(2026, 7, 15, 1, 0, 0, 0, time.UTC).UnixMilli(),
}); err != nil {
t.Fatalf("consume user dimension: %v", err)
}
if err := repository.ConsumeUserActive(ctx, UserActiveEvent{
AppCode: "lalu", EventID: "room:legacy-without-dimension", EventType: "RoomUserJoined", UserID: userID,
CountryID: 0, RegionID: 0, OccurredAtMS: time.Date(2026, 7, 15, 2, 0, 0, 0, time.UTC).UnixMilli(),
}); err != nil {
t.Fatalf("consume room active: %v", err)
}
for _, table := range []string{"stat_user_day_activity", "stat_social_user_day"} {
var countryID, regionID int64
query := "SELECT country_id, region_id FROM " + table + " WHERE app_code = 'lalu' AND stat_tz = 'UTC' AND stat_day = '2026-07-15' AND user_id = ?"
if err := repository.db.QueryRowContext(ctx, query, userID).Scan(&countryID, &regionID); err != nil {
t.Fatalf("query %s dimension: %v", table, err)
}
if countryID != 158 || regionID != 19 {
t.Fatalf("%s must use statistics-owned user dimension, got country=%d region=%d", table, countryID, regionID)
}
}
}
func TestConsumeReportMetricsAggregatesWalletSalaryGrantAndMic(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)

View File

@ -1537,7 +1537,14 @@ func (r *Repository) ConsumeUserRoomDailyStatsUpdated(ctx context.Context, event
func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEvent) error {
return r.withEvent(ctx, SourceRoom, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
// RoomUserJoined 的新版事实应自带完整国家/区域,但历史 outbox 和迁移期 producer 可能缺字段。
// 只回退 statistics-service 自己消费 UserRegistered/UserRegionChanged 建立的维度投影,既修正已知用户,
// 又不跨服务回查 user owner 明细;维度投影尚未到达时仍保留 0Social 宽表可由后续有效事实补齐,
// 旧 activity 聚合则保持原始事实并由查询层排除未知国家,不能在这里无账搬迁历史计数。
countryID, regionID, err := r.dimensionForUser(ctx, tx, event.AppCode, event.UserID, event.CountryID, event.RegionID)
if err != nil {
return err
}
for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) {
if err := applyActive(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil {
return err

View File

@ -549,6 +549,11 @@ func buildCanonicalOverviewActiveCube(canonicalRows []socialRequirementUserDay)
}
cube.Total++
cube.ByDay[row.StatDay]++
// 匿名设备在无法安全归并到账号时仍属于真实 DAU因此保留在总计和按日总计country_id=0
// 只是“尚无国家维度”,不是一个国家。国家/按日国家明细只能承载有效国家,不能把 0 暴露成伪国家。
if row.CountryID <= 0 {
continue
}
country := cube.ByCountry[row.CountryID]
country.ActiveUsers++
@ -656,12 +661,15 @@ func mergeSocialRequirementUserDay(current, next socialRequirementUserDay) socia
if current.DeviceID == "" {
current.DeviceID = next.DeviceID
}
// Country, region and role are one dimension tuple. A direct same-day u: row
// is authoritative over the d: row even when the anonymous tuple is nonzero;
// copying fields independently would create a country/region pair that never
// existed. No cross-day dimension is borrowed, so history is query-window stable.
// Country and region are one dimension tuple. A direct same-day u: row is
// authoritative only when it carries a real country; a legacy row with just a
// region cannot erase the anonymous row's known country because region alone
// has no valid country ownership semantics. No cross-day dimension is borrowed.
if next.DirectUser && !current.DirectUser {
current.CountryID, current.RegionID, current.UserRole = next.CountryID, next.RegionID, next.UserRole
if next.CountryID > 0 {
current.CountryID, current.RegionID = next.CountryID, next.RegionID
}
current.UserRole = next.UserRole
current.DirectUser = true
} else if next.DirectUser == current.DirectUser {
if current.CountryID == 0 && current.RegionID == 0 && (next.CountryID > 0 || next.RegionID > 0) {