Compare commits

..

No commits in common. "main" and "test" have entirely different histories.
main ... test

30 changed files with 50 additions and 470 deletions

View File

@ -11546,9 +11546,6 @@ 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
} }
@ -11702,13 +11699,6 @@ 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"`
@ -14899,7 +14889,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\"\x95\x05\n" + "\x16relation_updated_at_ms\x18\x02 \x01(\x03R\x13relationUpdatedAtMs\"\xed\x04\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" +
@ -14920,8 +14910,7 @@ 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\x12&\n" + "\x0froom_name_style\x18\x12 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\rroomNameStyle\"g\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" +

View File

@ -1419,9 +1419,6 @@ 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

View File

@ -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, gift_tray_skin, 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, 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, gift_tray_skin, 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, 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, gift_tray_skin, 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, 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, gift_tray_skin, 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, 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, gift_tray_skin, 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, 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,10 +5628,6 @@ 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:

View File

@ -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,7 +55,6 @@
| `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

View File

@ -38,7 +38,6 @@ 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"
) )

View File

@ -1,35 +0,0 @@
-- 与 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 ;

View File

@ -24,7 +24,6 @@ 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"
@ -949,7 +948,7 @@ func normalizeMP4AlphaLayout(value string) string {
func resourceAllowsAnimationMetadata(resourceType string) bool { func resourceAllowsAnimationMetadata(resourceType string) bool {
switch resourceType { switch resourceType {
case "gift", resourceTypeGiftTraySkin, "vehicle", "avatar_frame", "chat_bubble", "floating_screen", resourceTypeProfileCard, resourceTypeRoomBorder: case "gift", "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_")

View File

@ -28,6 +28,7 @@ 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。
@ -58,9 +59,8 @@ 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
// 显式字段选择只能放在 Count 之后GORM 会把既有 Select 改写为 COUNT(选择项) // 等级必须随政策一起预加载并按 level_no 排序,前端展示和后续编辑都依赖稳定顺序。
// 提前写成 table.* 会生成 MySQL 不支持的 COUNT(table.*)。取数阶段仍限定主表字段,避免 JOIN 区域表污染模型映射。 err := db.Preload("Levels", func(tx *gorm.DB) *gorm.DB {
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 {

View File

@ -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", OwnerVipLevel: 5}, {RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true, CountryCode: "US"},
}, },
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" || first["owner_vip_level"] != float64(5) { if first["im_group_id"] != "room-1" || first["locked"] != true || first["country_code"] != "US" {
t.Fatalf("room list must expose room IM group id: %+v", first) t.Fatalf("room list must expose room IM group id: %+v", first)
} }
} }

View File

@ -38,7 +38,6 @@ 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"`
@ -739,7 +738,6 @@ 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(),

View File

@ -38,8 +38,6 @@ 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 '业务模式',

View File

@ -1,35 +0,0 @@
-- 房主 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 ;

View File

@ -308,7 +308,6 @@ 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",

View File

@ -126,8 +126,6 @@ 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)
@ -258,8 +256,6 @@ 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)

View File

@ -356,49 +356,6 @@ 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
@ -634,11 +591,6 @@ 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,
@ -657,7 +609,6 @@ 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,
} }
} }

View File

@ -22,8 +22,6 @@ 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",
@ -53,9 +51,6 @@ 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) {

View File

@ -166,10 +166,6 @@ 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{

View File

@ -279,12 +279,6 @@ 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 是卡片封面;空值表示没有单独封面。
@ -855,12 +849,6 @@ 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 发现页,

View File

@ -181,20 +181,6 @@ 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

View File

@ -36,7 +36,6 @@ 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
} }
@ -99,7 +98,6 @@ 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 {

View File

@ -10,7 +10,6 @@ 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"
@ -18,72 +17,6 @@ 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)

View File

@ -30,12 +30,11 @@ const (
roomDecorationReconcileBatchSize = 100 roomDecorationReconcileBatchSize = 100
) )
// HandleWalletDecorationEvent 消费 wallet owner 已提交的 VIP/资源变化VIP 等级只更新列表读模型,装扮仍通过 Room Cell 命令收敛 // HandleWalletDecorationEvent 消费 wallet owner 已提交的权限/资源变化,并通过 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, decorationHandled, err := walletDecorationAssignmentQueries(message) queries, handled, err := walletDecorationAssignmentQueries(message)
vipProjectionHandled := strings.TrimSpace(message.EventType) == walletmq.EventTypeVIPActivated || strings.TrimSpace(message.EventType) == walletEventVipEffectiveChanged if err != nil || !handled {
if err != nil || (!decorationHandled && !vipProjectionHandled) {
return err return err
} }
store, ok := s.repository.(RoomDecorationReconciliationStore) store, ok := s.repository.(RoomDecorationReconciliationStore)
@ -47,17 +46,7 @@ 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
@ -67,7 +56,6 @@ 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())
} }

View File

@ -117,8 +117,7 @@ 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 {
@ -139,45 +138,6 @@ 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 {
@ -273,14 +233,9 @@ func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservic
&entry.CreatedAtMS, &entry.CreatedAtMS,
&entry.UpdatedAtMS, &entry.UpdatedAtMS,
} }
columnCount := scannerColumnCount(scanner) if scannerColumnCount(scanner) >= len(dest)+2 {
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
} }
@ -294,14 +249,9 @@ 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,
} }
columnCount := scannerColumnCount(scanner) if scannerColumnCount(scanner) >= len(dest)+2+4 {
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
@ -545,8 +495,7 @@ 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 契约稳定。
@ -554,23 +503,20 @@ 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`
@ -613,7 +559,6 @@ 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

View File

@ -4,51 +4,12 @@ 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 {

View File

@ -35,8 +35,6 @@ 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,
@ -510,9 +508,6 @@ 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 {

View File

@ -314,20 +314,6 @@ 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

View File

@ -54,28 +54,6 @@ 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 自己。

View File

@ -602,23 +602,6 @@ 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)

View File

@ -9,9 +9,6 @@ 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

View File

@ -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, TypeGiftTraySkin, TypeMicSeatIcon, TypeMicSeatAnimation, TypeRoomBorder, TypeRoomNameStyle, TypeEmojiPack, TypeVIPTrialCard: case TypeAvatarFrame, TypeProfileCard, TypeCoin, TypeVehicle, TypeChatBubble, TypeBadge, TypeFloatingScreen, TypeGift, TypeMicSeatIcon, TypeMicSeatAnimation, TypeRoomBorder, TypeRoomNameStyle, TypeEmojiPack, TypeVIPTrialCard:
return true return true
default: default:
return false return false