切换播报群

This commit is contained in:
zhx 2026-07-04 01:53:10 +08:00
parent ec35fcad5e
commit 0a072894ec
20 changed files with 352 additions and 121 deletions

View File

@ -272,7 +272,7 @@ sequenceDiagram
| `cp_relationship_created` | 区域群 | 关系建立后发送区域播报 |
| 无 | 无 | 解除关系成功不发 IMApp 根据解除接口返回刷新 |
C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A拒绝为 B同意会发 `B -> A``A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_<region_id>`,本地测试可通过 `group_id_prefix` 加前缀。
C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A拒绝为 B同意会发 `B -> A``A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_v2_<region_id>`v1 旧群 `hy_<app_code>_bc_r_<region_id>` 仅保留过渡期双发),本地测试可通过 `group_id_prefix` 加前缀。
## 12. 错误码

View File

@ -57,8 +57,15 @@ GroupID 必须稳定、短、可解析,并满足腾讯 IM 自定义 GroupID
| Group Type | GroupID | Meaning |
| --- | --- | --- |
| room | `<room_id>` | 现有房间群,必须满足 `pkg/roomid` 规则 |
| global_broadcast | `hy_<app_code>_bc_g` | App 内全局播报群 |
| region_broadcast | `hy_<app_code>_bc_r_<region_id>` | App 内指定区域播报群 |
| global_broadcast | `hy_<app_code>_bc_g_v2`v1: `hy_<app_code>_bc_g` | App 内全局播报群 |
| region_broadcast | `hy_<app_code>_bc_r_v2_<region_id>`v1: `hy_<app_code>_bc_r_<region_id>` | App 内指定区域播报群 |
v1 → v2 迁移2026-07
- v1 播报群是 ChatRoom 类型,受套餐成员上限约束(标准版 2000 人),线上已触发 10038 拒绝加群;全局群与区域群先后都迁移到 AVChatRoom 类型的 v2 群(成员无上限、免审批)。
- `imgroup.GlobalBroadcastGroupID*` / `RegionBroadcastGroupID*` 只生成 v2 IDv1 ID 仅能由 `Legacy*` 变体生成,用于过渡期双发和旧群成员移除。
- 解析层对 v1/v2 返回同一个 Kind调用方不感知版本reconciler 只 ensure v2 群v1 旧群不再维护。
- 过渡期 outbox 投递成功后会向对应 v1 旧群补发一份 `<event_id>_legacy` 副本,覆盖尚未重新拉取 join 列表的存量会话;老客户端全部换代后可移除双发。
规则:
@ -123,8 +130,8 @@ sequenceDiagram
U-->>G: user.region_id, profile_completed
G-->>C: UserSig + join_groups
C->>IM: login(user_id, user_sig)
C->>IM: joinGroup(hy_<app>_bc_g)
C->>IM: joinGroup(hy_<app>_bc_r_<region_id>)
C->>IM: joinGroup(hy_<app>_bc_g_v2)
C->>IM: joinGroup(hy_<app>_bc_r_v2_<region_id>)
```
`/api/v1/im/usersig` response 增加 `join_groups`,客户端必须按返回列表加入播报群:
@ -137,11 +144,11 @@ sequenceDiagram
"expire_at_ms": 1770000000000,
"join_groups": [
{
"group_id": "hy_lalu_bc_g",
"group_id": "hy_lalu_bc_g_v2",
"type": "global_broadcast"
},
{
"group_id": "hy_lalu_bc_r_1001",
"group_id": "hy_lalu_bc_r_v2_1001",
"type": "region_broadcast",
"region_id": 1001
}

View File

@ -410,11 +410,11 @@ Response `data`:
"expire_at_ms": 1778086400000,
"join_groups": [
{
"group_id": "hy_lalu_bc_g",
"group_id": "hy_lalu_bc_g_v2",
"type": "global_broadcast"
},
{
"group_id": "hy_lalu_bc_r_1001",
"group_id": "hy_lalu_bc_r_v2_1001",
"type": "region_broadcast",
"region_id": 1001
}

View File

@ -27,7 +27,11 @@ const (
globalSuffix = "_bc_g"
// globalSuffixV2 是 v2 全局播报群后缀AVChatRoom 类型,成员数不受套餐上限约束。
globalSuffixV2 = "_bc_g_v2"
regionToken = "_bc_r_"
// regionToken 是 v1 区域播报群片段:与 v1 全局群同样受 ChatRoom 成员上限约束,
// 仅保留解析与过渡期双发能力,新群一律生成 v2。
regionToken = "_bc_r_"
// regionTokenV2 是 v2 区域播报群片段AVChatRoom 类型,成员数不受套餐上限约束。
regionTokenV2 = "_bc_r_v2_"
)
// Kind 是回调分流和客户端 join 列表使用的稳定分类,不直接暴露腾讯云原始字符串判断。
@ -74,14 +78,24 @@ func globalBroadcastGroupIDWithSuffix(prefix string, value string, suffix string
return groupID, nil
}
// RegionBroadcastGroupID 生成区域播报群 ID
// RegionBroadcastGroupID 生成区域播报群 IDv2AVChatRoom
// region_id <= 0 被拒绝GLOBAL/未知区域不能变成一个可 join 的区域群。
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
return RegionBroadcastGroupIDWithPrefix("", value, regionID)
}
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 ID
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 IDv2AVChatRoom
func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int64) (string, error) {
return regionBroadcastGroupIDWithToken(prefix, value, regionID, regionTokenV2)
}
// LegacyRegionBroadcastGroupIDWithPrefix 生成 v1 区域播报群 ID。
// 仅供过渡期双发和旧群成员移除使用:老客户端在拉取新 join 列表前仍停留在 v1 群里。
func LegacyRegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int64) (string, error) {
return regionBroadcastGroupIDWithToken(prefix, value, regionID, regionToken)
}
func regionBroadcastGroupIDWithToken(prefix string, value string, regionID int64, token string) (string, error) {
prefix = strings.TrimSpace(prefix)
if err := validateGroupIDPrefix(prefix); err != nil {
return "", err
@ -93,7 +107,7 @@ func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int6
if regionID <= 0 {
return "", fmt.Errorf("region_id must be positive")
}
groupID := prefix + "hy_" + app + regionToken + strconv.FormatInt(regionID, 10)
groupID := prefix + "hy_" + app + token + strconv.FormatInt(regionID, 10)
if len(groupID) > roomid.MaxStringIDLength {
return "", fmt.Errorf("region broadcast group_id is too long")
}
@ -200,21 +214,33 @@ func parseBroadcastGroupID(groupID string) ParsedGroup {
if after, ok := strings.CutPrefix(groupID, "hy_"); ok {
body := after
tokenIndex := strings.LastIndex(body, regionToken)
if tokenIndex >= 0 {
app := body[:tokenIndex]
rawRegion := body[tokenIndex+len(regionToken):]
regionID, err := strconv.ParseInt(rawRegion, 10, 64)
if err == nil && regionID > 0 && validateAppCodeForGroupID(app) == nil {
return ParsedGroup{Kind: KindRegionBroadcast, AppCode: app, RegionID: regionID}
}
return ParsedGroup{Kind: KindUnknown}
// v2 片段必须先于 v1 判断v1 的 `_bc_r_` 是 v2 `_bc_r_v2_` 的前缀,
// 先按 v1 解析会把 v2 群的 `v2_<id>` 当成非法区域。两个版本解析为同一个 Kind调用方不感知版本。
if parsed, matched := parseRegionBroadcastBody(body, regionTokenV2); matched {
return parsed
}
if parsed, matched := parseRegionBroadcastBody(body, regionToken); matched {
return parsed
}
}
return ParsedGroup{Kind: KindUnknown}
}
func parseRegionBroadcastBody(body string, token string) (ParsedGroup, bool) {
tokenIndex := strings.LastIndex(body, token)
if tokenIndex < 0 {
return ParsedGroup{Kind: KindUnknown}, false
}
app := body[:tokenIndex]
rawRegion := body[tokenIndex+len(token):]
regionID, err := strconv.ParseInt(rawRegion, 10, 64)
if err == nil && regionID > 0 && validateAppCodeForGroupID(app) == nil {
return ParsedGroup{Kind: KindRegionBroadcast, AppCode: app, RegionID: regionID}, true
}
return ParsedGroup{Kind: KindUnknown}, true
}
func validateGroupIDPrefix(prefix string) error {
if prefix == "" {
return nil

View File

@ -32,13 +32,26 @@ func TestBroadcastGroupIDGenerateAndParse(t *testing.T) {
if err != nil {
t.Fatalf("RegionBroadcastGroupID failed: %v", err)
}
if region != "hy_lalu_bc_r_1001" {
if region != "hy_lalu_bc_r_v2_1001" {
t.Fatalf("region group mismatch: %s", region)
}
parsedRegion := Parse(region)
if parsedRegion.Kind != KindRegionBroadcast || parsedRegion.AppCode != "lalu" || parsedRegion.RegionID != 1001 {
t.Fatalf("region parse mismatch: %+v", parsedRegion)
}
// v1 旧区域群在过渡期仍要能解析成同一 Kind双发、切区移除和腾讯回调都依赖这一点。
legacyRegion, err := LegacyRegionBroadcastGroupIDWithPrefix("", "lalu", 1001)
if err != nil {
t.Fatalf("LegacyRegionBroadcastGroupIDWithPrefix failed: %v", err)
}
if legacyRegion != "hy_lalu_bc_r_1001" {
t.Fatalf("legacy region group mismatch: %s", legacyRegion)
}
parsedLegacyRegion := Parse(legacyRegion)
if parsedLegacyRegion.Kind != KindRegionBroadcast || parsedLegacyRegion.AppCode != "lalu" || parsedLegacyRegion.RegionID != 1001 {
t.Fatalf("legacy region parse mismatch: %+v", parsedLegacyRegion)
}
}
func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
@ -48,9 +61,15 @@ func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
if parsed := Parse("hy_lalu_bc_r_0"); parsed.Kind != KindUnknown {
t.Fatalf("zero region must be rejected: %+v", parsed)
}
if parsed := Parse("hy_lalu_bc_r_v2_0"); parsed.Kind != KindUnknown {
t.Fatalf("zero v2 region must be rejected: %+v", parsed)
}
if _, err := RegionBroadcastGroupID("lalu", 0); err == nil {
t.Fatal("zero region must not generate a region broadcast group")
}
if _, err := LegacyRegionBroadcastGroupIDWithPrefix("", "lalu", 0); err == nil {
t.Fatal("zero region must not generate a legacy region broadcast group")
}
if _, err := GlobalBroadcastGroupID("bad app"); err == nil {
t.Fatal("app_code with spaces must be rejected")
}
@ -83,9 +102,19 @@ func TestBroadcastGroupIDWithPrefix(t *testing.T) {
if err != nil {
t.Fatalf("RegionBroadcastGroupIDWithPrefix failed: %v", err)
}
if region != "test_hy_lalu_bc_r_1001" {
if region != "test_hy_lalu_bc_r_v2_1001" {
t.Fatalf("prefixed region group mismatch: %s", region)
}
legacyRegion, err := LegacyRegionBroadcastGroupIDWithPrefix("test_", "lalu", 1001)
if err != nil {
t.Fatalf("LegacyRegionBroadcastGroupIDWithPrefix failed: %v", err)
}
if legacyRegion != "test_hy_lalu_bc_r_1001" {
t.Fatalf("prefixed legacy region group mismatch: %s", legacyRegion)
}
if parsedLegacy := ParseWithPrefix(legacyRegion, "test_"); parsedLegacy.Kind != KindRegionBroadcast || parsedLegacy.RegionID != 1001 {
t.Fatalf("prefixed legacy region parse mismatch: %+v", parsedLegacy)
}
parsedGlobal := ParseWithPrefix(global, "test_")
if parsedGlobal.Kind != KindGlobalBroadcast || parsedGlobal.AppCode != "lalu" {

View File

@ -66,6 +66,7 @@ broadcast:
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
ensure_groups_interval: "30m"
rocketmq:
# Docker 测试环境使用 compose RocketMQ 消费 wallet_outbox 红包事实。
enabled: true

View File

@ -66,6 +66,7 @@ broadcast:
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
ensure_groups_interval: "30m"
rocketmq:
enabled: true
name_servers:

View File

@ -66,6 +66,7 @@ broadcast:
worker_lock_ttl: "30s"
worker_max_retry: 8
ensure_groups_on_startup: true
ensure_groups_interval: "30m"
rocketmq:
# 本地默认走 RocketMQ fanout和 Docker/testbox/线上保持同一条 outbox 链路。
enabled: true

View File

@ -93,6 +93,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
EnsureGroupsInterval: cfg.Broadcast.EnsureGroupsInterval,
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
}, repository, broadcastPublisher, activityclient.NewGRPCRegionSource(clients.userConn))
userProfileSource := activityclient.NewGRPCUserProfileSource(clients.userConn)

View File

@ -173,6 +173,9 @@ type BroadcastConfig struct {
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
WorkerMaxRetry int `yaml:"worker_max_retry"`
EnsureGroupsOnStartup bool `yaml:"ensure_groups_on_startup"`
// EnsureGroupsInterval 是播报群定时 reconcile 周期0 表示只在启动时补偿一次。
// 新区域上线或启动补偿失败时靠它自动补群,不再依赖人工调用 gRPC。
EnsureGroupsInterval time.Duration `yaml:"ensure_groups_interval"`
}
// RocketMQConfig 描述 activity-service 消费房间事实的 MQ 连接。
@ -298,6 +301,7 @@ func Default() Config {
WorkerLockTTL: 30 * time.Second,
WorkerMaxRetry: 8,
EnsureGroupsOnStartup: true,
EnsureGroupsInterval: 30 * time.Minute,
},
RocketMQ: defaultRocketMQConfig(),
MySQLAutoMigrate: false,

View File

@ -20,7 +20,7 @@ const (
TypeRoomRocketReward = "room_rocket_reward_granted"
// TypeRoomPasswordChanged 是锁房状态变化的区域通知,客户端用它刷新房间锁状态入口。
TypeRoomPasswordChanged = "room_password_changed"
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前只让 5 倍及以上大奖进入播报 outbox。
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前只让 10 倍及以上大奖进入播报 outbox。
TypeLuckyGiftBigWin = "lucky_gift_big_win"
// StatusPending 表示消息已持久化,尚未被 worker claim。

View File

@ -5,7 +5,9 @@ package broadcast
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
@ -13,6 +15,7 @@ import (
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/imgroup"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
@ -73,7 +76,10 @@ type Config struct {
WorkerMaxRetry int
WorkerPollInterval time.Duration
EnsureGroupsOnStartup bool
GroupIDPrefix string
// EnsureGroupsInterval 是常驻 worker 内的定时建群 reconcile 周期;<=0 表示只在启动时补偿一次。
// 新区域上线、启动时腾讯 REST 抖动都靠这个周期兜底,不再依赖人工调用 gRPC 补群。
EnsureGroupsInterval time.Duration
GroupIDPrefix string
}
// PublishInput 是服务端入队播报请求。
@ -121,28 +127,32 @@ func (s *Service) SetSenderProfileSource(source SenderProfileSource) {
// EnsureBroadcastGroups 幂等创建全局播报群和所有 active 区域播报群。
// 这个动作放在启动/定时 reconciler不放在 /im/usersig 路径,避免用户登录被外部腾讯 REST 拖慢。
// 单个群创建失败不中断其余群的补偿:全局群配额问题不应让所有区域群一起缺失,错误聚合后返回。
func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
if s == nil || s.publisher == nil {
return 0, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
}
app := appcode.FromContext(ctx)
count := 0
var errs []error
globalID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app)
if err != nil {
return 0, xerr.New(xerr.InvalidArgument, err.Error())
}
if err := s.publisher.EnsureGroup(ctx, s.broadcastGroupSpec(globalID)); err != nil {
return count, err
errs = append(errs, fmt.Errorf("ensure global group %s: %w", globalID, err))
} else {
count++
}
count++
if s.regionSource == nil {
// 没有区域源时至少保证全局群存在;区域群等待下次有 user-service 依赖的 reconciler 补齐。
return count, nil
return count, errors.Join(errs...)
}
regionIDs, err := s.regionSource.ListActiveRegionIDs(ctx)
if err != nil {
return count, err
errs = append(errs, fmt.Errorf("list active regions: %w", err))
return count, errors.Join(errs...)
}
for _, regionID := range regionIDs {
if regionID <= 0 {
@ -151,14 +161,16 @@ func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
}
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app, regionID)
if err != nil {
return count, xerr.New(xerr.InvalidArgument, err.Error())
errs = append(errs, fmt.Errorf("region %d group_id: %w", regionID, err))
continue
}
if err := s.publisher.EnsureGroup(ctx, s.broadcastGroupSpec(groupID)); err != nil {
return count, err
errs = append(errs, fmt.Errorf("ensure region group %s: %w", groupID, err))
continue
}
count++
}
return count, nil
return count, errors.Join(errs...)
}
// PublishRegionBroadcast 把区域播报写入持久化 outbox。
@ -212,6 +224,11 @@ func (s *Service) RemoveRegionBroadcastMember(ctx context.Context, userID int64,
if err := s.publisher.DeleteGroupMember(ctx, groupID, userID); err != nil {
return groupID, false, err
}
// 过渡期老客户端可能仍在 v1 旧区域群里,切区时一并尽力移除;
// 用户不在旧群或旧群不存在都会报错,不能因此阻断已成功的 v2 移除主流程。
if legacyID, legacyErr := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), regionID); legacyErr == nil && legacyID != groupID {
_ = s.publisher.DeleteGroupMember(ctx, legacyID, userID)
}
return groupID, true, nil
}
@ -286,6 +303,16 @@ func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOp
})
if err != nil {
result.FailureCount++
// 单条投递失败必须落日志:区域群缺失/满员这类腾讯侧错误此前只写 last_error 列,
// 重试烧完后记录无声沉底为 failed线上排障只能翻表。attempt 达到上限时标记 final 便于告警。
logx.Error(ctx, "broadcast_outbox_deliver_failed", err,
slog.String("event_id", record.EventID),
slog.String("group_id", record.GroupID),
slog.String("scope", record.Scope),
slog.String("broadcast_type", record.BroadcastType),
slog.Int("attempt_count", record.AttemptCount+1),
slog.Int("max_retry", options.MaxRetry),
slog.Bool("final", record.AttemptCount+1 >= options.MaxRetry))
// Mark 失败只记录为 best-effort当前记录锁会过期下一轮 worker 仍可重新接管。
_ = s.repository.MarkBroadcastOutboxFailed(ctx, record.EventID, options.WorkerID, s.now().UTC().UnixMilli(), s.now().UTC().Add(options.PollInterval).UnixMilli(), options.MaxRetry, trimError(err.Error()))
continue
@ -294,21 +321,28 @@ func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOp
return result, err
}
result.SuccessCount++
s.dualSendLegacyGlobal(ctx, record)
s.dualSendLegacyBroadcast(ctx, record)
}
return result, nil
}
// dualSendLegacyGlobal 在全局播报群从 v1(ChatRoom) 切到 v2(AVChatRoom) 的过渡期内,
// 把已成功投递到 v2 群的全局播报补发一份到 v1 旧群:老客户端在重新拉取 join 列表前
// dualSendLegacyBroadcast 在播报群从 v1(ChatRoom) 切到 v2(AVChatRoom) 的过渡期内,
// 把已成功投递到 v2 群的全局/区域播报补发一份到 v1 旧群:老客户端在重新拉取 join 列表前
// 仍停留在旧群里。补发尽力而为,失败不影响主投递的成功状态。
// 单个用户不会重复收到:新登录只加入 v2 群,存量会话只在 v1 群。
func (s *Service) dualSendLegacyGlobal(ctx context.Context, record broadcastdomain.OutboxRecord) {
func (s *Service) dualSendLegacyBroadcast(ctx context.Context, record broadcastdomain.OutboxRecord) {
parsed := imgroup.ParseWithPrefix(record.GroupID, s.cfg.GroupIDPrefix)
if parsed.Kind != imgroup.KindGlobalBroadcast {
var legacyID string
var err error
switch parsed.Kind {
case imgroup.KindGlobalBroadcast:
legacyID, err = imgroup.LegacyGlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode)
case imgroup.KindRegionBroadcast:
legacyID, err = imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID)
default:
return
}
legacyID, err := imgroup.LegacyGlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode)
// legacyID == record.GroupID 说明这条记录本身就是迁移前入队的 v1 群消息,无需再补发。
if err != nil || legacyID == record.GroupID {
return
}
@ -605,17 +639,18 @@ func normalizeWorkerOptions(options WorkerOptions, cfg Config) WorkerOptions {
}
func (s *Service) broadcastGroupSpec(groupID string) tencentim.GroupSpec {
if imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix).Kind == imgroup.KindGlobalBroadcast {
// 全局播报群使用 AVChatRoomChatRoom 的套餐成员上限(标准版 2000 人)曾把新设备
// 挡在群外10038AVChatRoom 成员无上限、免审批,也不再需要定时清理成员。
switch imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix).Kind {
case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
// 播报群统一使用 AVChatRoomChatRoom 的套餐成员上限(标准版 2000 人)曾把新设备
// 挡在群外10038全局群先踩坑迁移区域群成员同样只进不出是同一失败模式。
// AVChatRoom 成员无上限、免审批,也不再需要定时清理成员。
// reconciler 只生成 v2 群 IDv1 旧群不再 ensure过渡期由双发覆盖存量会话。
return tencentim.GroupSpec{
GroupID: groupID,
Name: groupID,
Type: tencentim.BroadcastGroupType,
}
}
// 区域播报群暂保持 ChatRoomAVChatRoom 的并发群数量受套餐配额限制,而区域群数量 =
// 品牌数 × 区域数;先由离线成员定时清理兜底,确认配额后再整体迁移。
return tencentim.GroupSpec{
GroupID: groupID,
Name: groupID,

View File

@ -53,7 +53,7 @@ func TestBroadcastServiceUsesRealMySQLOutbox(t *testing.T) {
if err != nil {
t.Fatalf("read real mysql outbox failed: %v", err)
}
if !exists || record.Status != broadcastdomain.StatusPending || record.GroupID != "hy_lalu_bc_r_210" {
if !exists || record.Status != broadcastdomain.StatusPending || record.GroupID != "hy_lalu_bc_r_v2_210" {
t.Fatalf("unexpected pending outbox record: exists=%v record=%+v", exists, record)
}
t.Logf("real mysql pending record: event_id=%s group_id=%s status=%s", record.EventID, record.GroupID, record.Status)
@ -68,10 +68,12 @@ func TestBroadcastServiceUsesRealMySQLOutbox(t *testing.T) {
if result.ClaimedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
t.Fatalf("unexpected process result: %+v", result)
}
if len(publisher.messages) != 1 {
t.Fatalf("expected one Tencent message, got %d", len(publisher.messages))
// v2 区域群投递成功后过渡期会向 v1 旧群补发一条 _legacy 副本。
if len(publisher.messages) != 2 {
t.Fatalf("expected v2 message plus legacy copy, got %d", len(publisher.messages))
}
assertPublishedMessage(t, publisher.messages[0], "hy_lalu_bc_r_210", created.BroadcastEventID)
assertPublishedMessage(t, publisher.messages[0], "hy_lalu_bc_r_v2_210", created.BroadcastEventID)
assertPublishedMessage(t, publisher.messages[1], "hy_lalu_bc_r_210", created.BroadcastEventID+"_legacy")
delivered, exists, err := repository.Repository.GetBroadcastOutbox(ctx, created.BroadcastEventID)
if err != nil {
@ -89,13 +91,18 @@ func TestRedPacketCreatedWalletEventUsesRealMySQLOutbox(t *testing.T) {
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_456_000) })
ctx := appcode.WithContext(context.Background(), "lalu")
// 只有 delayed 且带 room_id/region_id 的红包事实才会产生房间 CREATED 加区域飘屏两条记录。
event := broadcastdomain.RedPacketCreatedEvent{
AppCode: "lalu",
EventID: "wallet-red-packet-created-real",
TransactionID: "wallet-tx-red-packet-real",
CommandID: "cmd-red-packet-real",
PacketID: "rp-real",
PacketType: "delayed",
RoomID: "room-310",
RegionID: 310,
PayloadJSON: `{"packet_id":"rp-real","room_id":"room-310","region_id":310}`,
OpenAtMS: 1_700_000_755_000,
CreatedAtMS: 1_700_000_455_000,
}
if err := service.ConsumeRedPacketCreatedEvent(ctx, event); err != nil {
@ -105,14 +112,21 @@ func TestRedPacketCreatedWalletEventUsesRealMySQLOutbox(t *testing.T) {
t.Fatalf("duplicate ConsumeRedPacketCreatedEvent failed: %v", err)
}
record, exists, err := repository.Repository.GetBroadcastOutbox(ctx, event.EventID)
roomRecord, exists, err := repository.Repository.GetBroadcastOutbox(ctx, event.EventID)
if err != nil {
t.Fatalf("read red packet broadcast outbox failed: %v", err)
t.Fatalf("read red packet room broadcast outbox failed: %v", err)
}
if !exists || record.BroadcastType != broadcastdomain.TypeRedPacket || record.GroupID != "hy_lalu_bc_r_310" || record.Status != broadcastdomain.StatusPending {
t.Fatalf("unexpected red packet broadcast record: exists=%v record=%+v", exists, record)
if !exists || roomRecord.BroadcastType != broadcastdomain.TypeRedPacket || roomRecord.GroupID != "room-310" || roomRecord.Status != broadcastdomain.StatusPending {
t.Fatalf("unexpected red packet room broadcast record: exists=%v record=%+v", exists, roomRecord)
}
record, exists, err := repository.Repository.GetBroadcastOutbox(ctx, event.EventID+":region_created")
if err != nil {
t.Fatalf("read red packet region broadcast outbox failed: %v", err)
}
if !exists || record.BroadcastType != broadcastdomain.TypeRedPacket || record.GroupID != "hy_lalu_bc_r_v2_310" || record.Status != broadcastdomain.StatusPending {
t.Fatalf("unexpected red packet region broadcast record: exists=%v record=%+v", exists, record)
}
assertJSONField(t, record.PayloadJSON, "packet_id", "rp-real")
assertJSONField(t, record.PayloadJSON, "region_id", float64(310))
}

View File

@ -43,7 +43,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
t.Fatalf("unexpected result: %+v", result)
}
record := repository.records[result.BroadcastEventID]
if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
t.Fatalf("unexpected broadcast record: %+v", record)
}
var payload map[string]any
@ -90,7 +90,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcastForTaggedGiftBelowThreshold(t *
t.Fatalf("tagged gift should create broadcast: %+v", result)
}
record := repository.records[result.BroadcastEventID]
if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
t.Fatalf("unexpected tagged gift broadcast record: %+v", record)
}
}
@ -113,7 +113,7 @@ func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
t.Fatalf("unexpected password broadcast result: %+v", result)
}
record := repository.records[result.BroadcastEventID]
if record.GroupID != "hy_lalu_bc_r_1001" || record.Scope != broadcastdomain.ScopeRegion || record.BroadcastType != broadcastdomain.TypeRoomPasswordChanged {
if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.Scope != broadcastdomain.ScopeRegion || record.BroadcastType != broadcastdomain.TypeRoomPasswordChanged {
t.Fatalf("unexpected password broadcast record: %+v", record)
}
var payload map[string]any
@ -270,7 +270,7 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
if roomCreated.GroupID != "9001" || roomCreated.Scope != broadcastdomain.ScopeRoom || roomCreated.NextRetryAtMS != nowMS {
t.Fatalf("room created outbox mismatch: %+v", roomCreated)
}
if regionCreated.GroupID != "hy_lalu_bc_r_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS {
if regionCreated.GroupID != "hy_lalu_bc_r_v2_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS {
t.Fatalf("region created outbox mismatch: %+v", regionCreated)
}
if openDue.GroupID != "9001" || openDue.Scope != broadcastdomain.ScopeRoom || openDue.NextRetryAtMS != openAtMS {
@ -289,9 +289,14 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
if err != nil {
t.Fatalf("ProcessPendingBroadcasts before open failed: %v", err)
}
if result.SuccessCount != 2 || len(publisher.messages) != 2 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
// v2 区域群投递成功后过渡期会向 v1 旧群补发一条 _legacy 副本,所以消息比 outbox 记录多一条。
if result.SuccessCount != 2 || len(publisher.messages) != 3 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
t.Fatalf("before open only room/region CREATED should be sent: result=%+v messages=%+v", result, publisher.messages)
}
legacyCopy := findCustomMessageEvent(publisher.messages, event.EventID+":region_created_legacy")
if legacyCopy == nil || legacyCopy.GroupID != "hy_lalu_bc_r_310" {
t.Fatalf("region broadcast should dual-send to legacy v1 group: %+v", publisher.messages)
}
service.SetClock(func() time.Time { return time.UnixMilli(openAtMS) })
result, err = service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
@ -311,11 +316,12 @@ func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
if err != nil {
t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
}
if !removed || groupID != "hy_lalu_bc_r_1001" {
if !removed || groupID != "hy_lalu_bc_r_v2_1001" {
t.Fatalf("unexpected remove result: group_id=%s removed=%t", groupID, removed)
}
if publisher.deletedGroupID != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 {
t.Fatalf("unexpected deleted member: group_id=%s user_id=%d", publisher.deletedGroupID, publisher.deletedUserID)
// 切区必须同时清 v2 主群和过渡期 v1 旧群的成员关系。
if len(publisher.deletedGroupIDs) != 2 || publisher.deletedGroupIDs[0] != "hy_lalu_bc_r_v2_1001" || publisher.deletedGroupIDs[1] != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 {
t.Fatalf("unexpected deleted member: group_ids=%v user_id=%d", publisher.deletedGroupIDs, publisher.deletedUserID)
}
groupID, removed, err = service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 0)
@ -341,10 +347,10 @@ func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) {
if err != nil {
t.Fatalf("PublishRegionBroadcast failed: %v", err)
}
if result.GroupID != "test_hy_lalu_bc_r_1001" {
if result.GroupID != "test_hy_lalu_bc_r_v2_1001" {
t.Fatalf("prefixed publish group mismatch: %+v", result)
}
if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_1001" {
if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_v2_1001" {
t.Fatalf("prefixed outbox group mismatch: %+v", record)
}
@ -352,8 +358,34 @@ func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) {
if err != nil {
t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
}
if !removed || groupID != "test_hy_lalu_bc_r_1001" || publisher.deletedGroupID != "test_hy_lalu_bc_r_1001" {
t.Fatalf("prefixed remove mismatch: group_id=%s removed=%t deleted=%s", groupID, removed, publisher.deletedGroupID)
if !removed || groupID != "test_hy_lalu_bc_r_v2_1001" || len(publisher.deletedGroupIDs) != 2 || publisher.deletedGroupIDs[0] != "test_hy_lalu_bc_r_v2_1001" || publisher.deletedGroupIDs[1] != "test_hy_lalu_bc_r_1001" {
t.Fatalf("prefixed remove mismatch: group_id=%s removed=%t deleted=%v", groupID, removed, publisher.deletedGroupIDs)
}
}
func TestEnsureBroadcastGroupsUsesAVChatRoomAndSurvivesGlobalFailure(t *testing.T) {
publisher := &fakePublisher{ensureErrors: map[string]error{"hy_lalu_bc_g_v2": errors.New("quota exceeded")}}
service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, fakeRegionSource{regionIDs: []int64{310, 320}})
count, err := service.EnsureBroadcastGroups(appcode.WithContext(context.Background(), "lalu"))
if err == nil {
t.Fatal("global group failure must be reported")
}
// 全局群失败不再阻断区域群补偿:两个区域群仍应建成。
if count != 2 {
t.Fatalf("region groups must still be ensured after global failure: count=%d", count)
}
if len(publisher.ensuredGroups) != 3 {
t.Fatalf("expected global+2 region ensure calls: %+v", publisher.ensuredGroups)
}
for _, spec := range publisher.ensuredGroups[1:] {
// 区域群一律生成 v2 并使用 AVChatRoom无 ApplyJoinOption不再受 ChatRoom 成员上限约束。
if spec.Type != tencentim.BroadcastGroupType || spec.ApplyJoinOption != "" {
t.Fatalf("region group must be AVChatRoom without apply_join_option: %+v", spec)
}
}
if publisher.ensuredGroups[1].GroupID != "hy_lalu_bc_r_v2_310" || publisher.ensuredGroups[2].GroupID != "hy_lalu_bc_r_v2_320" {
t.Fatalf("region group ids mismatch: %+v", publisher.ensuredGroups)
}
}
@ -408,12 +440,16 @@ func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any {
}
func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool {
for _, message := range messages {
if message.EventID == eventID {
return true
return findCustomMessageEvent(messages, eventID) != nil
}
func findCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) *tencentim.CustomGroupMessage {
for index := range messages {
if messages[index].EventID == eventID {
return &messages[index]
}
}
return false
return nil
}
type fakeRepository struct {
@ -479,13 +515,16 @@ func (r *fakeRepository) MarkBroadcastOutboxFailed(_ context.Context, eventID st
}
type fakePublisher struct {
messages []tencentim.CustomGroupMessage
deletedGroupID string
deletedUserID int64
messages []tencentim.CustomGroupMessage
ensuredGroups []tencentim.GroupSpec
ensureErrors map[string]error
deletedGroupIDs []string
deletedUserID int64
}
func (p *fakePublisher) EnsureGroup(context.Context, tencentim.GroupSpec) error {
return nil
func (p *fakePublisher) EnsureGroup(_ context.Context, spec tencentim.GroupSpec) error {
p.ensuredGroups = append(p.ensuredGroups, spec)
return p.ensureErrors[spec.GroupID]
}
func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
@ -494,11 +533,19 @@ func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message ten
}
func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, userID int64) error {
p.deletedGroupID = groupID
p.deletedGroupIDs = append(p.deletedGroupIDs, groupID)
p.deletedUserID = userID
return nil
}
type fakeRegionSource struct {
regionIDs []int64
}
func (s fakeRegionSource) ListActiveRegionIDs(context.Context) ([]int64, error) {
return s.regionIDs, nil
}
type fakeSenderProfileSource struct {
profile SenderProfile
profiles map[int64]SenderProfile

View File

@ -14,14 +14,19 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
options = normalizeWorkerOptions(options, s.cfg)
if s.cfg.EnsureGroupsOnStartup {
// 建群失败不退出进程:下一轮部署/定时任务可继续补偿,已经入队的消息仍保留在 MySQL。
if _, err := s.EnsureBroadcastGroups(ctx); err != nil && ctx.Err() == nil {
logx.Error(ctx, "broadcast_group_reconcile_failed", err, slog.String("worker_id", options.WorkerID))
}
s.reconcileBroadcastGroups(ctx, options)
}
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
lastReconcile := s.now()
for {
// 定时 reconcile 兜底新区域上线和启动时腾讯 REST 抖动:只跑一次的启动补偿失败后,
// 缺失的区域群会让该区域全部播报静默 failed 沉底,不能等下一次部署才补群。
if s.cfg.EnsureGroupsInterval > 0 && s.now().Sub(lastReconcile) >= s.cfg.EnsureGroupsInterval {
lastReconcile = s.now()
s.reconcileBroadcastGroups(ctx, options)
}
if _, err := s.ProcessPendingBroadcasts(ctx, options); err != nil && ctx.Err() == nil {
// 批处理失败只影响当前轮询outbox 记录没有被删除,下一轮会按锁和重试状态重新处理。
logx.Error(ctx, "broadcast_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID))
@ -33,3 +38,9 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
}
}
}
func (s *Service) reconcileBroadcastGroups(ctx context.Context, options WorkerOptions) {
if _, err := s.EnsureBroadcastGroups(ctx); err != nil && ctx.Err() == nil {
logx.Error(ctx, "broadcast_group_reconcile_failed", err, slog.String("worker_id", options.WorkerID))
}
}

View File

@ -67,7 +67,8 @@ type RegionBroadcaster interface {
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
}
const luckyGiftRegionBroadcastMinMultiplierPPM = 5_000_000
// luckyGiftBroadcastMinMultiplierPPM 是房间飘屏和区域飘屏共用的大奖门槛10 倍以下的普通返奖两类飘屏都不发。
const luckyGiftBroadcastMinMultiplierPPM = 10_000_000
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。
type Service struct {
@ -574,6 +575,10 @@ func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDr
if s.publisher == nil || payload.EffectiveRewardCoins <= 0 {
return nil
}
// 房间飘屏与区域飘屏同门槛10 倍以下的普通返奖不再发房间中奖 IM中奖用户余额仍靠钱包私信同步。
if payload.MultiplierPPM < luckyGiftBroadcastMinMultiplierPPM {
return nil
}
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
if err != nil {
return err
@ -736,9 +741,9 @@ type luckyGiftDrawnPayload struct {
}
func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool {
// 区域飘屏只承担大奖氛围,不再把 1x/2x/3x 的普通返奖广播到整区,避免高频低价值 IM 干扰用户。
// 区域飘屏只承担大奖氛围,10 倍以下的普通返奖不广播到整区,避免高频低价值 IM 干扰用户。
// 跨房展示没有当前房间成员资料可兜底,缺 sender_name 时宁可跳过,也不能再让 Flutter 退回 `User {id}`。
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= luckyGiftRegionBroadcastMinMultiplierPPM && strings.TrimSpace(payload.SenderName) != ""
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= luckyGiftBroadcastMinMultiplierPPM && strings.TrimSpace(payload.SenderName) != ""
}
func luckyGiftRoomMessagePayload(payload luckyGiftDrawnPayload) map[string]any {

View File

@ -168,7 +168,7 @@ func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
}
}
func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing.T) {
func TestProcessPendingDrawOutboxCreditsWalletAndSkipsRoomDisplayBelowTenTimes(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_test",
@ -215,7 +215,9 @@ func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
}
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_test", "lucky_draw_test", 300)
if publisher.last.EventID != "" {
t.Fatalf("3x lucky gift must not publish room display: %+v", publisher.last)
}
if broadcaster.last.EventID != "" {
t.Fatalf("3x lucky gift must not enter region broadcast: %+v", broadcaster.last)
}
@ -225,13 +227,13 @@ func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing
}
}
func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProfile(t *testing.T) {
func TestProcessPendingDrawOutboxPublishesTenTimesRegionBroadcastWithSenderProfile(t *testing.T) {
payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:lucky_draw_5x",
"event_id": "lucky_gift_drawn:lucky_draw_10x",
"app_code": "lalu",
"draw_id": "lucky_draw_5x",
"command_id": "cmd-gift-5x",
"draw_id": "lucky_draw_10x",
"command_id": "cmd-gift-10x",
"pool_id": "super_lucky",
"user_id": 42,
"sender_user_id": 42,
@ -242,14 +244,14 @@ func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProf
"gift_id": "rose",
"gift_count": 1,
"coin_spent": 100,
"effective_reward_coins": 500,
"multiplier_ppm": luckyGiftRegionBroadcastMinMultiplierPPM,
"effective_reward_coins": 1000,
"multiplier_ppm": luckyGiftBroadcastMinMultiplierPPM,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
outbox: []domain.DrawOutbox{{
AppCode: "lalu",
OutboxID: "lucky_lucky_draw_5x",
OutboxID: "lucky_lucky_draw_10x",
EventType: "LuckyGiftDrawn",
PayloadJSON: string(payload),
}},
@ -276,7 +278,7 @@ func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProf
if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed)
}
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_5x", "lucky_draw_5x", 500)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_10x", "lucky_draw_10x", 1000)
var roomPayload map[string]any
if err := json.Unmarshal(publisher.last.PayloadJSON, &roomPayload); err != nil {
t.Fatalf("room payload is invalid: %v", err)
@ -284,8 +286,8 @@ func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProf
if roomPayload["sender_name"] != "Real Sender" || roomPayload["sender_display_user_id"] != "160042" {
t.Fatalf("room payload must include sender display snapshot: %+v", roomPayload)
}
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_5x" || broadcaster.last.RegionID != 210 {
t.Fatalf("5x lucky gift region broadcast mismatch: %+v", broadcaster.last)
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_10x" || broadcaster.last.RegionID != 210 {
t.Fatalf("10x lucky gift region broadcast mismatch: %+v", broadcaster.last)
}
var regionPayload map[string]any
if err := json.Unmarshal([]byte(broadcaster.last.PayloadJSON), &regionPayload); err != nil {
@ -321,8 +323,8 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
"gift_count": 3,
"visible_region_id": 210,
"coin_spent": 300,
"effective_reward_coins": 800,
"multiplier_ppm": 8000000,
"effective_reward_coins": 3600,
"multiplier_ppm": 12000000,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
@ -355,14 +357,14 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed)
}
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetCountryId() != 15 || wallet.last.GetVisibleRegionId() != 210 {
if wallet.last == nil || wallet.last.GetAmount() != 3600 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetCountryId() != 15 || wallet.last.GetVisibleRegionId() != 210 {
t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last)
}
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
t.Fatalf("all sub draws should be granted together, got %s", got)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 800, 0)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 800)
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 3600, 0)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 3600)
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_batch_1" || broadcaster.last.RegionID != 210 {
t.Fatalf("batch big win region broadcast mismatch: %+v", broadcaster.last)
}
@ -382,8 +384,8 @@ func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(
"gift_id": "rose",
"gift_count": 1,
"coin_spent": 100,
"effective_reward_coins": 300,
"multiplier_ppm": 3000000,
"effective_reward_coins": 1200,
"multiplier_ppm": 12000000,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
@ -410,11 +412,11 @@ func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(
if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" {
t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID)
}
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 300)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 1200)
if broadcaster.last.EventID != "" {
t.Fatalf("3x settlement lucky gift must not enter region broadcast: %+v", broadcaster.last)
t.Fatalf("settlement without sender profile must not enter region broadcast: %+v", broadcaster.last)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 1200, 0)
}
func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
@ -442,8 +444,8 @@ func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
CoinSpent: 99900,
RuleVersion: 2,
ExperiencePool: "advanced",
SelectedTierID: "advanced_1x",
MultiplierPPM: 1000000,
SelectedTierID: "batch",
MultiplierPPM: 12000000,
BaseRewardCoins: 73983,
RoomAtmosphereRewardCoins: 0,
ActivitySubsidyCoins: 0,
@ -482,8 +484,8 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(t *testing.T) {
"gift_id": "rose",
"gift_count": 1,
"coin_spent": 100,
"effective_reward_coins": 300,
"multiplier_ppm": 3000000,
"effective_reward_coins": 1200,
"multiplier_ppm": 12000000,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
@ -516,7 +518,7 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(t *testing.T) {
if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_im_fail" {
t.Fatalf("room IM should still be attempted before ignoring failure: %+v", publisher.last)
}
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 1200, 0)
}
func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) {
@ -532,8 +534,8 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
"gift_id": "rose",
"gift_count": 1,
"coin_spent": 100,
"effective_reward_coins": 300,
"multiplier_ppm": 3_000_000,
"effective_reward_coins": 1200,
"multiplier_ppm": 12_000_000,
"created_at_ms": 1760000000000,
})
repository := &fakeLuckyGiftRepository{
@ -566,7 +568,7 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
if userPublisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
t.Fatalf("already granted draw should not resend wallet notice and must deliver outbox: user_publisher=%+v delivered=%s", userPublisher.last, repository.deliveredOutboxID)
}
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 300)
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 1200)
}
type fakeLuckyGiftRepository struct {

View File

@ -10394,7 +10394,7 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
t.Fatalf("join_groups mismatch: %+v", data["join_groups"])
}
regionGroup, ok := groups[1].(map[string]any)
if !ok || regionGroup["group_id"] != "hy_lalu_bc_r_1001" || regionGroup["type"] != "region_broadcast" {
if !ok || regionGroup["group_id"] != "hy_lalu_bc_r_v2_1001" || regionGroup["type"] != "region_broadcast" {
t.Fatalf("region join group mismatch: %+v", groups[1])
}
@ -10424,7 +10424,7 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
t.Fatalf("prefixed join_groups mismatch: %+v", prefixedData["join_groups"])
}
prefixedRegionGroup, ok := prefixedGroups[1].(map[string]any)
if !ok || prefixedRegionGroup["group_id"] != "test_hy_lalu_bc_r_1001" {
if !ok || prefixedRegionGroup["group_id"] != "test_hy_lalu_bc_r_v2_1001" {
t.Fatalf("prefixed region join group mismatch: %+v", prefixedGroups[1])
}

View File

@ -120,12 +120,7 @@ func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, opti
})
case channelTencentIMRegion:
// 区域群是长期播报群;发送前幂等创建,避免本地真实测试或新区域缺群导致关系主流程事实无法通知。
if ensureErr := s.publisher.EnsureGroup(publishCtx, tencentim.GroupSpec{
GroupID: delivery.GroupID,
Name: delivery.GroupID,
Type: tencentim.DefaultGroupType,
ApplyJoinOption: "FreeAccess",
}); ensureErr != nil {
if ensureErr := s.publisher.EnsureGroup(publishCtx, s.regionGroupSpec(delivery.GroupID)); ensureErr != nil {
err = ensureErr
break
}
@ -136,6 +131,9 @@ func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, opti
Ext: "cp_region_broadcast",
PayloadJSON: delivery.PayloadJSON,
})
if err == nil {
s.dualSendLegacyRegion(publishCtx, delivery)
}
default:
err = fmt.Errorf("unsupported cp notice channel %q", delivery.Channel)
}
@ -157,6 +155,47 @@ func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, opti
return s.markFailed(ctx, delivery, options, err)
}
// regionGroupSpec 按群 ID 版本决定建群参数v2 区域群是 AVChatRoomChatRoom 套餐成员上限
// 曾在线上触发 10038 拒绝加群),迁移前入队的存量 v1 投递记录仍按 ChatRoom 幂等创建。
func (s *Service) regionGroupSpec(groupID string) tencentim.GroupSpec {
parsed := imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix)
if parsed.Kind == imgroup.KindRegionBroadcast {
if legacyID, err := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID); err == nil && legacyID != groupID {
return tencentim.GroupSpec{
GroupID: groupID,
Name: groupID,
Type: tencentim.BroadcastGroupType,
}
}
}
return tencentim.GroupSpec{
GroupID: groupID,
Name: groupID,
Type: tencentim.DefaultGroupType,
ApplyJoinOption: "FreeAccess",
}
}
// dualSendLegacyRegion 在区域播报群 v1→v2 过渡期内,把已成功投递到 v2 群的 CP 播报补发到 v1 旧群,
// 覆盖尚未重新拉取 join 列表的老客户端。补发尽力而为,失败不影响主投递状态,与 activity 播报双发同一模式。
func (s *Service) dualSendLegacyRegion(ctx context.Context, delivery CPDelivery) {
parsed := imgroup.ParseWithPrefix(delivery.GroupID, s.cfg.GroupIDPrefix)
if parsed.Kind != imgroup.KindRegionBroadcast {
return
}
legacyID, err := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID)
if err != nil || legacyID == delivery.GroupID {
return
}
_ = s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
GroupID: legacyID,
EventID: delivery.EventID + "_legacy",
Desc: delivery.NoticeType,
Ext: "cp_region_broadcast",
PayloadJSON: delivery.PayloadJSON,
})
}
func (s *Service) markFailed(ctx context.Context, delivery CPDelivery, options CPNoticeWorkerOptions, cause error) error {
nowMs := time.Now().UnixMilli()
nextRetryCount := delivery.RetryCount + 1

View File

@ -145,12 +145,20 @@ func TestProcessRelationshipCreatedPublishesRegionIM(t *testing.T) {
if err != nil || !handled {
t.Fatalf("ProcessUserOutboxMessage failed handled=%v err=%v", handled, err)
}
if len(publisher.ensuredGroups) != 1 || publisher.ensuredGroups[0].GroupID != "test_hy_lalu_bc_r_86" {
if len(publisher.ensuredGroups) != 1 || publisher.ensuredGroups[0].GroupID != "test_hy_lalu_bc_r_v2_86" {
t.Fatalf("expected region group ensure, got %+v", publisher.ensuredGroups)
}
if len(publisher.groupMessages) != 1 || publisher.groupMessages[0].GroupID != "test_hy_lalu_bc_r_86" {
// v2 区域群是 AVChatRoom(不带 ApplyJoinOption),不再受 ChatRoom 套餐成员上限约束。
if publisher.ensuredGroups[0].Type != tencentim.BroadcastGroupType || publisher.ensuredGroups[0].ApplyJoinOption != "" {
t.Fatalf("v2 region group must be AVChatRoom: %+v", publisher.ensuredGroups[0])
}
// v2 群投递成功后过渡期向 v1 旧群补发一条 _legacy 副本。
if len(publisher.groupMessages) != 2 || publisher.groupMessages[0].GroupID != "test_hy_lalu_bc_r_v2_86" {
t.Fatalf("expected region group message, got %+v", publisher.groupMessages)
}
if publisher.groupMessages[1].GroupID != "test_hy_lalu_bc_r_86" || publisher.groupMessages[1].EventID != "cp_relationship:created:1002:1_legacy" {
t.Fatalf("expected legacy region copy, got %+v", publisher.groupMessages[1])
}
if len(publisher.userMessages) != 0 {
t.Fatalf("relationship created should not publish c2c: %+v", publisher.userMessages)
}