Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6053da56 | ||
|
|
bbf70d8f37 | ||
|
|
1c45fdc65b |
@ -11546,6 +11546,9 @@ type RoomListItem struct {
|
|||||||
CountryCode string `protobuf:"bytes,16,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"`
|
CountryCode string `protobuf:"bytes,16,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"`
|
||||||
RoomBorder *RoomDecorationResource `protobuf:"bytes,17,opt,name=room_border,json=roomBorder,proto3" json:"room_border,omitempty"`
|
RoomBorder *RoomDecorationResource `protobuf:"bytes,17,opt,name=room_border,json=roomBorder,proto3" json:"room_border,omitempty"`
|
||||||
RoomNameStyle *RoomDecorationResource `protobuf:"bytes,18,opt,name=room_name_style,json=roomNameStyle,proto3" json:"room_name_style,omitempty"`
|
RoomNameStyle *RoomDecorationResource `protobuf:"bytes,18,opt,name=room_name_style,json=roomNameStyle,proto3" json:"room_name_style,omitempty"`
|
||||||
|
// owner_vip_level 是 wallet effective VIP 的异步列表投影;0 表示房主当前没有有效 VIP。
|
||||||
|
// room-service 在创建/房主进房和 wallet outbox 变化时刷新,列表查询本身不回查 wallet。
|
||||||
|
OwnerVipLevel int32 `protobuf:"varint,19,opt,name=owner_vip_level,json=ownerVipLevel,proto3" json:"owner_vip_level,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -11699,6 +11702,13 @@ func (x *RoomListItem) GetRoomNameStyle() *RoomDecorationResource {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *RoomListItem) GetOwnerVipLevel() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.OwnerVipLevel
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。
|
// ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。
|
||||||
type ListRoomsResponse struct {
|
type ListRoomsResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -14889,7 +14899,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
|||||||
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\"c\n" +
|
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\"c\n" +
|
||||||
"\x13RoomFeedRelatedUser\x12\x17\n" +
|
"\x13RoomFeedRelatedUser\x12\x17\n" +
|
||||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x123\n" +
|
"\auser_id\x18\x01 \x01(\x03R\x06userId\x123\n" +
|
||||||
"\x16relation_updated_at_ms\x18\x02 \x01(\x03R\x13relationUpdatedAtMs\"\xed\x04\n" +
|
"\x16relation_updated_at_ms\x18\x02 \x01(\x03R\x13relationUpdatedAtMs\"\x95\x05\n" +
|
||||||
"\fRoomListItem\x12\x17\n" +
|
"\fRoomListItem\x12\x17\n" +
|
||||||
"\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" +
|
"\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" +
|
||||||
"\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x14\n" +
|
"\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x14\n" +
|
||||||
@ -14910,7 +14920,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
|||||||
"\fcountry_code\x18\x10 \x01(\tR\vcountryCode\x12F\n" +
|
"\fcountry_code\x18\x10 \x01(\tR\vcountryCode\x12F\n" +
|
||||||
"\vroom_border\x18\x11 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\n" +
|
"\vroom_border\x18\x11 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\n" +
|
||||||
"roomBorder\x12M\n" +
|
"roomBorder\x12M\n" +
|
||||||
"\x0froom_name_style\x18\x12 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\rroomNameStyle\"g\n" +
|
"\x0froom_name_style\x18\x12 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\rroomNameStyle\x12&\n" +
|
||||||
|
"\x0fowner_vip_level\x18\x13 \x01(\x05R\rownerVipLevel\"g\n" +
|
||||||
"\x11ListRoomsResponse\x121\n" +
|
"\x11ListRoomsResponse\x121\n" +
|
||||||
"\x05rooms\x18\x01 \x03(\v2\x1b.hyapp.room.v1.RoomListItemR\x05rooms\x12\x1f\n" +
|
"\x05rooms\x18\x01 \x03(\v2\x1b.hyapp.room.v1.RoomListItemR\x05rooms\x12\x1f\n" +
|
||||||
"\vnext_cursor\x18\x02 \x01(\tR\n" +
|
"\vnext_cursor\x18\x02 \x01(\tR\n" +
|
||||||
|
|||||||
@ -1419,6 +1419,9 @@ message RoomListItem {
|
|||||||
string country_code = 16;
|
string country_code = 16;
|
||||||
RoomDecorationResource room_border = 17;
|
RoomDecorationResource room_border = 17;
|
||||||
RoomDecorationResource room_name_style = 18;
|
RoomDecorationResource room_name_style = 18;
|
||||||
|
// owner_vip_level 是 wallet effective VIP 的异步列表投影;0 表示房主当前没有有效 VIP。
|
||||||
|
// room-service 在创建/房主进房和 wallet outbox 变化时刷新,列表查询本身不回查 wallet。
|
||||||
|
int32 owner_vip_level = 19;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。
|
// ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。
|
||||||
|
|||||||
@ -2585,7 +2585,7 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, gift_tray_skin, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
||||||
- name: keyword
|
- name: keyword
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
@ -2629,7 +2629,7 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, gift_tray_skin, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
||||||
- name: page
|
- name: page
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
@ -2813,7 +2813,7 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, gift_tray_skin, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 返回当前用户有效资源权益。
|
description: 返回当前用户有效资源权益。
|
||||||
@ -4431,7 +4431,7 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
resource_type:
|
resource_type:
|
||||||
type: string
|
type: string
|
||||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, gift_tray_skin, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
status:
|
status:
|
||||||
@ -4595,7 +4595,7 @@ definitions:
|
|||||||
description: 资源 ID;按字符串返回避免 64 位整数在客户端丢精度。
|
description: 资源 ID;按字符串返回避免 64 位整数在客户端丢精度。
|
||||||
resource_type:
|
resource_type:
|
||||||
type: string
|
type: string
|
||||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, gift_tray_skin, mic_seat_icon, mic_seat_animation, room_border, room_name_style, emoji_pack, vip_trial_card]
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
asset_url:
|
asset_url:
|
||||||
@ -5628,6 +5628,10 @@ definitions:
|
|||||||
description: 腾讯 IM 房间群 ID。当前等于 `room_id`,只代表目标群,不代表入群授权。
|
description: 腾讯 IM 房间群 ID。当前等于 `room_id`,只代表目标群,不代表入群授权。
|
||||||
owner_user_id:
|
owner_user_id:
|
||||||
type: string
|
type: string
|
||||||
|
owner_vip_level:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: 房主当前有效 VIP 等级;0 表示无有效 VIP。该字段来自异步读模型,不会在列表请求中回查钱包服务。
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
cover_url:
|
cover_url:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Resource And Resource Group Architecture
|
# Resource And Resource Group Architecture
|
||||||
|
|
||||||
本文定义资源、资源组、礼物配置和用户资源赠送的后端边界。资源类型固定为头像框、金币、座驾、气泡、徽章、飘屏、礼物;资源组是一组资源和钱包资产项的集合,可以一次性赠送给用户。实现必须以这里的服务归属、数据边界和 App/Admin 接口边界为准。
|
本文定义资源、资源组、礼物配置和用户资源赠送的后端边界。资源类型包含头像框、金币、座驾、气泡、徽章、飘屏、礼物和送礼托盘等可配置素材;资源组是一组资源和钱包资产项的集合,可以一次性赠送给用户。实现必须以这里的服务归属、数据边界和 App/Admin 接口边界为准。
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
| Concept | Meaning |
|
| Concept | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `resource` | 可配置、可引用、可赠送的最小资源。头像框、资料卡、金币、座驾、气泡、徽章、飘屏、礼物都属于资源。 |
|
| `resource` | 可配置、可引用、可赠送的最小资源。头像框、资料卡、金币、座驾、气泡、徽章、飘屏、礼物和送礼托盘都属于资源。 |
|
||||||
| `resource_group` | 一组资源和钱包资产项的集合。赠送资源组时展开为组内每个资源或资产赠送项。 |
|
| `resource_group` | 一组资源和钱包资产项的集合。赠送资源组时展开为组内每个资源或资产赠送项。 |
|
||||||
| `resource_group_item` | 资源组成员。`item_type=resource` 引用资源库资源;`item_type=wallet_asset` 直接配置 `COIN` 或 `DIAMOND` 数量。 |
|
| `resource_group_item` | 资源组成员。`item_type=resource` 引用资源库资源;`item_type=wallet_asset` 直接配置 `COIN` 或 `DIAMOND` 数量。 |
|
||||||
| `gift_config` | 礼物配置是一个绑定了 `resource_type=gift` 资源的业务配置,并额外拥有礼物类型、有效期、特效、价格、收费资产、热度和主播积分规则。 |
|
| `gift_config` | 礼物配置是一个绑定了 `resource_type=gift` 资源的业务配置,并额外拥有礼物类型、有效期、特效、价格、收费资产、热度和主播积分规则。 |
|
||||||
@ -55,6 +55,7 @@
|
|||||||
| `badge` | 徽章 | 写入 `user_resource_entitlements` |
|
| `badge` | 徽章 | 写入 `user_resource_entitlements` |
|
||||||
| `floating_screen` | 飘屏 | 写入 `user_resource_entitlements`,使用时由服务端触发飘屏展示 |
|
| `floating_screen` | 飘屏 | 写入 `user_resource_entitlements`,使用时由服务端触发飘屏展示 |
|
||||||
| `gift` | 礼物 | 写入 `user_resource_entitlements`;礼物配置只能绑定这类资源 |
|
| `gift` | 礼物 | 写入 `user_resource_entitlements`;礼物配置只能绑定这类资源 |
|
||||||
|
| `gift_tray_skin` | 送礼托盘 | 保存送礼托盘展示素材;与 `gift` 礼物本体分开 |
|
||||||
|
|
||||||
## Gift Boundary
|
## Gift Boundary
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ INCREMENTAL_MIGRATION_FILES=(
|
|||||||
"services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql"
|
"services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql"
|
||||||
"services/user-service/deploy/mysql/migrations/019_user_profile_anonymous_visits.sql"
|
"services/user-service/deploy/mysql/migrations/019_user_profile_anonymous_visits.sql"
|
||||||
"services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql"
|
"services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql"
|
||||||
|
"services/room-service/deploy/mysql/migrations/004_room_list_owner_vip_level.sql"
|
||||||
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
|
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
|
||||||
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql"
|
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql"
|
||||||
)
|
)
|
||||||
|
|||||||
35
scripts/mysql/072_room_list_owner_vip_level.sql
Normal file
35
scripts/mysql/072_room_list_owner_vip_level.sql
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
-- 与 room-service/deploy/mysql/migrations/004_room_list_owner_vip_level.sql 保持同一可重入结构。
|
||||||
|
-- owner 复合索引已经覆盖异步更新条件,本迁移不新增索引,也不改变列表排序执行计划。
|
||||||
|
DROP PROCEDURE IF EXISTS hyapp_migrate_072_room_list_owner_vip_level;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
|
||||||
|
CREATE PROCEDURE hyapp_migrate_072_room_list_owner_vip_level()
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries'
|
||||||
|
) THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'owner_vip_level'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE room_list_entries
|
||||||
|
ADD COLUMN owner_vip_level INT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 等级异步投影' AFTER owner_user_id,
|
||||||
|
ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'owner_vip_expires_at_ms'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE room_list_entries
|
||||||
|
ADD COLUMN owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 到期时间,UTC epoch ms' AFTER owner_vip_level,
|
||||||
|
ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
CALL hyapp_migrate_072_room_list_owner_vip_level()$$
|
||||||
|
DROP PROCEDURE IF EXISTS hyapp_migrate_072_room_list_owner_vip_level$$
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
@ -24,6 +24,7 @@ const (
|
|||||||
resourceTypeAvatarFrame = "avatar_frame"
|
resourceTypeAvatarFrame = "avatar_frame"
|
||||||
resourceTypeBadge = "badge"
|
resourceTypeBadge = "badge"
|
||||||
resourceTypeEmojiPack = "emoji_pack"
|
resourceTypeEmojiPack = "emoji_pack"
|
||||||
|
resourceTypeGiftTraySkin = "gift_tray_skin"
|
||||||
resourceTypeMicAnimation = "mic_seat_animation"
|
resourceTypeMicAnimation = "mic_seat_animation"
|
||||||
resourceTypeProfileCard = "profile_card"
|
resourceTypeProfileCard = "profile_card"
|
||||||
resourceTypeRoomBorder = "room_border"
|
resourceTypeRoomBorder = "room_border"
|
||||||
@ -948,7 +949,7 @@ func normalizeMP4AlphaLayout(value string) string {
|
|||||||
|
|
||||||
func resourceAllowsAnimationMetadata(resourceType string) bool {
|
func resourceAllowsAnimationMetadata(resourceType string) bool {
|
||||||
switch resourceType {
|
switch resourceType {
|
||||||
case "gift", "vehicle", "avatar_frame", "chat_bubble", "floating_screen", resourceTypeProfileCard, resourceTypeRoomBorder:
|
case "gift", resourceTypeGiftTraySkin, "vehicle", "avatar_frame", "chat_bubble", "floating_screen", resourceTypeProfileCard, resourceTypeRoomBorder:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return strings.HasPrefix(resourceType, "mic_seat_")
|
return strings.HasPrefix(resourceType, "mic_seat_")
|
||||||
|
|||||||
@ -28,7 +28,6 @@ type HostAgencySalaryPolicyListOptions struct {
|
|||||||
func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListOptions) ([]model.HostAgencySalaryPolicy, int64, error) {
|
func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListOptions) ([]model.HostAgencySalaryPolicy, int64, error) {
|
||||||
// 工资政策是应用隔离配置,所有查询都必须先限定 app_code,再叠加运营筛选条件。
|
// 工资政策是应用隔离配置,所有查询都必须先限定 app_code,再叠加运营筛选条件。
|
||||||
db := s.db.Model(&model.HostAgencySalaryPolicy{}).
|
db := s.db.Model(&model.HostAgencySalaryPolicy{}).
|
||||||
Select("admin_host_agency_salary_policies.*").
|
|
||||||
Where("app_code = ?", strings.TrimSpace(options.AppCode))
|
Where("app_code = ?", strings.TrimSpace(options.AppCode))
|
||||||
if options.RegionID > 0 {
|
if options.RegionID > 0 {
|
||||||
// 区域筛选走关联表索引;一份政策可覆盖多区,不能再只检查兼容用的主表 region_id。
|
// 区域筛选走关联表索引;一份政策可覆盖多区,不能再只检查兼容用的主表 region_id。
|
||||||
@ -59,8 +58,9 @@ func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListO
|
|||||||
}
|
}
|
||||||
page, pageSize := normalizePage(options.Page, options.PageSize)
|
page, pageSize := normalizePage(options.Page, options.PageSize)
|
||||||
var items []model.HostAgencySalaryPolicy
|
var items []model.HostAgencySalaryPolicy
|
||||||
// 等级必须随政策一起预加载并按 level_no 排序,前端展示和后续编辑都依赖稳定顺序。
|
// 显式字段选择只能放在 Count 之后;GORM 会把既有 Select 改写为 COUNT(选择项),
|
||||||
err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
|
// 提前写成 table.* 会生成 MySQL 不支持的 COUNT(table.*)。取数阶段仍限定主表字段,避免 JOIN 区域表污染模型映射。
|
||||||
|
err := db.Select("admin_host_agency_salary_policies.*").Preload("Levels", func(tx *gorm.DB) *gorm.DB {
|
||||||
return tx.Order("level_no ASC")
|
return tx.Order("level_no ASC")
|
||||||
}).Order("admin_host_agency_salary_policies.region_id ASC, admin_host_agency_salary_policies.cycle_key DESC, admin_host_agency_salary_policies.policy_version DESC, admin_host_agency_salary_policies.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error
|
}).Order("admin_host_agency_salary_policies.region_id ASC, admin_host_agency_salary_policies.cycle_key DESC, admin_host_agency_salary_policies.policy_version DESC, admin_host_agency_salary_policies.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -3888,7 +3888,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{
|
queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{
|
||||||
Rooms: []*roomv1.RoomListItem{
|
Rooms: []*roomv1.RoomListItem{
|
||||||
{RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true, CountryCode: "US"},
|
{RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true, CountryCode: "US", OwnerVipLevel: 5},
|
||||||
},
|
},
|
||||||
NextCursor: "cursor-2",
|
NextCursor: "cursor-2",
|
||||||
}}
|
}}
|
||||||
@ -3939,7 +3939,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rooms := data["rooms"].([]any)
|
rooms := data["rooms"].([]any)
|
||||||
first := rooms[0].(map[string]any)
|
first := rooms[0].(map[string]any)
|
||||||
if first["im_group_id"] != "room-1" || first["locked"] != true || first["country_code"] != "US" {
|
if first["im_group_id"] != "room-1" || first["locked"] != true || first["country_code"] != "US" || first["owner_vip_level"] != float64(5) {
|
||||||
t.Fatalf("room list must expose room IM group id: %+v", first)
|
t.Fatalf("room list must expose room IM group id: %+v", first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ type roomListItemData struct {
|
|||||||
RoomID string `json:"room_id"`
|
RoomID string `json:"room_id"`
|
||||||
IMGroupID string `json:"im_group_id"`
|
IMGroupID string `json:"im_group_id"`
|
||||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||||
|
OwnerVIPLevel int32 `json:"owner_vip_level"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
@ -738,6 +739,7 @@ func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
|
|||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
IMGroupID: roomIMGroupID(roomID),
|
IMGroupID: roomIMGroupID(roomID),
|
||||||
OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()),
|
OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()),
|
||||||
|
OwnerVIPLevel: room.GetOwnerVipLevel(),
|
||||||
Title: room.GetTitle(),
|
Title: room.GetTitle(),
|
||||||
CoverURL: room.GetCoverUrl(),
|
CoverURL: room.GetCoverUrl(),
|
||||||
Mode: room.GetMode(),
|
Mode: room.GetMode(),
|
||||||
|
|||||||
@ -38,6 +38,8 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
|
|||||||
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '可见区域 ID',
|
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '可见区域 ID',
|
||||||
owner_country_code VARCHAR(3) NOT NULL DEFAULT '' COMMENT '房主国家码',
|
owner_country_code VARCHAR(3) NOT NULL DEFAULT '' COMMENT '房主国家码',
|
||||||
owner_user_id BIGINT NOT NULL COMMENT '房主用户 ID',
|
owner_user_id BIGINT NOT NULL COMMENT '房主用户 ID',
|
||||||
|
owner_vip_level INT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 等级异步投影',
|
||||||
|
owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 到期时间,UTC epoch ms',
|
||||||
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '标题',
|
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '标题',
|
||||||
cover_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面 URL',
|
cover_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面 URL',
|
||||||
mode VARCHAR(64) NOT NULL COMMENT '业务模式',
|
mode VARCHAR(64) NOT NULL COMMENT '业务模式',
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
-- 房主 VIP 只作为房间列表读模型,不进入 Room Cell 核心状态。
|
||||||
|
-- 两列均不参与索引;MySQL 8 INSTANT DDL 只更新数据字典,不扫描或重写 room_list_entries。
|
||||||
|
DROP PROCEDURE IF EXISTS hyapp_migrate_004_room_list_owner_vip_level;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
|
||||||
|
CREATE PROCEDURE hyapp_migrate_004_room_list_owner_vip_level()
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries'
|
||||||
|
) THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'owner_vip_level'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE room_list_entries
|
||||||
|
ADD COLUMN owner_vip_level INT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 等级异步投影' AFTER owner_user_id,
|
||||||
|
ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'room_list_entries' AND column_name = 'owner_vip_expires_at_ms'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE room_list_entries
|
||||||
|
ADD COLUMN owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '房主当前 effective VIP 到期时间,UTC epoch ms' AFTER owner_vip_level,
|
||||||
|
ALGORITHM=INSTANT;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
CALL hyapp_migrate_004_room_list_owner_vip_level()$$
|
||||||
|
DROP PROCEDURE IF EXISTS hyapp_migrate_004_room_list_owner_vip_level$$
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
@ -308,6 +308,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
if cfg.RocketMQ.WalletOutbox.Enabled {
|
if cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
// 兼容 legacy Tag 与 typed Tag 的滚动窗口;独立 group 只消费会改变当前房间装扮的 owner 事实。
|
// 兼容 legacy Tag 与 typed Tag 的滚动窗口;独立 group 只消费会改变当前房间装扮的 owner 事实。
|
||||||
walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(
|
walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(
|
||||||
|
walletmq.EventTypeVIPActivated,
|
||||||
"ResourceChanged",
|
"ResourceChanged",
|
||||||
"ResourceGrantRevoked",
|
"ResourceGrantRevoked",
|
||||||
"UserResourceChanged",
|
"UserResourceChanged",
|
||||||
|
|||||||
@ -126,6 +126,8 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
|||||||
// CreateRoom 不经过 mutateRoom 管线,幂等重试也要补齐恢复读模型,避免旧请求成功但投影缺失。
|
// CreateRoom 不经过 mutateRoom 管线,幂等重试也要补齐恢复读模型,避免旧请求成功但投影缺失。
|
||||||
projectionStartedAt := time.Now()
|
projectionStartedAt := time.Now()
|
||||||
s.projectRoomPresenceBestEffort(ctx, snapshot)
|
s.projectRoomPresenceBestEffort(ctx, snapshot)
|
||||||
|
// 幂等重试也补齐低频 VIP 列表投影,兼容首次创建成功但 wallet 当时短暂不可用的情况。
|
||||||
|
s.refreshRoomOwnerVIPProjectionBestEffort(ctx, cmd.RequestID, cmd.OwnerUserID)
|
||||||
projectionMS = elapsedMS(projectionStartedAt)
|
projectionMS = elapsedMS(projectionStartedAt)
|
||||||
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), false, elapsedMS(startedAt), idempotencyMS, 0, 0, 0, projectionMS, 0, 0, 0, false, false)
|
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), false, elapsedMS(startedAt), idempotencyMS, 0, 0, 0, projectionMS, 0, 0, 0, false, false)
|
||||||
|
|
||||||
@ -256,6 +258,8 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
|||||||
snapshotMS = elapsedMS(snapshotStartedAt)
|
snapshotMS = elapsedMS(snapshotStartedAt)
|
||||||
projectionStartedAt := time.Now()
|
projectionStartedAt := time.Now()
|
||||||
s.projectRoomListBestEffort(ctx, snapshot)
|
s.projectRoomListBestEffort(ctx, snapshot)
|
||||||
|
// 创建房间是低频写路径;在这里固化房主 VIP,列表接口无需新增 wallet RPC 或 SQL join。
|
||||||
|
s.refreshRoomOwnerVIPProjectionBestEffort(ctx, cmd.RequestID, cmd.OwnerUserID)
|
||||||
// owner 创建房间后已经是在线成员,必须立即投影到用户当前房间读模型。
|
// owner 创建房间后已经是在线成员,必须立即投影到用户当前房间读模型。
|
||||||
s.projectRoomPresenceBestEffort(ctx, snapshot)
|
s.projectRoomPresenceBestEffort(ctx, snapshot)
|
||||||
projectionMS = elapsedMS(projectionStartedAt)
|
projectionMS = elapsedMS(projectionStartedAt)
|
||||||
|
|||||||
@ -356,6 +356,49 @@ func (s *Service) projectRoomListCacheBestEffort(ctx context.Context, entry Room
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateRoomOwnerVIPProjection 更新 MySQL 权威列表投影后复用同一张卡片刷新 Redis。
|
||||||
|
// wallet 查询发生在创建、房主进房或 MQ 消费阶段;房间列表读取路径永远只读本地投影。
|
||||||
|
func (s *Service) updateRoomOwnerVIPProjection(ctx context.Context, ownerUserID int64, vip effectiveVIPAccess) error {
|
||||||
|
store, ok := s.repository.(RoomOwnerVIPProjectionStore)
|
||||||
|
if !ok || ownerUserID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entry, exists, err := store.UpdateRoomOwnerVIP(ctx, ownerUserID, vip.level, vip.expiresAtMS)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.projectRoomListCacheBestEffort(ctx, entry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectRoomOwnerVIPBestEffort 用于已经成功的房间命令;展示投影失败不能回滚 Room Cell 状态。
|
||||||
|
func (s *Service) projectRoomOwnerVIPBestEffort(ctx context.Context, ownerUserID int64, vip effectiveVIPAccess) {
|
||||||
|
if err := s.updateRoomOwnerVIPProjection(ctx, ownerUserID, vip); err != nil {
|
||||||
|
logx.Warn(ctx, "room_owner_vip_project_failed",
|
||||||
|
slog.Int64("owner_user_id", ownerUserID),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshRoomOwnerVIPProjection 只在低频触发点回查 wallet,并把 paid/trial 合并算法留在 wallet owner。
|
||||||
|
func (s *Service) refreshRoomOwnerVIPProjection(ctx context.Context, requestID string, ownerUserID int64) error {
|
||||||
|
vip, err := s.loadEffectiveVIPAccess(ctx, requestID, ownerUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.updateRoomOwnerVIPProjection(ctx, ownerUserID, vip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) refreshRoomOwnerVIPProjectionBestEffort(ctx context.Context, requestID string, ownerUserID int64) {
|
||||||
|
if err := s.refreshRoomOwnerVIPProjection(ctx, requestID, ownerUserID); err != nil {
|
||||||
|
logx.Warn(ctx, "room_owner_vip_refresh_failed",
|
||||||
|
slog.Int64("owner_user_id", ownerUserID),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) deleteRoomListCacheBestEffort(ctx context.Context, roomID string) {
|
func (s *Service) deleteRoomListCacheBestEffort(ctx context.Context, roomID string) {
|
||||||
if s == nil || s.roomListCache == nil {
|
if s == nil || s.roomListCache == nil {
|
||||||
return
|
return
|
||||||
@ -591,6 +634,11 @@ func encodeRoomListCursor(tab string, query string, countryCode string, viewerCo
|
|||||||
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
|
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
|
||||||
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
|
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
|
||||||
nowMS := time.Now().UTC().UnixMilli()
|
nowMS := time.Now().UTC().UnixMilli()
|
||||||
|
ownerVIPLevel := entry.OwnerVIPLevel
|
||||||
|
if ownerVIPLevel <= 0 || entry.OwnerVIPExpiresAtMS <= nowMS {
|
||||||
|
// 到期判断留在服务端,避免自然到期后仍依赖 wallet 事件才能从列表撤下 VIP 展示。
|
||||||
|
ownerVIPLevel = 0
|
||||||
|
}
|
||||||
return &roomv1.RoomListItem{
|
return &roomv1.RoomListItem{
|
||||||
AppCode: entry.AppCode,
|
AppCode: entry.AppCode,
|
||||||
RoomId: entry.RoomID,
|
RoomId: entry.RoomID,
|
||||||
@ -609,6 +657,7 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
|
|||||||
CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode),
|
CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode),
|
||||||
RoomBorder: unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS),
|
RoomBorder: unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS),
|
||||||
RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS),
|
RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS),
|
||||||
|
OwnerVipLevel: ownerVIPLevel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,8 @@ func TestListRoomsUsesCacheWhenEligible(t *testing.T) {
|
|||||||
RoomShortID: "cache-hit",
|
RoomShortID: "cache-hit",
|
||||||
VisibleRegionID: 7001,
|
VisibleRegionID: 7001,
|
||||||
OwnerUserID: 6001,
|
OwnerUserID: 6001,
|
||||||
|
OwnerVIPLevel: 5,
|
||||||
|
OwnerVIPExpiresAtMS: time.Now().UTC().Add(time.Hour).UnixMilli(),
|
||||||
Title: "cache",
|
Title: "cache",
|
||||||
Mode: "voice",
|
Mode: "voice",
|
||||||
Status: "active",
|
Status: "active",
|
||||||
@ -51,6 +53,9 @@ func TestListRoomsUsesCacheWhenEligible(t *testing.T) {
|
|||||||
if got := roomIDs(resp.GetRooms()); len(got) != 1 || got[0] != "room-cache-hit" {
|
if got := roomIDs(resp.GetRooms()); len(got) != 1 || got[0] != "room-cache-hit" {
|
||||||
t.Fatalf("list response must come from cache, got=%v", got)
|
t.Fatalf("list response must come from cache, got=%v", got)
|
||||||
}
|
}
|
||||||
|
if got := resp.GetRooms()[0].GetOwnerVipLevel(); got != 5 {
|
||||||
|
t.Fatalf("list response must expose the cached owner VIP without a wallet lookup, got=%d", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListRoomsFallsBackToMySQLWhenCacheMisses(t *testing.T) {
|
func TestListRoomsFallsBackToMySQLWhenCacheMisses(t *testing.T) {
|
||||||
|
|||||||
@ -166,6 +166,10 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if result.snapshot.GetOwnerUserId() == cmd.ActorUserID() {
|
||||||
|
// 复用本次进房已经取得的 effective VIP;房主重连可修复历史列表投影且不增加任何查询。
|
||||||
|
s.projectRoomOwnerVIPBestEffort(ctx, cmd.ActorUserID(), joinVIP)
|
||||||
|
}
|
||||||
s.projectRoomVisitBestEffort(ctx, cmd.ActorUserID(), cmd.RoomID())
|
s.projectRoomVisitBestEffort(ctx, cmd.ActorUserID(), cmd.RoomID())
|
||||||
|
|
||||||
return &roomv1.JoinRoomResponse{
|
return &roomv1.JoinRoomResponse{
|
||||||
|
|||||||
@ -279,6 +279,12 @@ type RoomListEntry struct {
|
|||||||
OwnerCountryCode string
|
OwnerCountryCode string
|
||||||
// OwnerUserID 是房间所有者,用于卡片展示和运营排查。
|
// OwnerUserID 是房间所有者,用于卡片展示和运营排查。
|
||||||
OwnerUserID int64
|
OwnerUserID int64
|
||||||
|
// OwnerVIPLevel/OwnerVIPExpiresAtMS 是 wallet effective VIP 的异步读模型。
|
||||||
|
// 过期边界只在服务端判断,客户端只收到仍有效的 owner_vip_level。
|
||||||
|
OwnerVIPLevel int32
|
||||||
|
OwnerVIPExpiresAtMS int64
|
||||||
|
// OwnerVIPProjectionKnown 区分“投影明确为 0”和“Room Cell 快照没有携带该外部事实”;它不写入 Redis payload。
|
||||||
|
OwnerVIPProjectionKnown bool `json:"-"`
|
||||||
// Title 是卡片标题;空值由客户端或后续资料聚合兜底。
|
// Title 是卡片标题;空值由客户端或后续资料聚合兜底。
|
||||||
Title string
|
Title string
|
||||||
// CoverURL 是卡片封面;空值表示没有单独封面。
|
// CoverURL 是卡片封面;空值表示没有单独封面。
|
||||||
@ -849,6 +855,12 @@ type RoomOwnerProjectionStore interface {
|
|||||||
RenameOwnerCountryCode(ctx context.Context, oldCountryCode string, newCountryCode string, nowMS int64) (OwnerCountryCodeRenameResult, error)
|
RenameOwnerCountryCode(ctx context.Context, oldCountryCode string, newCountryCode string, nowMS int64) (OwnerCountryCodeRenameResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomOwnerVIPProjectionStore 把 wallet 的低频 VIP 事实写入列表读模型。
|
||||||
|
// 该能力保持为可选窄接口,避免列表查询为了展示字段同步调用 wallet 或扩散到纯内存测试仓储。
|
||||||
|
type RoomOwnerVIPProjectionStore interface {
|
||||||
|
UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64) (RoomListEntry, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
// RoomListCacheStore 是公共发现页 Redis 展示读模型边界。
|
// RoomListCacheStore 是公共发现页 Redis 展示读模型边界。
|
||||||
//
|
//
|
||||||
// MySQL 仍然是房间列表和置顶事实 owner;缓存只服务高频 hot/new 发现页,
|
// MySQL 仍然是房间列表和置顶事实 owner;缓存只服务高频 hot/new 发现页,
|
||||||
|
|||||||
@ -181,6 +181,20 @@ func (s *RedisRoomListCacheStore) UpsertRoomListEntry(ctx context.Context, entry
|
|||||||
}
|
}
|
||||||
entry.AppCode = app
|
entry.AppCode = app
|
||||||
entry.OwnerCountryCode = normalizeRoomCountryCode(entry.OwnerCountryCode)
|
entry.OwnerCountryCode = normalizeRoomCountryCode(entry.OwnerCountryCode)
|
||||||
|
if !entry.OwnerVIPProjectionKnown {
|
||||||
|
// Room Cell 的普通状态刷新不携带 wallet 外部事实;保留现有卡片 VIP,避免送礼/上下麦后把等级误清零。
|
||||||
|
raw, err := s.client.HGet(ctx, roomListCacheCardKey(app), roomID).Result()
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
var previous RoomListEntry
|
||||||
|
if json.Unmarshal([]byte(raw), &previous) == nil {
|
||||||
|
entry.OwnerVIPLevel = previous.OwnerVIPLevel
|
||||||
|
entry.OwnerVIPExpiresAtMS = previous.OwnerVIPExpiresAtMS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
payload, err := json.Marshal(entry)
|
payload, err := json.Marshal(entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -36,6 +36,7 @@ type effectiveVIPAccess struct {
|
|||||||
programType string
|
programType string
|
||||||
level int32
|
level int32
|
||||||
name string
|
name string
|
||||||
|
expiresAtMS int64
|
||||||
benefits map[string]struct{}
|
benefits map[string]struct{}
|
||||||
roomEntryNoticeSettingEnabled bool
|
roomEntryNoticeSettingEnabled bool
|
||||||
}
|
}
|
||||||
@ -98,6 +99,7 @@ func (s *Service) loadEffectiveVIPAccess(ctx context.Context, requestID string,
|
|||||||
if effective := state.GetEffectiveVip(); effective != nil && effective.GetActive() {
|
if effective := state.GetEffectiveVip(); effective != nil && effective.GetActive() {
|
||||||
access.level = effective.GetLevel()
|
access.level = effective.GetLevel()
|
||||||
access.name = strings.TrimSpace(effective.GetName())
|
access.name = strings.TrimSpace(effective.GetName())
|
||||||
|
access.expiresAtMS = effective.GetExpiresAtMs()
|
||||||
}
|
}
|
||||||
for _, benefit := range state.GetEffectiveBenefits() {
|
for _, benefit := range state.GetEffectiveBenefits() {
|
||||||
if benefit == nil {
|
if benefit == nil {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
roomservice "hyapp/services/room-service/internal/room/service"
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||||
@ -17,6 +18,72 @@ import (
|
|||||||
|
|
||||||
const vipTestAppCode = "fami"
|
const vipTestAppCode = "fami"
|
||||||
|
|
||||||
|
func TestCreateRoomProjectsOwnerVIPWithoutListWalletLookup(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
ownerID := int64(8191)
|
||||||
|
vipResponse := tieredVIPResponse(ownerID, 5, "VIP5", "colored_room_name")
|
||||||
|
vipResponse.GetState().GetEffectiveVip().ExpiresAtMs = time.Now().UTC().Add(time.Hour).UnixMilli()
|
||||||
|
wallet := &rocketTestWallet{vipResponses: map[int64]*walletv1.GetMyVipResponse{ownerID: vipResponse}}
|
||||||
|
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 20, 8, 0, 0, 0, time.UTC)}
|
||||||
|
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher())
|
||||||
|
|
||||||
|
roomID := "fami-owner-vip-list-room"
|
||||||
|
createVIPRoom(t, ctx, svc, roomID, ownerID)
|
||||||
|
vipCallsAfterCreate := len(wallet.vipRequests)
|
||||||
|
response, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: vipTestAppCode}, ViewerUserId: ownerID, VisibleRegionId: 1001, Tab: "new", Limit: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list owner VIP room failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(response.GetRooms()) != 1 || response.GetRooms()[0].GetOwnerVipLevel() != 5 {
|
||||||
|
t.Fatalf("room list must return projected owner VIP5: %+v", response.GetRooms())
|
||||||
|
}
|
||||||
|
if len(wallet.vipRequests) != vipCallsAfterCreate {
|
||||||
|
t.Fatalf("room list must not query wallet: before=%d after=%d requests=%+v", vipCallsAfterCreate, len(wallet.vipRequests), wallet.vipRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVIPActivatedEventRefreshesOwnerVIPProjectionOnce(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
ownerID := int64(8192)
|
||||||
|
wallet := &rocketTestWallet{vipResponses: map[int64]*walletv1.GetMyVipResponse{
|
||||||
|
ownerID: tieredVIPResponse(ownerID, 0, ""),
|
||||||
|
}}
|
||||||
|
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 20, 8, 0, 0, 0, time.UTC)}
|
||||||
|
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher())
|
||||||
|
createVIPRoom(t, ctx, svc, "fami-owner-vip-event-room", ownerID)
|
||||||
|
|
||||||
|
activated := tieredVIPResponse(ownerID, 7, "VIP7", "colored_room_name")
|
||||||
|
activated.GetState().GetEffectiveVip().ExpiresAtMs = time.Now().UTC().Add(time.Hour).UnixMilli()
|
||||||
|
wallet.vipResponses[ownerID] = activated
|
||||||
|
event := walletmq.WalletOutboxMessage{
|
||||||
|
AppCode: vipTestAppCode, EventID: "vip-activated-owner-projection", EventType: walletmq.EventTypeVIPActivated, UserID: ownerID,
|
||||||
|
}
|
||||||
|
if err := svc.HandleWalletDecorationEvent(ctx, event); err != nil {
|
||||||
|
t.Fatalf("refresh owner VIP from activation event: %v", err)
|
||||||
|
}
|
||||||
|
vipCallsAfterFirstEvent := len(wallet.vipRequests)
|
||||||
|
if err := svc.HandleWalletDecorationEvent(ctx, event); err != nil {
|
||||||
|
t.Fatalf("repeat activation event: %v", err)
|
||||||
|
}
|
||||||
|
if len(wallet.vipRequests) != vipCallsAfterFirstEvent {
|
||||||
|
t.Fatalf("consumed activation event must not query wallet twice: before=%d after=%d", vipCallsAfterFirstEvent, len(wallet.vipRequests))
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: vipTestAppCode}, ViewerUserId: ownerID, VisibleRegionId: 1001, Tab: "new", Limit: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list room after activation event: %v", err)
|
||||||
|
}
|
||||||
|
if len(response.GetRooms()) != 1 || response.GetRooms()[0].GetOwnerVipLevel() != 7 {
|
||||||
|
t.Fatalf("activation event must project owner VIP7 into room list: %+v", response.GetRooms())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJoinRoomCarriesEffectiveVIPAndQueriesWalletOnce(t *testing.T) {
|
func TestJoinRoomCarriesEffectiveVIPAndQueriesWalletOnce(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -30,11 +30,12 @@ const (
|
|||||||
roomDecorationReconcileBatchSize = 100
|
roomDecorationReconcileBatchSize = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleWalletDecorationEvent 消费 wallet owner 已提交的权限/资源变化,并通过 Room Cell 命令即时更新装扮。
|
// HandleWalletDecorationEvent 消费 wallet owner 已提交的 VIP/资源变化:VIP 等级只更新列表读模型,装扮仍通过 Room Cell 命令收敛。
|
||||||
// 专用 assignment 表提供反向索引;每个房间的 command log 和全局 consumption 表共同承担 at-least-once 幂等。
|
// 专用 assignment 表提供反向索引;每个房间的 command log 和全局 consumption 表共同承担 at-least-once 幂等。
|
||||||
func (s *Service) HandleWalletDecorationEvent(ctx context.Context, message walletmq.WalletOutboxMessage) error {
|
func (s *Service) HandleWalletDecorationEvent(ctx context.Context, message walletmq.WalletOutboxMessage) error {
|
||||||
queries, handled, err := walletDecorationAssignmentQueries(message)
|
queries, decorationHandled, err := walletDecorationAssignmentQueries(message)
|
||||||
if err != nil || !handled {
|
vipProjectionHandled := strings.TrimSpace(message.EventType) == walletmq.EventTypeVIPActivated || strings.TrimSpace(message.EventType) == walletEventVipEffectiveChanged
|
||||||
|
if err != nil || (!decorationHandled && !vipProjectionHandled) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
store, ok := s.repository.(RoomDecorationReconciliationStore)
|
store, ok := s.repository.(RoomDecorationReconciliationStore)
|
||||||
@ -46,7 +47,17 @@ func (s *Service) HandleWalletDecorationEvent(ctx context.Context, message walle
|
|||||||
if err != nil || consumed {
|
if err != nil || consumed {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if vipProjectionHandled {
|
||||||
|
if message.UserID <= 0 {
|
||||||
|
return fmt.Errorf("%s has no user_id", message.EventType)
|
||||||
|
}
|
||||||
|
// 事件 payload 在体验卡卸下时不承诺携带回落等级;重新读取 wallet effective state 才能避免复制合并算法。
|
||||||
|
if err := s.refreshRoomOwnerVIPProjection(ctx, message.EventID, message.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if decorationHandled {
|
||||||
assignments, err := listWalletEventDecorationAssignments(ctx, store, queries)
|
assignments, err := listWalletEventDecorationAssignments(ctx, store, queries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -56,6 +67,7 @@ func (s *Service) HandleWalletDecorationEvent(ctx context.Context, message walle
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return store.MarkWalletDecorationEventConsumed(ctx, message.EventID, message.EventType, s.clock.Now().UnixMilli())
|
return store.MarkWalletDecorationEventConsumed(ctx, message.EventID, message.EventType, s.clock.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -117,7 +117,8 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser
|
|||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
||||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
||||||
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), '')
|
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
|
||||||
|
owner_vip_level, owner_vip_expires_at_ms
|
||||||
FROM room_list_entries
|
FROM room_list_entries
|
||||||
WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
|
WHERE app_code = ? AND room_id IN (`+placeholders+`)`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -138,6 +139,45 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateRoomOwnerVIP 把 wallet 已求值的 effective VIP 写入房主唯一房间的列表投影。
|
||||||
|
// 写入和随后点查都命中 idx_room_list_owner;高频列表查询只读取同一行,不增加 join、子查询或跨服务 RPC。
|
||||||
|
func (r *Repository) UpdateRoomOwnerVIP(ctx context.Context, ownerUserID int64, level int32, expiresAtMS int64) (roomservice.RoomListEntry, bool, error) {
|
||||||
|
if ownerUserID <= 0 {
|
||||||
|
return roomservice.RoomListEntry{}, false, nil
|
||||||
|
}
|
||||||
|
if level <= 0 || expiresAtMS <= time.Now().UTC().UnixMilli() {
|
||||||
|
level = 0
|
||||||
|
expiresAtMS = 0
|
||||||
|
}
|
||||||
|
appCode := normalizedRecordAppCode(ctx, "")
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE room_list_entries
|
||||||
|
SET owner_vip_level = ?, owner_vip_expires_at_ms = ?
|
||||||
|
WHERE app_code = ? AND owner_user_id = ?`, level, expiresAtMS, appCode, ownerUserID); err != nil {
|
||||||
|
return roomservice.RoomListEntry{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RowsAffected=0 同时表示“不存在”和“值未变化”,因此必须按同一 owner 索引点查后再决定是否刷新 Redis 卡片。
|
||||||
|
rows, err := r.db.QueryContext(ctx, roomListSelectColumns+`
|
||||||
|
WHERE app_code = ? AND owner_user_id = ?
|
||||||
|
LIMIT 1`, appCode, ownerUserID)
|
||||||
|
if err != nil {
|
||||||
|
return roomservice.RoomListEntry{}, false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return roomservice.RoomListEntry{}, false, err
|
||||||
|
}
|
||||||
|
return roomservice.RoomListEntry{}, false, nil
|
||||||
|
}
|
||||||
|
entry, err := scanRoomListEntry(rows)
|
||||||
|
if err != nil {
|
||||||
|
return roomservice.RoomListEntry{}, false, err
|
||||||
|
}
|
||||||
|
return entry, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListRoomListCacheEntries 按 app_code/room_id 主键批量扫描 active 列表投影,供 Redis 展示读模型重建。
|
// ListRoomListCacheEntries 按 app_code/room_id 主键批量扫描 active 列表投影,供 Redis 展示读模型重建。
|
||||||
func (r *Repository) ListRoomListCacheEntries(ctx context.Context, query roomservice.RoomListCacheEntryScanQuery) ([]roomservice.RoomListEntry, error) {
|
func (r *Repository) ListRoomListCacheEntries(ctx context.Context, query roomservice.RoomListCacheEntryScanQuery) ([]roomservice.RoomListEntry, error) {
|
||||||
if query.Limit <= 0 {
|
if query.Limit <= 0 {
|
||||||
@ -233,9 +273,14 @@ func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservic
|
|||||||
&entry.CreatedAtMS,
|
&entry.CreatedAtMS,
|
||||||
&entry.UpdatedAtMS,
|
&entry.UpdatedAtMS,
|
||||||
}
|
}
|
||||||
if scannerColumnCount(scanner) >= len(dest)+2 {
|
columnCount := scannerColumnCount(scanner)
|
||||||
|
if columnCount >= len(dest)+2 {
|
||||||
dest = append(dest, &entry.RoomBorderJSON, &entry.RoomNameStyleJSON)
|
dest = append(dest, &entry.RoomBorderJSON, &entry.RoomNameStyleJSON)
|
||||||
}
|
}
|
||||||
|
if columnCount >= len(dest)+2 {
|
||||||
|
dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS)
|
||||||
|
entry.OwnerVIPProjectionKnown = true
|
||||||
|
}
|
||||||
err := scanner.Scan(dest...)
|
err := scanner.Scan(dest...)
|
||||||
return entry, err
|
return entry, err
|
||||||
}
|
}
|
||||||
@ -249,9 +294,14 @@ func scanRoomListEntryWithPins(scanner interface{ Scan(dest ...any) error }) (ro
|
|||||||
&entry.Heat, &entry.OnlineCount, &entry.SeatCount, &entry.OccupiedSeatCount, &entry.SortScore,
|
&entry.Heat, &entry.OnlineCount, &entry.SeatCount, &entry.OccupiedSeatCount, &entry.SortScore,
|
||||||
&entry.CreatedAtMS, &entry.UpdatedAtMS,
|
&entry.CreatedAtMS, &entry.UpdatedAtMS,
|
||||||
}
|
}
|
||||||
if scannerColumnCount(scanner) >= len(dest)+2+4 {
|
columnCount := scannerColumnCount(scanner)
|
||||||
|
if columnCount >= len(dest)+2+4 {
|
||||||
dest = append(dest, &entry.RoomBorderJSON, &entry.RoomNameStyleJSON)
|
dest = append(dest, &entry.RoomBorderJSON, &entry.RoomNameStyleJSON)
|
||||||
}
|
}
|
||||||
|
if columnCount >= len(dest)+2+4 {
|
||||||
|
dest = append(dest, &entry.OwnerVIPLevel, &entry.OwnerVIPExpiresAtMS)
|
||||||
|
entry.OwnerVIPProjectionKnown = true
|
||||||
|
}
|
||||||
dest = append(dest, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS)
|
dest = append(dest, &pinned, &entry.PinListRank, &entry.PinWeight, &entry.PinnedUntilMS)
|
||||||
err := scanner.Scan(dest...)
|
err := scanner.Scan(dest...)
|
||||||
return entry, pinned, err
|
return entry, pinned, err
|
||||||
@ -495,7 +545,8 @@ func (r *Repository) ListRoomRelatedFeedEntries(ctx context.Context, query rooms
|
|||||||
const roomListSelectColumns = `
|
const roomListSelectColumns = `
|
||||||
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
||||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
||||||
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), '')
|
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
|
||||||
|
owner_vip_level, owner_vip_expires_at_ms
|
||||||
FROM room_list_entries`
|
FROM room_list_entries`
|
||||||
|
|
||||||
// owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。
|
// owner 定向列表复用公共列表扫描器,但不参与公共发现页置顶排序;这里补零值列保持 scan 契约稳定。
|
||||||
@ -503,20 +554,23 @@ const roomListOwnerSelectColumns = `
|
|||||||
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
SELECT app_code, room_id, room_short_id, visible_region_id, owner_country_code, owner_user_id, title, cover_url, mode, status, locked,
|
||||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms,
|
||||||
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
|
COALESCE(CAST(room_border_json AS CHAR), ''), COALESCE(CAST(room_name_style_json AS CHAR), ''),
|
||||||
|
owner_vip_level, owner_vip_expires_at_ms,
|
||||||
0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_ms
|
0 AS is_pinned, 0 AS pin_list_rank, 0 AS pin_weight, 0 AS pinned_until_ms
|
||||||
FROM room_list_entries`
|
FROM room_list_entries`
|
||||||
|
|
||||||
const roomUserFeedSelectColumns = `
|
const roomUserFeedSelectColumns = `
|
||||||
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
||||||
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms,
|
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms,
|
||||||
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), '')
|
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
|
||||||
|
r.owner_vip_level, r.owner_vip_expires_at_ms
|
||||||
FROM room_user_feed_entries f
|
FROM room_user_feed_entries f
|
||||||
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
|
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
|
||||||
|
|
||||||
const roomFollowSelectColumns = `
|
const roomFollowSelectColumns = `
|
||||||
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
||||||
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms,
|
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.followed_at_ms,
|
||||||
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), '')
|
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
|
||||||
|
r.owner_vip_level, r.owner_vip_expires_at_ms
|
||||||
FROM room_follows f
|
FROM room_follows f
|
||||||
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
|
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
|
||||||
|
|
||||||
@ -559,6 +613,7 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
|
|||||||
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked,
|
||||||
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms,
|
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms,
|
||||||
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
|
COALESCE(CAST(r.room_border_json AS CHAR), ''), COALESCE(CAST(r.room_name_style_json AS CHAR), ''),
|
||||||
|
r.owner_vip_level, r.owner_vip_expires_at_ms,
|
||||||
` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms
|
` + pinnedExpr + ` AS is_pinned, ` + pinRankExpr + ` AS pin_list_rank, ` + pinWeightExpr + ` AS pin_weight, ` + pinExpiresExpr + ` AS pinned_until_ms
|
||||||
FROM room_list_entries r
|
FROM room_list_entries r
|
||||||
LEFT JOIN room_region_pins gp
|
LEFT JOIN room_region_pins gp
|
||||||
|
|||||||
@ -4,12 +4,51 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
roomservice "hyapp/services/room-service/internal/room/service"
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestUpdateRoomOwnerVIPUsesOwnerIndexProjection(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
repository := &Repository{db: db}
|
||||||
|
expiresAtMS := time.Now().UTC().Add(time.Hour).UnixMilli()
|
||||||
|
mock.ExpectExec(`(?s)UPDATE room_list_entries.*SET owner_vip_level = \?, owner_vip_expires_at_ms = \?.*WHERE app_code = \? AND owner_user_id = \?`).
|
||||||
|
WithArgs(int32(5), expiresAtMS, appcode.Default, int64(6201)).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
rows := sqlmock.NewRows([]string{
|
||||||
|
"app_code", "room_id", "room_short_id", "visible_region_id", "owner_country_code", "owner_user_id",
|
||||||
|
"title", "cover_url", "mode", "status", "locked", "heat", "online_count", "seat_count",
|
||||||
|
"occupied_seat_count", "sort_score", "created_at_ms", "updated_at_ms", "room_border_json", "room_name_style_json",
|
||||||
|
"owner_vip_level", "owner_vip_expires_at_ms",
|
||||||
|
}).AddRow(
|
||||||
|
appcode.Default, "room-owner-vip", "6201", int64(7101), "US", int64(6201),
|
||||||
|
"VIP room", "", "voice", "active", false, int64(0), int64(1), int64(8),
|
||||||
|
int64(1), int64(99), int64(1700000000000), int64(1700000001000), "", "", int64(5), expiresAtMS,
|
||||||
|
)
|
||||||
|
mock.ExpectQuery(`(?s)SELECT .*owner_vip_level, owner_vip_expires_at_ms.*FROM room_list_entries.*WHERE app_code = \? AND owner_user_id = \?.*LIMIT 1`).
|
||||||
|
WithArgs(appcode.Default, int64(6201)).
|
||||||
|
WillReturnRows(rows)
|
||||||
|
|
||||||
|
entry, exists, err := repository.UpdateRoomOwnerVIP(context.Background(), 6201, 5, expiresAtMS)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update room owner VIP: %v", err)
|
||||||
|
}
|
||||||
|
if !exists || entry.OwnerVIPLevel != 5 || entry.OwnerVIPExpiresAtMS != expiresAtMS || !entry.OwnerVIPProjectionKnown {
|
||||||
|
t.Fatalf("unexpected owner VIP projection: exists=%v entry=%+v", exists, entry)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListRoomListEntriesByOwnersScansPinPlaceholders(t *testing.T) {
|
func TestListRoomListEntriesByOwnersScansPinPlaceholders(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -35,6 +35,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
owner_country_code VARCHAR(3) NOT NULL DEFAULT '',
|
owner_country_code VARCHAR(3) NOT NULL DEFAULT '',
|
||||||
owner_user_id BIGINT NOT NULL,
|
owner_user_id BIGINT NOT NULL,
|
||||||
|
owner_vip_level INT NOT NULL DEFAULT 0,
|
||||||
|
owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
title VARCHAR(128) NOT NULL DEFAULT '',
|
title VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
cover_url VARCHAR(512) NOT NULL DEFAULT '',
|
cover_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
mode VARCHAR(64) NOT NULL,
|
mode VARCHAR(64) NOT NULL,
|
||||||
@ -508,6 +510,9 @@ func (r *Repository) ensureRoomVIPMediaSchema(ctx context.Context) error {
|
|||||||
for _, column := range []struct{ name, ddl string }{
|
for _, column := range []struct{ name, ddl string }{
|
||||||
{"room_border_json", "ALTER TABLE room_list_entries ADD COLUMN room_border_json JSON NULL"},
|
{"room_border_json", "ALTER TABLE room_list_entries ADD COLUMN room_border_json JSON NULL"},
|
||||||
{"room_name_style_json", "ALTER TABLE room_list_entries ADD COLUMN room_name_style_json JSON NULL"},
|
{"room_name_style_json", "ALTER TABLE room_list_entries ADD COLUMN room_name_style_json JSON NULL"},
|
||||||
|
// 两列不参与索引和排序,MySQL 8 使用 INSTANT 只改数据字典,不重写高频房间列表表。
|
||||||
|
{"owner_vip_level", "ALTER TABLE room_list_entries ADD COLUMN owner_vip_level INT NOT NULL DEFAULT 0, ALGORITHM=INSTANT"},
|
||||||
|
{"owner_vip_expires_at_ms", "ALTER TABLE room_list_entries ADD COLUMN owner_vip_expires_at_ms BIGINT NOT NULL DEFAULT 0, ALGORITHM=INSTANT"},
|
||||||
} {
|
} {
|
||||||
exists, err := r.columnExists(ctx, "room_list_entries", column.name)
|
exists, err := r.columnExists(ctx, "room_list_entries", column.name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -314,6 +314,20 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
|
|||||||
} else if ok {
|
} else if ok {
|
||||||
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency")
|
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency")
|
||||||
}
|
}
|
||||||
|
// 后台直建 Agency 是对角色归属的最终确认;目标先前收到的 Agency 邀请已经不可再接受。
|
||||||
|
// 必须在同一事务关闭 pending 消息,否则消息中心会长期展示一个点击后必然冲突的旧邀请。
|
||||||
|
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeAgency, command.OwnerUserID, true); err != nil {
|
||||||
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
|
} else if ok {
|
||||||
|
pending.Status = hostdomain.InvitationStatusCancelled
|
||||||
|
pending.ProcessedByUserID = command.AdminUserID
|
||||||
|
pending.ProcessReason = "superseded by admin agency creation"
|
||||||
|
pending.ProcessedAtMs = command.NowMs
|
||||||
|
pending.UpdatedAtMs = command.NowMs
|
||||||
|
if err := updateRoleInvitationProcessed(ctx, tx, pending); err != nil {
|
||||||
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, command.OwnerUserID, true)
|
activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, command.OwnerUserID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
|
|||||||
@ -54,6 +54,28 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
|
|||||||
} else if ok {
|
} else if ok {
|
||||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already owns active agency")
|
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already owns active agency")
|
||||||
}
|
}
|
||||||
|
// H5 在首个响应丢失、弹窗重新打开等情况下可能为同一目标生成新的 command_id。
|
||||||
|
// 同一 BD 的 pending 邀请属于同一业务动作,记录新命令到既有结果并按成功回放;其他 BD 仍不能覆盖目标已收到的邀请。
|
||||||
|
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeAgency, command.TargetUserID, true); err != nil {
|
||||||
|
return hostdomain.RoleInvitation{}, err
|
||||||
|
} else if ok {
|
||||||
|
if pending.InviterBDUserID != inviter.UserID {
|
||||||
|
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has pending agency invitation")
|
||||||
|
}
|
||||||
|
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
|
||||||
|
CommandID: command.CommandID,
|
||||||
|
CommandType: hostdomain.CommandTypeInviteAgency,
|
||||||
|
ResultType: hostdomain.ResultTypeInvitation,
|
||||||
|
ResultID: pending.InvitationID,
|
||||||
|
CreatedAtMs: command.NowMs,
|
||||||
|
}); err != nil {
|
||||||
|
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return hostdomain.RoleInvitation{}, err
|
||||||
|
}
|
||||||
|
return pending, nil
|
||||||
|
}
|
||||||
|
|
||||||
// BD 发出的 Agency 邀请要记录直接上级 BD 和负责人链路;
|
// BD 发出的 Agency 邀请要记录直接上级 BD 和负责人链路;
|
||||||
// 如果本次以负责人身份邀请,则直接 BD 和负责人都指向 Leader 自己。
|
// 如果本次以负责人身份邀请,则直接 BD 和负责人都指向 Leader 自己。
|
||||||
|
|||||||
@ -602,6 +602,23 @@ func (r *Repository) ListRoleInvitations(ctx context.Context, command hostservic
|
|||||||
return queryRoleInvitations(ctx, r.db, query, args...)
|
return queryRoleInvitations(ctx, r.db, query, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryPendingRoleInvitationByTarget 通过 pending_target_key 唯一索引读取目标当前邀请。
|
||||||
|
// 邀请创建事务传入 forUpdate=true,把“检查 pending + 创建/回放邀请”串行化,避免连续点击用不同 command_id 撞成泛化 409。
|
||||||
|
func queryPendingRoleInvitationByTarget(ctx context.Context, q sqlQueryer, invitationType string, targetUserID int64, forUpdate bool) (hostdomain.RoleInvitation, bool, error) {
|
||||||
|
query := fmt.Sprintf(`SELECT %s FROM role_invitations WHERE app_code = ? AND pending_target_key = ?`, roleInvitationColumns)
|
||||||
|
if forUpdate {
|
||||||
|
query += " FOR UPDATE"
|
||||||
|
}
|
||||||
|
invitation, err := scanRoleInvitation(q.QueryRowContext(ctx, query, appcode.FromContext(ctx), fmt.Sprintf("%s:%d", invitationType, targetUserID)))
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return hostdomain.RoleInvitation{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.RoleInvitation{}, false, err
|
||||||
|
}
|
||||||
|
return invitation, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListAgencyMembers 读取 Agency 成员关系列表。
|
// ListAgencyMembers 读取 Agency 成员关系列表。
|
||||||
func (r *Repository) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) {
|
func (r *Repository) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) {
|
||||||
query := fmt.Sprintf(`SELECT %s FROM agency_memberships WHERE app_code = ? AND agency_id = ?`, agencyMembershipColumns)
|
query := fmt.Sprintf(`SELECT %s FROM agency_memberships WHERE app_code = ? AND agency_id = ?`, agencyMembershipColumns)
|
||||||
|
|||||||
@ -9,6 +9,9 @@ const (
|
|||||||
TypeBadge = "badge"
|
TypeBadge = "badge"
|
||||||
TypeFloatingScreen = "floating_screen"
|
TypeFloatingScreen = "floating_screen"
|
||||||
TypeGift = "gift"
|
TypeGift = "gift"
|
||||||
|
// TypeGiftTraySkin 复用 VIP 权益的稳定编码;目录素材与 gift 礼物本体分开,
|
||||||
|
// 避免客户端把送礼托盘皮肤误当成可发送、可定价的礼物。
|
||||||
|
TypeGiftTraySkin = "gift_tray_skin"
|
||||||
TypeMicSeatIcon = "mic_seat_icon"
|
TypeMicSeatIcon = "mic_seat_icon"
|
||||||
TypeMicSeatAnimation = "mic_seat_animation"
|
TypeMicSeatAnimation = "mic_seat_animation"
|
||||||
// TypeRoomBorder 与 TypeRoomNameStyle 是房间级装扮目录类型;应用动作必须进入 Room Cell,
|
// TypeRoomBorder 与 TypeRoomNameStyle 是房间级装扮目录类型;应用动作必须进入 Room Cell,
|
||||||
|
|||||||
@ -123,7 +123,7 @@ func NormalizeGrantSource(value string) string {
|
|||||||
|
|
||||||
func ValidResourceType(value string) bool {
|
func ValidResourceType(value string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case TypeAvatarFrame, TypeProfileCard, TypeCoin, TypeVehicle, TypeChatBubble, TypeBadge, TypeFloatingScreen, TypeGift, TypeMicSeatIcon, TypeMicSeatAnimation, TypeRoomBorder, TypeRoomNameStyle, TypeEmojiPack, TypeVIPTrialCard:
|
case TypeAvatarFrame, TypeProfileCard, TypeCoin, TypeVehicle, TypeChatBubble, TypeBadge, TypeFloatingScreen, TypeGift, TypeGiftTraySkin, TypeMicSeatIcon, TypeMicSeatAnimation, TypeRoomBorder, TypeRoomNameStyle, TypeEmojiPack, TypeVIPTrialCard:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user