政策相关
This commit is contained in:
parent
aa0ddbc3b0
commit
070b4f179a
@ -4762,7 +4762,10 @@ func (x *SetTaskDefinitionStatusResponse) GetTask() *TaskDefinition {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishTaskRewardPolicyRequest 是后台策略发布写入 activity 运行侧任务奖励默认资产的命令。
|
||||
// Deprecated: 任务奖励资产现在由每条 TaskDefinition.reward_asset_type 显式配置。
|
||||
// 保留消息仅用于旧 Admin 客户端与新 Activity 服务滚动发布时的 wire 兼容。
|
||||
//
|
||||
// Deprecated: Marked as deprecated in proto/activity/v1/activity.proto.
|
||||
type PublishTaskRewardPolicyRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
@ -4871,6 +4874,9 @@ func (x *PublishTaskRewardPolicyRequest) GetPublishedAtMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Deprecated: 服务端不再发布运行侧任务默认资产,调用会返回明确的已退役错误。
|
||||
//
|
||||
// Deprecated: Marked as deprecated in proto/activity/v1/activity.proto.
|
||||
type PublishTaskRewardPolicyResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
||||
@ -26958,7 +26964,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status\x12*\n" +
|
||||
"\x11operator_admin_id\x18\x04 \x01(\x03R\x0foperatorAdminId\"X\n" +
|
||||
"\x1fSetTaskDefinitionStatusResponse\x125\n" +
|
||||
"\x04task\x18\x01 \x01(\v2!.hyapp.activity.v1.TaskDefinitionR\x04task\"\xfe\x02\n" +
|
||||
"\x04task\x18\x01 \x01(\v2!.hyapp.activity.v1.TaskDefinitionR\x04task\"\x82\x03\n" +
|
||||
"\x1ePublishTaskRewardPolicyRequest\x122\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12#\n" +
|
||||
"\rinstance_code\x18\x02 \x01(\tR\finstanceCode\x12#\n" +
|
||||
@ -26968,13 +26974,13 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x11reward_asset_type\x18\x06 \x01(\tR\x0frewardAssetType\x12\x1b\n" +
|
||||
"\trule_json\x18\a \x01(\tR\bruleJson\x12*\n" +
|
||||
"\x11operator_admin_id\x18\b \x01(\x03R\x0foperatorAdminId\x12&\n" +
|
||||
"\x0fpublished_at_ms\x18\t \x01(\x03R\rpublishedAtMs\"\xcd\x01\n" +
|
||||
"\x0fpublished_at_ms\x18\t \x01(\x03R\rpublishedAtMs:\x02\x18\x01\"\xd1\x01\n" +
|
||||
"\x1fPublishTaskRewardPolicyResponse\x12\x19\n" +
|
||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12#\n" +
|
||||
"\rinstance_code\x18\x02 \x01(\tR\finstanceCode\x12\x16\n" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status\x12*\n" +
|
||||
"\x11reward_asset_type\x18\x04 \x01(\tR\x0frewardAssetType\x12&\n" +
|
||||
"\x0fpublished_at_ms\x18\x05 \x01(\x03R\rpublishedAtMs\"\xd5\x02\n" +
|
||||
"\x0fpublished_at_ms\x18\x05 \x01(\x03R\rpublishedAtMs:\x02\x18\x01\"\xd5\x02\n" +
|
||||
"\x18RegistrationRewardConfig\x12\x19\n" +
|
||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x18\n" +
|
||||
"\aenabled\x18\x02 \x01(\bR\aenabled\x12\x1f\n" +
|
||||
@ -28987,12 +28993,12 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x19GetGameWinBroadcastConfig\x123.hyapp.activity.v1.GetGameWinBroadcastConfigRequest\x1a4.hyapp.activity.v1.GetGameWinBroadcastConfigResponse\x12\x8f\x01\n" +
|
||||
"\x1cUpdateGameWinBroadcastConfig\x126.hyapp.activity.v1.UpdateGameWinBroadcastConfigRequest\x1a7.hyapp.activity.v1.UpdateGameWinBroadcastConfigResponse2\x87\x01\n" +
|
||||
"\x18RoomEventConsumerService\x12k\n" +
|
||||
"\x10ConsumeRoomEvent\x12*.hyapp.activity.v1.ConsumeRoomEventRequest\x1a+.hyapp.activity.v1.ConsumeRoomEventResponse2\x87\x04\n" +
|
||||
"\x10ConsumeRoomEvent\x12*.hyapp.activity.v1.ConsumeRoomEventRequest\x1a+.hyapp.activity.v1.ConsumeRoomEventResponse2\x8c\x04\n" +
|
||||
"\x10AdminTaskService\x12t\n" +
|
||||
"\x13ListTaskDefinitions\x12-.hyapp.activity.v1.ListTaskDefinitionsRequest\x1a..hyapp.activity.v1.ListTaskDefinitionsResponse\x12w\n" +
|
||||
"\x14UpsertTaskDefinition\x12..hyapp.activity.v1.UpsertTaskDefinitionRequest\x1a/.hyapp.activity.v1.UpsertTaskDefinitionResponse\x12\x80\x01\n" +
|
||||
"\x17SetTaskDefinitionStatus\x121.hyapp.activity.v1.SetTaskDefinitionStatusRequest\x1a2.hyapp.activity.v1.SetTaskDefinitionStatusResponse\x12\x80\x01\n" +
|
||||
"\x17PublishTaskRewardPolicy\x121.hyapp.activity.v1.PublishTaskRewardPolicyRequest\x1a2.hyapp.activity.v1.PublishTaskRewardPolicyResponse2\xbc\x02\n" +
|
||||
"\x17SetTaskDefinitionStatus\x121.hyapp.activity.v1.SetTaskDefinitionStatusRequest\x1a2.hyapp.activity.v1.SetTaskDefinitionStatusResponse\x12\x85\x01\n" +
|
||||
"\x17PublishTaskRewardPolicy\x121.hyapp.activity.v1.PublishTaskRewardPolicyRequest\x1a2.hyapp.activity.v1.PublishTaskRewardPolicyResponse\"\x03\x88\x02\x012\xbc\x02\n" +
|
||||
"\x19RegistrationRewardService\x12\x9b\x01\n" +
|
||||
" GetRegistrationRewardEligibility\x12:.hyapp.activity.v1.GetRegistrationRewardEligibilityRequest\x1a;.hyapp.activity.v1.GetRegistrationRewardEligibilityResponse\x12\x80\x01\n" +
|
||||
"\x17IssueRegistrationReward\x121.hyapp.activity.v1.IssueRegistrationRewardRequest\x1a2.hyapp.activity.v1.IssueRegistrationRewardResponse2\xd9\x03\n" +
|
||||
|
||||
@ -539,8 +539,11 @@ message SetTaskDefinitionStatusResponse {
|
||||
TaskDefinition task = 1;
|
||||
}
|
||||
|
||||
// PublishTaskRewardPolicyRequest 是后台策略发布写入 activity 运行侧任务奖励默认资产的命令。
|
||||
// Deprecated: 任务奖励资产现在由每条 TaskDefinition.reward_asset_type 显式配置。
|
||||
// 保留消息仅用于旧 Admin 客户端与新 Activity 服务滚动发布时的 wire 兼容。
|
||||
message PublishTaskRewardPolicyRequest {
|
||||
option deprecated = true;
|
||||
|
||||
RequestMeta meta = 1;
|
||||
string instance_code = 2;
|
||||
string template_code = 3;
|
||||
@ -552,7 +555,10 @@ message PublishTaskRewardPolicyRequest {
|
||||
int64 published_at_ms = 9;
|
||||
}
|
||||
|
||||
// Deprecated: 服务端不再发布运行侧任务默认资产,调用会返回明确的已退役错误。
|
||||
message PublishTaskRewardPolicyResponse {
|
||||
option deprecated = true;
|
||||
|
||||
string app_code = 1;
|
||||
string instance_code = 2;
|
||||
string status = 3;
|
||||
@ -3063,7 +3069,9 @@ service AdminTaskService {
|
||||
rpc ListTaskDefinitions(ListTaskDefinitionsRequest) returns (ListTaskDefinitionsResponse);
|
||||
rpc UpsertTaskDefinition(UpsertTaskDefinitionRequest) returns (UpsertTaskDefinitionResponse);
|
||||
rpc SetTaskDefinitionStatus(SetTaskDefinitionStatusRequest) returns (SetTaskDefinitionStatusResponse);
|
||||
rpc PublishTaskRewardPolicy(PublishTaskRewardPolicyRequest) returns (PublishTaskRewardPolicyResponse);
|
||||
rpc PublishTaskRewardPolicy(PublishTaskRewardPolicyRequest) returns (PublishTaskRewardPolicyResponse) {
|
||||
option deprecated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// RegistrationRewardService 拥有 App 查询和注册完成发放入口。
|
||||
|
||||
@ -3330,6 +3330,7 @@ type AdminTaskServiceClient interface {
|
||||
ListTaskDefinitions(ctx context.Context, in *ListTaskDefinitionsRequest, opts ...grpc.CallOption) (*ListTaskDefinitionsResponse, error)
|
||||
UpsertTaskDefinition(ctx context.Context, in *UpsertTaskDefinitionRequest, opts ...grpc.CallOption) (*UpsertTaskDefinitionResponse, error)
|
||||
SetTaskDefinitionStatus(ctx context.Context, in *SetTaskDefinitionStatusRequest, opts ...grpc.CallOption) (*SetTaskDefinitionStatusResponse, error)
|
||||
// Deprecated: Do not use.
|
||||
PublishTaskRewardPolicy(ctx context.Context, in *PublishTaskRewardPolicyRequest, opts ...grpc.CallOption) (*PublishTaskRewardPolicyResponse, error)
|
||||
}
|
||||
|
||||
@ -3371,6 +3372,7 @@ func (c *adminTaskServiceClient) SetTaskDefinitionStatus(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Deprecated: Do not use.
|
||||
func (c *adminTaskServiceClient) PublishTaskRewardPolicy(ctx context.Context, in *PublishTaskRewardPolicyRequest, opts ...grpc.CallOption) (*PublishTaskRewardPolicyResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PublishTaskRewardPolicyResponse)
|
||||
@ -3390,6 +3392,7 @@ type AdminTaskServiceServer interface {
|
||||
ListTaskDefinitions(context.Context, *ListTaskDefinitionsRequest) (*ListTaskDefinitionsResponse, error)
|
||||
UpsertTaskDefinition(context.Context, *UpsertTaskDefinitionRequest) (*UpsertTaskDefinitionResponse, error)
|
||||
SetTaskDefinitionStatus(context.Context, *SetTaskDefinitionStatusRequest) (*SetTaskDefinitionStatusResponse, error)
|
||||
// Deprecated: Do not use.
|
||||
PublishTaskRewardPolicy(context.Context, *PublishTaskRewardPolicyRequest) (*PublishTaskRewardPolicyResponse, error)
|
||||
mustEmbedUnimplementedAdminTaskServiceServer()
|
||||
}
|
||||
|
||||
@ -126,8 +126,8 @@ type LuckyGiftMeta struct {
|
||||
Recharge_7DCoins int64 `protobuf:"varint,19,opt,name=recharge_7d_coins,json=recharge7dCoins,proto3" json:"recharge_7d_coins,omitempty"`
|
||||
Recharge_30DCoins int64 `protobuf:"varint,20,opt,name=recharge_30d_coins,json=recharge30dCoins,proto3" json:"recharge_30d_coins,omitempty"`
|
||||
LastRechargedAtMs int64 `protobuf:"varint,21,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"`
|
||||
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次结算的主播收益:
|
||||
// POINT 政策读 host_point_added,旧链路读 host_period_diamond_added;Agency 额外收益不包含在内。
|
||||
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次按 Host 政策结算的周期钻石;
|
||||
// 已退役的直接 POINT 回执不再参与拆分,Agency 额外收益也不包含在内。
|
||||
// 幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。
|
||||
GiftIncomeCoins int64 `protobuf:"varint,22,opt,name=gift_income_coins,json=giftIncomeCoins,proto3" json:"gift_income_coins,omitempty"`
|
||||
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
|
||||
|
||||
@ -40,8 +40,8 @@ message LuckyGiftMeta {
|
||||
int64 recharge_7d_coins = 19;
|
||||
int64 recharge_30d_coins = 20;
|
||||
int64 last_recharged_at_ms = 21;
|
||||
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次结算的主播收益:
|
||||
// POINT 政策读 host_point_added,旧链路读 host_period_diamond_added;Agency 额外收益不包含在内。
|
||||
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次按 Host 政策结算的周期钻石;
|
||||
// 已退役的直接 POINT 回执不再参与拆分,Agency 额外收益也不包含在内。
|
||||
// 幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。
|
||||
int64 gift_income_coins = 22;
|
||||
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -489,6 +489,7 @@ message RoleScopePolicy {
|
||||
bool region_expansion_configurable = 3;
|
||||
// expanded_scope 是经理通用范围开关开启后的范围;调用方不能自行假设一定是 region。
|
||||
string expanded_scope = 4;
|
||||
int64 updated_at_ms = 5;
|
||||
}
|
||||
|
||||
message GetRoleScopePolicyRequest {
|
||||
@ -500,6 +501,16 @@ message GetRoleScopePolicyResponse {
|
||||
RoleScopePolicy policy = 1;
|
||||
}
|
||||
|
||||
message UpdateRoleScopePoliciesRequest {
|
||||
RequestMeta meta = 1;
|
||||
// 两个 scene 必须一起提交,user-service 在同一事务内保存完整 App 快照。
|
||||
repeated RoleScopePolicy policies = 2;
|
||||
}
|
||||
|
||||
message UpdateRoleScopePoliciesResponse {
|
||||
repeated RoleScopePolicy policies = 1;
|
||||
}
|
||||
|
||||
message GetAgencyMembersRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 agency_id = 2;
|
||||
@ -770,6 +781,8 @@ service UserHostService {
|
||||
|
||||
// UserHostAdminService 是后台关系管理入口;公网 admin 鉴权由 admin-server 承担。
|
||||
service UserHostAdminService {
|
||||
// UpdateRoleScopePolicies 是后台修改角色范围的唯一写入口,配置事实归 user DB 所有。
|
||||
rpc UpdateRoleScopePolicies(UpdateRoleScopePoliciesRequest) returns (UpdateRoleScopePoliciesResponse);
|
||||
rpc CreateBDLeader(CreateBDLeaderRequest) returns (CreateBDLeaderResponse);
|
||||
rpc CreateBD(CreateBDRequest) returns (CreateBDResponse);
|
||||
rpc SetBDStatus(SetBDStatusRequest) returns (SetBDStatusResponse);
|
||||
|
||||
@ -1305,6 +1305,7 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
UserHostAdminService_UpdateRoleScopePolicies_FullMethodName = "/hyapp.user.v1.UserHostAdminService/UpdateRoleScopePolicies"
|
||||
UserHostAdminService_CreateBDLeader_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBDLeader"
|
||||
UserHostAdminService_CreateBD_FullMethodName = "/hyapp.user.v1.UserHostAdminService/CreateBD"
|
||||
UserHostAdminService_SetBDStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetBDStatus"
|
||||
@ -1325,6 +1326,8 @@ const (
|
||||
//
|
||||
// UserHostAdminService 是后台关系管理入口;公网 admin 鉴权由 admin-server 承担。
|
||||
type UserHostAdminServiceClient interface {
|
||||
// UpdateRoleScopePolicies 是后台修改角色范围的唯一写入口,配置事实归 user DB 所有。
|
||||
UpdateRoleScopePolicies(ctx context.Context, in *UpdateRoleScopePoliciesRequest, opts ...grpc.CallOption) (*UpdateRoleScopePoliciesResponse, error)
|
||||
CreateBDLeader(ctx context.Context, in *CreateBDLeaderRequest, opts ...grpc.CallOption) (*CreateBDLeaderResponse, error)
|
||||
CreateBD(ctx context.Context, in *CreateBDRequest, opts ...grpc.CallOption) (*CreateBDResponse, error)
|
||||
SetBDStatus(ctx context.Context, in *SetBDStatusRequest, opts ...grpc.CallOption) (*SetBDStatusResponse, error)
|
||||
@ -1347,6 +1350,16 @@ func NewUserHostAdminServiceClient(cc grpc.ClientConnInterface) UserHostAdminSer
|
||||
return &userHostAdminServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *userHostAdminServiceClient) UpdateRoleScopePolicies(ctx context.Context, in *UpdateRoleScopePoliciesRequest, opts ...grpc.CallOption) (*UpdateRoleScopePoliciesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateRoleScopePoliciesResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostAdminService_UpdateRoleScopePolicies_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostAdminServiceClient) CreateBDLeader(ctx context.Context, in *CreateBDLeaderRequest, opts ...grpc.CallOption) (*CreateBDLeaderResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateBDLeaderResponse)
|
||||
@ -1473,6 +1486,8 @@ func (c *userHostAdminServiceClient) SetAgencyJoinEnabled(ctx context.Context, i
|
||||
//
|
||||
// UserHostAdminService 是后台关系管理入口;公网 admin 鉴权由 admin-server 承担。
|
||||
type UserHostAdminServiceServer interface {
|
||||
// UpdateRoleScopePolicies 是后台修改角色范围的唯一写入口,配置事实归 user DB 所有。
|
||||
UpdateRoleScopePolicies(context.Context, *UpdateRoleScopePoliciesRequest) (*UpdateRoleScopePoliciesResponse, error)
|
||||
CreateBDLeader(context.Context, *CreateBDLeaderRequest) (*CreateBDLeaderResponse, error)
|
||||
CreateBD(context.Context, *CreateBDRequest) (*CreateBDResponse, error)
|
||||
SetBDStatus(context.Context, *SetBDStatusRequest) (*SetBDStatusResponse, error)
|
||||
@ -1495,6 +1510,9 @@ type UserHostAdminServiceServer interface {
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedUserHostAdminServiceServer struct{}
|
||||
|
||||
func (UnimplementedUserHostAdminServiceServer) UpdateRoleScopePolicies(context.Context, *UpdateRoleScopePoliciesRequest) (*UpdateRoleScopePoliciesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateRoleScopePolicies not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostAdminServiceServer) CreateBDLeader(context.Context, *CreateBDLeaderRequest) (*CreateBDLeaderResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CreateBDLeader not implemented")
|
||||
}
|
||||
@ -1552,6 +1570,24 @@ func RegisterUserHostAdminServiceServer(s grpc.ServiceRegistrar, srv UserHostAdm
|
||||
s.RegisterService(&UserHostAdminService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _UserHostAdminService_UpdateRoleScopePolicies_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateRoleScopePoliciesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostAdminServiceServer).UpdateRoleScopePolicies(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostAdminService_UpdateRoleScopePolicies_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostAdminServiceServer).UpdateRoleScopePolicies(ctx, req.(*UpdateRoleScopePoliciesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostAdminService_CreateBDLeader_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateBDLeaderRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1775,6 +1811,10 @@ var UserHostAdminService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.user.v1.UserHostAdminService",
|
||||
HandlerType: (*UserHostAdminServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "UpdateRoleScopePolicies",
|
||||
Handler: _UserHostAdminService_UpdateRoleScopePolicies_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateBDLeader",
|
||||
Handler: _UserHostAdminService_CreateBDLeader_Handler,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -80,16 +80,18 @@ message DebitGiftResponse {
|
||||
int64 gift_income_coin_amount = 20;
|
||||
// gift_income_balance_after 是收礼人 COIN 账后余额;未返币时为 0。
|
||||
int64 gift_income_balance_after = 21;
|
||||
// host_point_added 是命中 App 收益政策后给主播 POINT 钱包增加的积分;不参与房间热度/贡献榜。
|
||||
int64 host_point_added = 22;
|
||||
// host_point_balance_after 是主播 POINT 入账后的可用余额;未命中 POINT 政策时为 0。
|
||||
int64 host_point_balance_after = 23;
|
||||
// host_point_asset_type 当前固定为 POINT,显式返回避免客户端把收益积分当成 COIN。
|
||||
string host_point_asset_type = 24;
|
||||
// host_point_policy_instance_code 是本次使用的策略实例快照。
|
||||
string host_point_policy_instance_code = 25;
|
||||
string host_point_template_code = 26;
|
||||
string host_point_template_version = 27;
|
||||
// Deprecated: 仅用于旧 command_id 幂等重放返回首次 POINT 回执;新送礼不会再写入或消费该值。
|
||||
int64 host_point_added = 22 [deprecated = true];
|
||||
// Deprecated: 仅保留旧 POINT 回执的账后余额快照;新送礼不会再写入或消费该值。
|
||||
int64 host_point_balance_after = 23 [deprecated = true];
|
||||
// Deprecated: 仅保留旧 POINT 回执的资产类型快照;新送礼不会再写入或消费该值。
|
||||
string host_point_asset_type = 24 [deprecated = true];
|
||||
// Deprecated: 仅保留旧 POINT 回执的政策实例快照;新送礼不会再读取旧收益政策。
|
||||
string host_point_policy_instance_code = 25 [deprecated = true];
|
||||
// Deprecated: 仅保留旧 POINT 回执的模板编码快照。
|
||||
string host_point_template_code = 26 [deprecated = true];
|
||||
// Deprecated: 仅保留旧 POINT 回执的模板版本快照。
|
||||
string host_point_template_version = 27 [deprecated = true];
|
||||
// balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。
|
||||
int64 balance_version = 28;
|
||||
// recharge_seven_day_coins / recharge_thirty_day_coins 是扣费时由 wallet owner 固化的 UTC 自然日充值快照。
|
||||
@ -101,6 +103,18 @@ message DebitGiftResponse {
|
||||
// paid_at_ms 是 wallet_transactions.created_at_ms 的稳定快照;首次扣费与同 command_id 重放必须完全一致。
|
||||
// room/lucky 只能用该 wallet owner 事实做 UTC 日、小时和充值后短窗归属,不能用 saga 或恢复 worker 时钟替代。
|
||||
int64 paid_at_ms = 32;
|
||||
// Host 政策无关收益快照:工资政策写 HOST_PERIOD_DIAMOND,永久积分类政策写 POINT_DIAMOND。
|
||||
string host_income_policy_type = 33;
|
||||
int64 host_income_balance_after = 36;
|
||||
string income_asset_type = 37;
|
||||
int64 host_base_amount = 38;
|
||||
int64 agency_amount = 39;
|
||||
int64 agency_owner_user_id = 40;
|
||||
int64 agency_income_balance_after = 41;
|
||||
uint64 host_income_policy_id = 42;
|
||||
uint64 host_income_policy_version = 43;
|
||||
string host_policy_coefficient = 44;
|
||||
int32 agency_point_share_bps = 45;
|
||||
}
|
||||
|
||||
// DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。
|
||||
@ -203,6 +217,12 @@ message HostSalaryPolicy {
|
||||
repeated HostSalaryPolicyLevel levels = 11;
|
||||
string cycle_key = 12;
|
||||
uint64 policy_version = 13;
|
||||
string policy_type = 14;
|
||||
int64 point_diamonds_per_usd = 15;
|
||||
int64 coins_per_usd = 16;
|
||||
int64 minimum_withdraw_usd_minor = 17;
|
||||
int32 withdraw_fee_bps = 18;
|
||||
int32 agency_point_share_bps = 19;
|
||||
}
|
||||
|
||||
message GetActiveHostSalaryPolicyRequest {
|
||||
@ -437,6 +457,8 @@ message FreezeSalaryWithdrawalRequest {
|
||||
string reason = 5;
|
||||
string app_code = 6;
|
||||
string withdrawal_ref = 7;
|
||||
// region_id comes from the verified business identity and selects the cycle-bound Host policy.
|
||||
int64 region_id = 8;
|
||||
}
|
||||
|
||||
message FreezeSalaryWithdrawalResponse {
|
||||
@ -481,26 +503,28 @@ message ReleaseSalaryWithdrawalResponse {
|
||||
int64 salary_usd_minor = 3;
|
||||
}
|
||||
|
||||
// FreezePointWithdrawalRequest 将 Huwaa 策略积分从可用余额冻结到提现审核池。
|
||||
// FreezePointWithdrawalRequest 将 POINT 从可用余额冻结到提现审核池。
|
||||
message FreezePointWithdrawalRequest {
|
||||
string command_id = 1;
|
||||
int64 user_id = 2;
|
||||
// asset_type 只能是 POINT 或 COIN_SELLER_POINT;旧工资美元资产继续使用 SalaryWithdrawal 接口。
|
||||
string asset_type = 3;
|
||||
// gross_point_amount 是本次冻结的积分总额,Huwaa 普通 POINT 最低 1,000,000。
|
||||
// gross_point_amount 是本次冻结的积分总额,最小值由 App + 区域 wallet 政策决定。
|
||||
int64 gross_point_amount = 4;
|
||||
// fee_point_amount 是平台手续费积分;为空时服务端按 fee_bps 计算。
|
||||
int64 fee_point_amount = 5;
|
||||
// net_point_amount 是预计到账积分,必须等于 gross - fee;为空时服务端计算。
|
||||
int64 net_point_amount = 6;
|
||||
// points_per_usd 是展示折算快照,Huwaa 第一套默认 100000。
|
||||
// points_per_usd 是 Gateway 预览值;wallet 会在冻结事务内按已发布政策重新固化。
|
||||
int64 points_per_usd = 7;
|
||||
int32 fee_bps = 8;
|
||||
string reason = 9;
|
||||
string app_code = 10;
|
||||
string withdrawal_ref = 11;
|
||||
// region_id 只能由 gateway 从登录用户资料注入,客户端请求体不能决定工资政策边界。
|
||||
// region_id 只能由 gateway 从登录用户资料注入,客户端请求体不能决定 wallet 政策边界。
|
||||
int64 region_id = 12;
|
||||
// POINT_DIAMOND 由美元美分和 Host 政策换算冻结积分;历史 POINT 继续传 gross_point_amount。
|
||||
int64 gross_usd_minor = 13;
|
||||
}
|
||||
|
||||
message FreezePointWithdrawalResponse {
|
||||
@ -511,6 +535,14 @@ message FreezePointWithdrawalResponse {
|
||||
int64 net_point_amount = 5;
|
||||
int64 points_per_usd = 6;
|
||||
int32 fee_bps = 7;
|
||||
// policy_instance_code 是首次冻结事务实际使用的 wallet 政策实例,重放返回同一快照。
|
||||
string policy_instance_code = 8;
|
||||
int64 gross_usd_minor = 9;
|
||||
int64 fee_usd_minor = 10;
|
||||
int64 net_usd_minor = 11;
|
||||
uint64 policy_id = 12;
|
||||
uint64 policy_version = 13;
|
||||
int64 coins_per_usd = 14;
|
||||
}
|
||||
|
||||
// SettlePointWithdrawalRequest 在审核通过时扣掉已冻结积分;不会再次扣 available。
|
||||
@ -527,6 +559,7 @@ message SettlePointWithdrawalRequest {
|
||||
string reason = 10;
|
||||
string app_code = 11;
|
||||
string withdrawal_application_id = 12;
|
||||
string freeze_transaction_id = 13;
|
||||
}
|
||||
|
||||
message SettlePointWithdrawalResponse {
|
||||
@ -555,6 +588,7 @@ message ReleasePointWithdrawalRequest {
|
||||
string withdrawal_application_id = 12;
|
||||
// withdrawal_ref 只用于申请创建失败回滚对应的次数 reservation;人工驳回不传。
|
||||
string withdrawal_ref = 13;
|
||||
string freeze_transaction_id = 14;
|
||||
}
|
||||
|
||||
message ReleasePointWithdrawalResponse {
|
||||
@ -1957,6 +1991,8 @@ message ExchangePointToCoinRequest {
|
||||
int64 point_amount = 4;
|
||||
int64 region_id = 5;
|
||||
int64 now_ms = 6;
|
||||
// 为空保持历史 POINT;永久钻石积分入口固定传 POINT_DIAMOND。
|
||||
string source_asset_type = 7;
|
||||
}
|
||||
|
||||
message ExchangePointToCoinResponse {
|
||||
@ -1967,6 +2003,10 @@ message ExchangePointToCoinResponse {
|
||||
int64 coin_balance_after = 5;
|
||||
int64 ratio_point_amount = 6;
|
||||
int64 ratio_coin_amount = 7;
|
||||
string source_asset_type = 8;
|
||||
// POINT_DIAMOND 固化首次兑换所用 Host 政策快照;历史 POINT 返回 0。
|
||||
uint64 policy_id = 9;
|
||||
uint64 policy_version = 10;
|
||||
}
|
||||
|
||||
// WalletTransaction 是用户钱包流水页使用的分录级投影。
|
||||
@ -1984,6 +2024,19 @@ message WalletTransaction {
|
||||
int64 created_at_ms = 11;
|
||||
int64 transfer_usd_minor = 12;
|
||||
string transfer_currency_code = 13;
|
||||
// entry_role 是面向客户端的分录语义;同一 gift_debit 下可区分 Host 基础收入和 Agency 分成。
|
||||
string entry_role = 14;
|
||||
// 同人既是 Host 又是 Agency 时账户只写一笔增量,但这两个字段仍保留各自的礼物收益组成。
|
||||
int64 host_base_amount = 15;
|
||||
int64 agency_amount = 16;
|
||||
uint64 policy_id = 17;
|
||||
uint64 policy_version = 18;
|
||||
int64 ratio_point_amount = 19;
|
||||
int64 ratio_coin_amount = 20;
|
||||
int64 gross_usd_minor = 21;
|
||||
int64 fee_usd_minor = 22;
|
||||
int64 net_usd_minor = 23;
|
||||
int64 withdraw_fee_bps = 24;
|
||||
}
|
||||
|
||||
message ListWalletTransactionsRequest {
|
||||
@ -2889,9 +2942,12 @@ message GetGiftCatalogVersionResponse {
|
||||
|
||||
// HostRevenueStats 聚合主播在自然日区间内的收益 POINT 和送礼用户;gateway 对外仍按产品文案展示为钻石。
|
||||
message HostRevenueStats {
|
||||
// 旧字段保持 SALARY_DIAMOND 收益与历史 POINT 转出,避免滚动升级改变 Lalu 口径。
|
||||
int64 diamond_earnings = 1;
|
||||
int64 diamond_exchanged = 2;
|
||||
int64 gift_senders = 3;
|
||||
int64 point_diamond_earnings = 4;
|
||||
int64 point_diamond_exchanged = 5;
|
||||
}
|
||||
|
||||
message GetHostRevenueStatsRequest {
|
||||
@ -2900,33 +2956,19 @@ message GetHostRevenueStatsRequest {
|
||||
int64 host_user_id = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
int64 region_id = 6;
|
||||
}
|
||||
|
||||
message GetHostRevenueStatsResponse {
|
||||
HostRevenueStats stats = 1;
|
||||
}
|
||||
|
||||
// AgencyPointShareStats 只统计独立 Agency 分成流水,不把 owner 自己的主播礼物收益重复算入分成。
|
||||
message AgencyPointShareStats {
|
||||
int64 share_income = 1;
|
||||
int64 gifted_host_count = 2;
|
||||
}
|
||||
|
||||
message GetAgencyPointShareStatsRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 agency_owner_user_id = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
}
|
||||
|
||||
message GetAgencyPointShareStatsResponse {
|
||||
AgencyPointShareStats stats = 1;
|
||||
}
|
||||
|
||||
message AgencyHostGiftStats {
|
||||
// 旧 gift_income 只表示 SALARY_DIAMOND Host 收益;永久积分收入不得混入。
|
||||
int64 gift_income = 1;
|
||||
int64 gifted_host_count = 2;
|
||||
int64 point_diamond_host_income = 4;
|
||||
int64 point_diamond_agency_share_income = 5;
|
||||
}
|
||||
|
||||
message GetAgencyHostGiftStatsRequest {
|
||||
@ -2935,10 +2977,19 @@ message GetAgencyHostGiftStatsRequest {
|
||||
repeated int64 host_user_ids = 3;
|
||||
int64 start_at_ms = 4;
|
||||
int64 end_at_ms = 5;
|
||||
// Agency Center 传 owner user_id 以汇总 POINT_DIAMOND agency_amount;BD 视图保持 0。
|
||||
int64 agency_user_id = 6;
|
||||
}
|
||||
|
||||
message GetAgencyHostGiftStatsResponse {
|
||||
AgencyHostGiftStats stats = 1;
|
||||
// 一次返回请求内所有主播的统计,避免 Agency Center 对 wallet-service 发起 N 次 RPC。
|
||||
repeated AgencyHostRevenueStats host_stats = 2;
|
||||
}
|
||||
|
||||
message AgencyHostRevenueStats {
|
||||
int64 host_user_id = 1;
|
||||
HostRevenueStats stats = 2;
|
||||
}
|
||||
|
||||
// PointWithdrawalCoinSellerConfig 是按 App 隔离的 POINT 提现币商白名单与兑换比例。
|
||||
@ -2978,6 +3029,9 @@ message TransferPointToCoinSellerRequest {
|
||||
string reason = 7;
|
||||
// region_id 只能由 gateway 从登录用户资料注入,用于解析当前 UTC 月工资政策。
|
||||
int64 region_id = 8;
|
||||
// 为空保持历史 POINT;POINT_DIAMOND 按 gross_usd_minor 和 Host 政策换算双方资产。
|
||||
string source_asset_type = 9;
|
||||
int64 gross_usd_minor = 10;
|
||||
}
|
||||
|
||||
message TransferPointToCoinSellerResponse {
|
||||
@ -2988,6 +3042,13 @@ message TransferPointToCoinSellerResponse {
|
||||
int64 seller_coin_amount = 5;
|
||||
int64 ratio_point_amount = 6;
|
||||
int64 ratio_seller_coin_amount = 7;
|
||||
string source_asset_type = 8;
|
||||
int64 gross_usd_minor = 9;
|
||||
int64 fee_usd_minor = 10;
|
||||
int64 net_usd_minor = 11;
|
||||
int32 withdraw_fee_bps = 12;
|
||||
uint64 policy_id = 13;
|
||||
uint64 policy_version = 14;
|
||||
}
|
||||
|
||||
message GetPointWithdrawalConfigRequest {
|
||||
@ -2995,6 +3056,20 @@ message GetPointWithdrawalConfigRequest {
|
||||
string app_code = 2;
|
||||
int64 region_id = 3;
|
||||
int64 now_ms = 4;
|
||||
string asset_type = 5;
|
||||
// overview 传登录 user_id,以便 wallet 按当前 UTC 周期返回真实次数余量。
|
||||
int64 user_id = 6;
|
||||
}
|
||||
|
||||
message PointWithdrawalActionAvailability {
|
||||
bool allowed = 1;
|
||||
string limit_period = 2;
|
||||
// limit_count=0、remaining_count=-1 表示不限次数。
|
||||
int64 limit_count = 3;
|
||||
int64 used_count = 4;
|
||||
int64 remaining_count = 5;
|
||||
string allowed_days = 6;
|
||||
string block_reason = 7;
|
||||
}
|
||||
|
||||
message GetPointWithdrawalConfigResponse {
|
||||
@ -3005,6 +3080,14 @@ message GetPointWithdrawalConfigResponse {
|
||||
string policy_instance_code = 5;
|
||||
// coins_per_usd 复用工资兑换普通金币的统一比例,与 points_per_usd 组合得到 POINT->COIN 展示比例。
|
||||
int64 coins_per_usd = 6;
|
||||
string policy_type = 7;
|
||||
int64 minimum_withdraw_usd_minor = 8;
|
||||
int32 agency_point_share_bps = 9;
|
||||
uint64 policy_id = 10;
|
||||
uint64 policy_version = 11;
|
||||
bool availability_evaluated = 12;
|
||||
PointWithdrawalActionAvailability coin_seller_availability = 13;
|
||||
PointWithdrawalActionAvailability platform_availability = 14;
|
||||
}
|
||||
|
||||
// VipBenefitPreviewItem 是同一权益下的一项预览内容;独立稳定键允许 Flutter 在多图、动态资源间切换,
|
||||
@ -3071,7 +3154,6 @@ service WalletService {
|
||||
rpc GetActiveHostSalaryPolicy(GetActiveHostSalaryPolicyRequest) returns (GetActiveHostSalaryPolicyResponse);
|
||||
rpc GetHostSalaryProgress(GetHostSalaryProgressRequest) returns (GetHostSalaryProgressResponse);
|
||||
rpc GetHostRevenueStats(GetHostRevenueStatsRequest) returns (GetHostRevenueStatsResponse);
|
||||
rpc GetAgencyPointShareStats(GetAgencyPointShareStatsRequest) returns (GetAgencyPointShareStatsResponse);
|
||||
rpc GetAgencyHostGiftStats(GetAgencyHostGiftStatsRequest) returns (GetAgencyHostGiftStatsResponse);
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
rpc GetTeamHostSalaryStats(GetTeamHostSalaryStatsRequest) returns (GetTeamHostSalaryStatsResponse);
|
||||
|
||||
@ -247,7 +247,6 @@ const (
|
||||
WalletService_GetActiveHostSalaryPolicy_FullMethodName = "/hyapp.wallet.v1.WalletService/GetActiveHostSalaryPolicy"
|
||||
WalletService_GetHostSalaryProgress_FullMethodName = "/hyapp.wallet.v1.WalletService/GetHostSalaryProgress"
|
||||
WalletService_GetHostRevenueStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetHostRevenueStats"
|
||||
WalletService_GetAgencyPointShareStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetAgencyPointShareStats"
|
||||
WalletService_GetAgencyHostGiftStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetAgencyHostGiftStats"
|
||||
WalletService_GetTeamHostSalaryStats_FullMethodName = "/hyapp.wallet.v1.WalletService/GetTeamHostSalaryStats"
|
||||
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
|
||||
@ -384,7 +383,6 @@ type WalletServiceClient interface {
|
||||
GetActiveHostSalaryPolicy(ctx context.Context, in *GetActiveHostSalaryPolicyRequest, opts ...grpc.CallOption) (*GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(ctx context.Context, in *GetHostSalaryProgressRequest, opts ...grpc.CallOption) (*GetHostSalaryProgressResponse, error)
|
||||
GetHostRevenueStats(ctx context.Context, in *GetHostRevenueStatsRequest, opts ...grpc.CallOption) (*GetHostRevenueStatsResponse, error)
|
||||
GetAgencyPointShareStats(ctx context.Context, in *GetAgencyPointShareStatsRequest, opts ...grpc.CallOption) (*GetAgencyPointShareStatsResponse, error)
|
||||
GetAgencyHostGiftStats(ctx context.Context, in *GetAgencyHostGiftStatsRequest, opts ...grpc.CallOption) (*GetAgencyHostGiftStatsResponse, error)
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
GetTeamHostSalaryStats(ctx context.Context, in *GetTeamHostSalaryStatsRequest, opts ...grpc.CallOption) (*GetTeamHostSalaryStatsResponse, error)
|
||||
@ -597,16 +595,6 @@ func (c *walletServiceClient) GetHostRevenueStats(ctx context.Context, in *GetHo
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetAgencyPointShareStats(ctx context.Context, in *GetAgencyPointShareStatsRequest, opts ...grpc.CallOption) (*GetAgencyPointShareStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetAgencyPointShareStatsResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetAgencyPointShareStats_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetAgencyHostGiftStats(ctx context.Context, in *GetAgencyHostGiftStatsRequest, opts ...grpc.CallOption) (*GetAgencyHostGiftStatsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetAgencyHostGiftStatsResponse)
|
||||
@ -1821,7 +1809,6 @@ type WalletServiceServer interface {
|
||||
GetActiveHostSalaryPolicy(context.Context, *GetActiveHostSalaryPolicyRequest) (*GetActiveHostSalaryPolicyResponse, error)
|
||||
GetHostSalaryProgress(context.Context, *GetHostSalaryProgressRequest) (*GetHostSalaryProgressResponse, error)
|
||||
GetHostRevenueStats(context.Context, *GetHostRevenueStatsRequest) (*GetHostRevenueStatsResponse, error)
|
||||
GetAgencyPointShareStats(context.Context, *GetAgencyPointShareStatsRequest) (*GetAgencyPointShareStatsResponse, error)
|
||||
GetAgencyHostGiftStats(context.Context, *GetAgencyHostGiftStatsRequest) (*GetAgencyHostGiftStatsResponse, error)
|
||||
// GetTeamHostSalaryStats 按 Agency 收款人集合聚合主播预收入工资;经理中心团队工资卡片使用。
|
||||
GetTeamHostSalaryStats(context.Context, *GetTeamHostSalaryStatsRequest) (*GetTeamHostSalaryStatsResponse, error)
|
||||
@ -1978,9 +1965,6 @@ func (UnimplementedWalletServiceServer) GetHostSalaryProgress(context.Context, *
|
||||
func (UnimplementedWalletServiceServer) GetHostRevenueStats(context.Context, *GetHostRevenueStatsRequest) (*GetHostRevenueStatsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetHostRevenueStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetAgencyPointShareStats(context.Context, *GetAgencyPointShareStatsRequest) (*GetAgencyPointShareStatsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetAgencyPointShareStats not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetAgencyHostGiftStats(context.Context, *GetAgencyHostGiftStatsRequest) (*GetAgencyHostGiftStatsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetAgencyHostGiftStats not implemented")
|
||||
}
|
||||
@ -2506,24 +2490,6 @@ func _WalletService_GetHostRevenueStats_Handler(srv interface{}, ctx context.Con
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetAgencyPointShareStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetAgencyPointShareStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetAgencyPointShareStats(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetAgencyPointShareStats_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetAgencyPointShareStats(ctx, req.(*GetAgencyPointShareStatsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetAgencyHostGiftStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetAgencyHostGiftStatsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -4723,10 +4689,6 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetHostRevenueStats",
|
||||
Handler: _WalletService_GetHostRevenueStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetAgencyPointShareStats",
|
||||
Handler: _WalletService_GetAgencyPointShareStats_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetAgencyHostGiftStats",
|
||||
Handler: _WalletService_GetAgencyHostGiftStats_Handler,
|
||||
|
||||
@ -26,18 +26,6 @@ func Normalize(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// UsesRegionalLeaderboards 集中声明需要按用户所属区域隔离活动榜的 App。
|
||||
// Lalu/Fami 同时运营中东和欧美区域,榜单 key 必须包含 region_id;其他 App 保持既有全 App 榜,
|
||||
// 避免一次区域修复暗改尚未确认产品口径的租户。
|
||||
func UsesRegionalLeaderboards(value string) bool {
|
||||
switch Normalize(value) {
|
||||
case "lalu", "fami":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext 把 app_code 写入 context,供 service/repository 在不扩散方法签名的情况下取用。
|
||||
func WithContext(ctx context.Context, value string) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, Normalize(value))
|
||||
|
||||
@ -94,7 +94,10 @@ type CustomUserMessage struct {
|
||||
FromAccount string
|
||||
// SyncOtherMachine 透传腾讯 C2C 同步策略;0 保持历史默认 2,不同步发送方其他终端。
|
||||
SyncOtherMachine int
|
||||
PayloadJSON json.RawMessage
|
||||
// DeduplicateByEventID 只用于允许 at-least-once 重投的业务事实;开启后 REST 层用 event_id
|
||||
// 稳定生成腾讯 120 秒去重键。普通聊天保持平台默认序列,避免改变同秒消息排序。
|
||||
DeduplicateByEventID bool
|
||||
PayloadJSON json.RawMessage
|
||||
}
|
||||
|
||||
// AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。
|
||||
@ -352,8 +355,18 @@ func stableMessageRandom(eventID string) uint32 {
|
||||
return binary.BigEndian.Uint32(sum[:4])
|
||||
}
|
||||
|
||||
func stableMessageSequence(eventID string) uint32 {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(eventID)))
|
||||
sequence := binary.BigEndian.Uint32(sum[4:8])
|
||||
if sequence == 0 {
|
||||
// MsgSeq=0 省略或由平台生成会破坏重试去重键;极小概率哈希为零时固定提升到 1。
|
||||
return 1
|
||||
}
|
||||
return sequence
|
||||
}
|
||||
|
||||
// PublishUserCustomMessage 向单个腾讯云 IM identifier 发送 TIMCustomElem。
|
||||
// 该方法只负责投递协议;业务幂等、隐私字段和重试策略必须由调用方在 outbox 中保证。
|
||||
// 该方法只负责投递协议和腾讯的短窗口去重;持久业务回执、隐私字段和重试策略仍由调用方保证。
|
||||
func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message CustomUserMessage) error {
|
||||
message.ToAccount = strings.TrimSpace(message.ToAccount)
|
||||
message.EventID = strings.TrimSpace(message.EventID)
|
||||
@ -374,10 +387,19 @@ func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message Custo
|
||||
return fmt.Errorf("user custom message is incomplete")
|
||||
}
|
||||
|
||||
messageSequence := uint32(0)
|
||||
messageRandom := randomUint32()
|
||||
if message.DeduplicateByEventID {
|
||||
// 腾讯云以发送人、MsgSeq、MsgRandom 和消息体 CRC 组成 120 秒去重键。
|
||||
// event_id 是项目内逻辑消息幂等键,稳定派生两项可收敛并发首发和响应丢失后的即时重试。
|
||||
messageSequence = stableMessageSequence(message.EventID)
|
||||
messageRandom = stableMessageRandom(message.EventID)
|
||||
}
|
||||
request := sendC2CMsgRequest{
|
||||
SyncOtherMachine: syncOtherMachine,
|
||||
ToAccount: message.ToAccount,
|
||||
MsgRandom: randomUint32(),
|
||||
MsgSeq: messageSequence,
|
||||
MsgRandom: messageRandom,
|
||||
CloudCustomData: string(payload),
|
||||
FromAccount: message.FromAccount,
|
||||
MsgBody: []messageElement{
|
||||
@ -673,6 +695,7 @@ type sendC2CMsgRequest struct {
|
||||
FromAccount string `json:"From_Account,omitempty"`
|
||||
ToAccount string `json:"To_Account"`
|
||||
MsgLifeTime int `json:"MsgLifeTime,omitempty"`
|
||||
MsgSeq uint32 `json:"MsgSeq,omitempty"`
|
||||
MsgRandom uint32 `json:"MsgRandom"`
|
||||
CloudCustomData string `json:"CloudCustomData,omitempty"`
|
||||
MsgBody []messageElement `json:"MsgBody"`
|
||||
|
||||
@ -200,11 +200,12 @@ func TestRESTClientPublishUserCustomMessageBuildsC2CPayload(t *testing.T) {
|
||||
})
|
||||
|
||||
err := client.PublishUserCustomMessage(context.Background(), CustomUserMessage{
|
||||
ToAccount: "10001",
|
||||
EventID: "evt-wallet-1",
|
||||
Desc: "WalletBalanceChanged",
|
||||
Ext: "wallet_notice",
|
||||
PayloadJSON: json.RawMessage(`{"event_id":"evt-wallet-1"}`),
|
||||
ToAccount: "10001",
|
||||
EventID: "evt-wallet-1",
|
||||
Desc: "WalletBalanceChanged",
|
||||
Ext: "wallet_notice",
|
||||
DeduplicateByEventID: true,
|
||||
PayloadJSON: json.RawMessage(`{"event_id":"evt-wallet-1"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PublishUserCustomMessage failed: %v", err)
|
||||
@ -218,6 +219,11 @@ func TestRESTClientPublishUserCustomMessageBuildsC2CPayload(t *testing.T) {
|
||||
if capturedBody["From_Account"] != "administrator" {
|
||||
t.Fatalf("unexpected c2c sender: %+v", capturedBody)
|
||||
}
|
||||
firstSeq, seqOK := capturedBody["MsgSeq"].(float64)
|
||||
firstRandom, randomOK := capturedBody["MsgRandom"].(float64)
|
||||
if !seqOK || !randomOK || firstSeq <= 0 {
|
||||
t.Fatalf("c2c message must carry stable Tencent dedup fields: %+v", capturedBody)
|
||||
}
|
||||
body, ok := capturedBody["MsgBody"].([]any)
|
||||
if !ok || len(body) != 1 {
|
||||
t.Fatalf("unexpected msg body: %+v", capturedBody["MsgBody"])
|
||||
@ -230,6 +236,21 @@ func TestRESTClientPublishUserCustomMessageBuildsC2CPayload(t *testing.T) {
|
||||
if content["Desc"] != "WalletBalanceChanged" || content["Ext"] != "wallet_notice" || content["Data"] != `{"event_id":"evt-wallet-1"}` {
|
||||
t.Fatalf("unexpected custom content: %+v", content)
|
||||
}
|
||||
|
||||
// 同一逻辑 event_id 的重试必须复用腾讯 120 秒去重键,不能因 REST 请求重新生成随机数而形成第二条消息。
|
||||
if err := client.PublishUserCustomMessage(context.Background(), CustomUserMessage{
|
||||
ToAccount: "10001",
|
||||
EventID: "evt-wallet-1",
|
||||
Desc: "WalletBalanceChanged",
|
||||
Ext: "wallet_notice",
|
||||
DeduplicateByEventID: true,
|
||||
PayloadJSON: json.RawMessage(`{"event_id":"evt-wallet-1"}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("PublishUserCustomMessage replay failed: %v", err)
|
||||
}
|
||||
if capturedBody["MsgSeq"] != firstSeq || capturedBody["MsgRandom"] != firstRandom {
|
||||
t.Fatalf("c2c retry dedup fields changed: first=(%v,%v) replay=(%v,%v)", firstSeq, firstRandom, capturedBody["MsgSeq"], capturedBody["MsgRandom"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTClientImportAccountBuildsTencentRequest(t *testing.T) {
|
||||
|
||||
@ -34,8 +34,6 @@ const (
|
||||
|
||||
var ErrNotConfigured = errors.New("user leaderboard redis is not configured")
|
||||
|
||||
var ErrRegionRequired = errors.New("user leaderboard region is required")
|
||||
|
||||
// Store keeps user leaderboard buckets in Redis zsets plus per-user hashes.
|
||||
// Gift facts populate sent/received, while successful game debits populate game; gateway only reads this projection.
|
||||
type Store struct {
|
||||
@ -136,19 +134,17 @@ func (s *Store) ApplyGiftEvent(ctx context.Context, event GiftEvent) (bool, erro
|
||||
}
|
||||
|
||||
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
|
||||
regional := appcode.UsesRegionalLeaderboards(event.AppCode)
|
||||
updates := make([]leaderboardUpdate, 0, 12)
|
||||
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
|
||||
// 全 App key 在兼容窗口继续写入,保证 gateway/activity 滚动发布期间旧实例仍能读到连续数据;
|
||||
// Lalu/Fami 的新查询只读取 region key,因此 legacy 全局榜不会再造成跨区混排。
|
||||
// 每个事件同时维护全 App 与已知区域投影;Gateway 只按后台 scope 选 key,写入层不识别产品名称。
|
||||
updates = append(updates,
|
||||
s.updateFor(event.AppCode, 0, BoardSent, period, event.SenderUserID, occurredAt),
|
||||
s.updateFor(event.AppCode, 0, BoardReceived, period, event.TargetUserID, occurredAt),
|
||||
)
|
||||
if regional && event.SenderRegionID > 0 {
|
||||
if event.SenderRegionID > 0 {
|
||||
updates = append(updates, s.updateFor(event.AppCode, event.SenderRegionID, BoardSent, period, event.SenderUserID, occurredAt))
|
||||
}
|
||||
if regional && event.TargetRegionID > 0 {
|
||||
if event.TargetRegionID > 0 {
|
||||
updates = append(updates, s.updateFor(event.AppCode, event.TargetRegionID, BoardReceived, period, event.TargetUserID, occurredAt))
|
||||
}
|
||||
}
|
||||
@ -168,11 +164,10 @@ func (s *Store) ApplyGameEvent(ctx context.Context, event GameEvent) (bool, erro
|
||||
}
|
||||
|
||||
occurredAt := time.UnixMilli(event.OccurredAtMS).UTC()
|
||||
regional := appcode.UsesRegionalLeaderboards(event.AppCode)
|
||||
updates := make([]leaderboardUpdate, 0, 6)
|
||||
for _, period := range []string{PeriodToday, PeriodWeek, PeriodMonth} {
|
||||
updates = append(updates, s.updateFor(event.AppCode, 0, BoardGame, period, event.UserID, occurredAt))
|
||||
if regional && event.RegionID > 0 {
|
||||
if event.RegionID > 0 {
|
||||
updates = append(updates, s.updateFor(event.AppCode, event.RegionID, BoardGame, period, event.UserID, occurredAt))
|
||||
}
|
||||
}
|
||||
@ -185,9 +180,6 @@ func (s *Store) List(ctx context.Context, query Query) (Page, error) {
|
||||
return Page{}, ErrNotConfigured
|
||||
}
|
||||
query = normalizeQuery(query)
|
||||
if appcode.UsesRegionalLeaderboards(query.AppCode) && query.RegionID <= 0 {
|
||||
return Page{}, ErrRegionRequired
|
||||
}
|
||||
start, end := QueryWindow(query.Period, query.Now)
|
||||
key := s.scopedScoreKey(query.AppCode, query.RegionID, query.BoardType, query.Period, start)
|
||||
offset := int64((query.Page - 1) * query.PageSize)
|
||||
@ -235,9 +227,6 @@ func (s *Store) RankForUser(ctx context.Context, query Query, userID int64) (Ent
|
||||
return Entry{}, false, nil
|
||||
}
|
||||
query = normalizeQuery(query)
|
||||
if appcode.UsesRegionalLeaderboards(query.AppCode) && query.RegionID <= 0 {
|
||||
return Entry{}, false, ErrRegionRequired
|
||||
}
|
||||
start, _ := QueryWindow(query.Period, query.Now)
|
||||
key := s.scopedScoreKey(query.AppCode, query.RegionID, query.BoardType, query.Period, start)
|
||||
member := strconv.FormatInt(userID, 10)
|
||||
@ -336,14 +325,14 @@ func (s *Store) itemKey(app string, board string, period string, start time.Time
|
||||
}
|
||||
|
||||
func (s *Store) scopedScoreKey(app string, regionID int64, board string, period string, start time.Time) string {
|
||||
if !appcode.UsesRegionalLeaderboards(app) || regionID <= 0 {
|
||||
if regionID <= 0 {
|
||||
return s.scoreKey(app, board, period, start)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s:region:%d:%s:%s:%s:scores", s.keyPrefix, appcode.Normalize(app), regionID, board, period, bucketID(period, start))
|
||||
}
|
||||
|
||||
func (s *Store) scopedItemKey(app string, regionID int64, board string, period string, start time.Time, userID string) string {
|
||||
if !appcode.UsesRegionalLeaderboards(app) || regionID <= 0 {
|
||||
if regionID <= 0 {
|
||||
return s.itemKey(app, board, period, start, userID)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s:region:%d:%s:%s:%s:users:%s", s.keyPrefix, appcode.Normalize(app), regionID, board, period, bucketID(period, start), userID)
|
||||
|
||||
@ -84,6 +84,7 @@ func TestApplyGameEventUsesDedicatedRedisBucket(t *testing.T) {
|
||||
AppCode: "huwaa",
|
||||
EventID: "game_level:test_order_1",
|
||||
UserID: 20002,
|
||||
RegionID: 9001,
|
||||
CoinSpent: 120,
|
||||
OccurredAtMS: occurredAt.UnixMilli(),
|
||||
}
|
||||
@ -110,8 +111,25 @@ func TestApplyGameEventUsesDedicatedRedisBucket(t *testing.T) {
|
||||
if page.BoardType != BoardGame || page.Total != 1 || len(page.Items) != 1 || page.Items[0].UserID != "20002" || page.Items[0].GiftValue != 120 || page.Items[0].GiftCount != 0 || page.Items[0].TransactionCount != 1 || page.Items[0].LastGiftAtMS != occurredAt.UnixMilli() {
|
||||
t.Fatalf("game leaderboard item mismatch: %+v", page)
|
||||
}
|
||||
regionalPage, err := store.List(ctx, Query{AppCode: "huwaa", RegionID: 9001, BoardType: BoardGame, Period: PeriodToday, Page: 1, PageSize: 20, Now: occurredAt.Add(time.Minute)})
|
||||
if err != nil || regionalPage.Total != 1 || len(regionalPage.Items) != 1 || regionalPage.Items[0].GiftValue != 120 {
|
||||
t.Fatalf("game event must maintain the regional projection beside the global projection: page=%+v err=%v", regionalPage, err)
|
||||
}
|
||||
sent, err := store.List(ctx, Query{AppCode: "huwaa", BoardType: BoardSent, Period: PeriodToday, Page: 1, PageSize: 20, Now: occurredAt.Add(time.Minute)})
|
||||
if err != nil || sent.Total != 0 {
|
||||
t.Fatalf("game event must not leak into sent board: page=%+v err=%v", sent, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedKeysDependOnlyOnRequestedRegion(t *testing.T) {
|
||||
store := NewStore(nil, "activity:user_leaderboard")
|
||||
start := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC)
|
||||
global := store.scopedScoreKey("future-app", 0, BoardSent, PeriodToday, start)
|
||||
regional := store.scopedScoreKey("future-app", 7001, BoardSent, PeriodToday, start)
|
||||
if global != "activity:user_leaderboard:future-app:sent:today:20260721:scores" {
|
||||
t.Fatalf("global key mismatch: %s", global)
|
||||
}
|
||||
if regional != "activity:user_leaderboard:future-app:region:7001:sent:today:20260721:scores" {
|
||||
t.Fatalf("regional key must not depend on a product allowlist: %s", regional)
|
||||
}
|
||||
}
|
||||
|
||||
@ -375,7 +375,8 @@ func main() {
|
||||
BindLandingPageURLBuilder(activityLandingBuilder).
|
||||
BindBannerTargetDisabler(store)
|
||||
appConfigHandler := appconfigmodule.New(store, auditHandler).
|
||||
BindActivityTemplateTarget(activityAdminClient, activityLandingBuilder)
|
||||
BindActivityTemplateTarget(activityAdminClient, activityLandingBuilder).
|
||||
BindRoleScopePolicyClient(userclient.NewGRPC(userConn))
|
||||
handlers := router.Handlers{
|
||||
Audit: auditHandler,
|
||||
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
||||
@ -441,7 +442,7 @@ func main() {
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
OpsCenter: opscentermodule.New(appRegistryService, luckyGiftHandler),
|
||||
Payment: paymentHandler,
|
||||
PolicyConfig: policyconfigmodule.New(sqlDB, walletDB, userDB, activityclient.NewGRPC(activityConn), auditHandler),
|
||||
PolicyConfig: policyconfigmodule.New(sqlDB, walletDB, userDB, auditHandler),
|
||||
PointWithdrawalConfig: pointwithdrawalconfigmodule.New(walletDB, userDB, auditHandler),
|
||||
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
|
||||
@ -13,7 +13,6 @@ type Client interface {
|
||||
ListTaskDefinitions(ctx context.Context, req *activityv1.ListTaskDefinitionsRequest) (*activityv1.ListTaskDefinitionsResponse, error)
|
||||
UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error)
|
||||
SetTaskDefinitionStatus(ctx context.Context, req *activityv1.SetTaskDefinitionStatusRequest) (*activityv1.SetTaskDefinitionStatusResponse, error)
|
||||
PublishTaskRewardPolicy(ctx context.Context, req *activityv1.PublishTaskRewardPolicyRequest) (*activityv1.PublishTaskRewardPolicyResponse, error)
|
||||
ListAchievementDefinitions(ctx context.Context, req *activityv1.ListAchievementsRequest) (*activityv1.ListAchievementsResponse, error)
|
||||
UpsertAchievementDefinition(ctx context.Context, req *activityv1.UpsertAchievementDefinitionRequest) (*activityv1.UpsertAchievementDefinitionResponse, error)
|
||||
DeleteAchievementDefinition(ctx context.Context, req *activityv1.DeleteAchievementDefinitionRequest) (*activityv1.DeleteAchievementDefinitionResponse, error)
|
||||
@ -141,10 +140,6 @@ func (c *GRPCClient) SetTaskDefinitionStatus(ctx context.Context, req *activityv
|
||||
return c.taskClient.SetTaskDefinitionStatus(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) PublishTaskRewardPolicy(ctx context.Context, req *activityv1.PublishTaskRewardPolicyRequest) (*activityv1.PublishTaskRewardPolicyResponse, error) {
|
||||
return c.taskClient.PublishTaskRewardPolicy(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListAchievementDefinitions(ctx context.Context, req *activityv1.ListAchievementsRequest) (*activityv1.ListAchievementsResponse, error) {
|
||||
return c.achievementClient.ListAchievementDefinitions(ctx, req)
|
||||
}
|
||||
|
||||
@ -43,6 +43,7 @@ type Client interface {
|
||||
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
|
||||
GetUserRoleSummary(ctx context.Context, req GetUserRoleSummaryRequest) (*UserRoleSummary, error)
|
||||
GetRoleScopePolicy(ctx context.Context, req GetRoleScopePolicyRequest) (*RoleScopePolicy, error)
|
||||
UpdateRoleScopePolicies(ctx context.Context, req UpdateRoleScopePoliciesRequest) ([]RoleScopePolicy, error)
|
||||
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
|
||||
AdminAddAgencyHost(ctx context.Context, req AdminAddAgencyHostRequest) (*CreateAgencyResult, error)
|
||||
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
|
||||
@ -124,6 +125,13 @@ type RoleScopePolicy struct {
|
||||
BaseScope string `json:"baseScope"`
|
||||
ExpandedScope string `json:"expandedScope"`
|
||||
RegionExpansionConfigurable bool `json:"regionExpansionConfigurable"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type UpdateRoleScopePoliciesRequest struct {
|
||||
RequestID string
|
||||
Caller string
|
||||
Policies []RoleScopePolicy
|
||||
}
|
||||
|
||||
type SetUserStatusResult struct {
|
||||
@ -875,9 +883,43 @@ func (c *GRPCClient) GetRoleScopePolicy(ctx context.Context, req GetRoleScopePol
|
||||
BaseScope: policy.GetBaseScope(),
|
||||
ExpandedScope: policy.GetExpandedScope(),
|
||||
RegionExpansionConfigurable: policy.GetRegionExpansionConfigurable(),
|
||||
UpdatedAtMS: policy.GetUpdatedAtMs(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateRoleScopePolicies(ctx context.Context, req UpdateRoleScopePoliciesRequest) ([]RoleScopePolicy, error) {
|
||||
policies := make([]*userv1.RoleScopePolicy, 0, len(req.Policies))
|
||||
for _, policy := range req.Policies {
|
||||
policies = append(policies, &userv1.RoleScopePolicy{
|
||||
Scene: policy.Scene,
|
||||
BaseScope: policy.BaseScope,
|
||||
ExpandedScope: policy.ExpandedScope,
|
||||
RegionExpansionConfigurable: policy.RegionExpansionConfigurable,
|
||||
})
|
||||
}
|
||||
resp, err := c.hostAdminClient.UpdateRoleScopePolicies(ctx, &userv1.UpdateRoleScopePoliciesRequest{
|
||||
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||
Policies: policies,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := make([]RoleScopePolicy, 0, len(resp.GetPolicies()))
|
||||
for _, policy := range resp.GetPolicies() {
|
||||
if policy == nil {
|
||||
continue
|
||||
}
|
||||
stored = append(stored, RoleScopePolicy{
|
||||
Scene: policy.GetScene(),
|
||||
BaseScope: policy.GetBaseScope(),
|
||||
ExpandedScope: policy.GetExpandedScope(),
|
||||
RegionExpansionConfigurable: policy.GetRegionExpansionConfigurable(),
|
||||
UpdatedAtMS: policy.GetUpdatedAtMs(),
|
||||
})
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) CreateBD(ctx context.Context, req CreateBDRequest) (*BDProfile, error) {
|
||||
resp, err := c.hostAdminClient.CreateBD(ctx, &userv1.CreateBDRequest{
|
||||
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||
|
||||
@ -430,6 +430,8 @@ type HostAgencySalaryPolicy struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AppCode string `gorm:"size:32;uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null;default:lalu" json:"appCode"`
|
||||
Name string `gorm:"size:120;uniqueIndex:uk_admin_host_agency_salary_policy_name;not null" json:"name"`
|
||||
// PolicyType 决定送礼收益进入周期工资投影还是永久钻石积分钱包;业务代码只按该快照分流,不按 App 名称判断。
|
||||
PolicyType string `gorm:"column:policy_type;size:32;index:idx_admin_host_agency_salary_policy_cycle,not null;default:SALARY_DIAMOND" json:"policyType"`
|
||||
// RegionID 只保存 RegionIDs 中最小的兼容值;真实适用边界由关联表按用户区域匹配。
|
||||
RegionID int64 `gorm:"uniqueIndex:uk_admin_host_agency_salary_policy_name;uniqueIndex:uk_admin_host_agency_salary_policy_version;index:idx_admin_host_agency_salary_policy_region;index:idx_admin_host_agency_salary_policy_cycle,not null" json:"regionId"`
|
||||
// RegionIDs 是这份政策实际覆盖的区域集合;RegionID 只保留首个值用于旧客户端和旧索引兼容。
|
||||
@ -444,6 +446,11 @@ type HostAgencySalaryPolicy struct {
|
||||
SettlementTriggerMode string `gorm:"column:settlement_trigger_mode;size:24;not null;default:automatic" json:"settlementTriggerMode"`
|
||||
// 比例字段使用 decimal 字符串承载,避免金币、钻石、美元换算出现 float 精度误差。
|
||||
GiftCoinToDiamondRatio string `gorm:"column:gift_coin_to_diamond_ratio;type:decimal(18,6);not null;default:1.000000" json:"giftCoinToDiamondRatio"`
|
||||
PointDiamondsPerUSD int64 `gorm:"column:point_diamonds_per_usd;not null;default:0" json:"pointDiamondsPerUsd"`
|
||||
CoinsPerUSD int64 `gorm:"column:coins_per_usd;not null;default:0" json:"coinsPerUsd"`
|
||||
MinimumWithdrawUSDMinor int64 `gorm:"column:minimum_withdraw_usd_minor;not null;default:0" json:"minimumWithdrawUsdMinor"`
|
||||
WithdrawFeeBPS int32 `gorm:"column:withdraw_fee_bps;not null;default:0" json:"withdrawFeeBps"`
|
||||
AgencyPointShareBPS int32 `gorm:"column:agency_point_share_bps;not null;default:0" json:"agencyPointShareBps"`
|
||||
ResidualDiamondToUSDRate string `gorm:"column:residual_diamond_to_usd_rate;type:decimal(24,12);not null;default:0.000000000000" json:"residualDiamondToUsdRate"`
|
||||
// 提现次数限制随月度政策形成不可变快照;count=0 表示不限,period 只允许 day/week/month。
|
||||
CoinSellerWithdrawalLimitPeriod string `gorm:"column:coin_seller_withdrawal_limit_period;size:16;not null;default:month" json:"coinSellerWithdrawalLimitPeriod"`
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"hyapp-admin-server/internal/response"
|
||||
@ -30,6 +31,13 @@ func (h *Handler) BindActivityTemplateTarget(activity activityclient.Client, lan
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) BindRoleScopePolicyClient(client userclient.Client) *Handler {
|
||||
if h != nil {
|
||||
h.service.BindRoleScopePolicyClient(client)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) ListH5Links(c *gin.Context) {
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
items, err := h.service.ListH5Links(appCode)
|
||||
|
||||
@ -4,6 +4,22 @@ type updateH5LinksRequest struct {
|
||||
Items []h5LinkPayload `json:"items" binding:"required"`
|
||||
}
|
||||
|
||||
type roomDiscoveryConfigRequest struct {
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
}
|
||||
|
||||
type runtimeCapabilitiesConfigRequest struct {
|
||||
ActivityLeaderboardScope string `json:"activityLeaderboardScope" binding:"required"`
|
||||
ResourceEquipCommandIDPolicy string `json:"resourceEquipCommandIdPolicy" binding:"required"`
|
||||
}
|
||||
|
||||
type roleScopePoliciesConfigRequest struct {
|
||||
OrganizationScope string `json:"organizationScope" binding:"required"`
|
||||
ManagerBaseScope string `json:"managerBaseScope" binding:"required"`
|
||||
ManagerExpandedScope string `json:"managerExpandedScope" binding:"required"`
|
||||
ManagerRegionExpansionConfigurable bool `json:"managerRegionExpansionConfigurable"`
|
||||
}
|
||||
|
||||
type h5LinkPayload struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Label string `json:"label" binding:"required"`
|
||||
|
||||
166
server/admin/internal/modules/appconfig/role_scope_policies.go
Normal file
166
server/admin/internal/modules/appconfig/role_scope_policies.go
Normal file
@ -0,0 +1,166 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
)
|
||||
|
||||
const (
|
||||
organizationExpansionScene = "organization_role_expansion"
|
||||
managerOperationsScene = "manager_operations"
|
||||
|
||||
roleScopeCountry = "country"
|
||||
roleScopeRegion = "region"
|
||||
roleScopeGlobal = "global"
|
||||
)
|
||||
|
||||
// RoleScopePolicyClient 是 Admin 到 user-service owner 的最小边界;后台不能直接写 user DB 配置表。
|
||||
type RoleScopePolicyClient interface {
|
||||
GetRoleScopePolicy(ctx context.Context, req userclient.GetRoleScopePolicyRequest) (*userclient.RoleScopePolicy, error)
|
||||
UpdateRoleScopePolicies(ctx context.Context, req userclient.UpdateRoleScopePoliciesRequest) ([]userclient.RoleScopePolicy, error)
|
||||
}
|
||||
|
||||
type RoleScopePoliciesConfig struct {
|
||||
AppCode string `json:"appCode"`
|
||||
OrganizationScope string `json:"organizationScope"`
|
||||
ManagerBaseScope string `json:"managerBaseScope"`
|
||||
ManagerExpandedScope string `json:"managerExpandedScope"`
|
||||
ManagerRegionExpansionConfigurable bool `json:"managerRegionExpansionConfigurable"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func (s *AppConfigService) GetRoleScopePoliciesConfig(ctx context.Context, appCode string, requestID string) (RoleScopePoliciesConfig, error) {
|
||||
if s == nil || s.roleScopePolicies == nil {
|
||||
return RoleScopePoliciesConfig{}, fmt.Errorf("role scope policy client is not configured")
|
||||
}
|
||||
organization, err := s.roleScopePolicies.GetRoleScopePolicy(ctx, userclient.GetRoleScopePolicyRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
Scene: organizationExpansionScene,
|
||||
})
|
||||
if err != nil {
|
||||
return RoleScopePoliciesConfig{}, err
|
||||
}
|
||||
manager, err := s.roleScopePolicies.GetRoleScopePolicy(ctx, userclient.GetRoleScopePolicyRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
Scene: managerOperationsScene,
|
||||
})
|
||||
if err != nil {
|
||||
return RoleScopePoliciesConfig{}, err
|
||||
}
|
||||
return roleScopePoliciesConfigFromPolicies(appCode, []userclient.RoleScopePolicy{*organization, *manager})
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateRoleScopePoliciesConfig(ctx context.Context, appCode string, requestID string, request roleScopePoliciesConfigRequest) (RoleScopePoliciesConfig, error) {
|
||||
if s == nil || s.roleScopePolicies == nil {
|
||||
return RoleScopePoliciesConfig{}, fmt.Errorf("role scope policy client is not configured")
|
||||
}
|
||||
policies, err := roleScopePoliciesFromRequest(request)
|
||||
if err != nil {
|
||||
return RoleScopePoliciesConfig{}, err
|
||||
}
|
||||
stored, err := s.roleScopePolicies.UpdateRoleScopePolicies(ctx, userclient.UpdateRoleScopePoliciesRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
Policies: policies,
|
||||
})
|
||||
if err != nil {
|
||||
return RoleScopePoliciesConfig{}, err
|
||||
}
|
||||
return roleScopePoliciesConfigFromPolicies(appCode, stored)
|
||||
}
|
||||
|
||||
func roleScopePoliciesFromRequest(request roleScopePoliciesConfigRequest) ([]userclient.RoleScopePolicy, error) {
|
||||
organizationScope := normalizeRoleScope(request.OrganizationScope)
|
||||
if organizationScope != roleScopeRegion && organizationScope != roleScopeGlobal {
|
||||
return nil, fmt.Errorf("组织角色范围不正确")
|
||||
}
|
||||
managerBase := normalizeRoleScope(request.ManagerBaseScope)
|
||||
managerExpanded := normalizeRoleScope(request.ManagerExpandedScope)
|
||||
if !validManagerRoleScope(managerBase) || !validManagerRoleScope(managerExpanded) {
|
||||
return nil, fmt.Errorf("经理操作范围不正确")
|
||||
}
|
||||
if !request.ManagerRegionExpansionConfigurable && managerExpanded != managerBase {
|
||||
return nil, fmt.Errorf("关闭经理扩区开关时两个范围必须一致")
|
||||
}
|
||||
if request.ManagerRegionExpansionConfigurable && roleScopeRank(managerExpanded) < roleScopeRank(managerBase) {
|
||||
return nil, fmt.Errorf("经理扩展范围不能小于基础范围")
|
||||
}
|
||||
return []userclient.RoleScopePolicy{
|
||||
{
|
||||
Scene: organizationExpansionScene,
|
||||
BaseScope: organizationScope,
|
||||
ExpandedScope: organizationScope,
|
||||
},
|
||||
{
|
||||
Scene: managerOperationsScene,
|
||||
BaseScope: managerBase,
|
||||
ExpandedScope: managerExpanded,
|
||||
RegionExpansionConfigurable: request.ManagerRegionExpansionConfigurable,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func roleScopePoliciesConfigFromPolicies(appCode string, policies []userclient.RoleScopePolicy) (RoleScopePoliciesConfig, error) {
|
||||
config := RoleScopePoliciesConfig{AppCode: appctx.Normalize(appCode)}
|
||||
seen := make(map[string]struct{}, len(policies))
|
||||
for _, policy := range policies {
|
||||
scene := strings.ToLower(strings.TrimSpace(policy.Scene))
|
||||
switch scene {
|
||||
case organizationExpansionScene:
|
||||
config.OrganizationScope = normalizeRoleScope(policy.BaseScope)
|
||||
if (config.OrganizationScope != roleScopeRegion && config.OrganizationScope != roleScopeGlobal) || normalizeRoleScope(policy.ExpandedScope) != config.OrganizationScope || policy.RegionExpansionConfigurable {
|
||||
return RoleScopePoliciesConfig{}, fmt.Errorf("组织角色范围配置无效")
|
||||
}
|
||||
case managerOperationsScene:
|
||||
config.ManagerBaseScope = normalizeRoleScope(policy.BaseScope)
|
||||
config.ManagerExpandedScope = normalizeRoleScope(policy.ExpandedScope)
|
||||
config.ManagerRegionExpansionConfigurable = policy.RegionExpansionConfigurable
|
||||
if !validManagerRoleScope(config.ManagerBaseScope) || !validManagerRoleScope(config.ManagerExpandedScope) {
|
||||
return RoleScopePoliciesConfig{}, fmt.Errorf("经理操作范围配置无效")
|
||||
}
|
||||
if (!config.ManagerRegionExpansionConfigurable && config.ManagerExpandedScope != config.ManagerBaseScope) || (config.ManagerRegionExpansionConfigurable && roleScopeRank(config.ManagerExpandedScope) < roleScopeRank(config.ManagerBaseScope)) {
|
||||
return RoleScopePoliciesConfig{}, fmt.Errorf("经理扩展范围配置无效")
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
seen[scene] = struct{}{}
|
||||
if policy.UpdatedAtMS > config.UpdatedAtMS {
|
||||
config.UpdatedAtMS = policy.UpdatedAtMS
|
||||
}
|
||||
}
|
||||
if _, ok := seen[organizationExpansionScene]; !ok {
|
||||
return RoleScopePoliciesConfig{}, fmt.Errorf("组织角色范围配置缺失")
|
||||
}
|
||||
if _, ok := seen[managerOperationsScene]; !ok {
|
||||
return RoleScopePoliciesConfig{}, fmt.Errorf("经理操作范围配置缺失")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func normalizeRoleScope(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func validManagerRoleScope(value string) bool {
|
||||
return value == roleScopeCountry || value == roleScopeRegion || value == roleScopeGlobal
|
||||
}
|
||||
|
||||
func roleScopeRank(value string) int {
|
||||
switch value {
|
||||
case roleScopeCountry:
|
||||
return 1
|
||||
case roleScopeRegion:
|
||||
return 2
|
||||
case roleScopeGlobal:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handler) GetRoleScopePoliciesConfig(c *gin.Context) {
|
||||
item, err := h.service.GetRoleScopePoliciesConfig(c.Request.Context(), appctx.FromContext(c.Request.Context()), middleware.CurrentRequestID(c))
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取角色范围配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRoleScopePoliciesConfig(c *gin.Context) {
|
||||
var request roleScopePoliciesConfigRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.BadRequest(c, "参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateRoleScopePoliciesConfig(c.Request.Context(), appctx.FromContext(c.Request.Context()), middleware.CurrentRequestID(c), request)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
// 审计只记录规范化范围枚举;真正配置由 user-service 写 user DB,后台不保存第二份可漂移副本。
|
||||
shared.OperationLog(c, h.audit, "update-role-scope-policies", "role_scope_policies", "success", fmt.Sprintf(
|
||||
"app_code=%s organization_scope=%s manager_base_scope=%s manager_expanded_scope=%s manager_region_expansion_configurable=%t",
|
||||
item.AppCode, item.OrganizationScope, item.ManagerBaseScope, item.ManagerExpandedScope, item.ManagerRegionExpansionConfigurable,
|
||||
))
|
||||
response.OK(c, item)
|
||||
}
|
||||
81
server/admin/internal/modules/appconfig/room_discovery.go
Normal file
81
server/admin/internal/modules/appconfig/room_discovery.go
Normal file
@ -0,0 +1,81 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
roomDiscoveryGroup = "room-discovery"
|
||||
roomDiscoveryScopeKey = "scope"
|
||||
roomDiscoveryScopeViewerRegion = "viewer_region"
|
||||
roomDiscoveryScopeAllActiveRegions = "all_active_regions"
|
||||
roomDiscoveryDescription = "房间发现范围:viewer_region 或 all_active_regions"
|
||||
)
|
||||
|
||||
type RoomDiscoveryConfig struct {
|
||||
AppCode string `json:"appCode"`
|
||||
Scope string `json:"scope"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func (s *AppConfigService) GetRoomDiscoveryConfig(appCode string) (RoomDiscoveryConfig, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
item, err := s.store.GetOwnedAppConfig(appCode, roomDiscoveryGroup, roomDiscoveryScopeKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 未配置时只允许查看者所属区域;这是跨区域数据暴露的 fail-closed 边界,后台保存后才扩大范围。
|
||||
return RoomDiscoveryConfig{AppCode: appCode, Scope: roomDiscoveryScopeViewerRegion}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RoomDiscoveryConfig{}, err
|
||||
}
|
||||
return roomDiscoveryConfigFromModel(item), nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateRoomDiscoveryConfig(appCode string, request roomDiscoveryConfigRequest) (RoomDiscoveryConfig, error) {
|
||||
item, err := roomDiscoveryConfigModel(appCode, request)
|
||||
if err != nil {
|
||||
return RoomDiscoveryConfig{}, err
|
||||
}
|
||||
// App 作用域只来自鉴权上下文;请求体不能指定 app_code,也不能覆盖其他租户的同名配置。
|
||||
if err := s.store.UpsertScopedAppConfigs(item.AppCode, []model.AppConfig{item}); err != nil {
|
||||
return RoomDiscoveryConfig{}, err
|
||||
}
|
||||
stored, err := s.store.GetOwnedAppConfig(item.AppCode, roomDiscoveryGroup, roomDiscoveryScopeKey)
|
||||
if err != nil {
|
||||
return RoomDiscoveryConfig{}, err
|
||||
}
|
||||
return roomDiscoveryConfigFromModel(stored), nil
|
||||
}
|
||||
|
||||
func roomDiscoveryConfigModel(appCode string, request roomDiscoveryConfigRequest) (model.AppConfig, error) {
|
||||
scope := strings.ToLower(strings.TrimSpace(request.Scope))
|
||||
if scope != roomDiscoveryScopeViewerRegion && scope != roomDiscoveryScopeAllActiveRegions {
|
||||
return model.AppConfig{}, errors.New("房间发现范围不正确")
|
||||
}
|
||||
return model.AppConfig{
|
||||
AppCode: appctx.Normalize(appCode),
|
||||
Group: roomDiscoveryGroup,
|
||||
Key: roomDiscoveryScopeKey,
|
||||
Value: scope,
|
||||
Description: roomDiscoveryDescription,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func roomDiscoveryConfigFromModel(item model.AppConfig) RoomDiscoveryConfig {
|
||||
scope := strings.ToLower(strings.TrimSpace(item.Value))
|
||||
if scope != roomDiscoveryScopeViewerRegion && scope != roomDiscoveryScopeAllActiveRegions {
|
||||
// 旧 SQL 或人工写入的非法值不能突破 API 枚举契约;与 gateway 保持一致,统一降级到查看者区域。
|
||||
scope = roomDiscoveryScopeViewerRegion
|
||||
}
|
||||
return RoomDiscoveryConfig{
|
||||
AppCode: appctx.Normalize(item.AppCode),
|
||||
Scope: scope,
|
||||
UpdatedAtMS: item.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handler) GetRoomDiscoveryConfig(c *gin.Context) {
|
||||
item, err := h.service.GetRoomDiscoveryConfig(appctx.FromContext(c.Request.Context()))
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取房间发现范围失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRoomDiscoveryConfig(c *gin.Context) {
|
||||
var request roomDiscoveryConfigRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.BadRequest(c, "参数不正确")
|
||||
return
|
||||
}
|
||||
appCode := appctx.FromContext(c.Request.Context())
|
||||
item, err := h.service.UpdateRoomDiscoveryConfig(appCode, request)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
// 运行时 gateway 最多在 room_discovery_cache_ttl 后读取新值;审计保留 App 和最终规范化范围,便于定位缓存窗口内差异。
|
||||
shared.OperationLog(c, h.audit, "update-room-discovery-scope", "admin_app_configs", "success", fmt.Sprintf("app_code=%s scope=%s", item.AppCode, item.Scope))
|
||||
response.OK(c, item)
|
||||
}
|
||||
@ -20,6 +20,12 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.PUT("/admin/app-config/gift-combo", middleware.RequirePermission("app-config:update"), h.UpdateGiftComboConfig)
|
||||
protected.GET("/admin/app-config/floating-screen", middleware.RequirePermission("app-config:view"), h.GetFloatingScreenConfig)
|
||||
protected.PUT("/admin/app-config/floating-screen", middleware.RequirePermission("app-config:update"), h.UpdateFloatingScreenConfig)
|
||||
protected.GET("/admin/app-config/room-discovery", middleware.RequirePermission("app-config:view"), h.GetRoomDiscoveryConfig)
|
||||
protected.PUT("/admin/app-config/room-discovery", middleware.RequirePermission("app-config:update"), h.UpdateRoomDiscoveryConfig)
|
||||
protected.GET("/admin/app-config/runtime-capabilities", middleware.RequirePermission("app-config:view"), h.GetRuntimeCapabilitiesConfig)
|
||||
protected.PUT("/admin/app-config/runtime-capabilities", middleware.RequirePermission("app-config:update"), h.UpdateRuntimeCapabilitiesConfig)
|
||||
protected.GET("/admin/app-config/role-scope-policies", middleware.RequirePermission("app-config:view"), h.GetRoleScopePoliciesConfig)
|
||||
protected.PUT("/admin/app-config/role-scope-policies", middleware.RequirePermission("app-config:update"), h.UpdateRoleScopePoliciesConfig)
|
||||
|
||||
protected.GET("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:view"), h.ListExploreTabs)
|
||||
protected.POST("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:update"), h.CreateExploreTab)
|
||||
|
||||
111
server/admin/internal/modules/appconfig/runtime_capabilities.go
Normal file
111
server/admin/internal/modules/appconfig/runtime_capabilities.go
Normal file
@ -0,0 +1,111 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeCapabilitiesGroup = "runtime-capabilities"
|
||||
|
||||
activityLeaderboardScopeKey = "activity_leaderboard_scope"
|
||||
activityLeaderboardScopeAppGlobal = "app_global"
|
||||
activityLeaderboardScopeViewerRegion = "viewer_region"
|
||||
|
||||
resourceEquipCommandIDPolicyKey = "resource_equip_command_id_policy"
|
||||
resourceEquipCommandIDPolicyLegacyOptional = "legacy_optional"
|
||||
resourceEquipCommandIDPolicyRequired = "required"
|
||||
)
|
||||
|
||||
type RuntimeCapabilitiesConfig struct {
|
||||
AppCode string `json:"appCode"`
|
||||
ActivityLeaderboardScope string `json:"activityLeaderboardScope"`
|
||||
ResourceEquipCommandIDPolicy string `json:"resourceEquipCommandIdPolicy"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
func defaultRuntimeCapabilitiesConfig(appCode string) RuntimeCapabilitiesConfig {
|
||||
return RuntimeCapabilitiesConfig{
|
||||
AppCode: appctx.Normalize(appCode),
|
||||
ActivityLeaderboardScope: activityLeaderboardScopeAppGlobal,
|
||||
ResourceEquipCommandIDPolicy: resourceEquipCommandIDPolicyLegacyOptional,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AppConfigService) GetRuntimeCapabilitiesConfig(appCode string) (RuntimeCapabilitiesConfig, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
items, err := s.store.ListOwnedAppConfigs(appCode, runtimeCapabilitiesGroup)
|
||||
if err != nil {
|
||||
return RuntimeCapabilitiesConfig{}, err
|
||||
}
|
||||
return runtimeCapabilitiesConfigFromModels(appCode, items)
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateRuntimeCapabilitiesConfig(appCode string, request runtimeCapabilitiesConfigRequest) (RuntimeCapabilitiesConfig, error) {
|
||||
items, err := runtimeCapabilitiesConfigModels(appCode, request)
|
||||
if err != nil {
|
||||
return RuntimeCapabilitiesConfig{}, err
|
||||
}
|
||||
// 两项能力必须在同一批 upsert 中写入当前鉴权 App,避免页面保存后 Gateway 看到半套策略。
|
||||
if err := s.store.UpsertScopedAppConfigs(appctx.Normalize(appCode), items); err != nil {
|
||||
return RuntimeCapabilitiesConfig{}, err
|
||||
}
|
||||
return s.GetRuntimeCapabilitiesConfig(appCode)
|
||||
}
|
||||
|
||||
func runtimeCapabilitiesConfigModels(appCode string, request runtimeCapabilitiesConfigRequest) ([]model.AppConfig, error) {
|
||||
appCode = appctx.Normalize(appCode)
|
||||
leaderboardScope := strings.ToLower(strings.TrimSpace(request.ActivityLeaderboardScope))
|
||||
if leaderboardScope != activityLeaderboardScopeAppGlobal && leaderboardScope != activityLeaderboardScopeViewerRegion {
|
||||
return nil, errors.New("活动榜单范围不正确")
|
||||
}
|
||||
equipPolicy := strings.ToLower(strings.TrimSpace(request.ResourceEquipCommandIDPolicy))
|
||||
if equipPolicy != resourceEquipCommandIDPolicyLegacyOptional && equipPolicy != resourceEquipCommandIDPolicyRequired {
|
||||
return nil, errors.New("资源佩戴 command_id 策略不正确")
|
||||
}
|
||||
return []model.AppConfig{
|
||||
{
|
||||
AppCode: appCode,
|
||||
Group: runtimeCapabilitiesGroup,
|
||||
Key: activityLeaderboardScopeKey,
|
||||
Value: leaderboardScope,
|
||||
Description: "活动榜单范围:app_global 或 viewer_region",
|
||||
},
|
||||
{
|
||||
AppCode: appCode,
|
||||
Group: runtimeCapabilitiesGroup,
|
||||
Key: resourceEquipCommandIDPolicyKey,
|
||||
Value: equipPolicy,
|
||||
Description: "资源佩戴幂等键策略:legacy_optional 或 required",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runtimeCapabilitiesConfigFromModels(appCode string, items []model.AppConfig) (RuntimeCapabilitiesConfig, error) {
|
||||
config := defaultRuntimeCapabilitiesConfig(appCode)
|
||||
for _, item := range items {
|
||||
value := strings.ToLower(strings.TrimSpace(item.Value))
|
||||
switch strings.TrimSpace(item.Key) {
|
||||
case activityLeaderboardScopeKey:
|
||||
if value != activityLeaderboardScopeAppGlobal && value != activityLeaderboardScopeViewerRegion {
|
||||
return RuntimeCapabilitiesConfig{}, fmt.Errorf("活动榜单范围配置无效: %s", value)
|
||||
}
|
||||
config.ActivityLeaderboardScope = value
|
||||
case resourceEquipCommandIDPolicyKey:
|
||||
if value != resourceEquipCommandIDPolicyLegacyOptional && value != resourceEquipCommandIDPolicyRequired {
|
||||
return RuntimeCapabilitiesConfig{}, fmt.Errorf("资源佩戴 command_id 策略配置无效: %s", value)
|
||||
}
|
||||
config.ResourceEquipCommandIDPolicy = value
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if item.UpdatedAtMS > config.UpdatedAtMS {
|
||||
config.UpdatedAtMS = item.UpdatedAtMS
|
||||
}
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handler) GetRuntimeCapabilitiesConfig(c *gin.Context) {
|
||||
item, err := h.service.GetRuntimeCapabilitiesConfig(appctx.FromContext(c.Request.Context()))
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取运行能力配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRuntimeCapabilitiesConfig(c *gin.Context) {
|
||||
var request runtimeCapabilitiesConfigRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.BadRequest(c, "参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateRuntimeCapabilitiesConfig(appctx.FromContext(c.Request.Context()), request)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
// 审计只记录规范化枚举和 App 作用域;它们决定 Gateway 选用哪个榜单投影以及是否强制业务幂等键。
|
||||
shared.OperationLog(c, h.audit, "update-runtime-capabilities", "admin_app_configs", "success", fmt.Sprintf("app_code=%s activity_leaderboard_scope=%s resource_equip_command_id_policy=%s", item.AppCode, item.ActivityLeaderboardScope, item.ResourceEquipCommandIDPolicy))
|
||||
response.OK(c, item)
|
||||
}
|
||||
@ -36,9 +36,17 @@ const (
|
||||
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge, bannerDisplayScopeMe}
|
||||
|
||||
type AppConfigService struct {
|
||||
store *repository.Store
|
||||
activity activityclient.Client
|
||||
landing ActivityLandingURLBuilder
|
||||
store *repository.Store
|
||||
activity activityclient.Client
|
||||
landing ActivityLandingURLBuilder
|
||||
roleScopePolicies RoleScopePolicyClient
|
||||
}
|
||||
|
||||
func (s *AppConfigService) BindRoleScopePolicyClient(client RoleScopePolicyClient) *AppConfigService {
|
||||
if s != nil {
|
||||
s.roleScopePolicies = client
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ActivityLandingURLBuilder keeps COS/CDN placement outside banner business logic.
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/model"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
)
|
||||
@ -76,6 +77,128 @@ func TestFloatingScreenNavigationUsesAppConfigChildMenu(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomDiscoveryNavigationMigratesLegacyScopeWithoutRuntimeBranch(t *testing.T) {
|
||||
body, err := os.ReadFile("../../../migrations/117_room_discovery_scope_config.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read room discovery navigation migration: %v", err)
|
||||
}
|
||||
sqlText := string(body)
|
||||
for _, snippet := range []string{"'房间发现范围'", "'app-config-room-discovery'", "'/app-config/room-discovery'", "parent.code = 'app-config'", "'app-config:view'"} {
|
||||
if !strings.Contains(sqlText, snippet) {
|
||||
t.Fatalf("room discovery navigation migration missing %q", snippet)
|
||||
}
|
||||
}
|
||||
// 旧 Huwaa 分支必须一次性落成配置数据,否则发布新代码后会错误退回 viewer_region;运行时仍只读 App 配置。
|
||||
for _, snippet := range []string{"'huwaa'", "'room-discovery'", "'scope'", "'all_active_regions'"} {
|
||||
if !strings.Contains(strings.ToLower(sqlText), snippet) {
|
||||
t.Fatalf("room discovery legacy capability migration missing %q", snippet)
|
||||
}
|
||||
}
|
||||
for _, appCode := range []string{"fami", "lalu", "yumi", "aslan"} {
|
||||
if strings.Contains(strings.ToLower(sqlText), "'"+appCode+"'") {
|
||||
t.Fatalf("room discovery migration must not seed unrelated app_code %q", appCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomDiscoveryConfigModelAcceptsOnlySupportedScopes(t *testing.T) {
|
||||
item, err := roomDiscoveryConfigModel(" FUTURE-APP ", roomDiscoveryConfigRequest{Scope: " ALL_ACTIVE_REGIONS "})
|
||||
if err != nil {
|
||||
t.Fatalf("valid room discovery scope failed: %v", err)
|
||||
}
|
||||
if item.AppCode != "future-app" || item.Group != roomDiscoveryGroup || item.Key != roomDiscoveryScopeKey || item.Value != roomDiscoveryScopeAllActiveRegions {
|
||||
t.Fatalf("room discovery model mismatch: %+v", item)
|
||||
}
|
||||
if _, err := roomDiscoveryConfigModel("future-app", roomDiscoveryConfigRequest{Scope: "global"}); err == nil {
|
||||
t.Fatal("unsupported room discovery scope must fail")
|
||||
}
|
||||
legacy := roomDiscoveryConfigFromModel(model.AppConfig{AppCode: "future-app", Value: "global", UpdatedAtMS: 99})
|
||||
if legacy.Scope != roomDiscoveryScopeViewerRegion || legacy.UpdatedAtMS != 99 {
|
||||
t.Fatalf("invalid stored scope must fail closed: %+v", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCapabilitiesConfigUsesGenericDefaultsAndValidatesEnums(t *testing.T) {
|
||||
defaults, err := runtimeCapabilitiesConfigFromModels(" FUTURE-APP ", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runtime capability defaults failed: %v", err)
|
||||
}
|
||||
if defaults.AppCode != "future-app" || defaults.ActivityLeaderboardScope != activityLeaderboardScopeAppGlobal || defaults.ResourceEquipCommandIDPolicy != resourceEquipCommandIDPolicyLegacyOptional {
|
||||
t.Fatalf("runtime capability defaults mismatch: %+v", defaults)
|
||||
}
|
||||
|
||||
items, err := runtimeCapabilitiesConfigModels(" FUTURE-APP ", runtimeCapabilitiesConfigRequest{
|
||||
ActivityLeaderboardScope: " VIEWER_REGION ",
|
||||
ResourceEquipCommandIDPolicy: " REQUIRED ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("valid runtime capabilities failed: %v", err)
|
||||
}
|
||||
if len(items) != 2 || items[0].AppCode != "future-app" || items[0].Value != activityLeaderboardScopeViewerRegion || items[1].Value != resourceEquipCommandIDPolicyRequired {
|
||||
t.Fatalf("runtime capability models mismatch: %+v", items)
|
||||
}
|
||||
if _, err := runtimeCapabilitiesConfigModels("future-app", runtimeCapabilitiesConfigRequest{ActivityLeaderboardScope: "all", ResourceEquipCommandIDPolicy: "required"}); err == nil {
|
||||
t.Fatal("unsupported leaderboard scope must fail")
|
||||
}
|
||||
if _, err := runtimeCapabilitiesConfigModels("future-app", runtimeCapabilitiesConfigRequest{ActivityLeaderboardScope: "app_global", ResourceEquipCommandIDPolicy: "optional"}); err == nil {
|
||||
t.Fatal("unsupported equip policy must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCapabilitiesMigrationSeedsOnlyCompatibilityRows(t *testing.T) {
|
||||
body, err := os.ReadFile("../../../migrations/118_runtime_capabilities_config.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read runtime capabilities migration: %v", err)
|
||||
}
|
||||
sqlText := strings.ToLower(string(body))
|
||||
for _, snippet := range []string{
|
||||
"'lalu', 'runtime-capabilities', 'activity_leaderboard_scope', 'viewer_region'",
|
||||
"'fami', 'runtime-capabilities', 'activity_leaderboard_scope', 'viewer_region'",
|
||||
"'fami', 'runtime-capabilities', 'resource_equip_command_id_policy', 'required'",
|
||||
"'app-config-runtime-capabilities'",
|
||||
"'/app-config/runtime-capabilities'",
|
||||
} {
|
||||
if !strings.Contains(sqlText, snippet) {
|
||||
t.Fatalf("runtime capabilities migration missing %q", snippet)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sqlText, "'lalu', 'runtime-capabilities', 'resource_equip_command_id_policy'") {
|
||||
t.Fatal("lalu must retain the missing-key legacy_optional default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleScopePoliciesConfigMapsOnlyValidatedOwnerSnapshot(t *testing.T) {
|
||||
policies, err := roleScopePoliciesFromRequest(roleScopePoliciesConfigRequest{
|
||||
OrganizationScope: " GLOBAL ",
|
||||
ManagerBaseScope: "country",
|
||||
ManagerExpandedScope: "region",
|
||||
ManagerRegionExpansionConfigurable: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("valid role scope config failed: %v", err)
|
||||
}
|
||||
policies[0].UpdatedAtMS = 100
|
||||
policies[1].UpdatedAtMS = 200
|
||||
config, err := roleScopePoliciesConfigFromPolicies(" TENANT-A ", policies)
|
||||
if err != nil {
|
||||
t.Fatalf("map role scope owner response: %v", err)
|
||||
}
|
||||
if config.AppCode != "tenant-a" || config.OrganizationScope != roleScopeGlobal || config.ManagerBaseScope != roleScopeCountry || config.ManagerExpandedScope != roleScopeRegion || !config.ManagerRegionExpansionConfigurable || config.UpdatedAtMS != 200 {
|
||||
t.Fatalf("role scope config mismatch: %+v", config)
|
||||
}
|
||||
|
||||
if _, err := roleScopePoliciesFromRequest(roleScopePoliciesConfigRequest{
|
||||
OrganizationScope: roleScopeRegion,
|
||||
ManagerBaseScope: roleScopeCountry,
|
||||
ManagerExpandedScope: roleScopeGlobal,
|
||||
}); err == nil {
|
||||
t.Fatal("disabled manager expansion must keep one scope")
|
||||
}
|
||||
if _, err := roleScopePoliciesConfigFromPolicies("tenant-a", []userclient.RoleScopePolicy{policies[0]}); err == nil {
|
||||
t.Fatal("partial owner response must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
|
||||
item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{
|
||||
Key: "host-center.v2",
|
||||
|
||||
@ -39,7 +39,7 @@ type taskRequest struct {
|
||||
TargetValue int64 `json:"target_value"`
|
||||
TargetUnit string `json:"target_unit"`
|
||||
RewardCoinAmount int64 `json:"reward_coin_amount"`
|
||||
RewardAssetType string `json:"reward_asset_type"`
|
||||
RewardAssetType string `json:"reward_asset_type" binding:"required,oneof=COIN POINT"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
|
||||
@ -27,12 +27,6 @@ const (
|
||||
overviewConcurrency = 4
|
||||
)
|
||||
|
||||
var appTrackingFunnelSupportedApps = map[string]struct{}{
|
||||
"fami": {},
|
||||
"huwaa": {},
|
||||
"lalu": {},
|
||||
}
|
||||
|
||||
// LegacyRegionCatalogSource 由 payment.MongoMoneyRegionSource 实现;
|
||||
// 社交 BI 与财务范围共用同一套 legacy 区域目录与合成 region_id 口径。
|
||||
type LegacyRegionCatalogSource interface {
|
||||
@ -568,10 +562,6 @@ func (s *Service) appFunnel(ctx context.Context, app AppInfo, regions []RegionIn
|
||||
out.Error = "该 App 暂未接入 App 埋点漏斗"
|
||||
return out
|
||||
}
|
||||
if !supportsAppTrackingFunnel(app.AppCode) {
|
||||
out.Error = "该 App 暂未接入 App 埋点漏斗"
|
||||
return out
|
||||
}
|
||||
allowAll, allowedIDs := allowedRegions(access, app.AppCode)
|
||||
allowedIDs = normalizeRegionIDs(regions, allowedIDs)
|
||||
if !allowAll && len(allowedIDs) == 0 {
|
||||
@ -619,18 +609,16 @@ func (s *Service) appFunnel(ctx context.Context, app AppInfo, regions []RegionIn
|
||||
func filterAppTrackingFunnelApps(apps []AppInfo) []AppInfo {
|
||||
out := make([]AppInfo, 0, len(apps))
|
||||
for _, app := range apps {
|
||||
if app.Kind == appKindHyapp && supportsAppTrackingFunnel(app.AppCode) {
|
||||
// App registry 的 active 记录才会被 listApps 标记为 hyapp;statistics-service 对所有
|
||||
// hyapp 都按 app_code 查询同一份埋点投影,没有数据时返回空漏斗。因此能力边界是 registry kind,
|
||||
// 而不是产品名白名单;legacy 外接 App 仍保持原有隔离。
|
||||
if app.Kind == appKindHyapp {
|
||||
out = append(out, app)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func supportsAppTrackingFunnel(appCode string) bool {
|
||||
_, ok := appTrackingFunnelSupportedApps[appctx.Normalize(appCode)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []RegionInfo, countries map[int64]countryDirectoryEntry, access repository.MoneyAccess, query OverviewQuery) AppOverview {
|
||||
out := AppOverview{
|
||||
AppCode: app.AppCode,
|
||||
|
||||
@ -29,6 +29,16 @@ func TestListAppsPreservesLegacyLogoURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterAppTrackingFunnelAppsUsesRegistryKind(t *testing.T) {
|
||||
registered := AppInfo{AppCode: "new-social-product", AppName: "New Social Product", Kind: appKindHyapp}
|
||||
legacy := AppInfo{AppCode: "external-product", AppName: "External Product", Kind: appKindLegacy}
|
||||
|
||||
got := filterAppTrackingFunnelApps([]AppInfo{legacy, registered})
|
||||
if !reflect.DeepEqual(got, []AppInfo{registered}) {
|
||||
t.Fatalf("expected every registered hyapp and no legacy app, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testAccess() repository.MoneyAccess {
|
||||
return repository.MoneyAccess{
|
||||
UserID: 7,
|
||||
|
||||
@ -625,11 +625,11 @@ func (s *Service) applyWithdrawalDecision(ctx context.Context, item model.UserWi
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), applicationID)
|
||||
transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, item.FreezeTransactionID, actor, strings.TrimSpace(remark), applicationID)
|
||||
return transactionID, userID, err
|
||||
}
|
||||
|
||||
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) {
|
||||
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, freezeTransactionID string, actor shared.Actor, remark string, applicationID uint) (string, error) {
|
||||
reason := strings.TrimSpace(remark)
|
||||
if reason == "" {
|
||||
reason = "salary withdrawal " + decision
|
||||
@ -659,6 +659,7 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm
|
||||
Reason: reason,
|
||||
AppCode: appCode,
|
||||
WithdrawalApplicationId: applicationIDText,
|
||||
FreezeTransactionId: strings.TrimSpace(freezeTransactionID),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -678,6 +679,7 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm
|
||||
Reason: reason,
|
||||
AppCode: appCode,
|
||||
WithdrawalApplicationId: applicationIDText,
|
||||
FreezeTransactionId: strings.TrimSpace(freezeTransactionID),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -730,7 +732,7 @@ func withdrawalAuditCommandID(id uint, stage repository.WithdrawalApplicationRev
|
||||
|
||||
func isPointWithdrawalAssetType(assetType string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(assetType)) {
|
||||
case "POINT", "COIN_SELLER_POINT":
|
||||
case "POINT", "POINT_DIAMOND", "COIN_SELLER_POINT":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@ -400,7 +400,7 @@ func TestApplyWalletDecisionRoutesPointApprovalToPointSettlement(t *testing.T) {
|
||||
wallet := &fakeAuditWalletClient{}
|
||||
svc := &Service{wallet: wallet}
|
||||
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:77:approved", "fami", 42001, "POINT", 1_000_000, 25_000, 975_000, 200_000, 250, shared.Actor{UserID: 90001}, "paid", 77)
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:77:approved", "fami", 42001, "POINT", 1_000_000, 25_000, 975_000, 200_000, 250, "freeze-tx-77", shared.Actor{UserID: 90001}, "paid", 77)
|
||||
if err != nil {
|
||||
t.Fatalf("apply point approval failed: %v", err)
|
||||
}
|
||||
@ -430,7 +430,7 @@ func TestApplyWalletDecisionRoutesPointRejectionToPointRelease(t *testing.T) {
|
||||
wallet := &fakeAuditWalletClient{}
|
||||
svc := &Service{wallet: wallet}
|
||||
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusRejected, "audit:78:rejected", "huwaa", 42002, "COIN_SELLER_POINT", 2_000_000, 100_000, 1_900_000, 100_000, 500, shared.Actor{UserID: 90002}, "bad address", 78)
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusRejected, "audit:78:rejected", "huwaa", 42002, "COIN_SELLER_POINT", 2_000_000, 100_000, 1_900_000, 100_000, 500, "freeze-tx-78", shared.Actor{UserID: 90002}, "bad address", 78)
|
||||
if err != nil {
|
||||
t.Fatalf("apply point rejection failed: %v", err)
|
||||
}
|
||||
@ -459,7 +459,7 @@ func TestApplyWalletDecisionKeepsLegacySalaryAssetsOnSalaryRPC(t *testing.T) {
|
||||
wallet := &fakeAuditWalletClient{}
|
||||
svc := &Service{wallet: wallet}
|
||||
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:79:approved", "lalu", 42003, "HOST_SALARY_USD", 12_345, 0, 0, 0, 0, shared.Actor{UserID: 90003}, "", 79)
|
||||
transactionID, err := svc.applyWalletDecision(context.Background(), model.WithdrawalApplicationStatusApproved, "audit:79:approved", "lalu", 42003, "HOST_SALARY_USD", 12_345, 0, 0, 0, 0, "", shared.Actor{UserID: 90003}, "", 79)
|
||||
if err != nil {
|
||||
t.Fatalf("apply salary approval failed: %v", err)
|
||||
}
|
||||
|
||||
@ -11,8 +11,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const statusActive = "active"
|
||||
|
||||
var giftTypes = []string{"normal", "lucky", "super_lucky"}
|
||||
|
||||
type Service struct {
|
||||
@ -29,11 +27,13 @@ func (s *Service) List(ctx context.Context, appCode string, regionID int64) (rat
|
||||
}
|
||||
items := make([]ratioDTO, 0, len(giftTypes))
|
||||
for _, giftType := range giftTypes {
|
||||
item, err := s.resolveRatio(ctx, appCode, regionID, giftType)
|
||||
item, configured, err := s.resolveRatio(ctx, appCode, regionID, giftType)
|
||||
if err != nil {
|
||||
return ratioListDTO{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
if configured {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return ratioListDTO{RegionID: regionID, Items: items}, nil
|
||||
}
|
||||
@ -90,17 +90,19 @@ func (s *Service) Update(ctx context.Context, appCode string, req updateRequest,
|
||||
return s.List(ctx, appCode, req.RegionID)
|
||||
}
|
||||
|
||||
func (s *Service) resolveRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, error) {
|
||||
func (s *Service) resolveRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, bool, error) {
|
||||
if item, exists, err := s.getRatio(ctx, appCode, regionID, giftType); err != nil || exists {
|
||||
return item, err
|
||||
return item, exists, err
|
||||
}
|
||||
if regionID != 0 {
|
||||
if item, exists, err := s.getRatio(ctx, appCode, 0, giftType); err != nil || exists {
|
||||
item.RegionID = regionID
|
||||
return item, err
|
||||
return item, exists, err
|
||||
}
|
||||
}
|
||||
return ratioDTO{GiftTypeCode: giftType, RatioPercent: defaultRatioPercent(giftType), ReturnCoinRatioPercent: defaultReturnCoinRatioPercent(giftType), RegionID: regionID, EffectiveRegionID: 0, Status: statusActive}, nil
|
||||
// 后台必须如实展示运行库状态;没有区域配置且没有全局继承时返回未配置,
|
||||
// 不能合成一组未持久化的 100/10/1 让运营误以为送礼链路已有政策。
|
||||
return ratioDTO{}, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) getRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, bool, error) {
|
||||
@ -119,8 +121,14 @@ func (s *Service) getRatio(ctx context.Context, appCode string, regionID int64,
|
||||
return ratioDTO{}, false, err
|
||||
}
|
||||
item.RegionID = regionID
|
||||
item.RatioPercent = formatPercentString(percent)
|
||||
item.ReturnCoinRatioPercent = formatPercentStringWithDefault(returnCoinPercent, defaultReturnCoinRatioPercent(giftType))
|
||||
item.RatioPercent, err = persistedPercentString(percent)
|
||||
if err != nil {
|
||||
return ratioDTO{}, false, fmt.Errorf("%s 主播钻石比例存储值不正确: %w", giftType, err)
|
||||
}
|
||||
item.ReturnCoinRatioPercent, err = persistedPercentString(returnCoinPercent)
|
||||
if err != nil {
|
||||
return ratioDTO{}, false, fmt.Errorf("%s 返还金币比例存储值不正确: %w", giftType, err)
|
||||
}
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
@ -136,38 +144,12 @@ func normalizePercent(raw interface{}) (string, error) {
|
||||
return fmt.Sprintf("%.2f", value), nil
|
||||
}
|
||||
|
||||
func formatPercentString(raw string) string {
|
||||
return formatPercentStringWithDefault(raw, "100.00")
|
||||
}
|
||||
|
||||
func formatPercentStringWithDefault(raw string, fallback string) string {
|
||||
func persistedPercentString(raw string) (string, error) {
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return fmt.Sprintf("%.2f", value)
|
||||
}
|
||||
|
||||
func defaultRatioPercent(giftType string) string {
|
||||
switch strings.TrimSpace(giftType) {
|
||||
case "lucky":
|
||||
return "10.00"
|
||||
case "super_lucky":
|
||||
return "1.00"
|
||||
default:
|
||||
return "100.00"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultReturnCoinRatioPercent(giftType string) string {
|
||||
switch strings.TrimSpace(giftType) {
|
||||
case "lucky":
|
||||
return "10.00"
|
||||
case "super_lucky":
|
||||
return "1.00"
|
||||
default:
|
||||
return "30.00"
|
||||
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 100 {
|
||||
return "", errors.New("invalid persisted percent")
|
||||
}
|
||||
return fmt.Sprintf("%.2f", value), nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureSchema(ctx context.Context) error {
|
||||
@ -191,23 +173,12 @@ func (s *Service) ensureSchema(ctx context.Context) error {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil {
|
||||
return err
|
||||
}
|
||||
if added, err := ensureReturnCoinRatioColumn(ctx, s.db); err != nil {
|
||||
if _, err := ensureReturnCoinRatioColumn(ctx, s.db); err != nil {
|
||||
return err
|
||||
} else if added {
|
||||
if err := seedReturnCoinRatioDefaults(ctx, s.db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO gift_diamond_ratio_configs (
|
||||
app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
(?, 0, 'normal', 'active', 100.00, 30.00, 0, 0, 0, 0),
|
||||
(?, 0, 'lucky', 'active', 10.00, 10.00, 0, 0, 0, 0),
|
||||
(?, 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, 0, 0)`,
|
||||
"lalu", "lalu", "lalu")
|
||||
return err
|
||||
// 这里只维护结构,不按任何 App 名创建倍率记录;Update 是唯一新增/覆盖运行政策的入口。
|
||||
// 存量正式配置不会被删除或重写。
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureReturnCoinRatioColumn(ctx context.Context, db *sql.DB) (bool, error) {
|
||||
@ -222,14 +193,3 @@ func ensureReturnCoinRatioColumn(ctx context.Context, db *sql.DB) (bool, error)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func seedReturnCoinRatioDefaults(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE gift_diamond_ratio_configs
|
||||
SET coin_return_ratio_percent = CASE gift_type_code
|
||||
WHEN 'lucky' THEN 10.00
|
||||
WHEN 'super_lucky' THEN 1.00
|
||||
ELSE 30.00
|
||||
END`)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -1,26 +1,66 @@
|
||||
package giftdiamond
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
func TestDefaultReturnCoinRatioPercentByGiftType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"normal": "30.00",
|
||||
"lucky": "10.00",
|
||||
"super_lucky": "1.00",
|
||||
"": "30.00",
|
||||
}
|
||||
for giftType, want := range cases {
|
||||
if got := defaultReturnCoinRatioPercent(giftType); got != want {
|
||||
t.Fatalf("defaultReturnCoinRatioPercent(%q) = %s, want %s", giftType, got, want)
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestPersistedPercentStringRejectsEmptyOrInvalidValues(t *testing.T) {
|
||||
for _, raw := range []string{"", "bad", "-1", "101"} {
|
||||
if _, err := persistedPercentString(raw); err == nil {
|
||||
t.Fatalf("persisted percent %q must not be replaced by a code default", raw)
|
||||
}
|
||||
}
|
||||
if got, err := persistedPercentString("12.345"); err != nil || got != "12.35" {
|
||||
t.Fatalf("valid persisted percent mismatch: got=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatReturnCoinRatioUsesGiftTypeDefault(t *testing.T) {
|
||||
if got := formatPercentStringWithDefault("", defaultReturnCoinRatioPercent("normal")); got != "30.00" {
|
||||
t.Fatalf("normal return coin fallback = %s, want 30.00", got)
|
||||
func TestResolveRatioReturnsUnconfiguredInsteadOfSyntheticDefaults(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
if got := formatPercentStringWithDefault("12.345", defaultReturnCoinRatioPercent("lucky")); got != "12.35" {
|
||||
t.Fatalf("return coin rounding = %s, want 12.35", got)
|
||||
defer db.Close()
|
||||
query := regexp.QuoteMeta(`
|
||||
SELECT region_id, gift_type_code, CAST(ratio_percent AS CHAR), CAST(coin_return_ratio_percent AS CHAR), status, updated_at_ms
|
||||
FROM gift_diamond_ratio_configs
|
||||
WHERE app_code = ? AND region_id = ? AND gift_type_code = ?
|
||||
LIMIT 1`)
|
||||
mock.ExpectQuery(query).WithArgs("fami", int64(25), "normal").WillReturnError(sqlmock.ErrCancelled)
|
||||
service := NewService(db)
|
||||
if _, _, err := service.resolveRatio(context.Background(), "fami", 25, "normal"); !errors.Is(err, sqlmock.ErrCancelled) {
|
||||
t.Fatalf("query errors must be returned: %v", err)
|
||||
}
|
||||
|
||||
// 两级查询均无记录时必须返回 configured=false,不能显示 100/10/1 幽灵政策。
|
||||
mock.ExpectQuery(query).WithArgs("fami", int64(25), "normal").WillReturnRows(sqlmock.NewRows([]string{"region_id", "gift_type_code", "ratio", "return_ratio", "status", "updated_at_ms"}))
|
||||
mock.ExpectQuery(query).WithArgs("fami", int64(0), "normal").WillReturnRows(sqlmock.NewRows([]string{"region_id", "gift_type_code", "ratio", "return_ratio", "status", "updated_at_ms"}))
|
||||
item, configured, err := service.resolveRatio(context.Background(), "fami", 25, "normal")
|
||||
if err != nil || configured || item != (ratioDTO{}) {
|
||||
t.Fatalf("unconfigured ratio mismatch: item=%+v configured=%t err=%v", item, configured, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet SQL expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSchemaDoesNotInsertGiftMultiplierDefaults(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectExec("CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("ALTER TABLE gift_diamond_ratio_configs").WillReturnError(errors.New("Duplicate column name 'coin_return_ratio_percent'"))
|
||||
if err := NewService(db).ensureSchema(context.Background()); err != nil {
|
||||
t.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("startup must not issue an implicit INSERT: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ func (h *Handler) ListPolicies(c *gin.Context) {
|
||||
// query 层保留 snake_case/camelCase 两种写法,兼容生成端和手写端调用。
|
||||
items, total, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), repository.HostAgencySalaryPolicyListOptions{
|
||||
Keyword: options.Keyword,
|
||||
PolicyType: firstQuery(c, "policy_type", "policyType"),
|
||||
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||
CycleKey: cycleKey,
|
||||
Status: options.Status,
|
||||
|
||||
@ -2,6 +2,7 @@ package hostagencypolicy
|
||||
|
||||
type policyRequest struct {
|
||||
Name string `json:"name"`
|
||||
PolicyType string `json:"policy_type"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
@ -9,6 +10,11 @@ type policyRequest struct {
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
|
||||
CoinsPerUSD int64 `json:"coins_per_usd"`
|
||||
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
|
||||
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
|
||||
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
|
||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
|
||||
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
|
||||
|
||||
@ -10,6 +10,7 @@ type policyDTO struct {
|
||||
ID uint `json:"id"`
|
||||
AppCode string `json:"app_code"`
|
||||
Name string `json:"name"`
|
||||
PolicyType string `json:"policy_type"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
RegionIDs []int64 `json:"region_ids"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
@ -18,6 +19,11 @@ type policyDTO struct {
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
|
||||
CoinsPerUSD int64 `json:"coins_per_usd"`
|
||||
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
|
||||
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
|
||||
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
|
||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||
CoinSellerWithdrawalLimitPeriod string `json:"coin_seller_withdrawal_limit_period"`
|
||||
CoinSellerWithdrawalLimitCount int64 `json:"coin_seller_withdrawal_limit_count"`
|
||||
@ -64,6 +70,7 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
|
||||
ID: item.ID,
|
||||
AppCode: item.AppCode,
|
||||
Name: item.Name,
|
||||
PolicyType: normalizePolicyType(item.PolicyType),
|
||||
RegionID: item.RegionID,
|
||||
RegionIDs: responseRegionIDs(item),
|
||||
CycleKey: item.CycleKey,
|
||||
@ -72,6 +79,11 @@ func policyFromModel(item model.HostAgencySalaryPolicy) policyDTO {
|
||||
SettlementMode: item.SettlementMode,
|
||||
SettlementTriggerMode: firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
||||
GiftCoinToDiamondRatio: trimDecimalZeros(item.GiftCoinToDiamondRatio),
|
||||
PointDiamondsPerUSD: item.PointDiamondsPerUSD,
|
||||
CoinsPerUSD: item.CoinsPerUSD,
|
||||
MinimumWithdrawUSDMinor: item.MinimumWithdrawUSDMinor,
|
||||
WithdrawFeeBPS: item.WithdrawFeeBPS,
|
||||
AgencyPointShareBPS: item.AgencyPointShareBPS,
|
||||
ResidualDiamondToUSDRate: trimDecimalZeros(item.ResidualDiamondToUSDRate),
|
||||
CoinSellerWithdrawalLimitPeriod: firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth),
|
||||
CoinSellerWithdrawalLimitCount: item.CoinSellerWithdrawalLimitCount,
|
||||
|
||||
@ -18,6 +18,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
policyTypeSalaryDiamond = "SALARY_DIAMOND"
|
||||
policyTypePointDiamond = "POINT_DIAMOND"
|
||||
// active/disabled 只表达后台政策是否可用于结算匹配;历史结算记录不应因为停用政策被回写改口径。
|
||||
policyStatusActive = "active"
|
||||
policyStatusDisabled = "disabled"
|
||||
@ -30,7 +32,7 @@ const (
|
||||
settlementTriggerAutomatic = "automatic"
|
||||
settlementTriggerManual = "manual"
|
||||
|
||||
// 礼物金币转主播钻石由 gift_diamond_ratio_configs 统一控制;工资政策保留 1:1 快照只用于运行表字段完整。
|
||||
// gift_diamond_ratio_configs 定义礼物类型基础倍率,Host 政策字段定义其叠加系数;1 表示不调整基础倍率。
|
||||
// 月底剩余钻石转美元默认不开启,由后台显式配置。
|
||||
defaultGiftCoinToDiamondRatio = "1"
|
||||
defaultResidualDiamondRate = "0"
|
||||
@ -67,6 +69,12 @@ func (s *Service) List(ctx context.Context, appCode string, options repository.H
|
||||
}
|
||||
}
|
||||
options.Status = normalizeStatusFilter(options.Status)
|
||||
if strings.TrimSpace(options.PolicyType) != "" {
|
||||
options.PolicyType = normalizePolicyType(options.PolicyType)
|
||||
if options.PolicyType == "" {
|
||||
return nil, 0, errors.New("政策类型不正确")
|
||||
}
|
||||
}
|
||||
options.SettlementMode = normalizeSettlementModeFilter(options.SettlementMode)
|
||||
options.SettlementTriggerMode = normalizeSettlementTriggerModeFilter(options.SettlementTriggerMode)
|
||||
items, total, err := s.store.ListHostAgencySalaryPolicies(options)
|
||||
@ -201,12 +209,17 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
|
||||
// 运行侧已经按周期绑定该版本;编辑会让历史结算口径漂移,只能为新周期新建政策。
|
||||
return policyDTO{}, errors.New("已发布的周期政策版本不可修改,请为新周期创建政策")
|
||||
}
|
||||
if strings.TrimSpace(req.PolicyType) == "" {
|
||||
// Update DTO 的 policy_type 可选;旧前端没有该字段时必须保留草稿原类型,不能按创建默认值覆盖为工资型。
|
||||
req.PolicyType = policyTypeForUpdate(req.PolicyType, item.PolicyType)
|
||||
}
|
||||
// 请求体重新走创建同一套校验,编辑和新增保持完全一致的字段约束。
|
||||
updated, err := policyModelFromRequest(item.AppCode, actorID, req)
|
||||
if err != nil {
|
||||
return policyDTO{}, err
|
||||
}
|
||||
item.Name = updated.Name
|
||||
item.PolicyType = updated.PolicyType
|
||||
item.RegionID = updated.RegionID
|
||||
item.RegionIDs = updated.RegionIDs
|
||||
item.CycleKey = updated.CycleKey
|
||||
@ -214,6 +227,11 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
|
||||
item.SettlementMode = updated.SettlementMode
|
||||
item.SettlementTriggerMode = updated.SettlementTriggerMode
|
||||
item.GiftCoinToDiamondRatio = updated.GiftCoinToDiamondRatio
|
||||
item.PointDiamondsPerUSD = updated.PointDiamondsPerUSD
|
||||
item.CoinsPerUSD = updated.CoinsPerUSD
|
||||
item.MinimumWithdrawUSDMinor = updated.MinimumWithdrawUSDMinor
|
||||
item.WithdrawFeeBPS = updated.WithdrawFeeBPS
|
||||
item.AgencyPointShareBPS = updated.AgencyPointShareBPS
|
||||
item.ResidualDiamondToUSDRate = updated.ResidualDiamondToUSDRate
|
||||
item.CoinSellerWithdrawalLimitPeriod = updated.CoinSellerWithdrawalLimitPeriod
|
||||
item.CoinSellerWithdrawalLimitCount = updated.CoinSellerWithdrawalLimitCount
|
||||
@ -236,6 +254,13 @@ func (s *Service) Update(ctx context.Context, appCode string, actorID uint, id u
|
||||
return policyFromModel(item), nil
|
||||
}
|
||||
|
||||
func policyTypeForUpdate(requested string, existing string) string {
|
||||
if strings.TrimSpace(requested) == "" {
|
||||
return existing
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, appCode string, id uint) error {
|
||||
// 先按 app_code 查一次,确保删除动作只能落在当前应用上下文内,随后仓储层事务删除主表和等级表。
|
||||
item, err := s.store.GetHostAgencySalaryPolicy(appctx.Normalize(appCode), id)
|
||||
@ -340,9 +365,12 @@ func (s *Service) publishRuntimePolicy(ctx context.Context, item model.HostAgenc
|
||||
if s == nil || s.walletDB == nil {
|
||||
return errors.New("wallet mysql is not configured")
|
||||
}
|
||||
if len(item.Levels) == 0 {
|
||||
if normalizePolicyType(item.PolicyType) == policyTypeSalaryDiamond && len(item.Levels) == 0 {
|
||||
return errors.New("至少需要配置一个等级")
|
||||
}
|
||||
if normalizePolicyType(item.PolicyType) == policyTypePointDiamond && len(item.Levels) != 0 {
|
||||
return errors.New("钻石积分政策不能配置工资等级")
|
||||
}
|
||||
tx, err := s.walletDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -373,11 +401,17 @@ type runtimeHostSalaryPolicySnapshot struct {
|
||||
CycleKey string
|
||||
PolicyVersion uint64
|
||||
Name string
|
||||
PolicyType string
|
||||
RegionID int64
|
||||
Status string
|
||||
SettlementMode string
|
||||
SettlementTriggerMode string
|
||||
GiftCoinToDiamondRatio string
|
||||
PointDiamondsPerUSD int64
|
||||
CoinsPerUSD int64
|
||||
MinimumWithdrawUSDMinor int64
|
||||
WithdrawFeeBPS int32
|
||||
AgencyPointShareBPS int32
|
||||
ResidualDiamondToUSDRate string
|
||||
CoinSellerWithdrawalLimitPeriod string
|
||||
CoinSellerWithdrawalLimitCount int64
|
||||
@ -401,16 +435,18 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
|
||||
// 两个首次发布请求会在同一主键写锁上排队,避免“都 SELECT 到不存在,再 INSERT”形成 gap-lock 死锁。
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO host_agency_salary_policies (
|
||||
app_code, policy_id, cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode,
|
||||
gift_coin_to_diamond_ratio, residual_diamond_to_usd_rate,
|
||||
app_code, policy_id, cycle_key, policy_version, name, policy_type, region_id, status, settlement_mode, settlement_trigger_mode,
|
||||
gift_coin_to_diamond_ratio, point_diamonds_per_usd, coins_per_usd, minimum_withdraw_usd_minor, withdraw_fee_bps, agency_point_share_bps,
|
||||
residual_diamond_to_usd_rate,
|
||||
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
|
||||
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id)`,
|
||||
item.AppCode, item.ID, item.CycleKey, policyVersion, item.Name, item.RegionID, item.Status,
|
||||
item.AppCode, item.ID, item.CycleKey, policyVersion, item.Name, normalizePolicyType(item.PolicyType), item.RegionID, item.Status,
|
||||
item.SettlementMode, firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic),
|
||||
item.GiftCoinToDiamondRatio, item.ResidualDiamondToUSDRate,
|
||||
item.GiftCoinToDiamondRatio, item.PointDiamondsPerUSD, item.CoinsPerUSD, item.MinimumWithdrawUSDMinor, item.WithdrawFeeBPS, item.AgencyPointShareBPS,
|
||||
item.ResidualDiamondToUSDRate,
|
||||
firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.CoinSellerWithdrawalLimitCount,
|
||||
firstNonBlank(item.PlatformWithdrawalLimitPeriod, withdrawalLimitPeriodMonth), item.PlatformWithdrawalLimitCount,
|
||||
item.PlatformWithdrawalAllowedDays,
|
||||
@ -421,16 +457,18 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
|
||||
|
||||
var existing runtimeHostSalaryPolicySnapshot
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT cycle_key, policy_version, name, region_id, status, settlement_mode, settlement_trigger_mode,
|
||||
CAST(gift_coin_to_diamond_ratio AS CHAR), CAST(residual_diamond_to_usd_rate AS CHAR),
|
||||
SELECT cycle_key, policy_version, name, policy_type, region_id, status, settlement_mode, settlement_trigger_mode,
|
||||
CAST(gift_coin_to_diamond_ratio AS CHAR), point_diamonds_per_usd, coins_per_usd,
|
||||
minimum_withdraw_usd_minor, withdraw_fee_bps, agency_point_share_bps, CAST(residual_diamond_to_usd_rate AS CHAR),
|
||||
coin_seller_withdrawal_limit_period, coin_seller_withdrawal_limit_count,
|
||||
platform_withdrawal_limit_period, platform_withdrawal_limit_count, platform_withdrawal_allowed_days
|
||||
FROM host_agency_salary_policies
|
||||
WHERE app_code = ? AND policy_id = ?
|
||||
FOR UPDATE`, item.AppCode, item.ID).Scan(
|
||||
&existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.RegionID, &existing.Status,
|
||||
&existing.CycleKey, &existing.PolicyVersion, &existing.Name, &existing.PolicyType, &existing.RegionID, &existing.Status,
|
||||
&existing.SettlementMode, &existing.SettlementTriggerMode,
|
||||
&existing.GiftCoinToDiamondRatio, &existing.ResidualDiamondToUSDRate,
|
||||
&existing.GiftCoinToDiamondRatio, &existing.PointDiamondsPerUSD, &existing.CoinsPerUSD,
|
||||
&existing.MinimumWithdrawUSDMinor, &existing.WithdrawFeeBPS, &existing.AgencyPointShareBPS, &existing.ResidualDiamondToUSDRate,
|
||||
&existing.CoinSellerWithdrawalLimitPeriod, &existing.CoinSellerWithdrawalLimitCount,
|
||||
&existing.PlatformWithdrawalLimitPeriod, &existing.PlatformWithdrawalLimitCount,
|
||||
&existing.PlatformWithdrawalAllowedDays,
|
||||
@ -480,9 +518,13 @@ func ensureRuntimeHostSalaryPolicySnapshot(ctx context.Context, tx *sql.Tx, item
|
||||
|
||||
func runtimeHostSalaryPolicyMatches(existing runtimeHostSalaryPolicySnapshot, item model.HostAgencySalaryPolicy, policyVersion uint64) bool {
|
||||
return existing.CycleKey == item.CycleKey && existing.PolicyVersion == policyVersion && existing.Name == item.Name &&
|
||||
existing.PolicyType == normalizePolicyType(item.PolicyType) &&
|
||||
existing.RegionID == item.RegionID && existing.Status == item.Status && existing.SettlementMode == item.SettlementMode &&
|
||||
existing.SettlementTriggerMode == firstNonBlank(item.SettlementTriggerMode, settlementTriggerAutomatic) &&
|
||||
trimDecimalZeros(existing.GiftCoinToDiamondRatio) == trimDecimalZeros(item.GiftCoinToDiamondRatio) &&
|
||||
existing.PointDiamondsPerUSD == item.PointDiamondsPerUSD && existing.CoinsPerUSD == item.CoinsPerUSD &&
|
||||
existing.MinimumWithdrawUSDMinor == item.MinimumWithdrawUSDMinor && existing.WithdrawFeeBPS == item.WithdrawFeeBPS &&
|
||||
existing.AgencyPointShareBPS == item.AgencyPointShareBPS &&
|
||||
trimDecimalZeros(existing.ResidualDiamondToUSDRate) == trimDecimalZeros(item.ResidualDiamondToUSDRate) &&
|
||||
existing.CoinSellerWithdrawalLimitPeriod == firstNonBlank(item.CoinSellerWithdrawalLimitPeriod, withdrawalLimitPeriodMonth) &&
|
||||
existing.CoinSellerWithdrawalLimitCount == item.CoinSellerWithdrawalLimitCount &&
|
||||
@ -599,6 +641,10 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
if name == "" || len([]rune(name)) > 120 {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("政策名称不正确")
|
||||
}
|
||||
policyType := normalizePolicyType(req.PolicyType)
|
||||
if policyType == "" {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("政策类型不正确")
|
||||
}
|
||||
regionIDs, err := normalizePolicyRegionIDs(req.RegionID, req.RegionIDs)
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
@ -615,29 +661,63 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
if !validStatus(status) {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("政策状态不正确")
|
||||
}
|
||||
settlementMode := normalizeSettlementMode(req.SettlementMode)
|
||||
if settlementMode == "" {
|
||||
// 产品当前默认日结;半月结算只是调度节奏差异,等级和差额发放规则相同。
|
||||
settlementMode = settlementModeDaily
|
||||
settlementMode := settlementModeDaily
|
||||
settlementTriggerMode := settlementTriggerAutomatic
|
||||
if policyType == policyTypeSalaryDiamond {
|
||||
settlementMode = normalizeSettlementMode(req.SettlementMode)
|
||||
if settlementMode == "" {
|
||||
// 产品当前默认日结;半月结算只是调度节奏差异,等级和差额发放规则相同。
|
||||
settlementMode = settlementModeDaily
|
||||
}
|
||||
if !validSettlementMode(settlementMode) {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("结算方式不正确")
|
||||
}
|
||||
settlementTriggerMode = normalizeSettlementTriggerMode(req.SettlementTriggerMode)
|
||||
if settlementTriggerMode == "" {
|
||||
// 默认自动触发,兼容现有政策和现有 cron 日结/半月结任务;需要人工复核时由后台显式切到 manual。
|
||||
settlementTriggerMode = settlementTriggerAutomatic
|
||||
}
|
||||
if !validSettlementTriggerMode(settlementTriggerMode) {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("结算触发方式不正确")
|
||||
}
|
||||
}
|
||||
if !validSettlementMode(settlementMode) {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("结算方式不正确")
|
||||
}
|
||||
settlementTriggerMode := normalizeSettlementTriggerMode(req.SettlementTriggerMode)
|
||||
if settlementTriggerMode == "" {
|
||||
// 默认自动触发,兼容现有政策和现有 cron 日结/半月结任务;需要人工复核时由后台显式切到 manual。
|
||||
settlementTriggerMode = settlementTriggerAutomatic
|
||||
}
|
||||
if !validSettlementTriggerMode(settlementTriggerMode) {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("结算触发方式不正确")
|
||||
}
|
||||
// 送礼入主播周期钻石的实际比例已经由“礼物钻石”配置在 wallet 扣礼物时写入周期账户;
|
||||
// 这里不再接受工资政策单独覆盖,避免同一笔礼物在入账和结算阶段出现两套钻石口径。
|
||||
giftRatio, _, err := parseFixedDecimal(defaultGiftCoinToDiamondRatio, 6, false, "金币转钻石比例")
|
||||
// 礼物配置负责 normal/lucky/super_lucky 等类型基础倍率,Host 工资政策只叠加一个周期系数;
|
||||
// 发布后 wallet 按周期不可变快照读取,系数 1 保持原礼物类型倍率不变。
|
||||
giftRatio := firstNonBlank(req.GiftCoinToDiamondRatio, defaultGiftCoinToDiamondRatio)
|
||||
giftRatio, _, err = parseFixedDecimal(giftRatio, 6, false, "周期钻石政策系数")
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
residualRate := firstNonBlank(req.ResidualDiamondToUSDRate, defaultResidualDiamondRate)
|
||||
pointDiamondsPerUSD := req.PointDiamondsPerUSD
|
||||
coinsPerUSD := req.CoinsPerUSD
|
||||
minimumWithdrawUSDMinor := req.MinimumWithdrawUSDMinor
|
||||
withdrawFeeBPS := req.WithdrawFeeBPS
|
||||
agencyPointShareBPS := req.AgencyPointShareBPS
|
||||
if policyType == policyTypeSalaryDiamond {
|
||||
// 工资型政策不消费积分兑换字段;即使旧/恶意客户端提交也统一清零,避免后台出现“保存成功但不生效”的幽灵配置。
|
||||
pointDiamondsPerUSD = 0
|
||||
coinsPerUSD = 0
|
||||
minimumWithdrawUSDMinor = 0
|
||||
withdrawFeeBPS = 0
|
||||
agencyPointShareBPS = 0
|
||||
}
|
||||
if pointDiamondsPerUSD < 0 || coinsPerUSD < 0 || minimumWithdrawUSDMinor < 0 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10_000 || agencyPointShareBPS < 0 || agencyPointShareBPS > 10_000 {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("钻石积分兑换或提现配置不正确")
|
||||
}
|
||||
if policyType == policyTypePointDiamond && (pointDiamondsPerUSD <= 0 || coinsPerUSD <= 0 || minimumWithdrawUSDMinor <= 0) {
|
||||
// POINT_DIAMOND 的金额入口全部读取这份月度不可变快照;缺少任一比例时必须拒绝发布前保存,不能回退代码常量。
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("钻石积分政策必须配置每美元钻石、每美元金币和最低提现金额")
|
||||
}
|
||||
if policyType == policyTypePointDiamond && pointDiamondsPerUSD%100 != 0 {
|
||||
// HTTP 只接受美元分整数;比例必须能精确映射每一美分,禁止依赖向下取整吞掉用户积分。
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("每美元钻石积分必须是 100 的整数倍")
|
||||
}
|
||||
residualRateInput := req.ResidualDiamondToUSDRate
|
||||
if policyType == policyTypePointDiamond {
|
||||
// 永久积分不参与工资结算和月底剩余钻石折算;隐藏字段无论提交什么都固化为中性零值。
|
||||
residualRateInput = defaultResidualDiamondRate
|
||||
}
|
||||
residualRate := firstNonBlank(residualRateInput, defaultResidualDiamondRate)
|
||||
residualRate, _, err = parseFixedDecimal(residualRate, 12, true, "剩余钻石转美元比例")
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
@ -659,13 +739,22 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("备注不能超过 255 个字符")
|
||||
}
|
||||
// 等级表保存的是累计门槛和累计权益;实际发薪时应减去上一已结算等级,只发差额。
|
||||
levels, err := levelModelsFromRequest(req.Levels)
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
var levels []model.HostAgencySalaryLevel
|
||||
if policyType == policyTypePointDiamond {
|
||||
if len(req.Levels) != 0 {
|
||||
return model.HostAgencySalaryPolicy{}, errors.New("钻石积分政策不能配置工资等级")
|
||||
}
|
||||
levels = []model.HostAgencySalaryLevel{}
|
||||
} else {
|
||||
levels, err = levelModelsFromRequest(req.Levels)
|
||||
if err != nil {
|
||||
return model.HostAgencySalaryPolicy{}, err
|
||||
}
|
||||
}
|
||||
return model.HostAgencySalaryPolicy{
|
||||
AppCode: appCode,
|
||||
Name: name,
|
||||
AppCode: appCode,
|
||||
Name: name,
|
||||
PolicyType: policyType,
|
||||
// RegionID 保留首个区域以兼容旧索引和旧客户端;实际覆盖范围只由 RegionIDs 关联表决定。
|
||||
RegionID: regionIDs[0],
|
||||
RegionIDs: regionIDs,
|
||||
@ -674,6 +763,11 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
SettlementMode: settlementMode,
|
||||
SettlementTriggerMode: settlementTriggerMode,
|
||||
GiftCoinToDiamondRatio: giftRatio,
|
||||
PointDiamondsPerUSD: pointDiamondsPerUSD,
|
||||
CoinsPerUSD: coinsPerUSD,
|
||||
MinimumWithdrawUSDMinor: minimumWithdrawUSDMinor,
|
||||
WithdrawFeeBPS: withdrawFeeBPS,
|
||||
AgencyPointShareBPS: agencyPointShareBPS,
|
||||
ResidualDiamondToUSDRate: residualRate,
|
||||
CoinSellerWithdrawalLimitPeriod: coinSellerLimitPeriod,
|
||||
CoinSellerWithdrawalLimitCount: coinSellerLimitCount,
|
||||
@ -687,6 +781,19 @@ func policyModelFromRequest(appCode string, actorID uint, req policyRequest) (mo
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizePolicyType(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return policyTypeSalaryDiamond
|
||||
}
|
||||
switch value {
|
||||
case policyTypeSalaryDiamond, policyTypePointDiamond:
|
||||
return value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeWithdrawalLimit 固定月度政策允许的周期枚举;次数为 0 时保留周期但运行侧直接按不限处理。
|
||||
func normalizeWithdrawalLimit(period string, count int64, field string) (string, int64, error) {
|
||||
period = strings.ToLower(strings.TrimSpace(period))
|
||||
|
||||
@ -41,8 +41,8 @@ func TestPolicyModelFromRequestNormalizesAndSortsLevels(t *testing.T) {
|
||||
if item.RegionID != 101 || len(item.RegionIDs) != 2 || item.RegionIDs[0] != 101 || item.RegionIDs[1] != 102 {
|
||||
t.Fatalf("multi-region scope should be sorted and deduplicated: %+v", item.RegionIDs)
|
||||
}
|
||||
// 工资政策不能单独改送礼入主播周期钻石的比例;该比例只由礼物钻石配置页控制。
|
||||
if item.GiftCoinToDiamondRatio != "1.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" {
|
||||
// Host 政策只保存叠加系数,并随发布快照进入 wallet 运行表;礼物类型基础倍率仍由礼物钻石配置提供。
|
||||
if item.GiftCoinToDiamondRatio != "9.000000" || item.ResidualDiamondToUSDRate != "0.000001000000" {
|
||||
t.Fatalf("ratio fields mismatch: %+v", item)
|
||||
}
|
||||
if item.CoinSellerWithdrawalLimitPeriod != "week" || item.CoinSellerWithdrawalLimitCount != 3 || item.PlatformWithdrawalLimitPeriod != "day" || item.PlatformWithdrawalLimitCount != 1 || item.PlatformWithdrawalAllowedDays != "15,30" {
|
||||
@ -148,6 +148,33 @@ func TestPolicyModelFromRequestRejectsInvalidSettlementTriggerMode(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyModelFromRequestClearsFieldsOwnedByTheOtherPolicyType(t *testing.T) {
|
||||
pointItem, err := policyModelFromRequest("fami", 1, policyRequest{
|
||||
Name: "Permanent point policy", PolicyType: policyTypePointDiamond, RegionID: 25, CycleKey: "2026-07",
|
||||
SettlementMode: "malicious-mode", SettlementTriggerMode: "malicious-trigger", ResidualDiamondToUSDRate: "not-a-number",
|
||||
PointDiamondsPerUSD: 300000, CoinsPerUSD: 80000, MinimumWithdrawUSDMinor: 100, WithdrawFeeBPS: 333, AgencyPointShareBPS: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("POINT_DIAMOND must ignore hidden salary fields: %v", err)
|
||||
}
|
||||
if pointItem.SettlementMode != settlementModeDaily || pointItem.SettlementTriggerMode != settlementTriggerAutomatic || pointItem.ResidualDiamondToUSDRate != "0.000000000000" {
|
||||
t.Fatalf("POINT_DIAMOND salary fields were not normalized to neutral values: %+v", pointItem)
|
||||
}
|
||||
|
||||
salaryItem, err := policyModelFromRequest("lalu", 1, policyRequest{
|
||||
Name: "Salary policy", PolicyType: policyTypeSalaryDiamond, RegionID: 25, CycleKey: "2026-07",
|
||||
SettlementMode: settlementModeHalfMonth, SettlementTriggerMode: settlementTriggerManual, ResidualDiamondToUSDRate: "0.01",
|
||||
PointDiamondsPerUSD: -1, CoinsPerUSD: -2, MinimumWithdrawUSDMinor: -3, WithdrawFeeBPS: -4, AgencyPointShareBPS: -5,
|
||||
Levels: []levelRequest{{Level: 1, RequiredDiamonds: 100, HostSalaryUSD: "1", AgencySalaryUSD: "0.2"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SALARY_DIAMOND must ignore hidden point fields: %v", err)
|
||||
}
|
||||
if salaryItem.PointDiamondsPerUSD != 0 || salaryItem.CoinsPerUSD != 0 || salaryItem.MinimumWithdrawUSDMinor != 0 || salaryItem.WithdrawFeeBPS != 0 || salaryItem.AgencyPointShareBPS != 0 {
|
||||
t.Fatalf("SALARY_DIAMOND point fields were not cleared: %+v", salaryItem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyModelFromRequestRejectsMissingExplicitCycle(t *testing.T) {
|
||||
_, err := policyModelFromRequest("lalu", 1, policyRequest{
|
||||
Name: "Legacy cycle",
|
||||
@ -206,7 +233,8 @@ func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t
|
||||
}
|
||||
item := model.HostAgencySalaryPolicy{
|
||||
ID: 81, AppCode: "fami", Name: "August policy", RegionID: 25, CycleKey: "2026-08", PolicyVersion: 81,
|
||||
Status: policyStatusActive, SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
|
||||
PolicyType: policyTypeSalaryDiamond,
|
||||
Status: policyStatusActive, SettlementMode: settlementModeDaily, SettlementTriggerMode: settlementTriggerAutomatic,
|
||||
GiftCoinToDiamondRatio: "1.000000", ResidualDiamondToUSDRate: "0.000000000000",
|
||||
CoinSellerWithdrawalLimitPeriod: "week", CoinSellerWithdrawalLimitCount: 2,
|
||||
PlatformWithdrawalLimitPeriod: "day", PlatformWithdrawalLimitCount: 1,
|
||||
@ -215,9 +243,10 @@ func TestEnsureRuntimeHostSalaryPolicySnapshotAcceptsIdenticalExistingSnapshot(t
|
||||
}
|
||||
mock.ExpectExec("INSERT INTO host_agency_salary_policies").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectQuery("SELECT cycle_key, policy_version").WithArgs("fami", uint(81)).WillReturnRows(sqlmock.NewRows([]string{
|
||||
"cycle_key", "policy_version", "name", "region_id", "status", "settlement_mode", "settlement_trigger_mode",
|
||||
"gift_ratio", "residual_rate", "coin_period", "coin_count", "platform_period", "platform_count", "platform_allowed_days",
|
||||
}).AddRow("2026-08", 81, "August policy", 25, "active", "daily", "automatic", "1.000000", "0.000000000000", "week", 2, "day", 1, "15,30"))
|
||||
"cycle_key", "policy_version", "name", "policy_type", "region_id", "status", "settlement_mode", "settlement_trigger_mode",
|
||||
"gift_ratio", "points_per_usd", "coins_per_usd", "minimum_usd_minor", "fee_bps", "agency_bps", "residual_rate",
|
||||
"coin_period", "coin_count", "platform_period", "platform_count", "platform_allowed_days",
|
||||
}).AddRow("2026-08", 81, "August policy", "SALARY_DIAMOND", 25, "active", "daily", "automatic", "1.000000", 0, 0, 0, 0, 0, "0.000000000000", "week", 2, "day", 1, "15,30"))
|
||||
mock.ExpectQuery("SELECT level_no, required_diamonds").WithArgs("fami", uint(81)).WillReturnRows(sqlmock.NewRows([]string{
|
||||
"level_no", "required_diamonds", "host_salary_usd_minor", "host_coin_reward", "agency_salary_usd_minor", "status", "sort_order",
|
||||
}).AddRow(1, 100, 150, 20, 50, "active", 1))
|
||||
@ -272,3 +301,12 @@ func TestPublishFailureUpdateUsesNotPublishedCAS(t *testing.T) {
|
||||
t.Fatalf("failure update must retain the published guard: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyTypeForUpdatePreservesPointDraftWhenFieldIsOmitted(t *testing.T) {
|
||||
if got := policyTypeForUpdate("", policyTypePointDiamond); got != policyTypePointDiamond {
|
||||
t.Fatalf("omitted policy_type must preserve POINT_DIAMOND draft, got %q", got)
|
||||
}
|
||||
if got := policyTypeForUpdate(policyTypeSalaryDiamond, policyTypePointDiamond); got != policyTypeSalaryDiamond {
|
||||
t.Fatalf("explicit draft type change must remain visible to validation, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ const (
|
||||
settlementRoleHost = "host"
|
||||
settlementRoleAgency = "agency"
|
||||
statusPending = "pending"
|
||||
policyTypeSalaryDiamond = "SALARY_DIAMOND"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@ -57,6 +58,7 @@ type hostPolicy struct {
|
||||
CycleKey string
|
||||
PolicyVersion uint64
|
||||
Name string
|
||||
PolicyType string
|
||||
RegionID int64
|
||||
SettlementMode string
|
||||
TriggerMode string
|
||||
@ -257,6 +259,8 @@ func (s *Service) collectPendingCandidates(ctx context.Context, appCode string,
|
||||
userClause = "AND a.user_id = ?"
|
||||
args = append(args, req.UserID)
|
||||
}
|
||||
// 工资候选必须由工资型政策绑定产生;POINT_DIAMOND 不清空、不结算工资,不能出现在人工结算页。
|
||||
args = append(args, policyTypeSalaryDiamond)
|
||||
triggerClause := ""
|
||||
if req.TriggerMode != "" {
|
||||
triggerClause = "AND p.settlement_trigger_mode = ?"
|
||||
@ -294,6 +298,7 @@ func (s *Service) collectPendingCandidates(ctx context.Context, appCode string,
|
||||
AND binding.region_id = a.region_id
|
||||
AND binding.cycle_key = a.cycle_key
|
||||
AND p.status = 'active'
|
||||
AND p.policy_type = ?
|
||||
%s
|
||||
%s
|
||||
)
|
||||
@ -532,7 +537,7 @@ func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID in
|
||||
args = append(args, settlementType)
|
||||
}
|
||||
row := s.walletDB.QueryRowContext(ctx, `
|
||||
SELECT p.policy_id, binding.cycle_key, p.policy_version, p.name, binding.region_id,
|
||||
SELECT p.policy_id, binding.cycle_key, p.policy_version, p.name, p.policy_type, binding.region_id,
|
||||
p.settlement_mode, p.settlement_trigger_mode, CAST(p.residual_diamond_to_usd_rate AS CHAR)
|
||||
FROM host_salary_policy_cycle_bindings binding
|
||||
JOIN host_agency_salary_policies p
|
||||
@ -542,12 +547,15 @@ func (s *Service) resolvePolicy(ctx context.Context, appCode string, regionID in
|
||||
WHERE `+strings.Join(conditions, " AND ")+`
|
||||
LIMIT 1`, args...)
|
||||
var policy hostPolicy
|
||||
if err := row.Scan(&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.RegionID, &policy.SettlementMode, &policy.TriggerMode, &policy.ResidualDiamondToUSDRate); err != nil {
|
||||
if err := row.Scan(&policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.PolicyType, &policy.RegionID, &policy.SettlementMode, &policy.TriggerMode, &policy.ResidualDiamondToUSDRate); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return hostPolicy{}, false, nil
|
||||
}
|
||||
return hostPolicy{}, false, err
|
||||
}
|
||||
if strings.TrimSpace(policy.PolicyType) != policyTypeSalaryDiamond {
|
||||
return hostPolicy{}, false, fmt.Errorf("policy type %s is not eligible for salary settlement", policy.PolicyType)
|
||||
}
|
||||
levels, err := s.policyLevels(ctx, appCode, policy.PolicyID)
|
||||
if err != nil {
|
||||
return hostPolicy{}, false, err
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
@ -19,8 +18,8 @@ type Handler struct {
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, activity activityclient.Client, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(adminDB, walletDB, userDB, activity), audit: audit}
|
||||
func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(adminDB, walletDB, userDB), audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) ListTemplates(c *gin.Context) {
|
||||
@ -32,7 +31,7 @@ func (h *Handler) ListTemplates(c *gin.Context) {
|
||||
PageSize: options.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取收益政策模板失败")
|
||||
response.ServerError(c, "获取 POINT 钱包政策失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
|
||||
@ -41,7 +40,7 @@ func (h *Handler) ListTemplates(c *gin.Context) {
|
||||
func (h *Handler) CreateTemplate(c *gin.Context) {
|
||||
var req templateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "收益政策模板参数不正确")
|
||||
response.BadRequest(c, "POINT 钱包政策参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateTemplate(c.Request.Context(), shared.ActorFromContext(c).UserID, req)
|
||||
@ -62,7 +61,7 @@ func (h *Handler) ListInstances(c *gin.Context) {
|
||||
PageSize: options.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取收益政策实例失败")
|
||||
response.ServerError(c, "获取 POINT 钱包政策实例失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
|
||||
@ -71,7 +70,7 @@ func (h *Handler) ListInstances(c *gin.Context) {
|
||||
func (h *Handler) CreateInstance(c *gin.Context) {
|
||||
var req instanceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "收益政策实例参数不正确")
|
||||
response.BadRequest(c, "POINT 钱包政策实例参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateInstance(c.Request.Context(), appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, req)
|
||||
@ -104,7 +103,7 @@ func (h *Handler) UpdateInstanceStatus(c *gin.Context) {
|
||||
}
|
||||
var req statusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "收益政策实例状态参数不正确")
|
||||
response.BadRequest(c, "POINT 钱包政策实例状态参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateInstanceStatus(c.Request.Context(), appctx.FromContext(c.Request.Context()), shared.ActorFromContext(c).UserID, instanceID, req.Status)
|
||||
|
||||
@ -5,15 +5,12 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -23,21 +20,12 @@ const (
|
||||
publishStatusDraft = "draft"
|
||||
publishStatusPublished = "published"
|
||||
publishStatusFailed = "failed"
|
||||
|
||||
policyTaskRewardAssetCoin = "COIN"
|
||||
|
||||
agencyShareBaseChargeAmount = "charge_amount"
|
||||
agencyShareBaseHostIncome = "host_income"
|
||||
|
||||
defaultPolicyTemplateCode = "first_google70000_coin_seller_92000_100000_v1"
|
||||
defaultPolicyTemplateVersion = "v1"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
adminDB *sql.DB
|
||||
walletDB *sql.DB
|
||||
userDB *sql.DB
|
||||
activity activityclient.Client
|
||||
}
|
||||
|
||||
type listTemplatesQuery struct {
|
||||
@ -67,18 +55,14 @@ type policyInstanceRow struct {
|
||||
}
|
||||
|
||||
type compiledPolicy struct {
|
||||
HostPointRatioPPM int64
|
||||
HostUseGiftTypeRatio bool
|
||||
AgencyPointRatioPPM int64
|
||||
AgencyShareBase string
|
||||
PointsPerUSD int64
|
||||
WithdrawFeeBPS int64
|
||||
TaskRewardAssetType string
|
||||
RuleJSON json.RawMessage
|
||||
PointsPerUSD int64
|
||||
MinimumPoints int64
|
||||
WithdrawFeeBPS int64
|
||||
RuleJSON json.RawMessage
|
||||
}
|
||||
|
||||
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, activity activityclient.Client) *Service {
|
||||
return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB, activity: activity}
|
||||
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB) *Service {
|
||||
return &Service{adminDB: adminDB, walletDB: walletDB, userDB: userDB}
|
||||
}
|
||||
|
||||
func (s *Service) ListTemplates(ctx context.Context, query listTemplatesQuery) ([]templateDTO, int64, error) {
|
||||
@ -253,14 +237,6 @@ func (s *Service) UpdateInstanceStatus(ctx context.Context, appCode string, acto
|
||||
if err != nil {
|
||||
return instanceDTO{}, err
|
||||
}
|
||||
ruleJSON, err := s.getTemplateRuleJSONSnapshot(ctx, instance.TemplateCode, instance.TemplateVersion)
|
||||
if err != nil {
|
||||
return instanceDTO{}, err
|
||||
}
|
||||
compiled, err := compilePolicy(ruleJSON)
|
||||
if err != nil {
|
||||
return instanceDTO{}, err
|
||||
}
|
||||
result, err := s.adminDB.ExecContext(ctx, `
|
||||
UPDATE admin_policy_instances
|
||||
SET status = ?, updated_by_admin_id = ?, updated_at_ms = ?
|
||||
@ -281,14 +257,11 @@ func (s *Service) UpdateInstanceStatus(ctx context.Context, appCode string, acto
|
||||
if err := s.syncWalletRuntimeStatus(ctx, instance, status, nowMS); err != nil {
|
||||
return instanceDTO{}, err
|
||||
}
|
||||
if err := s.publishActivityRuntime(ctx, instance, compiled, actorID, nowMS); err != nil {
|
||||
return instanceDTO{}, err
|
||||
}
|
||||
return s.getInstanceDTO(ctx, appCode, instanceID)
|
||||
}
|
||||
|
||||
func (s *Service) PublishInstance(ctx context.Context, appCode string, actorID uint, instanceID uint64) (publishDTO, error) {
|
||||
if s == nil || s.adminDB == nil || s.walletDB == nil || s.userDB == nil || s.activity == nil {
|
||||
if s == nil || s.adminDB == nil || s.walletDB == nil || s.userDB == nil {
|
||||
return publishDTO{}, errors.New("policy publish database is not configured")
|
||||
}
|
||||
appCode = appctx.Normalize(appCode)
|
||||
@ -312,7 +285,8 @@ func (s *Service) PublishInstance(ctx context.Context, appCode string, actorID u
|
||||
return publishDTO{}, err
|
||||
}
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
targetCount := len(regionIDs) + 1
|
||||
// 每日任务资产由 task definition 显式保存;通用政策只发布 wallet 目标,不能恢复 App 级任务默认值。
|
||||
targetCount := len(regionIDs)
|
||||
jobID, err := s.createPublishJob(ctx, instance, actorID, targetCount, nowMS)
|
||||
if err != nil {
|
||||
return publishDTO{}, err
|
||||
@ -322,12 +296,7 @@ func (s *Service) PublishInstance(ctx context.Context, appCode string, actorID u
|
||||
_ = s.markInstancePublishFailed(ctx, instance.AppCode, instance.InstanceID, err.Error(), nowMS)
|
||||
return publishDTO{}, err
|
||||
}
|
||||
if err := s.publishActivityRuntime(ctx, instance, compiled, actorID, nowMS); err != nil {
|
||||
_ = s.finishPublishJob(ctx, jobID, publishStatusFailed, targetCount, len(regionIDs), 1, err.Error(), nowMS)
|
||||
_ = s.markInstancePublishFailed(ctx, instance.AppCode, instance.InstanceID, err.Error(), nowMS)
|
||||
return publishDTO{}, err
|
||||
}
|
||||
if err := s.insertPublishItems(ctx, jobID, instance.AppCode, regionIDs, true, nowMS); err != nil {
|
||||
if err := s.insertPublishItems(ctx, jobID, instance.AppCode, regionIDs, nowMS); err != nil {
|
||||
_ = s.finishPublishJob(ctx, jobID, publishStatusFailed, targetCount, targetCount, 0, err.Error(), nowMS)
|
||||
_ = s.markInstancePublishFailed(ctx, instance.AppCode, instance.InstanceID, err.Error(), nowMS)
|
||||
return publishDTO{}, err
|
||||
@ -368,7 +337,7 @@ func (s *Service) getTemplate(ctx context.Context, templateCode string, version
|
||||
)
|
||||
item, err := scanTemplate(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return templateDTO{}, errors.New("policy template version not found")
|
||||
return templateDTO{}, errors.New("POINT wallet policy version not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
@ -382,23 +351,7 @@ func (s *Service) getTemplateRuleJSON(ctx context.Context, templateCode string,
|
||||
templateCode, version, policyStatusActive,
|
||||
).Scan(&raw); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("active policy template version not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return normalizeRuleJSON(json.RawMessage(raw))
|
||||
}
|
||||
|
||||
func (s *Service) getTemplateRuleJSONSnapshot(ctx context.Context, templateCode string, version string) (json.RawMessage, error) {
|
||||
var raw string
|
||||
if err := s.adminDB.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(CAST(rule_json AS CHAR), '{}')
|
||||
FROM admin_policy_template_versions
|
||||
WHERE template_code = ? AND template_version = ?`,
|
||||
templateCode, version,
|
||||
).Scan(&raw); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("policy template version not found")
|
||||
return nil, errors.New("active POINT wallet policy version not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@ -508,8 +461,8 @@ func (s *Service) publishWalletRuntime(ctx context.Context, item policyInstanceR
|
||||
published_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'POINT', ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?)`,
|
||||
item.AppCode, item.InstanceCode, item.TemplateCode, item.TemplateVersion, regionID, item.Status,
|
||||
item.EffectiveFromMS, item.EffectiveToMS, compiled.HostPointRatioPPM, compiled.AgencyPointRatioPPM, compiled.PointsPerUSD,
|
||||
compiled.WithdrawFeeBPS, string(compiled.RuleJSON), actorID, nowMS, nowMS, nowMS,
|
||||
item.EffectiveFromMS, item.EffectiveToMS, int64(0), int64(0),
|
||||
compiled.PointsPerUSD, compiled.WithdrawFeeBPS, string(compiled.RuleJSON), actorID, nowMS, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -534,29 +487,6 @@ func (s *Service) syncWalletRuntimeStatus(ctx context.Context, item policyInstan
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) publishActivityRuntime(ctx context.Context, item policyInstanceRow, compiled compiledPolicy, actorID uint, nowMS int64) error {
|
||||
if s.activity == nil {
|
||||
return nil
|
||||
}
|
||||
// activity 目前只需要 App 级任务奖励默认资产;具体任务定义仍由 activity-service 自己持久化和快照。
|
||||
_, err := s.activity.PublishTaskRewardPolicy(ctx, &activityv1.PublishTaskRewardPolicyRequest{
|
||||
Meta: &activityv1.RequestMeta{
|
||||
Caller: "admin-server",
|
||||
AppCode: item.AppCode,
|
||||
SentAtMs: nowMS,
|
||||
},
|
||||
InstanceCode: item.InstanceCode,
|
||||
TemplateCode: item.TemplateCode,
|
||||
TemplateVersion: item.TemplateVersion,
|
||||
Status: item.Status,
|
||||
RewardAssetType: compiled.TaskRewardAssetType,
|
||||
RuleJson: string(compiled.RuleJSON),
|
||||
OperatorAdminId: int64(actorID),
|
||||
PublishedAtMs: nowMS,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS wallet_policy_instances (
|
||||
@ -568,9 +498,9 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||||
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'UTC epoch ms',
|
||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
|
||||
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
||||
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm',
|
||||
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定,ppm',
|
||||
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '兼容既有表结构;新礼物不读取',
|
||||
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容既有表结构;新礼物不读取',
|
||||
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容既有表结构;新礼物不读取',
|
||||
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
|
||||
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
||||
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
||||
@ -580,12 +510,11 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, instance_code, region_id),
|
||||
KEY idx_wallet_policy_active (app_code, region_id, status, effective_from_ms, effective_to_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包运行侧收益政策实例表'`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包运行侧政策实例表'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 运行库可能是旧版本建表;幂等补列保证发布新策略时不会因列缺失中断。
|
||||
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定,ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
|
||||
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '兼容既有表结构;新礼物不读取' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@ -596,7 +525,7 @@ func isDuplicateColumnError(err error) bool {
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
|
||||
}
|
||||
|
||||
func (s *Service) insertPublishItems(ctx context.Context, jobID uint64, appCode string, regionIDs []int64, activitySucceeded bool, nowMS int64) error {
|
||||
func (s *Service) insertPublishItems(ctx context.Context, jobID uint64, appCode string, regionIDs []int64, nowMS int64) error {
|
||||
tx, err := s.adminDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -612,18 +541,6 @@ func (s *Service) insertPublishItems(ctx context.Context, jobID uint64, appCode
|
||||
return err
|
||||
}
|
||||
}
|
||||
activityStatus := "failed"
|
||||
if activitySucceeded {
|
||||
activityStatus = "succeeded"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO admin_policy_publish_items (
|
||||
job_id, owner_service, app_code, region_id, status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, 'activity', ?, 0, ?, ?, ?)`,
|
||||
jobID, appCode, activityStatus, nowMS, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@ -648,89 +565,47 @@ func (s *Service) markInstancePublishFailed(ctx context.Context, appCode string,
|
||||
}
|
||||
|
||||
func compilePolicy(raw json.RawMessage) (compiledPolicy, error) {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
normalized, err := normalizeRuleJSON(raw)
|
||||
if err != nil {
|
||||
return compiledPolicy{}, errors.New("rule_json is invalid")
|
||||
}
|
||||
pointsPerUSD := int64FromJSON(decoded["points_per_usd"], 100000)
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(normalized, &decoded); err != nil {
|
||||
return compiledPolicy{}, errors.New("rule_json is invalid")
|
||||
}
|
||||
pointsPerUSD, err := requiredInt64FromJSON(decoded, "points_per_usd")
|
||||
if err != nil || pointsPerUSD <= 0 {
|
||||
return compiledPolicy{}, errors.New("rule_json points_per_usd must be a positive integer")
|
||||
}
|
||||
host, _ := decoded["host"].(map[string]any)
|
||||
hostRatioPercent := floatFromJSON(host["point_ratio_percent"], 70)
|
||||
// 礼物类型倍率是 wallet 解释完整 rule_json 时使用的开关;Admin 只负责拒绝字符串/数字等歧义配置。
|
||||
hostUseGiftTypeRatio, err := optionalBoolFromJSON(host, "use_gift_type_ratio", false)
|
||||
if err != nil {
|
||||
return compiledPolicy{}, errors.New("rule_json host use_gift_type_ratio must be boolean")
|
||||
minimumPoints, err := requiredInt64FromJSON(host, "minimum_withdraw_points")
|
||||
if err != nil || minimumPoints <= 0 {
|
||||
return compiledPolicy{}, errors.New("rule_json host minimum_withdraw_points must be a positive integer")
|
||||
}
|
||||
agency, _ := decoded["agency"].(map[string]any)
|
||||
// Agency 比例由跨 App 共用的策略模板显式配置;旧模板缺字段时保持 0,避免发布动作隐式改变存量 App 账务。
|
||||
agencyRatioPercent := floatFromJSON(agency["point_ratio_percent"], 0)
|
||||
// charge_amount 延续旧策略的守恒分账;host_income 是平台在主播实际 POINT 收益之上追加,不能套用二者合计不超过 100% 的限制。
|
||||
agencyShareBase, err := agencyShareBaseFromJSON(agency)
|
||||
if err != nil {
|
||||
return compiledPolicy{}, err
|
||||
}
|
||||
withdrawFeeBPS := int64FromJSON(host["withdraw_fee_bps"], 500)
|
||||
ratioPPM := int64(math.Round(hostRatioPercent * 10000))
|
||||
agencyRatioPPM := int64(math.Round(agencyRatioPercent * 10000))
|
||||
tasks, _ := decoded["tasks"].(map[string]any)
|
||||
taskRewardAssetType := strings.ToUpper(strings.TrimSpace(stringFromJSON(tasks["reward_asset_type"], policyTaskRewardAssetCoin)))
|
||||
if pointsPerUSD <= 0 || ratioPPM <= 0 || ratioPPM > 1000000 || agencyRatioPPM < 0 || agencyRatioPPM > 1000000 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10000 {
|
||||
return compiledPolicy{}, errors.New("rule_json host point policy is invalid")
|
||||
}
|
||||
if agencyShareBase == agencyShareBaseChargeAmount && ratioPPM+agencyRatioPPM > 1000000 {
|
||||
return compiledPolicy{}, errors.New("rule_json host point policy is invalid")
|
||||
}
|
||||
if taskRewardAssetType != policyTaskRewardAssetCoin && taskRewardAssetType != "POINT" {
|
||||
return compiledPolicy{}, errors.New("rule_json task reward asset_type is invalid")
|
||||
withdrawFeeBPS, err := requiredInt64FromJSON(host, "withdraw_fee_bps")
|
||||
if err != nil || withdrawFeeBPS < 0 || withdrawFeeBPS > 10_000 {
|
||||
return compiledPolicy{}, errors.New("rule_json host withdraw_fee_bps must be an integer between 0 and 10000")
|
||||
}
|
||||
return compiledPolicy{
|
||||
HostPointRatioPPM: ratioPPM,
|
||||
HostUseGiftTypeRatio: hostUseGiftTypeRatio,
|
||||
AgencyPointRatioPPM: agencyRatioPPM,
|
||||
AgencyShareBase: agencyShareBase,
|
||||
PointsPerUSD: pointsPerUSD,
|
||||
WithdrawFeeBPS: withdrawFeeBPS,
|
||||
TaskRewardAssetType: taskRewardAssetType,
|
||||
// 不为编译字段增设运行表列;owner service 必须收到完整规则快照,才能按礼物类型和收益基数解释账务。
|
||||
RuleJSON: raw,
|
||||
PointsPerUSD: pointsPerUSD,
|
||||
MinimumPoints: minimumPoints,
|
||||
WithdrawFeeBPS: withdrawFeeBPS,
|
||||
RuleJSON: normalized,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func agencyShareBaseFromJSON(agency map[string]any) (string, error) {
|
||||
value, exists := agency["share_base"]
|
||||
if !exists {
|
||||
return agencyShareBaseChargeAmount, nil
|
||||
}
|
||||
shareBase, ok := value.(string)
|
||||
if !ok {
|
||||
return "", errors.New("rule_json agency share_base is invalid")
|
||||
}
|
||||
shareBase = strings.ToLower(strings.TrimSpace(shareBase))
|
||||
switch shareBase {
|
||||
case agencyShareBaseChargeAmount, agencyShareBaseHostIncome:
|
||||
return shareBase, nil
|
||||
default:
|
||||
return "", errors.New("rule_json agency share_base is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func optionalBoolFromJSON(object map[string]any, key string, fallback bool) (bool, error) {
|
||||
value, exists := object[key]
|
||||
if !exists {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, ok := value.(bool)
|
||||
if !ok {
|
||||
return false, errors.New("JSON value is not boolean")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func scanTemplate(row interface{ Scan(dest ...any) error }) (templateDTO, error) {
|
||||
var item templateDTO
|
||||
var rule string
|
||||
err := row.Scan(&item.TemplateID, &item.VersionID, &item.TemplateCode, &item.TemplateVersion, &item.Name, &item.Status, &rule, &item.Description, &item.CreatedAtMS, &item.UpdatedAtMS)
|
||||
item.RuleJSON = json.RawMessage(rule)
|
||||
return item, err
|
||||
if err := row.Scan(&item.TemplateID, &item.VersionID, &item.TemplateCode, &item.TemplateVersion, &item.Name, &item.Status, &rule, &item.Description, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
||||
return templateDTO{}, err
|
||||
}
|
||||
normalized, err := normalizeRuleJSON(json.RawMessage(rule))
|
||||
if err != nil {
|
||||
return templateDTO{}, err
|
||||
}
|
||||
item.RuleJSON = normalized
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanInstanceDTO(row interface{ Scan(dest ...any) error }) (instanceDTO, error) {
|
||||
@ -829,61 +704,50 @@ func normalizeRuleJSON(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil || decoded == nil {
|
||||
return nil, errors.New("rule_json must be a JSON object")
|
||||
}
|
||||
normalized, err := json.Marshal(decoded)
|
||||
// wallet owner 当前只消费 POINT/USD、最低提现 POINT 和提现手续费。保存、列表回显和发布都使用
|
||||
// 同一白名单,确保旧模板或任意扩展 JSON 不能重新制造“后台保存成功但业务不生效”的幽灵配置。
|
||||
normalizedRule := make(map[string]any, 2)
|
||||
if pointsPerUSD, exists := decoded["points_per_usd"]; exists {
|
||||
normalizedRule["points_per_usd"] = pointsPerUSD
|
||||
}
|
||||
if sourceHost, ok := decoded["host"].(map[string]any); ok {
|
||||
host := make(map[string]any, 2)
|
||||
if minimumPoints, exists := sourceHost["minimum_withdraw_points"]; exists {
|
||||
host["minimum_withdraw_points"] = minimumPoints
|
||||
}
|
||||
if withdrawFeeBPS, exists := sourceHost["withdraw_fee_bps"]; exists {
|
||||
host["withdraw_fee_bps"] = withdrawFeeBPS
|
||||
}
|
||||
if len(host) > 0 {
|
||||
normalizedRule["host"] = host
|
||||
}
|
||||
}
|
||||
normalized, err := json.Marshal(normalizedRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(normalized), nil
|
||||
}
|
||||
|
||||
func int64FromJSON(value any, fallback int64) int64 {
|
||||
func requiredInt64FromJSON(object map[string]any, key string) (int64, error) {
|
||||
value, exists := object[key]
|
||||
if !exists {
|
||||
return 0, errors.New("required JSON value is missing")
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed > 0 {
|
||||
return int64(typed)
|
||||
parsed := int64(typed)
|
||||
if typed != float64(parsed) {
|
||||
return 0, errors.New("JSON value is not an integer")
|
||||
}
|
||||
return parsed, nil
|
||||
case json.Number:
|
||||
if parsed, err := typed.Int64(); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
return typed.Int64()
|
||||
case string:
|
||||
if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
return strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
default:
|
||||
return 0, errors.New("JSON value is not an integer")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func floatFromJSON(value any, fallback float64) float64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed > 0 {
|
||||
return typed
|
||||
}
|
||||
case json.Number:
|
||||
if parsed, err := typed.Float64(); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
case string:
|
||||
if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func stringFromJSON(value any, fallback string) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
return typed
|
||||
}
|
||||
case json.Number:
|
||||
if strings.TrimSpace(typed.String()) != "" {
|
||||
return typed.String()
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func truncate(value string, limit int) string {
|
||||
@ -893,8 +757,3 @@ func truncate(value string, limit int) string {
|
||||
}
|
||||
return value[:limit]
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = defaultPolicyTemplateCode
|
||||
_ = defaultPolicyTemplateVersion
|
||||
}
|
||||
|
||||
@ -4,17 +4,20 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestGetTemplateJoinsVersionByTemplateCodeAndVersion(t *testing.T) {
|
||||
adminDB, mock, closeDB := newPolicySQLMock(t)
|
||||
defer closeDB()
|
||||
svc := NewService(adminDB, nil, nil, nil)
|
||||
svc := NewService(adminDB, nil, nil)
|
||||
|
||||
mock.ExpectQuery(`(?s)JOIN admin_policy_template_versions v ON v\.template_code = t\.template_code AND v\.template_version = t\.template_version.*WHERE t\.template_code = \? AND v\.template_version = \?`).
|
||||
WithArgs("huwaa_point_policy", "v2").
|
||||
@ -29,7 +32,7 @@ func TestGetTemplateJoinsVersionByTemplateCodeAndVersion(t *testing.T) {
|
||||
"description",
|
||||
"created_at_ms",
|
||||
"updated_at_ms",
|
||||
}).AddRow(1, 22, "huwaa_point_policy", "v2", "Huwaa Point Policy", "active", `{"tasks":{"reward_asset_type":"POINT"}}`, "version scoped", int64(1700000000000), int64(1700000001000)))
|
||||
}).AddRow(1, 22, "huwaa_point_policy", "v2", "Huwaa Point Policy", "active", `{"host":{"point_ratio_percent":70},"tasks":{"reward_asset_type":"POINT"}}`, "version scoped", int64(1700000000000), int64(1700000001000)))
|
||||
|
||||
got, err := svc.getTemplate(context.Background(), "huwaa_point_policy", "v2")
|
||||
if err != nil {
|
||||
@ -38,16 +41,18 @@ func TestGetTemplateJoinsVersionByTemplateCodeAndVersion(t *testing.T) {
|
||||
if got.TemplateCode != "huwaa_point_policy" || got.TemplateVersion != "v2" || got.VersionID != 22 {
|
||||
t.Fatalf("template version join result mismatch: %+v", got)
|
||||
}
|
||||
if string(got.RuleJSON) != `{}` {
|
||||
t.Fatalf("template DTO must hide retired direct-gift and task-default fields: %s", got.RuleJSON)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("admin sql expectations mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRuntimeWritesWalletRowsAndActivityTaskRewardPolicy(t *testing.T) {
|
||||
func TestPublishRuntimeWritesWalletRows(t *testing.T) {
|
||||
walletDB, walletMock, closeWallet := newPolicySQLMock(t)
|
||||
defer closeWallet()
|
||||
activity := &fakePolicyActivityClient{}
|
||||
svc := NewService(nil, walletDB, nil, activity)
|
||||
svc := NewService(nil, walletDB, nil)
|
||||
ctx := context.Background()
|
||||
nowMS := int64(1700000005000)
|
||||
item := policyInstanceRow{
|
||||
@ -60,12 +65,10 @@ func TestPublishRuntimeWritesWalletRowsAndActivityTaskRewardPolicy(t *testing.T)
|
||||
EffectiveToMS: 0,
|
||||
}
|
||||
compiled := compiledPolicy{
|
||||
HostPointRatioPPM: 700000,
|
||||
AgencyPointRatioPPM: 0,
|
||||
PointsPerUSD: 100000,
|
||||
WithdrawFeeBPS: 500,
|
||||
TaskRewardAssetType: "POINT",
|
||||
RuleJSON: []byte(`{"tasks":{"reward_asset_type":"POINT"},"host":{"point_ratio_percent":70}}`),
|
||||
PointsPerUSD: 100000,
|
||||
MinimumPoints: 1000000,
|
||||
WithdrawFeeBPS: 500,
|
||||
RuleJSON: json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":1000000,"withdraw_fee_bps":500}}`),
|
||||
}
|
||||
|
||||
walletMock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS wallet_policy_instances`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
@ -85,7 +88,7 @@ func TestPublishRuntimeWritesWalletRowsAndActivityTaskRewardPolicy(t *testing.T)
|
||||
policyStatusActive,
|
||||
int64(1700000000000),
|
||||
int64(0),
|
||||
int64(700000),
|
||||
int64(0),
|
||||
int64(0),
|
||||
int64(100000),
|
||||
int64(500),
|
||||
@ -102,34 +105,40 @@ func TestPublishRuntimeWritesWalletRowsAndActivityTaskRewardPolicy(t *testing.T)
|
||||
if err := svc.publishWalletRuntime(ctx, item, compiled, []int64{0, 12}, 90001, nowMS); err != nil {
|
||||
t.Fatalf("publishWalletRuntime failed: %v", err)
|
||||
}
|
||||
if err := svc.publishActivityRuntime(ctx, item, compiled, 90001, nowMS); err != nil {
|
||||
t.Fatalf("publishActivityRuntime failed: %v", err)
|
||||
}
|
||||
if activity.last == nil ||
|
||||
activity.last.GetMeta().GetAppCode() != "huwaa" ||
|
||||
activity.last.GetInstanceCode() != "huwaa-point-live" ||
|
||||
activity.last.GetTemplateCode() != "huwaa_point_policy" ||
|
||||
activity.last.GetTemplateVersion() != "v2" ||
|
||||
activity.last.GetStatus() != policyStatusActive ||
|
||||
activity.last.GetRewardAssetType() != "POINT" ||
|
||||
activity.last.GetOperatorAdminId() != 90001 ||
|
||||
activity.last.GetPublishedAtMs() != nowMS {
|
||||
t.Fatalf("activity task reward policy payload mismatch: %+v", activity.last)
|
||||
}
|
||||
if err := walletMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("wallet sql expectations mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInstanceStatusSyncsWalletRuntimeAndActivityRuntime(t *testing.T) {
|
||||
func TestInsertPublishItemsRecordsWalletTargetsOnly(t *testing.T) {
|
||||
adminDB, mock, closeDB := newPolicySQLMock(t)
|
||||
defer closeDB()
|
||||
svc := NewService(adminDB, nil, nil)
|
||||
nowMS := int64(1700000005000)
|
||||
|
||||
mock.ExpectBegin()
|
||||
for _, regionID := range []int64{0, 12} {
|
||||
mock.ExpectExec(`(?s)INSERT INTO admin_policy_publish_items.*VALUES \(\?, 'wallet', \?, \?, 'succeeded', \?, \?\)`).
|
||||
WithArgs(uint64(77), "configured-app", regionID, nowMS, nowMS).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
}
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := svc.insertPublishItems(context.Background(), 77, "configured-app", []int64{0, 12}, nowMS); err != nil {
|
||||
t.Fatalf("insert wallet-only publish items failed: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("publish items must not include the retired App-global activity target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInstanceStatusSyncsWalletRuntime(t *testing.T) {
|
||||
adminDB, adminMock, closeAdmin := newPolicySQLMock(t)
|
||||
defer closeAdmin()
|
||||
walletDB, walletMock, closeWallet := newPolicySQLMock(t)
|
||||
defer closeWallet()
|
||||
activity := &fakePolicyActivityClient{}
|
||||
svc := NewService(adminDB, walletDB, nil, activity)
|
||||
svc := NewService(adminDB, walletDB, nil)
|
||||
ctx := context.Background()
|
||||
ruleJSON := `{"points_per_usd":100000,"host":{"point_ratio_percent":70,"withdraw_fee_bps":500},"tasks":{"reward_asset_type":"POINT"}}`
|
||||
|
||||
adminMock.ExpectQuery(`(?s)SELECT instance_id, app_code, instance_code, template_code, template_version, region_scope, status,\s+effective_from_ms, effective_to_ms\s+FROM admin_policy_instances\s+WHERE app_code = \? AND instance_id = \?`).
|
||||
WithArgs("huwaa", uint64(9)).
|
||||
@ -144,9 +153,6 @@ func TestUpdateInstanceStatusSyncsWalletRuntimeAndActivityRuntime(t *testing.T)
|
||||
"effective_from_ms",
|
||||
"effective_to_ms",
|
||||
}).AddRow(9, "huwaa", "huwaa-point-live", "huwaa_point_policy", "v2", "all_active_regions", policyStatusActive, int64(1700000000000), int64(0)))
|
||||
adminMock.ExpectQuery(`(?s)SELECT COALESCE\(CAST\(rule_json AS CHAR\), '\{\}'\)\s+FROM admin_policy_template_versions\s+WHERE template_code = \? AND template_version = \?`).
|
||||
WithArgs("huwaa_point_policy", "v2").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"rule_json"}).AddRow(ruleJSON))
|
||||
adminMock.ExpectExec(`(?s)UPDATE admin_policy_instances\s+SET status = \?, updated_by_admin_id = \?, updated_at_ms = \?\s+WHERE app_code = \? AND instance_id = \?`).
|
||||
WithArgs(policyStatusDisabled, uint(90002), sqlmock.AnyArg(), "huwaa", uint64(9)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@ -181,13 +187,6 @@ func TestUpdateInstanceStatusSyncsWalletRuntimeAndActivityRuntime(t *testing.T)
|
||||
if got.Status != policyStatusDisabled {
|
||||
t.Fatalf("instance status mismatch: %+v", got)
|
||||
}
|
||||
if activity.last == nil ||
|
||||
activity.last.GetMeta().GetAppCode() != "huwaa" ||
|
||||
activity.last.GetInstanceCode() != "huwaa-point-live" ||
|
||||
activity.last.GetStatus() != policyStatusDisabled ||
|
||||
activity.last.GetRewardAssetType() != "POINT" {
|
||||
t.Fatalf("activity status sync payload mismatch: %+v", activity.last)
|
||||
}
|
||||
if err := adminMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("admin sql expectations mismatch: %v", err)
|
||||
}
|
||||
@ -196,81 +195,263 @@ func TestUpdateInstanceStatusSyncsWalletRuntimeAndActivityRuntime(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePolicyUsesExplicitAgencyRatioAndKeepsLegacyDefaultZero(t *testing.T) {
|
||||
ruleJSON := json.RawMessage(`{"host":{"point_ratio_percent":70}}`)
|
||||
fami, err := compilePolicy(ruleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("compile Fami policy failed: %v", err)
|
||||
}
|
||||
if fami.AgencyPointRatioPPM != 0 {
|
||||
t.Fatalf("legacy policy without agency section must remain zero: %+v", fami)
|
||||
}
|
||||
if fami.AgencyShareBase != agencyShareBaseChargeAmount || fami.HostUseGiftTypeRatio {
|
||||
t.Fatalf("legacy policy must keep charge_amount base and disabled gift type ratio: %+v", fami)
|
||||
}
|
||||
configured, err := compilePolicy(json.RawMessage(`{"host":{"point_ratio_percent":70},"agency":{"point_ratio_percent":20}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("compile configured policy failed: %v", err)
|
||||
}
|
||||
if configured.AgencyPointRatioPPM != 200000 {
|
||||
t.Fatalf("configured agency ratio mismatch: %+v", configured)
|
||||
}
|
||||
if configured.AgencyShareBase != agencyShareBaseChargeAmount {
|
||||
t.Fatalf("missing agency share_base must use legacy charge_amount semantics: %+v", configured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePolicyRejectsChargeAmountOverAllocation(t *testing.T) {
|
||||
for name, ruleJSON := range map[string]json.RawMessage{
|
||||
"legacy default": json.RawMessage(`{"host":{"point_ratio_percent":100},"agency":{"point_ratio_percent":20}}`),
|
||||
"explicit base": json.RawMessage(`{"host":{"point_ratio_percent":100},"agency":{"point_ratio_percent":20,"share_base":"charge_amount"}}`),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := compilePolicy(ruleJSON); err == nil {
|
||||
t.Fatal("charge_amount policy must reject host and agency allocations above 100 percent")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePolicyAllowsHostIncomeBonusAndParsesFlags(t *testing.T) {
|
||||
ruleJSON := json.RawMessage(`{"host":{"point_ratio_percent":100,"use_gift_type_ratio":true},"agency":{"point_ratio_percent":20,"share_base":"host_income"}}`)
|
||||
func TestCompilePolicyRetainsOnlyPointFinancialFields(t *testing.T) {
|
||||
ruleJSON := json.RawMessage(`{"points_per_usd":120000,"google_coin_per_usd":70000,"allow_self_brushing":true,"host":{"point_ratio_percent":70,"use_gift_type_ratio":true,"affects_room_heat":true,"minimum_withdraw_points":3000000,"withdraw_fee_bps":250,"future_host_option":"keep"},"agency":{"point_ratio_percent":20},"agent":{"levels":[]},"bd":{"levels":[]},"manager":{"host_point_commission_percent":2,"future_manager_option":"keep"},"coin_seller":{"levels":[],"seller_point_reward_percent":5,"seller_point_settle_percent":95},"tasks":{"reward_asset_type":"POINT","new_host_7d_max_points":350000},"game_invite":{"enabled":true},"extension":{"enabled":true}}`)
|
||||
compiled, err := compilePolicy(ruleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("compile host_income policy failed: %v", err)
|
||||
t.Fatalf("compile POINT policy failed: %v", err)
|
||||
}
|
||||
if compiled.HostPointRatioPPM != 1000000 || compiled.AgencyPointRatioPPM != 200000 {
|
||||
t.Fatalf("host_income ratios mismatch: %+v", compiled)
|
||||
if compiled.PointsPerUSD != 120000 || compiled.MinimumPoints != 3000000 || compiled.WithdrawFeeBPS != 250 {
|
||||
t.Fatalf("retained POINT financial fields mismatch: %+v", compiled)
|
||||
}
|
||||
if compiled.AgencyShareBase != agencyShareBaseHostIncome || !compiled.HostUseGiftTypeRatio {
|
||||
t.Fatalf("host_income flags mismatch: %+v", compiled)
|
||||
normalized, err := normalizeRuleJSON(ruleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize POINT policy failed: %v", err)
|
||||
}
|
||||
if string(compiled.RuleJSON) != string(ruleJSON) {
|
||||
t.Fatalf("wallet owner snapshot must keep the complete source rule_json: got %s", compiled.RuleJSON)
|
||||
if string(normalized) != `{"host":{"minimum_withdraw_points":3000000,"withdraw_fee_bps":250},"points_per_usd":120000}` {
|
||||
t.Fatalf("normalized policy must contain only runtime POINT fields: %s", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePolicyRejectsNonBooleanGiftTypeRatioFlag(t *testing.T) {
|
||||
_, err := compilePolicy(json.RawMessage(`{"host":{"point_ratio_percent":70,"use_gift_type_ratio":"true"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("use_gift_type_ratio must reject non-boolean JSON values")
|
||||
func TestCompilePolicyIgnoresInvalidRetiredDirectGiftFieldTypes(t *testing.T) {
|
||||
ruleJSON := json.RawMessage(`{"host":{"point_ratio_percent":"obsolete","use_gift_type_ratio":"obsolete","minimum_withdraw_points":1000000,"withdraw_fee_bps":500},"agency":{"point_ratio_percent":{},"share_base":123},"points_per_usd":100000}`)
|
||||
compiled, err := compilePolicy(ruleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("retired direct-gift fields must no longer participate in validation: %v", err)
|
||||
}
|
||||
if compiled.MinimumPoints != 1_000_000 {
|
||||
t.Fatalf("explicit minimum withdrawal points mismatch: %+v", compiled)
|
||||
}
|
||||
normalized, err := normalizeRuleJSON(ruleJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize retired fields failed: %v", err)
|
||||
}
|
||||
if string(normalized) != `{"host":{"minimum_withdraw_points":1000000,"withdraw_fee_bps":500},"points_per_usd":100000}` {
|
||||
t.Fatalf("retired direct-gift fields must be absent from normalized rule_json: %s", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePolicyActivityClient struct {
|
||||
activityclient.Client
|
||||
last *activityv1.PublishTaskRewardPolicyRequest
|
||||
func TestCompilePolicyRejectsInvalidMinimumWithdrawalPoints(t *testing.T) {
|
||||
for _, ruleJSON := range []json.RawMessage{
|
||||
json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":0,"withdraw_fee_bps":500}}`),
|
||||
json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":-1,"withdraw_fee_bps":500}}`),
|
||||
json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":1.5,"withdraw_fee_bps":500}}`),
|
||||
json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":"invalid","withdraw_fee_bps":500}}`),
|
||||
} {
|
||||
if _, err := compilePolicy(ruleJSON); err == nil {
|
||||
t.Fatalf("invalid minimum withdrawal points must be rejected: %s", ruleJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakePolicyActivityClient) PublishTaskRewardPolicy(_ context.Context, req *activityv1.PublishTaskRewardPolicyRequest) (*activityv1.PublishTaskRewardPolicyResponse, error) {
|
||||
f.last = req
|
||||
return &activityv1.PublishTaskRewardPolicyResponse{
|
||||
AppCode: req.GetMeta().GetAppCode(),
|
||||
InstanceCode: req.GetInstanceCode(),
|
||||
Status: req.GetStatus(),
|
||||
RewardAssetType: req.GetRewardAssetType(),
|
||||
PublishedAtMs: req.GetPublishedAtMs(),
|
||||
}, nil
|
||||
func TestCompilePolicyRequiresEveryPointFinancialFieldExplicitly(t *testing.T) {
|
||||
for _, ruleJSON := range []json.RawMessage{
|
||||
json.RawMessage(`{}`),
|
||||
json.RawMessage(`{"host":{"minimum_withdraw_points":1000000,"withdraw_fee_bps":500}}`),
|
||||
json.RawMessage(`{"points_per_usd":100000,"host":{"withdraw_fee_bps":500}}`),
|
||||
json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":1000000}}`),
|
||||
json.RawMessage(`{"points_per_usd":0,"host":{"minimum_withdraw_points":1000000,"withdraw_fee_bps":500}}`),
|
||||
json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":1000000,"withdraw_fee_bps":-1}}`),
|
||||
json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":1000000,"withdraw_fee_bps":10001}}`),
|
||||
} {
|
||||
if _, err := compilePolicy(ruleJSON); err == nil {
|
||||
t.Fatalf("incomplete or invalid POINT policy must not publish: %s", ruleJSON)
|
||||
}
|
||||
}
|
||||
|
||||
compiled, err := compilePolicy(json.RawMessage(`{"points_per_usd":100000,"host":{"minimum_withdraw_points":1000000,"withdraw_fee_bps":0}}`))
|
||||
if err != nil || compiled.WithdrawFeeBPS != 0 {
|
||||
t.Fatalf("explicit zero withdrawal fee must remain valid: compiled=%+v err=%v", compiled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRuleJSONDropsEmptyRetiredSections(t *testing.T) {
|
||||
normalized, err := normalizeRuleJSON(json.RawMessage(`{"host":{"point_ratio_percent":70,"use_gift_type_ratio":true},"agency":{"point_ratio_percent":20,"share_base":"charge_amount"},"tasks":{"reward_asset_type":"COIN","new_host_7d_max_points":350000}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("normalize rule_json failed: %v", err)
|
||||
}
|
||||
if string(normalized) != `{}` {
|
||||
t.Fatalf("empty retired sections must not remain in policy DTO/runtime snapshot: %s", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDirectGiftPointPolicyMigrationIsScopedAndIdempotent(t *testing.T) {
|
||||
baseDSN := strings.TrimSpace(os.Getenv("POLICY_CONFIG_MYSQL_TEST_DSN"))
|
||||
if baseDSN == "" {
|
||||
baseDSN = strings.TrimSpace(os.Getenv("WALLET_SERVICE_MYSQL_TEST_DSN"))
|
||||
}
|
||||
if baseDSN == "" {
|
||||
t.Skip("set POLICY_CONFIG_MYSQL_TEST_DSN to run policy migration regression")
|
||||
}
|
||||
db := newPolicyMigrationMySQLDB(t, baseDSN)
|
||||
execPolicyMigrationSQL(t, db, `
|
||||
CREATE TABLE admin_policy_templates (
|
||||
template_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
template_code VARCHAR(96) NOT NULL,
|
||||
rule_json JSON NOT NULL,
|
||||
PRIMARY KEY (template_id),
|
||||
UNIQUE KEY uk_template_code (template_code)
|
||||
);
|
||||
CREATE TABLE admin_policy_template_versions (
|
||||
version_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
template_code VARCHAR(96) NOT NULL,
|
||||
template_version VARCHAR(64) NOT NULL,
|
||||
rule_json JSON NOT NULL,
|
||||
PRIMARY KEY (version_id),
|
||||
UNIQUE KEY uk_template_version (template_code, template_version),
|
||||
KEY idx_admin_policy_template_versions_status (template_code)
|
||||
);
|
||||
CREATE TABLE admin_policy_instances (
|
||||
instance_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
instance_code VARCHAR(96) NOT NULL,
|
||||
template_code VARCHAR(96) NOT NULL,
|
||||
template_version VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (instance_id),
|
||||
KEY idx_admin_policy_instances_template (template_code, template_version)
|
||||
);
|
||||
CREATE TABLE admin_policy_publish_jobs (
|
||||
job_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
instance_id BIGINT UNSIGNED NOT NULL,
|
||||
template_code VARCHAR(96) NOT NULL,
|
||||
PRIMARY KEY (job_id),
|
||||
KEY idx_admin_policy_publish_jobs_instance (instance_id)
|
||||
);
|
||||
CREATE TABLE admin_policy_publish_items (
|
||||
item_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
job_id BIGINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (item_id),
|
||||
KEY idx_admin_policy_publish_items_job (job_id)
|
||||
);
|
||||
CREATE TABLE admin_menus (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
title VARCHAR(80) NOT NULL,
|
||||
code VARCHAR(100) NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_admin_menus_code (code)
|
||||
);
|
||||
CREATE TABLE admin_permissions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
code VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(255) NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_admin_permissions_code (code)
|
||||
);
|
||||
SET @policy_rule = JSON_OBJECT(
|
||||
'points_per_usd', 120000,
|
||||
'host', JSON_OBJECT('minimum_withdraw_points', 3000000, 'withdraw_fee_bps', 250, 'point_ratio_percent', 70, 'use_gift_type_ratio', TRUE, 'affects_room_heat', TRUE),
|
||||
'agency', JSON_OBJECT('point_ratio_percent', 20, 'share_base', 'host_income'),
|
||||
'agent', JSON_OBJECT('levels', JSON_ARRAY(JSON_OBJECT('level', 1))),
|
||||
'bd', JSON_OBJECT('levels', JSON_ARRAY(JSON_OBJECT('level', 1))),
|
||||
'manager', JSON_OBJECT('host_point_commission_percent', 2, 'future_manager_option', 'keep'),
|
||||
'coin_seller', JSON_OBJECT('seller_point_reward_percent', 5, 'seller_point_settle_percent', 95, 'future_coin_seller_option', 'keep'),
|
||||
'game_invite', JSON_OBJECT('enabled', TRUE),
|
||||
'tasks', JSON_OBJECT('reward_asset_type', 'POINT', 'new_host_7d_max_points', 350000),
|
||||
'extension', JSON_OBJECT('enabled', TRUE)
|
||||
);
|
||||
INSERT INTO admin_policy_templates (template_id, template_code, rule_json) VALUES
|
||||
(1, 'fami_guild_revenue_policy', @policy_rule),
|
||||
(2, 'unrelated_point_policy', @policy_rule);
|
||||
INSERT INTO admin_policy_template_versions (version_id, template_code, template_version, rule_json) VALUES
|
||||
(1, 'fami_guild_revenue_policy', 'v1', @policy_rule),
|
||||
(2, 'unrelated_point_policy', 'v1', @policy_rule);
|
||||
INSERT INTO admin_policy_instances (instance_id, app_code, instance_code, template_code, template_version) VALUES
|
||||
(1, 'fami', 'fami-policy', 'fami_guild_revenue_policy', 'v1'),
|
||||
(2, 'lalu', 'lalu-policy', 'unrelated_point_policy', 'v1');
|
||||
INSERT INTO admin_policy_publish_jobs (job_id, instance_id, template_code) VALUES
|
||||
(1, 1, 'fami_guild_revenue_policy'),
|
||||
(2, 2, 'unrelated_point_policy');
|
||||
INSERT INTO admin_policy_publish_items (item_id, job_id) VALUES (1, 1), (2, 2);
|
||||
INSERT INTO admin_menus (id, title, code, updated_at_ms) VALUES
|
||||
(1, '收益政策模板', 'policy-template', 0),
|
||||
(2, '其他菜单', 'other-menu', 0);
|
||||
INSERT INTO admin_permissions (id, name, code, description, updated_at_ms) VALUES
|
||||
(1, '收益政策模板查看', 'policy-template:view', '旧文案', 0),
|
||||
(2, '其他权限', 'other:view', '其他文案', 0);
|
||||
`)
|
||||
|
||||
migrationSQL, err := os.ReadFile("../../../migrations/116_remove_direct_gift_point_policy.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read policy removal migration failed: %v", err)
|
||||
}
|
||||
// 重复在同一个真实 MySQL schema 执行,确保子表清理顺序与幂等性都由数据库验证。
|
||||
execPolicyMigrationSQL(t, db, string(migrationSQL))
|
||||
execPolicyMigrationSQL(t, db, string(migrationSQL))
|
||||
|
||||
for _, assertion := range []struct {
|
||||
name string
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{name: "removed template", query: `SELECT COUNT(*) FROM admin_policy_templates WHERE template_code = 'fami_guild_revenue_policy'`, want: 0},
|
||||
{name: "removed version", query: `SELECT COUNT(*) FROM admin_policy_template_versions WHERE template_code = 'fami_guild_revenue_policy'`, want: 0},
|
||||
{name: "removed instance", query: `SELECT COUNT(*) FROM admin_policy_instances WHERE template_code = 'fami_guild_revenue_policy'`, want: 0},
|
||||
{name: "removed job", query: `SELECT COUNT(*) FROM admin_policy_publish_jobs WHERE template_code = 'fami_guild_revenue_policy'`, want: 0},
|
||||
{name: "removed item", query: `SELECT COUNT(*) FROM admin_policy_publish_items WHERE item_id = 1`, want: 0},
|
||||
{name: "unrelated template", query: `SELECT COUNT(*) FROM admin_policy_templates WHERE template_code = 'unrelated_point_policy'`, want: 1},
|
||||
{name: "unrelated version", query: `SELECT COUNT(*) FROM admin_policy_template_versions WHERE template_code = 'unrelated_point_policy'`, want: 1},
|
||||
{name: "unrelated instance", query: `SELECT COUNT(*) FROM admin_policy_instances WHERE template_code = 'unrelated_point_policy'`, want: 1},
|
||||
{name: "unrelated job", query: `SELECT COUNT(*) FROM admin_policy_publish_jobs WHERE template_code = 'unrelated_point_policy'`, want: 1},
|
||||
{name: "unrelated item", query: `SELECT COUNT(*) FROM admin_policy_publish_items WHERE item_id = 2`, want: 1},
|
||||
{name: "ghost sections removed", query: `SELECT COUNT(*) FROM admin_policy_templates WHERE template_code = 'unrelated_point_policy' AND JSON_EXTRACT(rule_json, '$.tasks') IS NULL AND JSON_EXTRACT(rule_json, '$.host.point_ratio_percent') IS NULL AND JSON_EXTRACT(rule_json, '$.agency') IS NULL AND JSON_EXTRACT(rule_json, '$.agent') IS NULL AND JSON_EXTRACT(rule_json, '$.bd') IS NULL AND JSON_EXTRACT(rule_json, '$.manager') IS NULL AND JSON_EXTRACT(rule_json, '$.coin_seller') IS NULL AND JSON_EXTRACT(rule_json, '$.game_invite') IS NULL`, want: 1},
|
||||
{name: "financial and unrelated extension retained", query: `SELECT COUNT(*) FROM admin_policy_templates WHERE template_code = 'unrelated_point_policy' AND JSON_EXTRACT(rule_json, '$.points_per_usd') = 120000 AND JSON_EXTRACT(rule_json, '$.host.minimum_withdraw_points') = 3000000 AND JSON_EXTRACT(rule_json, '$.host.withdraw_fee_bps') = 250 AND JSON_EXTRACT(rule_json, '$.extension.enabled') = TRUE`, want: 1},
|
||||
{name: "menu title updated", query: `SELECT COUNT(*) FROM admin_menus WHERE code = 'policy-template' AND title = 'POINT 钱包政策'`, want: 1},
|
||||
{name: "permission title updated", query: `SELECT COUNT(*) FROM admin_permissions WHERE code = 'policy-template:view' AND name = 'POINT 钱包政策查看' AND description = '允许查看 POINT 钱包政策和实例'`, want: 1},
|
||||
{name: "other menu unchanged", query: `SELECT COUNT(*) FROM admin_menus WHERE code = 'other-menu' AND title = '其他菜单'`, want: 1},
|
||||
{name: "other permission unchanged", query: `SELECT COUNT(*) FROM admin_permissions WHERE code = 'other:view' AND name = '其他权限' AND description = '其他文案'`, want: 1},
|
||||
} {
|
||||
var got int
|
||||
if err := db.QueryRow(assertion.query).Scan(&got); err != nil {
|
||||
t.Fatalf("query %s failed: %v", assertion.name, err)
|
||||
}
|
||||
if got != assertion.want {
|
||||
t.Fatalf("%s mismatch: got=%d want=%d", assertion.name, got, assertion.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newPolicyMigrationMySQLDB(t *testing.T, baseDSN string) *sql.DB {
|
||||
t.Helper()
|
||||
baseConfig, err := mysqlDriver.ParseDSN(baseDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("parse policy migration MySQL DSN failed: %v", err)
|
||||
}
|
||||
databaseName := fmt.Sprintf("hy_policy_remove_%d", time.Now().UnixNano())
|
||||
adminConfig := baseConfig.Clone()
|
||||
adminConfig.DBName = ""
|
||||
adminDB, err := sql.Open("mysql", adminConfig.FormatDSN())
|
||||
if err != nil {
|
||||
t.Fatalf("open policy migration admin DB failed: %v", err)
|
||||
}
|
||||
if _, err := adminDB.Exec("CREATE DATABASE `" + databaseName + "` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
|
||||
_ = adminDB.Close()
|
||||
t.Fatalf("create policy migration database failed: %v", err)
|
||||
}
|
||||
testConfig := baseConfig.Clone()
|
||||
testConfig.DBName = databaseName
|
||||
testConfig.MultiStatements = true
|
||||
db, err := sql.Open("mysql", testConfig.FormatDSN())
|
||||
if err != nil {
|
||||
_, _ = adminDB.Exec("DROP DATABASE IF EXISTS `" + databaseName + "`")
|
||||
_ = adminDB.Close()
|
||||
t.Fatalf("open policy migration test DB failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
_, _ = adminDB.Exec("DROP DATABASE IF EXISTS `" + databaseName + "`")
|
||||
_ = adminDB.Close()
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func execPolicyMigrationSQL(t *testing.T, db *sql.DB, script string) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(script); err != nil {
|
||||
t.Fatalf("execute policy migration SQL failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPolicySQLMock(t *testing.T) (*sql.DB, sqlmock.Sqlmock, func()) {
|
||||
|
||||
@ -16,6 +16,7 @@ var ErrHostAgencySalaryPolicyActiveScopeConflict = errors.New("host agency salar
|
||||
type HostAgencySalaryPolicyListOptions struct {
|
||||
AppCode string
|
||||
Keyword string
|
||||
PolicyType string
|
||||
RegionID int64
|
||||
CycleKey string
|
||||
Status string
|
||||
@ -34,6 +35,9 @@ func (s *Store) ListHostAgencySalaryPolicies(options HostAgencySalaryPolicyListO
|
||||
db = db.Joins("JOIN admin_host_agency_salary_policy_regions policy_region ON policy_region.policy_id = admin_host_agency_salary_policies.id").
|
||||
Where("policy_region.region_id = ?", options.RegionID)
|
||||
}
|
||||
if policyType := strings.TrimSpace(options.PolicyType); policyType != "" {
|
||||
db = db.Where("policy_type = ?", policyType)
|
||||
}
|
||||
if cycleKey := strings.TrimSpace(options.CycleKey); cycleKey != "" {
|
||||
db = db.Where("cycle_key = ?", cycleKey)
|
||||
}
|
||||
|
||||
@ -124,11 +124,11 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
|
||||
{Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"},
|
||||
{Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"},
|
||||
{Name: "收益政策模板查看", Code: "policy-template:view", Kind: "menu"},
|
||||
{Name: "收益政策模板创建", Code: "policy-template:create", Kind: "button"},
|
||||
{Name: "收益政策实例创建", Code: "policy-instance:create", Kind: "button"},
|
||||
{Name: "收益政策实例更新", Code: "policy-instance:update", Kind: "button"},
|
||||
{Name: "收益政策实例发布", Code: "policy-instance:publish", Kind: "button"},
|
||||
{Name: "POINT 钱包政策查看", Code: "policy-template:view", Kind: "menu"},
|
||||
{Name: "POINT 钱包政策创建", Code: "policy-template:create", Kind: "button"},
|
||||
{Name: "POINT 钱包政策实例创建", Code: "policy-instance:create", Kind: "button"},
|
||||
{Name: "POINT 钱包政策实例更新", Code: "policy-instance:update", Kind: "button"},
|
||||
{Name: "POINT 钱包政策实例发布", Code: "policy-instance:publish", Kind: "button"},
|
||||
{Name: "系统消息推送查看", Code: "full-server-notice:view", Kind: "menu"},
|
||||
{Name: "系统消息推送发送", Code: "full-server-notice:send", Kind: "button"},
|
||||
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
|
||||
@ -375,7 +375,7 @@ func (s *Store) seedMenus() error {
|
||||
{ParentID: &operationsID, Title: "外管用户", Code: "operation-external-admin-users", Path: "/operations/external-admin-users", Icon: "shield", PermissionCode: "external-admin-user:view", Sort: 72, Visible: true},
|
||||
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 73, Visible: true},
|
||||
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 74, Visible: true},
|
||||
{ParentID: &operationsID, Title: "收益政策模板", Code: "policy-template", Path: "/policy/templates", Icon: "settings", PermissionCode: "policy-template:view", Sort: 88, Visible: true},
|
||||
{ParentID: &operationsID, Title: "POINT 钱包政策", Code: "policy-template", Path: "/policy/templates", Icon: "settings", PermissionCode: "policy-template:view", Sort: 88, Visible: true},
|
||||
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
|
||||
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
|
||||
{ParentID: &paymentID, Title: "三方临时支付链接", Code: "payment-temporary-links", Path: "/payment/temporary-links", Icon: "receipt", PermissionCode: "payment-temporary-link:view", Sort: 70, Visible: true},
|
||||
|
||||
@ -678,7 +678,7 @@ func (s *Store) ApprovedWithdrawalStats(appCode string, startAtMS int64, endAtMS
|
||||
COUNT(*),
|
||||
COALESCE(SUM(CASE
|
||||
WHEN actual_amount_minor IS NOT NULL THEN actual_amount_minor
|
||||
WHEN salary_asset_type IN ('POINT', 'COIN_SELLER_POINT') THEN CAST(withdraw_amount * 100 AS SIGNED)
|
||||
WHEN salary_asset_type IN ('POINT', 'POINT_DIAMOND', 'COIN_SELLER_POINT') THEN CAST(withdraw_amount * 100 AS SIGNED)
|
||||
ELSE withdraw_amount_minor
|
||||
END), 0)`).Row()
|
||||
if err := row.Scan(&stats.ApprovedCount, &stats.ApprovedUSDMinor); err != nil {
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Remove publish audit children before their parent rows. The joins start from the exact template through
|
||||
-- idx_admin_policy_instances_template, then use the existing job/item indexes; no audit-table full scan is required.
|
||||
-- These are policy configuration/audit tables only; balances, wallet transactions, Daily Task definitions,
|
||||
-- and Host salary policies are outside this migration.
|
||||
DELETE item
|
||||
FROM admin_policy_publish_items AS item
|
||||
INNER JOIN admin_policy_publish_jobs AS job ON job.job_id = item.job_id
|
||||
INNER JOIN admin_policy_instances AS instance
|
||||
ON instance.instance_id = job.instance_id
|
||||
AND instance.template_code = 'fami_guild_revenue_policy'
|
||||
WHERE job.template_code = 'fami_guild_revenue_policy';
|
||||
|
||||
DELETE job
|
||||
FROM admin_policy_publish_jobs AS job
|
||||
INNER JOIN admin_policy_instances AS instance
|
||||
ON instance.instance_id = job.instance_id
|
||||
AND instance.template_code = 'fami_guild_revenue_policy'
|
||||
WHERE job.template_code = 'fami_guild_revenue_policy';
|
||||
|
||||
-- template_code is the exact removed capability identity; no product-name branch is added to runtime code.
|
||||
DELETE FROM admin_policy_instances
|
||||
WHERE template_code = 'fami_guild_revenue_policy';
|
||||
|
||||
DELETE FROM admin_policy_template_versions
|
||||
WHERE template_code = 'fami_guild_revenue_policy';
|
||||
|
||||
DELETE FROM admin_policy_templates
|
||||
WHERE template_code = 'fami_guild_revenue_policy';
|
||||
|
||||
-- Keep the three POINT financial fields. Team salary, coin-seller and invite configuration have independent owners;
|
||||
-- their duplicate PolicyConfig sections are removed so Admin cannot continue presenting inert settings.
|
||||
UPDATE admin_policy_template_versions
|
||||
SET rule_json = JSON_REMOVE(
|
||||
rule_json,
|
||||
'$.allow_self_brushing',
|
||||
'$.google_coin_per_usd',
|
||||
'$.host.point_ratio_percent',
|
||||
'$.host.use_gift_type_ratio',
|
||||
'$.host.affects_room_heat',
|
||||
'$.agency',
|
||||
'$.agent',
|
||||
'$.bd',
|
||||
'$.manager',
|
||||
'$.coin_seller',
|
||||
'$.game_invite',
|
||||
'$.tasks'
|
||||
);
|
||||
|
||||
UPDATE admin_policy_templates
|
||||
SET rule_json = JSON_REMOVE(
|
||||
rule_json,
|
||||
'$.allow_self_brushing',
|
||||
'$.google_coin_per_usd',
|
||||
'$.host.point_ratio_percent',
|
||||
'$.host.use_gift_type_ratio',
|
||||
'$.host.affects_room_heat',
|
||||
'$.agency',
|
||||
'$.agent',
|
||||
'$.bd',
|
||||
'$.manager',
|
||||
'$.coin_seller',
|
||||
'$.game_invite',
|
||||
'$.tasks'
|
||||
);
|
||||
|
||||
-- 菜单和权限 code 都有唯一索引;以下点更新只修正文案,不重命名权限码、路由或存量 template_code。
|
||||
UPDATE admin_menus
|
||||
SET title = 'POINT 钱包政策',
|
||||
updated_at_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED)
|
||||
WHERE code = 'policy-template';
|
||||
|
||||
UPDATE admin_permissions
|
||||
SET name = CASE code
|
||||
WHEN 'policy-template:view' THEN 'POINT 钱包政策查看'
|
||||
WHEN 'policy-template:create' THEN 'POINT 钱包政策创建'
|
||||
WHEN 'policy-instance:create' THEN 'POINT 钱包政策实例创建'
|
||||
WHEN 'policy-instance:update' THEN 'POINT 钱包政策实例更新'
|
||||
WHEN 'policy-instance:publish' THEN 'POINT 钱包政策实例发布'
|
||||
ELSE name
|
||||
END,
|
||||
description = CASE code
|
||||
WHEN 'policy-template:view' THEN '允许查看 POINT 钱包政策和实例'
|
||||
WHEN 'policy-template:create' THEN '允许创建 POINT 钱包政策'
|
||||
WHEN 'policy-instance:create' THEN '允许创建 POINT 钱包政策实例'
|
||||
WHEN 'policy-instance:update' THEN '允许更新 POINT 钱包政策实例状态'
|
||||
WHEN 'policy-instance:publish' THEN '允许发布 POINT 钱包政策实例到运行侧'
|
||||
ELSE description
|
||||
END,
|
||||
updated_at_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED)
|
||||
WHERE code IN (
|
||||
'policy-template:view',
|
||||
'policy-template:create',
|
||||
'policy-instance:create',
|
||||
'policy-instance:update',
|
||||
'policy-instance:publish'
|
||||
);
|
||||
23
server/admin/migrations/117_room_discovery_scope_config.sql
Normal file
23
server/admin/migrations/117_room_discovery_scope_config.sql
Normal file
@ -0,0 +1,23 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
-- 仅在首次把旧分支迁入配置时保留 Huwaa 已上线的全区域行为;运行时只读取当前 App 的配置,
|
||||
-- 未配置 App 仍 fail closed 为 viewer_region,新增租户不会继承其他产品的跨区域范围。
|
||||
INSERT INTO admin_app_configs (app_code, `group`, `key`, value, description, is_deleted, created_at_ms, updated_at_ms)
|
||||
VALUES ('huwaa', 'room-discovery', 'scope', 'all_active_regions', '房间发现范围:viewer_region 或 all_active_regions', FALSE, @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE id = id;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '房间发现范围', 'app-config-room-discovery', '/app-config/room-discovery', 'public', 'app-config:view', 72, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'app-config'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
26
server/admin/migrations/118_runtime_capabilities_config.sql
Normal file
26
server/admin/migrations/118_runtime_capabilities_config.sql
Normal file
@ -0,0 +1,26 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
-- 这些行只是在首次引入通用能力配置时保存旧产品行为;运行时只按当前 App 的配置值决策,
|
||||
-- 未配置 App 的默认值仍是 app_global + legacy_optional,不需要在 Go/JS 中维护产品名单。
|
||||
INSERT INTO admin_app_configs (app_code, `group`, `key`, value, description, is_deleted, created_at_ms, updated_at_ms)
|
||||
VALUES
|
||||
('lalu', 'runtime-capabilities', 'activity_leaderboard_scope', 'viewer_region', '活动榜单范围:app_global 或 viewer_region', FALSE, @now_ms, @now_ms),
|
||||
('fami', 'runtime-capabilities', 'activity_leaderboard_scope', 'viewer_region', '活动榜单范围:app_global 或 viewer_region', FALSE, @now_ms, @now_ms),
|
||||
('fami', 'runtime-capabilities', 'resource_equip_command_id_policy', 'required', '资源佩戴幂等键策略:legacy_optional 或 required', FALSE, @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE id = id;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '运行能力配置', 'app-config-runtime-capabilities', '/app-config/runtime-capabilities', 'settings', 'app-config:view', 73, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'app-config'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
27
server/admin/migrations/119_host_policy_types.sql
Normal file
27
server/admin/migrations/119_host_policy_types.sql
Normal file
@ -0,0 +1,27 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 仅扩展小型政策配置表;每列先查 information_schema,使迁移可安全重放且不扫描钱包流水。
|
||||
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'policy_type') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN policy_type VARCHAR(32) NOT NULL DEFAULT ''SALARY_DIAMOND'' COMMENT ''政策类型:SALARY_DIAMOND/POINT_DIAMOND'' AFTER name, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'point_diamonds_per_usd') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN point_diamonds_per_usd BIGINT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 每美元对应钻石积分'' AFTER gift_coin_to_diamond_ratio, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'coins_per_usd') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN coins_per_usd BIGINT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 每美元对应金币'' AFTER point_diamonds_per_usd, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'minimum_withdraw_usd_minor') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 最低提现美元美分'' AFTER coins_per_usd, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'withdraw_fee_bps') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN withdraw_fee_bps INT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 提现费率基点'' AFTER minimum_withdraw_usd_minor, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_host_agency_salary_policies' AND COLUMN_NAME = 'agency_point_share_bps') = 0,
|
||||
'ALTER TABLE admin_host_agency_salary_policies ADD COLUMN agency_point_share_bps INT NOT NULL DEFAULT 0 COMMENT ''POINT_DIAMOND 代理分成占主播基础积分的基点'' AFTER withdraw_fee_bps, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
@ -206,22 +206,6 @@ CREATE TABLE IF NOT EXISTS game_win_broadcast_configs (
|
||||
PRIMARY KEY (app_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏中奖飘屏配置';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_reward_policy_instances (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
instance_code VARCHAR(96) NOT NULL COMMENT '后台策略实例编码',
|
||||
template_code VARCHAR(96) NOT NULL COMMENT '模板编码快照',
|
||||
template_version VARCHAR(64) NOT NULL COMMENT '模板版本快照',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||||
reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '任务默认奖励资产:COIN/POINT',
|
||||
rule_json JSON NOT NULL COMMENT '完整策略快照',
|
||||
published_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布管理员 ID',
|
||||
published_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发布时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, instance_code),
|
||||
KEY idx_task_reward_policy_active (app_code, status, published_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务奖励运行侧策略实例表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_definitions (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
task_id VARCHAR(96) NOT NULL COMMENT '任务 ID',
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_reward_policy_instances (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
instance_code VARCHAR(96) NOT NULL COMMENT '后台策略实例编码',
|
||||
template_code VARCHAR(96) NOT NULL COMMENT '模板编码快照',
|
||||
template_version VARCHAR(64) NOT NULL COMMENT '模板版本快照',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||||
reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '任务默认奖励资产:COIN/POINT',
|
||||
rule_json JSON NOT NULL COMMENT '完整策略快照',
|
||||
published_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布管理员 ID',
|
||||
published_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发布时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, instance_code),
|
||||
KEY idx_task_reward_policy_active (app_code, status, published_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务奖励运行侧策略实例表';
|
||||
@ -168,18 +168,6 @@ type DefinitionCommand struct {
|
||||
OperatorAdminID int64
|
||||
}
|
||||
|
||||
// RewardPolicyCommand 是后台 policy instance 发布到 activity 运行侧的任务奖励默认资产快照。
|
||||
type RewardPolicyCommand struct {
|
||||
InstanceCode string
|
||||
TemplateCode string
|
||||
TemplateVersion string
|
||||
Status string
|
||||
RewardAssetType string
|
||||
RuleJSON string
|
||||
OperatorAdminID int64
|
||||
PublishedAtMS int64
|
||||
}
|
||||
|
||||
// Event 是由 wallet/user/room outbox 派生的任务进度事实。
|
||||
type Event struct {
|
||||
EventID string
|
||||
|
||||
@ -29,11 +29,6 @@ type Repository interface {
|
||||
MarkTaskClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
|
||||
}
|
||||
|
||||
type taskRewardPolicyStore interface {
|
||||
GetActiveTaskRewardAssetType(ctx context.Context, nowMS int64) (string, bool, error)
|
||||
PublishTaskRewardPolicy(ctx context.Context, command taskdomain.RewardPolicyCommand, nowMS int64) error
|
||||
}
|
||||
|
||||
// WalletClient 是 activity-service 发放金币奖励时依赖的钱包入账接口。
|
||||
type WalletClient interface {
|
||||
CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error)
|
||||
@ -317,15 +312,9 @@ func (s *Service) UpsertTaskDefinition(ctx context.Context, command taskdomain.D
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return taskdomain.Definition{}, false, err
|
||||
}
|
||||
// 奖励资产属于任务定义本身;必须由调用方显式携带,避免旧客户端编辑 POINT 任务时用空字段静默覆盖存量资产。
|
||||
if strings.TrimSpace(command.RewardAssetType) == "" {
|
||||
if policyStore, ok := s.repository.(taskRewardPolicyStore); ok {
|
||||
// policy instance 只提供“未显式配置时”的默认奖励资产;任务定义一旦保存仍会按定义和进度快照发放。
|
||||
if assetType, found, err := policyStore.GetActiveTaskRewardAssetType(ctx, s.now().UnixMilli()); err != nil {
|
||||
return taskdomain.Definition{}, false, err
|
||||
} else if found {
|
||||
command.RewardAssetType = assetType
|
||||
}
|
||||
}
|
||||
return taskdomain.Definition{}, false, xerr.New(xerr.InvalidArgument, "reward_asset_type is required")
|
||||
}
|
||||
command = normalizeDefinitionCommand(command)
|
||||
if err := validateDefinitionCommand(ctx, command); err != nil {
|
||||
@ -334,37 +323,6 @@ func (s *Service) UpsertTaskDefinition(ctx context.Context, command taskdomain.D
|
||||
return s.repository.UpsertTaskDefinition(ctx, command, s.now().UnixMilli())
|
||||
}
|
||||
|
||||
// PublishTaskRewardPolicy 写入 activity 运行侧默认任务奖励资产;它不改历史任务,只影响后续未显式传 reward_asset_type 的配置。
|
||||
func (s *Service) PublishTaskRewardPolicy(ctx context.Context, command taskdomain.RewardPolicyCommand) error {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return err
|
||||
}
|
||||
policyStore, ok := s.repository.(taskRewardPolicyStore)
|
||||
if !ok {
|
||||
return xerr.New(xerr.Unavailable, "task reward policy store is not configured")
|
||||
}
|
||||
command.InstanceCode = strings.TrimSpace(command.InstanceCode)
|
||||
command.TemplateCode = strings.TrimSpace(command.TemplateCode)
|
||||
command.TemplateVersion = strings.TrimSpace(command.TemplateVersion)
|
||||
command.Status = strings.TrimSpace(command.Status)
|
||||
command.RewardAssetType = normalizeRewardAssetType(command.RewardAssetType)
|
||||
command.RuleJSON = normalizeJSONForCommand(command.RuleJSON)
|
||||
if command.InstanceCode == "" || command.TemplateCode == "" || command.TemplateVersion == "" || command.OperatorAdminID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "task reward policy command is incomplete")
|
||||
}
|
||||
if command.Status != "active" && command.Status != "disabled" {
|
||||
return xerr.New(xerr.InvalidArgument, "task reward policy status is invalid")
|
||||
}
|
||||
if !validRewardAssetType(appcode.FromContext(ctx), command.RewardAssetType) {
|
||||
return xerr.New(xerr.InvalidArgument, "reward_asset_type is invalid")
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
if command.PublishedAtMS <= 0 {
|
||||
command.PublishedAtMS = nowMS
|
||||
}
|
||||
return policyStore.PublishTaskRewardPolicy(ctx, command, nowMS)
|
||||
}
|
||||
|
||||
// SetTaskDefinitionStatus 只做状态流转,不删除历史进度和领奖事实。
|
||||
func (s *Service) SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64) (taskdomain.Definition, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
@ -515,7 +473,7 @@ func validateDefinitionCommand(ctx context.Context, command taskdomain.Definitio
|
||||
if command.TargetValue <= 0 || command.RewardCoinAmount <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "target_value and reward_coin_amount must be positive")
|
||||
}
|
||||
if !validRewardAssetType(appcode.FromContext(ctx), command.RewardAssetType) {
|
||||
if !validRewardAssetType(command.RewardAssetType) {
|
||||
return xerr.New(xerr.InvalidArgument, "reward_asset_type is invalid")
|
||||
}
|
||||
if !validTargetUnit(command.TargetUnit) {
|
||||
@ -541,13 +499,11 @@ func normalizeRewardAssetType(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func validRewardAssetType(appCode string, value string) bool {
|
||||
func validRewardAssetType(value string) bool {
|
||||
switch normalizeRewardAssetType(value) {
|
||||
case taskdomain.RewardAssetCoin:
|
||||
case taskdomain.RewardAssetCoin, taskdomain.RewardAssetPoint:
|
||||
// 资产由每条任务定义显式配置;业务校验只验证通用资产能力,不按应用名复制规则。
|
||||
return true
|
||||
case taskdomain.RewardAssetPoint:
|
||||
// POINT 是 Huwaa 第一套政策的真实收益资产;其它 App 继续只能发 COIN,避免配置误伤旧任务。
|
||||
return appcode.Normalize(appCode) == "huwaa"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@ -184,6 +184,7 @@ func TestTaskEventQueryAndClaimFlow(t *testing.T) {
|
||||
TargetValue: 100,
|
||||
TargetUnit: "coin",
|
||||
RewardCoinAmount: 10,
|
||||
RewardAssetType: taskdomain.RewardAssetCoin,
|
||||
Status: taskdomain.StatusActive,
|
||||
SortOrder: 1,
|
||||
OperatorAdminID: 90001,
|
||||
@ -272,6 +273,7 @@ func TestTaskEventDimensionFilter(t *testing.T) {
|
||||
TargetValue: 100,
|
||||
TargetUnit: "coin",
|
||||
RewardCoinAmount: 10,
|
||||
RewardAssetType: taskdomain.RewardAssetCoin,
|
||||
Status: taskdomain.StatusActive,
|
||||
OperatorAdminID: 90001,
|
||||
})
|
||||
@ -322,7 +324,7 @@ func TestTaskEventDimensionFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHuwaaTaskRewardPolicyDefaultsNewTasksToPointAndClaimsPoint(t *testing.T) {
|
||||
func TestExplicitPointTaskDefinitionClaimsPoint(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
wallet := &fakeWalletClient{}
|
||||
svc := taskservice.New(repository, wallet)
|
||||
@ -330,18 +332,6 @@ func TestHuwaaTaskRewardPolicyDefaultsNewTasksToPointAndClaimsPoint(t *testing.T
|
||||
svc.SetClock(func() time.Time { return now })
|
||||
ctx := appcode.WithContext(context.Background(), "huwaa")
|
||||
|
||||
if err := svc.PublishTaskRewardPolicy(ctx, taskdomain.RewardPolicyCommand{
|
||||
InstanceCode: "huwaa-point-live",
|
||||
TemplateCode: "huwaa_point_policy",
|
||||
TemplateVersion: "v2",
|
||||
Status: "active",
|
||||
RewardAssetType: taskdomain.RewardAssetPoint,
|
||||
RuleJSON: `{"tasks":{"reward_asset_type":"POINT"}}`,
|
||||
OperatorAdminID: 90001,
|
||||
PublishedAtMS: now.UnixMilli(),
|
||||
}); err != nil {
|
||||
t.Fatalf("PublishTaskRewardPolicy failed: %v", err)
|
||||
}
|
||||
definition, created, err := svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{
|
||||
TaskType: taskdomain.TypeDaily,
|
||||
Category: "gift",
|
||||
@ -350,14 +340,15 @@ func TestHuwaaTaskRewardPolicyDefaultsNewTasksToPointAndClaimsPoint(t *testing.T
|
||||
TargetValue: 100,
|
||||
TargetUnit: "coin",
|
||||
RewardCoinAmount: 12,
|
||||
RewardAssetType: taskdomain.RewardAssetPoint,
|
||||
Status: taskdomain.StatusActive,
|
||||
OperatorAdminID: 90001,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("create Huwaa task definition failed: created=%v err=%v", created, err)
|
||||
t.Fatalf("create explicit POINT task definition failed: created=%v err=%v", created, err)
|
||||
}
|
||||
if definition.RewardAssetType != taskdomain.RewardAssetPoint {
|
||||
t.Fatalf("policy default reward asset mismatch: %+v", definition)
|
||||
t.Fatalf("explicit reward asset mismatch: %+v", definition)
|
||||
}
|
||||
|
||||
if _, err := svc.ConsumeTaskEvent(ctx, taskdomain.Event{
|
||||
@ -392,23 +383,12 @@ func TestHuwaaTaskRewardPolicyDefaultsNewTasksToPointAndClaimsPoint(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointTaskRewardPolicyIsRejectedOutsideHuwaa(t *testing.T) {
|
||||
func TestExplicitPointTaskDefinitionDoesNotDependOnApplicationName(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := taskservice.New(repository, &fakeWalletClient{})
|
||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||
|
||||
if err := svc.PublishTaskRewardPolicy(ctx, taskdomain.RewardPolicyCommand{
|
||||
InstanceCode: "lalu-point-denied",
|
||||
TemplateCode: "huwaa_point_policy",
|
||||
TemplateVersion: "v2",
|
||||
Status: "active",
|
||||
RewardAssetType: taskdomain.RewardAssetPoint,
|
||||
RuleJSON: `{}`,
|
||||
OperatorAdminID: 90001,
|
||||
}); err == nil {
|
||||
t.Fatal("Lalu POINT policy publish must be rejected")
|
||||
}
|
||||
if _, _, err := svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{
|
||||
definition, _, err := svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{
|
||||
TaskType: taskdomain.TypeDaily,
|
||||
Category: "gift",
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
@ -419,46 +399,18 @@ func TestPointTaskRewardPolicyIsRejectedOutsideHuwaa(t *testing.T) {
|
||||
RewardAssetType: taskdomain.RewardAssetPoint,
|
||||
Status: taskdomain.StatusActive,
|
||||
OperatorAdminID: 90001,
|
||||
}); err == nil {
|
||||
t.Fatal("Lalu explicit POINT task definition must be rejected")
|
||||
})
|
||||
if err != nil || definition.RewardAssetType != taskdomain.RewardAssetPoint {
|
||||
t.Fatalf("configured POINT task definition failed: definition=%+v err=%v", definition, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskRewardPointPolicyAppGateWithoutMySQL(t *testing.T) {
|
||||
repository := &fakeTaskPolicyRepository{activeAsset: taskdomain.RewardAssetPoint, activeFound: true}
|
||||
func TestEmptyRewardAssetUpdateDoesNotOverwriteStoredPoint(t *testing.T) {
|
||||
repository := &fakeTaskDefinitionRepository{storedAsset: taskdomain.RewardAssetPoint}
|
||||
svc := taskservice.New(repository, &fakeWalletClient{})
|
||||
|
||||
if err := svc.PublishTaskRewardPolicy(appcode.WithContext(context.Background(), "lalu"), taskdomain.RewardPolicyCommand{
|
||||
InstanceCode: "lalu-point-denied",
|
||||
TemplateCode: "huwaa_point_policy",
|
||||
TemplateVersion: "v2",
|
||||
Status: "active",
|
||||
RewardAssetType: taskdomain.RewardAssetPoint,
|
||||
RuleJSON: `{}`,
|
||||
OperatorAdminID: 90001,
|
||||
}); err == nil {
|
||||
t.Fatal("Lalu POINT task reward policy must be rejected before repository call")
|
||||
}
|
||||
if repository.publishCalls != 0 {
|
||||
t.Fatalf("rejected Lalu POINT policy must not call repository, got %d calls", repository.publishCalls)
|
||||
}
|
||||
|
||||
if err := svc.PublishTaskRewardPolicy(appcode.WithContext(context.Background(), "huwaa"), taskdomain.RewardPolicyCommand{
|
||||
InstanceCode: "huwaa-point-live",
|
||||
TemplateCode: "huwaa_point_policy",
|
||||
TemplateVersion: "v2",
|
||||
Status: "active",
|
||||
RewardAssetType: taskdomain.RewardAssetPoint,
|
||||
RuleJSON: `{}`,
|
||||
OperatorAdminID: 90001,
|
||||
}); err != nil {
|
||||
t.Fatalf("Huwaa POINT task reward policy should reach repository: %v", err)
|
||||
}
|
||||
if repository.publishCalls != 1 || repository.lastPublishApp != "huwaa" || repository.lastPublish.RewardAssetType != taskdomain.RewardAssetPoint {
|
||||
t.Fatalf("Huwaa POINT policy repository payload mismatch: repo=%+v", repository)
|
||||
}
|
||||
|
||||
definition, created, err := svc.UpsertTaskDefinition(appcode.WithContext(context.Background(), "huwaa"), taskdomain.DefinitionCommand{
|
||||
_, created, err := svc.UpsertTaskDefinition(appcode.WithContext(context.Background(), "configured-app"), taskdomain.DefinitionCommand{
|
||||
TaskID: "existing-point-task",
|
||||
TaskType: taskdomain.TypeDaily,
|
||||
Category: "gift",
|
||||
MetricType: taskdomain.MetricGiftSpendCoin,
|
||||
@ -469,42 +421,26 @@ func TestTaskRewardPointPolicyAppGateWithoutMySQL(t *testing.T) {
|
||||
Status: taskdomain.StatusActive,
|
||||
OperatorAdminID: 90001,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("Huwaa policy default task definition failed: created=%v err=%v", created, err)
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) || created {
|
||||
t.Fatalf("empty reward asset update error=%v created=%v, want INVALID_ARGUMENT", err, created)
|
||||
}
|
||||
if definition.RewardAssetType != taskdomain.RewardAssetPoint ||
|
||||
repository.activeLookupApp != "huwaa" ||
|
||||
repository.lastUpsert.RewardAssetType != taskdomain.RewardAssetPoint {
|
||||
t.Fatalf("Huwaa policy default task definition mismatch: definition=%+v repo=%+v", definition, repository)
|
||||
if repository.upsertCalls != 0 || repository.storedAsset != taskdomain.RewardAssetPoint {
|
||||
t.Fatalf("rejected empty asset update must preserve stored POINT definition: repo=%+v", repository)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTaskPolicyRepository struct {
|
||||
type fakeTaskDefinitionRepository struct {
|
||||
taskservice.Repository
|
||||
|
||||
activeAsset string
|
||||
activeFound bool
|
||||
activeLookupApp string
|
||||
publishCalls int
|
||||
lastPublish taskdomain.RewardPolicyCommand
|
||||
lastPublishApp string
|
||||
lastUpsert taskdomain.DefinitionCommand
|
||||
lastUpsert taskdomain.DefinitionCommand
|
||||
storedAsset string
|
||||
upsertCalls int
|
||||
}
|
||||
|
||||
func (f *fakeTaskPolicyRepository) GetActiveTaskRewardAssetType(ctx context.Context, _ int64) (string, bool, error) {
|
||||
f.activeLookupApp = appcode.FromContext(ctx)
|
||||
return f.activeAsset, f.activeFound, nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskPolicyRepository) PublishTaskRewardPolicy(ctx context.Context, command taskdomain.RewardPolicyCommand, _ int64) error {
|
||||
f.publishCalls++
|
||||
f.lastPublish = command
|
||||
f.lastPublishApp = appcode.FromContext(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskPolicyRepository) UpsertTaskDefinition(_ context.Context, command taskdomain.DefinitionCommand, _ int64) (taskdomain.Definition, bool, error) {
|
||||
func (f *fakeTaskDefinitionRepository) UpsertTaskDefinition(_ context.Context, command taskdomain.DefinitionCommand, _ int64) (taskdomain.Definition, bool, error) {
|
||||
f.upsertCalls++
|
||||
f.lastUpsert = command
|
||||
f.storedAsset = command.RewardAssetType
|
||||
return taskdomain.Definition{
|
||||
AppCode: "huwaa",
|
||||
TaskID: "fake-point-task",
|
||||
|
||||
@ -110,35 +110,12 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
if err := r.ensureTaskDefinitionMetadataColumns(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureTaskRewardPolicyTables(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureTemporaryGrowthLevelTables(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ensureTaskRewardPolicyTables(ctx context.Context) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS task_reward_policy_instances (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
instance_code VARCHAR(96) NOT NULL COMMENT '后台策略实例编码',
|
||||
template_code VARCHAR(96) NOT NULL COMMENT '模板编码快照',
|
||||
template_version VARCHAR(64) NOT NULL COMMENT '模板版本快照',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||||
reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '任务默认奖励资产:COIN/POINT',
|
||||
rule_json JSON NOT NULL COMMENT '完整策略快照',
|
||||
published_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布管理员 ID',
|
||||
published_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发布时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, instance_code),
|
||||
KEY idx_task_reward_policy_active (app_code, status, published_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务奖励运行侧策略实例表'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) ensureTaskDefinitionMetadataColumns(ctx context.Context) error {
|
||||
// 本地和测试环境会走自动迁移,逐列检查避免旧库重复启动时报 duplicate column;线上仍可用 deploy/mysql/migrations 的显式 SQL。
|
||||
additions := []struct {
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
taskdomain "hyapp/services/activity-service/internal/domain/task"
|
||||
)
|
||||
|
||||
func (r *Repository) GetActiveTaskRewardAssetType(ctx context.Context, nowMS int64) (string, bool, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT reward_asset_type
|
||||
FROM task_reward_policy_instances
|
||||
WHERE app_code = ? AND status = 'active'
|
||||
ORDER BY published_at_ms DESC, instance_code DESC
|
||||
LIMIT 1`,
|
||||
appcode.FromContext(ctx),
|
||||
)
|
||||
var assetType string
|
||||
if err := row.Scan(&assetType); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return assetType, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) PublishTaskRewardPolicy(ctx context.Context, command taskdomain.RewardPolicyCommand, nowMS int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO task_reward_policy_instances (
|
||||
app_code, instance_code, template_code, template_version, status,
|
||||
reward_asset_type, rule_json, published_by_admin_id, published_at_ms,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
template_code = VALUES(template_code),
|
||||
template_version = VALUES(template_version),
|
||||
status = VALUES(status),
|
||||
reward_asset_type = VALUES(reward_asset_type),
|
||||
rule_json = VALUES(rule_json),
|
||||
published_by_admin_id = VALUES(published_by_admin_id),
|
||||
published_at_ms = VALUES(published_at_ms),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appcode.FromContext(ctx),
|
||||
command.InstanceCode,
|
||||
command.TemplateCode,
|
||||
command.TemplateVersion,
|
||||
command.Status,
|
||||
command.RewardAssetType,
|
||||
command.RuleJSON,
|
||||
command.OperatorAdminID,
|
||||
command.PublishedAtMS,
|
||||
nowMS,
|
||||
nowMS,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@ -13,7 +13,9 @@ import (
|
||||
"hyapp/services/activity-service/internal/testutil/mysqltest"
|
||||
|
||||
grpcpkg "google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
|
||||
@ -29,6 +31,16 @@ func TestTaskGRPCInterfaceFlow(t *testing.T) {
|
||||
appClient := activityv1.NewTaskServiceClient(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
// 旧 Admin 二进制仍可能在滚动发布窗口调用已退役 RPC;wire contract 保留,但不能再静默写入 App 级默认资产。
|
||||
_, err := adminClient.PublishTaskRewardPolicy(ctx, &activityv1.PublishTaskRewardPolicyRequest{
|
||||
Meta: taskFlowMeta("retired-task-reward-policy"),
|
||||
InstanceCode: "legacy-instance",
|
||||
RewardAssetType: taskdomain.RewardAssetPoint,
|
||||
})
|
||||
if status.Code(err) != codes.FailedPrecondition {
|
||||
t.Fatalf("retired task reward policy rpc should fail explicitly: %v", err)
|
||||
}
|
||||
|
||||
// 后台真实 gRPC 接口创建一条带 icon、跳转、维度过滤的每日任务,模拟 admin-server 最终调用 activity-service 的配置链路。
|
||||
created, err := adminClient.UpsertTaskDefinition(ctx, &activityv1.UpsertTaskDefinitionRequest{
|
||||
Meta: taskFlowMeta("admin-task-create"),
|
||||
@ -47,6 +59,7 @@ func TestTaskGRPCInterfaceFlow(t *testing.T) {
|
||||
TargetValue: 100,
|
||||
TargetUnit: "coin",
|
||||
RewardCoinAmount: 20,
|
||||
RewardAssetType: taskdomain.RewardAssetCoin,
|
||||
Status: taskdomain.StatusActive,
|
||||
SortOrder: 10,
|
||||
OperatorAdminId: 90001,
|
||||
@ -70,7 +83,7 @@ func TestTaskGRPCInterfaceFlow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("admin list task failed: %v", err)
|
||||
}
|
||||
if adminList.GetTotal() != 1 || adminList.GetTasks()[0].GetIconKey() != "gift" || adminList.GetTasks()[0].GetIconUrl() == "" {
|
||||
if adminList.GetTotal() != 1 || adminList.GetTasks()[0].GetIconKey() != "gift" || adminList.GetTasks()[0].GetIconUrl() == "" || adminList.GetTasks()[0].GetRewardAssetType() != taskdomain.RewardAssetCoin {
|
||||
t.Fatalf("admin list should include icon metadata: %+v", adminList)
|
||||
}
|
||||
|
||||
|
||||
@ -147,29 +147,10 @@ func (s *AdminTaskServer) SetTaskDefinitionStatus(ctx context.Context, req *acti
|
||||
return &activityv1.SetTaskDefinitionStatusResponse{Task: definitionToProto(item)}, nil
|
||||
}
|
||||
|
||||
// PublishTaskRewardPolicy 写入 policy instance 编译后的 activity 运行侧任务奖励默认资产。
|
||||
func (s *AdminTaskServer) PublishTaskRewardPolicy(ctx context.Context, req *activityv1.PublishTaskRewardPolicyRequest) (*activityv1.PublishTaskRewardPolicyResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
command := taskdomain.RewardPolicyCommand{
|
||||
InstanceCode: req.GetInstanceCode(),
|
||||
TemplateCode: req.GetTemplateCode(),
|
||||
TemplateVersion: req.GetTemplateVersion(),
|
||||
Status: req.GetStatus(),
|
||||
RewardAssetType: req.GetRewardAssetType(),
|
||||
RuleJSON: req.GetRuleJson(),
|
||||
OperatorAdminID: int64(req.GetOperatorAdminId()),
|
||||
PublishedAtMS: req.GetPublishedAtMs(),
|
||||
}
|
||||
if err := s.svc.PublishTaskRewardPolicy(ctx, command); err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.PublishTaskRewardPolicyResponse{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
InstanceCode: command.InstanceCode,
|
||||
Status: command.Status,
|
||||
RewardAssetType: command.RewardAssetType,
|
||||
PublishedAtMs: command.PublishedAtMS,
|
||||
}, nil
|
||||
// PublishTaskRewardPolicy 保留旧 RPC 的 wire contract,但拒绝继续写入已退役的默认奖励政策。
|
||||
// 调用方必须把资产类型显式保存到每条任务定义,避免一次 App 级发布静默改写既有任务语义。
|
||||
func (s *AdminTaskServer) PublishTaskRewardPolicy(context.Context, *activityv1.PublishTaskRewardPolicyRequest) (*activityv1.PublishTaskRewardPolicyResponse, error) {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Conflict, "task reward policy publishing is retired; configure reward_asset_type on each task definition"))
|
||||
}
|
||||
|
||||
func taskListToProto(result taskdomain.ListResult) *activityv1.ListUserTasksResponse {
|
||||
|
||||
@ -64,6 +64,8 @@ app_config:
|
||||
redis_db: 0
|
||||
key_prefix: "gateway:app_config:"
|
||||
cache_ttl: "5m"
|
||||
# 房间发现范围走本进程短缓存;后台保存后最长 30 秒刷新,且单请求只解析一次。
|
||||
room_discovery_cache_ttl: "30s"
|
||||
finance_notifications:
|
||||
dingtalk:
|
||||
enabled: true
|
||||
|
||||
@ -68,6 +68,8 @@ app_config:
|
||||
redis_db: 0
|
||||
key_prefix: "gateway:app_config:"
|
||||
cache_ttl: "5m"
|
||||
# 房间发现范围走本进程短缓存;后台保存后最长 30 秒刷新,且单请求只解析一次。
|
||||
room_discovery_cache_ttl: "30s"
|
||||
finance_notifications:
|
||||
dingtalk:
|
||||
enabled: true
|
||||
|
||||
@ -64,6 +64,8 @@ app_config:
|
||||
redis_db: 0
|
||||
key_prefix: "gateway:app_config:"
|
||||
cache_ttl: "5m"
|
||||
# 房间发现范围走本进程短缓存;后台保存后最长 30 秒刷新,且单请求只解析一次。
|
||||
room_discovery_cache_ttl: "30s"
|
||||
finance_notifications:
|
||||
dingtalk:
|
||||
enabled: true
|
||||
|
||||
@ -366,6 +366,7 @@ func openAppConfigReader(cfg config.AppConfigConfig) (*appconfig.MySQLReader, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader.SetRoomDiscoveryCacheTTL(cfg.RoomDiscoveryCacheTTL)
|
||||
if strings.TrimSpace(cfg.RedisAddr) == "" {
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
@ -16,9 +17,29 @@ import (
|
||||
|
||||
const h5LinkGroup = "h5-links"
|
||||
const roomRegionWhitelistGroup = "room-region-whitelist"
|
||||
const roomDiscoveryGroup = "room-discovery"
|
||||
const roomDiscoveryScopeKey = "scope"
|
||||
const roomDiscoveryScopeAllActiveRegions = "all_active_regions"
|
||||
|
||||
const runtimeCapabilitiesGroup = "runtime-capabilities"
|
||||
const activityLeaderboardScopeKey = "activity_leaderboard_scope"
|
||||
const resourceEquipCommandIDPolicyKey = "resource_equip_command_id_policy"
|
||||
|
||||
const (
|
||||
ActivityLeaderboardScopeAppGlobal = "app_global"
|
||||
ActivityLeaderboardScopeViewerRegion = "viewer_region"
|
||||
|
||||
ResourceEquipCommandIDPolicyLegacyOptional = "legacy_optional"
|
||||
ResourceEquipCommandIDPolicyRequired = "required"
|
||||
)
|
||||
|
||||
const defaultRoomDiscoveryCacheTTL = 30 * time.Second
|
||||
const defaultRuntimeCapabilitiesCacheTTL = 30 * time.Second
|
||||
|
||||
const listH5LinksSQL = "SELECT `key`, COALESCE(description, ''), COALESCE(value, ''), updated_at_ms FROM admin_app_configs WHERE app_code = ? AND `group` = ? AND is_deleted = FALSE ORDER BY `key` ASC"
|
||||
const getRoomRegionWhitelistSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE app_code = '' AND `group` = ? AND `key` = ? LIMIT 1"
|
||||
const getRoomDiscoveryScopeSQL = "SELECT COALESCE(value, '') FROM admin_app_configs WHERE app_code = ? AND `group` = ? AND `key` = ? AND is_deleted = FALSE LIMIT 1"
|
||||
const getRuntimeCapabilitiesSQL = "SELECT `key`, COALESCE(value, '') FROM admin_app_configs WHERE app_code = ? AND `group` = ? AND `key` IN (?, ?) AND is_deleted = FALSE"
|
||||
const bdLeaderPositionAliasSQL = `
|
||||
SELECT position_alias
|
||||
FROM admin_bd_leader_position_aliases
|
||||
@ -181,6 +202,17 @@ type Version struct {
|
||||
UpdatedAtMs int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
// RuntimeCapabilities 是后台按 App 保存的运行时行为选择;缺少单项时使用通用兼容默认值。
|
||||
type RuntimeCapabilities struct {
|
||||
ActivityLeaderboardScope string
|
||||
ResourceEquipCommandIDPolicy string
|
||||
}
|
||||
|
||||
// RuntimeCapabilityReader 把活动榜单和资源佩戴统一收敛到同一次 App 配置读取。
|
||||
type RuntimeCapabilityReader interface {
|
||||
GetRuntimeCapabilities(ctx context.Context, appCode string) (RuntimeCapabilities, error)
|
||||
}
|
||||
|
||||
// Reader 是 HTTP 层读取 H5 配置的最小依赖。
|
||||
type Reader interface {
|
||||
ListH5Links(ctx context.Context, query H5LinkQuery) ([]H5Link, error)
|
||||
@ -194,10 +226,25 @@ type Reader interface {
|
||||
|
||||
// MySQLReader 从 hyapp_admin 读取后台 App 配置。
|
||||
type MySQLReader struct {
|
||||
db *sql.DB
|
||||
cache *redis.Client
|
||||
cachePrefix string
|
||||
cacheTTL time.Duration
|
||||
db *sql.DB
|
||||
cache *redis.Client
|
||||
cachePrefix string
|
||||
cacheTTL time.Duration
|
||||
roomDiscoveryMu sync.Mutex
|
||||
roomDiscoveryCache map[string]roomDiscoveryCacheEntry
|
||||
roomDiscoveryCacheTTL time.Duration
|
||||
runtimeCapabilitiesMu sync.Mutex
|
||||
runtimeCapabilitiesCache map[string]runtimeCapabilitiesCacheEntry
|
||||
}
|
||||
|
||||
type roomDiscoveryCacheEntry struct {
|
||||
allActiveRegions bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type runtimeCapabilitiesCacheEntry struct {
|
||||
capabilities RuntimeCapabilities
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// OpenMySQLReader 创建后台配置只读连接池。
|
||||
@ -207,7 +254,27 @@ func OpenMySQLReader(dsn string) (*MySQLReader, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MySQLReader{db: db}, nil
|
||||
return &MySQLReader{
|
||||
db: db,
|
||||
roomDiscoveryCache: make(map[string]roomDiscoveryCacheEntry),
|
||||
roomDiscoveryCacheTTL: defaultRoomDiscoveryCacheTTL,
|
||||
runtimeCapabilitiesCache: make(map[string]runtimeCapabilitiesCacheEntry),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetRoomDiscoveryCacheTTL 明确后台更新的最长可见窗口。缓存仅保存一个 App 的枚举解析结果,过期后再查 admin DB。
|
||||
func (r *MySQLReader) SetRoomDiscoveryCacheTTL(ttl time.Duration) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = defaultRoomDiscoveryCacheTTL
|
||||
}
|
||||
r.roomDiscoveryMu.Lock()
|
||||
r.roomDiscoveryCacheTTL = ttl
|
||||
// TTL 变更通常发生在启动注入期;清空旧项可保证运行期重配不会沿用更长的旧过期时间。
|
||||
r.roomDiscoveryCache = make(map[string]roomDiscoveryCacheEntry)
|
||||
r.roomDiscoveryMu.Unlock()
|
||||
}
|
||||
|
||||
// Close 关闭后台配置连接池。
|
||||
@ -534,6 +601,126 @@ func (r *MySQLReader) RoomRegionWhitelistAllows(ctx context.Context, appCode str
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
// RoomDiscoveryAllRegions returns the configured discovery capability for one tenant.
|
||||
// Missing or invalid configuration fails closed to viewer-region scope.
|
||||
func (r *MySQLReader) RoomDiscoveryAllRegions(ctx context.Context, appCode string) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("app config reader is not configured")
|
||||
}
|
||||
appCode = normalizeAppCode(appCode)
|
||||
now := time.Now()
|
||||
if entry, ok := r.cachedRoomDiscovery(appCode, now); ok {
|
||||
return entry.allActiveRegions, nil
|
||||
}
|
||||
var scope string
|
||||
err := r.db.QueryRowContext(ctx, getRoomDiscoveryScopeSQL, appCode, roomDiscoveryGroup, roomDiscoveryScopeKey).Scan(&scope)
|
||||
if errors.Is(err, sql.ErrNoRows) || isMissingTableError(err) {
|
||||
scope = ""
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
allActiveRegions := strings.EqualFold(strings.TrimSpace(scope), roomDiscoveryScopeAllActiveRegions)
|
||||
r.roomDiscoveryMu.Lock()
|
||||
ttl := r.roomDiscoveryCacheTTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultRoomDiscoveryCacheTTL
|
||||
}
|
||||
if r.roomDiscoveryCache == nil {
|
||||
r.roomDiscoveryCache = make(map[string]roomDiscoveryCacheEntry)
|
||||
}
|
||||
// 缺失或非法枚举同样短暂缓存为 viewer_region;这既 fail closed,也避免错误配置把 DB 压力放大。
|
||||
r.roomDiscoveryCache[appCode] = roomDiscoveryCacheEntry{allActiveRegions: allActiveRegions, expiresAt: now.Add(ttl)}
|
||||
r.roomDiscoveryMu.Unlock()
|
||||
return allActiveRegions, nil
|
||||
}
|
||||
|
||||
// GetRuntimeCapabilities 在短 TTL 内复用每个 App 的配置;缓存过期后的并发请求允许各自查询,
|
||||
// 避免为极短查询维护不会回收的逐 App 锁。查询命中 (app_code, group) 索引且最多返回两行。
|
||||
func (r *MySQLReader) GetRuntimeCapabilities(ctx context.Context, appCode string) (RuntimeCapabilities, error) {
|
||||
defaults := defaultRuntimeCapabilities()
|
||||
if r == nil || r.db == nil {
|
||||
return defaults, errors.New("app config reader is not configured")
|
||||
}
|
||||
appCode = normalizeAppCode(appCode)
|
||||
now := time.Now()
|
||||
if entry, ok := r.cachedRuntimeCapabilities(appCode, now); ok {
|
||||
return entry.capabilities, nil
|
||||
}
|
||||
capabilities, err := r.runtimeCapabilitiesFromDB(ctx, appCode)
|
||||
if err != nil {
|
||||
return defaults, err
|
||||
}
|
||||
r.storeRuntimeCapabilitiesCache(appCode, capabilities, now.Add(defaultRuntimeCapabilitiesCacheTTL))
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
func (r *MySQLReader) runtimeCapabilitiesFromDB(ctx context.Context, appCode string) (RuntimeCapabilities, error) {
|
||||
capabilities := defaultRuntimeCapabilities()
|
||||
rows, err := r.db.QueryContext(ctx, getRuntimeCapabilitiesSQL, appCode, runtimeCapabilitiesGroup, activityLeaderboardScopeKey, resourceEquipCommandIDPolicyKey)
|
||||
if isMissingTableError(err) {
|
||||
return capabilities, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RuntimeCapabilities{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var value string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
return RuntimeCapabilities{}, err
|
||||
}
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch strings.TrimSpace(key) {
|
||||
case activityLeaderboardScopeKey:
|
||||
if value != ActivityLeaderboardScopeAppGlobal && value != ActivityLeaderboardScopeViewerRegion {
|
||||
return RuntimeCapabilities{}, errors.New("invalid activity leaderboard scope")
|
||||
}
|
||||
capabilities.ActivityLeaderboardScope = value
|
||||
case resourceEquipCommandIDPolicyKey:
|
||||
if value != ResourceEquipCommandIDPolicyLegacyOptional && value != ResourceEquipCommandIDPolicyRequired {
|
||||
return RuntimeCapabilities{}, errors.New("invalid resource equip command id policy")
|
||||
}
|
||||
capabilities.ResourceEquipCommandIDPolicy = value
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return RuntimeCapabilities{}, err
|
||||
}
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
func defaultRuntimeCapabilities() RuntimeCapabilities {
|
||||
return RuntimeCapabilities{
|
||||
ActivityLeaderboardScope: ActivityLeaderboardScopeAppGlobal,
|
||||
ResourceEquipCommandIDPolicy: ResourceEquipCommandIDPolicyLegacyOptional,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MySQLReader) cachedRuntimeCapabilities(appCode string, now time.Time) (runtimeCapabilitiesCacheEntry, bool) {
|
||||
r.runtimeCapabilitiesMu.Lock()
|
||||
defer r.runtimeCapabilitiesMu.Unlock()
|
||||
entry, ok := r.runtimeCapabilitiesCache[appCode]
|
||||
return entry, ok && now.Before(entry.expiresAt)
|
||||
}
|
||||
|
||||
func (r *MySQLReader) storeRuntimeCapabilitiesCache(appCode string, capabilities RuntimeCapabilities, expiresAt time.Time) {
|
||||
r.runtimeCapabilitiesMu.Lock()
|
||||
defer r.runtimeCapabilitiesMu.Unlock()
|
||||
if r.runtimeCapabilitiesCache == nil {
|
||||
r.runtimeCapabilitiesCache = make(map[string]runtimeCapabilitiesCacheEntry)
|
||||
}
|
||||
r.runtimeCapabilitiesCache[appCode] = runtimeCapabilitiesCacheEntry{capabilities: capabilities, expiresAt: expiresAt}
|
||||
}
|
||||
|
||||
func (r *MySQLReader) cachedRoomDiscovery(appCode string, now time.Time) (roomDiscoveryCacheEntry, bool) {
|
||||
r.roomDiscoveryMu.Lock()
|
||||
defer r.roomDiscoveryMu.Unlock()
|
||||
entry, ok := r.roomDiscoveryCache[appCode]
|
||||
return entry, ok && now.Before(entry.expiresAt)
|
||||
}
|
||||
|
||||
func (r *MySQLReader) roomRegionWhitelistAllowsFromDB(ctx context.Context, appCode string, userID int64) (bool, error) {
|
||||
var raw string
|
||||
err := r.db.QueryRowContext(ctx, getRoomRegionWhitelistSQL, roomRegionWhitelistGroup, appCode).Scan(&raw)
|
||||
@ -611,15 +798,17 @@ func parseRoomRegionWhitelist(raw string) (map[int64]struct{}, error) {
|
||||
|
||||
// StaticReader 给测试和临时环境提供内存版 H5 配置读取。
|
||||
type StaticReader struct {
|
||||
Links []H5Link
|
||||
ExploreTabs []ExploreTab
|
||||
Banners []Banner
|
||||
SplashScreens []SplashScreen
|
||||
Popups []Popup
|
||||
Version Version
|
||||
Err error
|
||||
PositionAliases map[string]string
|
||||
RoomWhitelist map[string]bool
|
||||
Links []H5Link
|
||||
ExploreTabs []ExploreTab
|
||||
Banners []Banner
|
||||
SplashScreens []SplashScreen
|
||||
Popups []Popup
|
||||
Version Version
|
||||
Err error
|
||||
PositionAliases map[string]string
|
||||
RoomWhitelist map[string]bool
|
||||
RoomDiscoveryAll map[string]bool
|
||||
RuntimeCapabilitiesByApp map[string]RuntimeCapabilities
|
||||
}
|
||||
|
||||
// ListH5Links 返回预置 H5 配置。
|
||||
@ -708,6 +897,26 @@ func (r StaticReader) RoomRegionWhitelistAllows(_ context.Context, appCode strin
|
||||
return r.RoomWhitelist[bdLeaderPositionAliasKey(appCode, userID)], nil
|
||||
}
|
||||
|
||||
// RoomDiscoveryAllRegions returns the preconfigured tenant capability in tests and local tools.
|
||||
func (r StaticReader) RoomDiscoveryAllRegions(_ context.Context, appCode string) (bool, error) {
|
||||
if r.Err != nil {
|
||||
return false, r.Err
|
||||
}
|
||||
return r.RoomDiscoveryAll[normalizeAppCode(appCode)], nil
|
||||
}
|
||||
|
||||
// GetRuntimeCapabilities 返回测试预置的 App 能力;未预置时与生产缺行行为一致使用通用默认值。
|
||||
func (r StaticReader) GetRuntimeCapabilities(_ context.Context, appCode string) (RuntimeCapabilities, error) {
|
||||
if r.Err != nil {
|
||||
return RuntimeCapabilities{}, r.Err
|
||||
}
|
||||
capabilities, ok := r.RuntimeCapabilitiesByApp[normalizeAppCode(appCode)]
|
||||
if !ok {
|
||||
return defaultRuntimeCapabilities(), nil
|
||||
}
|
||||
return capabilities, nil
|
||||
}
|
||||
|
||||
func normalizeAppCode(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
|
||||
@ -4,10 +4,100 @@ import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestRoomDiscoveryAllRegionsCachesPerAppUntilTTL(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("new sql mock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(getRoomDiscoveryScopeSQL)).
|
||||
WithArgs("wide-app", roomDiscoveryGroup, roomDiscoveryScopeKey).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow(roomDiscoveryScopeAllActiveRegions))
|
||||
reader := &MySQLReader{
|
||||
db: db,
|
||||
roomDiscoveryCache: make(map[string]roomDiscoveryCacheEntry),
|
||||
roomDiscoveryCacheTTL: time.Minute,
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
allowed, err := reader.RoomDiscoveryAllRegions(context.Background(), " WIDE-APP ")
|
||||
if err != nil || !allowed {
|
||||
t.Fatalf("cached all-region scope attempt %d failed: allowed=%t err=%v", attempt, allowed, err)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("scope must query DB only once inside TTL: %v", err)
|
||||
}
|
||||
|
||||
reader.roomDiscoveryMu.Lock()
|
||||
entry := reader.roomDiscoveryCache["wide-app"]
|
||||
entry.expiresAt = time.Now().Add(-time.Second)
|
||||
reader.roomDiscoveryCache["wide-app"] = entry
|
||||
reader.roomDiscoveryMu.Unlock()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(getRoomDiscoveryScopeSQL)).
|
||||
WithArgs("wide-app", roomDiscoveryGroup, roomDiscoveryScopeKey).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("viewer_region"))
|
||||
allowed, err := reader.RoomDiscoveryAllRegions(context.Background(), "wide-app")
|
||||
if err != nil || allowed {
|
||||
t.Fatalf("expired scope did not refresh to viewer-region: allowed=%t err=%v", allowed, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expired scope refresh mismatch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCapabilitiesReadBothKeysOnceAndCachePerApp(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("new sql mock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(getRuntimeCapabilitiesSQL)).
|
||||
WithArgs("tenant-a", runtimeCapabilitiesGroup, activityLeaderboardScopeKey, resourceEquipCommandIDPolicyKey).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "value"}).
|
||||
AddRow(activityLeaderboardScopeKey, ActivityLeaderboardScopeViewerRegion).
|
||||
AddRow(resourceEquipCommandIDPolicyKey, ResourceEquipCommandIDPolicyRequired))
|
||||
reader := &MySQLReader{
|
||||
db: db,
|
||||
runtimeCapabilitiesCache: make(map[string]runtimeCapabilitiesCacheEntry),
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
capabilities, err := reader.GetRuntimeCapabilities(context.Background(), " TENANT-A ")
|
||||
if err != nil || capabilities.ActivityLeaderboardScope != ActivityLeaderboardScopeViewerRegion || capabilities.ResourceEquipCommandIDPolicy != ResourceEquipCommandIDPolicyRequired {
|
||||
t.Fatalf("runtime capabilities attempt %d mismatch: config=%+v err=%v", attempt, capabilities, err)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("runtime capabilities must query DB once inside TTL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCapabilitiesMissingRowsUseGenericDefaults(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("new sql mock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(getRuntimeCapabilitiesSQL)).
|
||||
WithArgs("future-app", runtimeCapabilitiesGroup, activityLeaderboardScopeKey, resourceEquipCommandIDPolicyKey).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "value"}))
|
||||
|
||||
reader := &MySQLReader{db: db}
|
||||
capabilities, err := reader.GetRuntimeCapabilities(context.Background(), "future-app")
|
||||
if err != nil {
|
||||
t.Fatalf("missing runtime capabilities failed: %v", err)
|
||||
}
|
||||
if capabilities.ActivityLeaderboardScope != ActivityLeaderboardScopeAppGlobal || capabilities.ResourceEquipCommandIDPolicy != ResourceEquipCommandIDPolicyLegacyOptional {
|
||||
t.Fatalf("missing runtime capability defaults mismatch: %+v", capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListH5LinksReadsOnlyRequestedApp(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@ -105,10 +105,6 @@ func (c *grpcWalletClient) GetHostRevenueStats(ctx context.Context, req *walletv
|
||||
return c.client.GetHostRevenueStats(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetAgencyPointShareStats(ctx context.Context, req *walletv1.GetAgencyPointShareStatsRequest) (*walletv1.GetAgencyPointShareStatsResponse, error) {
|
||||
return c.client.GetAgencyPointShareStats(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetAgencyHostGiftStats(ctx context.Context, req *walletv1.GetAgencyHostGiftStatsRequest) (*walletv1.GetAgencyHostGiftStatsResponse, error) {
|
||||
return c.client.GetAgencyHostGiftStats(ctx, req)
|
||||
}
|
||||
|
||||
@ -130,6 +130,8 @@ type AppConfigConfig struct {
|
||||
KeyPrefix string `yaml:"key_prefix"`
|
||||
// CacheTTL 控制后台配置读缓存时间;房间白名单默认五分钟。
|
||||
CacheTTL time.Duration `yaml:"cache_ttl"`
|
||||
// RoomDiscoveryCacheTTL 控制房间发现范围的本进程缓存;后台更新最多经过该时长对单个 gateway 实例可见。
|
||||
RoomDiscoveryCacheTTL time.Duration `yaml:"room_discovery_cache_ttl"`
|
||||
}
|
||||
|
||||
// LeaderboardConfig 描述 App 活动榜单读取 Redis 聚合读模型的只读数据源。
|
||||
@ -379,10 +381,11 @@ func Default() Config {
|
||||
RetryableStatusCodes: []string{"UNAVAILABLE"},
|
||||
},
|
||||
AppConfig: AppConfigConfig{
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
RedisAddr: "127.0.0.1:13379",
|
||||
KeyPrefix: "gateway:app_config:",
|
||||
CacheTTL: 5 * time.Minute,
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
RedisAddr: "127.0.0.1:13379",
|
||||
KeyPrefix: "gateway:app_config:",
|
||||
CacheTTL: 5 * time.Minute,
|
||||
RoomDiscoveryCacheTTL: 30 * time.Second,
|
||||
},
|
||||
Leaderboard: LeaderboardConfig{
|
||||
RedisAddr: "127.0.0.1:13379",
|
||||
@ -565,6 +568,9 @@ func (cfg *Config) Normalize() error {
|
||||
if cfg.AppConfig.CacheTTL <= 0 {
|
||||
cfg.AppConfig.CacheTTL = 5 * time.Minute
|
||||
}
|
||||
if cfg.AppConfig.RoomDiscoveryCacheTTL <= 0 {
|
||||
cfg.AppConfig.RoomDiscoveryCacheTTL = 30 * time.Second
|
||||
}
|
||||
cfg.Leaderboard.WalletMySQLDSN = strings.TrimSpace(cfg.Leaderboard.WalletMySQLDSN)
|
||||
cfg.Leaderboard.RedisAddr = strings.TrimSpace(cfg.Leaderboard.RedisAddr)
|
||||
cfg.Leaderboard.KeyPrefix = strings.TrimSpace(cfg.Leaderboard.KeyPrefix)
|
||||
|
||||
@ -281,9 +281,14 @@ func validateCreateApplicationCommand(command CreateApplicationCommand) error {
|
||||
case command.CreatedAtMS <= 0:
|
||||
return errors.New("created_at_ms is required")
|
||||
default:
|
||||
if command.SalaryAssetType == "POINT" || command.SalaryAssetType == "COIN_SELLER_POINT" {
|
||||
if command.SalaryAssetType == "POINT" || command.SalaryAssetType == "POINT_DIAMOND" || command.SalaryAssetType == "COIN_SELLER_POINT" {
|
||||
// POINT 的政策快照会被财务审核原样复用;写入前先验证内部一致性,避免冻结成功但审核无法结算。
|
||||
if command.PointsPerUSD <= 0 || command.PointFeeBPS < 0 || command.PointFeeBPS > 10000 || command.PointFeeAmount < 0 || command.PointNetAmount < 0 || command.PointFeeAmount+command.PointNetAmount != command.WithdrawAmountMinor || command.PointFeeAmount != command.WithdrawAmountMinor*int64(command.PointFeeBPS)/10000 {
|
||||
feeMatchesPolicy := command.PointFeeAmount == command.WithdrawAmountMinor*int64(command.PointFeeBPS)/10000
|
||||
if command.SalaryAssetType == "POINT_DIAMOND" {
|
||||
// 永久积分先按美元分 floor 手续费,不能从积分数量反推同一舍入结果。
|
||||
feeMatchesPolicy = true
|
||||
}
|
||||
if command.PointsPerUSD <= 0 || command.PointFeeBPS < 0 || command.PointFeeBPS > 10000 || command.PointFeeAmount < 0 || command.PointNetAmount < 0 || command.PointFeeAmount+command.PointNetAmount != command.WithdrawAmountMinor || !feeMatchesPolicy {
|
||||
return errors.New("point withdrawal policy snapshot is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/pkg/userleaderboard"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/client"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httproutes"
|
||||
)
|
||||
@ -19,6 +20,7 @@ type Handler struct {
|
||||
roomQueryClient client.RoomQueryClient
|
||||
walletClient client.WalletClient
|
||||
userLeaderboard UserLeaderboardStore
|
||||
runtimeCapabilities appconfig.RuntimeCapabilityReader
|
||||
registrationReward client.RegistrationRewardClient
|
||||
firstRechargeReward client.FirstRechargeRewardClient
|
||||
cumulativeRecharge client.CumulativeRechargeRewardClient
|
||||
@ -55,6 +57,7 @@ type Config struct {
|
||||
RoomQueryClient client.RoomQueryClient
|
||||
WalletClient client.WalletClient
|
||||
UserLeaderboard UserLeaderboardStore
|
||||
RuntimeCapabilities appconfig.RuntimeCapabilityReader
|
||||
RegistrationReward client.RegistrationRewardClient
|
||||
FirstRechargeReward client.FirstRechargeRewardClient
|
||||
CumulativeRecharge client.CumulativeRechargeRewardClient
|
||||
@ -79,6 +82,7 @@ func New(config Config) *Handler {
|
||||
roomQueryClient: config.RoomQueryClient,
|
||||
walletClient: config.WalletClient,
|
||||
userLeaderboard: config.UserLeaderboard,
|
||||
runtimeCapabilities: config.RuntimeCapabilities,
|
||||
registrationReward: config.RegistrationReward,
|
||||
firstRechargeReward: config.FirstRechargeReward,
|
||||
cumulativeRecharge: config.CumulativeRecharge,
|
||||
|
||||
@ -105,7 +105,9 @@ func (f *fakeInviteActivityRewardClient) ListInviteActivityLeaderboard(_ context
|
||||
|
||||
type fakeInviteActivityUserProfileClient struct {
|
||||
users map[int64]*userv1.User
|
||||
user *userv1.User
|
||||
lastBatch *userv1.BatchGetUsersRequest
|
||||
getCalls int
|
||||
}
|
||||
|
||||
func (f *fakeInviteActivityUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
|
||||
@ -118,7 +120,8 @@ func (f *fakeInviteActivityUserProfileClient) BatchGetRoomBasicUsers(context.Con
|
||||
}
|
||||
|
||||
func (f *fakeInviteActivityUserProfileClient) GetUser(context.Context, *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
|
||||
return &userv1.GetUserResponse{}, nil
|
||||
f.getCalls++
|
||||
return &userv1.GetUserResponse{User: f.user}, nil
|
||||
}
|
||||
|
||||
func (f *fakeInviteActivityUserProfileClient) BusinessUserLookup(context.Context, *userv1.BusinessUserLookupRequest) (*userv1.BusinessUserLookupResponse, error) {
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/userleaderboard"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
|
||||
@ -99,7 +100,21 @@ func (h *Handler) listUserLeaderboards(writer http.ResponseWriter, request *http
|
||||
app := appcode.FromContext(request.Context())
|
||||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||||
regionID := int64(0)
|
||||
if appcode.UsesRegionalLeaderboards(app) {
|
||||
leaderboardScope := appconfig.ActivityLeaderboardScopeAppGlobal
|
||||
if h.runtimeCapabilities != nil {
|
||||
capabilities, err := h.runtimeCapabilities.GetRuntimeCapabilities(request.Context(), app)
|
||||
if err != nil {
|
||||
// 配置读取失败时不能擅自从区域榜切到全 App 榜,否则会在后台库故障期间扩大数据可见范围。
|
||||
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
leaderboardScope = capabilities.ActivityLeaderboardScope
|
||||
}
|
||||
if leaderboardScope != appconfig.ActivityLeaderboardScopeAppGlobal && leaderboardScope != appconfig.ActivityLeaderboardScopeViewerRegion {
|
||||
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
if leaderboardScope == appconfig.ActivityLeaderboardScopeViewerRegion {
|
||||
if h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
|
||||
@ -10,9 +10,11 @@ import (
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/userleaderboard"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
@ -141,7 +143,17 @@ func TestListUserLeaderboardsReadsRedisStore(t *testing.T) {
|
||||
},
|
||||
rankFound: true,
|
||||
}
|
||||
handler := &Handler{userLeaderboard: store}
|
||||
capabilityReader := &fakeRuntimeCapabilityReader{capabilities: appconfig.RuntimeCapabilities{
|
||||
ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeViewerRegion,
|
||||
ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyLegacyOptional,
|
||||
}}
|
||||
handler := &Handler{
|
||||
userLeaderboard: store,
|
||||
runtimeCapabilities: capabilityReader,
|
||||
userProfileClient: &fakeInviteActivityUserProfileClient{user: &userv1.User{
|
||||
AppCode: "hyapp_room", RegionId: 1001,
|
||||
}},
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/user-leaderboards?board_type=sent&period=week&page=1&page_size=20", nil)
|
||||
ctx := appcode.WithContext(request.Context(), "hyapp_room")
|
||||
ctx = auth.WithUserID(ctx, 10001)
|
||||
@ -155,7 +167,7 @@ func TestListUserLeaderboardsReadsRedisStore(t *testing.T) {
|
||||
if store.listCalls != 1 || store.rankCalls != 1 {
|
||||
t.Fatalf("store call count mismatch: list=%d rank=%d", store.listCalls, store.rankCalls)
|
||||
}
|
||||
if store.listQuery.AppCode != "hyapp_room" || store.listQuery.BoardType != leaderboardTypeSent || store.listQuery.Period != leaderboardPeriodWeek || store.listQuery.Page != 1 || store.listQuery.PageSize != 20 {
|
||||
if capabilityReader.calls != 1 || store.listQuery.RegionID != 1001 || store.listQuery.AppCode != "hyapp_room" || store.listQuery.BoardType != leaderboardTypeSent || store.listQuery.Period != leaderboardPeriodWeek || store.listQuery.Page != 1 || store.listQuery.PageSize != 20 {
|
||||
t.Fatalf("list query mismatch: %+v", store.listQuery)
|
||||
}
|
||||
|
||||
@ -200,7 +212,17 @@ func TestListUserLeaderboardsAcceptsGameBoard(t *testing.T) {
|
||||
},
|
||||
rankFound: true,
|
||||
}
|
||||
handler := &Handler{userLeaderboard: store}
|
||||
profileReader := &fakeInviteActivityUserProfileClient{user: &userv1.User{
|
||||
AppCode: "huwaa", RegionId: 1001,
|
||||
}}
|
||||
handler := &Handler{
|
||||
userLeaderboard: store,
|
||||
runtimeCapabilities: &fakeRuntimeCapabilityReader{capabilities: appconfig.RuntimeCapabilities{
|
||||
ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeAppGlobal,
|
||||
ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyLegacyOptional,
|
||||
}},
|
||||
userProfileClient: profileReader,
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/user-leaderboards?board_type=gaming&period=today&page=1&page_size=20", nil)
|
||||
ctx := appcode.WithContext(request.Context(), "huwaa")
|
||||
ctx = auth.WithUserID(ctx, 10001)
|
||||
@ -211,7 +233,7 @@ func TestListUserLeaderboardsAcceptsGameBoard(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if store.listCalls != 1 || store.rankCalls != 1 || store.listQuery.AppCode != "huwaa" || store.listQuery.BoardType != leaderboardTypeGame || store.rankQuery.BoardType != leaderboardTypeGame {
|
||||
if profileReader.getCalls != 0 || store.listCalls != 1 || store.rankCalls != 1 || store.listQuery.RegionID != 0 || store.listQuery.AppCode != "huwaa" || store.listQuery.BoardType != leaderboardTypeGame || store.rankQuery.BoardType != leaderboardTypeGame {
|
||||
t.Fatalf("game query should use its own Redis bucket: list=%+v rank=%+v", store.listQuery, store.rankQuery)
|
||||
}
|
||||
var envelope struct {
|
||||
@ -226,6 +248,17 @@ func TestListUserLeaderboardsAcceptsGameBoard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRuntimeCapabilityReader struct {
|
||||
capabilities appconfig.RuntimeCapabilities
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeRuntimeCapabilityReader) GetRuntimeCapabilities(context.Context, string) (appconfig.RuntimeCapabilities, error) {
|
||||
f.calls++
|
||||
return f.capabilities, f.err
|
||||
}
|
||||
|
||||
type fakeUserLeaderboardStore struct {
|
||||
listCalls int
|
||||
rankCalls int
|
||||
|
||||
@ -25,6 +25,12 @@ type fakeGiftCapacityLimiter struct {
|
||||
releases int
|
||||
}
|
||||
|
||||
func giftTestHostAuthority(handler *Handler) *Handler {
|
||||
// 成功路径显式装配 Host owner,并让空映射表示这些测试 target 不是 Host;生产不允许缺依赖时降级。
|
||||
handler.SetUserHostClient(&fakeUserHostClient{})
|
||||
return handler
|
||||
}
|
||||
|
||||
func (f *fakeGiftCapacityLimiter) Acquire(_ context.Context, input giftlimit.Input) (giftlimit.Decision, error) {
|
||||
f.inputs = append(f.inputs, input)
|
||||
return f.decision, f.err
|
||||
@ -38,7 +44,7 @@ func (f *fakeGiftCapacityLimiter) Release(context.Context, giftlimit.Lease) erro
|
||||
func TestGiftCapacityLimitWeightsUniqueTargetsAndReleasesInFlightSlot(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-weighted","target_user_ids":[43,44,43,0],"gift_id":"rose","gift_count":1}`)))
|
||||
@ -67,7 +73,7 @@ func TestGiftCapacityLimitWeightsUniqueTargetsAndReleasesInFlightSlot(t *testing
|
||||
|
||||
func TestGiftCapacityLimitPreservesSingleTargetWeight(t *testing.T) {
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-single","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
||||
@ -86,7 +92,7 @@ func TestGiftCapacityLimitPreservesSingleTargetWeight(t *testing.T) {
|
||||
|
||||
func TestGiftCapacityLimitWeightsGiftCountTimesUniqueTargets(t *testing.T) {
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-units","target_user_ids":[43,44,43],"gift_id":"rose","gift_count":99}`)))
|
||||
@ -117,7 +123,7 @@ func TestGiftCapacityLimitRejectsExplosiveGiftUnitsBeforeRedis(t *testing.T) {
|
||||
}
|
||||
roomClient := &fakeRoomClient{}
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
|
||||
@ -134,7 +140,7 @@ func TestGiftCapacityLimitRejectsExplosiveGiftUnitsBeforeRedis(t *testing.T) {
|
||||
func TestGiftCapacityLimitRejectsGiftCountAboveV2Contract(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-count-overflow","target_user_id":43,"gift_id":"rose","gift_count":1000}`)))
|
||||
@ -159,7 +165,7 @@ func TestSendGiftV2PreservesV1FieldsAndForwardsStableBatchIdentity(t *testing.T)
|
||||
SettledGiftCount: 9, SettledTargetUserIds: []int64{43}, OperationStatus: "committed",
|
||||
},
|
||||
}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"combo-1_b1","combo_session_id":"combo-1","batch_seq":1,"target_user_id":43,"gift_id":"rose","gift_count":9}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
@ -222,7 +228,7 @@ func TestSendGiftV1RoutesPreserveTopLevelFieldsAndStripNestedV2(t *testing.T) {
|
||||
ApiVersion: 2, CommandId: "internal-result", OperationStatus: "committed",
|
||||
},
|
||||
}}
|
||||
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
router := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -265,7 +271,7 @@ func TestSendGiftV1RoutesPreserveTopLevelFieldsAndStripNestedV2(t *testing.T) {
|
||||
|
||||
func TestSendGiftV2RequiresComboIdentityBeforeDownstream(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-no-combo","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
@ -291,7 +297,7 @@ func TestGiftCapacityLimitReturns429EnvelopeBeforeDownstreamCalls(t *testing.T)
|
||||
roomClient := &fakeRoomClient{}
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{RetryAfter: 275 * time.Millisecond}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, profileClient)
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, profileClient))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, endpoint.path, bytes.NewReader([]byte(endpoint.body)))
|
||||
@ -333,7 +339,7 @@ func TestGiftCapacityLimitReturns429EnvelopeBeforeDownstreamCalls(t *testing.T)
|
||||
func TestGiftCapacityLimitFailsClosedWhenRedisUnavailable(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
limiter := &fakeGiftCapacityLimiter{err: errors.New("redis unavailable")}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-error","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
||||
@ -368,7 +374,7 @@ func TestGiftCapacityLimitBlockedRedisFailsClosedQuickly(t *testing.T) {
|
||||
defer redisClient.Close()
|
||||
limiter := giftlimit.NewRedisLimiter(redisClient, giftlimit.Config{RedisOperationTimeout: 50 * time.Millisecond})
|
||||
roomClient := &fakeRoomClient{}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-blocked-redis","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
||||
@ -401,7 +407,7 @@ func TestGiftCapacityLimitRejectsTooManyUniqueTargetsBeforeRedis(t *testing.T) {
|
||||
}
|
||||
roomClient := &fakeRoomClient{}
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
|
||||
@ -418,7 +424,7 @@ func TestGiftCapacityLimitRejectsTooManyUniqueTargetsBeforeRedis(t *testing.T) {
|
||||
func TestGiftCapacityLimitRejectsUnsupportedTargetTypeBeforeRedis(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-unsupported","target_type":"all_mic","gift_id":"rose","gift_count":1}`)))
|
||||
@ -449,7 +455,7 @@ func TestGiftCapacityLimitAppliesMaximumAfterTargetDeduplication(t *testing.T) {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{RetryAfter: 10 * time.Millisecond}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
|
||||
@ -466,7 +472,7 @@ func TestGiftCapacityLimitAppliesMaximumAfterTargetDeduplication(t *testing.T) {
|
||||
func TestGiftRequestTotalDeadlineReleasesInFlightLease(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{sendGiftWaitForContext: true}
|
||||
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}))
|
||||
handler.SetGiftCapacityProtection(true, 40*time.Millisecond, 30, limiter)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-timeout","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
||||
|
||||
@ -310,6 +310,12 @@ type WalletHandlers struct {
|
||||
WithdrawPointWallet http.HandlerFunc
|
||||
TransferPointWalletToCoinSeller http.HandlerFunc
|
||||
ExchangePointWalletToCoins http.HandlerFunc
|
||||
GetPointDiamondWalletOverview http.HandlerFunc
|
||||
GetPointDiamondWalletHistory http.HandlerFunc
|
||||
ExchangePointDiamondWalletToCoins http.HandlerFunc
|
||||
WithdrawPointDiamondWallet http.HandlerFunc
|
||||
TransferPointDiamondToCoinSeller http.HandlerFunc
|
||||
HandlePointDiamondWithdrawAddress http.HandlerFunc
|
||||
GetRedPacketConfig http.HandlerFunc
|
||||
ListRoomRedPackets http.HandlerFunc
|
||||
CreateRoomRedPacket http.HandlerFunc
|
||||
@ -740,6 +746,14 @@ func (r routes) registerWalletRoutes() {
|
||||
r.profile("/point-wallet/withdraw", http.MethodPost, h.WithdrawPointWallet)
|
||||
r.profile("/point-wallet/transfer-to-coin-seller", http.MethodPost, h.TransferPointWalletToCoinSeller)
|
||||
r.profile("/point-wallet/exchange-to-coins", http.MethodPost, h.ExchangePointWalletToCoins)
|
||||
// 永久钻石积分钱包只要求有效登录。各 handler 按资金动作自行 GetUser 获取实时 region,
|
||||
// 不能让 profile-completed 中间件阻断已有成功 command_id 的幂等重试。
|
||||
r.auth("/point-diamond-wallet/overview", http.MethodGet, h.GetPointDiamondWalletOverview)
|
||||
r.auth("/point-diamond-wallet/history", http.MethodGet, h.GetPointDiamondWalletHistory)
|
||||
r.auth("/point-diamond-wallet/exchange-to-coins", http.MethodPost, h.ExchangePointDiamondWalletToCoins)
|
||||
r.auth("/point-diamond-wallet/withdraw", http.MethodPost, h.WithdrawPointDiamondWallet)
|
||||
r.auth("/point-diamond-wallet/transfer-to-coin-seller", http.MethodPost, h.TransferPointDiamondToCoinSeller)
|
||||
r.auth("/point-diamond-wallet/withdraw-address", "", h.HandlePointDiamondWithdrawAddress)
|
||||
r.profile("/red-packets/config", http.MethodGet, h.GetRedPacketConfig)
|
||||
r.profile("/rooms/{room_id}/red-packets", http.MethodGet, h.ListRoomRedPackets)
|
||||
r.profile("/rooms/{room_id}/red-packets/create", http.MethodPost, h.CreateRoomRedPacket)
|
||||
|
||||
@ -3,21 +3,24 @@ package resourceapi
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/client"
|
||||
)
|
||||
|
||||
// Handler owns public resource catalog and current-user resource inventory endpoints.
|
||||
// Wallet-service remains the owner for entitlement and grant semantics.
|
||||
type Handler struct {
|
||||
walletClient client.WalletClient
|
||||
walletClient client.WalletClient
|
||||
runtimeCapabilities appconfig.RuntimeCapabilityReader
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
WalletClient client.WalletClient
|
||||
WalletClient client.WalletClient
|
||||
RuntimeCapabilities appconfig.RuntimeCapabilityReader
|
||||
}
|
||||
|
||||
func New(config Config) *Handler {
|
||||
return &Handler{walletClient: config.WalletClient}
|
||||
return &Handler{walletClient: config.WalletClient, runtimeCapabilities: config.RuntimeCapabilities}
|
||||
}
|
||||
|
||||
func (h *Handler) ListResources(writer http.ResponseWriter, request *http.Request) {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/resourceview"
|
||||
)
|
||||
@ -496,12 +497,25 @@ func (h *Handler) equipMyResource(writer http.ResponseWriter, request *http.Requ
|
||||
}
|
||||
appCode := appcode.FromContext(request.Context())
|
||||
if commandID == "" {
|
||||
if appCode == "fami" {
|
||||
// Fami tiered VIP 是本次新契约,不能静默用每次都会变化的 request_id 伪装业务幂等。
|
||||
policy := appconfig.ResourceEquipCommandIDPolicyLegacyOptional
|
||||
if h.runtimeCapabilities != nil {
|
||||
capabilities, err := h.runtimeCapabilities.GetRuntimeCapabilities(request.Context(), appCode)
|
||||
if err != nil {
|
||||
// 冷缓存读取失败时不生成降级键;这避免 required App 因后台库故障被静默放宽幂等约束。
|
||||
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
policy = capabilities.ResourceEquipCommandIDPolicy
|
||||
}
|
||||
if policy == appconfig.ResourceEquipCommandIDPolicyRequired {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
// Lalu 等旧客户端没有业务 command_id;用本次 request_id 保持原有可调用性,但不承诺跨请求重放。
|
||||
if policy != appconfig.ResourceEquipCommandIDPolicyLegacyOptional {
|
||||
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
// 只有后台明确保持 legacy_optional 时才为旧客户端生成请求级兼容键;新客户端仍应传稳定 command_id。
|
||||
commandID = "legacy_equip:" + httpkit.RequestIDFromContext(request.Context())
|
||||
}
|
||||
resp, err := h.walletClient.EquipUserResource(request.Context(), &walletv1.EquipUserResourceRequest{
|
||||
|
||||
@ -385,6 +385,7 @@ type fakeTencentIMAccountImporter struct {
|
||||
lastUserID int64
|
||||
lastNickname string
|
||||
lastFaceURL string
|
||||
userIDs []int64
|
||||
err error
|
||||
}
|
||||
|
||||
@ -392,6 +393,7 @@ func (f *fakeTencentIMAccountImporter) ImportAccount(_ context.Context, userID i
|
||||
f.lastUserID = userID
|
||||
f.lastNickname = nickname
|
||||
f.lastFaceURL = faceURL
|
||||
f.userIDs = append(f.userIDs, userID)
|
||||
return f.err
|
||||
}
|
||||
|
||||
@ -606,6 +608,7 @@ type fakeWalletClient struct {
|
||||
balancesByUserID map[int64]*walletv1.GetBalancesResponse
|
||||
err error
|
||||
lastDirectGift *walletv1.DebitDirectGiftRequest
|
||||
directGiftRequests []*walletv1.DebitDirectGiftRequest
|
||||
directGiftResp *walletv1.DebitGiftResponse
|
||||
directGiftErr error
|
||||
lastHostSalaryPolicy *walletv1.GetActiveHostSalaryPolicyRequest
|
||||
@ -728,6 +731,7 @@ type fakeWalletClient struct {
|
||||
lastListUserResources *walletv1.ListUserResourcesRequest
|
||||
listUserResourcesCalls int
|
||||
listUserResourcesResp *walletv1.ListUserResourcesResponse
|
||||
lastEquipUserResource *walletv1.EquipUserResourceRequest
|
||||
lastGrantResource *walletv1.GrantResourceRequest
|
||||
grantResourceRequests []*walletv1.GrantResourceRequest
|
||||
grantResourceResp *walletv1.ResourceGrantResponse
|
||||
@ -2155,6 +2159,7 @@ func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalan
|
||||
|
||||
func (f *fakeWalletClient) DebitDirectGift(_ context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||
f.lastDirectGift = req
|
||||
f.directGiftRequests = append(f.directGiftRequests, req)
|
||||
if f.directGiftErr != nil {
|
||||
return nil, f.directGiftErr
|
||||
}
|
||||
@ -2810,7 +2815,8 @@ func (f *fakeWalletClient) ListUserResources(_ context.Context, req *walletv1.Li
|
||||
return &walletv1.ListUserResourcesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) EquipUserResource(context.Context, *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) {
|
||||
func (f *fakeWalletClient) EquipUserResource(_ context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) {
|
||||
f.lastEquipUserResource = req
|
||||
return &walletv1.EquipUserResourceResponse{}, nil
|
||||
}
|
||||
|
||||
@ -3198,7 +3204,7 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
router := giftTestHostAuthority(NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001})).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-"+test.name)
|
||||
@ -3257,7 +3263,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
{Enabled: true, DrawId: "lucky_draw_2", CommandId: "cmd-gift-lucky:target:44", PoolId: "super_lucky", GiftId: "rose", MultiplierPpm: 3_000_000, EffectiveRewardCoins: 300, RewardStatus: "granted", WalletTransactionId: "wallet_tx_lucky_2", TargetUserId: 44},
|
||||
},
|
||||
}}
|
||||
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
router := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-lucky","target_user_id":43,"gift_id":"rose","gift_count":1,"pool_id":"super_lucky"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -3353,7 +3359,7 @@ func TestSendGiftDoesNotInjectHostPeriodScopeForNonHost(t *testing.T) {
|
||||
|
||||
func TestSendGiftForwardsMultipleTargetUserIDs(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
router := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-multi","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -3388,12 +3394,16 @@ func TestBatchSendGiftForwardsDisplayModeAndResponse(t *testing.T) {
|
||||
GiftEffectTypes: []string{"room"},
|
||||
RoomHeat: 500,
|
||||
Targets: []*roomv1.SendGiftBatchTarget{
|
||||
{TargetUserId: 43, TargetGiftValue: 220, CommandId: "cmd-gift-batch:target:43"},
|
||||
{TargetUserId: 44, TargetGiftValue: 180, CommandId: "cmd-gift-batch:target:44"},
|
||||
{
|
||||
TargetUserId: 43, TargetGiftValue: 220, CommandId: "cmd-gift-batch:target:43",
|
||||
},
|
||||
{
|
||||
TargetUserId: 44, TargetGiftValue: 180, CommandId: "cmd-gift-batch:target:44",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
router := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":2,"pool_id":"super_lucky"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -3427,6 +3437,12 @@ func TestBatchSendGiftForwardsDisplayModeAndResponse(t *testing.T) {
|
||||
if envelope.Code != "OK" || envelope.Data.CoinBalanceAfter != 7600 || envelope.Data.BatchDisplay.EventID != "evt-batch" || len(envelope.Data.BatchDisplay.TargetUserIDs) != 2 || len(envelope.Data.BatchDisplay.Targets) != 2 {
|
||||
t.Fatalf("batch send response mismatch: %+v", envelope.Data)
|
||||
}
|
||||
if target := envelope.Data.BatchDisplay.Targets[0]; target.TargetUserID != 43 || target.TargetGiftValue != 220 || target.CommandID != "cmd-gift-batch:target:43" {
|
||||
t.Fatalf("gateway batch first target display mismatch: %+v", target)
|
||||
}
|
||||
if target := envelope.Data.BatchDisplay.Targets[1]; target.TargetUserID != 44 || target.TargetGiftValue != 180 || target.CommandID != "cmd-gift-batch:target:44" {
|
||||
t.Fatalf("gateway batch second target display mismatch: %+v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
|
||||
@ -3455,6 +3471,7 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, profileClient)
|
||||
handler.SetUserHostClient(&fakeUserHostClient{})
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-profile","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
||||
@ -4065,6 +4082,7 @@ func TestSaveRoomBorderAppliesOwnedResourceAndReturnsFlatURL(t *testing.T) {
|
||||
func TestListRoomsHuwaaDefaultsToAllRegionsAndSupportsValidatedFilters(t *testing.T) {
|
||||
newHandler := func(regionClient *fakeUserRegionClient, countryClient *fakeUserCountryQueryClient, queryClient *fakeRoomQueryClient) http.Handler {
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler.SetAppConfigReader(appconfig.StaticReader{RoomDiscoveryAll: map[string]bool{"huwaa": true}})
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
handler.SetUserRegionClient(regionClient)
|
||||
handler.SetUserCountryQueryClient(countryClient)
|
||||
@ -4204,6 +4222,7 @@ func TestListRoomsHuwaaAllowsAllAndCatalogFiltersWithoutViewerRegion(t *testing.
|
||||
countryClient := &fakeUserCountryQueryClient{resp: registrationCountries}
|
||||
queryClient := &fakeRoomQueryClient{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetAppConfigReader(appconfig.StaticReader{RoomDiscoveryAll: map[string]bool{"huwaa": true}})
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
handler.SetUserRegionClient(regionClient)
|
||||
handler.SetUserCountryQueryClient(countryClient)
|
||||
@ -6908,6 +6927,7 @@ func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
|
||||
CycleKey: expectedCycle,
|
||||
PolicyVersion: 9001,
|
||||
Name: "Host Growth Policy",
|
||||
PolicyType: "SALARY_DIAMOND",
|
||||
RegionId: 30,
|
||||
Status: "active",
|
||||
SettlementMode: "half_month",
|
||||
@ -6963,7 +6983,7 @@ func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
|
||||
firstLevel := levels[0].(map[string]any)
|
||||
progress := data["progress"].(map[string]any)
|
||||
levelProgress := data["level_progress"].(map[string]any)
|
||||
if data["found"] != true || data["host_region_id"] != float64(30) || policy["policy_id"] != "9001" || policy["cycle_key"] != expectedCycle || policy["policy_version"] != float64(9001) || policy["settlement_mode"] != "half_month" || policy["settlement_trigger_mode"] != "manual" || firstLevel["host_salary_usd"] != 15.0 || firstLevel["agency_salary_usd"] != 3.0 {
|
||||
if data["found"] != true || data["host_region_id"] != float64(30) || policy["policy_id"] != "9001" || policy["cycle_key"] != expectedCycle || policy["policy_version"] != float64(9001) || policy["policy_type"] != "SALARY_DIAMOND" || policy["settlement_mode"] != "half_month" || policy["settlement_trigger_mode"] != "manual" || policy["point_diamonds_per_usd"] != float64(0) || policy["coins_per_usd"] != float64(0) || policy["agency_point_share_bps"] != float64(0) || policy["minimum_withdraw_usd_minor"] != float64(0) || policy["withdraw_fee_bps"] != float64(0) || firstLevel["host_salary_usd"] != 15.0 || firstLevel["agency_salary_usd"] != 3.0 {
|
||||
t.Fatalf("host policy response mismatch: %+v", data)
|
||||
}
|
||||
if progress["cycle_key"] != expectedCycle || progress["total_diamonds"] != float64(1200) {
|
||||
@ -7002,6 +7022,52 @@ func TestHostCenterPlatformPolicyRejectsNonActiveHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCenterPlatformPolicySkipsSalaryProgressForNonSalaryPolicies(t *testing.T) {
|
||||
hostProfile := &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}
|
||||
tests := []struct {
|
||||
name string
|
||||
policyResp *walletv1.GetActiveHostSalaryPolicyResponse
|
||||
}{
|
||||
{
|
||||
name: "point diamond",
|
||||
policyResp: &walletv1.GetActiveHostSalaryPolicyResponse{Found: true, Policy: &walletv1.HostSalaryPolicy{
|
||||
PolicyId: 9002, PolicyVersion: 3, PolicyType: "POINT_DIAMOND", RegionId: 30, Status: "active",
|
||||
}},
|
||||
},
|
||||
{name: "no policy", policyResp: &walletv1.GetActiveHostSalaryPolicyResponse{}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{hostProfile: hostProfile}
|
||||
walletClient := &fakeWalletClient{hostSalaryPolicyResp: test.policyResp}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/platform-policy", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if walletClient.lastHostSalaryProgress != nil {
|
||||
t.Fatalf("non-salary policy queried salary progress: %+v", walletClient.lastHostSalaryProgress)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data := response.Data.(map[string]any)
|
||||
if data["progress"] != nil || data["level_progress"] != nil {
|
||||
t.Fatalf("non-salary policy exposed salary progress: %+v", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgencyCenterOverviewAllowsOwnerWithoutManagerProfile(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{
|
||||
roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001},
|
||||
@ -7080,6 +7146,7 @@ func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) {
|
||||
CycleKey: expectedCycle,
|
||||
PolicyVersion: 9101,
|
||||
Name: "Agency Region Policy",
|
||||
PolicyType: "SALARY_DIAMOND",
|
||||
RegionId: 31,
|
||||
Status: "active",
|
||||
SettlementMode: "half_month",
|
||||
@ -7141,6 +7208,45 @@ func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgencyCenterPointDiamondPolicySkipsOwnerSalaryProgress(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{
|
||||
roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001},
|
||||
getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
|
||||
AgencyId: 7001, OwnerUserId: 42, RegionId: 31, Status: "active",
|
||||
}},
|
||||
}
|
||||
walletClient := &fakeWalletClient{hostSalaryPolicyResp: &walletv1.GetActiveHostSalaryPolicyResponse{
|
||||
Found: true,
|
||||
Policy: &walletv1.HostSalaryPolicy{
|
||||
PolicyId: 9102, PolicyVersion: 4, PolicyType: "POINT_DIAMOND", RegionId: 31, Status: "active",
|
||||
},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/platform-policy", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if walletClient.lastHostSalaryProgress != nil {
|
||||
t.Fatalf("POINT_DIAMOND agency policy queried salary progress: %+v", walletClient.lastHostSalaryProgress)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data := response.Data.(map[string]any)
|
||||
if data["progress"] != nil {
|
||||
t.Fatalf("POINT_DIAMOND agency policy exposed salary progress: %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBDCenterOverviewUsesBDSalaryAndDirectAgencies(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{
|
||||
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
|
||||
@ -10936,24 +11042,26 @@ func TestCoinSellerSubSellerTransferChecksRelationBeforeWallet(t *testing.T) {
|
||||
|
||||
func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) {
|
||||
imPublisher := &fakeTencentIMUserMessagePublisher{}
|
||||
walletClient := &fakeWalletClient{directGiftResp: &walletv1.DebitGiftResponse{
|
||||
BillingReceiptId: "receipt-direct-gift",
|
||||
TransactionId: "wtx-direct-gift",
|
||||
CoinSpent: 200,
|
||||
ChargeAssetType: "COIN",
|
||||
ChargeAmount: 200,
|
||||
GiftTypeCode: "normal",
|
||||
GiftName: "Rose",
|
||||
GiftIconUrl: "https://cdn.example/gifts/rose.png",
|
||||
GiftAnimationUrl: "https://cdn.example/gifts/rose.mp4",
|
||||
GiftEffectTypes: []string{"mp4"},
|
||||
PriceVersion: "v1",
|
||||
BalanceAfter: 800,
|
||||
GiftIncomeCoinAmount: 60,
|
||||
GiftIncomeBalanceAfter: 460,
|
||||
HostPeriodDiamondAdded: 200,
|
||||
HostPeriodCycleKey: "2026-06",
|
||||
}}
|
||||
imImporter := &fakeTencentIMAccountImporter{}
|
||||
walletClient := &fakeWalletClient{
|
||||
directGiftResp: &walletv1.DebitGiftResponse{
|
||||
BillingReceiptId: "receipt-direct-gift",
|
||||
TransactionId: "wtx-direct-gift",
|
||||
CoinSpent: 200,
|
||||
ChargeAssetType: "COIN",
|
||||
ChargeAmount: 200,
|
||||
GiftTypeCode: "normal",
|
||||
GiftName: "Rose",
|
||||
GiftIconUrl: "https://cdn.example/gifts/rose.png",
|
||||
GiftAnimationUrl: "https://cdn.example/gifts/rose.mp4",
|
||||
GiftEffectTypes: []string{"mp4"},
|
||||
PriceVersion: "v1",
|
||||
BalanceAfter: 800,
|
||||
GiftIncomeCoinAmount: 60,
|
||||
GiftIncomeBalanceAfter: 460,
|
||||
HostPeriodCycleKey: "2026-06",
|
||||
},
|
||||
}
|
||||
profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 2002}}
|
||||
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{
|
||||
UserId: 900002,
|
||||
@ -10964,6 +11072,7 @@ func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) {
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
handler.SetUserHostClient(hostClient)
|
||||
handler.SetTencentIMAccountImporter(imImporter)
|
||||
handler.SetTencentIMUserMessagePublisher(imPublisher)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"commandId":"cmd-direct-gift","targetUserId":900002,"giftId":"rose","giftCount":2,"region_id":9999}`)
|
||||
@ -10990,12 +11099,18 @@ func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) {
|
||||
walletClient.lastDirectGift.GetTargetAgencyOwnerUserId() != 30001 {
|
||||
t.Fatalf("direct gift wallet request mismatch: %+v", walletClient.lastDirectGift)
|
||||
}
|
||||
if len(walletClient.directGiftRequests) != 1 {
|
||||
t.Fatalf("direct gift must execute one authoritative wallet debit: %+v", walletClient.directGiftRequests)
|
||||
}
|
||||
if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 900002 {
|
||||
t.Fatalf("direct gift must resolve sender and target through user-service: %+v", profileClient.getRequests)
|
||||
}
|
||||
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 900002 {
|
||||
t.Fatalf("direct gift target host scope was not resolved: %+v", hostClient.lastHost)
|
||||
}
|
||||
if len(imImporter.userIDs) != 2 || imImporter.userIDs[0] != 42 || imImporter.userIDs[1] != 900002 {
|
||||
t.Fatalf("direct gift first execution must ensure both IM accounts before debit: %+v", imImporter.userIDs)
|
||||
}
|
||||
if len(imPublisher.messages) != 1 {
|
||||
t.Fatalf("direct gift must publish exactly one C2C message: %+v", imPublisher.messages)
|
||||
}
|
||||
@ -11005,7 +11120,8 @@ func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) {
|
||||
imMessage.SyncOtherMachine != 1 ||
|
||||
imMessage.EventID != "direct_gift:lalu:cmd-direct-gift" ||
|
||||
imMessage.Desc != "gift" ||
|
||||
imMessage.Ext != "direct_message_gift" {
|
||||
imMessage.Ext != "direct_message_gift" ||
|
||||
!imMessage.DeduplicateByEventID {
|
||||
t.Fatalf("direct gift C2C message mismatch: %+v", imMessage)
|
||||
}
|
||||
var imPayload map[string]any
|
||||
@ -11041,6 +11157,26 @@ func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectGiftFailsClosedWithoutHostAuthority(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{}
|
||||
profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 2002}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
handler.SetTencentIMUserMessagePublisher(&fakeTencentIMUserMessagePublisher{})
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/messages/direct-gifts/send", bytes.NewReader([]byte(`{"command_id":"cmd-direct-no-host-owner","target_user_id":900002,"gift_id":"rose","gift_count":1}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-direct-no-host-owner")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-direct-no-host-owner")
|
||||
if walletClient.lastDirectGift != nil {
|
||||
t.Fatalf("missing Host authority reached debit: %+v", walletClient.lastDirectGift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinSellerTransferAllowsSelfTargetAndSkipsDuplicateRegionLookup(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
|
||||
TransactionId: "wtx-coin-seller-self",
|
||||
@ -12502,6 +12638,74 @@ func int64SliceToStrings(values []int64) []string {
|
||||
return items
|
||||
}
|
||||
|
||||
func TestEquipMyResourceUsesRuntimeCommandIDPolicy(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
capability *appconfig.RuntimeCapabilities
|
||||
body string
|
||||
wantStatus int
|
||||
wantCommand string
|
||||
}{
|
||||
{
|
||||
name: "required rejects missing command id",
|
||||
capability: &appconfig.RuntimeCapabilities{ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeAppGlobal, ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyRequired},
|
||||
body: `{}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing config keeps legacy optional default",
|
||||
body: `{}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantCommand: "legacy_equip:",
|
||||
},
|
||||
{
|
||||
name: "required accepts client command id",
|
||||
capability: &appconfig.RuntimeCapabilities{ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeAppGlobal, ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyRequired},
|
||||
body: `{"command_id":"equip-command-1"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantCommand: "equip-command-1",
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetWalletClient(walletClient)
|
||||
capabilities := map[string]appconfig.RuntimeCapabilities{}
|
||||
if testCase.capability != nil {
|
||||
capabilities["lalu"] = *testCase.capability
|
||||
}
|
||||
handler.SetAppConfigReader(appconfig.StaticReader{RuntimeCapabilitiesByApp: capabilities})
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/resources/501/equip", strings.NewReader(testCase.body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != testCase.wantStatus {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", recorder.Code, testCase.wantStatus, recorder.Body.String())
|
||||
}
|
||||
if testCase.wantStatus != http.StatusOK {
|
||||
if walletClient.lastEquipUserResource != nil {
|
||||
t.Fatalf("rejected request must not call wallet: %+v", walletClient.lastEquipUserResource)
|
||||
}
|
||||
return
|
||||
}
|
||||
if walletClient.lastEquipUserResource == nil {
|
||||
t.Fatal("successful request did not call wallet")
|
||||
}
|
||||
if testCase.wantCommand == "legacy_equip:" {
|
||||
if !strings.HasPrefix(walletClient.lastEquipUserResource.GetCommandId(), testCase.wantCommand) {
|
||||
t.Fatalf("legacy command id mismatch: %+v", walletClient.lastEquipUserResource)
|
||||
}
|
||||
} else if walletClient.lastEquipUserResource.GetCommandId() != testCase.wantCommand {
|
||||
t.Fatalf("command id mismatch: %+v", walletClient.lastEquipUserResource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authRateLimitForTest(mutator func(*AuthRateLimitConfig)) AuthRateLimitConfig {
|
||||
config := AuthRateLimitConfig{
|
||||
Enabled: true,
|
||||
|
||||
@ -1,17 +1,29 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
func TestListRoomFiltersReturnsAllAppScopedFiltersForHuwaa(t *testing.T) {
|
||||
type countingRoomDiscoveryReader struct {
|
||||
appconfig.StaticReader
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *countingRoomDiscoveryReader) RoomDiscoveryAllRegions(ctx context.Context, appCode string) (bool, error) {
|
||||
r.calls++
|
||||
return r.StaticReader.RoomDiscoveryAllRegions(ctx, appCode)
|
||||
}
|
||||
|
||||
func TestListRoomFiltersReturnsAllAppScopedFiltersWhenConfigured(t *testing.T) {
|
||||
regionClient := &fakeUserRegionClient{listResp: &userv1.ListRegionsResponse{Regions: []*userv1.Region{
|
||||
{RegionId: 1001, RegionCode: "MIDDLE_EAST", Name: "Middle East", Status: "active", Countries: []string{"AE", "SA"}, SortOrder: 10},
|
||||
{RegionId: 1002, RegionCode: "SOUTH_ASIA", Name: "South Asia", Status: "active", Countries: []string{"PK"}, SortOrder: 20},
|
||||
@ -30,10 +42,12 @@ func TestListRoomFiltersReturnsAllAppScopedFiltersForHuwaa(t *testing.T) {
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetUserRegionClient(regionClient)
|
||||
handler.SetUserCountryQueryClient(countryClient)
|
||||
configReader := &countingRoomDiscoveryReader{StaticReader: appconfig.StaticReader{RoomDiscoveryAll: map[string]bool{"wide-app": true}}}
|
||||
handler.SetAppConfigReader(configReader)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/filters", nil)
|
||||
request.Header.Set("X-App-Code", "huwaa")
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
|
||||
request.Header.Set("X-App-Code", "wide-app")
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "wide-app"))
|
||||
request.Header.Set("X-Request-ID", "req-room-filters")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@ -42,14 +56,17 @@ func TestListRoomFiltersReturnsAllAppScopedFiltersForHuwaa(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if regionClient.lastList == nil || regionClient.lastList.GetStatus() != "active" || regionClient.lastList.GetMeta().GetAppCode() != "huwaa" {
|
||||
if regionClient.lastList == nil || regionClient.lastList.GetStatus() != "active" || regionClient.lastList.GetMeta().GetAppCode() != "wide-app" {
|
||||
t.Fatalf("region query must be active and app-scoped: %+v", regionClient.lastList)
|
||||
}
|
||||
if countryClient.last == nil || countryClient.last.GetMeta().GetAppCode() != "huwaa" {
|
||||
if countryClient.last == nil || countryClient.last.GetMeta().GetAppCode() != "wide-app" {
|
||||
t.Fatalf("country query must use the same app scope: %+v", countryClient.last)
|
||||
}
|
||||
if profileClient.lastGet != nil || regionClient.last != nil {
|
||||
t.Fatalf("Huwaa catalog must read all active regions without narrowing to the viewer: profile=%+v region=%+v", profileClient.lastGet, regionClient.last)
|
||||
t.Fatalf("all-region catalog must not narrow to the viewer: profile=%+v region=%+v", profileClient.lastGet, regionClient.last)
|
||||
}
|
||||
if configReader.calls != 1 {
|
||||
t.Fatalf("one room-filter request must resolve discovery scope once, got %d calls", configReader.calls)
|
||||
}
|
||||
|
||||
var response httpkit.ResponseEnvelope
|
||||
@ -87,7 +104,7 @@ func TestListRoomFiltersReturnsAllAppScopedFiltersForHuwaa(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoomFiltersKeepsNonHuwaaInsideCurrentUserRegion(t *testing.T) {
|
||||
func TestListRoomFiltersKeepsViewerScopeInsideCurrentUserRegion(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{regionID: 1002}
|
||||
regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{
|
||||
RegionId: 1002, RegionCode: "SOUTH_ASIA", Name: "South Asia", Status: "active", Countries: []string{"PK"}, SortOrder: 20,
|
||||
@ -101,8 +118,8 @@ func TestListRoomFiltersKeepsNonHuwaaInsideCurrentUserRegion(t *testing.T) {
|
||||
handler.SetUserCountryQueryClient(countryClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/filters", nil)
|
||||
request.Header.Set("X-App-Code", "lalu")
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-App-Code", "regional-app")
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "regional-app"))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
@ -111,7 +128,7 @@ func TestListRoomFiltersKeepsNonHuwaaInsideCurrentUserRegion(t *testing.T) {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if regionClient.lastList != nil || profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || regionClient.last == nil || regionClient.last.GetRegionId() != 1002 {
|
||||
t.Fatalf("non-Huwaa catalog must resolve only the viewer region: list=%+v user=%+v region=%+v", regionClient.lastList, profileClient.lastGet, regionClient.last)
|
||||
t.Fatalf("viewer-scope catalog must resolve only the viewer region: list=%+v user=%+v region=%+v", regionClient.lastList, profileClient.lastGet, regionClient.last)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
@ -133,8 +150,8 @@ func TestListRoomFiltersRejectsNonGETWithoutCallingUserService(t *testing.T) {
|
||||
handler.SetUserCountryQueryClient(countryClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/filters", nil)
|
||||
request.Header.Set("X-App-Code", "huwaa")
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
|
||||
request.Header.Set("X-App-Code", "wide-app")
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "wide-app"))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@ -22,6 +22,10 @@ type objectURLVerifier interface {
|
||||
OwnsObjectURL(objectURL string, objectKey string) bool
|
||||
}
|
||||
|
||||
type roomDiscoveryCapabilityReader interface {
|
||||
RoomDiscoveryAllRegions(ctx context.Context, appCode string) (bool, error)
|
||||
}
|
||||
|
||||
// Handler owns room-facing HTTP endpoints in gateway.
|
||||
// It keeps gateway as protocol adapter only: room state still belongs to room-service.
|
||||
type Handler struct {
|
||||
|
||||
@ -5,7 +5,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
|
||||
@ -13,10 +12,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
roomFilterTypeAll = "all"
|
||||
roomFilterTypeRegion = "region"
|
||||
roomFilterTypeCountry = "country"
|
||||
roomFilterAllRegionsAppCode = "huwaa"
|
||||
roomFilterTypeAll = "all"
|
||||
roomFilterTypeRegion = "region"
|
||||
roomFilterTypeCountry = "country"
|
||||
|
||||
// 区域筛选目录只暴露当前可用的业务区域;禁用区域继续保留在后台治理面,不下发给客户端。
|
||||
roomFilterRegionStatusActive = "active"
|
||||
@ -60,14 +58,16 @@ type roomCountryFilterData struct {
|
||||
}
|
||||
|
||||
// listRoomFilters 返回当前 App 可用的首页区域/国家筛选目录。
|
||||
// Huwaa 已开放跨区域浏览,因此返回全部 active 区域;其他 App 继续只返回当前用户区域,避免下发不可用的跨区值。
|
||||
// 筛选目录按租户能力返回全部 active 区域或当前用户区域,不按应用名分支。
|
||||
func (h *Handler) listRoomFilters(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userRegionClient == nil || h.userCountryClient == nil || (appcode.FromContext(request.Context()) != roomFilterAllRegionsAppCode && h.userProfileClient == nil) {
|
||||
// 一个请求只解析一次能力;避免依赖检查和目录查询分别访问缓存/后台库。
|
||||
allRegionDiscovery := h.roomDiscoveryAllRegions(request)
|
||||
if h.userRegionClient == nil || h.userCountryClient == nil || (!allRegionDiscovery && h.userProfileClient == nil) {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
|
||||
regions, err := h.roomFilterRegions(request)
|
||||
regions, err := h.roomFilterRegions(request, allRegionDiscovery)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
@ -83,8 +83,8 @@ func (h *Handler) listRoomFilters(writer http.ResponseWriter, request *http.Requ
|
||||
httpkit.WriteOK(writer, request, roomFilterCatalogDataFromProto(regions, countryResp.GetCountries()))
|
||||
}
|
||||
|
||||
func (h *Handler) roomFilterRegions(request *http.Request) ([]*userv1.Region, error) {
|
||||
if appcode.FromContext(request.Context()) == roomFilterAllRegionsAppCode {
|
||||
func (h *Handler) roomFilterRegions(request *http.Request, allRegionDiscovery bool) ([]*userv1.Region, error) {
|
||||
if allRegionDiscovery {
|
||||
resp, err := h.userRegionClient.ListRegions(request.Context(), &userv1.ListRegionsRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
Status: roomFilterRegionStatusActive,
|
||||
@ -102,7 +102,6 @@ func (h *Handler) roomFilterRegions(request *http.Request) ([]*userv1.Region, er
|
||||
return nil, err
|
||||
}
|
||||
if userResp.GetUser().GetRegionId() <= 0 {
|
||||
// GLOBAL 是内部兜底桶,不是客户端可选业务区域;All 仍表示该 App 原有的当前区域语义。
|
||||
return []*userv1.Region{}, nil
|
||||
}
|
||||
regionResp, err := h.userRegionClient.GetRegion(request.Context(), &userv1.GetRegionRequest{
|
||||
|
||||
@ -30,8 +30,6 @@ import (
|
||||
const (
|
||||
defaultRoomFeedRelationPageSize = 100
|
||||
maxRoomFeedRelationScanCount = 500
|
||||
// Huwaa 首页的产品范围是全区;其他 App 继续沿用当前区域/白名单语义。
|
||||
huwaaRoomDiscoveryAppCode = "huwaa"
|
||||
|
||||
// 房间展示资料只读取客户端能在首屏、公屏或资料卡直接渲染的当前佩戴资源。
|
||||
roomAppearanceResourceAvatarFrame = "avatar_frame"
|
||||
@ -129,9 +127,9 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
appCode := appcode.FromContext(request.Context())
|
||||
if appCode != huwaaRoomDiscoveryAppCode && userResp.GetUser().GetRegionId() <= 0 {
|
||||
// 非 Huwaa 仍以用户区域作为隔离边界;缺失时不能猜测或退化到 GLOBAL。
|
||||
allRegionDiscovery := h.roomDiscoveryAllRegions(request)
|
||||
if !allRegionDiscovery && userResp.GetUser().GetRegionId() <= 0 {
|
||||
// viewer_region 能力必须有身份区域;配置缺失或读取失败均 fail closed,不退化到 GLOBAL。
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
@ -143,7 +141,7 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
|
||||
allVisibleRegions := false
|
||||
filterRegionID := int64(0)
|
||||
countryCode := filter.CountryCode
|
||||
if appCode == huwaaRoomDiscoveryAppCode {
|
||||
if allRegionDiscovery {
|
||||
switch {
|
||||
case filter.RegionID > 0:
|
||||
regions, err := h.roomListActiveRegions(request)
|
||||
@ -166,15 +164,12 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
// 国家项在 active 区域中有唯一归属,但查询按国家投影直接收敛,不再叠加区域条件。
|
||||
allVisibleRegions = true
|
||||
default:
|
||||
// Huwaa All 是产品默认页,不依赖运营白名单。
|
||||
allVisibleRegions = true
|
||||
}
|
||||
} else {
|
||||
if filter.RegionID > 0 || !roomListCountryAllowed(countries, countryCode) {
|
||||
// 非 Huwaa App 不开放客户端跨区筛选,国家也仍限当前用户区域。
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
@ -208,6 +203,18 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
|
||||
httpkit.WriteOK(writer, request, data)
|
||||
}
|
||||
|
||||
func (h *Handler) roomDiscoveryAllRegions(request *http.Request) bool {
|
||||
reader, ok := h.appConfigReader.(roomDiscoveryCapabilityReader)
|
||||
if !ok || reader == nil {
|
||||
return false
|
||||
}
|
||||
allowed, err := reader.RoomDiscoveryAllRegions(request.Context(), appcode.FromContext(request.Context()))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// listRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
|
||||
func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userProfileClient == nil || h.roomQueryClient == nil {
|
||||
@ -2283,9 +2290,12 @@ func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) {
|
||||
if targetUserID <= 0 || h.userHostClient == nil {
|
||||
// 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed,避免客户端伪造主播入账。
|
||||
return false, 0, 0, nil
|
||||
if targetUserID <= 0 {
|
||||
return false, 0, 0, status.Error(codes.InvalidArgument, "target user is invalid")
|
||||
}
|
||||
if h.userHostClient == nil {
|
||||
// Host 身份与 Agency 归属只能由 user-service 判定;配置缺失时不能把真实 Host 降级成普通收礼人绕过收益政策。
|
||||
return false, 0, 0, status.Error(codes.Unavailable, "user host service is unavailable")
|
||||
}
|
||||
resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
@ -2297,11 +2307,17 @@ func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID
|
||||
}
|
||||
return false, 0, 0, err
|
||||
}
|
||||
if resp == nil || resp.GetHostProfile() == nil {
|
||||
return false, 0, 0, status.Error(codes.Unavailable, "user host service returned an invalid response")
|
||||
}
|
||||
profile := resp.GetHostProfile()
|
||||
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
|
||||
// 只有 active host 且带区域才能进入工资政策链路;disabled/缺区域按非主播处理,不影响正常送礼。
|
||||
if !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") {
|
||||
// user-service 明确返回非 active profile 时才按非主播处理。
|
||||
return false, 0, 0, nil
|
||||
}
|
||||
if profile.GetRegionId() <= 0 {
|
||||
return false, 0, 0, status.Error(codes.FailedPrecondition, "active host region is missing")
|
||||
}
|
||||
return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil
|
||||
}
|
||||
|
||||
@ -2310,12 +2326,7 @@ func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserI
|
||||
return []*roomv1.SendGiftTargetHostScope{}, nil
|
||||
}
|
||||
if h.userHostClient == nil {
|
||||
scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
|
||||
for _, targetUserID := range targetUserIDs {
|
||||
// 未装配 host client 时送礼仍可继续,但所有 target 都 fail-closed 为非主播。
|
||||
scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, nil))
|
||||
}
|
||||
return scopes, nil
|
||||
return nil, status.Error(codes.Unavailable, "user host service is unavailable")
|
||||
}
|
||||
resp, err := h.userHostClient.BatchGetHostProfiles(request.Context(), &userv1.BatchGetHostProfilesRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
@ -2328,11 +2339,18 @@ func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserI
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, status.Error(codes.Unavailable, "user host service returned an invalid response")
|
||||
}
|
||||
profiles := resp.GetHostProfiles()
|
||||
scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
|
||||
for _, targetUserID := range targetUserIDs {
|
||||
// 批量送礼必须按接收方分别固化主播快照;缺失 profile 表示该 target 当前不是主播。
|
||||
scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, profiles[targetUserID]))
|
||||
scope, err := giftTargetHostScopeFromProfile(targetUserID, profiles[targetUserID])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
return scopes, nil
|
||||
}
|
||||
@ -2355,16 +2373,19 @@ func (h *Handler) resolveGiftTargetHostScopesBySingleLookup(request *http.Reques
|
||||
return scopes, nil
|
||||
}
|
||||
|
||||
func giftTargetHostScopeFromProfile(targetUserID int64, profile *userv1.HostProfile) *roomv1.SendGiftTargetHostScope {
|
||||
func giftTargetHostScopeFromProfile(targetUserID int64, profile *userv1.HostProfile) (*roomv1.SendGiftTargetHostScope, error) {
|
||||
scope := &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
|
||||
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
|
||||
// 只有 active host 且带区域才能进入工资政策链路;disabled/缺区域按非主播处理,不影响正常送礼。
|
||||
return scope
|
||||
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") {
|
||||
// BatchGet 明确省略目标或返回非 active profile 时,该目标当前不是 Host。
|
||||
return scope, nil
|
||||
}
|
||||
if profile.GetRegionId() <= 0 {
|
||||
return nil, status.Error(codes.FailedPrecondition, "active host region is missing")
|
||||
}
|
||||
scope.TargetIsHost = true
|
||||
scope.TargetHostRegionId = profile.GetRegionId()
|
||||
scope.TargetAgencyOwnerUserId = profile.GetCurrentAgencyOwnerUserId()
|
||||
return scope
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfile {
|
||||
|
||||
@ -8,10 +8,25 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
func TestGiftHostAuthorityDependencyFailsClosed(t *testing.T) {
|
||||
handler := New(Config{})
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", nil)
|
||||
|
||||
if _, _, _, err := handler.resolveGiftTargetHostScope(request, 42); status.Code(err) != codes.Unavailable {
|
||||
t.Fatalf("single gift missing Host authority must be unavailable: %v", err)
|
||||
}
|
||||
if _, err := handler.resolveGiftTargetHostScopes(request, []int64{42, 43}); status.Code(err) != codes.Unavailable {
|
||||
t.Fatalf("batch gift missing Host authority must be unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomRejectsMissingRoomAvatar(t *testing.T) {
|
||||
handler := New(Config{})
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewBufferString(`{
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"hyapp/pkg/tencentrtc"
|
||||
"hyapp/services/gateway-service/internal/appconfig"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
gatewayclient "hyapp/services/gateway-service/internal/client"
|
||||
"hyapp/services/gateway-service/internal/transport/http/activityapi"
|
||||
@ -23,6 +24,8 @@ import (
|
||||
// Routes 把 Handler 的私有方法适配成独立路由包需要的显式 handler 表。
|
||||
// 路径注册集中在 httproutes,gateway 依赖装配和鉴权 wrapper 仍留在 transport 入口层。
|
||||
func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
// appConfigReader 的主接口只覆盖公开 App 配置;运行能力通过窄接口注入,测试或本地缺失时使用各能力通用默认值。
|
||||
runtimeCapabilities, _ := h.appConfigReader.(appconfig.RuntimeCapabilityReader)
|
||||
roomAPI := roomapi.New(roomapi.Config{
|
||||
RoomClient: h.roomClient,
|
||||
RoomGuardClient: h.roomGuardClient,
|
||||
@ -94,6 +97,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
RoomQueryClient: h.roomQueryClient,
|
||||
WalletClient: h.walletClient,
|
||||
UserLeaderboard: h.userLeaderboard,
|
||||
RuntimeCapabilities: runtimeCapabilities,
|
||||
RegistrationReward: h.registrationReward,
|
||||
FirstRechargeReward: h.firstRechargeReward,
|
||||
CumulativeRecharge: h.cumulativeRecharge,
|
||||
@ -117,7 +121,8 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
JWTVerifier: jwtVerifier,
|
||||
})
|
||||
resourceAPI := resourceapi.New(resourceapi.Config{
|
||||
WalletClient: h.walletClient,
|
||||
WalletClient: h.walletClient,
|
||||
RuntimeCapabilities: runtimeCapabilities,
|
||||
})
|
||||
managerAPI := managerapi.New(managerapi.Config{
|
||||
WalletClient: h.walletClient,
|
||||
|
||||
@ -134,24 +134,28 @@ func (h *Handler) getAgencyCenterPlatformPolicy(writer http.ResponseWriter, requ
|
||||
return
|
||||
}
|
||||
|
||||
// Agency 和 Host Center 的钻石卡片都代表当前登录用户本人的周期主播账户。
|
||||
// 这里直接按 owner user_id 读取同一份账务事实,避免 Agency 把下属主播合计误显示成 owner 本人的钻石。
|
||||
progressResp, err := h.walletClient.GetHostSalaryProgress(request.Context(), &walletv1.GetHostSalaryProgressRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
HostUserId: agency.GetOwnerUserId(),
|
||||
CycleKey: cycleKey,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
var progressData any
|
||||
policy := resp.GetPolicy()
|
||||
if resp.GetFound() && strings.EqualFold(strings.TrimSpace(policy.GetPolicyType()), hostPolicyTypeSalaryDiamond) {
|
||||
// 只有工资型政策才存在 owner 的周期主播进度;永久 POINT_DIAMOND 钱包不读取、更不暴露旧工资周期数据。
|
||||
progressResp, err := h.walletClient.GetHostSalaryProgress(request.Context(), &walletv1.GetHostSalaryProgressRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
HostUserId: agency.GetOwnerUserId(),
|
||||
CycleKey: cycleKey,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
progressData = hostCenterSalaryProgressFromProto(progressResp.GetProgress())
|
||||
}
|
||||
// H5 需要完整政策明细和 owner 本人的周期进度;Agency 没有单主播等级卡,因此不返回 level_progress。
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"found": resp.GetFound(),
|
||||
"agency_region_id": agency.GetRegionId(),
|
||||
"policy": hostCenterPolicyFromProto(resp.GetPolicy()),
|
||||
"progress": hostCenterSalaryProgressFromProto(progressResp.GetProgress()),
|
||||
"policy": hostCenterPolicyFromProto(policy),
|
||||
"progress": progressData,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,10 @@ package userapi
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
@ -12,37 +15,43 @@ import (
|
||||
)
|
||||
|
||||
type agencyRevenueStatsClient interface {
|
||||
GetHostRevenueStats(context.Context, *walletv1.GetHostRevenueStatsRequest) (*walletv1.GetHostRevenueStatsResponse, error)
|
||||
GetAgencyPointShareStats(context.Context, *walletv1.GetAgencyPointShareStatsRequest) (*walletv1.GetAgencyPointShareStatsResponse, error)
|
||||
GetAgencyHostGiftStats(context.Context, *walletv1.GetAgencyHostGiftStatsRequest) (*walletv1.GetAgencyHostGiftStatsResponse, error)
|
||||
}
|
||||
|
||||
type agencyCenterStatsSummaryData struct {
|
||||
TotalEarnings int64 `json:"total_earnings"`
|
||||
GiftIncome int64 `json:"gift_income"`
|
||||
ShareIncome int64 `json:"share_income"`
|
||||
TotalHosts int `json:"total_hosts"`
|
||||
GiftedHostCount int64 `json:"gifted_host_count"`
|
||||
PolicyType string `json:"policy_type"`
|
||||
TotalEarnings string `json:"total_earnings"`
|
||||
GiftIncome string `json:"gift_income"`
|
||||
ShareIncome string `json:"share_income"`
|
||||
SalaryHostIncome string `json:"salary_host_income"`
|
||||
PointDiamondHostIncome string `json:"point_diamond_host_income"`
|
||||
PointDiamondAgencyShareIncome string `json:"point_diamond_agency_share_income"`
|
||||
TotalHosts int `json:"total_hosts"`
|
||||
GiftedHostCount int64 `json:"gifted_host_count"`
|
||||
}
|
||||
|
||||
type agencyCenterHostStatsData struct {
|
||||
MembershipID string `json:"membership_id"`
|
||||
HostUserID string `json:"host_user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms"`
|
||||
DiamondEarnings int64 `json:"diamond_earnings"`
|
||||
DiamondExchanged int64 `json:"diamond_exchanged"`
|
||||
GiftSenders int64 `json:"gift_senders"`
|
||||
OnlineDurationMS int64 `json:"online_duration_ms"`
|
||||
ValidMicDurationMS int64 `json:"valid_mic_duration_ms"`
|
||||
ValidMicDays int64 `json:"valid_mic_days"`
|
||||
Removable bool `json:"removable"`
|
||||
MembershipID string `json:"membership_id"`
|
||||
HostUserID string `json:"host_user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms"`
|
||||
DiamondEarnings string `json:"diamond_earnings"`
|
||||
DiamondExchanged string `json:"diamond_exchanged"`
|
||||
SalaryDiamondEarnings string `json:"salary_diamond_earnings"`
|
||||
LegacyPointExchanged string `json:"legacy_point_exchanged"`
|
||||
PointDiamondEarnings string `json:"point_diamond_earnings"`
|
||||
PointDiamondExchanged string `json:"point_diamond_exchanged"`
|
||||
GiftSenders int64 `json:"gift_senders"`
|
||||
OnlineDurationMS int64 `json:"online_duration_ms"`
|
||||
ValidMicDurationMS int64 `json:"valid_mic_duration_ms"`
|
||||
ValidMicDays int64 `json:"valid_mic_days"`
|
||||
Removable bool `json:"removable"`
|
||||
}
|
||||
|
||||
// getAgencyCenterStats 只做跨 owner 编排:成员关系/互动来自 user-service,金额和分成来自 wallet-service。
|
||||
// 每个主播的两次 RPC 使用有界并发,避免大公会串行超时,也避免无上限 goroutine 压垮下游。
|
||||
// getAgencyCenterStats 只做跨 owner 编排:成员关系/互动来自 user-service,金额和分成由
|
||||
// wallet-service 一次批量返回;这里只对无法批量取得的互动统计使用有界并发。
|
||||
func (h *Handler) getAgencyCenterStats(writer http.ResponseWriter, request *http.Request) {
|
||||
agency, ok := h.resolveCurrentOwnerAgency(writer, request)
|
||||
if !ok {
|
||||
@ -59,6 +68,8 @@ func (h *Handler) getAgencyCenterStats(writer http.ResponseWriter, request *http
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid date range")
|
||||
return
|
||||
}
|
||||
requestID := httpkit.RequestIDFromContext(request.Context())
|
||||
appCode := appcode.FromContext(request.Context())
|
||||
|
||||
membersResp, err := h.userHostClient.GetAgencyMembers(request.Context(), &userv1.GetAgencyMembersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), AgencyId: agency.GetAgencyId(), Status: membershipStatusActive,
|
||||
@ -67,6 +78,14 @@ func (h *Handler) getAgencyCenterStats(writer http.ResponseWriter, request *http
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
policyResp, err := h.walletClient.GetActiveHostSalaryPolicy(request.Context(), &walletv1.GetActiveHostSalaryPolicyRequest{
|
||||
RequestId: requestID, AppCode: appCode, RegionId: agency.GetRegionId(), CycleKey: time.Now().UTC().Format("2006-01"),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
policyType := strings.TrimSpace(policyResp.GetPolicy().GetPolicyType())
|
||||
memberships := make([]*userv1.AgencyMembership, 0, len(membersResp.GetMemberships()))
|
||||
userIDs := make([]int64, 0, len(membersResp.GetMemberships()))
|
||||
for _, membership := range membersResp.GetMemberships() {
|
||||
@ -78,21 +97,16 @@ func (h *Handler) getAgencyCenterStats(writer http.ResponseWriter, request *http
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
requestID := httpkit.RequestIDFromContext(request.Context())
|
||||
appCode := appcode.FromContext(request.Context())
|
||||
shareResp, err := revenueClient.GetAgencyPointShareStats(request.Context(), &walletv1.GetAgencyPointShareStatsRequest{
|
||||
RequestId: requestID, AppCode: appCode, AgencyOwnerUserId: agency.GetOwnerUserId(), StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
giftResp, err := revenueClient.GetAgencyHostGiftStats(request.Context(), &walletv1.GetAgencyHostGiftStatsRequest{
|
||||
RequestId: requestID, AppCode: appCode, HostUserIds: userIDs, AgencyUserId: agency.GetOwnerUserId(), StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
giftResp, err := revenueClient.GetAgencyHostGiftStats(request.Context(), &walletv1.GetAgencyHostGiftStatsRequest{
|
||||
RequestId: requestID, AppCode: appCode, HostUserIds: userIDs, StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
hostRevenue := make(map[int64]*walletv1.HostRevenueStats, len(giftResp.GetHostStats()))
|
||||
for _, host := range giftResp.GetHostStats() {
|
||||
hostRevenue[host.GetHostUserId()] = host.GetStats()
|
||||
}
|
||||
|
||||
items := make([]agencyCenterHostStatsData, len(memberships))
|
||||
@ -109,23 +123,22 @@ func (h *Handler) getAgencyCenterStats(writer http.ResponseWriter, request *http
|
||||
defer wait.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
revenue, callErr := revenueClient.GetHostRevenueStats(ctx, &walletv1.GetHostRevenueStatsRequest{
|
||||
RequestId: requestID, AppCode: appCode, HostUserId: membership.GetHostUserId(), StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
engagement, callErr := engagementClient.GetHostEngagementStats(ctx, &userv1.GetHostEngagementStatsRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), HostUserId: membership.GetHostUserId(), StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if callErr == nil {
|
||||
var engagement *userv1.GetHostEngagementStatsResponse
|
||||
engagement, callErr = engagementClient.GetHostEngagementStats(ctx, &userv1.GetHostEngagementStatsRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), HostUserId: membership.GetHostUserId(), StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if callErr == nil {
|
||||
profile := profiles[membership.GetHostUserId()]
|
||||
items[index] = agencyCenterHostStatsData{
|
||||
MembershipID: int64String(membership.GetMembershipId()), HostUserID: int64String(membership.GetHostUserId()),
|
||||
DisplayUserID: profile.DisplayUserID, Username: profile.Username, Avatar: profile.Avatar, JoinedAtMS: membership.GetJoinedAtMs(),
|
||||
DiamondEarnings: revenue.GetStats().GetDiamondEarnings(), DiamondExchanged: revenue.GetStats().GetDiamondExchanged(), GiftSenders: revenue.GetStats().GetGiftSenders(),
|
||||
OnlineDurationMS: engagement.GetStats().GetOnlineDurationMs(), ValidMicDurationMS: engagement.GetStats().GetValidMicDurationMs(), ValidMicDays: engagement.GetStats().GetValidMicDays(),
|
||||
Removable: membership.GetHostUserId() != agency.GetOwnerUserId() && membership.GetMembershipType() != membershipTypeOwner,
|
||||
}
|
||||
profile := profiles[membership.GetHostUserId()]
|
||||
revenue := hostRevenue[membership.GetHostUserId()]
|
||||
revenueMetrics := hostCenterRevenueMetrics(revenue, policyType)
|
||||
items[index] = agencyCenterHostStatsData{
|
||||
MembershipID: int64String(membership.GetMembershipId()), HostUserID: int64String(membership.GetHostUserId()),
|
||||
DisplayUserID: profile.DisplayUserID, Username: profile.Username, Avatar: profile.Avatar, JoinedAtMS: membership.GetJoinedAtMs(),
|
||||
DiamondEarnings: revenueMetrics.DiamondEarnings, DiamondExchanged: revenueMetrics.DiamondExchanged,
|
||||
SalaryDiamondEarnings: revenueMetrics.SalaryDiamondEarnings, LegacyPointExchanged: revenueMetrics.LegacyPointExchanged,
|
||||
PointDiamondEarnings: revenueMetrics.PointDiamondEarnings, PointDiamondExchanged: revenueMetrics.PointDiamondExchanged,
|
||||
GiftSenders: revenue.GetGiftSenders(),
|
||||
OnlineDurationMS: engagement.GetStats().GetOnlineDurationMs(), ValidMicDurationMS: engagement.GetStats().GetValidMicDurationMs(), ValidMicDays: engagement.GetStats().GetValidMicDays(),
|
||||
Removable: membership.GetHostUserId() != agency.GetOwnerUserId() && membership.GetMembershipType() != membershipTypeOwner,
|
||||
}
|
||||
}
|
||||
if callErr != nil {
|
||||
@ -143,23 +156,31 @@ func (h *Handler) getAgencyCenterStats(writer http.ResponseWriter, request *http
|
||||
httpkit.WriteRPCError(writer, request, firstErr)
|
||||
return
|
||||
}
|
||||
shareIncome := shareResp.GetStats().GetShareIncome()
|
||||
// 公会长同时是主播时,其 POINT 流水包含 Agency 分成;主播行只展示个人收益,必须扣除同区间分成。
|
||||
for index := range items {
|
||||
if items[index].HostUserID == int64String(agency.GetOwnerUserId()) {
|
||||
items[index].DiamondEarnings -= shareIncome
|
||||
if items[index].DiamondEarnings < 0 {
|
||||
items[index].DiamondEarnings = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
giftIncome := giftResp.GetStats().GetGiftIncome()
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"range": hostCenterStatsRangeData{StartDate: startDate, EndDate: endDate, Timezone: "UTC"},
|
||||
"summary": agencyCenterStatsSummaryData{
|
||||
TotalEarnings: giftIncome + shareIncome, GiftIncome: giftIncome, ShareIncome: shareIncome,
|
||||
TotalHosts: len(items), GiftedHostCount: giftResp.GetStats().GetGiftedHostCount(),
|
||||
},
|
||||
"hosts": items,
|
||||
"range": hostCenterStatsRangeData{StartDate: startDate, EndDate: endDate, Timezone: "UTC"},
|
||||
"summary": agencyCenterSummaryFromGiftStats(giftResp.GetStats(), len(items), policyType),
|
||||
"hosts": items,
|
||||
})
|
||||
}
|
||||
|
||||
func agencyCenterSummaryFromGiftStats(stats *walletv1.AgencyHostGiftStats, totalHosts int, policyType string) agencyCenterStatsSummaryData {
|
||||
if stats == nil {
|
||||
return agencyCenterStatsSummaryData{
|
||||
TotalHosts: totalHosts, PolicyType: policyType,
|
||||
TotalEarnings: "0", GiftIncome: "0", ShareIncome: "0", SalaryHostIncome: "0",
|
||||
PointDiamondHostIncome: "0", PointDiamondAgencyShareIncome: "0",
|
||||
}
|
||||
}
|
||||
// POINT_DIAMOND 的 Agency 分成来自不可变送礼投影 agency_amount;SALARY_DIAMOND 不写该投影,
|
||||
// 因此不会把已结算 AGENCY_SALARY_USD 或通用钱包账变混进日期统计。
|
||||
giftIncome, shareIncome := stats.GetGiftIncome(), int64(0)
|
||||
if strings.EqualFold(strings.TrimSpace(policyType), "POINT_DIAMOND") {
|
||||
giftIncome, shareIncome = stats.GetPointDiamondHostIncome(), stats.GetPointDiamondAgencyShareIncome()
|
||||
}
|
||||
return agencyCenterStatsSummaryData{
|
||||
TotalEarnings: strconv.FormatInt(giftIncome+shareIncome, 10), GiftIncome: strconv.FormatInt(giftIncome, 10), ShareIncome: strconv.FormatInt(shareIncome, 10),
|
||||
PolicyType: policyType, SalaryHostIncome: strconv.FormatInt(stats.GetGiftIncome(), 10),
|
||||
PointDiamondHostIncome: strconv.FormatInt(stats.GetPointDiamondHostIncome(), 10), PointDiamondAgencyShareIncome: strconv.FormatInt(stats.GetPointDiamondAgencyShareIncome(), 10),
|
||||
TotalHosts: totalHosts, GiftedHostCount: stats.GetGiftedHostCount(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package userapi
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
@ -12,18 +13,27 @@ import (
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
const hostProfileStatusActive = "active"
|
||||
const (
|
||||
hostProfileStatusActive = "active"
|
||||
hostPolicyTypeSalaryDiamond = "SALARY_DIAMOND"
|
||||
)
|
||||
|
||||
type hostCenterPolicyData struct {
|
||||
PolicyID string `json:"policy_id"`
|
||||
CycleKey string `json:"cycle_key"`
|
||||
PolicyVersion uint64 `json:"policy_version"`
|
||||
Name string `json:"name"`
|
||||
PolicyType string `json:"policy_type"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
Status string `json:"status"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
SettlementTriggerMode string `json:"settlement_trigger_mode"`
|
||||
GiftCoinToDiamondRatio string `json:"gift_coin_to_diamond_ratio"`
|
||||
PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"`
|
||||
CoinsPerUSD int64 `json:"coins_per_usd"`
|
||||
AgencyPointShareBPS int32 `json:"agency_point_share_bps"`
|
||||
MinimumWithdrawUSDMinor int64 `json:"minimum_withdraw_usd_minor"`
|
||||
WithdrawFeeBPS int32 `json:"withdraw_fee_bps"`
|
||||
ResidualDiamondToUSDRate string `json:"residual_diamond_to_usd_rate"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
EffectiveToMS int64 `json:"effective_to_ms"`
|
||||
@ -84,25 +94,32 @@ func (h *Handler) getHostCenterPlatformPolicy(writer http.ResponseWriter, reques
|
||||
return
|
||||
}
|
||||
|
||||
// 等级进度必须按工资周期钻石账户计算,不能使用用户成长等级,否则“距离下一级”会和后台工资政策不一致。
|
||||
progressResp, err := h.walletClient.GetHostSalaryProgress(request.Context(), &walletv1.GetHostSalaryProgressRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
HostUserId: profile.GetUserId(),
|
||||
CycleKey: cycleKey,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
var progressData any
|
||||
var levelProgressData any
|
||||
policy := resp.GetPolicy()
|
||||
if resp.GetFound() && strings.EqualFold(strings.TrimSpace(policy.GetPolicyType()), hostPolicyTypeSalaryDiamond) {
|
||||
// 工资周期进度只属于 SALARY_DIAMOND。POINT_DIAMOND 是永久钱包资产,查询或返回工资进度会把旧周期钻石误展示给用户。
|
||||
progressResp, err := h.walletClient.GetHostSalaryProgress(request.Context(), &walletv1.GetHostSalaryProgressRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
HostUserId: profile.GetUserId(),
|
||||
CycleKey: cycleKey,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
progress := progressResp.GetProgress()
|
||||
progressData = hostCenterSalaryProgressFromProto(progress)
|
||||
levelProgressData = hostCenterLevelProgressFromPolicy(policy, progress.GetTotalDiamonds())
|
||||
}
|
||||
progress := progressResp.GetProgress()
|
||||
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"found": resp.GetFound(),
|
||||
"host_region_id": profile.GetRegionId(),
|
||||
"policy": hostCenterPolicyFromProto(resp.GetPolicy()),
|
||||
"progress": hostCenterSalaryProgressFromProto(progress),
|
||||
"level_progress": hostCenterLevelProgressFromPolicy(resp.GetPolicy(), progress.GetTotalDiamonds()),
|
||||
"policy": hostCenterPolicyFromProto(policy),
|
||||
"progress": progressData,
|
||||
"level_progress": levelProgressData,
|
||||
})
|
||||
}
|
||||
|
||||
@ -136,16 +153,24 @@ func hostCenterPolicyFromProto(policy *walletv1.HostSalaryPolicy) *hostCenterPol
|
||||
for _, level := range policy.GetLevels() {
|
||||
levels = append(levels, hostCenterPolicyLevelFromProto(level))
|
||||
}
|
||||
// Host 与 Agency Center 共用该 mapper;政策类型和永久积分参数必须原样透传,
|
||||
// 客户端不能通过 levels 是否为空反推类型,也不能自行补不同 App 的默认值。
|
||||
return &hostCenterPolicyData{
|
||||
PolicyID: strconv.FormatUint(policy.GetPolicyId(), 10),
|
||||
CycleKey: policy.GetCycleKey(),
|
||||
PolicyVersion: policy.GetPolicyVersion(),
|
||||
Name: policy.GetName(),
|
||||
PolicyType: policy.GetPolicyType(),
|
||||
RegionID: policy.GetRegionId(),
|
||||
Status: policy.GetStatus(),
|
||||
SettlementMode: policy.GetSettlementMode(),
|
||||
SettlementTriggerMode: policy.GetSettlementTriggerMode(),
|
||||
GiftCoinToDiamondRatio: policy.GetGiftCoinToDiamondRatio(),
|
||||
PointDiamondsPerUSD: policy.GetPointDiamondsPerUsd(),
|
||||
CoinsPerUSD: policy.GetCoinsPerUsd(),
|
||||
AgencyPointShareBPS: policy.GetAgencyPointShareBps(),
|
||||
MinimumWithdrawUSDMinor: policy.GetMinimumWithdrawUsdMinor(),
|
||||
WithdrawFeeBPS: policy.GetWithdrawFeeBps(),
|
||||
ResidualDiamondToUSDRate: policy.GetResidualDiamondToUsdRate(),
|
||||
EffectiveFromMS: policy.GetEffectiveFromMs(),
|
||||
EffectiveToMS: policy.GetEffectiveToMs(),
|
||||
|
||||
@ -3,6 +3,7 @@ package userapi
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -29,14 +30,19 @@ type hostCenterStatsRangeData struct {
|
||||
}
|
||||
|
||||
type hostCenterStatsMetricsData struct {
|
||||
DiamondEarnings int64 `json:"diamond_earnings"`
|
||||
DiamondExchanged int64 `json:"diamond_exchanged"`
|
||||
GiftSenders int64 `json:"gift_senders"`
|
||||
OnlineDurationMS int64 `json:"online_duration_ms"`
|
||||
ValidMicDurationMS int64 `json:"valid_mic_duration_ms"`
|
||||
ValidMicDays int64 `json:"valid_mic_days"`
|
||||
PrivateMessageSenders int64 `json:"private_message_senders"`
|
||||
NewFollowers int64 `json:"new_followers"`
|
||||
PolicyType string `json:"policy_type"`
|
||||
DiamondEarnings string `json:"diamond_earnings"`
|
||||
DiamondExchanged string `json:"diamond_exchanged"`
|
||||
SalaryDiamondEarnings string `json:"salary_diamond_earnings"`
|
||||
LegacyPointExchanged string `json:"legacy_point_exchanged"`
|
||||
PointDiamondEarnings string `json:"point_diamond_earnings"`
|
||||
PointDiamondExchanged string `json:"point_diamond_exchanged"`
|
||||
GiftSenders int64 `json:"gift_senders"`
|
||||
OnlineDurationMS int64 `json:"online_duration_ms"`
|
||||
ValidMicDurationMS int64 `json:"valid_mic_duration_ms"`
|
||||
ValidMicDays int64 `json:"valid_mic_days"`
|
||||
PrivateMessageSenders int64 `json:"private_message_senders"`
|
||||
NewFollowers int64 `json:"new_followers"`
|
||||
}
|
||||
|
||||
// getHostCenterStats 只编排 user/wallet 两个 owner 的只读聚合,不在 gateway 重算账务或在线时长。
|
||||
@ -71,6 +77,7 @@ func (h *Handler) getHostCenterStats(writer http.ResponseWriter, request *http.R
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
HostUserId: profile.GetUserId(),
|
||||
RegionId: profile.GetRegionId(),
|
||||
StartAtMs: startAtMS,
|
||||
EndAtMs: endAtMS,
|
||||
})
|
||||
@ -78,23 +85,47 @@ func (h *Handler) getHostCenterStats(writer http.ResponseWriter, request *http.R
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
policyResp, err := h.walletClient.GetActiveHostSalaryPolicy(request.Context(), &walletv1.GetActiveHostSalaryPolicyRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
RegionId: profile.GetRegionId(), CycleKey: time.Now().UTC().Format("2006-01"),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
engagement := engagementResp.GetStats()
|
||||
revenue := revenueResp.GetStats()
|
||||
metrics := hostCenterRevenueMetrics(revenue, policyResp.GetPolicy().GetPolicyType())
|
||||
metrics.GiftSenders = revenue.GetGiftSenders()
|
||||
metrics.OnlineDurationMS = engagement.GetOnlineDurationMs()
|
||||
metrics.ValidMicDurationMS = engagement.GetValidMicDurationMs()
|
||||
metrics.ValidMicDays = engagement.GetValidMicDays()
|
||||
metrics.PrivateMessageSenders = engagement.GetPrivateMessageSenders()
|
||||
metrics.NewFollowers = engagement.GetNewFollowers()
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"range": hostCenterStatsRangeData{StartDate: startDate, EndDate: endDate, Timezone: "UTC"},
|
||||
"metrics": hostCenterStatsMetricsData{
|
||||
DiamondEarnings: revenue.GetDiamondEarnings(),
|
||||
DiamondExchanged: revenue.GetDiamondExchanged(),
|
||||
GiftSenders: revenue.GetGiftSenders(),
|
||||
OnlineDurationMS: engagement.GetOnlineDurationMs(),
|
||||
ValidMicDurationMS: engagement.GetValidMicDurationMs(),
|
||||
ValidMicDays: engagement.GetValidMicDays(),
|
||||
PrivateMessageSenders: engagement.GetPrivateMessageSenders(),
|
||||
NewFollowers: engagement.GetNewFollowers(),
|
||||
},
|
||||
"range": hostCenterStatsRangeData{StartDate: startDate, EndDate: endDate, Timezone: "UTC"},
|
||||
"metrics": metrics,
|
||||
})
|
||||
}
|
||||
|
||||
func hostCenterRevenueMetrics(revenue *walletv1.HostRevenueStats, policyType string) hostCenterStatsMetricsData {
|
||||
salaryEarnings := revenue.GetDiamondEarnings()
|
||||
legacyExchanged := revenue.GetDiamondExchanged()
|
||||
pointEarnings := revenue.GetPointDiamondEarnings()
|
||||
pointExchanged := revenue.GetPointDiamondExchanged()
|
||||
selectedEarnings, selectedExchanged := salaryEarnings, legacyExchanged
|
||||
if strings.EqualFold(strings.TrimSpace(policyType), "POINT_DIAMOND") {
|
||||
selectedEarnings, selectedExchanged = pointEarnings, pointExchanged
|
||||
}
|
||||
result := hostCenterStatsMetricsData{
|
||||
PolicyType: policyType,
|
||||
SalaryDiamondEarnings: strconv.FormatInt(salaryEarnings, 10), LegacyPointExchanged: strconv.FormatInt(legacyExchanged, 10),
|
||||
PointDiamondEarnings: strconv.FormatInt(pointEarnings, 10), PointDiamondExchanged: strconv.FormatInt(pointExchanged, 10),
|
||||
DiamondEarnings: strconv.FormatInt(selectedEarnings, 10), DiamondExchanged: strconv.FormatInt(selectedExchanged, 10),
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseHostCenterStatsRange(request *http.Request) (string, string, int64, int64, bool) {
|
||||
startText := strings.TrimSpace(request.URL.Query().Get("start_date"))
|
||||
endText := strings.TrimSpace(request.URL.Query().Get("end_date"))
|
||||
|
||||
@ -3,8 +3,25 @@ package userapi
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
func TestHostCenterPolicyFromProtoMapsPointDiamondConfig(t *testing.T) {
|
||||
policy := hostCenterPolicyFromProto(&walletv1.HostSalaryPolicy{
|
||||
PolicyId: 9002,
|
||||
PolicyType: "POINT_DIAMOND",
|
||||
PointDiamondsPerUsd: 300000,
|
||||
CoinsPerUsd: 80000,
|
||||
AgencyPointShareBps: 2000,
|
||||
MinimumWithdrawUsdMinor: 500,
|
||||
WithdrawFeeBps: 250,
|
||||
})
|
||||
if policy == nil || policy.PolicyID != "9002" || policy.PolicyType != "POINT_DIAMOND" || policy.PointDiamondsPerUSD != 300000 || policy.CoinsPerUSD != 80000 || policy.AgencyPointShareBPS != 2000 || policy.MinimumWithdrawUSDMinor != 500 || policy.WithdrawFeeBPS != 250 {
|
||||
t.Fatalf("POINT_DIAMOND policy mapping mismatch: %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostCenterStatsRangeUsesInclusiveNaturalDays(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/v1/host-center/stats?start_date=2026-07-06&end_date=2026-07-12", nil)
|
||||
startDate, endDate, startAtMS, endAtMS, ok := parseHostCenterStatsRange(request)
|
||||
@ -28,3 +45,23 @@ func TestParseHostCenterStatsRangeRejectsInvalidOrOversizedRanges(t *testing.T)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgencyCenterSummaryKeepsHostAndAgencyPointDiamondComponentsSeparate(t *testing.T) {
|
||||
stats := &walletv1.AgencyHostGiftStats{GiftIncome: 999, PointDiamondHostIncome: 100, PointDiamondAgencyShareIncome: 20, GiftedHostCount: 1}
|
||||
summary := agencyCenterSummaryFromGiftStats(stats, 1, "POINT_DIAMOND")
|
||||
if summary.GiftIncome != "100" || summary.ShareIncome != "20" || summary.TotalEarnings != "120" || summary.SalaryHostIncome != "999" || summary.PointDiamondHostIncome != "100" || summary.PointDiamondAgencyShareIncome != "20" || summary.GiftedHostCount != 1 || summary.TotalHosts != 1 {
|
||||
t.Fatalf("self Host/Agency summary split mismatch: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCenterRevenueMetricsSelectsCurrentPolicyWithoutMixingAssets(t *testing.T) {
|
||||
stats := &walletv1.HostRevenueStats{DiamondEarnings: 900, DiamondExchanged: 80, PointDiamondEarnings: 120, PointDiamondExchanged: 40}
|
||||
pointMetrics := hostCenterRevenueMetrics(stats, "POINT_DIAMOND")
|
||||
if pointMetrics.DiamondEarnings != "120" || pointMetrics.DiamondExchanged != "40" || pointMetrics.SalaryDiamondEarnings != "900" || pointMetrics.LegacyPointExchanged != "80" {
|
||||
t.Fatalf("POINT_DIAMOND metrics mixed assets: %+v", pointMetrics)
|
||||
}
|
||||
salaryMetrics := hostCenterRevenueMetrics(stats, "SALARY_DIAMOND")
|
||||
if salaryMetrics.DiamondEarnings != "900" || salaryMetrics.DiamondExchanged != "80" || salaryMetrics.PointDiamondEarnings != "120" || salaryMetrics.PointDiamondExchanged != "40" {
|
||||
t.Fatalf("SALARY_DIAMOND metrics changed legacy fields: %+v", salaryMetrics)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
@ -159,7 +162,7 @@ func (b directGiftRequestBody) normalizedEntitlementID() string {
|
||||
|
||||
// sendDirectGift 完成私聊送礼账务结算,并由服务端发送腾讯云 IM C2C custom message。
|
||||
func (h *Handler) sendDirectGift(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil || h.userProfileClient == nil {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
@ -182,6 +185,11 @@ func (h *Handler) sendDirectGift(writer http.ResponseWriter, request *http.Reque
|
||||
}
|
||||
|
||||
senderUserID := auth.UserIDFromContext(request.Context())
|
||||
app := appcode.FromContext(request.Context())
|
||||
if h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
sender, senderRegionID, ok := h.resolveDirectGiftSender(writer, request, senderUserID)
|
||||
if !ok {
|
||||
return
|
||||
@ -198,8 +206,9 @@ func (h *Handler) sendDirectGift(writer http.ResponseWriter, request *http.Reque
|
||||
if !h.ensureDirectGiftIMAccounts(writer, request, sender, target) {
|
||||
return
|
||||
}
|
||||
app := appcode.FromContext(request.Context())
|
||||
resp, err := h.walletClient.DebitDirectGift(request.Context(), &walletv1.DebitDirectGiftRequest{
|
||||
// 首次和重试都使用同一稳定 command_id;Wallet 会在读取当前派生上下文后优先返回首次回执,
|
||||
// 因此正常请求无需额外做一次只读 receipt 探测。
|
||||
walletRequest := &walletv1.DebitDirectGiftRequest{
|
||||
CommandId: commandID,
|
||||
SenderUserId: senderUserID,
|
||||
TargetUserId: targetUserID,
|
||||
@ -214,11 +223,18 @@ func (h *Handler) sendDirectGift(writer http.ResponseWriter, request *http.Reque
|
||||
TargetAgencyOwnerUserId: hostScope.targetAgencyOwnerUserID,
|
||||
EntitlementId: body.normalizedEntitlementID(),
|
||||
ChargeSource: strings.TrimSpace(body.Source),
|
||||
})
|
||||
}
|
||||
resp, err := h.walletClient.DebitDirectGift(request.Context(), walletRequest)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
h.completeDirectGift(writer, request, app, commandID, senderUserID, targetUserID, giftID, giftCount, resp)
|
||||
}
|
||||
|
||||
// completeDirectGift 把首次与重放回执收敛成同一个逻辑 IM 事件和 HTTP 响应。
|
||||
// IM 投递允许重试,但 event_id、消息去重键和账务 payload 始终来自首次 Wallet 回执,客户端不会看到第二笔礼物事实。
|
||||
func (h *Handler) completeDirectGift(writer http.ResponseWriter, request *http.Request, app string, commandID string, senderUserID int64, targetUserID int64, giftID string, giftCount int32, resp *walletv1.DebitGiftResponse) {
|
||||
settlement := directGiftSettlementFromProto(app, commandID, senderUserID, targetUserID, giftID, giftCount, resp)
|
||||
_, message, ok := h.publishDirectGiftIM(writer, request, senderUserID, targetUserID, settlement)
|
||||
if !ok {
|
||||
@ -263,9 +279,12 @@ func (h *Handler) resolveDirectGiftTarget(writer http.ResponseWriter, request *h
|
||||
}
|
||||
|
||||
func (h *Handler) resolveDirectGiftTargetHostScope(request *http.Request, targetUserID int64) (directGiftHostScope, error) {
|
||||
if targetUserID <= 0 || h.userHostClient == nil {
|
||||
// host client 缺失时不阻断普通私聊送礼,但主播周期工资入账必须 fail-closed,避免客户端伪造主播身份。
|
||||
return directGiftHostScope{}, nil
|
||||
if targetUserID <= 0 {
|
||||
return directGiftHostScope{}, status.Error(codes.InvalidArgument, "target user is invalid")
|
||||
}
|
||||
if h.userHostClient == nil {
|
||||
// Host 身份与 Agency 归属只能由 user-service 判定;配置缺失时不能把真实 Host 降级成普通收礼人绕过收益政策。
|
||||
return directGiftHostScope{}, status.Error(codes.Unavailable, "user host service is unavailable")
|
||||
}
|
||||
resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
@ -277,10 +296,16 @@ func (h *Handler) resolveDirectGiftTargetHostScope(request *http.Request, target
|
||||
}
|
||||
return directGiftHostScope{}, err
|
||||
}
|
||||
if resp == nil || resp.GetHostProfile() == nil {
|
||||
return directGiftHostScope{}, status.Error(codes.Unavailable, "user host service returned an invalid response")
|
||||
}
|
||||
profile := resp.GetHostProfile()
|
||||
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
|
||||
if !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") {
|
||||
return directGiftHostScope{}, nil
|
||||
}
|
||||
if profile.GetRegionId() <= 0 {
|
||||
return directGiftHostScope{}, status.Error(codes.FailedPrecondition, "active host region is missing")
|
||||
}
|
||||
return directGiftHostScope{
|
||||
targetIsHost: true,
|
||||
targetHostRegionID: profile.GetRegionId(),
|
||||
@ -313,13 +338,14 @@ func (h *Handler) publishDirectGiftIM(writer http.ResponseWriter, request *http.
|
||||
return nil, nil, false
|
||||
}
|
||||
if err := h.imPublisher.PublishUserCustomMessage(request.Context(), tencentim.CustomUserMessage{
|
||||
ToAccount: tencentim.FormatUserID(targetUserID),
|
||||
FromAccount: tencentim.FormatUserID(senderUserID),
|
||||
SyncOtherMachine: 1,
|
||||
EventID: settlement.EventID,
|
||||
Desc: directGiftIMDesc,
|
||||
Ext: directGiftIMExt,
|
||||
PayloadJSON: payloadJSON,
|
||||
ToAccount: tencentim.FormatUserID(targetUserID),
|
||||
FromAccount: tencentim.FormatUserID(senderUserID),
|
||||
SyncOtherMachine: 1,
|
||||
EventID: settlement.EventID,
|
||||
Desc: directGiftIMDesc,
|
||||
Ext: directGiftIMExt,
|
||||
DeduplicateByEventID: true,
|
||||
PayloadJSON: payloadJSON,
|
||||
}); err != nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return nil, nil, false
|
||||
|
||||
@ -122,6 +122,12 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
|
||||
WithdrawPointWallet: h.withdrawPointWallet,
|
||||
TransferPointWalletToCoinSeller: h.transferPointWalletToCoinSeller,
|
||||
ExchangePointWalletToCoins: h.exchangePointWalletToCoins,
|
||||
GetPointDiamondWalletOverview: h.getPointDiamondWalletOverview,
|
||||
GetPointDiamondWalletHistory: h.getPointDiamondWalletHistory,
|
||||
ExchangePointDiamondWalletToCoins: h.exchangePointDiamondWalletToCoins,
|
||||
WithdrawPointDiamondWallet: h.withdrawPointDiamondWallet,
|
||||
TransferPointDiamondToCoinSeller: h.transferPointDiamondWalletToCoinSeller,
|
||||
HandlePointDiamondWithdrawAddress: h.handleSalaryWalletWithdrawAddress,
|
||||
GetRedPacketConfig: h.getRedPacketConfig,
|
||||
ListRoomRedPackets: h.listRoomRedPackets,
|
||||
CreateRoomRedPacket: h.createRoomRedPacket,
|
||||
|
||||
@ -0,0 +1,510 @@
|
||||
package walletapi
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/financewithdrawal"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
const (
|
||||
pointDiamondWalletAssetType = "POINT_DIAMOND"
|
||||
pointDiamondPolicyType = "POINT_DIAMOND"
|
||||
)
|
||||
|
||||
// pointDiamondWalletCommandBody 只接收用户的稳定业务意图。积分换算、费率和币商到账数量
|
||||
// 必须由 wallet-service 在锁账事务内读取已发布政策,HTTP 层不接受客户端自报结果。
|
||||
type pointDiamondWalletCommandBody struct {
|
||||
CommandID string `json:"command_id"`
|
||||
CommandIDCamel string `json:"commandId"`
|
||||
PointAmount flexibleJSONInt64 `json:"point_amount"`
|
||||
PointAmountCamel flexibleJSONInt64 `json:"pointAmount"`
|
||||
GrossUSDMinor flexibleJSONInt64 `json:"gross_usd_minor"`
|
||||
GrossUSDMinorCamel flexibleJSONInt64 `json:"grossUsdMinor"`
|
||||
TargetDisplayUserID string `json:"target_display_user_id"`
|
||||
TargetDisplayUserIDCamel string `json:"targetDisplayUserId"`
|
||||
|
||||
WithdrawUSDTTRC20Address string `json:"withdraw_usdt_trc20_address"`
|
||||
WithdrawUSDTTRC20AddressC string `json:"withdrawUsdtTrc20Address"`
|
||||
USDTTRC20Address string `json:"usdt_trc20_address"`
|
||||
USDTTRC20AddressC string `json:"usdtTrc20Address"`
|
||||
PaymentAddress string `json:"payment_address"`
|
||||
PaymentAddressC string `json:"paymentAddress"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
func (b pointDiamondWalletCommandBody) commandID() string {
|
||||
if value := strings.TrimSpace(b.CommandID); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(b.CommandIDCamel)
|
||||
}
|
||||
|
||||
func (b pointDiamondWalletCommandBody) pointAmount() int64 {
|
||||
if value := b.PointAmount.Int64(); value > 0 {
|
||||
return value
|
||||
}
|
||||
return b.PointAmountCamel.Int64()
|
||||
}
|
||||
|
||||
func (b pointDiamondWalletCommandBody) grossUSDMinor() int64 {
|
||||
if value := b.GrossUSDMinor.Int64(); value > 0 {
|
||||
return value
|
||||
}
|
||||
return b.GrossUSDMinorCamel.Int64()
|
||||
}
|
||||
|
||||
func (b pointDiamondWalletCommandBody) targetDisplayUserID() string {
|
||||
if value := strings.TrimSpace(b.TargetDisplayUserID); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(b.TargetDisplayUserIDCamel)
|
||||
}
|
||||
|
||||
func (b pointDiamondWalletCommandBody) usdtTRC20Address() string {
|
||||
return salaryWalletWithdrawAddressBody{
|
||||
WithdrawUSDTTRC20Address: b.WithdrawUSDTTRC20Address,
|
||||
WithdrawUSDTTRC20AddressC: b.WithdrawUSDTTRC20AddressC,
|
||||
USDTTRC20Address: b.USDTTRC20Address,
|
||||
USDTTRC20AddressC: b.USDTTRC20AddressC,
|
||||
PaymentAddress: b.PaymentAddress,
|
||||
PaymentAddressC: b.PaymentAddressC,
|
||||
Address: b.Address,
|
||||
}.usdtTRC20Address()
|
||||
}
|
||||
|
||||
type pointDiamondBalanceData struct {
|
||||
AssetType string `json:"asset_type"`
|
||||
AvailablePoints string `json:"available_points"`
|
||||
AvailableAmount string `json:"available_amount"`
|
||||
FrozenAmount string `json:"frozen_amount"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type pointDiamondPolicyData struct {
|
||||
PointDiamondsPerUSD string `json:"point_diamonds_per_usd"`
|
||||
CoinsPerUSD string `json:"coins_per_usd"`
|
||||
AgencyPointShareBPS string `json:"agency_point_share_bps"`
|
||||
MinimumWithdrawUSDMinor string `json:"minimum_withdraw_usd_minor"`
|
||||
WithdrawFeeBPS string `json:"withdraw_fee_bps"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
PolicyVersion string `json:"policy_version"`
|
||||
PolicyInstanceCode string `json:"policy_instance_code"`
|
||||
}
|
||||
|
||||
type pointDiamondActionsData struct {
|
||||
ExchangeToCoins bool `json:"exchange_to_coins"`
|
||||
Withdraw bool `json:"withdraw"`
|
||||
TransferToCoinSeller bool `json:"transfer_to_coin_seller"`
|
||||
}
|
||||
|
||||
type pointDiamondActionAvailabilityData struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
LimitPeriod string `json:"limit_period"`
|
||||
LimitCount string `json:"limit_count"`
|
||||
UsedCount string `json:"used_count"`
|
||||
RemainingCount string `json:"remaining_count"`
|
||||
AllowedDays string `json:"allowed_days"`
|
||||
BlockReason string `json:"block_reason"`
|
||||
}
|
||||
|
||||
type pointDiamondCoinSellerData struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
ServiceCountryCodes []string `json:"service_country_codes"`
|
||||
}
|
||||
|
||||
func (h *Handler) getPointDiamondWalletOverview(writer http.ResponseWriter, request *http.Request) {
|
||||
pointWallet, profile, config, ok := h.resolvePointDiamondWalletContext(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
balanceResp, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
UserId: auth.UserIDFromContext(request.Context()), AssetTypes: []string{pointDiamondWalletAssetType},
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
balance := pointDiamondBalanceFromProto(firstBalance(balanceResp.GetBalances()))
|
||||
policy := pointDiamondPolicyFromProto(config)
|
||||
enabled := config.GetFound() && config.GetPolicyType() == pointDiamondPolicyType
|
||||
sellers := []pointDiamondCoinSellerData{}
|
||||
if enabled {
|
||||
// POINT_DIAMOND 只能展示 wallet 按 App 与用户国家过滤后的币商白名单;通用币商目录
|
||||
// 不是资金资格事实,否则页面会展示最终被 wallet 拒绝的币商。
|
||||
sellerResp, err := pointWallet.ListPointWithdrawalCoinSellers(request.Context(), &walletv1.ListPointWithdrawalCoinSellersRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
CountryCode: pointWalletCountryCode(profile),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
legacySellers, err := h.pointWalletCoinSellerData(request, sellerResp.GetSellers())
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
sellers = make([]pointDiamondCoinSellerData, 0, len(legacySellers))
|
||||
for _, seller := range legacySellers {
|
||||
sellers = append(sellers, pointDiamondCoinSellerData{
|
||||
UserID: seller.UserID, DisplayUserID: seller.DisplayUserID, Nickname: seller.Nickname,
|
||||
Avatar: seller.Avatar, SortOrder: seller.SortOrder,
|
||||
ServiceCountryCodes: append([]string(nil), seller.ServiceCountryCodes...),
|
||||
})
|
||||
}
|
||||
}
|
||||
availabilityEvaluated := config.GetAvailabilityEvaluated()
|
||||
actions := pointDiamondActionsData{
|
||||
ExchangeToCoins: enabled,
|
||||
Withdraw: enabled && availabilityEvaluated && config.GetPlatformAvailability().GetAllowed(),
|
||||
TransferToCoinSeller: enabled && availabilityEvaluated && config.GetCoinSellerAvailability().GetAllowed() && len(sellers) > 0,
|
||||
}
|
||||
channels := []string{}
|
||||
if enabled {
|
||||
channels = append(channels, "COIN")
|
||||
if actions.Withdraw {
|
||||
channels = append(channels, "PLATFORM_USDT")
|
||||
}
|
||||
if actions.TransferToCoinSeller {
|
||||
channels = append(channels, "COIN_SELLER_USDT")
|
||||
}
|
||||
}
|
||||
// 根级兼容字段和结构化字段来自同一份 wallet 快照,避免不同客户端各自推导比例后产生口径分叉。
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"enabled": enabled, "policy_type": pointDiamondPolicyType, "asset_type": pointDiamondWalletAssetType,
|
||||
"balance": balance, "policy": policy, "actions": actions, "channels": channels,
|
||||
"coin_sellers": sellers,
|
||||
"availability_evaluated": availabilityEvaluated,
|
||||
"action_limits": map[string]pointDiamondActionAvailabilityData{
|
||||
"withdraw": pointDiamondActionAvailabilityFromProto(config.GetPlatformAvailability()),
|
||||
"transfer_to_coin_seller": pointDiamondActionAvailabilityFromProto(config.GetCoinSellerAvailability()),
|
||||
},
|
||||
"available_points": balance.AvailablePoints, "available_amount": balance.AvailableAmount,
|
||||
"point_diamonds_per_usd": policy.PointDiamondsPerUSD, "coins_per_usd": policy.CoinsPerUSD,
|
||||
"agency_point_share_bps": policy.AgencyPointShareBPS,
|
||||
"minimum_withdraw_usd_minor": policy.MinimumWithdrawUSDMinor, "withdraw_fee_bps": policy.WithdrawFeeBPS,
|
||||
"withdraw_address": salaryWithdrawAddressFromProto(profile),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) getPointDiamondWalletHistory(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
page, pageSize, startAtMS, endAtMS, ok := walletTransactionQuery(request, 20)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.walletClient.ListWalletTransactions(request.Context(), &walletv1.ListWalletTransactionsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
UserId: auth.UserIDFromContext(request.Context()), AssetType: pointDiamondWalletAssetType,
|
||||
Page: page, PageSize: pageSize, StartAtMs: startAtMS, EndAtMs: endAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(resp.GetTransactions()))
|
||||
for _, item := range resp.GetTransactions() {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
components := make([]map[string]string, 0, 2)
|
||||
if item.GetHostBaseAmount() > 0 {
|
||||
components = append(components, map[string]string{
|
||||
"entry_role": "host_gift_income", "amount": strconv.FormatInt(item.GetHostBaseAmount(), 10),
|
||||
})
|
||||
}
|
||||
if item.GetAgencyAmount() > 0 {
|
||||
components = append(components, map[string]string{
|
||||
"entry_role": "agency_gift_share", "amount": strconv.FormatInt(item.GetAgencyAmount(), 10),
|
||||
})
|
||||
}
|
||||
// 钱包流水可能超过 JavaScript 安全整数;所有 ID 与金额均返回十进制字符串。
|
||||
items = append(items, map[string]any{
|
||||
"entry_id": strconv.FormatInt(item.GetEntryId(), 10), "transaction_id": item.GetTransactionId(),
|
||||
"biz_type": item.GetBizType(), "entry_role": item.GetEntryRole(), "asset_type": item.GetAssetType(),
|
||||
"available_delta": strconv.FormatInt(item.GetAvailableDelta(), 10), "frozen_delta": strconv.FormatInt(item.GetFrozenDelta(), 10),
|
||||
"available_after": strconv.FormatInt(item.GetAvailableAfter(), 10), "frozen_after": strconv.FormatInt(item.GetFrozenAfter(), 10),
|
||||
"counterparty_user_id": strconv.FormatInt(item.GetCounterpartyUserId(), 10),
|
||||
"room_id": item.GetRoomId(), "created_at_ms": strconv.FormatInt(item.GetCreatedAtMs(), 10),
|
||||
"host_base_amount": strconv.FormatInt(item.GetHostBaseAmount(), 10),
|
||||
"agency_amount": strconv.FormatInt(item.GetAgencyAmount(), 10),
|
||||
"policy_id": strconv.FormatUint(item.GetPolicyId(), 10),
|
||||
"policy_version": strconv.FormatUint(item.GetPolicyVersion(), 10),
|
||||
"ratio_point_amount": strconv.FormatInt(item.GetRatioPointAmount(), 10),
|
||||
"ratio_coin_amount": strconv.FormatInt(item.GetRatioCoinAmount(), 10),
|
||||
"gross_usd_minor": strconv.FormatInt(item.GetGrossUsdMinor(), 10),
|
||||
"fee_usd_minor": strconv.FormatInt(item.GetFeeUsdMinor(), 10),
|
||||
"net_usd_minor": strconv.FormatInt(item.GetNetUsdMinor(), 10),
|
||||
"withdraw_fee_bps": strconv.FormatInt(item.GetWithdrawFeeBps(), 10),
|
||||
"components": components,
|
||||
})
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"asset_type": pointDiamondWalletAssetType, "items": items, "total": strconv.FormatInt(resp.GetTotal(), 10),
|
||||
"page": page, "page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) exchangePointDiamondWalletToCoins(writer http.ResponseWriter, request *http.Request) {
|
||||
pointWallet, profile, ok := h.resolvePointDiamondWalletProfile(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body pointDiamondWalletCommandBody
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID, pointAmount := body.commandID(), body.pointAmount()
|
||||
if commandID == "" || pointAmount <= 0 {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
receipt, err := pointWallet.ExchangePointToCoin(request.Context(), &walletv1.ExchangePointToCoinRequest{
|
||||
CommandId: commandID, AppCode: appcode.FromContext(request.Context()), UserId: auth.UserIDFromContext(request.Context()),
|
||||
PointAmount: pointAmount, RegionId: profile.GetRegionId(), NowMs: time.Now().UTC().UnixMilli(), SourceAssetType: pointDiamondWalletAssetType,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
balanceAfter := map[string]string{
|
||||
"asset_type": pointDiamondWalletAssetType,
|
||||
"available_amount": strconv.FormatInt(receipt.GetPointBalanceAfter(), 10),
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"transaction_id": receipt.GetTransactionId(), "asset_type": pointDiamondWalletAssetType,
|
||||
"point_amount": strconv.FormatInt(receipt.GetPointAmount(), 10), "coin_amount": strconv.FormatInt(receipt.GetCoinAmount(), 10),
|
||||
"point_diamond_balance_after": strconv.FormatInt(receipt.GetPointBalanceAfter(), 10),
|
||||
"coin_balance_after": strconv.FormatInt(receipt.GetCoinBalanceAfter(), 10), "balance_after": balanceAfter,
|
||||
"ratio_point_amount": strconv.FormatInt(receipt.GetRatioPointAmount(), 10),
|
||||
"ratio_coin_amount": strconv.FormatInt(receipt.GetRatioCoinAmount(), 10),
|
||||
"policy_id": strconv.FormatUint(receipt.GetPolicyId(), 10),
|
||||
"policy_version": strconv.FormatUint(receipt.GetPolicyVersion(), 10),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) transferPointDiamondWalletToCoinSeller(writer http.ResponseWriter, request *http.Request) {
|
||||
pointWallet, profile, ok := h.resolvePointDiamondWalletProfile(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.userIdentityClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
var body pointDiamondWalletCommandBody
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID, grossUSDMinor, displayUserID := body.commandID(), body.grossUSDMinor(), body.targetDisplayUserID()
|
||||
if commandID == "" || grossUSDMinor <= 0 || displayUserID == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
// 展示号只解析成稳定 user_id;币商 active/国家范围由 wallet 事务中的白名单再次校验。
|
||||
// 这样首次成功但响应丢失后,即使币商被停用或用户换区,同 command_id 仍能先命中原回执。
|
||||
sellerUserID, ok := h.resolveCoinSellerTransferTargetUserID(writer, request, displayUserID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
receipt, err := pointWallet.TransferPointToCoinSeller(request.Context(), &walletv1.TransferPointToCoinSellerRequest{
|
||||
CommandId: commandID, AppCode: appcode.FromContext(request.Context()), SourceUserId: auth.UserIDFromContext(request.Context()),
|
||||
SellerUserId: sellerUserID, SourceCountryCode: pointWalletCountryCode(profile), Reason: "point diamond transfer to coin seller",
|
||||
RegionId: profile.GetRegionId(), SourceAssetType: pointDiamondWalletAssetType, GrossUsdMinor: grossUSDMinor,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"transaction_id": receipt.GetTransactionId(), "asset_type": pointDiamondWalletAssetType,
|
||||
"target_display_user_id": displayUserID, "seller_user_id": strconv.FormatInt(sellerUserID, 10),
|
||||
"gross_usd_minor": strconv.FormatInt(receipt.GetGrossUsdMinor(), 10), "fee_usd_minor": strconv.FormatInt(receipt.GetFeeUsdMinor(), 10),
|
||||
"net_usd_minor": strconv.FormatInt(receipt.GetNetUsdMinor(), 10), "point_amount": strconv.FormatInt(receipt.GetPointAmount(), 10),
|
||||
"coin_amount": strconv.FormatInt(receipt.GetSellerCoinAmount(), 10),
|
||||
"point_diamond_balance_after": strconv.FormatInt(receipt.GetSourcePointBalanceAfter(), 10),
|
||||
"seller_balance_after": strconv.FormatInt(receipt.GetSellerBalanceAfter(), 10),
|
||||
"balance_after": map[string]string{"asset_type": pointDiamondWalletAssetType, "available_amount": strconv.FormatInt(receipt.GetSourcePointBalanceAfter(), 10)},
|
||||
"ratio_point_amount": strconv.FormatInt(receipt.GetRatioPointAmount(), 10),
|
||||
"ratio_coin_amount": strconv.FormatInt(receipt.GetRatioSellerCoinAmount(), 10),
|
||||
"withdraw_fee_bps": strconv.FormatInt(int64(receipt.GetWithdrawFeeBps()), 10),
|
||||
"policy_id": strconv.FormatUint(receipt.GetPolicyId(), 10),
|
||||
"policy_version": strconv.FormatUint(receipt.GetPolicyVersion(), 10),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) withdrawPointDiamondWallet(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.withdrawalWriter == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
pointWallet, profile, ok := h.resolvePointDiamondWalletProfile(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body pointDiamondWalletCommandBody
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID, grossUSDMinor := body.commandID(), body.grossUSDMinor()
|
||||
if commandID == "" || grossUSDMinor <= 0 {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
address, ok := h.resolveSalaryWalletWithdrawAddress(writer, request, userID, body.usdtTRC20Address())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
withdrawalRef := pointWithdrawalReference(commandID)
|
||||
freezeResp, err := pointWallet.FreezePointWithdrawal(request.Context(), &walletv1.FreezePointWithdrawalRequest{
|
||||
CommandId: commandID, UserId: userID, AssetType: pointDiamondWalletAssetType,
|
||||
Reason: "point diamond withdrawal submitted", AppCode: appcode.FromContext(request.Context()),
|
||||
WithdrawalRef: withdrawalRef, RegionId: profile.GetRegionId(), GrossUsdMinor: grossUSDMinor,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
// 后续申请和补偿只使用 wallet 首次冻结回执;同 command_id 重放不能混入本次页面预览政策。
|
||||
frozenGross := freezeResp.GetGrossPointAmount()
|
||||
frozenFee := freezeResp.GetFeePointAmount()
|
||||
frozenNet := freezeResp.GetNetPointAmount()
|
||||
frozenPointsPerUSD := freezeResp.GetPointsPerUsd()
|
||||
frozenFeeBPS := freezeResp.GetFeeBps()
|
||||
policyInstance := strings.TrimSpace(freezeResp.GetPolicyInstanceCode())
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
application, err := h.withdrawalWriter.CreateApplication(request.Context(), financewithdrawal.CreateApplicationCommand{
|
||||
AppCode: appcode.FromContext(request.Context()), UserID: userID, SalaryAssetType: pointDiamondWalletAssetType,
|
||||
WithdrawAmount: formatUSDMinor(freezeResp.GetNetUsdMinor()), WithdrawAmountMinor: frozenGross,
|
||||
PointFeeAmount: frozenFee, PointNetAmount: frozenNet, PointsPerUSD: frozenPointsPerUSD, PointFeeBPS: frozenFeeBPS,
|
||||
PointPolicyInstance: policyInstance, WithdrawMethod: financewithdrawal.MethodUSDTTRC20, WithdrawAddress: address,
|
||||
FreezeCommandID: commandID, FreezeTransactionID: freezeResp.GetTransactionId(), CreatedAtMS: nowMS,
|
||||
})
|
||||
if err != nil {
|
||||
if financewithdrawal.IsApplicationOutcomeUnknown(err) {
|
||||
// 申请是否落库未知时保留冻结;相同 command_id 重试会命中原回执并收敛,不能冒险释放真实余额。
|
||||
slog.Error("point_diamond_withdrawal_application_outcome_unknown", "user_id", userID, "app_code", appcode.FromContext(request.Context()), "command_id", commandID, "error", err)
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
rollbackPointWithdrawalFreezeForAsset(request, pointWallet, pointDiamondWalletAssetType, commandID, freezeResp.GetTransactionId(), userID, frozenGross, frozenFee, frozenNet, frozenPointsPerUSD, frozenFeeBPS)
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
if h.withdrawalNotifier != nil {
|
||||
// 通知不是资金事实;失败只告警,不能回滚已冻结余额或让用户二次提交。
|
||||
if err := h.withdrawalNotifier.NotifyApplicationCreated(request.Context(), application); err != nil {
|
||||
slog.Warn("point_diamond_withdrawal_notify_failed", "application_id", application.ID, "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"application_id": strconv.FormatInt(application.ID, 10), "status": application.Status,
|
||||
"operations_status": application.OperationsStatus, "asset_type": pointDiamondWalletAssetType,
|
||||
"gross_usd_minor": strconv.FormatInt(freezeResp.GetGrossUsdMinor(), 10),
|
||||
"fee_usd_minor": strconv.FormatInt(freezeResp.GetFeeUsdMinor(), 10), "net_usd_minor": strconv.FormatInt(freezeResp.GetNetUsdMinor(), 10),
|
||||
"point_amount": strconv.FormatInt(frozenGross, 10), "fee_point_amount": strconv.FormatInt(frozenFee, 10),
|
||||
"net_point_amount": strconv.FormatInt(frozenNet, 10), "point_diamonds_per_usd": strconv.FormatInt(frozenPointsPerUSD, 10),
|
||||
"withdraw_fee_bps": strconv.FormatInt(int64(frozenFeeBPS), 10), "policy_instance_code": policyInstance,
|
||||
"policy_id": strconv.FormatUint(freezeResp.GetPolicyId(), 10), "policy_version": strconv.FormatUint(freezeResp.GetPolicyVersion(), 10),
|
||||
"coins_per_usd": strconv.FormatInt(freezeResp.GetCoinsPerUsd(), 10),
|
||||
"point_diamond_balance_after": strconv.FormatInt(freezeResp.GetBalance().GetAvailableAmount(), 10),
|
||||
"balance_after": pointDiamondBalanceFromProto(freezeResp.GetBalance()),
|
||||
"withdraw_method": application.WithdrawMethod, "withdraw_address": application.WithdrawAddress,
|
||||
"freeze_transaction_id": freezeResp.GetTransactionId(), "created_at_ms": strconv.FormatInt(application.CreatedAtMS, 10),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) resolvePointDiamondWalletContext(writer http.ResponseWriter, request *http.Request) (pointWithdrawalGatewayClient, *userv1.User, *walletv1.GetPointWithdrawalConfigResponse, bool) {
|
||||
pointWallet, profile, ok := h.resolvePointDiamondWalletProfile(writer, request)
|
||||
if !ok {
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
config, err := pointWallet.GetPointWithdrawalConfig(request.Context(), &walletv1.GetPointWithdrawalConfigRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
|
||||
RegionId: profile.GetRegionId(), NowMs: time.Now().UTC().UnixMilli(), AssetType: pointDiamondWalletAssetType,
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
return pointWallet, profile, config, true
|
||||
}
|
||||
|
||||
func pointDiamondActionAvailabilityFromProto(value *walletv1.PointWithdrawalActionAvailability) pointDiamondActionAvailabilityData {
|
||||
if value == nil {
|
||||
return pointDiamondActionAvailabilityData{}
|
||||
}
|
||||
return pointDiamondActionAvailabilityData{
|
||||
Allowed: value.GetAllowed(), LimitPeriod: value.GetLimitPeriod(),
|
||||
LimitCount: strconv.FormatInt(value.GetLimitCount(), 10), UsedCount: strconv.FormatInt(value.GetUsedCount(), 10),
|
||||
RemainingCount: strconv.FormatInt(value.GetRemainingCount(), 10), AllowedDays: value.GetAllowedDays(), BlockReason: value.GetBlockReason(),
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePointDiamondWalletProfile 只解析动作所需的实时用户区域。当前政策、最低额和费率都由
|
||||
// wallet 事务内决定;若先查当前政策,后台改政策后会在到达幂等账本前错误拦住成功命令重试。
|
||||
func (h *Handler) resolvePointDiamondWalletProfile(writer http.ResponseWriter, request *http.Request) (pointWithdrawalGatewayClient, *userv1.User, bool) {
|
||||
pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
|
||||
if !ok || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return nil, nil, false
|
||||
}
|
||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return nil, nil, false
|
||||
}
|
||||
profile := profileResp.GetUser()
|
||||
if profile == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "user not found")
|
||||
return nil, nil, false
|
||||
}
|
||||
return pointWallet, profile, true
|
||||
}
|
||||
|
||||
func pointDiamondBalanceFromProto(balance *walletv1.AssetBalance) pointDiamondBalanceData {
|
||||
if balance == nil {
|
||||
return pointDiamondBalanceData{AssetType: pointDiamondWalletAssetType, AvailablePoints: "0", AvailableAmount: "0", FrozenAmount: "0", Version: "0"}
|
||||
}
|
||||
available := strconv.FormatInt(balance.GetAvailableAmount(), 10)
|
||||
return pointDiamondBalanceData{
|
||||
AssetType: balance.GetAssetType(), AvailablePoints: available, AvailableAmount: available,
|
||||
FrozenAmount: strconv.FormatInt(balance.GetFrozenAmount(), 10), Version: strconv.FormatInt(balance.GetVersion(), 10),
|
||||
}
|
||||
}
|
||||
|
||||
func pointDiamondPolicyFromProto(config *walletv1.GetPointWithdrawalConfigResponse) pointDiamondPolicyData {
|
||||
if config == nil {
|
||||
return pointDiamondPolicyData{}
|
||||
}
|
||||
return pointDiamondPolicyData{
|
||||
PointDiamondsPerUSD: strconv.FormatInt(config.GetPointsPerUsd(), 10), CoinsPerUSD: strconv.FormatInt(config.GetCoinsPerUsd(), 10),
|
||||
AgencyPointShareBPS: strconv.FormatInt(int64(config.GetAgencyPointShareBps()), 10),
|
||||
MinimumWithdrawUSDMinor: strconv.FormatInt(config.GetMinimumWithdrawUsdMinor(), 10),
|
||||
WithdrawFeeBPS: strconv.FormatInt(int64(config.GetFeeBps()), 10),
|
||||
PolicyID: strconv.FormatUint(config.GetPolicyId(), 10), PolicyVersion: strconv.FormatUint(config.GetPolicyVersion(), 10),
|
||||
PolicyInstanceCode: config.GetPolicyInstanceCode(),
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -183,10 +184,6 @@ func (b pointWalletWithdrawBody) usdtTRC20Address() string {
|
||||
}
|
||||
|
||||
func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
if h.walletClient == nil || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
@ -218,7 +215,7 @@ func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *ht
|
||||
return
|
||||
}
|
||||
if !config.GetFound() {
|
||||
// 没有已发布政策就关闭资金入口;不能回退到编译期常量造成 Admin 看似配置但账务仍走旧值。
|
||||
// 没有已发布 wallet 政策就关闭资金入口;不能回退到编译期常量。
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "point wallet policy is not configured")
|
||||
return
|
||||
}
|
||||
@ -261,10 +258,6 @@ func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *ht
|
||||
// getPointWalletExchangeConfig 只返回 Flutter 预览兑换所需的最小运行合同;
|
||||
// region 从当前登录用户资料解析,客户端不能通过自报地区选择其他政策。
|
||||
func (h *Handler) getPointWalletExchangeConfig(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
|
||||
if !ok || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
@ -292,7 +285,7 @@ func (h *Handler) getPointWalletExchangeConfig(writer http.ResponseWriter, reque
|
||||
}
|
||||
if !config.GetFound() || config.GetPointsPerUsd() <= 0 || config.GetCoinsPerUsd() <= 0 {
|
||||
// 没有完整已发布政策时不向 App 暴露伪造默认比例,兑换写入口也会在 wallet-service 拒绝。
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "point exchange policy is not configured")
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "point wallet policy is not configured")
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, pointWalletExchangeConfigData{
|
||||
@ -304,10 +297,6 @@ func (h *Handler) getPointWalletExchangeConfig(writer http.ResponseWriter, reque
|
||||
}
|
||||
|
||||
func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
if h.walletClient == nil || h.userProfileClient == nil || h.withdrawalWriter == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
@ -351,7 +340,12 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
feePoints := grossPoints * int64(config.GetFeeBps()) / 10000
|
||||
feePoints, ok := calculatePointFee(grossPoints, config.GetFeeBps())
|
||||
if !ok {
|
||||
// 政策参数来自 wallet owner;非法 bps 属于上游配置损坏,不能让 int64 溢出后继续冻结错误金额。
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
netPoints := grossPoints - feePoints
|
||||
address, ok := h.resolveSalaryWalletWithdrawAddress(writer, request, userID, body.usdtTRC20Address())
|
||||
if !ok {
|
||||
@ -385,6 +379,11 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
frozenNetPoints := freezeResp.GetNetPointAmount()
|
||||
frozenPointsPerUSD := freezeResp.GetPointsPerUsd()
|
||||
frozenFeeBPS := freezeResp.GetFeeBps()
|
||||
frozenPolicyInstanceCode := strings.TrimSpace(freezeResp.GetPolicyInstanceCode())
|
||||
if frozenPolicyInstanceCode == "" {
|
||||
// 兼容字段上线前已经冻结的幂等回执;新冻结必须始终由 wallet 返回实际政策实例编码。
|
||||
frozenPolicyInstanceCode = config.GetPolicyInstanceCode()
|
||||
}
|
||||
application, err := h.withdrawalWriter.CreateApplication(request.Context(), financewithdrawal.CreateApplicationCommand{
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserID: userID,
|
||||
@ -395,7 +394,7 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
PointNetAmount: frozenNetPoints,
|
||||
PointsPerUSD: frozenPointsPerUSD,
|
||||
PointFeeBPS: frozenFeeBPS,
|
||||
PointPolicyInstance: config.GetPolicyInstanceCode(),
|
||||
PointPolicyInstance: frozenPolicyInstanceCode,
|
||||
WithdrawMethod: financewithdrawal.MethodUSDTTRC20,
|
||||
WithdrawAddress: address,
|
||||
FreezeCommandID: freezeCommandID,
|
||||
@ -410,7 +409,7 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, userID, frozenGrossPoints, frozenFeePoints, frozenNetPoints, frozenPointsPerUSD, frozenFeeBPS)
|
||||
rollbackPointWithdrawalFreeze(request, pointWallet, freezeCommandID, freezeResp.GetTransactionId(), userID, frozenGrossPoints, frozenFeePoints, frozenNetPoints, frozenPointsPerUSD, frozenFeeBPS)
|
||||
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
@ -430,7 +429,7 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
"net_point_amount": frozenNetPoints,
|
||||
"points_per_usd": frozenPointsPerUSD,
|
||||
"fee_bps": frozenFeeBPS,
|
||||
"policy_instance_code": config.GetPolicyInstanceCode(),
|
||||
"policy_instance_code": frozenPolicyInstanceCode,
|
||||
"gross_usd": formatPointUSD(frozenGrossPoints, frozenPointsPerUSD),
|
||||
"fee_usd": formatPointUSD(frozenFeePoints, frozenPointsPerUSD),
|
||||
"net_usd": formatPointUSD(frozenNetPoints, frozenPointsPerUSD),
|
||||
@ -442,16 +441,6 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http.
|
||||
})
|
||||
}
|
||||
|
||||
func isPointWalletRequest(request *http.Request) bool {
|
||||
// 新钱包首先服务 Huwaa 与 Fami;Lalu 旧 gonghui 页面和既有工资钱包路由不做行为变更。
|
||||
switch strings.ToLower(strings.TrimSpace(appcode.FromContext(request.Context()))) {
|
||||
case "huwaa", "fami":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pointBalanceFromProto(balance *walletv1.AssetBalance, pointsPerUSD int64) pointWalletBalanceData {
|
||||
if balance == nil {
|
||||
return pointWalletBalanceData{AssetType: pointWalletAssetType, DisplayUSD: "0.00"}
|
||||
@ -469,8 +458,28 @@ func formatPointUSD(points int64, pointsPerUSD int64) string {
|
||||
if points <= 0 || pointsPerUSD <= 0 {
|
||||
return "0.00"
|
||||
}
|
||||
usdMinor := points * 100 / pointsPerUSD
|
||||
return formatUSDMinor(usdMinor)
|
||||
// POINT 余额允许达到 int64 上限;先用任意精度整数完成 *100 和整除,避免展示层溢出成负数。
|
||||
usdMinor := new(big.Int).Mul(big.NewInt(points), big.NewInt(100))
|
||||
usdMinor.Quo(usdMinor, big.NewInt(pointsPerUSD))
|
||||
dollars, cents := new(big.Int), new(big.Int)
|
||||
dollars.QuoRem(usdMinor, big.NewInt(100), cents)
|
||||
return dollars.String() + "." + leftPadTwoDigits(cents.Int64())
|
||||
}
|
||||
|
||||
func calculatePointFee(points int64, feeBPS int32) (int64, bool) {
|
||||
if points <= 0 || feeBPS < 0 || feeBPS > 10_000 {
|
||||
return 0, false
|
||||
}
|
||||
// 把 points 拆成商和余数后再乘 bps;两项都不会超过原 points,且结果与 floor(points*bps/10000) 完全一致。
|
||||
fee := points/10_000*int64(feeBPS) + points%10_000*int64(feeBPS)/10_000
|
||||
return fee, true
|
||||
}
|
||||
|
||||
func leftPadTwoDigits(value int64) string {
|
||||
if value < 10 {
|
||||
return "0" + strconv.FormatInt(value, 10)
|
||||
}
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func pointWithdrawalReference(freezeCommandID string) string {
|
||||
@ -480,23 +489,28 @@ func pointWithdrawalReference(freezeCommandID string) string {
|
||||
return "point-withdrawal:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWithdrawalGatewayClient, freezeCommandID string, userID int64, grossPoints int64, feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
|
||||
func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWithdrawalGatewayClient, freezeCommandID string, freezeTransactionID string, userID int64, grossPoints int64, feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
|
||||
rollbackPointWithdrawalFreezeForAsset(request, walletClient, pointWalletAssetType, freezeCommandID, freezeTransactionID, userID, grossPoints, feePoints, netPoints, pointsPerUSD, feeBPS)
|
||||
}
|
||||
|
||||
func rollbackPointWithdrawalFreezeForAsset(request *http.Request, walletClient pointWithdrawalGatewayClient, assetType string, freezeCommandID string, freezeTransactionID string, userID int64, grossPoints int64, feePoints int64, netPoints int64, pointsPerUSD int64, feeBPS int32) {
|
||||
withdrawalRef := pointWithdrawalReference(freezeCommandID)
|
||||
// 回滚事务也必须跨 HTTP 重试保持同一个 command_id;直接由固定长度的 reservation 引用派生,
|
||||
// 不会因为原始 command_id 接近 128 字节而退化为不稳定的 request_id。
|
||||
rollbackCommandID := withdrawalRef + ":rollback"
|
||||
rollbackRequest := &walletv1.ReleasePointWithdrawalRequest{
|
||||
CommandId: rollbackCommandID,
|
||||
UserId: userID,
|
||||
AssetType: pointWalletAssetType,
|
||||
GrossPointAmount: grossPoints,
|
||||
FeePointAmount: feePoints,
|
||||
NetPointAmount: netPoints,
|
||||
PointsPerUsd: pointsPerUSD,
|
||||
FeeBps: feeBPS,
|
||||
Reason: "point withdrawal application create failed",
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
WithdrawalRef: withdrawalRef,
|
||||
CommandId: rollbackCommandID,
|
||||
UserId: userID,
|
||||
AssetType: assetType,
|
||||
GrossPointAmount: grossPoints,
|
||||
FeePointAmount: feePoints,
|
||||
NetPointAmount: netPoints,
|
||||
PointsPerUsd: pointsPerUSD,
|
||||
FeeBps: feeBPS,
|
||||
Reason: "point withdrawal application create failed",
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
WithdrawalRef: withdrawalRef,
|
||||
FreezeTransactionId: strings.TrimSpace(freezeTransactionID),
|
||||
}
|
||||
baseCtx := appcode.WithContext(context.Background(), appcode.FromContext(request.Context()))
|
||||
var rollbackErr error
|
||||
@ -517,10 +531,6 @@ func rollbackPointWithdrawalFreeze(request *http.Request, walletClient pointWith
|
||||
|
||||
// transferPointWalletToCoinSeller 只接收目标和 POINT 金额;wallet 事务会重新读取 active 白名单与比例。
|
||||
func (h *Handler) transferPointWalletToCoinSeller(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
|
||||
if !ok || h.userProfileClient == nil || h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
@ -556,7 +566,7 @@ func (h *Handler) transferPointWalletToCoinSeller(writer http.ResponseWriter, re
|
||||
return
|
||||
}
|
||||
if !runtimeConfig.GetFound() || pointAmount < runtimeConfig.GetMinimumPoints() {
|
||||
// 币商和 USDT 两种提现共用已发布政策中的最低门槛,客户端不能通过切换页签绕过。
|
||||
// 币商和 USDT 两种提现共用已发布 wallet 政策中的最低门槛,客户端不能通过切换页签绕过。
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "point withdrawal amount is below minimum")
|
||||
return
|
||||
}
|
||||
@ -593,10 +603,6 @@ func (h *Handler) transferPointWalletToCoinSeller(writer http.ResponseWriter, re
|
||||
|
||||
// exchangePointWalletToCoins 只接受幂等键和 POINT 数量;比例、COIN 数量及余额都由 wallet-service 原子返回。
|
||||
func (h *Handler) exchangePointWalletToCoins(writer http.ResponseWriter, request *http.Request) {
|
||||
if !isPointWalletRequest(request) {
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied")
|
||||
return
|
||||
}
|
||||
pointWallet, ok := h.walletClient.(pointWithdrawalGatewayClient)
|
||||
if !ok || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@ -18,24 +19,213 @@ import (
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
func TestWithdrawPointWalletRequiresHuwaaAndClientCommandID(t *testing.T) {
|
||||
handler := New(Config{
|
||||
WalletClient: &fakePointWalletClient{},
|
||||
UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF"},
|
||||
WithdrawalWriter: &fakePointWithdrawalWriter{},
|
||||
WithdrawalNotifier: nil,
|
||||
})
|
||||
|
||||
nonHuwaaRecorder := httptest.NewRecorder()
|
||||
nonHuwaaRequest := pointWalletRequest(`{"command_id":"cmd-point","gross_point_amount":1000000}`, "lalu")
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.withdrawPointWallet)).ServeHTTP(nonHuwaaRecorder, nonHuwaaRequest)
|
||||
if nonHuwaaRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-Huwaa POINT withdrawal status mismatch: got %d body=%s", nonHuwaaRecorder.Code, nonHuwaaRecorder.Body.String())
|
||||
func TestCalculatePointFeeUsesOverflowSafeFixedPoint(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
points int64
|
||||
feeBPS int32
|
||||
want int64
|
||||
}{
|
||||
{name: "normal", points: 1_000_000, feeBPS: 500, want: 50_000},
|
||||
{name: "maximum-half-tenth", points: math.MaxInt64, feeBPS: 500, want: math.MaxInt64 / 20},
|
||||
{name: "maximum-full-fee", points: math.MaxInt64, feeBPS: 10_000, want: math.MaxInt64},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, ok := calculatePointFee(test.points, test.feeBPS)
|
||||
if !ok || got != test.want {
|
||||
t.Fatalf("calculatePointFee(%d, %d) = (%d, %t), want (%d, true)", test.points, test.feeBPS, got, ok, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, invalidBPS := range []int32{-1, 10_001} {
|
||||
if _, ok := calculatePointFee(math.MaxInt64, invalidBPS); ok {
|
||||
t.Fatalf("invalid fee bps %d must be rejected", invalidBPS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPointUSDUsesOverflowSafeFixedPoint(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
points int64
|
||||
pointsPerUSD int64
|
||||
want string
|
||||
}{
|
||||
{name: "normal", points: 950_000, pointsPerUSD: 100_000, want: "9.50"},
|
||||
{name: "fraction-floor", points: 1, pointsPerUSD: 3, want: "0.33"},
|
||||
{name: "maximum-unit-ratio", points: math.MaxInt64, pointsPerUSD: 1, want: "9223372036854775807.00"},
|
||||
{name: "maximum-production-scale", points: math.MaxInt64, pointsPerUSD: 100_000, want: "92233720368547.75"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := formatPointUSD(test.points, test.pointsPerUSD); got != test.want {
|
||||
t.Fatalf("formatPointUSD(%d, %d) = %q, want %q", test.points, test.pointsPerUSD, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointDiamondWalletHistoryExposesMergedHostAgencyComponentsAsStrings(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{transactions: []*walletv1.WalletTransaction{{
|
||||
EntryId: 91, TransactionId: "gift-tx", BizType: "gift_debit", AssetType: "POINT_DIAMOND",
|
||||
AvailableDelta: 120, AvailableAfter: 120, EntryRole: "host_gift_income_and_agency_share",
|
||||
HostBaseAmount: 100, AgencyAmount: 20, CreatedAtMs: 1_784_635_000_000,
|
||||
}}}
|
||||
handler := New(Config{WalletClient: wallet})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/point-diamond-wallet/history?page=1&page_size=20", nil)
|
||||
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.getPointDiamondWalletHistory)).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("POINT_DIAMOND history status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
EntryRole string `json:"entry_role"`
|
||||
AvailableDelta string `json:"available_delta"`
|
||||
HostBaseAmount string `json:"host_base_amount"`
|
||||
AgencyAmount string `json:"agency_amount"`
|
||||
Components []map[string]string `json:"components"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode POINT_DIAMOND history: %v", err)
|
||||
}
|
||||
if response.Code != httpkit.CodeOK || len(response.Data.Items) != 1 {
|
||||
t.Fatalf("POINT_DIAMOND history response mismatch: %+v", response)
|
||||
}
|
||||
item := response.Data.Items[0]
|
||||
if item.EntryRole != "host_gift_income_and_agency_share" || item.AvailableDelta != "120" || item.HostBaseAmount != "100" || item.AgencyAmount != "20" || len(item.Components) != 2 || item.Components[0]["amount"] != "100" || item.Components[1]["amount"] != "20" {
|
||||
t.Fatalf("merged Host/Agency history components mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointDiamondWalletOverviewReturnsEvaluatedActionLimits(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{
|
||||
balances: []*walletv1.AssetBalance{{AssetType: "POINT_DIAMOND", AvailableAmount: 3_000_000}},
|
||||
pointConfigs: []*walletv1.GetPointWithdrawalConfigResponse{{
|
||||
Found: true, PolicyType: "POINT_DIAMOND", AvailabilityEvaluated: true,
|
||||
CoinSellerAvailability: &walletv1.PointWithdrawalActionAvailability{Allowed: false, LimitPeriod: "month", LimitCount: 2, UsedCount: 2, RemainingCount: 0, BlockReason: "limit_reached"},
|
||||
PlatformAvailability: &walletv1.PointWithdrawalActionAvailability{Allowed: true, LimitPeriod: "month", LimitCount: 0, RemainingCount: -1, AllowedDays: ""},
|
||||
}},
|
||||
}
|
||||
handler := New(Config{WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{regionID: 25}})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/point-diamond-wallet/overview", nil)
|
||||
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.getPointDiamondWalletOverview)).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("overview status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastPointConfig == nil || wallet.lastPointConfig.GetUserId() != 42001 || wallet.lastPointConfig.GetAssetType() != "POINT_DIAMOND" {
|
||||
t.Fatalf("overview must request user-scoped availability: %+v", wallet.lastPointConfig)
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
Actions struct {
|
||||
Exchange bool `json:"exchange_to_coins"`
|
||||
Withdraw bool `json:"withdraw"`
|
||||
Seller bool `json:"transfer_to_coin_seller"`
|
||||
} `json:"actions"`
|
||||
Channels []string `json:"channels"`
|
||||
ActionLimits map[string]struct {
|
||||
Remaining string `json:"remaining_count"`
|
||||
Reason string `json:"block_reason"`
|
||||
} `json:"action_limits"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode overview: %v", err)
|
||||
}
|
||||
if !response.Data.Actions.Exchange || !response.Data.Actions.Withdraw || response.Data.Actions.Seller || len(response.Data.Channels) != 2 || response.Data.ActionLimits["transfer_to_coin_seller"].Remaining != "0" || response.Data.ActionLimits["transfer_to_coin_seller"].Reason != "limit_reached" {
|
||||
t.Fatalf("overview capability mismatch: %+v", response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointDiamondWalletOverviewUsesWalletCoinSellerWhitelist(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{
|
||||
balances: []*walletv1.AssetBalance{{AssetType: "POINT_DIAMOND", AvailableAmount: 3_000_000}},
|
||||
pointSellers: []*walletv1.PointWithdrawalCoinSellerConfig{{
|
||||
SellerUserId: 73001, SortOrder: 2, ServiceCountryCodes: []string{"SA"}, Status: "active",
|
||||
}},
|
||||
pointConfigs: []*walletv1.GetPointWithdrawalConfigResponse{{
|
||||
Found: true, PolicyType: "POINT_DIAMOND", AvailabilityEvaluated: true,
|
||||
CoinSellerAvailability: &walletv1.PointWithdrawalActionAvailability{Allowed: true, RemainingCount: -1},
|
||||
PlatformAvailability: &walletv1.PointWithdrawalActionAvailability{Allowed: true, RemainingCount: -1},
|
||||
}},
|
||||
}
|
||||
profiles := &fakePointUserProfileClient{
|
||||
country: "sa", regionID: 25,
|
||||
users: map[int64]*userv1.User{
|
||||
73001: {UserId: 73001, DisplayUserId: "seller-73001", Username: "Trusted Seller", Status: userv1.UserStatus_USER_STATUS_ACTIVE},
|
||||
},
|
||||
}
|
||||
handler := New(Config{WalletClient: wallet, UserProfileClient: profiles})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/point-diamond-wallet/overview", nil)
|
||||
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.getPointDiamondWalletOverview)).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("overview status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastSellerList == nil || wallet.lastSellerList.GetCountryCode() != "SA" || wallet.lastSellerList.GetAppCode() != "fami" {
|
||||
t.Fatalf("wallet seller whitelist request mismatch: %+v", wallet.lastSellerList)
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
Actions struct {
|
||||
Seller bool `json:"transfer_to_coin_seller"`
|
||||
} `json:"actions"`
|
||||
Sellers []struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
} `json:"coin_sellers"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode overview: %v", err)
|
||||
}
|
||||
if !response.Data.Actions.Seller || len(response.Data.Sellers) != 1 || response.Data.Sellers[0].UserID != "73001" || response.Data.Sellers[0].DisplayUserID != "seller-73001" {
|
||||
t.Fatalf("POINT_DIAMOND seller whitelist mismatch: %+v", response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointDiamondWalletOverviewDisablesSellerActionWithoutEligibleSeller(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{
|
||||
pointConfigs: []*walletv1.GetPointWithdrawalConfigResponse{{
|
||||
Found: true, PolicyType: "POINT_DIAMOND", AvailabilityEvaluated: true,
|
||||
CoinSellerAvailability: &walletv1.PointWithdrawalActionAvailability{Allowed: true, RemainingCount: -1},
|
||||
}},
|
||||
}
|
||||
handler := New(Config{WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{country: "SA", regionID: 25}})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/point-diamond-wallet/overview", nil)
|
||||
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "fami"), 42001))
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.getPointDiamondWalletOverview)).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("overview status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
Actions struct {
|
||||
Seller bool `json:"transfer_to_coin_seller"`
|
||||
} `json:"actions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode overview: %v", err)
|
||||
}
|
||||
if response.Data.Actions.Seller {
|
||||
t.Fatalf("seller action must be disabled when the wallet whitelist has no eligible seller: %+v", response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawPointWalletRequiresClientCommandID(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{}
|
||||
writer := &fakePointWithdrawalWriter{}
|
||||
handler = New(Config{
|
||||
handler := New(Config{
|
||||
WalletClient: wallet,
|
||||
UserProfileClient: &fakePointUserProfileClient{address: "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", regionID: 25},
|
||||
WithdrawalWriter: writer,
|
||||
@ -117,6 +307,7 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t
|
||||
response.Data["operations_status"] != financewithdrawal.OperationsStatusPending ||
|
||||
response.Data["gross_point_amount"] != float64(1_000_000) ||
|
||||
response.Data["fee_point_amount"] != float64(50_000) ||
|
||||
response.Data["policy_instance_code"] != "test-policy" ||
|
||||
response.Data["net_usd"] != "9.50" {
|
||||
t.Fatalf("POINT withdrawal response mismatch: %+v", response)
|
||||
}
|
||||
@ -148,6 +339,7 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t
|
||||
writer.last.PointNetAmount != 950_000 ||
|
||||
writer.last.PointsPerUSD != 100_000 ||
|
||||
writer.last.PointFeeBPS != 500 ||
|
||||
writer.last.PointPolicyInstance != "test-policy" ||
|
||||
writer.last.WithdrawAmount != "9.50" {
|
||||
t.Fatalf("same-command retry must create application from first wallet receipt, got %+v", writer.last)
|
||||
}
|
||||
@ -158,7 +350,7 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t
|
||||
if err := json.NewDecoder(retryRecorder.Body).Decode(&retryResponse); err != nil {
|
||||
t.Fatalf("decode retry response failed: %v", err)
|
||||
}
|
||||
if retryResponse.Code != httpkit.CodeOK || retryResponse.Data["fee_point_amount"] != float64(50_000) || retryResponse.Data["points_per_usd"] != float64(100_000) || retryResponse.Data["net_usd"] != "9.50" {
|
||||
if retryResponse.Code != httpkit.CodeOK || retryResponse.Data["fee_point_amount"] != float64(50_000) || retryResponse.Data["points_per_usd"] != float64(100_000) || retryResponse.Data["policy_instance_code"] != "test-policy" || retryResponse.Data["net_usd"] != "9.50" {
|
||||
t.Fatalf("retry response must expose first wallet receipt: %+v", retryResponse)
|
||||
}
|
||||
}
|
||||
@ -256,6 +448,36 @@ func TestExchangePointWalletToCoinsUsesAuthenticatedUserAndClientCommandID(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferPointDiamondToCoinSellerLetsWalletOwnReplayValidation(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{}
|
||||
profile := &fakePointUserProfileClient{country: "SA", regionID: 26}
|
||||
handler := New(Config{
|
||||
WalletClient: wallet,
|
||||
UserProfileClient: profile,
|
||||
UserIdentityClient: &fakePointUserIdentityClient{userID: 9001},
|
||||
})
|
||||
requestBody := `{"command_id":"pd-seller-1","target_display_user_id":"700001","gross_usd_minor":10000}`
|
||||
recorder := httptest.NewRecorder()
|
||||
request := pointWalletRequest(requestBody, "fami")
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.transferPointDiamondWalletToCoinSeller)).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("POINT_DIAMOND seller transfer status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if wallet.lastPointTransfer == nil || wallet.lastPointTransfer.GetSellerUserId() != 9001 || wallet.lastPointTransfer.GetSourceCountryCode() != "SA" || wallet.lastPointTransfer.GetRegionId() != 26 || wallet.lastPointTransfer.GetSourceAssetType() != "POINT_DIAMOND" || wallet.lastPointTransfer.GetGrossUsdMinor() != 10000 {
|
||||
t.Fatalf("POINT_DIAMOND seller transfer request mismatch: %+v", wallet.lastPointTransfer)
|
||||
}
|
||||
|
||||
// Gateway 不再用当前区域的 active 币商列表拦截重放;变化后的派生资料仍送到 wallet,
|
||||
// wallet 会在事务开头按稳定 command_id 返回首次回执。
|
||||
profile.country, profile.regionID = "AE", 99
|
||||
retryRecorder := httptest.NewRecorder()
|
||||
retryRequest := pointWalletRequest(requestBody, "fami")
|
||||
httpkit.WithRequestID(http.HandlerFunc(handler.transferPointDiamondWalletToCoinSeller)).ServeHTTP(retryRecorder, retryRequest)
|
||||
if retryRecorder.Code != http.StatusOK || wallet.lastPointTransfer.GetSourceCountryCode() != "AE" || wallet.lastPointTransfer.GetRegionId() != 99 {
|
||||
t.Fatalf("profile drift must still reach wallet replay: status=%d request=%+v body=%s", retryRecorder.Code, wallet.lastPointTransfer, retryRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPointWalletExchangeConfigUsesTrustedRegionAndPublishedPolicy(t *testing.T) {
|
||||
wallet := &fakePointWalletClient{}
|
||||
handler := New(Config{WalletClient: wallet, UserProfileClient: &fakePointUserProfileClient{regionID: 24}})
|
||||
@ -294,19 +516,23 @@ func TestGetPointWalletExchangeConfigUsesTrustedRegionAndPublishedPolicy(t *test
|
||||
|
||||
type fakePointWalletClient struct {
|
||||
client.WalletClient
|
||||
balances []*walletv1.AssetBalance
|
||||
lastBalance *walletv1.GetBalancesRequest
|
||||
lastFreeze *walletv1.FreezePointWithdrawalRequest
|
||||
lastRelease *walletv1.ReleasePointWithdrawalRequest
|
||||
releaseRequests []*walletv1.ReleasePointWithdrawalRequest
|
||||
lastPointTransfer *walletv1.TransferPointToCoinSellerRequest
|
||||
lastPointExchange *walletv1.ExchangePointToCoinRequest
|
||||
lastPointConfig *walletv1.GetPointWithdrawalConfigRequest
|
||||
freezeErr error
|
||||
releaseFailures int
|
||||
freezeReceipts map[string]*walletv1.FreezePointWithdrawalResponse
|
||||
pointConfigs []*walletv1.GetPointWithdrawalConfigResponse
|
||||
pointConfigCalls int
|
||||
balances []*walletv1.AssetBalance
|
||||
lastBalance *walletv1.GetBalancesRequest
|
||||
lastFreeze *walletv1.FreezePointWithdrawalRequest
|
||||
lastRelease *walletv1.ReleasePointWithdrawalRequest
|
||||
releaseRequests []*walletv1.ReleasePointWithdrawalRequest
|
||||
lastPointTransfer *walletv1.TransferPointToCoinSellerRequest
|
||||
lastPointExchange *walletv1.ExchangePointToCoinRequest
|
||||
lastPointConfig *walletv1.GetPointWithdrawalConfigRequest
|
||||
freezeErr error
|
||||
releaseFailures int
|
||||
freezeReceipts map[string]*walletv1.FreezePointWithdrawalResponse
|
||||
pointConfigs []*walletv1.GetPointWithdrawalConfigResponse
|
||||
pointConfigCalls int
|
||||
lastPointConfigCode string
|
||||
transactions []*walletv1.WalletTransaction
|
||||
pointSellers []*walletv1.PointWithdrawalCoinSellerConfig
|
||||
lastSellerList *walletv1.ListPointWithdrawalCoinSellersRequest
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||
@ -318,6 +544,10 @@ func (f *fakePointWalletClient) GetBalances(_ context.Context, req *walletv1.Get
|
||||
return &walletv1.GetBalancesResponse{Balances: balances}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) ListWalletTransactions(_ context.Context, _ *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) {
|
||||
return &walletv1.ListWalletTransactionsResponse{Transactions: f.transactions, Total: int64(len(f.transactions))}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) FreezePointWithdrawal(_ context.Context, req *walletv1.FreezePointWithdrawalRequest) (*walletv1.FreezePointWithdrawalResponse, error) {
|
||||
f.lastFreeze = req
|
||||
if f.freezeErr != nil {
|
||||
@ -327,13 +557,14 @@ func (f *fakePointWalletClient) FreezePointWithdrawal(_ context.Context, req *wa
|
||||
return existing, nil
|
||||
}
|
||||
receipt := &walletv1.FreezePointWithdrawalResponse{
|
||||
TransactionId: "point-freeze-tx",
|
||||
GrossPointAmount: req.GetGrossPointAmount(),
|
||||
FeePointAmount: req.GetFeePointAmount(),
|
||||
NetPointAmount: req.GetNetPointAmount(),
|
||||
PointsPerUsd: req.GetPointsPerUsd(),
|
||||
FeeBps: req.GetFeeBps(),
|
||||
Balance: &walletv1.AssetBalance{AssetType: "POINT", AvailableAmount: 1_000_000, FrozenAmount: req.GetGrossPointAmount(), Version: 4},
|
||||
TransactionId: "point-freeze-tx",
|
||||
GrossPointAmount: req.GetGrossPointAmount(),
|
||||
FeePointAmount: req.GetFeePointAmount(),
|
||||
NetPointAmount: req.GetNetPointAmount(),
|
||||
PointsPerUsd: req.GetPointsPerUsd(),
|
||||
FeeBps: req.GetFeeBps(),
|
||||
PolicyInstanceCode: f.lastPointConfigCode,
|
||||
Balance: &walletv1.AssetBalance{AssetType: "POINT", AvailableAmount: 1_000_000, FrozenAmount: req.GetGrossPointAmount(), Version: 4},
|
||||
}
|
||||
if f.freezeReceipts == nil {
|
||||
f.freezeReceipts = make(map[string]*walletv1.FreezePointWithdrawalResponse)
|
||||
@ -359,13 +590,18 @@ func (f *fakePointWalletClient) GetPointWithdrawalConfig(_ context.Context, req
|
||||
index = len(f.pointConfigs) - 1
|
||||
}
|
||||
f.pointConfigCalls++
|
||||
return f.pointConfigs[index], nil
|
||||
config := f.pointConfigs[index]
|
||||
f.lastPointConfigCode = config.GetPolicyInstanceCode()
|
||||
return config, nil
|
||||
}
|
||||
return &walletv1.GetPointWithdrawalConfigResponse{Found: true, PointsPerUsd: 100_000, CoinsPerUsd: 80_000, FeeBps: 500, MinimumPoints: 1_000_000, PolicyInstanceCode: "test-policy"}, nil
|
||||
config := &walletv1.GetPointWithdrawalConfigResponse{Found: true, PointsPerUsd: 100_000, CoinsPerUsd: 80_000, FeeBps: 500, MinimumPoints: 1_000_000, PolicyInstanceCode: "test-policy"}
|
||||
f.lastPointConfigCode = config.GetPolicyInstanceCode()
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) ListPointWithdrawalCoinSellers(_ context.Context, _ *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error) {
|
||||
return &walletv1.ListPointWithdrawalCoinSellersResponse{}, nil
|
||||
func (f *fakePointWalletClient) ListPointWithdrawalCoinSellers(_ context.Context, req *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error) {
|
||||
f.lastSellerList = req
|
||||
return &walletv1.ListPointWithdrawalCoinSellersResponse{Sellers: f.pointSellers}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointWalletClient) TransferPointToCoinSeller(_ context.Context, req *walletv1.TransferPointToCoinSellerRequest) (*walletv1.TransferPointToCoinSellerResponse, error) {
|
||||
@ -388,6 +624,7 @@ type fakePointUserProfileClient struct {
|
||||
country string
|
||||
regionID int64
|
||||
err error
|
||||
users map[int64]*userv1.User
|
||||
}
|
||||
|
||||
func (f *fakePointUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
|
||||
@ -397,6 +634,16 @@ func (f *fakePointUserProfileClient) GetUser(_ context.Context, req *userv1.GetU
|
||||
return &userv1.GetUserResponse{User: &userv1.User{UserId: req.GetUserId(), Country: f.country, RegionId: f.regionID, WithdrawUsdtTrc20Address: f.address}}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
|
||||
users := make(map[int64]*userv1.User, len(req.GetUserIds()))
|
||||
for _, userID := range req.GetUserIds() {
|
||||
if user := f.users[userID]; user != nil {
|
||||
users[userID] = user
|
||||
}
|
||||
}
|
||||
return &userv1.BatchGetUsersResponse{Users: users}, nil
|
||||
}
|
||||
|
||||
type fakePointUserHostClient struct {
|
||||
client.UserHostClient
|
||||
}
|
||||
@ -405,6 +652,15 @@ func (f *fakePointUserHostClient) GetCoinSellerProfile(_ context.Context, req *u
|
||||
return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: &userv1.CoinSellerProfile{UserId: req.GetUserId(), Status: "active", MerchantAssetType: "COIN_SELLER_COIN"}}, nil
|
||||
}
|
||||
|
||||
type fakePointUserIdentityClient struct {
|
||||
client.UserIdentityClient
|
||||
userID int64
|
||||
}
|
||||
|
||||
func (f *fakePointUserIdentityClient) ResolveDisplayUserID(_ context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error) {
|
||||
return &userv1.ResolveDisplayUserIDResponse{Identity: &userv1.UserIdentity{UserId: f.userID, DisplayUserId: req.GetDisplayUserId()}}, nil
|
||||
}
|
||||
|
||||
func (f *fakePointUserProfileClient) UpdateUserWithdrawAddress(_ context.Context, req *userv1.UpdateUserWithdrawAddressRequest) (*userv1.UpdateUserWithdrawAddressResponse, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
|
||||
@ -375,6 +375,7 @@ func (h *Handler) withdrawSalaryWallet(writer http.ResponseWriter, request *http
|
||||
Reason: "salary withdrawal submitted",
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
WithdrawalRef: httpkit.RequestIDFromContext(request.Context()),
|
||||
RegionId: identity.RegionID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
|
||||
@ -294,7 +294,7 @@ func (h *Handler) triggerVIPOnlineNotice(writer http.ResponseWriter, request *ht
|
||||
"vip_program_type": effectiveVIP.GetProgramType(),
|
||||
"vip_level": effectiveVIP.GetLevel(),
|
||||
"vip_name": effectiveVIP.GetName(),
|
||||
"message": vipOnlineNoticeMessage(appCode, benefitResp.GetBenefit().GetMetadataJson()),
|
||||
"message": vipOnlineNoticeMessage(benefitResp.GetBenefit().GetMetadataJson()),
|
||||
"action": map[string]string{"type": "none"},
|
||||
})
|
||||
if err != nil {
|
||||
@ -348,18 +348,14 @@ func vipOnlineNoticeAvatarFrameFromResponse(resp *walletv1.BatchGetUserEquippedR
|
||||
return vipOnlineNoticeAvatarFrameData{}
|
||||
}
|
||||
|
||||
// vipOnlineNoticeMessage 从每级权益 metadata_json 读取 App 独立文案。
|
||||
// Fami 旧配置没有 metadata 时保留产品确认的默认文案;其他 App 使用中性兜底,避免把 Fami 名称串到 Lalu。
|
||||
func vipOnlineNoticeMessage(appCode string, metadataJSON string) string {
|
||||
// vipOnlineNoticeMessage 从每级权益 metadata_json 读取产品文案;缺失时使用中性兜底。
|
||||
func vipOnlineNoticeMessage(metadataJSON string) string {
|
||||
var metadata vipOnlineNoticeMetadata
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &metadata) == nil {
|
||||
if message := strings.TrimSpace(metadata.Message); message != "" && len(message) <= vipOnlineNoticeMessageMaxLen {
|
||||
return message
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(appCode), "fami") {
|
||||
return "欢迎进入Fami,祝你有美好的一天"
|
||||
}
|
||||
return "欢迎进入,祝你有美好的一天"
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,13 @@ import (
|
||||
)
|
||||
|
||||
func TestVIPOnlineNoticeMessageUsesConfiguredValueAndSafeFallbacks(t *testing.T) {
|
||||
if got := vipOnlineNoticeMessage("fami", "{"); got != "欢迎进入Fami,祝你有美好的一天" {
|
||||
t.Fatalf("Fami fallback mismatch: %q", got)
|
||||
if got := vipOnlineNoticeMessage("{"); got != "欢迎进入,祝你有美好的一天" {
|
||||
t.Fatalf("fallback mismatch: %q", got)
|
||||
}
|
||||
if got := vipOnlineNoticeMessage("lalu", `{"message":" Lalu VIP online "}`); got != "Lalu VIP online" {
|
||||
if got := vipOnlineNoticeMessage(`{"message":" Lalu VIP online "}`); got != "Lalu VIP online" {
|
||||
t.Fatalf("configured message mismatch: %q", got)
|
||||
}
|
||||
if got := vipOnlineNoticeMessage("lalu", `{"message":"`+strings.Repeat("x", 257)+`"}`); got != "欢迎进入,祝你有美好的一天" {
|
||||
if got := vipOnlineNoticeMessage(`{"message":"` + strings.Repeat("x", 257) + `"}`); got != "欢迎进入,祝你有美好的一天" {
|
||||
t.Fatalf("oversized message fallback mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user