From 070b4f179a6ccafb261f29322553546c0c921d32 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 22 Jul 2026 13:55:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=BF=E7=AD=96=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/activity/v1/activity.pb.go | 20 +- api/proto/activity/v1/activity.proto | 12 +- api/proto/activity/v1/activity_grpc.pb.go | 3 + api/proto/luckygift/v1/luckygift.pb.go | 4 +- api/proto/luckygift/v1/luckygift.proto | 4 +- api/proto/user/v1/host.pb.go | 744 ++++--- api/proto/user/v1/host.proto | 13 + api/proto/user/v1/host_grpc.pb.go | 40 + api/proto/wallet/v1/wallet.pb.go | 1902 +++++++++++------ api/proto/wallet/v1/wallet.proto | 148 +- api/proto/wallet/v1/wallet_grpc.pb.go | 38 - pkg/appcode/appcode.go | 12 - pkg/tencentim/rest_client.go | 29 +- pkg/tencentim/rest_client_test.go | 31 +- pkg/userleaderboard/store.go | 23 +- pkg/userleaderboard/store_test.go | 18 + server/admin/cmd/server/main.go | 5 +- .../integration/activityclient/client.go | 5 - .../internal/integration/userclient/client.go | 42 + server/admin/internal/model/models.go | 7 + .../internal/modules/appconfig/handler.go | 8 + .../internal/modules/appconfig/request.go | 16 + .../modules/appconfig/role_scope_policies.go | 166 ++ .../appconfig/role_scope_policies_handler.go | 40 + .../modules/appconfig/room_discovery.go | 81 + .../appconfig/room_discovery_handler.go | 37 + .../internal/modules/appconfig/routes.go | 6 + .../modules/appconfig/runtime_capabilities.go | 111 + .../appconfig/runtime_capabilities_handler.go | 36 + .../internal/modules/appconfig/service.go | 14 +- .../modules/appconfig/service_test.go | 123 ++ .../internal/modules/dailytask/handler.go | 2 +- .../admin/internal/modules/databi/service.go | 20 +- .../internal/modules/databi/service_test.go | 10 + .../modules/financewithdrawal/service.go | 8 +- .../modules/financewithdrawal/service_test.go | 6 +- .../internal/modules/giftdiamond/service.go | 92 +- .../modules/giftdiamond/service_test.go | 72 +- .../modules/hostagencypolicy/handler.go | 1 + .../modules/hostagencypolicy/request.go | 6 + .../modules/hostagencypolicy/response.go | 12 + .../modules/hostagencypolicy/service.go | 177 +- .../modules/hostagencypolicy/service_test.go | 50 +- .../modules/hostsalarysettlement/service.go | 12 +- .../internal/modules/policyconfig/handler.go | 15 +- .../internal/modules/policyconfig/service.go | 303 +-- .../modules/policyconfig/service_test.go | 389 +++- .../host_agency_salary_policy_repository.go | 4 + server/admin/internal/repository/seed.go | 12 +- .../withdrawal_application_repository.go | 2 +- .../116_remove_direct_gift_point_policy.sql | 98 + .../117_room_discovery_scope_config.sql | 23 + .../118_runtime_capabilities_config.sql | 26 + .../migrations/119_host_policy_types.sql | 27 + .../mysql/initdb/001_activity_service.sql | 16 - .../008_task_reward_policy_instances.sql | 17 - .../internal/domain/task/task.go | 12 - .../internal/service/task/service.go | 56 +- .../internal/service/task/service_test.go | 116 +- .../internal/storage/mysql/repository.go | 23 - .../storage/mysql/task_reward_policy.go | 60 - .../grpc/task_flow_integration_test.go | 15 +- .../internal/transport/grpc/task_server.go | 27 +- .../configs/config.docker.yaml | 2 + .../configs/config.tencent.example.yaml | 2 + services/gateway-service/configs/config.yaml | 2 + services/gateway-service/internal/app/app.go | 1 + .../internal/appconfig/mysql.go | 237 +- .../internal/appconfig/mysql_test.go | 90 + .../internal/client/wallet_client.go | 4 - .../gateway-service/internal/config/config.go | 14 +- .../internal/financewithdrawal/writer.go | 9 +- .../transport/http/activityapi/handler.go | 4 + .../invite_activity_reward_handler_test.go | 5 +- .../activityapi/user_leaderboard_handler.go | 17 +- .../user_leaderboard_handler_test.go | 41 +- .../transport/http/gift_capacity_test.go | 36 +- .../transport/http/httproutes/router.go | 14 + .../transport/http/resourceapi/handler.go | 9 +- .../http/resourceapi/resource_handler.go | 20 +- .../internal/transport/http/response_test.go | 258 ++- .../http/room_filter_handler_test.go | 41 +- .../transport/http/roomapi/handler.go | 4 + .../http/roomapi/room_filter_handler.go | 21 +- .../transport/http/roomapi/room_handler.go | 73 +- .../http/roomapi/room_handler_test.go | 15 + .../internal/transport/http/router.go | 7 +- .../http/userapi/agency_center_handler.go | 30 +- .../userapi/agency_center_stats_handler.go | 149 +- .../userapi/host_center_policy_handler.go | 55 +- .../http/userapi/host_center_stats_handler.go | 69 +- .../userapi/host_center_stats_handler_test.go | 37 + .../http/walletapi/direct_gift_handler.go | 56 +- .../transport/http/walletapi/handler.go | 6 + .../walletapi/point_diamond_wallet_handler.go | 510 +++++ .../http/walletapi/point_wallet_handler.go | 108 +- .../walletapi/point_wallet_handler_test.go | 334 ++- .../http/walletapi/salary_wallet_handler.go | 1 + .../transport/http/walletapi/vip_handler.go | 10 +- .../http/walletapi/vip_notice_test.go | 8 +- .../internal/integration/tencent_im.go | 49 +- .../internal/integration/tencent_im_test.go | 16 +- .../internal/room/service/gift_batch.go | 10 +- .../internal/room/service/gift_events_test.go | 12 + .../internal/room/service/gift_lucky.go | 17 +- .../internal/room/service/lucky_gift_test.go | 14 +- .../room/service/room_gift_leaderboard.go | 9 +- .../deploy/mysql/initdb/001_user_service.sql | 19 + .../migrations/022_role_scope_policies.sql | 20 + services/user-service/internal/app/app.go | 7 +- .../service/host/role_scope_policy.go | 169 +- .../service/host/role_scope_policy_test.go | 70 +- .../internal/service/host/service.go | 13 +- .../internal/service/user/moderation.go | 5 +- .../internal/service/user/moderation_test.go | 39 +- .../internal/service/user/service.go | 17 + .../storage/mysql/host/applications.go | 16 +- .../internal/storage/mysql/host/common.go | 14 +- .../storage/mysql/host/invitations.go | 37 +- .../internal/storage/mysql/host/queries.go | 12 +- .../storage/mysql/host/region_policy.go | 150 +- .../storage/mysql/host/region_policy_test.go | 88 +- .../internal/storage/mysql/host/stats.go | 42 +- .../internal/storage/mysql/host/stats_test.go | 6 - .../internal/transport/grpc/host.go | 44 +- .../wallet-service/configs/config.docker.yaml | 2 + .../configs/config.tencent.example.yaml | 2 + services/wallet-service/configs/config.yaml | 2 + .../mysql/initdb/001_wallet_service.sql | 55 +- .../015_remove_direct_gift_point_policy.sql | 26 + .../migrations/017_host_policy_types.sql | 72 + .../internal/client/binance/client.go | 14 +- .../internal/client/binance/client_test.go | 29 +- .../wallet-service/internal/config/config.go | 62 +- .../internal/config/config_test.go | 36 +- .../domain/ledger/agency_host_gift_stats.go | 22 +- .../domain/ledger/agency_point_share_stats.go | 13 - .../internal/domain/ledger/constants.go | 6 + .../internal/domain/ledger/gift.go | 23 +- .../domain/ledger/host_revenue_stats.go | 7 +- .../internal/domain/ledger/host_salary.go | 21 +- .../domain/ledger/point_coin_seller.go | 65 +- .../domain/ledger/salary_withdrawal.go | 83 +- .../internal/domain/ledger/wallet_query.go | 31 +- .../service/wallet/agency_host_gift_stats.go | 2 +- .../wallet/agency_point_share_stats.go | 26 - .../service/wallet/host_revenue_stats.go | 2 +- .../service/wallet/point_coin_seller.go | 50 +- .../service/wallet/point_withdrawal.go | 83 +- .../service/wallet/ports/repository.go | 3 +- .../internal/service/wallet/reward.go | 4 - .../service/wallet/salary_withdrawal.go | 4 + .../internal/service/wallet/service_test.go | 1362 ++++++------ .../internal/storage/mysql/account.go | 28 +- .../storage/mysql/agency_host_gift_stats.go | 170 +- .../storage/mysql/agency_point_share.go | 28 - .../storage/mysql/agency_point_share_stats.go | 25 - .../mysql/agency_point_share_stats_test.go | 40 - .../storage/mysql/coin_seller_ledger.go | 7 + .../mysql/gift_batch_debit_repository.go | 453 ++-- .../gift_config_query_repository_test.go | 253 +++ .../storage/mysql/gift_debit_repository.go | 438 ++-- .../storage/mysql/gift_ledger_helpers.go | 34 +- .../storage/mysql/gift_point_policy.go | 53 - .../storage/mysql/gift_point_policy_test.go | 64 - .../internal/storage/mysql/gift_pricing.go | 140 +- .../storage/mysql/host_revenue_stats.go | 44 +- .../storage/mysql/host_revenue_stats_test.go | 190 +- .../storage/mysql/host_salary_settlement.go | 27 +- .../storage/mysql/host_salary_stats.go | 5 + .../internal/storage/mysql/metadata.go | 83 +- .../internal/storage/mysql/outbox.go | 124 +- .../internal/storage/mysql/outbox_purge.go | 6 +- .../storage/mysql/point_coin_seller.go | 227 +- .../point_diamond_gift_income_projection.go | 55 + .../internal/storage/mysql/point_exchange.go | 115 +- .../storage/mysql/point_withdrawal_limit.go | 200 +- .../internal/storage/mysql/policy_instance.go | 96 +- .../internal/storage/mysql/receipts.go | 42 +- .../storage/mysql/salary_withdrawal.go | 164 +- .../internal/storage/mysql/schema.go | 164 +- .../storage/mysql/vip_admin_repository.go | 4 +- .../storage/mysql/wallet_query_repository.go | 105 +- .../internal/testutil/mysqltest/mysqltest.go | 165 +- .../transport/grpc/agency_host_gift_stats.go | 21 +- .../grpc/agency_point_share_stats.go | 26 - .../internal/transport/grpc/balance.go | 11 + .../internal/transport/grpc/gift.go | 11 + .../transport/grpc/host_revenue_stats.go | 9 +- .../internal/transport/grpc/host_salary.go | 6 + .../transport/grpc/point_coin_seller.go | 30 +- .../transport/grpc/point_withdrawal.go | 26 +- .../transport/grpc/salary_withdrawal.go | 1 + 193 files changed, 9959 insertions(+), 4479 deletions(-) create mode 100644 server/admin/internal/modules/appconfig/role_scope_policies.go create mode 100644 server/admin/internal/modules/appconfig/role_scope_policies_handler.go create mode 100644 server/admin/internal/modules/appconfig/room_discovery.go create mode 100644 server/admin/internal/modules/appconfig/room_discovery_handler.go create mode 100644 server/admin/internal/modules/appconfig/runtime_capabilities.go create mode 100644 server/admin/internal/modules/appconfig/runtime_capabilities_handler.go create mode 100644 server/admin/migrations/116_remove_direct_gift_point_policy.sql create mode 100644 server/admin/migrations/117_room_discovery_scope_config.sql create mode 100644 server/admin/migrations/118_runtime_capabilities_config.sql create mode 100644 server/admin/migrations/119_host_policy_types.sql delete mode 100644 services/activity-service/deploy/mysql/migrations/008_task_reward_policy_instances.sql delete mode 100644 services/activity-service/internal/storage/mysql/task_reward_policy.go create mode 100644 services/gateway-service/internal/transport/http/walletapi/point_diamond_wallet_handler.go create mode 100644 services/user-service/deploy/mysql/migrations/022_role_scope_policies.sql create mode 100644 services/wallet-service/deploy/mysql/migrations/015_remove_direct_gift_point_policy.sql create mode 100644 services/wallet-service/deploy/mysql/migrations/017_host_policy_types.sql delete mode 100644 services/wallet-service/internal/domain/ledger/agency_point_share_stats.go delete mode 100644 services/wallet-service/internal/service/wallet/agency_point_share_stats.go delete mode 100644 services/wallet-service/internal/storage/mysql/agency_point_share.go delete mode 100644 services/wallet-service/internal/storage/mysql/agency_point_share_stats.go delete mode 100644 services/wallet-service/internal/storage/mysql/agency_point_share_stats_test.go delete mode 100644 services/wallet-service/internal/storage/mysql/gift_point_policy.go delete mode 100644 services/wallet-service/internal/storage/mysql/gift_point_policy_test.go create mode 100644 services/wallet-service/internal/storage/mysql/point_diamond_gift_income_projection.go delete mode 100644 services/wallet-service/internal/transport/grpc/agency_point_share_stats.go diff --git a/api/proto/activity/v1/activity.pb.go b/api/proto/activity/v1/activity.pb.go index bf69b994..173ef6cf 100644 --- a/api/proto/activity/v1/activity.pb.go +++ b/api/proto/activity/v1/activity.pb.go @@ -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" + diff --git a/api/proto/activity/v1/activity.proto b/api/proto/activity/v1/activity.proto index 16886292..e55bc645 100644 --- a/api/proto/activity/v1/activity.proto +++ b/api/proto/activity/v1/activity.proto @@ -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 查询和注册完成发放入口。 diff --git a/api/proto/activity/v1/activity_grpc.pb.go b/api/proto/activity/v1/activity_grpc.pb.go index d5278171..d243c290 100644 --- a/api/proto/activity/v1/activity_grpc.pb.go +++ b/api/proto/activity/v1/activity_grpc.pb.go @@ -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() } diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index 1abed13d..456376c8 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -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 在送礼入口返回的账号创建事实快照。 diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index 4afa6a55..b7ce88c5 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -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 在送礼入口返回的账号创建事实快照。 diff --git a/api/proto/user/v1/host.pb.go b/api/proto/user/v1/host.pb.go index 6c41d455..67c323b3 100644 --- a/api/proto/user/v1/host.pb.go +++ b/api/proto/user/v1/host.pb.go @@ -4472,6 +4472,7 @@ type RoleScopePolicy struct { RegionExpansionConfigurable bool `protobuf:"varint,3,opt,name=region_expansion_configurable,json=regionExpansionConfigurable,proto3" json:"region_expansion_configurable,omitempty"` // expanded_scope 是经理通用范围开关开启后的范围;调用方不能自行假设一定是 region。 ExpandedScope string `protobuf:"bytes,4,opt,name=expanded_scope,json=expandedScope,proto3" json:"expanded_scope,omitempty"` + UpdatedAtMs int64 `protobuf:"varint,5,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4534,6 +4535,13 @@ func (x *RoleScopePolicy) GetExpandedScope() string { return "" } +func (x *RoleScopePolicy) GetUpdatedAtMs() int64 { + if x != nil { + return x.UpdatedAtMs + } + return 0 +} + type GetRoleScopePolicyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` @@ -4630,6 +4638,103 @@ func (x *GetRoleScopePolicyResponse) GetPolicy() *RoleScopePolicy { return nil } +type UpdateRoleScopePoliciesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + // 两个 scene 必须一起提交,user-service 在同一事务内保存完整 App 快照。 + Policies []*RoleScopePolicy `protobuf:"bytes,2,rep,name=policies,proto3" json:"policies,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRoleScopePoliciesRequest) Reset() { + *x = UpdateRoleScopePoliciesRequest{} + mi := &file_proto_user_v1_host_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRoleScopePoliciesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRoleScopePoliciesRequest) ProtoMessage() {} + +func (x *UpdateRoleScopePoliciesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_host_proto_msgTypes[67] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRoleScopePoliciesRequest.ProtoReflect.Descriptor instead. +func (*UpdateRoleScopePoliciesRequest) Descriptor() ([]byte, []int) { + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{67} +} + +func (x *UpdateRoleScopePoliciesRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *UpdateRoleScopePoliciesRequest) GetPolicies() []*RoleScopePolicy { + if x != nil { + return x.Policies + } + return nil +} + +type UpdateRoleScopePoliciesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Policies []*RoleScopePolicy `protobuf:"bytes,1,rep,name=policies,proto3" json:"policies,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRoleScopePoliciesResponse) Reset() { + *x = UpdateRoleScopePoliciesResponse{} + mi := &file_proto_user_v1_host_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRoleScopePoliciesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRoleScopePoliciesResponse) ProtoMessage() {} + +func (x *UpdateRoleScopePoliciesResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_host_proto_msgTypes[68] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRoleScopePoliciesResponse.ProtoReflect.Descriptor instead. +func (*UpdateRoleScopePoliciesResponse) Descriptor() ([]byte, []int) { + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{68} +} + +func (x *UpdateRoleScopePoliciesResponse) GetPolicies() []*RoleScopePolicy { + if x != nil { + return x.Policies + } + return nil +} + type GetAgencyMembersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` @@ -4641,7 +4746,7 @@ type GetAgencyMembersRequest struct { func (x *GetAgencyMembersRequest) Reset() { *x = GetAgencyMembersRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[67] + mi := &file_proto_user_v1_host_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4653,7 +4758,7 @@ func (x *GetAgencyMembersRequest) String() string { func (*GetAgencyMembersRequest) ProtoMessage() {} func (x *GetAgencyMembersRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[67] + mi := &file_proto_user_v1_host_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4666,7 +4771,7 @@ func (x *GetAgencyMembersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetAgencyMembersRequest.ProtoReflect.Descriptor instead. func (*GetAgencyMembersRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{67} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{69} } func (x *GetAgencyMembersRequest) GetMeta() *RequestMeta { @@ -4699,7 +4804,7 @@ type GetAgencyMembersResponse struct { func (x *GetAgencyMembersResponse) Reset() { *x = GetAgencyMembersResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[68] + mi := &file_proto_user_v1_host_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4711,7 +4816,7 @@ func (x *GetAgencyMembersResponse) String() string { func (*GetAgencyMembersResponse) ProtoMessage() {} func (x *GetAgencyMembersResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[68] + mi := &file_proto_user_v1_host_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4724,7 +4829,7 @@ func (x *GetAgencyMembersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetAgencyMembersResponse.ProtoReflect.Descriptor instead. func (*GetAgencyMembersResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{68} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{70} } func (x *GetAgencyMembersResponse) GetMemberships() []*AgencyMembership { @@ -4745,7 +4850,7 @@ type GetAgencyApplicationsRequest struct { func (x *GetAgencyApplicationsRequest) Reset() { *x = GetAgencyApplicationsRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[69] + mi := &file_proto_user_v1_host_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4757,7 +4862,7 @@ func (x *GetAgencyApplicationsRequest) String() string { func (*GetAgencyApplicationsRequest) ProtoMessage() {} func (x *GetAgencyApplicationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[69] + mi := &file_proto_user_v1_host_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4770,7 +4875,7 @@ func (x *GetAgencyApplicationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetAgencyApplicationsRequest.ProtoReflect.Descriptor instead. func (*GetAgencyApplicationsRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{69} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{71} } func (x *GetAgencyApplicationsRequest) GetMeta() *RequestMeta { @@ -4803,7 +4908,7 @@ type GetAgencyApplicationsResponse struct { func (x *GetAgencyApplicationsResponse) Reset() { *x = GetAgencyApplicationsResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[70] + mi := &file_proto_user_v1_host_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4815,7 +4920,7 @@ func (x *GetAgencyApplicationsResponse) String() string { func (*GetAgencyApplicationsResponse) ProtoMessage() {} func (x *GetAgencyApplicationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[70] + mi := &file_proto_user_v1_host_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4828,7 +4933,7 @@ func (x *GetAgencyApplicationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetAgencyApplicationsResponse.ProtoReflect.Descriptor instead. func (*GetAgencyApplicationsResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{70} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{72} } func (x *GetAgencyApplicationsResponse) GetApplications() []*AgencyApplication { @@ -4851,7 +4956,7 @@ type CreateBDLeaderRequest struct { func (x *CreateBDLeaderRequest) Reset() { *x = CreateBDLeaderRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[71] + mi := &file_proto_user_v1_host_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4863,7 +4968,7 @@ func (x *CreateBDLeaderRequest) String() string { func (*CreateBDLeaderRequest) ProtoMessage() {} func (x *CreateBDLeaderRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[71] + mi := &file_proto_user_v1_host_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4876,7 +4981,7 @@ func (x *CreateBDLeaderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateBDLeaderRequest.ProtoReflect.Descriptor instead. func (*CreateBDLeaderRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{71} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{73} } func (x *CreateBDLeaderRequest) GetMeta() *RequestMeta { @@ -4923,7 +5028,7 @@ type CreateBDLeaderResponse struct { func (x *CreateBDLeaderResponse) Reset() { *x = CreateBDLeaderResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[72] + mi := &file_proto_user_v1_host_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4935,7 +5040,7 @@ func (x *CreateBDLeaderResponse) String() string { func (*CreateBDLeaderResponse) ProtoMessage() {} func (x *CreateBDLeaderResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[72] + mi := &file_proto_user_v1_host_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4948,7 +5053,7 @@ func (x *CreateBDLeaderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateBDLeaderResponse.ProtoReflect.Descriptor instead. func (*CreateBDLeaderResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{72} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{74} } func (x *CreateBDLeaderResponse) GetBdProfile() *BDProfile { @@ -4972,7 +5077,7 @@ type CreateBDRequest struct { func (x *CreateBDRequest) Reset() { *x = CreateBDRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[73] + mi := &file_proto_user_v1_host_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4984,7 +5089,7 @@ func (x *CreateBDRequest) String() string { func (*CreateBDRequest) ProtoMessage() {} func (x *CreateBDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[73] + mi := &file_proto_user_v1_host_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4997,7 +5102,7 @@ func (x *CreateBDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateBDRequest.ProtoReflect.Descriptor instead. func (*CreateBDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{73} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{75} } func (x *CreateBDRequest) GetMeta() *RequestMeta { @@ -5051,7 +5156,7 @@ type CreateBDResponse struct { func (x *CreateBDResponse) Reset() { *x = CreateBDResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[74] + mi := &file_proto_user_v1_host_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5063,7 +5168,7 @@ func (x *CreateBDResponse) String() string { func (*CreateBDResponse) ProtoMessage() {} func (x *CreateBDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[74] + mi := &file_proto_user_v1_host_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5076,7 +5181,7 @@ func (x *CreateBDResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateBDResponse.ProtoReflect.Descriptor instead. func (*CreateBDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{74} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{76} } func (x *CreateBDResponse) GetBdProfile() *BDProfile { @@ -5101,7 +5206,7 @@ type SetBDStatusRequest struct { func (x *SetBDStatusRequest) Reset() { *x = SetBDStatusRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[75] + mi := &file_proto_user_v1_host_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5113,7 +5218,7 @@ func (x *SetBDStatusRequest) String() string { func (*SetBDStatusRequest) ProtoMessage() {} func (x *SetBDStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[75] + mi := &file_proto_user_v1_host_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5126,7 +5231,7 @@ func (x *SetBDStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetBDStatusRequest.ProtoReflect.Descriptor instead. func (*SetBDStatusRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{75} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{77} } func (x *SetBDStatusRequest) GetMeta() *RequestMeta { @@ -5187,7 +5292,7 @@ type SetBDStatusResponse struct { func (x *SetBDStatusResponse) Reset() { *x = SetBDStatusResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[76] + mi := &file_proto_user_v1_host_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5199,7 +5304,7 @@ func (x *SetBDStatusResponse) String() string { func (*SetBDStatusResponse) ProtoMessage() {} func (x *SetBDStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[76] + mi := &file_proto_user_v1_host_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5212,7 +5317,7 @@ func (x *SetBDStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetBDStatusResponse.ProtoReflect.Descriptor instead. func (*SetBDStatusResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{76} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{78} } func (x *SetBDStatusResponse) GetBdProfile() *BDProfile { @@ -5236,7 +5341,7 @@ type CreateCoinSellerRequest struct { func (x *CreateCoinSellerRequest) Reset() { *x = CreateCoinSellerRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[77] + mi := &file_proto_user_v1_host_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5248,7 +5353,7 @@ func (x *CreateCoinSellerRequest) String() string { func (*CreateCoinSellerRequest) ProtoMessage() {} func (x *CreateCoinSellerRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[77] + mi := &file_proto_user_v1_host_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5261,7 +5366,7 @@ func (x *CreateCoinSellerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateCoinSellerRequest.ProtoReflect.Descriptor instead. func (*CreateCoinSellerRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{77} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{79} } func (x *CreateCoinSellerRequest) GetMeta() *RequestMeta { @@ -5315,7 +5420,7 @@ type CreateCoinSellerResponse struct { func (x *CreateCoinSellerResponse) Reset() { *x = CreateCoinSellerResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[78] + mi := &file_proto_user_v1_host_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5327,7 +5432,7 @@ func (x *CreateCoinSellerResponse) String() string { func (*CreateCoinSellerResponse) ProtoMessage() {} func (x *CreateCoinSellerResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[78] + mi := &file_proto_user_v1_host_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5340,7 +5445,7 @@ func (x *CreateCoinSellerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateCoinSellerResponse.ProtoReflect.Descriptor instead. func (*CreateCoinSellerResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{78} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{80} } func (x *CreateCoinSellerResponse) GetCoinSellerProfile() *CoinSellerProfile { @@ -5365,7 +5470,7 @@ type SetCoinSellerStatusRequest struct { func (x *SetCoinSellerStatusRequest) Reset() { *x = SetCoinSellerStatusRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[79] + mi := &file_proto_user_v1_host_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5377,7 +5482,7 @@ func (x *SetCoinSellerStatusRequest) String() string { func (*SetCoinSellerStatusRequest) ProtoMessage() {} func (x *SetCoinSellerStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[79] + mi := &file_proto_user_v1_host_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5390,7 +5495,7 @@ func (x *SetCoinSellerStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetCoinSellerStatusRequest.ProtoReflect.Descriptor instead. func (*SetCoinSellerStatusRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{79} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{81} } func (x *SetCoinSellerStatusRequest) GetMeta() *RequestMeta { @@ -5451,7 +5556,7 @@ type SetCoinSellerStatusResponse struct { func (x *SetCoinSellerStatusResponse) Reset() { *x = SetCoinSellerStatusResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[80] + mi := &file_proto_user_v1_host_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5463,7 +5568,7 @@ func (x *SetCoinSellerStatusResponse) String() string { func (*SetCoinSellerStatusResponse) ProtoMessage() {} func (x *SetCoinSellerStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[80] + mi := &file_proto_user_v1_host_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5476,7 +5581,7 @@ func (x *SetCoinSellerStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetCoinSellerStatusResponse.ProtoReflect.Descriptor instead. func (*SetCoinSellerStatusResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{80} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{82} } func (x *SetCoinSellerStatusResponse) GetCoinSellerProfile() *CoinSellerProfile { @@ -5500,7 +5605,7 @@ type ReviewSubCoinSellerApplicationRequest struct { func (x *ReviewSubCoinSellerApplicationRequest) Reset() { *x = ReviewSubCoinSellerApplicationRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[81] + mi := &file_proto_user_v1_host_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5512,7 +5617,7 @@ func (x *ReviewSubCoinSellerApplicationRequest) String() string { func (*ReviewSubCoinSellerApplicationRequest) ProtoMessage() {} func (x *ReviewSubCoinSellerApplicationRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[81] + mi := &file_proto_user_v1_host_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5525,7 +5630,7 @@ func (x *ReviewSubCoinSellerApplicationRequest) ProtoReflect() protoreflect.Mess // Deprecated: Use ReviewSubCoinSellerApplicationRequest.ProtoReflect.Descriptor instead. func (*ReviewSubCoinSellerApplicationRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{81} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{83} } func (x *ReviewSubCoinSellerApplicationRequest) GetMeta() *RequestMeta { @@ -5581,7 +5686,7 @@ type ReviewSubCoinSellerApplicationResponse struct { func (x *ReviewSubCoinSellerApplicationResponse) Reset() { *x = ReviewSubCoinSellerApplicationResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[82] + mi := &file_proto_user_v1_host_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5593,7 +5698,7 @@ func (x *ReviewSubCoinSellerApplicationResponse) String() string { func (*ReviewSubCoinSellerApplicationResponse) ProtoMessage() {} func (x *ReviewSubCoinSellerApplicationResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[82] + mi := &file_proto_user_v1_host_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5606,7 +5711,7 @@ func (x *ReviewSubCoinSellerApplicationResponse) ProtoReflect() protoreflect.Mes // Deprecated: Use ReviewSubCoinSellerApplicationResponse.ProtoReflect.Descriptor instead. func (*ReviewSubCoinSellerApplicationResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{82} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{84} } func (x *ReviewSubCoinSellerApplicationResponse) GetApplication() *CoinSellerSubApplication { @@ -5648,7 +5753,7 @@ type CreateAgencyRequest struct { func (x *CreateAgencyRequest) Reset() { *x = CreateAgencyRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[83] + mi := &file_proto_user_v1_host_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5660,7 +5765,7 @@ func (x *CreateAgencyRequest) String() string { func (*CreateAgencyRequest) ProtoMessage() {} func (x *CreateAgencyRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[83] + mi := &file_proto_user_v1_host_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5673,7 +5778,7 @@ func (x *CreateAgencyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateAgencyRequest.ProtoReflect.Descriptor instead. func (*CreateAgencyRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{83} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{85} } func (x *CreateAgencyRequest) GetMeta() *RequestMeta { @@ -5750,7 +5855,7 @@ type CreateAgencyResponse struct { func (x *CreateAgencyResponse) Reset() { *x = CreateAgencyResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[84] + mi := &file_proto_user_v1_host_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5762,7 +5867,7 @@ func (x *CreateAgencyResponse) String() string { func (*CreateAgencyResponse) ProtoMessage() {} func (x *CreateAgencyResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[84] + mi := &file_proto_user_v1_host_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5775,7 +5880,7 @@ func (x *CreateAgencyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateAgencyResponse.ProtoReflect.Descriptor instead. func (*CreateAgencyResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{84} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{86} } func (x *CreateAgencyResponse) GetAgency() *Agency { @@ -5813,7 +5918,7 @@ type AdminAddAgencyHostRequest struct { func (x *AdminAddAgencyHostRequest) Reset() { *x = AdminAddAgencyHostRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[85] + mi := &file_proto_user_v1_host_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5825,7 +5930,7 @@ func (x *AdminAddAgencyHostRequest) String() string { func (*AdminAddAgencyHostRequest) ProtoMessage() {} func (x *AdminAddAgencyHostRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[85] + mi := &file_proto_user_v1_host_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5838,7 +5943,7 @@ func (x *AdminAddAgencyHostRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminAddAgencyHostRequest.ProtoReflect.Descriptor instead. func (*AdminAddAgencyHostRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{85} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{87} } func (x *AdminAddAgencyHostRequest) GetMeta() *RequestMeta { @@ -5894,7 +5999,7 @@ type AdminAddAgencyHostResponse struct { func (x *AdminAddAgencyHostResponse) Reset() { *x = AdminAddAgencyHostResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[86] + mi := &file_proto_user_v1_host_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5906,7 +6011,7 @@ func (x *AdminAddAgencyHostResponse) String() string { func (*AdminAddAgencyHostResponse) ProtoMessage() {} func (x *AdminAddAgencyHostResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[86] + mi := &file_proto_user_v1_host_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5919,7 +6024,7 @@ func (x *AdminAddAgencyHostResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminAddAgencyHostResponse.ProtoReflect.Descriptor instead. func (*AdminAddAgencyHostResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{86} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{88} } func (x *AdminAddAgencyHostResponse) GetAgency() *Agency { @@ -5956,7 +6061,7 @@ type CloseAgencyRequest struct { func (x *CloseAgencyRequest) Reset() { *x = CloseAgencyRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[87] + mi := &file_proto_user_v1_host_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5968,7 +6073,7 @@ func (x *CloseAgencyRequest) String() string { func (*CloseAgencyRequest) ProtoMessage() {} func (x *CloseAgencyRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[87] + mi := &file_proto_user_v1_host_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5981,7 +6086,7 @@ func (x *CloseAgencyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CloseAgencyRequest.ProtoReflect.Descriptor instead. func (*CloseAgencyRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{87} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{89} } func (x *CloseAgencyRequest) GetMeta() *RequestMeta { @@ -6028,7 +6133,7 @@ type CloseAgencyResponse struct { func (x *CloseAgencyResponse) Reset() { *x = CloseAgencyResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[88] + mi := &file_proto_user_v1_host_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6040,7 +6145,7 @@ func (x *CloseAgencyResponse) String() string { func (*CloseAgencyResponse) ProtoMessage() {} func (x *CloseAgencyResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[88] + mi := &file_proto_user_v1_host_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6053,7 +6158,7 @@ func (x *CloseAgencyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CloseAgencyResponse.ProtoReflect.Descriptor instead. func (*CloseAgencyResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{88} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{90} } func (x *CloseAgencyResponse) GetAgency() *Agency { @@ -6076,7 +6181,7 @@ type DeleteAgencyRequest struct { func (x *DeleteAgencyRequest) Reset() { *x = DeleteAgencyRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[89] + mi := &file_proto_user_v1_host_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6088,7 +6193,7 @@ func (x *DeleteAgencyRequest) String() string { func (*DeleteAgencyRequest) ProtoMessage() {} func (x *DeleteAgencyRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[89] + mi := &file_proto_user_v1_host_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6101,7 +6206,7 @@ func (x *DeleteAgencyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteAgencyRequest.ProtoReflect.Descriptor instead. func (*DeleteAgencyRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{89} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{91} } func (x *DeleteAgencyRequest) GetMeta() *RequestMeta { @@ -6148,7 +6253,7 @@ type DeleteAgencyResponse struct { func (x *DeleteAgencyResponse) Reset() { *x = DeleteAgencyResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[90] + mi := &file_proto_user_v1_host_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6160,7 +6265,7 @@ func (x *DeleteAgencyResponse) String() string { func (*DeleteAgencyResponse) ProtoMessage() {} func (x *DeleteAgencyResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[90] + mi := &file_proto_user_v1_host_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6173,7 +6278,7 @@ func (x *DeleteAgencyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteAgencyResponse.ProtoReflect.Descriptor instead. func (*DeleteAgencyResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{90} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{92} } func (x *DeleteAgencyResponse) GetAgency() *Agency { @@ -6197,7 +6302,7 @@ type SetAgencyStatusRequest struct { func (x *SetAgencyStatusRequest) Reset() { *x = SetAgencyStatusRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[91] + mi := &file_proto_user_v1_host_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6209,7 +6314,7 @@ func (x *SetAgencyStatusRequest) String() string { func (*SetAgencyStatusRequest) ProtoMessage() {} func (x *SetAgencyStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[91] + mi := &file_proto_user_v1_host_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6222,7 +6327,7 @@ func (x *SetAgencyStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetAgencyStatusRequest.ProtoReflect.Descriptor instead. func (*SetAgencyStatusRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{91} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{93} } func (x *SetAgencyStatusRequest) GetMeta() *RequestMeta { @@ -6276,7 +6381,7 @@ type SetAgencyStatusResponse struct { func (x *SetAgencyStatusResponse) Reset() { *x = SetAgencyStatusResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[92] + mi := &file_proto_user_v1_host_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6288,7 +6393,7 @@ func (x *SetAgencyStatusResponse) String() string { func (*SetAgencyStatusResponse) ProtoMessage() {} func (x *SetAgencyStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[92] + mi := &file_proto_user_v1_host_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6301,7 +6406,7 @@ func (x *SetAgencyStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetAgencyStatusResponse.ProtoReflect.Descriptor instead. func (*SetAgencyStatusResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{92} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{94} } func (x *SetAgencyStatusResponse) GetAgency() *Agency { @@ -6325,7 +6430,7 @@ type SetAgencyJoinEnabledRequest struct { func (x *SetAgencyJoinEnabledRequest) Reset() { *x = SetAgencyJoinEnabledRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[93] + mi := &file_proto_user_v1_host_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6337,7 +6442,7 @@ func (x *SetAgencyJoinEnabledRequest) String() string { func (*SetAgencyJoinEnabledRequest) ProtoMessage() {} func (x *SetAgencyJoinEnabledRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[93] + mi := &file_proto_user_v1_host_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6350,7 +6455,7 @@ func (x *SetAgencyJoinEnabledRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetAgencyJoinEnabledRequest.ProtoReflect.Descriptor instead. func (*SetAgencyJoinEnabledRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{93} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{95} } func (x *SetAgencyJoinEnabledRequest) GetMeta() *RequestMeta { @@ -6404,7 +6509,7 @@ type SetAgencyJoinEnabledResponse struct { func (x *SetAgencyJoinEnabledResponse) Reset() { *x = SetAgencyJoinEnabledResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[94] + mi := &file_proto_user_v1_host_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6416,7 +6521,7 @@ func (x *SetAgencyJoinEnabledResponse) String() string { func (*SetAgencyJoinEnabledResponse) ProtoMessage() {} func (x *SetAgencyJoinEnabledResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[94] + mi := &file_proto_user_v1_host_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6429,7 +6534,7 @@ func (x *SetAgencyJoinEnabledResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetAgencyJoinEnabledResponse.ProtoReflect.Descriptor instead. func (*SetAgencyJoinEnabledResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{94} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{96} } func (x *SetAgencyJoinEnabledResponse) GetAgency() *Agency { @@ -6453,7 +6558,7 @@ type UpdateAgencyProfileRequest struct { func (x *UpdateAgencyProfileRequest) Reset() { *x = UpdateAgencyProfileRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[95] + mi := &file_proto_user_v1_host_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6465,7 +6570,7 @@ func (x *UpdateAgencyProfileRequest) String() string { func (*UpdateAgencyProfileRequest) ProtoMessage() {} func (x *UpdateAgencyProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[95] + mi := &file_proto_user_v1_host_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6478,7 +6583,7 @@ func (x *UpdateAgencyProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateAgencyProfileRequest.ProtoReflect.Descriptor instead. func (*UpdateAgencyProfileRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{95} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{97} } func (x *UpdateAgencyProfileRequest) GetMeta() *RequestMeta { @@ -6532,7 +6637,7 @@ type UpdateAgencyProfileResponse struct { func (x *UpdateAgencyProfileResponse) Reset() { *x = UpdateAgencyProfileResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[96] + mi := &file_proto_user_v1_host_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6544,7 +6649,7 @@ func (x *UpdateAgencyProfileResponse) String() string { func (*UpdateAgencyProfileResponse) ProtoMessage() {} func (x *UpdateAgencyProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[96] + mi := &file_proto_user_v1_host_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6557,7 +6662,7 @@ func (x *UpdateAgencyProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateAgencyProfileResponse.ProtoReflect.Descriptor instead. func (*UpdateAgencyProfileResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{96} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{98} } func (x *UpdateAgencyProfileResponse) GetAgency() *Agency { @@ -6581,7 +6686,7 @@ type HostEngagementStats struct { func (x *HostEngagementStats) Reset() { *x = HostEngagementStats{} - mi := &file_proto_user_v1_host_proto_msgTypes[97] + mi := &file_proto_user_v1_host_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6593,7 +6698,7 @@ func (x *HostEngagementStats) String() string { func (*HostEngagementStats) ProtoMessage() {} func (x *HostEngagementStats) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[97] + mi := &file_proto_user_v1_host_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6606,7 +6711,7 @@ func (x *HostEngagementStats) ProtoReflect() protoreflect.Message { // Deprecated: Use HostEngagementStats.ProtoReflect.Descriptor instead. func (*HostEngagementStats) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{97} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{99} } func (x *HostEngagementStats) GetOnlineDurationMs() int64 { @@ -6656,7 +6761,7 @@ type GetHostEngagementStatsRequest struct { func (x *GetHostEngagementStatsRequest) Reset() { *x = GetHostEngagementStatsRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[98] + mi := &file_proto_user_v1_host_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6668,7 +6773,7 @@ func (x *GetHostEngagementStatsRequest) String() string { func (*GetHostEngagementStatsRequest) ProtoMessage() {} func (x *GetHostEngagementStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[98] + mi := &file_proto_user_v1_host_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6681,7 +6786,7 @@ func (x *GetHostEngagementStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetHostEngagementStatsRequest.ProtoReflect.Descriptor instead. func (*GetHostEngagementStatsRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{98} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{100} } func (x *GetHostEngagementStatsRequest) GetMeta() *RequestMeta { @@ -6721,7 +6826,7 @@ type GetHostEngagementStatsResponse struct { func (x *GetHostEngagementStatsResponse) Reset() { *x = GetHostEngagementStatsResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[99] + mi := &file_proto_user_v1_host_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6733,7 +6838,7 @@ func (x *GetHostEngagementStatsResponse) String() string { func (*GetHostEngagementStatsResponse) ProtoMessage() {} func (x *GetHostEngagementStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[99] + mi := &file_proto_user_v1_host_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6746,7 +6851,7 @@ func (x *GetHostEngagementStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetHostEngagementStatsResponse.ProtoReflect.Descriptor instead. func (*GetHostEngagementStatsResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{99} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{101} } func (x *GetHostEngagementStatsResponse) GetStats() *HostEngagementStats { @@ -6770,7 +6875,7 @@ type RecordPrivateMessageEventRequest struct { func (x *RecordPrivateMessageEventRequest) Reset() { *x = RecordPrivateMessageEventRequest{} - mi := &file_proto_user_v1_host_proto_msgTypes[100] + mi := &file_proto_user_v1_host_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6782,7 +6887,7 @@ func (x *RecordPrivateMessageEventRequest) String() string { func (*RecordPrivateMessageEventRequest) ProtoMessage() {} func (x *RecordPrivateMessageEventRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[100] + mi := &file_proto_user_v1_host_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6795,7 +6900,7 @@ func (x *RecordPrivateMessageEventRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RecordPrivateMessageEventRequest.ProtoReflect.Descriptor instead. func (*RecordPrivateMessageEventRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{100} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{102} } func (x *RecordPrivateMessageEventRequest) GetMeta() *RequestMeta { @@ -6842,7 +6947,7 @@ type RecordPrivateMessageEventResponse struct { func (x *RecordPrivateMessageEventResponse) Reset() { *x = RecordPrivateMessageEventResponse{} - mi := &file_proto_user_v1_host_proto_msgTypes[101] + mi := &file_proto_user_v1_host_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6854,7 +6959,7 @@ func (x *RecordPrivateMessageEventResponse) String() string { func (*RecordPrivateMessageEventResponse) ProtoMessage() {} func (x *RecordPrivateMessageEventResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_host_proto_msgTypes[101] + mi := &file_proto_user_v1_host_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6867,7 +6972,7 @@ func (x *RecordPrivateMessageEventResponse) ProtoReflect() protoreflect.Message // Deprecated: Use RecordPrivateMessageEventResponse.ProtoReflect.Descriptor instead. func (*RecordPrivateMessageEventResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_host_proto_rawDescGZIP(), []int{101} + return file_proto_user_v1_host_proto_rawDescGZIP(), []int{103} } func (x *RecordPrivateMessageEventResponse) GetCreated() bool { @@ -7269,18 +7374,24 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "capability\"S\n" + "\x1fCheckBusinessCapabilityResponse\x12\x18\n" + "\aallowed\x18\x01 \x01(\bR\aallowed\x12\x16\n" + - "\x06reason\x18\x02 \x01(\tR\x06reason\"\xb1\x01\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\"\xd5\x01\n" + "\x0fRoleScopePolicy\x12\x14\n" + "\x05scene\x18\x01 \x01(\tR\x05scene\x12\x1d\n" + "\n" + "base_scope\x18\x02 \x01(\tR\tbaseScope\x12B\n" + "\x1dregion_expansion_configurable\x18\x03 \x01(\bR\x1bregionExpansionConfigurable\x12%\n" + - "\x0eexpanded_scope\x18\x04 \x01(\tR\rexpandedScope\"a\n" + + "\x0eexpanded_scope\x18\x04 \x01(\tR\rexpandedScope\x12\"\n" + + "\rupdated_at_ms\x18\x05 \x01(\x03R\vupdatedAtMs\"a\n" + "\x19GetRoleScopePolicyRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x14\n" + "\x05scene\x18\x02 \x01(\tR\x05scene\"T\n" + "\x1aGetRoleScopePolicyResponse\x126\n" + - "\x06policy\x18\x01 \x01(\v2\x1e.hyapp.user.v1.RoleScopePolicyR\x06policy\"~\n" + + "\x06policy\x18\x01 \x01(\v2\x1e.hyapp.user.v1.RoleScopePolicyR\x06policy\"\x8c\x01\n" + + "\x1eUpdateRoleScopePoliciesRequest\x12.\n" + + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12:\n" + + "\bpolicies\x18\x02 \x03(\v2\x1e.hyapp.user.v1.RoleScopePolicyR\bpolicies\"]\n" + + "\x1fUpdateRoleScopePoliciesResponse\x12:\n" + + "\bpolicies\x18\x01 \x03(\v2\x1e.hyapp.user.v1.RoleScopePolicyR\bpolicies\"~\n" + "\x17GetAgencyMembersRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x1b\n" + "\tagency_id\x18\x02 \x01(\x03R\bagencyId\x12\x16\n" + @@ -7497,8 +7608,10 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "\x15GetAgencyApplications\x12+.hyapp.user.v1.GetAgencyApplicationsRequest\x1a,.hyapp.user.v1.GetAgencyApplicationsResponse\x12l\n" + "\x13UpdateAgencyProfile\x12).hyapp.user.v1.UpdateAgencyProfileRequest\x1a*.hyapp.user.v1.UpdateAgencyProfileResponse\x12u\n" + "\x16GetHostEngagementStats\x12,.hyapp.user.v1.GetHostEngagementStatsRequest\x1a-.hyapp.user.v1.GetHostEngagementStatsResponse\x12~\n" + - "\x19RecordPrivateMessageEvent\x12/.hyapp.user.v1.RecordPrivateMessageEventRequest\x1a0.hyapp.user.v1.RecordPrivateMessageEventResponse2\xc1\t\n" + - "\x14UserHostAdminService\x12]\n" + + "\x19RecordPrivateMessageEvent\x12/.hyapp.user.v1.RecordPrivateMessageEventRequest\x1a0.hyapp.user.v1.RecordPrivateMessageEventResponse2\xbb\n" + + "\n" + + "\x14UserHostAdminService\x12x\n" + + "\x17UpdateRoleScopePolicies\x12-.hyapp.user.v1.UpdateRoleScopePoliciesRequest\x1a..hyapp.user.v1.UpdateRoleScopePoliciesResponse\x12]\n" + "\x0eCreateBDLeader\x12$.hyapp.user.v1.CreateBDLeaderRequest\x1a%.hyapp.user.v1.CreateBDLeaderResponse\x12K\n" + "\bCreateBD\x12\x1e.hyapp.user.v1.CreateBDRequest\x1a\x1f.hyapp.user.v1.CreateBDResponse\x12T\n" + "\vSetBDStatus\x12!.hyapp.user.v1.SetBDStatusRequest\x1a\".hyapp.user.v1.SetBDStatusResponse\x12c\n" + @@ -7524,7 +7637,7 @@ func file_proto_user_v1_host_proto_rawDescGZIP() []byte { return file_proto_user_v1_host_proto_rawDescData } -var file_proto_user_v1_host_proto_msgTypes = make([]protoimpl.MessageInfo, 103) +var file_proto_user_v1_host_proto_msgTypes = make([]protoimpl.MessageInfo, 105) var file_proto_user_v1_host_proto_goTypes = []any{ (*HostProfile)(nil), // 0: hyapp.user.v1.HostProfile (*Agency)(nil), // 1: hyapp.user.v1.Agency @@ -7593,239 +7706,246 @@ var file_proto_user_v1_host_proto_goTypes = []any{ (*RoleScopePolicy)(nil), // 64: hyapp.user.v1.RoleScopePolicy (*GetRoleScopePolicyRequest)(nil), // 65: hyapp.user.v1.GetRoleScopePolicyRequest (*GetRoleScopePolicyResponse)(nil), // 66: hyapp.user.v1.GetRoleScopePolicyResponse - (*GetAgencyMembersRequest)(nil), // 67: hyapp.user.v1.GetAgencyMembersRequest - (*GetAgencyMembersResponse)(nil), // 68: hyapp.user.v1.GetAgencyMembersResponse - (*GetAgencyApplicationsRequest)(nil), // 69: hyapp.user.v1.GetAgencyApplicationsRequest - (*GetAgencyApplicationsResponse)(nil), // 70: hyapp.user.v1.GetAgencyApplicationsResponse - (*CreateBDLeaderRequest)(nil), // 71: hyapp.user.v1.CreateBDLeaderRequest - (*CreateBDLeaderResponse)(nil), // 72: hyapp.user.v1.CreateBDLeaderResponse - (*CreateBDRequest)(nil), // 73: hyapp.user.v1.CreateBDRequest - (*CreateBDResponse)(nil), // 74: hyapp.user.v1.CreateBDResponse - (*SetBDStatusRequest)(nil), // 75: hyapp.user.v1.SetBDStatusRequest - (*SetBDStatusResponse)(nil), // 76: hyapp.user.v1.SetBDStatusResponse - (*CreateCoinSellerRequest)(nil), // 77: hyapp.user.v1.CreateCoinSellerRequest - (*CreateCoinSellerResponse)(nil), // 78: hyapp.user.v1.CreateCoinSellerResponse - (*SetCoinSellerStatusRequest)(nil), // 79: hyapp.user.v1.SetCoinSellerStatusRequest - (*SetCoinSellerStatusResponse)(nil), // 80: hyapp.user.v1.SetCoinSellerStatusResponse - (*ReviewSubCoinSellerApplicationRequest)(nil), // 81: hyapp.user.v1.ReviewSubCoinSellerApplicationRequest - (*ReviewSubCoinSellerApplicationResponse)(nil), // 82: hyapp.user.v1.ReviewSubCoinSellerApplicationResponse - (*CreateAgencyRequest)(nil), // 83: hyapp.user.v1.CreateAgencyRequest - (*CreateAgencyResponse)(nil), // 84: hyapp.user.v1.CreateAgencyResponse - (*AdminAddAgencyHostRequest)(nil), // 85: hyapp.user.v1.AdminAddAgencyHostRequest - (*AdminAddAgencyHostResponse)(nil), // 86: hyapp.user.v1.AdminAddAgencyHostResponse - (*CloseAgencyRequest)(nil), // 87: hyapp.user.v1.CloseAgencyRequest - (*CloseAgencyResponse)(nil), // 88: hyapp.user.v1.CloseAgencyResponse - (*DeleteAgencyRequest)(nil), // 89: hyapp.user.v1.DeleteAgencyRequest - (*DeleteAgencyResponse)(nil), // 90: hyapp.user.v1.DeleteAgencyResponse - (*SetAgencyStatusRequest)(nil), // 91: hyapp.user.v1.SetAgencyStatusRequest - (*SetAgencyStatusResponse)(nil), // 92: hyapp.user.v1.SetAgencyStatusResponse - (*SetAgencyJoinEnabledRequest)(nil), // 93: hyapp.user.v1.SetAgencyJoinEnabledRequest - (*SetAgencyJoinEnabledResponse)(nil), // 94: hyapp.user.v1.SetAgencyJoinEnabledResponse - (*UpdateAgencyProfileRequest)(nil), // 95: hyapp.user.v1.UpdateAgencyProfileRequest - (*UpdateAgencyProfileResponse)(nil), // 96: hyapp.user.v1.UpdateAgencyProfileResponse - (*HostEngagementStats)(nil), // 97: hyapp.user.v1.HostEngagementStats - (*GetHostEngagementStatsRequest)(nil), // 98: hyapp.user.v1.GetHostEngagementStatsRequest - (*GetHostEngagementStatsResponse)(nil), // 99: hyapp.user.v1.GetHostEngagementStatsResponse - (*RecordPrivateMessageEventRequest)(nil), // 100: hyapp.user.v1.RecordPrivateMessageEventRequest - (*RecordPrivateMessageEventResponse)(nil), // 101: hyapp.user.v1.RecordPrivateMessageEventResponse - nil, // 102: hyapp.user.v1.BatchGetHostProfilesResponse.HostProfilesEntry - (*RequestMeta)(nil), // 103: hyapp.user.v1.RequestMeta + (*UpdateRoleScopePoliciesRequest)(nil), // 67: hyapp.user.v1.UpdateRoleScopePoliciesRequest + (*UpdateRoleScopePoliciesResponse)(nil), // 68: hyapp.user.v1.UpdateRoleScopePoliciesResponse + (*GetAgencyMembersRequest)(nil), // 69: hyapp.user.v1.GetAgencyMembersRequest + (*GetAgencyMembersResponse)(nil), // 70: hyapp.user.v1.GetAgencyMembersResponse + (*GetAgencyApplicationsRequest)(nil), // 71: hyapp.user.v1.GetAgencyApplicationsRequest + (*GetAgencyApplicationsResponse)(nil), // 72: hyapp.user.v1.GetAgencyApplicationsResponse + (*CreateBDLeaderRequest)(nil), // 73: hyapp.user.v1.CreateBDLeaderRequest + (*CreateBDLeaderResponse)(nil), // 74: hyapp.user.v1.CreateBDLeaderResponse + (*CreateBDRequest)(nil), // 75: hyapp.user.v1.CreateBDRequest + (*CreateBDResponse)(nil), // 76: hyapp.user.v1.CreateBDResponse + (*SetBDStatusRequest)(nil), // 77: hyapp.user.v1.SetBDStatusRequest + (*SetBDStatusResponse)(nil), // 78: hyapp.user.v1.SetBDStatusResponse + (*CreateCoinSellerRequest)(nil), // 79: hyapp.user.v1.CreateCoinSellerRequest + (*CreateCoinSellerResponse)(nil), // 80: hyapp.user.v1.CreateCoinSellerResponse + (*SetCoinSellerStatusRequest)(nil), // 81: hyapp.user.v1.SetCoinSellerStatusRequest + (*SetCoinSellerStatusResponse)(nil), // 82: hyapp.user.v1.SetCoinSellerStatusResponse + (*ReviewSubCoinSellerApplicationRequest)(nil), // 83: hyapp.user.v1.ReviewSubCoinSellerApplicationRequest + (*ReviewSubCoinSellerApplicationResponse)(nil), // 84: hyapp.user.v1.ReviewSubCoinSellerApplicationResponse + (*CreateAgencyRequest)(nil), // 85: hyapp.user.v1.CreateAgencyRequest + (*CreateAgencyResponse)(nil), // 86: hyapp.user.v1.CreateAgencyResponse + (*AdminAddAgencyHostRequest)(nil), // 87: hyapp.user.v1.AdminAddAgencyHostRequest + (*AdminAddAgencyHostResponse)(nil), // 88: hyapp.user.v1.AdminAddAgencyHostResponse + (*CloseAgencyRequest)(nil), // 89: hyapp.user.v1.CloseAgencyRequest + (*CloseAgencyResponse)(nil), // 90: hyapp.user.v1.CloseAgencyResponse + (*DeleteAgencyRequest)(nil), // 91: hyapp.user.v1.DeleteAgencyRequest + (*DeleteAgencyResponse)(nil), // 92: hyapp.user.v1.DeleteAgencyResponse + (*SetAgencyStatusRequest)(nil), // 93: hyapp.user.v1.SetAgencyStatusRequest + (*SetAgencyStatusResponse)(nil), // 94: hyapp.user.v1.SetAgencyStatusResponse + (*SetAgencyJoinEnabledRequest)(nil), // 95: hyapp.user.v1.SetAgencyJoinEnabledRequest + (*SetAgencyJoinEnabledResponse)(nil), // 96: hyapp.user.v1.SetAgencyJoinEnabledResponse + (*UpdateAgencyProfileRequest)(nil), // 97: hyapp.user.v1.UpdateAgencyProfileRequest + (*UpdateAgencyProfileResponse)(nil), // 98: hyapp.user.v1.UpdateAgencyProfileResponse + (*HostEngagementStats)(nil), // 99: hyapp.user.v1.HostEngagementStats + (*GetHostEngagementStatsRequest)(nil), // 100: hyapp.user.v1.GetHostEngagementStatsRequest + (*GetHostEngagementStatsResponse)(nil), // 101: hyapp.user.v1.GetHostEngagementStatsResponse + (*RecordPrivateMessageEventRequest)(nil), // 102: hyapp.user.v1.RecordPrivateMessageEventRequest + (*RecordPrivateMessageEventResponse)(nil), // 103: hyapp.user.v1.RecordPrivateMessageEventResponse + nil, // 104: hyapp.user.v1.BatchGetHostProfilesResponse.HostProfilesEntry + (*RequestMeta)(nil), // 105: hyapp.user.v1.RequestMeta } var file_proto_user_v1_host_proto_depIdxs = []int32{ - 103, // 0: hyapp.user.v1.SearchAgenciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 0: hyapp.user.v1.SearchAgenciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 1: hyapp.user.v1.SearchAgenciesResponse.agencies:type_name -> hyapp.user.v1.Agency - 103, // 2: hyapp.user.v1.ApplyToAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 2: hyapp.user.v1.ApplyToAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta 9, // 3: hyapp.user.v1.ApplyToAgencyResponse.application:type_name -> hyapp.user.v1.AgencyApplication - 103, // 4: hyapp.user.v1.ReviewAgencyApplicationRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 4: hyapp.user.v1.ReviewAgencyApplicationRequest.meta:type_name -> hyapp.user.v1.RequestMeta 9, // 5: hyapp.user.v1.ReviewAgencyApplicationResponse.application:type_name -> hyapp.user.v1.AgencyApplication 0, // 6: hyapp.user.v1.ReviewAgencyApplicationResponse.host_profile:type_name -> hyapp.user.v1.HostProfile 8, // 7: hyapp.user.v1.ReviewAgencyApplicationResponse.membership:type_name -> hyapp.user.v1.AgencyMembership - 103, // 8: hyapp.user.v1.KickAgencyHostRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 8: hyapp.user.v1.KickAgencyHostRequest.meta:type_name -> hyapp.user.v1.RequestMeta 8, // 9: hyapp.user.v1.KickAgencyHostResponse.membership:type_name -> hyapp.user.v1.AgencyMembership 0, // 10: hyapp.user.v1.KickAgencyHostResponse.host_profile:type_name -> hyapp.user.v1.HostProfile - 103, // 11: hyapp.user.v1.InviteAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 11: hyapp.user.v1.InviteAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta 10, // 12: hyapp.user.v1.InviteAgencyResponse.invitation:type_name -> hyapp.user.v1.RoleInvitation - 103, // 13: hyapp.user.v1.InviteBDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 13: hyapp.user.v1.InviteBDRequest.meta:type_name -> hyapp.user.v1.RequestMeta 10, // 14: hyapp.user.v1.InviteBDResponse.invitation:type_name -> hyapp.user.v1.RoleInvitation - 103, // 15: hyapp.user.v1.InviteHostRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 15: hyapp.user.v1.InviteHostRequest.meta:type_name -> hyapp.user.v1.RequestMeta 10, // 16: hyapp.user.v1.InviteHostResponse.invitation:type_name -> hyapp.user.v1.RoleInvitation - 103, // 17: hyapp.user.v1.ListBDLeaderBDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 17: hyapp.user.v1.ListBDLeaderBDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta 2, // 18: hyapp.user.v1.ListBDLeaderBDsResponse.bd_profiles:type_name -> hyapp.user.v1.BDProfile - 103, // 19: hyapp.user.v1.ListBDLeaderAgenciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 19: hyapp.user.v1.ListBDLeaderAgenciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 20: hyapp.user.v1.ListBDLeaderAgenciesResponse.agencies:type_name -> hyapp.user.v1.Agency - 103, // 21: hyapp.user.v1.ListManagerTeamAgenciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 21: hyapp.user.v1.ListManagerTeamAgenciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta 30, // 22: hyapp.user.v1.ListManagerTeamAgenciesResponse.agencies:type_name -> hyapp.user.v1.ManagerTeamAgency - 103, // 23: hyapp.user.v1.ListBDAgenciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 23: hyapp.user.v1.ListBDAgenciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 24: hyapp.user.v1.ListBDAgenciesResponse.agencies:type_name -> hyapp.user.v1.Agency - 103, // 25: hyapp.user.v1.ListRoleInvitationsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 25: hyapp.user.v1.ListRoleInvitationsRequest.meta:type_name -> hyapp.user.v1.RequestMeta 10, // 26: hyapp.user.v1.ListRoleInvitationsResponse.invitations:type_name -> hyapp.user.v1.RoleInvitation - 103, // 27: hyapp.user.v1.GetRoleInvitationRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 27: hyapp.user.v1.GetRoleInvitationRequest.meta:type_name -> hyapp.user.v1.RequestMeta 10, // 28: hyapp.user.v1.GetRoleInvitationResponse.invitation:type_name -> hyapp.user.v1.RoleInvitation - 103, // 29: hyapp.user.v1.ProcessRoleInvitationRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 29: hyapp.user.v1.ProcessRoleInvitationRequest.meta:type_name -> hyapp.user.v1.RequestMeta 10, // 30: hyapp.user.v1.ProcessRoleInvitationResponse.invitation:type_name -> hyapp.user.v1.RoleInvitation 0, // 31: hyapp.user.v1.ProcessRoleInvitationResponse.host_profile:type_name -> hyapp.user.v1.HostProfile 1, // 32: hyapp.user.v1.ProcessRoleInvitationResponse.agency:type_name -> hyapp.user.v1.Agency 8, // 33: hyapp.user.v1.ProcessRoleInvitationResponse.membership:type_name -> hyapp.user.v1.AgencyMembership 2, // 34: hyapp.user.v1.ProcessRoleInvitationResponse.bd_profile:type_name -> hyapp.user.v1.BDProfile - 103, // 35: hyapp.user.v1.GetHostProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 35: hyapp.user.v1.GetHostProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta 0, // 36: hyapp.user.v1.GetHostProfileResponse.host_profile:type_name -> hyapp.user.v1.HostProfile - 103, // 37: hyapp.user.v1.BatchGetHostProfilesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 102, // 38: hyapp.user.v1.BatchGetHostProfilesResponse.host_profiles:type_name -> hyapp.user.v1.BatchGetHostProfilesResponse.HostProfilesEntry - 103, // 39: hyapp.user.v1.GetBDProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 37: hyapp.user.v1.BatchGetHostProfilesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 104, // 38: hyapp.user.v1.BatchGetHostProfilesResponse.host_profiles:type_name -> hyapp.user.v1.BatchGetHostProfilesResponse.HostProfilesEntry + 105, // 39: hyapp.user.v1.GetBDProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta 2, // 40: hyapp.user.v1.GetBDProfileResponse.bd_profile:type_name -> hyapp.user.v1.BDProfile - 103, // 41: hyapp.user.v1.GetCoinSellerProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 41: hyapp.user.v1.GetCoinSellerProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta 3, // 42: hyapp.user.v1.GetCoinSellerProfileResponse.coin_seller_profile:type_name -> hyapp.user.v1.CoinSellerProfile - 103, // 43: hyapp.user.v1.ListActiveCoinSellersInMyRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 43: hyapp.user.v1.ListActiveCoinSellersInMyRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta 4, // 44: hyapp.user.v1.ListActiveCoinSellersInMyRegionResponse.coin_sellers:type_name -> hyapp.user.v1.CoinSellerListItem - 103, // 45: hyapp.user.v1.CreateSubCoinSellerRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 45: hyapp.user.v1.CreateSubCoinSellerRequest.meta:type_name -> hyapp.user.v1.RequestMeta 5, // 46: hyapp.user.v1.CreateSubCoinSellerResponse.relation:type_name -> hyapp.user.v1.CoinSellerSubRelation 4, // 47: hyapp.user.v1.CreateSubCoinSellerResponse.child:type_name -> hyapp.user.v1.CoinSellerListItem - 103, // 48: hyapp.user.v1.SubmitSubCoinSellerApplicationRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 48: hyapp.user.v1.SubmitSubCoinSellerApplicationRequest.meta:type_name -> hyapp.user.v1.RequestMeta 6, // 49: hyapp.user.v1.SubmitSubCoinSellerApplicationResponse.application:type_name -> hyapp.user.v1.CoinSellerSubApplication - 103, // 50: hyapp.user.v1.ListSubCoinSellersRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 50: hyapp.user.v1.ListSubCoinSellersRequest.meta:type_name -> hyapp.user.v1.RequestMeta 4, // 51: hyapp.user.v1.ListSubCoinSellersResponse.children:type_name -> hyapp.user.v1.CoinSellerListItem - 103, // 52: hyapp.user.v1.CheckCoinSellerSubRelationRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 52: hyapp.user.v1.CheckCoinSellerSubRelationRequest.meta:type_name -> hyapp.user.v1.RequestMeta 4, // 53: hyapp.user.v1.CheckCoinSellerSubRelationResponse.child:type_name -> hyapp.user.v1.CoinSellerListItem - 103, // 54: hyapp.user.v1.GetUserRoleSummaryRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 54: hyapp.user.v1.GetUserRoleSummaryRequest.meta:type_name -> hyapp.user.v1.RequestMeta 7, // 55: hyapp.user.v1.GetUserRoleSummaryResponse.summary:type_name -> hyapp.user.v1.UserRoleSummary - 103, // 56: hyapp.user.v1.GetAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 56: hyapp.user.v1.GetAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 57: hyapp.user.v1.GetAgencyResponse.agency:type_name -> hyapp.user.v1.Agency - 103, // 58: hyapp.user.v1.CheckBusinessCapabilityRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 103, // 59: hyapp.user.v1.GetRoleScopePolicyRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 58: hyapp.user.v1.CheckBusinessCapabilityRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 105, // 59: hyapp.user.v1.GetRoleScopePolicyRequest.meta:type_name -> hyapp.user.v1.RequestMeta 64, // 60: hyapp.user.v1.GetRoleScopePolicyResponse.policy:type_name -> hyapp.user.v1.RoleScopePolicy - 103, // 61: hyapp.user.v1.GetAgencyMembersRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 8, // 62: hyapp.user.v1.GetAgencyMembersResponse.memberships:type_name -> hyapp.user.v1.AgencyMembership - 103, // 63: hyapp.user.v1.GetAgencyApplicationsRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 9, // 64: hyapp.user.v1.GetAgencyApplicationsResponse.applications:type_name -> hyapp.user.v1.AgencyApplication - 103, // 65: hyapp.user.v1.CreateBDLeaderRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 2, // 66: hyapp.user.v1.CreateBDLeaderResponse.bd_profile:type_name -> hyapp.user.v1.BDProfile - 103, // 67: hyapp.user.v1.CreateBDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 2, // 68: hyapp.user.v1.CreateBDResponse.bd_profile:type_name -> hyapp.user.v1.BDProfile - 103, // 69: hyapp.user.v1.SetBDStatusRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 2, // 70: hyapp.user.v1.SetBDStatusResponse.bd_profile:type_name -> hyapp.user.v1.BDProfile - 103, // 71: hyapp.user.v1.CreateCoinSellerRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 3, // 72: hyapp.user.v1.CreateCoinSellerResponse.coin_seller_profile:type_name -> hyapp.user.v1.CoinSellerProfile - 103, // 73: hyapp.user.v1.SetCoinSellerStatusRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 3, // 74: hyapp.user.v1.SetCoinSellerStatusResponse.coin_seller_profile:type_name -> hyapp.user.v1.CoinSellerProfile - 103, // 75: hyapp.user.v1.ReviewSubCoinSellerApplicationRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 6, // 76: hyapp.user.v1.ReviewSubCoinSellerApplicationResponse.application:type_name -> hyapp.user.v1.CoinSellerSubApplication - 5, // 77: hyapp.user.v1.ReviewSubCoinSellerApplicationResponse.relation:type_name -> hyapp.user.v1.CoinSellerSubRelation - 4, // 78: hyapp.user.v1.ReviewSubCoinSellerApplicationResponse.child:type_name -> hyapp.user.v1.CoinSellerListItem - 103, // 79: hyapp.user.v1.CreateAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 80: hyapp.user.v1.CreateAgencyResponse.agency:type_name -> hyapp.user.v1.Agency - 0, // 81: hyapp.user.v1.CreateAgencyResponse.host_profile:type_name -> hyapp.user.v1.HostProfile - 8, // 82: hyapp.user.v1.CreateAgencyResponse.membership:type_name -> hyapp.user.v1.AgencyMembership - 103, // 83: hyapp.user.v1.AdminAddAgencyHostRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 84: hyapp.user.v1.AdminAddAgencyHostResponse.agency:type_name -> hyapp.user.v1.Agency - 0, // 85: hyapp.user.v1.AdminAddAgencyHostResponse.host_profile:type_name -> hyapp.user.v1.HostProfile - 8, // 86: hyapp.user.v1.AdminAddAgencyHostResponse.membership:type_name -> hyapp.user.v1.AgencyMembership - 103, // 87: hyapp.user.v1.CloseAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 88: hyapp.user.v1.CloseAgencyResponse.agency:type_name -> hyapp.user.v1.Agency - 103, // 89: hyapp.user.v1.DeleteAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 90: hyapp.user.v1.DeleteAgencyResponse.agency:type_name -> hyapp.user.v1.Agency - 103, // 91: hyapp.user.v1.SetAgencyStatusRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 92: hyapp.user.v1.SetAgencyStatusResponse.agency:type_name -> hyapp.user.v1.Agency - 103, // 93: hyapp.user.v1.SetAgencyJoinEnabledRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 94: hyapp.user.v1.SetAgencyJoinEnabledResponse.agency:type_name -> hyapp.user.v1.Agency - 103, // 95: hyapp.user.v1.UpdateAgencyProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 96: hyapp.user.v1.UpdateAgencyProfileResponse.agency:type_name -> hyapp.user.v1.Agency - 103, // 97: hyapp.user.v1.GetHostEngagementStatsRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 97, // 98: hyapp.user.v1.GetHostEngagementStatsResponse.stats:type_name -> hyapp.user.v1.HostEngagementStats - 103, // 99: hyapp.user.v1.RecordPrivateMessageEventRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 0, // 100: hyapp.user.v1.BatchGetHostProfilesResponse.HostProfilesEntry.value:type_name -> hyapp.user.v1.HostProfile - 11, // 101: hyapp.user.v1.UserHostService.SearchAgencies:input_type -> hyapp.user.v1.SearchAgenciesRequest - 13, // 102: hyapp.user.v1.UserHostService.ApplyToAgency:input_type -> hyapp.user.v1.ApplyToAgencyRequest - 15, // 103: hyapp.user.v1.UserHostService.ReviewAgencyApplication:input_type -> hyapp.user.v1.ReviewAgencyApplicationRequest - 17, // 104: hyapp.user.v1.UserHostService.KickAgencyHost:input_type -> hyapp.user.v1.KickAgencyHostRequest - 19, // 105: hyapp.user.v1.UserHostService.InviteAgency:input_type -> hyapp.user.v1.InviteAgencyRequest - 21, // 106: hyapp.user.v1.UserHostService.InviteBD:input_type -> hyapp.user.v1.InviteBDRequest - 23, // 107: hyapp.user.v1.UserHostService.InviteHost:input_type -> hyapp.user.v1.InviteHostRequest - 25, // 108: hyapp.user.v1.UserHostService.ListBDLeaderBDs:input_type -> hyapp.user.v1.ListBDLeaderBDsRequest - 27, // 109: hyapp.user.v1.UserHostService.ListBDLeaderAgencies:input_type -> hyapp.user.v1.ListBDLeaderAgenciesRequest - 32, // 110: hyapp.user.v1.UserHostService.ListBDAgencies:input_type -> hyapp.user.v1.ListBDAgenciesRequest - 29, // 111: hyapp.user.v1.UserHostService.ListManagerTeamAgencies:input_type -> hyapp.user.v1.ListManagerTeamAgenciesRequest - 34, // 112: hyapp.user.v1.UserHostService.ListRoleInvitations:input_type -> hyapp.user.v1.ListRoleInvitationsRequest - 36, // 113: hyapp.user.v1.UserHostService.GetRoleInvitation:input_type -> hyapp.user.v1.GetRoleInvitationRequest - 38, // 114: hyapp.user.v1.UserHostService.ProcessRoleInvitation:input_type -> hyapp.user.v1.ProcessRoleInvitationRequest - 40, // 115: hyapp.user.v1.UserHostService.GetHostProfile:input_type -> hyapp.user.v1.GetHostProfileRequest - 42, // 116: hyapp.user.v1.UserHostService.BatchGetHostProfiles:input_type -> hyapp.user.v1.BatchGetHostProfilesRequest - 44, // 117: hyapp.user.v1.UserHostService.GetBDProfile:input_type -> hyapp.user.v1.GetBDProfileRequest - 46, // 118: hyapp.user.v1.UserHostService.GetCoinSellerProfile:input_type -> hyapp.user.v1.GetCoinSellerProfileRequest - 48, // 119: hyapp.user.v1.UserHostService.ListActiveCoinSellersInMyRegion:input_type -> hyapp.user.v1.ListActiveCoinSellersInMyRegionRequest - 50, // 120: hyapp.user.v1.UserHostService.CreateSubCoinSeller:input_type -> hyapp.user.v1.CreateSubCoinSellerRequest - 52, // 121: hyapp.user.v1.UserHostService.SubmitSubCoinSellerApplication:input_type -> hyapp.user.v1.SubmitSubCoinSellerApplicationRequest - 54, // 122: hyapp.user.v1.UserHostService.ListSubCoinSellers:input_type -> hyapp.user.v1.ListSubCoinSellersRequest - 56, // 123: hyapp.user.v1.UserHostService.CheckCoinSellerSubRelation:input_type -> hyapp.user.v1.CheckCoinSellerSubRelationRequest - 58, // 124: hyapp.user.v1.UserHostService.GetUserRoleSummary:input_type -> hyapp.user.v1.GetUserRoleSummaryRequest - 60, // 125: hyapp.user.v1.UserHostService.GetAgency:input_type -> hyapp.user.v1.GetAgencyRequest - 62, // 126: hyapp.user.v1.UserHostService.CheckBusinessCapability:input_type -> hyapp.user.v1.CheckBusinessCapabilityRequest - 65, // 127: hyapp.user.v1.UserHostService.GetRoleScopePolicy:input_type -> hyapp.user.v1.GetRoleScopePolicyRequest - 67, // 128: hyapp.user.v1.UserHostService.GetAgencyMembers:input_type -> hyapp.user.v1.GetAgencyMembersRequest - 69, // 129: hyapp.user.v1.UserHostService.GetAgencyApplications:input_type -> hyapp.user.v1.GetAgencyApplicationsRequest - 95, // 130: hyapp.user.v1.UserHostService.UpdateAgencyProfile:input_type -> hyapp.user.v1.UpdateAgencyProfileRequest - 98, // 131: hyapp.user.v1.UserHostService.GetHostEngagementStats:input_type -> hyapp.user.v1.GetHostEngagementStatsRequest - 100, // 132: hyapp.user.v1.UserHostService.RecordPrivateMessageEvent:input_type -> hyapp.user.v1.RecordPrivateMessageEventRequest - 71, // 133: hyapp.user.v1.UserHostAdminService.CreateBDLeader:input_type -> hyapp.user.v1.CreateBDLeaderRequest - 73, // 134: hyapp.user.v1.UserHostAdminService.CreateBD:input_type -> hyapp.user.v1.CreateBDRequest - 75, // 135: hyapp.user.v1.UserHostAdminService.SetBDStatus:input_type -> hyapp.user.v1.SetBDStatusRequest - 77, // 136: hyapp.user.v1.UserHostAdminService.CreateCoinSeller:input_type -> hyapp.user.v1.CreateCoinSellerRequest - 79, // 137: hyapp.user.v1.UserHostAdminService.SetCoinSellerStatus:input_type -> hyapp.user.v1.SetCoinSellerStatusRequest - 81, // 138: hyapp.user.v1.UserHostAdminService.ReviewSubCoinSellerApplication:input_type -> hyapp.user.v1.ReviewSubCoinSellerApplicationRequest - 83, // 139: hyapp.user.v1.UserHostAdminService.CreateAgency:input_type -> hyapp.user.v1.CreateAgencyRequest - 85, // 140: hyapp.user.v1.UserHostAdminService.AdminAddAgencyHost:input_type -> hyapp.user.v1.AdminAddAgencyHostRequest - 87, // 141: hyapp.user.v1.UserHostAdminService.CloseAgency:input_type -> hyapp.user.v1.CloseAgencyRequest - 89, // 142: hyapp.user.v1.UserHostAdminService.DeleteAgency:input_type -> hyapp.user.v1.DeleteAgencyRequest - 91, // 143: hyapp.user.v1.UserHostAdminService.SetAgencyStatus:input_type -> hyapp.user.v1.SetAgencyStatusRequest - 93, // 144: hyapp.user.v1.UserHostAdminService.SetAgencyJoinEnabled:input_type -> hyapp.user.v1.SetAgencyJoinEnabledRequest - 12, // 145: hyapp.user.v1.UserHostService.SearchAgencies:output_type -> hyapp.user.v1.SearchAgenciesResponse - 14, // 146: hyapp.user.v1.UserHostService.ApplyToAgency:output_type -> hyapp.user.v1.ApplyToAgencyResponse - 16, // 147: hyapp.user.v1.UserHostService.ReviewAgencyApplication:output_type -> hyapp.user.v1.ReviewAgencyApplicationResponse - 18, // 148: hyapp.user.v1.UserHostService.KickAgencyHost:output_type -> hyapp.user.v1.KickAgencyHostResponse - 20, // 149: hyapp.user.v1.UserHostService.InviteAgency:output_type -> hyapp.user.v1.InviteAgencyResponse - 22, // 150: hyapp.user.v1.UserHostService.InviteBD:output_type -> hyapp.user.v1.InviteBDResponse - 24, // 151: hyapp.user.v1.UserHostService.InviteHost:output_type -> hyapp.user.v1.InviteHostResponse - 26, // 152: hyapp.user.v1.UserHostService.ListBDLeaderBDs:output_type -> hyapp.user.v1.ListBDLeaderBDsResponse - 28, // 153: hyapp.user.v1.UserHostService.ListBDLeaderAgencies:output_type -> hyapp.user.v1.ListBDLeaderAgenciesResponse - 33, // 154: hyapp.user.v1.UserHostService.ListBDAgencies:output_type -> hyapp.user.v1.ListBDAgenciesResponse - 31, // 155: hyapp.user.v1.UserHostService.ListManagerTeamAgencies:output_type -> hyapp.user.v1.ListManagerTeamAgenciesResponse - 35, // 156: hyapp.user.v1.UserHostService.ListRoleInvitations:output_type -> hyapp.user.v1.ListRoleInvitationsResponse - 37, // 157: hyapp.user.v1.UserHostService.GetRoleInvitation:output_type -> hyapp.user.v1.GetRoleInvitationResponse - 39, // 158: hyapp.user.v1.UserHostService.ProcessRoleInvitation:output_type -> hyapp.user.v1.ProcessRoleInvitationResponse - 41, // 159: hyapp.user.v1.UserHostService.GetHostProfile:output_type -> hyapp.user.v1.GetHostProfileResponse - 43, // 160: hyapp.user.v1.UserHostService.BatchGetHostProfiles:output_type -> hyapp.user.v1.BatchGetHostProfilesResponse - 45, // 161: hyapp.user.v1.UserHostService.GetBDProfile:output_type -> hyapp.user.v1.GetBDProfileResponse - 47, // 162: hyapp.user.v1.UserHostService.GetCoinSellerProfile:output_type -> hyapp.user.v1.GetCoinSellerProfileResponse - 49, // 163: hyapp.user.v1.UserHostService.ListActiveCoinSellersInMyRegion:output_type -> hyapp.user.v1.ListActiveCoinSellersInMyRegionResponse - 51, // 164: hyapp.user.v1.UserHostService.CreateSubCoinSeller:output_type -> hyapp.user.v1.CreateSubCoinSellerResponse - 53, // 165: hyapp.user.v1.UserHostService.SubmitSubCoinSellerApplication:output_type -> hyapp.user.v1.SubmitSubCoinSellerApplicationResponse - 55, // 166: hyapp.user.v1.UserHostService.ListSubCoinSellers:output_type -> hyapp.user.v1.ListSubCoinSellersResponse - 57, // 167: hyapp.user.v1.UserHostService.CheckCoinSellerSubRelation:output_type -> hyapp.user.v1.CheckCoinSellerSubRelationResponse - 59, // 168: hyapp.user.v1.UserHostService.GetUserRoleSummary:output_type -> hyapp.user.v1.GetUserRoleSummaryResponse - 61, // 169: hyapp.user.v1.UserHostService.GetAgency:output_type -> hyapp.user.v1.GetAgencyResponse - 63, // 170: hyapp.user.v1.UserHostService.CheckBusinessCapability:output_type -> hyapp.user.v1.CheckBusinessCapabilityResponse - 66, // 171: hyapp.user.v1.UserHostService.GetRoleScopePolicy:output_type -> hyapp.user.v1.GetRoleScopePolicyResponse - 68, // 172: hyapp.user.v1.UserHostService.GetAgencyMembers:output_type -> hyapp.user.v1.GetAgencyMembersResponse - 70, // 173: hyapp.user.v1.UserHostService.GetAgencyApplications:output_type -> hyapp.user.v1.GetAgencyApplicationsResponse - 96, // 174: hyapp.user.v1.UserHostService.UpdateAgencyProfile:output_type -> hyapp.user.v1.UpdateAgencyProfileResponse - 99, // 175: hyapp.user.v1.UserHostService.GetHostEngagementStats:output_type -> hyapp.user.v1.GetHostEngagementStatsResponse - 101, // 176: hyapp.user.v1.UserHostService.RecordPrivateMessageEvent:output_type -> hyapp.user.v1.RecordPrivateMessageEventResponse - 72, // 177: hyapp.user.v1.UserHostAdminService.CreateBDLeader:output_type -> hyapp.user.v1.CreateBDLeaderResponse - 74, // 178: hyapp.user.v1.UserHostAdminService.CreateBD:output_type -> hyapp.user.v1.CreateBDResponse - 76, // 179: hyapp.user.v1.UserHostAdminService.SetBDStatus:output_type -> hyapp.user.v1.SetBDStatusResponse - 78, // 180: hyapp.user.v1.UserHostAdminService.CreateCoinSeller:output_type -> hyapp.user.v1.CreateCoinSellerResponse - 80, // 181: hyapp.user.v1.UserHostAdminService.SetCoinSellerStatus:output_type -> hyapp.user.v1.SetCoinSellerStatusResponse - 82, // 182: hyapp.user.v1.UserHostAdminService.ReviewSubCoinSellerApplication:output_type -> hyapp.user.v1.ReviewSubCoinSellerApplicationResponse - 84, // 183: hyapp.user.v1.UserHostAdminService.CreateAgency:output_type -> hyapp.user.v1.CreateAgencyResponse - 86, // 184: hyapp.user.v1.UserHostAdminService.AdminAddAgencyHost:output_type -> hyapp.user.v1.AdminAddAgencyHostResponse - 88, // 185: hyapp.user.v1.UserHostAdminService.CloseAgency:output_type -> hyapp.user.v1.CloseAgencyResponse - 90, // 186: hyapp.user.v1.UserHostAdminService.DeleteAgency:output_type -> hyapp.user.v1.DeleteAgencyResponse - 92, // 187: hyapp.user.v1.UserHostAdminService.SetAgencyStatus:output_type -> hyapp.user.v1.SetAgencyStatusResponse - 94, // 188: hyapp.user.v1.UserHostAdminService.SetAgencyJoinEnabled:output_type -> hyapp.user.v1.SetAgencyJoinEnabledResponse - 145, // [145:189] is the sub-list for method output_type - 101, // [101:145] is the sub-list for method input_type - 101, // [101:101] is the sub-list for extension type_name - 101, // [101:101] is the sub-list for extension extendee - 0, // [0:101] is the sub-list for field type_name + 105, // 61: hyapp.user.v1.UpdateRoleScopePoliciesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 64, // 62: hyapp.user.v1.UpdateRoleScopePoliciesRequest.policies:type_name -> hyapp.user.v1.RoleScopePolicy + 64, // 63: hyapp.user.v1.UpdateRoleScopePoliciesResponse.policies:type_name -> hyapp.user.v1.RoleScopePolicy + 105, // 64: hyapp.user.v1.GetAgencyMembersRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 8, // 65: hyapp.user.v1.GetAgencyMembersResponse.memberships:type_name -> hyapp.user.v1.AgencyMembership + 105, // 66: hyapp.user.v1.GetAgencyApplicationsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 9, // 67: hyapp.user.v1.GetAgencyApplicationsResponse.applications:type_name -> hyapp.user.v1.AgencyApplication + 105, // 68: hyapp.user.v1.CreateBDLeaderRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 2, // 69: hyapp.user.v1.CreateBDLeaderResponse.bd_profile:type_name -> hyapp.user.v1.BDProfile + 105, // 70: hyapp.user.v1.CreateBDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 2, // 71: hyapp.user.v1.CreateBDResponse.bd_profile:type_name -> hyapp.user.v1.BDProfile + 105, // 72: hyapp.user.v1.SetBDStatusRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 2, // 73: hyapp.user.v1.SetBDStatusResponse.bd_profile:type_name -> hyapp.user.v1.BDProfile + 105, // 74: hyapp.user.v1.CreateCoinSellerRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 3, // 75: hyapp.user.v1.CreateCoinSellerResponse.coin_seller_profile:type_name -> hyapp.user.v1.CoinSellerProfile + 105, // 76: hyapp.user.v1.SetCoinSellerStatusRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 3, // 77: hyapp.user.v1.SetCoinSellerStatusResponse.coin_seller_profile:type_name -> hyapp.user.v1.CoinSellerProfile + 105, // 78: hyapp.user.v1.ReviewSubCoinSellerApplicationRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 6, // 79: hyapp.user.v1.ReviewSubCoinSellerApplicationResponse.application:type_name -> hyapp.user.v1.CoinSellerSubApplication + 5, // 80: hyapp.user.v1.ReviewSubCoinSellerApplicationResponse.relation:type_name -> hyapp.user.v1.CoinSellerSubRelation + 4, // 81: hyapp.user.v1.ReviewSubCoinSellerApplicationResponse.child:type_name -> hyapp.user.v1.CoinSellerListItem + 105, // 82: hyapp.user.v1.CreateAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 83: hyapp.user.v1.CreateAgencyResponse.agency:type_name -> hyapp.user.v1.Agency + 0, // 84: hyapp.user.v1.CreateAgencyResponse.host_profile:type_name -> hyapp.user.v1.HostProfile + 8, // 85: hyapp.user.v1.CreateAgencyResponse.membership:type_name -> hyapp.user.v1.AgencyMembership + 105, // 86: hyapp.user.v1.AdminAddAgencyHostRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 87: hyapp.user.v1.AdminAddAgencyHostResponse.agency:type_name -> hyapp.user.v1.Agency + 0, // 88: hyapp.user.v1.AdminAddAgencyHostResponse.host_profile:type_name -> hyapp.user.v1.HostProfile + 8, // 89: hyapp.user.v1.AdminAddAgencyHostResponse.membership:type_name -> hyapp.user.v1.AgencyMembership + 105, // 90: hyapp.user.v1.CloseAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 91: hyapp.user.v1.CloseAgencyResponse.agency:type_name -> hyapp.user.v1.Agency + 105, // 92: hyapp.user.v1.DeleteAgencyRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 93: hyapp.user.v1.DeleteAgencyResponse.agency:type_name -> hyapp.user.v1.Agency + 105, // 94: hyapp.user.v1.SetAgencyStatusRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 95: hyapp.user.v1.SetAgencyStatusResponse.agency:type_name -> hyapp.user.v1.Agency + 105, // 96: hyapp.user.v1.SetAgencyJoinEnabledRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 97: hyapp.user.v1.SetAgencyJoinEnabledResponse.agency:type_name -> hyapp.user.v1.Agency + 105, // 98: hyapp.user.v1.UpdateAgencyProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 99: hyapp.user.v1.UpdateAgencyProfileResponse.agency:type_name -> hyapp.user.v1.Agency + 105, // 100: hyapp.user.v1.GetHostEngagementStatsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 99, // 101: hyapp.user.v1.GetHostEngagementStatsResponse.stats:type_name -> hyapp.user.v1.HostEngagementStats + 105, // 102: hyapp.user.v1.RecordPrivateMessageEventRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 0, // 103: hyapp.user.v1.BatchGetHostProfilesResponse.HostProfilesEntry.value:type_name -> hyapp.user.v1.HostProfile + 11, // 104: hyapp.user.v1.UserHostService.SearchAgencies:input_type -> hyapp.user.v1.SearchAgenciesRequest + 13, // 105: hyapp.user.v1.UserHostService.ApplyToAgency:input_type -> hyapp.user.v1.ApplyToAgencyRequest + 15, // 106: hyapp.user.v1.UserHostService.ReviewAgencyApplication:input_type -> hyapp.user.v1.ReviewAgencyApplicationRequest + 17, // 107: hyapp.user.v1.UserHostService.KickAgencyHost:input_type -> hyapp.user.v1.KickAgencyHostRequest + 19, // 108: hyapp.user.v1.UserHostService.InviteAgency:input_type -> hyapp.user.v1.InviteAgencyRequest + 21, // 109: hyapp.user.v1.UserHostService.InviteBD:input_type -> hyapp.user.v1.InviteBDRequest + 23, // 110: hyapp.user.v1.UserHostService.InviteHost:input_type -> hyapp.user.v1.InviteHostRequest + 25, // 111: hyapp.user.v1.UserHostService.ListBDLeaderBDs:input_type -> hyapp.user.v1.ListBDLeaderBDsRequest + 27, // 112: hyapp.user.v1.UserHostService.ListBDLeaderAgencies:input_type -> hyapp.user.v1.ListBDLeaderAgenciesRequest + 32, // 113: hyapp.user.v1.UserHostService.ListBDAgencies:input_type -> hyapp.user.v1.ListBDAgenciesRequest + 29, // 114: hyapp.user.v1.UserHostService.ListManagerTeamAgencies:input_type -> hyapp.user.v1.ListManagerTeamAgenciesRequest + 34, // 115: hyapp.user.v1.UserHostService.ListRoleInvitations:input_type -> hyapp.user.v1.ListRoleInvitationsRequest + 36, // 116: hyapp.user.v1.UserHostService.GetRoleInvitation:input_type -> hyapp.user.v1.GetRoleInvitationRequest + 38, // 117: hyapp.user.v1.UserHostService.ProcessRoleInvitation:input_type -> hyapp.user.v1.ProcessRoleInvitationRequest + 40, // 118: hyapp.user.v1.UserHostService.GetHostProfile:input_type -> hyapp.user.v1.GetHostProfileRequest + 42, // 119: hyapp.user.v1.UserHostService.BatchGetHostProfiles:input_type -> hyapp.user.v1.BatchGetHostProfilesRequest + 44, // 120: hyapp.user.v1.UserHostService.GetBDProfile:input_type -> hyapp.user.v1.GetBDProfileRequest + 46, // 121: hyapp.user.v1.UserHostService.GetCoinSellerProfile:input_type -> hyapp.user.v1.GetCoinSellerProfileRequest + 48, // 122: hyapp.user.v1.UserHostService.ListActiveCoinSellersInMyRegion:input_type -> hyapp.user.v1.ListActiveCoinSellersInMyRegionRequest + 50, // 123: hyapp.user.v1.UserHostService.CreateSubCoinSeller:input_type -> hyapp.user.v1.CreateSubCoinSellerRequest + 52, // 124: hyapp.user.v1.UserHostService.SubmitSubCoinSellerApplication:input_type -> hyapp.user.v1.SubmitSubCoinSellerApplicationRequest + 54, // 125: hyapp.user.v1.UserHostService.ListSubCoinSellers:input_type -> hyapp.user.v1.ListSubCoinSellersRequest + 56, // 126: hyapp.user.v1.UserHostService.CheckCoinSellerSubRelation:input_type -> hyapp.user.v1.CheckCoinSellerSubRelationRequest + 58, // 127: hyapp.user.v1.UserHostService.GetUserRoleSummary:input_type -> hyapp.user.v1.GetUserRoleSummaryRequest + 60, // 128: hyapp.user.v1.UserHostService.GetAgency:input_type -> hyapp.user.v1.GetAgencyRequest + 62, // 129: hyapp.user.v1.UserHostService.CheckBusinessCapability:input_type -> hyapp.user.v1.CheckBusinessCapabilityRequest + 65, // 130: hyapp.user.v1.UserHostService.GetRoleScopePolicy:input_type -> hyapp.user.v1.GetRoleScopePolicyRequest + 69, // 131: hyapp.user.v1.UserHostService.GetAgencyMembers:input_type -> hyapp.user.v1.GetAgencyMembersRequest + 71, // 132: hyapp.user.v1.UserHostService.GetAgencyApplications:input_type -> hyapp.user.v1.GetAgencyApplicationsRequest + 97, // 133: hyapp.user.v1.UserHostService.UpdateAgencyProfile:input_type -> hyapp.user.v1.UpdateAgencyProfileRequest + 100, // 134: hyapp.user.v1.UserHostService.GetHostEngagementStats:input_type -> hyapp.user.v1.GetHostEngagementStatsRequest + 102, // 135: hyapp.user.v1.UserHostService.RecordPrivateMessageEvent:input_type -> hyapp.user.v1.RecordPrivateMessageEventRequest + 67, // 136: hyapp.user.v1.UserHostAdminService.UpdateRoleScopePolicies:input_type -> hyapp.user.v1.UpdateRoleScopePoliciesRequest + 73, // 137: hyapp.user.v1.UserHostAdminService.CreateBDLeader:input_type -> hyapp.user.v1.CreateBDLeaderRequest + 75, // 138: hyapp.user.v1.UserHostAdminService.CreateBD:input_type -> hyapp.user.v1.CreateBDRequest + 77, // 139: hyapp.user.v1.UserHostAdminService.SetBDStatus:input_type -> hyapp.user.v1.SetBDStatusRequest + 79, // 140: hyapp.user.v1.UserHostAdminService.CreateCoinSeller:input_type -> hyapp.user.v1.CreateCoinSellerRequest + 81, // 141: hyapp.user.v1.UserHostAdminService.SetCoinSellerStatus:input_type -> hyapp.user.v1.SetCoinSellerStatusRequest + 83, // 142: hyapp.user.v1.UserHostAdminService.ReviewSubCoinSellerApplication:input_type -> hyapp.user.v1.ReviewSubCoinSellerApplicationRequest + 85, // 143: hyapp.user.v1.UserHostAdminService.CreateAgency:input_type -> hyapp.user.v1.CreateAgencyRequest + 87, // 144: hyapp.user.v1.UserHostAdminService.AdminAddAgencyHost:input_type -> hyapp.user.v1.AdminAddAgencyHostRequest + 89, // 145: hyapp.user.v1.UserHostAdminService.CloseAgency:input_type -> hyapp.user.v1.CloseAgencyRequest + 91, // 146: hyapp.user.v1.UserHostAdminService.DeleteAgency:input_type -> hyapp.user.v1.DeleteAgencyRequest + 93, // 147: hyapp.user.v1.UserHostAdminService.SetAgencyStatus:input_type -> hyapp.user.v1.SetAgencyStatusRequest + 95, // 148: hyapp.user.v1.UserHostAdminService.SetAgencyJoinEnabled:input_type -> hyapp.user.v1.SetAgencyJoinEnabledRequest + 12, // 149: hyapp.user.v1.UserHostService.SearchAgencies:output_type -> hyapp.user.v1.SearchAgenciesResponse + 14, // 150: hyapp.user.v1.UserHostService.ApplyToAgency:output_type -> hyapp.user.v1.ApplyToAgencyResponse + 16, // 151: hyapp.user.v1.UserHostService.ReviewAgencyApplication:output_type -> hyapp.user.v1.ReviewAgencyApplicationResponse + 18, // 152: hyapp.user.v1.UserHostService.KickAgencyHost:output_type -> hyapp.user.v1.KickAgencyHostResponse + 20, // 153: hyapp.user.v1.UserHostService.InviteAgency:output_type -> hyapp.user.v1.InviteAgencyResponse + 22, // 154: hyapp.user.v1.UserHostService.InviteBD:output_type -> hyapp.user.v1.InviteBDResponse + 24, // 155: hyapp.user.v1.UserHostService.InviteHost:output_type -> hyapp.user.v1.InviteHostResponse + 26, // 156: hyapp.user.v1.UserHostService.ListBDLeaderBDs:output_type -> hyapp.user.v1.ListBDLeaderBDsResponse + 28, // 157: hyapp.user.v1.UserHostService.ListBDLeaderAgencies:output_type -> hyapp.user.v1.ListBDLeaderAgenciesResponse + 33, // 158: hyapp.user.v1.UserHostService.ListBDAgencies:output_type -> hyapp.user.v1.ListBDAgenciesResponse + 31, // 159: hyapp.user.v1.UserHostService.ListManagerTeamAgencies:output_type -> hyapp.user.v1.ListManagerTeamAgenciesResponse + 35, // 160: hyapp.user.v1.UserHostService.ListRoleInvitations:output_type -> hyapp.user.v1.ListRoleInvitationsResponse + 37, // 161: hyapp.user.v1.UserHostService.GetRoleInvitation:output_type -> hyapp.user.v1.GetRoleInvitationResponse + 39, // 162: hyapp.user.v1.UserHostService.ProcessRoleInvitation:output_type -> hyapp.user.v1.ProcessRoleInvitationResponse + 41, // 163: hyapp.user.v1.UserHostService.GetHostProfile:output_type -> hyapp.user.v1.GetHostProfileResponse + 43, // 164: hyapp.user.v1.UserHostService.BatchGetHostProfiles:output_type -> hyapp.user.v1.BatchGetHostProfilesResponse + 45, // 165: hyapp.user.v1.UserHostService.GetBDProfile:output_type -> hyapp.user.v1.GetBDProfileResponse + 47, // 166: hyapp.user.v1.UserHostService.GetCoinSellerProfile:output_type -> hyapp.user.v1.GetCoinSellerProfileResponse + 49, // 167: hyapp.user.v1.UserHostService.ListActiveCoinSellersInMyRegion:output_type -> hyapp.user.v1.ListActiveCoinSellersInMyRegionResponse + 51, // 168: hyapp.user.v1.UserHostService.CreateSubCoinSeller:output_type -> hyapp.user.v1.CreateSubCoinSellerResponse + 53, // 169: hyapp.user.v1.UserHostService.SubmitSubCoinSellerApplication:output_type -> hyapp.user.v1.SubmitSubCoinSellerApplicationResponse + 55, // 170: hyapp.user.v1.UserHostService.ListSubCoinSellers:output_type -> hyapp.user.v1.ListSubCoinSellersResponse + 57, // 171: hyapp.user.v1.UserHostService.CheckCoinSellerSubRelation:output_type -> hyapp.user.v1.CheckCoinSellerSubRelationResponse + 59, // 172: hyapp.user.v1.UserHostService.GetUserRoleSummary:output_type -> hyapp.user.v1.GetUserRoleSummaryResponse + 61, // 173: hyapp.user.v1.UserHostService.GetAgency:output_type -> hyapp.user.v1.GetAgencyResponse + 63, // 174: hyapp.user.v1.UserHostService.CheckBusinessCapability:output_type -> hyapp.user.v1.CheckBusinessCapabilityResponse + 66, // 175: hyapp.user.v1.UserHostService.GetRoleScopePolicy:output_type -> hyapp.user.v1.GetRoleScopePolicyResponse + 70, // 176: hyapp.user.v1.UserHostService.GetAgencyMembers:output_type -> hyapp.user.v1.GetAgencyMembersResponse + 72, // 177: hyapp.user.v1.UserHostService.GetAgencyApplications:output_type -> hyapp.user.v1.GetAgencyApplicationsResponse + 98, // 178: hyapp.user.v1.UserHostService.UpdateAgencyProfile:output_type -> hyapp.user.v1.UpdateAgencyProfileResponse + 101, // 179: hyapp.user.v1.UserHostService.GetHostEngagementStats:output_type -> hyapp.user.v1.GetHostEngagementStatsResponse + 103, // 180: hyapp.user.v1.UserHostService.RecordPrivateMessageEvent:output_type -> hyapp.user.v1.RecordPrivateMessageEventResponse + 68, // 181: hyapp.user.v1.UserHostAdminService.UpdateRoleScopePolicies:output_type -> hyapp.user.v1.UpdateRoleScopePoliciesResponse + 74, // 182: hyapp.user.v1.UserHostAdminService.CreateBDLeader:output_type -> hyapp.user.v1.CreateBDLeaderResponse + 76, // 183: hyapp.user.v1.UserHostAdminService.CreateBD:output_type -> hyapp.user.v1.CreateBDResponse + 78, // 184: hyapp.user.v1.UserHostAdminService.SetBDStatus:output_type -> hyapp.user.v1.SetBDStatusResponse + 80, // 185: hyapp.user.v1.UserHostAdminService.CreateCoinSeller:output_type -> hyapp.user.v1.CreateCoinSellerResponse + 82, // 186: hyapp.user.v1.UserHostAdminService.SetCoinSellerStatus:output_type -> hyapp.user.v1.SetCoinSellerStatusResponse + 84, // 187: hyapp.user.v1.UserHostAdminService.ReviewSubCoinSellerApplication:output_type -> hyapp.user.v1.ReviewSubCoinSellerApplicationResponse + 86, // 188: hyapp.user.v1.UserHostAdminService.CreateAgency:output_type -> hyapp.user.v1.CreateAgencyResponse + 88, // 189: hyapp.user.v1.UserHostAdminService.AdminAddAgencyHost:output_type -> hyapp.user.v1.AdminAddAgencyHostResponse + 90, // 190: hyapp.user.v1.UserHostAdminService.CloseAgency:output_type -> hyapp.user.v1.CloseAgencyResponse + 92, // 191: hyapp.user.v1.UserHostAdminService.DeleteAgency:output_type -> hyapp.user.v1.DeleteAgencyResponse + 94, // 192: hyapp.user.v1.UserHostAdminService.SetAgencyStatus:output_type -> hyapp.user.v1.SetAgencyStatusResponse + 96, // 193: hyapp.user.v1.UserHostAdminService.SetAgencyJoinEnabled:output_type -> hyapp.user.v1.SetAgencyJoinEnabledResponse + 149, // [149:194] is the sub-list for method output_type + 104, // [104:149] is the sub-list for method input_type + 104, // [104:104] is the sub-list for extension type_name + 104, // [104:104] is the sub-list for extension extendee + 0, // [0:104] is the sub-list for field type_name } func init() { file_proto_user_v1_host_proto_init() } @@ -7834,16 +7954,16 @@ func file_proto_user_v1_host_proto_init() { return } file_proto_user_v1_user_proto_init() - file_proto_user_v1_host_proto_msgTypes[77].OneofWrappers = []any{} file_proto_user_v1_host_proto_msgTypes[79].OneofWrappers = []any{} - file_proto_user_v1_host_proto_msgTypes[95].OneofWrappers = []any{} + file_proto_user_v1_host_proto_msgTypes[81].OneofWrappers = []any{} + file_proto_user_v1_host_proto_msgTypes[97].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_host_proto_rawDesc), len(file_proto_user_v1_host_proto_rawDesc)), NumEnums: 0, - NumMessages: 103, + NumMessages: 105, NumExtensions: 0, NumServices: 2, }, diff --git a/api/proto/user/v1/host.proto b/api/proto/user/v1/host.proto index aa3d6060..601e6879 100644 --- a/api/proto/user/v1/host.proto +++ b/api/proto/user/v1/host.proto @@ -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); diff --git a/api/proto/user/v1/host_grpc.pb.go b/api/proto/user/v1/host_grpc.pb.go index d9959a03..b7e4bd15 100644 --- a/api/proto/user/v1/host_grpc.pb.go +++ b/api/proto/user/v1/host_grpc.pb.go @@ -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, diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 31b8123b..e00cb1f8 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -367,16 +367,30 @@ type DebitGiftResponse struct { GiftIncomeCoinAmount int64 `protobuf:"varint,20,opt,name=gift_income_coin_amount,json=giftIncomeCoinAmount,proto3" json:"gift_income_coin_amount,omitempty"` // gift_income_balance_after 是收礼人 COIN 账后余额;未返币时为 0。 GiftIncomeBalanceAfter int64 `protobuf:"varint,21,opt,name=gift_income_balance_after,json=giftIncomeBalanceAfter,proto3" json:"gift_income_balance_after,omitempty"` - // host_point_added 是命中 App 收益政策后给主播 POINT 钱包增加的积分;不参与房间热度/贡献榜。 + // Deprecated: 仅用于旧 command_id 幂等重放返回首次 POINT 回执;新送礼不会再写入或消费该值。 + // + // Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. HostPointAdded int64 `protobuf:"varint,22,opt,name=host_point_added,json=hostPointAdded,proto3" json:"host_point_added,omitempty"` - // host_point_balance_after 是主播 POINT 入账后的可用余额;未命中 POINT 政策时为 0。 + // Deprecated: 仅保留旧 POINT 回执的账后余额快照;新送礼不会再写入或消费该值。 + // + // Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. HostPointBalanceAfter int64 `protobuf:"varint,23,opt,name=host_point_balance_after,json=hostPointBalanceAfter,proto3" json:"host_point_balance_after,omitempty"` - // host_point_asset_type 当前固定为 POINT,显式返回避免客户端把收益积分当成 COIN。 + // Deprecated: 仅保留旧 POINT 回执的资产类型快照;新送礼不会再写入或消费该值。 + // + // Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. HostPointAssetType string `protobuf:"bytes,24,opt,name=host_point_asset_type,json=hostPointAssetType,proto3" json:"host_point_asset_type,omitempty"` - // host_point_policy_instance_code 是本次使用的策略实例快照。 + // Deprecated: 仅保留旧 POINT 回执的政策实例快照;新送礼不会再读取旧收益政策。 + // + // Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. HostPointPolicyInstanceCode string `protobuf:"bytes,25,opt,name=host_point_policy_instance_code,json=hostPointPolicyInstanceCode,proto3" json:"host_point_policy_instance_code,omitempty"` - HostPointTemplateCode string `protobuf:"bytes,26,opt,name=host_point_template_code,json=hostPointTemplateCode,proto3" json:"host_point_template_code,omitempty"` - HostPointTemplateVersion string `protobuf:"bytes,27,opt,name=host_point_template_version,json=hostPointTemplateVersion,proto3" json:"host_point_template_version,omitempty"` + // Deprecated: 仅保留旧 POINT 回执的模板编码快照。 + // + // Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. + HostPointTemplateCode string `protobuf:"bytes,26,opt,name=host_point_template_code,json=hostPointTemplateCode,proto3" json:"host_point_template_code,omitempty"` + // Deprecated: 仅保留旧 POINT 回执的模板版本快照。 + // + // Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. + HostPointTemplateVersion string `protobuf:"bytes,27,opt,name=host_point_template_version,json=hostPointTemplateVersion,proto3" json:"host_point_template_version,omitempty"` // balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。 BalanceVersion int64 `protobuf:"varint,28,opt,name=balance_version,json=balanceVersion,proto3" json:"balance_version,omitempty"` // recharge_seven_day_coins / recharge_thirty_day_coins 是扣费时由 wallet owner 固化的 UTC 自然日充值快照。 @@ -387,9 +401,21 @@ type DebitGiftResponse struct { LastRechargedAtMs int64 `protobuf:"varint,31,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"` // paid_at_ms 是 wallet_transactions.created_at_ms 的稳定快照;首次扣费与同 command_id 重放必须完全一致。 // room/lucky 只能用该 wallet owner 事实做 UTC 日、小时和充值后短窗归属,不能用 saga 或恢复 worker 时钟替代。 - PaidAtMs int64 `protobuf:"varint,32,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + PaidAtMs int64 `protobuf:"varint,32,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"` + // Host 政策无关收益快照:工资政策写 HOST_PERIOD_DIAMOND,永久积分类政策写 POINT_DIAMOND。 + HostIncomePolicyType string `protobuf:"bytes,33,opt,name=host_income_policy_type,json=hostIncomePolicyType,proto3" json:"host_income_policy_type,omitempty"` + HostIncomeBalanceAfter int64 `protobuf:"varint,36,opt,name=host_income_balance_after,json=hostIncomeBalanceAfter,proto3" json:"host_income_balance_after,omitempty"` + IncomeAssetType string `protobuf:"bytes,37,opt,name=income_asset_type,json=incomeAssetType,proto3" json:"income_asset_type,omitempty"` + HostBaseAmount int64 `protobuf:"varint,38,opt,name=host_base_amount,json=hostBaseAmount,proto3" json:"host_base_amount,omitempty"` + AgencyAmount int64 `protobuf:"varint,39,opt,name=agency_amount,json=agencyAmount,proto3" json:"agency_amount,omitempty"` + AgencyOwnerUserId int64 `protobuf:"varint,40,opt,name=agency_owner_user_id,json=agencyOwnerUserId,proto3" json:"agency_owner_user_id,omitempty"` + AgencyIncomeBalanceAfter int64 `protobuf:"varint,41,opt,name=agency_income_balance_after,json=agencyIncomeBalanceAfter,proto3" json:"agency_income_balance_after,omitempty"` + HostIncomePolicyId uint64 `protobuf:"varint,42,opt,name=host_income_policy_id,json=hostIncomePolicyId,proto3" json:"host_income_policy_id,omitempty"` + HostIncomePolicyVersion uint64 `protobuf:"varint,43,opt,name=host_income_policy_version,json=hostIncomePolicyVersion,proto3" json:"host_income_policy_version,omitempty"` + HostPolicyCoefficient string `protobuf:"bytes,44,opt,name=host_policy_coefficient,json=hostPolicyCoefficient,proto3" json:"host_policy_coefficient,omitempty"` + AgencyPointShareBps int32 `protobuf:"varint,45,opt,name=agency_point_share_bps,json=agencyPointShareBps,proto3" json:"agency_point_share_bps,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebitGiftResponse) Reset() { @@ -569,6 +595,7 @@ func (x *DebitGiftResponse) GetGiftIncomeBalanceAfter() int64 { return 0 } +// Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. func (x *DebitGiftResponse) GetHostPointAdded() int64 { if x != nil { return x.HostPointAdded @@ -576,6 +603,7 @@ func (x *DebitGiftResponse) GetHostPointAdded() int64 { return 0 } +// Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. func (x *DebitGiftResponse) GetHostPointBalanceAfter() int64 { if x != nil { return x.HostPointBalanceAfter @@ -583,6 +611,7 @@ func (x *DebitGiftResponse) GetHostPointBalanceAfter() int64 { return 0 } +// Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. func (x *DebitGiftResponse) GetHostPointAssetType() string { if x != nil { return x.HostPointAssetType @@ -590,6 +619,7 @@ func (x *DebitGiftResponse) GetHostPointAssetType() string { return "" } +// Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. func (x *DebitGiftResponse) GetHostPointPolicyInstanceCode() string { if x != nil { return x.HostPointPolicyInstanceCode @@ -597,6 +627,7 @@ func (x *DebitGiftResponse) GetHostPointPolicyInstanceCode() string { return "" } +// Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. func (x *DebitGiftResponse) GetHostPointTemplateCode() string { if x != nil { return x.HostPointTemplateCode @@ -604,6 +635,7 @@ func (x *DebitGiftResponse) GetHostPointTemplateCode() string { return "" } +// Deprecated: Marked as deprecated in proto/wallet/v1/wallet.proto. func (x *DebitGiftResponse) GetHostPointTemplateVersion() string { if x != nil { return x.HostPointTemplateVersion @@ -646,6 +678,83 @@ func (x *DebitGiftResponse) GetPaidAtMs() int64 { return 0 } +func (x *DebitGiftResponse) GetHostIncomePolicyType() string { + if x != nil { + return x.HostIncomePolicyType + } + return "" +} + +func (x *DebitGiftResponse) GetHostIncomeBalanceAfter() int64 { + if x != nil { + return x.HostIncomeBalanceAfter + } + return 0 +} + +func (x *DebitGiftResponse) GetIncomeAssetType() string { + if x != nil { + return x.IncomeAssetType + } + return "" +} + +func (x *DebitGiftResponse) GetHostBaseAmount() int64 { + if x != nil { + return x.HostBaseAmount + } + return 0 +} + +func (x *DebitGiftResponse) GetAgencyAmount() int64 { + if x != nil { + return x.AgencyAmount + } + return 0 +} + +func (x *DebitGiftResponse) GetAgencyOwnerUserId() int64 { + if x != nil { + return x.AgencyOwnerUserId + } + return 0 +} + +func (x *DebitGiftResponse) GetAgencyIncomeBalanceAfter() int64 { + if x != nil { + return x.AgencyIncomeBalanceAfter + } + return 0 +} + +func (x *DebitGiftResponse) GetHostIncomePolicyId() uint64 { + if x != nil { + return x.HostIncomePolicyId + } + return 0 +} + +func (x *DebitGiftResponse) GetHostIncomePolicyVersion() uint64 { + if x != nil { + return x.HostIncomePolicyVersion + } + return 0 +} + +func (x *DebitGiftResponse) GetHostPolicyCoefficient() string { + if x != nil { + return x.HostPolicyCoefficient + } + return "" +} + +func (x *DebitGiftResponse) GetAgencyPointShareBps() int32 { + if x != nil { + return x.AgencyPointShareBps + } + return 0 +} + // DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。 type DebitGiftTarget struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1387,6 +1496,12 @@ type HostSalaryPolicy struct { Levels []*HostSalaryPolicyLevel `protobuf:"bytes,11,rep,name=levels,proto3" json:"levels,omitempty"` CycleKey string `protobuf:"bytes,12,opt,name=cycle_key,json=cycleKey,proto3" json:"cycle_key,omitempty"` PolicyVersion uint64 `protobuf:"varint,13,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + PolicyType string `protobuf:"bytes,14,opt,name=policy_type,json=policyType,proto3" json:"policy_type,omitempty"` + PointDiamondsPerUsd int64 `protobuf:"varint,15,opt,name=point_diamonds_per_usd,json=pointDiamondsPerUsd,proto3" json:"point_diamonds_per_usd,omitempty"` + CoinsPerUsd int64 `protobuf:"varint,16,opt,name=coins_per_usd,json=coinsPerUsd,proto3" json:"coins_per_usd,omitempty"` + MinimumWithdrawUsdMinor int64 `protobuf:"varint,17,opt,name=minimum_withdraw_usd_minor,json=minimumWithdrawUsdMinor,proto3" json:"minimum_withdraw_usd_minor,omitempty"` + WithdrawFeeBps int32 `protobuf:"varint,18,opt,name=withdraw_fee_bps,json=withdrawFeeBps,proto3" json:"withdraw_fee_bps,omitempty"` + AgencyPointShareBps int32 `protobuf:"varint,19,opt,name=agency_point_share_bps,json=agencyPointShareBps,proto3" json:"agency_point_share_bps,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1512,6 +1627,48 @@ func (x *HostSalaryPolicy) GetPolicyVersion() uint64 { return 0 } +func (x *HostSalaryPolicy) GetPolicyType() string { + if x != nil { + return x.PolicyType + } + return "" +} + +func (x *HostSalaryPolicy) GetPointDiamondsPerUsd() int64 { + if x != nil { + return x.PointDiamondsPerUsd + } + return 0 +} + +func (x *HostSalaryPolicy) GetCoinsPerUsd() int64 { + if x != nil { + return x.CoinsPerUsd + } + return 0 +} + +func (x *HostSalaryPolicy) GetMinimumWithdrawUsdMinor() int64 { + if x != nil { + return x.MinimumWithdrawUsdMinor + } + return 0 +} + +func (x *HostSalaryPolicy) GetWithdrawFeeBps() int32 { + if x != nil { + return x.WithdrawFeeBps + } + return 0 +} + +func (x *HostSalaryPolicy) GetAgencyPointShareBps() int32 { + if x != nil { + return x.AgencyPointShareBps + } + return 0 +} + type GetActiveHostSalaryPolicyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -3470,8 +3627,10 @@ type FreezeSalaryWithdrawalRequest struct { Reason string `protobuf:"bytes,5,opt,name=reason,proto3" json:"reason,omitempty"` AppCode string `protobuf:"bytes,6,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` WithdrawalRef string `protobuf:"bytes,7,opt,name=withdrawal_ref,json=withdrawalRef,proto3" json:"withdrawal_ref,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // region_id comes from the verified business identity and selects the cycle-bound Host policy. + RegionId int64 `protobuf:"varint,8,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FreezeSalaryWithdrawalRequest) Reset() { @@ -3553,6 +3712,13 @@ func (x *FreezeSalaryWithdrawalRequest) GetWithdrawalRef() string { return "" } +func (x *FreezeSalaryWithdrawalRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type FreezeSalaryWithdrawalResponse struct { state protoimpl.MessageState `protogen:"open.v1"` TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` @@ -3935,27 +4101,29 @@ func (x *ReleaseSalaryWithdrawalResponse) GetSalaryUsdMinor() int64 { return 0 } -// FreezePointWithdrawalRequest 将 Huwaa 策略积分从可用余额冻结到提现审核池。 +// FreezePointWithdrawalRequest 将 POINT 从可用余额冻结到提现审核池。 type FreezePointWithdrawalRequest struct { state protoimpl.MessageState `protogen:"open.v1"` CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` // asset_type 只能是 POINT 或 COIN_SELLER_POINT;旧工资美元资产继续使用 SalaryWithdrawal 接口。 AssetType string `protobuf:"bytes,3,opt,name=asset_type,json=assetType,proto3" json:"asset_type,omitempty"` - // gross_point_amount 是本次冻结的积分总额,Huwaa 普通 POINT 最低 1,000,000。 + // gross_point_amount 是本次冻结的积分总额,最小值由 App + 区域 wallet 政策决定。 GrossPointAmount int64 `protobuf:"varint,4,opt,name=gross_point_amount,json=grossPointAmount,proto3" json:"gross_point_amount,omitempty"` // fee_point_amount 是平台手续费积分;为空时服务端按 fee_bps 计算。 FeePointAmount int64 `protobuf:"varint,5,opt,name=fee_point_amount,json=feePointAmount,proto3" json:"fee_point_amount,omitempty"` // net_point_amount 是预计到账积分,必须等于 gross - fee;为空时服务端计算。 NetPointAmount int64 `protobuf:"varint,6,opt,name=net_point_amount,json=netPointAmount,proto3" json:"net_point_amount,omitempty"` - // points_per_usd 是展示折算快照,Huwaa 第一套默认 100000。 + // points_per_usd 是 Gateway 预览值;wallet 会在冻结事务内按已发布政策重新固化。 PointsPerUsd int64 `protobuf:"varint,7,opt,name=points_per_usd,json=pointsPerUsd,proto3" json:"points_per_usd,omitempty"` FeeBps int32 `protobuf:"varint,8,opt,name=fee_bps,json=feeBps,proto3" json:"fee_bps,omitempty"` Reason string `protobuf:"bytes,9,opt,name=reason,proto3" json:"reason,omitempty"` AppCode string `protobuf:"bytes,10,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` WithdrawalRef string `protobuf:"bytes,11,opt,name=withdrawal_ref,json=withdrawalRef,proto3" json:"withdrawal_ref,omitempty"` - // region_id 只能由 gateway 从登录用户资料注入,客户端请求体不能决定工资政策边界。 - RegionId int64 `protobuf:"varint,12,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + // region_id 只能由 gateway 从登录用户资料注入,客户端请求体不能决定 wallet 政策边界。 + RegionId int64 `protobuf:"varint,12,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + // POINT_DIAMOND 由美元美分和 Host 政策换算冻结积分;历史 POINT 继续传 gross_point_amount。 + GrossUsdMinor int64 `protobuf:"varint,13,opt,name=gross_usd_minor,json=grossUsdMinor,proto3" json:"gross_usd_minor,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4074,6 +4242,13 @@ func (x *FreezePointWithdrawalRequest) GetRegionId() int64 { return 0 } +func (x *FreezePointWithdrawalRequest) GetGrossUsdMinor() int64 { + if x != nil { + return x.GrossUsdMinor + } + return 0 +} + type FreezePointWithdrawalResponse struct { state protoimpl.MessageState `protogen:"open.v1"` TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` @@ -4083,8 +4258,16 @@ type FreezePointWithdrawalResponse struct { NetPointAmount int64 `protobuf:"varint,5,opt,name=net_point_amount,json=netPointAmount,proto3" json:"net_point_amount,omitempty"` PointsPerUsd int64 `protobuf:"varint,6,opt,name=points_per_usd,json=pointsPerUsd,proto3" json:"points_per_usd,omitempty"` FeeBps int32 `protobuf:"varint,7,opt,name=fee_bps,json=feeBps,proto3" json:"fee_bps,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // policy_instance_code 是首次冻结事务实际使用的 wallet 政策实例,重放返回同一快照。 + PolicyInstanceCode string `protobuf:"bytes,8,opt,name=policy_instance_code,json=policyInstanceCode,proto3" json:"policy_instance_code,omitempty"` + GrossUsdMinor int64 `protobuf:"varint,9,opt,name=gross_usd_minor,json=grossUsdMinor,proto3" json:"gross_usd_minor,omitempty"` + FeeUsdMinor int64 `protobuf:"varint,10,opt,name=fee_usd_minor,json=feeUsdMinor,proto3" json:"fee_usd_minor,omitempty"` + NetUsdMinor int64 `protobuf:"varint,11,opt,name=net_usd_minor,json=netUsdMinor,proto3" json:"net_usd_minor,omitempty"` + PolicyId uint64 `protobuf:"varint,12,opt,name=policy_id,json=policyId,proto3" json:"policy_id,omitempty"` + PolicyVersion uint64 `protobuf:"varint,13,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + CoinsPerUsd int64 `protobuf:"varint,14,opt,name=coins_per_usd,json=coinsPerUsd,proto3" json:"coins_per_usd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FreezePointWithdrawalResponse) Reset() { @@ -4166,6 +4349,55 @@ func (x *FreezePointWithdrawalResponse) GetFeeBps() int32 { return 0 } +func (x *FreezePointWithdrawalResponse) GetPolicyInstanceCode() string { + if x != nil { + return x.PolicyInstanceCode + } + return "" +} + +func (x *FreezePointWithdrawalResponse) GetGrossUsdMinor() int64 { + if x != nil { + return x.GrossUsdMinor + } + return 0 +} + +func (x *FreezePointWithdrawalResponse) GetFeeUsdMinor() int64 { + if x != nil { + return x.FeeUsdMinor + } + return 0 +} + +func (x *FreezePointWithdrawalResponse) GetNetUsdMinor() int64 { + if x != nil { + return x.NetUsdMinor + } + return 0 +} + +func (x *FreezePointWithdrawalResponse) GetPolicyId() uint64 { + if x != nil { + return x.PolicyId + } + return 0 +} + +func (x *FreezePointWithdrawalResponse) GetPolicyVersion() uint64 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *FreezePointWithdrawalResponse) GetCoinsPerUsd() int64 { + if x != nil { + return x.CoinsPerUsd + } + return 0 +} + // SettlePointWithdrawalRequest 在审核通过时扣掉已冻结积分;不会再次扣 available。 type SettlePointWithdrawalRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4181,6 +4413,7 @@ type SettlePointWithdrawalRequest struct { Reason string `protobuf:"bytes,10,opt,name=reason,proto3" json:"reason,omitempty"` AppCode string `protobuf:"bytes,11,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` WithdrawalApplicationId string `protobuf:"bytes,12,opt,name=withdrawal_application_id,json=withdrawalApplicationId,proto3" json:"withdrawal_application_id,omitempty"` + FreezeTransactionId string `protobuf:"bytes,13,opt,name=freeze_transaction_id,json=freezeTransactionId,proto3" json:"freeze_transaction_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4299,6 +4532,13 @@ func (x *SettlePointWithdrawalRequest) GetWithdrawalApplicationId() string { return "" } +func (x *SettlePointWithdrawalRequest) GetFreezeTransactionId() string { + if x != nil { + return x.FreezeTransactionId + } + return "" +} + type SettlePointWithdrawalResponse struct { state protoimpl.MessageState `protogen:"open.v1"` TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` @@ -4407,9 +4647,10 @@ type ReleasePointWithdrawalRequest struct { AppCode string `protobuf:"bytes,11,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` WithdrawalApplicationId string `protobuf:"bytes,12,opt,name=withdrawal_application_id,json=withdrawalApplicationId,proto3" json:"withdrawal_application_id,omitempty"` // withdrawal_ref 只用于申请创建失败回滚对应的次数 reservation;人工驳回不传。 - WithdrawalRef string `protobuf:"bytes,13,opt,name=withdrawal_ref,json=withdrawalRef,proto3" json:"withdrawal_ref,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + WithdrawalRef string `protobuf:"bytes,13,opt,name=withdrawal_ref,json=withdrawalRef,proto3" json:"withdrawal_ref,omitempty"` + FreezeTransactionId string `protobuf:"bytes,14,opt,name=freeze_transaction_id,json=freezeTransactionId,proto3" json:"freeze_transaction_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReleasePointWithdrawalRequest) Reset() { @@ -4533,6 +4774,13 @@ func (x *ReleasePointWithdrawalRequest) GetWithdrawalRef() string { return "" } +func (x *ReleasePointWithdrawalRequest) GetFreezeTransactionId() string { + if x != nil { + return x.FreezeTransactionId + } + return "" +} + type ReleasePointWithdrawalResponse struct { state protoimpl.MessageState `protogen:"open.v1"` TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` @@ -16952,15 +17200,17 @@ func (x *GetDiamondExchangeConfigResponse) GetRules() []*DiamondExchangeRule { // ExchangePointToCoin 把产品文案中的“钻石”POINT 兑换到本人 COIN; // 客户端只提交源数量,region/now 由 gateway 从登录用户与服务端时间注入,交易在事务内重新解析已发布政策。 type ExchangePointToCoinRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` - AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - PointAmount int64 `protobuf:"varint,4,opt,name=point_amount,json=pointAmount,proto3" json:"point_amount,omitempty"` - RegionId int64 `protobuf:"varint,5,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` - NowMs int64 `protobuf:"varint,6,opt,name=now_ms,json=nowMs,proto3" json:"now_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + PointAmount int64 `protobuf:"varint,4,opt,name=point_amount,json=pointAmount,proto3" json:"point_amount,omitempty"` + RegionId int64 `protobuf:"varint,5,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + NowMs int64 `protobuf:"varint,6,opt,name=now_ms,json=nowMs,proto3" json:"now_ms,omitempty"` + // 为空保持历史 POINT;永久钻石积分入口固定传 POINT_DIAMOND。 + SourceAssetType string `protobuf:"bytes,7,opt,name=source_asset_type,json=sourceAssetType,proto3" json:"source_asset_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExchangePointToCoinRequest) Reset() { @@ -17035,6 +17285,13 @@ func (x *ExchangePointToCoinRequest) GetNowMs() int64 { return 0 } +func (x *ExchangePointToCoinRequest) GetSourceAssetType() string { + if x != nil { + return x.SourceAssetType + } + return "" +} + type ExchangePointToCoinResponse struct { state protoimpl.MessageState `protogen:"open.v1"` TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` @@ -17044,8 +17301,12 @@ type ExchangePointToCoinResponse struct { CoinBalanceAfter int64 `protobuf:"varint,5,opt,name=coin_balance_after,json=coinBalanceAfter,proto3" json:"coin_balance_after,omitempty"` RatioPointAmount int64 `protobuf:"varint,6,opt,name=ratio_point_amount,json=ratioPointAmount,proto3" json:"ratio_point_amount,omitempty"` RatioCoinAmount int64 `protobuf:"varint,7,opt,name=ratio_coin_amount,json=ratioCoinAmount,proto3" json:"ratio_coin_amount,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + SourceAssetType string `protobuf:"bytes,8,opt,name=source_asset_type,json=sourceAssetType,proto3" json:"source_asset_type,omitempty"` + // POINT_DIAMOND 固化首次兑换所用 Host 政策快照;历史 POINT 返回 0。 + PolicyId uint64 `protobuf:"varint,9,opt,name=policy_id,json=policyId,proto3" json:"policy_id,omitempty"` + PolicyVersion uint64 `protobuf:"varint,10,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExchangePointToCoinResponse) Reset() { @@ -17127,6 +17388,27 @@ func (x *ExchangePointToCoinResponse) GetRatioCoinAmount() int64 { return 0 } +func (x *ExchangePointToCoinResponse) GetSourceAssetType() string { + if x != nil { + return x.SourceAssetType + } + return "" +} + +func (x *ExchangePointToCoinResponse) GetPolicyId() uint64 { + if x != nil { + return x.PolicyId + } + return 0 +} + +func (x *ExchangePointToCoinResponse) GetPolicyVersion() uint64 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + // WalletTransaction 是用户钱包流水页使用的分录级投影。 type WalletTransaction struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -17143,8 +17425,21 @@ type WalletTransaction struct { CreatedAtMs int64 `protobuf:"varint,11,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` TransferUsdMinor int64 `protobuf:"varint,12,opt,name=transfer_usd_minor,json=transferUsdMinor,proto3" json:"transfer_usd_minor,omitempty"` TransferCurrencyCode string `protobuf:"bytes,13,opt,name=transfer_currency_code,json=transferCurrencyCode,proto3" json:"transfer_currency_code,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // entry_role 是面向客户端的分录语义;同一 gift_debit 下可区分 Host 基础收入和 Agency 分成。 + EntryRole string `protobuf:"bytes,14,opt,name=entry_role,json=entryRole,proto3" json:"entry_role,omitempty"` + // 同人既是 Host 又是 Agency 时账户只写一笔增量,但这两个字段仍保留各自的礼物收益组成。 + HostBaseAmount int64 `protobuf:"varint,15,opt,name=host_base_amount,json=hostBaseAmount,proto3" json:"host_base_amount,omitempty"` + AgencyAmount int64 `protobuf:"varint,16,opt,name=agency_amount,json=agencyAmount,proto3" json:"agency_amount,omitempty"` + PolicyId uint64 `protobuf:"varint,17,opt,name=policy_id,json=policyId,proto3" json:"policy_id,omitempty"` + PolicyVersion uint64 `protobuf:"varint,18,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + RatioPointAmount int64 `protobuf:"varint,19,opt,name=ratio_point_amount,json=ratioPointAmount,proto3" json:"ratio_point_amount,omitempty"` + RatioCoinAmount int64 `protobuf:"varint,20,opt,name=ratio_coin_amount,json=ratioCoinAmount,proto3" json:"ratio_coin_amount,omitempty"` + GrossUsdMinor int64 `protobuf:"varint,21,opt,name=gross_usd_minor,json=grossUsdMinor,proto3" json:"gross_usd_minor,omitempty"` + FeeUsdMinor int64 `protobuf:"varint,22,opt,name=fee_usd_minor,json=feeUsdMinor,proto3" json:"fee_usd_minor,omitempty"` + NetUsdMinor int64 `protobuf:"varint,23,opt,name=net_usd_minor,json=netUsdMinor,proto3" json:"net_usd_minor,omitempty"` + WithdrawFeeBps int64 `protobuf:"varint,24,opt,name=withdraw_fee_bps,json=withdrawFeeBps,proto3" json:"withdraw_fee_bps,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WalletTransaction) Reset() { @@ -17268,6 +17563,83 @@ func (x *WalletTransaction) GetTransferCurrencyCode() string { return "" } +func (x *WalletTransaction) GetEntryRole() string { + if x != nil { + return x.EntryRole + } + return "" +} + +func (x *WalletTransaction) GetHostBaseAmount() int64 { + if x != nil { + return x.HostBaseAmount + } + return 0 +} + +func (x *WalletTransaction) GetAgencyAmount() int64 { + if x != nil { + return x.AgencyAmount + } + return 0 +} + +func (x *WalletTransaction) GetPolicyId() uint64 { + if x != nil { + return x.PolicyId + } + return 0 +} + +func (x *WalletTransaction) GetPolicyVersion() uint64 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *WalletTransaction) GetRatioPointAmount() int64 { + if x != nil { + return x.RatioPointAmount + } + return 0 +} + +func (x *WalletTransaction) GetRatioCoinAmount() int64 { + if x != nil { + return x.RatioCoinAmount + } + return 0 +} + +func (x *WalletTransaction) GetGrossUsdMinor() int64 { + if x != nil { + return x.GrossUsdMinor + } + return 0 +} + +func (x *WalletTransaction) GetFeeUsdMinor() int64 { + if x != nil { + return x.FeeUsdMinor + } + return 0 +} + +func (x *WalletTransaction) GetNetUsdMinor() int64 { + if x != nil { + return x.NetUsdMinor + } + return 0 +} + +func (x *WalletTransaction) GetWithdrawFeeBps() int64 { + if x != nil { + return x.WithdrawFeeBps + } + return 0 +} + type ListWalletTransactionsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -25101,12 +25473,15 @@ func (x *GetGiftCatalogVersionResponse) GetVersion() int64 { // HostRevenueStats 聚合主播在自然日区间内的收益 POINT 和送礼用户;gateway 对外仍按产品文案展示为钻石。 type HostRevenueStats struct { - state protoimpl.MessageState `protogen:"open.v1"` - DiamondEarnings int64 `protobuf:"varint,1,opt,name=diamond_earnings,json=diamondEarnings,proto3" json:"diamond_earnings,omitempty"` - DiamondExchanged int64 `protobuf:"varint,2,opt,name=diamond_exchanged,json=diamondExchanged,proto3" json:"diamond_exchanged,omitempty"` - GiftSenders int64 `protobuf:"varint,3,opt,name=gift_senders,json=giftSenders,proto3" json:"gift_senders,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + // 旧字段保持 SALARY_DIAMOND 收益与历史 POINT 转出,避免滚动升级改变 Lalu 口径。 + DiamondEarnings int64 `protobuf:"varint,1,opt,name=diamond_earnings,json=diamondEarnings,proto3" json:"diamond_earnings,omitempty"` + DiamondExchanged int64 `protobuf:"varint,2,opt,name=diamond_exchanged,json=diamondExchanged,proto3" json:"diamond_exchanged,omitempty"` + GiftSenders int64 `protobuf:"varint,3,opt,name=gift_senders,json=giftSenders,proto3" json:"gift_senders,omitempty"` + PointDiamondEarnings int64 `protobuf:"varint,4,opt,name=point_diamond_earnings,json=pointDiamondEarnings,proto3" json:"point_diamond_earnings,omitempty"` + PointDiamondExchanged int64 `protobuf:"varint,5,opt,name=point_diamond_exchanged,json=pointDiamondExchanged,proto3" json:"point_diamond_exchanged,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *HostRevenueStats) Reset() { @@ -25160,6 +25535,20 @@ func (x *HostRevenueStats) GetGiftSenders() int64 { return 0 } +func (x *HostRevenueStats) GetPointDiamondEarnings() int64 { + if x != nil { + return x.PointDiamondEarnings + } + return 0 +} + +func (x *HostRevenueStats) GetPointDiamondExchanged() int64 { + if x != nil { + return x.PointDiamondExchanged + } + return 0 +} + type GetHostRevenueStatsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -25167,6 +25556,7 @@ type GetHostRevenueStatsRequest struct { HostUserId int64 `protobuf:"varint,3,opt,name=host_user_id,json=hostUserId,proto3" json:"host_user_id,omitempty"` StartAtMs int64 `protobuf:"varint,4,opt,name=start_at_ms,json=startAtMs,proto3" json:"start_at_ms,omitempty"` EndAtMs int64 `protobuf:"varint,5,opt,name=end_at_ms,json=endAtMs,proto3" json:"end_at_ms,omitempty"` + RegionId int64 `protobuf:"varint,6,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -25236,6 +25626,13 @@ func (x *GetHostRevenueStatsRequest) GetEndAtMs() int64 { return 0 } +func (x *GetHostRevenueStatsRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type GetHostRevenueStatsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Stats *HostRevenueStats `protobuf:"bytes,1,opt,name=stats,proto3" json:"stats,omitempty"` @@ -25280,190 +25677,20 @@ func (x *GetHostRevenueStatsResponse) GetStats() *HostRevenueStats { return nil } -// AgencyPointShareStats 只统计独立 Agency 分成流水,不把 owner 自己的主播礼物收益重复算入分成。 -type AgencyPointShareStats struct { - state protoimpl.MessageState `protogen:"open.v1"` - ShareIncome int64 `protobuf:"varint,1,opt,name=share_income,json=shareIncome,proto3" json:"share_income,omitempty"` - GiftedHostCount int64 `protobuf:"varint,2,opt,name=gifted_host_count,json=giftedHostCount,proto3" json:"gifted_host_count,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgencyPointShareStats) Reset() { - *x = AgencyPointShareStats{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[284] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgencyPointShareStats) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgencyPointShareStats) ProtoMessage() {} - -func (x *AgencyPointShareStats) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[284] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgencyPointShareStats.ProtoReflect.Descriptor instead. -func (*AgencyPointShareStats) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{284} -} - -func (x *AgencyPointShareStats) GetShareIncome() int64 { - if x != nil { - return x.ShareIncome - } - return 0 -} - -func (x *AgencyPointShareStats) GetGiftedHostCount() int64 { - if x != nil { - return x.GiftedHostCount - } - return 0 -} - -type GetAgencyPointShareStatsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - AgencyOwnerUserId int64 `protobuf:"varint,3,opt,name=agency_owner_user_id,json=agencyOwnerUserId,proto3" json:"agency_owner_user_id,omitempty"` - StartAtMs int64 `protobuf:"varint,4,opt,name=start_at_ms,json=startAtMs,proto3" json:"start_at_ms,omitempty"` - EndAtMs int64 `protobuf:"varint,5,opt,name=end_at_ms,json=endAtMs,proto3" json:"end_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetAgencyPointShareStatsRequest) Reset() { - *x = GetAgencyPointShareStatsRequest{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[285] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetAgencyPointShareStatsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetAgencyPointShareStatsRequest) ProtoMessage() {} - -func (x *GetAgencyPointShareStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[285] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetAgencyPointShareStatsRequest.ProtoReflect.Descriptor instead. -func (*GetAgencyPointShareStatsRequest) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{285} -} - -func (x *GetAgencyPointShareStatsRequest) GetRequestId() string { - if x != nil { - return x.RequestId - } - return "" -} - -func (x *GetAgencyPointShareStatsRequest) GetAppCode() string { - if x != nil { - return x.AppCode - } - return "" -} - -func (x *GetAgencyPointShareStatsRequest) GetAgencyOwnerUserId() int64 { - if x != nil { - return x.AgencyOwnerUserId - } - return 0 -} - -func (x *GetAgencyPointShareStatsRequest) GetStartAtMs() int64 { - if x != nil { - return x.StartAtMs - } - return 0 -} - -func (x *GetAgencyPointShareStatsRequest) GetEndAtMs() int64 { - if x != nil { - return x.EndAtMs - } - return 0 -} - -type GetAgencyPointShareStatsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Stats *AgencyPointShareStats `protobuf:"bytes,1,opt,name=stats,proto3" json:"stats,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetAgencyPointShareStatsResponse) Reset() { - *x = GetAgencyPointShareStatsResponse{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[286] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetAgencyPointShareStatsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetAgencyPointShareStatsResponse) ProtoMessage() {} - -func (x *GetAgencyPointShareStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[286] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetAgencyPointShareStatsResponse.ProtoReflect.Descriptor instead. -func (*GetAgencyPointShareStatsResponse) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{286} -} - -func (x *GetAgencyPointShareStatsResponse) GetStats() *AgencyPointShareStats { - if x != nil { - return x.Stats - } - return nil -} - type AgencyHostGiftStats struct { - state protoimpl.MessageState `protogen:"open.v1"` - GiftIncome int64 `protobuf:"varint,1,opt,name=gift_income,json=giftIncome,proto3" json:"gift_income,omitempty"` - GiftedHostCount int64 `protobuf:"varint,2,opt,name=gifted_host_count,json=giftedHostCount,proto3" json:"gifted_host_count,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + // 旧 gift_income 只表示 SALARY_DIAMOND Host 收益;永久积分收入不得混入。 + GiftIncome int64 `protobuf:"varint,1,opt,name=gift_income,json=giftIncome,proto3" json:"gift_income,omitempty"` + GiftedHostCount int64 `protobuf:"varint,2,opt,name=gifted_host_count,json=giftedHostCount,proto3" json:"gifted_host_count,omitempty"` + PointDiamondHostIncome int64 `protobuf:"varint,4,opt,name=point_diamond_host_income,json=pointDiamondHostIncome,proto3" json:"point_diamond_host_income,omitempty"` + PointDiamondAgencyShareIncome int64 `protobuf:"varint,5,opt,name=point_diamond_agency_share_income,json=pointDiamondAgencyShareIncome,proto3" json:"point_diamond_agency_share_income,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AgencyHostGiftStats) Reset() { *x = AgencyHostGiftStats{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[287] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[284] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -25475,7 +25702,7 @@ func (x *AgencyHostGiftStats) String() string { func (*AgencyHostGiftStats) ProtoMessage() {} func (x *AgencyHostGiftStats) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[287] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[284] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -25488,7 +25715,7 @@ func (x *AgencyHostGiftStats) ProtoReflect() protoreflect.Message { // Deprecated: Use AgencyHostGiftStats.ProtoReflect.Descriptor instead. func (*AgencyHostGiftStats) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{287} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{284} } func (x *AgencyHostGiftStats) GetGiftIncome() int64 { @@ -25505,20 +25732,36 @@ func (x *AgencyHostGiftStats) GetGiftedHostCount() int64 { return 0 } +func (x *AgencyHostGiftStats) GetPointDiamondHostIncome() int64 { + if x != nil { + return x.PointDiamondHostIncome + } + return 0 +} + +func (x *AgencyHostGiftStats) GetPointDiamondAgencyShareIncome() int64 { + if x != nil { + return x.PointDiamondAgencyShareIncome + } + return 0 +} + type GetAgencyHostGiftStatsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - HostUserIds []int64 `protobuf:"varint,3,rep,packed,name=host_user_ids,json=hostUserIds,proto3" json:"host_user_ids,omitempty"` - StartAtMs int64 `protobuf:"varint,4,opt,name=start_at_ms,json=startAtMs,proto3" json:"start_at_ms,omitempty"` - EndAtMs int64 `protobuf:"varint,5,opt,name=end_at_ms,json=endAtMs,proto3" json:"end_at_ms,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + HostUserIds []int64 `protobuf:"varint,3,rep,packed,name=host_user_ids,json=hostUserIds,proto3" json:"host_user_ids,omitempty"` + StartAtMs int64 `protobuf:"varint,4,opt,name=start_at_ms,json=startAtMs,proto3" json:"start_at_ms,omitempty"` + EndAtMs int64 `protobuf:"varint,5,opt,name=end_at_ms,json=endAtMs,proto3" json:"end_at_ms,omitempty"` + // Agency Center 传 owner user_id 以汇总 POINT_DIAMOND agency_amount;BD 视图保持 0。 + AgencyUserId int64 `protobuf:"varint,6,opt,name=agency_user_id,json=agencyUserId,proto3" json:"agency_user_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAgencyHostGiftStatsRequest) Reset() { *x = GetAgencyHostGiftStatsRequest{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[288] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[285] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -25530,7 +25773,7 @@ func (x *GetAgencyHostGiftStatsRequest) String() string { func (*GetAgencyHostGiftStatsRequest) ProtoMessage() {} func (x *GetAgencyHostGiftStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[288] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[285] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -25543,7 +25786,7 @@ func (x *GetAgencyHostGiftStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetAgencyHostGiftStatsRequest.ProtoReflect.Descriptor instead. func (*GetAgencyHostGiftStatsRequest) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{288} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{285} } func (x *GetAgencyHostGiftStatsRequest) GetRequestId() string { @@ -25581,16 +25824,25 @@ func (x *GetAgencyHostGiftStatsRequest) GetEndAtMs() int64 { return 0 } +func (x *GetAgencyHostGiftStatsRequest) GetAgencyUserId() int64 { + if x != nil { + return x.AgencyUserId + } + return 0 +} + type GetAgencyHostGiftStatsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Stats *AgencyHostGiftStats `protobuf:"bytes,1,opt,name=stats,proto3" json:"stats,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Stats *AgencyHostGiftStats `protobuf:"bytes,1,opt,name=stats,proto3" json:"stats,omitempty"` + // 一次返回请求内所有主播的统计,避免 Agency Center 对 wallet-service 发起 N 次 RPC。 + HostStats []*AgencyHostRevenueStats `protobuf:"bytes,2,rep,name=host_stats,json=hostStats,proto3" json:"host_stats,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetAgencyHostGiftStatsResponse) Reset() { *x = GetAgencyHostGiftStatsResponse{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[289] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[286] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -25602,7 +25854,7 @@ func (x *GetAgencyHostGiftStatsResponse) String() string { func (*GetAgencyHostGiftStatsResponse) ProtoMessage() {} func (x *GetAgencyHostGiftStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[289] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[286] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -25615,7 +25867,7 @@ func (x *GetAgencyHostGiftStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetAgencyHostGiftStatsResponse.ProtoReflect.Descriptor instead. func (*GetAgencyHostGiftStatsResponse) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{289} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{286} } func (x *GetAgencyHostGiftStatsResponse) GetStats() *AgencyHostGiftStats { @@ -25625,6 +25877,65 @@ func (x *GetAgencyHostGiftStatsResponse) GetStats() *AgencyHostGiftStats { return nil } +func (x *GetAgencyHostGiftStatsResponse) GetHostStats() []*AgencyHostRevenueStats { + if x != nil { + return x.HostStats + } + return nil +} + +type AgencyHostRevenueStats struct { + state protoimpl.MessageState `protogen:"open.v1"` + HostUserId int64 `protobuf:"varint,1,opt,name=host_user_id,json=hostUserId,proto3" json:"host_user_id,omitempty"` + Stats *HostRevenueStats `protobuf:"bytes,2,opt,name=stats,proto3" json:"stats,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgencyHostRevenueStats) Reset() { + *x = AgencyHostRevenueStats{} + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[287] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgencyHostRevenueStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgencyHostRevenueStats) ProtoMessage() {} + +func (x *AgencyHostRevenueStats) ProtoReflect() protoreflect.Message { + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[287] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgencyHostRevenueStats.ProtoReflect.Descriptor instead. +func (*AgencyHostRevenueStats) Descriptor() ([]byte, []int) { + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{287} +} + +func (x *AgencyHostRevenueStats) GetHostUserId() int64 { + if x != nil { + return x.HostUserId + } + return 0 +} + +func (x *AgencyHostRevenueStats) GetStats() *HostRevenueStats { + if x != nil { + return x.Stats + } + return nil +} + // PointWithdrawalCoinSellerConfig 是按 App 隔离的 POINT 提现币商白名单与兑换比例。 // 比例用整数分子/分母保存,避免浮点金额在 H5、gateway 和账本间产生舍入分歧。 type PointWithdrawalCoinSellerConfig struct { @@ -25644,7 +25955,7 @@ type PointWithdrawalCoinSellerConfig struct { func (x *PointWithdrawalCoinSellerConfig) Reset() { *x = PointWithdrawalCoinSellerConfig{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[290] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[288] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -25656,7 +25967,7 @@ func (x *PointWithdrawalCoinSellerConfig) String() string { func (*PointWithdrawalCoinSellerConfig) ProtoMessage() {} func (x *PointWithdrawalCoinSellerConfig) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[290] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[288] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -25669,7 +25980,7 @@ func (x *PointWithdrawalCoinSellerConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use PointWithdrawalCoinSellerConfig.ProtoReflect.Descriptor instead. func (*PointWithdrawalCoinSellerConfig) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{290} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{288} } func (x *PointWithdrawalCoinSellerConfig) GetAppCode() string { @@ -25748,7 +26059,7 @@ type ListPointWithdrawalCoinSellersRequest struct { func (x *ListPointWithdrawalCoinSellersRequest) Reset() { *x = ListPointWithdrawalCoinSellersRequest{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[291] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[289] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -25760,7 +26071,7 @@ func (x *ListPointWithdrawalCoinSellersRequest) String() string { func (*ListPointWithdrawalCoinSellersRequest) ProtoMessage() {} func (x *ListPointWithdrawalCoinSellersRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[291] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[289] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -25773,7 +26084,7 @@ func (x *ListPointWithdrawalCoinSellersRequest) ProtoReflect() protoreflect.Mess // Deprecated: Use ListPointWithdrawalCoinSellersRequest.ProtoReflect.Descriptor instead. func (*ListPointWithdrawalCoinSellersRequest) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{291} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{289} } func (x *ListPointWithdrawalCoinSellersRequest) GetRequestId() string { @@ -25813,7 +26124,7 @@ type ListPointWithdrawalCoinSellersResponse struct { func (x *ListPointWithdrawalCoinSellersResponse) Reset() { *x = ListPointWithdrawalCoinSellersResponse{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[292] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[290] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -25825,7 +26136,7 @@ func (x *ListPointWithdrawalCoinSellersResponse) String() string { func (*ListPointWithdrawalCoinSellersResponse) ProtoMessage() {} func (x *ListPointWithdrawalCoinSellersResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[292] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[290] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -25838,7 +26149,7 @@ func (x *ListPointWithdrawalCoinSellersResponse) ProtoReflect() protoreflect.Mes // Deprecated: Use ListPointWithdrawalCoinSellersResponse.ProtoReflect.Descriptor instead. func (*ListPointWithdrawalCoinSellersResponse) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{292} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{290} } func (x *ListPointWithdrawalCoinSellersResponse) GetSellers() []*PointWithdrawalCoinSellerConfig { @@ -25859,14 +26170,17 @@ type TransferPointToCoinSellerRequest struct { SourceCountryCode string `protobuf:"bytes,6,opt,name=source_country_code,json=sourceCountryCode,proto3" json:"source_country_code,omitempty"` Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"` // region_id 只能由 gateway 从登录用户资料注入,用于解析当前 UTC 月工资政策。 - RegionId int64 `protobuf:"varint,8,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + RegionId int64 `protobuf:"varint,8,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + // 为空保持历史 POINT;POINT_DIAMOND 按 gross_usd_minor 和 Host 政策换算双方资产。 + SourceAssetType string `protobuf:"bytes,9,opt,name=source_asset_type,json=sourceAssetType,proto3" json:"source_asset_type,omitempty"` + GrossUsdMinor int64 `protobuf:"varint,10,opt,name=gross_usd_minor,json=grossUsdMinor,proto3" json:"gross_usd_minor,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *TransferPointToCoinSellerRequest) Reset() { *x = TransferPointToCoinSellerRequest{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[293] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[291] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -25878,7 +26192,7 @@ func (x *TransferPointToCoinSellerRequest) String() string { func (*TransferPointToCoinSellerRequest) ProtoMessage() {} func (x *TransferPointToCoinSellerRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[293] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[291] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -25891,7 +26205,7 @@ func (x *TransferPointToCoinSellerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TransferPointToCoinSellerRequest.ProtoReflect.Descriptor instead. func (*TransferPointToCoinSellerRequest) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{293} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{291} } func (x *TransferPointToCoinSellerRequest) GetCommandId() string { @@ -25950,6 +26264,20 @@ func (x *TransferPointToCoinSellerRequest) GetRegionId() int64 { return 0 } +func (x *TransferPointToCoinSellerRequest) GetSourceAssetType() string { + if x != nil { + return x.SourceAssetType + } + return "" +} + +func (x *TransferPointToCoinSellerRequest) GetGrossUsdMinor() int64 { + if x != nil { + return x.GrossUsdMinor + } + return 0 +} + type TransferPointToCoinSellerResponse struct { state protoimpl.MessageState `protogen:"open.v1"` TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` @@ -25959,13 +26287,20 @@ type TransferPointToCoinSellerResponse struct { SellerCoinAmount int64 `protobuf:"varint,5,opt,name=seller_coin_amount,json=sellerCoinAmount,proto3" json:"seller_coin_amount,omitempty"` RatioPointAmount int64 `protobuf:"varint,6,opt,name=ratio_point_amount,json=ratioPointAmount,proto3" json:"ratio_point_amount,omitempty"` RatioSellerCoinAmount int64 `protobuf:"varint,7,opt,name=ratio_seller_coin_amount,json=ratioSellerCoinAmount,proto3" json:"ratio_seller_coin_amount,omitempty"` + SourceAssetType string `protobuf:"bytes,8,opt,name=source_asset_type,json=sourceAssetType,proto3" json:"source_asset_type,omitempty"` + GrossUsdMinor int64 `protobuf:"varint,9,opt,name=gross_usd_minor,json=grossUsdMinor,proto3" json:"gross_usd_minor,omitempty"` + FeeUsdMinor int64 `protobuf:"varint,10,opt,name=fee_usd_minor,json=feeUsdMinor,proto3" json:"fee_usd_minor,omitempty"` + NetUsdMinor int64 `protobuf:"varint,11,opt,name=net_usd_minor,json=netUsdMinor,proto3" json:"net_usd_minor,omitempty"` + WithdrawFeeBps int32 `protobuf:"varint,12,opt,name=withdraw_fee_bps,json=withdrawFeeBps,proto3" json:"withdraw_fee_bps,omitempty"` + PolicyId uint64 `protobuf:"varint,13,opt,name=policy_id,json=policyId,proto3" json:"policy_id,omitempty"` + PolicyVersion uint64 `protobuf:"varint,14,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TransferPointToCoinSellerResponse) Reset() { *x = TransferPointToCoinSellerResponse{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[294] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[292] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -25977,7 +26312,7 @@ func (x *TransferPointToCoinSellerResponse) String() string { func (*TransferPointToCoinSellerResponse) ProtoMessage() {} func (x *TransferPointToCoinSellerResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[294] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[292] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -25990,7 +26325,7 @@ func (x *TransferPointToCoinSellerResponse) ProtoReflect() protoreflect.Message // Deprecated: Use TransferPointToCoinSellerResponse.ProtoReflect.Descriptor instead. func (*TransferPointToCoinSellerResponse) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{294} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{292} } func (x *TransferPointToCoinSellerResponse) GetTransactionId() string { @@ -26042,19 +26377,71 @@ func (x *TransferPointToCoinSellerResponse) GetRatioSellerCoinAmount() int64 { return 0 } +func (x *TransferPointToCoinSellerResponse) GetSourceAssetType() string { + if x != nil { + return x.SourceAssetType + } + return "" +} + +func (x *TransferPointToCoinSellerResponse) GetGrossUsdMinor() int64 { + if x != nil { + return x.GrossUsdMinor + } + return 0 +} + +func (x *TransferPointToCoinSellerResponse) GetFeeUsdMinor() int64 { + if x != nil { + return x.FeeUsdMinor + } + return 0 +} + +func (x *TransferPointToCoinSellerResponse) GetNetUsdMinor() int64 { + if x != nil { + return x.NetUsdMinor + } + return 0 +} + +func (x *TransferPointToCoinSellerResponse) GetWithdrawFeeBps() int32 { + if x != nil { + return x.WithdrawFeeBps + } + return 0 +} + +func (x *TransferPointToCoinSellerResponse) GetPolicyId() uint64 { + if x != nil { + return x.PolicyId + } + return 0 +} + +func (x *TransferPointToCoinSellerResponse) GetPolicyVersion() uint64 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + type GetPointWithdrawalConfigRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - RegionId int64 `protobuf:"varint,3,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` - NowMs int64 `protobuf:"varint,4,opt,name=now_ms,json=nowMs,proto3" json:"now_ms,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + RegionId int64 `protobuf:"varint,3,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + NowMs int64 `protobuf:"varint,4,opt,name=now_ms,json=nowMs,proto3" json:"now_ms,omitempty"` + AssetType string `protobuf:"bytes,5,opt,name=asset_type,json=assetType,proto3" json:"asset_type,omitempty"` + // overview 传登录 user_id,以便 wallet 按当前 UTC 周期返回真实次数余量。 + UserId int64 `protobuf:"varint,6,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetPointWithdrawalConfigRequest) Reset() { *x = GetPointWithdrawalConfigRequest{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[295] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[293] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -26066,7 +26453,7 @@ func (x *GetPointWithdrawalConfigRequest) String() string { func (*GetPointWithdrawalConfigRequest) ProtoMessage() {} func (x *GetPointWithdrawalConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[295] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[293] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -26079,7 +26466,7 @@ func (x *GetPointWithdrawalConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPointWithdrawalConfigRequest.ProtoReflect.Descriptor instead. func (*GetPointWithdrawalConfigRequest) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{295} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{293} } func (x *GetPointWithdrawalConfigRequest) GetRequestId() string { @@ -26110,6 +26497,113 @@ func (x *GetPointWithdrawalConfigRequest) GetNowMs() int64 { return 0 } +func (x *GetPointWithdrawalConfigRequest) GetAssetType() string { + if x != nil { + return x.AssetType + } + return "" +} + +func (x *GetPointWithdrawalConfigRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +type PointWithdrawalActionAvailability struct { + state protoimpl.MessageState `protogen:"open.v1"` + Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"` + LimitPeriod string `protobuf:"bytes,2,opt,name=limit_period,json=limitPeriod,proto3" json:"limit_period,omitempty"` + // limit_count=0、remaining_count=-1 表示不限次数。 + LimitCount int64 `protobuf:"varint,3,opt,name=limit_count,json=limitCount,proto3" json:"limit_count,omitempty"` + UsedCount int64 `protobuf:"varint,4,opt,name=used_count,json=usedCount,proto3" json:"used_count,omitempty"` + RemainingCount int64 `protobuf:"varint,5,opt,name=remaining_count,json=remainingCount,proto3" json:"remaining_count,omitempty"` + AllowedDays string `protobuf:"bytes,6,opt,name=allowed_days,json=allowedDays,proto3" json:"allowed_days,omitempty"` + BlockReason string `protobuf:"bytes,7,opt,name=block_reason,json=blockReason,proto3" json:"block_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PointWithdrawalActionAvailability) Reset() { + *x = PointWithdrawalActionAvailability{} + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[294] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PointWithdrawalActionAvailability) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PointWithdrawalActionAvailability) ProtoMessage() {} + +func (x *PointWithdrawalActionAvailability) ProtoReflect() protoreflect.Message { + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[294] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PointWithdrawalActionAvailability.ProtoReflect.Descriptor instead. +func (*PointWithdrawalActionAvailability) Descriptor() ([]byte, []int) { + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{294} +} + +func (x *PointWithdrawalActionAvailability) GetAllowed() bool { + if x != nil { + return x.Allowed + } + return false +} + +func (x *PointWithdrawalActionAvailability) GetLimitPeriod() string { + if x != nil { + return x.LimitPeriod + } + return "" +} + +func (x *PointWithdrawalActionAvailability) GetLimitCount() int64 { + if x != nil { + return x.LimitCount + } + return 0 +} + +func (x *PointWithdrawalActionAvailability) GetUsedCount() int64 { + if x != nil { + return x.UsedCount + } + return 0 +} + +func (x *PointWithdrawalActionAvailability) GetRemainingCount() int64 { + if x != nil { + return x.RemainingCount + } + return 0 +} + +func (x *PointWithdrawalActionAvailability) GetAllowedDays() string { + if x != nil { + return x.AllowedDays + } + return "" +} + +func (x *PointWithdrawalActionAvailability) GetBlockReason() string { + if x != nil { + return x.BlockReason + } + return "" +} + type GetPointWithdrawalConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Found bool `protobuf:"varint,1,opt,name=found,proto3" json:"found,omitempty"` @@ -26118,14 +26612,22 @@ type GetPointWithdrawalConfigResponse struct { MinimumPoints int64 `protobuf:"varint,4,opt,name=minimum_points,json=minimumPoints,proto3" json:"minimum_points,omitempty"` PolicyInstanceCode string `protobuf:"bytes,5,opt,name=policy_instance_code,json=policyInstanceCode,proto3" json:"policy_instance_code,omitempty"` // coins_per_usd 复用工资兑换普通金币的统一比例,与 points_per_usd 组合得到 POINT->COIN 展示比例。 - CoinsPerUsd int64 `protobuf:"varint,6,opt,name=coins_per_usd,json=coinsPerUsd,proto3" json:"coins_per_usd,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CoinsPerUsd int64 `protobuf:"varint,6,opt,name=coins_per_usd,json=coinsPerUsd,proto3" json:"coins_per_usd,omitempty"` + PolicyType string `protobuf:"bytes,7,opt,name=policy_type,json=policyType,proto3" json:"policy_type,omitempty"` + MinimumWithdrawUsdMinor int64 `protobuf:"varint,8,opt,name=minimum_withdraw_usd_minor,json=minimumWithdrawUsdMinor,proto3" json:"minimum_withdraw_usd_minor,omitempty"` + AgencyPointShareBps int32 `protobuf:"varint,9,opt,name=agency_point_share_bps,json=agencyPointShareBps,proto3" json:"agency_point_share_bps,omitempty"` + PolicyId uint64 `protobuf:"varint,10,opt,name=policy_id,json=policyId,proto3" json:"policy_id,omitempty"` + PolicyVersion uint64 `protobuf:"varint,11,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + AvailabilityEvaluated bool `protobuf:"varint,12,opt,name=availability_evaluated,json=availabilityEvaluated,proto3" json:"availability_evaluated,omitempty"` + CoinSellerAvailability *PointWithdrawalActionAvailability `protobuf:"bytes,13,opt,name=coin_seller_availability,json=coinSellerAvailability,proto3" json:"coin_seller_availability,omitempty"` + PlatformAvailability *PointWithdrawalActionAvailability `protobuf:"bytes,14,opt,name=platform_availability,json=platformAvailability,proto3" json:"platform_availability,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetPointWithdrawalConfigResponse) Reset() { *x = GetPointWithdrawalConfigResponse{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[296] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[295] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -26137,7 +26639,7 @@ func (x *GetPointWithdrawalConfigResponse) String() string { func (*GetPointWithdrawalConfigResponse) ProtoMessage() {} func (x *GetPointWithdrawalConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[296] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[295] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -26150,7 +26652,7 @@ func (x *GetPointWithdrawalConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPointWithdrawalConfigResponse.ProtoReflect.Descriptor instead. func (*GetPointWithdrawalConfigResponse) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{296} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{295} } func (x *GetPointWithdrawalConfigResponse) GetFound() bool { @@ -26195,6 +26697,62 @@ func (x *GetPointWithdrawalConfigResponse) GetCoinsPerUsd() int64 { return 0 } +func (x *GetPointWithdrawalConfigResponse) GetPolicyType() string { + if x != nil { + return x.PolicyType + } + return "" +} + +func (x *GetPointWithdrawalConfigResponse) GetMinimumWithdrawUsdMinor() int64 { + if x != nil { + return x.MinimumWithdrawUsdMinor + } + return 0 +} + +func (x *GetPointWithdrawalConfigResponse) GetAgencyPointShareBps() int32 { + if x != nil { + return x.AgencyPointShareBps + } + return 0 +} + +func (x *GetPointWithdrawalConfigResponse) GetPolicyId() uint64 { + if x != nil { + return x.PolicyId + } + return 0 +} + +func (x *GetPointWithdrawalConfigResponse) GetPolicyVersion() uint64 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *GetPointWithdrawalConfigResponse) GetAvailabilityEvaluated() bool { + if x != nil { + return x.AvailabilityEvaluated + } + return false +} + +func (x *GetPointWithdrawalConfigResponse) GetCoinSellerAvailability() *PointWithdrawalActionAvailability { + if x != nil { + return x.CoinSellerAvailability + } + return nil +} + +func (x *GetPointWithdrawalConfigResponse) GetPlatformAvailability() *PointWithdrawalActionAvailability { + if x != nil { + return x.PlatformAvailability + } + return nil +} + // VipBenefitPreviewItem 是同一权益下的一项预览内容;独立稳定键允许 Flutter 在多图、动态资源间切换, // 且不会把数组下标误当成资源身份。 type VipBenefitPreviewItem struct { @@ -26215,7 +26773,7 @@ type VipBenefitPreviewItem struct { func (x *VipBenefitPreviewItem) Reset() { *x = VipBenefitPreviewItem{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[297] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[296] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -26227,7 +26785,7 @@ func (x *VipBenefitPreviewItem) String() string { func (*VipBenefitPreviewItem) ProtoMessage() {} func (x *VipBenefitPreviewItem) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[297] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[296] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -26240,7 +26798,7 @@ func (x *VipBenefitPreviewItem) ProtoReflect() protoreflect.Message { // Deprecated: Use VipBenefitPreviewItem.ProtoReflect.Descriptor instead. func (*VipBenefitPreviewItem) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{297} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{296} } func (x *VipBenefitPreviewItem) GetPreviewId() string { @@ -26312,7 +26870,7 @@ type VipBenefitNumericReward struct { func (x *VipBenefitNumericReward) Reset() { *x = VipBenefitNumericReward{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[298] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[297] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -26324,7 +26882,7 @@ func (x *VipBenefitNumericReward) String() string { func (*VipBenefitNumericReward) ProtoMessage() {} func (x *VipBenefitNumericReward) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[298] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[297] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -26337,7 +26895,7 @@ func (x *VipBenefitNumericReward) ProtoReflect() protoreflect.Message { // Deprecated: Use VipBenefitNumericReward.ProtoReflect.Descriptor instead. func (*VipBenefitNumericReward) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{298} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{297} } func (x *VipBenefitNumericReward) GetLabel() string { @@ -26381,7 +26939,7 @@ type VipBenefitPresentation struct { func (x *VipBenefitPresentation) Reset() { *x = VipBenefitPresentation{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[299] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[298] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -26393,7 +26951,7 @@ func (x *VipBenefitPresentation) String() string { func (*VipBenefitPresentation) ProtoMessage() {} func (x *VipBenefitPresentation) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[299] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[298] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -26406,7 +26964,7 @@ func (x *VipBenefitPresentation) ProtoReflect() protoreflect.Message { // Deprecated: Use VipBenefitPresentation.ProtoReflect.Descriptor instead. func (*VipBenefitPresentation) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{299} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{298} } func (x *VipBenefitPresentation) GetDescription() string { @@ -26446,7 +27004,7 @@ type RevokeUserResourceRequest struct { func (x *RevokeUserResourceRequest) Reset() { *x = RevokeUserResourceRequest{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[300] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[299] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -26458,7 +27016,7 @@ func (x *RevokeUserResourceRequest) String() string { func (*RevokeUserResourceRequest) ProtoMessage() {} func (x *RevokeUserResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[300] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[299] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -26471,7 +27029,7 @@ func (x *RevokeUserResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeUserResourceRequest.ProtoReflect.Descriptor instead. func (*RevokeUserResourceRequest) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{300} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{299} } func (x *RevokeUserResourceRequest) GetRequestId() string { @@ -26525,7 +27083,7 @@ type RevokeUserResourceResponse struct { func (x *RevokeUserResourceResponse) Reset() { *x = RevokeUserResourceResponse{} - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[301] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[300] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -26537,7 +27095,7 @@ func (x *RevokeUserResourceResponse) String() string { func (*RevokeUserResourceResponse) ProtoMessage() {} func (x *RevokeUserResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_wallet_v1_wallet_proto_msgTypes[301] + mi := &file_proto_wallet_v1_wallet_proto_msgTypes[300] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -26550,7 +27108,7 @@ func (x *RevokeUserResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeUserResourceResponse.ProtoReflect.Descriptor instead. func (*RevokeUserResourceResponse) Descriptor() ([]byte, []int) { - return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{301} + return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{300} } func (x *RevokeUserResourceResponse) GetResource() *UserResourceEntitlement { @@ -26601,7 +27159,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" + "\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\x12%\n" + "\x0eentitlement_id\x18\r \x01(\tR\rentitlementId\x12#\n" + - "\rcharge_source\x18\x0e \x01(\tR\fchargeSource\"\xc2\v\n" + + "\rcharge_source\x18\x0e \x01(\tR\fchargeSource\"\x94\x10\n" + "\x11DebitGiftResponse\x12,\n" + "\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" + "\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" + @@ -26626,19 +27184,30 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x0eentitlement_id\x18\x12 \x01(\tR\rentitlementId\x12#\n" + "\rcharge_source\x18\x13 \x01(\tR\fchargeSource\x125\n" + "\x17gift_income_coin_amount\x18\x14 \x01(\x03R\x14giftIncomeCoinAmount\x129\n" + - "\x19gift_income_balance_after\x18\x15 \x01(\x03R\x16giftIncomeBalanceAfter\x12(\n" + - "\x10host_point_added\x18\x16 \x01(\x03R\x0ehostPointAdded\x127\n" + - "\x18host_point_balance_after\x18\x17 \x01(\x03R\x15hostPointBalanceAfter\x121\n" + - "\x15host_point_asset_type\x18\x18 \x01(\tR\x12hostPointAssetType\x12D\n" + - "\x1fhost_point_policy_instance_code\x18\x19 \x01(\tR\x1bhostPointPolicyInstanceCode\x127\n" + - "\x18host_point_template_code\x18\x1a \x01(\tR\x15hostPointTemplateCode\x12=\n" + - "\x1bhost_point_template_version\x18\x1b \x01(\tR\x18hostPointTemplateVersion\x12'\n" + + "\x19gift_income_balance_after\x18\x15 \x01(\x03R\x16giftIncomeBalanceAfter\x12,\n" + + "\x10host_point_added\x18\x16 \x01(\x03B\x02\x18\x01R\x0ehostPointAdded\x12;\n" + + "\x18host_point_balance_after\x18\x17 \x01(\x03B\x02\x18\x01R\x15hostPointBalanceAfter\x125\n" + + "\x15host_point_asset_type\x18\x18 \x01(\tB\x02\x18\x01R\x12hostPointAssetType\x12H\n" + + "\x1fhost_point_policy_instance_code\x18\x19 \x01(\tB\x02\x18\x01R\x1bhostPointPolicyInstanceCode\x12;\n" + + "\x18host_point_template_code\x18\x1a \x01(\tB\x02\x18\x01R\x15hostPointTemplateCode\x12A\n" + + "\x1bhost_point_template_version\x18\x1b \x01(\tB\x02\x18\x01R\x18hostPointTemplateVersion\x12'\n" + "\x0fbalance_version\x18\x1c \x01(\x03R\x0ebalanceVersion\x127\n" + "\x18recharge_seven_day_coins\x18\x1d \x01(\x03R\x15rechargeSevenDayCoins\x129\n" + "\x19recharge_thirty_day_coins\x18\x1e \x01(\x03R\x16rechargeThirtyDayCoins\x12/\n" + "\x14last_recharged_at_ms\x18\x1f \x01(\x03R\x11lastRechargedAtMs\x12\x1c\n" + "\n" + - "paid_at_ms\x18 \x01(\x03R\bpaidAtMs\"\xed\x01\n" + + "paid_at_ms\x18 \x01(\x03R\bpaidAtMs\x125\n" + + "\x17host_income_policy_type\x18! \x01(\tR\x14hostIncomePolicyType\x129\n" + + "\x19host_income_balance_after\x18$ \x01(\x03R\x16hostIncomeBalanceAfter\x12*\n" + + "\x11income_asset_type\x18% \x01(\tR\x0fincomeAssetType\x12(\n" + + "\x10host_base_amount\x18& \x01(\x03R\x0ehostBaseAmount\x12#\n" + + "\ragency_amount\x18' \x01(\x03R\fagencyAmount\x12/\n" + + "\x14agency_owner_user_id\x18( \x01(\x03R\x11agencyOwnerUserId\x12=\n" + + "\x1bagency_income_balance_after\x18) \x01(\x03R\x18agencyIncomeBalanceAfter\x121\n" + + "\x15host_income_policy_id\x18* \x01(\x04R\x12hostIncomePolicyId\x12;\n" + + "\x1ahost_income_policy_version\x18+ \x01(\x04R\x17hostIncomePolicyVersion\x126\n" + + "\x17host_policy_coefficient\x18, \x01(\tR\x15hostPolicyCoefficient\x123\n" + + "\x16agency_point_share_bps\x18- \x01(\x05R\x13agencyPointShareBps\"\xed\x01\n" + "\x0fDebitGiftTarget\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12$\n" + @@ -26708,7 +27277,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x17agency_salary_usd_minor\x18\x05 \x01(\x03R\x14agencySalaryUsdMinor\x12\x16\n" + "\x06status\x18\x06 \x01(\tR\x06status\x12\x1d\n" + "\n" + - "sort_order\x18\a \x01(\x05R\tsortOrder\"\xad\x04\n" + + "sort_order\x18\a \x01(\x05R\tsortOrder\"\xc3\x06\n" + "\x10HostSalaryPolicy\x12\x1b\n" + "\tpolicy_id\x18\x01 \x01(\x04R\bpolicyId\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" + @@ -26723,7 +27292,14 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + " \x01(\x03R\reffectiveToMs\x12>\n" + "\x06levels\x18\v \x03(\v2&.hyapp.wallet.v1.HostSalaryPolicyLevelR\x06levels\x12\x1b\n" + "\tcycle_key\x18\f \x01(\tR\bcycleKey\x12%\n" + - "\x0epolicy_version\x18\r \x01(\x04R\rpolicyVersion\"\x8e\x02\n" + + "\x0epolicy_version\x18\r \x01(\x04R\rpolicyVersion\x12\x1f\n" + + "\vpolicy_type\x18\x0e \x01(\tR\n" + + "policyType\x123\n" + + "\x16point_diamonds_per_usd\x18\x0f \x01(\x03R\x13pointDiamondsPerUsd\x12\"\n" + + "\rcoins_per_usd\x18\x10 \x01(\x03R\vcoinsPerUsd\x12;\n" + + "\x1aminimum_withdraw_usd_minor\x18\x11 \x01(\x03R\x17minimumWithdrawUsdMinor\x12(\n" + + "\x10withdraw_fee_bps\x18\x12 \x01(\x05R\x0ewithdrawFeeBps\x123\n" + + "\x16agency_point_share_bps\x18\x13 \x01(\x05R\x13agencyPointShareBps\"\x8e\x02\n" + " GetActiveHostSalaryPolicyRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + @@ -26911,7 +27487,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\fcoin_per_usd\x18\x06 \x01(\x03R\n" + "coinPerUsd\x12+\n" + "\x12rate_min_usd_minor\x18\a \x01(\x03R\x0frateMinUsdMinor\x12+\n" + - "\x12rate_max_usd_minor\x18\b \x01(\x03R\x0frateMaxUsdMinor\"\x87\x02\n" + + "\x12rate_max_usd_minor\x18\b \x01(\x03R\x0frateMaxUsdMinor\"\xa4\x02\n" + "\x1dFreezeSalaryWithdrawalRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" + @@ -26920,7 +27496,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x10salary_usd_minor\x18\x04 \x01(\x03R\x0esalaryUsdMinor\x12\x16\n" + "\x06reason\x18\x05 \x01(\tR\x06reason\x12\x19\n" + "\bapp_code\x18\x06 \x01(\tR\aappCode\x12%\n" + - "\x0ewithdrawal_ref\x18\a \x01(\tR\rwithdrawalRef\"\xaa\x01\n" + + "\x0ewithdrawal_ref\x18\a \x01(\tR\rwithdrawalRef\x12\x1b\n" + + "\tregion_id\x18\b \x01(\x03R\bregionId\"\xaa\x01\n" + "\x1eFreezeSalaryWithdrawalResponse\x12%\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12(\n" + @@ -26952,7 +27529,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x1fReleaseSalaryWithdrawalResponse\x12%\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12(\n" + - "\x10salary_usd_minor\x18\x03 \x01(\x03R\x0esalaryUsdMinor\"\xad\x03\n" + + "\x10salary_usd_minor\x18\x03 \x01(\x03R\x0esalaryUsdMinor\"\xd5\x03\n" + "\x1cFreezePointWithdrawalRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" + @@ -26968,7 +27545,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\bapp_code\x18\n" + " \x01(\tR\aappCode\x12%\n" + "\x0ewithdrawal_ref\x18\v \x01(\tR\rwithdrawalRef\x12\x1b\n" + - "\tregion_id\x18\f \x01(\x03R\bregionId\"\xc0\x02\n" + + "\tregion_id\x18\f \x01(\x03R\bregionId\x12&\n" + + "\x0fgross_usd_minor\x18\r \x01(\x03R\rgrossUsdMinor\"\xca\x04\n" + "\x1dFreezePointWithdrawalResponse\x12%\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12,\n" + @@ -26976,7 +27554,15 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x10fee_point_amount\x18\x04 \x01(\x03R\x0efeePointAmount\x12(\n" + "\x10net_point_amount\x18\x05 \x01(\x03R\x0enetPointAmount\x12$\n" + "\x0epoints_per_usd\x18\x06 \x01(\x03R\fpointsPerUsd\x12\x17\n" + - "\afee_bps\x18\a \x01(\x05R\x06feeBps\"\xcf\x03\n" + + "\afee_bps\x18\a \x01(\x05R\x06feeBps\x120\n" + + "\x14policy_instance_code\x18\b \x01(\tR\x12policyInstanceCode\x12&\n" + + "\x0fgross_usd_minor\x18\t \x01(\x03R\rgrossUsdMinor\x12\"\n" + + "\rfee_usd_minor\x18\n" + + " \x01(\x03R\vfeeUsdMinor\x12\"\n" + + "\rnet_usd_minor\x18\v \x01(\x03R\vnetUsdMinor\x12\x1b\n" + + "\tpolicy_id\x18\f \x01(\x04R\bpolicyId\x12%\n" + + "\x0epolicy_version\x18\r \x01(\x04R\rpolicyVersion\x12\"\n" + + "\rcoins_per_usd\x18\x0e \x01(\x03R\vcoinsPerUsd\"\x83\x04\n" + "\x1cSettlePointWithdrawalRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" + @@ -26992,7 +27578,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x06reason\x18\n" + " \x01(\tR\x06reason\x12\x19\n" + "\bapp_code\x18\v \x01(\tR\aappCode\x12:\n" + - "\x19withdrawal_application_id\x18\f \x01(\tR\x17withdrawalApplicationId\"\xc0\x02\n" + + "\x19withdrawal_application_id\x18\f \x01(\tR\x17withdrawalApplicationId\x122\n" + + "\x15freeze_transaction_id\x18\r \x01(\tR\x13freezeTransactionId\"\xc0\x02\n" + "\x1dSettlePointWithdrawalResponse\x12%\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12,\n" + @@ -27000,7 +27587,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x10fee_point_amount\x18\x04 \x01(\x03R\x0efeePointAmount\x12(\n" + "\x10net_point_amount\x18\x05 \x01(\x03R\x0enetPointAmount\x12$\n" + "\x0epoints_per_usd\x18\x06 \x01(\x03R\fpointsPerUsd\x12\x17\n" + - "\afee_bps\x18\a \x01(\x05R\x06feeBps\"\xf7\x03\n" + + "\afee_bps\x18\a \x01(\x05R\x06feeBps\"\xab\x04\n" + "\x1dReleasePointWithdrawalRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" + @@ -27017,7 +27604,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + " \x01(\tR\x06reason\x12\x19\n" + "\bapp_code\x18\v \x01(\tR\aappCode\x12:\n" + "\x19withdrawal_application_id\x18\f \x01(\tR\x17withdrawalApplicationId\x12%\n" + - "\x0ewithdrawal_ref\x18\r \x01(\tR\rwithdrawalRef\"\xc1\x02\n" + + "\x0ewithdrawal_ref\x18\r \x01(\tR\rwithdrawalRef\x122\n" + + "\x15freeze_transaction_id\x18\x0e \x01(\tR\x13freezeTransactionId\"\xc1\x02\n" + "\x1eReleasePointWithdrawalResponse\x12%\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\x12,\n" + @@ -28306,7 +28894,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" + "\auser_id\x18\x03 \x01(\x03R\x06userId\"^\n" + " GetDiamondExchangeConfigResponse\x12:\n" + - "\x05rules\x18\x01 \x03(\v2$.hyapp.wallet.v1.DiamondExchangeRuleR\x05rules\"\xc6\x01\n" + + "\x05rules\x18\x01 \x03(\v2$.hyapp.wallet.v1.DiamondExchangeRuleR\x05rules\"\xf2\x01\n" + "\x1aExchangePointToCoinRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" + @@ -28314,7 +28902,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\auser_id\x18\x03 \x01(\x03R\x06userId\x12!\n" + "\fpoint_amount\x18\x04 \x01(\x03R\vpointAmount\x12\x1b\n" + "\tregion_id\x18\x05 \x01(\x03R\bregionId\x12\x15\n" + - "\x06now_ms\x18\x06 \x01(\x03R\x05nowMs\"\xc0\x02\n" + + "\x06now_ms\x18\x06 \x01(\x03R\x05nowMs\x12*\n" + + "\x11source_asset_type\x18\a \x01(\tR\x0fsourceAssetType\"\xb0\x03\n" + "\x1bExchangePointToCoinResponse\x12%\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12!\n" + "\fpoint_amount\x18\x02 \x01(\x03R\vpointAmount\x12\x1f\n" + @@ -28323,7 +28912,11 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x13point_balance_after\x18\x04 \x01(\x03R\x11pointBalanceAfter\x12,\n" + "\x12coin_balance_after\x18\x05 \x01(\x03R\x10coinBalanceAfter\x12,\n" + "\x12ratio_point_amount\x18\x06 \x01(\x03R\x10ratioPointAmount\x12*\n" + - "\x11ratio_coin_amount\x18\a \x01(\x03R\x0fratioCoinAmount\"\xfa\x03\n" + + "\x11ratio_coin_amount\x18\a \x01(\x03R\x0fratioCoinAmount\x12*\n" + + "\x11source_asset_type\x18\b \x01(\tR\x0fsourceAssetType\x12\x1b\n" + + "\tpolicy_id\x18\t \x01(\x04R\bpolicyId\x12%\n" + + "\x0epolicy_version\x18\n" + + " \x01(\x04R\rpolicyVersion\"\xa0\a\n" + "\x11WalletTransaction\x12\x19\n" + "\bentry_id\x18\x01 \x01(\x03R\aentryId\x12%\n" + "\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x19\n" + @@ -28339,7 +28932,19 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + " \x01(\tR\x06roomId\x12\"\n" + "\rcreated_at_ms\x18\v \x01(\x03R\vcreatedAtMs\x12,\n" + "\x12transfer_usd_minor\x18\f \x01(\x03R\x10transferUsdMinor\x124\n" + - "\x16transfer_currency_code\x18\r \x01(\tR\x14transferCurrencyCode\"\xfe\x01\n" + + "\x16transfer_currency_code\x18\r \x01(\tR\x14transferCurrencyCode\x12\x1d\n" + + "\n" + + "entry_role\x18\x0e \x01(\tR\tentryRole\x12(\n" + + "\x10host_base_amount\x18\x0f \x01(\x03R\x0ehostBaseAmount\x12#\n" + + "\ragency_amount\x18\x10 \x01(\x03R\fagencyAmount\x12\x1b\n" + + "\tpolicy_id\x18\x11 \x01(\x04R\bpolicyId\x12%\n" + + "\x0epolicy_version\x18\x12 \x01(\x04R\rpolicyVersion\x12,\n" + + "\x12ratio_point_amount\x18\x13 \x01(\x03R\x10ratioPointAmount\x12*\n" + + "\x11ratio_coin_amount\x18\x14 \x01(\x03R\x0fratioCoinAmount\x12&\n" + + "\x0fgross_usd_minor\x18\x15 \x01(\x03R\rgrossUsdMinor\x12\"\n" + + "\rfee_usd_minor\x18\x16 \x01(\x03R\vfeeUsdMinor\x12\"\n" + + "\rnet_usd_minor\x18\x17 \x01(\x03R\vnetUsdMinor\x12(\n" + + "\x10withdraw_fee_bps\x18\x18 \x01(\x03R\x0ewithdrawFeeBps\"\xfe\x01\n" + "\x1dListWalletTransactionsRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + @@ -29111,11 +29716,13 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\"9\n" + "\x1dGetGiftCatalogVersionResponse\x12\x18\n" + - "\aversion\x18\x01 \x01(\x03R\aversion\"\x8d\x01\n" + + "\aversion\x18\x01 \x01(\x03R\aversion\"\xfb\x01\n" + "\x10HostRevenueStats\x12)\n" + "\x10diamond_earnings\x18\x01 \x01(\x03R\x0fdiamondEarnings\x12+\n" + "\x11diamond_exchanged\x18\x02 \x01(\x03R\x10diamondExchanged\x12!\n" + - "\fgift_senders\x18\x03 \x01(\x03R\vgiftSenders\"\xb4\x01\n" + + "\fgift_senders\x18\x03 \x01(\x03R\vgiftSenders\x124\n" + + "\x16point_diamond_earnings\x18\x04 \x01(\x03R\x14pointDiamondEarnings\x126\n" + + "\x17point_diamond_exchanged\x18\x05 \x01(\x03R\x15pointDiamondExchanged\"\xd1\x01\n" + "\x1aGetHostRevenueStatsRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + @@ -29123,34 +29730,32 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\fhost_user_id\x18\x03 \x01(\x03R\n" + "hostUserId\x12\x1e\n" + "\vstart_at_ms\x18\x04 \x01(\x03R\tstartAtMs\x12\x1a\n" + - "\tend_at_ms\x18\x05 \x01(\x03R\aendAtMs\"V\n" + + "\tend_at_ms\x18\x05 \x01(\x03R\aendAtMs\x12\x1b\n" + + "\tregion_id\x18\x06 \x01(\x03R\bregionId\"V\n" + "\x1bGetHostRevenueStatsResponse\x127\n" + - "\x05stats\x18\x01 \x01(\v2!.hyapp.wallet.v1.HostRevenueStatsR\x05stats\"f\n" + - "\x15AgencyPointShareStats\x12!\n" + - "\fshare_income\x18\x01 \x01(\x03R\vshareIncome\x12*\n" + - "\x11gifted_host_count\x18\x02 \x01(\x03R\x0fgiftedHostCount\"\xc8\x01\n" + - "\x1fGetAgencyPointShareStatsRequest\x12\x1d\n" + - "\n" + - "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + - "\bapp_code\x18\x02 \x01(\tR\aappCode\x12/\n" + - "\x14agency_owner_user_id\x18\x03 \x01(\x03R\x11agencyOwnerUserId\x12\x1e\n" + - "\vstart_at_ms\x18\x04 \x01(\x03R\tstartAtMs\x12\x1a\n" + - "\tend_at_ms\x18\x05 \x01(\x03R\aendAtMs\"`\n" + - " GetAgencyPointShareStatsResponse\x12<\n" + - "\x05stats\x18\x01 \x01(\v2&.hyapp.wallet.v1.AgencyPointShareStatsR\x05stats\"b\n" + + "\x05stats\x18\x01 \x01(\v2!.hyapp.wallet.v1.HostRevenueStatsR\x05stats\"\xe7\x01\n" + "\x13AgencyHostGiftStats\x12\x1f\n" + "\vgift_income\x18\x01 \x01(\x03R\n" + "giftIncome\x12*\n" + - "\x11gifted_host_count\x18\x02 \x01(\x03R\x0fgiftedHostCount\"\xb9\x01\n" + + "\x11gifted_host_count\x18\x02 \x01(\x03R\x0fgiftedHostCount\x129\n" + + "\x19point_diamond_host_income\x18\x04 \x01(\x03R\x16pointDiamondHostIncome\x12H\n" + + "!point_diamond_agency_share_income\x18\x05 \x01(\x03R\x1dpointDiamondAgencyShareIncome\"\xdf\x01\n" + "\x1dGetAgencyHostGiftStatsRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\"\n" + "\rhost_user_ids\x18\x03 \x03(\x03R\vhostUserIds\x12\x1e\n" + "\vstart_at_ms\x18\x04 \x01(\x03R\tstartAtMs\x12\x1a\n" + - "\tend_at_ms\x18\x05 \x01(\x03R\aendAtMs\"\\\n" + + "\tend_at_ms\x18\x05 \x01(\x03R\aendAtMs\x12$\n" + + "\x0eagency_user_id\x18\x06 \x01(\x03R\fagencyUserId\"\xa4\x01\n" + "\x1eGetAgencyHostGiftStatsResponse\x12:\n" + - "\x05stats\x18\x01 \x01(\v2$.hyapp.wallet.v1.AgencyHostGiftStatsR\x05stats\"\xe6\x02\n" + + "\x05stats\x18\x01 \x01(\v2$.hyapp.wallet.v1.AgencyHostGiftStatsR\x05stats\x12F\n" + + "\n" + + "host_stats\x18\x02 \x03(\v2'.hyapp.wallet.v1.AgencyHostRevenueStatsR\thostStats\"s\n" + + "\x16AgencyHostRevenueStats\x12 \n" + + "\fhost_user_id\x18\x01 \x01(\x03R\n" + + "hostUserId\x127\n" + + "\x05stats\x18\x02 \x01(\v2!.hyapp.wallet.v1.HostRevenueStatsR\x05stats\"\xe6\x02\n" + "\x1fPointWithdrawalCoinSellerConfig\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12$\n" + "\x0eseller_user_id\x18\x02 \x01(\x03R\fsellerUserId\x12\x1d\n" + @@ -29169,7 +29774,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\fcountry_code\x18\x03 \x01(\tR\vcountryCode\x12)\n" + "\x10include_disabled\x18\x04 \x01(\bR\x0fincludeDisabled\"t\n" + "&ListPointWithdrawalCoinSellersResponse\x12J\n" + - "\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\xb0\x02\n" + + "\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\x84\x03\n" + " TransferPointToCoinSellerRequest\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x19\n" + @@ -29179,7 +29784,10 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\fpoint_amount\x18\x05 \x01(\x03R\vpointAmount\x12.\n" + "\x13source_country_code\x18\x06 \x01(\tR\x11sourceCountryCode\x12\x16\n" + "\x06reason\x18\a \x01(\tR\x06reason\x12\x1b\n" + - "\tregion_id\x18\b \x01(\x03R\bregionId\"\xf1\x02\n" + + "\tregion_id\x18\b \x01(\x03R\bregionId\x12*\n" + + "\x11source_asset_type\x18\t \x01(\tR\x0fsourceAssetType\x12&\n" + + "\x0fgross_usd_minor\x18\n" + + " \x01(\x03R\rgrossUsdMinor\"\xfb\x04\n" + "!TransferPointToCoinSellerResponse\x12%\n" + "\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x12;\n" + "\x1asource_point_balance_after\x18\x02 \x01(\x03R\x17sourcePointBalanceAfter\x120\n" + @@ -29187,20 +29795,51 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\fpoint_amount\x18\x04 \x01(\x03R\vpointAmount\x12,\n" + "\x12seller_coin_amount\x18\x05 \x01(\x03R\x10sellerCoinAmount\x12,\n" + "\x12ratio_point_amount\x18\x06 \x01(\x03R\x10ratioPointAmount\x127\n" + - "\x18ratio_seller_coin_amount\x18\a \x01(\x03R\x15ratioSellerCoinAmount\"\x8f\x01\n" + + "\x18ratio_seller_coin_amount\x18\a \x01(\x03R\x15ratioSellerCoinAmount\x12*\n" + + "\x11source_asset_type\x18\b \x01(\tR\x0fsourceAssetType\x12&\n" + + "\x0fgross_usd_minor\x18\t \x01(\x03R\rgrossUsdMinor\x12\"\n" + + "\rfee_usd_minor\x18\n" + + " \x01(\x03R\vfeeUsdMinor\x12\"\n" + + "\rnet_usd_minor\x18\v \x01(\x03R\vnetUsdMinor\x12(\n" + + "\x10withdraw_fee_bps\x18\f \x01(\x05R\x0ewithdrawFeeBps\x12\x1b\n" + + "\tpolicy_id\x18\r \x01(\x04R\bpolicyId\x12%\n" + + "\x0epolicy_version\x18\x0e \x01(\x04R\rpolicyVersion\"\xc7\x01\n" + "\x1fGetPointWithdrawalConfigRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x1b\n" + "\tregion_id\x18\x03 \x01(\x03R\bregionId\x12\x15\n" + - "\x06now_ms\x18\x04 \x01(\x03R\x05nowMs\"\xf4\x01\n" + + "\x06now_ms\x18\x04 \x01(\x03R\x05nowMs\x12\x1d\n" + + "\n" + + "asset_type\x18\x05 \x01(\tR\tassetType\x12\x17\n" + + "\auser_id\x18\x06 \x01(\x03R\x06userId\"\x8f\x02\n" + + "!PointWithdrawalActionAvailability\x12\x18\n" + + "\aallowed\x18\x01 \x01(\bR\aallowed\x12!\n" + + "\flimit_period\x18\x02 \x01(\tR\vlimitPeriod\x12\x1f\n" + + "\vlimit_count\x18\x03 \x01(\x03R\n" + + "limitCount\x12\x1d\n" + + "\n" + + "used_count\x18\x04 \x01(\x03R\tusedCount\x12'\n" + + "\x0fremaining_count\x18\x05 \x01(\x03R\x0eremainingCount\x12!\n" + + "\fallowed_days\x18\x06 \x01(\tR\vallowedDays\x12!\n" + + "\fblock_reason\x18\a \x01(\tR\vblockReason\"\xd9\x05\n" + " GetPointWithdrawalConfigResponse\x12\x14\n" + "\x05found\x18\x01 \x01(\bR\x05found\x12$\n" + "\x0epoints_per_usd\x18\x02 \x01(\x03R\fpointsPerUsd\x12\x17\n" + "\afee_bps\x18\x03 \x01(\x05R\x06feeBps\x12%\n" + "\x0eminimum_points\x18\x04 \x01(\x03R\rminimumPoints\x120\n" + "\x14policy_instance_code\x18\x05 \x01(\tR\x12policyInstanceCode\x12\"\n" + - "\rcoins_per_usd\x18\x06 \x01(\x03R\vcoinsPerUsd\"\x98\x02\n" + + "\rcoins_per_usd\x18\x06 \x01(\x03R\vcoinsPerUsd\x12\x1f\n" + + "\vpolicy_type\x18\a \x01(\tR\n" + + "policyType\x12;\n" + + "\x1aminimum_withdraw_usd_minor\x18\b \x01(\x03R\x17minimumWithdrawUsdMinor\x123\n" + + "\x16agency_point_share_bps\x18\t \x01(\x05R\x13agencyPointShareBps\x12\x1b\n" + + "\tpolicy_id\x18\n" + + " \x01(\x04R\bpolicyId\x12%\n" + + "\x0epolicy_version\x18\v \x01(\x04R\rpolicyVersion\x125\n" + + "\x16availability_evaluated\x18\f \x01(\bR\x15availabilityEvaluated\x12l\n" + + "\x18coin_seller_availability\x18\r \x01(\v22.hyapp.wallet.v1.PointWithdrawalActionAvailabilityR\x16coinSellerAvailability\x12g\n" + + "\x15platform_availability\x18\x0e \x01(\v22.hyapp.wallet.v1.PointWithdrawalActionAvailabilityR\x14platformAvailability\"\x98\x02\n" + "\x15VipBenefitPreviewItem\x12\x1d\n" + "\n" + "preview_id\x18\x01 \x01(\tR\tpreviewId\x12\x14\n" + @@ -29237,7 +29876,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "%ProcessHostSalaryDailySettlementBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse\x12r\n" + ")ProcessHostSalaryHalfMonthSettlementBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse\x12g\n" + "\x1eProcessHostSalaryMonthEndBatch\x12!.hyapp.wallet.v1.CronBatchRequest\x1a\".hyapp.wallet.v1.CronBatchResponse\x12\x91\x01\n" + - "\x1eProcessVipDailyCoinRebateBatch\x126.hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchRequest\x1a7.hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse2\xc8u\n" + + "\x1eProcessVipDailyCoinRebateBatch\x126.hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchRequest\x1a7.hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse2\xc7t\n" + "\rWalletService\x12R\n" + "\tDebitGift\x12!.hyapp.wallet.v1.DebitGiftRequest\x1a\".hyapp.wallet.v1.DebitGiftResponse\x12^\n" + "\x0fDebitDirectGift\x12'.hyapp.wallet.v1.DebitDirectGiftRequest\x1a\".hyapp.wallet.v1.DebitGiftResponse\x12a\n" + @@ -29246,8 +29885,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\vGetBalances\x12#.hyapp.wallet.v1.GetBalancesRequest\x1a$.hyapp.wallet.v1.GetBalancesResponse\x12\x82\x01\n" + "\x19GetActiveHostSalaryPolicy\x121.hyapp.wallet.v1.GetActiveHostSalaryPolicyRequest\x1a2.hyapp.wallet.v1.GetActiveHostSalaryPolicyResponse\x12v\n" + "\x15GetHostSalaryProgress\x12-.hyapp.wallet.v1.GetHostSalaryProgressRequest\x1a..hyapp.wallet.v1.GetHostSalaryProgressResponse\x12p\n" + - "\x13GetHostRevenueStats\x12+.hyapp.wallet.v1.GetHostRevenueStatsRequest\x1a,.hyapp.wallet.v1.GetHostRevenueStatsResponse\x12\x7f\n" + - "\x18GetAgencyPointShareStats\x120.hyapp.wallet.v1.GetAgencyPointShareStatsRequest\x1a1.hyapp.wallet.v1.GetAgencyPointShareStatsResponse\x12y\n" + + "\x13GetHostRevenueStats\x12+.hyapp.wallet.v1.GetHostRevenueStatsRequest\x1a,.hyapp.wallet.v1.GetHostRevenueStatsResponse\x12y\n" + "\x16GetAgencyHostGiftStats\x12..hyapp.wallet.v1.GetAgencyHostGiftStatsRequest\x1a/.hyapp.wallet.v1.GetAgencyHostGiftStatsResponse\x12y\n" + "\x16GetTeamHostSalaryStats\x12..hyapp.wallet.v1.GetTeamHostSalaryStatsRequest\x1a/.hyapp.wallet.v1.GetTeamHostSalaryStatsResponse\x12g\n" + "\x10AdminCreditAsset\x12(.hyapp.wallet.v1.AdminCreditAssetRequest\x1a).hyapp.wallet.v1.AdminCreditAssetResponse\x12\x85\x01\n" + @@ -29381,7 +30019,7 @@ func file_proto_wallet_v1_wallet_proto_rawDescGZIP() []byte { return file_proto_wallet_v1_wallet_proto_rawDescData } -var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 303) +var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 302) var file_proto_wallet_v1_wallet_proto_goTypes = []any{ (*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest (*DebitDirectGiftRequest)(nil), // 1: hyapp.wallet.v1.DebitDirectGiftRequest @@ -29667,25 +30305,24 @@ var file_proto_wallet_v1_wallet_proto_goTypes = []any{ (*HostRevenueStats)(nil), // 281: hyapp.wallet.v1.HostRevenueStats (*GetHostRevenueStatsRequest)(nil), // 282: hyapp.wallet.v1.GetHostRevenueStatsRequest (*GetHostRevenueStatsResponse)(nil), // 283: hyapp.wallet.v1.GetHostRevenueStatsResponse - (*AgencyPointShareStats)(nil), // 284: hyapp.wallet.v1.AgencyPointShareStats - (*GetAgencyPointShareStatsRequest)(nil), // 285: hyapp.wallet.v1.GetAgencyPointShareStatsRequest - (*GetAgencyPointShareStatsResponse)(nil), // 286: hyapp.wallet.v1.GetAgencyPointShareStatsResponse - (*AgencyHostGiftStats)(nil), // 287: hyapp.wallet.v1.AgencyHostGiftStats - (*GetAgencyHostGiftStatsRequest)(nil), // 288: hyapp.wallet.v1.GetAgencyHostGiftStatsRequest - (*GetAgencyHostGiftStatsResponse)(nil), // 289: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse - (*PointWithdrawalCoinSellerConfig)(nil), // 290: hyapp.wallet.v1.PointWithdrawalCoinSellerConfig - (*ListPointWithdrawalCoinSellersRequest)(nil), // 291: hyapp.wallet.v1.ListPointWithdrawalCoinSellersRequest - (*ListPointWithdrawalCoinSellersResponse)(nil), // 292: hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse - (*TransferPointToCoinSellerRequest)(nil), // 293: hyapp.wallet.v1.TransferPointToCoinSellerRequest - (*TransferPointToCoinSellerResponse)(nil), // 294: hyapp.wallet.v1.TransferPointToCoinSellerResponse - (*GetPointWithdrawalConfigRequest)(nil), // 295: hyapp.wallet.v1.GetPointWithdrawalConfigRequest - (*GetPointWithdrawalConfigResponse)(nil), // 296: hyapp.wallet.v1.GetPointWithdrawalConfigResponse - (*VipBenefitPreviewItem)(nil), // 297: hyapp.wallet.v1.VipBenefitPreviewItem - (*VipBenefitNumericReward)(nil), // 298: hyapp.wallet.v1.VipBenefitNumericReward - (*VipBenefitPresentation)(nil), // 299: hyapp.wallet.v1.VipBenefitPresentation - (*RevokeUserResourceRequest)(nil), // 300: hyapp.wallet.v1.RevokeUserResourceRequest - (*RevokeUserResourceResponse)(nil), // 301: hyapp.wallet.v1.RevokeUserResourceResponse - nil, // 302: hyapp.wallet.v1.HandleV5PayNotifyRequest.FieldsEntry + (*AgencyHostGiftStats)(nil), // 284: hyapp.wallet.v1.AgencyHostGiftStats + (*GetAgencyHostGiftStatsRequest)(nil), // 285: hyapp.wallet.v1.GetAgencyHostGiftStatsRequest + (*GetAgencyHostGiftStatsResponse)(nil), // 286: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse + (*AgencyHostRevenueStats)(nil), // 287: hyapp.wallet.v1.AgencyHostRevenueStats + (*PointWithdrawalCoinSellerConfig)(nil), // 288: hyapp.wallet.v1.PointWithdrawalCoinSellerConfig + (*ListPointWithdrawalCoinSellersRequest)(nil), // 289: hyapp.wallet.v1.ListPointWithdrawalCoinSellersRequest + (*ListPointWithdrawalCoinSellersResponse)(nil), // 290: hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse + (*TransferPointToCoinSellerRequest)(nil), // 291: hyapp.wallet.v1.TransferPointToCoinSellerRequest + (*TransferPointToCoinSellerResponse)(nil), // 292: hyapp.wallet.v1.TransferPointToCoinSellerResponse + (*GetPointWithdrawalConfigRequest)(nil), // 293: hyapp.wallet.v1.GetPointWithdrawalConfigRequest + (*PointWithdrawalActionAvailability)(nil), // 294: hyapp.wallet.v1.PointWithdrawalActionAvailability + (*GetPointWithdrawalConfigResponse)(nil), // 295: hyapp.wallet.v1.GetPointWithdrawalConfigResponse + (*VipBenefitPreviewItem)(nil), // 296: hyapp.wallet.v1.VipBenefitPreviewItem + (*VipBenefitNumericReward)(nil), // 297: hyapp.wallet.v1.VipBenefitNumericReward + (*VipBenefitPresentation)(nil), // 298: hyapp.wallet.v1.VipBenefitPresentation + (*RevokeUserResourceRequest)(nil), // 299: hyapp.wallet.v1.RevokeUserResourceRequest + (*RevokeUserResourceResponse)(nil), // 300: hyapp.wallet.v1.RevokeUserResourceResponse + nil, // 301: hyapp.wallet.v1.HandleV5PayNotifyRequest.FieldsEntry } var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{ 3, // 0: hyapp.wallet.v1.BatchDebitGiftRequest.targets:type_name -> hyapp.wallet.v1.DebitGiftTarget @@ -29770,10 +30407,10 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{ 157, // 79: hyapp.wallet.v1.H5RechargeOptionsResponse.payment_methods:type_name -> hyapp.wallet.v1.ThirdPartyPaymentMethod 169, // 80: hyapp.wallet.v1.ListTemporaryRechargeOrdersResponse.orders:type_name -> hyapp.wallet.v1.ExternalRechargeOrder 169, // 81: hyapp.wallet.v1.H5RechargeOrderResponse.order:type_name -> hyapp.wallet.v1.ExternalRechargeOrder - 302, // 82: hyapp.wallet.v1.HandleV5PayNotifyRequest.fields:type_name -> hyapp.wallet.v1.HandleV5PayNotifyRequest.FieldsEntry + 301, // 82: hyapp.wallet.v1.HandleV5PayNotifyRequest.fields:type_name -> hyapp.wallet.v1.HandleV5PayNotifyRequest.FieldsEntry 184, // 83: hyapp.wallet.v1.GetDiamondExchangeConfigResponse.rules:type_name -> hyapp.wallet.v1.DiamondExchangeRule 189, // 84: hyapp.wallet.v1.ListWalletTransactionsResponse.transactions:type_name -> hyapp.wallet.v1.WalletTransaction - 299, // 85: hyapp.wallet.v1.VipBenefit.presentation:type_name -> hyapp.wallet.v1.VipBenefitPresentation + 298, // 85: hyapp.wallet.v1.VipBenefit.presentation:type_name -> hyapp.wallet.v1.VipBenefitPresentation 48, // 86: hyapp.wallet.v1.VipBenefit.resource:type_name -> hyapp.wallet.v1.Resource 192, // 87: hyapp.wallet.v1.VipLevel.reward_items:type_name -> hyapp.wallet.v1.VipRewardItem 194, // 88: hyapp.wallet.v1.VipLevel.benefits:type_name -> hyapp.wallet.v1.VipBenefit @@ -29839,283 +30476,284 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{ 48, // 148: hyapp.wallet.v1.ResourceShopPurchaseOrder.resource:type_name -> hyapp.wallet.v1.Resource 276, // 149: hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse.orders:type_name -> hyapp.wallet.v1.ResourceShopPurchaseOrder 281, // 150: hyapp.wallet.v1.GetHostRevenueStatsResponse.stats:type_name -> hyapp.wallet.v1.HostRevenueStats - 284, // 151: hyapp.wallet.v1.GetAgencyPointShareStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyPointShareStats - 287, // 152: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyHostGiftStats - 290, // 153: hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse.sellers:type_name -> hyapp.wallet.v1.PointWithdrawalCoinSellerConfig - 297, // 154: hyapp.wallet.v1.VipBenefitPresentation.preview_items:type_name -> hyapp.wallet.v1.VipBenefitPreviewItem - 298, // 155: hyapp.wallet.v1.VipBenefitPresentation.numeric_reward:type_name -> hyapp.wallet.v1.VipBenefitNumericReward - 57, // 156: hyapp.wallet.v1.RevokeUserResourceResponse.resource:type_name -> hyapp.wallet.v1.UserResourceEntitlement - 274, // 157: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest - 274, // 158: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest - 274, // 159: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest - 220, // 160: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:input_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchRequest - 0, // 161: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest - 1, // 162: hyapp.wallet.v1.WalletService.DebitDirectGift:input_type -> hyapp.wallet.v1.DebitDirectGiftRequest - 4, // 163: hyapp.wallet.v1.WalletService.BatchDebitGift:input_type -> hyapp.wallet.v1.BatchDebitGiftRequest - 7, // 164: hyapp.wallet.v1.WalletService.DebitRobotGift:input_type -> hyapp.wallet.v1.DebitRobotGiftRequest - 9, // 165: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest - 13, // 166: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:input_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyRequest - 16, // 167: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:input_type -> hyapp.wallet.v1.GetHostSalaryProgressRequest - 282, // 168: hyapp.wallet.v1.WalletService.GetHostRevenueStats:input_type -> hyapp.wallet.v1.GetHostRevenueStatsRequest - 285, // 169: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:input_type -> hyapp.wallet.v1.GetAgencyPointShareStatsRequest - 288, // 170: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:input_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsRequest - 19, // 171: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:input_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsRequest - 21, // 172: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest - 23, // 173: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest - 25, // 174: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest - 27, // 175: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:input_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildRequest - 30, // 176: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:input_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersRequest - 32, // 177: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:input_type -> hyapp.wallet.v1.ExchangeSalaryToCoinRequest - 34, // 178: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:input_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerRequest - 291, // 179: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:input_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersRequest - 293, // 180: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:input_type -> hyapp.wallet.v1.TransferPointToCoinSellerRequest - 295, // 181: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:input_type -> hyapp.wallet.v1.GetPointWithdrawalConfigRequest - 36, // 182: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:input_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalRequest - 38, // 183: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:input_type -> hyapp.wallet.v1.SettleSalaryWithdrawalRequest - 40, // 184: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:input_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalRequest - 42, // 185: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:input_type -> hyapp.wallet.v1.FreezePointWithdrawalRequest - 44, // 186: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:input_type -> hyapp.wallet.v1.SettlePointWithdrawalRequest - 46, // 187: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:input_type -> hyapp.wallet.v1.ReleasePointWithdrawalRequest - 61, // 188: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest - 63, // 189: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest - 65, // 190: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest - 66, // 191: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest - 67, // 192: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest - 68, // 193: hyapp.wallet.v1.WalletService.DeleteResource:input_type -> hyapp.wallet.v1.DeleteResourceRequest - 69, // 194: hyapp.wallet.v1.WalletService.BatchDeleteResources:input_type -> hyapp.wallet.v1.BatchDeleteResourcesRequest - 72, // 195: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest - 74, // 196: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest - 76, // 197: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest - 77, // 198: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest - 78, // 199: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest - 52, // 200: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:input_type -> hyapp.wallet.v1.PinResourceGroupSnapshotRequest - 80, // 201: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest - 82, // 202: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest - 279, // 203: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:input_type -> hyapp.wallet.v1.GetGiftCatalogVersionRequest - 86, // 204: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest - 87, // 205: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:input_type -> hyapp.wallet.v1.BatchCreateGiftConfigsRequest - 89, // 206: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest - 90, // 207: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest - 91, // 208: hyapp.wallet.v1.WalletService.DeleteGiftConfig:input_type -> hyapp.wallet.v1.DeleteGiftConfigRequest - 84, // 209: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest - 93, // 210: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest - 94, // 211: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest - 95, // 212: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:input_type -> hyapp.wallet.v1.GrantPinnedResourceGroupRequest - 96, // 213: hyapp.wallet.v1.WalletService.RevokeResourceGrant:input_type -> hyapp.wallet.v1.RevokeResourceGrantRequest - 300, // 214: hyapp.wallet.v1.WalletService.RevokeUserResource:input_type -> hyapp.wallet.v1.RevokeUserResourceRequest - 98, // 215: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest - 100, // 216: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest - 102, // 217: hyapp.wallet.v1.WalletService.UnequipUserResource:input_type -> hyapp.wallet.v1.UnequipUserResourceRequest - 104, // 218: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:input_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesRequest - 107, // 219: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest - 110, // 220: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest - 112, // 221: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest - 114, // 222: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest - 277, // 223: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:input_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersRequest - 116, // 224: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest - 119, // 225: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest - 121, // 226: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:input_type -> hyapp.wallet.v1.GetRechargeBillSummaryRequest - 125, // 227: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:input_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsRequest - 127, // 228: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:input_type -> hyapp.wallet.v1.GetRechargeBillOverviewRequest - 133, // 229: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:input_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesRequest - 137, // 230: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest - 140, // 231: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest - 143, // 232: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest - 146, // 233: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest - 148, // 234: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest - 150, // 235: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest - 152, // 236: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest - 153, // 237: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest - 154, // 238: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest - 159, // 239: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:input_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsRequest - 161, // 240: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:input_type -> hyapp.wallet.v1.SetThirdPartyPaymentMethodStatusRequest - 163, // 241: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:input_type -> hyapp.wallet.v1.UpdateThirdPartyPaymentRateRequest - 165, // 242: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:input_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsRequest - 167, // 243: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:input_type -> hyapp.wallet.v1.H5RechargeOptionsRequest - 170, // 244: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:input_type -> hyapp.wallet.v1.CreateH5RechargeOrderRequest - 171, // 245: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.CreateTemporaryRechargeOrderRequest - 172, // 246: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.GetTemporaryRechargeOrderRequest - 173, // 247: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:input_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersRequest - 175, // 248: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:input_type -> hyapp.wallet.v1.SubmitH5RechargeTxRequest - 176, // 249: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:input_type -> hyapp.wallet.v1.GetH5RechargeOrderRequest - 178, // 250: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:input_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptRequest - 180, // 251: hyapp.wallet.v1.WalletService.HandleMifapayNotify:input_type -> hyapp.wallet.v1.HandleMifapayNotifyRequest - 182, // 252: hyapp.wallet.v1.WalletService.HandleV5PayNotify:input_type -> hyapp.wallet.v1.HandleV5PayNotifyRequest - 185, // 253: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest - 187, // 254: hyapp.wallet.v1.WalletService.ExchangePointToCoin:input_type -> hyapp.wallet.v1.ExchangePointToCoinRequest - 190, // 255: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest - 190, // 256: hyapp.wallet.v1.WalletService.ListHostPeriodDiamondTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest - 200, // 257: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest - 212, // 258: hyapp.wallet.v1.WalletService.GetVipProgramConfig:input_type -> hyapp.wallet.v1.GetVipProgramConfigRequest - 202, // 259: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest - 234, // 260: hyapp.wallet.v1.WalletService.CheckVipBenefit:input_type -> hyapp.wallet.v1.CheckVipBenefitRequest - 204, // 261: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest - 206, // 262: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:input_type -> hyapp.wallet.v1.DebitCPBreakupFeeRequest - 208, // 263: hyapp.wallet.v1.WalletService.DebitWheelDraw:input_type -> hyapp.wallet.v1.DebitWheelDrawRequest - 210, // 264: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest - 228, // 265: hyapp.wallet.v1.WalletService.GrantVipTrialCard:input_type -> hyapp.wallet.v1.GrantVipTrialCardRequest - 230, // 266: hyapp.wallet.v1.WalletService.EquipVipTrialCard:input_type -> hyapp.wallet.v1.EquipVipTrialCardRequest - 232, // 267: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:input_type -> hyapp.wallet.v1.UnequipVipTrialCardRequest - 237, // 268: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest - 239, // 269: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest - 214, // 270: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:input_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest - 216, // 271: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:input_type -> hyapp.wallet.v1.UpdateMyVipSettingsRequest - 222, // 272: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:input_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateRequest - 224, // 273: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:input_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesRequest - 226, // 274: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:input_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateRequest - 241, // 275: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest - 243, // 276: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest - 245, // 277: hyapp.wallet.v1.WalletService.CreditWheelReward:input_type -> hyapp.wallet.v1.CreditWheelRewardRequest - 247, // 278: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:input_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardRequest - 249, // 279: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:input_type -> hyapp.wallet.v1.CreditInviteActivityRewardRequest - 251, // 280: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:input_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardRequest - 253, // 281: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest - 258, // 282: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest - 260, // 283: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest - 262, // 284: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest - 264, // 285: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest - 266, // 286: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest - 268, // 287: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest - 270, // 288: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest - 272, // 289: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest - 275, // 290: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse - 275, // 291: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse - 275, // 292: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse - 221, // 293: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:output_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse - 2, // 294: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse - 2, // 295: hyapp.wallet.v1.WalletService.DebitDirectGift:output_type -> hyapp.wallet.v1.DebitGiftResponse - 6, // 296: hyapp.wallet.v1.WalletService.BatchDebitGift:output_type -> hyapp.wallet.v1.BatchDebitGiftResponse - 2, // 297: hyapp.wallet.v1.WalletService.DebitRobotGift:output_type -> hyapp.wallet.v1.DebitGiftResponse - 10, // 298: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse - 14, // 299: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:output_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyResponse - 17, // 300: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:output_type -> hyapp.wallet.v1.GetHostSalaryProgressResponse - 283, // 301: hyapp.wallet.v1.WalletService.GetHostRevenueStats:output_type -> hyapp.wallet.v1.GetHostRevenueStatsResponse - 286, // 302: hyapp.wallet.v1.WalletService.GetAgencyPointShareStats:output_type -> hyapp.wallet.v1.GetAgencyPointShareStatsResponse - 289, // 303: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:output_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsResponse - 20, // 304: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:output_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsResponse - 22, // 305: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse - 24, // 306: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse - 26, // 307: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse - 28, // 308: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:output_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildResponse - 31, // 309: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:output_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersResponse - 33, // 310: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:output_type -> hyapp.wallet.v1.ExchangeSalaryToCoinResponse - 35, // 311: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:output_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerResponse - 292, // 312: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:output_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse - 294, // 313: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:output_type -> hyapp.wallet.v1.TransferPointToCoinSellerResponse - 296, // 314: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:output_type -> hyapp.wallet.v1.GetPointWithdrawalConfigResponse - 37, // 315: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:output_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalResponse - 39, // 316: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:output_type -> hyapp.wallet.v1.SettleSalaryWithdrawalResponse - 41, // 317: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:output_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalResponse - 43, // 318: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:output_type -> hyapp.wallet.v1.FreezePointWithdrawalResponse - 45, // 319: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:output_type -> hyapp.wallet.v1.SettlePointWithdrawalResponse - 47, // 320: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:output_type -> hyapp.wallet.v1.ReleasePointWithdrawalResponse - 62, // 321: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse - 64, // 322: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse - 71, // 323: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse - 71, // 324: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse - 71, // 325: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse - 71, // 326: hyapp.wallet.v1.WalletService.DeleteResource:output_type -> hyapp.wallet.v1.ResourceResponse - 70, // 327: hyapp.wallet.v1.WalletService.BatchDeleteResources:output_type -> hyapp.wallet.v1.BatchDeleteResourcesResponse - 73, // 328: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse - 75, // 329: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse - 79, // 330: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse - 79, // 331: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse - 79, // 332: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse - 53, // 333: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:output_type -> hyapp.wallet.v1.PinResourceGroupSnapshotResponse - 81, // 334: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse - 83, // 335: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse - 280, // 336: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:output_type -> hyapp.wallet.v1.GetGiftCatalogVersionResponse - 92, // 337: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse - 88, // 338: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:output_type -> hyapp.wallet.v1.BatchCreateGiftConfigsResponse - 92, // 339: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse - 92, // 340: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse - 92, // 341: hyapp.wallet.v1.WalletService.DeleteGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse - 85, // 342: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse - 97, // 343: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse - 97, // 344: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse - 97, // 345: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse - 97, // 346: hyapp.wallet.v1.WalletService.RevokeResourceGrant:output_type -> hyapp.wallet.v1.ResourceGrantResponse - 301, // 347: hyapp.wallet.v1.WalletService.RevokeUserResource:output_type -> hyapp.wallet.v1.RevokeUserResourceResponse - 99, // 348: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse - 101, // 349: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse - 103, // 350: hyapp.wallet.v1.WalletService.UnequipUserResource:output_type -> hyapp.wallet.v1.UnequipUserResourceResponse - 106, // 351: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:output_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesResponse - 108, // 352: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse - 111, // 353: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse - 113, // 354: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse - 115, // 355: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse - 278, // 356: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:output_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse - 117, // 357: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse - 120, // 358: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse - 123, // 359: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:output_type -> hyapp.wallet.v1.GetRechargeBillSummaryResponse - 126, // 360: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:output_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsResponse - 132, // 361: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:output_type -> hyapp.wallet.v1.GetRechargeBillOverviewResponse - 135, // 362: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:output_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesResponse - 138, // 363: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse - 141, // 364: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse - 144, // 365: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse - 147, // 366: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse - 149, // 367: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse - 151, // 368: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse - 155, // 369: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse - 155, // 370: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse - 156, // 371: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse - 160, // 372: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:output_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsResponse - 162, // 373: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse - 162, // 374: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse - 166, // 375: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:output_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsResponse - 168, // 376: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:output_type -> hyapp.wallet.v1.H5RechargeOptionsResponse - 177, // 377: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse - 177, // 378: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse - 177, // 379: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse - 174, // 380: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:output_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersResponse - 177, // 381: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse - 177, // 382: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse - 179, // 383: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:output_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptResponse - 181, // 384: hyapp.wallet.v1.WalletService.HandleMifapayNotify:output_type -> hyapp.wallet.v1.HandleMifapayNotifyResponse - 183, // 385: hyapp.wallet.v1.WalletService.HandleV5PayNotify:output_type -> hyapp.wallet.v1.HandleV5PayNotifyResponse - 186, // 386: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse - 188, // 387: hyapp.wallet.v1.WalletService.ExchangePointToCoin:output_type -> hyapp.wallet.v1.ExchangePointToCoinResponse - 191, // 388: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse - 191, // 389: hyapp.wallet.v1.WalletService.ListHostPeriodDiamondTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse - 201, // 390: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse - 213, // 391: hyapp.wallet.v1.WalletService.GetVipProgramConfig:output_type -> hyapp.wallet.v1.GetVipProgramConfigResponse - 203, // 392: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse - 235, // 393: hyapp.wallet.v1.WalletService.CheckVipBenefit:output_type -> hyapp.wallet.v1.CheckVipBenefitResponse - 205, // 394: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse - 207, // 395: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:output_type -> hyapp.wallet.v1.DebitCPBreakupFeeResponse - 209, // 396: hyapp.wallet.v1.WalletService.DebitWheelDraw:output_type -> hyapp.wallet.v1.DebitWheelDrawResponse - 211, // 397: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse - 229, // 398: hyapp.wallet.v1.WalletService.GrantVipTrialCard:output_type -> hyapp.wallet.v1.GrantVipTrialCardResponse - 231, // 399: hyapp.wallet.v1.WalletService.EquipVipTrialCard:output_type -> hyapp.wallet.v1.EquipVipTrialCardResponse - 233, // 400: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:output_type -> hyapp.wallet.v1.UnequipVipTrialCardResponse - 238, // 401: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse - 240, // 402: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse - 215, // 403: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:output_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse - 217, // 404: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:output_type -> hyapp.wallet.v1.UpdateMyVipSettingsResponse - 223, // 405: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:output_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse - 225, // 406: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:output_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse - 227, // 407: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:output_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse - 242, // 408: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse - 244, // 409: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse - 246, // 410: hyapp.wallet.v1.WalletService.CreditWheelReward:output_type -> hyapp.wallet.v1.CreditWheelRewardResponse - 248, // 411: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:output_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardResponse - 250, // 412: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:output_type -> hyapp.wallet.v1.CreditInviteActivityRewardResponse - 252, // 413: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:output_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardResponse - 254, // 414: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse - 259, // 415: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse - 261, // 416: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse - 263, // 417: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse - 265, // 418: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse - 267, // 419: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse - 269, // 420: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse - 271, // 421: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse - 273, // 422: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse - 290, // [290:423] is the sub-list for method output_type - 157, // [157:290] is the sub-list for method input_type - 157, // [157:157] is the sub-list for extension type_name - 157, // [157:157] is the sub-list for extension extendee - 0, // [0:157] is the sub-list for field type_name + 284, // 151: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse.stats:type_name -> hyapp.wallet.v1.AgencyHostGiftStats + 287, // 152: hyapp.wallet.v1.GetAgencyHostGiftStatsResponse.host_stats:type_name -> hyapp.wallet.v1.AgencyHostRevenueStats + 281, // 153: hyapp.wallet.v1.AgencyHostRevenueStats.stats:type_name -> hyapp.wallet.v1.HostRevenueStats + 288, // 154: hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse.sellers:type_name -> hyapp.wallet.v1.PointWithdrawalCoinSellerConfig + 294, // 155: hyapp.wallet.v1.GetPointWithdrawalConfigResponse.coin_seller_availability:type_name -> hyapp.wallet.v1.PointWithdrawalActionAvailability + 294, // 156: hyapp.wallet.v1.GetPointWithdrawalConfigResponse.platform_availability:type_name -> hyapp.wallet.v1.PointWithdrawalActionAvailability + 296, // 157: hyapp.wallet.v1.VipBenefitPresentation.preview_items:type_name -> hyapp.wallet.v1.VipBenefitPreviewItem + 297, // 158: hyapp.wallet.v1.VipBenefitPresentation.numeric_reward:type_name -> hyapp.wallet.v1.VipBenefitNumericReward + 57, // 159: hyapp.wallet.v1.RevokeUserResourceResponse.resource:type_name -> hyapp.wallet.v1.UserResourceEntitlement + 274, // 160: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest + 274, // 161: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:input_type -> hyapp.wallet.v1.CronBatchRequest + 274, // 162: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:input_type -> hyapp.wallet.v1.CronBatchRequest + 220, // 163: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:input_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchRequest + 0, // 164: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest + 1, // 165: hyapp.wallet.v1.WalletService.DebitDirectGift:input_type -> hyapp.wallet.v1.DebitDirectGiftRequest + 4, // 166: hyapp.wallet.v1.WalletService.BatchDebitGift:input_type -> hyapp.wallet.v1.BatchDebitGiftRequest + 7, // 167: hyapp.wallet.v1.WalletService.DebitRobotGift:input_type -> hyapp.wallet.v1.DebitRobotGiftRequest + 9, // 168: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest + 13, // 169: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:input_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyRequest + 16, // 170: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:input_type -> hyapp.wallet.v1.GetHostSalaryProgressRequest + 282, // 171: hyapp.wallet.v1.WalletService.GetHostRevenueStats:input_type -> hyapp.wallet.v1.GetHostRevenueStatsRequest + 285, // 172: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:input_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsRequest + 19, // 173: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:input_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsRequest + 21, // 174: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest + 23, // 175: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:input_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockRequest + 25, // 176: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:input_type -> hyapp.wallet.v1.TransferCoinFromSellerRequest + 27, // 177: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:input_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildRequest + 30, // 178: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:input_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersRequest + 32, // 179: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:input_type -> hyapp.wallet.v1.ExchangeSalaryToCoinRequest + 34, // 180: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:input_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerRequest + 289, // 181: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:input_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersRequest + 291, // 182: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:input_type -> hyapp.wallet.v1.TransferPointToCoinSellerRequest + 293, // 183: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:input_type -> hyapp.wallet.v1.GetPointWithdrawalConfigRequest + 36, // 184: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:input_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalRequest + 38, // 185: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:input_type -> hyapp.wallet.v1.SettleSalaryWithdrawalRequest + 40, // 186: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:input_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalRequest + 42, // 187: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:input_type -> hyapp.wallet.v1.FreezePointWithdrawalRequest + 44, // 188: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:input_type -> hyapp.wallet.v1.SettlePointWithdrawalRequest + 46, // 189: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:input_type -> hyapp.wallet.v1.ReleasePointWithdrawalRequest + 61, // 190: hyapp.wallet.v1.WalletService.ListResources:input_type -> hyapp.wallet.v1.ListResourcesRequest + 63, // 191: hyapp.wallet.v1.WalletService.GetResource:input_type -> hyapp.wallet.v1.GetResourceRequest + 65, // 192: hyapp.wallet.v1.WalletService.CreateResource:input_type -> hyapp.wallet.v1.CreateResourceRequest + 66, // 193: hyapp.wallet.v1.WalletService.UpdateResource:input_type -> hyapp.wallet.v1.UpdateResourceRequest + 67, // 194: hyapp.wallet.v1.WalletService.SetResourceStatus:input_type -> hyapp.wallet.v1.SetResourceStatusRequest + 68, // 195: hyapp.wallet.v1.WalletService.DeleteResource:input_type -> hyapp.wallet.v1.DeleteResourceRequest + 69, // 196: hyapp.wallet.v1.WalletService.BatchDeleteResources:input_type -> hyapp.wallet.v1.BatchDeleteResourcesRequest + 72, // 197: hyapp.wallet.v1.WalletService.ListResourceGroups:input_type -> hyapp.wallet.v1.ListResourceGroupsRequest + 74, // 198: hyapp.wallet.v1.WalletService.GetResourceGroup:input_type -> hyapp.wallet.v1.GetResourceGroupRequest + 76, // 199: hyapp.wallet.v1.WalletService.CreateResourceGroup:input_type -> hyapp.wallet.v1.CreateResourceGroupRequest + 77, // 200: hyapp.wallet.v1.WalletService.UpdateResourceGroup:input_type -> hyapp.wallet.v1.UpdateResourceGroupRequest + 78, // 201: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:input_type -> hyapp.wallet.v1.SetResourceGroupStatusRequest + 52, // 202: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:input_type -> hyapp.wallet.v1.PinResourceGroupSnapshotRequest + 80, // 203: hyapp.wallet.v1.WalletService.ListGiftConfigs:input_type -> hyapp.wallet.v1.ListGiftConfigsRequest + 82, // 204: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:input_type -> hyapp.wallet.v1.ListGiftTypeConfigsRequest + 279, // 205: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:input_type -> hyapp.wallet.v1.GetGiftCatalogVersionRequest + 86, // 206: hyapp.wallet.v1.WalletService.CreateGiftConfig:input_type -> hyapp.wallet.v1.CreateGiftConfigRequest + 87, // 207: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:input_type -> hyapp.wallet.v1.BatchCreateGiftConfigsRequest + 89, // 208: hyapp.wallet.v1.WalletService.UpdateGiftConfig:input_type -> hyapp.wallet.v1.UpdateGiftConfigRequest + 90, // 209: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:input_type -> hyapp.wallet.v1.SetGiftConfigStatusRequest + 91, // 210: hyapp.wallet.v1.WalletService.DeleteGiftConfig:input_type -> hyapp.wallet.v1.DeleteGiftConfigRequest + 84, // 211: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:input_type -> hyapp.wallet.v1.UpsertGiftTypeConfigRequest + 93, // 212: hyapp.wallet.v1.WalletService.GrantResource:input_type -> hyapp.wallet.v1.GrantResourceRequest + 94, // 213: hyapp.wallet.v1.WalletService.GrantResourceGroup:input_type -> hyapp.wallet.v1.GrantResourceGroupRequest + 95, // 214: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:input_type -> hyapp.wallet.v1.GrantPinnedResourceGroupRequest + 96, // 215: hyapp.wallet.v1.WalletService.RevokeResourceGrant:input_type -> hyapp.wallet.v1.RevokeResourceGrantRequest + 299, // 216: hyapp.wallet.v1.WalletService.RevokeUserResource:input_type -> hyapp.wallet.v1.RevokeUserResourceRequest + 98, // 217: hyapp.wallet.v1.WalletService.ListUserResources:input_type -> hyapp.wallet.v1.ListUserResourcesRequest + 100, // 218: hyapp.wallet.v1.WalletService.EquipUserResource:input_type -> hyapp.wallet.v1.EquipUserResourceRequest + 102, // 219: hyapp.wallet.v1.WalletService.UnequipUserResource:input_type -> hyapp.wallet.v1.UnequipUserResourceRequest + 104, // 220: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:input_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesRequest + 107, // 221: hyapp.wallet.v1.WalletService.ListResourceGrants:input_type -> hyapp.wallet.v1.ListResourceGrantsRequest + 110, // 222: hyapp.wallet.v1.WalletService.ListResourceShopItems:input_type -> hyapp.wallet.v1.ListResourceShopItemsRequest + 112, // 223: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:input_type -> hyapp.wallet.v1.UpsertResourceShopItemsRequest + 114, // 224: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:input_type -> hyapp.wallet.v1.SetResourceShopItemStatusRequest + 277, // 225: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:input_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersRequest + 116, // 226: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:input_type -> hyapp.wallet.v1.PurchaseResourceShopItemRequest + 119, // 227: hyapp.wallet.v1.WalletService.ListRechargeBills:input_type -> hyapp.wallet.v1.ListRechargeBillsRequest + 121, // 228: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:input_type -> hyapp.wallet.v1.GetRechargeBillSummaryRequest + 125, // 229: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:input_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsRequest + 127, // 230: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:input_type -> hyapp.wallet.v1.GetRechargeBillOverviewRequest + 133, // 231: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:input_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesRequest + 137, // 232: hyapp.wallet.v1.WalletService.GetWalletOverview:input_type -> hyapp.wallet.v1.GetWalletOverviewRequest + 140, // 233: hyapp.wallet.v1.WalletService.GetWalletValueSummary:input_type -> hyapp.wallet.v1.GetWalletValueSummaryRequest + 143, // 234: hyapp.wallet.v1.WalletService.GetUserGiftWall:input_type -> hyapp.wallet.v1.GetUserGiftWallRequest + 146, // 235: hyapp.wallet.v1.WalletService.ListRechargeProducts:input_type -> hyapp.wallet.v1.ListRechargeProductsRequest + 148, // 236: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:input_type -> hyapp.wallet.v1.ConfirmGooglePaymentRequest + 150, // 237: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:input_type -> hyapp.wallet.v1.ListAdminRechargeProductsRequest + 152, // 238: hyapp.wallet.v1.WalletService.CreateRechargeProduct:input_type -> hyapp.wallet.v1.CreateRechargeProductRequest + 153, // 239: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:input_type -> hyapp.wallet.v1.UpdateRechargeProductRequest + 154, // 240: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:input_type -> hyapp.wallet.v1.DeleteRechargeProductRequest + 159, // 241: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:input_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsRequest + 161, // 242: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:input_type -> hyapp.wallet.v1.SetThirdPartyPaymentMethodStatusRequest + 163, // 243: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:input_type -> hyapp.wallet.v1.UpdateThirdPartyPaymentRateRequest + 165, // 244: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:input_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsRequest + 167, // 245: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:input_type -> hyapp.wallet.v1.H5RechargeOptionsRequest + 170, // 246: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:input_type -> hyapp.wallet.v1.CreateH5RechargeOrderRequest + 171, // 247: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.CreateTemporaryRechargeOrderRequest + 172, // 248: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:input_type -> hyapp.wallet.v1.GetTemporaryRechargeOrderRequest + 173, // 249: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:input_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersRequest + 175, // 250: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:input_type -> hyapp.wallet.v1.SubmitH5RechargeTxRequest + 176, // 251: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:input_type -> hyapp.wallet.v1.GetH5RechargeOrderRequest + 178, // 252: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:input_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptRequest + 180, // 253: hyapp.wallet.v1.WalletService.HandleMifapayNotify:input_type -> hyapp.wallet.v1.HandleMifapayNotifyRequest + 182, // 254: hyapp.wallet.v1.WalletService.HandleV5PayNotify:input_type -> hyapp.wallet.v1.HandleV5PayNotifyRequest + 185, // 255: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:input_type -> hyapp.wallet.v1.GetDiamondExchangeConfigRequest + 187, // 256: hyapp.wallet.v1.WalletService.ExchangePointToCoin:input_type -> hyapp.wallet.v1.ExchangePointToCoinRequest + 190, // 257: hyapp.wallet.v1.WalletService.ListWalletTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest + 190, // 258: hyapp.wallet.v1.WalletService.ListHostPeriodDiamondTransactions:input_type -> hyapp.wallet.v1.ListWalletTransactionsRequest + 200, // 259: hyapp.wallet.v1.WalletService.ListVipPackages:input_type -> hyapp.wallet.v1.ListVipPackagesRequest + 212, // 260: hyapp.wallet.v1.WalletService.GetVipProgramConfig:input_type -> hyapp.wallet.v1.GetVipProgramConfigRequest + 202, // 261: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest + 234, // 262: hyapp.wallet.v1.WalletService.CheckVipBenefit:input_type -> hyapp.wallet.v1.CheckVipBenefitRequest + 204, // 263: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest + 206, // 264: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:input_type -> hyapp.wallet.v1.DebitCPBreakupFeeRequest + 208, // 265: hyapp.wallet.v1.WalletService.DebitWheelDraw:input_type -> hyapp.wallet.v1.DebitWheelDrawRequest + 210, // 266: hyapp.wallet.v1.WalletService.GrantVip:input_type -> hyapp.wallet.v1.GrantVipRequest + 228, // 267: hyapp.wallet.v1.WalletService.GrantVipTrialCard:input_type -> hyapp.wallet.v1.GrantVipTrialCardRequest + 230, // 268: hyapp.wallet.v1.WalletService.EquipVipTrialCard:input_type -> hyapp.wallet.v1.EquipVipTrialCardRequest + 232, // 269: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:input_type -> hyapp.wallet.v1.UnequipVipTrialCardRequest + 237, // 270: hyapp.wallet.v1.WalletService.ListAdminVipLevels:input_type -> hyapp.wallet.v1.ListAdminVipLevelsRequest + 239, // 271: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:input_type -> hyapp.wallet.v1.UpdateAdminVipLevelsRequest + 214, // 272: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:input_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigRequest + 216, // 273: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:input_type -> hyapp.wallet.v1.UpdateMyVipSettingsRequest + 222, // 274: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:input_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateRequest + 224, // 275: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:input_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesRequest + 226, // 276: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:input_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateRequest + 241, // 277: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest + 243, // 278: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:input_type -> hyapp.wallet.v1.CreditLuckyGiftRewardRequest + 245, // 279: hyapp.wallet.v1.WalletService.CreditWheelReward:input_type -> hyapp.wallet.v1.CreditWheelRewardRequest + 247, // 280: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:input_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardRequest + 249, // 281: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:input_type -> hyapp.wallet.v1.CreditInviteActivityRewardRequest + 251, // 282: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:input_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardRequest + 253, // 283: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest + 258, // 284: hyapp.wallet.v1.WalletService.GetRedPacketConfig:input_type -> hyapp.wallet.v1.GetRedPacketConfigRequest + 260, // 285: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:input_type -> hyapp.wallet.v1.UpdateRedPacketConfigRequest + 262, // 286: hyapp.wallet.v1.WalletService.CreateRedPacket:input_type -> hyapp.wallet.v1.CreateRedPacketRequest + 264, // 287: hyapp.wallet.v1.WalletService.ClaimRedPacket:input_type -> hyapp.wallet.v1.ClaimRedPacketRequest + 266, // 288: hyapp.wallet.v1.WalletService.ListRedPackets:input_type -> hyapp.wallet.v1.ListRedPacketsRequest + 268, // 289: hyapp.wallet.v1.WalletService.GetRedPacket:input_type -> hyapp.wallet.v1.GetRedPacketRequest + 270, // 290: hyapp.wallet.v1.WalletService.ExpireRedPackets:input_type -> hyapp.wallet.v1.ExpireRedPacketsRequest + 272, // 291: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:input_type -> hyapp.wallet.v1.RetryRedPacketRefundRequest + 275, // 292: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryDailySettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse + 275, // 293: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryHalfMonthSettlementBatch:output_type -> hyapp.wallet.v1.CronBatchResponse + 275, // 294: hyapp.wallet.v1.WalletCronService.ProcessHostSalaryMonthEndBatch:output_type -> hyapp.wallet.v1.CronBatchResponse + 221, // 295: hyapp.wallet.v1.WalletCronService.ProcessVipDailyCoinRebateBatch:output_type -> hyapp.wallet.v1.ProcessVipDailyCoinRebateBatchResponse + 2, // 296: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse + 2, // 297: hyapp.wallet.v1.WalletService.DebitDirectGift:output_type -> hyapp.wallet.v1.DebitGiftResponse + 6, // 298: hyapp.wallet.v1.WalletService.BatchDebitGift:output_type -> hyapp.wallet.v1.BatchDebitGiftResponse + 2, // 299: hyapp.wallet.v1.WalletService.DebitRobotGift:output_type -> hyapp.wallet.v1.DebitGiftResponse + 10, // 300: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse + 14, // 301: hyapp.wallet.v1.WalletService.GetActiveHostSalaryPolicy:output_type -> hyapp.wallet.v1.GetActiveHostSalaryPolicyResponse + 17, // 302: hyapp.wallet.v1.WalletService.GetHostSalaryProgress:output_type -> hyapp.wallet.v1.GetHostSalaryProgressResponse + 283, // 303: hyapp.wallet.v1.WalletService.GetHostRevenueStats:output_type -> hyapp.wallet.v1.GetHostRevenueStatsResponse + 286, // 304: hyapp.wallet.v1.WalletService.GetAgencyHostGiftStats:output_type -> hyapp.wallet.v1.GetAgencyHostGiftStatsResponse + 20, // 305: hyapp.wallet.v1.WalletService.GetTeamHostSalaryStats:output_type -> hyapp.wallet.v1.GetTeamHostSalaryStatsResponse + 22, // 306: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse + 24, // 307: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse + 26, // 308: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse + 28, // 309: hyapp.wallet.v1.WalletService.TransferCoinSellerStockToChild:output_type -> hyapp.wallet.v1.TransferCoinSellerStockToChildResponse + 31, // 310: hyapp.wallet.v1.WalletService.ListCoinSellerSalaryExchangeRateTiers:output_type -> hyapp.wallet.v1.ListCoinSellerSalaryExchangeRateTiersResponse + 33, // 311: hyapp.wallet.v1.WalletService.ExchangeSalaryToCoin:output_type -> hyapp.wallet.v1.ExchangeSalaryToCoinResponse + 35, // 312: hyapp.wallet.v1.WalletService.TransferSalaryToCoinSeller:output_type -> hyapp.wallet.v1.TransferSalaryToCoinSellerResponse + 290, // 313: hyapp.wallet.v1.WalletService.ListPointWithdrawalCoinSellers:output_type -> hyapp.wallet.v1.ListPointWithdrawalCoinSellersResponse + 292, // 314: hyapp.wallet.v1.WalletService.TransferPointToCoinSeller:output_type -> hyapp.wallet.v1.TransferPointToCoinSellerResponse + 295, // 315: hyapp.wallet.v1.WalletService.GetPointWithdrawalConfig:output_type -> hyapp.wallet.v1.GetPointWithdrawalConfigResponse + 37, // 316: hyapp.wallet.v1.WalletService.FreezeSalaryWithdrawal:output_type -> hyapp.wallet.v1.FreezeSalaryWithdrawalResponse + 39, // 317: hyapp.wallet.v1.WalletService.SettleSalaryWithdrawal:output_type -> hyapp.wallet.v1.SettleSalaryWithdrawalResponse + 41, // 318: hyapp.wallet.v1.WalletService.ReleaseSalaryWithdrawal:output_type -> hyapp.wallet.v1.ReleaseSalaryWithdrawalResponse + 43, // 319: hyapp.wallet.v1.WalletService.FreezePointWithdrawal:output_type -> hyapp.wallet.v1.FreezePointWithdrawalResponse + 45, // 320: hyapp.wallet.v1.WalletService.SettlePointWithdrawal:output_type -> hyapp.wallet.v1.SettlePointWithdrawalResponse + 47, // 321: hyapp.wallet.v1.WalletService.ReleasePointWithdrawal:output_type -> hyapp.wallet.v1.ReleasePointWithdrawalResponse + 62, // 322: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse + 64, // 323: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse + 71, // 324: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse + 71, // 325: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse + 71, // 326: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse + 71, // 327: hyapp.wallet.v1.WalletService.DeleteResource:output_type -> hyapp.wallet.v1.ResourceResponse + 70, // 328: hyapp.wallet.v1.WalletService.BatchDeleteResources:output_type -> hyapp.wallet.v1.BatchDeleteResourcesResponse + 73, // 329: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse + 75, // 330: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse + 79, // 331: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse + 79, // 332: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse + 79, // 333: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse + 53, // 334: hyapp.wallet.v1.WalletService.PinResourceGroupSnapshot:output_type -> hyapp.wallet.v1.PinResourceGroupSnapshotResponse + 81, // 335: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse + 83, // 336: hyapp.wallet.v1.WalletService.ListGiftTypeConfigs:output_type -> hyapp.wallet.v1.ListGiftTypeConfigsResponse + 280, // 337: hyapp.wallet.v1.WalletService.GetGiftCatalogVersion:output_type -> hyapp.wallet.v1.GetGiftCatalogVersionResponse + 92, // 338: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse + 88, // 339: hyapp.wallet.v1.WalletService.BatchCreateGiftConfigs:output_type -> hyapp.wallet.v1.BatchCreateGiftConfigsResponse + 92, // 340: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse + 92, // 341: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse + 92, // 342: hyapp.wallet.v1.WalletService.DeleteGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse + 85, // 343: hyapp.wallet.v1.WalletService.UpsertGiftTypeConfig:output_type -> hyapp.wallet.v1.GiftTypeConfigResponse + 97, // 344: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse + 97, // 345: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse + 97, // 346: hyapp.wallet.v1.WalletService.GrantPinnedResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse + 97, // 347: hyapp.wallet.v1.WalletService.RevokeResourceGrant:output_type -> hyapp.wallet.v1.ResourceGrantResponse + 300, // 348: hyapp.wallet.v1.WalletService.RevokeUserResource:output_type -> hyapp.wallet.v1.RevokeUserResourceResponse + 99, // 349: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse + 101, // 350: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse + 103, // 351: hyapp.wallet.v1.WalletService.UnequipUserResource:output_type -> hyapp.wallet.v1.UnequipUserResourceResponse + 106, // 352: hyapp.wallet.v1.WalletService.BatchGetUserEquippedResources:output_type -> hyapp.wallet.v1.BatchGetUserEquippedResourcesResponse + 108, // 353: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse + 111, // 354: hyapp.wallet.v1.WalletService.ListResourceShopItems:output_type -> hyapp.wallet.v1.ListResourceShopItemsResponse + 113, // 355: hyapp.wallet.v1.WalletService.UpsertResourceShopItems:output_type -> hyapp.wallet.v1.UpsertResourceShopItemsResponse + 115, // 356: hyapp.wallet.v1.WalletService.SetResourceShopItemStatus:output_type -> hyapp.wallet.v1.ResourceShopItemResponse + 278, // 357: hyapp.wallet.v1.WalletService.ListResourceShopPurchaseOrders:output_type -> hyapp.wallet.v1.ListResourceShopPurchaseOrdersResponse + 117, // 358: hyapp.wallet.v1.WalletService.PurchaseResourceShopItem:output_type -> hyapp.wallet.v1.PurchaseResourceShopItemResponse + 120, // 359: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse + 123, // 360: hyapp.wallet.v1.WalletService.GetRechargeBillSummary:output_type -> hyapp.wallet.v1.GetRechargeBillSummaryResponse + 126, // 361: hyapp.wallet.v1.WalletService.BatchGetUserRechargeStats:output_type -> hyapp.wallet.v1.BatchGetUserRechargeStatsResponse + 132, // 362: hyapp.wallet.v1.WalletService.GetRechargeBillOverview:output_type -> hyapp.wallet.v1.GetRechargeBillOverviewResponse + 135, // 363: hyapp.wallet.v1.WalletService.RefreshGooglePaymentPrices:output_type -> hyapp.wallet.v1.RefreshGooglePaymentPricesResponse + 138, // 364: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse + 141, // 365: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse + 144, // 366: hyapp.wallet.v1.WalletService.GetUserGiftWall:output_type -> hyapp.wallet.v1.GetUserGiftWallResponse + 147, // 367: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse + 149, // 368: hyapp.wallet.v1.WalletService.ConfirmGooglePayment:output_type -> hyapp.wallet.v1.ConfirmGooglePaymentResponse + 151, // 369: hyapp.wallet.v1.WalletService.ListAdminRechargeProducts:output_type -> hyapp.wallet.v1.ListAdminRechargeProductsResponse + 155, // 370: hyapp.wallet.v1.WalletService.CreateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse + 155, // 371: hyapp.wallet.v1.WalletService.UpdateRechargeProduct:output_type -> hyapp.wallet.v1.RechargeProductResponse + 156, // 372: hyapp.wallet.v1.WalletService.DeleteRechargeProduct:output_type -> hyapp.wallet.v1.DeleteRechargeProductResponse + 160, // 373: hyapp.wallet.v1.WalletService.ListThirdPartyPaymentChannels:output_type -> hyapp.wallet.v1.ListThirdPartyPaymentChannelsResponse + 162, // 374: hyapp.wallet.v1.WalletService.SetThirdPartyPaymentMethodStatus:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse + 162, // 375: hyapp.wallet.v1.WalletService.UpdateThirdPartyPaymentRate:output_type -> hyapp.wallet.v1.ThirdPartyPaymentMethodResponse + 166, // 376: hyapp.wallet.v1.WalletService.SyncThirdPartyPaymentMethods:output_type -> hyapp.wallet.v1.SyncThirdPartyPaymentMethodsResponse + 168, // 377: hyapp.wallet.v1.WalletService.ListH5RechargeOptions:output_type -> hyapp.wallet.v1.H5RechargeOptionsResponse + 177, // 378: hyapp.wallet.v1.WalletService.CreateH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse + 177, // 379: hyapp.wallet.v1.WalletService.CreateTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse + 177, // 380: hyapp.wallet.v1.WalletService.GetTemporaryRechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse + 174, // 381: hyapp.wallet.v1.WalletService.ListTemporaryRechargeOrders:output_type -> hyapp.wallet.v1.ListTemporaryRechargeOrdersResponse + 177, // 382: hyapp.wallet.v1.WalletService.SubmitH5RechargeTx:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse + 177, // 383: hyapp.wallet.v1.WalletService.GetH5RechargeOrder:output_type -> hyapp.wallet.v1.H5RechargeOrderResponse + 179, // 384: hyapp.wallet.v1.WalletService.VerifyCoinSellerRechargeReceipt:output_type -> hyapp.wallet.v1.VerifyCoinSellerRechargeReceiptResponse + 181, // 385: hyapp.wallet.v1.WalletService.HandleMifapayNotify:output_type -> hyapp.wallet.v1.HandleMifapayNotifyResponse + 183, // 386: hyapp.wallet.v1.WalletService.HandleV5PayNotify:output_type -> hyapp.wallet.v1.HandleV5PayNotifyResponse + 186, // 387: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse + 188, // 388: hyapp.wallet.v1.WalletService.ExchangePointToCoin:output_type -> hyapp.wallet.v1.ExchangePointToCoinResponse + 191, // 389: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse + 191, // 390: hyapp.wallet.v1.WalletService.ListHostPeriodDiamondTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse + 201, // 391: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse + 213, // 392: hyapp.wallet.v1.WalletService.GetVipProgramConfig:output_type -> hyapp.wallet.v1.GetVipProgramConfigResponse + 203, // 393: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse + 235, // 394: hyapp.wallet.v1.WalletService.CheckVipBenefit:output_type -> hyapp.wallet.v1.CheckVipBenefitResponse + 205, // 395: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse + 207, // 396: hyapp.wallet.v1.WalletService.DebitCPBreakupFee:output_type -> hyapp.wallet.v1.DebitCPBreakupFeeResponse + 209, // 397: hyapp.wallet.v1.WalletService.DebitWheelDraw:output_type -> hyapp.wallet.v1.DebitWheelDrawResponse + 211, // 398: hyapp.wallet.v1.WalletService.GrantVip:output_type -> hyapp.wallet.v1.GrantVipResponse + 229, // 399: hyapp.wallet.v1.WalletService.GrantVipTrialCard:output_type -> hyapp.wallet.v1.GrantVipTrialCardResponse + 231, // 400: hyapp.wallet.v1.WalletService.EquipVipTrialCard:output_type -> hyapp.wallet.v1.EquipVipTrialCardResponse + 233, // 401: hyapp.wallet.v1.WalletService.UnequipVipTrialCard:output_type -> hyapp.wallet.v1.UnequipVipTrialCardResponse + 238, // 402: hyapp.wallet.v1.WalletService.ListAdminVipLevels:output_type -> hyapp.wallet.v1.ListAdminVipLevelsResponse + 240, // 403: hyapp.wallet.v1.WalletService.UpdateAdminVipLevels:output_type -> hyapp.wallet.v1.UpdateAdminVipLevelsResponse + 215, // 404: hyapp.wallet.v1.WalletService.UpdateAdminVipProgramConfig:output_type -> hyapp.wallet.v1.UpdateAdminVipProgramConfigResponse + 217, // 405: hyapp.wallet.v1.WalletService.UpdateMyVipSettings:output_type -> hyapp.wallet.v1.UpdateMyVipSettingsResponse + 223, // 406: hyapp.wallet.v1.WalletService.GetMyCurrentVipDailyCoinRebate:output_type -> hyapp.wallet.v1.GetMyCurrentVipDailyCoinRebateResponse + 225, // 407: hyapp.wallet.v1.WalletService.ListMyVipDailyCoinRebateStatuses:output_type -> hyapp.wallet.v1.ListMyVipDailyCoinRebateStatusesResponse + 227, // 408: hyapp.wallet.v1.WalletService.ClaimVipDailyCoinRebate:output_type -> hyapp.wallet.v1.ClaimVipDailyCoinRebateResponse + 242, // 409: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse + 244, // 410: hyapp.wallet.v1.WalletService.CreditLuckyGiftReward:output_type -> hyapp.wallet.v1.CreditLuckyGiftRewardResponse + 246, // 411: hyapp.wallet.v1.WalletService.CreditWheelReward:output_type -> hyapp.wallet.v1.CreditWheelRewardResponse + 248, // 412: hyapp.wallet.v1.WalletService.CreditRoomTurnoverReward:output_type -> hyapp.wallet.v1.CreditRoomTurnoverRewardResponse + 250, // 413: hyapp.wallet.v1.WalletService.CreditInviteActivityReward:output_type -> hyapp.wallet.v1.CreditInviteActivityRewardResponse + 252, // 414: hyapp.wallet.v1.WalletService.CreditAgencyOpeningReward:output_type -> hyapp.wallet.v1.CreditAgencyOpeningRewardResponse + 254, // 415: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse + 259, // 416: hyapp.wallet.v1.WalletService.GetRedPacketConfig:output_type -> hyapp.wallet.v1.GetRedPacketConfigResponse + 261, // 417: hyapp.wallet.v1.WalletService.UpdateRedPacketConfig:output_type -> hyapp.wallet.v1.UpdateRedPacketConfigResponse + 263, // 418: hyapp.wallet.v1.WalletService.CreateRedPacket:output_type -> hyapp.wallet.v1.CreateRedPacketResponse + 265, // 419: hyapp.wallet.v1.WalletService.ClaimRedPacket:output_type -> hyapp.wallet.v1.ClaimRedPacketResponse + 267, // 420: hyapp.wallet.v1.WalletService.ListRedPackets:output_type -> hyapp.wallet.v1.ListRedPacketsResponse + 269, // 421: hyapp.wallet.v1.WalletService.GetRedPacket:output_type -> hyapp.wallet.v1.GetRedPacketResponse + 271, // 422: hyapp.wallet.v1.WalletService.ExpireRedPackets:output_type -> hyapp.wallet.v1.ExpireRedPacketsResponse + 273, // 423: hyapp.wallet.v1.WalletService.RetryRedPacketRefund:output_type -> hyapp.wallet.v1.RetryRedPacketRefundResponse + 292, // [292:424] is the sub-list for method output_type + 160, // [160:292] is the sub-list for method input_type + 160, // [160:160] is the sub-list for extension type_name + 160, // [160:160] is the sub-list for extension extendee + 0, // [0:160] is the sub-list for field type_name } func init() { file_proto_wallet_v1_wallet_proto_init() } @@ -30135,7 +30773,7 @@ func file_proto_wallet_v1_wallet_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_wallet_v1_wallet_proto_rawDesc), len(file_proto_wallet_v1_wallet_proto_rawDesc)), NumEnums: 0, - NumMessages: 303, + NumMessages: 302, NumExtensions: 0, NumServices: 2, }, diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 05ecbb11..9fc92682 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -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); diff --git a/api/proto/wallet/v1/wallet_grpc.pb.go b/api/proto/wallet/v1/wallet_grpc.pb.go index 6cdf19c9..0c1ce2f4 100644 --- a/api/proto/wallet/v1/wallet_grpc.pb.go +++ b/api/proto/wallet/v1/wallet_grpc.pb.go @@ -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, diff --git a/pkg/appcode/appcode.go b/pkg/appcode/appcode.go index 7e457e19..7b5fb4cc 100644 --- a/pkg/appcode/appcode.go +++ b/pkg/appcode/appcode.go @@ -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)) diff --git a/pkg/tencentim/rest_client.go b/pkg/tencentim/rest_client.go index 8d812b82..b6cb39c3 100644 --- a/pkg/tencentim/rest_client.go +++ b/pkg/tencentim/rest_client.go @@ -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"` diff --git a/pkg/tencentim/rest_client_test.go b/pkg/tencentim/rest_client_test.go index 98d2199c..51027818 100644 --- a/pkg/tencentim/rest_client_test.go +++ b/pkg/tencentim/rest_client_test.go @@ -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) { diff --git a/pkg/userleaderboard/store.go b/pkg/userleaderboard/store.go index 93e474c9..7ec192ef 100644 --- a/pkg/userleaderboard/store.go +++ b/pkg/userleaderboard/store.go @@ -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) diff --git a/pkg/userleaderboard/store_test.go b/pkg/userleaderboard/store_test.go index ea3ce104..45623b30 100644 --- a/pkg/userleaderboard/store_test.go +++ b/pkg/userleaderboard/store_test.go @@ -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) + } +} diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index b96da384..3b429419 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -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), diff --git a/server/admin/internal/integration/activityclient/client.go b/server/admin/internal/integration/activityclient/client.go index 87928159..24c95496 100644 --- a/server/admin/internal/integration/activityclient/client.go +++ b/server/admin/internal/integration/activityclient/client.go @@ -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) } diff --git a/server/admin/internal/integration/userclient/client.go b/server/admin/internal/integration/userclient/client.go index 75973316..2fc85a4b 100644 --- a/server/admin/internal/integration/userclient/client.go +++ b/server/admin/internal/integration/userclient/client.go @@ -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), diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 017a5eb7..e402dfa2 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -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"` diff --git a/server/admin/internal/modules/appconfig/handler.go b/server/admin/internal/modules/appconfig/handler.go index 66f59070..664ca2b3 100644 --- a/server/admin/internal/modules/appconfig/handler.go +++ b/server/admin/internal/modules/appconfig/handler.go @@ -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) diff --git a/server/admin/internal/modules/appconfig/request.go b/server/admin/internal/modules/appconfig/request.go index d67fa254..8bfdb942 100644 --- a/server/admin/internal/modules/appconfig/request.go +++ b/server/admin/internal/modules/appconfig/request.go @@ -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"` diff --git a/server/admin/internal/modules/appconfig/role_scope_policies.go b/server/admin/internal/modules/appconfig/role_scope_policies.go new file mode 100644 index 00000000..52bc0ef5 --- /dev/null +++ b/server/admin/internal/modules/appconfig/role_scope_policies.go @@ -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 + } +} diff --git a/server/admin/internal/modules/appconfig/role_scope_policies_handler.go b/server/admin/internal/modules/appconfig/role_scope_policies_handler.go new file mode 100644 index 00000000..c35beae6 --- /dev/null +++ b/server/admin/internal/modules/appconfig/role_scope_policies_handler.go @@ -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) +} diff --git a/server/admin/internal/modules/appconfig/room_discovery.go b/server/admin/internal/modules/appconfig/room_discovery.go new file mode 100644 index 00000000..01785858 --- /dev/null +++ b/server/admin/internal/modules/appconfig/room_discovery.go @@ -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, + } +} diff --git a/server/admin/internal/modules/appconfig/room_discovery_handler.go b/server/admin/internal/modules/appconfig/room_discovery_handler.go new file mode 100644 index 00000000..ead8d09d --- /dev/null +++ b/server/admin/internal/modules/appconfig/room_discovery_handler.go @@ -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) +} diff --git a/server/admin/internal/modules/appconfig/routes.go b/server/admin/internal/modules/appconfig/routes.go index c5f09eaf..9999cfda 100644 --- a/server/admin/internal/modules/appconfig/routes.go +++ b/server/admin/internal/modules/appconfig/routes.go @@ -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) diff --git a/server/admin/internal/modules/appconfig/runtime_capabilities.go b/server/admin/internal/modules/appconfig/runtime_capabilities.go new file mode 100644 index 00000000..476af1c8 --- /dev/null +++ b/server/admin/internal/modules/appconfig/runtime_capabilities.go @@ -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 +} diff --git a/server/admin/internal/modules/appconfig/runtime_capabilities_handler.go b/server/admin/internal/modules/appconfig/runtime_capabilities_handler.go new file mode 100644 index 00000000..52425950 --- /dev/null +++ b/server/admin/internal/modules/appconfig/runtime_capabilities_handler.go @@ -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) +} diff --git a/server/admin/internal/modules/appconfig/service.go b/server/admin/internal/modules/appconfig/service.go index cf16d436..592bb64a 100644 --- a/server/admin/internal/modules/appconfig/service.go +++ b/server/admin/internal/modules/appconfig/service.go @@ -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. diff --git a/server/admin/internal/modules/appconfig/service_test.go b/server/admin/internal/modules/appconfig/service_test.go index 8b52fbb2..04056075 100644 --- a/server/admin/internal/modules/appconfig/service_test.go +++ b/server/admin/internal/modules/appconfig/service_test.go @@ -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", diff --git a/server/admin/internal/modules/dailytask/handler.go b/server/admin/internal/modules/dailytask/handler.go index 4c59e946..ec2a36a2 100644 --- a/server/admin/internal/modules/dailytask/handler.go +++ b/server/admin/internal/modules/dailytask/handler.go @@ -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"` diff --git a/server/admin/internal/modules/databi/service.go b/server/admin/internal/modules/databi/service.go index 3952cfec..5509acaf 100644 --- a/server/admin/internal/modules/databi/service.go +++ b/server/admin/internal/modules/databi/service.go @@ -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, diff --git a/server/admin/internal/modules/databi/service_test.go b/server/admin/internal/modules/databi/service_test.go index 8f8c7f45..0cc38b62 100644 --- a/server/admin/internal/modules/databi/service_test.go +++ b/server/admin/internal/modules/databi/service_test.go @@ -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, diff --git a/server/admin/internal/modules/financewithdrawal/service.go b/server/admin/internal/modules/financewithdrawal/service.go index 2d8f817d..97faee1c 100644 --- a/server/admin/internal/modules/financewithdrawal/service.go +++ b/server/admin/internal/modules/financewithdrawal/service.go @@ -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 diff --git a/server/admin/internal/modules/financewithdrawal/service_test.go b/server/admin/internal/modules/financewithdrawal/service_test.go index 25947ebc..624744ed 100644 --- a/server/admin/internal/modules/financewithdrawal/service_test.go +++ b/server/admin/internal/modules/financewithdrawal/service_test.go @@ -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) } diff --git a/server/admin/internal/modules/giftdiamond/service.go b/server/admin/internal/modules/giftdiamond/service.go index 6fa26c8e..51c05637 100644 --- a/server/admin/internal/modules/giftdiamond/service.go +++ b/server/admin/internal/modules/giftdiamond/service.go @@ -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 -} diff --git a/server/admin/internal/modules/giftdiamond/service_test.go b/server/admin/internal/modules/giftdiamond/service_test.go index 0d668028..b2e24ea6 100644 --- a/server/admin/internal/modules/giftdiamond/service_test.go +++ b/server/admin/internal/modules/giftdiamond/service_test.go @@ -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) } } diff --git a/server/admin/internal/modules/hostagencypolicy/handler.go b/server/admin/internal/modules/hostagencypolicy/handler.go index 80b1a6f6..411361ed 100644 --- a/server/admin/internal/modules/hostagencypolicy/handler.go +++ b/server/admin/internal/modules/hostagencypolicy/handler.go @@ -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, diff --git a/server/admin/internal/modules/hostagencypolicy/request.go b/server/admin/internal/modules/hostagencypolicy/request.go index dd4a5eaa..9052c848 100644 --- a/server/admin/internal/modules/hostagencypolicy/request.go +++ b/server/admin/internal/modules/hostagencypolicy/request.go @@ -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"` diff --git a/server/admin/internal/modules/hostagencypolicy/response.go b/server/admin/internal/modules/hostagencypolicy/response.go index 967b73ac..a3b034e1 100644 --- a/server/admin/internal/modules/hostagencypolicy/response.go +++ b/server/admin/internal/modules/hostagencypolicy/response.go @@ -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, diff --git a/server/admin/internal/modules/hostagencypolicy/service.go b/server/admin/internal/modules/hostagencypolicy/service.go index a2905f00..bbc07dc4 100644 --- a/server/admin/internal/modules/hostagencypolicy/service.go +++ b/server/admin/internal/modules/hostagencypolicy/service.go @@ -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)) diff --git a/server/admin/internal/modules/hostagencypolicy/service_test.go b/server/admin/internal/modules/hostagencypolicy/service_test.go index d1800283..1b0654da 100644 --- a/server/admin/internal/modules/hostagencypolicy/service_test.go +++ b/server/admin/internal/modules/hostagencypolicy/service_test.go @@ -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) + } +} diff --git a/server/admin/internal/modules/hostsalarysettlement/service.go b/server/admin/internal/modules/hostsalarysettlement/service.go index aa1eca18..308a912d 100644 --- a/server/admin/internal/modules/hostsalarysettlement/service.go +++ b/server/admin/internal/modules/hostsalarysettlement/service.go @@ -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 diff --git a/server/admin/internal/modules/policyconfig/handler.go b/server/admin/internal/modules/policyconfig/handler.go index 112b54b2..8fd4e5b2 100644 --- a/server/admin/internal/modules/policyconfig/handler.go +++ b/server/admin/internal/modules/policyconfig/handler.go @@ -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) diff --git a/server/admin/internal/modules/policyconfig/service.go b/server/admin/internal/modules/policyconfig/service.go index b7a2464d..91cb0fec 100644 --- a/server/admin/internal/modules/policyconfig/service.go +++ b/server/admin/internal/modules/policyconfig/service.go @@ -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 -} diff --git a/server/admin/internal/modules/policyconfig/service_test.go b/server/admin/internal/modules/policyconfig/service_test.go index ab2d4383..a2196d69 100644 --- a/server/admin/internal/modules/policyconfig/service_test.go +++ b/server/admin/internal/modules/policyconfig/service_test.go @@ -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()) { diff --git a/server/admin/internal/repository/host_agency_salary_policy_repository.go b/server/admin/internal/repository/host_agency_salary_policy_repository.go index 586392b7..e4faa0b0 100644 --- a/server/admin/internal/repository/host_agency_salary_policy_repository.go +++ b/server/admin/internal/repository/host_agency_salary_policy_repository.go @@ -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) } diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index af1cf52e..498a1096 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -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}, diff --git a/server/admin/internal/repository/withdrawal_application_repository.go b/server/admin/internal/repository/withdrawal_application_repository.go index 017f6ad6..08dd5793 100644 --- a/server/admin/internal/repository/withdrawal_application_repository.go +++ b/server/admin/internal/repository/withdrawal_application_repository.go @@ -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 { diff --git a/server/admin/migrations/116_remove_direct_gift_point_policy.sql b/server/admin/migrations/116_remove_direct_gift_point_policy.sql new file mode 100644 index 00000000..b8da8ebb --- /dev/null +++ b/server/admin/migrations/116_remove_direct_gift_point_policy.sql @@ -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' +); diff --git a/server/admin/migrations/117_room_discovery_scope_config.sql b/server/admin/migrations/117_room_discovery_scope_config.sql new file mode 100644 index 00000000..3edd2707 --- /dev/null +++ b/server/admin/migrations/117_room_discovery_scope_config.sql @@ -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); diff --git a/server/admin/migrations/118_runtime_capabilities_config.sql b/server/admin/migrations/118_runtime_capabilities_config.sql new file mode 100644 index 00000000..cc471d9a --- /dev/null +++ b/server/admin/migrations/118_runtime_capabilities_config.sql @@ -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); diff --git a/server/admin/migrations/119_host_policy_types.sql b/server/admin/migrations/119_host_policy_types.sql new file mode 100644 index 00000000..955dc4b2 --- /dev/null +++ b/server/admin/migrations/119_host_policy_types.sql @@ -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; + diff --git a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql index fcd4d073..d6e0c35c 100644 --- a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql +++ b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql @@ -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', diff --git a/services/activity-service/deploy/mysql/migrations/008_task_reward_policy_instances.sql b/services/activity-service/deploy/mysql/migrations/008_task_reward_policy_instances.sql deleted file mode 100644 index bcd228fc..00000000 --- a/services/activity-service/deploy/mysql/migrations/008_task_reward_policy_instances.sql +++ /dev/null @@ -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='任务奖励运行侧策略实例表'; diff --git a/services/activity-service/internal/domain/task/task.go b/services/activity-service/internal/domain/task/task.go index e3a43893..a2f80014 100644 --- a/services/activity-service/internal/domain/task/task.go +++ b/services/activity-service/internal/domain/task/task.go @@ -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 diff --git a/services/activity-service/internal/service/task/service.go b/services/activity-service/internal/service/task/service.go index 958a2a54..f26db04b 100644 --- a/services/activity-service/internal/service/task/service.go +++ b/services/activity-service/internal/service/task/service.go @@ -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 } diff --git a/services/activity-service/internal/service/task/service_test.go b/services/activity-service/internal/service/task/service_test.go index 898fe891..a16ae3ec 100644 --- a/services/activity-service/internal/service/task/service_test.go +++ b/services/activity-service/internal/service/task/service_test.go @@ -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", diff --git a/services/activity-service/internal/storage/mysql/repository.go b/services/activity-service/internal/storage/mysql/repository.go index 8d172df9..c1b8b5b4 100644 --- a/services/activity-service/internal/storage/mysql/repository.go +++ b/services/activity-service/internal/storage/mysql/repository.go @@ -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 { diff --git a/services/activity-service/internal/storage/mysql/task_reward_policy.go b/services/activity-service/internal/storage/mysql/task_reward_policy.go deleted file mode 100644 index a7065dff..00000000 --- a/services/activity-service/internal/storage/mysql/task_reward_policy.go +++ /dev/null @@ -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 -} diff --git a/services/activity-service/internal/transport/grpc/task_flow_integration_test.go b/services/activity-service/internal/transport/grpc/task_flow_integration_test.go index 39e55def..bfd81ff4 100644 --- a/services/activity-service/internal/transport/grpc/task_flow_integration_test.go +++ b/services/activity-service/internal/transport/grpc/task_flow_integration_test.go @@ -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) } diff --git a/services/activity-service/internal/transport/grpc/task_server.go b/services/activity-service/internal/transport/grpc/task_server.go index 6148dc76..7c45ea44 100644 --- a/services/activity-service/internal/transport/grpc/task_server.go +++ b/services/activity-service/internal/transport/grpc/task_server.go @@ -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 { diff --git a/services/gateway-service/configs/config.docker.yaml b/services/gateway-service/configs/config.docker.yaml index c153f530..e977acb3 100644 --- a/services/gateway-service/configs/config.docker.yaml +++ b/services/gateway-service/configs/config.docker.yaml @@ -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 diff --git a/services/gateway-service/configs/config.tencent.example.yaml b/services/gateway-service/configs/config.tencent.example.yaml index 9b369326..674e1c18 100644 --- a/services/gateway-service/configs/config.tencent.example.yaml +++ b/services/gateway-service/configs/config.tencent.example.yaml @@ -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 diff --git a/services/gateway-service/configs/config.yaml b/services/gateway-service/configs/config.yaml index f239d637..92f1b9b3 100644 --- a/services/gateway-service/configs/config.yaml +++ b/services/gateway-service/configs/config.yaml @@ -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 diff --git a/services/gateway-service/internal/app/app.go b/services/gateway-service/internal/app/app.go index bda878ea..3607386b 100644 --- a/services/gateway-service/internal/app/app.go +++ b/services/gateway-service/internal/app/app.go @@ -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 } diff --git a/services/gateway-service/internal/appconfig/mysql.go b/services/gateway-service/internal/appconfig/mysql.go index b398e7c6..bab16f88 100644 --- a/services/gateway-service/internal/appconfig/mysql.go +++ b/services/gateway-service/internal/appconfig/mysql.go @@ -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 == "" { diff --git a/services/gateway-service/internal/appconfig/mysql_test.go b/services/gateway-service/internal/appconfig/mysql_test.go index 23b6c26f..46a73896 100644 --- a/services/gateway-service/internal/appconfig/mysql_test.go +++ b/services/gateway-service/internal/appconfig/mysql_test.go @@ -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 { diff --git a/services/gateway-service/internal/client/wallet_client.go b/services/gateway-service/internal/client/wallet_client.go index d12a5a32..f72d0164 100644 --- a/services/gateway-service/internal/client/wallet_client.go +++ b/services/gateway-service/internal/client/wallet_client.go @@ -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) } diff --git a/services/gateway-service/internal/config/config.go b/services/gateway-service/internal/config/config.go index 89c950be..609a41ec 100644 --- a/services/gateway-service/internal/config/config.go +++ b/services/gateway-service/internal/config/config.go @@ -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) diff --git a/services/gateway-service/internal/financewithdrawal/writer.go b/services/gateway-service/internal/financewithdrawal/writer.go index 94e2f83a..37c50372 100644 --- a/services/gateway-service/internal/financewithdrawal/writer.go +++ b/services/gateway-service/internal/financewithdrawal/writer.go @@ -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") } } diff --git a/services/gateway-service/internal/transport/http/activityapi/handler.go b/services/gateway-service/internal/transport/http/activityapi/handler.go index 4a5d1aa4..1183e589 100644 --- a/services/gateway-service/internal/transport/http/activityapi/handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/handler.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/activityapi/invite_activity_reward_handler_test.go b/services/gateway-service/internal/transport/http/activityapi/invite_activity_reward_handler_test.go index 5238cbf1..df7189cd 100644 --- a/services/gateway-service/internal/transport/http/activityapi/invite_activity_reward_handler_test.go +++ b/services/gateway-service/internal/transport/http/activityapi/invite_activity_reward_handler_test.go @@ -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) { diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go index 17c1b7ac..4126d010 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go index 39fffff5..22607c58 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/gift_capacity_test.go b/services/gateway-service/internal/transport/http/gift_capacity_test.go index c4d8af88..eb0ca704 100644 --- a/services/gateway-service/internal/transport/http/gift_capacity_test.go +++ b/services/gateway-service/internal/transport/http/gift_capacity_test.go @@ -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}`))) diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 8abb2e17..d44944b2 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -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) diff --git a/services/gateway-service/internal/transport/http/resourceapi/handler.go b/services/gateway-service/internal/transport/http/resourceapi/handler.go index 8bbb651e..1668159c 100644 --- a/services/gateway-service/internal/transport/http/resourceapi/handler.go +++ b/services/gateway-service/internal/transport/http/resourceapi/handler.go @@ -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) { diff --git a/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go b/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go index 517177c1..9604d5ca 100644 --- a/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go +++ b/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go @@ -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{ diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index a4939188..ee2ec2a7 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/room_filter_handler_test.go b/services/gateway-service/internal/transport/http/room_filter_handler_test.go index 1efe9e8a..aa665a25 100644 --- a/services/gateway-service/internal/transport/http/room_filter_handler_test.go +++ b/services/gateway-service/internal/transport/http/room_filter_handler_test.go @@ -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) diff --git a/services/gateway-service/internal/transport/http/roomapi/handler.go b/services/gateway-service/internal/transport/http/roomapi/handler.go index 4e25fb36..b892b8cb 100644 --- a/services/gateway-service/internal/transport/http/roomapi/handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/handler.go @@ -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 { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_filter_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_filter_handler.go index 00d4a7fd..49caf208 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_filter_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_filter_handler.go @@ -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{ diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index 6d2fddc9..d2dd7370 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -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 { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go index a48a8663..70be506b 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler_test.go @@ -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(`{ diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 86843608..b24c94bf 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/userapi/agency_center_handler.go b/services/gateway-service/internal/transport/http/userapi/agency_center_handler.go index 6395ec99..7e727abe 100644 --- a/services/gateway-service/internal/transport/http/userapi/agency_center_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/agency_center_handler.go @@ -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, }) } diff --git a/services/gateway-service/internal/transport/http/userapi/agency_center_stats_handler.go b/services/gateway-service/internal/transport/http/userapi/agency_center_stats_handler.go index a290c590..e2089391 100644 --- a/services/gateway-service/internal/transport/http/userapi/agency_center_stats_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/agency_center_stats_handler.go @@ -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(), + } +} diff --git a/services/gateway-service/internal/transport/http/userapi/host_center_policy_handler.go b/services/gateway-service/internal/transport/http/userapi/host_center_policy_handler.go index 4b69398b..dd0d41fd 100644 --- a/services/gateway-service/internal/transport/http/userapi/host_center_policy_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/host_center_policy_handler.go @@ -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(), diff --git a/services/gateway-service/internal/transport/http/userapi/host_center_stats_handler.go b/services/gateway-service/internal/transport/http/userapi/host_center_stats_handler.go index 228ecde2..6c01b872 100644 --- a/services/gateway-service/internal/transport/http/userapi/host_center_stats_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/host_center_stats_handler.go @@ -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")) diff --git a/services/gateway-service/internal/transport/http/userapi/host_center_stats_handler_test.go b/services/gateway-service/internal/transport/http/userapi/host_center_stats_handler_test.go index 321643fb..e3fa98b0 100644 --- a/services/gateway-service/internal/transport/http/userapi/host_center_stats_handler_test.go +++ b/services/gateway-service/internal/transport/http/userapi/host_center_stats_handler_test.go @@ -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) + } +} diff --git a/services/gateway-service/internal/transport/http/walletapi/direct_gift_handler.go b/services/gateway-service/internal/transport/http/walletapi/direct_gift_handler.go index a0ab3018..4b2b3d34 100644 --- a/services/gateway-service/internal/transport/http/walletapi/direct_gift_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/direct_gift_handler.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/walletapi/handler.go b/services/gateway-service/internal/transport/http/walletapi/handler.go index b764aa99..07fc84fc 100644 --- a/services/gateway-service/internal/transport/http/walletapi/handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/handler.go @@ -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, diff --git a/services/gateway-service/internal/transport/http/walletapi/point_diamond_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/point_diamond_wallet_handler.go new file mode 100644 index 00000000..93e60a9b --- /dev/null +++ b/services/gateway-service/internal/transport/http/walletapi/point_diamond_wallet_handler.go @@ -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(), + } +} diff --git a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go index cfa5ada7..e8b528e4 100644 --- a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go @@ -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") diff --git a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go index 22d29af2..29042bee 100644 --- a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go +++ b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go index 2aa1fd73..b877c416 100644 --- a/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go @@ -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) diff --git a/services/gateway-service/internal/transport/http/walletapi/vip_handler.go b/services/gateway-service/internal/transport/http/walletapi/vip_handler.go index 62549ac4..f8f73259 100644 --- a/services/gateway-service/internal/transport/http/walletapi/vip_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/vip_handler.go @@ -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 "欢迎进入,祝你有美好的一天" } diff --git a/services/gateway-service/internal/transport/http/walletapi/vip_notice_test.go b/services/gateway-service/internal/transport/http/walletapi/vip_notice_test.go index 383f69d1..4cabc776 100644 --- a/services/gateway-service/internal/transport/http/walletapi/vip_notice_test.go +++ b/services/gateway-service/internal/transport/http/walletapi/vip_notice_test.go @@ -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) } } diff --git a/services/room-service/internal/integration/tencent_im.go b/services/room-service/internal/integration/tencent_im.go index 032f6de6..e1f772e2 100644 --- a/services/room-service/internal/integration/tencent_im.go +++ b/services/room-service/internal/integration/tencent_im.go @@ -427,12 +427,16 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room } // 数组元素同样属于用户标识,必须保持字符串;仅把外层 attributes 设为字符串并不能避免客户端二次 jsonDecode 丢精度。 targetUserIDsJSON := tencentim.FormatUserIDsJSON(body.GetTargetUserIds()) + targetsJSON, err := roomGiftBatchIMTargetsJSON(body.GetTargets()) + if err != nil { + return tencentim.RoomEvent{}, false, err + } base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId()) base.TargetUserID = tencentim.FormatOptionalUserID(firstInt64(body.GetTargetUserIds())) base.GiftValue = body.GetTotalGiftValue() base.RoomHeat = body.GetRoomHeat() - // 房间 IM 只保留客户端房内展示需要的小字段:飞座位依赖 target_user_ids,礼物图标/名称用于公屏和飞行图标兜底。 - // 逐目标结算、收礼累计和幸运明细已经在 durable RoomGiftSent / lucky_gift_drawn 事实中消费,不能塞进 direct IM。 + // batch 必须携带逐目标 wallet 收益快照,客户端不能把根节点 total/aggregate 平均分到不同 Host。 + // lucky 明细仍由独立协议承载,targets 只包含展示和账务快照,避免重复嵌套中奖对象。 base.Attributes = map[string]string{ "gift_id": body.GetGiftId(), "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), @@ -445,6 +449,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room "gift_icon_url": body.GetGiftIconUrl(), "target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())), "target_user_ids": targetUserIDsJSON, + "targets": targetsJSON, "command_id": body.GetCommandId(), "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), "sender_name": body.GetSenderName(), @@ -675,6 +680,46 @@ func roomUserJoinedAttributesFromEvent(body *roomeventsv1.RoomUserJoined) map[st return values } +type roomGiftBatchIMTarget struct { + TargetUserID string `json:"target_user_id"` + ReceiverNickname string `json:"receiver_nickname,omitempty"` + ReceiverAvatar string `json:"receiver_avatar,omitempty"` + ReceiverDisplayUserID string `json:"receiver_display_user_id,omitempty"` + ReceiverPrettyDisplayUserID string `json:"receiver_pretty_display_user_id,omitempty"` + GiftValue string `json:"gift_value"` + TargetGiftValue string `json:"target_gift_value"` + CoinSpent string `json:"coin_spent"` + BillingReceiptID string `json:"billing_receipt_id,omitempty"` + CommandID string `json:"command_id,omitempty"` +} + +func roomGiftBatchIMTargetsJSON(targets []*roomeventsv1.RoomGiftBatchTarget) (string, error) { + items := make([]roomGiftBatchIMTarget, 0, len(targets)) + for _, target := range targets { + if target == nil || target.GetTargetUserId() <= 0 { + continue + } + item := roomGiftBatchIMTarget{ + TargetUserID: strconv.FormatInt(target.GetTargetUserId(), 10), + ReceiverNickname: target.GetReceiverNickname(), + ReceiverAvatar: target.GetReceiverAvatar(), + ReceiverDisplayUserID: target.GetReceiverDisplayUserId(), + ReceiverPrettyDisplayUserID: target.GetReceiverPrettyDisplayUserId(), + GiftValue: strconv.FormatInt(target.GetGiftValue(), 10), + TargetGiftValue: strconv.FormatInt(target.GetTargetGiftValue(), 10), + CoinSpent: strconv.FormatInt(target.GetCoinSpent(), 10), + BillingReceiptID: target.GetBillingReceiptId(), + CommandID: target.GetCommandId(), + } + items = append(items, item) + } + payload, err := json.Marshal(items) + if err != nil { + return "", err + } + return string(payload), nil +} + func firstInt64(values []int64) int64 { for _, value := range values { if value > 0 { diff --git a/services/room-service/internal/integration/tencent_im_test.go b/services/room-service/internal/integration/tencent_im_test.go index d173ad22..994c78ec 100644 --- a/services/room-service/internal/integration/tencent_im_test.go +++ b/services/room-service/internal/integration/tencent_im_test.go @@ -272,8 +272,20 @@ func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) { if event.Attributes["sender_vip_level"] != "8" { t.Fatalf("batch gift IM sender VIP mismatch: %+v", event.Attributes) } - if _, exists := event.Attributes["targets"]; exists { - t.Fatalf("room batch IM must not include bulky targets: %+v", event.Attributes) + var targets []struct { + TargetUserID string `json:"target_user_id"` + ReceiverNickname string `json:"receiver_nickname"` + GiftValue string `json:"gift_value"` + TargetGiftValue string `json:"target_gift_value"` + CommandID string `json:"command_id"` + } + if err := json.Unmarshal([]byte(event.Attributes["targets"]), &targets); err != nil { + t.Fatalf("decode room batch IM targets failed: %v payload=%s", err, event.Attributes["targets"]) + } + if len(targets) != 2 || targets[0].TargetUserID != "1002" || targets[0].ReceiverNickname != "Receiver One" || + targets[0].GiftValue != "300" || targets[0].TargetGiftValue != "1300" || targets[0].CommandID != "cmd-batch:target:1002" || + targets[1].TargetUserID != "1003" || targets[1].ReceiverNickname != "Receiver Two" || targets[1].TargetGiftValue != "900" { + t.Fatalf("room batch IM target display snapshot mismatch: %+v", targets) } if _, exists := event.Attributes["lucky_gifts"]; exists { t.Fatalf("room batch IM must not include bulky lucky_gifts: %+v", event.Attributes) diff --git a/services/room-service/internal/room/service/gift_batch.go b/services/room-service/internal/room/service/gift_batch.go index a4fb7cfc..ffb8ee73 100644 --- a/services/room-service/internal/room/service/gift_batch.go +++ b/services/room-service/internal/room/service/gift_batch.go @@ -20,7 +20,7 @@ func buildSendGiftBatchDisplay(cmd command.SendGift, billing *walletv1.DebitGift } targetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, targetBilling.TargetUserID) targetUserIDs = append(targetUserIDs, targetBilling.TargetUserID) - targets = append(targets, &roomv1.SendGiftBatchTarget{ + target := &roomv1.SendGiftBatchTarget{ TargetUserId: targetBilling.TargetUserID, ReceiverNickname: giftDisplayName(targetDisplayProfile), ReceiverAvatar: strings.TrimSpace(targetDisplayProfile.Avatar), @@ -32,7 +32,8 @@ func buildSendGiftBatchDisplay(cmd command.SendGift, billing *walletv1.DebitGift BillingReceiptId: targetBilling.Billing.GetBillingReceiptId(), CommandId: targetBilling.CommandID, LuckyGift: luckyByTarget[targetBilling.TargetUserID], - }) + } + targets = append(targets, target) } if len(targetUserIDs) == 0 { targetUserIDs = append(targetUserIDs, cmd.TargetUserIDs...) @@ -72,7 +73,7 @@ func roomGiftBatchSentEventFromDisplay(display *roomv1.SendGiftBatchDisplay, vis if target == nil { continue } - targets = append(targets, &roomeventsv1.RoomGiftBatchTarget{ + batchTarget := &roomeventsv1.RoomGiftBatchTarget{ TargetUserId: target.GetTargetUserId(), ReceiverNickname: target.GetReceiverNickname(), ReceiverAvatar: target.GetReceiverAvatar(), @@ -84,7 +85,8 @@ func roomGiftBatchSentEventFromDisplay(display *roomv1.SendGiftBatchDisplay, vis BillingReceiptId: target.GetBillingReceiptId(), CommandId: target.GetCommandId(), LuckyGift: roomGiftBatchLuckyResultFromRoom(target.GetLuckyGift()), - }) + } + targets = append(targets, batchTarget) } return &roomeventsv1.RoomGiftBatchSent{ SenderUserId: display.GetSenderUserId(), diff --git a/services/room-service/internal/room/service/gift_events_test.go b/services/room-service/internal/room/service/gift_events_test.go index 0420bd80..984e2dcf 100644 --- a/services/room-service/internal/room/service/gift_events_test.go +++ b/services/room-service/internal/room/service/gift_events_test.go @@ -250,3 +250,15 @@ func TestRoomHeatAndRankChangedRecordsPreserveSnapshotFields(t *testing.T) { t.Fatalf("rank event mismatch: record=%+v event=%+v", rankRecord, &rank) } } + +func TestRoomGiftLeaderboardScopedKeyDependsOnlyOnRegion(t *testing.T) { + start := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC) + global := roomGiftLeaderboardScopedKey("future-app", 0, roomGiftLeaderboardPeriodToday, start) + regional := roomGiftLeaderboardScopedKey("future-app", 7001, roomGiftLeaderboardPeriodToday, start) + if global != "room:gift_leaderboard:future-app:day:20260721" { + t.Fatalf("global room leaderboard key mismatch: %s", global) + } + if regional != "room:gift_leaderboard:future-app:region:7001:day:20260721" { + t.Fatalf("regional room leaderboard key must not depend on a product allowlist: %s", regional) + } +} diff --git a/services/room-service/internal/room/service/gift_lucky.go b/services/room-service/internal/room/service/gift_lucky.go index c817a9cf..4b3c920d 100644 --- a/services/room-service/internal/room/service/gift_lucky.go +++ b/services/room-service/internal/room/service/gift_lucky.go @@ -129,22 +129,21 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(), Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(), LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(), - // LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 实际给主播 - // 结算的收益:新 POINT 政策读 host_point_added,旧工资钻石链路读 host_period_diamond_added。 - // Agency 额外 20% 不属于主播收益,绝不能混入幸运礼物公共池/平台利润的守恒拆分。 - GiftIncomeCoins: walletHostGiftIncome(targetBilling.Billing), + // LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名,但资金事实只允许读取 + // Host 政策结算出的主播基础收益;已退役的直接 POINT 回执字段即使来自旧重放也不能重新参与拆分。 + // Agency 额外收益不属于主播收益,绝不能混入幸运礼物公共池/平台利润的守恒拆分。 + GiftIncomeCoins: hostBaseIncomeFromBilling(targetBilling.Billing), }, nil } -// walletHostGiftIncome 收敛 wallet 两代主播收益字段。两个字段在 wallet 事务里互斥;优先读 POINT -// 让 Fami 收益政策参与幸运礼物资金拆分,回退周期钻石则保持 Lalu 与旧回执的滚动发布兼容。 -func walletHostGiftIncome(billing *walletv1.DebitGiftResponse) int64 { +func hostBaseIncomeFromBilling(billing *walletv1.DebitGiftResponse) int64 { if billing == nil { return 0 } - if billing.GetHostPointAdded() > 0 { - return billing.GetHostPointAdded() + if value := billing.GetHostBaseAmount(); value > 0 { + return value } + // 升级前已扣费 saga 只有 SALARY_DIAMOND 周期钻石字段,保留其恢复能力。 return billing.GetHostPeriodDiamondAdded() } diff --git a/services/room-service/internal/room/service/lucky_gift_test.go b/services/room-service/internal/room/service/lucky_gift_test.go index 266066e4..6b682bd5 100644 --- a/services/room-service/internal/room/service/lucky_gift_test.go +++ b/services/room-service/internal/room/service/lucky_gift_test.go @@ -122,12 +122,14 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) { repository := mysqltest.NewRepository(t) wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ { - BillingReceiptId: "receipt-lucky", - CoinSpent: 100, - GiftPointAdded: 100, - HeatValue: 100, - GiftTypeCode: "super_lucky", - HostPointAdded: 7, + BillingReceiptId: "receipt-lucky", + CoinSpent: 100, + GiftPointAdded: 100, + HeatValue: 100, + GiftTypeCode: "super_lucky", + HostPeriodDiamondAdded: 7, + // 旧字段故意给出不同的非零值,证明退休后的直接 POINT 不会再进入幸运礼物资金拆分。 + HostPointAdded: 99, }, { BillingReceiptId: "receipt-lucky-legacy-token", diff --git a/services/room-service/internal/room/service/room_gift_leaderboard.go b/services/room-service/internal/room/service/room_gift_leaderboard.go index dbcc3729..bbad5b14 100644 --- a/services/room-service/internal/room/service/room_gift_leaderboard.go +++ b/services/room-service/internal/room/service/room_gift_leaderboard.go @@ -94,8 +94,8 @@ func (s *RedisRoomGiftLeaderboardStore) IncrementRoomGift(ctx context.Context, i pipe.ZIncrBy(ctx, legacyKey, float64(input.CoinSpent), roomID) // Redis 榜单是可重建读模型,TTL 只需要覆盖当前周期和排查窗口。 pipe.Expire(ctx, legacyKey, roomGiftLeaderboardTTL(period, start, end)) - if appcode.UsesRegionalLeaderboards(app) && input.RegionID > 0 { - // 兼容 key 与区域 key 双写,避免 room/gateway 滚动发布期间旧实例出现榜单断层。 + if input.RegionID > 0 { + // 所有 App 都同时维护全局与区域读模型;Gateway 根据后台配置选择读取范围,room-service 不识别产品。 regionalKey := roomGiftLeaderboardScopedKey(app, input.RegionID, period, start) pipe.ZIncrBy(ctx, regionalKey, float64(input.CoinSpent), roomID) pipe.Expire(ctx, regionalKey, roomGiftLeaderboardTTL(period, start, end)) @@ -110,9 +110,6 @@ func (s *RedisRoomGiftLeaderboardStore) ListRoomGiftLeaderboard(ctx context.Cont return RoomGiftLeaderboardPage{}, xerr.New(xerr.Unavailable, "room gift leaderboard store is not configured") } query = normalizeRoomGiftLeaderboardQuery(query) - if appcode.UsesRegionalLeaderboards(query.AppCode) && query.RegionID <= 0 { - return RoomGiftLeaderboardPage{}, xerr.New(xerr.InvalidArgument, "room gift leaderboard region is required") - } now := time.UnixMilli(query.NowMS).UTC() start, end := roomGiftLeaderboardWindow(query.Period, now) key := roomGiftLeaderboardScopedKey(query.AppCode, query.RegionID, query.Period, start) @@ -297,7 +294,7 @@ func roomGiftLeaderboardKey(app string, period string, start time.Time) string { } func roomGiftLeaderboardScopedKey(app string, regionID int64, period string, start time.Time) string { - if !appcode.UsesRegionalLeaderboards(app) || regionID <= 0 { + if regionID <= 0 { return roomGiftLeaderboardKey(app, period, start) } switch period { diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index 97bc85a1..c20cc3a9 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -49,6 +49,25 @@ FROM apps ON DUPLICATE KEY UPDATE max_accounts_per_device = max_accounts_per_device; +CREATE TABLE IF NOT EXISTS role_scope_policies ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', + scene VARCHAR(64) NOT NULL COMMENT '角色范围业务场景', + base_scope VARCHAR(16) NOT NULL COMMENT '默认范围:country/region/global', + expanded_scope VARCHAR(16) NOT NULL COMMENT '扩展开关开启后的范围', + region_expansion_configurable BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许经理个人扩区开关改变范围', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, scene) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App 角色范围 owner 配置'; + +-- 旧版跨区能力只通过运行时代码存在;初始数据仅固化已有行为,后续一律由后台写 owner 表。 +INSERT IGNORE INTO role_scope_policies ( + app_code, scene, base_scope, expanded_scope, + region_expansion_configurable, created_at_ms, updated_at_ms +) VALUES + ('huwaa', 'organization_role_expansion', 'global', 'global', FALSE, 0, 0), + ('huwaa', 'manager_operations', 'global', 'global', FALSE, 0, 0); + CREATE TABLE IF NOT EXISTS users ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID', diff --git a/services/user-service/deploy/mysql/migrations/022_role_scope_policies.sql b/services/user-service/deploy/mysql/migrations/022_role_scope_policies.sql new file mode 100644 index 00000000..dcb2700e --- /dev/null +++ b/services/user-service/deploy/mysql/migrations/022_role_scope_policies.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS role_scope_policies ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', + scene VARCHAR(64) NOT NULL COMMENT '角色范围业务场景', + base_scope VARCHAR(16) NOT NULL COMMENT '默认范围:country/region/global', + expanded_scope VARCHAR(16) NOT NULL COMMENT '扩展开关开启后的范围', + region_expansion_configurable BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否允许经理个人扩区开关改变范围', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, scene) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App 角色范围 owner 配置'; + +SET @now_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- INSERT IGNORE 只把旧运行时行为固化成初值;迁移重放不会覆盖已经由后台修改过的 owner 配置。 +INSERT IGNORE INTO role_scope_policies ( + app_code, scene, base_scope, expanded_scope, + region_expansion_configurable, created_at_ms, updated_at_ms +) VALUES + ('huwaa', 'organization_role_expansion', 'global', 'global', FALSE, @now_ms, @now_ms), + ('huwaa', 'manager_operations', 'global', 'global', FALSE, @now_ms, @now_ms); diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index 4647b652..86355b14 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -321,6 +321,9 @@ func New(cfg config.Config) (*App, error) { authservice.WithRegistrationLevelBadgeIssuer(registrationLevelBadgeIssuer), authservice.WithIMAccountImporter(imAccountImporter), ) + hostSvc := hostservice.New(hostRepo, + hostservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)), + ) // user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。 userSvc := userservice.New(userRepo, userservice.WithAppRegistryRepository(appRepo), @@ -333,6 +336,7 @@ func New(cfg config.Config) (*App, error) { userservice.WithRoleSummaryRepository(hostRepo), userservice.WithDeviceRepository(deviceRepo), userservice.WithModerationRepository(userRepo), + userservice.WithRoleScopePolicyResolver(hostSvc), userservice.WithPrivacyBenefitChecker(privacyBenefitChecker), userservice.WithVIPBenefitDecisionChecker(vipBenefitDecisionChecker), userservice.WithSessionDenylist(ipDecisionCache, time.Duration(cfg.JWT.AccessTokenTTLSec+60)*time.Second), @@ -341,9 +345,6 @@ func New(cfg config.Config) (*App, error) { userservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)), userservice.WithDisplayUserIDPolicy(cfg.DisplayUserID.AllocateMaxAttempts, time.Duration(cfg.DisplayUserID.ChangeCooldownSec)*time.Second), ) - hostSvc := hostservice.New(hostRepo, - hostservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)), - ) inviteSvc := inviteservice.New(inviteRepo) micTimeSvc := mictimeservice.New(micTimeRepo) roomTimeSvc := roomtimeservice.New(roomTimeRepo) diff --git a/services/user-service/internal/service/host/role_scope_policy.go b/services/user-service/internal/service/host/role_scope_policy.go index 56478939..43706081 100644 --- a/services/user-service/internal/service/host/role_scope_policy.go +++ b/services/user-service/internal/service/host/role_scope_policy.go @@ -1,9 +1,9 @@ package host import ( + "context" "strings" - "hyapp/pkg/appcode" "hyapp/pkg/xerr" ) @@ -20,61 +20,148 @@ const ( RoleScopeSceneManagerAddBDLeader = "manager_add_bd_leader" ) -// RoleScopePolicy 把 App 固有边界和可运营扩展能力分开;业务入口只根据解析结果判断目标范围。 +// RoleScopePolicy 是 user-service 拥有的 App 范围策略;调用方只消费解析结果,不能再按产品名复制分支。 type RoleScopePolicy struct { Scene string BaseScope RoleScope ExpandedScope RoleScope RegionExpansionConfigurable bool + UpdatedAtMS int64 } -var defaultRoleScopePolicies = map[string]RoleScopePolicy{ - RoleScopeSceneOrganizationExpansion: { - Scene: RoleScopeSceneOrganizationExpansion, - BaseScope: RoleScopeRegion, - ExpandedScope: RoleScopeRegion, - }, - RoleScopeSceneManagerOperations: { - Scene: RoleScopeSceneManagerOperations, - BaseScope: RoleScopeCountry, - ExpandedScope: RoleScopeRegion, - RegionExpansionConfigurable: true, - }, +// RoleScopePolicyRepository 只暴露小配置表的精确键读取和原子批量写入,避免把后台配置混入 Host 关系事务接口。 +type RoleScopePolicyRepository interface { + GetRoleScopePolicy(ctx context.Context, scene string) (RoleScopePolicy, bool, error) + UpsertRoleScopePolicies(ctx context.Context, policies []RoleScopePolicy, updatedAtMS int64) ([]RoleScopePolicy, error) } -var appRoleScopePolicyOverrides = map[string]map[string]RoleScopePolicy{ - "huwaa": { - RoleScopeSceneOrganizationExpansion: { - Scene: RoleScopeSceneOrganizationExpansion, - BaseScope: RoleScopeGlobal, - ExpandedScope: RoleScopeGlobal, - }, - RoleScopeSceneManagerOperations: { - Scene: RoleScopeSceneManagerOperations, - BaseScope: RoleScopeGlobal, - ExpandedScope: RoleScopeGlobal, - }, - }, -} - -// ResolveRoleScopePolicy 是 App 差异的唯一解析入口;新增 App 只扩展注册表,不修改搜索或写入 handler。 -func ResolveRoleScopePolicy(rawAppCode string, rawScene string) (RoleScopePolicy, error) { - scene := strings.ToLower(strings.TrimSpace(rawScene)) - if scene == RoleScopeSceneManagerAddBDLeader { - scene = RoleScopeSceneManagerOperations - } - policy, ok := defaultRoleScopePolicies[scene] - if !ok { +// DefaultRoleScopePolicy 定义未配置新 App 的安全基线。产品差异必须写入 owner 表,不能再扩展代码注册表。 +func DefaultRoleScopePolicy(rawScene string) (RoleScopePolicy, error) { + scene := normalizeRoleScopeScene(rawScene) + switch scene { + case RoleScopeSceneOrganizationExpansion: + return RoleScopePolicy{ + Scene: scene, + BaseScope: RoleScopeRegion, + ExpandedScope: RoleScopeRegion, + }, nil + case RoleScopeSceneManagerOperations: + return RoleScopePolicy{ + Scene: scene, + BaseScope: RoleScopeCountry, + ExpandedScope: RoleScopeRegion, + RegionExpansionConfigurable: true, + }, nil + default: return RoleScopePolicy{}, xerr.New(xerr.InvalidArgument, "role scope scene is invalid") } - if overrides := appRoleScopePolicyOverrides[appcode.Normalize(rawAppCode)]; overrides != nil { - if override, exists := overrides[scene]; exists { - policy = override - } +} + +// ResolveRoleScopePolicy 先验证 scene,再读取 user DB 的 App 精确配置;数据库错误必须向上返回,不能降级扩大权限。 +func ResolveRoleScopePolicy(ctx context.Context, repository RoleScopePolicyRepository, rawScene string) (RoleScopePolicy, error) { + defaultPolicy, err := DefaultRoleScopePolicy(rawScene) + if err != nil { + return RoleScopePolicy{}, err + } + if repository == nil { + return RoleScopePolicy{}, xerr.New(xerr.Unavailable, "role scope policy repository is not configured") + } + policy, found, err := repository.GetRoleScopePolicy(ctx, defaultPolicy.Scene) + if err != nil { + return RoleScopePolicy{}, err + } + if !found { + return defaultPolicy, nil + } + policy.Scene = defaultPolicy.Scene + if err := validateRoleScopePolicy(policy); err != nil { + return RoleScopePolicy{}, err } return policy, nil } +// ResolveRoleScopePolicy 是 Host service 内所有角色边界查询的统一入口。 +func (s *Service) ResolveRoleScopePolicy(ctx context.Context, rawScene string) (RoleScopePolicy, error) { + return ResolveRoleScopePolicy(ctx, s.roleScopePolicyRepository, rawScene) +} + +// UpdateRoleScopePolicies 把两个 scene 作为同一 App 配置快照提交,避免后台只写一半后留下混合语义。 +func (s *Service) UpdateRoleScopePolicies(ctx context.Context, policies []RoleScopePolicy) ([]RoleScopePolicy, error) { + if s == nil || s.roleScopePolicyRepository == nil { + return nil, xerr.New(xerr.Unavailable, "role scope policy repository is not configured") + } + if len(policies) != 2 { + return nil, xerr.New(xerr.InvalidArgument, "both role scope policies are required") + } + seen := make(map[string]struct{}, len(policies)) + normalized := make([]RoleScopePolicy, 0, len(policies)) + for _, policy := range policies { + policy.Scene = normalizeRoleScopeScene(policy.Scene) + if _, exists := seen[policy.Scene]; exists { + return nil, xerr.New(xerr.InvalidArgument, "role scope scene is duplicated") + } + if err := validateRoleScopePolicy(policy); err != nil { + return nil, err + } + seen[policy.Scene] = struct{}{} + normalized = append(normalized, policy) + } + if _, ok := seen[RoleScopeSceneOrganizationExpansion]; !ok { + return nil, xerr.New(xerr.InvalidArgument, "organization role scope policy is required") + } + if _, ok := seen[RoleScopeSceneManagerOperations]; !ok { + return nil, xerr.New(xerr.InvalidArgument, "manager role scope policy is required") + } + return s.roleScopePolicyRepository.UpsertRoleScopePolicies(ctx, normalized, s.now().UTC().UnixMilli()) +} + +func normalizeRoleScopeScene(rawScene string) string { + scene := strings.ToLower(strings.TrimSpace(rawScene)) + if scene == RoleScopeSceneManagerAddBDLeader { + return RoleScopeSceneManagerOperations + } + return scene +} + +func validateRoleScopePolicy(policy RoleScopePolicy) error { + if policy.Scene != RoleScopeSceneOrganizationExpansion && policy.Scene != RoleScopeSceneManagerOperations { + return xerr.New(xerr.InvalidArgument, "role scope scene is invalid") + } + if !validRoleScope(policy.BaseScope) || !validRoleScope(policy.ExpandedScope) { + return xerr.New(xerr.InvalidArgument, "role scope is invalid") + } + if policy.Scene == RoleScopeSceneOrganizationExpansion { + if policy.BaseScope == RoleScopeCountry || policy.RegionExpansionConfigurable || policy.ExpandedScope != policy.BaseScope { + return xerr.New(xerr.InvalidArgument, "organization role scope policy is invalid") + } + return nil + } + if !policy.RegionExpansionConfigurable && policy.ExpandedScope != policy.BaseScope { + return xerr.New(xerr.InvalidArgument, "non-configurable manager scope must keep one scope") + } + if policy.RegionExpansionConfigurable && roleScopeRank(policy.ExpandedScope) < roleScopeRank(policy.BaseScope) { + return xerr.New(xerr.InvalidArgument, "expanded manager scope cannot be narrower than base scope") + } + return nil +} + +func validRoleScope(scope RoleScope) bool { + return scope == RoleScopeCountry || scope == RoleScopeRegion || scope == RoleScopeGlobal +} + +func roleScopeRank(scope RoleScope) int { + switch scope { + case RoleScopeCountry: + return 1 + case RoleScopeRegion: + return 2 + case RoleScopeGlobal: + return 3 + default: + return 0 + } +} + // EffectiveScope 只允许声明为 configurable 的策略接受经理个人扩区开关。 func (p RoleScopePolicy) EffectiveScope(regionExpansionEnabled bool) RoleScope { if p.RegionExpansionConfigurable && regionExpansionEnabled { diff --git a/services/user-service/internal/service/host/role_scope_policy_test.go b/services/user-service/internal/service/host/role_scope_policy_test.go index 2549c67e..0e1ed66a 100644 --- a/services/user-service/internal/service/host/role_scope_policy_test.go +++ b/services/user-service/internal/service/host/role_scope_policy_test.go @@ -1,21 +1,57 @@ package host -import "testing" +import ( + "context" + "testing" + "time" +) -func TestResolveRoleScopePolicyKeepsAppDifferencesInRegistry(t *testing.T) { +type fakeRoleScopePolicyRepository struct { + policy RoleScopePolicy + found bool + err error +} + +func (r *fakeRoleScopePolicyRepository) GetRoleScopePolicy(context.Context, string) (RoleScopePolicy, bool, error) { + return r.policy, r.found, r.err +} + +func (r *fakeRoleScopePolicyRepository) UpsertRoleScopePolicies(_ context.Context, policies []RoleScopePolicy, updatedAtMS int64) ([]RoleScopePolicy, error) { + for index := range policies { + policies[index].UpdatedAtMS = updatedAtMS + } + return policies, r.err +} + +func TestResolveRoleScopePolicyUsesOwnerConfigOrGenericDefault(t *testing.T) { tests := []struct { name string - appCode string + repository *fakeRoleScopePolicyRepository baseScope RoleScope configurable bool expanded RoleScope }{ - {name: "default app", appCode: "lalu", baseScope: RoleScopeCountry, configurable: true, expanded: RoleScopeRegion}, - {name: "normalized huwaa", appCode: " HUWAa ", baseScope: RoleScopeGlobal, configurable: false, expanded: RoleScopeGlobal}, + { + name: "new app uses generic default", + repository: &fakeRoleScopePolicyRepository{}, + baseScope: RoleScopeCountry, + configurable: true, + expanded: RoleScopeRegion, + }, + { + name: "stored owner config is authoritative", + repository: &fakeRoleScopePolicyRepository{found: true, policy: RoleScopePolicy{ + Scene: RoleScopeSceneManagerOperations, + BaseScope: RoleScopeGlobal, + ExpandedScope: RoleScopeGlobal, + }}, + baseScope: RoleScopeGlobal, + expanded: RoleScopeGlobal, + }, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { - policy, err := ResolveRoleScopePolicy(testCase.appCode, RoleScopeSceneManagerOperations) + policy, err := ResolveRoleScopePolicy(context.Background(), testCase.repository, RoleScopeSceneManagerOperations) if err != nil { t.Fatalf("resolve policy: %v", err) } @@ -29,8 +65,8 @@ func TestResolveRoleScopePolicyKeepsAppDifferencesInRegistry(t *testing.T) { } } -func TestResolveRoleScopePolicyKeepsLegacyManagerSceneCompatible(t *testing.T) { - policy, err := ResolveRoleScopePolicy("lalu", RoleScopeSceneManagerAddBDLeader) +func TestDefaultRoleScopePolicyKeepsLegacyManagerSceneCompatible(t *testing.T) { + policy, err := DefaultRoleScopePolicy(RoleScopeSceneManagerAddBDLeader) if err != nil { t.Fatalf("resolve legacy manager scene: %v", err) } @@ -38,3 +74,21 @@ func TestResolveRoleScopePolicyKeepsLegacyManagerSceneCompatible(t *testing.T) { t.Fatalf("legacy scene must resolve to general manager policy: %+v", policy) } } + +func TestUpdateRoleScopePoliciesRejectsPartialOrNarrowerSnapshots(t *testing.T) { + svc := New(nil, WithClock(func() time.Time { return time.UnixMilli(123).UTC() })) + svc.roleScopePolicyRepository = &fakeRoleScopePolicyRepository{} + if _, err := svc.UpdateRoleScopePolicies(context.Background(), []RoleScopePolicy{{ + Scene: RoleScopeSceneOrganizationExpansion, + BaseScope: RoleScopeRegion, + ExpandedScope: RoleScopeRegion, + }}); err == nil { + t.Fatal("partial role scope snapshot must be rejected") + } + if _, err := svc.UpdateRoleScopePolicies(context.Background(), []RoleScopePolicy{ + {Scene: RoleScopeSceneOrganizationExpansion, BaseScope: RoleScopeRegion, ExpandedScope: RoleScopeRegion}, + {Scene: RoleScopeSceneManagerOperations, BaseScope: RoleScopeGlobal, ExpandedScope: RoleScopeCountry, RegionExpansionConfigurable: true}, + }); err == nil { + t.Fatal("narrower expanded manager scope must be rejected") + } +} diff --git a/services/user-service/internal/service/host/service.go b/services/user-service/internal/service/host/service.go index cc46da30..dea22f8a 100644 --- a/services/user-service/internal/service/host/service.go +++ b/services/user-service/internal/service/host/service.go @@ -68,9 +68,10 @@ type IDGenerator interface { // Service 承载 Host/Agency/BD 用例;它不直接访问数据库,事务语义保留在持久化层。 type Service struct { - repository Repository - idGenerator IDGenerator - now func() time.Time + repository Repository + roleScopePolicyRepository RoleScopePolicyRepository + idGenerator IDGenerator + now func() time.Time } // Option 调整 Host 业务层的运行时依赖,测试可以注入固定时钟和确定性发号器。 @@ -83,6 +84,10 @@ func New(repository Repository, options ...Option) *Service { idGenerator: idgen.NewInt64Generator(0), now: time.Now, } + if roleScopeRepository, ok := repository.(RoleScopePolicyRepository); ok { + // 生产 Host repository 同时拥有 App 角色范围小表;自动接入可以保证 App 入口和后台 RPC 读取同一 owner。 + svc.roleScopePolicyRepository = roleScopeRepository + } for _, option := range options { option(svc) } @@ -108,7 +113,7 @@ func WithClock(now func() time.Time) Option { } } -// SearchAgencies 返回当前用户可加入的有效 Agency;持久化层按 app_code 决定 Huwaa 全域或其他 App 同国家范围。 +// SearchAgencies 返回当前用户可加入的有效 Agency;持久化层按 owner 配置决定全域或同国家范围。 func (s *Service) SearchAgencies(ctx context.Context, userID int64, keyword string, pageSize int32) ([]hostdomain.Agency, error) { if s.repository == nil { return nil, xerr.New(xerr.Unavailable, "host repository is not configured") diff --git a/services/user-service/internal/service/user/moderation.go b/services/user-service/internal/service/user/moderation.go index 07c2d08b..0ebd0569 100644 --- a/services/user-service/internal/service/user/moderation.go +++ b/services/user-service/internal/service/user/moderation.go @@ -188,7 +188,10 @@ func (s *Service) CreateManagerUserBlock(ctx context.Context, command ManagerUse if err != nil { return ManagerUserBlock{}, UserStatusResult{}, err } - policy, err := hostservice.ResolveRoleScopePolicy(appcode.FromContext(ctx), hostservice.RoleScopeSceneManagerOperations) + if s.roleScopePolicyResolver == nil { + return ManagerUserBlock{}, UserStatusResult{}, xerr.New(xerr.Unavailable, "role scope policy resolver is not configured") + } + policy, err := s.roleScopePolicyResolver.ResolveRoleScopePolicy(ctx, hostservice.RoleScopeSceneManagerOperations) if err != nil { return ManagerUserBlock{}, UserStatusResult{}, err } diff --git a/services/user-service/internal/service/user/moderation_test.go b/services/user-service/internal/service/user/moderation_test.go index e74281b2..5eed1717 100644 --- a/services/user-service/internal/service/user/moderation_test.go +++ b/services/user-service/internal/service/user/moderation_test.go @@ -11,6 +11,7 @@ import ( "hyapp/pkg/xerr" invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" + hostservice "hyapp/services/user-service/internal/service/host" ) func TestSetUserStatusBanRevokesSessionsAndKicksRealtimeSurfaces(t *testing.T) { @@ -106,15 +107,15 @@ func TestSetUserStatusSurfacesAccessDenylistError(t *testing.T) { func TestCreateManagerUserBlockUsesGeneralCrossRegionScope(t *testing.T) { for _, testCase := range []struct { name string - appCode string + policy hostservice.RoleScopePolicy crossRegion bool targetRegion int64 wantAllowed bool }{ - {name: "default manager denied", appCode: "lalu", targetRegion: 1, wantAllowed: false}, - {name: "cross-region manager allowed inside region", appCode: "lalu", crossRegion: true, targetRegion: 1, wantAllowed: true}, - {name: "cross-region manager denied outside region", appCode: "lalu", crossRegion: true, targetRegion: 2, wantAllowed: false}, - {name: "huwaa globally allowed", appCode: "huwaa", targetRegion: 2, wantAllowed: true}, + {name: "country scope denied", policy: managerRoleScopePolicy(hostservice.RoleScopeCountry, hostservice.RoleScopeRegion, true), targetRegion: 1, wantAllowed: false}, + {name: "expanded region allows same region", policy: managerRoleScopePolicy(hostservice.RoleScopeCountry, hostservice.RoleScopeRegion, true), crossRegion: true, targetRegion: 1, wantAllowed: true}, + {name: "expanded region denies another region", policy: managerRoleScopePolicy(hostservice.RoleScopeCountry, hostservice.RoleScopeRegion, true), crossRegion: true, targetRegion: 2, wantAllowed: false}, + {name: "global owner policy allows all regions", policy: managerRoleScopePolicy(hostservice.RoleScopeGlobal, hostservice.RoleScopeGlobal, false), targetRegion: 2, wantAllowed: true}, } { t.Run(testCase.name, func(t *testing.T) { repository := &fakeModerationRepository{ @@ -124,10 +125,12 @@ func TestCreateManagerUserBlockUsesGeneralCrossRegionScope(t *testing.T) { 200: {UserID: 200, Country: "CA", RegionID: testCase.targetRegion, Status: userdomain.StatusActive}, }, } - svc := New(repository, WithModerationRepository(repository), WithClock(func() time.Time { - return time.UnixMilli(1_000).UTC() - })) - _, _, err := svc.CreateManagerUserBlock(appcode.WithContext(context.Background(), testCase.appCode), ManagerUserBlockCommand{ + svc := New(repository, + WithModerationRepository(repository), + WithRoleScopePolicyResolver(staticRoleScopePolicyResolver{policy: testCase.policy}), + WithClock(func() time.Time { return time.UnixMilli(1_000).UTC() }), + ) + _, _, err := svc.CreateManagerUserBlock(appcode.WithContext(context.Background(), "tenant-a"), ManagerUserBlockCommand{ CommandID: "manager-block", ManagerUserID: 100, TargetUserID: 200, BlockedUntilMS: 2_000, }) if testCase.wantAllowed && err != nil { @@ -140,6 +143,24 @@ func TestCreateManagerUserBlockUsesGeneralCrossRegionScope(t *testing.T) { } } +type staticRoleScopePolicyResolver struct { + policy hostservice.RoleScopePolicy + err error +} + +func (r staticRoleScopePolicyResolver) ResolveRoleScopePolicy(context.Context, string) (hostservice.RoleScopePolicy, error) { + return r.policy, r.err +} + +func managerRoleScopePolicy(base hostservice.RoleScope, expanded hostservice.RoleScope, configurable bool) hostservice.RoleScopePolicy { + return hostservice.RoleScopePolicy{ + Scene: hostservice.RoleScopeSceneManagerOperations, + BaseScope: base, + ExpandedScope: expanded, + RegionExpansionConfigurable: configurable, + } +} + func TestAdminBanUserAcceptsPermanentBanAndRunsRealtimeSideEffectsOnce(t *testing.T) { repository := &fakeModerationRepository{ user: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusBanned}, diff --git a/services/user-service/internal/service/user/service.go b/services/user-service/internal/service/user/service.go index 64eb8ae0..e7ae03bb 100644 --- a/services/user-service/internal/service/user/service.go +++ b/services/user-service/internal/service/user/service.go @@ -10,6 +10,7 @@ import ( hostdomain "hyapp/services/user-service/internal/domain/host" invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" + hostservice "hyapp/services/user-service/internal/service/host" ) // UserRepository 隔离用户主数据读写。 @@ -148,6 +149,11 @@ type RegionRebuildRepository interface { ProcessNextRegionRebuildTask(ctx context.Context, workerID string, nowMs int64, lockTTL time.Duration, batchSize int) (userdomain.RegionRebuildProcessResult, error) } +// RoleScopePolicyResolver 从 user-service owner 配置解析 App 角色范围;数据库故障必须返回错误而不是扩大治理权限。 +type RoleScopePolicyResolver interface { + ResolveRoleScopePolicy(ctx context.Context, scene string) (hostservice.RoleScopePolicy, error) +} + // RoleSummaryRepository 提供用户当前业务身份摘要,App 自助改国家必须先基于该事实做权限截断。 type RoleSummaryRepository interface { GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) @@ -282,6 +288,8 @@ type Service struct { deviceRepository DeviceRepository // moderationRepository 持有用户状态和 session 吊销事务能力。 moderationRepository ModerationRepository + // roleScopePolicyResolver 与 Host 组织链路共享同一 owner 配置,经理治理不能复制 App 分支。 + roleScopePolicyResolver RoleScopePolicyResolver // sessionDenylist 让已签发 access token 在 gateway 立即失效。 sessionDenylist SessionDenylist // sessionDenylistTTL 覆盖 access token 剩余有效期,避免 Redis key 先于 JWT 过期。 @@ -364,6 +372,15 @@ func WithModerationRepository(repository ModerationRepository) Option { } } +// WithRoleScopePolicyResolver 注入 user-service 自有的角色范围解析器。 +func WithRoleScopePolicyResolver(resolver RoleScopePolicyResolver) Option { + return func(s *Service) { + if resolver != nil { + s.roleScopePolicyResolver = resolver + } + } +} + // WithSessionDenylist 注入 access session denylist 缓存。 func WithSessionDenylist(denylist SessionDenylist, ttl time.Duration) Option { return func(s *Service) { diff --git a/services/user-service/internal/storage/mysql/host/applications.go b/services/user-service/internal/storage/mysql/host/applications.go index 14898747..edbafbc7 100644 --- a/services/user-service/internal/storage/mysql/host/applications.go +++ b/services/user-service/internal/storage/mysql/host/applications.go @@ -16,6 +16,10 @@ const userOutboxEventAgencyHostRemoved = "AgencyHostRemoved" // ApplyToAgency 创建待处理申请;用户行和 Agency 行在同一事务内锁定。 func (r *Repository) ApplyToAgency(ctx context.Context, command hostservice.ApplyToAgencyCommand) (hostdomain.AgencyApplication, error) { + regionMatchRequired, err := r.roleRegionMatchRequired(ctx) + if err != nil { + return hostdomain.AgencyApplication{}, err + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.AgencyApplication{}, err @@ -34,7 +38,7 @@ func (r *Repository) ApplyToAgency(ctx context.Context, command hostservice.Appl } // 申请人此时可能还没有 host_profiles 行,因此以 users 行作为“按用户串行化”的主锁。 - // 区域从用户表读取,作为后续 Host/成员关系的当前投影;Huwaa 允许它与 Agency owner 区域不同,但不会迁移申请人区域。 + // 区域从用户表读取,作为后续 Host/成员关系的当前投影;全域策略允许跨区但不会迁移申请人区域。 userRegionID, err := r.userRegion(ctx, tx, command.UserID, "FOR UPDATE") if err != nil { return hostdomain.AgencyApplication{}, err @@ -52,7 +56,7 @@ func (r *Repository) ApplyToAgency(ctx context.Context, command hostservice.Appl if agency.Status != hostdomain.AgencyStatusActive || !agency.JoinEnabled { return hostdomain.AgencyApplication{}, xerr.New(xerr.Conflict, "agency is not joinable") } - if roleRegionMatchRequired(ctx) && agency.RegionID != userRegionID { + if regionMatchRequired && agency.RegionID != userRegionID { return hostdomain.AgencyApplication{}, xerr.New(xerr.PermissionDenied, "agency region does not match user region") } // 第一阶段规则是一个 Host 同时只能归属一个有效 Agency; @@ -96,6 +100,10 @@ func (r *Repository) ApplyToAgency(ctx context.Context, command hostservice.Appl // ReviewAgencyApplication 审核申请;通过路径同时固化 Host 身份和成员关系。 func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostservice.ReviewAgencyApplicationCommand) (hostdomain.ReviewAgencyApplicationResult, error) { + regionMatchRequired, err := r.roleRegionMatchRequired(ctx) + if err != nil { + return hostdomain.ReviewAgencyApplicationResult{}, err + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err @@ -136,12 +144,12 @@ func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostse } // 申请创建后申请人或 Agency 拥有者区域都可能被后台调整;通过前重新锁申请人 users 行。 - // 其他 App 继续按 Agency 当前拥有者区域复验,Huwaa 只跳过匹配,不在申请表恢复区域快照或修改 users。 + // 非全域策略继续按 Agency 当前拥有者区域复验;全域策略只跳过匹配,不恢复区域快照或修改 users。 regionID, err := r.userRegion(ctx, tx, application.ApplicantUserID, "FOR UPDATE") if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } - if roleRegionMatchRequired(ctx) && regionID != agency.RegionID { + if regionMatchRequired && regionID != agency.RegionID { return hostdomain.ReviewAgencyApplicationResult{}, xerr.New(xerr.PermissionDenied, "application region no longer matches") } diff --git a/services/user-service/internal/storage/mysql/host/common.go b/services/user-service/internal/storage/mysql/host/common.go index 67204118..a14c1663 100644 --- a/services/user-service/internal/storage/mysql/host/common.go +++ b/services/user-service/internal/storage/mysql/host/common.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "strings" + "sync" + "time" mysqldriver "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" @@ -20,11 +22,21 @@ import ( type Repository struct { // 数据库连接由父级 user-service MySQL 存储对象持有,本包只复用连接池。 db *sql.DB + // 角色范围是低频后台配置、高频业务读取;按 App+scene 短缓存并为每个键收敛并发 miss。 + roleScopeMu sync.Mutex + roleScopeCache map[string]roleScopePolicyCacheEntry + roleScopeLocks map[string]*sync.Mutex + roleScopeTTL time.Duration } // 基于 user-service 共享连接池创建 Host 领域存储对象。 func New(db *sql.DB) *Repository { - return &Repository{db: db} + return &Repository{ + db: db, + roleScopeCache: make(map[string]roleScopePolicyCacheEntry), + roleScopeLocks: make(map[string]*sync.Mutex), + roleScopeTTL: 30 * time.Second, + } } const ( diff --git a/services/user-service/internal/storage/mysql/host/invitations.go b/services/user-service/internal/storage/mysql/host/invitations.go index 614649d7..d703b585 100644 --- a/services/user-service/internal/storage/mysql/host/invitations.go +++ b/services/user-service/internal/storage/mysql/host/invitations.go @@ -14,8 +14,12 @@ import ( const userOutboxEventRoleInvitationCreated = "RoleInvitationCreated" -// InviteAgency 创建 Agency 角色邀请;除 Huwaa 外校验 inviter BD 和目标用户区域一致。 +// InviteAgency 创建 Agency 角色邀请;非全域策略校验 inviter BD 和目标用户区域一致。 func (r *Repository) InviteAgency(ctx context.Context, command hostservice.InviteAgencyCommand) (hostdomain.RoleInvitation, error) { + regionMatchRequired, err := r.roleRegionMatchRequired(ctx) + if err != nil { + return hostdomain.RoleInvitation{}, err + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.RoleInvitation{}, err @@ -44,7 +48,7 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit if err != nil { return hostdomain.RoleInvitation{}, err } - if roleRegionMatchRequired(ctx) && targetRegionID != inviter.RegionID { + if regionMatchRequired && targetRegionID != inviter.RegionID { return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match inviter region") } // Agency owner 和 Host 成员归属是两套身份;目标已有 active Host 绑定时仍允许成为 Agency owner, @@ -123,6 +127,10 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit // InviteBD 创建 BD Leader 到 BD 的邀请。 func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDCommand) (hostdomain.RoleInvitation, error) { + regionMatchRequired, err := r.roleRegionMatchRequired(ctx) + if err != nil { + return hostdomain.RoleInvitation{}, err + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.RoleInvitation{}, err @@ -147,12 +155,12 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC if inviter.Status != hostdomain.BDStatusActive { return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "inviter is not active bd leader") } - // 邀请创建时锁目标用户当前区域;其他 App 要求和 Leader 同区,Huwaa 允许跨区但 BD 投影仍来自目标 users 当前区域。 + // 邀请创建时锁目标用户当前区域;非全域策略要求和 Leader 同区,BD 投影始终来自目标 users 当前区域。 targetRegionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE") if err != nil { return hostdomain.RoleInvitation{}, err } - if roleRegionMatchRequired(ctx) && targetRegionID != inviter.RegionID { + if regionMatchRequired && targetRegionID != inviter.RegionID { return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match leader region") } // 只有 active 普通 BD 才算已有权限;disabled 行可以重新发邀请, @@ -388,6 +396,11 @@ func queryActiveAgencyInviterProfile(ctx context.Context, tx *sql.Tx, userID int // ProcessRoleInvitation 处理角色邀请,接受路径在同一事务内创建 Host/Agency/BD 角色事实。 func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostservice.ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error) { + // 策略必须在占用事务连接前解析;否则缓存 miss 时并发事务可能耗尽连接池并互相等待第二条连接。 + regionMatchRequired, err := r.roleRegionMatchRequired(ctx) + if err != nil { + return hostdomain.ProcessRoleInvitationResult{}, err + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return hostdomain.ProcessRoleInvitationResult{}, err @@ -439,7 +452,7 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv invitation.Status = hostdomain.InvitationStatusAccepted if invitation.InvitationType == hostdomain.InvitationTypeAgency { // Agency 邀请的接受会一次性创建 Agency 拥有者身份、Agency 和拥有者成员关系。 - hostProfileChanged, membershipCreated, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result) + hostProfileChanged, membershipCreated, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result, regionMatchRequired) if err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } @@ -460,7 +473,7 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv } } else if invitation.InvitationType == hostdomain.InvitationTypeBD { // BD 邀请的接受只创建 bd_profile;BD 不需要 host_profile。 - if err := r.acceptBDInvitation(ctx, tx, command, &invitation, &result); err != nil { + if err := r.acceptBDInvitation(ctx, tx, command, &invitation, &result, regionMatchRequired); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", invitation.TargetUserID, command.NowMs); err != nil { @@ -505,9 +518,9 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv return result, nil } -func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, bool, error) { +func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult, regionMatchRequired bool) (bool, bool, error) { // 邀请可能放置一段时间后才被接受,所以目标用户和邀请 BD 都读取 users 当前区域。 - // 其他 App 用它们重新对齐;Huwaa 允许跨区,且邀请表仍不保存区域快照、不迁移目标用户。 + // 非全域策略用它们重新对齐;全域策略允许跨区,且邀请表仍不保存区域快照、不迁移目标用户。 targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE") if err != nil { return false, false, err @@ -516,7 +529,7 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com if err != nil { return false, false, err } - if roleRegionMatchRequired(ctx) && targetRegionID != inviterRegionID { + if regionMatchRequired && targetRegionID != inviterRegionID { return false, false, xerr.New(xerr.RegionMismatch, "target region no longer matches invitation") } // 目标已有 Host 绑定时保留绑定,不再把 Host 当前归属切到自己新建的 Agency。 @@ -645,8 +658,8 @@ func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, comma return hostProfileCreated, nil } -func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) error { - // BD 邀请同样读取目标用户和发起 Leader 的 users 当前区域;其他 App 阻止漂移/跨区,Huwaa 只取消匹配校验。 +func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult, regionMatchRequired bool) error { + // BD 邀请同样读取目标用户和发起 Leader 的 users 当前区域;非全域策略阻止漂移/跨区。 targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE") if err != nil { return err @@ -655,7 +668,7 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command if err != nil { return err } - if roleRegionMatchRequired(ctx) && targetRegionID != leaderRegionID { + if regionMatchRequired && targetRegionID != leaderRegionID { return xerr.New(xerr.RegionMismatch, "target region no longer matches invitation") } // BD 身份是独立角色事实,不依赖 host_profile;active BD 不能被覆盖, diff --git a/services/user-service/internal/storage/mysql/host/queries.go b/services/user-service/internal/storage/mysql/host/queries.go index 477a0b04..405beef6 100644 --- a/services/user-service/internal/storage/mysql/host/queries.go +++ b/services/user-service/internal/storage/mysql/host/queries.go @@ -13,13 +13,17 @@ import ( hostservice "hyapp/services/user-service/internal/service/host" ) -// SearchAgencies 返回当前 App 内可加入的有效 Agency;除 Huwaa 外继续按用户当前国家收敛候选集。 +// SearchAgencies 返回当前 App 内可加入的有效 Agency;owner 策略为非全域时继续按用户当前国家收敛候选集。 func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.SearchAgenciesCommand) ([]hostdomain.Agency, error) { + regionMatchRequired, err := r.roleRegionMatchRequired(ctx) + if err != nil { + return nil, err + } country, err := r.userCountry(ctx, r.db, command.UserID, "") if err != nil { return nil, err } - if roleRegionMatchRequired(ctx) && country == "" { + if regionMatchRequired && country == "" { // 国家是 App 推荐列表的硬边界,缺失时不能退化成跨国或全局列表。 return nil, xerr.New(xerr.InvalidArgument, "user country is required") } @@ -52,8 +56,8 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea AND a.join_enabled = TRUE ` args := []any{hostdomain.MembershipStatusActive, appcode.FromContext(ctx), hostdomain.AgencyStatusActive} - if roleRegionMatchRequired(ctx) { - // 其他 App 保留历史国家边界;条件由服务端用户资料派生,客户端不能伪造国家扩大结果集。 + if regionMatchRequired { + // 非全域策略保留历史国家边界;条件由服务端用户资料派生,客户端不能伪造国家扩大结果集。 query += ` AND owner.country = ?` args = append(args, country) } diff --git a/services/user-service/internal/storage/mysql/host/region_policy.go b/services/user-service/internal/storage/mysql/host/region_policy.go index f85ca420..7e12d849 100644 --- a/services/user-service/internal/storage/mysql/host/region_policy.go +++ b/services/user-service/internal/storage/mysql/host/region_policy.go @@ -2,14 +2,154 @@ package host import ( "context" + "database/sql" + "strings" + "sync" + "time" "hyapp/pkg/appcode" + "hyapp/pkg/xerr" hostservice "hyapp/services/user-service/internal/service/host" ) -// roleRegionMatchRequired 只控制 Agency 角色邀请、Host 主动申请和 BD 邀请的区域匹配。 -// App 差异由统一策略注册表解析;Agency 主动邀请 Host、币商列表和子币商关系不得复用该例外。 -func roleRegionMatchRequired(ctx context.Context) bool { - policy, err := hostservice.ResolveRoleScopePolicy(appcode.FromContext(ctx), hostservice.RoleScopeSceneOrganizationExpansion) - return err != nil || policy.BaseScope != hostservice.RoleScopeGlobal +type roleScopePolicyCacheEntry struct { + policy hostservice.RoleScopePolicy + found bool + expiresAt time.Time +} + +// GetRoleScopePolicy 通过 (app_code, scene) 主键读取 owner 配置。过期后数据库失败直接返回错误,不使用旧权限放宽结果。 +func (r *Repository) GetRoleScopePolicy(ctx context.Context, scene string) (hostservice.RoleScopePolicy, bool, error) { + if r == nil || r.db == nil { + return hostservice.RoleScopePolicy{}, false, xerr.New(xerr.Unavailable, "host repository is not configured") + } + app := appcode.FromContext(ctx) + cacheKey := app + "\x00" + scene + now := time.Now() + if cached, ok := r.cachedRoleScopePolicy(cacheKey, now); ok { + return cached.policy, cached.found, nil + } + + // 同一 App+scene 的并发首次读取只能落一次 MySQL,防止缓存过期瞬间形成配置表尖峰。 + keyLock := r.roleScopePolicyLock(cacheKey) + keyLock.Lock() + defer keyLock.Unlock() + if cached, ok := r.cachedRoleScopePolicy(cacheKey, time.Now()); ok { + return cached.policy, cached.found, nil + } + + var policy hostservice.RoleScopePolicy + err := r.db.QueryRowContext(ctx, ` + SELECT base_scope, expanded_scope, region_expansion_configurable, updated_at_ms + FROM role_scope_policies + WHERE app_code = ? AND scene = ? + LIMIT 1 + `, app, scene).Scan(&policy.BaseScope, &policy.ExpandedScope, &policy.RegionExpansionConfigurable, &policy.UpdatedAtMS) + if err != nil && err != sql.ErrNoRows { + return hostservice.RoleScopePolicy{}, false, err + } + policy.Scene = scene + entry := roleScopePolicyCacheEntry{ + policy: policy, + found: err == nil, + expiresAt: time.Now().Add(r.roleScopePolicyTTL()), + } + r.storeRoleScopePolicyCache(cacheKey, entry) + return entry.policy, entry.found, nil +} + +// UpsertRoleScopePolicies 在 user DB 单事务保存同一 App 的完整策略快照,提交后使当前实例缓存立即失效。 +func (r *Repository) UpsertRoleScopePolicies(ctx context.Context, policies []hostservice.RoleScopePolicy, updatedAtMS int64) ([]hostservice.RoleScopePolicy, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "host repository is not configured") + } + app := appcode.FromContext(ctx) + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + + stored := make([]hostservice.RoleScopePolicy, 0, len(policies)) + for _, policy := range policies { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO role_scope_policies ( + app_code, scene, base_scope, expanded_scope, + region_expansion_configurable, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + base_scope = VALUES(base_scope), + expanded_scope = VALUES(expanded_scope), + region_expansion_configurable = VALUES(region_expansion_configurable), + updated_at_ms = VALUES(updated_at_ms) + `, app, policy.Scene, policy.BaseScope, policy.ExpandedScope, policy.RegionExpansionConfigurable, updatedAtMS, updatedAtMS); err != nil { + return nil, err + } + policy.UpdatedAtMS = updatedAtMS + stored = append(stored, policy) + } + if err := tx.Commit(); err != nil { + return nil, err + } + r.invalidateRoleScopePolicies(app) + return stored, nil +} + +// roleRegionMatchRequired 把组织关系写入统一解释为 owner 策略;读取失败时调用方必须终止事务而不是放宽区域。 +func (r *Repository) roleRegionMatchRequired(ctx context.Context) (bool, error) { + policy, err := hostservice.ResolveRoleScopePolicy(ctx, r, hostservice.RoleScopeSceneOrganizationExpansion) + if err != nil { + return true, err + } + return policy.BaseScope != hostservice.RoleScopeGlobal, nil +} + +func (r *Repository) cachedRoleScopePolicy(key string, now time.Time) (roleScopePolicyCacheEntry, bool) { + r.roleScopeMu.Lock() + defer r.roleScopeMu.Unlock() + entry, ok := r.roleScopeCache[key] + return entry, ok && now.Before(entry.expiresAt) +} + +func (r *Repository) storeRoleScopePolicyCache(key string, entry roleScopePolicyCacheEntry) { + r.roleScopeMu.Lock() + defer r.roleScopeMu.Unlock() + if r.roleScopeCache == nil { + r.roleScopeCache = make(map[string]roleScopePolicyCacheEntry) + } + r.roleScopeCache[key] = entry +} + +func (r *Repository) roleScopePolicyLock(key string) *sync.Mutex { + r.roleScopeMu.Lock() + defer r.roleScopeMu.Unlock() + if r.roleScopeLocks == nil { + r.roleScopeLocks = make(map[string]*sync.Mutex) + } + if lock := r.roleScopeLocks[key]; lock != nil { + return lock + } + lock := &sync.Mutex{} + r.roleScopeLocks[key] = lock + return lock +} + +func (r *Repository) roleScopePolicyTTL() time.Duration { + r.roleScopeMu.Lock() + defer r.roleScopeMu.Unlock() + if r.roleScopeTTL <= 0 { + return 30 * time.Second + } + return r.roleScopeTTL +} + +func (r *Repository) invalidateRoleScopePolicies(app string) { + prefix := app + "\x00" + r.roleScopeMu.Lock() + defer r.roleScopeMu.Unlock() + for key := range r.roleScopeCache { + if strings.HasPrefix(key, prefix) { + delete(r.roleScopeCache, key) + } + } } diff --git a/services/user-service/internal/storage/mysql/host/region_policy_test.go b/services/user-service/internal/storage/mysql/host/region_policy_test.go index f1ea8c96..0e50046b 100644 --- a/services/user-service/internal/storage/mysql/host/region_policy_test.go +++ b/services/user-service/internal/storage/mysql/host/region_policy_test.go @@ -2,27 +2,81 @@ package host import ( "context" + "errors" + "os" + "strings" "testing" + "github.com/DATA-DOG/go-sqlmock" "hyapp/pkg/appcode" + hostservice "hyapp/services/user-service/internal/service/host" ) -func TestRoleRegionMatchRequiredOnlyExemptsNormalizedHuwaa(t *testing.T) { - for _, testCase := range []struct { - name string - app string - required bool - }{ - {name: "default_lalu", app: "", required: true}, - {name: "explicit_lalu", app: "lalu", required: true}, - {name: "fami", app: "fami", required: true}, - {name: "normalized_huwaa", app: " HUWAa ", required: false}, - } { - t.Run(testCase.name, func(t *testing.T) { - ctx := appcode.WithContext(context.Background(), testCase.app) - if got := roleRegionMatchRequired(ctx); got != testCase.required { - t.Fatalf("roleRegionMatchRequired(%q) = %v, want %v", testCase.app, got, testCase.required) - } - }) +func TestRoleRegionMatchRequiredUsesIndexedOwnerConfigAndCache(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + defer db.Close() + repository := New(db) + ctx := appcode.WithContext(context.Background(), "tenant-global") + + mock.ExpectQuery(`SELECT base_scope, expanded_scope, region_expansion_configurable, updated_at_ms`). + WithArgs("tenant-global", hostservice.RoleScopeSceneOrganizationExpansion). + WillReturnRows(sqlmock.NewRows([]string{"base_scope", "expanded_scope", "region_expansion_configurable", "updated_at_ms"}). + AddRow("global", "global", false, int64(123))) + + for attempt := 0; attempt < 2; attempt++ { + required, err := repository.roleRegionMatchRequired(ctx) + if err != nil { + t.Fatalf("resolve role scope attempt %d: %v", attempt, err) + } + if required { + t.Fatalf("global owner config must allow cross-region organization links") + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("cache must collapse repeated owner config reads: %v", err) + } +} + +func TestRoleRegionMatchRequiredFailsClosedOnDatabaseError(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + defer db.Close() + repository := New(db) + ctx := appcode.WithContext(context.Background(), "tenant-a") + mock.ExpectQuery(`SELECT base_scope, expanded_scope, region_expansion_configurable, updated_at_ms`). + WithArgs("tenant-a", hostservice.RoleScopeSceneOrganizationExpansion). + WillReturnError(errors.New("database unavailable")) + + required, err := repository.roleRegionMatchRequired(ctx) + if err == nil || !required { + t.Fatalf("database error must fail closed: required=%v err=%v", required, err) + } +} + +func TestRoleScopePolicyMigrationOnlyMaterializesLegacyGlobalTenant(t *testing.T) { + body, err := os.ReadFile("../../../../deploy/mysql/migrations/022_role_scope_policies.sql") + if err != nil { + t.Fatalf("read role scope migration: %v", err) + } + sqlText := strings.ToLower(string(body)) + for _, snippet := range []string{ + "primary key (app_code, scene)", + "'huwaa', 'organization_role_expansion', 'global', 'global'", + "'huwaa', 'manager_operations', 'global', 'global'", + "insert ignore into role_scope_policies", + } { + if !strings.Contains(sqlText, snippet) { + t.Fatalf("role scope migration missing %q", snippet) + } + } + for _, app := range []string{"lalu", "fami", "yumi", "aslan"} { + if strings.Contains(sqlText, "'"+app+"'") { + t.Fatalf("migration must not seed unrelated app %q", app) + } } } diff --git a/services/user-service/internal/storage/mysql/host/stats.go b/services/user-service/internal/storage/mysql/host/stats.go index b41786eb..bfb83591 100644 --- a/services/user-service/internal/storage/mysql/host/stats.go +++ b/services/user-service/internal/storage/mysql/host/stats.go @@ -31,44 +31,20 @@ func (r *Repository) GetHostEngagementStats(ctx context.Context, query hostdomai return hostdomain.EngagementStats{}, err } - // 旧版 C2C 回调缺 app_code 时会把 Fami 事实写到默认租户。读取仅扩展到这两个索引范围, - // 并用 users 全局主键校验发送者真实属于当前 App,既恢复历史数据又不放大跨租户计数。 - privateMessageSQL := ` - SELECT COUNT(DISTINCT matched.sender_user_id) - FROM ( - SELECT sender_user_id - FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) - WHERE app_code = ? AND target_user_id = ? - AND occurred_at_ms >= ? AND occurred_at_ms < ? - ) matched - INNER JOIN users sender - ON sender.user_id = matched.sender_user_id AND sender.app_code = ?` - privateMessageArgs := []any{appCode, query.HostUserID, query.StartAtMS, query.EndAtMS, appCode} - if appCode != appcode.Default { - // 分成两个精确 app_code 区间,避免 MySQL 对 IN 条件选择覆盖索引全扫; - // UNION ALL 后在最终 COUNT(DISTINCT) 收敛同一发送者,历史/新记录重叠时也不会重复计数。 - privateMessageSQL = ` + // 私聊事实严格按当前 App 的复合索引读取;Fami 尚未上线,不再跨租户回捞旧错误数据。 + // 这样所有 App 走同一查询路径,也避免默认 App 身份成为业务分支。 + if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(DISTINCT matched.sender_user_id) FROM ( SELECT sender_user_id - FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) - WHERE app_code = ? AND target_user_id = ? - AND occurred_at_ms >= ? AND occurred_at_ms < ? - UNION ALL - SELECT sender_user_id - FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) - WHERE app_code = ? AND target_user_id = ? - AND occurred_at_ms >= ? AND occurred_at_ms < ? + FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) + WHERE app_code = ? AND target_user_id = ? + AND occurred_at_ms >= ? AND occurred_at_ms < ? ) matched INNER JOIN users sender - ON sender.user_id = matched.sender_user_id AND sender.app_code = ?` - privateMessageArgs = []any{ - appCode, query.HostUserID, query.StartAtMS, query.EndAtMS, - appcode.Default, query.HostUserID, query.StartAtMS, query.EndAtMS, - appCode, - } - } - if err := r.db.QueryRowContext(ctx, privateMessageSQL, privateMessageArgs...).Scan(&stats.PrivateMessageSenders); err != nil { + ON sender.user_id = matched.sender_user_id AND sender.app_code = ?`, + appCode, query.HostUserID, query.StartAtMS, query.EndAtMS, appCode, + ).Scan(&stats.PrivateMessageSenders); err != nil { return hostdomain.EngagementStats{}, err } diff --git a/services/user-service/internal/storage/mysql/host/stats_test.go b/services/user-service/internal/storage/mysql/host/stats_test.go index c98eb8b0..a96cc234 100644 --- a/services/user-service/internal/storage/mysql/host/stats_test.go +++ b/services/user-service/internal/storage/mysql/host/stats_test.go @@ -27,17 +27,11 @@ func TestGetHostEngagementStatsAggregatesOwnedFacts(t *testing.T) { FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) WHERE app_code = ? AND target_user_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ? - UNION ALL - SELECT sender_user_id - FROM user_private_message_events FORCE INDEX (idx_private_message_target_time) - WHERE app_code = ? AND target_user_id = ? - AND occurred_at_ms >= ? AND occurred_at_ms < ? ) matched INNER JOIN users sender ON sender.user_id = matched.sender_user_id AND sender.app_code = ?`)). WithArgs( "fami", int64(42), int64(1783296000000), int64(1783900800000), - "lalu", int64(42), int64(1783296000000), int64(1783900800000), "fami", ). WillReturnRows(sqlmock.NewRows([]string{"senders"}).AddRow(int64(6))) diff --git a/services/user-service/internal/transport/grpc/host.go b/services/user-service/internal/transport/grpc/host.go index f986a88c..0d103c8a 100644 --- a/services/user-service/internal/transport/grpc/host.go +++ b/services/user-service/internal/transport/grpc/host.go @@ -3,7 +3,6 @@ package grpc import ( "context" - "hyapp/pkg/appcode" "hyapp/pkg/xerr" hostservice "hyapp/services/user-service/internal/service/host" @@ -542,17 +541,54 @@ func (s *Server) CheckBusinessCapability(ctx context.Context, req *userv1.CheckB // GetRoleScopePolicy 暴露 App 级角色范围元数据,让 gateway/admin 只消费策略结果而不复制 app_code 分支。 func (s *Server) GetRoleScopePolicy(ctx context.Context, req *userv1.GetRoleScopePolicyRequest) (*userv1.GetRoleScopePolicyResponse, error) { + if s.hostSvc == nil { + return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) + } ctx = contextWithApp(ctx, req.GetMeta()) - policy, err := hostservice.ResolveRoleScopePolicy(appcode.FromContext(ctx), req.GetScene()) + policy, err := s.hostSvc.ResolveRoleScopePolicy(ctx, req.GetScene()) if err != nil { return nil, xerr.ToGRPCError(err) } - return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{ + return &userv1.GetRoleScopePolicyResponse{Policy: toProtoRoleScopePolicy(policy)}, nil +} + +// UpdateRoleScopePolicies 只做协议转换;完整快照校验和 user DB 原子写入由 Host service/repository 负责。 +func (s *Server) UpdateRoleScopePolicies(ctx context.Context, req *userv1.UpdateRoleScopePoliciesRequest) (*userv1.UpdateRoleScopePoliciesResponse, error) { + if s.hostSvc == nil { + return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) + } + ctx = contextWithApp(ctx, req.GetMeta()) + policies := make([]hostservice.RoleScopePolicy, 0, len(req.GetPolicies())) + for _, policy := range req.GetPolicies() { + if policy == nil { + continue + } + policies = append(policies, hostservice.RoleScopePolicy{ + Scene: policy.GetScene(), + BaseScope: hostservice.RoleScope(policy.GetBaseScope()), + ExpandedScope: hostservice.RoleScope(policy.GetExpandedScope()), + RegionExpansionConfigurable: policy.GetRegionExpansionConfigurable(), + }) + } + stored, err := s.hostSvc.UpdateRoleScopePolicies(ctx, policies) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + response := &userv1.UpdateRoleScopePoliciesResponse{Policies: make([]*userv1.RoleScopePolicy, 0, len(stored))} + for _, policy := range stored { + response.Policies = append(response.Policies, toProtoRoleScopePolicy(policy)) + } + return response, nil +} + +func toProtoRoleScopePolicy(policy hostservice.RoleScopePolicy) *userv1.RoleScopePolicy { + return &userv1.RoleScopePolicy{ Scene: policy.Scene, BaseScope: string(policy.BaseScope), ExpandedScope: string(policy.ExpandedScope), RegionExpansionConfigurable: policy.RegionExpansionConfigurable, - }}, nil + UpdatedAtMs: policy.UpdatedAtMS, + } } // GetAgencyMembers 返回 Agency 成员关系事实列表。 diff --git a/services/wallet-service/configs/config.docker.yaml b/services/wallet-service/configs/config.docker.yaml index 96c7fc92..9ae8486a 100644 --- a/services/wallet-service/configs/config.docker.yaml +++ b/services/wallet-service/configs/config.docker.yaml @@ -121,6 +121,8 @@ bian: api_base_url: "https://api.binance.com" recv_window: 10000 http_timeout: "10s" + # 保留旧层级,确保滚动发布或回滚到旧二进制时仍能读取账号; + # 新二进制会按任意 app_code 通用归一化,不再依赖产品名分支。 lalu: api_key: "" api_secret_key: "" diff --git a/services/wallet-service/configs/config.tencent.example.yaml b/services/wallet-service/configs/config.tencent.example.yaml index 4430e068..a7d613db 100644 --- a/services/wallet-service/configs/config.tencent.example.yaml +++ b/services/wallet-service/configs/config.tencent.example.yaml @@ -121,6 +121,8 @@ bian: api_base_url: "https://api.binance.com" recv_window: 10000 http_timeout: "10s" + # 保留旧层级,确保滚动发布或回滚到旧二进制时仍能读取账号; + # 新二进制会按任意 app_code 通用归一化,不再依赖产品名分支。 lalu: api_key: "" api_secret_key: "" diff --git a/services/wallet-service/configs/config.yaml b/services/wallet-service/configs/config.yaml index 9d15aba7..e94112f2 100644 --- a/services/wallet-service/configs/config.yaml +++ b/services/wallet-service/configs/config.yaml @@ -122,6 +122,8 @@ bian: api_base_url: "https://api.binance.com" recv_window: 10000 http_timeout: "10s" + # 生产滚动发布期间继续使用旧层级;新二进制会把任意 bian. + # 归一化到通用账号表,避免回滚到旧二进制时读取不到已有账号。 lalu: api_key: "${HYAPP_WALLET_BINANCE_LALU_API_KEY}" api_secret_key: "${HYAPP_WALLET_BINANCE_LALU_API_SECRET_KEY}" diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index d528a41a..403906cc 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -257,7 +257,7 @@ CREATE TABLE IF NOT EXISTS wallet_outbox_archive_purge_receipts ( KEY idx_wallet_outbox_purge_receipt_time (app_code, purged_at_ms, purge_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='wallet outbox 精确 PK 清理审计回执'; --- 钱包运行侧收益政策实例:由 admin-server 发布写入,wallet 送礼、提现和结算只读本库运行快照。 +-- 现有 POINT 提现和兑换继续读取原运行政策表;礼物收益只走 Host 政策,不能读取本表入 POINT。 CREATE TABLE IF NOT EXISTS wallet_policy_instances ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码', instance_code VARCHAR(96) NOT NULL COMMENT '后台策略实例编码', @@ -267,9 +267,9 @@ CREATE TABLE IF NOT EXISTS wallet_policy_instances ( 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 '完整政策快照', @@ -279,7 +279,7 @@ CREATE TABLE IF NOT EXISTS wallet_policy_instances ( 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='钱包运行侧政策实例表'; -- 三套 App 共享同一张配置表,但 app_code 是主键的一部分;新 App 只需配置数据,不再复制表和业务代码。 -- 未配置任何 active 行时 H5 币商提现入口显示为空,不能用伪造默认币商兜底。 @@ -316,7 +316,7 @@ CREATE TABLE IF NOT EXISTS agency_point_share_entries ( PRIMARY KEY (app_code, transaction_id, agency_owner_user_id), KEY idx_agency_point_share_owner_time (app_code, agency_owner_user_id, created_at_ms), KEY idx_agency_point_share_host_time (app_code, host_user_id, created_at_ms) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agency POINT 礼物分成流水'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旧 Agency POINT 流水结构;新送礼不读写'; -- 主播工资周期钻石账户:只记录政策结算用累计值,不作为用户可消费或可提现的钱包资产。 -- cycle_key 当前固定为 UTC yyyy-MM;后续日结/半月结不清空该账户,月末清算任务按策略处理。 @@ -375,6 +375,23 @@ CREATE TABLE IF NOT EXISTS host_period_diamond_entries ( KEY idx_host_period_diamond_entries_region_cycle (app_code, region_id, cycle_key, created_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='主播工资周期钻石流水表'; +-- POINT_DIAMOND 的钱包分录允许 Host 与 Agency 同用户时合并入账,不能承担角色统计。 +-- 该表只由成功送礼事务写入,并保留两部分原始金额;任务奖励、兑换、提现和退回没有写入入口。 +CREATE TABLE IF NOT EXISTS point_diamond_gift_income_entries ( + entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '投影流水 ID', + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,必须显式写入以保证多租户隔离', + transaction_id VARCHAR(96) NOT NULL COMMENT '送礼钱包交易 ID', + host_user_id BIGINT NOT NULL COMMENT '收礼主播用户 ID', + agency_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '本次代理分成收款用户 ID,0 表示无分成', + sender_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户 ID', + host_base_amount BIGINT NOT NULL COMMENT '本次 Host 基础钻石积分,禁止包含 Agency 分成', + agency_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次 Agency 钻石积分分成', + created_at_ms BIGINT NOT NULL COMMENT '送礼成功时间,UTC epoch ms', + UNIQUE KEY uk_point_diamond_gift_income_tx (app_code, transaction_id), + KEY idx_point_diamond_gift_host_time (app_code, host_user_id, created_at_ms, entry_id), + KEY idx_point_diamond_gift_agency_time (app_code, agency_user_id, created_at_ms, entry_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT_DIAMOND 送礼收益角色拆分投影'; + -- 运行侧工资政策表:字段与 admin_host_agency_salary_policies 对齐,wallet-service 结算只读取这里的已发布快照。 -- 后台配置仍由 admin-server 管理;同步/导入时必须整份写入 policy + levels,结算事务不直接读 admin 库。 CREATE TABLE IF NOT EXISTS host_agency_salary_policies ( @@ -383,11 +400,17 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies ( cycle_key VARCHAR(7) NOT NULL COMMENT '政策归属工资周期,UTC yyyy-MM', policy_version BIGINT UNSIGNED NOT NULL COMMENT '不可变政策版本,等于后台政策 ID', name VARCHAR(120) NOT NULL DEFAULT '' COMMENT '政策名称', + policy_type VARCHAR(32) NOT NULL DEFAULT 'SALARY_DIAMOND' COMMENT '政策类型:SALARY_DIAMOND/POINT_DIAMOND', region_id BIGINT NOT NULL COMMENT '适用区域 ID', status VARCHAR(24) NOT NULL DEFAULT 'disabled' COMMENT '状态:active/disabled', settlement_mode VARCHAR(24) NOT NULL DEFAULT 'daily' COMMENT '结算方式:daily/half_month', settlement_trigger_mode VARCHAR(24) NOT NULL DEFAULT 'automatic' COMMENT '结算触发方式:automatic/manual', - gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '礼物金币转主播钻石比例快照', + gift_coin_to_diamond_ratio DECIMAL(18,6) NOT NULL DEFAULT 1.000000 COMMENT '周期钻石政策系数快照,1 表示不调整礼物类型倍率', + point_diamonds_per_usd BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 每美元对应钻石积分', + coins_per_usd BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 每美元对应金币', + minimum_withdraw_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 最低提现美元美分', + withdraw_fee_bps INT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 提现费率基点', + agency_point_share_bps INT NOT NULL DEFAULT 0 COMMENT 'POINT_DIAMOND 代理分成占主播基础积分的基点', residual_diamond_to_usd_rate DECIMAL(24,12) NOT NULL DEFAULT 0.000000000000 COMMENT '月底剩余钻石转美元比例', coin_seller_withdrawal_limit_period VARCHAR(16) NOT NULL DEFAULT 'month' COMMENT '用户找币商提现限制周期:day/week/month', coin_seller_withdrawal_limit_count BIGINT NOT NULL DEFAULT 0 COMMENT '用户找币商提现次数,0 表示不限', @@ -405,6 +428,7 @@ CREATE TABLE IF NOT EXISTS host_agency_salary_policies ( -- 计数按完整主键点查/行锁,不读取钱包交易或提现申请流水,限制检查的成本与历史交易量无关。 CREATE TABLE IF NOT EXISTS point_withdrawal_policy_counters ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + source_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '提现来源资产;POINT 与 POINT_DIAMOND 独立计数', user_id BIGINT NOT NULL COMMENT '提现用户 ID', channel VARCHAR(24) NOT NULL COMMENT '提现渠道:coin_seller/platform', period VARCHAR(16) NOT NULL COMMENT '限制周期类型:day/week/month', @@ -412,12 +436,13 @@ CREATE TABLE IF NOT EXISTS point_withdrawal_policy_counters ( withdrawal_count BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '周期内已计次数', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', - PRIMARY KEY (app_code, user_id, channel, period, period_key) + PRIMARY KEY (app_code, source_asset_type, user_id, channel, period, period_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自然周期提现次数计数表'; -- 平台提现只有申请创建失败回滚才释放 reservation;审核驳回仍保留已发起次数。 CREATE TABLE IF NOT EXISTS point_withdrawal_limit_reservations ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + source_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '提现来源资产;避免不同积分钱包共享幂等域', withdrawal_ref VARCHAR(128) NOT NULL COMMENT 'gateway 生成的申请前置引用', freeze_command_id VARCHAR(128) NOT NULL COMMENT '首次冻结命令幂等 ID', user_id BIGINT NOT NULL COMMENT '提现用户 ID', @@ -430,8 +455,8 @@ CREATE TABLE IF NOT EXISTS point_withdrawal_limit_reservations ( status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态:active/released', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', released_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '创建失败回滚时间,UTC epoch ms', - PRIMARY KEY (app_code, withdrawal_ref), - UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, freeze_command_id) + PRIMARY KEY (app_code, source_asset_type, withdrawal_ref), + UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, source_asset_type, freeze_command_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表'; -- 周期绑定一旦插入不可更新;复合主键保证新月份政策无法覆盖历史月份结算口径。 @@ -1712,7 +1737,7 @@ CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs ( region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域,0 表示全局默认', gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码', status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态', - ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例,百分比', + ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石的礼物类型基础倍率,百分比', coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例,百分比', created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID', updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID', @@ -2604,11 +2629,3 @@ INSERT IGNORE INTO gift_type_configs ( ('lalu', 'activity', '活动礼物', 'Activity', 'active', 80, 0, 0, 0, 0), ('lalu', 'magic', '魔法礼物', 'Magic', 'active', 90, 0, 0, 0, 0), ('lalu', 'custom', '定制礼物', 'Custom', 'active', 100, 0, 0, 0, 0); - -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 - ('lalu', 0, 'normal', 'active', 100.00, 30.00, 0, 0, 0, 0), - ('lalu', 0, 'lucky', 'active', 10.00, 10.00, 0, 0, 0, 0), - ('lalu', 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, 0, 0); diff --git a/services/wallet-service/deploy/mysql/migrations/015_remove_direct_gift_point_policy.sql b/services/wallet-service/deploy/mysql/migrations/015_remove_direct_gift_point_policy.sql new file mode 100644 index 00000000..2f356085 --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/015_remove_direct_gift_point_policy.sql @@ -0,0 +1,26 @@ +-- Direct-gift POINT has no consumer after Host period diamonds become the only gift-income path. +-- The predicate uses the removed template capability rather than app_code, so no runtime tenant branch is introduced. +-- wallet_policy_instances is a small configuration table; the delete is idempotent and never touches accounts or ledger rows. +DELETE FROM wallet_policy_instances +WHERE template_code = 'fami_guild_revenue_policy'; + +-- Remaining POINT policies keep only withdrawal/exchange parameters. Deprecated columns stay for rolling schema safety, +-- while every Admin-only section with no wallet consumer is removed from the runtime snapshot. +UPDATE wallet_policy_instances +SET host_point_ratio_ppm = 0, + agency_point_ratio_ppm = 0, + 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' + ); diff --git a/services/wallet-service/deploy/mysql/migrations/017_host_policy_types.sql b/services/wallet-service/deploy/mysql/migrations/017_host_policy_types.sql new file mode 100644 index 00000000..293d515c --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/017_host_policy_types.sql @@ -0,0 +1,72 @@ +-- 运行快照只保存后台发布的不可变政策;逐列检查使迁移可安全重放。 +SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'host_agency_salary_policies' AND COLUMN_NAME = 'policy_type') = 0, + 'ALTER TABLE 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 = 'host_agency_salary_policies' AND COLUMN_NAME = 'point_diamonds_per_usd') = 0, + 'ALTER TABLE 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 = 'host_agency_salary_policies' AND COLUMN_NAME = 'coins_per_usd') = 0, + 'ALTER TABLE 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 = 'host_agency_salary_policies' AND COLUMN_NAME = 'minimum_withdraw_usd_minor') = 0, + 'ALTER TABLE 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 = 'host_agency_salary_policies' AND COLUMN_NAME = 'withdraw_fee_bps') = 0, + 'ALTER TABLE 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 = 'host_agency_salary_policies' AND COLUMN_NAME = 'agency_point_share_bps') = 0, + 'ALTER TABLE 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; + +-- 新表从空表开始,CREATE TABLE IF NOT EXISTS 可安全重放且不会扫描 wallet_transactions/wallet_entries 大流水。 +-- Host/Agency 时间索引直接服务用户与日期范围聚合,不依赖 metadata_json 的 JSON_EXTRACT。 +CREATE TABLE IF NOT EXISTS point_diamond_gift_income_entries ( + entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '投影流水 ID', + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,必须显式写入以保证多租户隔离', + transaction_id VARCHAR(96) NOT NULL COMMENT '送礼钱包交易 ID', + host_user_id BIGINT NOT NULL COMMENT '收礼主播用户 ID', + agency_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '本次代理分成收款用户 ID,0 表示无分成', + sender_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户 ID', + host_base_amount BIGINT NOT NULL COMMENT '本次 Host 基础钻石积分,禁止包含 Agency 分成', + agency_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次 Agency 钻石积分分成', + created_at_ms BIGINT NOT NULL COMMENT '送礼成功时间,UTC epoch ms', + UNIQUE KEY uk_point_diamond_gift_income_tx (app_code, transaction_id), + KEY idx_point_diamond_gift_host_time (app_code, host_user_id, created_at_ms, entry_id), + KEY idx_point_diamond_gift_agency_time (app_code, agency_user_id, created_at_ms, entry_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT_DIAMOND 送礼收益角色拆分投影'; + +DROP TABLE IF EXISTS host_period_diamond_policy_behaviors; + +-- 次数和 reservation 是小型聚合表;只补一列并改复合键,不回填或扫描 wallet_entries 大流水。 +SET @ddl = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'point_withdrawal_policy_counters' AND COLUMN_NAME = 'source_asset_type') = 0, + 'ALTER TABLE point_withdrawal_policy_counters ADD COLUMN source_asset_type VARCHAR(32) NOT NULL DEFAULT ''POINT'' COMMENT ''提现来源资产'' AFTER app_code, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @counter_pk = (SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION SEPARATOR ',') FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'point_withdrawal_policy_counters' AND CONSTRAINT_NAME = 'PRIMARY'); +SET @ddl = IF(@counter_pk <> 'app_code,source_asset_type,user_id,channel,period,period_key', + 'ALTER TABLE point_withdrawal_policy_counters DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, source_asset_type, user_id, channel, period, period_key), 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 = 'point_withdrawal_limit_reservations' AND COLUMN_NAME = 'source_asset_type') = 0, + 'ALTER TABLE point_withdrawal_limit_reservations ADD COLUMN source_asset_type VARCHAR(32) NOT NULL DEFAULT ''POINT'' COMMENT ''提现来源资产'' AFTER app_code, ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @reservation_pk = (SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION SEPARATOR ',') FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'point_withdrawal_limit_reservations' AND CONSTRAINT_NAME = 'PRIMARY'); +SET @ddl = IF(@reservation_pk <> 'app_code,source_asset_type,withdrawal_ref', + 'ALTER TABLE point_withdrawal_limit_reservations DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, source_asset_type, withdrawal_ref), ALGORITHM=INPLACE, LOCK=NONE', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @reservation_uk = (SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'point_withdrawal_limit_reservations' AND INDEX_NAME = 'uk_point_withdrawal_limit_reservation_command'); +SET @ddl = CASE + WHEN @reservation_uk IS NULL THEN + 'ALTER TABLE point_withdrawal_limit_reservations ADD UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, source_asset_type, freeze_command_id), ALGORITHM=INPLACE, LOCK=NONE' + WHEN @reservation_uk <> 'app_code,source_asset_type,freeze_command_id' THEN + 'ALTER TABLE point_withdrawal_limit_reservations DROP INDEX uk_point_withdrawal_limit_reservation_command, ADD UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, source_asset_type, freeze_command_id), ALGORITHM=INPLACE, LOCK=NONE' + ELSE 'SELECT 1' +END; +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/services/wallet-service/internal/client/binance/client.go b/services/wallet-service/internal/client/binance/client.go index d87ef570..cd17df16 100644 --- a/services/wallet-service/internal/client/binance/client.go +++ b/services/wallet-service/internal/client/binance/client.go @@ -48,15 +48,19 @@ func New(cfg config.BinanceConfig) *Client { if timeout <= 0 { timeout = 10 * time.Second } + // 适配器只复制规范化租户注册表;新增 App 账号由环境配置扩展,不能在客户端再维护产品名映射。 + accounts := make(map[string]account, len(cfg.Accounts)) + for appCode, configured := range cfg.Accounts { + appCode = strings.ToLower(strings.TrimSpace(appCode)) + if appCode != "" { + accounts[appCode] = newAccount(configured) + } + } return &Client{ httpClient: &http.Client{Timeout: timeout}, apiBaseURL: strings.TrimRight(strings.TrimSpace(firstNonEmpty(cfg.APIBaseURL, "https://api.binance.com")), "/"), recvWindow: firstPositiveInt64(cfg.RecvWindow, 10000), - accounts: map[string]account{ - "lalu": newAccount(cfg.Lalu), - "aslan": newAccount(cfg.Aslan), - "yumi": newAccount(cfg.Yumi), - }, + accounts: accounts, } } diff --git a/services/wallet-service/internal/client/binance/client_test.go b/services/wallet-service/internal/client/binance/client_test.go index 7a3ef5f0..44d045b6 100644 --- a/services/wallet-service/internal/client/binance/client_test.go +++ b/services/wallet-service/internal/client/binance/client_test.go @@ -64,9 +64,8 @@ func TestFindUSDTTransferMatchesDepositAndSignedRequest(t *testing.T) { APIBaseURL: server.URL, RecvWindow: 10000, HTTPTimeout: time.Second, - Lalu: config.BinanceAccountConfig{ - APIKey: "api-lalu", - APISecretKey: "secret-lalu", + Accounts: map[string]config.BinanceAccountConfig{ + "lalu": {APIKey: "api-lalu", APISecretKey: "secret-lalu"}, }, }) record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ @@ -119,7 +118,7 @@ func TestFindUSDTTransferOffchainUsesDepositHistoryOnly(t *testing.T) { Enabled: true, APIBaseURL: server.URL, HTTPTimeout: time.Second, - Aslan: config.BinanceAccountConfig{APIKey: "api-aslan", APISecretKey: "secret-aslan"}, + Accounts: map[string]config.BinanceAccountConfig{"aslan": {APIKey: "api-aslan", APISecretKey: "secret-aslan"}}, }) record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ AppCode: "aslan", @@ -230,9 +229,8 @@ func TestFindUSDTTransferFallsBackToIncomingPayC2C(t *testing.T) { APIBaseURL: server.URL, RecvWindow: 10000, HTTPTimeout: time.Second, - Yumi: config.BinanceAccountConfig{ - APIKey: "api-yumi", - APISecretKey: "secret-yumi", + Accounts: map[string]config.BinanceAccountConfig{ + "yumi": {APIKey: "api-yumi", APISecretKey: "secret-yumi"}, }, }) record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ @@ -282,7 +280,7 @@ func TestFindUSDTTransferC2CUsesPayAndAcceptsUSDC(t *testing.T) { } })) defer server.Close() - client := New(config.BinanceConfig{Enabled: true, APIBaseURL: server.URL, RecvWindow: 10000, HTTPTimeout: time.Second, Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"}}) + client := New(config.BinanceConfig{Enabled: true, APIBaseURL: server.URL, RecvWindow: 10000, HTTPTimeout: time.Second, Accounts: map[string]config.BinanceAccountConfig{"lalu": {APIKey: "api-lalu", APISecretKey: "secret-lalu"}}}) record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{AppCode: "lalu", ExternalID: orderID, Coin: "USDT", Network: "C2C", AmountMinor: 20000, OrderTimeMS: 1784017560000}) if err != nil { t.Fatalf("C2C lookup failed: %v", err) @@ -314,7 +312,7 @@ func TestFindUSDTTransferDoesNotBypassDepositConflictWithPay(t *testing.T) { client := New(config.BinanceConfig{ APIBaseURL: server.URL, - Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"}, + Accounts: map[string]config.BinanceAccountConfig{"lalu": {APIKey: "api-lalu", APISecretKey: "secret-lalu"}}, }) _, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ AppCode: "lalu", ExternalID: "433527303245463552", Coin: "USDT", Network: "TRX", @@ -365,9 +363,8 @@ func TestFindUSDTTransferMissingAccountReturnsConfiguredError(t *testing.T) { client := New(config.BinanceConfig{ Enabled: true, APIBaseURL: "https://binance.invalid", - Lalu: config.BinanceAccountConfig{ - APIKey: "api-lalu", - APISecretKey: "secret-lalu", + Accounts: map[string]config.BinanceAccountConfig{ + "lalu": {APIKey: "api-lalu", APISecretKey: "secret-lalu"}, }, }) _, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ @@ -385,9 +382,11 @@ func TestFindUSDTTransferMissingAccountReturnsConfiguredError(t *testing.T) { func TestAccountForAppSupportsAllProductionApps(t *testing.T) { client := New(config.BinanceConfig{ - Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"}, - Yumi: config.BinanceAccountConfig{APIKey: "api-yumi", APISecretKey: "secret-yumi"}, - Aslan: config.BinanceAccountConfig{APIKey: "api-aslan", APISecretKey: "secret-aslan"}, + Accounts: map[string]config.BinanceAccountConfig{ + "lalu": {APIKey: "api-lalu", APISecretKey: "secret-lalu"}, + "yumi": {APIKey: "api-yumi", APISecretKey: "secret-yumi"}, + "aslan": {APIKey: "api-aslan", APISecretKey: "secret-aslan"}, + }, }) for _, appCode := range []string{"lalu", "yumi", "aslan"} { account, ok := client.accountForApp(appCode) diff --git a/services/wallet-service/internal/config/config.go b/services/wallet-service/internal/config/config.go index 963569dd..f9ff5353 100644 --- a/services/wallet-service/internal/config/config.go +++ b/services/wallet-service/internal/config/config.go @@ -41,7 +41,7 @@ type Config struct { TronGrid TronGridConfig `yaml:"tron_grid"` // BSCUSDT 控制 USDT-BEP20 tx_hash 链上只读校验;默认关闭,避免生产误依赖公共 RPC。 BSCUSDT BSCUSDTConfig `yaml:"bsc_usdt"` - // Binance 控制 Binance 只读查单;配置键仍兼容现有 bian.lalu/aslan/yumi。 + // Binance 控制 Binance 只读查单;账号按 app_code 注册,旧版 bian. 仍由兼容映射读取。 Binance BinanceConfig `yaml:"bian"` // ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。 ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"` @@ -277,13 +277,13 @@ type BSCUSDTConfig struct { // BinanceConfig 保存 Binance 只读查询配置;不同 App 独立 key,避免跨账套查错收款记录。 type BinanceConfig struct { - Enabled bool `yaml:"enabled"` - APIBaseURL string `yaml:"api_base_url"` - RecvWindow int64 `yaml:"recv_window"` - HTTPTimeout time.Duration `yaml:"http_timeout"` - Lalu BinanceAccountConfig `yaml:"lalu"` - Aslan BinanceAccountConfig `yaml:"aslan"` - Yumi BinanceAccountConfig `yaml:"yumi"` + Enabled bool `yaml:"enabled"` + APIBaseURL string `yaml:"api_base_url"` + RecvWindow int64 `yaml:"recv_window"` + HTTPTimeout time.Duration `yaml:"http_timeout"` + Accounts map[string]BinanceAccountConfig `yaml:"accounts"` + // LegacyAccounts 泛化接收旧版 bian. 顶层账号;不枚举产品名,归一化后统一并入 Accounts。 + LegacyAccounts map[string]BinanceAccountConfig `yaml:",inline"` } // BinanceAccountConfig 是单个 Binance 账号的只读 API 凭证;日志和错误都不能输出这些字段。 @@ -292,23 +292,23 @@ type BinanceAccountConfig struct { APISecretKey string `yaml:"api_secret_key"` } -// Account 按 appCode 选择对应 Binance 账号;未知 app 明确返回 false,避免默认落到 lalu。 +// Account 按 appCode 选择对应 Binance 账号;未知 app 明确返回 false,避免落入其它租户账套。 func (cfg BinanceConfig) Account(appCode string) (BinanceAccountConfig, bool) { - switch strings.ToLower(strings.TrimSpace(appCode)) { - case "lalu": - return cfg.Lalu, binanceAccountReady(cfg.Lalu) - case "aslan": - return cfg.Aslan, binanceAccountReady(cfg.Aslan) - case "yumi": - return cfg.Yumi, binanceAccountReady(cfg.Yumi) - default: + account, exists := cfg.Accounts[strings.ToLower(strings.TrimSpace(appCode))] + if !exists { return BinanceAccountConfig{}, false } + return account, binanceAccountReady(account) } // HasAnyAccount 用于启动装配判断是否需要注入 Binance client;缺失某个 App key 时仍让业务返回明确配置错误。 func (cfg BinanceConfig) HasAnyAccount() bool { - return binanceAccountReady(cfg.Lalu) || binanceAccountReady(cfg.Aslan) || binanceAccountReady(cfg.Yumi) + for _, account := range cfg.Accounts { + if binanceAccountReady(account) { + return true + } + } + return false } func binanceAccountReady(account BinanceAccountConfig) bool { @@ -765,9 +765,23 @@ func normalizeBinanceConfig(cfg BinanceConfig) BinanceConfig { if cfg.HTTPTimeout <= 0 { cfg.HTTPTimeout = defaults.HTTPTimeout } - cfg.Lalu = normalizeBinanceAccountConfig(cfg.Lalu) - cfg.Aslan = normalizeBinanceAccountConfig(cfg.Aslan) - cfg.Yumi = normalizeBinanceAccountConfig(cfg.Yumi) + // 新 accounts 节点覆盖同名旧顶层账号;两种格式都只经过通用 app_code map, + // 线上旧 Nacos/YAML 可滚动升级,新增租户则无需扩展结构体或业务分支。 + accounts := make(map[string]BinanceAccountConfig, len(cfg.LegacyAccounts)+len(cfg.Accounts)) + mergeAccounts := func(source map[string]BinanceAccountConfig) { + for appCode, account := range source { + appCode = strings.ToLower(strings.TrimSpace(appCode)) + account = normalizeBinanceAccountConfig(account) + if appCode == "" || !binanceAccountReady(account) { + continue + } + accounts[appCode] = account + } + } + mergeAccounts(cfg.LegacyAccounts) + mergeAccounts(cfg.Accounts) + cfg.Accounts = accounts + cfg.LegacyAccounts = nil return cfg } @@ -1004,11 +1018,7 @@ func normalizeOutboxArchiveConfig(cfg OutboxArchiveConfig, defaults OutboxArchiv if len(cfg.PurgeAppCodes) == 0 || len(proofsByApp) != 0 { return OutboxArchiveConfig{}, errors.New("outbox_archive purge requires a non-empty app allowlist with no unmatched restore proofs") } - for _, appCode := range cfg.PurgeAppCodes { - if appCode != "lalu" && appCode != "fami" { - return OutboxArchiveConfig{}, errors.New("outbox_archive financial-v1 purge only supports explicitly approved lalu and fami apps") - } - } + // 租户范围由显式 allowlist 和逐 App 恢复证明共同约束,不在代码里固化产品名称。 if cfg.PurgeMaxAge < 30*24*time.Hour || cfg.PurgeMaxAge < cfg.MaxAge { return OutboxArchiveConfig{}, errors.New("outbox_archive purge_max_age must be at least 30d and not shorter than archive max_age") } diff --git a/services/wallet-service/internal/config/config_test.go b/services/wallet-service/internal/config/config_test.go index 02e01b7f..fec22a8f 100644 --- a/services/wallet-service/internal/config/config_test.go +++ b/services/wallet-service/internal/config/config_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestLoadLocalEnablesOutboxMQ(t *testing.T) { @@ -114,13 +116,15 @@ func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T) func TestBinanceConfigDefaultsAndAccountSelection(t *testing.T) { cfg := normalizeBinanceConfig(BinanceConfig{ - Lalu: BinanceAccountConfig{ - APIKey: " lalu-key ", - APISecretKey: " lalu-secret ", - }, - Aslan: BinanceAccountConfig{ - APIKey: "aslan-key", - APISecretKey: "", + Accounts: map[string]BinanceAccountConfig{ + " LALU ": { + APIKey: " lalu-key ", + APISecretKey: " lalu-secret ", + }, + "aslan": { + APIKey: "aslan-key", + APISecretKey: "", + }, }, }) if cfg.APIBaseURL != "https://api.binance.com" || cfg.RecvWindow != 10000 || cfg.HTTPTimeout == 0 { @@ -141,6 +145,24 @@ func TestBinanceConfigDefaultsAndAccountSelection(t *testing.T) { } } +func TestBinanceConfigNormalizesLegacyTopLevelAccountMap(t *testing.T) { + var cfg Config + // 真实反序列化旧版顶层账号,证明线上 bian. 无需与新二进制同步切换格式。 + if err := yaml.Unmarshal([]byte(`bian: + enabled: true + Legacy-App: + api_key: " key " + api_secret_key: " secret " +`), &cfg); err != nil { + t.Fatalf("decode legacy Binance config: %v", err) + } + cfg.Binance = normalizeBinanceConfig(cfg.Binance) + account, ok := cfg.Binance.Account("legacy-app") + if !ok || account.APIKey != "key" || account.APISecretKey != "secret" { + t.Fatalf("legacy Binance account normalization mismatch: ok=%v account=%+v", ok, account) + } +} + func TestExternalRechargeAddressMapNormalizesAppOverrides(t *testing.T) { addresses := normalizeExternalRechargeAddressMap(map[string]string{ " ASLAN ": " TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J ", diff --git a/services/wallet-service/internal/domain/ledger/agency_host_gift_stats.go b/services/wallet-service/internal/domain/ledger/agency_host_gift_stats.go index b8cc4e28..cfe59540 100644 --- a/services/wallet-service/internal/domain/ledger/agency_host_gift_stats.go +++ b/services/wallet-service/internal/domain/ledger/agency_host_gift_stats.go @@ -1,12 +1,26 @@ package ledger type AgencyHostGiftStats struct { - GiftIncome int64 - GiftedHostCount int64 + // 旧字段保持 SALARY_DIAMOND Host 收入口径;永久积分使用独立字段,禁止跨资产相加。 + GiftIncome int64 + PointDiamondHostIncome int64 + PointDiamondAgencyShareIncome int64 + GiftedHostCount int64 + // HostStats 与 HostUserIDs 顺序一致,包含零收入主播;Agency Center 因此只需一次 + // wallet RPC,避免对成员逐个查询相同账本。 + HostStats []AgencyHostRevenueStats +} + +type AgencyHostRevenueStats struct { + HostUserID int64 + Stats HostRevenueStats } type AgencyHostGiftStatsQuery struct { HostUserIDs []int64 - StartAtMS int64 - EndAtMS int64 + // AgencyUserID 为 0 时只查询 Host 口径(例如 BD 视图);Agency Center 传 owner user_id + // 后从 POINT_DIAMOND 送礼投影独立汇总 agency_amount,避免自营主播合并钱包分录后重复计算。 + AgencyUserID int64 + StartAtMS int64 + EndAtMS int64 } diff --git a/services/wallet-service/internal/domain/ledger/agency_point_share_stats.go b/services/wallet-service/internal/domain/ledger/agency_point_share_stats.go deleted file mode 100644 index b4e484ca..00000000 --- a/services/wallet-service/internal/domain/ledger/agency_point_share_stats.go +++ /dev/null @@ -1,13 +0,0 @@ -package ledger - -// AgencyPointShareStats 聚合送礼时已固化归属的 Agency POINT 分成。 -type AgencyPointShareStats struct { - ShareIncome int64 - GiftedHostCount int64 -} - -type AgencyPointShareStatsQuery struct { - AgencyOwnerUserID int64 - StartAtMS int64 - EndAtMS int64 -} diff --git a/services/wallet-service/internal/domain/ledger/constants.go b/services/wallet-service/internal/domain/ledger/constants.go index dce2bcb1..7a0e079e 100644 --- a/services/wallet-service/internal/domain/ledger/constants.go +++ b/services/wallet-service/internal/domain/ledger/constants.go @@ -7,6 +7,8 @@ const ( AssetCoinSellerCoin = "COIN_SELLER_COIN" // AssetPoint 是 Huwaa 政策下主播、Agent、BD、经理收益使用的真实积分钱包资产。 AssetPoint = "POINT" + // AssetPointDiamond 是 POINT_DIAMOND Host 政策产生的永久钻石积分;它与历史 POINT、工资周期钻石完全隔离。 + AssetPointDiamond = "POINT_DIAMOND" // AssetCoinSellerPoint 是 Huwaa 币商提现订单结算给币商的专用积分资产。 AssetCoinSellerPoint = "COIN_SELLER_POINT" // AssetRobotCoin 是机器人房间专用金币资产,只允许内部机器人送礼链路扣减,不进入真人充值消费口径。 @@ -17,6 +19,10 @@ const ( AssetGiftPoint = "GIFT_POINT" // AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。 AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND" + // HostPolicyTypeSalaryDiamond 把送礼收益写入月度工资投影,由工资结算消费并关闭周期。 + HostPolicyTypeSalaryDiamond = "SALARY_DIAMOND" + // HostPolicyTypePointDiamond 把送礼收益写入永久钻石积分钱包,不进入工资等级结算。 + HostPolicyTypePointDiamond = "POINT_DIAMOND" // AssetHostSalaryUSD 是主播工资美元钱包,只接收主播工资和月底剩余钻石折美元。 AssetHostSalaryUSD = "HOST_SALARY_USD" // AssetAgencySalaryUSD 是代理工资美元钱包,只接收主播结算同时产生的代理工资。 diff --git a/services/wallet-service/internal/domain/ledger/gift.go b/services/wallet-service/internal/domain/ledger/gift.go index 1d4cee25..6fdd422f 100644 --- a/services/wallet-service/internal/domain/ledger/gift.go +++ b/services/wallet-service/internal/domain/ledger/gift.go @@ -87,18 +87,31 @@ type Receipt struct { BalanceVersion int64 // HostPeriodDiamondAdded 是本次送礼写入主播工资周期账户的钻石数;非主播恒为 0。 HostPeriodDiamondAdded int64 + // HostIncomePolicyType 与以下 canonical split 字段共同描述本次 Host/Agency 收益。 + HostIncomePolicyType string + HostIncomeBalanceAfter int64 + // Income split fields preserve the exact policy snapshot used by Room/Lucky and financial audit. + IncomeAssetType string + HostBaseAmount int64 + AgencyAmount int64 + AgencyOwnerUserID int64 + AgencyIncomeBalanceAfter int64 + HostIncomePolicyID uint64 + HostIncomePolicyVersion uint64 + HostPolicyCoefficient string + AgencyPointShareBPS int32 // HostPeriodCycleKey 是工资周期键,当前按 UTC 月生成,后续结算按此键定位周期账户。 HostPeriodCycleKey string - // HostPointAdded 是命中收益政策后写入主播 POINT 钱包的积分;只影响收益账本,不影响房间热度。 + // HostPointAdded 是旧送礼命令的回执兼容字段;新送礼不入 POINT,始终为 0。 HostPointAdded int64 - // HostPointBalanceAfter 是主播 POINT 入账后的可用余额;未命中 POINT 政策时为 0。 + // HostPointBalanceAfter 只供旧命令幂等重放;新送礼始终为 0。 HostPointBalanceAfter int64 - // HostPointPolicyInstanceCode 是本次 POINT 入账使用的后台策略实例快照,便于审计和重放核对。 + // HostPointPolicyInstanceCode 只保留旧命令已固化的快照;新送礼不读取收益政策。 HostPointPolicyInstanceCode string - // HostPointTemplateCode/Version 标记本次使用的模板版本,策略升级后历史礼物仍可追溯。 + // HostPointTemplateCode/Version 同样只是旧命令回执快照,新送礼为空。 HostPointTemplateCode string HostPointTemplateVersion string - // HostPointAssetType 当前固定为 POINT,显式返回给客户端和下游避免继续把收益当成 COIN。 + // HostPointAssetType 只供旧命令回执重放,新送礼为空。 HostPointAssetType string EntitlementID string ChargeSource string diff --git a/services/wallet-service/internal/domain/ledger/host_revenue_stats.go b/services/wallet-service/internal/domain/ledger/host_revenue_stats.go index cec9dfe2..48ebac38 100644 --- a/services/wallet-service/internal/domain/ledger/host_revenue_stats.go +++ b/services/wallet-service/internal/domain/ledger/host_revenue_stats.go @@ -2,14 +2,19 @@ package ledger // HostRevenueStats 是主播中心按日期展示的周期钻石收益、POINT 已兑换数量和去重送礼人数。 type HostRevenueStats struct { + // 旧字段继续只表示 SALARY_DIAMOND/历史 POINT,确保 Lalu 客户端口径不变。 DiamondEarnings int64 DiamondExchanged int64 - GiftSenders int64 + // 新字段显式隔离永久钻石积分,不能与任务 POINT 或周期工资钻石相加。 + PointDiamondEarnings int64 + PointDiamondExchanged int64 + GiftSenders int64 } // HostRevenueStatsQuery 使用左闭右开 UTC 毫秒区间;调用方必须限制区间长度。 type HostRevenueStatsQuery struct { HostUserID int64 + RegionID int64 StartAtMS int64 EndAtMS int64 } diff --git a/services/wallet-service/internal/domain/ledger/host_salary.go b/services/wallet-service/internal/domain/ledger/host_salary.go index 48c7582b..43a38ba2 100644 --- a/services/wallet-service/internal/domain/ledger/host_salary.go +++ b/services/wallet-service/internal/domain/ledger/host_salary.go @@ -30,14 +30,21 @@ type HostSalarySettlementBatchResult struct { type HostSalaryPolicy struct { PolicyID uint64 // CycleKey 与 PolicyVersion 共同标识已发布的不可变周期快照;结算只能按账户 cycle_key 精确命中。 - CycleKey string - PolicyVersion uint64 - Name string - RegionID int64 - Status string - SettlementMode string - SettlementTriggerMode string + CycleKey string + PolicyVersion uint64 + Name string + PolicyType string + RegionID int64 + Status string + SettlementMode string + SettlementTriggerMode string + // GiftCoinToDiamondRatio 是叠加在礼物类型倍率上的 Host 政策系数,1 表示不调整。 GiftCoinToDiamondRatio string + PointDiamondsPerUSD int64 + CoinsPerUSD int64 + MinimumWithdrawUSDMinor int64 + WithdrawFeeBPS int32 + AgencyPointShareBPS int32 ResidualDiamondToUSDRate string CoinSellerWithdrawalLimitPeriod string CoinSellerWithdrawalLimitCount int64 diff --git a/services/wallet-service/internal/domain/ledger/point_coin_seller.go b/services/wallet-service/internal/domain/ledger/point_coin_seller.go index c6716c03..0c40fa50 100644 --- a/services/wallet-service/internal/domain/ledger/point_coin_seller.go +++ b/services/wallet-service/internal/domain/ledger/point_coin_seller.go @@ -15,11 +15,14 @@ type PointWithdrawalCoinSellerConfig struct { // PointToCoinSellerCommand 只接受用户、目标和金额;比例必须由 wallet 数据库的 active 白名单解析。 type PointToCoinSellerCommand struct { - AppCode string - CommandID string - SourceUserID int64 - SellerUserID int64 - PointAmount int64 + AppCode string + CommandID string + SourceUserID int64 + SellerUserID int64 + PointAmount int64 + // SourceAssetType 为空保持历史 POINT;POINT_DIAMOND 时金额由 GrossUSDMinor 和 Host 政策快照换算。 + SourceAssetType string + GrossUSDMinor int64 SourceCountryCode string RegionID int64 Reason string @@ -36,17 +39,26 @@ type PointToCoinSellerReceipt struct { SellerCoinAmount int64 RatioPointAmount int64 RatioSellerCoinAmount int64 + SourceAssetType string + GrossUSDMinor int64 + FeeUSDMinor int64 + NetUSDMinor int64 + WithdrawFeeBPS int32 + PolicyID uint64 + PolicyVersion uint64 CreatedAtMS int64 } -// PointToCoinCommand 只声明用户和 POINT 数量;region/now 用于在事务内解析同一份已发布工资政策。 +// PointToCoinCommand 只声明用户和 POINT 数量;region/now 用于在事务内解析同一份已发布 wallet 政策。 type PointToCoinCommand struct { AppCode string CommandID string UserID int64 PointAmount int64 - RegionID int64 - NowMS int64 + // SourceAssetType 为空保持历史 POINT,POINT_DIAMOND 使用 Host 政策中的两组每美元比例。 + SourceAssetType string + RegionID int64 + NowMS int64 } // PointToCoinReceipt 固化首次成功兑换的比例和双方余额,网络重试必须返回同一快照。 @@ -60,14 +72,37 @@ type PointToCoinReceipt struct { RatioPointAmount int64 RatioCoinAmount int64 CreatedAtMS int64 + SourceAssetType string + PolicyID uint64 + PolicyVersion uint64 } -// PointWithdrawalRuntimeConfig 是已发布政策中钱包页需要展示并用于冻结的参数。 +// PointWithdrawalRuntimeConfig 是已发布 wallet 政策中钱包页需要展示并用于冻结的 POINT 参数。 type PointWithdrawalRuntimeConfig struct { - Found bool - PointsPerUSD int64 - CoinsPerUSD int64 - FeeBPS int64 - MinimumPoints int64 - PolicyInstanceCode string + Found bool + PointsPerUSD int64 + CoinsPerUSD int64 + FeeBPS int64 + MinimumPoints int64 + PolicyInstanceCode string + PolicyType string + MinimumWithdrawUSDMinor int64 + AgencyPointShareBPS int32 + PolicyID uint64 + PolicyVersion uint64 + AvailabilityEvaluated bool + CoinSellerAvailability PointWithdrawalActionAvailability + PlatformAvailability PointWithdrawalActionAvailability +} + +// PointWithdrawalActionAvailability 是钱包按当前 UTC 日期和已消费次数计算的实时能力; +// LimitCount=0/RemainingCount=-1 表示后台配置为不限次数。 +type PointWithdrawalActionAvailability struct { + Allowed bool + LimitPeriod string + LimitCount int64 + UsedCount int64 + RemainingCount int64 + AllowedDays string + BlockReason string } diff --git a/services/wallet-service/internal/domain/ledger/salary_withdrawal.go b/services/wallet-service/internal/domain/ledger/salary_withdrawal.go index 1470b709..a4f896f8 100644 --- a/services/wallet-service/internal/domain/ledger/salary_withdrawal.go +++ b/services/wallet-service/internal/domain/ledger/salary_withdrawal.go @@ -4,11 +4,13 @@ package ledger // // SalaryUSDMinor 使用美元分,保证 H5 表单、后台审核和 wallet-service 之间不经过浮点换算。 type SalaryWithdrawalCommand struct { - AppCode string - CommandID string - UserID int64 - SalaryAssetType string - SalaryUSDMinor int64 + AppCode string + CommandID string + UserID int64 + SalaryAssetType string + SalaryUSDMinor int64 + // GrossUSDMinor 只在 POINT_DIAMOND 首次冻结时使用,wallet 按 Host 政策换算实际冻结积分。 + GrossUSDMinor int64 PointFeeAmount int64 PointNetAmount int64 PointsPerUSD int64 @@ -17,27 +19,37 @@ type SalaryWithdrawalCommand struct { Reason string WithdrawalRef string WithdrawalApplicationID string - // RegionID 只在 POINT 首次冻结时由 gateway 注入,用于绑定当前 UTC 月工资政策。 + // FreezeTransactionID 让 POINT_DIAMOND 审核/回滚从首次冻结交易复制政策和美元快照, + // 不能根据审核时当前政策反推历史金额。 + FreezeTransactionID string + // RegionID 在首次冻结时由 gateway 从已校验的业务身份注入,用于绑定当前 UTC 月 Host 政策。 RegionID int64 } // SalaryWithdrawalReceipt 返回单边工资钱包变更后的余额,供 gateway/admin 回写申请单和前端展示。 type SalaryWithdrawalReceipt struct { - TransactionID string - UserID int64 - SalaryAssetType string - SalaryUSDMinor int64 - PointFeeAmount int64 - PointNetAmount int64 - PointsPerUSD int64 - PointWithdrawFeeBPS int64 - AvailableAfter int64 - FrozenAfter int64 - Version int64 - CreatedAtMS int64 + TransactionID string + UserID int64 + SalaryAssetType string + SalaryUSDMinor int64 + PointFeeAmount int64 + PointNetAmount int64 + PointsPerUSD int64 + PointWithdrawFeeBPS int64 + PointPolicyInstanceCode string + GrossUSDMinor int64 + FeeUSDMinor int64 + NetUSDMinor int64 + PointPolicyID uint64 + PointPolicyVersion uint64 + CoinsPerUSD int64 + AvailableAfter int64 + FrozenAfter int64 + Version int64 + CreatedAtMS int64 } -// PointWithdrawalCommand 是 Huwaa 策略积分提现的冻结/扣冻/解冻命令。 +// PointWithdrawalCommand 是 POINT 提现的冻结/扣冻/解冻命令。 // GrossPointAmount 是实际冻结和扣减的积分总额;Fee/Net/PointsPerUSD 只做提现展示和审核快照。 type PointWithdrawalCommand struct { AppCode string @@ -45,6 +57,7 @@ type PointWithdrawalCommand struct { UserID int64 AssetType string GrossPointAmount int64 + GrossUSDMinor int64 FeePointAmount int64 NetPointAmount int64 PointsPerUSD int64 @@ -53,21 +66,29 @@ type PointWithdrawalCommand struct { Reason string WithdrawalRef string WithdrawalApplicationID string + FreezeTransactionID string RegionID int64 } // PointWithdrawalReceipt 返回 POINT 钱包冻结/扣冻/释放后的余额和费用快照。 type PointWithdrawalReceipt struct { - TransactionID string - UserID int64 - AssetType string - GrossPointAmount int64 - FeePointAmount int64 - NetPointAmount int64 - PointsPerUSD int64 - FeeBPS int64 - AvailableAfter int64 - FrozenAfter int64 - Version int64 - CreatedAtMS int64 + TransactionID string + UserID int64 + AssetType string + GrossPointAmount int64 + FeePointAmount int64 + NetPointAmount int64 + PointsPerUSD int64 + FeeBPS int64 + PointPolicyInstanceCode string + GrossUSDMinor int64 + FeeUSDMinor int64 + NetUSDMinor int64 + PointPolicyID uint64 + PointPolicyVersion uint64 + CoinsPerUSD int64 + AvailableAfter int64 + FrozenAfter int64 + Version int64 + CreatedAtMS int64 } diff --git a/services/wallet-service/internal/domain/ledger/wallet_query.go b/services/wallet-service/internal/domain/ledger/wallet_query.go index 341a82f5..f1d91b6e 100644 --- a/services/wallet-service/internal/domain/ledger/wallet_query.go +++ b/services/wallet-service/internal/domain/ledger/wallet_query.go @@ -224,6 +224,18 @@ type WalletTransaction struct { CreatedAtMS int64 TransferUSDMinor int64 TransferCurrencyCode string + EntryRole string + // HostBaseAmount / AgencyAmount 是礼物收益分录对应的拆分值;同人合并入账时两者同时有值。 + HostBaseAmount int64 + AgencyAmount int64 + PolicyID uint64 + PolicyVersion uint64 + RatioPointAmount int64 + RatioCoinAmount int64 + GrossUSDMinor int64 + FeeUSDMinor int64 + NetUSDMinor int64 + WithdrawFeeBPS int64 } // ListWalletTransactionsQuery 描述 App 钱包流水分页条件。 @@ -264,7 +276,7 @@ type AdminCreditAssetCommand struct { // ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。 func ValidAssetType(assetType string) bool { switch assetType { - case AssetCoin, AssetCoinSellerCoin, AssetPoint, AssetCoinSellerPoint, AssetRobotCoin, AssetGiftPoint, AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD: + case AssetCoin, AssetCoinSellerCoin, AssetPoint, AssetPointDiamond, AssetCoinSellerPoint, AssetRobotCoin, AssetGiftPoint, AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD: return true default: return false @@ -281,10 +293,21 @@ func ValidSalaryAssetType(assetType string) bool { } } -// ValidPointWithdrawalAssetType 只允许 Huwaa 政策积分进入积分提现链路,避免普通 COIN 被当成积分出款。 -func ValidPointWithdrawalAssetType(assetType string) bool { +// UsesHostAgencySalaryPolicy identifies the two balances produced by Host policy settlement. +// Withdrawal rules follow the asset's business source instead of branching on an application name. +func UsesHostAgencySalaryPolicy(assetType string) bool { switch strings.ToUpper(strings.TrimSpace(assetType)) { - case AssetPoint, AssetCoinSellerPoint: + case AssetHostSalaryUSD, AssetAgencySalaryUSD: + return true + default: + return false + } +} + +// ValidPointWithdrawalAssetType 只允许历史政策积分进入积分提现链路,避免普通 COIN 被当成积分出款。 +func ValidPointWithdrawalAssetType(assetType string) bool { + switch strings.ToUpper(strings.TrimSpace(assetType)) { + case AssetPoint, AssetPointDiamond, AssetCoinSellerPoint: return true default: return false diff --git a/services/wallet-service/internal/service/wallet/agency_host_gift_stats.go b/services/wallet-service/internal/service/wallet/agency_host_gift_stats.go index 651afedd..b0a9fb96 100644 --- a/services/wallet-service/internal/service/wallet/agency_host_gift_stats.go +++ b/services/wallet-service/internal/service/wallet/agency_host_gift_stats.go @@ -15,7 +15,7 @@ func (s *Service) GetAgencyHostGiftStats(ctx context.Context, appCode string, qu if s.repository == nil { return ledger.AgencyHostGiftStats{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } - if len(query.HostUserIDs) > 1000 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS { + if len(query.HostUserIDs) > 1000 || query.AgencyUserID < 0 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS { return ledger.AgencyHostGiftStats{}, xerr.New(xerr.InvalidArgument, "agency host gift stats range is invalid") } for _, userID := range query.HostUserIDs { diff --git a/services/wallet-service/internal/service/wallet/agency_point_share_stats.go b/services/wallet-service/internal/service/wallet/agency_point_share_stats.go deleted file mode 100644 index c1c673a9..00000000 --- a/services/wallet-service/internal/service/wallet/agency_point_share_stats.go +++ /dev/null @@ -1,26 +0,0 @@ -package wallet - -import ( - "context" - "time" - - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" -) - -const maxAgencyPointShareStatsRange = 366 * 24 * time.Hour - -func (s *Service) GetAgencyPointShareStats(ctx context.Context, appCode string, query ledger.AgencyPointShareStatsQuery) (ledger.AgencyPointShareStats, error) { - if s.repository == nil { - return ledger.AgencyPointShareStats{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") - } - if query.AgencyOwnerUserID <= 0 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS { - return ledger.AgencyPointShareStats{}, xerr.New(xerr.InvalidArgument, "agency point share stats range is invalid") - } - if query.EndAtMS-query.StartAtMS > int64(maxAgencyPointShareStatsRange/time.Millisecond) { - return ledger.AgencyPointShareStats{}, xerr.New(xerr.InvalidArgument, "agency point share stats range is too large") - } - ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) - return s.repository.GetAgencyPointShareStats(ctx, query) -} diff --git a/services/wallet-service/internal/service/wallet/host_revenue_stats.go b/services/wallet-service/internal/service/wallet/host_revenue_stats.go index d29a647a..e401dd55 100644 --- a/services/wallet-service/internal/service/wallet/host_revenue_stats.go +++ b/services/wallet-service/internal/service/wallet/host_revenue_stats.go @@ -16,7 +16,7 @@ func (s *Service) GetHostRevenueStats(ctx context.Context, appCode string, query if s.repository == nil { return ledger.HostRevenueStats{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } - if query.HostUserID <= 0 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS { + if query.HostUserID <= 0 || query.RegionID < 0 || query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS { return ledger.HostRevenueStats{}, xerr.New(xerr.InvalidArgument, "host revenue stats range is invalid") } if query.EndAtMS-query.StartAtMS > int64(maxHostRevenueStatsRange/time.Millisecond) { diff --git a/services/wallet-service/internal/service/wallet/point_coin_seller.go b/services/wallet-service/internal/service/wallet/point_coin_seller.go index ea3188d8..cc4badfa 100644 --- a/services/wallet-service/internal/service/wallet/point_coin_seller.go +++ b/services/wallet-service/internal/service/wallet/point_coin_seller.go @@ -24,7 +24,16 @@ func (s *Service) ListPointWithdrawalCoinSellers(ctx context.Context, appCode st // TransferPointToCoinSeller 验证客户端允许提交的最小字段;兑换比例由 repository 事务内锁定配置解析。 func (s *Service) TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error) { - if strings.TrimSpace(command.AppCode) == "" || strings.TrimSpace(command.CommandID) == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.PointAmount <= 0 || command.RegionID <= 0 { + command.SourceAssetType = strings.ToUpper(strings.TrimSpace(command.SourceAssetType)) + if command.SourceAssetType == "" { + command.SourceAssetType = ledger.AssetPoint + } + validAmount := command.PointAmount > 0 + if command.SourceAssetType == ledger.AssetPointDiamond { + validAmount = command.GrossUSDMinor > 0 + } + if strings.TrimSpace(command.AppCode) == "" || strings.TrimSpace(command.CommandID) == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || !validAmount || command.RegionID <= 0 || + (command.SourceAssetType != ledger.AssetPoint && command.SourceAssetType != ledger.AssetPointDiamond) { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point coin seller transfer command is incomplete") } command.AppCode = appcode.Normalize(command.AppCode) @@ -40,9 +49,14 @@ func (s *Service) TransferPointToCoinSeller(ctx context.Context, command ledger. return s.repository.TransferPointToCoinSeller(appcode.WithContext(ctx, command.AppCode), command) } -// ExchangePointToCoin 校验入参;目标 COIN 数量由 repository 事务内的已发布政策与统一工资兑币比例决定。 +// ExchangePointToCoin 校验入参;目标 COIN 数量由 repository 事务内的 wallet 政策与统一工资兑币比例决定。 func (s *Service) ExchangePointToCoin(ctx context.Context, command ledger.PointToCoinCommand) (ledger.PointToCoinReceipt, error) { - if strings.TrimSpace(command.AppCode) == "" || strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 || command.PointAmount <= 0 || command.RegionID < 0 { + command.SourceAssetType = strings.ToUpper(strings.TrimSpace(command.SourceAssetType)) + if command.SourceAssetType == "" { + command.SourceAssetType = ledger.AssetPoint + } + if strings.TrimSpace(command.AppCode) == "" || strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 || command.PointAmount <= 0 || command.RegionID < 0 || + (command.SourceAssetType != ledger.AssetPoint && command.SourceAssetType != ledger.AssetPointDiamond) { return ledger.PointToCoinReceipt{}, xerr.New(xerr.InvalidArgument, "point to coin exchange command is incomplete") } command.AppCode = appcode.Normalize(command.AppCode) @@ -59,8 +73,24 @@ func (s *Service) ExchangePointToCoin(ctx context.Context, command ledger.PointT return s.repository.ExchangePointToCoin(appcode.WithContext(ctx, command.AppCode), command) } -// GetPointWithdrawalConfig 读取已发布政策快照;nowMS 由服务端补齐,避免客户端选择历史政策。 +// GetPointWithdrawalConfig 读取已发布 wallet 政策快照;nowMS 由服务端补齐,避免客户端选择过期配置。 func (s *Service) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error) { + return s.GetPointWithdrawalConfigForAsset(ctx, appCode, regionID, nowMS, ledger.AssetPoint) +} + +func (s *Service) GetPointWithdrawalConfigForAsset(ctx context.Context, appCode string, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { + return s.getPointWithdrawalConfig(ctx, appCode, 0, regionID, nowMS, assetType) +} + +// GetPointWithdrawalConfigForUser 为钱包 overview 追加实时日期/次数能力;动作执行仍在账变事务内二次校验。 +func (s *Service) GetPointWithdrawalConfigForUser(ctx context.Context, appCode string, userID int64, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { + if userID <= 0 { + return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.InvalidArgument, "point withdrawal config user is invalid") + } + return s.getPointWithdrawalConfig(ctx, appCode, userID, regionID, nowMS, assetType) +} + +func (s *Service) getPointWithdrawalConfig(ctx context.Context, appCode string, userID int64, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { if strings.TrimSpace(appCode) == "" || regionID < 0 { return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.InvalidArgument, "point withdrawal config query is invalid") } @@ -71,5 +101,15 @@ func (s *Service) GetPointWithdrawalConfig(ctx context.Context, appCode string, nowMS = time.Now().UnixMilli() } appCode = appcode.Normalize(appCode) - return s.repository.GetPointWithdrawalConfig(appcode.WithContext(ctx, appCode), appCode, regionID, nowMS) + assetType = strings.ToUpper(strings.TrimSpace(assetType)) + if assetType == "" { + assetType = ledger.AssetPoint + } + if assetType != ledger.AssetPoint && assetType != ledger.AssetPointDiamond { + return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.InvalidArgument, "point asset_type is invalid") + } + if userID > 0 { + return s.repository.GetPointWithdrawalConfigForUser(appcode.WithContext(ctx, appCode), appCode, userID, regionID, nowMS, assetType) + } + return s.repository.GetPointWithdrawalConfigForAsset(appcode.WithContext(ctx, appCode), appCode, regionID, nowMS, assetType) } diff --git a/services/wallet-service/internal/service/wallet/point_withdrawal.go b/services/wallet-service/internal/service/wallet/point_withdrawal.go index c25a2c94..6264f4d2 100644 --- a/services/wallet-service/internal/service/wallet/point_withdrawal.go +++ b/services/wallet-service/internal/service/wallet/point_withdrawal.go @@ -9,14 +9,10 @@ import ( "hyapp/services/wallet-service/internal/domain/ledger" ) -const ( - defaultPointWithdrawalPointsPerUSD int64 = 100000 - minPointWithdrawalGrossPoints int64 = 1000000 -) - -// FreezePointWithdrawal 把已启用 App 的收益积分从 available 冻结到 frozen;gateway 必须先读取已发布政策快照。 +// FreezePointWithdrawal 把 POINT 从 available 冻结到 frozen;repository 会在账变事务内 +// 重新解析已发布 wallet 政策,不能信任 gateway 预览时携带的比例与手续费。 func (s *Service) FreezePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { - if err := normalizePointWithdrawalCommand(&command, false, true); err != nil { + if err := normalizePointWithdrawalCommand(&command, false, true, false); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { @@ -36,7 +32,7 @@ func (s *Service) FreezePointWithdrawal(ctx context.Context, command ledger.Poin // SettlePointWithdrawal 在财务审核通过时扣掉 frozen 积分;冻结时已扣 available,因此这里不能再次扣 available。 func (s *Service) SettlePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { - if err := normalizePointWithdrawalCommand(&command, true, false); err != nil { + if err := normalizePointWithdrawalCommand(&command, true, false, true); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { @@ -56,7 +52,7 @@ func (s *Service) SettlePointWithdrawal(ctx context.Context, command ledger.Poin // ReleasePointWithdrawal 在审核拒绝或申请落库失败时把 frozen 积分返还 available,保证用户不会被永久冻结。 func (s *Service) ReleasePointWithdrawal(ctx context.Context, command ledger.PointWithdrawalCommand) (ledger.PointWithdrawalReceipt, error) { - if err := normalizePointWithdrawalCommand(&command, false, false); err != nil { + if err := normalizePointWithdrawalCommand(&command, false, false, true); err != nil { return ledger.PointWithdrawalReceipt{}, err } if command.Reason == "" { @@ -74,8 +70,8 @@ func (s *Service) ReleasePointWithdrawal(ctx context.Context, command ledger.Poi return pointWithdrawalReceiptFromSalary(receipt), nil } -func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, requireOperator bool, requireRegion bool) error { - if command == nil || strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 || command.GrossPointAmount <= 0 { +func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, requireOperator bool, requireRegion bool, validateFrozenSnapshot bool) error { + if command == nil || strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 { return xerr.New(xerr.InvalidArgument, "point withdrawal command is incomplete") } if requireOperator && command.OperatorUserID <= 0 { @@ -89,24 +85,36 @@ func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, req return xerr.New(xerr.InvalidArgument, "app_code is required") } command.AppCode = appcode.Normalize(command.AppCode) - if command.AppCode != "huwaa" && command.AppCode != "fami" { - // Lalu 继续使用旧公会工资提现链路;只有显式迁移到 POINT 钱包的 App 才能调用本接口。 - return xerr.New(xerr.InvalidArgument, "point withdrawal is not enabled for app") - } command.AssetType = strings.ToUpper(strings.TrimSpace(command.AssetType)) if !ledger.ValidPointWithdrawalAssetType(command.AssetType) { return xerr.New(xerr.InvalidArgument, "point asset_type is invalid") } - if command.GrossPointAmount < minPointWithdrawalGrossPoints { - return xerr.New(xerr.InvalidArgument, "point withdrawal amount is below minimum") + if command.GrossPointAmount <= 0 && !(command.AssetType == ledger.AssetPointDiamond && !validateFrozenSnapshot && command.GrossUSDMinor > 0) { + return xerr.New(xerr.InvalidArgument, "point withdrawal amount is invalid") + } + // 首次冻结的比例、门槛、手续费和净额由 repository 在同一数据库事务内覆盖; + // 审核与回滚则必须严格复用申请落库时的首次冻结快照。 + if !validateFrozenSnapshot { + command.Reason = strings.TrimSpace(command.Reason) + command.WithdrawalRef = strings.TrimSpace(command.WithdrawalRef) + command.WithdrawalApplicationID = strings.TrimSpace(command.WithdrawalApplicationID) + command.FreezeTransactionID = strings.TrimSpace(command.FreezeTransactionID) + if len(command.CommandID) > 128 || len(command.Reason) > 512 || len(command.WithdrawalRef) > 128 || len(command.WithdrawalApplicationID) > 64 { + return xerr.New(xerr.InvalidArgument, "point withdrawal text fields are too long") + } + return nil } if command.PointsPerUSD <= 0 { - command.PointsPerUSD = defaultPointWithdrawalPointsPerUSD + return xerr.New(xerr.InvalidArgument, "point withdrawal points_per_usd is invalid") } if command.FeeBPS < 0 || command.FeeBPS > 10000 { return xerr.New(xerr.InvalidArgument, "point withdrawal fee_bps is invalid") } expectedFee := command.GrossPointAmount * command.FeeBPS / 10000 + if command.AssetType == ledger.AssetPointDiamond { + // 永久积分冻结按美元分先 floor 手续费;审核/回滚只允许复用首次冻结保存的 fee/net 快照。 + expectedFee = command.FeePointAmount + } if command.FeePointAmount == 0 { command.FeePointAmount = expectedFee } @@ -123,7 +131,11 @@ func normalizePointWithdrawalCommand(command *ledger.PointWithdrawalCommand, req command.Reason = strings.TrimSpace(command.Reason) command.WithdrawalRef = strings.TrimSpace(command.WithdrawalRef) command.WithdrawalApplicationID = strings.TrimSpace(command.WithdrawalApplicationID) - if len(command.CommandID) > 128 || len(command.Reason) > 512 || len(command.WithdrawalRef) > 128 || len(command.WithdrawalApplicationID) > 64 { + command.FreezeTransactionID = strings.TrimSpace(command.FreezeTransactionID) + if command.AssetType == ledger.AssetPointDiamond && command.FreezeTransactionID == "" { + return xerr.New(xerr.InvalidArgument, "freeze_transaction_id is required for point diamond terminal mutation") + } + if len(command.CommandID) > 128 || len(command.Reason) > 512 || len(command.WithdrawalRef) > 128 || len(command.WithdrawalApplicationID) > 64 || len(command.FreezeTransactionID) > 128 { return xerr.New(xerr.InvalidArgument, "point withdrawal text fields are too long") } return nil @@ -136,6 +148,7 @@ func pointWithdrawalAsSalaryCommand(command ledger.PointWithdrawalCommand) ledge UserID: command.UserID, SalaryAssetType: command.AssetType, SalaryUSDMinor: command.GrossPointAmount, + GrossUSDMinor: command.GrossUSDMinor, PointFeeAmount: command.FeePointAmount, PointNetAmount: command.NetPointAmount, PointsPerUSD: command.PointsPerUSD, @@ -144,23 +157,31 @@ func pointWithdrawalAsSalaryCommand(command ledger.PointWithdrawalCommand) ledge Reason: command.Reason, WithdrawalRef: command.WithdrawalRef, WithdrawalApplicationID: command.WithdrawalApplicationID, + FreezeTransactionID: command.FreezeTransactionID, RegionID: command.RegionID, } } func pointWithdrawalReceiptFromSalary(receipt ledger.SalaryWithdrawalReceipt) ledger.PointWithdrawalReceipt { return ledger.PointWithdrawalReceipt{ - TransactionID: receipt.TransactionID, - UserID: receipt.UserID, - AssetType: receipt.SalaryAssetType, - GrossPointAmount: receipt.SalaryUSDMinor, - FeePointAmount: receipt.PointFeeAmount, - NetPointAmount: receipt.PointNetAmount, - PointsPerUSD: receipt.PointsPerUSD, - FeeBPS: receipt.PointWithdrawFeeBPS, - AvailableAfter: receipt.AvailableAfter, - FrozenAfter: receipt.FrozenAfter, - Version: receipt.Version, - CreatedAtMS: receipt.CreatedAtMS, + TransactionID: receipt.TransactionID, + UserID: receipt.UserID, + AssetType: receipt.SalaryAssetType, + GrossPointAmount: receipt.SalaryUSDMinor, + FeePointAmount: receipt.PointFeeAmount, + NetPointAmount: receipt.PointNetAmount, + PointsPerUSD: receipt.PointsPerUSD, + FeeBPS: receipt.PointWithdrawFeeBPS, + PointPolicyInstanceCode: receipt.PointPolicyInstanceCode, + GrossUSDMinor: receipt.GrossUSDMinor, + FeeUSDMinor: receipt.FeeUSDMinor, + NetUSDMinor: receipt.NetUSDMinor, + PointPolicyID: receipt.PointPolicyID, + PointPolicyVersion: receipt.PointPolicyVersion, + CoinsPerUSD: receipt.CoinsPerUSD, + AvailableAfter: receipt.AvailableAfter, + FrozenAfter: receipt.FrozenAfter, + Version: receipt.Version, + CreatedAtMS: receipt.CreatedAtMS, } } diff --git a/services/wallet-service/internal/service/wallet/ports/repository.go b/services/wallet-service/internal/service/wallet/ports/repository.go index ce34a24b..f0fc1b1d 100644 --- a/services/wallet-service/internal/service/wallet/ports/repository.go +++ b/services/wallet-service/internal/service/wallet/ports/repository.go @@ -57,7 +57,6 @@ type HostSalaryStore interface { GetHostSalaryProgress(ctx context.Context, userID int64, cycleKey string) (ledger.HostSalaryProgress, error) GetTeamHostSalaryStats(ctx context.Context, query ledger.TeamHostSalaryStatsQuery) ([]ledger.TeamHostSalaryCycleStat, error) GetHostRevenueStats(ctx context.Context, query ledger.HostRevenueStatsQuery) (ledger.HostRevenueStats, error) - GetAgencyPointShareStats(ctx context.Context, query ledger.AgencyPointShareStatsQuery) (ledger.AgencyPointShareStats, error) GetAgencyHostGiftStats(ctx context.Context, query ledger.AgencyHostGiftStatsQuery) (ledger.AgencyHostGiftStats, error) ProcessHostSalarySettlementBatch(ctx context.Context, command ledger.HostSalarySettlementBatchCommand) (ledger.HostSalarySettlementBatchResult, error) } @@ -74,6 +73,8 @@ type CoinSellerStore interface { TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error) ExchangePointToCoin(ctx context.Context, command ledger.PointToCoinCommand) (ledger.PointToCoinReceipt, error) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error) + GetPointWithdrawalConfigForAsset(ctx context.Context, appCode string, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) + GetPointWithdrawalConfigForUser(ctx context.Context, appCode string, userID int64, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) FreezeSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) SettleSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) ReleaseSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) diff --git a/services/wallet-service/internal/service/wallet/reward.go b/services/wallet-service/internal/service/wallet/reward.go index b773850c..060db259 100644 --- a/services/wallet-service/internal/service/wallet/reward.go +++ b/services/wallet-service/internal/service/wallet/reward.go @@ -32,10 +32,6 @@ func (s *Service) CreditTaskReward(ctx context.Context, command ledger.TaskRewar return ledger.TaskRewardReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") } command.AppCode = appcode.Normalize(command.AppCode) - if command.AssetType == ledger.AssetPoint && command.AppCode != "huwaa" { - // POINT 是 Huwaa 策略资产;钱包服务自身也做 App 隔离,避免后台或 activity 配错后污染其它 App 账本。 - return ledger.TaskRewardReceipt{}, xerr.New(xerr.InvalidArgument, "POINT task reward is only enabled for huwaa") - } ctx = appcode.WithContext(ctx, command.AppCode) return s.repository.CreditTaskReward(ctx, command) diff --git a/services/wallet-service/internal/service/wallet/salary_withdrawal.go b/services/wallet-service/internal/service/wallet/salary_withdrawal.go index a61f5684..526ce79f 100644 --- a/services/wallet-service/internal/service/wallet/salary_withdrawal.go +++ b/services/wallet-service/internal/service/wallet/salary_withdrawal.go @@ -14,6 +14,10 @@ func (s *Service) FreezeSalaryWithdrawal(ctx context.Context, command ledger.Sal if err := normalizeSalaryWithdrawalCommand(&command, false); err != nil { return ledger.SalaryWithdrawalReceipt{}, err } + if ledger.UsesHostAgencySalaryPolicy(command.SalaryAssetType) && command.RegionID <= 0 { + // Host/Agency 工资由区域月度政策产生,提现也必须使用同一身份区域解析周期绑定政策。 + return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is required") + } if command.Reason == "" { command.Reason = "salary withdrawal freeze" } diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 8854630f..cb1066a9 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -3,6 +3,8 @@ package wallet_test import ( "context" "encoding/json" + "fmt" + "os" "strings" "sync" "testing" @@ -119,454 +121,60 @@ func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(t *testing.T) { } } -func TestHuwaaHostGiftPolicyCreditsPointWithoutCoinIncomeAndAllowsSelfGift(t *testing.T) { - repository := mysqltest.NewRepository(t) - repository.SetBalanceForApp("huwaa", 31001, 1000) - repository.SetGiftPriceForApp("huwaa", "huwaa-rose", "v1", 100, 0, 100) - repository.SeedWalletPolicyInstance("huwaa", "huwaa-point-live", 0, "active", 700000) - svc := walletservice.New(repository) +func TestHostGiftWithoutPublishedPolicyFailsBeforeAnyMutation(t *testing.T) { + for index, app := range []string{"lalu", "fami", "huwaa"} { + t.Run(app, func(t *testing.T) { + repository := mysqltest.NewRepository(t) + senderID := int64(31011 + index*10) + targetID := senderID + 1 + giftID := app + "-policyless-rose" + commandID := "cmd-" + app + "-policyless-host-gift" + repository.SetBalanceForApp(app, senderID, 1000) + repository.SetGiftPriceForApp(app, giftID, "v1", 100, 0, 100) + repository.SetGiftDiamondRatioForApp(app, 20, resourcedomain.GiftTypeNormal, "100.00") + svc := walletservice.New(repository) - receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ - AppCode: "huwaa", - CommandID: "cmd-huwaa-self-host-point", - RoomID: "room-huwaa", - SenderUserID: 31001, - TargetUserID: 31001, - TargetIsHost: true, - TargetHostRegionID: 12, - GiftID: "huwaa-rose", - GiftCount: 1, - PriceVersion: "v1", - RegionID: 12, - SenderRegionID: 12, - }) - if err != nil { - t.Fatalf("DebitGift with Huwaa point policy failed: %v", err) - } - - if receipt.CoinSpent != 100 || - receipt.BalanceAfter != 900 || - receipt.GiftIncomeCoinAmount != 0 || - receipt.GiftIncomeBalanceAfter != 0 || - receipt.HostPeriodDiamondAdded != 0 || - receipt.HostPointAdded != 70 || - receipt.HostPointBalanceAfter != 70 || - receipt.HostPointAssetType != ledger.AssetPoint || - receipt.HostPointPolicyInstanceCode != "huwaa-point-live" || - receipt.HostPointTemplateVersion != "v1" { - t.Fatalf("Huwaa point gift receipt mismatch: %+v", receipt) - } - assertBalanceForApp(t, svc, "huwaa", 31001, ledger.AssetCoin, 900) - assertBalanceForApp(t, svc, "huwaa", 31001, ledger.AssetPoint, 70) - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPointCredited"); got != 1 { - t.Fatalf("Huwaa point gift should publish one HostPointCredited event, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 0 { - t.Fatalf("Huwaa point gift must not also publish legacy COIN income, got %d", got) - } - if got := repository.CountRows("wallet_entries", "transaction_id = ? AND asset_type = ?", receipt.TransactionID, ledger.AssetPoint); got != 1 { - t.Fatalf("Huwaa point gift should write one POINT entry, got %d", got) - } -} - -func TestFamiHostGiftPolicyCreditsAgencyShareWithHistoricalOwnershipSnapshot(t *testing.T) { - repository := mysqltest.NewRepository(t) - // Fami 公会收益先按礼物类型计算主播实收,再额外给 Agency owner 主播实收的 20%; - // 该公式允许一次礼物产生 100% 主播收益和额外 20% 公会收益,不能套用旧的总资金池切分语义。 - repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income") - svc := walletservice.New(repository) - - tests := []struct { - name string - giftType string - giftID string - commandID string - senderID int64 - hostID int64 - agencyID int64 - wantHost int64 - wantAgency int64 - wantRatio string - wantPPM int64 - }{ - {name: "normal", giftType: resourcedomain.GiftTypeNormal, giftID: "fami-normal-rose", commandID: "cmd-fami-agency-normal", senderID: 32001, hostID: 32002, agencyID: 32003, wantHost: 10_000, wantAgency: 2_000, wantRatio: "100.00", wantPPM: 1_000_000}, - {name: "lucky", giftType: resourcedomain.GiftTypeLucky, giftID: "fami-lucky-rose", commandID: "cmd-fami-agency-lucky", senderID: 32011, hostID: 32012, agencyID: 32013, wantHost: 1_000, wantAgency: 200, wantRatio: "10.00", wantPPM: 100_000}, - {name: "super_lucky", giftType: resourcedomain.GiftTypeSuperLucky, giftID: "fami-super-lucky-rose", commandID: "cmd-fami-agency-super-lucky", senderID: 32021, hostID: 32022, agencyID: 32023, wantHost: 100, wantAgency: 20, wantRatio: "1.00", wantPPM: 10_000}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - repository.SetBalanceForApp("fami", test.senderID, 10_000) - repository.SetGiftPriceForApp("fami", test.giftID, "v1", 10_000, 0, 10_000) - repository.SetGiftType(test.giftID, test.giftType) - // 区域值刻意与全局 fallback 区分,验证 POINT 政策把真正命中的第一阶段倍率来源一并冻结。 - repository.SetGiftDiamondRatioForApp("fami", 12, test.giftType, test.wantRatio) - command := ledger.DebitGiftCommand{ - AppCode: "fami", CommandID: test.commandID, RoomID: "room-fami", - SenderUserID: test.senderID, TargetUserID: test.hostID, TargetIsHost: true, TargetHostRegionID: 12, - TargetAgencyOwnerUserID: test.agencyID, GiftID: test.giftID, GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, + _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + AppCode: app, + CommandID: commandID, + RoomID: "room-" + app, + SenderUserID: senderID, + TargetUserID: targetID, + TargetIsHost: true, + TargetHostRegionID: 20, + GiftID: giftID, + GiftCount: 1, + PriceVersion: "v1", + RegionID: 20, + SenderRegionID: 20, + }) + if !xerr.IsCode(err, xerr.PermissionDenied) { + t.Fatalf("host gift without a published policy must fail closed, got %v", err) } - - receipt, err := svc.DebitGift(context.Background(), command) - if err != nil { - t.Fatalf("DebitGift with Fami %s agency policy failed: %v", test.giftType, err) + // 政策解析发生在账户行锁、金币扣减和收礼返币之前;任何 App 都不能用隐式品牌兜底继续结算。 + assertBalanceForApp(t, svc, app, senderID, ledger.AssetCoin, 1000) + assertBalanceForApp(t, svc, app, targetID, ledger.AssetCoin, 0) + assertBalanceForApp(t, svc, app, targetID, ledger.AssetPointDiamond, 0) + if got := repository.CountRows("wallet_transactions", "app_code = ? AND command_id = ?", app, commandID); got != 0 { + t.Fatalf("policyless host gift must not write a transaction, got %d", got) } - if receipt.CoinSpent != 10_000 || receipt.HostPointAdded != test.wantHost || receipt.HostPointBalanceAfter != test.wantHost || - receipt.HostPointAssetType != ledger.AssetPoint || receipt.HostPointPolicyInstanceCode != "fami-guild-live" { - t.Fatalf("Fami %s host point receipt mismatch: %+v", test.giftType, receipt) - } - assertBalanceForApp(t, svc, "fami", test.hostID, ledger.AssetPoint, test.wantHost) - assertBalanceForApp(t, svc, "fami", test.agencyID, ledger.AssetPoint, test.wantAgency) - - // 流水必须冻结送礼时的主播与 owner 归属、公式名称和实际主播收入;以后成员转会不能反推改写历史收益。 - shareWhere := "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ? AND share_base_type = ? AND share_base_amount = ?" - shareArgs := []any{receipt.TransactionID, test.hostID, test.agencyID, test.wantAgency, int64(200_000), "host_income", test.wantHost} - if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 { - t.Fatalf("Fami %s agency ownership/formula snapshot mismatch, got %d", test.giftType, got) - } - // POINT 政策也必须冻结第一阶段礼物倍率;只记录最终 host_income 无法解释未来配置变更前的计算过程。 - if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_percent')) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_ppm')) AS SIGNED) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_region_id')) AS SIGNED) = 12", receipt.TransactionID, test.wantRatio, test.wantPPM); got != 1 { - t.Fatalf("Fami %s gift-type ratio transaction snapshot mismatch, got %d", test.giftType, got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPointCredited"); got != 1 { - t.Fatalf("Fami %s host credit must publish one role fact, got %d", test.giftType, got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_diamond_ratio_percent')) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_diamond_ratio_ppm')) AS SIGNED) = ?", receipt.TransactionID, "HostPointCredited", test.wantRatio, test.wantPPM); got != 1 { - t.Fatalf("Fami %s gift-type ratio role fact mismatch, got %d", test.giftType, got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 { - t.Fatalf("Fami %s agency share must publish one role fact, got %d", test.giftType, got) - } - - // 同一命令重放只读首次交易快照,不重新计算礼物类型比例,也不重复写 owner 收益与角色事实。 - replayed, err := svc.DebitGift(context.Background(), command) - if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.HostPointAdded != receipt.HostPointAdded { - t.Fatalf("Fami %s agency share replay mismatch: receipt=%+v err=%v", test.giftType, replayed, err) - } - assertBalanceForApp(t, svc, "fami", test.hostID, ledger.AssetPoint, test.wantHost) - assertBalanceForApp(t, svc, "fami", test.agencyID, ledger.AssetPoint, test.wantAgency) - if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 { - t.Fatalf("Fami %s replay duplicated or changed owner snapshot, got %d", test.giftType, got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type IN (?, ?)", receipt.TransactionID, "HostPointCredited", "AgencyPointCredited"); got != 2 { - t.Fatalf("Fami %s replay duplicated role facts, got %d", test.giftType, got) + if got := repository.CountRows("host_period_diamond_accounts", "app_code = ? AND user_id = ?", app, targetID); got != 0 { + t.Fatalf("policyless host gift must not create a cycle account, got %d", got) } }) } } -// TestFamiAgencyOwnerSelfGiftMergesPointBalanceFact 验证公会长本人收礼时仍保留主播和 Agency 两套账务角色, -// 但同一 POINT 账户只能发布一条合并后的余额事实,避免下游按两条 balance event 覆盖最终余额。 -func TestFamiAgencyOwnerSelfGiftMergesPointBalanceFact(t *testing.T) { - repository := mysqltest.NewRepository(t) - repository.SetBalanceForApp("fami", 33001, 10_000) - repository.SetGiftPriceForApp("fami", "fami-owner-self-normal", "v1", 10_000, 0, 10_000) - repository.SetGiftType("fami-owner-self-normal", resourcedomain.GiftTypeNormal) - repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income") - svc := walletservice.New(repository) - command := ledger.DebitGiftCommand{ - AppCode: "fami", CommandID: "cmd-fami-owner-self-direct", DirectGift: true, - SenderUserID: 33001, TargetUserID: 33002, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 33002, - GiftID: "fami-owner-self-normal", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, - } - - receipt, err := svc.DebitGift(context.Background(), command) - if err != nil { - t.Fatalf("DebitGift with self-owned Fami agency failed: %v", err) - } - if receipt.CoinSpent != 10_000 || receipt.HostPointAdded != 10_000 || receipt.HostPointBalanceAfter != 12_000 { - t.Fatalf("self-owned Fami receipt mismatch: %+v", receipt) - } - assertBalanceForApp(t, svc, "fami", 33002, ledger.AssetPoint, 12_000) - if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ? AND status = ?", receipt.TransactionID, "direct_gift_debit", "succeeded"); got != 1 { - t.Fatalf("self-owned direct gift transaction must succeed exactly once, got %d", got) - } - - // 主播 10000 与 Agency 2000 是两条可审计分录;账户级余额事实则聚合成 12000,只允许一条。 - if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(33002), ledger.AssetPoint); got != 2 { - t.Fatalf("self-owned gift must preserve two role ledger entries, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint); got != 1 { - t.Fatalf("self-owned POINT account must publish one merged balance fact, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint, int64(12_000)); got != 1 { - t.Fatalf("self-owned POINT balance fact must carry merged +12000 delta, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPointCredited"); got != 1 { - t.Fatalf("self-owned gift must retain one HostPointCredited role fact, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 { - t.Fatalf("self-owned gift must retain one AgencyPointCredited role fact, got %d", got) - } - shareWhere := "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ? AND share_base_type = ? AND share_base_amount = ?" - shareArgs := []any{receipt.TransactionID, int64(33002), int64(33002), int64(2_000), int64(200_000), "host_income", int64(10_000)} - if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 { - t.Fatalf("self-owned agency share snapshot mismatch, got %d", got) - } - - replayed, err := svc.DebitGift(context.Background(), command) - if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.HostPointBalanceAfter != 12_000 { - t.Fatalf("self-owned Fami gift replay mismatch: receipt=%+v err=%v", replayed, err) - } - assertBalanceForApp(t, svc, "fami", 33002, ledger.AssetPoint, 12_000) - if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(33002), ledger.AssetPoint); got != 2 { - t.Fatalf("self-owned replay duplicated role entries, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint); got != 1 { - t.Fatalf("self-owned replay duplicated merged balance fact, got %d", got) - } - if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 { - t.Fatalf("self-owned replay duplicated share row, got %d", got) - } -} - -// TestFamiBatchGiftKeepsIndependentAgencyRoleFacts 验证批量事务按 target 独立固化主播/Agency 角色: -// 一个 target 是 owner 本人,另一个是旗下主播,两者共享 owner 账户也不能互相覆盖或重复投递。 -func TestFamiBatchGiftKeepsIndependentAgencyRoleFacts(t *testing.T) { - repository := mysqltest.NewRepository(t) - repository.SetBalanceForApp("fami", 34001, 20_000) - repository.SetGiftPriceForApp("fami", "fami-batch-normal", "v1", 10_000, 0, 10_000) - repository.SetGiftType("fami-batch-normal", resourcedomain.GiftTypeNormal) - repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income") - svc := walletservice.New(repository) - command := ledger.BatchDebitGiftCommand{ - AppCode: "fami", CommandID: "cmd-fami-agency-batch", RoomID: "room-fami-batch", - SenderUserID: 34001, GiftID: "fami-batch-normal", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, - Targets: []ledger.DebitGiftTargetCommand{ - {CommandID: "cmd-fami-agency-batch:target:34002", TargetUserID: 34002, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 34002}, - {CommandID: "cmd-fami-agency-batch:target:34003", TargetUserID: 34003, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 34002}, - }, - } - - receipt, err := svc.BatchDebitGift(context.Background(), command) - if err != nil { - t.Fatalf("BatchDebitGift with mixed Fami agency targets failed: %v", err) - } - if len(receipt.Targets) != 2 || receipt.Aggregate.CoinSpent != 20_000 || receipt.Aggregate.HostPointAdded != 20_000 { - t.Fatalf("mixed Fami agency batch aggregate mismatch: %+v", receipt) - } - assertBalanceForApp(t, svc, "fami", 34001, ledger.AssetCoin, 0) - assertBalanceForApp(t, svc, "fami", 34002, ledger.AssetPoint, 14_000) - assertBalanceForApp(t, svc, "fami", 34003, ledger.AssetPoint, 10_000) - - receiptsByTarget := make(map[int64]ledger.Receipt, len(receipt.Targets)) - for _, target := range receipt.Targets { - receiptsByTarget[target.TargetUserID] = target.Receipt - } - selfReceipt, selfOK := receiptsByTarget[34002] - memberReceipt, memberOK := receiptsByTarget[34003] - if !selfOK || !memberOK || selfReceipt.TransactionID == "" || memberReceipt.TransactionID == "" { - t.Fatalf("batch must return one stable receipt per target: %+v", receipt.Targets) - } - if selfReceipt.HostPointBalanceAfter != 12_000 { - t.Fatalf("batch self-owner receipt must expose final shared POINT balance: %+v", selfReceipt) - } - for _, targetReceipt := range []struct { - name string - hostID int64 - receipt ledger.Receipt - }{ - {name: "self_owner", hostID: 34002, receipt: selfReceipt}, - {name: "member", hostID: 34003, receipt: memberReceipt}, - } { - if targetReceipt.receipt.HostPointAdded != 10_000 { - t.Fatalf("%s target host credit mismatch: %+v", targetReceipt.name, targetReceipt.receipt) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", targetReceipt.receipt.TransactionID, "HostPointCredited"); got != 1 { - t.Fatalf("%s target must publish one host role fact, got %d", targetReceipt.name, got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", targetReceipt.receipt.TransactionID, "AgencyPointCredited"); got != 1 { - t.Fatalf("%s target must publish one agency role fact, got %d", targetReceipt.name, got) - } - if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND share_base_type = ? AND share_base_amount = ?", targetReceipt.receipt.TransactionID, targetReceipt.hostID, int64(34002), int64(2_000), "host_income", int64(10_000)); got != 1 { - t.Fatalf("%s target agency snapshot mismatch, got %d", targetReceipt.name, got) - } - } - - // Self target 的两种角色落在同一账户,只能产生 +12000 一条余额事实;成员 target 则分别通知成员与 owner。 - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND asset_type = ?", selfReceipt.TransactionID, "WalletBalanceChanged", ledger.AssetPoint); got != 1 { - t.Fatalf("batch self target must publish one merged POINT balance fact, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", selfReceipt.TransactionID, "WalletBalanceChanged", int64(34002), ledger.AssetPoint, int64(12_000)); got != 1 { - t.Fatalf("batch self target merged POINT delta mismatch, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND asset_type = ?", memberReceipt.TransactionID, "WalletBalanceChanged", ledger.AssetPoint); got != 2 { - t.Fatalf("batch member target must publish separate host/owner balance facts, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", memberReceipt.TransactionID, "WalletBalanceChanged", int64(34003), ledger.AssetPoint, int64(10_000)); got != 1 { - t.Fatalf("batch member host POINT balance fact mismatch, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", memberReceipt.TransactionID, "WalletBalanceChanged", int64(34002), ledger.AssetPoint, int64(2_000)); got != 1 { - t.Fatalf("batch member agency POINT balance fact mismatch, got %d", got) - } - - // 批量重放按两个 child command 的原交易返回,不重复扣 sender、不重复增加共享 owner,也不重发任一 target 的角色事实。 - replayed, err := svc.BatchDebitGift(context.Background(), command) - if err != nil { - t.Fatalf("mixed Fami agency batch replay failed: %v", err) - } - if len(replayed.Targets) != 2 || replayed.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || replayed.Targets[1].Receipt.TransactionID != receipt.Targets[1].Receipt.TransactionID || replayed.Targets[0].Receipt.HostPointBalanceAfter != 12_000 { - t.Fatalf("mixed Fami agency batch replay transaction mismatch: first=%+v replay=%+v", receipt.Targets, replayed.Targets) - } - assertBalanceForApp(t, svc, "fami", 34001, ledger.AssetCoin, 0) - assertBalanceForApp(t, svc, "fami", 34002, ledger.AssetPoint, 14_000) - assertBalanceForApp(t, svc, "fami", 34003, ledger.AssetPoint, 10_000) - if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", command.Targets[0].CommandID, command.Targets[1].CommandID); got != 2 { - t.Fatalf("mixed Fami agency batch replay duplicated child transactions, got %d", got) - } - if got := repository.CountRows("agency_point_share_entries", "transaction_id IN (?, ?)", selfReceipt.TransactionID, memberReceipt.TransactionID); got != 2 { - t.Fatalf("mixed Fami agency batch replay duplicated share rows, got %d", got) - } - for _, transactionID := range []string{selfReceipt.TransactionID, memberReceipt.TransactionID} { - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type IN (?, ?)", transactionID, "HostPointCredited", "AgencyPointCredited"); got != 2 { - t.Fatalf("mixed Fami agency batch replay duplicated role facts for transaction %s: got %d", transactionID, got) - } - } -} - -// TestBatchGiftCreditsAgencyWhenHostAmountRoundsToZero 固化旧 charge_amount 策略的极小额边界: -// host 与 Agency 各自向下取整,Agency 为正数时必须独立锁账户,不能依赖 host 也大于 0。 -func TestBatchGiftCreditsAgencyWhenHostAmountRoundsToZero(t *testing.T) { - repository := mysqltest.NewRepository(t) - repository.SetBalanceForApp("fami", 35001, 2) - repository.SetGiftPriceForApp("fami", "fami-batch-agency-rounding", "v1", 2, 0, 2) - repository.SeedWalletGiftRevenuePolicy("fami", "fami-legacy-rounding", 0, "active", 1, 999_999, false, "charge_amount") - svc := walletservice.New(repository) - command := ledger.BatchDebitGiftCommand{ - AppCode: "fami", CommandID: "cmd-fami-batch-agency-rounding", RoomID: "room-fami-rounding", - SenderUserID: 35001, GiftID: "fami-batch-agency-rounding", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, - Targets: []ledger.DebitGiftTargetCommand{{ - CommandID: "cmd-fami-batch-agency-rounding:target:35002", TargetUserID: 35002, - TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 35003, - }}, - } - - receipt, err := svc.BatchDebitGift(context.Background(), command) - if err != nil { - t.Fatalf("BatchDebitGift agency-only rounding failed: %v", err) - } - if len(receipt.Targets) != 1 || receipt.Targets[0].Receipt.HostPointAdded != 0 { - t.Fatalf("agency-only rounding receipt mismatch: %+v", receipt) - } - assertBalanceForApp(t, svc, "fami", 35002, ledger.AssetPoint, 0) - assertBalanceForApp(t, svc, "fami", 35003, ledger.AssetPoint, 1) - transactionID := receipt.Targets[0].Receipt.TransactionID - if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND point_delta = ? AND share_base_type = ? AND share_base_amount = ?", transactionID, int64(1), "charge_amount", int64(2)); got != 1 { - t.Fatalf("agency-only rounding audit snapshot mismatch, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND available_delta = ?", transactionID, "AgencyPointCredited", int64(35003), int64(1)); got != 1 { - t.Fatalf("agency-only rounding role fact mismatch, got %d", got) - } -} - -// TestGiftRevenuePolicyIsReusableAcrossApps 证明收益模块不依赖 Fami 分支: -// 任意 App 只要发布同一策略和区域礼物倍率,Direct/Batch 都应复用同一结算实现。 -func TestGiftRevenuePolicyIsReusableAcrossApps(t *testing.T) { - const appCode = "next-app" - repository := mysqltest.NewRepository(t) - repository.SetBalanceForApp(appCode, 36001, 10_000) - repository.SetGiftPriceForApp(appCode, "next-app-lucky", "v1", 10_000, 0, 10_000) - repository.SetGiftType("next-app-lucky", resourcedomain.GiftTypeLucky) - repository.SetGiftDiamondRatioForApp(appCode, 12, resourcedomain.GiftTypeLucky, "25.00") - repository.SeedWalletGiftRevenuePolicy(appCode, "shared-agency-host-income", 12, "active", 1_000_000, 200_000, true, "host_income") - svc := walletservice.New(repository) - - receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ - AppCode: appCode, CommandID: "cmd-next-app-agency-lucky", RoomID: "room-next-app", - SenderUserID: 36001, TargetUserID: 36002, TargetIsHost: true, - TargetHostRegionID: 12, TargetAgencyOwnerUserID: 36003, - GiftID: "next-app-lucky", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, - }) - if err != nil { - t.Fatalf("cross-app gift revenue policy failed: %v", err) - } - if receipt.HostPointAdded != 2_500 || receipt.HostPointBalanceAfter != 2_500 || receipt.HostPointPolicyInstanceCode != "shared-agency-host-income" { - t.Fatalf("cross-app host revenue mismatch: %+v", receipt) - } - assertBalanceForApp(t, svc, appCode, 36002, ledger.AssetPoint, 2_500) - assertBalanceForApp(t, svc, appCode, 36003, ledger.AssetPoint, 500) - if got := repository.CountRows("wallet_transactions", "app_code = ? AND transaction_id = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_region_id')) AS SIGNED) = 12 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_ppm')) AS SIGNED) = 250000", appCode, receipt.TransactionID); got != 1 { - t.Fatalf("cross-app effective-region ratio snapshot mismatch, got %d", got) - } - - repository.SetBalanceForApp(appCode, 36011, 10_000) - batch, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{ - AppCode: appCode, CommandID: "cmd-next-app-agency-lucky-batch", RoomID: "room-next-app", - SenderUserID: 36011, GiftID: "next-app-lucky", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, - Targets: []ledger.DebitGiftTargetCommand{{ - CommandID: "cmd-next-app-agency-lucky-batch:target:36012", TargetUserID: 36012, - TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 36013, - }}, - }) - if err != nil { - t.Fatalf("cross-app batch gift revenue policy failed: %v", err) - } - if len(batch.Targets) != 1 || batch.Targets[0].Receipt.HostPointAdded != 2_500 { - t.Fatalf("cross-app batch host revenue mismatch: %+v", batch) - } - assertBalanceForApp(t, svc, appCode, 36012, ledger.AssetPoint, 2_500) - assertBalanceForApp(t, svc, appCode, 36013, ledger.AssetPoint, 500) -} - -func TestHostGiftWithoutActiveHuwaaPolicyKeepsLegacyCoinIncome(t *testing.T) { - repository := mysqltest.NewRepository(t) - repository.SetBalance(31011, 1000) - repository.SetGiftPrice("lalu-rose", "v1", 100, 0, 100) - repository.SetBalanceForApp("huwaa", 31021, 1000) - repository.SetGiftPriceForApp("huwaa", "huwaa-disabled-rose", "v1", 100, 0, 100) - repository.SeedWalletPolicyInstance("huwaa", "huwaa-point-disabled", 0, "disabled", 700000) - svc := walletservice.New(repository) - - laluReceipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ - AppCode: "lalu", - CommandID: "cmd-lalu-host-coin-income", - RoomID: "room-lalu", - SenderUserID: 31011, - TargetUserID: 31012, - TargetIsHost: true, - TargetHostRegionID: 20, - GiftID: "lalu-rose", - GiftCount: 1, - PriceVersion: "v1", - RegionID: 20, - SenderRegionID: 20, - }) - if err != nil { - t.Fatalf("DebitGift without Huwaa policy failed: %v", err) - } - if laluReceipt.HostPointAdded != 0 || laluReceipt.GiftIncomeCoinAmount != 30 || laluReceipt.HostPeriodDiamondAdded != 100 { - t.Fatalf("legacy Lalu gift income mismatch: %+v", laluReceipt) - } - assertBalance(t, svc, 31012, ledger.AssetCoin, 30) - assertBalance(t, svc, 31012, ledger.AssetPoint, 0) - - disabledReceipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ - AppCode: "huwaa", - CommandID: "cmd-huwaa-disabled-host-coin-income", - RoomID: "room-huwaa-disabled", - SenderUserID: 31021, - TargetUserID: 31022, - TargetIsHost: true, - TargetHostRegionID: 20, - GiftID: "huwaa-disabled-rose", - GiftCount: 1, - PriceVersion: "v1", - RegionID: 20, - SenderRegionID: 20, - }) - if err != nil { - t.Fatalf("DebitGift with disabled Huwaa policy failed: %v", err) - } - if disabledReceipt.HostPointAdded != 0 || disabledReceipt.GiftIncomeCoinAmount != 30 || disabledReceipt.HostPeriodDiamondAdded != 100 { - t.Fatalf("disabled policy should keep legacy income: %+v", disabledReceipt) - } - assertBalanceForApp(t, svc, "huwaa", 31022, ledger.AssetCoin, 30) - assertBalanceForApp(t, svc, "huwaa", 31022, ledger.AssetPoint, 0) -} - func TestDebitDirectGiftCreditsReceiverAndKeepsDirectBizType(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(12001, 1000) repository.SetGiftPrice("direct-rose", "v1", 100, 0, 100) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 901, + Name: "direct gift host coefficient", + RegionID: 7701, + GiftCoinToDiamondRatio: "1", + }) svc := walletservice.New(repository) receipt, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{ @@ -603,14 +211,32 @@ func TestDebitDirectGiftCreditsReceiverAndKeepsDirectBizType(t *testing.T) { t.Fatalf("direct gift to active host should credit host period diamonds, got %d", got) } + // 模拟稳定 intent hash 上线前的 Lalu direct gift:旧 hash 曾包含派生的 region/Host/Agency。 + // 正常重试仍应由 Wallet 根据首次 metadata 验证稳定输入,不需要 Gateway 为每次送礼额外预读回执。 + repository.SetTransactionRequestHashForApp(appcode.Default, "cmd-direct-rose", "legacy-direct-gift-request-hash") + legacyFormalReplay, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-direct-rose", + SenderUserID: 12001, + SenderRegionID: 9999, + RegionID: 9999, + TargetUserID: 12002, + TargetIsHost: false, + TargetHostRegionID: 9999, + GiftID: "direct-rose", + GiftCount: 1, + PriceVersion: "v1", + }) + if err != nil || legacyFormalReplay.TransactionID != receipt.TransactionID { + t.Fatalf("legacy direct gift must replay through the formal rolling-deploy path: receipt=%+v err=%v", legacyFormalReplay, err) + } replayed, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-direct-rose", SenderUserID: 12001, - SenderRegionID: 1001, - RegionID: 1001, + SenderRegionID: 9999, + RegionID: 9999, TargetUserID: 12002, - TargetIsHost: true, - TargetHostRegionID: 7701, + TargetIsHost: false, + TargetHostRegionID: 9999, GiftID: "direct-rose", GiftCount: 1, PriceVersion: "v1", @@ -618,9 +244,20 @@ func TestDebitDirectGiftCreditsReceiverAndKeepsDirectBizType(t *testing.T) { if err != nil { t.Fatalf("DebitDirectGift replay failed: %v", err) } - if replayed.TransactionID != receipt.TransactionID || replayed.GiftIncomeCoinAmount != receipt.GiftIncomeCoinAmount { + if replayed.TransactionID != receipt.TransactionID || replayed.GiftIncomeCoinAmount != receipt.GiftIncomeCoinAmount || replayed.HostPeriodDiamondAdded != receipt.HostPeriodDiamondAdded { t.Fatalf("direct gift replay receipt mismatch: got %+v want %+v", replayed, receipt) } + _, err = svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-direct-rose", + SenderUserID: 12001, + TargetUserID: 12003, + GiftID: "direct-rose", + GiftCount: 1, + PriceVersion: "v1", + }) + if !xerr.IsCode(err, xerr.LedgerConflict) { + t.Fatalf("direct gift replay must reject a changed stable target, got %v", err) + } } // TestBatchDebitGiftSettlesAllTargetsAtomically 验证多目标送礼一次扣总额,并为每个接收方写独立收礼事实。 @@ -724,6 +361,13 @@ func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "v9", 7, 3, 11) + // 周期钻石必须来自已发布 Host 政策;系数 1 用于只验证主播身份与幂等累计。 + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 906, + Name: "host period identity coefficient", + RegionID: 8801, + GiftCoinToDiamondRatio: "1", + }) svc := walletservice.New(repository) cycleBefore := time.Now().UTC().Format("2006-01") @@ -775,51 +419,90 @@ func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) { } } -func TestDebitGiftCreditsHostPeriodDiamondsByGlobalDefaultAndGiftType(t *testing.T) { +func TestDebitGiftWithoutGiftTypeMultiplierFailsBeforeAnyMutation(t *testing.T) { repository := mysqltest.NewRepository(t) + repository.SetBalanceForApp("fami", 11001, 500) + repository.SetGiftPriceForApp("fami", "missing-multiplier-gift", "v1", 100, 0, 100) + repository.DeleteGiftDiamondRatiosForApp("fami") + repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{ + PolicyID: 904, + Name: "strict gift multiplier policy", + RegionID: 8801, + GiftCoinToDiamondRatio: "1", + }) svc := walletservice.New(repository) - repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeNormal, "30.00") - repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeLucky, "40.00") - repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeSuperLucky, "50.00") - cases := []struct { - giftID string - giftType string - commandID string - senderID int64 - coinPrice int64 - wantDiamond int64 - }{ - {giftID: "normal-ratio-gift", giftType: resourcedomain.GiftTypeNormal, commandID: "cmd-normal-ratio", senderID: 11001, coinPrice: 100, wantDiamond: 100}, - {giftID: "lucky-ratio-gift", giftType: resourcedomain.GiftTypeLucky, commandID: "cmd-lucky-ratio", senderID: 11002, coinPrice: 100, wantDiamond: 10}, - {giftID: "super-lucky-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, commandID: "cmd-super-lucky-ratio", senderID: 11003, coinPrice: 99, wantDiamond: 0}, + _, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + AppCode: "fami", + CommandID: "cmd-missing-gift-multiplier", + RoomID: "room-missing-gift-multiplier", + SenderUserID: 11001, + SenderRegionID: 1001, + TargetUserID: 11002, + GiftID: "missing-multiplier-gift", + GiftCount: 1, + PriceVersion: "v1", + TargetIsHost: true, + TargetHostRegionID: 8801, + }) + if !xerr.IsCode(err, xerr.PermissionDenied) { + t.Fatalf("missing gift multiplier must fail closed, got %v", err) } + assertBalanceForApp(t, svc, "fami", 11001, ledger.AssetCoin, 500) + assertBalanceForApp(t, svc, "fami", 11002, ledger.AssetCoin, 0) + if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-missing-gift-multiplier"); got != 0 { + t.Fatalf("missing multiplier must fail before transaction or account mutation, got %d", got) + } +} - for _, tc := range cases { - repository.SetBalance(tc.senderID, 100) - repository.SetGiftPrice(tc.giftID, "v1", tc.coinPrice, 100, tc.coinPrice) - repository.SetGiftType(tc.giftID, tc.giftType) - receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ - CommandID: tc.commandID, - RoomID: "room-ratio", - SenderUserID: tc.senderID, - SenderRegionID: 1001, - TargetUserID: 12001, - GiftID: tc.giftID, - GiftCount: 1, - PriceVersion: "v1", - TargetIsHost: true, - TargetHostRegionID: 8801, - }) - if err != nil { - t.Fatalf("DebitGift %s failed: %v", tc.giftType, err) - } - if receipt.HostPeriodDiamondAdded != tc.wantDiamond { - t.Fatalf("%s ratio diamond mismatch: got %+v want %d", tc.giftType, receipt, tc.wantDiamond) - } +func TestDebitGiftCombinesGiftTypeRatioAndHostPolicyCoefficient(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalance(12101, 1000) + repository.SetGiftPrice("lucky-host-coefficient", "v1", 100, 100, 100) + repository.SetGiftType("lucky-host-coefficient", resourcedomain.GiftTypeLucky) + repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeLucky, "10.00") + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 902, + Name: "host diamond coefficient", + RegionID: 8801, + GiftCoinToDiamondRatio: "1.25", + }) + svc := walletservice.New(repository) + + receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + CommandID: "cmd-lucky-host-coefficient", + RoomID: "room-host-coefficient", + SenderUserID: 12101, + SenderRegionID: 1001, + TargetUserID: 12102, + GiftID: "lucky-host-coefficient", + GiftCount: 1, + PriceVersion: "v1", + TargetIsHost: true, + TargetHostRegionID: 8801, + }) + if err != nil { + t.Fatalf("DebitGift with Host coefficient failed: %v", err) } - if got := repository.HostPeriodGiftDiamondTotal(12001); got != 110 { - t.Fatalf("host period account total mismatch, got %d", got) + // 100 金币 × lucky 10% × Host 系数 1.25 = 12.5,只在最终结果向下取整为 12。 + if receipt.HostPeriodDiamondAdded != 12 { + t.Fatalf("combined period diamond formula mismatch: %+v", receipt) + } + if got := repository.CountRows("wallet_transactions", `transaction_id = ? + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_ppm')) AS SIGNED) = 100000 + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_salary_policy_diamond_coefficient_ppm')) AS SIGNED) = 1250000 + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_salary_policy_id')) AS SIGNED) = 902 + AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_income_policy_type')) = 'SALARY_DIAMOND' + AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.income_asset_type')) = 'HOST_PERIOD_DIAMOND'`, receipt.TransactionID); got != 1 { + t.Fatalf("combined period diamond transaction snapshot mismatch, got %d", got) + } + if got := repository.CountRows("wallet_outbox", `transaction_id = ? AND event_type = 'HostPeriodDiamondCredited' + AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_type_code')) = 'lucky' + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_diamond_ratio_ppm')) AS SIGNED) = 100000 + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.host_salary_policy_diamond_coefficient_ppm')) AS SIGNED) = 1250000 + AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.host_income_policy_type')) = 'SALARY_DIAMOND' + AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.income_asset_type')) = 'HOST_PERIOD_DIAMOND'`, receipt.TransactionID); got != 1 { + t.Fatalf("combined period diamond outbox snapshot mismatch, got %d", got) } } @@ -885,6 +568,12 @@ func TestDebitGiftUsesTargetHostRegionDiamondRatioAndRegionalReturnCoinRatio(t * repository.SetGiftType("regional-return-gift", resourcedomain.GiftTypeNormal) repository.SetGiftDiamondRatio(7701, resourcedomain.GiftTypeNormal, "80.00") repository.SetGiftReturnCoinRatio(7701, resourcedomain.GiftTypeNormal, "45.00") + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 907, + Name: "regional gift ratio coefficient", + RegionID: 7701, + GiftCoinToDiamondRatio: "1", + }) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ CommandID: "cmd-regional-return-coin", @@ -998,15 +687,22 @@ func TestBatchDebitGiftAppliesGlobalDefaultGiftDiamondRatioToEachTargetHeatValue } } -func TestBatchDebitGiftUsesEachTargetHostRegionDiamondRatioWithGlobalFallback(t *testing.T) { +func TestBatchDebitGiftMissingOneTargetPolicyFailsBeforeAnyMutation(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(15101, 1000) repository.SetGiftPrice("batch-host-region-ratio-gift", "v1", 100, 100, 100) repository.SetGiftType("batch-host-region-ratio-gift", resourcedomain.GiftTypeNormal) repository.SetGiftDiamondRatio(7701, resourcedomain.GiftTypeNormal, "80.00") + repository.SetGiftDiamondRatio(7702, resourcedomain.GiftTypeNormal, "90.00") + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 903, + Name: "regional host coefficient", + RegionID: 7701, + GiftCoinToDiamondRatio: "1.5", + }) svc := walletservice.New(repository) - receipt, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{ + _, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{ CommandID: "cmd-batch-host-region-ratio", RoomID: "room-host-region-ratio", SenderUserID: 15101, @@ -1020,15 +716,112 @@ func TestBatchDebitGiftUsesEachTargetHostRegionDiamondRatioWithGlobalFallback(t {CommandID: "cmd-batch-host-region-ratio:target:15103", TargetUserID: 15103, TargetIsHost: true, TargetHostRegionID: 7702}, }, }) + if !xerr.IsCode(err, xerr.PermissionDenied) { + t.Fatalf("one target without a current or inherited policy must reject the whole batch, got %v", err) + } + // 所有目标政策和倍率都在批量账户锁及库存消耗前预解析;任一目标失败不能形成半笔批量账。 + assertBalance(t, svc, 15101, ledger.AssetCoin, 1000) + assertBalance(t, svc, 15102, ledger.AssetCoin, 0) + assertBalance(t, svc, 15103, ledger.AssetCoin, 0) + if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-batch-host-region-ratio:target:15102", "cmd-batch-host-region-ratio:target:15103"); got != 0 { + t.Fatalf("rejected batch must not write any target transaction, got %d", got) + } +} + +func TestPointDiamondGiftCombinesHostAndAgencyIncomeForSameUser(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalanceForApp("fami", 15111, 1000) + repository.SetGiftPriceForApp("fami", "point-diamond-self-gift", "v1", 100, 0, 100) + repository.SetGiftDiamondRatioForApp("fami", 0, resourcedomain.GiftTypeNormal, "100.00") + repository.SetGiftDiamondRatioForApp("fami", 31, resourcedomain.GiftTypeNormal, "100.00") + if got := repository.CountRows("gift_diamond_ratio_configs", "app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active'", "fami", int64(31), resourcedomain.GiftTypeNormal); got != 1 { + t.Fatalf("POINT_DIAMOND gift multiplier seed mismatch, got %d", got) + } + repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{ + PolicyID: 905, + PolicyVersion: 905, + Name: "point diamond self agency policy", + PolicyType: ledger.HostPolicyTypePointDiamond, + RegionID: 31, + GiftCoinToDiamondRatio: "1", + PointDiamondsPerUSD: 200000, + CoinsPerUSD: 80000, + MinimumWithdrawUSDMinor: 100, + AgencyPointShareBPS: 2000, + }) + svc := walletservice.New(repository) + + receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ + AppCode: "fami", + CommandID: "cmd-point-diamond-self-gift", + RoomID: "room-point-diamond-self-gift", + SenderUserID: 15111, + SenderRegionID: 31, + RegionID: 31, + TargetUserID: 15111, + TargetIsHost: true, + TargetHostRegionID: 31, + TargetAgencyOwnerUserID: 15111, + GiftID: "point-diamond-self-gift", + GiftCount: 1, + PriceVersion: "v1", + }) if err != nil { - t.Fatalf("BatchDebitGift failed: %v", err) + t.Fatalf("POINT_DIAMOND self gift failed: %v", err) } - if len(receipt.Targets) != 2 || receipt.Targets[0].Receipt.HostPeriodDiamondAdded != 80 || receipt.Targets[1].Receipt.HostPeriodDiamondAdded != 100 { - t.Fatalf("batch host region ratio or global fallback mismatch: %+v", receipt) + if receipt.HostIncomePolicyType != ledger.HostPolicyTypePointDiamond || receipt.IncomeAssetType != ledger.AssetPointDiamond || receipt.HostBaseAmount != 100 || receipt.AgencyAmount != 20 { + t.Fatalf("POINT_DIAMOND split receipt mismatch: %+v", receipt) } - if receipt.Targets[0].Receipt.HeatValue != 100 || receipt.Targets[1].Receipt.HeatValue != 100 { - t.Fatalf("host region ratio must not change global room contribution: %+v", receipt) + assertBalanceForApp(t, svc, "fami", 15111, ledger.AssetPointDiamond, 120) + if got := repository.CountRows("wallet_entries", `transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?`, receipt.TransactionID, int64(15111), ledger.AssetPointDiamond, int64(120)); got != 1 { + t.Fatalf("same Host and Agency must merge into one POINT_DIAMOND entry, got %d", got) } + if got := repository.CountRows("point_diamond_gift_income_entries", `transaction_id = ? AND host_user_id = ? AND agency_user_id = ? AND host_base_amount = ? AND agency_amount = ?`, receipt.TransactionID, int64(15111), int64(15111), int64(100), int64(20)); got != 1 { + t.Fatalf("gift income projection must preserve the 100 Host + 20 Agency split, got %d", got) + } + if got := repository.CountRows("wallet_transactions", `transaction_id = ? + AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_income_policy_type')) = 'POINT_DIAMOND' + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_base_amount')) AS SIGNED) = 100 + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.agency_amount')) AS SIGNED) = 20`, receipt.TransactionID); got != 1 { + t.Fatalf("self gift metadata must preserve Host/Agency split after merged posting, got %d", got) + } + if got := repository.CountRows("host_period_diamond_accounts", "app_code = ? AND user_id = ?", "fami", int64(15111)); got != 0 { + t.Fatalf("POINT_DIAMOND policy must never create salary-cycle diamonds, got %d", got) + } + statsContext := appcode.WithContext(context.Background(), "fami") + startAtMS, endAtMS := time.Now().Add(-time.Hour).UnixMilli(), time.Now().Add(time.Hour).UnixMilli() + hostStats, err := svc.GetHostRevenueStats(statsContext, "fami", ledger.HostRevenueStatsQuery{HostUserID: 15111, StartAtMS: startAtMS, EndAtMS: endAtMS}) + if err != nil || hostStats.DiamondEarnings != 100 || hostStats.GiftSenders != 1 { + t.Fatalf("Host stats must count only the 100 base points: stats=%+v err=%v", hostStats, err) + } + agencyHostStats, err := svc.GetAgencyHostGiftStats(statsContext, "fami", ledger.AgencyHostGiftStatsQuery{HostUserIDs: []int64{15111}, AgencyUserID: 15111, StartAtMS: startAtMS, EndAtMS: endAtMS}) + if err != nil || agencyHostStats.GiftIncome != 100 || agencyHostStats.PointDiamondAgencyShareIncome != 20 || agencyHostStats.GiftedHostCount != 1 { + t.Fatalf("Agency stats must preserve the self-host 100 Host + 20 Agency split: stats=%+v err=%v", agencyHostStats, err) + } + transactions, total, err := svc.ListWalletTransactions(statsContext, ledger.ListWalletTransactionsQuery{AppCode: "fami", UserID: 15111, AssetType: ledger.AssetPointDiamond, Page: 1, PageSize: 20}) + if err != nil || total != 1 || len(transactions) != 1 { + t.Fatalf("POINT_DIAMOND history query mismatch: total=%d items=%+v err=%v", total, transactions, err) + } + if transactions[0].EntryRole != "host_gift_income_and_agency_share" || transactions[0].AvailableDelta != 120 || transactions[0].HostBaseAmount != 100 || transactions[0].AgencyAmount != 20 { + t.Fatalf("POINT_DIAMOND merged history split mismatch: %+v", transactions[0]) + } + exchangeCommand := ledger.PointToCoinCommand{ + AppCode: "fami", CommandID: "cmd-point-diamond-self-exchange", UserID: 15111, + PointAmount: 120, SourceAssetType: ledger.AssetPointDiamond, RegionID: 31, NowMS: time.Now().UnixMilli(), + } + exchange, err := svc.ExchangePointToCoin(context.Background(), exchangeCommand) + if err != nil { + t.Fatalf("exchange POINT_DIAMOND to COIN: %v", err) + } + if exchange.PointAmount != 120 || exchange.CoinAmount != 48 || exchange.PointBalanceAfter != 0 || exchange.RatioPointAmount != 200000 || exchange.RatioCoinAmount != 80000 || exchange.SourceAssetType != ledger.AssetPointDiamond || exchange.PolicyID != 905 || exchange.PolicyVersion != 905 { + t.Fatalf("120 POINT_DIAMOND must exchange to 48 COIN: %+v", exchange) + } + replayedExchange, err := svc.ExchangePointToCoin(context.Background(), exchangeCommand) + if err != nil || replayedExchange.TransactionID != exchange.TransactionID || replayedExchange.CoinAmount != 48 || replayedExchange.PolicyID != 905 || replayedExchange.PolicyVersion != 905 { + t.Fatalf("POINT_DIAMOND exchange replay mismatch: replay=%+v err=%v", replayedExchange, err) + } + requireWalletBalanceForApp(t, svc, "fami", 15111, ledger.AssetPointDiamond, 0, 0) + requireWalletBalanceForApp(t, svc, "fami", 15111, ledger.AssetCoin, 978, 0) } // TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。 @@ -1105,7 +898,13 @@ func TestGetActiveHostSalaryPolicyReadsRuntimePolicy(t *testing.T) { func TestGetHostSalaryProgressReadsCurrentCycleDiamonds(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) - repository.SetGiftPrice("rose", "salary-progress", 200, 10, 10) + repository.SetGiftPrice("rose", "salary-progress", 14, 10, 10) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 908, + Name: "salary progress coefficient", + RegionID: 8801, + GiftCoinToDiamondRatio: "1", + }) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ @@ -1148,11 +947,12 @@ func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) { repository.SetBalance(10001, 1000) repository.SetGiftPrice("rose", "salary", 200, 10, 10) repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ - PolicyID: 101, - Name: "daily salary", - RegionID: 8801, - Status: "active", - SettlementMode: ledger.HostSalarySettlementModeDaily, + PolicyID: 101, + Name: "daily salary", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + GiftCoinToDiamondRatio: "1", Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, {LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2}, @@ -1191,7 +991,8 @@ func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) { t.Fatalf("first settlement result mismatch: %+v", result) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150) - assertBalance(t, svc, 10002, ledger.AssetCoin, 90) + // 主播 COIN 同时包含普通礼物 30% 返币 60 和等级金币奖励 90。 + assertBalance(t, svc, 10002, ledger.AssetCoin, 150) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) second, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ @@ -1228,7 +1029,8 @@ func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) { t.Fatalf("second settlement result mismatch: %+v", result) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300) - assertBalance(t, svc, 10002, ledger.AssetCoin, 198) + // 两笔礼物累计返币 120;等级金币奖励按累计权益补到 198。 + assertBalance(t, svc, 10002, ledger.AssetCoin, 318) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80) if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 2 { t.Fatalf("salary settlement records mismatch, got %d", got) @@ -1327,12 +1129,13 @@ func TestHostSalaryManualSettlementRoleSplitsHostAndAgencyWallets(t *testing.T) repository.SetBalance(10001, 1000) repository.SetGiftPrice("rose", "salary-role-split", 120, 10, 10) repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ - PolicyID: 303, - Name: "manual role split salary", - RegionID: 8801, - Status: "active", - SettlementMode: ledger.HostSalarySettlementModeDaily, - SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual, + PolicyID: 303, + Name: "manual role split salary", + RegionID: 8801, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, + SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual, + GiftCoinToDiamondRatio: "1", Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, }, @@ -1375,7 +1178,8 @@ func TestHostSalaryManualSettlementRoleSplitsHostAndAgencyWallets(t *testing.T) } assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 0) - assertBalance(t, svc, 10002, ledger.AssetCoin, 0) + // Agency-only 结算不能发 Host 等级奖励,但送礼事务已独立发放普通礼物 30% 返币。 + assertBalance(t, svc, 10002, ledger.AssetCoin, 36) if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ? AND settlement_role = ? AND agency_salary_usd_minor_delta = ? AND host_salary_usd_minor_delta = 0", int64(10002), receipt.HostPeriodCycleKey, "agency", int64(50)); got != 1 { t.Fatalf("agency-only record mismatch, got %d", got) } @@ -1402,7 +1206,7 @@ func TestHostSalaryManualSettlementRoleSplitsHostAndAgencyWallets(t *testing.T) t.Fatalf("host-only result mismatch: %+v", hostResult) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150) - assertBalance(t, svc, 10002, ledger.AssetCoin, 90) + assertBalance(t, svc, 10002, ledger.AssetCoin, 126) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ? AND settlement_role = ? AND host_salary_usd_minor_delta = ? AND agency_salary_usd_minor_delta = 0", int64(10002), receipt.HostPeriodCycleKey, "host", int64(150)); got != 1 { t.Fatalf("host-only record mismatch, got %d", got) @@ -1423,6 +1227,7 @@ func TestHostSalaryMonthEndCreditsResidualAndClearsCycle(t *testing.T) { RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeDaily, + GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0.01", Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, @@ -1581,6 +1386,7 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) { RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeDaily, + GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0.01", Levels: levels, }) @@ -1591,6 +1397,7 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) { RegionID: 8801, Status: "active", SettlementMode: ledger.HostSalarySettlementModeDaily, + GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0.01", Levels: levels, }) @@ -1634,7 +1441,8 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) { t.Fatalf("level 1 daily settlement result mismatch: %+v", result) } assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150) - assertBalance(t, svc, 10002, ledger.AssetCoin, 90) + // 首笔礼物返币 36,达到 1 级后另发等级金币奖励 90。 + assertBalance(t, svc, 10002, ledger.AssetCoin, 126) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50) previousIDs := repository.HostSalarySettlementIDs(10002, previousCycle) if len(previousIDs) != 1 || previousIDs[0] == "" { @@ -1698,7 +1506,8 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) { } // 第二次只补发累计 2 级与已发 1 级之间的差额,最终余额等于 2 级累计权益。 assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300) - assertBalance(t, svc, 10002, ledger.AssetCoin, 198) + // 第二笔三连礼返币 108,等级奖励再增量补发 108,累计 342。 + assertBalance(t, svc, 10002, ledger.AssetCoin, 342) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80) previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle) if len(previousIDs) != 2 { @@ -1783,7 +1592,8 @@ func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) { } // 新 cycle_key 使用独立进度,因此月末清算后的下一周期从 1 级累计权益重新开始发放。 assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 530) - assertBalance(t, svc, 10002, ledger.AssetCoin, 288) + // 新周期再收礼返币 36,并重新获得 1 级金币奖励 90。 + assertBalance(t, svc, 10002, ledger.AssetCoin, 468) assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 130) nextIDs := repository.HostSalarySettlementIDs(10002, third.HostPeriodCycleKey) if len(nextIDs) != 1 || nextIDs[0] == "" { @@ -1798,11 +1608,12 @@ func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) { repository.SetBalance(10001, 500) repository.SetGiftPrice("rose", "salary-half-month", 200, 10, 10) repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ - PolicyID: 302, - Name: "published half-month salary", - RegionID: 8802, - Status: "active", - SettlementMode: ledger.HostSalarySettlementModeHalfMonth, + PolicyID: 302, + Name: "published half-month salary", + RegionID: 8802, + Status: "active", + SettlementMode: ledger.HostSalarySettlementModeHalfMonth, + GiftCoinToDiamondRatio: "1", Levels: []ledger.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1}, }, @@ -1857,7 +1668,8 @@ func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) { t.Fatalf("half-month settlement result mismatch: %+v", result) } assertBalance(t, svc, 10003, ledger.AssetHostSalaryUSD, 150) - assertBalance(t, svc, 10003, ledger.AssetCoin, 90) + // 普通礼物返币 60 与半月结等级金币奖励 90 共存。 + assertBalance(t, svc, 10003, ledger.AssetCoin, 150) assertBalance(t, svc, 30002, ledger.AssetAgencySalaryUSD, 50) if ids := repository.HostSalarySettlementIDs(10003, receipt.HostPeriodCycleKey); len(ids) != 1 || ids[0] == "" { t.Fatalf("half-month settlement id missing: %v", ids) @@ -1869,22 +1681,35 @@ func TestDebitGiftAllowsSelfGift(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(10001, 100) repository.SetGiftPrice("rose", "v9", 7, 3, 11) + repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{ + PolicyID: 905, + Name: "self host gift coefficient", + RegionID: 1001, + GiftCoinToDiamondRatio: "1", + }) svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ - CommandID: "cmd-self-gift", - RoomID: "room-1", - SenderUserID: 10001, - TargetUserID: 10001, - GiftID: "rose", - GiftCount: 2, - PriceVersion: "v9", + CommandID: "cmd-self-gift", + RoomID: "room-1", + SenderUserID: 10001, + SenderRegionID: 1001, + TargetUserID: 10001, + TargetIsHost: true, + TargetHostRegionID: 1001, + TargetAgencyOwnerUserID: 10001, + GiftID: "rose", + GiftCount: 2, + PriceVersion: "v9", + RegionID: 1001, }) if err != nil { t.Fatalf("self DebitGift failed: %v", err) } - if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 0 || receipt.BalanceAfter != 90 { + // 公会长同时也是主播并给自己送礼时,金币账户仍按扣 14、返 4 结算; + // 周期钻石只按 14 金币 × normal 100% × Host 系数 1 累计,不产生 POINT。 + if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 0 || receipt.HostPointAdded != 0 || receipt.HostPeriodDiamondAdded != 14 || receipt.BalanceAfter != 90 { t.Fatalf("self gift settlement mismatch: %+v", receipt) } balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin, ledger.AssetGiftPoint}) @@ -1906,6 +1731,12 @@ func TestDebitGiftAllowsSelfGift(t *testing.T) { if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 { t.Fatalf("self gift should still publish income fact, got %d", got) } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPeriodDiamondCredited"); got != 1 { + t.Fatalf("self host gift should publish one period-diamond fact, got %d", got) + } + if got := repository.HostPeriodGiftDiamondTotal(10001); got != 14 { + t.Fatalf("self host gift period diamonds mismatch, got %d", got) + } } // TestGetUserGiftWallAggregatesSettledGifts 验证礼物墙只统计扣费成功后的收礼事实。 @@ -1964,7 +1795,7 @@ func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) { if err != nil { t.Fatalf("GetUserGiftWall failed: %v", err) } - if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalGiftPoint != 65 || wall.TotalHeatValue != 135 { + if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalCoinValue != 135 || wall.TotalDiamondValue != 0 || wall.TotalGiftPoint != 0 || wall.TotalHeatValue != 135 { t.Fatalf("gift wall summary mismatch: %+v", wall) } rose, ok := giftWallItemByID(wall.Items, "rose") @@ -2539,11 +2370,11 @@ func TestCreditTaskRewardIsIdempotent(t *testing.T) { } } -func TestCreditTaskRewardPointAppGateWithoutMySQL(t *testing.T) { +func TestCreditTaskRewardPointUsesConfiguredAssetWithoutAppGate(t *testing.T) { repository := &fakePointGateRepository{} svc := walletservice.New(repository) - _, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{ + receipt, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{ AppCode: "lalu", CommandID: "cmd-fake-lalu-point-task-reward", TargetUserID: 23001, @@ -2554,41 +2385,23 @@ func TestCreditTaskRewardPointAppGateWithoutMySQL(t *testing.T) { CycleKey: "2026-05-09", Reason: "daily_task_reward", }) - if err == nil { - t.Fatal("Lalu POINT task reward must be rejected before repository call") - } - if repository.creditTaskRewardCalls != 0 { - t.Fatalf("rejected POINT task reward must not call repository, got %d calls", repository.creditTaskRewardCalls) - } - - receipt, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{ - AppCode: "huwaa", - CommandID: "cmd-fake-huwaa-point-task-reward", - TargetUserID: 23002, - AssetType: ledger.AssetPoint, - Amount: 99, - TaskType: "daily", - TaskID: "task-point", - CycleKey: "2026-05-09", - Reason: "daily_task_reward", - }) if err != nil { - t.Fatalf("Huwaa POINT task reward should reach repository: %v", err) + t.Fatalf("configured POINT task reward should reach repository without an app-name gate: %v", err) } if receipt.Balance.AssetType != ledger.AssetPoint || repository.creditTaskRewardCalls != 1 || - repository.lastTaskRewardAppFromContext != "huwaa" || + repository.lastTaskRewardAppFromContext != "lalu" || repository.lastTaskReward.AssetType != ledger.AssetPoint || - repository.lastTaskReward.AppCode != "huwaa" { - t.Fatalf("Huwaa POINT task reward repository payload mismatch: receipt=%+v repo=%+v", receipt, repository) + repository.lastTaskReward.AppCode != "lalu" { + t.Fatalf("POINT task reward repository payload mismatch: receipt=%+v repo=%+v", receipt, repository) } } -func TestCreditTaskRewardPointIsHuwaaOnly(t *testing.T) { +func TestCreditTaskRewardPointIsTenantScoped(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) - _, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{ + first, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{ AppCode: "lalu", CommandID: "cmd-lalu-point-task-reward", TargetUserID: 21011, @@ -2599,11 +2412,11 @@ func TestCreditTaskRewardPointIsHuwaaOnly(t *testing.T) { CycleKey: "2026-05-09", Reason: "daily_task_reward", }) - if err == nil { - t.Fatal("Lalu POINT task reward must be rejected") + if err != nil { + t.Fatalf("configured POINT task reward failed: %v", err) } - if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-lalu-point-task-reward"); got != 0 { - t.Fatalf("rejected POINT task reward must not write transaction, got %d", got) + if first.Balance.AssetType != ledger.AssetPoint || first.Balance.AvailableAmount != 88 { + t.Fatalf("first tenant POINT balance mismatch: %+v", first) } command := ledger.TaskRewardCommand{ @@ -2617,7 +2430,7 @@ func TestCreditTaskRewardPointIsHuwaaOnly(t *testing.T) { CycleKey: "2026-05-09", Reason: "daily_task_reward", } - first, err := svc.CreditTaskReward(context.Background(), command) + huwaaFirst, err := svc.CreditTaskReward(context.Background(), command) if err != nil { t.Fatalf("Huwaa POINT task reward failed: %v", err) } @@ -2625,15 +2438,76 @@ func TestCreditTaskRewardPointIsHuwaaOnly(t *testing.T) { if err != nil { t.Fatalf("Huwaa POINT task reward retry failed: %v", err) } - if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.Balance.AssetType != ledger.AssetPoint || second.Balance.AvailableAmount != 99 { - t.Fatalf("Huwaa POINT task reward receipt mismatch: first=%+v second=%+v", first, second) + if huwaaFirst.TransactionID == "" || huwaaFirst.TransactionID != second.TransactionID || huwaaFirst.Balance.AssetType != ledger.AssetPoint || second.Balance.AvailableAmount != 99 { + t.Fatalf("Huwaa POINT task reward receipt mismatch: first=%+v second=%+v", huwaaFirst, second) } assertBalanceForApp(t, svc, "huwaa", 21012, ledger.AssetPoint, 99) - if got := repository.CountRows("wallet_entries", "transaction_id = ? AND asset_type = ?", first.TransactionID, ledger.AssetPoint); got != 1 { + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND asset_type = ?", huwaaFirst.TransactionID, ledger.AssetPoint); got != 1 { t.Fatalf("Huwaa POINT task reward should write one POINT entry, got %d", got) } } +func TestRemoveDirectGiftPointPolicyMigrationKeepsUnrelatedPointPolicies(t *testing.T) { + repository := mysqltest.NewRepository(t) + fixtures := []struct { + appCode string + instanceCode string + templateCode string + pointsPerUSD int64 + minimumPoints int64 + feeBPS int64 + hostRatioPPM int64 + agencyRatioPPM int64 + taskAsset string + includeExtension bool + }{ + {appCode: "fami", instanceCode: "fami-policy", templateCode: "fami_guild_revenue_policy", pointsPerUSD: 100_000, minimumPoints: 1_000_000, feeBPS: 500, hostRatioPPM: 1_000_000, agencyRatioPPM: 200_000, taskAsset: "POINT", includeExtension: true}, + {appCode: "lalu", instanceCode: "lalu-policy", templateCode: "lalu-point-policy", pointsPerUSD: 110_000, minimumPoints: 2_000_000, feeBPS: 300, hostRatioPPM: 600_000, agencyRatioPPM: 50_000, taskAsset: "COIN", includeExtension: true}, + {appCode: "huwaa", instanceCode: "huwaa-policy", templateCode: "huwaa-point-policy", pointsPerUSD: 120_000, minimumPoints: 3_000_000, feeBPS: 250, hostRatioPPM: 700_000, agencyRatioPPM: 100_000, taskAsset: "POINT", includeExtension: true}, + } + for _, fixture := range fixtures { + repository.SeedPointWalletPolicy(fixture.appCode, fixture.instanceCode, 0, "active", fixture.pointsPerUSD, fixture.minimumPoints, fixture.feeBPS) + ruleJSON := fmt.Sprintf(`{"points_per_usd":%d,"host":{"minimum_withdraw_points":%d,"withdraw_fee_bps":%d,"point_ratio_percent":70,"use_gift_type_ratio":true,"affects_room_heat":true},"agency":{"point_ratio_percent":20,"share_base":"host_income"},"agent":{"levels":[]},"bd":{"levels":[]},"manager":{"host_point_commission_percent":2,"future_manager_option":"keep"},"coin_seller":{"seller_point_reward_percent":5,"seller_point_settle_percent":95,"future_coin_seller_option":"keep"},"game_invite":{"enabled":true},"tasks":{"reward_asset_type":%q,"new_host_7d_max_points":350000},"extension":{"enabled":%t}}`, fixture.pointsPerUSD, fixture.minimumPoints, fixture.feeBPS, fixture.taskAsset, fixture.includeExtension) + repository.SetPointWalletPolicyRawSnapshot(fixture.appCode, fixture.instanceCode, 0, fixture.templateCode, fixture.hostRatioPPM, fixture.agencyRatioPPM, ruleJSON) + } + + migrationSQL, err := os.ReadFile("../../../deploy/mysql/migrations/015_remove_direct_gift_point_policy.sql") + if err != nil { + t.Fatalf("read direct-gift POINT removal migration failed: %v", err) + } + // 真实执行两次,同时锁定迁移幂等性;这个迁移只允许动小型配置表。 + repository.ApplySQLScript(string(migrationSQL)) + repository.ApplySQLScript(string(migrationSQL)) + + if got := repository.CountRows("wallet_policy_instances", "template_code = ?", "fami_guild_revenue_policy"); got != 0 { + t.Fatalf("removed Fami direct-gift policy must have no runtime rows, got %d", got) + } + if got := repository.CountRows("wallet_policy_instances", "app_code IN (?, ?)", "lalu", "huwaa"); got != 2 { + t.Fatalf("unrelated POINT policies must remain, got %d", got) + } + if got := repository.CountRows("wallet_policy_instances", "app_code IN (?, ?) AND host_point_ratio_ppm = 0 AND agency_point_ratio_ppm = 0", "lalu", "huwaa"); got != 2 { + t.Fatalf("legacy gift ratios must be neutralized on remaining policies, got %d", got) + } + if got := repository.CountRows("wallet_policy_instances", `app_code IN (?, ?) + AND JSON_EXTRACT(rule_json, '$.tasks') IS NULL + AND JSON_EXTRACT(rule_json, '$.host.point_ratio_percent') IS NULL + AND JSON_EXTRACT(rule_json, '$.host.use_gift_type_ratio') IS NULL + AND JSON_EXTRACT(rule_json, '$.host.affects_room_heat') 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`, "lalu", "huwaa"); got != 2 { + t.Fatalf("unconsumed policy keys must be removed from remaining snapshots, got %d", got) + } + if got := repository.CountRows("wallet_policy_instances", `app_code = ? AND points_per_usd = ? AND withdraw_fee_bps = ? + AND CAST(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.host.minimum_withdraw_points')) AS SIGNED) = ? + AND JSON_EXTRACT(rule_json, '$.extension.enabled') = TRUE`, "lalu", int64(110_000), int64(300), int64(2_000_000)); got != 1 { + t.Fatalf("Lalu POINT financial fields and unrelated extensions must remain, got %d", got) + } +} + func seedPointWithdrawalSalaryPolicy(repository *mysqltest.Repository, appCode string, policyID uint64, regionID int64, coinSellerPeriod string, coinSellerCount int64, platformPeriod string, platformCount int64, platformAllowedDays ...string) { allowedDays := "" if len(platformAllowedDays) > 0 { @@ -2648,6 +2522,104 @@ func seedPointWithdrawalSalaryPolicy(repository *mysqltest.Repository, appCode s PlatformWithdrawalLimitPeriod: platformPeriod, PlatformWithdrawalLimitCount: platformCount, PlatformWithdrawalAllowedDays: allowedDays, }) + // POINT 比例/门槛继续来自原 wallet 政策;Host 政策负责频率和日期限制。 + repository.SeedPointWalletPolicy(appCode, fmt.Sprintf("point-policy-%d", regionID), regionID, "active", 100_000, 1_000_000, 500) +} + +func TestHostAgencySalaryWithdrawalWithoutPolicyPreservesLegacyAccessAndRollbackIdempotency(t *testing.T) { + tests := []struct { + name string + assetType string + userID int64 + regionID int64 + }{ + {name: "host", assetType: ledger.AssetHostSalaryUSD, userID: 22101, regionID: 3101}, + {name: "agency", assetType: ledger.AssetAgencySalaryUSD, userID: 22102, regionID: 3102}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetAssetBalanceForApp(appcode.Default, test.userID, test.assetType, 10_000) + svc := walletservice.New(repository) + freezeCommandID := "legacy-policyless-" + test.name + "-freeze" + withdrawalRef := "legacy-policyless-" + test.name + "-ref" + + freeze, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: appcode.Default, CommandID: freezeCommandID, UserID: test.userID, + SalaryAssetType: test.assetType, SalaryUSDMinor: 5_000, + RegionID: test.regionID, WithdrawalRef: withdrawalRef, + }) + if err != nil { + t.Fatalf("policyless %s salary freeze must preserve legacy access: %v", test.name, err) + } + if freeze.AvailableAfter != 5_000 || freeze.FrozenAfter != 5_000 { + t.Fatalf("policyless %s salary freeze balance mismatch: %+v", test.name, freeze) + } + if got := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ?", appcode.Default, test.userID); got != 0 { + t.Fatalf("policyless %s salary freeze must not consume a policy counter, got %d", test.name, got) + } + if got := repository.CountRows("point_withdrawal_limit_reservations", "app_code = ? AND freeze_command_id = ? AND policy_id = 0 AND policy_version = 0 AND counted = FALSE AND status = 'active'", appcode.Default, freezeCommandID); got != 1 { + t.Fatalf("policyless %s salary freeze must keep one non-counted rollback reservation, got %d", test.name, got) + } + + if _, err := svc.ReleaseSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: appcode.Default, CommandID: freezeCommandID + ":rollback", UserID: test.userID, + SalaryAssetType: test.assetType, SalaryUSDMinor: 5_000, + }); err != nil { + t.Fatalf("policyless %s failed-application rollback must restore salary: %v", test.name, err) + } + requireWalletBalanceForApp(t, svc, appcode.Default, test.userID, test.assetType, 10_000, 0) + if got := repository.CountRows("point_withdrawal_limit_reservations", "app_code = ? AND freeze_command_id = ? AND counted = FALSE AND status = 'released'", appcode.Default, freezeCommandID); got != 1 { + t.Fatalf("policyless %s rollback must release its reservation, got %d", test.name, got) + } + + _, err = svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: appcode.Default, CommandID: freezeCommandID, UserID: test.userID, + SalaryAssetType: test.assetType, SalaryUSDMinor: 5_000, + RegionID: test.regionID, WithdrawalRef: withdrawalRef, + }) + if !xerr.IsCode(err, xerr.IdempotencyConflict) { + t.Fatalf("policyless %s rolled-back freeze must not replay stale success, got %v", test.name, err) + } + requireWalletBalanceForApp(t, svc, appcode.Default, test.userID, test.assetType, 10_000, 0) + }) + } +} + +func TestHostSalaryWithdrawalInheritsPreviousCyclePolicyLimit(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + const userID int64 = 22121 + const regionID int64 = 3121 + repository.SetAssetBalanceForApp(appcode.Default, userID, ledger.AssetHostSalaryUSD, 10_000) + now := time.Now().UTC() + previousCycle := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -1, 0).Format("2006-01") + repository.SetHostSalaryPolicyForApp(appcode.Default, ledger.HostSalaryPolicy{ + PolicyID: 221, PolicyVersion: 221, CycleKey: previousCycle, + Name: "previous host withdrawal policy", RegionID: regionID, Status: "active", + SettlementMode: ledger.HostSalarySettlementModeDaily, SettlementTriggerMode: ledger.HostSalarySettlementTriggerAutomatic, + GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0", + CoinSellerWithdrawalLimitPeriod: "month", CoinSellerWithdrawalLimitCount: 0, + PlatformWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitCount: 1, + }) + + if _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: appcode.Default, CommandID: "inherited-host-platform-1", UserID: userID, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 1_000, + RegionID: regionID, WithdrawalRef: "inherited-host-platform-ref-1", + }); err != nil { + t.Fatalf("first withdrawal must inherit and use previous-cycle Host policy: %v", err) + } + _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: appcode.Default, CommandID: "inherited-host-platform-2", UserID: userID, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 1_000, + RegionID: regionID, WithdrawalRef: "inherited-host-platform-ref-2", + }) + if !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) { + t.Fatalf("inherited Host policy platform limit must reject the second withdrawal, got %v", err) + } + requireWalletBalanceForApp(t, svc, appcode.Default, userID, ledger.AssetHostSalaryUSD, 9_000, 1_000) } func TestPointWithdrawalPlatformAllowedDaysUseExactUTCDate(t *testing.T) { @@ -2686,50 +2658,10 @@ func TestPointWithdrawalPlatformAllowedDaysUseExactUTCDate(t *testing.T) { } } -func TestPointWithdrawalAppGateWithoutMySQL(t *testing.T) { - repository := &fakePointGateRepository{} - svc := walletservice.New(repository) - - _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ - AppCode: "lalu", - CommandID: "cmd-fake-lalu-point-freeze", - UserID: 23011, - AssetType: ledger.AssetPoint, - GrossPointAmount: 1_000_000, - }) - if err == nil { - t.Fatal("Lalu POINT withdrawal must be rejected before repository call") - } - if repository.freezeSalaryCalls != 0 { - t.Fatalf("rejected POINT withdrawal must not call repository, got %d calls", repository.freezeSalaryCalls) - } - - receipt, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ - AppCode: "huwaa", - CommandID: "cmd-fake-huwaa-point-freeze", - UserID: 23012, - AssetType: ledger.AssetPoint, - GrossPointAmount: 1_000_000, - FeeBPS: 500, - RegionID: 25, - }) - if err != nil { - t.Fatalf("Huwaa POINT withdrawal should reach repository: %v", err) - } - if receipt.AssetType != ledger.AssetPoint || - receipt.FeePointAmount != 50_000 || - receipt.NetPointAmount != 950_000 || - repository.freezeSalaryCalls != 1 || - repository.lastFreezeSalaryAppFromContext != "huwaa" || - repository.lastFreezeSalary.AppCode != "huwaa" || - repository.lastFreezeSalary.SalaryAssetType != ledger.AssetPoint || - repository.lastFreezeSalary.SalaryUSDMinor != 1_000_000 { - t.Fatalf("Huwaa POINT withdrawal repository payload mismatch: receipt=%+v repo=%+v", receipt, repository) - } -} - -func TestFamiPointWithdrawalAllowsAdminConfiguredZeroFee(t *testing.T) { - repository := &fakePointGateRepository{} +func TestPointWithdrawalAllowsAdminConfiguredZeroFee(t *testing.T) { + repository := &fakePointGateRepository{pointWalletPolicy: ledger.PointWithdrawalRuntimeConfig{ + Found: true, PointsPerUSD: 200_000, MinimumPoints: 1_000_000, FeeBPS: 0, PolicyInstanceCode: "fake-zero-fee-policy", + }} svc := walletservice.New(repository) receipt, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "fami", CommandID: "cmd-fami-zero-fee", UserID: 23013, @@ -2737,14 +2669,14 @@ func TestFamiPointWithdrawalAllowsAdminConfiguredZeroFee(t *testing.T) { RegionID: 25, }) if err != nil { - t.Fatalf("zero-fee Fami point withdrawal failed: %v", err) + t.Fatalf("zero-fee point withdrawal failed: %v", err) } if receipt.FeePointAmount != 0 || receipt.NetPointAmount != 1_000_000 || receipt.PointsPerUSD != 200_000 || receipt.FeeBPS != 0 { t.Fatalf("zero-fee policy snapshot mismatch: %+v", receipt) } } -func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) { +func TestPointWithdrawalFreezeSettleReleaseIsTenantScoped(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetAssetBalanceForApp("huwaa", 22001, ledger.AssetPoint, 2_000_000) repository.SetAssetBalanceForApp("huwaa", 22002, ledger.AssetPoint, 2_000_000) @@ -2757,9 +2689,10 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) { UserID: 22001, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, + RegionID: 25, }) - if err == nil { - t.Fatal("POINT withdrawal must be rejected outside Huwaa") + if !xerr.IsCode(err, xerr.PermissionDenied) { + t.Fatalf("POINT withdrawal must not reuse another tenant's wallet policy, got %v", err) } requireWalletBalanceForApp(t, svc, "huwaa", 22001, ledger.AssetPoint, 2_000_000, 0) @@ -2787,7 +2720,10 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) { UserID: 22001, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, - FeeBPS: 500, + FeePointAmount: freeze.FeePointAmount, + NetPointAmount: freeze.NetPointAmount, + PointsPerUSD: freeze.PointsPerUSD, + FeeBPS: freeze.FeeBPS, OperatorUserID: 90001, WithdrawalApplicationID: "77", }) @@ -2799,7 +2735,7 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) { } requireWalletBalanceForApp(t, svc, "huwaa", 22001, ledger.AssetPoint, 1_000_000, 0) - if _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ + freezeForRelease, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "huwaa", CommandID: "cmd-huwaa-point-freeze-release", UserID: 22002, @@ -2808,7 +2744,8 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) { FeeBPS: 500, RegionID: 25, WithdrawalRef: "point-freeze-22002", - }); err != nil { + }) + if err != nil { t.Fatalf("FreezePointWithdrawal for release failed: %v", err) } release, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ @@ -2817,7 +2754,10 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) { UserID: 22002, AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, - FeeBPS: 500, + FeePointAmount: freezeForRelease.FeePointAmount, + NetPointAmount: freezeForRelease.NetPointAmount, + PointsPerUSD: freezeForRelease.PointsPerUSD, + FeeBPS: freezeForRelease.FeeBPS, WithdrawalApplicationID: "78", }) if err != nil { @@ -2883,19 +2823,24 @@ func TestPointWithdrawalPolicyLimitRollbackOnlyReleasesFailedApplicationReservat // gateway 创建申请失败时传回原 withdrawal_ref;计数和冻结余额在同一补偿事务中一起恢复。 if _, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "huwaa", CommandID: "point-limit-freeze-1:rollback", UserID: 22006, AssetType: ledger.AssetPoint, - GrossPointAmount: 1_000_000, FeeBPS: 500, WithdrawalRef: "retry-request-ref-changed", + GrossPointAmount: firstReceipt.GrossPointAmount, FeePointAmount: firstReceipt.FeePointAmount, + NetPointAmount: firstReceipt.NetPointAmount, PointsPerUSD: firstReceipt.PointsPerUSD, FeeBPS: firstReceipt.FeeBPS, + WithdrawalRef: "retry-request-ref-changed", }); err != nil { t.Fatalf("failed-application rollback must release reservation: %v", err) } if _, err := svc.FreezePointWithdrawal(context.Background(), first); !xerr.IsCode(err, xerr.IdempotencyConflict) { t.Fatalf("rolled-back freeze command must not replay a stale success receipt, got %v", err) } - if _, err := svc.FreezePointWithdrawal(context.Background(), second); err != nil { + secondReceipt, err := svc.FreezePointWithdrawal(context.Background(), second) + if err != nil { t.Fatalf("withdrawal after failed-application rollback should reuse the slot: %v", err) } if _, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ AppCode: "huwaa", CommandID: "point-limit-manual-reject", UserID: 22006, AssetType: ledger.AssetPoint, - GrossPointAmount: 1_000_000, FeeBPS: 500, WithdrawalApplicationID: "point-application-2", + GrossPointAmount: secondReceipt.GrossPointAmount, FeePointAmount: secondReceipt.FeePointAmount, + NetPointAmount: secondReceipt.NetPointAmount, PointsPerUSD: secondReceipt.PointsPerUSD, FeeBPS: secondReceipt.FeeBPS, + WithdrawalApplicationID: "point-application-2", }); err != nil { t.Fatalf("manual rejection release failed: %v", err) } @@ -2914,7 +2859,7 @@ func TestSalaryWithdrawalTerminalReusesLegacyCommandAcrossReviewerRetryAndReject if _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ AppCode: "lalu", CommandID: "salary-withdrawal:legacy-freeze", UserID: 22003, - SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, WithdrawalRef: "legacy-freeze", + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, WithdrawalRef: "legacy-freeze", RegionID: 31, }); err != nil { t.Fatalf("freeze salary withdrawal failed: %v", err) } @@ -2958,6 +2903,43 @@ func TestSalaryWithdrawalTerminalReusesLegacyCommandAcrossReviewerRetryAndReject } } +func TestPointDiamondHistoryHidesInternalSettlementEntry(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetAssetBalanceForApp("fami", 22006, ledger.AssetPointDiamond, 1_000_000) + repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{ + PolicyID: 222, PolicyVersion: 222, Name: "point diamond withdrawal history", PolicyType: ledger.HostPolicyTypePointDiamond, + RegionID: 25, PointDiamondsPerUSD: 300_000, CoinsPerUSD: 80_000, MinimumWithdrawUSDMinor: 100, WithdrawFeeBPS: 500, + }) + svc := walletservice.New(repository) + freeze, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ + AppCode: "fami", CommandID: "point-diamond-history-freeze", UserID: 22006, + AssetType: ledger.AssetPointDiamond, GrossUSDMinor: 100, RegionID: 25, WithdrawalRef: "point-diamond-history-ref", + }) + if err != nil { + t.Fatalf("freeze POINT_DIAMOND withdrawal: %v", err) + } + if freeze.GrossPointAmount != 300_000 || freeze.FeePointAmount != 15_000 || freeze.NetPointAmount != 285_000 || freeze.GrossUSDMinor != 100 || freeze.FeeUSDMinor != 5 || freeze.NetUSDMinor != 95 { + t.Fatalf("POINT_DIAMOND freeze precision snapshot mismatch: %+v", freeze) + } + if _, err := svc.SettlePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ + AppCode: "fami", CommandID: "point-diamond-history-settle", UserID: 22006, AssetType: ledger.AssetPointDiamond, + GrossPointAmount: freeze.GrossPointAmount, FeePointAmount: freeze.FeePointAmount, NetPointAmount: freeze.NetPointAmount, + PointsPerUSD: freeze.PointsPerUSD, FeeBPS: freeze.FeeBPS, OperatorUserID: 9, Reason: "approved", WithdrawalApplicationID: "906", + }); err != nil { + t.Fatalf("settle POINT_DIAMOND withdrawal: %v", err) + } + + items, total, err := svc.ListWalletTransactions(appcode.WithContext(context.Background(), "fami"), ledger.ListWalletTransactionsQuery{ + AppCode: "fami", UserID: 22006, AssetType: ledger.AssetPointDiamond, Page: 1, PageSize: 20, + }) + if err != nil { + t.Fatalf("list POINT_DIAMOND withdrawal history: %v", err) + } + if total != 1 || len(items) != 1 || items[0].EntryRole != "platform_withdrawal" || items[0].AvailableDelta != -300_000 { + t.Fatalf("history must keep freeze and hide zero-available settle with matching total: total=%d items=%+v", total, items) + } +} + func TestPointWithdrawalTerminalReusesLegacyCommandWithNormalizedPolicySnapshot(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetAssetBalanceForApp("huwaa", 22005, ledger.AssetPoint, 2_000_000) @@ -3007,7 +2989,7 @@ func TestSalaryWithdrawalTerminalSerializesConcurrentOppositeDecisions(t *testin svc := walletservice.New(repository) if _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ AppCode: "lalu", CommandID: "salary-withdrawal:concurrent-freeze", UserID: 22004, - SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, WithdrawalRef: "concurrent-freeze", + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, WithdrawalRef: "concurrent-freeze", RegionID: 31, }); err != nil { t.Fatalf("freeze salary withdrawal failed: %v", err) } @@ -3099,7 +3081,7 @@ func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) { _, err = svc.TransferPointToCoinSeller(context.Background(), ledger.PointToCoinSellerCommand{ AppCode: "fami", CommandID: "cmd-fami-point-seller-limit", SourceUserID: 22501, - SellerUserID: 22502, PointAmount: 100_000, SourceCountryCode: "SA", + SellerUserID: 22502, PointAmount: 1_000_000, SourceCountryCode: "SA", RegionID: 26, }) if !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) { @@ -3108,12 +3090,55 @@ func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) { requireWalletBalanceForApp(t, svc, "fami", 22501, ledger.AssetPoint, 1_000_000, 0) } -// TestExchangePointToCoinUsesPublishedPointPolicy 验证本人兑换复用已发布 points_per_usd,并且重试不重复扣款。 -func TestExchangePointToCoinUsesPublishedPointPolicy(t *testing.T) { +func TestTransferPointDiamondToCoinSellerUsesGrossUSDAndIsolatesLimitByAsset(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPoint, 100_000) + repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPointDiamond, 30_003_000) + repository.SetPointWithdrawalCoinSellerConfig("fami", 22522, 100_000, 92_000, []string{"SA"}, "active", 1) + repository.SeedPointWalletPolicy("fami", "legacy-point-for-limit-isolation", 27, "active", 100_000, 1, 0) + repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{ + PolicyID: 226, PolicyVersion: 226, Name: "point diamond seller policy", PolicyType: ledger.HostPolicyTypePointDiamond, + RegionID: 27, PointDiamondsPerUSD: 300_000, CoinsPerUSD: 80_000, MinimumWithdrawUSDMinor: 100, + WithdrawFeeBPS: 333, CoinSellerWithdrawalLimitPeriod: "month", CoinSellerWithdrawalLimitCount: 1, + }) + svc := walletservice.New(repository) + + if _, err := svc.TransferPointToCoinSeller(context.Background(), ledger.PointToCoinSellerCommand{ + AppCode: "fami", CommandID: "cmd-limit-isolation-legacy-point", SourceUserID: 22521, SellerUserID: 22522, + PointAmount: 100_000, SourceAssetType: ledger.AssetPoint, SourceCountryCode: "SA", RegionID: 27, + }); err != nil { + t.Fatalf("legacy POINT seller transfer: %v", err) + } + command := ledger.PointToCoinSellerCommand{ + AppCode: "fami", CommandID: "cmd-point-diamond-seller", SourceUserID: 22521, SellerUserID: 22522, + SourceAssetType: ledger.AssetPointDiamond, GrossUSDMinor: 10_001, SourceCountryCode: "SA", RegionID: 27, + } + receipt, err := svc.TransferPointToCoinSeller(context.Background(), command) + if err != nil { + t.Fatalf("POINT_DIAMOND seller transfer must have an independent limit counter: %v", err) + } + if receipt.PointAmount != 30_003_000 || receipt.SellerCoinAmount != 7_734_400 || receipt.GrossUSDMinor != 10_001 || receipt.FeeUSDMinor != 333 || receipt.NetUSDMinor != 9_668 || receipt.WithdrawFeeBPS != 333 || receipt.PolicyID != 226 || receipt.PolicyVersion != 226 || receipt.SourceAssetType != ledger.AssetPointDiamond { + t.Fatalf("POINT_DIAMOND seller gross/fee/net snapshot mismatch: %+v", receipt) + } + replay, err := svc.TransferPointToCoinSeller(context.Background(), command) + if err != nil || replay.TransactionID != receipt.TransactionID || replay.SellerCoinAmount != receipt.SellerCoinAmount { + t.Fatalf("POINT_DIAMOND seller replay mismatch: replay=%+v err=%v", replay, err) + } + requireWalletBalanceForApp(t, svc, "fami", 22521, ledger.AssetPointDiamond, 0, 0) + requireWalletBalanceForApp(t, svc, "fami", 22522, ledger.AssetCoinSellerCoin, 7_826_400, 0) + legacyCounters := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ? AND source_asset_type = ?", "fami", int64(22521), ledger.AssetPoint) + diamondCounters := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ? AND source_asset_type = ?", "fami", int64(22521), ledger.AssetPointDiamond) + if legacyCounters != 1 || diamondCounters != 1 { + t.Fatalf("POINT and POINT_DIAMOND must own separate withdrawal counters: point=%d point_diamond=%d", legacyCounters, diamondCounters) + } +} + +// TestExchangePointToCoinUsesWalletPolicy 验证本人兑换继续读取原 wallet 政策,并且重试不重复扣款。 +func TestExchangePointToCoinUsesWalletPolicy(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetAssetBalanceForApp("fami", 22511, ledger.AssetPoint, 2_500_000) repository.SetAssetBalanceForApp("fami", 22511, ledger.AssetCoin, 10_000) - repository.SeedWalletPolicyInstanceWithRates("fami", "fami-point-exchange", 24, "active", 1_000_000, 0, 1_200_000, 0) + repository.SeedPointWalletPolicy("fami", "fami-point-exchange", 24, "active", 1_200_000, 1_000_000, 0) svc := walletservice.New(repository) runtimeConfig, err := svc.GetPointWithdrawalConfig(appcode.WithContext(context.Background(), "fami"), "fami", 24, time.Now().UTC().UnixMilli()) @@ -3643,6 +3668,41 @@ func TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly(t *testing.T) { if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35012), ledger.AssetCoinSellerCoin, int64(40500)); got != 1 { t.Fatalf("host salary transfer must credit target seller coin seller wallet once, got %d", got) } + if got := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ? AND channel = ?", appcode.Default, int64(35011), "coin_seller"); got != 0 { + t.Fatalf("host salary transfer without Host policy must not consume a withdrawal counter, got %d", got) + } +} + +func TestTransferHostSalaryToCoinSellerUsesPublishedPolicyLimit(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + const sourceUserID int64 = 35021 + const sellerUserID int64 = 35022 + const regionID int64 = 7003 + repository.SetAssetBalanceForApp(appcode.Default, sourceUserID, ledger.AssetHostSalaryUSD, 10_000) + repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, regionID, 10, 10_000, 81_000) + seedPointWithdrawalSalaryPolicy(repository, appcode.Default, 350, regionID, "month", 1, "month", 0) + + first := ledger.SalaryTransferToCoinSellerCommand{ + AppCode: appcode.Default, CommandID: "host-salary-policy-seller-1", + SourceUserID: sourceUserID, SellerUserID: sellerUserID, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 50, + RegionID: regionID, Reason: "host salary transfer with policy", + } + if _, err := svc.TransferSalaryToCoinSeller(context.Background(), first); err != nil { + t.Fatalf("first Host salary transfer within published policy limit failed: %v", err) + } + second := first + second.CommandID = "host-salary-policy-seller-2" + _, err := svc.TransferSalaryToCoinSeller(context.Background(), second) + if !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) { + t.Fatalf("published Host policy must reject the second coin-seller transfer, got %v", err) + } + requireWalletBalanceForApp(t, svc, appcode.Default, sourceUserID, ledger.AssetHostSalaryUSD, 9_950, 0) + requireWalletBalanceForApp(t, svc, appcode.Default, sellerUserID, ledger.AssetCoinSellerCoin, 40_500, 0) + if got := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ? AND channel = ? AND withdrawal_count = 1", appcode.Default, sourceUserID, "coin_seller"); got != 1 { + t.Fatalf("published Host policy must persist exactly one consumed coin-seller slot, got %d", got) + } } // TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets 验证同一用户同时具备主播/代理/BD/Admin 和币商身份时, @@ -4857,6 +4917,30 @@ func TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit(t *testing.T) { if got := repository.CountRows("wallet_outbox", "command_id = ? AND event_type = ?", "cmd-send-bag-spark", "UserResourceChanged"); got != 1 { t.Fatalf("bag gift must publish resource change event, got %d", got) } + + _, err = svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-send-bag-spark-policyless-host", + RoomID: "room-bag-policyless-host", + SenderUserID: 61001, + TargetUserID: 61003, + TargetIsHost: true, + TargetHostRegionID: 9911, + GiftID: "bag-spark", + GiftCount: 1, + EntitlementID: grant.Items[0].EntitlementID, + ChargeSource: "bag", + }) + if !xerr.IsCode(err, xerr.PermissionDenied) { + t.Fatalf("bag gift to a Host without published policy must fail closed, got %v", err) + } + // 背包库存锁和 consume 都位于 Host 政策/倍率预解析之后;失败不能吃掉最后一个礼物。 + _, quantity, remaining = repository.ActiveResourceEntitlement(61001, giftResource.ResourceID) + if quantity != 3 || remaining != 1 { + t.Fatalf("policyless Host failure mutated bag inventory: quantity=%d remaining=%d", quantity, remaining) + } + if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-send-bag-spark-policyless-host"); got != 0 { + t.Fatalf("policyless bag gift must not write transaction, got %d", got) + } } func TestGiftConfigCoercesDiamondChargeToCoinAndEffectiveRange(t *testing.T) { @@ -7909,7 +7993,8 @@ func vipLevelByNumber(levels []ledger.VipLevel, target int32) *ledger.VipLevel { return nil } -// TestWalletOverviewDisablesDiamondExchange 验证普通 DIAMOND 钱包停用后钱包概览不再返回钻石余额或兑换入口。 +// TestWalletOverviewDisablesDiamondExchange 验证普通 DIAMOND 钱包停用后不再返回钻石余额或兑换入口, +// POINT 仍作为独立资产保留在概览中。 func TestWalletOverviewDisablesDiamondExchange(t *testing.T) { repository := mysqltest.NewRepository(t) repository.SetBalance(52001, 700) @@ -7922,9 +8007,14 @@ func TestWalletOverviewDisablesDiamondExchange(t *testing.T) { if !overview.FeatureFlags.RechargeEnabled || overview.FeatureFlags.DiamondExchangeEnabled { t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags) } - if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, "DIAMOND") != 0 { + if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, ledger.AssetPoint) != 0 { t.Fatalf("overview balances mismatch: %+v", overview.Balances) } + for _, balance := range overview.Balances { + if balance.AssetType == "DIAMOND" { + t.Fatalf("retired DIAMOND asset must not be returned: %+v", overview.Balances) + } + } rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001) if err != nil { t.Fatalf("GetDiamondExchangeConfig failed: %v", err) @@ -8567,6 +8657,7 @@ type fakePointGateRepository struct { freezeSalaryCalls int lastFreezeSalary ledger.SalaryWithdrawalCommand lastFreezeSalaryAppFromContext string + pointWalletPolicy ledger.PointWithdrawalRuntimeConfig } func (f *fakePointGateRepository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) { @@ -8588,21 +8679,32 @@ func (f *fakePointGateRepository) CreditTaskReward(ctx context.Context, command func (f *fakePointGateRepository) FreezeSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) { f.freezeSalaryCalls++ + if !f.pointWalletPolicy.Found { + return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.PermissionDenied, "point wallet policy is not configured") + } + if command.SalaryUSDMinor < f.pointWalletPolicy.MinimumPoints { + return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InvalidArgument, "point withdrawal amount is below configured minimum") + } + command.PointsPerUSD = f.pointWalletPolicy.PointsPerUSD + command.PointWithdrawFeeBPS = f.pointWalletPolicy.FeeBPS + command.PointFeeAmount = command.SalaryUSDMinor * f.pointWalletPolicy.FeeBPS / 10_000 + command.PointNetAmount = command.SalaryUSDMinor - command.PointFeeAmount f.lastFreezeSalary = command f.lastFreezeSalaryAppFromContext = appcode.FromContext(ctx) return ledger.SalaryWithdrawalReceipt{ - TransactionID: "fake-point-freeze-tx", - UserID: command.UserID, - SalaryAssetType: command.SalaryAssetType, - SalaryUSDMinor: command.SalaryUSDMinor, - PointFeeAmount: command.PointFeeAmount, - PointNetAmount: command.PointNetAmount, - PointsPerUSD: command.PointsPerUSD, - PointWithdrawFeeBPS: command.PointWithdrawFeeBPS, - AvailableAfter: 0, - FrozenAfter: command.SalaryUSDMinor, - Version: 1, - CreatedAtMS: 1700000000000, + TransactionID: "fake-point-freeze-tx", + UserID: command.UserID, + SalaryAssetType: command.SalaryAssetType, + SalaryUSDMinor: command.SalaryUSDMinor, + PointFeeAmount: command.PointFeeAmount, + PointNetAmount: command.PointNetAmount, + PointsPerUSD: command.PointsPerUSD, + PointWithdrawFeeBPS: command.PointWithdrawFeeBPS, + PointPolicyInstanceCode: f.pointWalletPolicy.PolicyInstanceCode, + AvailableAfter: 0, + FrozenAfter: command.SalaryUSDMinor, + Version: 1, + CreatedAtMS: 1700000000000, }, nil } diff --git a/services/wallet-service/internal/storage/mysql/account.go b/services/wallet-service/internal/storage/mysql/account.go index c0c4d273..477ce2dc 100644 --- a/services/wallet-service/internal/storage/mysql/account.go +++ b/services/wallet-service/internal/storage/mysql/account.go @@ -39,6 +39,24 @@ func walletAccountLockKey(userID int64, assetType string) string { } func (r *Repository) lockAccountsOrdered(ctx context.Context, tx *sql.Tx, requests []walletAccountLockRequest, nowMs int64) (map[string]*walletAccountState, error) { + ordered, err := orderedWalletAccountLockRequests(requests) + if err != nil { + return nil, err + } + states := make(map[string]*walletAccountState, len(ordered)) + for _, request := range ordered { + account, err := r.lockAccount(ctx, tx, request.UserID, request.AssetType, request.CreateIfMissing, nowMs) + if err != nil { + return nil, err + } + states[walletAccountLockKey(request.UserID, request.AssetType)] = &walletAccountState{account: account} + } + return states, nil +} + +// orderedWalletAccountLockRequests 是所有多账户账变共用的稳定锁序。资产类型优先、用户 ID 次之, +// 保证送礼(可能 COIN -> POINT_DIAMOND)和积分兑币(POINT_DIAMOND -> COIN)方向相反时也不会反向拿锁。 +func orderedWalletAccountLockRequests(requests []walletAccountLockRequest) ([]walletAccountLockRequest, error) { merged := make(map[string]walletAccountLockRequest, len(requests)) for _, request := range requests { request.AssetType = strings.ToUpper(strings.TrimSpace(request.AssetType)) @@ -69,15 +87,7 @@ func (r *Repository) lockAccountsOrdered(ctx context.Context, tx *sql.Tx, reques return ordered[i].AssetType < ordered[j].AssetType }) - states := make(map[string]*walletAccountState, len(ordered)) - for _, request := range ordered { - account, err := r.lockAccount(ctx, tx, request.UserID, request.AssetType, request.CreateIfMissing, nowMs) - if err != nil { - return nil, err - } - states[walletAccountLockKey(request.UserID, request.AssetType)] = &walletAccountState{account: account} - } - return states, nil + return ordered, nil } func (r *Repository) applyTrackedAccountDelta(ctx context.Context, tx *sql.Tx, state *walletAccountState, availableDelta int64, frozenDelta int64, nowMs int64) (walletAccount, error) { diff --git a/services/wallet-service/internal/storage/mysql/agency_host_gift_stats.go b/services/wallet-service/internal/storage/mysql/agency_host_gift_stats.go index 3e0a0525..c68666e9 100644 --- a/services/wallet-service/internal/storage/mysql/agency_host_gift_stats.go +++ b/services/wallet-service/internal/storage/mysql/agency_host_gift_stats.go @@ -4,51 +4,155 @@ import ( "context" "fmt" "strings" + "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) -// GetAgencyHostGiftStats 只从送礼交易的不可变快照统计礼物收益;新政策写 host_point_added。 -// Fami 未发布收益政策的历史/过渡区域仍写 host_period_diamond_added,且它与新政策都是先按 -// 礼物类型倍率得到的主播实际收益,因此 Fami 可在政策切换日安全求和;其他 App 仍保持 POINT 口径。 -// 任务奖励、提现释放和 Agency 分成不在礼物交易快照内,不会混入公会收礼口径。 +// GetAgencyHostGiftStats 用固定数量的 GROUP BY 查询同时返回 Agency 汇总和每个 Host 的统计。 +// POINT_DIAMOND 投影明确保留 host_base_amount 与 agency_amount:Host 列表只取前者,Agency +// owner 再走 agency/time 索引汇总后者,因此自营主播的一笔 100+20 会稳定呈现为 Host 100、Agency 20。 func (r *Repository) GetAgencyHostGiftStats(ctx context.Context, query ledger.AgencyHostGiftStatsQuery) (ledger.AgencyHostGiftStats, error) { if r == nil || r.db == nil { return ledger.AgencyHostGiftStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } - if len(query.HostUserIDs) == 0 { + if len(query.HostUserIDs) == 0 && query.AgencyUserID == 0 { return ledger.AgencyHostGiftStats{}, nil } - legacyIncomeSQL := "0" - if appcode.FromContext(ctx) == "fami" { - // 该片段是固定 SQL 常量,不包含用户输入;非 Fami 不解析旧字段,避免改变其他 App 的收益单位。 - legacyIncomeSQL = "CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_period_diamond_added')), '0') AS SIGNED)" + stats := ledger.AgencyHostGiftStats{HostStats: make([]ledger.AgencyHostRevenueStats, 0, len(query.HostUserIDs))} + hostIndexes := make(map[int64]int, len(query.HostUserIDs)) + for _, hostUserID := range query.HostUserIDs { + if _, exists := hostIndexes[hostUserID]; exists { + continue + } + hostIndexes[hostUserID] = len(stats.HostStats) + stats.HostStats = append(stats.HostStats, ledger.AgencyHostRevenueStats{HostUserID: hostUserID}) } - placeholders := make([]string, len(query.HostUserIDs)) - args := make([]any, 0, len(query.HostUserIDs)+5) - args = append(args, appcode.FromContext(ctx), bizTypeGiftDebit, bizTypeDirectGiftDebit, query.StartAtMS, query.EndAtMS) - for index, userID := range query.HostUserIDs { - placeholders[index] = "?" - args = append(args, userID) - } - querySQL := fmt.Sprintf(` + if len(stats.HostStats) > 0 { + startCycle := time.UnixMilli(query.StartAtMS).UTC().Format("2006-01") + endCycle := time.UnixMilli(query.EndAtMS - 1).UTC().Format("2006-01") + placeholders := make([]string, len(stats.HostStats)) + args := make([]any, 0, len(stats.HostStats)*2+10) + args = append(args, appcode.FromContext(ctx)) + for index, host := range stats.HostStats { + placeholders[index] = "?" + args = append(args, host.HostUserID) + } + args = append(args, startCycle, endCycle, query.StartAtMS, query.EndAtMS) + args = append(args, appcode.FromContext(ctx)) + for _, host := range stats.HostStats { + args = append(args, host.HostUserID) + } + args = append(args, query.StartAtMS, query.EndAtMS) + querySQL := fmt.Sprintf(` SELECT - COALESCE(SUM( - CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_point_added')), '0') AS SIGNED) + - %s - ), 0), - COUNT(DISTINCT CASE - WHEN CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_point_added')), '0') AS SIGNED) + - %s > 0 - THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) - END) - FROM wallet_transactions - WHERE app_code = ? AND biz_type IN (?, ?) AND status = 'succeeded' - AND created_at_ms >= ? AND created_at_ms < ? - AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.target_user_id')) AS SIGNED) IN (%s)`, legacyIncomeSQL, legacyIncomeSQL, strings.Join(placeholders, ",")) - var stats ledger.AgencyHostGiftStats - err := r.db.QueryRowContext(ctx, querySQL, args...).Scan(&stats.GiftIncome, &stats.GiftedHostCount) - return stats, err + host_user_id, + COALESCE(SUM(CASE WHEN income_type = 'SALARY_DIAMOND' THEN host_base_amount ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN income_type = 'POINT_DIAMOND' THEN host_base_amount ELSE 0 END), 0), + COUNT(DISTINCT CASE WHEN sender_user_id > 0 THEN sender_user_id END), + MAX(CASE WHEN host_base_amount > 0 THEN 1 ELSE 0 END) + FROM ( + SELECT diamond_delta AS host_base_amount, user_id AS host_user_id, sender_user_id, 'SALARY_DIAMOND' AS income_type + FROM host_period_diamond_entries FORCE INDEX (idx_host_period_diamond_entries_user_cycle) + WHERE app_code = ? AND user_id IN (%s) + AND cycle_key BETWEEN ? AND ? + AND created_at_ms >= ? AND created_at_ms < ? + UNION ALL + SELECT host_base_amount, host_user_id, sender_user_id, 'POINT_DIAMOND' AS income_type + FROM point_diamond_gift_income_entries FORCE INDEX (idx_point_diamond_gift_host_time) + WHERE app_code = ? AND host_user_id IN (%s) + AND created_at_ms >= ? AND created_at_ms < ? + ) AS gift_income + GROUP BY host_user_id`, strings.Join(placeholders, ","), strings.Join(placeholders, ",")) + rows, err := r.db.QueryContext(ctx, querySQL, args...) + if err != nil { + return ledger.AgencyHostGiftStats{}, err + } + for rows.Next() { + var hostUserID int64 + var hostStats ledger.HostRevenueStats + var gifted int64 + if err := rows.Scan(&hostUserID, &hostStats.DiamondEarnings, &hostStats.PointDiamondEarnings, &hostStats.GiftSenders, &gifted); err != nil { + rows.Close() + return ledger.AgencyHostGiftStats{}, err + } + index, exists := hostIndexes[hostUserID] + if !exists { + continue + } + stats.HostStats[index].Stats = hostStats + stats.GiftIncome += hostStats.DiamondEarnings + stats.PointDiamondHostIncome += hostStats.PointDiamondEarnings + stats.GiftedHostCount += gifted + } + if err := rows.Close(); err != nil { + return ledger.AgencyHostGiftStats{}, err + } + if err := rows.Err(); err != nil { + return ledger.AgencyHostGiftStats{}, err + } + + transferArgs := make([]any, 0, len(stats.HostStats)+11) + transferArgs = append(transferArgs, ledger.AssetPoint, ledger.AssetPointDiamond, appcode.FromContext(ctx)) + for _, host := range stats.HostStats { + transferArgs = append(transferArgs, host.HostUserID) + } + transferArgs = append(transferArgs, + ledger.AssetPoint, ledger.AssetPointDiamond, query.StartAtMS, query.EndAtMS, + bizTypePointExchangeToCoin, bizTypePointTransferToCoinSeller, bizTypeSalaryWithdrawalFreeze, + ) + transferSQL := fmt.Sprintf(` + SELECT + e.user_id, + COALESCE(SUM(CASE WHEN e.asset_type = ? THEN -e.available_delta ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN e.asset_type = ? THEN -e.available_delta ELSE 0 END), 0) + FROM wallet_entries e FORCE INDEX (idx_wallet_entries_asset_user_time) + INNER JOIN wallet_transactions t + ON t.app_code = e.app_code AND t.transaction_id = e.transaction_id + WHERE e.app_code = ? AND e.user_id IN (%s) AND e.asset_type IN (?, ?) + AND e.created_at_ms >= ? AND e.created_at_ms < ? + AND e.available_delta < 0 + AND t.biz_type IN (?, ?, ?) + GROUP BY e.user_id`, strings.Join(placeholders, ",")) + transferRows, err := r.db.QueryContext(ctx, transferSQL, transferArgs...) + if err != nil { + return ledger.AgencyHostGiftStats{}, err + } + for transferRows.Next() { + var hostUserID int64 + var pointExchanged int64 + var pointDiamondExchanged int64 + if err := transferRows.Scan(&hostUserID, &pointExchanged, &pointDiamondExchanged); err != nil { + transferRows.Close() + return ledger.AgencyHostGiftStats{}, err + } + if index, exists := hostIndexes[hostUserID]; exists { + stats.HostStats[index].Stats.DiamondExchanged = pointExchanged + stats.HostStats[index].Stats.PointDiamondExchanged = pointDiamondExchanged + } + } + if err := transferRows.Close(); err != nil { + return ledger.AgencyHostGiftStats{}, err + } + if err := transferRows.Err(); err != nil { + return ledger.AgencyHostGiftStats{}, err + } + } + if query.AgencyUserID > 0 { + // 该索引把 app/agency/time 放在最左侧;不扫描 wallet_entries JSON,也不受 Host 与 Agency + // 同一用户时合并为一条 POINT_DIAMOND 分录的影响。 + err := r.db.QueryRowContext(ctx, ` + SELECT COALESCE(SUM(agency_amount), 0) + FROM point_diamond_gift_income_entries FORCE INDEX (idx_point_diamond_gift_agency_time) + WHERE app_code = ? AND agency_user_id = ? + AND created_at_ms >= ? AND created_at_ms < ?`, + appcode.FromContext(ctx), query.AgencyUserID, query.StartAtMS, query.EndAtMS, + ).Scan(&stats.PointDiamondAgencyShareIncome) + if err != nil { + return ledger.AgencyHostGiftStats{}, err + } + } + return stats, nil } diff --git a/services/wallet-service/internal/storage/mysql/agency_point_share.go b/services/wallet-service/internal/storage/mysql/agency_point_share.go deleted file mode 100644 index 6a77166f..00000000 --- a/services/wallet-service/internal/storage/mysql/agency_point_share.go +++ /dev/null @@ -1,28 +0,0 @@ -package mysql - -import ( - "context" - "database/sql" - - "hyapp/pkg/appcode" -) - -// insertAgencyPointShare 把“当时属于哪个 Agency”与实际入账金额固化在同一送礼事务内; -// 后续主播转会时统计仍按这条快照归属,不能用当前成员关系重算历史收入。 -func (r *Repository) insertAgencyPointShare(ctx context.Context, tx *sql.Tx, transactionID string, metadata giftMetadata, nowMS int64) error { - if metadata.AgencyPointAdded <= 0 || metadata.TargetAgencyOwnerUserID <= 0 { - return nil - } - _, err := tx.ExecContext(ctx, ` - INSERT INTO agency_point_share_entries ( - app_code, transaction_id, host_user_id, agency_owner_user_id, sender_user_id, - room_id, point_delta, ratio_ppm, share_base_type, share_base_amount, - policy_instance_code, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - appcode.FromContext(ctx), transactionID, metadata.TargetUserID, metadata.TargetAgencyOwnerUserID, - metadata.SenderUserID, metadata.RoomID, metadata.AgencyPointAdded, metadata.AgencyPointRatioPPM, - metadata.AgencyPointShareBase, metadata.AgencyPointShareBaseAmount, - metadata.HostPointPolicyInstanceCode, nowMS, - ) - return err -} diff --git a/services/wallet-service/internal/storage/mysql/agency_point_share_stats.go b/services/wallet-service/internal/storage/mysql/agency_point_share_stats.go deleted file mode 100644 index 39ae7404..00000000 --- a/services/wallet-service/internal/storage/mysql/agency_point_share_stats.go +++ /dev/null @@ -1,25 +0,0 @@ -package mysql - -import ( - "context" - - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" -) - -// GetAgencyPointShareStats 按入账时归属快照统计,不受主播之后转会或被移除影响。 -func (r *Repository) GetAgencyPointShareStats(ctx context.Context, query ledger.AgencyPointShareStatsQuery) (ledger.AgencyPointShareStats, error) { - if r == nil || r.db == nil { - return ledger.AgencyPointShareStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") - } - var stats ledger.AgencyPointShareStats - err := r.db.QueryRowContext(ctx, ` - SELECT COALESCE(SUM(point_delta), 0), COUNT(DISTINCT host_user_id) - FROM agency_point_share_entries - WHERE app_code = ? AND agency_owner_user_id = ? - AND created_at_ms >= ? AND created_at_ms < ?`, - appcode.FromContext(ctx), query.AgencyOwnerUserID, query.StartAtMS, query.EndAtMS, - ).Scan(&stats.ShareIncome, &stats.GiftedHostCount) - return stats, err -} diff --git a/services/wallet-service/internal/storage/mysql/agency_point_share_stats_test.go b/services/wallet-service/internal/storage/mysql/agency_point_share_stats_test.go deleted file mode 100644 index 4d0e0b2f..00000000 --- a/services/wallet-service/internal/storage/mysql/agency_point_share_stats_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package mysql - -import ( - "context" - "regexp" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "hyapp/pkg/appcode" - "hyapp/services/wallet-service/internal/domain/ledger" -) - -func TestGetAgencyPointShareStatsUsesOwnershipSnapshot(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("create sqlmock: %v", err) - } - defer db.Close() - mock.ExpectQuery(regexp.QuoteMeta(` - SELECT COALESCE(SUM(point_delta), 0), COUNT(DISTINCT host_user_id) - FROM agency_point_share_entries - WHERE app_code = ? AND agency_owner_user_id = ? - AND created_at_ms >= ? AND created_at_ms < ?`)). - WithArgs("fami", int64(73), int64(1000), int64(2000)). - WillReturnRows(sqlmock.NewRows([]string{"share_income", "gifted_hosts"}).AddRow(int64(2400), int64(3))) - - repo := &Repository{db: db} - stats, err := repo.GetAgencyPointShareStats(appcode.WithContext(context.Background(), "fami"), ledger.AgencyPointShareStatsQuery{ - AgencyOwnerUserID: 73, StartAtMS: 1000, EndAtMS: 2000, - }) - if err != nil { - t.Fatalf("GetAgencyPointShareStats failed: %v", err) - } - if stats.ShareIncome != 2400 || stats.GiftedHostCount != 3 { - t.Fatalf("unexpected agency stats: %+v", stats) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("unmet SQL expectations: %v", err) - } -} diff --git a/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go b/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go index 4cd22ff6..e6663d57 100644 --- a/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go +++ b/services/wallet-service/internal/storage/mysql/coin_seller_ledger.go @@ -524,6 +524,13 @@ func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command led if err != nil { return ledger.SalaryTransferToCoinSellerReceipt{}, err } + if ledger.UsesHostAgencySalaryPolicy(command.SalaryAssetType) { + // Host/Agency 工资转币商和平台提现共享后台 Host 政策;资产来源决定规则,不按应用名分支。 + // 未发布本月及紧邻上月政策时保留旧工资余额的无限次转出能力;已发布政策仍正常限制。 + if err := r.consumeHostPolicyWithdrawalLimitForPolicyType(ctx, tx, command.SourceUserID, command.RegionID, pointWithdrawalChannelCoinSeller, command.CommandID, "", nowMs, command.SalaryAssetType, "", allowLegacyWithoutHostPolicy); err != nil { + return ledger.SalaryTransferToCoinSellerReceipt{}, err + } + } transactionID := transactionID(command.AppCode, command.CommandID) sourceAfter := source.AvailableAmount - command.SalaryUSDMinor diff --git a/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go b/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go index 39d5e618..ba301569 100644 --- a/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go @@ -119,92 +119,49 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb } type resolvedHostDiamondRatio struct { - ratio giftDiamondRatioSnapshot - effectiveRegionID int64 + giftTypeRatio giftDiamondRatioSnapshot + giftTypeRatioRegionID int64 + coefficient hostPeriodDiamondCoefficientSnapshot + coefficientFound bool } - // 同一批目标可能跨主播区域。POINT 新政策与旧周期钻石都必须消费相同的 owner 比例解析器; - // 以 region_id 缓存可避免为每个 target 重复查询,同时保留区域缺失时回退全局配置的历史行为。 + // 同一批目标可能跨主播区域。礼物类型倍率与 Host 政策系数都按 region_id 缓存, + // 既避免为每个 target 重复查询,也确保不同区域不会误用第一位主播的配置。 hostRatiosByRegion := make(map[int64]resolvedHostDiamondRatio) resolveHostRatio := func(regionID int64) (resolvedHostDiamondRatio, error) { if resolved, exists := hostRatiosByRegion[regionID]; exists { return resolved, nil } - ratio, effectiveRegionID, resolveErr := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, regionID, giftConfig.GiftTypeCode) + giftTypeRatio, effectiveRegionID, resolveErr := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, regionID, giftConfig.GiftTypeCode) if resolveErr != nil { return resolvedHostDiamondRatio{}, resolveErr } - resolved := resolvedHostDiamondRatio{ratio: ratio, effectiveRegionID: effectiveRegionID} + diamondCoefficient := hostPeriodDiamondCoefficientSnapshot{} + resolvedCoefficient, found, resolveErr := r.resolveHostPeriodDiamondCoefficient(ctx, tx, regionID, hostPeriodCycleKeyFromMS(nowMs)) + if resolveErr != nil { + return resolvedHostDiamondRatio{}, resolveErr + } + if !found { + // 批量命令在构造账户锁和扣 sender 前解析所有主播;任一 Host 缺政策则整批原子失败。 + return resolvedHostDiamondRatio{}, xerr.New(xerr.PermissionDenied, "host income policy is not configured") + } + diamondCoefficient = resolvedCoefficient + resolved := resolvedHostDiamondRatio{ + giftTypeRatio: giftTypeRatio, + giftTypeRatioRegionID: effectiveRegionID, + coefficient: diamondCoefficient, + coefficientFound: found, + } hostRatiosByRegion[regionID] = resolved return resolved, nil } - - type targetHostPointPolicy struct { - policy walletPolicyInstance - amount int64 - userID int64 - giftRatioPercent string - giftRatioPPM int64 - giftRatioRegionID int64 - agencyAmount int64 - agencyShareBaseAmount int64 - agencyOwnerUserID int64 - active bool - } - hostPointPolicies := make(map[string]targetHostPointPolicy, len(command.Targets)) - if chargeSource != giftChargeSourceBag { - for _, target := range command.Targets { - if !target.TargetIsHost { - continue - } - hostPointRegionID := target.TargetHostRegionID - if hostPointRegionID <= 0 { - hostPointRegionID = command.RegionID - } - policy, active, err := r.resolveActiveWalletPolicy(ctx, tx, command.AppCode, hostPointRegionID, nowMs) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - if !active { - continue - } - // 批量目标逐一按其政策和主播区域计算;同一礼物不能拿第一个主播的区域或收益覆盖后续目标。 - hostGiftTypeRatio := giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000} - giftRatioPercent := "0.00" - giftRatioPPM := int64(0) - giftRatioRegionID := int64(0) - if policy.HostGiftTypeRatioEnabled { - resolvedRatio, err := resolveHostRatio(target.TargetHostRegionID) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - hostGiftTypeRatio = resolvedRatio.ratio - giftRatioPercent = resolvedRatio.ratio.Percent - giftRatioPPM = resolvedRatio.ratio.PPM - giftRatioRegionID = resolvedRatio.effectiveRegionID - } - pointAmounts, err := calculateGiftPointPolicyAmounts(chargeAmount, hostGiftTypeRatio, policy) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - amount := pointAmounts.HostPointAdded - agencyAmount := int64(0) - agencyShareBaseAmount := int64(0) - if target.TargetAgencyOwnerUserID > 0 && policy.AgencyPointRatioPPM > 0 { - agencyAmount = pointAmounts.AgencyPointAdded - agencyShareBaseAmount = pointAmounts.AgencyShareBaseAmount - } - hostPointPolicies[target.CommandID] = targetHostPointPolicy{ - policy: policy, - amount: amount, - userID: target.TargetUserID, - giftRatioPercent: giftRatioPercent, - giftRatioPPM: giftRatioPPM, - giftRatioRegionID: giftRatioRegionID, - agencyAmount: agencyAmount, - agencyShareBaseAmount: agencyShareBaseAmount, - agencyOwnerUserID: target.TargetAgencyOwnerUserID, - active: true, - } + // 先解析所有主播政策,再一次性生成有序钱包锁集合。POINT_DIAMOND 目标账户必须和 sender/返币账户 + // 在同一个顺序中加锁,避免批量送礼因目标排列不同形成交叉锁等待。 + for _, target := range command.Targets { + if !target.TargetIsHost { + continue + } + if _, err := resolveHostRatio(target.TargetHostRegionID); err != nil { + return ledger.BatchGiftReceipt{}, err } } @@ -226,20 +183,20 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb }) } } - for _, hostPointPolicy := range hostPointPolicies { - if hostPointPolicy.amount > 0 { - lockRequests = append(lockRequests, walletAccountLockRequest{ - UserID: hostPointPolicy.userID, - AssetType: ledger.AssetPoint, - CreateIfMissing: true, - }) + for _, target := range command.Targets { + resolvedRatio, exists := hostRatiosByRegion[target.TargetHostRegionID] + if !target.TargetIsHost || !exists || !resolvedRatio.coefficientFound || resolvedRatio.coefficient.PolicyType != ledger.HostPolicyTypePointDiamond { + continue } - if hostPointPolicy.agencyAmount > 0 { - // charge_amount 兼容策略可能因两种比例不同而出现 host 向下取整为 0、Agency 仍为正数; - // Agency 账户锁不能被 host amount 的判断包住,否则 Direct 成功而 Batch 返回内部错误。 + lockRequests = append(lockRequests, walletAccountLockRequest{ + UserID: target.TargetUserID, + AssetType: ledger.AssetPointDiamond, + CreateIfMissing: true, + }) + if target.TargetAgencyOwnerUserID > 0 && resolvedRatio.coefficient.AgencyPointShareBPS > 0 { lockRequests = append(lockRequests, walletAccountLockRequest{ - UserID: hostPointPolicy.agencyOwnerUserID, - AssetType: ledger.AssetPoint, + UserID: target.TargetAgencyOwnerUserID, + AssetType: ledger.AssetPointDiamond, CreateIfMissing: true, }) } @@ -276,41 +233,62 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb events = append(events, resourceEvent) } - // 批量送礼中的主播可能属于不同区域,必须逐区域解析周期钻石比例;同一区域只查一次, - // 解析器会在区域配置缺失时回退 region_id=0,既避免 N 次重复查询,也不混用其他主播区域。 - for _, target := range command.Targets { - if !target.TargetIsHost || hostPointPolicies[target.CommandID].active { - continue - } - if _, err := resolveHostRatio(target.TargetHostRegionID); err != nil { - return ledger.BatchGiftReceipt{}, err - } - } - targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets)) for _, target := range command.Targets { - hostPointPolicy := hostPointPolicies[target.CommandID] hostPeriodDiamondAdded := int64(0) + hostIncomePolicyType := "" + hostIncomeAssetType := "" + hostIncomeAmount := int64(0) + agencyPointAmount := int64(0) + agencyPointShareBPS := int32(0) hostPeriodCycleKey := "" - giftDiamondRatioPercent := hostPointPolicy.giftRatioPercent - if giftDiamondRatioPercent == "" { - giftDiamondRatioPercent = "0.00" - } - giftDiamondRatioPPM := hostPointPolicy.giftRatioPPM - giftDiamondRatioRegionID := hostPointPolicy.giftRatioRegionID - if target.TargetIsHost && !hostPointPolicy.active { + giftDiamondRatioPercent := "0.00" + giftDiamondRatioPPM := int64(0) + giftDiamondRatioRegionID := int64(0) + hostSalaryPolicyID := uint64(0) + hostSalaryPolicyVersion := uint64(0) + hostSalaryPolicyDiamondCoefficient := "" + hostSalaryPolicyDiamondCoefficientPPM := int64(0) + pointDiamondsPerUSD := int64(0) + coinsPerUSD := int64(0) + if target.TargetIsHost { resolvedRatio, exists := hostRatiosByRegion[target.TargetHostRegionID] if !exists { return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "host gift diamond ratio is missing") } - hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, resolvedRatio.ratio.PPM) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - giftDiamondRatioPercent = resolvedRatio.ratio.Percent - giftDiamondRatioPPM = resolvedRatio.ratio.PPM - giftDiamondRatioRegionID = resolvedRatio.effectiveRegionID hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) + if resolvedRatio.coefficientFound && resolvedRatio.coefficient.PPM > 0 { + hostIncomeAmount, err = giftDiamondAmountWithPolicyCoefficient(chargeAmount, resolvedRatio.giftTypeRatio.PPM, resolvedRatio.coefficient.PPM) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + hostIncomePolicyType = resolvedRatio.coefficient.PolicyType + switch resolvedRatio.coefficient.PolicyType { + case ledger.HostPolicyTypeSalaryDiamond: + hostIncomeAssetType = ledger.AssetHostPeriodDiamond + hostPeriodDiamondAdded = hostIncomeAmount + case ledger.HostPolicyTypePointDiamond: + hostIncomeAssetType = ledger.AssetPointDiamond + agencyPointShareBPS = resolvedRatio.coefficient.AgencyPointShareBPS + if target.TargetAgencyOwnerUserID > 0 { + agencyPointAmount, err = amountByBPS(hostIncomeAmount, agencyPointShareBPS) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + } + default: + return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "host policy type is invalid") + } + } + giftDiamondRatioPercent = resolvedRatio.giftTypeRatio.Percent + giftDiamondRatioPPM = resolvedRatio.giftTypeRatio.PPM + giftDiamondRatioRegionID = resolvedRatio.giftTypeRatioRegionID + hostSalaryPolicyID = resolvedRatio.coefficient.PolicyID + hostSalaryPolicyVersion = resolvedRatio.coefficient.PolicyVersion + hostSalaryPolicyDiamondCoefficient = resolvedRatio.coefficient.Value + hostSalaryPolicyDiamondCoefficientPPM = resolvedRatio.coefficient.PPM + pointDiamondsPerUSD = resolvedRatio.coefficient.PointDiamondsPerUSD + coinsPerUSD = resolvedRatio.coefficient.CoinsPerUSD } transactionID := transactionID(command.AppCode, target.CommandID) @@ -320,10 +298,6 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb senderAfter = senderState.account.AvailableAmount } targetGiftIncomeCoinAmount := giftIncomeCoinAmount - if hostPointPolicy.active { - // 命中新政策的主播目标只给 POINT 收益,不再走旧 COIN 收礼返还。 - targetGiftIncomeCoinAmount = 0 - } giftIncomeBalanceAfter := int64(0) targetCoinState := accountStates[walletAccountLockKey(target.TargetUserID, ledger.AssetCoin)] if targetGiftIncomeCoinAmount > 0 { @@ -337,27 +311,6 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb senderAfter = giftIncomeBalanceAfter } } - hostPointBalanceAfter := int64(0) - var targetPointState *walletAccountState - if hostPointPolicy.amount > 0 { - targetPointState = accountStates[walletAccountLockKey(target.TargetUserID, ledger.AssetPoint)] - if targetPointState == nil { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "host point account is missing") - } - hostPointBalanceAfter = targetPointState.account.AvailableAmount + hostPointPolicy.amount - } - agencyPointBalanceAfter := int64(0) - var agencyPointState *walletAccountState - if hostPointPolicy.agencyAmount > 0 { - agencyPointState = accountStates[walletAccountLockKey(hostPointPolicy.agencyOwnerUserID, ledger.AssetPoint)] - if agencyPointState == nil { - return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "agency point account is missing") - } - agencyPointBalanceAfter = agencyPointState.account.AvailableAmount + hostPointPolicy.agencyAmount - if agencyPointState == targetPointState { - agencyPointBalanceAfter += hostPointPolicy.amount - } - } // 批量事务逐目标生成独立账务交易;这里从当前 sender 版本预测本目标完成后的版本, // 下一轮会读取已推进的 senderState,因此最后一条回执自然代表整批最终版本。 senderBalanceVersionAfter := giftSenderBalanceVersionAfter( @@ -373,64 +326,62 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb GiftIconURL: giftDisplayIconURL(giftConfig.Resource), GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL), - GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes), - ResourceID: giftConfig.ResourceID, - ResourceSnapshot: mustJSON(giftConfig.Resource), - GiftTypeCode: giftConfig.GiftTypeCode, - CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON), - PresentationJSON: giftConfig.PresentationJSON, - SortOrder: giftConfig.SortOrder, - GiftCount: command.GiftCount, - PriceVersion: price.PriceVersion, - ChargeAssetType: chargeAssetType, - ChargeAmount: chargeAmount, - CoinSpent: chargeAmount, - ChargeSource: chargeSource, - EntitlementID: strings.TrimSpace(command.EntitlementID), - GiftPointAdded: 0, - HeatValue: heatValue, - BalanceAfter: senderAfter, - BalanceVersion: senderBalanceVersionAfter, - GiftIncomeCoinAmount: targetGiftIncomeCoinAmount, - GiftIncomeRatioPercent: giftIncomeRatio.Percent, - GiftIncomeRatioRegionID: giftIncomeRatioRegionID, - GiftIncomeBalanceAfter: giftIncomeBalanceAfter, - RechargeSevenDayCoins: rechargeSnapshot.SevenDayCoins, - RechargeThirtyDayCoins: rechargeSnapshot.ThirtyDayCoins, - LastRechargedAtMS: rechargeSnapshot.LastAtMS, - PaidAtMS: nowMs, - BillingReceipt: billingReceiptID(command.AppCode, target.CommandID), - SenderUserID: command.SenderUserID, - SenderRegionID: command.SenderRegionID, - TargetUserID: target.TargetUserID, - TargetIsHost: target.TargetIsHost, - TargetHostRegionID: target.TargetHostRegionID, - TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID, - HostPeriodDiamondAdded: hostPeriodDiamondAdded, - GiftDiamondRatioPercent: giftDiamondRatioPercent, - GiftDiamondRatioPPM: giftDiamondRatioPPM, - GiftDiamondRatioRegionID: giftDiamondRatioRegionID, - HostPeriodCycleKey: hostPeriodCycleKey, - HostPointAssetType: hostPointPolicy.policy.HostPointAsset, - HostPointAdded: hostPointPolicy.amount, - HostPointBalanceAfter: hostPointBalanceAfter, - HostPointPolicyInstanceCode: hostPointPolicy.policy.InstanceCode, - HostPointTemplateCode: hostPointPolicy.policy.TemplateCode, - HostPointTemplateVersion: hostPointPolicy.policy.TemplateVersion, - HostPointRatioPPM: hostPointPolicy.policy.HostPointRatioPPM, - AgencyPointAdded: hostPointPolicy.agencyAmount, - AgencyPointBalanceAfter: agencyPointBalanceAfter, - AgencyPointRatioPPM: hostPointPolicy.policy.AgencyPointRatioPPM, - AgencyPointShareBase: hostPointPolicy.policy.AgencyPointShareBase, - AgencyPointShareBaseAmount: hostPointPolicy.agencyShareBaseAmount, - PointsPerUSD: hostPointPolicy.policy.PointsPerUSD, - RoomID: command.RoomID, - RoomRegionID: command.RegionID, - RoomContributionRatioPercent: roomContributionRatio.Percent, - RoomContributionRatioRegionID: roomContributionRatioRegionID, - CoinPrice: price.CoinPrice, - GiftPointAmount: 0, - HeatUnitValue: price.CoinPrice, + GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes), + ResourceID: giftConfig.ResourceID, + ResourceSnapshot: mustJSON(giftConfig.Resource), + GiftTypeCode: giftConfig.GiftTypeCode, + CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON), + PresentationJSON: giftConfig.PresentationJSON, + SortOrder: giftConfig.SortOrder, + GiftCount: command.GiftCount, + PriceVersion: price.PriceVersion, + ChargeAssetType: chargeAssetType, + ChargeAmount: chargeAmount, + CoinSpent: chargeAmount, + ChargeSource: chargeSource, + EntitlementID: strings.TrimSpace(command.EntitlementID), + GiftPointAdded: 0, + HeatValue: heatValue, + BalanceAfter: senderAfter, + BalanceVersion: senderBalanceVersionAfter, + GiftIncomeCoinAmount: targetGiftIncomeCoinAmount, + GiftIncomeRatioPercent: giftIncomeRatio.Percent, + GiftIncomeRatioRegionID: giftIncomeRatioRegionID, + GiftIncomeBalanceAfter: giftIncomeBalanceAfter, + RechargeSevenDayCoins: rechargeSnapshot.SevenDayCoins, + RechargeThirtyDayCoins: rechargeSnapshot.ThirtyDayCoins, + LastRechargedAtMS: rechargeSnapshot.LastAtMS, + PaidAtMS: nowMs, + BillingReceipt: billingReceiptID(command.AppCode, target.CommandID), + SenderUserID: command.SenderUserID, + SenderRegionID: command.SenderRegionID, + TargetUserID: target.TargetUserID, + TargetIsHost: target.TargetIsHost, + TargetHostRegionID: target.TargetHostRegionID, + TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID, + HostPeriodDiamondAdded: hostPeriodDiamondAdded, + HostIncomePolicyType: hostIncomePolicyType, + IncomeAssetType: hostIncomeAssetType, + HostBaseAmount: hostIncomeAmount, + AgencyAmount: agencyPointAmount, + AgencyPointShareBPS: agencyPointShareBPS, + GiftDiamondRatioPercent: giftDiamondRatioPercent, + GiftDiamondRatioPPM: giftDiamondRatioPPM, + GiftDiamondRatioRegionID: giftDiamondRatioRegionID, + HostSalaryPolicyID: hostSalaryPolicyID, + HostSalaryPolicyVersion: hostSalaryPolicyVersion, + PointDiamondsPerUSD: pointDiamondsPerUSD, + CoinsPerUSD: coinsPerUSD, + HostSalaryPolicyDiamondCoefficient: hostSalaryPolicyDiamondCoefficient, + HostSalaryPolicyDiamondCoefficientPPM: hostSalaryPolicyDiamondCoefficientPPM, + HostPeriodCycleKey: hostPeriodCycleKey, + RoomID: command.RoomID, + RoomRegionID: command.RegionID, + RoomContributionRatioPercent: roomContributionRatio.Percent, + RoomContributionRatioRegionID: roomContributionRatioRegionID, + CoinPrice: price.CoinPrice, + GiftPointAmount: 0, + HeatUnitValue: price.CoinPrice, } if err := r.insertTransaction(ctx, tx, transactionID, target.CommandID, bizTypeGiftDebit, debitRequestHash(debitGiftCommandFromBatchTarget(command, target)), fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { return ledger.BatchGiftReceipt{}, err @@ -484,18 +435,42 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb return ledger.BatchGiftReceipt{}, err } } - if hostPointPolicy.amount > 0 { - pointAccount, err := r.applyTrackedAccountDelta(ctx, tx, targetPointState, hostPointPolicy.amount, 0, nowMs) + if hostPeriodDiamondAdded > 0 { + hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs) if err != nil { return ledger.BatchGiftReceipt{}, err } - metadata.HostPointBalanceAfter = pointAccount.AvailableAmount + metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter + metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion + metadata.HostIncomeBalanceAfter = hostPeriodDiamondAfter + } + var targetPointDiamondState *walletAccountState + var agencyPointDiamondState *walletAccountState + if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 { + targetPointDiamondState = accountStates[walletAccountLockKey(target.TargetUserID, ledger.AssetPointDiamond)] + if targetPointDiamondState == nil { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "point diamond account is missing") + } + combinedAmount := hostIncomeAmount + if agencyPointAmount > 0 && target.TargetAgencyOwnerUserID == target.TargetUserID { + combinedAmount, err = checkedAdd(combinedAmount, agencyPointAmount) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + } + pointAccount, err := r.applyTrackedAccountDelta(ctx, tx, targetPointDiamondState, combinedAmount, 0, nowMs) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + metadata.HostIncomeBalanceAfter = pointAccount.AvailableAmount + if agencyPointAmount > 0 && target.TargetAgencyOwnerUserID == target.TargetUserID { + metadata.AgencyIncomeBalanceAfter = pointAccount.AvailableAmount + } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: target.TargetUserID, - AssetType: ledger.AssetPoint, - AvailableDelta: hostPointPolicy.amount, - FrozenDelta: 0, + AssetType: ledger.AssetPointDiamond, + AvailableDelta: combinedAmount, AvailableAfter: pointAccount.AvailableAmount, FrozenAfter: pointAccount.FrozenAmount, CounterpartyUserID: command.SenderUserID, @@ -505,41 +480,34 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb return ledger.BatchGiftReceipt{}, err } } - if hostPointPolicy.agencyAmount > 0 { - agencyPointAccount, err := r.applyTrackedAccountDelta(ctx, tx, agencyPointState, hostPointPolicy.agencyAmount, 0, nowMs) + if agencyPointAmount > 0 && target.TargetAgencyOwnerUserID != target.TargetUserID { + agencyPointDiamondState = accountStates[walletAccountLockKey(target.TargetAgencyOwnerUserID, ledger.AssetPointDiamond)] + if agencyPointDiamondState == nil { + return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "agency point diamond account is missing") + } + agencyAccount, err := r.applyTrackedAccountDelta(ctx, tx, agencyPointDiamondState, agencyPointAmount, 0, nowMs) if err != nil { return ledger.BatchGiftReceipt{}, err } - metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount - if agencyPointState == targetPointState { - // 同一 child transaction 的两个角色共用账户时,回执和主播角色事实都以第二条 - // 分录后的最终 POINT 余额为准,不能暴露本批次内部的中间余额。 - metadata.HostPointBalanceAfter = agencyPointAccount.AvailableAmount - } + metadata.AgencyIncomeBalanceAfter = agencyAccount.AvailableAmount if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, - UserID: hostPointPolicy.agencyOwnerUserID, - AssetType: ledger.AssetPoint, - AvailableDelta: hostPointPolicy.agencyAmount, - AvailableAfter: agencyPointAccount.AvailableAmount, - FrozenAfter: agencyPointAccount.FrozenAmount, + UserID: target.TargetAgencyOwnerUserID, + AssetType: ledger.AssetPointDiamond, + AvailableDelta: agencyPointAmount, + AvailableAfter: agencyAccount.AvailableAmount, + FrozenAfter: agencyAccount.FrozenAmount, CounterpartyUserID: command.SenderUserID, RoomID: command.RoomID, CreatedAtMS: nowMs, }); err != nil { return ledger.BatchGiftReceipt{}, err } - if err := r.insertAgencyPointShare(ctx, tx, transactionID, metadata, nowMs); err != nil { - return ledger.BatchGiftReceipt{}, err - } } - if hostPeriodDiamondAdded > 0 { - hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter - metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion + // 批量命令的每个 target 都有独立 transaction_id;逐目标写投影既继承原幂等域, + // 又让整批事务任一失败时同时回滚扣币、积分和所有统计事实。 + if err := r.insertPointDiamondGiftIncomeProjection(ctx, tx, transactionID, metadata, nowMs); err != nil { + return ledger.BatchGiftReceipt{}, err } // Each target owns an independent idempotent wallet transaction. Freeze the actual sender snapshot // after this target's ledger entries, so aggregate replay can take the last receipt and recover the @@ -564,33 +532,22 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb if targetGiftIncomeCoinAmount > 0 { events = append(events, giftIncomeCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) } - sharedHostAgencyPointAccount := hostPointPolicy.amount > 0 && hostPointPolicy.agencyAmount > 0 && target.TargetUserID == hostPointPolicy.agencyOwnerUserID - sharedPointDelta := int64(0) - if sharedHostAgencyPointAccount { - sharedPointDelta, err = checkedAdd(hostPointPolicy.amount, hostPointPolicy.agencyAmount) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - } - if hostPointPolicy.amount > 0 { - if sharedHostAgencyPointAccount { - // 单个 batch target 仍是一笔独立 wallet transaction。owner=self 时只聚合该 target 的 - // 通用余额事件,不跨 target 合并;这样每个子 command 的重放与下游游标仍保持独立。 - events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, sharedPointDelta, 0, metadata.AgencyPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit)) - } else { - events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, hostPointPolicy.amount, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit)) - } - events = append(events, hostPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) - } - if hostPointPolicy.agencyAmount > 0 { - if !sharedHostAgencyPointAccount { - events = append(events, balanceChangedEvent(transactionID, target.CommandID, hostPointPolicy.agencyOwnerUserID, ledger.AssetPoint, hostPointPolicy.agencyAmount, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizTypeGiftDebit)) - } - events = append(events, agencyPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) - } if hostPeriodDiamondAdded > 0 { events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) } + if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 { + hostDelta := hostIncomeAmount + if target.TargetAgencyOwnerUserID == target.TargetUserID { + hostDelta, err = checkedAdd(hostDelta, agencyPointAmount) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + } + events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPointDiamond, hostDelta, 0, metadata.HostIncomeBalanceAfter, targetPointDiamondState.account.FrozenAmount, targetPointDiamondState.account.Version, metadata, nowMs, bizTypeGiftDebit)) + } + if agencyPointAmount > 0 && target.TargetAgencyOwnerUserID != target.TargetUserID { + events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetAgencyOwnerUserID, ledger.AssetPointDiamond, agencyPointAmount, 0, metadata.AgencyIncomeBalanceAfter, agencyPointDiamondState.account.FrozenAmount, agencyPointDiamondState.account.Version, metadata, nowMs, bizTypeGiftDebit)) + } targetReceipts = append(targetReceipts, ledger.BatchGiftTargetReceipt{ TargetUserID: target.TargetUserID, CommandID: target.CommandID, diff --git a/services/wallet-service/internal/storage/mysql/gift_config_query_repository_test.go b/services/wallet-service/internal/storage/mysql/gift_config_query_repository_test.go index aeac8fc4..7f5efe8c 100644 --- a/services/wallet-service/internal/storage/mysql/gift_config_query_repository_test.go +++ b/services/wallet-service/internal/storage/mysql/gift_config_query_repository_test.go @@ -1,11 +1,93 @@ package mysql import ( + "context" + "regexp" "testing" + "time" + "hyapp/pkg/appcode" + "hyapp/services/wallet-service/internal/domain/ledger" resourcedomain "hyapp/services/wallet-service/internal/domain/resource" + + "github.com/DATA-DOG/go-sqlmock" + mysqlDriver "github.com/go-sql-driver/mysql" ) +func TestEnsureGiftDiamondRatioSchemaDoesNotSeedAnyApp(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(&mysqlDriver.MySQLError{Number: 1060, Message: "Duplicate column name"}) + if err := ensureGiftDiamondRatioSchema(context.Background(), db); err != nil { + t.Fatalf("ensure gift ratio schema: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("wallet startup must not issue a gift multiplier INSERT: %v", err) + } +} + +func TestOrderedWalletAccountLockRequestsUsesSameOrderForGiftAndExchange(t *testing.T) { + giftOrder, err := orderedWalletAccountLockRequests([]walletAccountLockRequest{ + {UserID: 42, AssetType: ledger.AssetCoin, CreateIfMissing: false}, + {UserID: 42, AssetType: ledger.AssetPointDiamond, CreateIfMissing: true}, + }) + if err != nil { + t.Fatalf("order gift locks: %v", err) + } + exchangeOrder, err := orderedWalletAccountLockRequests([]walletAccountLockRequest{ + {UserID: 42, AssetType: ledger.AssetPointDiamond, CreateIfMissing: false}, + {UserID: 42, AssetType: ledger.AssetCoin, CreateIfMissing: true}, + }) + if err != nil { + t.Fatalf("order exchange locks: %v", err) + } + if len(giftOrder) != 2 || len(exchangeOrder) != 2 || giftOrder[0].AssetType != ledger.AssetCoin || giftOrder[1].AssetType != ledger.AssetPointDiamond || exchangeOrder[0].AssetType != ledger.AssetCoin || exchangeOrder[1].AssetType != ledger.AssetPointDiamond { + t.Fatalf("opposite money flows must lock in one canonical order: gift=%+v exchange=%+v", giftOrder, exchangeOrder) + } + sellerOrder, err := orderedWalletAccountLockRequests([]walletAccountLockRequest{ + {UserID: 900, AssetType: ledger.AssetPointDiamond, CreateIfMissing: false}, + {UserID: 1, AssetType: ledger.AssetCoinSellerCoin, CreateIfMissing: true}, + }) + if err != nil { + t.Fatalf("order coin seller locks: %v", err) + } + if len(sellerOrder) != 2 || sellerOrder[0].AssetType != ledger.AssetCoinSellerCoin || sellerOrder[0].UserID != 1 || sellerOrder[1].AssetType != ledger.AssetPointDiamond || sellerOrder[1].UserID != 900 { + t.Fatalf("coin seller transfer must use canonical asset/user lock order: %+v", sellerOrder) + } +} + +func TestResolveGiftReturnCoinRatioFailsWithoutPersistedConfiguration(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + defer db.Close() + mock.ExpectBegin() + tx, err := db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + query := regexp.QuoteMeta(` + SELECT CAST(coin_return_ratio_percent AS CHAR), CAST(ROUND(coin_return_ratio_percent * 10000) AS SIGNED) + FROM gift_diamond_ratio_configs + WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active' + LIMIT 1`) + mock.ExpectQuery(query).WithArgs("fami", int64(25), "normal").WillReturnRows(sqlmock.NewRows([]string{"percent", "ppm"})) + mock.ExpectQuery(query).WithArgs("fami", int64(0), "normal").WillReturnRows(sqlmock.NewRows([]string{"percent", "ppm"})) + repository := &Repository{db: db} + if _, _, err := repository.resolveGiftReturnCoinRatio(appcode.WithContext(context.Background(), "fami"), tx, "fami", 25, "normal"); err == nil { + t.Fatal("missing persisted return-coin ratio must fail closed") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet SQL expectations: %v", err) + } +} + func TestNormalizeGiftConfigListQueryAllowsPanelPageSize(t *testing.T) { query := normalizeGiftConfigListQuery(resourcedomain.ListGiftConfigsQuery{ Page: 1, @@ -23,3 +105,174 @@ func TestNormalizeGiftConfigListQueryAllowsPanelPageSize(t *testing.T) { t.Fatalf("gift list page size must be capped at 500, got %d", query.PageSize) } } + +func TestResolveHostPolicyDiamondCoefficientUsesPublishedCycleSnapshot(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sql mock: %v", err) + } + defer db.Close() + mock.ExpectBegin() + tx, err := db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + mock.ExpectQuery(regexp.QuoteMeta(` + SELECT p.policy_id, p.policy_version, p.policy_type, + CAST(p.gift_coin_to_diamond_ratio AS CHAR), + CAST(ROUND(p.gift_coin_to_diamond_ratio * 1000000) AS SIGNED), + p.point_diamonds_per_usd, p.coins_per_usd, p.minimum_withdraw_usd_minor, p.withdraw_fee_bps, + p.agency_point_share_bps + FROM host_salary_policy_cycle_bindings binding + JOIN host_agency_salary_policies p + ON p.app_code = binding.app_code + AND p.policy_id = binding.policy_id + AND p.policy_version = binding.policy_version + WHERE binding.app_code = ? + AND binding.region_id = ? + AND binding.cycle_key = ? + AND p.status = 'active' + LIMIT 1`)). + WithArgs("fami", int64(26), "2026-07"). + WillReturnRows(sqlmock.NewRows([]string{"policy_id", "policy_version", "policy_type", "coefficient", "coefficient_ppm", "points_per_usd", "coins_per_usd", "minimum_usd_minor", "fee_bps", "agency_bps"}). + AddRow(uint64(81), uint64(7), "POINT_DIAMOND", "1.250000", int64(1_250_000), int64(300000), int64(80000), int64(5000), int32(100), int32(2000))) + + repository := &Repository{db: db} + snapshot, found, err := repository.resolveHostPolicyDiamondCoefficient(appcode.WithContext(context.Background(), "fami"), tx, 26, "2026-07") + if err != nil { + t.Fatalf("resolve Host policy diamond coefficient: %v", err) + } + if !found || snapshot.PolicyID != 81 || snapshot.PolicyVersion != 7 || snapshot.PolicyType != "POINT_DIAMOND" || snapshot.PPM != 1_250_000 || snapshot.Value != "1.250000" || snapshot.AgencyPointShareBPS != 2000 { + t.Fatalf("published Host policy snapshot mismatch: %+v found=%v", snapshot, found) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sql expectations: %v", err) + } +} + +func TestGiftDiamondAmountWithPolicyCoefficientFloorsOnce(t *testing.T) { + tests := []struct { + name string + chargeAmount int64 + giftTypeRatioPPM int64 + policyCoefficientPPM int64 + want int64 + }{ + {name: "normal neutral", chargeAmount: 100, giftTypeRatioPPM: 1_000_000, policyCoefficientPPM: 1_000_000, want: 100}, + {name: "lucky neutral", chargeAmount: 100, giftTypeRatioPPM: 100_000, policyCoefficientPPM: 1_000_000, want: 10}, + {name: "super lucky neutral", chargeAmount: 100, giftTypeRatioPPM: 10_000, policyCoefficientPPM: 1_000_000, want: 1}, + {name: "lucky with Host coefficient", chargeAmount: 100, giftTypeRatioPPM: 100_000, policyCoefficientPPM: 1_250_000, want: 12}, + {name: "single final floor", chargeAmount: 1, giftTypeRatioPPM: 500_000, policyCoefficientPPM: 2_000_000, want: 1}, + {name: "disabled gift type", chargeAmount: 100, giftTypeRatioPPM: 0, policyCoefficientPPM: 1_000_000, want: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := giftDiamondAmountWithPolicyCoefficient(tt.chargeAmount, tt.giftTypeRatioPPM, tt.policyCoefficientPPM) + if err != nil { + t.Fatalf("calculate period diamonds: %v", err) + } + if got != tt.want { + t.Fatalf("period diamonds mismatch: got %d want %d", got, tt.want) + } + }) + } +} + +func TestGiftDiamondAmountWithPolicyCoefficientRejectsInvalidOrOverflowingValues(t *testing.T) { + if _, err := giftDiamondAmountWithPolicyCoefficient(100, 1_000_000, 0); err == nil { + t.Fatal("zero Host policy coefficient must be rejected") + } + if _, err := giftDiamondAmountWithPolicyCoefficient(9_223_372_036_854_775_807, 1_000_000, 2_000_000); err == nil { + t.Fatal("period diamond result outside BIGINT must be rejected") + } +} + +func TestCalculatePointDiamondCoinSellerAmountsAppliesFeeBeforeSellerCredit(t *testing.T) { + points, feeUSDMinor, netUSDMinor, sellerCoins, err := calculatePointDiamondCoinSellerAmounts(10_001, 300_000, 80_000, 333) + if err != nil { + t.Fatalf("calculate point diamond seller withdrawal: %v", err) + } + // 扣积分按 gross;手续费先在美元分上 floor;币商库存只按 net 计算。 + if points != 30_003_000 || feeUSDMinor != 333 || netUSDMinor != 9_668 || sellerCoins != 7_734_400 { + t.Fatalf("seller withdrawal snapshot mismatch: points=%d fee=%d net=%d coins=%d", points, feeUSDMinor, netUSDMinor, sellerCoins) + } +} + +func TestPointWithdrawalActionAvailabilityUsesCurrentCounterAndAllowedDay(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sql mock: %v", err) + } + defer db.Close() + mock.ExpectBegin() + tx, err := db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + now := time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC) + mock.ExpectQuery(regexp.QuoteMeta(` + SELECT withdrawal_count + FROM point_withdrawal_policy_counters + WHERE app_code = ? AND source_asset_type = ? AND user_id = ? + AND channel = ? AND period = ? AND period_key = ?`)). + WithArgs("fami", ledger.AssetPointDiamond, int64(42), pointWithdrawalChannelCoinSeller, withdrawalLimitPeriodMonth, "2026-07"). + WillReturnRows(sqlmock.NewRows([]string{"withdrawal_count"}).AddRow(int64(2))) + repo := &Repository{db: db} + ctx := appcode.WithContext(context.Background(), "fami") + coinSeller, err := repo.pointWithdrawalActionAvailability(ctx, tx, 42, ledger.AssetPointDiamond, pointWithdrawalChannelCoinSeller, hostPolicyWithdrawalLimit{ + Period: withdrawalLimitPeriodMonth, PeriodKey: "2026-07", Limit: 2, + }, now) + if err != nil { + t.Fatalf("coin seller availability: %v", err) + } + if coinSeller.Allowed || coinSeller.UsedCount != 2 || coinSeller.RemainingCount != 0 || coinSeller.BlockReason != "limit_reached" { + t.Fatalf("limit-reached capability mismatch: %+v", coinSeller) + } + platform, err := repo.pointWithdrawalActionAvailability(ctx, tx, 42, ledger.AssetPointDiamond, pointWithdrawalChannelPlatform, hostPolicyWithdrawalLimit{ + Period: withdrawalLimitPeriodMonth, PeriodKey: "2026-07", Limit: 0, AllowedDays: "1,15", + }, now) + if err != nil { + t.Fatalf("platform availability: %v", err) + } + if platform.Allowed || platform.RemainingCount != -1 || platform.BlockReason != "date_not_allowed" { + t.Fatalf("date capability mismatch: %+v", platform) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet SQL expectations: %v", err) + } +} + +func TestWalletTransactionEntryComponentsPreservesMergedHostAgencySplit(t *testing.T) { + item := ledger.WalletTransaction{BizType: bizTypeGiftDebit} + metadata := `{"target_user_id":101,"target_agency_owner_user_id":101,"host_base_amount":100,"agency_amount":20}` + role, hostBase, agency := walletTransactionEntryComponents(101, item, metadata) + if role != "host_gift_income_and_agency_share" || hostBase != 100 || agency != 20 { + t.Fatalf("merged gift income components mismatch: role=%q host=%d agency=%d", role, hostBase, agency) + } + + metadata = `{"target_user_id":101,"target_agency_owner_user_id":202,"host_base_amount":100,"agency_amount":20}` + role, hostBase, agency = walletTransactionEntryComponents(101, item, metadata) + if role != "host_gift_income" || hostBase != 100 || agency != 0 { + t.Fatalf("Host-only gift income components mismatch: role=%q host=%d agency=%d", role, hostBase, agency) + } + role, hostBase, agency = walletTransactionEntryComponents(202, item, metadata) + if role != "agency_gift_share" || hostBase != 0 || agency != 20 { + t.Fatalf("Agency-only gift income components mismatch: role=%q host=%d agency=%d", role, hostBase, agency) + } +} + +func TestDirectGiftWalletHistoryUsesGiftPolicyAndRoleSnapshot(t *testing.T) { + metadata := `{"target_user_id":101,"target_agency_owner_user_id":101,"host_base_amount":100,"agency_amount":20,"host_salary_policy_id":81,"host_salary_policy_version":7,"point_diamonds_per_usd":300000,"coins_per_usd":80000}` + item := ledger.WalletTransaction{BizType: bizTypeDirectGiftDebit} + role, hostBase, agency := walletTransactionEntryComponents(101, item, metadata) + if role != "host_gift_income_and_agency_share" || hostBase != 100 || agency != 20 { + t.Fatalf("direct gift role snapshot mismatch: role=%q host=%d agency=%d", role, hostBase, agency) + } + policyID, version, pointRatio, coinRatio, gross, fee, net, feeBPS := walletTransactionPolicySnapshot(bizTypeDirectGiftDebit, metadata) + if policyID != 81 || version != 7 || pointRatio != 300000 || coinRatio != 80000 || gross != 0 || fee != 0 || net != 0 || feeBPS != 0 { + t.Fatalf("direct gift policy snapshot mismatch: id=%d version=%d point=%d coin=%d gross=%d fee=%d net=%d fee_bps=%d", policyID, version, pointRatio, coinRatio, gross, fee, net, feeBPS) + } +} diff --git a/services/wallet-service/internal/storage/mysql/gift_debit_repository.go b/services/wallet-service/internal/storage/mysql/gift_debit_repository.go index 8ba8620e..b24cb11f 100644 --- a/services/wallet-service/internal/storage/mysql/gift_debit_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_debit_repository.go @@ -2,6 +2,9 @@ package mysql import ( "context" + "database/sql" + "encoding/json" + "errors" "fmt" "hyapp/pkg/appcode" "hyapp/pkg/xerr" @@ -30,13 +33,21 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm requestHash := debitRequestHash(command) bizType := giftDebitBizType(command) if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizType); err != nil || exists { - if err != nil || !exists { + if err != nil { + if command.DirectGift && xerr.IsCode(err, xerr.LedgerConflict) { + // 滚动部署期间旧 Gateway 尚不会调用 receipt RPC;正式 DebitDirectGift 遇到历史 hash + // 也必须复用同一 metadata 兼容校验,避免短暂发布顺序影响已成功的 Lalu 命令。 + compatRow, compatExists, compatErr := r.lookupDirectGiftReceiptTransaction(ctx, tx, command, requestHash) + if compatErr != nil || !compatExists { + return ledger.Receipt{}, compatErr + } + return r.receiptForGiftTransaction(ctx, tx, compatRow.TransactionID) + } return ledger.Receipt{}, err } receipt, receiptErr := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID) return receipt, receiptErr } - nowMs := time.Now().UnixMilli() if command.RegionID < 0 { return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid") @@ -95,70 +106,73 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } } - // 礼物类型倍率既参与旧周期钻石,也可能参与新 POINT 政策。三项快照在事务内一次解析并落 metadata, - // 后续运营调整区域倍率时,历史流水仍能证明 charge_amount 如何得到主播实际收益。 + // 主播礼物只累计周期钻石;第二页礼物类型倍率与 Host 政策系数都在扣费事务内固化, + // 后续运营调整任一配置时,历史流水仍能完整还原本次累计公式。 giftDiamondRatioPercent := "0.00" giftDiamondRatioPPM := int64(0) giftDiamondRatioRegionID := int64(0) - hostPointAdded := int64(0) - agencyPointAdded := int64(0) - agencyPointShareBaseAmount := int64(0) - hostPointPolicy := walletPolicyInstance{} - hostPointPolicyActive := false - hostPointRegionID := command.TargetHostRegionID - if hostPointRegionID <= 0 { - hostPointRegionID = command.RegionID - } - if command.TargetIsHost && !command.RobotGift && chargeSource != giftChargeSourceBag { - hostPointPolicy, hostPointPolicyActive, err = r.resolveActiveWalletPolicy(ctx, tx, command.AppCode, hostPointRegionID, nowMs) + hostSalaryPolicyID := uint64(0) + hostSalaryPolicyVersion := uint64(0) + hostSalaryPolicyDiamondCoefficient := "" + hostSalaryPolicyDiamondCoefficientPPM := int64(0) + pointDiamondsPerUSD := int64(0) + coinsPerUSD := int64(0) + hostPeriodDiamondAdded := int64(0) + hostIncomePolicyType := "" + hostIncomeAssetType := "" + hostIncomeAmount := int64(0) + agencyPointAmount := int64(0) + agencyPointShareBPS := int32(0) + hostPeriodCycleKey := "" + if command.TargetIsHost && !command.RobotGift { + // 礼物类型倍率按主播区域解析,并在区域缺失时读取全局后台配置;两者都缺失会在扣币前失败。 + // Host 政策只作为额外系数,任何一层都不能由应用名或礼物类型代码推导默认倍率。 + hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) + giftTypeRatio, effectiveRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.TargetHostRegionID, giftConfig.GiftTypeCode) if err != nil { return ledger.Receipt{}, err } - if hostPointPolicyActive { - // 旧政策直接以 charge_amount 为主播基数;Fami 新政策显式开启 gift-type ratio 后, - // 必须先按主播所在区域和礼物类型得到原有钻石收益,再换算 POINT。两种模式都由运行快照决定, - // 不能按 app_code 写死,避免未来新 App 复制代码分支。 - hostGiftTypeRatio := giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000} - if hostPointPolicy.HostGiftTypeRatioEnabled { - hostGiftTypeRatio, giftDiamondRatioRegionID, err = r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.TargetHostRegionID, giftConfig.GiftTypeCode) + diamondCoefficient := hostPeriodDiamondCoefficientSnapshot{} + if resolvedCoefficient, found, resolveErr := r.resolveHostPeriodDiamondCoefficient(ctx, tx, command.TargetHostRegionID, hostPeriodCycleKey); resolveErr != nil { + return ledger.Receipt{}, resolveErr + } else if found { + diamondCoefficient = resolvedCoefficient + if diamondCoefficient.PPM > 0 { + hostIncomeAmount, err = giftDiamondAmountWithPolicyCoefficient(chargeAmount, giftTypeRatio.PPM, diamondCoefficient.PPM) if err != nil { return ledger.Receipt{}, err } - giftDiamondRatioPercent = hostGiftTypeRatio.Percent - giftDiamondRatioPPM = hostGiftTypeRatio.PPM - } - pointAmounts, err := calculateGiftPointPolicyAmounts(chargeAmount, hostGiftTypeRatio, hostPointPolicy) - if err != nil { - return ledger.Receipt{}, err - } - hostPointAdded = pointAmounts.HostPointAdded - // 命中新政策后不再给主播走旧 COIN 返还,避免同一笔礼物同时产生 COIN 收礼返还和 POINT 收益。 - giftIncomeCoinAmount = 0 - if command.TargetAgencyOwnerUserID > 0 && hostPointPolicy.AgencyPointRatioPPM > 0 { - // Agency 资格只跟随 gateway 注入并进入 wallet 幂等哈希的归属快照;用户不能自报 owner。 - agencyPointAdded = pointAmounts.AgencyPointAdded - agencyPointShareBaseAmount = pointAmounts.AgencyShareBaseAmount + hostIncomePolicyType = diamondCoefficient.PolicyType + switch diamondCoefficient.PolicyType { + case ledger.HostPolicyTypeSalaryDiamond: + hostIncomeAssetType = ledger.AssetHostPeriodDiamond + hostPeriodDiamondAdded = hostIncomeAmount + case ledger.HostPolicyTypePointDiamond: + hostIncomeAssetType = ledger.AssetPointDiamond + agencyPointShareBPS = diamondCoefficient.AgencyPointShareBPS + if command.TargetAgencyOwnerUserID > 0 { + agencyPointAmount, err = amountByBPS(hostIncomeAmount, agencyPointShareBPS) + if err != nil { + return ledger.Receipt{}, err + } + } + default: + return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "host policy type is invalid") + } } + } else { + // Host 收礼必须先命中当前月或紧邻上月继承的发布政策;零收益后继续扣币会制造不可追溯的资金缺口。 + return ledger.Receipt{}, xerr.New(xerr.PermissionDenied, "host income policy is not configured") } - } - hostPeriodDiamondAdded := int64(0) - hostPeriodCycleKey := "" - if command.TargetIsHost && !command.RobotGift && !hostPointPolicyActive { - // 主播周期钻石按主播身份所属区域结算,而不是房间可见区域或送礼人区域; - // 区域未配置时由解析器回退 region_id=0,保证历史全局配置继续生效。 - ratio, ratioRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.TargetHostRegionID, giftConfig.GiftTypeCode) - if err != nil { - return ledger.Receipt{}, err - } - - hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, ratio.PPM) - if err != nil { - return ledger.Receipt{}, err - } - giftDiamondRatioPercent = ratio.Percent - giftDiamondRatioPPM = ratio.PPM - giftDiamondRatioRegionID = ratioRegionID - hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) + giftDiamondRatioPercent = giftTypeRatio.Percent + giftDiamondRatioPPM = giftTypeRatio.PPM + giftDiamondRatioRegionID = effectiveRegionID + hostSalaryPolicyID = diamondCoefficient.PolicyID + hostSalaryPolicyVersion = diamondCoefficient.PolicyVersion + hostSalaryPolicyDiamondCoefficient = diamondCoefficient.Value + hostSalaryPolicyDiamondCoefficientPPM = diamondCoefficient.PPM + pointDiamondsPerUSD = diamondCoefficient.PointDiamondsPerUSD + coinsPerUSD = diamondCoefficient.CoinsPerUSD } accountAssetType := chargeAssetType @@ -167,8 +181,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm } var senderState *walletAccountState var targetCoinState *walletAccountState - var targetPointState *walletAccountState - var agencyPointState *walletAccountState + var targetPointDiamondState *walletAccountState + var agencyPointDiamondState *walletAccountState if command.RobotGift { sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, true, nowMs) @@ -189,17 +203,17 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm CreateIfMissing: true, }) } - if hostPointAdded > 0 { + if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 { lockRequests = append(lockRequests, walletAccountLockRequest{ UserID: command.TargetUserID, - AssetType: ledger.AssetPoint, + AssetType: ledger.AssetPointDiamond, CreateIfMissing: true, }) } - if agencyPointAdded > 0 { + if agencyPointAmount > 0 { lockRequests = append(lockRequests, walletAccountLockRequest{ UserID: command.TargetAgencyOwnerUserID, - AssetType: ledger.AssetPoint, + AssetType: ledger.AssetPointDiamond, CreateIfMissing: true, }) } @@ -211,11 +225,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm if giftIncomeCoinAmount > 0 { targetCoinState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetCoin)] } - if hostPointAdded > 0 { - targetPointState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetPoint)] + if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 { + targetPointDiamondState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetPointDiamond)] } - if agencyPointAdded > 0 { - agencyPointState = states[walletAccountLockKey(command.TargetAgencyOwnerUserID, ledger.AssetPoint)] + if agencyPointAmount > 0 { + agencyPointDiamondState = states[walletAccountLockKey(command.TargetAgencyOwnerUserID, ledger.AssetPointDiamond)] } } sender := senderState.account @@ -244,23 +258,6 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm senderAfter = giftIncomeBalanceAfter } } - hostPointBalanceAfter := int64(0) - if hostPointAdded > 0 { - if targetPointState == nil { - return ledger.Receipt{}, xerr.New(xerr.Internal, "host point account is missing") - } - hostPointBalanceAfter = targetPointState.account.AvailableAmount + hostPointAdded - } - agencyPointBalanceAfter := int64(0) - if agencyPointAdded > 0 { - if agencyPointState == nil { - return ledger.Receipt{}, xerr.New(xerr.Internal, "agency point account is missing") - } - agencyPointBalanceAfter = agencyPointState.account.AvailableAmount + agencyPointAdded - if agencyPointState == targetPointState { - agencyPointBalanceAfter += hostPointAdded - } - } transactionID := transactionID(command.AppCode, command.CommandID) // metadata 在账户更新前写入交易表,因此版本必须按本事务即将执行的 sender 分录精确预测; // 事务内实际更新仍由 wallet_accounts.version 乐观锁裁决,失败会整体回滚该预测值。 @@ -277,66 +274,64 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm GiftIconURL: giftDisplayIconURL(giftConfig.Resource), GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL), - GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes), - ResourceID: giftConfig.ResourceID, - ResourceSnapshot: mustJSON(giftConfig.Resource), - GiftTypeCode: giftConfig.GiftTypeCode, - CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON), - PresentationJSON: giftConfig.PresentationJSON, - SortOrder: giftConfig.SortOrder, - GiftCount: command.GiftCount, - PriceVersion: price.PriceVersion, - ChargeAssetType: chargeAssetType, - ChargeAmount: chargeAmount, - CoinSpent: chargeAmount, - ChargeSource: chargeSource, - EntitlementID: strings.TrimSpace(command.EntitlementID), - GiftPointAdded: 0, - HeatValue: heatValue, - BalanceAfter: senderAfter, - BalanceVersion: senderBalanceVersionAfter, - GiftIncomeCoinAmount: giftIncomeCoinAmount, - GiftIncomeRatioPercent: giftIncomeRatio.Percent, - GiftIncomeRatioRegionID: giftIncomeRatioRegionID, - GiftIncomeBalanceAfter: giftIncomeBalanceAfter, - RechargeSevenDayCoins: rechargeSnapshot.SevenDayCoins, - RechargeThirtyDayCoins: rechargeSnapshot.ThirtyDayCoins, - LastRechargedAtMS: rechargeSnapshot.LastAtMS, - PaidAtMS: nowMs, - BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), - SenderUserID: command.SenderUserID, - SenderRegionID: command.SenderRegionID, - TargetUserID: command.TargetUserID, - TargetIsHost: command.TargetIsHost, - TargetHostRegionID: command.TargetHostRegionID, - TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, - HostPeriodDiamondAdded: hostPeriodDiamondAdded, - GiftDiamondRatioPercent: giftDiamondRatioPercent, - GiftDiamondRatioPPM: giftDiamondRatioPPM, - GiftDiamondRatioRegionID: giftDiamondRatioRegionID, - HostPeriodCycleKey: hostPeriodCycleKey, - HostPointAssetType: hostPointPolicy.HostPointAsset, - HostPointAdded: hostPointAdded, - HostPointBalanceAfter: hostPointBalanceAfter, - HostPointPolicyInstanceCode: hostPointPolicy.InstanceCode, - HostPointTemplateCode: hostPointPolicy.TemplateCode, - HostPointTemplateVersion: hostPointPolicy.TemplateVersion, - HostPointRatioPPM: hostPointPolicy.HostPointRatioPPM, - AgencyPointAdded: agencyPointAdded, - AgencyPointBalanceAfter: agencyPointBalanceAfter, - AgencyPointRatioPPM: hostPointPolicy.AgencyPointRatioPPM, - AgencyPointShareBase: hostPointPolicy.AgencyPointShareBase, - AgencyPointShareBaseAmount: agencyPointShareBaseAmount, - PointsPerUSD: hostPointPolicy.PointsPerUSD, - RoomID: command.RoomID, - RoomRegionID: command.RegionID, - RoomContributionRatioPercent: roomContributionRatio.Percent, - RoomContributionRatioRegionID: roomContributionRatioRegionID, - CoinPrice: price.CoinPrice, - GiftPointAmount: 0, - HeatUnitValue: price.CoinPrice, - RobotGift: command.RobotGift, - DirectGift: command.DirectGift, + GiftEffectTypes: normalizeGiftEffectTypes(giftConfig.EffectTypes), + ResourceID: giftConfig.ResourceID, + ResourceSnapshot: mustJSON(giftConfig.Resource), + GiftTypeCode: giftConfig.GiftTypeCode, + CPRelationType: cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON), + PresentationJSON: giftConfig.PresentationJSON, + SortOrder: giftConfig.SortOrder, + GiftCount: command.GiftCount, + PriceVersion: price.PriceVersion, + ChargeAssetType: chargeAssetType, + ChargeAmount: chargeAmount, + CoinSpent: chargeAmount, + ChargeSource: chargeSource, + EntitlementID: strings.TrimSpace(command.EntitlementID), + GiftPointAdded: 0, + HeatValue: heatValue, + BalanceAfter: senderAfter, + BalanceVersion: senderBalanceVersionAfter, + GiftIncomeCoinAmount: giftIncomeCoinAmount, + GiftIncomeRatioPercent: giftIncomeRatio.Percent, + GiftIncomeRatioRegionID: giftIncomeRatioRegionID, + GiftIncomeBalanceAfter: giftIncomeBalanceAfter, + RechargeSevenDayCoins: rechargeSnapshot.SevenDayCoins, + RechargeThirtyDayCoins: rechargeSnapshot.ThirtyDayCoins, + LastRechargedAtMS: rechargeSnapshot.LastAtMS, + PaidAtMS: nowMs, + BillingReceipt: billingReceiptID(command.AppCode, command.CommandID), + SenderUserID: command.SenderUserID, + SenderRegionID: command.SenderRegionID, + TargetUserID: command.TargetUserID, + TargetIsHost: command.TargetIsHost, + TargetHostRegionID: command.TargetHostRegionID, + TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, + HostPeriodDiamondAdded: hostPeriodDiamondAdded, + HostIncomePolicyType: hostIncomePolicyType, + IncomeAssetType: hostIncomeAssetType, + HostBaseAmount: hostIncomeAmount, + AgencyAmount: agencyPointAmount, + AgencyPointShareBPS: agencyPointShareBPS, + GiftDiamondRatioPercent: giftDiamondRatioPercent, + GiftDiamondRatioPPM: giftDiamondRatioPPM, + GiftDiamondRatioRegionID: giftDiamondRatioRegionID, + HostSalaryPolicyID: hostSalaryPolicyID, + HostSalaryPolicyVersion: hostSalaryPolicyVersion, + PointDiamondsPerUSD: pointDiamondsPerUSD, + CoinsPerUSD: coinsPerUSD, + HostSalaryPolicyDiamondCoefficient: hostSalaryPolicyDiamondCoefficient, + HostSalaryPolicyDiamondCoefficientPPM: hostSalaryPolicyDiamondCoefficientPPM, + HostPeriodCycleKey: hostPeriodCycleKey, + RoomID: command.RoomID, + RoomRegionID: command.RegionID, + RoomContributionRatioPercent: roomContributionRatio.Percent, + RoomContributionRatioRegionID: roomContributionRatioRegionID, + CoinPrice: price.CoinPrice, + GiftPointAmount: 0, + HeatUnitValue: price.CoinPrice, + RobotGift: command.RobotGift, + DirectGift: command.DirectGift, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil { return ledger.Receipt{}, err @@ -422,18 +417,40 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } } - if hostPointAdded > 0 { - pointAccount, err := r.applyTrackedAccountDelta(ctx, tx, targetPointState, hostPointAdded, 0, nowMs) + if hostPeriodDiamondAdded > 0 { + + hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs) if err != nil { return ledger.Receipt{}, err } - metadata.HostPointBalanceAfter = pointAccount.AvailableAmount + metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter + metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion + metadata.HostIncomeBalanceAfter = hostPeriodDiamondAfter + } + if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 { + if targetPointDiamondState == nil { + return ledger.Receipt{}, xerr.New(xerr.Internal, "point diamond account is missing") + } + combinedAmount := hostIncomeAmount + if agencyPointAmount > 0 && command.TargetAgencyOwnerUserID == command.TargetUserID { + combinedAmount, err = checkedAdd(combinedAmount, agencyPointAmount) + if err != nil { + return ledger.Receipt{}, err + } + } + pointAccount, err := r.applyTrackedAccountDelta(ctx, tx, targetPointDiamondState, combinedAmount, 0, nowMs) + if err != nil { + return ledger.Receipt{}, err + } + metadata.HostIncomeBalanceAfter = pointAccount.AvailableAmount + if command.TargetAgencyOwnerUserID == command.TargetUserID && agencyPointAmount > 0 { + metadata.AgencyIncomeBalanceAfter = pointAccount.AvailableAmount + } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.TargetUserID, - AssetType: ledger.AssetPoint, - AvailableDelta: hostPointAdded, - FrozenDelta: 0, + AssetType: ledger.AssetPointDiamond, + AvailableDelta: combinedAmount, AvailableAfter: pointAccount.AvailableAmount, FrozenAfter: pointAccount.FrozenAmount, CounterpartyUserID: command.SenderUserID, @@ -443,42 +460,33 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } } - if agencyPointAdded > 0 { - agencyPointAccount, err := r.applyTrackedAccountDelta(ctx, tx, agencyPointState, agencyPointAdded, 0, nowMs) + if agencyPointAmount > 0 && command.TargetAgencyOwnerUserID != command.TargetUserID { + if agencyPointDiamondState == nil { + return ledger.Receipt{}, xerr.New(xerr.Internal, "agency point diamond account is missing") + } + agencyAccount, err := r.applyTrackedAccountDelta(ctx, tx, agencyPointDiamondState, agencyPointAmount, 0, nowMs) if err != nil { return ledger.Receipt{}, err } - metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount - if agencyPointState == targetPointState { - // owner=self 的两个角色共享一个 POINT 账户;同步回执和 HostPointCredited 必须返回整笔 - // 交易完成后的最终余额,而不是只完成主播分录时的中间余额。 - metadata.HostPointBalanceAfter = agencyPointAccount.AvailableAmount - } + metadata.AgencyIncomeBalanceAfter = agencyAccount.AvailableAmount if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.TargetAgencyOwnerUserID, - AssetType: ledger.AssetPoint, - AvailableDelta: agencyPointAdded, - AvailableAfter: agencyPointAccount.AvailableAmount, - FrozenAfter: agencyPointAccount.FrozenAmount, + AssetType: ledger.AssetPointDiamond, + AvailableDelta: agencyPointAmount, + AvailableAfter: agencyAccount.AvailableAmount, + FrozenAfter: agencyAccount.FrozenAmount, CounterpartyUserID: command.SenderUserID, RoomID: command.RoomID, CreatedAtMS: nowMs, }); err != nil { return ledger.Receipt{}, err } - if err := r.insertAgencyPointShare(ctx, tx, transactionID, metadata, nowMs); err != nil { - return ledger.Receipt{}, err - } } - if hostPeriodDiamondAdded > 0 { - - hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs) - if err != nil { - return ledger.Receipt{}, err - } - metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter - metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion + // 收益投影与钱包分录共享事务:任一侧失败都会回滚,查询因此不需要扫描或解析 + // wallet_transactions.metadata_json,也不会把自营主播合并后的钱包 delta 全算作 Host。 + if err := r.insertPointDiamondGiftIncomeProjection(ctx, tx, transactionID, metadata, nowMs); err != nil { + return ledger.Receipt{}, err } // The transaction row is inserted before ledger entries, so its first JSON value is necessarily a // provisional snapshot. Replace it with the locked account's actual post-mutation version inside the @@ -506,34 +514,22 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm if giftIncomeCoinAmount > 0 { events = append(events, giftIncomeCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) } - sharedHostAgencyPointAccount := hostPointAdded > 0 && agencyPointAdded > 0 && command.TargetUserID == command.TargetAgencyOwnerUserID - sharedPointDelta := int64(0) - if sharedHostAgencyPointAccount { - sharedPointDelta, err = checkedAdd(hostPointAdded, agencyPointAdded) + } + if hostPeriodDiamondAdded > 0 { + events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) + } + if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 { + hostDelta := hostIncomeAmount + if command.TargetAgencyOwnerUserID == command.TargetUserID { + hostDelta, err = checkedAdd(hostDelta, agencyPointAmount) if err != nil { return ledger.Receipt{}, err } } - if hostPointAdded > 0 { - if sharedHostAgencyPointAccount { - // 公会长自己收礼时两种角色写同一个 POINT 账户。账本仍保留两条分录和两个角色事实, - // 但通用余额事件的幂等 ID 只含 transaction/user/asset;必须合并成最终净变动,否则第二条 - // WalletBalanceChanged 会撞主键并让整笔扣费事务回滚。 - events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, sharedPointDelta, 0, metadata.AgencyPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType)) - } else { - events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, hostPointAdded, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType)) - } - events = append(events, hostPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) - } - if agencyPointAdded > 0 { - if !sharedHostAgencyPointAccount { - events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetAgencyOwnerUserID, ledger.AssetPoint, agencyPointAdded, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizType)) - } - events = append(events, agencyPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) - } + events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPointDiamond, hostDelta, 0, metadata.HostIncomeBalanceAfter, targetPointDiamondState.account.FrozenAmount, targetPointDiamondState.account.Version, metadata, nowMs, bizType)) } - if hostPeriodDiamondAdded > 0 { - events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) + if agencyPointAmount > 0 && command.TargetAgencyOwnerUserID != command.TargetUserID { + events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetAgencyOwnerUserID, ledger.AssetPointDiamond, agencyPointAmount, 0, metadata.AgencyIncomeBalanceAfter, agencyPointDiamondState.account.FrozenAmount, agencyPointDiamondState.account.Version, metadata, nowMs, bizType)) } if err := r.insertWalletOutbox(ctx, tx, events); err != nil { return ledger.Receipt{}, err @@ -544,3 +540,57 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return receiptFromGiftMetadata(transactionID, metadata), nil } + +// lookupDirectGiftReceiptTransaction 兼容 request_hash 改为“只含稳定意图”之前创建的 direct gift。 +// 历史 hash 含 region/Host/Agency 派生字段,重试时无法也不应重查这些旧值;只有 Wallet 自身 metadata +// 中的稳定入参与当前请求逐项一致才允许恢复回执,目标、礼物或扣费来源变化仍返回幂等冲突。 +func (r *Repository) lookupDirectGiftReceiptTransaction(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, requestHash string) (transactionRow, bool, error) { + row := tx.QueryRowContext(ctx, + `SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND command_id = ?`, + appcode.FromContext(ctx), + command.CommandID, + ) + var txRow transactionRow + var storedHash string + var storedBizType string + if err := row.Scan(&txRow.TransactionID, &storedHash, &storedBizType, &txRow.MetadataJSON); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return transactionRow{}, false, nil + } + return transactionRow{}, false, err + } + if storedBizType != bizTypeDirectGiftDebit { + return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict") + } + if storedHash == requestHash { + return txRow, true, nil + } + + var metadata giftMetadata + if err := json.Unmarshal([]byte(txRow.MetadataJSON), &metadata); err != nil { + return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict") + } + priceVersion := strings.TrimSpace(command.PriceVersion) + if normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID) == giftChargeSourceBag { + priceVersion = "" + } + metadataPriceVersion := strings.TrimSpace(metadata.PriceVersion) + if normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID) == giftChargeSourceBag { + metadataPriceVersion = "" + } + if !metadata.DirectGift || + metadata.RoomID != command.RoomID || + metadata.SenderUserID != command.SenderUserID || + metadata.TargetUserID != command.TargetUserID || + strings.TrimSpace(metadata.GiftID) != strings.TrimSpace(command.GiftID) || + metadata.GiftCount != command.GiftCount || + metadataPriceVersion != priceVersion || + metadata.RobotGift != command.RobotGift || + strings.TrimSpace(metadata.EntitlementID) != strings.TrimSpace(command.EntitlementID) || + normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID) != normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID) { + return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict") + } + return txRow, true, nil +} diff --git a/services/wallet-service/internal/storage/mysql/gift_ledger_helpers.go b/services/wallet-service/internal/storage/mysql/gift_ledger_helpers.go index f4ed063f..1f0a96d7 100644 --- a/services/wallet-service/internal/storage/mysql/gift_ledger_helpers.go +++ b/services/wallet-service/internal/storage/mysql/gift_ledger_helpers.go @@ -59,6 +59,12 @@ func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Rece if aggregate.HostPeriodDiamondAdded, err = checkedAdd(aggregate.HostPeriodDiamondAdded, receipt.HostPeriodDiamondAdded); err != nil { return ledger.Receipt{}, err } + if aggregate.HostBaseAmount, err = checkedAdd(aggregate.HostBaseAmount, receipt.HostBaseAmount); err != nil { + return ledger.Receipt{}, err + } + if aggregate.AgencyAmount, err = checkedAdd(aggregate.AgencyAmount, receipt.AgencyAmount); err != nil { + return ledger.Receipt{}, err + } if aggregate.HostPointAdded, err = checkedAdd(aggregate.HostPointAdded, receipt.HostPointAdded); err != nil { return ledger.Receipt{}, err } @@ -88,6 +94,16 @@ func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Rece if aggregate.HostPeriodCycleKey == "" { aggregate.HostPeriodCycleKey = receipt.HostPeriodCycleKey } + if aggregate.HostIncomePolicyType == "" { + aggregate.HostIncomePolicyType = receipt.HostIncomePolicyType + } else if receipt.HostIncomePolicyType != "" && aggregate.HostIncomePolicyType != receipt.HostIncomePolicyType { + aggregate.HostIncomePolicyType = "MIXED" + } + if aggregate.IncomeAssetType == "" { + aggregate.IncomeAssetType = receipt.IncomeAssetType + } else if receipt.IncomeAssetType != "" && aggregate.IncomeAssetType != receipt.IncomeAssetType { + aggregate.IncomeAssetType = "MIXED" + } if aggregate.HostPointAssetType == "" { aggregate.HostPointAssetType = receipt.HostPointAssetType } @@ -111,6 +127,8 @@ func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Rece aggregate.BalanceAfter = receipt.BalanceAfter aggregate.BalanceVersion = receipt.BalanceVersion aggregate.HostPointBalanceAfter = receipt.HostPointBalanceAfter + aggregate.HostIncomeBalanceAfter = receipt.HostIncomeBalanceAfter + aggregate.AgencyIncomeBalanceAfter = receipt.AgencyIncomeBalanceAfter } aggregate.BillingReceiptID = strings.Join(billingReceiptIDs, ",") aggregate.TransactionID = strings.Join(transactionIDs, ",") @@ -127,7 +145,9 @@ func debitRequestHash(command ledger.DebitGiftCommand) string { if command.DirectGift { // direct gift 没有 Room Cell 命令,必须把场景写进幂等摘要,避免和空 room_id 的内部兼容调用互相复用。 - return stableHash(fmt.Sprintf("direct_gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t|%s|%s", + // region、Host 身份和 Agency 归属均由服务端实时解析并固化在首次回执,不属于客户端稳定意图; + // 重试时这些派生值变化不能把已成功 command_id 误判为冲突。 + return stableHash(fmt.Sprintf("direct_gift|%s|%s|%d|%d|%s|%d|%s|%t|%s|%s", appcode.Normalize(command.AppCode), command.RoomID, command.SenderUserID, @@ -135,18 +155,13 @@ func debitRequestHash(command ledger.DebitGiftCommand) string { command.GiftID, command.GiftCount, priceVersion, - command.RegionID, - command.SenderRegionID, - command.TargetIsHost, - command.TargetHostRegionID, - command.TargetAgencyOwnerUserID, command.RobotGift, strings.TrimSpace(command.EntitlementID), normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID), )) } - return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t|%s|%s", + return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%t|%s|%s", appcode.Normalize(command.AppCode), command.RoomID, command.SenderUserID, @@ -154,11 +169,6 @@ func debitRequestHash(command ledger.DebitGiftCommand) string { command.GiftID, command.GiftCount, priceVersion, - command.RegionID, - command.SenderRegionID, - command.TargetIsHost, - command.TargetHostRegionID, - command.TargetAgencyOwnerUserID, command.RobotGift, strings.TrimSpace(command.EntitlementID), normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID), diff --git a/services/wallet-service/internal/storage/mysql/gift_point_policy.go b/services/wallet-service/internal/storage/mysql/gift_point_policy.go deleted file mode 100644 index 3a97b7c1..00000000 --- a/services/wallet-service/internal/storage/mysql/gift_point_policy.go +++ /dev/null @@ -1,53 +0,0 @@ -package mysql - -import "hyapp/pkg/xerr" - -// giftPointPolicyAmounts 把一次付费礼物拆成主播 POINT 与 Agency POINT 两个独立账务分量。 -// AgencyShareBaseAmount 一并固化到审计流水,避免策略发布后只能看到 20% 却无法证明当时的计算基数。 -type giftPointPolicyAmounts struct { - HostPointAdded int64 - AgencyPointAdded int64 - AgencyShareBaseAmount int64 -} - -// calculateGiftPointPolicyAmounts 只处理金额公式,不决定 Agency 资格;owner 快照仍由 gateway 在 -// 送礼受理时从 user-service 获取,并由调用方在 owner_id>0 时才真正写 Agency 账。 -func calculateGiftPointPolicyAmounts(chargeAmount int64, giftTypeRatio giftDiamondRatioSnapshot, policy walletPolicyInstance) (giftPointPolicyAmounts, error) { - if chargeAmount < 0 || giftTypeRatio.PPM < 0 || giftTypeRatio.PPM > 1_000_000 { - return giftPointPolicyAmounts{}, xerr.New(xerr.InvalidArgument, "gift point policy amount is invalid") - } - - hostBaseAmount := chargeAmount - if policy.HostGiftTypeRatioEnabled { - // Fami 的普通/幸运/超级幸运礼物先沿用主播原有 100%/10%/1% 钻石口径;区域覆盖也在 - // 调用本函数前由 wallet owner 解析。这里保留两阶段向下取整,严格对应“主播实收后的 20%”。 - var err error - hostBaseAmount, err = giftDiamondAmount(chargeAmount, giftTypeRatio.PPM) - if err != nil { - return giftPointPolicyAmounts{}, err - } - } - hostPointAdded, err := giftDiamondAmount(hostBaseAmount, policy.HostPointRatioPPM) - if err != nil { - return giftPointPolicyAmounts{}, err - } - - agencyShareBaseAmount := chargeAmount - switch policy.AgencyPointShareBase { - case agencyPointShareBaseChargeAmount: - // 旧政策直接从礼物实扣金额切分 Agency POINT,保持已发布 Huwaa/Fami 历史实例兼容。 - case agencyPointShareBaseHostIncome: - agencyShareBaseAmount = hostPointAdded - default: - return giftPointPolicyAmounts{}, xerr.New(xerr.InvalidArgument, "wallet policy agency share base is invalid") - } - agencyPointAdded, err := giftDiamondAmount(agencyShareBaseAmount, policy.AgencyPointRatioPPM) - if err != nil { - return giftPointPolicyAmounts{}, err - } - return giftPointPolicyAmounts{ - HostPointAdded: hostPointAdded, - AgencyPointAdded: agencyPointAdded, - AgencyShareBaseAmount: agencyShareBaseAmount, - }, nil -} diff --git a/services/wallet-service/internal/storage/mysql/gift_point_policy_test.go b/services/wallet-service/internal/storage/mysql/gift_point_policy_test.go deleted file mode 100644 index 39f5ba4a..00000000 --- a/services/wallet-service/internal/storage/mysql/gift_point_policy_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package mysql - -import "testing" - -func TestCalculateGiftPointPolicyAmountsUsesActualHostIncomeForAgencyBonus(t *testing.T) { - policy := walletPolicyInstance{ - HostPointRatioPPM: 1_000_000, - HostGiftTypeRatioEnabled: true, - AgencyPointRatioPPM: 200_000, - AgencyPointShareBase: agencyPointShareBaseHostIncome, - } - cases := []struct { - name string - typeRatio int64 - hostPoint int64 - agencyPoint int64 - }{ - {name: "normal", typeRatio: 1_000_000, hostPoint: 10_000, agencyPoint: 2_000}, - {name: "lucky", typeRatio: 100_000, hostPoint: 1_000, agencyPoint: 200}, - {name: "super lucky", typeRatio: 10_000, hostPoint: 100, agencyPoint: 20}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - amounts, err := calculateGiftPointPolicyAmounts(10_000, giftDiamondRatioSnapshot{PPM: tc.typeRatio}, policy) - if err != nil { - t.Fatalf("calculate gift point policy amounts: %v", err) - } - if amounts.HostPointAdded != tc.hostPoint || amounts.AgencyShareBaseAmount != tc.hostPoint || amounts.AgencyPointAdded != tc.agencyPoint { - t.Fatalf("unexpected amounts: %+v", amounts) - } - }) - } -} - -func TestCalculateGiftPointPolicyAmountsPreservesLegacyChargeBase(t *testing.T) { - amounts, err := calculateGiftPointPolicyAmounts(10_000, giftDiamondRatioSnapshot{PPM: 10_000}, walletPolicyInstance{ - HostPointRatioPPM: 700_000, - AgencyPointRatioPPM: 200_000, - AgencyPointShareBase: agencyPointShareBaseChargeAmount, - }) - if err != nil { - t.Fatalf("calculate legacy policy amounts: %v", err) - } - // 旧策略没有开启礼物类型倍率,并直接按实扣金额计算 Agency 分成,确保已发布的 70%+20% 快照不变义。 - if amounts.HostPointAdded != 7_000 || amounts.AgencyShareBaseAmount != 10_000 || amounts.AgencyPointAdded != 2_000 { - t.Fatalf("unexpected legacy amounts: %+v", amounts) - } -} - -func TestCalculateGiftPointPolicyAmountsRoundsAgencyFromRoundedHostIncome(t *testing.T) { - amounts, err := calculateGiftPointPolicyAmounts(499, giftDiamondRatioSnapshot{PPM: 10_000}, walletPolicyInstance{ - HostPointRatioPPM: 1_000_000, - HostGiftTypeRatioEnabled: true, - AgencyPointRatioPPM: 200_000, - AgencyPointShareBase: agencyPointShareBaseHostIncome, - }) - if err != nil { - t.Fatalf("calculate rounded policy amounts: %v", err) - } - // 499 * 1% 先向下取整为主播实收 4;Agency 再按该 4 的 20% 向下取整为 0,不能从原价直接算出 99。 - if amounts.HostPointAdded != 4 || amounts.AgencyShareBaseAmount != 4 || amounts.AgencyPointAdded != 0 { - t.Fatalf("unexpected rounded amounts: %+v", amounts) - } -} diff --git a/services/wallet-service/internal/storage/mysql/gift_pricing.go b/services/wallet-service/internal/storage/mysql/gift_pricing.go index 97581a3c..1c26c4ca 100644 --- a/services/wallet-service/internal/storage/mysql/gift_pricing.go +++ b/services/wallet-service/internal/storage/mysql/gift_pricing.go @@ -4,12 +4,12 @@ import ( "context" "database/sql" "errors" + "math/big" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" - resourcedomain "hyapp/services/wallet-service/internal/domain/resource" ) type giftPrice struct { @@ -89,7 +89,9 @@ func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, ap return ratio, regionID, nil } } - return defaultGiftDiamondRatio(giftTypeCode), 0, nil + // 礼物类型倍率属于后台运行政策。区域和全局都未配置时必须在扣币前失败, + // 不能用 normal/lucky/super_lucky 编译期常量悄悄制造 Host 收益。 + return giftDiamondRatioSnapshot{}, 0, xerr.New(xerr.PermissionDenied, "gift diamond ratio is not configured") } func (r *Repository) resolveGlobalGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) { @@ -118,7 +120,7 @@ func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCod } percent = strings.TrimSpace(percent) if percent == "" { - percent = "100.00" + return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid") } return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil } @@ -141,7 +143,9 @@ func (r *Repository) resolveGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, return ratio, regionID, nil } } - return defaultGiftReturnCoinRatio(giftTypeCode), 0, nil + // 返金币与主播钻石倍率共用后台配置行,但业务含义独立。区域和全局均无记录时 + // 必须在账户锁和扣币前失败,不能再按 gift_type 推导 30/10/1。 + return giftDiamondRatioSnapshot{}, 0, xerr.New(xerr.PermissionDenied, "gift return coin ratio is not configured") } func (r *Repository) getGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) { @@ -165,7 +169,7 @@ func (r *Repository) getGiftReturnCoinRatio(ctx context.Context, tx *sql.Tx, app } percent = strings.TrimSpace(percent) if percent == "" { - percent = defaultGiftReturnCoinRatio(giftTypeCode).Percent + return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift return coin ratio is invalid") } return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil } @@ -185,26 +189,120 @@ func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) { return product / 1_000_000, nil } -func defaultGiftDiamondRatio(giftTypeCode string) giftDiamondRatioSnapshot { - switch strings.TrimSpace(giftTypeCode) { - case "lucky": - return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000} - case "super_lucky": - return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000} - default: - return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000} +// giftDiamondAmountWithPolicyCoefficient 在一次定点运算中完成 +// 金币 * 礼物类型倍率 * Host 政策系数,最后统一向下取整。 +// 不能先算礼物倍率再乘政策系数,否则两次整数截断会让小额礼物少记周期钻石。 +func giftDiamondAmountWithPolicyCoefficient(chargeAmount int64, giftTypeRatioPPM int64, policyCoefficientPPM int64) (int64, error) { + if chargeAmount < 0 || giftTypeRatioPPM < 0 || policyCoefficientPPM <= 0 { + return 0, xerr.New(xerr.InvalidArgument, "host period diamond amount is invalid") } + if chargeAmount == 0 || giftTypeRatioPPM == 0 { + return 0, nil + } + + // 系数允许 6 位小数,两个 ppm 分母合并为 10^12。big.Int 只用于防止合法大额 + // 在中间乘法溢出;最终值仍必须落入账本的 BIGINT 边界。 + amount := new(big.Int).SetInt64(chargeAmount) + amount.Mul(amount, big.NewInt(giftTypeRatioPPM)) + amount.Mul(amount, big.NewInt(policyCoefficientPPM)) + amount.Quo(amount, big.NewInt(1_000_000_000_000)) + if !amount.IsInt64() { + return 0, xerr.New(xerr.InvalidArgument, "host period diamond amount overflow") + } + return amount.Int64(), nil } -func defaultGiftReturnCoinRatio(giftTypeCode string) giftDiamondRatioSnapshot { - switch strings.TrimSpace(giftTypeCode) { - case resourcedomain.GiftTypeLucky: - return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000} - case resourcedomain.GiftTypeSuperLucky: - return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000} - default: - return giftDiamondRatioSnapshot{Percent: "30.00", PPM: 300_000} +func amountByBPS(amount int64, bps int32) (int64, error) { + if amount < 0 || bps < 0 || bps > 10_000 { + return 0, xerr.New(xerr.InvalidArgument, "income share bps is invalid") } + if amount == 0 || bps == 0 { + return 0, nil + } + value := new(big.Int).Mul(big.NewInt(amount), big.NewInt(int64(bps))) + value.Quo(value, big.NewInt(10_000)) + if !value.IsInt64() { + return 0, xerr.New(xerr.InvalidArgument, "income share amount overflow") + } + return value.Int64(), nil +} + +type hostPeriodDiamondCoefficientSnapshot struct { + PolicyID uint64 + PolicyVersion uint64 + PolicyType string + Value string + PPM int64 + PointDiamondsPerUSD int64 + CoinsPerUSD int64 + MinimumWithdrawUSDMinor int64 + WithdrawFeeBPS int32 + AgencyPointShareBPS int32 +} + +// resolveHostPolicyDiamondCoefficient 只读取主播区域在当前周期绑定的已发布 Host 政策。 +// 当前月缺少显式绑定时仅继承紧邻上月;完全没有政策时返回 found=false,调用方不得用代码默认系数累计周期钻石。 +func (r *Repository) resolveHostPolicyDiamondCoefficient(ctx context.Context, tx *sql.Tx, regionID int64, cycleKey string) (hostPeriodDiamondCoefficientSnapshot, bool, error) { + query := func() (hostPeriodDiamondCoefficientSnapshot, bool, error) { + var snapshot hostPeriodDiamondCoefficientSnapshot + err := tx.QueryRowContext(ctx, ` + SELECT p.policy_id, p.policy_version, p.policy_type, + CAST(p.gift_coin_to_diamond_ratio AS CHAR), + CAST(ROUND(p.gift_coin_to_diamond_ratio * 1000000) AS SIGNED), + p.point_diamonds_per_usd, p.coins_per_usd, p.minimum_withdraw_usd_minor, p.withdraw_fee_bps, + p.agency_point_share_bps + FROM host_salary_policy_cycle_bindings binding + JOIN host_agency_salary_policies p + ON p.app_code = binding.app_code + AND p.policy_id = binding.policy_id + AND p.policy_version = binding.policy_version + WHERE binding.app_code = ? + AND binding.region_id = ? + AND binding.cycle_key = ? + AND p.status = 'active' + LIMIT 1`, appcode.FromContext(ctx), regionID, cycleKey).Scan( + &snapshot.PolicyID, &snapshot.PolicyVersion, &snapshot.PolicyType, &snapshot.Value, &snapshot.PPM, + &snapshot.PointDiamondsPerUSD, &snapshot.CoinsPerUSD, &snapshot.MinimumWithdrawUSDMinor, &snapshot.WithdrawFeeBPS, + &snapshot.AgencyPointShareBPS, + ) + if errors.Is(err, sql.ErrNoRows) { + return hostPeriodDiamondCoefficientSnapshot{}, false, nil + } + if err != nil { + return hostPeriodDiamondCoefficientSnapshot{}, false, err + } + if snapshot.PPM <= 0 { + return hostPeriodDiamondCoefficientSnapshot{}, false, xerr.New(xerr.InvalidArgument, "host policy diamond coefficient is invalid") + } + snapshot.PolicyType = strings.ToUpper(strings.TrimSpace(snapshot.PolicyType)) + if snapshot.PolicyType != ledger.HostPolicyTypeSalaryDiamond && snapshot.PolicyType != ledger.HostPolicyTypePointDiamond { + return hostPeriodDiamondCoefficientSnapshot{}, false, xerr.New(xerr.InvalidArgument, "host policy type is invalid") + } + if snapshot.AgencyPointShareBPS < 0 || snapshot.AgencyPointShareBPS > 10_000 { + return hostPeriodDiamondCoefficientSnapshot{}, false, xerr.New(xerr.InvalidArgument, "agency point share bps is invalid") + } + snapshot.Value = strings.TrimSpace(snapshot.Value) + return snapshot, true, nil + } + + snapshot, found, err := query() + if err != nil || found { + return snapshot, found, err + } + previousCycleKey, err := previousHostSalaryCycleKey(cycleKey) + if err != nil { + return hostPeriodDiamondCoefficientSnapshot{}, false, nil + } + if err := r.inheritHostSalaryPolicyBinding(ctx, tx, regionID, previousCycleKey, cycleKey, "", ""); err != nil { + return hostPeriodDiamondCoefficientSnapshot{}, false, err + } + return query() +} + +// resolveHostPeriodDiamondCoefficient 只返回后台发布并绑定的 Host 政策。缺失政策时不产生任何主播钻石收益, +// 从而保证运行口径完全来自政策快照,而不是产品名或代码默认值。 +func (r *Repository) resolveHostPeriodDiamondCoefficient(ctx context.Context, tx *sql.Tx, regionID int64, cycleKey string) (hostPeriodDiamondCoefficientSnapshot, bool, error) { + return r.resolveHostPolicyDiamondCoefficient(ctx, tx, regionID, cycleKey) } func (r *Repository) resolveRechargePolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMs int64) (rechargePolicy, error) { diff --git a/services/wallet-service/internal/storage/mysql/host_revenue_stats.go b/services/wallet-service/internal/storage/mysql/host_revenue_stats.go index 21b05da9..d7907ec0 100644 --- a/services/wallet-service/internal/storage/mysql/host_revenue_stats.go +++ b/services/wallet-service/internal/storage/mysql/host_revenue_stats.go @@ -9,8 +9,9 @@ import ( "hyapp/services/wallet-service/internal/domain/ledger" ) -// GetHostRevenueStats 的“钻石收益/送礼人数”只读主播周期钻石流水; -// POINT 是可兑换资产,任务奖励、Agency 分成和提现退回都不能被误算成收礼钻石。 +// GetHostRevenueStats 合并两种 Host 政策各自的送礼事实:SALARY_DIAMOND 保持读取周期钻石流水, +// POINT_DIAMOND 只读取事务内角色拆分投影。它不读取钱包余额或通用分录,因此任务奖励、Agency +// 分成、兑换、提现和退回都不会被误算成主播收礼收益。 func (r *Repository) GetHostRevenueStats(ctx context.Context, query ledger.HostRevenueStatsQuery) (ledger.HostRevenueStats, error) { if r == nil || r.db == nil { return ledger.HostRevenueStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") @@ -18,34 +19,47 @@ func (r *Repository) GetHostRevenueStats(ctx context.Context, query ledger.HostR var stats ledger.HostRevenueStats startCycle := time.UnixMilli(query.StartAtMS).UTC().Format("2006-01") endCycle := time.UnixMilli(query.EndAtMS - 1).UTC().Format("2006-01") - // (app_code,user_id,cycle_key,created_at_ms) 索引先限定单主播和最多 13 个周期, - // 再在已收敛的送礼流水上按时间求和/去重,不扫全表也不解析大 JSON 字段。 + // 两个 UNION 分支分别命中 (app,user,cycle,time) 与 (app,host,time);外层只对已经按 + // 用户/时间收敛的行去重 sender。这样政策在查询区间内切换时,同一送礼人也只计一次。 if err := r.db.QueryRowContext(ctx, ` SELECT - COALESCE(SUM(diamond_delta), 0), + COALESCE(SUM(CASE WHEN income_type = 'SALARY_DIAMOND' THEN host_base_amount ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN income_type = 'POINT_DIAMOND' THEN host_base_amount ELSE 0 END), 0), COUNT(DISTINCT CASE WHEN sender_user_id > 0 THEN sender_user_id END) - FROM host_period_diamond_entries FORCE INDEX (idx_host_period_diamond_entries_user_cycle) - WHERE app_code = ? AND user_id = ? AND cycle_key BETWEEN ? AND ? - AND created_at_ms >= ? AND created_at_ms < ?`, + FROM ( + SELECT diamond_delta AS host_base_amount, sender_user_id, 'SALARY_DIAMOND' AS income_type + FROM host_period_diamond_entries FORCE INDEX (idx_host_period_diamond_entries_user_cycle) + WHERE app_code = ? AND user_id = ? AND cycle_key BETWEEN ? AND ? + AND created_at_ms >= ? AND created_at_ms < ? + UNION ALL + SELECT host_base_amount, sender_user_id, 'POINT_DIAMOND' AS income_type + FROM point_diamond_gift_income_entries FORCE INDEX (idx_point_diamond_gift_host_time) + WHERE app_code = ? AND host_user_id = ? + AND created_at_ms >= ? AND created_at_ms < ? + ) AS gift_income`, appcode.FromContext(ctx), query.HostUserID, startCycle, endCycle, query.StartAtMS, query.EndAtMS, - ).Scan(&stats.DiamondEarnings, &stats.GiftSenders); err != nil { + appcode.FromContext(ctx), query.HostUserID, query.StartAtMS, query.EndAtMS, + ).Scan(&stats.DiamondEarnings, &stats.PointDiamondEarnings, &stats.GiftSenders); err != nil { return ledger.HostRevenueStats{}, err } - // “已兑换”保留现有 POINT 资产口径,但只计用户主动兑金币、转币商和平台提现首次冻结; - // 审核扣 frozen 、驳回释放和其他 POINT 变动不重复计入。 + // 旧 Lalu/POINT 与永久钻石积分分别聚合;旧字段绝不能混入任务 POINT 以外的新资产, + // POINT_DIAMOND 在政策切回工资后仍能通过独立字段保留历史转出。 if err := r.db.QueryRowContext(ctx, ` - SELECT COALESCE(SUM(-e.available_delta), 0) + SELECT + COALESCE(SUM(CASE WHEN e.asset_type = ? THEN -e.available_delta ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN e.asset_type = ? THEN -e.available_delta ELSE 0 END), 0) FROM wallet_entries e FORCE INDEX (idx_wallet_entries_asset_user_time) INNER JOIN wallet_transactions t ON t.app_code = e.app_code AND t.transaction_id = e.transaction_id - WHERE e.app_code = ? AND e.user_id = ? AND e.asset_type = ? + WHERE e.app_code = ? AND e.user_id = ? AND e.asset_type IN (?, ?) AND e.created_at_ms >= ? AND e.created_at_ms < ? AND e.available_delta < 0 AND t.biz_type IN (?, ?, ?)`, - appcode.FromContext(ctx), query.HostUserID, ledger.AssetPoint, query.StartAtMS, query.EndAtMS, + ledger.AssetPoint, ledger.AssetPointDiamond, + appcode.FromContext(ctx), query.HostUserID, ledger.AssetPoint, ledger.AssetPointDiamond, query.StartAtMS, query.EndAtMS, bizTypePointExchangeToCoin, bizTypePointTransferToCoinSeller, bizTypeSalaryWithdrawalFreeze, - ).Scan(&stats.DiamondExchanged); err != nil { + ).Scan(&stats.DiamondExchanged, &stats.PointDiamondExchanged); err != nil { return ledger.HostRevenueStats{}, err } return stats, nil diff --git a/services/wallet-service/internal/storage/mysql/host_revenue_stats_test.go b/services/wallet-service/internal/storage/mysql/host_revenue_stats_test.go index 9b3f5cb0..9971fd90 100644 --- a/services/wallet-service/internal/storage/mysql/host_revenue_stats_test.go +++ b/services/wallet-service/internal/storage/mysql/host_revenue_stats_test.go @@ -10,7 +10,7 @@ import ( "hyapp/services/wallet-service/internal/domain/ledger" ) -func TestGetHostRevenueStatsAggregatesHostDiamondsAndPointExchanges(t *testing.T) { +func TestGetHostRevenueStatsAggregatesOnlyHostPolicyDiamonds(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("create sqlmock: %v", err) @@ -19,26 +19,37 @@ func TestGetHostRevenueStatsAggregatesHostDiamondsAndPointExchanges(t *testing.T mock.ExpectQuery(regexp.QuoteMeta(` SELECT - COALESCE(SUM(diamond_delta), 0), + COALESCE(SUM(CASE WHEN income_type = 'SALARY_DIAMOND' THEN host_base_amount ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN income_type = 'POINT_DIAMOND' THEN host_base_amount ELSE 0 END), 0), COUNT(DISTINCT CASE WHEN sender_user_id > 0 THEN sender_user_id END) - FROM host_period_diamond_entries FORCE INDEX (idx_host_period_diamond_entries_user_cycle) - WHERE app_code = ? AND user_id = ? AND cycle_key BETWEEN ? AND ? - AND created_at_ms >= ? AND created_at_ms < ?`)). - WithArgs("fami", int64(42), "1970-01", "1970-01", int64(1000), int64(2000)). - WillReturnRows(sqlmock.NewRows([]string{"earnings", "senders"}).AddRow(int64(8800), int64(7))) + FROM ( + SELECT diamond_delta AS host_base_amount, sender_user_id, 'SALARY_DIAMOND' AS income_type + FROM host_period_diamond_entries FORCE INDEX (idx_host_period_diamond_entries_user_cycle) + WHERE app_code = ? AND user_id = ? AND cycle_key BETWEEN ? AND ? + AND created_at_ms >= ? AND created_at_ms < ? + UNION ALL + SELECT host_base_amount, sender_user_id, 'POINT_DIAMOND' AS income_type + FROM point_diamond_gift_income_entries FORCE INDEX (idx_point_diamond_gift_host_time) + WHERE app_code = ? AND host_user_id = ? + AND created_at_ms >= ? AND created_at_ms < ? + ) AS gift_income`)). + WithArgs("fami", int64(42), "1970-01", "1970-01", int64(1000), int64(2000), + "fami", int64(42), int64(1000), int64(2000)). + WillReturnRows(sqlmock.NewRows([]string{"salary_earnings", "point_diamond_earnings", "senders"}).AddRow(int64(8800), int64(1200), int64(7))) mock.ExpectQuery(regexp.QuoteMeta(` - SELECT COALESCE(SUM(-e.available_delta), 0) + SELECT + COALESCE(SUM(CASE WHEN e.asset_type = ? THEN -e.available_delta ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN e.asset_type = ? THEN -e.available_delta ELSE 0 END), 0) FROM wallet_entries e FORCE INDEX (idx_wallet_entries_asset_user_time) INNER JOIN wallet_transactions t ON t.app_code = e.app_code AND t.transaction_id = e.transaction_id - WHERE e.app_code = ? AND e.user_id = ? AND e.asset_type = ? + WHERE e.app_code = ? AND e.user_id = ? AND e.asset_type IN (?, ?) AND e.created_at_ms >= ? AND e.created_at_ms < ? AND e.available_delta < 0 AND t.biz_type IN (?, ?, ?)`)). - WithArgs("fami", int64(42), ledger.AssetPoint, int64(1000), int64(2000), + WithArgs(ledger.AssetPoint, ledger.AssetPointDiamond, "fami", int64(42), ledger.AssetPoint, ledger.AssetPointDiamond, int64(1000), int64(2000), bizTypePointExchangeToCoin, bizTypePointTransferToCoinSeller, bizTypeSalaryWithdrawalFreeze). - WillReturnRows(sqlmock.NewRows([]string{"exchanged"}).AddRow(int64(1200))) - + WillReturnRows(sqlmock.NewRows([]string{"legacy_exchanged", "point_diamond_exchanged"}).AddRow(int64(2400), int64(500))) repo := &Repository{db: db} stats, err := repo.GetHostRevenueStats(appcode.WithContext(context.Background(), "fami"), ledger.HostRevenueStatsQuery{ HostUserID: 42, @@ -48,10 +59,163 @@ func TestGetHostRevenueStatsAggregatesHostDiamondsAndPointExchanges(t *testing.T if err != nil { t.Fatalf("GetHostRevenueStats failed: %v", err) } - if stats.DiamondEarnings != 8800 || stats.DiamondExchanged != 1200 || stats.GiftSenders != 7 { + if stats.DiamondEarnings != 8800 || stats.PointDiamondEarnings != 1200 || stats.DiamondExchanged != 2400 || stats.PointDiamondExchanged != 500 || stats.GiftSenders != 7 { t.Fatalf("unexpected host revenue stats: %+v", stats) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("unmet SQL expectations: %v", err) } } + +func TestGetAgencyHostGiftStatsUsesIndexedDiamondProjection(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectQuery(regexp.QuoteMeta(` + SELECT + host_user_id, + COALESCE(SUM(CASE WHEN income_type = 'SALARY_DIAMOND' THEN host_base_amount ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN income_type = 'POINT_DIAMOND' THEN host_base_amount ELSE 0 END), 0), + COUNT(DISTINCT CASE WHEN sender_user_id > 0 THEN sender_user_id END), + MAX(CASE WHEN host_base_amount > 0 THEN 1 ELSE 0 END) + FROM ( + SELECT diamond_delta AS host_base_amount, user_id AS host_user_id, sender_user_id, 'SALARY_DIAMOND' AS income_type + FROM host_period_diamond_entries FORCE INDEX (idx_host_period_diamond_entries_user_cycle) + WHERE app_code = ? AND user_id IN (?,?) + AND cycle_key BETWEEN ? AND ? + AND created_at_ms >= ? AND created_at_ms < ? + UNION ALL + SELECT host_base_amount, host_user_id, sender_user_id, 'POINT_DIAMOND' AS income_type + FROM point_diamond_gift_income_entries FORCE INDEX (idx_point_diamond_gift_host_time) + WHERE app_code = ? AND host_user_id IN (?,?) + AND created_at_ms >= ? AND created_at_ms < ? + ) AS gift_income + GROUP BY host_user_id`)). + WithArgs("fami", int64(42), int64(84), "2026-06", "2026-07", int64(1780272000000), int64(1785542400000), + "fami", int64(42), int64(84), int64(1780272000000), int64(1785542400000)). + WillReturnRows(sqlmock.NewRows([]string{"host_user_id", "salary_earnings", "point_diamond_earnings", "senders", "gifted"}). + AddRow(int64(42), int64(900), int64(6000000), int64(3), int64(1)). + AddRow(int64(84), int64(0), int64(92150), int64(2), int64(1))) + mock.ExpectQuery(regexp.QuoteMeta(` + SELECT + e.user_id, + COALESCE(SUM(CASE WHEN e.asset_type = ? THEN -e.available_delta ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN e.asset_type = ? THEN -e.available_delta ELSE 0 END), 0) + FROM wallet_entries e FORCE INDEX (idx_wallet_entries_asset_user_time) + INNER JOIN wallet_transactions t + ON t.app_code = e.app_code AND t.transaction_id = e.transaction_id + WHERE e.app_code = ? AND e.user_id IN (?,?) AND e.asset_type IN (?, ?) + AND e.created_at_ms >= ? AND e.created_at_ms < ? + AND e.available_delta < 0 + AND t.biz_type IN (?, ?, ?) + GROUP BY e.user_id`)). + WithArgs(ledger.AssetPoint, ledger.AssetPointDiamond, "fami", int64(42), int64(84), ledger.AssetPoint, ledger.AssetPointDiamond, + int64(1780272000000), int64(1785542400000), bizTypePointExchangeToCoin, bizTypePointTransferToCoinSeller, bizTypeSalaryWithdrawalFreeze). + WillReturnRows(sqlmock.NewRows([]string{"host_user_id", "point_exchanged", "point_diamond_exchanged"}). + AddRow(int64(42), int64(200), int64(500)). + AddRow(int64(84), int64(50), int64(70))) + mock.ExpectQuery(regexp.QuoteMeta(` + SELECT COALESCE(SUM(agency_amount), 0) + FROM point_diamond_gift_income_entries FORCE INDEX (idx_point_diamond_gift_agency_time) + WHERE app_code = ? AND agency_user_id = ? + AND created_at_ms >= ? AND created_at_ms < ?`)). + WithArgs("fami", int64(42), int64(1780272000000), int64(1785542400000)). + WillReturnRows(sqlmock.NewRows([]string{"agency_income"}).AddRow(int64(1218430))) + + repo := &Repository{db: db} + stats, err := repo.GetAgencyHostGiftStats(appcode.WithContext(context.Background(), "fami"), ledger.AgencyHostGiftStatsQuery{ + HostUserIDs: []int64{42, 84}, + AgencyUserID: 42, + StartAtMS: 1780272000000, + EndAtMS: 1785542400000, + }) + if err != nil { + t.Fatalf("GetAgencyHostGiftStats failed: %v", err) + } + if stats.GiftIncome != 900 || stats.PointDiamondHostIncome != 6092150 || stats.PointDiamondAgencyShareIncome != 1218430 || stats.GiftedHostCount != 2 { + t.Fatalf("unexpected agency host gift stats: %+v", stats) + } + if len(stats.HostStats) != 2 || stats.HostStats[0].HostUserID != 42 || stats.HostStats[0].Stats.DiamondEarnings != 900 || stats.HostStats[0].Stats.PointDiamondEarnings != 6000000 || stats.HostStats[0].Stats.DiamondExchanged != 200 || stats.HostStats[0].Stats.PointDiamondExchanged != 500 || stats.HostStats[0].Stats.GiftSenders != 3 { + t.Fatalf("unexpected first host stats: %+v", stats.HostStats) + } + if stats.HostStats[1].HostUserID != 84 || stats.HostStats[1].Stats.DiamondEarnings != 0 || stats.HostStats[1].Stats.PointDiamondEarnings != 92150 || stats.HostStats[1].Stats.DiamondExchanged != 50 || stats.HostStats[1].Stats.PointDiamondExchanged != 70 || stats.HostStats[1].Stats.GiftSenders != 2 { + t.Fatalf("unexpected second host stats: %+v", stats.HostStats) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet SQL expectations: %v", err) + } +} + +func TestInsertPointDiamondGiftIncomeProjectionPreservesHostAgencySplit(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectBegin() + tx, err := db.Begin() + if err != nil { + t.Fatalf("begin transaction: %v", err) + } + mock.ExpectExec(regexp.QuoteMeta(` + INSERT INTO point_diamond_gift_income_entries ( + app_code, transaction_id, host_user_id, agency_user_id, sender_user_id, + host_base_amount, agency_amount, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)). + WithArgs("fami", "wtx-self-host", int64(42), int64(42), int64(9), int64(100), int64(20), int64(1785542400000)). + WillReturnResult(sqlmock.NewResult(1, 1)) + + repo := &Repository{db: db} + err = repo.insertPointDiamondGiftIncomeProjection( + appcode.WithContext(context.Background(), "fami"), + tx, + "wtx-self-host", + giftMetadata{ + HostIncomePolicyType: ledger.HostPolicyTypePointDiamond, + IncomeAssetType: ledger.AssetPointDiamond, + TargetUserID: 42, + TargetAgencyOwnerUserID: 42, + SenderUserID: 9, + TargetHostRegionID: 25, + HostSalaryPolicyID: 81, + HostSalaryPolicyVersion: 7, + HostBaseAmount: 100, + AgencyAmount: 20, + }, + 1785542400000, + ) + if err != nil { + t.Fatalf("insert projection: %v", err) + } + mock.ExpectRollback() + if err := tx.Rollback(); err != nil { + t.Fatalf("rollback transaction: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet SQL expectations: %v", err) + } +} + +func TestInsertPointDiamondGiftIncomeProjectionSkipsSalaryPolicy(t *testing.T) { + // 工资型礼物仍只写 host_period_diamond_entries;即使它也携带 HostBaseAmount, + // 这里必须在接触事务前返回,避免同一礼物被两个统计来源重复累计。 + err := (&Repository{}).insertPointDiamondGiftIncomeProjection( + context.Background(), + nil, + "wtx-salary", + giftMetadata{ + HostIncomePolicyType: ledger.HostPolicyTypeSalaryDiamond, + IncomeAssetType: ledger.AssetHostPeriodDiamond, + TargetUserID: 42, + HostBaseAmount: 100, + }, + 1785542400000, + ) + if err != nil { + t.Fatalf("salary policy must not write point projection: %v", err) + } +} diff --git a/services/wallet-service/internal/storage/mysql/host_salary_settlement.go b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go index 23a37c29..1c8dd7ee 100644 --- a/services/wallet-service/internal/storage/mysql/host_salary_settlement.go +++ b/services/wallet-service/internal/storage/mysql/host_salary_settlement.go @@ -160,7 +160,7 @@ func (r *Repository) listHostSalaryCandidates(ctx context.Context, command ledge } } // 候选扫描也接受紧邻上月的绑定;具体结算事务会把它复制到当前周期,避免扫描阶段漏掉应继承的主播。 - args = append(args, previousCycleKey, command.TriggerMode) + args = append(args, previousCycleKey, command.TriggerMode, ledger.HostPolicyTypeSalaryDiamond) args = append(args, policyModeArgs...) // 日结/半月结只扫描“本周期钻石比上次结算更多”的主播,避免 cron 重跑时反复锁定无新增收益的账户。 // 月底批次必须扫描全部未关闭周期账户,因为它还负责剩余钻石折美元和周期逻辑关闭。 @@ -192,6 +192,7 @@ func (r *Repository) listHostSalaryCandidates(ctx context.Context, command ledge AND binding.cycle_key IN (a.cycle_key, ?) AND p.status = 'active' AND p.settlement_trigger_mode = ? + AND p.policy_type = ? %s ) ORDER BY a.updated_at_ms ASC, a.user_id ASC @@ -445,7 +446,15 @@ func (r *Repository) resolveHostSalaryPolicy(ctx context.Context, tx *sql.Tx, re if settlementType != ledger.HostSalarySettlementTypeMonthEnd { settlementMode = settlementType } - return r.queryHostSalaryPolicyByCycle(ctx, tx, regionID, cycleKey, settlementMode, triggerMode) + policy, found, err := r.queryHostSalaryPolicyByCycle(ctx, tx, regionID, cycleKey, settlementMode, triggerMode) + if err != nil || !found { + return policy, found, err + } + if policy.PolicyType != ledger.HostPolicyTypeSalaryDiamond { + // POINT_DIAMOND 永久钱包没有工资等级和月底清算语义,结算任务必须将其视为无工资政策。 + return ledger.HostSalaryPolicy{}, false, nil + } + return policy, true, nil } func (r *Repository) queryHostSalaryPolicyByCycle(ctx context.Context, q hostSalaryPolicyQuerier, regionID int64, cycleKey string, settlementMode string, triggerMode string) (ledger.HostSalaryPolicy, bool, error) { @@ -479,9 +488,10 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar args = append(args, settlementMode) } query := fmt.Sprintf(` - SELECT p.policy_id, binding.cycle_key, p.policy_version, p.name, binding.region_id, p.status, + SELECT p.policy_id, binding.cycle_key, p.policy_version, p.name, p.policy_type, binding.region_id, p.status, p.settlement_mode, p.settlement_trigger_mode, CAST(p.gift_coin_to_diamond_ratio AS CHAR), + p.point_diamonds_per_usd, p.coins_per_usd, p.minimum_withdraw_usd_minor, p.withdraw_fee_bps, p.agency_point_share_bps, CAST(p.residual_diamond_to_usd_rate AS CHAR), p.coin_seller_withdrawal_limit_period, p.coin_seller_withdrawal_limit_count, p.platform_withdrawal_limit_period, p.platform_withdrawal_limit_count, p.platform_withdrawal_allowed_days, @@ -500,8 +510,11 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar LIMIT 1`, triggerClause, modeClause) var policy ledger.HostSalaryPolicy err := q.QueryRowContext(ctx, query, args...).Scan( - &policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.RegionID, &policy.Status, - &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, &policy.ResidualDiamondToUSDRate, + &policy.PolicyID, &policy.CycleKey, &policy.PolicyVersion, &policy.Name, &policy.PolicyType, &policy.RegionID, &policy.Status, + &policy.SettlementMode, &policy.SettlementTriggerMode, &policy.GiftCoinToDiamondRatio, + &policy.PointDiamondsPerUSD, &policy.CoinsPerUSD, &policy.MinimumWithdrawUSDMinor, &policy.WithdrawFeeBPS, + &policy.AgencyPointShareBPS, + &policy.ResidualDiamondToUSDRate, &policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount, &policy.PlatformWithdrawalLimitPeriod, &policy.PlatformWithdrawalLimitCount, &policy.PlatformWithdrawalAllowedDays, @@ -513,6 +526,10 @@ func (r *Repository) queryBoundHostSalaryPolicy(ctx context.Context, q hostSalar } return ledger.HostSalaryPolicy{}, false, err } + policy.PolicyType = strings.ToUpper(strings.TrimSpace(policy.PolicyType)) + if policy.PolicyType != ledger.HostPolicyTypeSalaryDiamond && policy.PolicyType != ledger.HostPolicyTypePointDiamond { + return ledger.HostSalaryPolicy{}, false, xerr.New(xerr.Internal, "host policy type is invalid") + } levels, err := r.listHostSalaryPolicyLevels(ctx, q, policy.PolicyID) if err != nil { return ledger.HostSalaryPolicy{}, false, err diff --git a/services/wallet-service/internal/storage/mysql/host_salary_stats.go b/services/wallet-service/internal/storage/mysql/host_salary_stats.go index c5b684b8..d7eb8c4c 100644 --- a/services/wallet-service/internal/storage/mysql/host_salary_stats.go +++ b/services/wallet-service/internal/storage/mysql/host_salary_stats.go @@ -153,6 +153,11 @@ func (r *Repository) resolveTeamSalaryPolicy(ctx context.Context, regionID int64 policyByRegionCycle[cacheKey] = nil return nil, nil } + if policy.PolicyType != ledger.HostPolicyTypeSalaryDiamond { + // 永久钻石积分不会生成周期工资统计;同一团队页面只保留 SALARY_DIAMOND 的工资口径。 + policyByRegionCycle[cacheKey] = nil + return nil, nil + } policyByRegionCycle[cacheKey] = &policy return &policy, nil } diff --git a/services/wallet-service/internal/storage/mysql/metadata.go b/services/wallet-service/internal/storage/mysql/metadata.go index 90f78d1d..07f8b02d 100644 --- a/services/wallet-service/internal/storage/mysql/metadata.go +++ b/services/wallet-service/internal/storage/mysql/metadata.go @@ -42,42 +42,46 @@ type giftMetadata struct { LastRechargedAtMS int64 `json:"last_recharged_at_ms,omitempty"` // PaidAtMS 与 wallet_transactions.created_at_ms 同源;写进 metadata 让新交易直接重放, // 历史 metadata 缺字段时 receipt 查询会从交易主表创建时间补齐。 - PaidAtMS int64 `json:"paid_at_ms,omitempty"` - BillingReceipt string `json:"billing_receipt_id"` - SenderUserID int64 `json:"sender_user_id"` - SenderRegionID int64 `json:"sender_region_id"` - TargetUserID int64 `json:"target_user_id"` - TargetIsHost bool `json:"target_is_host"` - TargetHostRegionID int64 `json:"target_host_region_id"` - TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` - HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` - GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"` - GiftDiamondRatioPPM int64 `json:"gift_diamond_ratio_ppm,omitempty"` - GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"` - HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` - HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` - HostPeriodCycleKey string `json:"host_period_cycle_key"` - HostPointAssetType string `json:"host_point_asset_type,omitempty"` - HostPointAdded int64 `json:"host_point_added,omitempty"` - HostPointBalanceAfter int64 `json:"host_point_balance_after,omitempty"` - HostPointPolicyInstanceCode string `json:"host_point_policy_instance_code,omitempty"` - HostPointTemplateCode string `json:"host_point_template_code,omitempty"` - HostPointTemplateVersion string `json:"host_point_template_version,omitempty"` - HostPointRatioPPM int64 `json:"host_point_ratio_ppm,omitempty"` - AgencyPointAdded int64 `json:"agency_point_added,omitempty"` - AgencyPointBalanceAfter int64 `json:"agency_point_balance_after,omitempty"` - AgencyPointRatioPPM int64 `json:"agency_point_ratio_ppm,omitempty"` - // AgencyPointShareBase/Amount 是分成公式与基数的不可变交易快照;只存 ratio 无法区分旧的 - // charge_amount 口径和 Fami 新的 host_income 额外收益口径。 - AgencyPointShareBase string `json:"agency_point_share_base,omitempty"` - AgencyPointShareBaseAmount int64 `json:"agency_point_share_base_amount,omitempty"` - PointsPerUSD int64 `json:"points_per_usd,omitempty"` - RoomID string `json:"room_id"` - RoomRegionID int64 `json:"room_region_id"` - RoomContributionRatioPercent string `json:"room_contribution_ratio_percent"` - RoomContributionRatioRegionID int64 `json:"room_contribution_ratio_region_id"` - RobotGift bool `json:"robot_gift,omitempty"` - DirectGift bool `json:"direct_gift,omitempty"` + PaidAtMS int64 `json:"paid_at_ms,omitempty"` + BillingReceipt string `json:"billing_receipt_id"` + SenderUserID int64 `json:"sender_user_id"` + SenderRegionID int64 `json:"sender_region_id"` + TargetUserID int64 `json:"target_user_id"` + TargetIsHost bool `json:"target_is_host"` + TargetHostRegionID int64 `json:"target_host_region_id"` + TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` + HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` + HostIncomePolicyType string `json:"host_income_policy_type,omitempty"` + HostIncomeBalanceAfter int64 `json:"host_income_balance_after,omitempty"` + IncomeAssetType string `json:"income_asset_type,omitempty"` + HostBaseAmount int64 `json:"host_base_amount,omitempty"` + AgencyAmount int64 `json:"agency_amount,omitempty"` + AgencyIncomeBalanceAfter int64 `json:"agency_income_balance_after,omitempty"` + AgencyPointShareBPS int32 `json:"agency_point_share_bps,omitempty"` + GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"` + GiftDiamondRatioPPM int64 `json:"gift_diamond_ratio_ppm,omitempty"` + GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"` + HostSalaryPolicyID uint64 `json:"host_salary_policy_id,omitempty"` + HostSalaryPolicyVersion uint64 `json:"host_salary_policy_version,omitempty"` + PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd,omitempty"` + CoinsPerUSD int64 `json:"coins_per_usd,omitempty"` + HostSalaryPolicyDiamondCoefficient string `json:"host_salary_policy_diamond_coefficient,omitempty"` + HostSalaryPolicyDiamondCoefficientPPM int64 `json:"host_salary_policy_diamond_coefficient_ppm,omitempty"` + HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` + HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` + HostPeriodCycleKey string `json:"host_period_cycle_key"` + HostPointAssetType string `json:"host_point_asset_type,omitempty"` + HostPointAdded int64 `json:"host_point_added,omitempty"` + HostPointBalanceAfter int64 `json:"host_point_balance_after,omitempty"` + HostPointPolicyInstanceCode string `json:"host_point_policy_instance_code,omitempty"` + HostPointTemplateCode string `json:"host_point_template_code,omitempty"` + HostPointTemplateVersion string `json:"host_point_template_version,omitempty"` + RoomID string `json:"room_id"` + RoomRegionID int64 `json:"room_region_id"` + RoomContributionRatioPercent string `json:"room_contribution_ratio_percent"` + RoomContributionRatioRegionID int64 `json:"room_contribution_ratio_region_id"` + RobotGift bool `json:"robot_gift,omitempty"` + DirectGift bool `json:"direct_gift,omitempty"` } type adminCreditMetadata struct { @@ -299,6 +303,13 @@ type salaryWithdrawalMetadata struct { PointNetAmount int64 `json:"point_net_amount,omitempty"` PointsPerUSD int64 `json:"points_per_usd,omitempty"` PointWithdrawFeeBPS int64 `json:"point_withdraw_fee_bps,omitempty"` + PointPolicyInstanceCode string `json:"point_policy_instance_code,omitempty"` + GrossUSDMinor int64 `json:"gross_usd_minor,omitempty"` + FeeUSDMinor int64 `json:"fee_usd_minor,omitempty"` + NetUSDMinor int64 `json:"net_usd_minor,omitempty"` + PointPolicyID uint64 `json:"point_policy_id,omitempty"` + PointPolicyVersion uint64 `json:"point_policy_version,omitempty"` + CoinsPerUSD int64 `json:"coins_per_usd,omitempty"` OperatorUserID int64 `json:"operator_user_id,omitempty"` Reason string `json:"reason"` WithdrawalRef string `json:"withdrawal_ref,omitempty"` diff --git a/services/wallet-service/internal/storage/mysql/outbox.go b/services/wallet-service/internal/storage/mysql/outbox.go index 98e45dbb..67eddb7d 100644 --- a/services/wallet-service/internal/storage/mysql/outbox.go +++ b/services/wallet-service/internal/storage/mysql/outbox.go @@ -782,100 +782,36 @@ func hostPeriodDiamondCreditedEvent(transactionID string, commandID string, meta AvailableDelta: metadata.HostPeriodDiamondAdded, FrozenDelta: 0, Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "host_user_id": metadata.TargetUserID, - "cycle_key": metadata.HostPeriodCycleKey, - "region_id": metadata.TargetHostRegionID, - "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, - "diamond_delta": metadata.HostPeriodDiamondAdded, - "diamond_after": metadata.HostPeriodDiamondAfter, - "gift_charge_asset_type": metadata.ChargeAssetType, - "gift_charge_amount": metadata.ChargeAmount, - "gift_id": metadata.GiftID, - "gift_count": metadata.GiftCount, - "sender_user_id": metadata.SenderUserID, - "sender_region_id": metadata.SenderRegionID, - "room_id": metadata.RoomID, - "gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent, - "gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func hostPointCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "HostPointCredited", metadata.TargetUserID, ledger.AssetPoint), - EventType: "HostPointCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetUserID, - AssetType: ledger.AssetPoint, - AvailableDelta: metadata.HostPointAdded, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "host_user_id": metadata.TargetUserID, - "sender_user_id": metadata.SenderUserID, - "room_id": metadata.RoomID, - "region_id": metadata.TargetHostRegionID, - "asset_type": ledger.AssetPoint, - "point_delta": metadata.HostPointAdded, - "point_balance_after": metadata.HostPointBalanceAfter, - "gift_charge_asset_type": metadata.ChargeAssetType, - "gift_charge_amount": metadata.ChargeAmount, - "charge_source": metadata.ChargeSource, - "gift_id": metadata.GiftID, - "gift_count": metadata.GiftCount, - "policy_instance_code": metadata.HostPointPolicyInstanceCode, - "policy_template_code": metadata.HostPointTemplateCode, - "policy_template_version": metadata.HostPointTemplateVersion, - "host_point_ratio_ppm": metadata.HostPointRatioPPM, - "gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent, - "gift_diamond_ratio_ppm": metadata.GiftDiamondRatioPPM, - "gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID, - "points_per_usd": metadata.PointsPerUSD, - "allow_self_brushing": true, - "created_at_ms": nowMs, - }, - CreatedAtMS: nowMs, - } -} - -func agencyPointCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent { - return walletOutboxEvent{ - EventID: eventID(transactionID, "AgencyPointCredited", metadata.TargetAgencyOwnerUserID, ledger.AssetPoint), - EventType: "AgencyPointCredited", - TransactionID: transactionID, - CommandID: commandID, - UserID: metadata.TargetAgencyOwnerUserID, - AssetType: ledger.AssetPoint, - AvailableDelta: metadata.AgencyPointAdded, - FrozenDelta: 0, - Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "host_user_id": metadata.TargetUserID, - "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, - "sender_user_id": metadata.SenderUserID, - "room_id": metadata.RoomID, - "asset_type": ledger.AssetPoint, - "point_delta": metadata.AgencyPointAdded, - "point_balance_after": metadata.AgencyPointBalanceAfter, - "gift_charge_asset_type": metadata.ChargeAssetType, - "gift_charge_amount": metadata.ChargeAmount, - "gift_id": metadata.GiftID, - "gift_count": metadata.GiftCount, - "policy_instance_code": metadata.HostPointPolicyInstanceCode, - "policy_template_code": metadata.HostPointTemplateCode, - "policy_template_version": metadata.HostPointTemplateVersion, - "agency_point_ratio_ppm": metadata.AgencyPointRatioPPM, - "agency_share_base": metadata.AgencyPointShareBase, - "agency_share_base_amount": metadata.AgencyPointShareBaseAmount, - "created_at_ms": nowMs, + "transaction_id": transactionID, + "command_id": commandID, + "host_user_id": metadata.TargetUserID, + "cycle_key": metadata.HostPeriodCycleKey, + "region_id": metadata.TargetHostRegionID, + "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, + "diamond_delta": metadata.HostPeriodDiamondAdded, + "diamond_after": metadata.HostPeriodDiamondAfter, + "gift_charge_asset_type": metadata.ChargeAssetType, + "gift_charge_amount": metadata.ChargeAmount, + "gift_id": metadata.GiftID, + "gift_count": metadata.GiftCount, + "gift_type_code": metadata.GiftTypeCode, + "sender_user_id": metadata.SenderUserID, + "sender_region_id": metadata.SenderRegionID, + "room_id": metadata.RoomID, + "gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent, + "gift_diamond_ratio_ppm": metadata.GiftDiamondRatioPPM, + "gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID, + "host_salary_policy_id": metadata.HostSalaryPolicyID, + "host_salary_policy_version": metadata.HostSalaryPolicyVersion, + "host_salary_policy_diamond_coefficient": metadata.HostSalaryPolicyDiamondCoefficient, + "host_salary_policy_diamond_coefficient_ppm": metadata.HostSalaryPolicyDiamondCoefficientPPM, + "host_income_policy_type": metadata.HostIncomePolicyType, + "income_asset_type": metadata.IncomeAssetType, + "host_base_amount": metadata.HostBaseAmount, + "agency_amount": metadata.AgencyAmount, + "agency_income_balance_after": metadata.AgencyIncomeBalanceAfter, + "agency_point_share_bps": metadata.AgencyPointShareBPS, + "created_at_ms": nowMs, }, CreatedAtMS: nowMs, } diff --git a/services/wallet-service/internal/storage/mysql/outbox_purge.go b/services/wallet-service/internal/storage/mysql/outbox_purge.go index f56395dd..3469cbb9 100644 --- a/services/wallet-service/internal/storage/mysql/outbox_purge.go +++ b/services/wallet-service/internal/storage/mysql/outbox_purge.go @@ -306,10 +306,8 @@ func walletOutboxPurgeContext(ctx context.Context, appCode string) (context.Cont } ctx = contextWithCommandApp(ctx, appCode) normalized := appcode.FromContext(ctx) - // 本轮容量治理边界只有 Lalu/Fami;其他租户即使误配也必须在进入事务前失败。 - if normalized != "lalu" && normalized != "fami" { - return ctx, "", xerr.New(xerr.InvalidArgument, "wallet outbox purge app_code is not allowed") - } + // 可清理租户由启动配置的显式 allowlist、恢复证明以及数据库授权三重约束; + // 仓储层不再把产品名称写进代码,否则新增租户会形成隐蔽的行为分支。 return ctx, normalized, nil } diff --git a/services/wallet-service/internal/storage/mysql/point_coin_seller.go b/services/wallet-service/internal/storage/mysql/point_coin_seller.go index 79e0adfe..a574ad67 100644 --- a/services/wallet-service/internal/storage/mysql/point_coin_seller.go +++ b/services/wallet-service/internal/storage/mysql/point_coin_seller.go @@ -14,8 +14,6 @@ import ( "hyapp/services/wallet-service/internal/domain/ledger" ) -const pointWithdrawalMinimumPoints int64 = 1_000_000 - type pointToCoinSellerMetadata struct { AppCode string `json:"app_code"` SourceUserID int64 `json:"source_user_id"` @@ -28,6 +26,12 @@ type pointToCoinSellerMetadata struct { SellerCoinAmount int64 `json:"seller_coin_amount"` RatioPointAmount int64 `json:"ratio_point_amount"` RatioSellerCoinAmount int64 `json:"ratio_seller_coin_amount"` + GrossUSDMinor int64 `json:"gross_usd_minor,omitempty"` + FeeUSDMinor int64 `json:"fee_usd_minor,omitempty"` + NetUSDMinor int64 `json:"net_usd_minor,omitempty"` + WithdrawFeeBPS int32 `json:"withdraw_fee_bps,omitempty"` + PolicyID uint64 `json:"policy_id,omitempty"` + PolicyVersion uint64 `json:"policy_version,omitempty"` SourcePointBalanceAfter int64 `json:"source_point_balance_after"` SellerBalanceAfter int64 `json:"seller_balance_after"` Reason string `json:"reason"` @@ -92,9 +96,17 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg } defer func() { _ = tx.Rollback() }() - requestHash := stableHash(fmt.Sprintf("point_transfer_coin_seller|%s|%d|%d|%d|%s|%d|%s", - command.AppCode, command.SourceUserID, command.SellerUserID, command.PointAmount, - strings.ToUpper(strings.TrimSpace(command.SourceCountryCode)), command.RegionID, strings.TrimSpace(command.Reason))) + sourceAssetType := strings.ToUpper(strings.TrimSpace(command.SourceAssetType)) + if sourceAssetType == "" { + sourceAssetType = ledger.AssetPoint + } + if sourceAssetType != ledger.AssetPoint && sourceAssetType != ledger.AssetPointDiamond { + return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point source asset type is invalid") + } + // region/country/reason 都是 Gateway 根据当前资料派生的权威上下文,不属于用户稳定意图。 + // 同一 command_id 仍严格绑定源用户、目标币商、资产和用户提交金额,防止篡改真实兑换目标。 + requestHash := stableHash(fmt.Sprintf("point_transfer_coin_seller|%s|%d|%d|%s|%d|%d", + command.AppCode, command.SourceUserID, command.SellerUserID, sourceAssetType, command.PointAmount, command.GrossUSDMinor)) if txRow, exists, lookupErr := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypePointTransferToCoinSeller, xerr.IdempotencyConflict); lookupErr != nil || exists { if lookupErr != nil || !exists { return ledger.PointToCoinSellerReceipt{}, lookupErr @@ -102,6 +114,50 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg return r.receiptForPointToCoinSellerTransaction(ctx, tx, txRow.TransactionID) } + nowMS := time.Now().UTC().UnixMilli() + pointAmount := command.PointAmount + ratioPointAmount := int64(0) + ratioSellerCoinAmount := int64(0) + sellerCoinAmount := int64(0) + grossUSDMinor := int64(0) + feeUSDMinor := int64(0) + netUSDMinor := int64(0) + withdrawFeeBPS := int32(0) + policyID := uint64(0) + policyVersion := uint64(0) + if sourceAssetType == ledger.AssetPointDiamond { + policy, err := r.resolvePointDiamondHostPolicy(ctx, tx, command.RegionID, nowMS) + if err != nil { + return ledger.PointToCoinSellerReceipt{}, err + } + if command.GrossUSDMinor < policy.MinimumWithdrawUSDMinor { + return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below configured minimum") + } + pointAmount, feeUSDMinor, netUSDMinor, sellerCoinAmount, err = calculatePointDiamondCoinSellerAmounts( + command.GrossUSDMinor, policy.PointDiamondsPerUSD, policy.CoinsPerUSD, policy.WithdrawFeeBPS, + ) + if err != nil { + return ledger.PointToCoinSellerReceipt{}, err + } + ratioPointAmount, ratioSellerCoinAmount = policy.PointDiamondsPerUSD, policy.CoinsPerUSD + grossUSDMinor, withdrawFeeBPS = command.GrossUSDMinor, policy.WithdrawFeeBPS + policyID, policyVersion = policy.PolicyID, policy.PolicyVersion + if pointAmount <= 0 || sellerCoinAmount <= 0 { + return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below exchange precision") + } + } else { + policy, found, err := r.resolveActiveWalletPolicy(ctx, tx, command.AppCode, command.RegionID, nowMS) + if err != nil { + return ledger.PointToCoinSellerReceipt{}, err + } + if !found { + return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "point wallet policy is not configured") + } + if pointAmount < policy.MinimumPoints { + return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point withdrawal amount is below configured minimum") + } + } + config, err := r.lockPointWithdrawalCoinSellerConfig(ctx, tx, command.SellerUserID) if err != nil { return ledger.PointToCoinSellerReceipt{}, err @@ -110,31 +166,41 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg if !countryAllowed(config.ServiceCountryCodes, countryCode) { return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "coin seller does not serve user country") } - numerator, err := checkedMul(command.PointAmount, config.SellerCoinAmount) - if err != nil { - return ledger.PointToCoinSellerReceipt{}, err - } - sellerCoinAmount := numerator / config.PointAmount - if sellerCoinAmount <= 0 { - return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point amount is below exchange precision") + if sourceAssetType == ledger.AssetPoint { + numerator, err := checkedMul(pointAmount, config.SellerCoinAmount) + if err != nil { + return ledger.PointToCoinSellerReceipt{}, err + } + sellerCoinAmount = numerator / config.PointAmount + ratioPointAmount, ratioSellerCoinAmount = config.PointAmount, config.SellerCoinAmount + if sellerCoinAmount <= 0 { + return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "point amount is below exchange precision") + } } - nowMS := time.Now().UnixMilli() - source, err := r.lockAccount(ctx, tx, command.SourceUserID, ledger.AssetPoint, false, nowMS) + // 送礼、积分兑币和币商提现可能沿相反方向触达相同资产账户;双方账户必须先按全局 + // asset/user 顺序一次锁齐,再消费次数限制,避免 source->limit->seller 与其他账变反向等待。 + accountStates, err := r.lockAccountsOrdered(ctx, tx, []walletAccountLockRequest{ + {UserID: command.SourceUserID, AssetType: sourceAssetType, CreateIfMissing: false}, + {UserID: command.SellerUserID, AssetType: ledger.AssetCoinSellerCoin, CreateIfMissing: true}, + }, nowMS) if err != nil { return ledger.PointToCoinSellerReceipt{}, err } - if source.AvailableAmount < command.PointAmount { - return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient POINT balance") + source := accountStates[walletAccountLockKey(command.SourceUserID, sourceAssetType)].account + seller := accountStates[walletAccountLockKey(command.SellerUserID, ledger.AssetCoinSellerCoin)].account + if source.AvailableAmount < pointAmount { + return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient point balance") } - if err := r.consumePointWithdrawalLimit(ctx, tx, command.SourceUserID, command.RegionID, pointWithdrawalChannelCoinSeller, command.CommandID, "", nowMS); err != nil { + policyType := "" + if sourceAssetType == ledger.AssetPointDiamond { + // 永久积分继续引用最后一份 POINT_DIAMOND 快照,后续切换工资政策不影响旧余额的转出限制。 + policyType = ledger.HostPolicyTypePointDiamond + } + if err := r.consumeHostPolicyWithdrawalLimitForPolicyType(ctx, tx, command.SourceUserID, command.RegionID, pointWithdrawalChannelCoinSeller, command.CommandID, "", nowMS, sourceAssetType, policyType, requireHostPolicy); err != nil { return ledger.PointToCoinSellerReceipt{}, err } - seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMS) - if err != nil { - return ledger.PointToCoinSellerReceipt{}, err - } - sourceAfter := source.AvailableAmount - command.PointAmount + sourceAfter := source.AvailableAmount - pointAmount sellerAfter, err := checkedAdd(seller.AvailableAmount, sellerCoinAmount) if err != nil { return ledger.PointToCoinSellerReceipt{}, err @@ -142,19 +208,21 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg transactionID := transactionID(command.AppCode, command.CommandID) metadata := pointToCoinSellerMetadata{ AppCode: command.AppCode, SourceUserID: command.SourceUserID, SellerUserID: command.SellerUserID, - SourceCountryCode: countryCode, SourceAssetType: ledger.AssetPoint, SellerAssetType: ledger.AssetCoinSellerCoin, + SourceCountryCode: countryCode, SourceAssetType: sourceAssetType, SellerAssetType: ledger.AssetCoinSellerCoin, RegionID: command.RegionID, - PointAmount: command.PointAmount, SellerCoinAmount: sellerCoinAmount, - RatioPointAmount: config.PointAmount, RatioSellerCoinAmount: config.SellerCoinAmount, + PointAmount: pointAmount, SellerCoinAmount: sellerCoinAmount, + RatioPointAmount: ratioPointAmount, RatioSellerCoinAmount: ratioSellerCoinAmount, + GrossUSDMinor: grossUSDMinor, FeeUSDMinor: feeUSDMinor, NetUSDMinor: netUSDMinor, + WithdrawFeeBPS: withdrawFeeBPS, PolicyID: policyID, PolicyVersion: policyVersion, SourcePointBalanceAfter: sourceAfter, SellerBalanceAfter: sellerAfter, Reason: command.Reason, CreatedAtMS: nowMS, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypePointTransferToCoinSeller, requestHash, fmt.Sprintf("point_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMS); err != nil { return ledger.PointToCoinSellerReceipt{}, err } - if err := r.applyAccountDelta(ctx, tx, source, -command.PointAmount, 0, nowMS); err != nil { + if err := r.applyAccountDelta(ctx, tx, source, -pointAmount, 0, nowMS); err != nil { return ledger.PointToCoinSellerReceipt{}, err } - if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.SourceUserID, AssetType: ledger.AssetPoint, AvailableDelta: -command.PointAmount, AvailableAfter: sourceAfter, FrozenAfter: source.FrozenAmount, CounterpartyUserID: command.SellerUserID, CreatedAtMS: nowMS}); err != nil { + if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.SourceUserID, AssetType: sourceAssetType, AvailableDelta: -pointAmount, AvailableAfter: sourceAfter, FrozenAfter: source.FrozenAmount, CounterpartyUserID: command.SellerUserID, CreatedAtMS: nowMS}); err != nil { return ledger.PointToCoinSellerReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, seller, sellerCoinAmount, 0, nowMS); err != nil { @@ -164,7 +232,7 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg return ledger.PointToCoinSellerReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, ledger.AssetPoint, -command.PointAmount, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMS, bizTypePointTransferToCoinSeller), + balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, sourceAssetType, -pointAmount, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMS, bizTypePointTransferToCoinSeller), balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, sellerCoinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMS, bizTypePointTransferToCoinSeller), }); err != nil { return ledger.PointToCoinSellerReceipt{}, err @@ -175,22 +243,117 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg return receiptFromPointToCoinSellerMetadata(transactionID, metadata), nil } -// GetPointWithdrawalConfig 只读取已发布钱包政策;不存在时显式 found=false,gateway 不回退到猜测参数。 +func calculatePointDiamondCoinSellerAmounts(grossUSDMinor int64, pointsPerUSD int64, coinsPerUSD int64, feeBPS int32) (pointAmount int64, feeUSDMinor int64, netUSDMinor int64, sellerCoinAmount int64, err error) { + if grossUSDMinor <= 0 || pointsPerUSD <= 0 || coinsPerUSD <= 0 || feeBPS < 0 || feeBPS > 10_000 { + err = xerr.New(xerr.InvalidArgument, "point diamond withdrawal policy is invalid") + return + } + pointProduct, calculateErr := checkedMul(grossUSDMinor, pointsPerUSD) + if calculateErr != nil { + err = calculateErr + return + } + feeProduct, calculateErr := checkedMul(grossUSDMinor, int64(feeBPS)) + if calculateErr != nil { + err = calculateErr + return + } + pointAmount = pointProduct / 100 + feeUSDMinor = feeProduct / 10_000 + netUSDMinor = grossUSDMinor - feeUSDMinor + coinProduct, calculateErr := checkedMul(netUSDMinor, coinsPerUSD) + if calculateErr != nil { + err = calculateErr + return + } + sellerCoinAmount = coinProduct / 100 + if pointAmount <= 0 || netUSDMinor <= 0 || sellerCoinAmount <= 0 { + err = xerr.New(xerr.InvalidArgument, "withdrawal amount is below exchange precision") + } + return +} + +// GetPointWithdrawalConfig 只暴露原 wallet 政策中 POINT 提现和兑换实际消费的字段。 +// 礼物收益不会读取该政策;缺少配置时显式 found=false,不回退编译期常量。 func (r *Repository) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error) { + return r.GetPointWithdrawalConfigForAsset(ctx, appCode, regionID, nowMS, ledger.AssetPoint) +} + +func (r *Repository) GetPointWithdrawalConfigForAsset(ctx context.Context, appCode string, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { + return r.getPointWithdrawalConfig(ctx, appCode, 0, regionID, nowMS, assetType) +} + +func (r *Repository) GetPointWithdrawalConfigForUser(ctx context.Context, appCode string, userID int64, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { + if userID <= 0 { + return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.InvalidArgument, "point withdrawal config user is invalid") + } + return r.getPointWithdrawalConfig(ctx, appCode, userID, regionID, nowMS, assetType) +} + +func (r *Repository) getPointWithdrawalConfig(ctx context.Context, appCode string, userID int64, regionID int64, nowMS int64, assetType string) (ledger.PointWithdrawalRuntimeConfig, error) { if r == nil || r.db == nil { return ledger.PointWithdrawalRuntimeConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, appCode) - tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + tx, err := r.db.BeginTx(ctx, nil) if err != nil { return ledger.PointWithdrawalRuntimeConfig{}, err } defer func() { _ = tx.Rollback() }() + assetType = strings.ToUpper(strings.TrimSpace(assetType)) + if assetType == ledger.AssetPointDiamond { + policy, err := r.resolvePointDiamondHostPolicy(ctx, tx, regionID, nowMS) + if err != nil { + if xerr.CodeOf(err) == xerr.PermissionDenied { + return ledger.PointWithdrawalRuntimeConfig{Found: false}, nil + } + return ledger.PointWithdrawalRuntimeConfig{}, err + } + minimumPoints, calculateErr := checkedMul(policy.MinimumWithdrawUSDMinor, policy.PointDiamondsPerUSD) + if calculateErr != nil { + return ledger.PointWithdrawalRuntimeConfig{}, calculateErr + } + config := ledger.PointWithdrawalRuntimeConfig{ + Found: true, PointsPerUSD: policy.PointDiamondsPerUSD, CoinsPerUSD: policy.CoinsPerUSD, + FeeBPS: int64(policy.WithdrawFeeBPS), MinimumPoints: minimumPoints / 100, + PolicyInstanceCode: fmt.Sprintf("host:%d:%d", policy.PolicyID, policy.PolicyVersion), PolicyType: policy.PolicyType, + MinimumWithdrawUSDMinor: policy.MinimumWithdrawUSDMinor, AgencyPointShareBPS: policy.AgencyPointShareBPS, + PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion, + } + if userID > 0 { + now := time.UnixMilli(nowMS).UTC() + coinSellerLimit, limitErr := hostPolicyWithdrawalLimitFromPolicy(policy, pointWithdrawalChannelCoinSeller, now) + if limitErr != nil { + return ledger.PointWithdrawalRuntimeConfig{}, limitErr + } + platformLimit, limitErr := hostPolicyWithdrawalLimitFromPolicy(policy, pointWithdrawalChannelPlatform, now) + if limitErr != nil { + return ledger.PointWithdrawalRuntimeConfig{}, limitErr + } + config.CoinSellerAvailability, limitErr = r.pointWithdrawalActionAvailability(ctx, tx, userID, assetType, pointWithdrawalChannelCoinSeller, coinSellerLimit, now) + if limitErr != nil { + return ledger.PointWithdrawalRuntimeConfig{}, limitErr + } + config.PlatformAvailability, limitErr = r.pointWithdrawalActionAvailability(ctx, tx, userID, assetType, pointWithdrawalChannelPlatform, platformLimit, now) + if limitErr != nil { + return ledger.PointWithdrawalRuntimeConfig{}, limitErr + } + config.AvailabilityEvaluated = true + } + return config, nil + } policy, found, err := r.resolveActiveWalletPolicy(ctx, tx, appcode.FromContext(ctx), regionID, nowMS) if err != nil || !found { return ledger.PointWithdrawalRuntimeConfig{Found: false}, err } - return ledger.PointWithdrawalRuntimeConfig{Found: true, PointsPerUSD: policy.PointsPerUSD, CoinsPerUSD: ledger.SalaryExchangeCoinPerUSD, FeeBPS: policy.WithdrawFeeBPS, MinimumPoints: pointWithdrawalMinimumPoints, PolicyInstanceCode: policy.InstanceCode}, nil + return ledger.PointWithdrawalRuntimeConfig{ + Found: true, + PointsPerUSD: policy.PointsPerUSD, + CoinsPerUSD: ledger.SalaryExchangeCoinPerUSD, + FeeBPS: policy.WithdrawFeeBPS, + MinimumPoints: policy.MinimumPoints, + PolicyInstanceCode: policy.InstanceCode, + }, nil } func (r *Repository) lockPointWithdrawalCoinSellerConfig(ctx context.Context, tx *sql.Tx, sellerUserID int64) (ledger.PointWithdrawalCoinSellerConfig, error) { @@ -236,6 +399,8 @@ func receiptFromPointToCoinSellerMetadata(transactionID string, metadata pointTo SourcePointBalanceAfter: metadata.SourcePointBalanceAfter, SellerBalanceAfter: metadata.SellerBalanceAfter, PointAmount: metadata.PointAmount, SellerCoinAmount: metadata.SellerCoinAmount, RatioPointAmount: metadata.RatioPointAmount, RatioSellerCoinAmount: metadata.RatioSellerCoinAmount, CreatedAtMS: metadata.CreatedAtMS, + SourceAssetType: metadata.SourceAssetType, GrossUSDMinor: metadata.GrossUSDMinor, FeeUSDMinor: metadata.FeeUSDMinor, + NetUSDMinor: metadata.NetUSDMinor, WithdrawFeeBPS: metadata.WithdrawFeeBPS, PolicyID: metadata.PolicyID, PolicyVersion: metadata.PolicyVersion, } } diff --git a/services/wallet-service/internal/storage/mysql/point_diamond_gift_income_projection.go b/services/wallet-service/internal/storage/mysql/point_diamond_gift_income_projection.go new file mode 100644 index 00000000..c5f7b92c --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/point_diamond_gift_income_projection.go @@ -0,0 +1,55 @@ +package mysql + +import ( + "context" + "database/sql" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" +) + +// insertPointDiamondGiftIncomeProjection 在送礼账务事务内固化可查询的角色拆分事实。 +// POINT_DIAMOND 自营主播会把 Host 与 Agency 两部分合并写入同一个钱包分录;统计不能从该 +// 合并 delta 反推角色,因此这里必须原样保存 metadata 的 host_base_amount/agency_amount。 +func (r *Repository) insertPointDiamondGiftIncomeProjection(ctx context.Context, tx *sql.Tx, transactionID string, metadata giftMetadata, nowMs int64) error { + if metadata.HostIncomePolicyType != ledger.HostPolicyTypePointDiamond { + if metadata.IncomeAssetType == ledger.AssetPointDiamond { + // POINT_DIAMOND 钱包已准备入账却缺政策类型时不能静默跳过,否则账本与统计事实永久分叉。 + return xerr.New(xerr.Internal, "point diamond gift income policy is invalid") + } + return nil + } + if metadata.IncomeAssetType != ledger.AssetPointDiamond { + return xerr.New(xerr.Internal, "point diamond gift income asset is invalid") + } + if metadata.HostBaseAmount < 0 || metadata.AgencyAmount < 0 { + return xerr.New(xerr.Internal, "point diamond gift income amount is invalid") + } + if metadata.HostBaseAmount == 0 { + // 零价礼物不产生收益事实;不写空行可让去重送礼人数只依赖正收益投影。 + return nil + } + if tx == nil || metadata.TargetUserID <= 0 { + return xerr.New(xerr.Internal, "point diamond gift income projection is invalid") + } + if metadata.AgencyAmount > 0 && metadata.TargetAgencyOwnerUserID <= 0 { + return xerr.New(xerr.Internal, "point diamond agency income owner is invalid") + } + + _, err := tx.ExecContext(ctx, ` + INSERT INTO point_diamond_gift_income_entries ( + app_code, transaction_id, host_user_id, agency_user_id, sender_user_id, + host_base_amount, agency_amount, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + appcode.FromContext(ctx), + transactionID, + metadata.TargetUserID, + metadata.TargetAgencyOwnerUserID, + metadata.SenderUserID, + metadata.HostBaseAmount, + metadata.AgencyAmount, + nowMs, + ) + return err +} diff --git a/services/wallet-service/internal/storage/mysql/point_exchange.go b/services/wallet-service/internal/storage/mysql/point_exchange.go index 974d6574..22d918d1 100644 --- a/services/wallet-service/internal/storage/mysql/point_exchange.go +++ b/services/wallet-service/internal/storage/mysql/point_exchange.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" + "strings" "time" "hyapp/pkg/appcode" @@ -16,15 +18,18 @@ type pointToCoinExchangeMetadata struct { AppCode string `json:"app_code"` UserID int64 `json:"user_id"` PointAmount int64 `json:"point_amount"` + SourceAssetType string `json:"source_asset_type"` CoinAmount int64 `json:"coin_amount"` RatioPointAmount int64 `json:"ratio_point_amount"` RatioCoinAmount int64 `json:"ratio_coin_amount"` PointBalanceAfter int64 `json:"point_balance_after"` CoinBalanceAfter int64 `json:"coin_balance_after"` CreatedAtMS int64 `json:"created_at_ms"` + PolicyID uint64 `json:"policy_id"` + PolicyVersion uint64 `json:"policy_version"` } -// ExchangePointToCoin 在同一事务中锁定 active 比例和用户 POINT/COIN 账户; +// ExchangePointToCoin 在同一事务中解析 active POINT 政策并锁定用户 POINT/COIN 账户; // 客户端不能提交 coin_amount,避免篡改比例或在后台改价后重放得到不同结果。 func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.PointToCoinCommand) (ledger.PointToCoinReceipt, error) { if r == nil || r.db == nil { @@ -38,7 +43,16 @@ func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.Poi } defer func() { _ = tx.Rollback() }() - requestHash := stableHash(fmt.Sprintf("point_exchange_to_coin|%s|%d|%d|%d", command.AppCode, command.UserID, command.PointAmount, command.RegionID)) + sourceAssetType := strings.ToUpper(strings.TrimSpace(command.SourceAssetType)) + if sourceAssetType == "" { + sourceAssetType = ledger.AssetPoint + } + if sourceAssetType != ledger.AssetPoint && sourceAssetType != ledger.AssetPointDiamond { + return ledger.PointToCoinReceipt{}, xerr.New(xerr.InvalidArgument, "point source asset type is invalid") + } + // region 是服务端从当前用户资料派生的政策定位,不属于客户端稳定意图。同一 command_id + // 在用户区域变化后仍应返回首次回执;用户、资产和兑换数量变化仍构成幂等冲突。 + requestHash := stableHash(fmt.Sprintf("point_exchange_to_coin|%s|%d|%s|%d", command.AppCode, command.UserID, sourceAssetType, command.PointAmount)) if txRow, exists, lookupErr := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypePointExchangeToCoin, xerr.IdempotencyConflict); lookupErr != nil || exists { if lookupErr != nil || !exists { return ledger.PointToCoinReceipt{}, lookupErr @@ -46,16 +60,27 @@ func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.Poi return r.receiptForPointToCoinExchange(ctx, tx, txRow.TransactionID) } - // POINT 分母与 USDT 提现读取同一份已发布政策;COIN 分子复用工资兑金币的统一产品比例。 + // POINT 分母与 USDT 提现读取同一份已发布 wallet 政策;COIN 分子复用工资兑金币的统一产品比例。 // 在账变事务内重新解析,避免页面预览后 Admin 改政策导致按旧分母扣账。 - policy, found, err := r.resolveActiveWalletPolicy(ctx, tx, command.AppCode, command.RegionID, command.NowMS) - if err != nil { - return ledger.PointToCoinReceipt{}, err + var ratioPointAmount, ratioCoinAmount int64 + var policyID, policyVersion uint64 + if sourceAssetType == ledger.AssetPointDiamond { + policy, err := r.resolvePointDiamondHostPolicy(ctx, tx, command.RegionID, command.NowMS) + if err != nil { + return ledger.PointToCoinReceipt{}, err + } + ratioPointAmount, ratioCoinAmount = policy.PointDiamondsPerUSD, policy.CoinsPerUSD + policyID, policyVersion = policy.PolicyID, policy.PolicyVersion + } else { + policy, found, err := r.resolveActiveWalletPolicy(ctx, tx, command.AppCode, command.RegionID, command.NowMS) + if err != nil { + return ledger.PointToCoinReceipt{}, err + } + if !found { + return ledger.PointToCoinReceipt{}, xerr.New(xerr.PermissionDenied, "point wallet policy is not configured") + } + ratioPointAmount, ratioCoinAmount = policy.PointsPerUSD, ledger.SalaryExchangeCoinPerUSD } - if !found { - return ledger.PointToCoinReceipt{}, xerr.New(xerr.PermissionDenied, "point wallet policy is not configured") - } - ratioPointAmount, ratioCoinAmount := policy.PointsPerUSD, ledger.SalaryExchangeCoinPerUSD numerator, err := checkedMul(command.PointAmount, ratioCoinAmount) if err != nil { return ledger.PointToCoinReceipt{}, err @@ -66,17 +91,18 @@ func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.Poi } nowMS := time.Now().UTC().UnixMilli() - pointAccount, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetPoint, false, nowMS) + states, err := r.lockAccountsOrdered(ctx, tx, []walletAccountLockRequest{ + {UserID: command.UserID, AssetType: sourceAssetType, CreateIfMissing: false}, + {UserID: command.UserID, AssetType: ledger.AssetCoin, CreateIfMissing: true}, + }, nowMS) if err != nil { return ledger.PointToCoinReceipt{}, err } + pointAccount := states[walletAccountLockKey(command.UserID, sourceAssetType)].account if pointAccount.AvailableAmount < command.PointAmount { - return ledger.PointToCoinReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient POINT balance") - } - coinAccount, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMS) - if err != nil { - return ledger.PointToCoinReceipt{}, err + return ledger.PointToCoinReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient point balance") } + coinAccount := states[walletAccountLockKey(command.UserID, ledger.AssetCoin)].account pointAfter := pointAccount.AvailableAmount - command.PointAmount coinAfter, err := checkedAdd(coinAccount.AvailableAmount, coinAmount) if err != nil { @@ -85,9 +111,10 @@ func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.Poi transactionID := transactionID(command.AppCode, command.CommandID) metadata := pointToCoinExchangeMetadata{ - AppCode: command.AppCode, UserID: command.UserID, PointAmount: command.PointAmount, CoinAmount: coinAmount, + AppCode: command.AppCode, UserID: command.UserID, PointAmount: command.PointAmount, SourceAssetType: sourceAssetType, CoinAmount: coinAmount, RatioPointAmount: ratioPointAmount, RatioCoinAmount: ratioCoinAmount, PointBalanceAfter: pointAfter, CoinBalanceAfter: coinAfter, CreatedAtMS: nowMS, + PolicyID: policyID, PolicyVersion: policyVersion, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypePointExchangeToCoin, requestHash, fmt.Sprintf("point_exchange:%d", command.UserID), metadata, nowMS); err != nil { return ledger.PointToCoinReceipt{}, err @@ -95,7 +122,7 @@ func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.Poi if err := r.applyAccountDelta(ctx, tx, pointAccount, -command.PointAmount, 0, nowMS); err != nil { return ledger.PointToCoinReceipt{}, err } - if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.UserID, AssetType: ledger.AssetPoint, AvailableDelta: -command.PointAmount, AvailableAfter: pointAfter, FrozenAfter: pointAccount.FrozenAmount, CreatedAtMS: nowMS}); err != nil { + if err := r.insertEntry(ctx, tx, walletEntry{TransactionID: transactionID, UserID: command.UserID, AssetType: sourceAssetType, AvailableDelta: -command.PointAmount, AvailableAfter: pointAfter, FrozenAfter: pointAccount.FrozenAmount, CreatedAtMS: nowMS}); err != nil { return ledger.PointToCoinReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, coinAccount, coinAmount, 0, nowMS); err != nil { @@ -105,7 +132,7 @@ func (r *Repository) ExchangePointToCoin(ctx context.Context, command ledger.Poi return ledger.PointToCoinReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ - balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetPoint, -command.PointAmount, 0, pointAfter, pointAccount.FrozenAmount, pointAccount.Version+1, metadata, nowMS, bizTypePointExchangeToCoin), + balanceChangedEvent(transactionID, command.CommandID, command.UserID, sourceAssetType, -command.PointAmount, 0, pointAfter, pointAccount.FrozenAmount, pointAccount.Version+1, metadata, nowMS, bizTypePointExchangeToCoin), balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, coinAmount, 0, coinAfter, coinAccount.FrozenAmount, coinAccount.Version+1, metadata, nowMS, bizTypePointExchangeToCoin), }); err != nil { return ledger.PointToCoinReceipt{}, err @@ -133,5 +160,55 @@ func receiptFromPointToCoinExchangeMetadata(transactionID string, metadata point TransactionID: transactionID, UserID: metadata.UserID, PointAmount: metadata.PointAmount, CoinAmount: metadata.CoinAmount, PointBalanceAfter: metadata.PointBalanceAfter, CoinBalanceAfter: metadata.CoinBalanceAfter, RatioPointAmount: metadata.RatioPointAmount, RatioCoinAmount: metadata.RatioCoinAmount, CreatedAtMS: metadata.CreatedAtMS, + SourceAssetType: metadata.SourceAssetType, PolicyID: metadata.PolicyID, PolicyVersion: metadata.PolicyVersion, } } + +// resolvePointDiamondHostPolicy 读取当前区域不晚于本月的最近一份 POINT_DIAMOND 发布快照。 +// 永久积分不会随周期清空,因此当前政策切回工资型后,旧余额仍必须按最后一份积分政策兑换和退出。 +func (r *Repository) resolvePointDiamondHostPolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMS int64) (ledger.HostSalaryPolicy, error) { + if nowMS <= 0 { + nowMS = time.Now().UTC().UnixMilli() + } + cycleKey := time.UnixMilli(nowMS).UTC().Format("2006-01") + var policy ledger.HostSalaryPolicy + // 已发布政策快照不可修改,资金动作只需要在自身事务的一致性视图中读取并把版本写入回执; + // 对这行加 FOR UPDATE 不增加正确性,反而会让同一区域的 overview 和兑换/提现互相串行。 + err := tx.QueryRowContext(ctx, ` + SELECT p.policy_id, p.policy_version, binding.cycle_key, binding.region_id, + p.point_diamonds_per_usd, p.coins_per_usd, p.minimum_withdraw_usd_minor, + p.withdraw_fee_bps, p.agency_point_share_bps, + p.coin_seller_withdrawal_limit_period, p.coin_seller_withdrawal_limit_count, + p.platform_withdrawal_limit_period, p.platform_withdrawal_limit_count, + p.platform_withdrawal_allowed_days + FROM host_salary_policy_cycle_bindings binding + JOIN host_agency_salary_policies p + ON p.app_code = binding.app_code + AND p.policy_id = binding.policy_id + AND p.policy_version = binding.policy_version + WHERE binding.app_code = ? + AND binding.region_id = ? + AND binding.cycle_key <= ? + AND p.status = 'active' + AND p.policy_type = ? + ORDER BY binding.cycle_key DESC, p.policy_version DESC + LIMIT 1`, appcode.FromContext(ctx), regionID, cycleKey, ledger.HostPolicyTypePointDiamond).Scan( + &policy.PolicyID, &policy.PolicyVersion, &policy.CycleKey, &policy.RegionID, + &policy.PointDiamondsPerUSD, &policy.CoinsPerUSD, &policy.MinimumWithdrawUSDMinor, + &policy.WithdrawFeeBPS, &policy.AgencyPointShareBPS, + &policy.CoinSellerWithdrawalLimitPeriod, &policy.CoinSellerWithdrawalLimitCount, + &policy.PlatformWithdrawalLimitPeriod, &policy.PlatformWithdrawalLimitCount, + &policy.PlatformWithdrawalAllowedDays, + ) + if errors.Is(err, sql.ErrNoRows) { + return ledger.HostSalaryPolicy{}, xerr.New(xerr.PermissionDenied, "point diamond policy is not configured") + } + if err != nil { + return ledger.HostSalaryPolicy{}, err + } + policy.PolicyType = ledger.HostPolicyTypePointDiamond + if policy.PointDiamondsPerUSD <= 0 || policy.CoinsPerUSD <= 0 || policy.MinimumWithdrawUSDMinor <= 0 || policy.WithdrawFeeBPS < 0 || policy.WithdrawFeeBPS > 10_000 { + return ledger.HostSalaryPolicy{}, xerr.New(xerr.Internal, "point diamond policy is invalid") + } + return policy, nil +} diff --git a/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go b/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go index f03d2a49..72ee3eba 100644 --- a/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go +++ b/services/wallet-service/internal/storage/mysql/point_withdrawal_limit.go @@ -10,6 +10,7 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/domain/ledger" ) const ( @@ -21,7 +22,7 @@ const ( withdrawalLimitPeriodMonth = "month" ) -type pointWithdrawalPolicyLimit struct { +type hostPolicyWithdrawalLimit struct { PolicyID uint64 PolicyVersion uint64 Period string @@ -30,21 +31,43 @@ type pointWithdrawalPolicyLimit struct { AllowedDays string } -// consumePointWithdrawalLimit 在真实账变事务内解析当前 UTC 月绑定政策并锁定一条计数主键。 +type missingHostPolicyMode uint8 + +const ( + // 默认值必须失败关闭,避免新增调用点遗漏参数时意外绕过 POINT 的政策约束。 + requireHostPolicy missingHostPolicyMode = iota + // Host/Agency 旧工资余额可能早于政策发布;缺少本月及紧邻上月政策时继续沿用原无限次规则。 + allowLegacyWithoutHostPolicy +) + +// consumeHostPolicyWithdrawalLimit 在真实账变事务内解析当前 UTC 月绑定政策并锁定一条计数主键。 // 调用方必须先完成 wallet transaction 幂等命中检查;这样重放直接返回旧回执,不会重复消耗次数。 -func (r *Repository) consumePointWithdrawalLimit(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, channel string, commandID string, withdrawalRef string, nowMS int64) error { - if userID <= 0 || regionID <= 0 || (channel != pointWithdrawalChannelCoinSeller && channel != pointWithdrawalChannelPlatform) { +// consumeHostPolicyWithdrawalLimitForPolicyType 允许永久积分余额继续使用其最后一份已发布 +// POINT_DIAMOND 快照中的限制;政策切回工资型后,旧积分不能因为当前月类型改变而失去退出能力。 +func (r *Repository) consumeHostPolicyWithdrawalLimitForPolicyType(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, channel string, commandID string, withdrawalRef string, nowMS int64, sourceAssetType string, policyType string, missingPolicyMode missingHostPolicyMode) error { + if userID <= 0 || regionID < 0 || (channel != pointWithdrawalChannelCoinSeller && channel != pointWithdrawalChannelPlatform) { return xerr.New(xerr.InvalidArgument, "point withdrawal limit target is invalid") } - limit, err := r.resolvePointWithdrawalPolicyLimit(ctx, tx, regionID, channel, nowMS) + sourceAssetType = strings.ToUpper(strings.TrimSpace(sourceAssetType)) + if sourceAssetType == "" { + return xerr.New(xerr.InvalidArgument, "withdrawal source asset type is required") + } + if missingPolicyMode != requireHostPolicy && missingPolicyMode != allowLegacyWithoutHostPolicy { + return xerr.New(xerr.Internal, "host policy missing mode is invalid") + } + limit, found, err := r.resolveHostPolicyWithdrawalLimitForPolicyType(ctx, tx, regionID, channel, nowMS, policyType) if err != nil { return err } + if !found && missingPolicyMode == requireHostPolicy { + // POINT 只有后台政策才能定义提现口径;不存在政策时禁止按工资余额兼容规则放行。 + return xerr.New(xerr.PermissionDenied, "host salary policy is not configured for withdrawal region") + } if channel == pointWithdrawalChannelPlatform && strings.TrimSpace(withdrawalRef) == "" { // 平台先冻结再建申请;没有稳定引用就无法在建单失败时精确撤销这一次 reservation。 return xerr.New(xerr.InvalidArgument, "withdrawal_ref is required for platform withdrawal") } - if channel == pointWithdrawalChannelPlatform { + if found && channel == pointWithdrawalChannelPlatform { allowed, err := platformWithdrawalAllowedOnUTCDate(limit.AllowedDays, time.UnixMilli(nowMS).UTC()) if err != nil { return err @@ -59,14 +82,14 @@ func (r *Repository) consumePointWithdrawalLimit(ctx context.Context, tx *sql.Tx } appCode := appcode.FromContext(ctx) - if limit.Limit > 0 { + if found && limit.Limit > 0 { if _, err := tx.ExecContext(ctx, ` INSERT INTO point_withdrawal_policy_counters ( - app_code, user_id, channel, period, period_key, + app_code, source_asset_type, user_id, channel, period, period_key, withdrawal_count, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, 0, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE period_key = VALUES(period_key)`, - appCode, userID, channel, limit.Period, limit.PeriodKey, nowMS, nowMS, + appCode, sourceAssetType, userID, channel, limit.Period, limit.PeriodKey, nowMS, nowMS, ); err != nil { return err } @@ -74,8 +97,8 @@ func (r *Repository) consumePointWithdrawalLimit(ctx context.Context, tx *sql.Tx if err := tx.QueryRowContext(ctx, ` SELECT withdrawal_count FROM point_withdrawal_policy_counters - WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ? - FOR UPDATE`, appCode, userID, channel, limit.Period, limit.PeriodKey, + WHERE app_code = ? AND source_asset_type = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ? + FOR UPDATE`, appCode, sourceAssetType, userID, channel, limit.Period, limit.PeriodKey, ).Scan(¤t); err != nil { return err } @@ -89,23 +112,25 @@ func (r *Repository) consumePointWithdrawalLimit(ctx context.Context, tx *sql.Tx if _, err := tx.ExecContext(ctx, ` UPDATE point_withdrawal_policy_counters SET withdrawal_count = withdrawal_count + 1, updated_at_ms = ? - WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?`, - nowMS, appCode, userID, channel, limit.Period, limit.PeriodKey, + WHERE app_code = ? AND source_asset_type = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ?`, + nowMS, appCode, sourceAssetType, userID, channel, limit.Period, limit.PeriodKey, ); err != nil { return err } } if channel != pointWithdrawalChannelPlatform { + // 转币商是单事务账变;缺政策时无需 reservation,直接保留旧工资余额的无限次转出行为。 return nil } - // reservation 与冻结账变同事务提交;后续只允许“申请创建失败”按 withdrawal_ref 撤销,人工驳回仍计一次发起。 + // 即使 Host/Agency 没有政策也写 counted=false reservation:它不是次数限制,而是保证建单失败回滚后 + // 原 freeze command 不能重放旧成功回执。policy_id/version=0 明确表示兼容旧工资余额而非命中政策。 _, err = tx.ExecContext(ctx, ` INSERT INTO point_withdrawal_limit_reservations ( - app_code, withdrawal_ref, freeze_command_id, user_id, channel, + app_code, source_asset_type, withdrawal_ref, freeze_command_id, user_id, channel, policy_id, policy_version, period, period_key, counted, status, created_at_ms, released_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 0)`, - appCode, strings.TrimSpace(withdrawalRef), strings.TrimSpace(commandID), userID, channel, - limit.PolicyID, limit.PolicyVersion, limit.Period, limit.PeriodKey, limit.Limit > 0, nowMS, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, 0)`, + appCode, sourceAssetType, strings.TrimSpace(withdrawalRef), strings.TrimSpace(commandID), userID, channel, + limit.PolicyID, limit.PolicyVersion, limit.Period, limit.PeriodKey, found && limit.Limit > 0, nowMS, ) if isMySQLDuplicateError(err) { return xerr.New(xerr.IdempotencyConflict, "point withdrawal reservation already belongs to another command") @@ -113,21 +138,58 @@ func (r *Repository) consumePointWithdrawalLimit(ctx context.Context, tx *sql.Tx return err } -func (r *Repository) resolvePointWithdrawalPolicyLimit(ctx context.Context, tx *sql.Tx, regionID int64, channel string, nowMS int64) (pointWithdrawalPolicyLimit, error) { +func (r *Repository) resolveHostPolicyWithdrawalLimit(ctx context.Context, tx *sql.Tx, regionID int64, channel string, nowMS int64) (hostPolicyWithdrawalLimit, bool, error) { + return r.resolveHostPolicyWithdrawalLimitForPolicyType(ctx, tx, regionID, channel, nowMS, "") +} + +func (r *Repository) resolveHostPolicyWithdrawalLimitForPolicyType(ctx context.Context, tx *sql.Tx, regionID int64, channel string, nowMS int64, policyType string) (hostPolicyWithdrawalLimit, bool, error) { if nowMS <= 0 { nowMS = time.Now().UTC().UnixMilli() } now := time.UnixMilli(nowMS).UTC() cycleKey := now.Format("2006-01") - // 复用月工资政策的不可变周期绑定解析;当前月缺失时沿用同一套“只复制紧邻上月”规则,限制与发薪不会选到不同版本。 - policy, found, err := r.queryHostSalaryPolicyByCycle(ctx, tx, regionID, cycleKey, "", "") - if err != nil { - return pointWithdrawalPolicyLimit{}, err + // 无政策兼容 reservation 仍需要稳定且可审计的周期字段;不计数,因此不会占用该自然月额度。 + limit := hostPolicyWithdrawalLimit{Period: withdrawalLimitPeriodMonth, PeriodKey: cycleKey} + if regionID == 0 { + // 没有区域时不能猜测某个区域的 Host 政策;调用方必须显式决定失败关闭还是使用工资兼容语义。 + return limit, false, nil } - if !found { - return pointWithdrawalPolicyLimit{}, xerr.New(xerr.PermissionDenied, "host salary policy is not configured for withdrawal region") + var policy ledger.HostSalaryPolicy + var found bool + var err error + if strings.EqualFold(strings.TrimSpace(policyType), ledger.HostPolicyTypePointDiamond) { + // 永久积分按照最新一份历史 POINT_DIAMOND 绑定继续提现;当前政策改回工资型不影响既有余额。 + policy, err = r.resolvePointDiamondHostPolicy(ctx, tx, regionID, nowMS) + if err != nil { + if xerr.CodeOf(err) == xerr.PermissionDenied { + return limit, false, nil + } + return hostPolicyWithdrawalLimit{}, false, err + } + found = true + } else { + // 工资与历史 POINT 复用当前周期绑定,保留原有“仅紧邻上月复制”的限制语义。 + policy, found, err = r.queryHostSalaryPolicyByCycle(ctx, tx, regionID, cycleKey, "", "") + if err != nil { + return hostPolicyWithdrawalLimit{}, false, err + } + if !found { + return limit, false, nil + } + } + resolved, resolveErr := hostPolicyWithdrawalLimitFromPolicy(policy, channel, now) + if resolveErr != nil { + return hostPolicyWithdrawalLimit{}, false, resolveErr + } + return resolved, true, nil +} + +// hostPolicyWithdrawalLimitFromPolicy 让执行校验与 overview 复用同一份已发布政策快照和 UTC 周期算法。 +func hostPolicyWithdrawalLimitFromPolicy(policy ledger.HostSalaryPolicy, channel string, now time.Time) (hostPolicyWithdrawalLimit, error) { + limit := hostPolicyWithdrawalLimit{ + PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion, + Period: withdrawalLimitPeriodMonth, PeriodKey: now.UTC().Format("2006-01"), } - limit := pointWithdrawalPolicyLimit{PolicyID: policy.PolicyID, PolicyVersion: policy.PolicyVersion} switch channel { case pointWithdrawalChannelCoinSeller: limit.Period, limit.Limit = policy.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount @@ -135,11 +197,11 @@ func (r *Repository) resolvePointWithdrawalPolicyLimit(ctx context.Context, tx * limit.Period, limit.Limit = policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount limit.AllowedDays = strings.TrimSpace(policy.PlatformWithdrawalAllowedDays) default: - return pointWithdrawalPolicyLimit{}, xerr.New(xerr.InvalidArgument, "point withdrawal channel is invalid") + return hostPolicyWithdrawalLimit{}, xerr.New(xerr.InvalidArgument, "host policy withdrawal channel is invalid") } limit.Period = strings.ToLower(strings.TrimSpace(limit.Period)) if limit.Limit < 0 { - return pointWithdrawalPolicyLimit{}, xerr.New(xerr.Internal, "point withdrawal policy limit is invalid") + return hostPolicyWithdrawalLimit{}, xerr.New(xerr.Internal, "host policy withdrawal limit is invalid") } if limit.Period == "" { limit.Period = withdrawalLimitPeriodMonth @@ -152,9 +214,9 @@ func (r *Repository) resolvePointWithdrawalPolicyLimit(ctx context.Context, tx * mondayOffset := (int(now.Weekday()) + 6) % 7 limit.PeriodKey = now.AddDate(0, 0, -mondayOffset).Format("2006-01-02") case withdrawalLimitPeriodMonth: - limit.PeriodKey = cycleKey + limit.PeriodKey = now.UTC().Format("2006-01") default: - return pointWithdrawalPolicyLimit{}, xerr.New(xerr.Internal, "point withdrawal policy period is invalid") + return hostPolicyWithdrawalLimit{}, xerr.New(xerr.Internal, "host policy withdrawal period is invalid") } return limit, nil } @@ -185,10 +247,56 @@ func platformWithdrawalAllowedOnUTCDate(raw string, now time.Time) (bool, error) return allowed, nil } -// releasePointWithdrawalLimitReservation 只服务 gateway 建单失败补偿。 +// pointWithdrawalActionAvailability 只读命中 counter 主键,不加锁也不预占次数;实际动作仍会在 +// 账变事务内锁行并重新校验,因此 overview 只是准确的当前快照,不承担授权事实。 +func (r *Repository) pointWithdrawalActionAvailability(ctx context.Context, tx *sql.Tx, userID int64, sourceAssetType string, channel string, limit hostPolicyWithdrawalLimit, now time.Time) (ledger.PointWithdrawalActionAvailability, error) { + availability := ledger.PointWithdrawalActionAvailability{ + Allowed: true, LimitPeriod: limit.Period, LimitCount: limit.Limit, + RemainingCount: -1, AllowedDays: limit.AllowedDays, + } + if channel == pointWithdrawalChannelPlatform { + allowed, err := platformWithdrawalAllowedOnUTCDate(limit.AllowedDays, now.UTC()) + if err != nil { + return ledger.PointWithdrawalActionAvailability{}, err + } + if !allowed { + availability.Allowed = false + availability.BlockReason = "date_not_allowed" + } + } + if limit.Limit <= 0 { + return availability, nil + } + var used int64 + err := tx.QueryRowContext(ctx, ` + SELECT withdrawal_count + FROM point_withdrawal_policy_counters + WHERE app_code = ? AND source_asset_type = ? AND user_id = ? + AND channel = ? AND period = ? AND period_key = ?`, + appcode.FromContext(ctx), sourceAssetType, userID, channel, limit.Period, limit.PeriodKey, + ).Scan(&used) + if errors.Is(err, sql.ErrNoRows) { + used = 0 + } else if err != nil { + return ledger.PointWithdrawalActionAvailability{}, err + } + availability.UsedCount = used + availability.RemainingCount = limit.Limit - used + if availability.RemainingCount < 0 { + availability.RemainingCount = 0 + } + if used >= limit.Limit { + availability.Allowed = false + availability.BlockReason = "limit_reached" + } + return availability, nil +} + +// releaseHostPolicyWithdrawalLimitReservation 只服务 gateway 建单失败补偿。 // application_id 非空的人工驳回不会调用这里,因此驳回申请仍占用原限制周期的一次次数。 -func (r *Repository) releasePointWithdrawalLimitReservation(ctx context.Context, tx *sql.Tx, userID int64, withdrawalRef string, rollbackCommandID string, nowMS int64) error { +func (r *Repository) releaseHostPolicyWithdrawalLimitReservation(ctx context.Context, tx *sql.Tx, userID int64, sourceAssetType string, withdrawalRef string, rollbackCommandID string, nowMS int64) error { withdrawalRef = strings.TrimSpace(withdrawalRef) + sourceAssetType = strings.ToUpper(strings.TrimSpace(sourceAssetType)) appCode := appcode.FromContext(ctx) var reservedUserID int64 var channel string @@ -202,8 +310,8 @@ func (r *Repository) releasePointWithdrawalLimitReservation(ctx context.Context, err = tx.QueryRowContext(ctx, ` SELECT withdrawal_ref, user_id, channel, period, period_key, counted, status FROM point_withdrawal_limit_reservations - WHERE app_code = ? AND withdrawal_ref = ? - FOR UPDATE`, appCode, withdrawalRef, + WHERE app_code = ? AND source_asset_type = ? AND withdrawal_ref = ? + FOR UPDATE`, appCode, sourceAssetType, withdrawalRef, ).Scan(&withdrawalRef, &reservedUserID, &channel, &period, &periodKey, &counted, &status) } if !queryByRef || errors.Is(err, sql.ErrNoRows) { @@ -218,8 +326,8 @@ func (r *Repository) releasePointWithdrawalLimitReservation(ctx context.Context, err = tx.QueryRowContext(ctx, ` SELECT withdrawal_ref, user_id, channel, period, period_key, counted, status FROM point_withdrawal_limit_reservations - WHERE app_code = ? AND freeze_command_id = ? - FOR UPDATE`, appCode, freezeCommandID, + WHERE app_code = ? AND source_asset_type = ? AND freeze_command_id = ? + FOR UPDATE`, appCode, sourceAssetType, freezeCommandID, ).Scan(&withdrawalRef, &reservedUserID, &channel, &period, &periodKey, &counted, &status) } if errors.Is(err, sql.ErrNoRows) { @@ -230,21 +338,21 @@ func (r *Repository) releasePointWithdrawalLimitReservation(ctx context.Context, return err } if reservedUserID != userID || channel != pointWithdrawalChannelPlatform { - return xerr.New(xerr.IdempotencyConflict, "point withdrawal reservation does not match rollback") + return xerr.New(xerr.IdempotencyConflict, "withdrawal reservation does not match rollback") } if status == "released" { return nil } if status != "active" { - return xerr.New(xerr.Internal, "point withdrawal reservation status is invalid") + return xerr.New(xerr.Internal, "withdrawal reservation status is invalid") } if counted { // policy_id/version 只保留为命中政策审计;自然周期计数跨区域和同月显式政策切换连续,释放也必须命中共享周期键。 result, err := tx.ExecContext(ctx, ` UPDATE point_withdrawal_policy_counters SET withdrawal_count = withdrawal_count - 1, updated_at_ms = ? - WHERE app_code = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ? - AND withdrawal_count > 0`, nowMS, appCode, userID, channel, period, periodKey) + WHERE app_code = ? AND source_asset_type = ? AND user_id = ? AND channel = ? AND period = ? AND period_key = ? + AND withdrawal_count > 0`, nowMS, appCode, sourceAssetType, userID, channel, period, periodKey) if err != nil { return err } @@ -252,24 +360,24 @@ func (r *Repository) releasePointWithdrawalLimitReservation(ctx context.Context, if err != nil { return err } - return xerr.New(xerr.Internal, "point withdrawal reservation counter is missing") + return xerr.New(xerr.Internal, "withdrawal reservation counter is missing") } } _, err = tx.ExecContext(ctx, ` UPDATE point_withdrawal_limit_reservations SET status = 'released', released_at_ms = ? - WHERE app_code = ? AND withdrawal_ref = ? AND status = 'active'`, nowMS, appCode, withdrawalRef) + WHERE app_code = ? AND source_asset_type = ? AND withdrawal_ref = ? AND status = 'active'`, nowMS, appCode, sourceAssetType, withdrawalRef) return err } -// pointWithdrawalReservationWasReleased 防止已补偿冻结的 command_id 重放旧成功回执。 +// hostPolicyWithdrawalReservationWasReleased 防止已补偿冻结的 command_id 重放旧成功回执。 // reservation 与冻结交易同事务落库,因此 active/不存在仍可按原幂等语义返回;released 必须要求客户端换新命令。 -func (r *Repository) pointWithdrawalReservationWasReleased(ctx context.Context, tx *sql.Tx, commandID string) (bool, error) { +func (r *Repository) hostPolicyWithdrawalReservationWasReleased(ctx context.Context, tx *sql.Tx, sourceAssetType string, commandID string) (bool, error) { var status string err := tx.QueryRowContext(ctx, ` SELECT status FROM point_withdrawal_limit_reservations - WHERE app_code = ? AND freeze_command_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(commandID)).Scan(&status) + WHERE app_code = ? AND source_asset_type = ? AND freeze_command_id = ?`, appcode.FromContext(ctx), strings.ToUpper(strings.TrimSpace(sourceAssetType)), strings.TrimSpace(commandID)).Scan(&status) if errors.Is(err, sql.ErrNoRows) { return false, nil } diff --git a/services/wallet-service/internal/storage/mysql/policy_instance.go b/services/wallet-service/internal/storage/mysql/policy_instance.go index 601b15bd..c9c60489 100644 --- a/services/wallet-service/internal/storage/mysql/policy_instance.go +++ b/services/wallet-service/internal/storage/mysql/policy_instance.go @@ -9,105 +9,59 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" ) -const ( - // charge_amount 是 ca15f098 上线时的兼容口径:Agency 比例直接作用于礼物实扣金额, - // 因而主播比例与 Agency 比例必须共同受 100% 资金池约束。 - agencyPointShareBaseChargeAmount = "charge_amount" - // host_income 表示平台在主播本次实际 POINT 收益之外追加 Agency 奖励。 - // 该口径先完成礼物类型和主播政策换算,再对最终主播收益取比例,允许出现 100%+20%。 - agencyPointShareBaseHostIncome = "host_income" -) +const defaultPointWithdrawalMinimumPoints int64 = 1_000_000 +// walletPolicyInstance 只承载现有 POINT 提现和兑换仍需使用的参数。 +// 礼物结算不得调用这个解析器;主播周期钻石只由礼物类型倍率和 Host 政策系数计算。 type walletPolicyInstance struct { - InstanceCode string - TemplateCode string - TemplateVersion string - RegionID int64 - HostPointAsset string - HostPointRatioPPM int64 - HostGiftTypeRatioEnabled bool - AgencyPointRatioPPM int64 - AgencyPointShareBase string - PointsPerUSD int64 - WithdrawFeeBPS int64 + InstanceCode string + RegionID int64 + PointsPerUSD int64 + MinimumPoints int64 + WithdrawFeeBPS int64 } +// resolveActiveWalletPolicy 延续既有 POINT 钱包已发布实例的区域优先、App 兜底语义。 +// 查询由 idx_wallet_policy_active 覆盖配置筛选,不扫描账户、流水或礼物明细。 func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, nowMS int64) (walletPolicyInstance, bool, error) { appCode = appcode.Normalize(appCode) if regionID < 0 { return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "region_id is invalid") } row := tx.QueryRowContext(ctx, ` - SELECT instance_code, template_code, template_version, region_id, host_point_asset_type, - host_point_ratio_ppm, - COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.host.use_gift_type_ratio')), 'false'), - agency_point_ratio_ppm, - COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.agency.share_base')), 'charge_amount'), - points_per_usd, withdraw_fee_bps + SELECT instance_code, region_id, points_per_usd, withdraw_fee_bps, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.host.minimum_withdraw_points')), '') FROM wallet_policy_instances WHERE app_code = ? AND region_id IN (?, 0) AND status = 'active' AND effective_from_ms <= ? AND (effective_to_ms = 0 OR effective_to_ms > ?) - ORDER BY CASE WHEN region_id = ? THEN 0 ELSE 1 END, published_at_ms DESC, instance_code DESC + ORDER BY CASE WHEN region_id = ? THEN 0 ELSE 1 END, + published_at_ms DESC, instance_code DESC LIMIT 1`, appCode, regionID, nowMS, nowMS, regionID, ) var item walletPolicyInstance - var hostGiftTypeRatioRaw string - if err := row.Scan( - &item.InstanceCode, - &item.TemplateCode, - &item.TemplateVersion, - &item.RegionID, - &item.HostPointAsset, - &item.HostPointRatioPPM, - &hostGiftTypeRatioRaw, - &item.AgencyPointRatioPPM, - &item.AgencyPointShareBase, - &item.PointsPerUSD, - &item.WithdrawFeeBPS, - ); err != nil { + var minimumPointsRaw string + if err := row.Scan(&item.InstanceCode, &item.RegionID, &item.PointsPerUSD, &item.WithdrawFeeBPS, &minimumPointsRaw); err != nil { if errors.Is(err, sql.ErrNoRows) { return walletPolicyInstance{}, false, nil } return walletPolicyInstance{}, false, err } - item.HostPointAsset = strings.ToUpper(strings.TrimSpace(item.HostPointAsset)) - if item.HostPointAsset == "" { - item.HostPointAsset = ledger.AssetPoint - } - parsedGiftTypeRatio, err := strconv.ParseBool(strings.ToLower(strings.TrimSpace(hostGiftTypeRatioRaw))) - if err != nil { - return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy host gift type ratio flag is invalid") - } - item.HostGiftTypeRatioEnabled = parsedGiftTypeRatio - item.AgencyPointShareBase = strings.ToLower(strings.TrimSpace(item.AgencyPointShareBase)) - if item.AgencyPointShareBase == "" { - item.AgencyPointShareBase = agencyPointShareBaseChargeAmount - } - if item.HostPointAsset != ledger.AssetPoint || - item.HostPointRatioPPM <= 0 || item.HostPointRatioPPM > 1_000_000 || - item.AgencyPointRatioPPM < 0 || item.AgencyPointRatioPPM > 1_000_000 || - item.PointsPerUSD <= 0 || item.WithdrawFeeBPS < 0 || item.WithdrawFeeBPS > 10_000 { - return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy instance is invalid") - } - switch item.AgencyPointShareBase { - case agencyPointShareBaseChargeAmount: - // 旧实例没有 share_base,必须继续执行原先的总资金池守恒约束;否则一次重新发布就会 - // 把历史 70%+20% 配置悄然解释成可超发政策。 - if item.HostPointRatioPPM+item.AgencyPointRatioPPM > 1_000_000 { - return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy instance is invalid") + item.MinimumPoints = defaultPointWithdrawalMinimumPoints + if strings.TrimSpace(minimumPointsRaw) != "" { + minimumPoints, err := strconv.ParseInt(strings.TrimSpace(minimumPointsRaw), 10, 64) + if err != nil { + return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy minimum withdrawal points is invalid") } - case agencyPointShareBaseHostIncome: - // host_income 是额外平台奖励,Agency 百分比相对于主播实际到账值独立校验,不能与 - // host_point_ratio_ppm 相加后套用旧的 100% 上限。 - default: - return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy agency share base is invalid") + item.MinimumPoints = minimumPoints + } + if item.InstanceCode == "" || item.PointsPerUSD <= 0 || item.MinimumPoints <= 0 || item.WithdrawFeeBPS < 0 || item.WithdrawFeeBPS > 10_000 { + return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet point policy is invalid") } return item, true, nil } diff --git a/services/wallet-service/internal/storage/mysql/receipts.go b/services/wallet-service/internal/storage/mysql/receipts.go index 6d7b8168..4d56c621 100644 --- a/services/wallet-service/internal/storage/mysql/receipts.go +++ b/services/wallet-service/internal/storage/mysql/receipts.go @@ -60,6 +60,17 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger BalanceAfter: metadata.BalanceAfter, BalanceVersion: metadata.BalanceVersion, HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded, + HostIncomePolicyType: metadata.HostIncomePolicyType, + HostIncomeBalanceAfter: metadata.HostIncomeBalanceAfter, + IncomeAssetType: metadata.IncomeAssetType, + HostBaseAmount: metadata.HostBaseAmount, + AgencyAmount: metadata.AgencyAmount, + AgencyOwnerUserID: metadata.TargetAgencyOwnerUserID, + AgencyIncomeBalanceAfter: metadata.AgencyIncomeBalanceAfter, + HostIncomePolicyID: metadata.HostSalaryPolicyID, + HostIncomePolicyVersion: metadata.HostSalaryPolicyVersion, + HostPolicyCoefficient: metadata.HostSalaryPolicyDiamondCoefficient, + AgencyPointShareBPS: metadata.AgencyPointShareBPS, HostPeriodCycleKey: metadata.HostPeriodCycleKey, HostPointAdded: metadata.HostPointAdded, HostPointBalanceAfter: metadata.HostPointBalanceAfter, @@ -506,17 +517,24 @@ func (r *Repository) receiptForSalaryWithdrawalTransaction(ctx context.Context, func receiptFromSalaryWithdrawalMetadata(transactionID string, metadata salaryWithdrawalMetadata) ledger.SalaryWithdrawalReceipt { return ledger.SalaryWithdrawalReceipt{ - TransactionID: transactionID, - UserID: metadata.UserID, - SalaryAssetType: metadata.SalaryAssetType, - SalaryUSDMinor: metadata.SalaryUSDMinor, - PointFeeAmount: metadata.PointFeeAmount, - PointNetAmount: metadata.PointNetAmount, - PointsPerUSD: metadata.PointsPerUSD, - PointWithdrawFeeBPS: metadata.PointWithdrawFeeBPS, - AvailableAfter: metadata.AvailableAfter, - FrozenAfter: metadata.FrozenAfter, - Version: metadata.Version, - CreatedAtMS: metadata.CreatedAtMS, + TransactionID: transactionID, + UserID: metadata.UserID, + SalaryAssetType: metadata.SalaryAssetType, + SalaryUSDMinor: metadata.SalaryUSDMinor, + PointFeeAmount: metadata.PointFeeAmount, + PointNetAmount: metadata.PointNetAmount, + PointsPerUSD: metadata.PointsPerUSD, + PointWithdrawFeeBPS: metadata.PointWithdrawFeeBPS, + PointPolicyInstanceCode: metadata.PointPolicyInstanceCode, + GrossUSDMinor: metadata.GrossUSDMinor, + FeeUSDMinor: metadata.FeeUSDMinor, + NetUSDMinor: metadata.NetUSDMinor, + PointPolicyID: metadata.PointPolicyID, + PointPolicyVersion: metadata.PointPolicyVersion, + CoinsPerUSD: metadata.CoinsPerUSD, + AvailableAfter: metadata.AvailableAfter, + FrozenAfter: metadata.FrozenAfter, + Version: metadata.Version, + CreatedAtMS: metadata.CreatedAtMS, } } diff --git a/services/wallet-service/internal/storage/mysql/salary_withdrawal.go b/services/wallet-service/internal/storage/mysql/salary_withdrawal.go index 28a4f5bc..5a44d18f 100644 --- a/services/wallet-service/internal/storage/mysql/salary_withdrawal.go +++ b/services/wallet-service/internal/storage/mysql/salary_withdrawal.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "strings" "time" @@ -77,19 +78,105 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S if err != nil || !exists { return ledger.SalaryWithdrawalReceipt{}, err } - if mutation.BizType == bizTypeSalaryWithdrawalFreeze && strings.EqualFold(command.SalaryAssetType, ledger.AssetPoint) { - released, reservationErr := r.pointWithdrawalReservationWasReleased(ctx, tx, command.CommandID) + if mutation.BizType == bizTypeSalaryWithdrawalFreeze && salaryAssetUsesHostPolicyWithdrawal(command.SalaryAssetType) { + released, reservationErr := r.hostPolicyWithdrawalReservationWasReleased(ctx, tx, command.SalaryAssetType, command.CommandID) if reservationErr != nil { return ledger.SalaryWithdrawalReceipt{}, reservationErr } if released { - return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.IdempotencyConflict, "point withdrawal freeze was rolled back; use a new command_id") + return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.IdempotencyConflict, "withdrawal freeze was rolled back; use a new command_id") } } return r.receiptForSalaryWithdrawalTransaction(ctx, tx, txRow.TransactionID) } nowMs := time.Now().UTC().UnixMilli() + pointPolicyInstanceCode := "" + grossUSDMinor := int64(0) + feeUSDMinor := int64(0) + netUSDMinor := int64(0) + pointPolicyID := uint64(0) + pointPolicyVersion := uint64(0) + coinsPerUSD := int64(0) + if mutation.BizType == bizTypeSalaryWithdrawalFreeze && ledger.ValidPointWithdrawalAssetType(command.SalaryAssetType) { + // Gateway 的配置读取只是页面预览。资金冻结必须在本事务内重新解析 App + 区域 wallet 政策, + // 并由 wallet 覆盖比例/手续费快照,防止 Admin 改政策后的竞态或调用方篡改。 + pointsPerUSD := int64(0) + feeBPS := int64(0) + if command.SalaryAssetType == ledger.AssetPointDiamond { + policy, resolveErr := r.resolvePointDiamondHostPolicy(ctx, tx, command.RegionID, nowMs) + if resolveErr != nil { + return ledger.SalaryWithdrawalReceipt{}, resolveErr + } + if command.GrossUSDMinor < policy.MinimumWithdrawUSDMinor { + return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below configured minimum") + } + pointProduct, calculateErr := checkedMul(command.GrossUSDMinor, policy.PointDiamondsPerUSD) + if calculateErr != nil { + return ledger.SalaryWithdrawalReceipt{}, calculateErr + } + command.SalaryUSDMinor = pointProduct / 100 + pointsPerUSD, feeBPS = policy.PointDiamondsPerUSD, int64(policy.WithdrawFeeBPS) + pointPolicyInstanceCode = fmt.Sprintf("host:%d:%d", policy.PolicyID, policy.PolicyVersion) + pointPolicyID, pointPolicyVersion, coinsPerUSD = policy.PolicyID, policy.PolicyVersion, policy.CoinsPerUSD + feeUSDProduct, calculateErr := checkedMul(command.GrossUSDMinor, feeBPS) + if calculateErr != nil { + return ledger.SalaryWithdrawalReceipt{}, calculateErr + } + grossUSDMinor = command.GrossUSDMinor + feeUSDMinor = feeUSDProduct / 10_000 + netUSDMinor = grossUSDMinor - feeUSDMinor + } else { + policy, found, resolveErr := r.resolveActiveWalletPolicy(ctx, tx, command.AppCode, command.RegionID, nowMs) + if resolveErr != nil { + return ledger.SalaryWithdrawalReceipt{}, resolveErr + } + if !found { + return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.PermissionDenied, "point wallet policy is not configured") + } + if command.SalaryUSDMinor < policy.MinimumPoints { + return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InvalidArgument, "point withdrawal amount is below configured minimum") + } + pointsPerUSD, feeBPS = policy.PointsPerUSD, policy.WithdrawFeeBPS + pointPolicyInstanceCode = policy.InstanceCode + } + if command.SalaryUSDMinor <= 0 { + return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InvalidArgument, "withdrawal amount is below exchange precision") + } + command.PointsPerUSD = pointsPerUSD + command.PointWithdrawFeeBPS = feeBPS + if command.SalaryAssetType == ledger.AssetPointDiamond { + // 手续费先按美元分 floor,再把净美元换成积分,保证财务实际支付严格等于政策 net 美元分。 + netPointProduct, calculateErr := checkedMul(netUSDMinor, pointsPerUSD) + if calculateErr != nil { + return ledger.SalaryWithdrawalReceipt{}, calculateErr + } + command.PointNetAmount = netPointProduct / 100 + command.PointFeeAmount = command.SalaryUSDMinor - command.PointNetAmount + } else { + feeProduct, calculateErr := checkedMul(command.SalaryUSDMinor, feeBPS) + if calculateErr != nil { + return ledger.SalaryWithdrawalReceipt{}, calculateErr + } + command.PointFeeAmount = feeProduct / 10_000 + command.PointNetAmount = command.SalaryUSDMinor - command.PointFeeAmount + } + // POINT_DIAMOND 的积分冻结额直到事务内解析 Host 政策后才确定;FreezeSalaryWithdrawal + // 入参构造 mutation 时仍是 0,必须在锁账户前用最终积分重建双边 delta。 + mutation.AvailableDelta = -command.SalaryUSDMinor + mutation.FrozenDelta = command.SalaryUSDMinor + } else if command.SalaryAssetType == ledger.AssetPointDiamond { + // 审核通过、驳回和申请创建失败回滚都复制首次冻结快照;不能用审核时当前政策重算, + // 也不能把冻结积分数误写成 gross_usd_minor。 + freezeMetadata, loadErr := r.loadPointDiamondFreezeMetadata(ctx, tx, command) + if loadErr != nil { + return ledger.SalaryWithdrawalReceipt{}, loadErr + } + pointPolicyInstanceCode = freezeMetadata.PointPolicyInstanceCode + grossUSDMinor, feeUSDMinor, netUSDMinor = freezeMetadata.GrossUSDMinor, freezeMetadata.FeeUSDMinor, freezeMetadata.NetUSDMinor + pointPolicyID, pointPolicyVersion = freezeMetadata.PointPolicyID, freezeMetadata.PointPolicyVersion + coinsPerUSD = freezeMetadata.CoinsPerUSD + } account, err := r.lockAccount(ctx, tx, command.UserID, command.SalaryAssetType, false, nowMs) if err != nil { return ledger.SalaryWithdrawalReceipt{}, err @@ -97,15 +184,25 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S if account.AvailableAmount+mutation.AvailableDelta < 0 || account.FrozenAmount+mutation.FrozenDelta < 0 { return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } - // 只有 POINT 的首次冻结代表用户新发起一次“找平台提现”;审核扣款、驳回释放和其他工资资产都不能重复计数。 - if mutation.BizType == bizTypeSalaryWithdrawalFreeze && strings.EqualFold(command.SalaryAssetType, ledger.AssetPoint) { - if err := r.consumePointWithdrawalLimit(ctx, tx, command.UserID, command.RegionID, pointWithdrawalChannelPlatform, command.CommandID, command.WithdrawalRef, nowMs); err != nil { + // Host 政策工资和 POINT 都使用同一份周期绑定提现次数规则;审核扣款与驳回不能重复计数。 + if mutation.BizType == bizTypeSalaryWithdrawalFreeze && salaryAssetUsesHostPolicyWithdrawal(command.SalaryAssetType) { + missingPolicyMode := allowLegacyWithoutHostPolicy + if ledger.ValidPointWithdrawalAssetType(command.SalaryAssetType) { + // POINT 保持 Lalu 原链路:没有当前区域 Host 政策时失败关闭,不能绕过提现次数和日期限制。 + missingPolicyMode = requireHostPolicy + } + policyType := "" + if command.SalaryAssetType == ledger.AssetPointDiamond { + // 永久积分限制跟随最后一份 POINT_DIAMOND 快照,不能被随后发布的工资型政策覆盖。 + policyType = ledger.HostPolicyTypePointDiamond + } + if err := r.consumeHostPolicyWithdrawalLimitForPolicyType(ctx, tx, command.UserID, command.RegionID, pointWithdrawalChannelPlatform, command.CommandID, command.WithdrawalRef, nowMs, command.SalaryAssetType, policyType, missingPolicyMode); err != nil { return ledger.SalaryWithdrawalReceipt{}, err } } - if mutation.BizType == bizTypeSalaryWithdrawalRelease && strings.EqualFold(command.SalaryAssetType, ledger.AssetPoint) && strings.TrimSpace(command.WithdrawalApplicationID) == "" { + if mutation.BizType == bizTypeSalaryWithdrawalRelease && salaryAssetUsesHostPolicyWithdrawal(command.SalaryAssetType) && strings.TrimSpace(command.WithdrawalApplicationID) == "" { // 只有 gateway 建单失败补偿会携带 withdrawal_ref 且没有 application_id;人工驳回必须继续占用发起次数。 - if err := r.releasePointWithdrawalLimitReservation(ctx, tx, command.UserID, command.WithdrawalRef, command.CommandID, nowMs); err != nil { + if err := r.releaseHostPolicyWithdrawalLimitReservation(ctx, tx, command.UserID, command.SalaryAssetType, command.WithdrawalRef, command.CommandID, nowMs); err != nil { return ledger.SalaryWithdrawalReceipt{}, err } } @@ -122,6 +219,13 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S PointNetAmount: command.PointNetAmount, PointsPerUSD: command.PointsPerUSD, PointWithdrawFeeBPS: command.PointWithdrawFeeBPS, + PointPolicyInstanceCode: pointPolicyInstanceCode, + GrossUSDMinor: grossUSDMinor, + FeeUSDMinor: feeUSDMinor, + NetUSDMinor: netUSDMinor, + PointPolicyID: pointPolicyID, + PointPolicyVersion: pointPolicyVersion, + CoinsPerUSD: coinsPerUSD, OperatorUserID: command.OperatorUserID, Reason: command.Reason, WithdrawalRef: command.WithdrawalRef, @@ -161,15 +265,55 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S return receiptFromSalaryWithdrawalMetadata(transactionID, metadata), nil } +func (r *Repository) loadPointDiamondFreezeMetadata(ctx context.Context, tx *sql.Tx, command ledger.SalaryWithdrawalCommand) (salaryWithdrawalMetadata, error) { + freezeTransactionID := strings.TrimSpace(command.FreezeTransactionID) + if freezeTransactionID == "" { + return salaryWithdrawalMetadata{}, xerr.New(xerr.InvalidArgument, "point diamond freeze transaction id is required") + } + var metadataJSON string + err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? AND transaction_id = ? AND biz_type = ? + LIMIT 1 + FOR UPDATE`, appcode.FromContext(ctx), freezeTransactionID, bizTypeSalaryWithdrawalFreeze).Scan(&metadataJSON) + if errors.Is(err, sql.ErrNoRows) { + return salaryWithdrawalMetadata{}, xerr.New(xerr.NotFound, "point diamond freeze transaction not found") + } + if err != nil { + return salaryWithdrawalMetadata{}, err + } + var metadata salaryWithdrawalMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return salaryWithdrawalMetadata{}, err + } + if appcode.Normalize(metadata.AppCode) != appcode.Normalize(command.AppCode) || metadata.UserID != command.UserID || + strings.ToUpper(strings.TrimSpace(metadata.SalaryAssetType)) != ledger.AssetPointDiamond || metadata.SalaryUSDMinor != command.SalaryUSDMinor || + metadata.PointFeeAmount != command.PointFeeAmount || metadata.PointNetAmount != command.PointNetAmount || + metadata.PointsPerUSD != command.PointsPerUSD || metadata.PointWithdrawFeeBPS != command.PointWithdrawFeeBPS { + return salaryWithdrawalMetadata{}, xerr.New(xerr.IdempotencyConflict, "point diamond terminal snapshot conflicts with freeze transaction") + } + if metadata.PointPolicyID == 0 || metadata.PointPolicyVersion == 0 || metadata.PointsPerUSD <= 0 || metadata.CoinsPerUSD <= 0 || + metadata.GrossUSDMinor <= 0 || metadata.FeeUSDMinor < 0 || metadata.NetUSDMinor < 0 || metadata.FeeUSDMinor+metadata.NetUSDMinor != metadata.GrossUSDMinor { + return salaryWithdrawalMetadata{}, xerr.New(xerr.Internal, "point diamond freeze policy snapshot is invalid") + } + return metadata, nil +} + +func salaryAssetUsesHostPolicyWithdrawal(assetType string) bool { + return ledger.ValidPointWithdrawalAssetType(assetType) || ledger.UsesHostAgencySalaryPolicy(assetType) +} + func salaryWithdrawalRequestHash(command ledger.SalaryWithdrawalCommand, bizType string) string { - if bizType == bizTypeSalaryWithdrawalFreeze && strings.EqualFold(command.SalaryAssetType, ledger.AssetPoint) { + if bizType == bizTypeSalaryWithdrawalFreeze && ledger.ValidPointWithdrawalAssetType(command.SalaryAssetType) { // POINT 的费率、换算比例和 region 均由服务端按当前用户/政策注入,同一客户端 command 重试时这些快照可能已经变化。 // 哈希只锁定用户稳定提现意图;命中旧 transaction 后直接返回首次账变快照,既不重新选政策,也不重复消耗次数。 - return stableHash(fmt.Sprintf("point_withdrawal_freeze|%s|%d|%s|%d|%s", + return stableHash(fmt.Sprintf("point_withdrawal_freeze|%s|%d|%s|%d|%d|%s", appcode.Normalize(command.AppCode), command.UserID, strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)), command.SalaryUSDMinor, + command.GrossUSDMinor, strings.TrimSpace(command.WithdrawalRef), )) } diff --git a/services/wallet-service/internal/storage/mysql/schema.go b/services/wallet-service/internal/storage/mysql/schema.go index 71a62245..8396dd24 100644 --- a/services/wallet-service/internal/storage/mysql/schema.go +++ b/services/wallet-service/internal/storage/mysql/schema.go @@ -66,6 +66,55 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error { ADD COLUMN policy_version BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '不可变政策版本,等于后台政策 ID' AFTER cycle_key`); err != nil && !isDuplicateColumnError(err) { return err } + if _, err := db.ExecContext(ctx, ` + ALTER TABLE 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`); err != nil && !isDuplicateColumnError(err) { + return err + } + if _, err := db.ExecContext(ctx, ` + ALTER TABLE 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`); err != nil && !isDuplicateColumnError(err) { + return err + } + if _, err := db.ExecContext(ctx, ` + ALTER TABLE 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`); err != nil && !isDuplicateColumnError(err) { + return err + } + if _, err := db.ExecContext(ctx, ` + ALTER TABLE 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`); err != nil && !isDuplicateColumnError(err) { + return err + } + if _, err := db.ExecContext(ctx, ` + ALTER TABLE 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`); err != nil && !isDuplicateColumnError(err) { + return err + } + if _, err := db.ExecContext(ctx, ` + ALTER TABLE 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`); err != nil && !isDuplicateColumnError(err) { + return err + } + // POINT_DIAMOND 的 Host/Agency 金额可能共用一条钱包分录;独立投影从送礼事务写入, + // 让统计按用户和 UTC 时间索引读取,且完全排除其他 POINT_DIAMOND 资产变动。 + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS point_diamond_gift_income_entries ( + entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + app_code VARCHAR(32) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + host_user_id BIGINT NOT NULL, + agency_user_id BIGINT NOT NULL DEFAULT 0, + sender_user_id BIGINT NOT NULL DEFAULT 0, + host_base_amount BIGINT NOT NULL, + agency_amount BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + UNIQUE KEY uk_point_diamond_gift_income_tx (app_code, transaction_id), + KEY idx_point_diamond_gift_host_time (app_code, host_user_id, created_at_ms, entry_id), + KEY idx_point_diamond_gift_agency_time (app_code, agency_user_id, created_at_ms, entry_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT_DIAMOND 送礼收益角色拆分投影'`); err != nil { + return err + } // 提现限制随工资政策版本发布;启动兼容只补小型配置表列,绝不回查钱包或提现流水推导计数。 if _, err := db.ExecContext(ctx, ` ALTER TABLE host_agency_salary_policies @@ -95,6 +144,7 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS point_withdrawal_policy_counters ( app_code VARCHAR(32) NOT NULL, + source_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT', user_id BIGINT NOT NULL, channel VARCHAR(24) NOT NULL, period VARCHAR(16) NOT NULL, @@ -102,13 +152,14 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error { withdrawal_count BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, user_id, channel, period, period_key) + PRIMARY KEY (app_code, source_asset_type, user_id, channel, period, period_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自然周期提现次数计数表'`); err != nil { return err } if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS point_withdrawal_limit_reservations ( app_code VARCHAR(32) NOT NULL, + source_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT', withdrawal_ref VARCHAR(128) NOT NULL, freeze_command_id VARCHAR(128) NOT NULL, user_id BIGINT NOT NULL, @@ -121,11 +172,48 @@ func ensureHostSalarySettlementSchema(ctx context.Context, db *sql.DB) error { status VARCHAR(24) NOT NULL DEFAULT 'active', created_at_ms BIGINT NOT NULL, released_at_ms BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY (app_code, withdrawal_ref), - UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, freeze_command_id) + PRIMARY KEY (app_code, source_asset_type, withdrawal_ref), + UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, source_asset_type, freeze_command_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台积分提现次数预留表'`); err != nil { return err } + // 两张表只保存周期聚合和在途 reservation;补列与改键不会扫描大型钱包流水。 + if _, err := db.ExecContext(ctx, `ALTER TABLE point_withdrawal_policy_counters ADD COLUMN source_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' AFTER app_code, ALGORITHM=INPLACE, LOCK=NONE`); err != nil && !isDuplicateColumnError(err) { + return err + } + if columns, err := tablePrimaryKeyColumns(ctx, db, "point_withdrawal_policy_counters"); err != nil { + return err + } else if strings.Join(columns, ",") != "app_code,source_asset_type,user_id,channel,period,period_key" { + if _, err := db.ExecContext(ctx, `ALTER TABLE point_withdrawal_policy_counters DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, source_asset_type, user_id, channel, period, period_key), ALGORITHM=INPLACE, LOCK=NONE`); err != nil { + return err + } + } + if _, err := db.ExecContext(ctx, `ALTER TABLE point_withdrawal_limit_reservations ADD COLUMN source_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' AFTER app_code, ALGORITHM=INPLACE, LOCK=NONE`); err != nil && !isDuplicateColumnError(err) { + return err + } + if columns, err := tablePrimaryKeyColumns(ctx, db, "point_withdrawal_limit_reservations"); err != nil { + return err + } else if strings.Join(columns, ",") != "app_code,source_asset_type,withdrawal_ref" { + if _, err := db.ExecContext(ctx, `ALTER TABLE point_withdrawal_limit_reservations DROP PRIMARY KEY, ADD PRIMARY KEY (app_code, source_asset_type, withdrawal_ref), ALGORITHM=INPLACE, LOCK=NONE`); err != nil { + return err + } + } + reservationIndexColumns, err := tableIndexColumns(ctx, db, "point_withdrawal_limit_reservations", "uk_point_withdrawal_limit_reservation_command") + if err != nil { + return err + } + if strings.Join(reservationIndexColumns, ",") != "app_code,source_asset_type,freeze_command_id" { + if len(reservationIndexColumns) > 0 { + // 删除旧唯一键和建立资产隔离的新唯一键必须同一条 DDL 完成,不能给在线写入留下无约束窗口。 + if _, err := db.ExecContext(ctx, `ALTER TABLE point_withdrawal_limit_reservations DROP INDEX uk_point_withdrawal_limit_reservation_command, ADD UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, source_asset_type, freeze_command_id), ALGORITHM=INPLACE, LOCK=NONE`); err != nil { + return err + } + } else { + if _, err := db.ExecContext(ctx, `ALTER TABLE point_withdrawal_limit_reservations ADD UNIQUE KEY uk_point_withdrawal_limit_reservation_command (app_code, source_asset_type, freeze_command_id), ALGORITHM=INPLACE, LOCK=NONE`); err != nil { + return err + } + } + } if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS host_salary_policy_cycle_bindings ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', @@ -359,6 +447,8 @@ func ensureGooglePaymentPaidDetailsSchema(ctx context.Context, db *sql.DB) error return nil } +// ensureWalletPolicyInstanceSchema 保留现有 POINT 提现、兑换和旧分成统计所需的运行表。 +// 新送礼不读取这里的 Host/Agency POINT 字段,周期钻石只由 Host 工资政策路径累计。 func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS wallet_policy_instances ( @@ -370,9 +460,9 @@ func ensureWalletPolicyInstanceSchema(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 '完整政策快照', @@ -382,11 +472,11 @@ func ensureWalletPolicyInstanceSchema(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 } _, err = db.ExecContext(ctx, ` @@ -406,16 +496,14 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error { PRIMARY KEY (app_code, transaction_id, agency_owner_user_id), KEY idx_agency_point_share_owner_time (app_code, agency_owner_user_id, created_at_ms), KEY idx_agency_point_share_host_time (app_code, host_user_id, created_at_ms) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agency POINT 礼物分成流水'`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旧 Agency POINT 流水结构;新送礼不读写'`) if err != nil { return err } - // 存量库由旧版本创建表时没有公式基数字段;启动补列必须幂等,并在新代码接受送礼前完成, - // 否则账本已加钱而审计流水 INSERT 失败只能依赖事务回滚兜底。 if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '分成基数:unknown/charge_amount/host_income' AFTER ratio_ppm`); err != nil && !isDuplicateColumnError(err) { return err } - if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次分成计算基数;share_base_type=unknown 时数值不可解释' AFTER share_base_type`); err != nil && !isDuplicateColumnError(err) { + if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次分成计算基数;unknown 时不可解释' AFTER share_base_type`); err != nil && !isDuplicateColumnError(err) { return err } return nil @@ -461,23 +549,12 @@ func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil { return err } - if added, err := ensureGiftReturnCoinRatioColumn(ctx, db); err != nil { + if _, err := ensureGiftReturnCoinRatioColumn(ctx, db); err != nil { return err - } else if added { - - if err := seedGiftReturnCoinRatioDefaults(ctx, db); err != nil { - return err - } } - _, err := 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 - ('lalu', 0, 'normal', 'active', 100.00, 30.00, 0, 0, 0, 0), - ('lalu', 0, 'lucky', 'active', 10.00, 10.00, 0, 0, 0, 0), - ('lalu', 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, 0, 0)`) - return err + // 礼物倍率是运营显式发布的运行政策。启动只维护表结构,绝不能按 App 名自动生成 + // 100/10/1;已存在的正式记录原样保留,无配置时送礼链路会在扣费前失败关闭。 + return nil } func ensureGiftReturnCoinRatioColumn(ctx context.Context, db *sql.DB) (bool, error) { @@ -493,18 +570,6 @@ func ensureGiftReturnCoinRatioColumn(ctx context.Context, db *sql.DB) (bool, err return true, nil } -func seedGiftReturnCoinRatioDefaults(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 -} - func ensureCoinSellerSalaryExchangeRateSchema(ctx context.Context, db *sql.DB) error { _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers ( @@ -642,3 +707,24 @@ func tablePrimaryKeyColumns(ctx context.Context, db *sql.DB, tableName string) ( } return columns, rows.Err() } + +func tableIndexColumns(ctx context.Context, db *sql.DB, tableName string, indexName string) ([]string, error) { + rows, err := db.QueryContext(ctx, ` + SELECT COLUMN_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + ORDER BY SEQ_IN_INDEX ASC`, tableName, indexName) + if err != nil { + return nil, err + } + defer rows.Close() + columns := make([]string, 0, 4) + for rows.Next() { + var column string + if err := rows.Scan(&column); err != nil { + return nil, err + } + columns = append(columns, column) + } + return columns, rows.Err() +} diff --git a/services/wallet-service/internal/storage/mysql/vip_admin_repository.go b/services/wallet-service/internal/storage/mysql/vip_admin_repository.go index c6245e40..361d46de 100644 --- a/services/wallet-service/internal/storage/mysql/vip_admin_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_admin_repository.go @@ -423,11 +423,11 @@ func (r *Repository) validateAdminVipBenefit(ctx context.Context, tx *sql.Tx, pr } } } - // Fami tiered 的固定房间视觉权益是稳定跨服务契约,不能由后台把 code 改成 + // tiered privilege v1 的固定房间视觉权益是稳定跨服务契约,不能由后台把 code 改成 // function 或绑定到近似类型。disabled 可以暂不选素材,但一旦携带 resource_id, // 下方仍会校验资源 active 且类型精确匹配,避免脏绑定在之后切状态时直接生效。 expectedResourceType := "" - if program.ProgramType == ledger.VipProgramTypeTieredPrivilegeV1 && appcode.FromContext(ctx) == "fami" { + if program.ProgramType == ledger.VipProgramTypeTieredPrivilegeV1 { switch benefit.BenefitCode { case ledger.VipBenefitCodeMicSkin: // 麦克风皮肤替换麦位中心图标;mic_seat_animation 留给 voice_wave 声波动效, diff --git a/services/wallet-service/internal/storage/mysql/wallet_query_repository.go b/services/wallet-service/internal/storage/mysql/wallet_query_repository.go index e73b7987..f522082a 100644 --- a/services/wallet-service/internal/storage/mysql/wallet_query_repository.go +++ b/services/wallet-service/internal/storage/mysql/wallet_query_repository.go @@ -15,6 +15,7 @@ func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledge balances, err := r.GetBalances(ctx, userID, []string{ ledger.AssetCoin, ledger.AssetPoint, + ledger.AssetPointDiamond, ledger.AssetHostSalaryUSD, ledger.AssetAgencySalaryUSD, ledger.AssetBDSalaryUSD, @@ -142,6 +143,12 @@ func (r *Repository) ListWalletTransactions(ctx context.Context, query ledger.Li where += ` AND e.asset_type = ?` args = append(args, query.AssetType) } + if strings.EqualFold(strings.TrimSpace(query.AssetType), ledger.AssetPointDiamond) { + // 审核通过的 settle 只把 frozen 扣成 0,用户在首次 freeze 时已经看到一笔提现; + // 在 SQL 分页条件内隐藏内部完成分录,保证 items 与 total 完全一致,release 仍单独展示为退回。 + where += ` AND wt.biz_type <> ?` + args = append(args, bizTypeSalaryWithdrawalSettle) + } if query.StartAtMS > 0 { where += ` AND e.created_at_ms >= ? AND e.created_at_ms < ?` args = append(args, query.StartAtMS, query.EndAtMS) @@ -151,6 +158,7 @@ func (r *Repository) ListWalletTransactions(ctx context.Context, query ledger.Li if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM wallet_entries e + JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id `+where, args..., ).Scan(&total); err != nil { @@ -195,12 +203,94 @@ func (r *Repository) ListWalletTransactions(ctx context.Context, query ledger.Li return nil, 0, err } // H5 只需要展示用户实际转给币商的金额;这里统一从交易 metadata 提取安全字段,避免 gateway 或前端理解各类内部 JSON。 - item.TransferUSDMinor, item.TransferCurrencyCode = walletTransactionTransferAmount(item.BizType, metadataJSON) + item.TransferUSDMinor, item.TransferCurrencyCode = walletTransactionTransferAmount(item, metadataJSON) + item.EntryRole, item.HostBaseAmount, item.AgencyAmount = walletTransactionEntryComponents(query.UserID, item, metadataJSON) + item.PolicyID, item.PolicyVersion, item.RatioPointAmount, item.RatioCoinAmount, + item.GrossUSDMinor, item.FeeUSDMinor, item.NetUSDMinor, item.WithdrawFeeBPS = walletTransactionPolicySnapshot(item.BizType, metadataJSON) items = append(items, item) } return items, total, rows.Err() } +// walletTransactionPolicySnapshot 把各类 POINT_DIAMOND 交易首次落库的政策字段归一成稳定历史契约。 +// Gateway 只转成字符串,不再理解 gift/exchange/seller/withdraw 各自不同的 metadata key。 +func walletTransactionPolicySnapshot(bizType string, metadataJSON string) (uint64, uint64, int64, int64, int64, int64, int64, int64) { + var metadata struct { + HostSalaryPolicyID uint64 `json:"host_salary_policy_id"` + HostSalaryPolicyVersion uint64 `json:"host_salary_policy_version"` + PointDiamondsPerUSD int64 `json:"point_diamonds_per_usd"` + PolicyID uint64 `json:"policy_id"` + PolicyVersion uint64 `json:"policy_version"` + PointPolicyID uint64 `json:"point_policy_id"` + PointPolicyVersion uint64 `json:"point_policy_version"` + RatioPointAmount int64 `json:"ratio_point_amount"` + RatioCoinAmount int64 `json:"ratio_coin_amount"` + RatioSellerCoinAmount int64 `json:"ratio_seller_coin_amount"` + PointsPerUSD int64 `json:"points_per_usd"` + CoinsPerUSD int64 `json:"coins_per_usd"` + GrossUSDMinor int64 `json:"gross_usd_minor"` + FeeUSDMinor int64 `json:"fee_usd_minor"` + NetUSDMinor int64 `json:"net_usd_minor"` + WithdrawFeeBPS int64 `json:"withdraw_fee_bps"` + PointWithdrawFeeBPS int64 `json:"point_withdraw_fee_bps"` + } + if strings.TrimSpace(metadataJSON) == "" || json.Unmarshal([]byte(metadataJSON), &metadata) != nil { + return 0, 0, 0, 0, 0, 0, 0, 0 + } + switch strings.TrimSpace(bizType) { + case bizTypeGiftDebit, bizTypeDirectGiftDebit: + return metadata.HostSalaryPolicyID, metadata.HostSalaryPolicyVersion, metadata.PointDiamondsPerUSD, metadata.CoinsPerUSD, 0, 0, 0, int64(0) + case bizTypePointExchangeToCoin: + return metadata.PolicyID, metadata.PolicyVersion, metadata.RatioPointAmount, metadata.RatioCoinAmount, 0, 0, 0, 0 + case bizTypePointTransferToCoinSeller: + return metadata.PolicyID, metadata.PolicyVersion, metadata.RatioPointAmount, metadata.RatioSellerCoinAmount, + metadata.GrossUSDMinor, metadata.FeeUSDMinor, metadata.NetUSDMinor, metadata.WithdrawFeeBPS + case bizTypeSalaryWithdrawalFreeze, bizTypeSalaryWithdrawalSettle, bizTypeSalaryWithdrawalRelease: + return metadata.PointPolicyID, metadata.PointPolicyVersion, metadata.PointsPerUSD, metadata.CoinsPerUSD, + metadata.GrossUSDMinor, metadata.FeeUSDMinor, metadata.NetUSDMinor, metadata.PointWithdrawFeeBPS + default: + return 0, 0, 0, 0, 0, 0, 0, 0 + } +} + +func walletTransactionEntryComponents(entryUserID int64, item ledger.WalletTransaction, metadataJSON string) (string, int64, int64) { + switch item.BizType { + case bizTypeGiftDebit, bizTypeDirectGiftDebit: + var metadata struct { + TargetUserID int64 `json:"target_user_id"` + AgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` + HostBaseAmount int64 `json:"host_base_amount"` + AgencyAmount int64 `json:"agency_amount"` + } + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return "gift_income", 0, 0 + } + if entryUserID == metadata.TargetUserID && entryUserID == metadata.AgencyOwnerUserID && metadata.HostBaseAmount > 0 && metadata.AgencyAmount > 0 { + // 同一钱包分录的 available_delta 是两部分之和;同时返回拆分值,客户端不得把整笔误标为 Agency 分成。 + return "host_gift_income_and_agency_share", metadata.HostBaseAmount, metadata.AgencyAmount + } + if entryUserID == metadata.TargetUserID && metadata.HostBaseAmount > 0 { + return "host_gift_income", metadata.HostBaseAmount, 0 + } + if entryUserID == metadata.AgencyOwnerUserID && metadata.AgencyAmount > 0 { + return "agency_gift_share", 0, metadata.AgencyAmount + } + return "gift_income", 0, 0 + case bizTypePointExchangeToCoin: + return "exchange_to_coins", 0, 0 + case bizTypePointTransferToCoinSeller: + return "coin_seller_withdrawal", 0, 0 + case bizTypeSalaryWithdrawalFreeze: + return "platform_withdrawal", 0, 0 + case bizTypeSalaryWithdrawalSettle: + return "platform_withdrawal_settled", 0, 0 + case bizTypeSalaryWithdrawalRelease: + return "withdrawal_release", 0, 0 + default: + return item.BizType, 0, 0 + } +} + // ListHostPeriodDiamondTransactions 从主播周期收入明细分页读取钻石历史;按 app/user/time 的复合索引完成筛选和倒序,避免日期查询退化为全表扫描。 func (r *Repository) ListHostPeriodDiamondTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) { if r == nil || r.db == nil { @@ -257,13 +347,14 @@ func (r *Repository) ListHostPeriodDiamondTransactions(ctx context.Context, quer } // walletTransactionTransferAmount 把工资转币商、H5 币商进货和后台进货的不同 metadata 统一成展示金额;解析失败时返回空值,避免流水页展示错误金额。 -func walletTransactionTransferAmount(bizType string, metadataJSON string) (int64, string) { +func walletTransactionTransferAmount(item ledger.WalletTransaction, metadataJSON string) (int64, string) { var metadata struct { SalaryUSDMinor int64 `json:"salary_usd_minor"` RechargeUSDMinor int64 `json:"recharge_usd_minor"` PaidAmountMicro int64 `json:"paid_amount_micro"` PaidCurrencyCode string `json:"paid_currency_code"` RechargeCurrencyCode string `json:"recharge_currency_code"` + GrossUSDMinor int64 `json:"gross_usd_minor"` } if strings.TrimSpace(metadataJSON) == "" { return 0, "" @@ -271,8 +362,16 @@ func walletTransactionTransferAmount(bizType string, metadataJSON string) (int64 if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { return 0, "" } - switch strings.TrimSpace(bizType) { + switch strings.TrimSpace(item.BizType) { case bizTypeSalaryTransferToCoinSeller, bizTypeSalaryWithdrawalFreeze, bizTypeSalaryWithdrawalSettle, bizTypeSalaryWithdrawalRelease: + if ledger.ValidPointWithdrawalAssetType(item.AssetType) { + // POINT/POINT_DIAMOND 的 salary_usd_minor 字段承载冻结积分数,不是美元分;只有首次 + // 政策快照中的 gross_usd_minor 才能用于法币展示,历史 POINT 没有该值时保持空。 + if metadata.GrossUSDMinor > 0 { + return metadata.GrossUSDMinor, "USD" + } + return 0, "" + } if metadata.SalaryUSDMinor > 0 { return metadata.SalaryUSDMinor, "USD" } diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index 8de6ae58..23599857 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -11,6 +11,7 @@ import ( "time" "hyapp/internal/testutil/mysqlschema" + "hyapp/pkg/appcode" "hyapp/services/wallet-service/internal/domain/ledger" mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql" ) @@ -72,6 +73,24 @@ func (r *Repository) CountRows(table string, where string, args ...any) int { return count } +// SetTransactionRequestHashForApp 模拟 request_hash 口径升级前已成功落库的历史交易。 +// 使用 (app_code, command_id) 唯一索引定点更新,测试不会制造全表扫描。 +func (r *Repository) SetTransactionRequestHashForApp(app string, commandID string, requestHash string) { + r.t.Helper() + result, err := r.schema.DB.ExecContext(context.Background(), ` + UPDATE wallet_transactions + SET request_hash = ? + WHERE app_code = ? AND command_id = ? + `, requestHash, appcode.Normalize(app), commandID) + if err != nil { + r.t.Fatalf("set transaction request hash failed: %v", err) + } + affected, err := result.RowsAffected() + if err != nil || affected != 1 { + r.t.Fatalf("set transaction request hash affected %d rows: %v", affected, err) + } +} + // TransactionCreatedAtMS 读取 wallet owner 固化的交易创建时间,供跨服务契约测试核对回执 paid_at_ms。 // 测试只按唯一 command_id 查询,避免把服务时钟近似值误当成账务事实。 func (r *Repository) TransactionCreatedAtMS(commandID string) int64 { @@ -449,80 +468,69 @@ func (r *Repository) SetAssetBalanceForApp(appCode string, userID int64, assetTy } } -// SeedWalletPolicyInstance writes the wallet runtime policy row consumed by gift settlement tests. -func (r *Repository) SeedWalletPolicyInstance(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64) { - r.SeedWalletPolicyInstanceWithAgency(appCode, instanceCode, regionID, status, hostPointRatioPPM, 0) -} - -// SeedWalletPolicyInstanceWithAgency 让账务测试显式声明 Agency 比例,避免普通 policy fixture 意外改变旧用例语义。 -func (r *Repository) SeedWalletPolicyInstanceWithAgency(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64) { - r.SeedWalletPolicyInstanceWithRates(appCode, instanceCode, regionID, status, hostPointRatioPPM, agencyPointRatioPPM, 100000, 500) -} - -// SeedWalletGiftRevenuePolicy 写入送礼收益公式快照,专门覆盖“主播先按礼物类型折算、Agency 再按主播收入分成”的政策。 -// 旧 SeedWalletPolicyInstance* helper 继续写空 rule_json,避免新增测试夹具悄悄改变历史用例的 charge_amount 结算语义。 -func (r *Repository) SeedWalletGiftRevenuePolicy(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64, hostUseGiftTypeRatio bool, agencyShareBase string) { +// SeedPointWalletPolicy 写入 POINT 提现和兑换仍消费的 wallet 运行政策;礼物测试不得使用该夹具。 +func (r *Repository) SeedPointWalletPolicy(appCode string, instanceCode string, regionID int64, status string, pointsPerUSD int64, minimumPoints int64, feeBPS int64) { r.t.Helper() - appCode = strings.TrimSpace(appCode) - instanceCode = strings.TrimSpace(instanceCode) - status = strings.TrimSpace(status) - agencyShareBase = strings.TrimSpace(agencyShareBase) - nowMs := time.Now().UnixMilli() - ruleJSON, err := json.Marshal(map[string]any{ - "host": map[string]any{ - "use_gift_type_ratio": hostUseGiftTypeRatio, - }, - "agency": map[string]any{ - "share_base": agencyShareBase, - }, - }) - if err != nil { - r.t.Fatalf("marshal wallet gift revenue policy failed: %v", err) - } - _, err = r.schema.DB.ExecContext(context.Background(), ` - INSERT INTO wallet_policy_instances ( - app_code, instance_code, template_code, template_version, region_id, status, - effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, agency_point_ratio_ppm, - points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id, - published_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, 'v1', ?, ?, 0, 0, 'POINT', ?, ?, 100000, 500, ?, 90001, ?, ?, ?) - ON DUPLICATE KEY UPDATE - status = VALUES(status), - host_point_ratio_ppm = VALUES(host_point_ratio_ppm), - agency_point_ratio_ppm = VALUES(agency_point_ratio_ppm), - rule_json = VALUES(rule_json), - published_at_ms = VALUES(published_at_ms), - updated_at_ms = VALUES(updated_at_ms) - `, appCode, instanceCode, instanceCode+"_template", regionID, status, hostPointRatioPPM, agencyPointRatioPPM, string(ruleJSON), nowMs, nowMs, nowMs) - if err != nil { - r.t.Fatalf("seed wallet gift revenue policy failed: %v", err) - } -} - -// SeedWalletPolicyInstanceWithRates 让 POINT 提现和兑币测试都从同一份已发布政策读取分母。 -func (r *Repository) SeedWalletPolicyInstanceWithRates(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64, pointsPerUSD int64, feeBPS int64) { - r.t.Helper() - - nowMs := time.Now().UnixMilli() + nowMS := time.Now().UTC().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO wallet_policy_instances ( app_code, instance_code, template_code, template_version, region_id, status, - effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, agency_point_ratio_ppm, - points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id, - published_at_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, 'v1', ?, ?, 0, 0, 'POINT', ?, ?, ?, ?, JSON_OBJECT(), 90001, ?, ?, ?) + effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, + agency_point_ratio_ppm, points_per_usd, withdraw_fee_bps, rule_json, + published_by_admin_id, published_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 'v1', ?, ?, 0, 0, 'POINT', 0, 0, ?, ?, + JSON_OBJECT('host', JSON_OBJECT('minimum_withdraw_points', ?)), 90001, ?, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), - host_point_ratio_ppm = VALUES(host_point_ratio_ppm), - agency_point_ratio_ppm = VALUES(agency_point_ratio_ppm), points_per_usd = VALUES(points_per_usd), withdraw_fee_bps = VALUES(withdraw_fee_bps), + rule_json = VALUES(rule_json), published_at_ms = VALUES(published_at_ms), updated_at_ms = VALUES(updated_at_ms) - `, strings.TrimSpace(appCode), strings.TrimSpace(instanceCode), strings.TrimSpace(instanceCode)+"_template", regionID, strings.TrimSpace(status), hostPointRatioPPM, agencyPointRatioPPM, pointsPerUSD, feeBPS, nowMs, nowMs, nowMs) + `, strings.TrimSpace(appCode), strings.TrimSpace(instanceCode), strings.TrimSpace(instanceCode)+"_template", + regionID, strings.TrimSpace(status), pointsPerUSD, feeBPS, minimumPoints, nowMS, nowMS, nowMS) if err != nil { - r.t.Fatalf("seed wallet policy instance failed: %v", err) + r.t.Fatalf("seed POINT wallet policy failed: %v", err) + } +} + +// SetPointWalletPolicyRawSnapshot 只用于迁移回归:它在真实 wallet 运行表上构造已发布旧快照, +// 以便证明迁移只删指定模板、且不会损坏其他 App 的 POINT 财务参数。 +func (r *Repository) SetPointWalletPolicyRawSnapshot(appCode string, instanceCode string, regionID int64, templateCode string, hostRatioPPM int64, agencyRatioPPM int64, ruleJSON string) { + r.t.Helper() + if _, err := r.schema.DB.ExecContext(context.Background(), ` + UPDATE wallet_policy_instances + SET template_code = ?, host_point_ratio_ppm = ?, agency_point_ratio_ppm = ?, rule_json = CAST(? AS JSON) + WHERE app_code = ? AND instance_code = ? AND region_id = ?`, + strings.TrimSpace(templateCode), hostRatioPPM, agencyRatioPPM, ruleJSON, + strings.TrimSpace(appCode), strings.TrimSpace(instanceCode), regionID, + ); err != nil { + r.t.Fatalf("set raw POINT wallet policy snapshot failed: %v", err) + } +} + +// ApplySQLScript 在 mysqltest 的隔离真实 MySQL schema 中执行简单迁移脚本。 +// 该帮助方法不忽略 SQL 错误,因此重复执行测试能真正覆盖幂等性。 +func (r *Repository) ApplySQLScript(script string) { + r.t.Helper() + // 先移除独立行注释,否则注释中的分号会被误当成 SQL 边界。 + lines := strings.Split(script, "\n") + statementsOnly := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "--") { + continue + } + statementsOnly = append(statementsOnly, line) + } + for statement := range strings.SplitSeq(strings.Join(statementsOnly, "\n"), ";") { + statement = strings.TrimSpace(statement) + if statement == "" { + continue + } + if _, err := r.schema.DB.ExecContext(context.Background(), statement); err != nil { + r.t.Fatalf("apply SQL script failed: %v\nstatement:\n%s", err, statement) + } } } @@ -676,6 +684,11 @@ func (r *Repository) SetGiftPriceForApp(appCode string, giftID string, priceVers if err != nil { r.t.Fatalf("seed wallet gift price failed: %v", err) } + // 生产启动不再生成任何倍率。测试礼物 fixture 必须像后台发布一样显式写入三类全局配置, + // 让绝大多数账本测试聚焦自身语义;缺政策用例会再主动删除这些行。 + for _, giftTypeCode := range []string{"normal", "lucky", "super_lucky"} { + r.SetGiftDiamondRatioForApp(appCode, 0, giftTypeCode, defaultGiftDiamondRatioPercent(giftTypeCode)) + } } // SetGiftType updates a seeded gift config to the requested gift type. @@ -733,6 +746,14 @@ func (r *Repository) SetGiftReturnCoinRatio(regionID int64, giftTypeCode string, } } +// DeleteGiftDiamondRatiosForApp removes only test-fixture policy rows for fail-closed test cases. +func (r *Repository) DeleteGiftDiamondRatiosForApp(appCode string) { + r.t.Helper() + if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM gift_diamond_ratio_configs WHERE app_code = ?`, strings.TrimSpace(appCode)); err != nil { + r.t.Fatalf("delete gift diamond ratios failed: %v", err) + } +} + func defaultGiftDiamondRatioPercent(giftTypeCode string) string { switch strings.TrimSpace(giftTypeCode) { case "lucky": @@ -1000,6 +1021,9 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos if policy.GiftCoinToDiamondRatio == "" { policy.GiftCoinToDiamondRatio = "1" } + if strings.TrimSpace(policy.PolicyType) == "" { + policy.PolicyType = ledger.HostPolicyTypeSalaryDiamond + } if policy.ResidualDiamondToUSDRate == "" { policy.ResidualDiamondToUSDRate = "0" } @@ -1011,20 +1035,27 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos } _, err := r.schema.DB.ExecContext(context.Background(), ` 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, + 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, effective_from_ms, effective_to_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE cycle_key = VALUES(cycle_key), policy_version = VALUES(policy_version), name = VALUES(name), + policy_type = VALUES(policy_type), region_id = VALUES(region_id), status = VALUES(status), settlement_mode = VALUES(settlement_mode), settlement_trigger_mode = VALUES(settlement_trigger_mode), gift_coin_to_diamond_ratio = VALUES(gift_coin_to_diamond_ratio), + point_diamonds_per_usd = VALUES(point_diamonds_per_usd), + coins_per_usd = VALUES(coins_per_usd), + minimum_withdraw_usd_minor = VALUES(minimum_withdraw_usd_minor), + withdraw_fee_bps = VALUES(withdraw_fee_bps), + agency_point_share_bps = VALUES(agency_point_share_bps), residual_diamond_to_usd_rate = VALUES(residual_diamond_to_usd_rate), coin_seller_withdrawal_limit_period = VALUES(coin_seller_withdrawal_limit_period), coin_seller_withdrawal_limit_count = VALUES(coin_seller_withdrawal_limit_count), @@ -1034,7 +1065,8 @@ func (r *Repository) SetHostSalaryPolicyForApp(appCode string, policy ledger.Hos effective_from_ms = VALUES(effective_from_ms), effective_to_ms = VALUES(effective_to_ms), updated_at_ms = VALUES(updated_at_ms) - `, appCode, policy.PolicyID, policy.CycleKey, policy.PolicyVersion, policy.Name, policy.RegionID, policy.Status, policy.SettlementMode, policy.SettlementTriggerMode, policy.GiftCoinToDiamondRatio, policy.ResidualDiamondToUSDRate, + `, appCode, policy.PolicyID, policy.CycleKey, policy.PolicyVersion, policy.Name, policy.PolicyType, policy.RegionID, policy.Status, policy.SettlementMode, policy.SettlementTriggerMode, policy.GiftCoinToDiamondRatio, + policy.PointDiamondsPerUSD, policy.CoinsPerUSD, policy.MinimumWithdrawUSDMinor, policy.WithdrawFeeBPS, policy.AgencyPointShareBPS, policy.ResidualDiamondToUSDRate, policy.CoinSellerWithdrawalLimitPeriod, policy.CoinSellerWithdrawalLimitCount, policy.PlatformWithdrawalLimitPeriod, policy.PlatformWithdrawalLimitCount, policy.PlatformWithdrawalAllowedDays, policy.EffectiveFromMs, policy.EffectiveToMs, nowMs, nowMs) @@ -1085,9 +1117,10 @@ func validateTableName(table string) string { switch table { case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records", "payment_orders", "wallet_user_recharge_stats", "external_recharge_orders", "third_party_payment_methods", - "host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies", + "host_period_diamond_accounts", "host_period_diamond_entries", "point_diamond_gift_income_entries", "host_agency_salary_policies", "host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records", "coin_seller_stock_records", "gift_diamond_ratio_configs", "wallet_diamond_exchange_rules", "agency_point_share_entries", + "wallet_policy_instances", "point_withdrawal_policy_counters", "point_withdrawal_limit_reservations", "vip_program_configs", "vip_levels", "vip_level_benefits", "user_vip_memberships", "user_vip_settings", "vip_purchase_orders", "user_vip_history", "user_vip_trial_cards", "vip_command_locks", "vip_daily_coin_rebate_runs", "vip_daily_coin_rebates", "resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "wallet_gift_prices", "user_gift_wall", "wallet_projection_events", diff --git a/services/wallet-service/internal/transport/grpc/agency_host_gift_stats.go b/services/wallet-service/internal/transport/grpc/agency_host_gift_stats.go index 30c276ad..11fe0b5d 100644 --- a/services/wallet-service/internal/transport/grpc/agency_host_gift_stats.go +++ b/services/wallet-service/internal/transport/grpc/agency_host_gift_stats.go @@ -12,12 +12,27 @@ import ( func (s *Server) GetAgencyHostGiftStats(ctx context.Context, req *walletv1.GetAgencyHostGiftStatsRequest) (*walletv1.GetAgencyHostGiftStatsResponse, error) { ctx = appcode.WithContext(ctx, req.GetAppCode()) stats, err := s.svc.GetAgencyHostGiftStats(ctx, req.GetAppCode(), ledger.AgencyHostGiftStatsQuery{ - HostUserIDs: req.GetHostUserIds(), StartAtMS: req.GetStartAtMs(), EndAtMS: req.GetEndAtMs(), + HostUserIDs: req.GetHostUserIds(), AgencyUserID: req.GetAgencyUserId(), StartAtMS: req.GetStartAtMs(), EndAtMS: req.GetEndAtMs(), }) if err != nil { return nil, xerr.ToGRPCError(err) } + hostStats := make([]*walletv1.AgencyHostRevenueStats, 0, len(stats.HostStats)) + for _, host := range stats.HostStats { + hostStats = append(hostStats, &walletv1.AgencyHostRevenueStats{ + HostUserId: host.HostUserID, + Stats: &walletv1.HostRevenueStats{ + DiamondEarnings: host.Stats.DiamondEarnings, + DiamondExchanged: host.Stats.DiamondExchanged, + GiftSenders: host.Stats.GiftSenders, + PointDiamondEarnings: host.Stats.PointDiamondEarnings, + PointDiamondExchanged: host.Stats.PointDiamondExchanged, + }, + }) + } return &walletv1.GetAgencyHostGiftStatsResponse{Stats: &walletv1.AgencyHostGiftStats{ - GiftIncome: stats.GiftIncome, GiftedHostCount: stats.GiftedHostCount, - }}, nil + GiftIncome: stats.GiftIncome, + PointDiamondHostIncome: stats.PointDiamondHostIncome, PointDiamondAgencyShareIncome: stats.PointDiamondAgencyShareIncome, + GiftedHostCount: stats.GiftedHostCount, + }, HostStats: hostStats}, nil } diff --git a/services/wallet-service/internal/transport/grpc/agency_point_share_stats.go b/services/wallet-service/internal/transport/grpc/agency_point_share_stats.go deleted file mode 100644 index 50c7da9f..00000000 --- a/services/wallet-service/internal/transport/grpc/agency_point_share_stats.go +++ /dev/null @@ -1,26 +0,0 @@ -package grpc - -import ( - "context" - - walletv1 "hyapp.local/api/proto/wallet/v1" - "hyapp/pkg/appcode" - "hyapp/pkg/xerr" - "hyapp/services/wallet-service/internal/domain/ledger" -) - -func (s *Server) GetAgencyPointShareStats(ctx context.Context, req *walletv1.GetAgencyPointShareStatsRequest) (*walletv1.GetAgencyPointShareStatsResponse, error) { - ctx = appcode.WithContext(ctx, req.GetAppCode()) - stats, err := s.svc.GetAgencyPointShareStats(ctx, req.GetAppCode(), ledger.AgencyPointShareStatsQuery{ - AgencyOwnerUserID: req.GetAgencyOwnerUserId(), - StartAtMS: req.GetStartAtMs(), - EndAtMS: req.GetEndAtMs(), - }) - if err != nil { - return nil, xerr.ToGRPCError(err) - } - return &walletv1.GetAgencyPointShareStatsResponse{Stats: &walletv1.AgencyPointShareStats{ - ShareIncome: stats.ShareIncome, - GiftedHostCount: stats.GiftedHostCount, - }}, nil -} diff --git a/services/wallet-service/internal/transport/grpc/balance.go b/services/wallet-service/internal/transport/grpc/balance.go index 3e42061a..4da2479e 100644 --- a/services/wallet-service/internal/transport/grpc/balance.go +++ b/services/wallet-service/internal/transport/grpc/balance.go @@ -153,6 +153,17 @@ func walletTransactionsToProto(items []ledger.WalletTransaction, total int64) *w CreatedAtMs: item.CreatedAtMS, TransferUsdMinor: item.TransferUSDMinor, TransferCurrencyCode: item.TransferCurrencyCode, + EntryRole: item.EntryRole, + HostBaseAmount: item.HostBaseAmount, + AgencyAmount: item.AgencyAmount, + PolicyId: item.PolicyID, + PolicyVersion: item.PolicyVersion, + RatioPointAmount: item.RatioPointAmount, + RatioCoinAmount: item.RatioCoinAmount, + GrossUsdMinor: item.GrossUSDMinor, + FeeUsdMinor: item.FeeUSDMinor, + NetUsdMinor: item.NetUSDMinor, + WithdrawFeeBps: item.WithdrawFeeBPS, }) } return resp diff --git a/services/wallet-service/internal/transport/grpc/gift.go b/services/wallet-service/internal/transport/grpc/gift.go index 8b5ae2c3..53b7bdfa 100644 --- a/services/wallet-service/internal/transport/grpc/gift.go +++ b/services/wallet-service/internal/transport/grpc/gift.go @@ -148,6 +148,17 @@ func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftRes GiftEffectTypes: receipt.GiftEffectTypes, PriceVersion: receipt.PriceVersion, HostPeriodDiamondAdded: receipt.HostPeriodDiamondAdded, + HostIncomePolicyType: receipt.HostIncomePolicyType, + HostIncomeBalanceAfter: receipt.HostIncomeBalanceAfter, + IncomeAssetType: receipt.IncomeAssetType, + HostBaseAmount: receipt.HostBaseAmount, + AgencyAmount: receipt.AgencyAmount, + AgencyOwnerUserId: receipt.AgencyOwnerUserID, + AgencyIncomeBalanceAfter: receipt.AgencyIncomeBalanceAfter, + HostIncomePolicyId: receipt.HostIncomePolicyID, + HostIncomePolicyVersion: receipt.HostIncomePolicyVersion, + HostPolicyCoefficient: receipt.HostPolicyCoefficient, + AgencyPointShareBps: receipt.AgencyPointShareBPS, HostPeriodCycleKey: receipt.HostPeriodCycleKey, HostPointAdded: receipt.HostPointAdded, HostPointBalanceAfter: receipt.HostPointBalanceAfter, diff --git a/services/wallet-service/internal/transport/grpc/host_revenue_stats.go b/services/wallet-service/internal/transport/grpc/host_revenue_stats.go index 8adab713..eb54551d 100644 --- a/services/wallet-service/internal/transport/grpc/host_revenue_stats.go +++ b/services/wallet-service/internal/transport/grpc/host_revenue_stats.go @@ -14,6 +14,7 @@ func (s *Server) GetHostRevenueStats(ctx context.Context, req *walletv1.GetHostR ctx = appcode.WithContext(ctx, req.GetAppCode()) stats, err := s.svc.GetHostRevenueStats(ctx, req.GetAppCode(), ledger.HostRevenueStatsQuery{ HostUserID: req.GetHostUserId(), + RegionID: req.GetRegionId(), StartAtMS: req.GetStartAtMs(), EndAtMS: req.GetEndAtMs(), }) @@ -21,8 +22,10 @@ func (s *Server) GetHostRevenueStats(ctx context.Context, req *walletv1.GetHostR return nil, xerr.ToGRPCError(err) } return &walletv1.GetHostRevenueStatsResponse{Stats: &walletv1.HostRevenueStats{ - DiamondEarnings: stats.DiamondEarnings, - DiamondExchanged: stats.DiamondExchanged, - GiftSenders: stats.GiftSenders, + DiamondEarnings: stats.DiamondEarnings, + DiamondExchanged: stats.DiamondExchanged, + PointDiamondEarnings: stats.PointDiamondEarnings, + PointDiamondExchanged: stats.PointDiamondExchanged, + GiftSenders: stats.GiftSenders, }}, nil } diff --git a/services/wallet-service/internal/transport/grpc/host_salary.go b/services/wallet-service/internal/transport/grpc/host_salary.go index adaef2a0..ad256cf3 100644 --- a/services/wallet-service/internal/transport/grpc/host_salary.go +++ b/services/wallet-service/internal/transport/grpc/host_salary.go @@ -83,11 +83,17 @@ func hostSalaryPolicyToProto(policy ledger.HostSalaryPolicy) *walletv1.HostSalar CycleKey: policy.CycleKey, PolicyVersion: policy.PolicyVersion, Name: policy.Name, + PolicyType: policy.PolicyType, RegionId: policy.RegionID, Status: policy.Status, SettlementMode: policy.SettlementMode, SettlementTriggerMode: policy.SettlementTriggerMode, GiftCoinToDiamondRatio: policy.GiftCoinToDiamondRatio, + PointDiamondsPerUsd: policy.PointDiamondsPerUSD, + CoinsPerUsd: policy.CoinsPerUSD, + MinimumWithdrawUsdMinor: policy.MinimumWithdrawUSDMinor, + WithdrawFeeBps: policy.WithdrawFeeBPS, + AgencyPointShareBps: policy.AgencyPointShareBPS, ResidualDiamondToUsdRate: policy.ResidualDiamondToUSDRate, EffectiveFromMs: policy.EffectiveFromMs, EffectiveToMs: policy.EffectiveToMs, diff --git a/services/wallet-service/internal/transport/grpc/point_coin_seller.go b/services/wallet-service/internal/transport/grpc/point_coin_seller.go index 7ff88cf5..d907986a 100644 --- a/services/wallet-service/internal/transport/grpc/point_coin_seller.go +++ b/services/wallet-service/internal/transport/grpc/point_coin_seller.go @@ -31,6 +31,7 @@ func (s *Server) TransferPointToCoinSeller(ctx context.Context, req *walletv1.Tr receipt, err := s.svc.TransferPointToCoinSeller(ctx, ledger.PointToCoinSellerCommand{ AppCode: req.GetAppCode(), CommandID: req.GetCommandId(), SourceUserID: req.GetSourceUserId(), SellerUserID: req.GetSellerUserId(), PointAmount: req.GetPointAmount(), + SourceAssetType: req.GetSourceAssetType(), GrossUSDMinor: req.GetGrossUsdMinor(), SourceCountryCode: req.GetSourceCountryCode(), Reason: req.GetReason(), RegionID: req.GetRegionId(), }) @@ -42,6 +43,9 @@ func (s *Server) TransferPointToCoinSeller(ctx context.Context, req *walletv1.Tr SellerBalanceAfter: receipt.SellerBalanceAfter, PointAmount: receipt.PointAmount, SellerCoinAmount: receipt.SellerCoinAmount, RatioPointAmount: receipt.RatioPointAmount, RatioSellerCoinAmount: receipt.RatioSellerCoinAmount, + SourceAssetType: receipt.SourceAssetType, GrossUsdMinor: receipt.GrossUSDMinor, + FeeUsdMinor: receipt.FeeUSDMinor, NetUsdMinor: receipt.NetUSDMinor, WithdrawFeeBps: receipt.WithdrawFeeBPS, + PolicyId: receipt.PolicyID, PolicyVersion: receipt.PolicyVersion, }, nil } @@ -49,7 +53,7 @@ func (s *Server) TransferPointToCoinSeller(ctx context.Context, req *walletv1.Tr func (s *Server) ExchangePointToCoin(ctx context.Context, req *walletv1.ExchangePointToCoinRequest) (*walletv1.ExchangePointToCoinResponse, error) { receipt, err := s.svc.ExchangePointToCoin(ctx, ledger.PointToCoinCommand{ AppCode: req.GetAppCode(), CommandID: req.GetCommandId(), UserID: req.GetUserId(), PointAmount: req.GetPointAmount(), - RegionID: req.GetRegionId(), NowMS: req.GetNowMs(), + SourceAssetType: req.GetSourceAssetType(), RegionID: req.GetRegionId(), NowMS: req.GetNowMs(), }) if err != nil { return nil, xerr.ToGRPCError(err) @@ -58,17 +62,37 @@ func (s *Server) ExchangePointToCoin(ctx context.Context, req *walletv1.Exchange TransactionId: receipt.TransactionID, PointAmount: receipt.PointAmount, CoinAmount: receipt.CoinAmount, PointBalanceAfter: receipt.PointBalanceAfter, CoinBalanceAfter: receipt.CoinBalanceAfter, RatioPointAmount: receipt.RatioPointAmount, RatioCoinAmount: receipt.RatioCoinAmount, + SourceAssetType: receipt.SourceAssetType, PolicyId: receipt.PolicyID, PolicyVersion: receipt.PolicyVersion, }, nil } -// GetPointWithdrawalConfig 暴露政策编译后的最小运行参数,H5 不解析 Admin rule_json。 +// GetPointWithdrawalConfig 暴露 POINT 提现和兑换所需的最小运行参数,H5 不解析 Admin rule_json。 func (s *Server) GetPointWithdrawalConfig(ctx context.Context, req *walletv1.GetPointWithdrawalConfigRequest) (*walletv1.GetPointWithdrawalConfigResponse, error) { - config, err := s.svc.GetPointWithdrawalConfig(ctx, req.GetAppCode(), req.GetRegionId(), req.GetNowMs()) + var config ledger.PointWithdrawalRuntimeConfig + var err error + if req.GetUserId() > 0 { + config, err = s.svc.GetPointWithdrawalConfigForUser(ctx, req.GetAppCode(), req.GetUserId(), req.GetRegionId(), req.GetNowMs(), req.GetAssetType()) + } else { + config, err = s.svc.GetPointWithdrawalConfigForAsset(ctx, req.GetAppCode(), req.GetRegionId(), req.GetNowMs(), req.GetAssetType()) + } if err != nil { return nil, xerr.ToGRPCError(err) } return &walletv1.GetPointWithdrawalConfigResponse{ Found: config.Found, PointsPerUsd: config.PointsPerUSD, CoinsPerUsd: config.CoinsPerUSD, FeeBps: int32(config.FeeBPS), MinimumPoints: config.MinimumPoints, PolicyInstanceCode: config.PolicyInstanceCode, + PolicyType: config.PolicyType, MinimumWithdrawUsdMinor: config.MinimumWithdrawUSDMinor, + AgencyPointShareBps: config.AgencyPointShareBPS, PolicyId: config.PolicyID, PolicyVersion: config.PolicyVersion, + AvailabilityEvaluated: config.AvailabilityEvaluated, + CoinSellerAvailability: pointWithdrawalAvailabilityToProto(config.CoinSellerAvailability), + PlatformAvailability: pointWithdrawalAvailabilityToProto(config.PlatformAvailability), }, nil } + +func pointWithdrawalAvailabilityToProto(value ledger.PointWithdrawalActionAvailability) *walletv1.PointWithdrawalActionAvailability { + return &walletv1.PointWithdrawalActionAvailability{ + Allowed: value.Allowed, LimitPeriod: value.LimitPeriod, LimitCount: value.LimitCount, + UsedCount: value.UsedCount, RemainingCount: value.RemainingCount, + AllowedDays: value.AllowedDays, BlockReason: value.BlockReason, + } +} diff --git a/services/wallet-service/internal/transport/grpc/point_withdrawal.go b/services/wallet-service/internal/transport/grpc/point_withdrawal.go index 99b3ec78..cf3b177b 100644 --- a/services/wallet-service/internal/transport/grpc/point_withdrawal.go +++ b/services/wallet-service/internal/transport/grpc/point_withdrawal.go @@ -9,7 +9,7 @@ import ( "hyapp/services/wallet-service/internal/domain/ledger" ) -// FreezePointWithdrawal 处理 Huwaa POINT 提现申请提交前的积分冻结,调用方负责用户身份和提现地址校验。 +// FreezePointWithdrawal 处理 POINT 提现申请提交前的积分冻结,调用方负责用户身份和提现地址校验。 func (s *Server) FreezePointWithdrawal(ctx context.Context, req *walletv1.FreezePointWithdrawalRequest) (*walletv1.FreezePointWithdrawalResponse, error) { ctx = appcode.WithContext(ctx, req.GetAppCode()) receipt, err := s.svc.FreezePointWithdrawal(ctx, ledger.PointWithdrawalCommand{ @@ -18,6 +18,7 @@ func (s *Server) FreezePointWithdrawal(ctx context.Context, req *walletv1.Freeze UserID: req.GetUserId(), AssetType: req.GetAssetType(), GrossPointAmount: req.GetGrossPointAmount(), + GrossUSDMinor: req.GetGrossUsdMinor(), FeePointAmount: req.GetFeePointAmount(), NetPointAmount: req.GetNetPointAmount(), PointsPerUSD: req.GetPointsPerUsd(), @@ -30,13 +31,20 @@ func (s *Server) FreezePointWithdrawal(ctx context.Context, req *walletv1.Freeze return nil, xerr.ToGRPCError(err) } return &walletv1.FreezePointWithdrawalResponse{ - TransactionId: receipt.TransactionID, - Balance: pointWithdrawalBalance(receipt), - GrossPointAmount: receipt.GrossPointAmount, - FeePointAmount: receipt.FeePointAmount, - NetPointAmount: receipt.NetPointAmount, - PointsPerUsd: receipt.PointsPerUSD, - FeeBps: int32(receipt.FeeBPS), + TransactionId: receipt.TransactionID, + Balance: pointWithdrawalBalance(receipt), + GrossPointAmount: receipt.GrossPointAmount, + FeePointAmount: receipt.FeePointAmount, + NetPointAmount: receipt.NetPointAmount, + PointsPerUsd: receipt.PointsPerUSD, + FeeBps: int32(receipt.FeeBPS), + PolicyInstanceCode: receipt.PointPolicyInstanceCode, + GrossUsdMinor: receipt.GrossUSDMinor, + FeeUsdMinor: receipt.FeeUSDMinor, + NetUsdMinor: receipt.NetUSDMinor, + PolicyId: receipt.PointPolicyID, + PolicyVersion: receipt.PointPolicyVersion, + CoinsPerUsd: receipt.CoinsPerUSD, }, nil } @@ -56,6 +64,7 @@ func (s *Server) SettlePointWithdrawal(ctx context.Context, req *walletv1.Settle OperatorUserID: req.GetOperatorUserId(), Reason: req.GetReason(), WithdrawalApplicationID: req.GetWithdrawalApplicationId(), + FreezeTransactionID: req.GetFreezeTransactionId(), }) if err != nil { return nil, xerr.ToGRPCError(err) @@ -87,6 +96,7 @@ func (s *Server) ReleasePointWithdrawal(ctx context.Context, req *walletv1.Relea OperatorUserID: req.GetOperatorUserId(), Reason: req.GetReason(), WithdrawalApplicationID: req.GetWithdrawalApplicationId(), + FreezeTransactionID: req.GetFreezeTransactionId(), WithdrawalRef: req.GetWithdrawalRef(), }) if err != nil { diff --git a/services/wallet-service/internal/transport/grpc/salary_withdrawal.go b/services/wallet-service/internal/transport/grpc/salary_withdrawal.go index a5fe7b09..3ea065a3 100644 --- a/services/wallet-service/internal/transport/grpc/salary_withdrawal.go +++ b/services/wallet-service/internal/transport/grpc/salary_withdrawal.go @@ -20,6 +20,7 @@ func (s *Server) FreezeSalaryWithdrawal(ctx context.Context, req *walletv1.Freez SalaryUSDMinor: req.GetSalaryUsdMinor(), Reason: req.GetReason(), WithdrawalRef: req.GetWithdrawalRef(), + RegionID: req.GetRegionId(), }) if err != nil { return nil, xerr.ToGRPCError(err)