相关功能修复

This commit is contained in:
zhx 2026-07-05 01:55:03 +08:00
parent dc44b0bf08
commit 4bd22c61d6
41 changed files with 2336 additions and 911 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2222,6 +2222,15 @@ message ListResourceShopPurchaseOrdersResponse {
int64 total = 2;
}
message GetGiftCatalogVersionRequest {
string request_id = 1;
string app_code = 2;
}
message GetGiftCatalogVersionResponse {
int64 version = 1;
}
// WalletCronService cron-service wallet-service owner
service WalletCronService {
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
@ -2259,6 +2268,7 @@ service WalletService {
rpc SetResourceGroupStatus(SetResourceGroupStatusRequest) returns (ResourceGroupResponse);
rpc ListGiftConfigs(ListGiftConfigsRequest) returns (ListGiftConfigsResponse);
rpc ListGiftTypeConfigs(ListGiftTypeConfigsRequest) returns (ListGiftTypeConfigsResponse);
rpc GetGiftCatalogVersion(GetGiftCatalogVersionRequest) returns (GetGiftCatalogVersionResponse);
rpc CreateGiftConfig(CreateGiftConfigRequest) returns (GiftConfigResponse);
rpc UpdateGiftConfig(UpdateGiftConfigRequest) returns (GiftConfigResponse);
rpc SetGiftConfigStatus(SetGiftConfigStatusRequest) returns (GiftConfigResponse);

View File

@ -229,6 +229,7 @@ const (
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
WalletService_ListGiftTypeConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftTypeConfigs"
WalletService_GetGiftCatalogVersion_FullMethodName = "/hyapp.wallet.v1.WalletService/GetGiftCatalogVersion"
WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
@ -334,6 +335,7 @@ type WalletServiceClient interface {
SetResourceGroupStatus(ctx context.Context, in *SetResourceGroupStatusRequest, opts ...grpc.CallOption) (*ResourceGroupResponse, error)
ListGiftConfigs(ctx context.Context, in *ListGiftConfigsRequest, opts ...grpc.CallOption) (*ListGiftConfigsResponse, error)
ListGiftTypeConfigs(ctx context.Context, in *ListGiftTypeConfigsRequest, opts ...grpc.CallOption) (*ListGiftTypeConfigsResponse, error)
GetGiftCatalogVersion(ctx context.Context, in *GetGiftCatalogVersionRequest, opts ...grpc.CallOption) (*GetGiftCatalogVersionResponse, error)
CreateGiftConfig(ctx context.Context, in *CreateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
UpdateGiftConfig(ctx context.Context, in *UpdateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
SetGiftConfigStatus(ctx context.Context, in *SetGiftConfigStatusRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
@ -693,6 +695,16 @@ func (c *walletServiceClient) ListGiftTypeConfigs(ctx context.Context, in *ListG
return out, nil
}
func (c *walletServiceClient) GetGiftCatalogVersion(ctx context.Context, in *GetGiftCatalogVersionRequest, opts ...grpc.CallOption) (*GetGiftCatalogVersionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetGiftCatalogVersionResponse)
err := c.cc.Invoke(ctx, WalletService_GetGiftCatalogVersion_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreateGiftConfig(ctx context.Context, in *CreateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GiftConfigResponse)
@ -1417,6 +1429,7 @@ type WalletServiceServer interface {
SetResourceGroupStatus(context.Context, *SetResourceGroupStatusRequest) (*ResourceGroupResponse, error)
ListGiftConfigs(context.Context, *ListGiftConfigsRequest) (*ListGiftConfigsResponse, error)
ListGiftTypeConfigs(context.Context, *ListGiftTypeConfigsRequest) (*ListGiftTypeConfigsResponse, error)
GetGiftCatalogVersion(context.Context, *GetGiftCatalogVersionRequest) (*GetGiftCatalogVersionResponse, error)
CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error)
UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error)
SetGiftConfigStatus(context.Context, *SetGiftConfigStatusRequest) (*GiftConfigResponse, error)
@ -1580,6 +1593,9 @@ func (UnimplementedWalletServiceServer) ListGiftConfigs(context.Context, *ListGi
func (UnimplementedWalletServiceServer) ListGiftTypeConfigs(context.Context, *ListGiftTypeConfigsRequest) (*ListGiftTypeConfigsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListGiftTypeConfigs not implemented")
}
func (UnimplementedWalletServiceServer) GetGiftCatalogVersion(context.Context, *GetGiftCatalogVersionRequest) (*GetGiftCatalogVersionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetGiftCatalogVersion not implemented")
}
func (UnimplementedWalletServiceServer) CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateGiftConfig not implemented")
}
@ -2312,6 +2328,24 @@ func _WalletService_ListGiftTypeConfigs_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetGiftCatalogVersion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetGiftCatalogVersionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetGiftCatalogVersion(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetGiftCatalogVersion_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetGiftCatalogVersion(ctx, req.(*GetGiftCatalogVersionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreateGiftConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateGiftConfigRequest)
if err := dec(in); err != nil {
@ -3673,6 +3707,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListGiftTypeConfigs",
Handler: _WalletService_ListGiftTypeConfigs_Handler,
},
{
MethodName: "GetGiftCatalogVersion",
Handler: _WalletService_GetGiftCatalogVersion_Handler,
},
{
MethodName: "CreateGiftConfig",
Handler: _WalletService_CreateGiftConfig_Handler,

1
go.mod
View File

@ -20,6 +20,7 @@ replace hyapp.local/api => ./api
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect

3
go.sum
View File

@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
github.com/apache/rocketmq-client-go/v2 v2.1.2 h1:yt73olKe5N6894Dbm+ojRf/JPiP0cxfDNNffKwhpJVg=
github.com/apache/rocketmq-client-go/v2 v2.1.2/go.mod h1:6I6vgxHR3hzrvn+6n/4mrhS+UTulzK/X9LB2Vk1U5gE=
@ -67,6 +69,7 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=

View File

@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
can_grant_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送头像框',
can_grant_vehicle TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送座驾',
can_grant_badge TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送徽章',
can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送 VIP',
can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心升级用户等级',
can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
@ -68,6 +69,15 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_grant_vip') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送 VIP'' AFTER can_grant_badge',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_add_bd_leader') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心添加 BD Leader'' AFTER can_update_user_level',
@ -115,7 +125,7 @@ DEALLOCATE PREPARE stmt;
INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
)
SELECT
@ -132,6 +142,7 @@ SELECT
1,
1,
1,
1,
0,
MIN(bl.created_at_ms),
MAX(bl.updated_at_ms)

View File

@ -0,0 +1,92 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- apps 同时按 app_code 和 package_name/platform 唯一;包名若已属于别的 app必须中止避免 upsert 误改非 Yumi 行。
CREATE TEMPORARY TABLE yumi_app_package_conflict_guard (
guard_id TINYINT NOT NULL PRIMARY KEY
) ENGINE=Memory;
INSERT INTO yumi_app_package_conflict_guard (guard_id) VALUES (1);
INSERT INTO yumi_app_package_conflict_guard (guard_id)
SELECT 1
FROM apps
WHERE package_name = 'com.org.yumi'
AND platform = ''
AND app_code <> 'yumi'
LIMIT 1;
DROP TEMPORARY TABLE yumi_app_package_conflict_guard;
-- App 注册表是 gateway 按 app_code 或包名解析租户的事实来源Yumi 本地联调必须先注册为 active。
INSERT INTO apps (app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms)
VALUES ('yumi', 'Yumi', 'com.org.yumi', '', 'active', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
app_name = VALUES(app_name),
package_name = VALUES(package_name),
platform = VALUES(platform),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms);
-- 快捷建号和三方注册都会读取 app 级风控配置;补齐默认值避免 app 支持后注册链路再失败。
INSERT INTO auth_risk_configs (app_code, max_accounts_per_device, updated_by_admin_id, created_at_ms, updated_at_ms)
VALUES ('yumi', 1, 0, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
app_code = app_code;
-- 注册资料会校验 countries本地 Yumi 没有国家主数据时快捷建号会被 country is not supported 拦住。
INSERT INTO countries (
app_code, country_name, country_code, iso_alpha3, iso_numeric, country_display_name,
phone_country_code, flag, enabled, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
SELECT
'yumi', country_name, country_code, iso_alpha3, iso_numeric, country_display_name,
phone_country_code, flag, enabled, sort_order, created_by_user_id, updated_by_user_id, @now_ms, @now_ms
FROM countries
WHERE app_code = 'lalu'
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
iso_alpha3 = VALUES(iso_alpha3),
iso_numeric = VALUES(iso_numeric),
country_display_name = VALUES(country_display_name),
phone_country_code = VALUES(phone_country_code),
flag = VALUES(flag),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms);
-- 区域映射用于注册时把国家收敛到服务端 region_id复制 Lalu 目录即可满足本地联调。
INSERT INTO regions (
app_code, region_code, name, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
SELECT
'yumi', region_code, name, status, sort_order, created_by_user_id, updated_by_user_id, @now_ms, @now_ms
FROM regions
WHERE app_code = 'lalu'
ON DUPLICATE KEY UPDATE
name = VALUES(name),
status = VALUES(status),
sort_order = VALUES(sort_order),
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms);
INSERT INTO region_countries (
app_code, region_id, country_code, status, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
SELECT
'yumi', target_region.region_id, source_mapping.country_code, source_mapping.status,
source_mapping.created_by_user_id, source_mapping.updated_by_user_id, @now_ms, @now_ms
FROM region_countries source_mapping
JOIN regions source_region
ON source_region.app_code = source_mapping.app_code
AND source_region.region_id = source_mapping.region_id
JOIN regions target_region
ON target_region.app_code = 'yumi'
AND target_region.region_code = source_region.region_code
WHERE source_mapping.app_code = 'lalu'
ON DUPLICATE KEY UPDATE
region_id = VALUES(region_id),
status = VALUES(status),
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms);

View File

@ -0,0 +1,12 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'can_grant_vip') = 0,
'ALTER TABLE manager_profiles ADD COLUMN can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否允许在经理中心赠送 VIP'' AFTER can_grant_badge',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -54,6 +54,7 @@ type ManagerListItem struct {
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
CanGrantVehicle bool `json:"canGrantVehicle"`
CanGrantBadge bool `json:"canGrantBadge"`
CanGrantVIP bool `json:"canGrantVip"`
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
CanAddBDLeader bool `json:"canAddBdLeader"`
CanAddAdmin bool `json:"canAddAdmin"`
@ -619,15 +620,16 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
if _, err := r.db.ExecContext(ctx, `
INSERT INTO manager_profiles (
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = 'active',
contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
can_grant_badge = VALUES(can_grant_badge),
can_grant_vip = VALUES(can_grant_vip),
can_update_user_level = VALUES(can_update_user_level),
can_add_bd_leader = VALUES(can_add_bd_leader),
can_add_admin = VALUES(can_add_admin),
@ -635,8 +637,8 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
can_block_user = VALUES(can_block_user),
can_transfer_user_country = VALUES(can_transfer_user_country),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
permissions.CanGrantBadge, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
return nil, err
}
@ -740,14 +742,14 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''),
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
mp.status, COALESCE(mp.contact_info, ''),
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_update_user_level,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
mp.created_at_ms, mp.updated_at_ms
%s
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
manager_region.region_id, manager_region.name, mp.status, mp.contact_info,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_update_user_level,
mp.can_grant_avatar_frame, mp.can_grant_vehicle, mp.can_grant_badge, mp.can_grant_vip, mp.can_update_user_level,
mp.can_add_bd_leader, mp.can_add_admin, mp.can_add_superadmin, mp.can_block_user, mp.can_transfer_user_country,
mp.created_at_ms, mp.updated_at_ms
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
@ -773,6 +775,7 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL
&item.CanGrantAvatarFrame,
&item.CanGrantVehicle,
&item.CanGrantBadge,
&item.CanGrantVIP,
&item.CanUpdateUserLevel,
&item.CanAddBDLeader,
&item.CanAddAdmin,

View File

@ -72,6 +72,7 @@ type createManagerRequest struct {
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
@ -85,6 +86,7 @@ type updateManagerRequest struct {
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
CanGrantVehicle *bool `json:"canGrantVehicle"`
CanGrantBadge *bool `json:"canGrantBadge"`
CanGrantVIP *bool `json:"canGrantVip"`
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
CanAddBDLeader *bool `json:"canAddBdLeader"`
CanAddAdmin *bool `json:"canAddAdmin"`
@ -97,6 +99,7 @@ type managerPermissions struct {
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool
@ -110,6 +113,7 @@ func (r createManagerRequest) permissionsWithDefault() managerPermissions {
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
CanGrantVIP: boolDefaultTrue(r.CanGrantVIP),
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
@ -123,6 +127,7 @@ func (r updateManagerRequest) appendPermissionAssignments(assignments *[]string,
appendBoolAssignment(assignments, args, "can_grant_avatar_frame", r.CanGrantAvatarFrame)
appendBoolAssignment(assignments, args, "can_grant_vehicle", r.CanGrantVehicle)
appendBoolAssignment(assignments, args, "can_grant_badge", r.CanGrantBadge)
appendBoolAssignment(assignments, args, "can_grant_vip", r.CanGrantVIP)
appendBoolAssignment(assignments, args, "can_update_user_level", r.CanUpdateUserLevel)
appendBoolAssignment(assignments, args, "can_add_bd_leader", r.CanAddBDLeader)
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)

View File

@ -100,6 +100,7 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
Status: "active",
Contact: "+63",
CanGrantVehicle: true,
CanGrantVIP: true,
CanBlockUser: false,
BDLeaderCount: 3,
LastInvitedAtMs: 1720000000000,
@ -114,6 +115,7 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
`"displayUserId":"165549"`,
`"contact":"+63"`,
`"canGrantVehicle":true`,
`"canGrantVip":true`,
`"canBlockUser":false`,
`"bdLeaderCount":3`,
`"lastInvitedAtMs":1720000000000`,

View File

@ -48,6 +48,7 @@ type WalletClient interface {
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
PurchaseResourceShopItem(ctx context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error)
GetGiftCatalogVersion(ctx context.Context, req *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error)
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
@ -223,6 +224,10 @@ func (c *grpcWalletClient) PurchaseResourceShopItem(ctx context.Context, req *wa
return c.client.PurchaseResourceShopItem(ctx, req)
}
func (c *grpcWalletClient) GetGiftCatalogVersion(ctx context.Context, req *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
return c.client.GetGiftCatalogVersion(ctx, req)
}
func (c *grpcWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
return c.client.ListGiftConfigs(ctx, req)
}

View File

@ -200,6 +200,22 @@ func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *
if sameEnvelope.Data.Total != 1 || sameEnvelope.Data.Items[0].UserID != "900001" {
t.Fatalf("same-country search must filter cross-country users: %+v", sameEnvelope.Data)
}
if req := profileClient.lastBusinessLookup; req == nil || req.GetScene() != "manager_resource_grant" {
t.Fatalf("default manager search should use resource grant scene, got %+v", req)
}
vipReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20&action=send-vip", nil)
vipReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
vipReq.Header.Set("X-Request-ID", "req-manager-search-vip")
vipRecorder := httptest.NewRecorder()
router.ServeHTTP(vipRecorder, vipReq)
if vipRecorder.Code != http.StatusOK {
t.Fatalf("vip search status mismatch: got %d body=%s", vipRecorder.Code, vipRecorder.Body.String())
}
if req := profileClient.lastBusinessLookup; req == nil || req.GetScene() != "manager_vip_grant" {
t.Fatalf("VIP manager search should use vip grant scene, got %+v", req)
}
allReq := httptest.NewRequest(http.MethodGet, "/api/v1/manager-center/users/search?keyword=163&page_size=20&scope=all", nil)
allReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -373,6 +389,27 @@ func TestGrantManagerVIPRejectsLevelAboveManagerLimit(t *testing.T) {
}
}
func TestGrantManagerVIPRequiresManagerVIPCapability(t *testing.T) {
walletClient := &fakeWalletClient{}
hostClient := &fakeUserHostClient{capabilityResp: &userv1.CheckBusinessCapabilityResponse{Allowed: false, Reason: "manager_capability_required"}}
router := newManagerCenterTestRouter(walletClient, hostClient, &fakeUserProfileClient{})
body := []byte(`{"command_id":"mgr-vip-denied","target_user_id":"900001","level":5}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/vip-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-vip-denied")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodePermissionDenied, "req-manager-vip-denied")
if walletClient.lastGrantVip != nil {
t.Fatalf("unauthorized VIP grant must not reach wallet-service: %+v", walletClient.lastGrantVip)
}
if hostClient.lastCapability == nil || hostClient.lastCapability.GetCapability() != "manager_grant_vip" {
t.Fatalf("VIP grant should check managerGrantVIPCapability, got %+v", hostClient.lastCapability)
}
}
func TestGrantManagerVIPUsesManagerCenterGrantSource(t *testing.T) {
walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})

View File

@ -21,6 +21,7 @@ const managerResourceGrantCapability = "manager_resource_grant"
const managerGrantAvatarFrameCapability = "manager_grant_avatar_frame"
const managerGrantVehicleCapability = "manager_grant_vehicle"
const managerGrantBadgeCapability = "manager_grant_badge"
const managerGrantVIPCapability = "manager_grant_vip"
const managerUpdateUserLevelCapability = "manager_update_user_level"
const managerAddBDLeaderCapability = "manager_add_bd_leader"
const managerAddAdminCapability = "manager_add_admin"
@ -170,7 +171,7 @@ func (h *Handler) searchManagerUsers(writer http.ResponseWriter, request *http.R
lookupResp, err := h.userProfileClient.BusinessUserLookup(request.Context(), &userv1.BusinessUserLookupRequest{
Meta: httpkit.UserMeta(request, ""),
ActorUserId: actor.GetUserId(),
Scene: "manager_resource_grant",
Scene: managerSearchScene(request),
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
PageSize: pageSize,
})
@ -403,6 +404,9 @@ func (h *Handler) grantManagerVIP(writer http.ResponseWriter, request *http.Requ
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if !h.requireManagerCapability(writer, request, managerGrantVIPCapability) {
return
}
// 经理中心复用后台 VIP 发放账务能力,但 source 固定为 manager_center钱包侧据此保留统一 IM/outbox 行为。
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
return
@ -744,6 +748,13 @@ func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWrit
return h.requireManagerCapability(writer, request, managerResourceGrantCapability)
}
func managerSearchScene(request *http.Request) string {
if strings.EqualFold(strings.TrimSpace(request.URL.Query().Get("action")), "send-vip") {
return "manager_vip_grant"
}
return "manager_resource_grant"
}
func (h *Handler) requireManagerCenterActor(writer http.ResponseWriter, request *http.Request) (*userv1.User, bool) {
if !h.requireManagerCapability(writer, request, managerCenterCapability) {
return nil, false
@ -815,6 +826,7 @@ func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bo
"can_grant_avatar_frame": false,
"can_grant_vehicle": false,
"can_grant_badge": false,
"can_grant_vip": false,
"can_update_user_level": false,
"can_add_bd_leader": false,
"can_add_admin": false,
@ -826,6 +838,7 @@ func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bo
"can_grant_avatar_frame": managerGrantAvatarFrameCapability,
"can_grant_vehicle": managerGrantVehicleCapability,
"can_grant_badge": managerGrantBadgeCapability,
"can_grant_vip": managerGrantVIPCapability,
"can_update_user_level": managerUpdateUserLevelCapability,
"can_add_bd_leader": managerAddBDLeaderCapability,
"can_add_admin": managerAddAdminCapability,

View File

@ -626,6 +626,11 @@ type fakeWalletClient struct {
resourceShopResp *walletv1.ListResourceShopItemsResponse
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
lastGetGiftCatalog *walletv1.GetGiftCatalogVersionRequest
getGiftCatalogCalls int
giftCatalogVersion int64
giftCatalogVersionResp *walletv1.GetGiftCatalogVersionResponse
giftCatalogVersionErr error
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
listGiftConfigsCalls int
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
@ -2364,6 +2369,21 @@ func (f *fakeWalletClient) PurchaseResourceShopItem(_ context.Context, req *wall
return &walletv1.PurchaseResourceShopItemResponse{}, nil
}
func (f *fakeWalletClient) GetGiftCatalogVersion(_ context.Context, req *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
f.lastGetGiftCatalog = req
f.getGiftCatalogCalls++
if f.giftCatalogVersionErr != nil {
return nil, f.giftCatalogVersionErr
}
if f.err != nil {
return nil, f.err
}
if f.giftCatalogVersionResp != nil {
return f.giftCatalogVersionResp, nil
}
return &walletv1.GetGiftCatalogVersionResponse{Version: f.giftCatalogVersion}, nil
}
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
f.lastListGiftConfigs = req
f.listGiftConfigsCalls++
@ -8234,7 +8254,8 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
SortOrder: 1,
}
walletClient := &fakeWalletClient{
resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
giftCatalogVersion: 1,
listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
TypeCode: "normal",
Name: "普通礼物",
@ -8276,7 +8297,7 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
for attempt := 0; attempt < 2; attempt++ {
serveGiftPanel := func(attempt int) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-cache/gift-panel", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
@ -8284,19 +8305,140 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("gift panel status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String())
}
return recorder
}
if walletClient.listGiftTypesCalls != 1 || walletClient.listGiftConfigsCalls != 1 {
t.Fatalf("gift panel config should be cached, typeCalls=%d giftCalls=%d", walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
for attempt := 0; attempt < 2; attempt++ {
serveGiftPanel(attempt)
}
if walletClient.getGiftCatalogCalls != 2 || walletClient.listGiftTypesCalls != 1 || walletClient.listGiftConfigsCalls != 1 {
t.Fatalf("gift panel same-version config should be cached, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
}
if len(walletClient.balanceRequests) != 2 || walletClient.listUserResourcesCalls != 2 {
t.Fatalf("gift panel realtime data must not be cached, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
}
if walletClient.lastGetGiftCatalog == nil || walletClient.lastGetGiftCatalog.GetAppCode() != "lalu" {
t.Fatalf("gift panel catalog version request mismatch: %+v", walletClient.lastGetGiftCatalog)
}
if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetRegionId() != 1001 || walletClient.lastListGiftConfigs.GetPageSize() != 500 || !walletClient.lastListGiftConfigs.GetFilterRegion() {
t.Fatalf("gift panel list request mismatch: %+v", walletClient.lastListGiftConfigs)
}
if walletClient.lastListUserResources == nil || walletClient.lastListUserResources.GetUserId() != 42 || walletClient.lastListUserResources.GetResourceType() != "gift" || !walletClient.lastListUserResources.GetActiveOnly() {
t.Fatalf("gift panel bag request mismatch: %+v", walletClient.lastListUserResources)
}
lilyResource := &walletv1.Resource{
ResourceId: 12,
ResourceCode: "gift_lily",
ResourceType: "gift",
Name: "Lily",
Status: "active",
UsageScopes: []string{"gift"},
AssetUrl: "https://cdn.example/lily.svga",
SortOrder: 2,
}
walletClient.giftCatalogVersion = 2
walletClient.listGiftTypesResp = &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
TypeCode: "lucky",
Name: "幸运礼物",
TabKey: "Lucky",
Status: "active",
SortOrder: 20,
}}}
walletClient.listGiftConfigsResp = &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
GiftId: "lily",
ResourceId: lilyResource.GetResourceId(),
Resource: lilyResource,
Status: "active",
Name: "Lily",
GiftTypeCode: "lucky",
PriceVersion: "v2",
ChargeAssetType: "COIN",
CoinPrice: 200,
HeatValue: 200,
RegionIds: []int64{0, 1001},
}}, Total: 1}
recorder := serveGiftPanel(2)
if walletClient.getGiftCatalogCalls != 3 || walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 {
t.Fatalf("gift panel changed-version config should be refetched, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
}
if len(walletClient.balanceRequests) != 3 || walletClient.listUserResourcesCalls != 3 {
t.Fatalf("gift panel realtime data must stay per-request after version change, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
}
var envelope struct {
Code string `json:"code"`
Data struct {
Gifts []struct {
GiftID string `json:"gift_id"`
GiftTypeCode string `json:"gift_type_code"`
CoinPrice int64 `json:"coin_price"`
} `json:"gifts"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode changed-version gift panel failed: %v", err)
}
if envelope.Code != httpkit.CodeOK || len(envelope.Data.Gifts) != 1 || envelope.Data.Gifts[0].GiftID != "lily" || envelope.Data.Gifts[0].GiftTypeCode != "lucky" || envelope.Data.Gifts[0].CoinPrice != 200 {
t.Fatalf("gift panel changed-version response should use refetched config: %+v", envelope)
}
}
func TestRoomGiftPanelFallsBackWhenGiftCatalogVersionIsUnimplemented(t *testing.T) {
giftResource := &walletv1.Resource{
ResourceId: 11,
ResourceCode: "gift_rose",
ResourceType: "gift",
Name: "Rose",
Status: "active",
UsageScopes: []string{"gift"},
}
walletClient := &fakeWalletClient{
resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
giftCatalogVersionErr: status.Error(codes.Unimplemented, "unknown method GetGiftCatalogVersion"),
listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
TypeCode: "normal",
Name: "普通礼物",
TabKey: "Gift",
Status: "active",
SortOrder: 10,
}}},
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
GiftId: "rose",
ResourceId: giftResource.GetResourceId(),
Resource: giftResource,
Status: "active",
Name: "Rose",
GiftTypeCode: "normal",
CoinPrice: 100,
RegionIds: []int64{0, 1001},
}}, Total: 1},
}
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: &roomv1.RoomSnapshot{
RoomId: "room-gift-version-fallback",
OwnerUserId: 88,
Status: "active",
VisibleRegionId: 1001,
}}}
handler := NewHandler(&fakeRoomClient{})
handler.SetRoomQueryClient(queryClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
for attempt := 0; attempt < 2; attempt++ {
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-version-fallback/gift-panel", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("gift panel fallback status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String())
}
}
if walletClient.getGiftCatalogCalls != 2 || walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 {
t.Fatalf("unimplemented catalog version should disable config cache, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
}
if len(walletClient.balanceRequests) != 2 || walletClient.listUserResourcesCalls != 2 {
t.Fatalf("gift panel fallback realtime data must stay per-request, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
}
}
func TestRoomGiftPanelCacheSeparatesVisibleRegion(t *testing.T) {

View File

@ -21,6 +21,7 @@ type roomGiftPanelConfigCache struct {
type roomGiftPanelConfigCacheEntry struct {
config roomGiftPanelConfig
version int64
expiresAt time.Time
}
@ -34,7 +35,7 @@ func newRoomGiftPanelConfigCache(ttl time.Duration) *roomGiftPanelConfigCache {
}
}
func (c *roomGiftPanelConfigCache) Get(appCode string, regionID int64, now time.Time) (roomGiftPanelConfig, bool) {
func (c *roomGiftPanelConfigCache) Get(appCode string, regionID int64, version int64, now time.Time) (roomGiftPanelConfig, bool) {
if c == nil {
return roomGiftPanelConfig{}, false
}
@ -42,11 +43,11 @@ func (c *roomGiftPanelConfigCache) Get(appCode string, regionID int64, now time.
c.mu.RLock()
entry, ok := c.items[key]
c.mu.RUnlock()
if !ok || !now.Before(entry.expiresAt) {
if !ok || entry.version != version || !now.Before(entry.expiresAt) {
if ok {
c.mu.Lock()
// 过期项只在命中时顺手清理,避免为低频区域引入后台 goroutine。
if current, exists := c.items[key]; exists && !now.Before(current.expiresAt) {
// 过期或版本漂移时只清理当前 key避免低频区域为了维护缓存状态引入后台 goroutine。
if current, exists := c.items[key]; exists && (current.version != version || !now.Before(current.expiresAt)) {
delete(c.items, key)
}
c.mu.Unlock()
@ -56,7 +57,7 @@ func (c *roomGiftPanelConfigCache) Get(appCode string, regionID int64, now time.
return cloneRoomGiftPanelConfig(entry.config), true
}
func (c *roomGiftPanelConfigCache) Set(appCode string, regionID int64, config roomGiftPanelConfig, now time.Time) {
func (c *roomGiftPanelConfigCache) Set(appCode string, regionID int64, version int64, config roomGiftPanelConfig, now time.Time) {
if c == nil {
return
}
@ -65,6 +66,7 @@ func (c *roomGiftPanelConfigCache) Set(appCode string, regionID int64, config ro
// 写入时复制一次,读取时再复制一次;缓存只保存配置快照,不让请求级背包礼物追加影响下一次命中。
c.items[roomGiftPanelConfigCacheKey(appCode, regionID)] = roomGiftPanelConfigCacheEntry{
config: cloneRoomGiftPanelConfig(config),
version: version,
expiresAt: now.Add(c.ttl),
}
}

View File

@ -737,8 +737,12 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) {
app := appcode.FromContext(request.Context())
if h.giftPanelCache != nil {
if cached, ok := h.giftPanelCache.Get(app, regionID, time.Now()); ok {
catalogVersion, cacheable, err := h.roomGiftCatalogVersion(request, app)
if err != nil {
return roomGiftPanelConfig{}, err
}
if cacheable && h.giftPanelCache != nil {
if cached, ok := h.giftPanelCache.Get(app, regionID, catalogVersion, time.Now()); ok {
return cached, nil
}
}
@ -775,12 +779,28 @@ func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64)
// 礼物类型禁用后不应出现在房间面板;缓存保存已过滤的稳定物料,背包礼物仍按用户每次实时拼接。
Gifts: filterGiftPanelGifts(gifts, giftTypes),
}
if h.giftPanelCache != nil {
h.giftPanelCache.Set(app, regionID, config, time.Now())
if cacheable && h.giftPanelCache != nil {
h.giftPanelCache.Set(app, regionID, catalogVersion, config, time.Now())
}
return config, nil
}
func (h *Handler) roomGiftCatalogVersion(request *http.Request, app string) (int64, bool, error) {
resp, err := h.walletClient.GetGiftCatalogVersion(request.Context(), &walletv1.GetGiftCatalogVersionRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: app,
})
if err != nil {
if status.Code(err) == codes.Unimplemented {
// 滚动发布期间旧 wallet-service 不认识版本 RPC 时,禁用本次配置缓存并回退到原来的直接查询路径。
return 0, false, nil
}
return 0, false, err
}
// wallet-service 是礼物目录 ownergateway 只把 owner 版本当不透明令牌,避免用 TTL 命中跨过后台配置变更。
return resp.GetVersion(), true, nil
}
func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
return nil, nil

View File

@ -2,6 +2,7 @@ package userapi
import (
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
@ -187,7 +188,7 @@ func (h *Handler) rejectCPApplication(writer http.ResponseWriter, request *http.
httpkit.WriteOK(writer, request, map[string]any{"application": cpApplicationFromProto(resp.GetApplication())})
}
// listCPRelationships 返回当前用户 active 关系;服务端固定限制一个用户只能同时存在一条关系。
// listCPRelationships 默认返回当前用户关系;资料卡传 user_id 时返回被查看用户关系。
func (h *Handler) listCPRelationships(writer http.ResponseWriter, request *http.Request) {
if h.userCPClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -198,9 +199,10 @@ func (h *Handler) listCPRelationships(writer http.ResponseWriter, request *http.
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
targetUserID := cpRelationshipListUserID(request)
resp, err := h.userCPClient.ListCPRelationships(request.Context(), &userv1.ListCPRelationshipsRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
UserId: targetUserID,
RelationType: strings.TrimSpace(request.URL.Query().Get("relation_type")),
Page: page,
PageSize: pageSize,
@ -219,6 +221,20 @@ func (h *Handler) listCPRelationships(writer http.ResponseWriter, request *http.
})
}
func cpRelationshipListUserID(request *http.Request) int64 {
currentUserID := auth.UserIDFromContext(request.Context())
raw := strings.TrimSpace(request.URL.Query().Get("user_id"))
if raw == "" {
return currentUserID
}
// Flutter 已经会把被查看用户放进 user_id非法值按历史语义回落到登录用户避免破坏旧客户端的“我的关系”入口。
targetUserID, err := strconv.ParseInt(raw, 10, 64)
if err != nil || targetUserID <= 0 {
return currentUserID
}
return targetUserID
}
// listCPIntimacyLeaderboard 返回 cron 聚合后的 CP 亲密值排行榜,不在 gateway 实时扫描关系表。
func (h *Handler) listCPIntimacyLeaderboard(writer http.ResponseWriter, request *http.Request) {
if h.userCPClient == nil {

View File

@ -1,11 +1,106 @@
package userapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
)
type fakeCPRelationshipListClient struct {
lastList *userv1.ListCPRelationshipsRequest
}
func (f *fakeCPRelationshipListClient) ListCPApplications(context.Context, *userv1.ListCPApplicationsRequest) (*userv1.ListCPApplicationsResponse, error) {
return &userv1.ListCPApplicationsResponse{}, nil
}
func (f *fakeCPRelationshipListClient) AcceptCPApplication(context.Context, *userv1.AcceptCPApplicationRequest) (*userv1.AcceptCPApplicationResponse, error) {
return &userv1.AcceptCPApplicationResponse{}, nil
}
func (f *fakeCPRelationshipListClient) RejectCPApplication(context.Context, *userv1.RejectCPApplicationRequest) (*userv1.RejectCPApplicationResponse, error) {
return &userv1.RejectCPApplicationResponse{}, nil
}
func (f *fakeCPRelationshipListClient) ListCPRelationships(_ context.Context, req *userv1.ListCPRelationshipsRequest) (*userv1.ListCPRelationshipsResponse, error) {
f.lastList = req
return &userv1.ListCPRelationshipsResponse{}, nil
}
func (f *fakeCPRelationshipListClient) ListCPIntimacyLeaderboard(context.Context, *userv1.ListCPIntimacyLeaderboardRequest) (*userv1.ListCPIntimacyLeaderboardResponse, error) {
return &userv1.ListCPIntimacyLeaderboardResponse{}, nil
}
func (f *fakeCPRelationshipListClient) PrepareBreakCPRelationship(context.Context, *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) {
return &userv1.PrepareBreakCPRelationshipResponse{}, nil
}
func (f *fakeCPRelationshipListClient) ConfirmBreakCPRelationship(context.Context, *userv1.ConfirmBreakCPRelationshipRequest) (*userv1.ConfirmBreakCPRelationshipResponse, error) {
return &userv1.ConfirmBreakCPRelationshipResponse{}, nil
}
func (f *fakeCPRelationshipListClient) CancelBreakCPRelationship(context.Context, *userv1.CancelBreakCPRelationshipRequest) (*userv1.CancelBreakCPRelationshipResponse, error) {
return &userv1.CancelBreakCPRelationshipResponse{}, nil
}
func TestListCPRelationshipsQueryUserIDOverrideAndFallback(t *testing.T) {
cases := []struct {
name string
target string
wantUserID int64
}{
{
name: "valid query user id",
target: "/api/v1/cp/relationships?user_id=10002&relation_type=brother&page=3&page_size=7",
wantUserID: 10002,
},
{
name: "missing query user id keeps current user",
target: "/api/v1/cp/relationships?relation_type=brother&page=3&page_size=7",
wantUserID: 10001,
},
{
name: "invalid query user id keeps current user",
target: "/api/v1/cp/relationships?user_id=abc&relation_type=brother&page=3&page_size=7",
wantUserID: 10001,
},
{
name: "non positive query user id keeps current user",
target: "/api/v1/cp/relationships?user_id=0&relation_type=brother&page=3&page_size=7",
wantUserID: 10001,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := &fakeCPRelationshipListClient{}
handler := New(Config{UserCPClient: client})
request := httptest.NewRequest(http.MethodGet, tc.target, nil)
request = request.WithContext(auth.WithUserID(request.Context(), 10001))
recorder := httptest.NewRecorder()
handler.listCPRelationships(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
if client.lastList == nil {
t.Fatalf("ListCPRelationships was not called")
}
if client.lastList.GetUserId() != tc.wantUserID {
t.Fatalf("user_id = %d, want %d", client.lastList.GetUserId(), tc.wantUserID)
}
if client.lastList.GetRelationType() != "brother" || client.lastList.GetPage() != 3 || client.lastList.GetPageSize() != 7 {
t.Fatalf("query fields not preserved: relation_type=%q page=%d page_size=%d", client.lastList.GetRelationType(), client.lastList.GetPage(), client.lastList.GetPageSize())
}
})
}
}
func TestCPRelationshipFromProtoIncludesLevelProgress(t *testing.T) {
item := &userv1.CPRelationship{
RelationshipId: "cp_rel:1:2",

View File

@ -427,7 +427,10 @@ type SendGift struct {
TargetType string `json:"target_type"`
// TargetUserIDs 是新协议目标用户集合target_type=user 时允许多个房间内用户。
TargetUserIDs []int64 `json:"target_user_ids,omitempty"`
// TargetUserID 是兼容单目标字段和批量主目标,所有接收方仍要求在房间内。
// RequestedTargetUserIDs 保留客户端本次请求的原始目标集合batch-send 会把离房目标从 TargetUserIDs 裁掉,
// 但幂等比较仍必须识别同一个客户端动作,不能因为提交 payload 只保存实际送达目标而把重试判成冲突。
RequestedTargetUserIDs []int64 `json:"requested_target_user_ids,omitempty"`
// TargetUserID 是兼容单目标字段和批量主目标;普通 SendGift 严格要求在房间内SendGiftBatch 可先裁掉离房目标。
TargetUserID int64 `json:"target_user_id"`
// GiftID 是礼物配置标识,具体配置有效性当前由调用方和 wallet 链路保证。
GiftID string `json:"gift_id"`
@ -646,11 +649,26 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
// 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。
delete(values, "sender_display_profile")
delete(values, "target_display_profiles")
if requestedTargetUserIDs, ok := values["requested_target_user_ids"]; ok {
// SendGiftBatch 成功提交时只把实际送达目标写入 target_user_ids幂等比较必须回到客户端原始目标集合
// 否则同一 command_id 的正常重试会因为 presence 过滤结果不同而被误判为 payload conflict。
values["target_user_ids"] = requestedTargetUserIDs
if firstTargetUserID, exists := firstJSONNumber(requestedTargetUserIDs); exists {
values["target_user_id"] = firstTargetUserID
}
delete(values, "requested_target_user_ids")
}
// host scope 是 gateway 入站读取的工资快照;提交后目标过滤可能裁掉部分 scope同一 command_id 重试不应因此冲突。
delete(values, "target_host_scopes")
delete(values, "target_is_host")
delete(values, "target_host_region_id")
delete(values, "target_agency_owner_user_id")
// SendGift 的以下字段由 wallet-service 结算后写入 command log不属于客户端请求幂等语义。
delete(values, "billing_receipt_id")
delete(values, "coin_spent")
delete(values, "gift_point_added")
delete(values, "heat_value")
delete(values, "target_gift_values")
delete(values, "price_version")
delete(values, "gift_type_code")
delete(values, "host_period_diamond_added")
@ -697,6 +715,23 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
return json.Marshal(values)
}
func firstJSONNumber(value any) (any, bool) {
items, ok := value.([]any)
if !ok || len(items) == 0 {
return nil, false
}
switch typed := items[0].(type) {
case float64:
return typed, true
case int64:
return typed, true
case int:
return typed, true
default:
return nil, false
}
}
// Deserialize 按命令类型恢复命令载荷。
func Deserialize(commandType string, payload []byte) (Command, error) {
var cmd Command

View File

@ -88,6 +88,7 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
if cmd.TargetType == "" {
cmd.TargetType = "user"
}
cmd.RequestedTargetUserIDs = append([]int64(nil), cmd.TargetUserIDs...)
if cmd.TargetType == "user" && len(cmd.TargetUserIDs) > 0 {
cmd.TargetUserID = cmd.TargetUserIDs[0]
}
@ -106,15 +107,22 @@ func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
if len(f.cmd.TargetUserIDs) == 0 || f.cmd.TargetUserID <= 0 {
return xerr.New(xerr.InvalidArgument, "target_user_ids is required")
}
if f.batchDisplayEnabled && len(f.cmd.TargetUserIDs) < 2 {
// batch-send 是多人展示入口,必须明确至少两个收礼目标;单目标继续走普通 SendGift避免展示协议和账务事实混用。
return xerr.New(xerr.InvalidArgument, "batch gift requires multiple targets")
}
for _, targetUserID := range f.cmd.TargetUserIDs {
if _, exists := current.OnlineUsers[targetUserID]; !exists {
// 每个接收方都必须在房间 presence 内;批量扣费前先全部校验,避免钱包出现无房间表现的入账
if f.batchDisplayEnabled {
if len(f.cmd.RequestedTargetUserIDs) < 2 {
// batch-send 只承载“全部人/多人”语义;过滤后可以只剩一个真实送达目标,但原始请求不能退化成单人入口。
return xerr.New(xerr.InvalidArgument, "batch target_user_ids requires at least two users")
}
if !f.retainOnlineBatchTargets(current) {
// 全部目标都已离房时不能做成功空操作,也不能扣费
return xerr.New(xerr.NotFound, "target not in room")
}
} else {
for _, targetUserID := range f.cmd.TargetUserIDs {
if _, exists := current.OnlineUsers[targetUserID]; !exists {
// 普通 SendGift 继续 fail-close避免旧单目标入口把“目标不存在”静默吞掉。
return xerr.New(xerr.NotFound, "target not in room")
}
}
}
if f.robot.Enabled {
if err := requireRobotGiftParticipants(current, f.cmd.ActorUserID(), f.cmd.TargetUserIDs, f.robot.RobotUserIDs, !f.robot.RealRoomHeat); err != nil {
@ -132,6 +140,49 @@ func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
return nil
}
func (f *giftFlow) retainOnlineBatchTargets(current *state.RoomState) bool {
if f == nil || current == nil || len(f.cmd.TargetUserIDs) == 0 {
return false
}
kept := make([]int64, 0, len(f.cmd.TargetUserIDs))
for _, targetUserID := range f.cmd.TargetUserIDs {
if _, exists := current.OnlineUsers[targetUserID]; exists {
kept = append(kept, targetUserID)
}
}
if len(kept) == 0 {
return false
}
if len(kept) == len(f.cmd.TargetUserIDs) {
return true
}
originalPrimaryTargetUserID := f.cmd.TargetUserID
primaryHostScope, hasPrimaryHostScope := giftTargetHostScopeForExact(f.cmd.TargetHostScopes, kept[0])
// 后续 wallet、幸运礼物、RoomGiftSent、RoomGiftBatchSent 和 command log 都只能看到实际送达目标。
f.cmd.TargetUserIDs = kept
f.cmd.TargetUserID = kept[0]
f.cmd.TargetHostScopes = filterGiftTargetHostScopes(f.cmd.TargetHostScopes, kept)
f.cmd.TargetDisplayProfiles = filterGiftDisplayProfiles(f.cmd.TargetDisplayProfiles, kept)
if hasPrimaryHostScope {
f.cmd.TargetIsHost = primaryHostScope.TargetIsHost
f.cmd.TargetHostRegionID = primaryHostScope.TargetHostRegionID
f.cmd.TargetAgencyOwnerUserID = primaryHostScope.TargetAgencyOwnerUserID
} else if originalPrimaryTargetUserID != f.cmd.TargetUserID {
// 旧单目标 host 字段只描述原主目标;主目标被跳过时必须清空,避免工资入账串到剩余用户。
f.cmd.TargetIsHost = false
f.cmd.TargetHostRegionID = 0
f.cmd.TargetAgencyOwnerUserID = 0
}
f.settledCommand.TargetUserIDs = append([]int64(nil), f.cmd.TargetUserIDs...)
f.settledCommand.TargetUserID = f.cmd.TargetUserID
f.settledCommand.TargetIsHost = f.cmd.TargetIsHost
f.settledCommand.TargetHostRegionID = f.cmd.TargetHostRegionID
f.settledCommand.TargetAgencyOwnerUserID = f.cmd.TargetAgencyOwnerUserID
f.settledCommand.TargetHostScopes = append([]command.GiftTargetHostScope(nil), f.cmd.TargetHostScopes...)
f.settledCommand.TargetDisplayProfiles = append([]command.GiftDisplayProfile(nil), f.cmd.TargetDisplayProfiles...)
return true
}
func (f *giftFlow) prepareRoomFacts() error {
roomMeta, exists, err := f.s.repository.GetRoomMeta(f.ctx, f.cmd.RoomID())
if err != nil {

View File

@ -72,6 +72,56 @@ func giftDisplayProfilesFromProto(items []*roomv1.SendGiftDisplayProfile) []comm
return result
}
func filterGiftTargetHostScopes(scopes []command.GiftTargetHostScope, targetUserIDs []int64) []command.GiftTargetHostScope {
if len(scopes) == 0 || len(targetUserIDs) == 0 {
return nil
}
allowed := giftTargetUserIDSet(targetUserIDs)
result := make([]command.GiftTargetHostScope, 0, len(scopes))
for _, scope := range scopes {
if allowed[scope.TargetUserID] {
result = append(result, scope)
}
}
return result
}
func giftTargetHostScopeForExact(scopes []command.GiftTargetHostScope, targetUserID int64) (command.GiftTargetHostScope, bool) {
if targetUserID <= 0 {
return command.GiftTargetHostScope{}, false
}
for _, scope := range scopes {
if scope.TargetUserID == targetUserID {
return scope, true
}
}
return command.GiftTargetHostScope{}, false
}
func filterGiftDisplayProfiles(profiles []command.GiftDisplayProfile, targetUserIDs []int64) []command.GiftDisplayProfile {
if len(profiles) == 0 || len(targetUserIDs) == 0 {
return nil
}
allowed := giftTargetUserIDSet(targetUserIDs)
result := make([]command.GiftDisplayProfile, 0, len(profiles))
for _, profile := range profiles {
if allowed[profile.UserID] {
result = append(result, profile)
}
}
return result
}
func giftTargetUserIDSet(targetUserIDs []int64) map[int64]bool {
result := make(map[int64]bool, len(targetUserIDs))
for _, targetUserID := range targetUserIDs {
if targetUserID > 0 {
result[targetUserID] = true
}
}
return result
}
func giftDisplayProfileForUser(profiles []command.GiftDisplayProfile, userID int64) command.GiftDisplayProfile {
if userID <= 0 {
return command.GiftDisplayProfile{}

View File

@ -14,6 +14,8 @@ import (
"hyapp/services/room-service/internal/router"
)
const roomMutationCommitTimeout = 5 * time.Second
func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayable bool, mutate func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error)) (mutationResult, error) {
startedAt := time.Now()
var idempotencyMS int64
@ -84,7 +86,8 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
// 这类事件不能进入 durable outbox否则会污染活跃统计和补偿消费。
directIMRecords = append(directIMRecords, result.directIMRecords...)
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
commitCtx, commitCancel := roomMutationCommitContext(ctx)
err := s.repository.SaveMutation(commitCtx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
@ -107,7 +110,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
CloseInfo: result.closeInfo,
RoomUserGiftStats: result.roomUserGiftStats,
RoomWeeklyContribution: result.roomWeeklyContribution,
}); err != nil {
})
commitCancel()
if err != nil {
return nil, err
}
saveMutationMS += elapsedMS(saveStartedAt)
@ -134,7 +139,8 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
return nil, err
}
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
commitCtx, commitCancel := roomMutationCommitContext(ctx)
err := s.repository.SaveMutation(commitCtx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
@ -147,7 +153,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
}); err != nil {
})
commitCancel()
if err != nil {
return nil, err
}
saveMutationMS += elapsedMS(saveStartedAt)
@ -189,6 +197,12 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
return result, nil
}
func roomMutationCommitContext(ctx context.Context) (context.Context, context.CancelFunc) {
// 钱包扣费等外部副作用完成后command log/outbox 是恢复和补偿的权威来源;
// 这里保留 app_code 等 context values但不继承 HTTP 断连取消信号,避免已扣费命令在提交阶段被调用方取消打断。
return context.WithTimeout(context.WithoutCancel(ctx), roomMutationCommitTimeout)
}
func (s *Service) commandPayloadForIdempotency(ctx context.Context, cmd command.Command) ([]byte, bool, error) {
// 先序列化当前命令,后续无论首次提交还是冲突校验都使用同一份 payload。
payload, err := command.Serialize(cmd)

View File

@ -15,7 +15,9 @@ import (
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/roommq"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
"hyapp/services/room-service/internal/room/command"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/room/state"
"hyapp/services/room-service/internal/router"
@ -447,6 +449,320 @@ func TestBatchSendGiftPublishesSingleBatchDisplayAndKeepsDurableFacts(t *testing
}
}
func TestBatchSendGiftSkipsTargetsNoLongerInRoom(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-online-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980},
{BillingReceiptId: "receipt-online-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 31, GiftTypeCode: "normal", BalanceAfter: 950},
}}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
roomID := "room-batch-skip-offline"
ownerID := int64(8101)
firstTargetID := int64(8102)
offlineTargetID := int64(8103)
secondTargetID := int64(8104)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
joinRocketRoom(t, ctx, svc, roomID, offlineTargetID)
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlineTargetID, "batch-skip-offline-leave")}); err != nil {
t.Fatalf("offline target leave failed: %v", err)
}
before := outboxEventCounts(t, ctx, repository)
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "batch-skip-offline"),
TargetType: "user",
TargetUserIds: []int64{firstTargetID, offlineTargetID, secondTargetID},
GiftId: "clover",
GiftCount: 1,
TargetHostScopes: []*roomv1.SendGiftTargetHostScope{
{TargetUserId: firstTargetID, TargetIsHost: true, TargetHostRegionId: 8801, TargetAgencyOwnerUserId: 30001},
{TargetUserId: offlineTargetID, TargetIsHost: true, TargetHostRegionId: 8802, TargetAgencyOwnerUserId: 30002},
{TargetUserId: secondTargetID, TargetIsHost: true, TargetHostRegionId: 8803, TargetAgencyOwnerUserId: 30003},
},
TargetDisplayProfiles: []*roomv1.SendGiftDisplayProfile{
{UserId: firstTargetID, Username: "First Online"},
{UserId: offlineTargetID, Username: "Offline"},
{UserId: secondTargetID, Username: "Second Online"},
},
})
if err != nil {
t.Fatalf("batch gift with skipped target failed: %v", err)
}
if wallet.lastBatch == nil || len(wallet.lastBatch.GetTargets()) != 2 {
t.Fatalf("batch wallet must only include online targets: %+v", wallet.lastBatch)
}
if wallet.lastBatch.GetTargets()[0].GetTargetUserId() != firstTargetID || wallet.lastBatch.GetTargets()[1].GetTargetUserId() != secondTargetID {
t.Fatalf("batch wallet targets mismatch after filtering: %+v", wallet.lastBatch.GetTargets())
}
if wallet.lastBatch.GetTargets()[0].GetTargetHostRegionId() != 8801 || wallet.lastBatch.GetTargets()[1].GetTargetHostRegionId() != 8803 {
t.Fatalf("batch wallet host scopes must be filtered with targets: %+v", wallet.lastBatch.GetTargets())
}
if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) || len(display.GetTargets()) != 2 {
t.Fatalf("batch display must expose actual delivered targets: %+v", display)
}
after := outboxEventCounts(t, ctx, repository)
if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 2 {
t.Fatalf("filtered batch send must keep one durable RoomGiftSent per delivered target, delta=%d counts=%+v", delta, after)
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
batchEvents := roomDirectIMGiftBatchSentEvents(t, directIM)
if len(batchEvents) != 1 || !slices.Equal(batchEvents[0].GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) || len(batchEvents[0].GetTargets()) != 2 {
t.Fatalf("direct IM batch event must only include delivered targets: %+v", batchEvents)
}
record, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-skip-offline").GetCommandId())
if err != nil {
t.Fatalf("get batch gift command log failed: %v", err)
}
if !exists {
t.Fatalf("batch gift command log must be persisted")
}
decoded, err := command.Deserialize(command.SendGift{}.Type(), record.Payload)
if err != nil {
t.Fatalf("decode batch gift command log failed: %v", err)
}
giftCmd := decoded.(*command.SendGift)
if !slices.Equal(giftCmd.TargetUserIDs, []int64{firstTargetID, secondTargetID}) || !slices.Equal(giftCmd.RequestedTargetUserIDs, []int64{firstTargetID, offlineTargetID, secondTargetID}) {
t.Fatalf("command log must keep delivered and requested targets separately: %+v", giftCmd)
}
if _, exists := giftCmd.TargetGiftValues[offlineTargetID]; exists {
t.Fatalf("command log must not write gift value for skipped target: %+v", giftCmd.TargetGiftValues)
}
}
func TestBatchSendGiftSkipsOfflineTargetAndFallsBackToSingleDebit(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 30, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-single-online", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980},
}}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
roomID := "room-batch-skip-to-single"
ownerID := int64(8201)
onlineTargetID := int64(8202)
offlineTargetID := int64(8203)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, onlineTargetID)
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "batch-skip-to-single"),
TargetType: "user",
TargetUserIds: []int64{onlineTargetID, offlineTargetID},
GiftId: "clover",
GiftCount: 1,
})
if err != nil {
t.Fatalf("batch gift filtered to one target failed: %v", err)
}
if wallet.lastBatch != nil || wallet.lastDebit == nil || wallet.lastDebit.GetTargetUserId() != onlineTargetID {
t.Fatalf("filtered single target must use single wallet debit: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch)
}
if wallet.lastDebit.GetCommandId() != rocketMeta(roomID, ownerID, "batch-skip-to-single").GetCommandId() {
t.Fatalf("filtered single target must preserve root command id for wallet idempotency: %+v", wallet.lastDebit)
}
if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{onlineTargetID}) || len(display.GetTargets()) != 1 {
t.Fatalf("batch response must report the single delivered target: %+v", display)
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1})
}
func TestBatchSendGiftIdempotencyUsesRequestedTargetsAfterPrimarySkipped(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 35, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-idempotent-online", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980},
}}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
roomID := "room-batch-skip-primary-idempotency"
ownerID := int64(8231)
offlinePrimaryID := int64(8232)
onlineTargetID := int64(8233)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, offlinePrimaryID)
joinRocketRoom(t, ctx, svc, roomID, onlineTargetID)
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlinePrimaryID, "batch-skip-primary-leave")}); err != nil {
t.Fatalf("primary target leave failed: %v", err)
}
req := &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "batch-skip-primary"),
TargetType: "user",
TargetUserIds: []int64{offlinePrimaryID, onlineTargetID},
TargetUserId: offlinePrimaryID,
TargetIsHost: true,
TargetHostRegionId: 8802,
TargetAgencyOwnerUserId: 30002,
GiftId: "clover",
GiftCount: 1,
}
resp, err := svc.SendGiftBatch(ctx, req)
if err != nil {
t.Fatalf("first batch gift with skipped primary failed: %v", err)
}
if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{onlineTargetID}) {
t.Fatalf("first response must report delivered online target: %+v", display)
}
if wallet.lastDebit == nil || wallet.lastDebit.GetTargetUserId() != onlineTargetID || len(wallet.debitRequests) != 1 {
t.Fatalf("first send must debit only delivered target once: single=%+v requests=%+v", wallet.lastDebit, wallet.debitRequests)
}
if _, err := svc.SendGiftBatch(ctx, req); err != nil {
t.Fatalf("idempotent retry must compare requested targets, not filtered payload: %v", err)
}
if len(wallet.debitRequests) != 1 {
t.Fatalf("idempotent retry must not call wallet again: requests=%+v", wallet.debitRequests)
}
}
func TestBatchSendGiftRejectsOriginalSingleTarget(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 45, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-single-batch", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980},
}}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
roomID := "room-batch-single-online"
ownerID := int64(8251)
targetID := int64(8252)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID)
before := outboxEventCounts(t, ctx, repository)
_, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "batch-single-online"),
TargetType: "user",
TargetUserIds: []int64{targetID},
GiftId: "clover",
GiftCount: 1,
})
if err == nil || xerr.CodeOf(err) != xerr.InvalidArgument {
t.Fatalf("single-target batch gift must be rejected before wallet debit, err=%v", err)
}
if wallet.lastBatch != nil || wallet.lastDebit != nil {
t.Fatalf("single-target batch must not call wallet: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch)
}
after := outboxEventCounts(t, ctx, repository)
if after["RoomGiftSent"] != before["RoomGiftSent"] || after["RoomGiftBatchSent"] != before["RoomGiftBatchSent"] {
t.Fatalf("single-target batch must not write gift outbox: before=%+v after=%+v", before, after)
}
if _, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-single-online").GetCommandId()); err != nil || exists {
t.Fatalf("single-target batch must not write command log: exists=%t err=%v", exists, err)
}
}
func TestBatchSendGiftFailsWhenAllTargetsLeftRoom(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 2, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{}
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher())
roomID := "room-batch-all-targets-left"
ownerID := int64(8301)
firstTargetID := int64(8302)
secondTargetID := int64(8303)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, firstTargetID, "batch-all-left-first")}); err != nil {
t.Fatalf("first target leave failed: %v", err)
}
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, secondTargetID, "batch-all-left-second")}); err != nil {
t.Fatalf("second target leave failed: %v", err)
}
before := outboxEventCounts(t, ctx, repository)
_, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "batch-all-left"),
TargetType: "user",
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "clover",
GiftCount: 1,
})
if err == nil || xerr.CodeOf(err) != xerr.NotFound {
t.Fatalf("batch gift with no online targets must fail with NOT_FOUND, err=%v", err)
}
if wallet.lastDebit != nil || wallet.lastBatch != nil {
t.Fatalf("failed batch gift must not call wallet: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch)
}
after := outboxEventCounts(t, ctx, repository)
if after["RoomGiftSent"] != before["RoomGiftSent"] || after["RoomGiftBatchSent"] != before["RoomGiftBatchSent"] {
t.Fatalf("failed batch gift must not write gift outbox: before=%+v after=%+v", before, after)
}
if _, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-all-left").GetCommandId()); err != nil || exists {
t.Fatalf("failed batch gift must not write command log: exists=%t err=%v", exists, err)
}
}
func TestBatchLuckyGiftSkipsOfflineTargetsBeforeDraw(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 2, 30, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-lucky-online-1", CoinSpent: 60, ChargeAmount: 60, HeatValue: 60, GiftTypeCode: "super_lucky", BalanceAfter: 940},
{BillingReceiptId: "receipt-lucky-online-2", CoinSpent: 40, ChargeAmount: 40, HeatValue: 40, GiftTypeCode: "super_lucky", BalanceAfter: 900},
}}
luckyGift := &batchLuckyGiftTestClient{createdAtBase: now.Now().UnixMilli()}
directIM := newRecordingRoomDirectIMPublisher()
svc := roomservice.New(roomservice.Config{
NodeID: "node-batch-lucky-skip-offline-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: now,
RoomDisplayPublisher: directIM,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
roomID := "room-batch-lucky-skip-offline"
ownerID := int64(8401)
firstTargetID := int64(8402)
offlineTargetID := int64(8403)
secondTargetID := int64(8404)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
joinRocketRoom(t, ctx, svc, roomID, offlineTargetID)
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlineTargetID, "batch-lucky-skip-offline-leave")}); err != nil {
t.Fatalf("offline target leave failed: %v", err)
}
resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "batch-lucky-skip-offline"),
TargetType: "user",
TargetUserIds: []int64{firstTargetID, offlineTargetID, secondTargetID},
GiftId: "super-lucky-clover",
GiftCount: 1,
PoolId: "super_lucky",
})
if err != nil {
t.Fatalf("batch lucky gift with skipped target failed: %v", err)
}
if checks, singleDraws, batchDraws, batchCalls := luckyGift.stats(); checks != 1 || singleDraws != 0 || batchDraws != 2 || batchCalls != 1 {
t.Fatalf("batch lucky must draw only online targets: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls)
}
if len(resp.GetLuckyGifts()) != 2 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 {
t.Fatalf("batch lucky response must only include delivered targets: %+v", resp)
}
if !slices.Equal(resp.GetBatchDisplay().GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) {
t.Fatalf("batch lucky display target ids mismatch: %+v", resp.GetBatchDisplay())
}
}
func TestSendGiftDisplayModeDoesNotTriggerBatchDisplay(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -483,6 +799,35 @@ func TestSendGiftDisplayModeDoesNotTriggerBatchDisplay(t *testing.T) {
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftSent": 2})
}
func TestSendGiftKeepsStrictTargetPresenceValidation(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 3, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{}
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher())
roomID := "room-normal-gift-strict-presence"
ownerID := int64(8501)
onlineTargetID := int64(8502)
offlineTargetID := int64(8503)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, onlineTargetID)
_, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "normal-gift-strict-presence"),
TargetType: "user",
TargetUserIds: []int64{onlineTargetID, offlineTargetID},
GiftId: "clover",
GiftCount: 1,
})
if xerr.CodeOf(err) != xerr.NotFound {
t.Fatalf("plain SendGift must keep strict target presence validation, err=%v", err)
}
if wallet.lastDebit != nil || wallet.lastBatch != nil || len(wallet.debitRequests) != 0 {
t.Fatalf("plain SendGift strict validation must run before wallet debit: single=%+v batch=%+v requests=%+v", wallet.lastDebit, wallet.lastBatch, wallet.debitRequests)
}
}
func TestBatchLuckyGiftUsesSingleActivityBatchDrawAndDebitCoinBalance(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)

View File

@ -28,6 +28,8 @@ third_party:
project_id: "lalu-party"
huwaa:
project_id: "huwaa-6f3aa"
yumi:
project_id: "yumi"
invite_recharge_worker:
enabled: true
mic_time_worker:

View File

@ -28,6 +28,8 @@ third_party:
project_id: "lalu-party"
huwaa:
project_id: "huwaa-6f3aa"
yumi:
project_id: "YUMI_FIREBASE_PROJECT_ID"
invite_recharge_worker:
enabled: true
mic_time_worker:

View File

@ -29,6 +29,8 @@ third_party:
project_id: "lalu-party"
huwaa:
project_id: "huwaa-6f3aa"
yumi:
project_id: "yumi"
invite_recharge_worker:
enabled: true
mic_time_worker:

View File

@ -24,7 +24,8 @@ INSERT INTO apps (app_code, app_name, package_name, platform, status, created_at
VALUES
('lalu', 'Lalu', 'com.org.laluparty', '', 'active', 0, 0),
('huwaa', 'Huwaa', 'com.app.huwaa', '', 'active', 0, 0),
('fami', 'Fami', 'com.chat.fami', '', 'active', 0, 0)
('fami', 'Fami', 'com.chat.fami', '', 'active', 0, 0),
('yumi', 'Yumi', 'com.org.yumi', '', 'active', 0, 0)
ON DUPLICATE KEY UPDATE
app_name = VALUES(app_name),
package_name = VALUES(package_name),
@ -779,6 +780,7 @@ CREATE TABLE IF NOT EXISTS manager_profiles (
can_grant_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送头像框',
can_grant_vehicle TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送座驾',
can_grant_badge TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送徽章',
can_grant_vip TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送 VIP',
can_update_user_level TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心升级用户等级',
can_add_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',

View File

@ -206,6 +206,7 @@ type ManagerProfile struct {
CanGrantAvatarFrame bool
CanGrantVehicle bool
CanGrantBadge bool
CanGrantVIP bool
CanUpdateUserLevel bool
CanAddBDLeader bool
CanAddAdmin bool

View File

@ -13,6 +13,7 @@ const (
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
CapabilityManagerGrantBadge = "manager_grant_badge"
CapabilityManagerGrantVIP = "manager_grant_vip"
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
CapabilityManagerAddAdmin = "manager_add_admin"
@ -45,6 +46,7 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
CapabilityManagerGrantAvatarFrame,
CapabilityManagerGrantVehicle,
CapabilityManagerGrantBadge,
CapabilityManagerGrantVIP,
CapabilityManagerUpdateUserLevel,
CapabilityManagerAddBDLeader,
CapabilityManagerAddAdmin,

View File

@ -129,6 +129,15 @@ func TestCheckBusinessCapabilityRequiresActiveManagerProfile(t *testing.T) {
t.Fatalf("active manager should be allowed: allowed=%v reason=%q", allowed, reason)
}
repository.SetManagerGrantVIP(101, false)
allowed, reason, err = svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerGrantVIP)
if err != nil {
t.Fatalf("CheckBusinessCapability for vip grant failed: %v", err)
}
if allowed || reason != "manager_capability_required" {
t.Fatalf("manager without vip grant should be denied: allowed=%v reason=%q", allowed, reason)
}
allowed, reason, err = svc.CheckBusinessCapability(ctx, 202, hostservice.CapabilityManagerResourceGrant)
if err != nil {
t.Fatalf("CheckBusinessCapability for agency owner failed: %v", err)

View File

@ -11,6 +11,7 @@ import (
const (
BusinessSceneManagerResourceGrant = "manager_resource_grant"
BusinessSceneManagerVIPGrant = "manager_vip_grant"
defaultBusinessLookupPageSize = 20
maxBusinessLookupPageSize = 20
@ -20,7 +21,7 @@ const (
// service 层仍校验 scene 和 keyword避免未来入口把它误用成全局用户搜索。
func (s *Service) BusinessUserLookup(ctx context.Context, scene string, keyword string, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) {
scene = strings.ToLower(strings.TrimSpace(scene))
if scene != BusinessSceneManagerResourceGrant {
if scene != BusinessSceneManagerResourceGrant && scene != BusinessSceneManagerVIPGrant {
return nil, xerr.New(xerr.InvalidArgument, "business lookup scene is invalid")
}
if s.userRepository == nil {

View File

@ -361,6 +361,8 @@ func (r *Repository) HasActiveManagerCapability(ctx context.Context, userID int6
columnSQL = "can_grant_vehicle = 1"
case hostservice.CapabilityManagerGrantBadge:
columnSQL = "can_grant_badge = 1"
case hostservice.CapabilityManagerGrantVIP:
columnSQL = "can_grant_vip = 1"
case hostservice.CapabilityManagerUpdateUserLevel:
columnSQL = "can_update_user_level = 1"
case hostservice.CapabilityManagerAddBDLeader:

View File

@ -822,27 +822,28 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
profile.UpdatedAtMs = nowMs
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO manager_profiles (
user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
can_grant_badge = VALUES(can_grant_badge),
can_update_user_level = VALUES(can_update_user_level),
INSERT INTO manager_profiles (
user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
can_grant_vip, can_update_user_level, can_add_bd_leader, can_add_admin, can_add_superadmin, can_block_user, can_transfer_user_country,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
contact_info = VALUES(contact_info),
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
can_grant_vehicle = VALUES(can_grant_vehicle),
can_grant_badge = VALUES(can_grant_badge),
can_grant_vip = VALUES(can_grant_vip),
can_update_user_level = VALUES(can_update_user_level),
can_add_bd_leader = VALUES(can_add_bd_leader),
can_add_admin = VALUES(can_add_admin),
can_add_superadmin = VALUES(can_add_superadmin),
can_block_user = VALUES(can_block_user),
can_transfer_user_country = VALUES(can_transfer_user_country),
updated_at_ms = VALUES(updated_at_ms)
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanUpdateUserLevel),
defaultTrue(profile.CanAddBDLeader), defaultTrue(profile.CanAddAdmin),
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanGrantVIP),
defaultTrue(profile.CanUpdateUserLevel), defaultTrue(profile.CanAddBDLeader), defaultTrue(profile.CanAddAdmin),
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser), defaultTrue(profile.CanTransferCountry),
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil {
@ -850,6 +851,19 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
}
}
func (r *Repository) SetManagerGrantVIP(userID int64, enabled bool) {
r.t.Helper()
_, err := r.schema.DB.ExecContext(context.Background(), `
UPDATE manager_profiles
SET can_grant_vip = ?
WHERE user_id = ?
`, enabled, userID)
if err != nil {
r.t.Fatalf("set manager grant vip %d failed: %v", userID, err)
}
}
// PutAgency seeds an active Agency fact for host-domain tests.
func (r *Repository) PutAgency(agency hostdomain.Agency) {
r.t.Helper()

View File

@ -408,6 +408,8 @@ func capabilityForBusinessLookupScene(scene string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(scene)) {
case userservice.BusinessSceneManagerResourceGrant:
return hostservice.CapabilityManagerResourceGrant, true
case userservice.BusinessSceneManagerVIPGrant:
return hostservice.CapabilityManagerGrantVIP, true
default:
return "", false
}

View File

@ -464,7 +464,8 @@ CREATE TABLE IF NOT EXISTS wallet_gift_prices (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, gift_id, price_version),
KEY idx_wallet_gift_prices_effective (app_code, gift_id, status, effective_at_ms)
KEY idx_wallet_gift_prices_effective (app_code, gift_id, status, effective_at_ms),
KEY idx_wallet_gift_prices_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包礼物价格表';
CREATE TABLE IF NOT EXISTS wallet_recharge_policies (
@ -922,6 +923,7 @@ CREATE TABLE IF NOT EXISTS resources (
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_resources_code (app_code, resource_code),
KEY idx_resources_type_status (app_code, resource_type, status, sort_order),
KEY idx_resources_type_updated (app_code, resource_type, updated_at_ms),
KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, grantable, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源表';
@ -1057,7 +1059,8 @@ CREATE TABLE IF NOT EXISTS gift_type_configs (
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, type_code),
UNIQUE KEY uk_gift_type_configs_tab_key (app_code, tab_key),
KEY idx_gift_type_configs_status_sort (app_code, status, sort_order)
KEY idx_gift_type_configs_status_sort (app_code, status, sort_order),
KEY idx_gift_type_configs_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物类型和 App 面板 tab 配置表';
CREATE TABLE IF NOT EXISTS gift_configs (
@ -1079,7 +1082,8 @@ CREATE TABLE IF NOT EXISTS gift_configs (
PRIMARY KEY (app_code, gift_id),
KEY idx_gift_configs_resource (app_code, resource_id),
KEY idx_gift_configs_status_sort (app_code, status, sort_order),
KEY idx_gift_configs_effective (app_code, status, effective_from_ms, effective_to_ms)
KEY idx_gift_configs_effective (app_code, status, effective_from_ms, effective_to_ms),
KEY idx_gift_configs_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物配置表';
SET @ddl := IF(
@ -1134,9 +1138,56 @@ CREATE TABLE IF NOT EXISTS gift_config_regions (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, gift_id, region_id),
KEY idx_gift_config_regions_region (app_code, region_id, gift_id)
KEY idx_gift_config_regions_region (app_code, region_id, gift_id),
KEY idx_gift_config_regions_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物配置区域表';
-- gateway 礼物面板每次请求只读取 owner 版本号;这些索引让版本探测保持为单租户索引最大值查询。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_gift_prices' AND INDEX_NAME = 'idx_wallet_gift_prices_updated') = 0,
'ALTER TABLE wallet_gift_prices ADD INDEX idx_wallet_gift_prices_updated (app_code, updated_at_ms)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resources' AND INDEX_NAME = 'idx_resources_type_updated') = 0,
'ALTER TABLE resources ADD INDEX idx_resources_type_updated (app_code, resource_type, updated_at_ms)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_type_configs' AND INDEX_NAME = 'idx_gift_type_configs_updated') = 0,
'ALTER TABLE gift_type_configs ADD INDEX idx_gift_type_configs_updated (app_code, updated_at_ms)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_configs' AND INDEX_NAME = 'idx_gift_configs_updated') = 0,
'ALTER TABLE gift_configs ADD INDEX idx_gift_configs_updated (app_code, updated_at_ms)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_config_regions' AND INDEX_NAME = 'idx_gift_config_regions_updated') = 0,
'ALTER TABLE gift_config_regions ADD INDEX idx_gift_config_regions_updated (app_code, updated_at_ms)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS user_gift_wall (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '用户 ID',

View File

@ -165,6 +165,7 @@ type ResourceCatalogStore interface {
SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error)
ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error)
ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, error)
GetGiftCatalogVersion(ctx context.Context, appCode string) (int64, error)
CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error)
UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error)
SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error)

View File

@ -127,6 +127,15 @@ func (s *Service) ListGiftTypeConfigs(ctx context.Context, query resourcedomain.
return s.repository.ListGiftTypeConfigs(ctx, query)
}
func (s *Service) GetGiftCatalogVersion(ctx context.Context, appCode string) (int64, error) {
if s.repository == nil {
return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
return s.repository.GetGiftCatalogVersion(ctx, appCode)
}
func (s *Service) CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) {
if s.repository == nil {
return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")

View File

@ -35,6 +35,23 @@ func walletOutboxMessageFromRecord(record mysqlstorage.WalletOutboxRecord) walle
}
}
func waitForNextWalletVersionMillisecond(t *testing.T) {
t.Helper()
start := time.Now().UnixMilli()
for time.Now().UnixMilli() <= start {
time.Sleep(time.Millisecond)
}
}
func requireWalletVersionAdvanced(t *testing.T, change string, before int64, after int64) {
t.Helper()
if after <= before {
t.Fatalf("%s must advance gift catalog version: before=%d after=%d", change, before, after)
}
}
// TestDebitGiftUsesServerPriceWithoutGiftPointCredit 验证送礼只信服务端价格,收礼收入进入 COIN历史 GIFT_POINT 仍保持下线。
func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -3792,6 +3809,138 @@ func TestGiftTypeConfigDefaultsAndUpsert(t *testing.T) {
}
}
func TestGiftCatalogVersionTracksPanelConfigChanges(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
baseVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
if err != nil {
t.Fatalf("get empty gift catalog version failed: %v", err)
}
waitForNextWalletVersionMillisecond(t)
giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
AppCode: "lalu",
ResourceCode: "gift_catalog_version",
ResourceType: resourcedomain.TypeGift,
Name: "Version Gift",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create version gift resource failed: %v", err)
}
resourceVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
if err != nil {
t.Fatalf("get resource catalog version failed: %v", err)
}
requireWalletVersionAdvanced(t, "gift resource create", baseVersion, resourceVersion)
waitForNextWalletVersionMillisecond(t)
_, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
AppCode: "lalu",
GiftID: "catalog-version",
ResourceID: giftResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Version Gift",
PriceVersion: "v1",
CoinPrice: 5,
GiftTypeCode: resourcedomain.GiftTypeNormal,
RegionIDs: []int64{0},
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create version gift config failed: %v", err)
}
giftVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
if err != nil {
t.Fatalf("get gift config catalog version failed: %v", err)
}
requireWalletVersionAdvanced(t, "gift config create", resourceVersion, giftVersion)
waitForNextWalletVersionMillisecond(t)
_, err = svc.UpdateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
AppCode: "lalu",
GiftID: "catalog-version",
ResourceID: giftResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Version Gift Updated",
PriceVersion: "v2",
CoinPrice: 9,
GiftTypeCode: resourcedomain.GiftTypeNormal,
RegionIDs: []int64{0, 42},
OperatorUserID: 90002,
})
if err != nil {
t.Fatalf("update version gift config failed: %v", err)
}
priceRegionVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
if err != nil {
t.Fatalf("get gift price/region catalog version failed: %v", err)
}
requireWalletVersionAdvanced(t, "gift price and region update", giftVersion, priceRegionVersion)
waitForNextWalletVersionMillisecond(t)
_, err = svc.UpdateResource(ctx, resourcedomain.ResourceCommand{
AppCode: "lalu",
ResourceID: giftResource.ResourceID,
ResourceCode: giftResource.ResourceCode,
ResourceType: resourcedomain.TypeGift,
Name: "Version Gift Asset Updated",
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
UsageScopes: []string{"gift"},
AssetURL: "https://static.example.test/gift-version.png",
OperatorUserID: 90003,
})
if err != nil {
t.Fatalf("update version gift resource failed: %v", err)
}
resourceUpdateVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
if err != nil {
t.Fatalf("get updated resource catalog version failed: %v", err)
}
requireWalletVersionAdvanced(t, "gift resource update", priceRegionVersion, resourceUpdateVersion)
waitForNextWalletVersionMillisecond(t)
_, err = svc.UpsertGiftTypeConfig(ctx, resourcedomain.GiftTypeConfigCommand{
AppCode: "lalu",
TypeCode: resourcedomain.GiftTypeNormal,
Name: "Normal Gifts",
TabKey: resourcedomain.GiftTypeTabKeyNormal,
Status: resourcedomain.StatusActive,
SortOrder: 12,
OperatorUserID: 90004,
})
if err != nil {
t.Fatalf("update gift type config failed: %v", err)
}
typeVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
if err != nil {
t.Fatalf("get gift type catalog version failed: %v", err)
}
requireWalletVersionAdvanced(t, "gift type update", resourceUpdateVersion, typeVersion)
waitForNextWalletVersionMillisecond(t)
if _, err := svc.DeleteGiftConfig(ctx, resourcedomain.StatusCommand{
AppCode: "lalu",
StringID: "catalog-version",
OperatorUserID: 90005,
}); err != nil {
t.Fatalf("delete gift config failed: %v", err)
}
deleteVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
if err != nil {
t.Fatalf("get deleted gift catalog version failed: %v", err)
}
requireWalletVersionAdvanced(t, "gift config delete", typeVersion, deleteVersion)
}
// TestGiftConfigRegionFilter 验证礼物列表和送礼扣费都按区域配置校验region_id=0 作为 GLOBAL 兜底区域。
func TestGiftConfigRegionFilter(t *testing.T) {
repository := mysqltest.NewRepository(t)

View File

@ -72,6 +72,53 @@ func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.L
return items, total, nil
}
// GetGiftCatalogVersion returns the newest configuration timestamp that can change the App gift panel.
//
// Current owner rows cover create/update/status changes. Resource/GiftConfig outbox creation time is also part
// of the token because hard deletes or resource type changes can remove the row that would otherwise prove
// the cache is stale.
func (r *Repository) GetGiftCatalogVersion(ctx context.Context, appCode string) (int64, error) {
if r == nil || r.db == nil {
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, appCode)
appCode = appcode.FromContext(ctx)
var version int64
err := r.db.QueryRowContext(ctx, `
SELECT COALESCE(MAX(version_ms), 0)
FROM (
SELECT MAX(updated_at_ms) AS version_ms
FROM gift_configs
WHERE app_code = ?
UNION ALL
SELECT MAX(updated_at_ms) AS version_ms
FROM wallet_gift_prices
WHERE app_code = ?
UNION ALL
SELECT MAX(updated_at_ms) AS version_ms
FROM gift_type_configs
WHERE app_code = ?
UNION ALL
SELECT MAX(updated_at_ms) AS version_ms
FROM gift_config_regions
WHERE app_code = ?
UNION ALL
SELECT MAX(r.updated_at_ms) AS version_ms
FROM resources r
WHERE r.app_code = ?
AND r.resource_type = ?
UNION ALL
SELECT MAX(created_at_ms) AS version_ms
FROM wallet_outbox
WHERE app_code = ?
AND event_type IN ('GiftConfigChanged', 'ResourceChanged')
) gift_catalog_versions`,
appCode, appCode, appCode, appCode, appCode, resourcedomain.TypeGift, appCode,
).Scan(&version)
return version, err
}
func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resourcedomain.GiftConfig, error) {
row := r.db.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ?`, appcode.FromContext(ctx), giftID)
gift, err := scanGiftConfig(row)

View File

@ -159,6 +159,14 @@ func (s *Server) ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGift
return resp, nil
}
func (s *Server) GetGiftCatalogVersion(ctx context.Context, req *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
version, err := s.svc.GetGiftCatalogVersion(ctx, req.GetAppCode())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.GetGiftCatalogVersionResponse{Version: version}, nil
}
func (s *Server) CreateGiftConfig(ctx context.Context, req *walletv1.CreateGiftConfigRequest) (*walletv1.GiftConfigResponse, error) {
gift, err := s.svc.CreateGiftConfig(ctx, giftConfigCommandFromCreate(req))
if err != nil {