相关功能修复
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;
|
int64 total = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetGiftCatalogVersionRequest {
|
||||||
|
string request_id = 1;
|
||||||
|
string app_code = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetGiftCatalogVersionResponse {
|
||||||
|
int64 version = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
|
// WalletCronService 只给 cron-service 调用;账务状态仍由 wallet-service owner 修改。
|
||||||
service WalletCronService {
|
service WalletCronService {
|
||||||
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
rpc ProcessHostSalaryDailySettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||||
@ -2259,6 +2268,7 @@ service WalletService {
|
|||||||
rpc SetResourceGroupStatus(SetResourceGroupStatusRequest) returns (ResourceGroupResponse);
|
rpc SetResourceGroupStatus(SetResourceGroupStatusRequest) returns (ResourceGroupResponse);
|
||||||
rpc ListGiftConfigs(ListGiftConfigsRequest) returns (ListGiftConfigsResponse);
|
rpc ListGiftConfigs(ListGiftConfigsRequest) returns (ListGiftConfigsResponse);
|
||||||
rpc ListGiftTypeConfigs(ListGiftTypeConfigsRequest) returns (ListGiftTypeConfigsResponse);
|
rpc ListGiftTypeConfigs(ListGiftTypeConfigsRequest) returns (ListGiftTypeConfigsResponse);
|
||||||
|
rpc GetGiftCatalogVersion(GetGiftCatalogVersionRequest) returns (GetGiftCatalogVersionResponse);
|
||||||
rpc CreateGiftConfig(CreateGiftConfigRequest) returns (GiftConfigResponse);
|
rpc CreateGiftConfig(CreateGiftConfigRequest) returns (GiftConfigResponse);
|
||||||
rpc UpdateGiftConfig(UpdateGiftConfigRequest) returns (GiftConfigResponse);
|
rpc UpdateGiftConfig(UpdateGiftConfigRequest) returns (GiftConfigResponse);
|
||||||
rpc SetGiftConfigStatus(SetGiftConfigStatusRequest) returns (GiftConfigResponse);
|
rpc SetGiftConfigStatus(SetGiftConfigStatusRequest) returns (GiftConfigResponse);
|
||||||
|
|||||||
@ -229,6 +229,7 @@ const (
|
|||||||
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
|
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
|
||||||
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
|
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
|
||||||
WalletService_ListGiftTypeConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftTypeConfigs"
|
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_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
|
||||||
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
|
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
|
||||||
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
|
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)
|
SetResourceGroupStatus(ctx context.Context, in *SetResourceGroupStatusRequest, opts ...grpc.CallOption) (*ResourceGroupResponse, error)
|
||||||
ListGiftConfigs(ctx context.Context, in *ListGiftConfigsRequest, opts ...grpc.CallOption) (*ListGiftConfigsResponse, error)
|
ListGiftConfigs(ctx context.Context, in *ListGiftConfigsRequest, opts ...grpc.CallOption) (*ListGiftConfigsResponse, error)
|
||||||
ListGiftTypeConfigs(ctx context.Context, in *ListGiftTypeConfigsRequest, opts ...grpc.CallOption) (*ListGiftTypeConfigsResponse, 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)
|
CreateGiftConfig(ctx context.Context, in *CreateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error)
|
||||||
UpdateGiftConfig(ctx context.Context, in *UpdateGiftConfigRequest, 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)
|
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
|
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) {
|
func (c *walletServiceClient) CreateGiftConfig(ctx context.Context, in *CreateGiftConfigRequest, opts ...grpc.CallOption) (*GiftConfigResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(GiftConfigResponse)
|
out := new(GiftConfigResponse)
|
||||||
@ -1417,6 +1429,7 @@ type WalletServiceServer interface {
|
|||||||
SetResourceGroupStatus(context.Context, *SetResourceGroupStatusRequest) (*ResourceGroupResponse, error)
|
SetResourceGroupStatus(context.Context, *SetResourceGroupStatusRequest) (*ResourceGroupResponse, error)
|
||||||
ListGiftConfigs(context.Context, *ListGiftConfigsRequest) (*ListGiftConfigsResponse, error)
|
ListGiftConfigs(context.Context, *ListGiftConfigsRequest) (*ListGiftConfigsResponse, error)
|
||||||
ListGiftTypeConfigs(context.Context, *ListGiftTypeConfigsRequest) (*ListGiftTypeConfigsResponse, error)
|
ListGiftTypeConfigs(context.Context, *ListGiftTypeConfigsRequest) (*ListGiftTypeConfigsResponse, error)
|
||||||
|
GetGiftCatalogVersion(context.Context, *GetGiftCatalogVersionRequest) (*GetGiftCatalogVersionResponse, error)
|
||||||
CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error)
|
CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error)
|
||||||
UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error)
|
UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error)
|
||||||
SetGiftConfigStatus(context.Context, *SetGiftConfigStatusRequest) (*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) {
|
func (UnimplementedWalletServiceServer) ListGiftTypeConfigs(context.Context, *ListGiftTypeConfigsRequest) (*ListGiftTypeConfigsResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListGiftTypeConfigs not implemented")
|
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) {
|
func (UnimplementedWalletServiceServer) CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateGiftConfig not implemented")
|
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)
|
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) {
|
func _WalletService_CreateGiftConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(CreateGiftConfigRequest)
|
in := new(CreateGiftConfigRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -3673,6 +3707,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ListGiftTypeConfigs",
|
MethodName: "ListGiftTypeConfigs",
|
||||||
Handler: _WalletService_ListGiftTypeConfigs_Handler,
|
Handler: _WalletService_ListGiftTypeConfigs_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetGiftCatalogVersion",
|
||||||
|
Handler: _WalletService_GetGiftCatalogVersion_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "CreateGiftConfig",
|
MethodName: "CreateGiftConfig",
|
||||||
Handler: _WalletService_CreateGiftConfig_Handler,
|
Handler: _WalletService_CreateGiftConfig_Handler,
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -20,6 +20,7 @@ replace hyapp.local/api => ./api
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/clbanning/mxj v1.8.4 // indirect
|
github.com/clbanning/mxj v1.8.4 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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=
|
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 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
|
||||||
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
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/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 h1:yt73olKe5N6894Dbm+ojRf/JPiP0cxfDNNffKwhpJVg=
|
||||||
github.com/apache/rocketmq-client-go/v2 v2.1.2/go.mod h1:6I6vgxHR3hzrvn+6n/4mrhS+UTulzK/X9LB2Vk1U5gE=
|
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/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 h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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=
|
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_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送头像框',
|
||||||
can_grant_vehicle 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_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_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_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
|
||||||
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
|
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
|
||||||
@ -68,6 +69,15 @@ PREPARE stmt FROM @ddl;
|
|||||||
EXECUTE stmt;
|
EXECUTE stmt;
|
||||||
DEALLOCATE PREPARE 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(
|
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,
|
(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',
|
'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 (
|
INSERT INTO manager_profiles (
|
||||||
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
|
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
|
created_by_admin_id, created_at_ms, updated_at_ms
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@ -132,6 +142,7 @@ SELECT
|
|||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
|
1,
|
||||||
0,
|
0,
|
||||||
MIN(bl.created_at_ms),
|
MIN(bl.created_at_ms),
|
||||||
MAX(bl.updated_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"`
|
CanGrantAvatarFrame bool `json:"canGrantAvatarFrame"`
|
||||||
CanGrantVehicle bool `json:"canGrantVehicle"`
|
CanGrantVehicle bool `json:"canGrantVehicle"`
|
||||||
CanGrantBadge bool `json:"canGrantBadge"`
|
CanGrantBadge bool `json:"canGrantBadge"`
|
||||||
|
CanGrantVIP bool `json:"canGrantVip"`
|
||||||
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
|
CanUpdateUserLevel bool `json:"canUpdateUserLevel"`
|
||||||
CanAddBDLeader bool `json:"canAddBdLeader"`
|
CanAddBDLeader bool `json:"canAddBdLeader"`
|
||||||
CanAddAdmin bool `json:"canAddAdmin"`
|
CanAddAdmin bool `json:"canAddAdmin"`
|
||||||
@ -619,15 +620,16 @@ func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID
|
|||||||
if _, err := r.db.ExecContext(ctx, `
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO manager_profiles (
|
INSERT INTO manager_profiles (
|
||||||
app_code, user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
|
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
|
created_by_admin_id, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
status = 'active',
|
status = 'active',
|
||||||
contact_info = VALUES(contact_info),
|
contact_info = VALUES(contact_info),
|
||||||
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
|
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
|
||||||
can_grant_vehicle = VALUES(can_grant_vehicle),
|
can_grant_vehicle = VALUES(can_grant_vehicle),
|
||||||
can_grant_badge = VALUES(can_grant_badge),
|
can_grant_badge = VALUES(can_grant_badge),
|
||||||
|
can_grant_vip = VALUES(can_grant_vip),
|
||||||
can_update_user_level = VALUES(can_update_user_level),
|
can_update_user_level = VALUES(can_update_user_level),
|
||||||
can_add_bd_leader = VALUES(can_add_bd_leader),
|
can_add_bd_leader = VALUES(can_add_bd_leader),
|
||||||
can_add_admin = VALUES(can_add_admin),
|
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_block_user = VALUES(can_block_user),
|
||||||
can_transfer_user_country = VALUES(can_transfer_user_country),
|
can_transfer_user_country = VALUES(can_transfer_user_country),
|
||||||
updated_at_ms = VALUES(updated_at_ms)
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
|
`, appCode, userID, contact, permissions.CanGrantAvatarFrame, permissions.CanGrantVehicle,
|
||||||
permissions.CanGrantBadge, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
|
permissions.CanGrantBadge, permissions.CanGrantVIP, permissions.CanUpdateUserLevel, permissions.CanAddBDLeader, permissions.CanAddAdmin,
|
||||||
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
|
permissions.CanAddSuperadmin, permissions.CanBlockUser, permissions.CanTransferCountry, actorID, nowMs, nowMs); err != nil {
|
||||||
return nil, err
|
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, ''),
|
SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''),
|
||||||
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
|
COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`,
|
||||||
mp.status, COALESCE(mp.contact_info, ''),
|
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,
|
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),
|
COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0),
|
||||||
mp.created_at_ms, mp.updated_at_ms
|
mp.created_at_ms, mp.updated_at_ms
|
||||||
%s
|
%s
|
||||||
GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar,
|
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,
|
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.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
|
mp.created_at_ms, mp.updated_at_ms
|
||||||
ORDER BY mp.updated_at_ms DESC, mp.user_id DESC
|
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.CanGrantAvatarFrame,
|
||||||
&item.CanGrantVehicle,
|
&item.CanGrantVehicle,
|
||||||
&item.CanGrantBadge,
|
&item.CanGrantBadge,
|
||||||
|
&item.CanGrantVIP,
|
||||||
&item.CanUpdateUserLevel,
|
&item.CanUpdateUserLevel,
|
||||||
&item.CanAddBDLeader,
|
&item.CanAddBDLeader,
|
||||||
&item.CanAddAdmin,
|
&item.CanAddAdmin,
|
||||||
|
|||||||
@ -72,6 +72,7 @@ type createManagerRequest struct {
|
|||||||
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
|
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
|
||||||
CanGrantVehicle *bool `json:"canGrantVehicle"`
|
CanGrantVehicle *bool `json:"canGrantVehicle"`
|
||||||
CanGrantBadge *bool `json:"canGrantBadge"`
|
CanGrantBadge *bool `json:"canGrantBadge"`
|
||||||
|
CanGrantVIP *bool `json:"canGrantVip"`
|
||||||
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
|
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
|
||||||
CanAddBDLeader *bool `json:"canAddBdLeader"`
|
CanAddBDLeader *bool `json:"canAddBdLeader"`
|
||||||
CanAddAdmin *bool `json:"canAddAdmin"`
|
CanAddAdmin *bool `json:"canAddAdmin"`
|
||||||
@ -85,6 +86,7 @@ type updateManagerRequest struct {
|
|||||||
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
|
CanGrantAvatarFrame *bool `json:"canGrantAvatarFrame"`
|
||||||
CanGrantVehicle *bool `json:"canGrantVehicle"`
|
CanGrantVehicle *bool `json:"canGrantVehicle"`
|
||||||
CanGrantBadge *bool `json:"canGrantBadge"`
|
CanGrantBadge *bool `json:"canGrantBadge"`
|
||||||
|
CanGrantVIP *bool `json:"canGrantVip"`
|
||||||
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
|
CanUpdateUserLevel *bool `json:"canUpdateUserLevel"`
|
||||||
CanAddBDLeader *bool `json:"canAddBdLeader"`
|
CanAddBDLeader *bool `json:"canAddBdLeader"`
|
||||||
CanAddAdmin *bool `json:"canAddAdmin"`
|
CanAddAdmin *bool `json:"canAddAdmin"`
|
||||||
@ -97,6 +99,7 @@ type managerPermissions struct {
|
|||||||
CanGrantAvatarFrame bool
|
CanGrantAvatarFrame bool
|
||||||
CanGrantVehicle bool
|
CanGrantVehicle bool
|
||||||
CanGrantBadge bool
|
CanGrantBadge bool
|
||||||
|
CanGrantVIP bool
|
||||||
CanUpdateUserLevel bool
|
CanUpdateUserLevel bool
|
||||||
CanAddBDLeader bool
|
CanAddBDLeader bool
|
||||||
CanAddAdmin bool
|
CanAddAdmin bool
|
||||||
@ -110,6 +113,7 @@ func (r createManagerRequest) permissionsWithDefault() managerPermissions {
|
|||||||
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
|
CanGrantAvatarFrame: boolDefaultTrue(r.CanGrantAvatarFrame),
|
||||||
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
|
CanGrantVehicle: boolDefaultTrue(r.CanGrantVehicle),
|
||||||
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
|
CanGrantBadge: boolDefaultTrue(r.CanGrantBadge),
|
||||||
|
CanGrantVIP: boolDefaultTrue(r.CanGrantVIP),
|
||||||
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
|
CanUpdateUserLevel: boolDefaultTrue(r.CanUpdateUserLevel),
|
||||||
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
|
CanAddBDLeader: boolDefaultTrue(r.CanAddBDLeader),
|
||||||
CanAddAdmin: boolDefaultTrue(r.CanAddAdmin),
|
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_avatar_frame", r.CanGrantAvatarFrame)
|
||||||
appendBoolAssignment(assignments, args, "can_grant_vehicle", r.CanGrantVehicle)
|
appendBoolAssignment(assignments, args, "can_grant_vehicle", r.CanGrantVehicle)
|
||||||
appendBoolAssignment(assignments, args, "can_grant_badge", r.CanGrantBadge)
|
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_update_user_level", r.CanUpdateUserLevel)
|
||||||
appendBoolAssignment(assignments, args, "can_add_bd_leader", r.CanAddBDLeader)
|
appendBoolAssignment(assignments, args, "can_add_bd_leader", r.CanAddBDLeader)
|
||||||
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)
|
appendBoolAssignment(assignments, args, "can_add_admin", r.CanAddAdmin)
|
||||||
|
|||||||
@ -100,6 +100,7 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
|
|||||||
Status: "active",
|
Status: "active",
|
||||||
Contact: "+63",
|
Contact: "+63",
|
||||||
CanGrantVehicle: true,
|
CanGrantVehicle: true,
|
||||||
|
CanGrantVIP: true,
|
||||||
CanBlockUser: false,
|
CanBlockUser: false,
|
||||||
BDLeaderCount: 3,
|
BDLeaderCount: 3,
|
||||||
LastInvitedAtMs: 1720000000000,
|
LastInvitedAtMs: 1720000000000,
|
||||||
@ -114,6 +115,7 @@ func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) {
|
|||||||
`"displayUserId":"165549"`,
|
`"displayUserId":"165549"`,
|
||||||
`"contact":"+63"`,
|
`"contact":"+63"`,
|
||||||
`"canGrantVehicle":true`,
|
`"canGrantVehicle":true`,
|
||||||
|
`"canGrantVip":true`,
|
||||||
`"canBlockUser":false`,
|
`"canBlockUser":false`,
|
||||||
`"bdLeaderCount":3`,
|
`"bdLeaderCount":3`,
|
||||||
`"lastInvitedAtMs":1720000000000`,
|
`"lastInvitedAtMs":1720000000000`,
|
||||||
|
|||||||
@ -48,6 +48,7 @@ type WalletClient interface {
|
|||||||
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
|
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
|
||||||
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
|
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
|
||||||
PurchaseResourceShopItem(ctx context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, 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)
|
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
||||||
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
|
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
|
||||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, 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)
|
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) {
|
func (c *grpcWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||||
return c.client.ListGiftConfigs(ctx, req)
|
return c.client.ListGiftConfigs(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -200,6 +200,22 @@ func TestSearchManagerUsersDefaultsSameCountryAndAllowsAllForCountryTransfer(t *
|
|||||||
if sameEnvelope.Data.Total != 1 || sameEnvelope.Data.Items[0].UserID != "900001" {
|
if sameEnvelope.Data.Total != 1 || sameEnvelope.Data.Items[0].UserID != "900001" {
|
||||||
t.Fatalf("same-country search must filter cross-country users: %+v", sameEnvelope.Data)
|
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 := 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))
|
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) {
|
func TestGrantManagerVIPUsesManagerCenterGrantSource(t *testing.T) {
|
||||||
walletClient := &fakeWalletClient{}
|
walletClient := &fakeWalletClient{}
|
||||||
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})
|
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const managerResourceGrantCapability = "manager_resource_grant"
|
|||||||
const managerGrantAvatarFrameCapability = "manager_grant_avatar_frame"
|
const managerGrantAvatarFrameCapability = "manager_grant_avatar_frame"
|
||||||
const managerGrantVehicleCapability = "manager_grant_vehicle"
|
const managerGrantVehicleCapability = "manager_grant_vehicle"
|
||||||
const managerGrantBadgeCapability = "manager_grant_badge"
|
const managerGrantBadgeCapability = "manager_grant_badge"
|
||||||
|
const managerGrantVIPCapability = "manager_grant_vip"
|
||||||
const managerUpdateUserLevelCapability = "manager_update_user_level"
|
const managerUpdateUserLevelCapability = "manager_update_user_level"
|
||||||
const managerAddBDLeaderCapability = "manager_add_bd_leader"
|
const managerAddBDLeaderCapability = "manager_add_bd_leader"
|
||||||
const managerAddAdminCapability = "manager_add_admin"
|
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{
|
lookupResp, err := h.userProfileClient.BusinessUserLookup(request.Context(), &userv1.BusinessUserLookupRequest{
|
||||||
Meta: httpkit.UserMeta(request, ""),
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
ActorUserId: actor.GetUserId(),
|
ActorUserId: actor.GetUserId(),
|
||||||
Scene: "manager_resource_grant",
|
Scene: managerSearchScene(request),
|
||||||
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
|
Keyword: strings.TrimSpace(request.URL.Query().Get("keyword")),
|
||||||
PageSize: pageSize,
|
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")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !h.requireManagerCapability(writer, request, managerGrantVIPCapability) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// 经理中心复用后台 VIP 发放账务能力,但 source 固定为 manager_center,钱包侧据此保留统一 IM/outbox 行为。
|
// 经理中心复用后台 VIP 发放账务能力,但 source 固定为 manager_center,钱包侧据此保留统一 IM/outbox 行为。
|
||||||
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
|
if _, ok := h.requireSameCountryTarget(writer, request, targetUserID); !ok {
|
||||||
return
|
return
|
||||||
@ -744,6 +748,13 @@ func (h *Handler) requireManagerResourceGrantCapability(writer http.ResponseWrit
|
|||||||
return h.requireManagerCapability(writer, request, managerResourceGrantCapability)
|
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) {
|
func (h *Handler) requireManagerCenterActor(writer http.ResponseWriter, request *http.Request) (*userv1.User, bool) {
|
||||||
if !h.requireManagerCapability(writer, request, managerCenterCapability) {
|
if !h.requireManagerCapability(writer, request, managerCenterCapability) {
|
||||||
return nil, false
|
return nil, false
|
||||||
@ -815,6 +826,7 @@ func (h *Handler) managerPermissionOverview(request *http.Request) map[string]bo
|
|||||||
"can_grant_avatar_frame": false,
|
"can_grant_avatar_frame": false,
|
||||||
"can_grant_vehicle": false,
|
"can_grant_vehicle": false,
|
||||||
"can_grant_badge": false,
|
"can_grant_badge": false,
|
||||||
|
"can_grant_vip": false,
|
||||||
"can_update_user_level": false,
|
"can_update_user_level": false,
|
||||||
"can_add_bd_leader": false,
|
"can_add_bd_leader": false,
|
||||||
"can_add_admin": 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_avatar_frame": managerGrantAvatarFrameCapability,
|
||||||
"can_grant_vehicle": managerGrantVehicleCapability,
|
"can_grant_vehicle": managerGrantVehicleCapability,
|
||||||
"can_grant_badge": managerGrantBadgeCapability,
|
"can_grant_badge": managerGrantBadgeCapability,
|
||||||
|
"can_grant_vip": managerGrantVIPCapability,
|
||||||
"can_update_user_level": managerUpdateUserLevelCapability,
|
"can_update_user_level": managerUpdateUserLevelCapability,
|
||||||
"can_add_bd_leader": managerAddBDLeaderCapability,
|
"can_add_bd_leader": managerAddBDLeaderCapability,
|
||||||
"can_add_admin": managerAddAdminCapability,
|
"can_add_admin": managerAddAdminCapability,
|
||||||
|
|||||||
@ -626,6 +626,11 @@ type fakeWalletClient struct {
|
|||||||
resourceShopResp *walletv1.ListResourceShopItemsResponse
|
resourceShopResp *walletv1.ListResourceShopItemsResponse
|
||||||
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
|
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
|
||||||
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
|
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
|
||||||
|
lastGetGiftCatalog *walletv1.GetGiftCatalogVersionRequest
|
||||||
|
getGiftCatalogCalls int
|
||||||
|
giftCatalogVersion int64
|
||||||
|
giftCatalogVersionResp *walletv1.GetGiftCatalogVersionResponse
|
||||||
|
giftCatalogVersionErr error
|
||||||
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
||||||
listGiftConfigsCalls int
|
listGiftConfigsCalls int
|
||||||
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
||||||
@ -2364,6 +2369,21 @@ func (f *fakeWalletClient) PurchaseResourceShopItem(_ context.Context, req *wall
|
|||||||
return &walletv1.PurchaseResourceShopItemResponse{}, nil
|
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) {
|
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||||
f.lastListGiftConfigs = req
|
f.lastListGiftConfigs = req
|
||||||
f.listGiftConfigsCalls++
|
f.listGiftConfigsCalls++
|
||||||
@ -8234,7 +8254,8 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
|
|||||||
SortOrder: 1,
|
SortOrder: 1,
|
||||||
}
|
}
|
||||||
walletClient := &fakeWalletClient{
|
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{{
|
listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
|
||||||
TypeCode: "normal",
|
TypeCode: "normal",
|
||||||
Name: "普通礼物",
|
Name: "普通礼物",
|
||||||
@ -8276,7 +8297,7 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
|
|||||||
handler.SetWalletClient(walletClient)
|
handler.SetWalletClient(walletClient)
|
||||||
router := handler.Routes(auth.NewVerifier("secret"))
|
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 := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-cache/gift-panel", nil)
|
||||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@ -8284,19 +8305,140 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("gift panel status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String())
|
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 {
|
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)
|
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() {
|
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)
|
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() {
|
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)
|
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) {
|
func TestRoomGiftPanelCacheSeparatesVisibleRegion(t *testing.T) {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type roomGiftPanelConfigCache struct {
|
|||||||
|
|
||||||
type roomGiftPanelConfigCacheEntry struct {
|
type roomGiftPanelConfigCacheEntry struct {
|
||||||
config roomGiftPanelConfig
|
config roomGiftPanelConfig
|
||||||
|
version int64
|
||||||
expiresAt time.Time
|
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 {
|
if c == nil {
|
||||||
return roomGiftPanelConfig{}, false
|
return roomGiftPanelConfig{}, false
|
||||||
}
|
}
|
||||||
@ -42,11 +43,11 @@ func (c *roomGiftPanelConfigCache) Get(appCode string, regionID int64, now time.
|
|||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
entry, ok := c.items[key]
|
entry, ok := c.items[key]
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
if !ok || !now.Before(entry.expiresAt) {
|
if !ok || entry.version != version || !now.Before(entry.expiresAt) {
|
||||||
if ok {
|
if ok {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
// 过期项只在命中时顺手清理,避免为低频区域引入后台 goroutine。
|
// 过期或版本漂移时只清理当前 key,避免低频区域为了维护缓存状态引入后台 goroutine。
|
||||||
if current, exists := c.items[key]; exists && !now.Before(current.expiresAt) {
|
if current, exists := c.items[key]; exists && (current.version != version || !now.Before(current.expiresAt)) {
|
||||||
delete(c.items, key)
|
delete(c.items, key)
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@ -56,7 +57,7 @@ func (c *roomGiftPanelConfigCache) Get(appCode string, regionID int64, now time.
|
|||||||
return cloneRoomGiftPanelConfig(entry.config), true
|
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 {
|
if c == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -65,6 +66,7 @@ func (c *roomGiftPanelConfigCache) Set(appCode string, regionID int64, config ro
|
|||||||
// 写入时复制一次,读取时再复制一次;缓存只保存配置快照,不让请求级背包礼物追加影响下一次命中。
|
// 写入时复制一次,读取时再复制一次;缓存只保存配置快照,不让请求级背包礼物追加影响下一次命中。
|
||||||
c.items[roomGiftPanelConfigCacheKey(appCode, regionID)] = roomGiftPanelConfigCacheEntry{
|
c.items[roomGiftPanelConfigCacheKey(appCode, regionID)] = roomGiftPanelConfigCacheEntry{
|
||||||
config: cloneRoomGiftPanelConfig(config),
|
config: cloneRoomGiftPanelConfig(config),
|
||||||
|
version: version,
|
||||||
expiresAt: now.Add(c.ttl),
|
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) {
|
func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) {
|
||||||
app := appcode.FromContext(request.Context())
|
app := appcode.FromContext(request.Context())
|
||||||
if h.giftPanelCache != nil {
|
catalogVersion, cacheable, err := h.roomGiftCatalogVersion(request, app)
|
||||||
if cached, ok := h.giftPanelCache.Get(app, regionID, time.Now()); ok {
|
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
|
return cached, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -775,12 +779,28 @@ func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64)
|
|||||||
// 礼物类型禁用后不应出现在房间面板;缓存保存已过滤的稳定物料,背包礼物仍按用户每次实时拼接。
|
// 礼物类型禁用后不应出现在房间面板;缓存保存已过滤的稳定物料,背包礼物仍按用户每次实时拼接。
|
||||||
Gifts: filterGiftPanelGifts(gifts, giftTypes),
|
Gifts: filterGiftPanelGifts(gifts, giftTypes),
|
||||||
}
|
}
|
||||||
if h.giftPanelCache != nil {
|
if cacheable && h.giftPanelCache != nil {
|
||||||
h.giftPanelCache.Set(app, regionID, config, time.Now())
|
h.giftPanelCache.Set(app, regionID, catalogVersion, config, time.Now())
|
||||||
}
|
}
|
||||||
return config, nil
|
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) {
|
func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
|
||||||
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
|
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package userapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
userv1 "hyapp.local/api/proto/user/v1"
|
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())})
|
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) {
|
func (h *Handler) listCPRelationships(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.userCPClient == nil {
|
if h.userCPClient == nil {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
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")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
targetUserID := cpRelationshipListUserID(request)
|
||||||
resp, err := h.userCPClient.ListCPRelationships(request.Context(), &userv1.ListCPRelationshipsRequest{
|
resp, err := h.userCPClient.ListCPRelationships(request.Context(), &userv1.ListCPRelationshipsRequest{
|
||||||
Meta: httpkit.UserMeta(request, ""),
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
UserId: auth.UserIDFromContext(request.Context()),
|
UserId: targetUserID,
|
||||||
RelationType: strings.TrimSpace(request.URL.Query().Get("relation_type")),
|
RelationType: strings.TrimSpace(request.URL.Query().Get("relation_type")),
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
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 实时扫描关系表。
|
// listCPIntimacyLeaderboard 返回 cron 聚合后的 CP 亲密值排行榜,不在 gateway 实时扫描关系表。
|
||||||
func (h *Handler) listCPIntimacyLeaderboard(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) listCPIntimacyLeaderboard(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.userCPClient == nil {
|
if h.userCPClient == nil {
|
||||||
|
|||||||
@ -1,11 +1,106 @@
|
|||||||
package userapi
|
package userapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
userv1 "hyapp.local/api/proto/user/v1"
|
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) {
|
func TestCPRelationshipFromProtoIncludesLevelProgress(t *testing.T) {
|
||||||
item := &userv1.CPRelationship{
|
item := &userv1.CPRelationship{
|
||||||
RelationshipId: "cp_rel:1:2",
|
RelationshipId: "cp_rel:1:2",
|
||||||
|
|||||||
@ -427,7 +427,10 @@ type SendGift struct {
|
|||||||
TargetType string `json:"target_type"`
|
TargetType string `json:"target_type"`
|
||||||
// TargetUserIDs 是新协议目标用户集合;target_type=user 时允许多个房间内用户。
|
// TargetUserIDs 是新协议目标用户集合;target_type=user 时允许多个房间内用户。
|
||||||
TargetUserIDs []int64 `json:"target_user_ids,omitempty"`
|
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"`
|
TargetUserID int64 `json:"target_user_id"`
|
||||||
// GiftID 是礼物配置标识,具体配置有效性当前由调用方和 wallet 链路保证。
|
// GiftID 是礼物配置标识,具体配置有效性当前由调用方和 wallet 链路保证。
|
||||||
GiftID string `json:"gift_id"`
|
GiftID string `json:"gift_id"`
|
||||||
@ -646,11 +649,26 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
|
|||||||
// 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。
|
// 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。
|
||||||
delete(values, "sender_display_profile")
|
delete(values, "sender_display_profile")
|
||||||
delete(values, "target_display_profiles")
|
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,不属于客户端请求幂等语义。
|
// SendGift 的以下字段由 wallet-service 结算后写入 command log,不属于客户端请求幂等语义。
|
||||||
delete(values, "billing_receipt_id")
|
delete(values, "billing_receipt_id")
|
||||||
delete(values, "coin_spent")
|
delete(values, "coin_spent")
|
||||||
delete(values, "gift_point_added")
|
delete(values, "gift_point_added")
|
||||||
delete(values, "heat_value")
|
delete(values, "heat_value")
|
||||||
|
delete(values, "target_gift_values")
|
||||||
delete(values, "price_version")
|
delete(values, "price_version")
|
||||||
delete(values, "gift_type_code")
|
delete(values, "gift_type_code")
|
||||||
delete(values, "host_period_diamond_added")
|
delete(values, "host_period_diamond_added")
|
||||||
@ -697,6 +715,23 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
|
|||||||
return json.Marshal(values)
|
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 按命令类型恢复命令载荷。
|
// Deserialize 按命令类型恢复命令载荷。
|
||||||
func Deserialize(commandType string, payload []byte) (Command, error) {
|
func Deserialize(commandType string, payload []byte) (Command, error) {
|
||||||
var cmd Command
|
var cmd Command
|
||||||
|
|||||||
@ -88,6 +88,7 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
|
|||||||
if cmd.TargetType == "" {
|
if cmd.TargetType == "" {
|
||||||
cmd.TargetType = "user"
|
cmd.TargetType = "user"
|
||||||
}
|
}
|
||||||
|
cmd.RequestedTargetUserIDs = append([]int64(nil), cmd.TargetUserIDs...)
|
||||||
if cmd.TargetType == "user" && len(cmd.TargetUserIDs) > 0 {
|
if cmd.TargetType == "user" && len(cmd.TargetUserIDs) > 0 {
|
||||||
cmd.TargetUserID = 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 {
|
if len(f.cmd.TargetUserIDs) == 0 || f.cmd.TargetUserID <= 0 {
|
||||||
return xerr.New(xerr.InvalidArgument, "target_user_ids is required")
|
return xerr.New(xerr.InvalidArgument, "target_user_ids is required")
|
||||||
}
|
}
|
||||||
if f.batchDisplayEnabled && len(f.cmd.TargetUserIDs) < 2 {
|
if f.batchDisplayEnabled {
|
||||||
// batch-send 是多人展示入口,必须明确至少两个收礼目标;单目标继续走普通 SendGift,避免展示协议和账务事实混用。
|
if len(f.cmd.RequestedTargetUserIDs) < 2 {
|
||||||
return xerr.New(xerr.InvalidArgument, "batch gift requires multiple targets")
|
// batch-send 只承载“全部人/多人”语义;过滤后可以只剩一个真实送达目标,但原始请求不能退化成单人入口。
|
||||||
}
|
return xerr.New(xerr.InvalidArgument, "batch target_user_ids requires at least two users")
|
||||||
for _, targetUserID := range f.cmd.TargetUserIDs {
|
}
|
||||||
if _, exists := current.OnlineUsers[targetUserID]; !exists {
|
if !f.retainOnlineBatchTargets(current) {
|
||||||
// 每个接收方都必须在房间 presence 内;批量扣费前先全部校验,避免钱包出现无房间表现的入账。
|
// 全部目标都已离房时不能做成功空操作,也不能扣费。
|
||||||
return xerr.New(xerr.NotFound, "target not in room")
|
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 f.robot.Enabled {
|
||||||
if err := requireRobotGiftParticipants(current, f.cmd.ActorUserID(), f.cmd.TargetUserIDs, f.robot.RobotUserIDs, !f.robot.RealRoomHeat); err != nil {
|
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
|
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 {
|
func (f *giftFlow) prepareRoomFacts() error {
|
||||||
roomMeta, exists, err := f.s.repository.GetRoomMeta(f.ctx, f.cmd.RoomID())
|
roomMeta, exists, err := f.s.repository.GetRoomMeta(f.ctx, f.cmd.RoomID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -72,6 +72,56 @@ func giftDisplayProfilesFromProto(items []*roomv1.SendGiftDisplayProfile) []comm
|
|||||||
return result
|
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 {
|
func giftDisplayProfileForUser(profiles []command.GiftDisplayProfile, userID int64) command.GiftDisplayProfile {
|
||||||
if userID <= 0 {
|
if userID <= 0 {
|
||||||
return command.GiftDisplayProfile{}
|
return command.GiftDisplayProfile{}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import (
|
|||||||
"hyapp/services/room-service/internal/router"
|
"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) {
|
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()
|
startedAt := time.Now()
|
||||||
var idempotencyMS int64
|
var idempotencyMS int64
|
||||||
@ -84,7 +86,8 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
// 这类事件不能进入 durable outbox,否则会污染活跃统计和补偿消费。
|
// 这类事件不能进入 durable outbox,否则会污染活跃统计和补偿消费。
|
||||||
directIMRecords = append(directIMRecords, result.directIMRecords...)
|
directIMRecords = append(directIMRecords, result.directIMRecords...)
|
||||||
saveStartedAt := time.Now()
|
saveStartedAt := time.Now()
|
||||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
commitCtx, commitCancel := roomMutationCommitContext(ctx)
|
||||||
|
err := s.repository.SaveMutation(commitCtx, MutationCommit{
|
||||||
Command: CommandRecord{
|
Command: CommandRecord{
|
||||||
AppCode: appcode.FromContext(ctx),
|
AppCode: appcode.FromContext(ctx),
|
||||||
RoomID: cmd.RoomID(),
|
RoomID: cmd.RoomID(),
|
||||||
@ -107,7 +110,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
CloseInfo: result.closeInfo,
|
CloseInfo: result.closeInfo,
|
||||||
RoomUserGiftStats: result.roomUserGiftStats,
|
RoomUserGiftStats: result.roomUserGiftStats,
|
||||||
RoomWeeklyContribution: result.roomWeeklyContribution,
|
RoomWeeklyContribution: result.roomWeeklyContribution,
|
||||||
}); err != nil {
|
})
|
||||||
|
commitCancel()
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
saveMutationMS += elapsedMS(saveStartedAt)
|
saveMutationMS += elapsedMS(saveStartedAt)
|
||||||
@ -134,7 +139,8 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
saveStartedAt := time.Now()
|
saveStartedAt := time.Now()
|
||||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
commitCtx, commitCancel := roomMutationCommitContext(ctx)
|
||||||
|
err := s.repository.SaveMutation(commitCtx, MutationCommit{
|
||||||
Command: CommandRecord{
|
Command: CommandRecord{
|
||||||
AppCode: appcode.FromContext(ctx),
|
AppCode: appcode.FromContext(ctx),
|
||||||
RoomID: cmd.RoomID(),
|
RoomID: cmd.RoomID(),
|
||||||
@ -147,7 +153,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
LeaseToken: lease.LeaseToken,
|
LeaseToken: lease.LeaseToken,
|
||||||
CreatedAtMS: now.UnixMilli(),
|
CreatedAtMS: now.UnixMilli(),
|
||||||
},
|
},
|
||||||
}); err != nil {
|
})
|
||||||
|
commitCancel()
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
saveMutationMS += elapsedMS(saveStartedAt)
|
saveMutationMS += elapsedMS(saveStartedAt)
|
||||||
@ -189,6 +197,12 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
return result, nil
|
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) {
|
func (s *Service) commandPayloadForIdempotency(ctx context.Context, cmd command.Command) ([]byte, bool, error) {
|
||||||
// 先序列化当前命令,后续无论首次提交还是冲突校验都使用同一份 payload。
|
// 先序列化当前命令,后续无论首次提交还是冲突校验都使用同一份 payload。
|
||||||
payload, err := command.Serialize(cmd)
|
payload, err := command.Serialize(cmd)
|
||||||
|
|||||||
@ -15,7 +15,9 @@ import (
|
|||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/roommq"
|
"hyapp/pkg/roommq"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/room-service/internal/integration"
|
"hyapp/services/room-service/internal/integration"
|
||||||
|
"hyapp/services/room-service/internal/room/command"
|
||||||
roomservice "hyapp/services/room-service/internal/room/service"
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
"hyapp/services/room-service/internal/room/state"
|
"hyapp/services/room-service/internal/room/state"
|
||||||
"hyapp/services/room-service/internal/router"
|
"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) {
|
func TestSendGiftDisplayModeDoesNotTriggerBatchDisplay(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
@ -483,6 +799,35 @@ func TestSendGiftDisplayModeDoesNotTriggerBatchDisplay(t *testing.T) {
|
|||||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftSent": 2})
|
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) {
|
func TestBatchLuckyGiftUsesSingleActivityBatchDrawAndDebitCoinBalance(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -28,6 +28,8 @@ third_party:
|
|||||||
project_id: "lalu-party"
|
project_id: "lalu-party"
|
||||||
huwaa:
|
huwaa:
|
||||||
project_id: "huwaa-6f3aa"
|
project_id: "huwaa-6f3aa"
|
||||||
|
yumi:
|
||||||
|
project_id: "yumi"
|
||||||
invite_recharge_worker:
|
invite_recharge_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
mic_time_worker:
|
mic_time_worker:
|
||||||
|
|||||||
@ -28,6 +28,8 @@ third_party:
|
|||||||
project_id: "lalu-party"
|
project_id: "lalu-party"
|
||||||
huwaa:
|
huwaa:
|
||||||
project_id: "huwaa-6f3aa"
|
project_id: "huwaa-6f3aa"
|
||||||
|
yumi:
|
||||||
|
project_id: "YUMI_FIREBASE_PROJECT_ID"
|
||||||
invite_recharge_worker:
|
invite_recharge_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
mic_time_worker:
|
mic_time_worker:
|
||||||
|
|||||||
@ -29,6 +29,8 @@ third_party:
|
|||||||
project_id: "lalu-party"
|
project_id: "lalu-party"
|
||||||
huwaa:
|
huwaa:
|
||||||
project_id: "huwaa-6f3aa"
|
project_id: "huwaa-6f3aa"
|
||||||
|
yumi:
|
||||||
|
project_id: "yumi"
|
||||||
invite_recharge_worker:
|
invite_recharge_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
mic_time_worker:
|
mic_time_worker:
|
||||||
|
|||||||
@ -24,7 +24,8 @@ INSERT INTO apps (app_code, app_name, package_name, platform, status, created_at
|
|||||||
VALUES
|
VALUES
|
||||||
('lalu', 'Lalu', 'com.org.laluparty', '', 'active', 0, 0),
|
('lalu', 'Lalu', 'com.org.laluparty', '', 'active', 0, 0),
|
||||||
('huwaa', 'Huwaa', 'com.app.huwaa', '', '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
|
ON DUPLICATE KEY UPDATE
|
||||||
app_name = VALUES(app_name),
|
app_name = VALUES(app_name),
|
||||||
package_name = VALUES(package_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_avatar_frame TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心赠送头像框',
|
||||||
can_grant_vehicle 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_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_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_bd_leader TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 BD Leader',
|
||||||
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
|
can_add_admin TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许在经理中心添加 Admin',
|
||||||
|
|||||||
@ -206,6 +206,7 @@ type ManagerProfile struct {
|
|||||||
CanGrantAvatarFrame bool
|
CanGrantAvatarFrame bool
|
||||||
CanGrantVehicle bool
|
CanGrantVehicle bool
|
||||||
CanGrantBadge bool
|
CanGrantBadge bool
|
||||||
|
CanGrantVIP bool
|
||||||
CanUpdateUserLevel bool
|
CanUpdateUserLevel bool
|
||||||
CanAddBDLeader bool
|
CanAddBDLeader bool
|
||||||
CanAddAdmin bool
|
CanAddAdmin bool
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const (
|
|||||||
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
|
CapabilityManagerGrantAvatarFrame = "manager_grant_avatar_frame"
|
||||||
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
|
CapabilityManagerGrantVehicle = "manager_grant_vehicle"
|
||||||
CapabilityManagerGrantBadge = "manager_grant_badge"
|
CapabilityManagerGrantBadge = "manager_grant_badge"
|
||||||
|
CapabilityManagerGrantVIP = "manager_grant_vip"
|
||||||
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
|
CapabilityManagerUpdateUserLevel = "manager_update_user_level"
|
||||||
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
|
CapabilityManagerAddBDLeader = "manager_add_bd_leader"
|
||||||
CapabilityManagerAddAdmin = "manager_add_admin"
|
CapabilityManagerAddAdmin = "manager_add_admin"
|
||||||
@ -45,6 +46,7 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64
|
|||||||
CapabilityManagerGrantAvatarFrame,
|
CapabilityManagerGrantAvatarFrame,
|
||||||
CapabilityManagerGrantVehicle,
|
CapabilityManagerGrantVehicle,
|
||||||
CapabilityManagerGrantBadge,
|
CapabilityManagerGrantBadge,
|
||||||
|
CapabilityManagerGrantVIP,
|
||||||
CapabilityManagerUpdateUserLevel,
|
CapabilityManagerUpdateUserLevel,
|
||||||
CapabilityManagerAddBDLeader,
|
CapabilityManagerAddBDLeader,
|
||||||
CapabilityManagerAddAdmin,
|
CapabilityManagerAddAdmin,
|
||||||
|
|||||||
@ -129,6 +129,15 @@ func TestCheckBusinessCapabilityRequiresActiveManagerProfile(t *testing.T) {
|
|||||||
t.Fatalf("active manager should be allowed: allowed=%v reason=%q", allowed, reason)
|
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)
|
allowed, reason, err = svc.CheckBusinessCapability(ctx, 202, hostservice.CapabilityManagerResourceGrant)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CheckBusinessCapability for agency owner failed: %v", err)
|
t.Fatalf("CheckBusinessCapability for agency owner failed: %v", err)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
BusinessSceneManagerResourceGrant = "manager_resource_grant"
|
BusinessSceneManagerResourceGrant = "manager_resource_grant"
|
||||||
|
BusinessSceneManagerVIPGrant = "manager_vip_grant"
|
||||||
|
|
||||||
defaultBusinessLookupPageSize = 20
|
defaultBusinessLookupPageSize = 20
|
||||||
maxBusinessLookupPageSize = 20
|
maxBusinessLookupPageSize = 20
|
||||||
@ -20,7 +21,7 @@ const (
|
|||||||
// service 层仍校验 scene 和 keyword,避免未来入口把它误用成全局用户搜索。
|
// service 层仍校验 scene 和 keyword,避免未来入口把它误用成全局用户搜索。
|
||||||
func (s *Service) BusinessUserLookup(ctx context.Context, scene string, keyword string, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) {
|
func (s *Service) BusinessUserLookup(ctx context.Context, scene string, keyword string, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) {
|
||||||
scene = strings.ToLower(strings.TrimSpace(scene))
|
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")
|
return nil, xerr.New(xerr.InvalidArgument, "business lookup scene is invalid")
|
||||||
}
|
}
|
||||||
if s.userRepository == nil {
|
if s.userRepository == nil {
|
||||||
|
|||||||
@ -361,6 +361,8 @@ func (r *Repository) HasActiveManagerCapability(ctx context.Context, userID int6
|
|||||||
columnSQL = "can_grant_vehicle = 1"
|
columnSQL = "can_grant_vehicle = 1"
|
||||||
case hostservice.CapabilityManagerGrantBadge:
|
case hostservice.CapabilityManagerGrantBadge:
|
||||||
columnSQL = "can_grant_badge = 1"
|
columnSQL = "can_grant_badge = 1"
|
||||||
|
case hostservice.CapabilityManagerGrantVIP:
|
||||||
|
columnSQL = "can_grant_vip = 1"
|
||||||
case hostservice.CapabilityManagerUpdateUserLevel:
|
case hostservice.CapabilityManagerUpdateUserLevel:
|
||||||
columnSQL = "can_update_user_level = 1"
|
columnSQL = "can_update_user_level = 1"
|
||||||
case hostservice.CapabilityManagerAddBDLeader:
|
case hostservice.CapabilityManagerAddBDLeader:
|
||||||
|
|||||||
@ -822,27 +822,28 @@ func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) {
|
|||||||
profile.UpdatedAtMs = nowMs
|
profile.UpdatedAtMs = nowMs
|
||||||
|
|
||||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||||
INSERT INTO manager_profiles (
|
INSERT INTO manager_profiles (
|
||||||
user_id, status, contact_info, can_grant_avatar_frame, can_grant_vehicle, can_grant_badge,
|
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
|
created_by_admin_id, created_at_ms, updated_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
contact_info = VALUES(contact_info),
|
contact_info = VALUES(contact_info),
|
||||||
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
|
can_grant_avatar_frame = VALUES(can_grant_avatar_frame),
|
||||||
can_grant_vehicle = VALUES(can_grant_vehicle),
|
can_grant_vehicle = VALUES(can_grant_vehicle),
|
||||||
can_grant_badge = VALUES(can_grant_badge),
|
can_grant_badge = VALUES(can_grant_badge),
|
||||||
can_update_user_level = VALUES(can_update_user_level),
|
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_bd_leader = VALUES(can_add_bd_leader),
|
||||||
can_add_admin = VALUES(can_add_admin),
|
can_add_admin = VALUES(can_add_admin),
|
||||||
can_add_superadmin = VALUES(can_add_superadmin),
|
can_add_superadmin = VALUES(can_add_superadmin),
|
||||||
can_block_user = VALUES(can_block_user),
|
can_block_user = VALUES(can_block_user),
|
||||||
can_transfer_user_country = VALUES(can_transfer_user_country),
|
can_transfer_user_country = VALUES(can_transfer_user_country),
|
||||||
updated_at_ms = VALUES(updated_at_ms)
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
|
`, profile.UserID, profile.Status, profile.Contact, defaultTrue(profile.CanGrantAvatarFrame),
|
||||||
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanUpdateUserLevel),
|
defaultTrue(profile.CanGrantVehicle), defaultTrue(profile.CanGrantBadge), defaultTrue(profile.CanGrantVIP),
|
||||||
defaultTrue(profile.CanAddBDLeader), defaultTrue(profile.CanAddAdmin),
|
defaultTrue(profile.CanUpdateUserLevel), defaultTrue(profile.CanAddBDLeader), defaultTrue(profile.CanAddAdmin),
|
||||||
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser), defaultTrue(profile.CanTransferCountry),
|
defaultTrue(profile.CanAddSuperadmin), defaultTrue(profile.CanBlockUser), defaultTrue(profile.CanTransferCountry),
|
||||||
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
|
profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs)
|
||||||
if err != nil {
|
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.
|
// PutAgency seeds an active Agency fact for host-domain tests.
|
||||||
func (r *Repository) PutAgency(agency hostdomain.Agency) {
|
func (r *Repository) PutAgency(agency hostdomain.Agency) {
|
||||||
r.t.Helper()
|
r.t.Helper()
|
||||||
|
|||||||
@ -408,6 +408,8 @@ func capabilityForBusinessLookupScene(scene string) (string, bool) {
|
|||||||
switch strings.ToLower(strings.TrimSpace(scene)) {
|
switch strings.ToLower(strings.TrimSpace(scene)) {
|
||||||
case userservice.BusinessSceneManagerResourceGrant:
|
case userservice.BusinessSceneManagerResourceGrant:
|
||||||
return hostservice.CapabilityManagerResourceGrant, true
|
return hostservice.CapabilityManagerResourceGrant, true
|
||||||
|
case userservice.BusinessSceneManagerVIPGrant:
|
||||||
|
return hostservice.CapabilityManagerGrantVIP, true
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -464,7 +464,8 @@ CREATE TABLE IF NOT EXISTS wallet_gift_prices (
|
|||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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='钱包礼物价格表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包礼物价格表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wallet_recharge_policies (
|
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',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
UNIQUE KEY uk_resources_code (app_code, resource_code),
|
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_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)
|
KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, grantable, sort_order)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源表';
|
) 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',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, type_code),
|
PRIMARY KEY (app_code, type_code),
|
||||||
UNIQUE KEY uk_gift_type_configs_tab_key (app_code, tab_key),
|
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 配置表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物类型和 App 面板 tab 配置表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS gift_configs (
|
CREATE TABLE IF NOT EXISTS gift_configs (
|
||||||
@ -1079,7 +1082,8 @@ CREATE TABLE IF NOT EXISTS gift_configs (
|
|||||||
PRIMARY KEY (app_code, gift_id),
|
PRIMARY KEY (app_code, gift_id),
|
||||||
KEY idx_gift_configs_resource (app_code, resource_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_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='礼物配置表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物配置表';
|
||||||
|
|
||||||
SET @ddl := IF(
|
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',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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),
|
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='礼物配置区域表';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS user_gift_wall (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
|||||||
@ -165,6 +165,7 @@ type ResourceCatalogStore interface {
|
|||||||
SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error)
|
SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error)
|
||||||
ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error)
|
ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error)
|
||||||
ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, 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)
|
CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error)
|
||||||
UpdateGiftConfig(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)
|
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)
|
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) {
|
func (s *Service) CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) {
|
||||||
if s.repository == nil {
|
if s.repository == nil {
|
||||||
return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
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 仍保持下线。
|
// TestDebitGiftUsesServerPriceWithoutGiftPointCredit 验证送礼只信服务端价格,收礼收入进入 COIN,历史 GIFT_POINT 仍保持下线。
|
||||||
func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(t *testing.T) {
|
func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(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 兜底区域。
|
// TestGiftConfigRegionFilter 验证礼物列表和送礼扣费都按区域配置校验,region_id=0 作为 GLOBAL 兜底区域。
|
||||||
func TestGiftConfigRegionFilter(t *testing.T) {
|
func TestGiftConfigRegionFilter(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -72,6 +72,53 @@ func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.L
|
|||||||
return items, total, nil
|
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) {
|
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)
|
row := r.db.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ?`, appcode.FromContext(ctx), giftID)
|
||||||
gift, err := scanGiftConfig(row)
|
gift, err := scanGiftConfig(row)
|
||||||
|
|||||||
@ -159,6 +159,14 @@ func (s *Server) ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGift
|
|||||||
return resp, nil
|
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) {
|
func (s *Server) CreateGiftConfig(ctx context.Context, req *walletv1.CreateGiftConfigRequest) (*walletv1.GiftConfigResponse, error) {
|
||||||
gift, err := s.svc.CreateGiftConfig(ctx, giftConfigCommandFromCreate(req))
|
gift, err := s.svc.CreateGiftConfig(ctx, giftConfigCommandFromCreate(req))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user