相关功能修复
This commit is contained in:
parent
dc44b0bf08
commit
4bd22c61d6
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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
1
go.mod
@ -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
3
go.sum
@ -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=
|
||||
|
||||
@ -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)
|
||||
|
||||
92
scripts/mysql/057_add_yumi_app.sql
Normal file
92
scripts/mysql/057_add_yumi_app.sql
Normal 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);
|
||||
12
scripts/mysql/058_manager_grant_vip_permission.sql
Normal file
12
scripts/mysql/058_manager_grant_vip_permission.sql
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 是礼物目录 owner;gateway 只把 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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -206,6 +206,7 @@ type ManagerProfile struct {
|
||||
CanGrantAvatarFrame bool
|
||||
CanGrantVehicle bool
|
||||
CanGrantBadge bool
|
||||
CanGrantVIP bool
|
||||
CanUpdateUserLevel bool
|
||||
CanAddBDLeader bool
|
||||
CanAddAdmin bool
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user