修复性能问题
This commit is contained in:
parent
902ca5dc3c
commit
2254097ea4
File diff suppressed because it is too large
Load Diff
@ -313,6 +313,15 @@ message GetHostProfileResponse {
|
||||
HostProfile host_profile = 1;
|
||||
}
|
||||
|
||||
message BatchGetHostProfilesRequest {
|
||||
RequestMeta meta = 1;
|
||||
repeated int64 user_ids = 2;
|
||||
}
|
||||
|
||||
message BatchGetHostProfilesResponse {
|
||||
map<int64, HostProfile> host_profiles = 1;
|
||||
}
|
||||
|
||||
message GetBDProfileRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
@ -526,6 +535,7 @@ service UserHostService {
|
||||
rpc GetRoleInvitation(GetRoleInvitationRequest) returns (GetRoleInvitationResponse);
|
||||
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
|
||||
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
|
||||
rpc BatchGetHostProfiles(BatchGetHostProfilesRequest) returns (BatchGetHostProfilesResponse);
|
||||
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
|
||||
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
|
||||
rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse);
|
||||
|
||||
@ -33,6 +33,7 @@ const (
|
||||
UserHostService_GetRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleInvitation"
|
||||
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
|
||||
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
|
||||
UserHostService_BatchGetHostProfiles_FullMethodName = "/hyapp.user.v1.UserHostService/BatchGetHostProfiles"
|
||||
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
|
||||
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
|
||||
UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion"
|
||||
@ -63,6 +64,7 @@ type UserHostServiceClient interface {
|
||||
GetRoleInvitation(ctx context.Context, in *GetRoleInvitationRequest, opts ...grpc.CallOption) (*GetRoleInvitationResponse, error)
|
||||
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
|
||||
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
|
||||
BatchGetHostProfiles(ctx context.Context, in *BatchGetHostProfilesRequest, opts ...grpc.CallOption) (*BatchGetHostProfilesResponse, error)
|
||||
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
|
||||
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error)
|
||||
ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error)
|
||||
@ -221,6 +223,16 @@ func (c *userHostServiceClient) GetHostProfile(ctx context.Context, in *GetHostP
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) BatchGetHostProfiles(ctx context.Context, in *BatchGetHostProfilesRequest, opts ...grpc.CallOption) (*BatchGetHostProfilesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchGetHostProfilesResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostService_BatchGetHostProfiles_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetBDProfileResponse)
|
||||
@ -321,6 +333,7 @@ type UserHostServiceServer interface {
|
||||
GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error)
|
||||
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
|
||||
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
|
||||
BatchGetHostProfiles(context.Context, *BatchGetHostProfilesRequest) (*BatchGetHostProfilesResponse, error)
|
||||
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
|
||||
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
|
||||
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
|
||||
@ -381,6 +394,9 @@ func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context,
|
||||
func (UnimplementedUserHostServiceServer) GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetHostProfile not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) BatchGetHostProfiles(context.Context, *BatchGetHostProfilesRequest) (*BatchGetHostProfilesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetHostProfiles not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetBDProfile not implemented")
|
||||
}
|
||||
@ -678,6 +694,24 @@ func _UserHostService_GetHostProfile_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_BatchGetHostProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchGetHostProfilesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostServiceServer).BatchGetHostProfiles(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostService_BatchGetHostProfiles_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostServiceServer).BatchGetHostProfiles(ctx, req.(*BatchGetHostProfilesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_GetBDProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetBDProfileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -885,6 +919,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetHostProfile",
|
||||
Handler: _UserHostService_GetHostProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetHostProfiles",
|
||||
Handler: _UserHostService_BatchGetHostProfiles_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetBDProfile",
|
||||
Handler: _UserHostService_GetBDProfile_Handler,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1134,6 +1134,8 @@ message ConfirmGooglePaymentRequest {
|
||||
string purchase_token = 9;
|
||||
string order_id = 10;
|
||||
int64 purchase_time_ms = 11;
|
||||
// country_id 由 gateway 从 user-service 当前用户资料注入,用于充值事实按真实国家进入 BI。
|
||||
int64 country_id = 12;
|
||||
}
|
||||
|
||||
message ConfirmGooglePaymentResponse {
|
||||
@ -1340,6 +1342,8 @@ message ExternalRechargeOrder {
|
||||
int64 created_at_ms = 25;
|
||||
int64 updated_at_ms = 26;
|
||||
bool idempotent_replay = 27;
|
||||
// target_country_id 是下单时目标用户国家快照,支付回调入账直接复用,避免异步回调再查用户主数据。
|
||||
int64 target_country_id = 28;
|
||||
}
|
||||
|
||||
message CreateH5RechargeOrderRequest {
|
||||
@ -1361,6 +1365,8 @@ message CreateH5RechargeOrderRequest {
|
||||
string payer_name = 15;
|
||||
string payer_account = 16;
|
||||
string payer_email = 17;
|
||||
// target_country_id 由 gateway 从 user-service 解析目标用户,不接受 H5 前端自报。
|
||||
int64 target_country_id = 18;
|
||||
}
|
||||
|
||||
message CreateTemporaryRechargeOrderRequest {
|
||||
|
||||
@ -106,6 +106,7 @@ type UserHostClient interface {
|
||||
ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error)
|
||||
ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error)
|
||||
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
|
||||
BatchGetHostProfiles(ctx context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error)
|
||||
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
|
||||
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
|
||||
ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error)
|
||||
@ -505,6 +506,10 @@ func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.Get
|
||||
return c.client.GetHostProfile(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) BatchGetHostProfiles(ctx context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error) {
|
||||
return c.client.BatchGetHostProfiles(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
|
||||
return c.client.GetBDProfile(ctx, req)
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ import (
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
@ -314,6 +316,7 @@ type fakeUserProfileClient struct {
|
||||
lastBusinessLookup *userv1.BusinessUserLookupRequest
|
||||
lastStats *userv1.GetMyProfileStatsRequest
|
||||
lastBatch *userv1.BatchGetUsersRequest
|
||||
batchRequests []*userv1.BatchGetUsersRequest
|
||||
getRequests []*userv1.GetUserRequest
|
||||
lastComplete *userv1.CompleteOnboardingRequest
|
||||
lastUpdate *userv1.UpdateUserProfileRequest
|
||||
@ -459,9 +462,13 @@ type fakeUserHostClient struct {
|
||||
listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse
|
||||
listCoinSellersErr error
|
||||
lastHost *userv1.GetHostProfileRequest
|
||||
lastBatchHost *userv1.BatchGetHostProfilesRequest
|
||||
hostCalls int
|
||||
batchHostCalls int
|
||||
hostProfile *userv1.HostProfile
|
||||
hostProfiles map[int64]*userv1.HostProfile
|
||||
hostErr error
|
||||
batchHostErr error
|
||||
lastBD *userv1.GetBDProfileRequest
|
||||
bdProfile *userv1.BDProfile
|
||||
bdErr error
|
||||
@ -577,9 +584,14 @@ type fakeWalletClient struct {
|
||||
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
|
||||
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
|
||||
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
||||
listGiftConfigsCalls int
|
||||
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
||||
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
|
||||
listGiftTypesCalls int
|
||||
listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse
|
||||
lastListUserResources *walletv1.ListUserResourcesRequest
|
||||
listUserResourcesCalls int
|
||||
listUserResourcesResp *walletv1.ListUserResourcesResponse
|
||||
lastGrantResource *walletv1.GrantResourceRequest
|
||||
grantResourceResp *walletv1.ResourceGrantResponse
|
||||
grantResourceErr error
|
||||
@ -848,6 +860,7 @@ func (f *fakeUserProfileClient) GetMyProfileStats(_ context.Context, req *userv1
|
||||
|
||||
func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
|
||||
f.lastBatch = req
|
||||
f.batchRequests = append(f.batchRequests, req)
|
||||
users := make(map[int64]*userv1.User, len(req.GetUserIds()))
|
||||
for _, userID := range req.GetUserIds() {
|
||||
if f.usersByID != nil {
|
||||
@ -863,6 +876,7 @@ func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.Bat
|
||||
Username: "user-" + strconv.FormatInt(userID, 10),
|
||||
Gender: "female",
|
||||
Birth: "2000-01-02",
|
||||
CountryId: 840,
|
||||
Country: "US",
|
||||
CountryName: "United States",
|
||||
CountryDisplayName: "United States",
|
||||
@ -1537,6 +1551,7 @@ func (f *fakeUserHostClient) ProcessRoleInvitation(_ context.Context, req *userv
|
||||
|
||||
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
|
||||
f.lastHost = req
|
||||
f.hostCalls++
|
||||
if f.hostErr != nil {
|
||||
return nil, f.hostErr
|
||||
}
|
||||
@ -1550,6 +1565,27 @@ func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHo
|
||||
return &userv1.GetHostProfileResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) BatchGetHostProfiles(_ context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error) {
|
||||
f.lastBatchHost = req
|
||||
f.batchHostCalls++
|
||||
if f.batchHostErr != nil {
|
||||
return nil, f.batchHostErr
|
||||
}
|
||||
profiles := make(map[int64]*userv1.HostProfile, len(req.GetUserIds()))
|
||||
for _, userID := range req.GetUserIds() {
|
||||
if f.hostProfiles != nil {
|
||||
if profile := f.hostProfiles[userID]; profile != nil {
|
||||
profiles[userID] = profile
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.hostProfile != nil && (f.hostProfile.GetUserId() == 0 || f.hostProfile.GetUserId() == userID) {
|
||||
profiles[userID] = f.hostProfile
|
||||
}
|
||||
}
|
||||
return &userv1.BatchGetHostProfilesResponse{HostProfiles: profiles}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) GetBDProfile(_ context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
|
||||
f.lastBD = req
|
||||
if f.bdErr != nil {
|
||||
@ -2113,6 +2149,7 @@ func (f *fakeWalletClient) PurchaseResourceShopItem(_ context.Context, req *wall
|
||||
|
||||
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||
f.lastListGiftConfigs = req
|
||||
f.listGiftConfigsCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
@ -2124,6 +2161,7 @@ func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.List
|
||||
|
||||
func (f *fakeWalletClient) ListGiftTypeConfigs(_ context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) {
|
||||
f.lastListGiftTypes = req
|
||||
f.listGiftTypesCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
@ -2144,7 +2182,12 @@ func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantR
|
||||
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-1", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(), GrantSource: req.GetGrantSource(), GrantSubjectId: strconv.FormatInt(req.GetResourceId(), 10), Status: "succeeded", OperatorUserId: req.GetOperatorUserId()}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ListUserResources(context.Context, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||
func (f *fakeWalletClient) ListUserResources(_ context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||
f.lastListUserResources = req
|
||||
f.listUserResourcesCalls++
|
||||
if f.listUserResourcesResp != nil {
|
||||
return f.listUserResourcesResp, nil
|
||||
}
|
||||
return &walletv1.ListUserResourcesResponse{}, nil
|
||||
}
|
||||
|
||||
@ -2617,7 +2660,8 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, profileClient)
|
||||
handler.SetUserHostClient(hostClient)
|
||||
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-host","target_user_id":43,"gift_id":"rose","gift_count":2}`)))
|
||||
@ -2629,8 +2673,11 @@ func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 43 {
|
||||
t.Fatalf("gateway must query target host profile before sending gift: %+v", hostClient.lastHost)
|
||||
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 1 || hostClient.lastBatchHost.GetUserIds()[0] != 43 {
|
||||
t.Fatalf("gateway must batch query target host profile before sending gift: batch=%+v batch_calls=%d single_calls=%d", hostClient.lastBatchHost, hostClient.batchHostCalls, hostClient.hostCalls)
|
||||
}
|
||||
if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 {
|
||||
t.Fatalf("sender country/profile must come from one BatchGetUsers call: get=%+v batches=%d", profileClient.lastGet, len(profileClient.batchRequests))
|
||||
}
|
||||
if roomClient.lastGift == nil || !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 || roomClient.lastGift.GetTargetAgencyOwnerUserId() != 30001 {
|
||||
t.Fatalf("gateway must inject active host period scope: %+v", roomClient.lastGift)
|
||||
@ -2652,6 +2699,9 @@ func TestSendGiftDoesNotInjectHostPeriodScopeForNonHost(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 {
|
||||
t.Fatalf("non-host lookup must still use batch host query: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
|
||||
}
|
||||
if roomClient.lastGift == nil || roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 0 {
|
||||
t.Fatalf("non-host target must not receive host period scope: %+v", roomClient.lastGift)
|
||||
}
|
||||
@ -2714,6 +2764,9 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
|
||||
if len(targets) != 1 || targets[0].GetUserId() != 43 || targets[0].GetUsername() != "Robot Receiver" || targets[0].GetAvatar() == "" || targets[0].GetDisplayUserId() != "100043" {
|
||||
t.Fatalf("gateway must inject target display profile snapshots: %+v", targets)
|
||||
}
|
||||
if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 || len(profileClient.lastBatch.GetUserIds()) != 2 || profileClient.lastBatch.GetUserIds()[0] != 42 || profileClient.lastBatch.GetUserIds()[1] != 43 {
|
||||
t.Fatalf("gift user snapshots must use one batch profile request: get=%+v batch=%+v calls=%d", profileClient.lastGet, profileClient.lastBatch, len(profileClient.batchRequests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
|
||||
@ -2734,6 +2787,9 @@ func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 2 || hostClient.lastBatchHost.GetUserIds()[0] != 43 || hostClient.lastBatchHost.GetUserIds()[1] != 44 {
|
||||
t.Fatalf("gateway must resolve multi-target host profiles in one batch: batch=%+v batch_calls=%d single_calls=%d", hostClient.lastBatchHost, hostClient.batchHostCalls, hostClient.hostCalls)
|
||||
}
|
||||
scopes := roomClient.lastGift.GetTargetHostScopes()
|
||||
if len(scopes) != 2 || !scopes[0].GetTargetIsHost() || scopes[0].GetTargetHostRegionId() != 8801 || !scopes[1].GetTargetIsHost() || scopes[1].GetTargetHostRegionId() != 8802 {
|
||||
t.Fatalf("gateway must inject each target host scope: %+v", scopes)
|
||||
@ -2743,6 +2799,77 @@ func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftFallsBackToSingleHostLookupWhenBatchRPCIsUnimplemented(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
hostClient := &fakeUserHostClient{
|
||||
batchHostErr: status.Error(codes.Unimplemented, "unknown method BatchGetHostProfiles"),
|
||||
hostProfiles: map[int64]*userv1.HostProfile{
|
||||
43: {UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001},
|
||||
44: {UserId: 44, Status: "active", RegionId: 8802, CurrentAgencyOwnerUserId: 30002},
|
||||
},
|
||||
}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
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-host-fallback","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
|
||||
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 hostClient.batchHostCalls != 1 || hostClient.hostCalls != 2 {
|
||||
t.Fatalf("unimplemented batch RPC should fall back to one single lookup per target: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
|
||||
}
|
||||
scopes := roomClient.lastGift.GetTargetHostScopes()
|
||||
if len(scopes) != 2 || scopes[0].GetTargetHostRegionId() != 8801 || scopes[1].GetTargetHostRegionId() != 8802 {
|
||||
t.Fatalf("fallback must preserve each target host scope: %+v", scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftPropagatesBatchHostProfileErrors(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
hostClient := &fakeUserHostClient{batchHostErr: xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host profile lookup failed"))}
|
||||
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
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-host-error","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code == http.StatusOK || roomClient.lastGift != nil {
|
||||
t.Fatalf("batch host lookup errors must block SendGift: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String())
|
||||
}
|
||||
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 {
|
||||
t.Fatalf("non-unimplemented batch errors must not fall back to single lookup: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftRequiresSenderCountryRegionFromBatchUserProfile(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, RegionId: 1001},
|
||||
43: {UserId: 43, Status: userv1.UserStatus_USER_STATUS_ACTIVE, CountryId: 840, RegionId: 1001},
|
||||
}}
|
||||
router := NewHandlerWithClients(roomClient, nil, nil, profileClient).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-sender-country","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest || roomClient.lastGift != nil {
|
||||
t.Fatalf("sender missing country must block SendGift before room RPC: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String())
|
||||
}
|
||||
if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 {
|
||||
t.Fatalf("sender country validation must use batch profile path: get=%+v batches=%d", profileClient.lastGet, len(profileClient.batchRequests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
|
||||
previousNow := roomapi.TimeNow
|
||||
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
||||
@ -7030,6 +7157,129 @@ func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
|
||||
giftResource := &walletv1.Resource{
|
||||
ResourceId: 11,
|
||||
ResourceCode: "gift_rose",
|
||||
ResourceType: "gift",
|
||||
Name: "Rose",
|
||||
Status: "active",
|
||||
UsageScopes: []string{"gift"},
|
||||
AssetUrl: "https://cdn.example/rose.svga",
|
||||
SortOrder: 1,
|
||||
}
|
||||
walletClient := &fakeWalletClient{
|
||||
resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
|
||||
listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
|
||||
TypeCode: "normal",
|
||||
Name: "普通礼物",
|
||||
TabKey: "Gift",
|
||||
Status: "active",
|
||||
SortOrder: 10,
|
||||
}}},
|
||||
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
|
||||
GiftId: "rose",
|
||||
ResourceId: giftResource.GetResourceId(),
|
||||
Resource: giftResource,
|
||||
Status: "active",
|
||||
Name: "Rose",
|
||||
GiftTypeCode: "normal",
|
||||
PriceVersion: "v1",
|
||||
ChargeAssetType: "COIN",
|
||||
CoinPrice: 100,
|
||||
HeatValue: 100,
|
||||
RegionIds: []int64{0, 1001},
|
||||
}}, Total: 1},
|
||||
listUserResourcesResp: &walletv1.ListUserResourcesResponse{Resources: []*walletv1.UserResourceEntitlement{{
|
||||
EntitlementId: "ent-rose",
|
||||
UserId: 42,
|
||||
ResourceId: giftResource.GetResourceId(),
|
||||
Resource: giftResource,
|
||||
Status: "active",
|
||||
Quantity: 2,
|
||||
RemainingQuantity: 2,
|
||||
}}},
|
||||
}
|
||||
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: &roomv1.RoomSnapshot{
|
||||
RoomId: "room-gift-cache",
|
||||
OwnerUserId: 88,
|
||||
Status: "active",
|
||||
VisibleRegionId: 1001,
|
||||
}}}
|
||||
handler := NewHandler(&fakeRoomClient{})
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-cache/gift-panel", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("gift panel status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
if walletClient.listGiftTypesCalls != 1 || walletClient.listGiftConfigsCalls != 1 {
|
||||
t.Fatalf("gift panel config should be cached, typeCalls=%d giftCalls=%d", walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
|
||||
}
|
||||
if len(walletClient.balanceRequests) != 2 || walletClient.listUserResourcesCalls != 2 {
|
||||
t.Fatalf("gift panel realtime data must not be cached, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
|
||||
}
|
||||
if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetRegionId() != 1001 || walletClient.lastListGiftConfigs.GetPageSize() != 500 || !walletClient.lastListGiftConfigs.GetFilterRegion() {
|
||||
t.Fatalf("gift panel list request mismatch: %+v", walletClient.lastListGiftConfigs)
|
||||
}
|
||||
if walletClient.lastListUserResources == nil || walletClient.lastListUserResources.GetUserId() != 42 || walletClient.lastListUserResources.GetResourceType() != "gift" || !walletClient.lastListUserResources.GetActiveOnly() {
|
||||
t.Fatalf("gift panel bag request mismatch: %+v", walletClient.lastListUserResources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomGiftPanelCacheSeparatesVisibleRegion(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{
|
||||
resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
|
||||
listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{TypeCode: "normal", TabKey: "Gift", Status: "active"}}},
|
||||
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
|
||||
GiftId: "rose",
|
||||
ResourceId: 11,
|
||||
Resource: &walletv1.Resource{ResourceId: 11, ResourceCode: "gift_rose", ResourceType: "gift", Name: "Rose", Status: "active"},
|
||||
Status: "active",
|
||||
Name: "Rose",
|
||||
GiftTypeCode: "normal",
|
||||
CoinPrice: 100,
|
||||
}}, Total: 1},
|
||||
}
|
||||
snapshot := &roomv1.RoomSnapshot{
|
||||
RoomId: "room-gift-region-cache",
|
||||
OwnerUserId: 88,
|
||||
Status: "active",
|
||||
VisibleRegionId: 1001,
|
||||
}
|
||||
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: snapshot}}
|
||||
handler := NewHandler(&fakeRoomClient{})
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-region-cache/gift-panel", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("first gift panel status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
snapshot.VisibleRegionId = 1002
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-region-cache/gift-panel", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder = httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("second gift panel status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 || walletClient.lastListGiftConfigs.GetRegionId() != 1002 {
|
||||
t.Fatalf("gift panel config cache must be separated by visible region, typeCalls=%d giftCalls=%d lastGift=%+v", walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls, walletClient.lastListGiftConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) {
|
||||
messageClient := &fakeMessageInboxClient{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
@ -7686,6 +7936,7 @@ func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T)
|
||||
if walletClient.lastGoogleConfirm == nil ||
|
||||
walletClient.lastGoogleConfirm.GetUserId() != 42 ||
|
||||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
|
||||
walletClient.lastGoogleConfirm.GetCountryId() != 840 ||
|
||||
walletClient.lastGoogleConfirm.GetProductId() != 11 ||
|
||||
walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" ||
|
||||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
|
||||
@ -7737,6 +7988,7 @@ func TestConfirmGooglePaymentAllowsGoogleProductCodeWithoutProductID(t *testing.
|
||||
if walletClient.lastGoogleConfirm == nil ||
|
||||
walletClient.lastGoogleConfirm.GetUserId() != 42 ||
|
||||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
|
||||
walletClient.lastGoogleConfirm.GetCountryId() != 840 ||
|
||||
walletClient.lastGoogleConfirm.GetProductId() != 0 ||
|
||||
walletClient.lastGoogleConfirm.GetProductCode() != "first_recharge_google_999" ||
|
||||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
|
||||
@ -8079,6 +8331,7 @@ func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) {
|
||||
DisplayUserId: "163055",
|
||||
Username: "normal55",
|
||||
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
|
||||
CountryId: 818,
|
||||
Country: "EG",
|
||||
CountryDisplayName: "Egypt",
|
||||
RegionId: 7300,
|
||||
@ -8103,6 +8356,7 @@ func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) {
|
||||
walletClient.lastH5CreateOrder.GetCommandId() != "cmd-h5-create-eg" ||
|
||||
walletClient.lastH5CreateOrder.GetTargetUserId() != 55 ||
|
||||
walletClient.lastH5CreateOrder.GetTargetRegionId() != 7300 ||
|
||||
walletClient.lastH5CreateOrder.GetTargetCountryId() != 818 ||
|
||||
walletClient.lastH5CreateOrder.GetTargetCountryCode() != "EG" ||
|
||||
walletClient.lastH5CreateOrder.GetAudienceType() != "normal" ||
|
||||
walletClient.lastH5CreateOrder.GetProductId() != 93001 ||
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
package roomapi
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
)
|
||||
|
||||
type roomGiftPanelConfig struct {
|
||||
GiftTypes []giftTypeConfigData
|
||||
Gifts []giftConfigData
|
||||
}
|
||||
|
||||
type roomGiftPanelConfigCache struct {
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
items map[string]roomGiftPanelConfigCacheEntry
|
||||
}
|
||||
|
||||
type roomGiftPanelConfigCacheEntry struct {
|
||||
config roomGiftPanelConfig
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newRoomGiftPanelConfigCache(ttl time.Duration) *roomGiftPanelConfigCache {
|
||||
if ttl <= 0 {
|
||||
ttl = 45 * time.Second
|
||||
}
|
||||
return &roomGiftPanelConfigCache{
|
||||
ttl: ttl,
|
||||
items: make(map[string]roomGiftPanelConfigCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *roomGiftPanelConfigCache) Get(appCode string, regionID int64, now time.Time) (roomGiftPanelConfig, bool) {
|
||||
if c == nil {
|
||||
return roomGiftPanelConfig{}, false
|
||||
}
|
||||
key := roomGiftPanelConfigCacheKey(appCode, regionID)
|
||||
c.mu.RLock()
|
||||
entry, ok := c.items[key]
|
||||
c.mu.RUnlock()
|
||||
if !ok || !now.Before(entry.expiresAt) {
|
||||
if ok {
|
||||
c.mu.Lock()
|
||||
// 过期项只在命中时顺手清理,避免为低频区域引入后台 goroutine。
|
||||
if current, exists := c.items[key]; exists && !now.Before(current.expiresAt) {
|
||||
delete(c.items, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return roomGiftPanelConfig{}, false
|
||||
}
|
||||
return cloneRoomGiftPanelConfig(entry.config), true
|
||||
}
|
||||
|
||||
func (c *roomGiftPanelConfigCache) Set(appCode string, regionID int64, config roomGiftPanelConfig, now time.Time) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// 写入时复制一次,读取时再复制一次;缓存只保存配置快照,不让请求级背包礼物追加影响下一次命中。
|
||||
c.items[roomGiftPanelConfigCacheKey(appCode, regionID)] = roomGiftPanelConfigCacheEntry{
|
||||
config: cloneRoomGiftPanelConfig(config),
|
||||
expiresAt: now.Add(c.ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func roomGiftPanelConfigCacheKey(appCode string, regionID int64) string {
|
||||
return appcode.Normalize(appCode) + ":" + strconv.FormatInt(regionID, 10)
|
||||
}
|
||||
|
||||
func cloneRoomGiftPanelConfig(config roomGiftPanelConfig) roomGiftPanelConfig {
|
||||
return roomGiftPanelConfig{
|
||||
GiftTypes: append([]giftTypeConfigData(nil), config.GiftTypes...),
|
||||
Gifts: cloneGiftConfigDataSlice(config.Gifts),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneGiftConfigDataSlice(gifts []giftConfigData) []giftConfigData {
|
||||
out := make([]giftConfigData, 0, len(gifts))
|
||||
for _, gift := range gifts {
|
||||
out = append(out, cloneGiftConfigData(gift))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneGiftConfigData(gift giftConfigData) giftConfigData {
|
||||
gift.Resource.UsageScopes = append([]string(nil), gift.Resource.UsageScopes...)
|
||||
gift.EffectTypes = append([]string(nil), gift.EffectTypes...)
|
||||
gift.RegionIDs = append([]int64(nil), gift.RegionIDs...)
|
||||
return gift
|
||||
}
|
||||
@ -2,6 +2,7 @@ package roomapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/tencentrtc"
|
||||
"hyapp/services/gateway-service/internal/client"
|
||||
@ -22,6 +23,7 @@ type Handler struct {
|
||||
userHostClient client.UserHostClient
|
||||
appConfigReader appapi.ConfigReader
|
||||
walletClient client.WalletClient
|
||||
giftPanelCache *roomGiftPanelConfigCache
|
||||
growthLevelClient client.GrowthLevelClient
|
||||
achievementClient client.AchievementClient
|
||||
rtcTokenConfig tencentrtc.TokenConfig
|
||||
@ -55,6 +57,7 @@ func New(config Config) *Handler {
|
||||
userHostClient: config.UserHostClient,
|
||||
appConfigReader: config.AppConfigReader,
|
||||
walletClient: config.WalletClient,
|
||||
giftPanelCache: newRoomGiftPanelConfigCache(45 * time.Second),
|
||||
growthLevelClient: config.GrowthLevelClient,
|
||||
achievementClient: config.AchievementClient,
|
||||
rtcTokenConfig: config.RTCTokenConfig,
|
||||
|
||||
@ -15,6 +15,8 @@ import (
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
@ -705,37 +707,13 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
ActiveOnly: true,
|
||||
})
|
||||
panelConfig, err := h.roomGiftPanelBaseConfig(request, snapshot.GetVisibleRegionId())
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
|
||||
for _, item := range giftTypeResp.GetGiftTypes() {
|
||||
giftTypes = append(giftTypes, giftTypeFromProto(item))
|
||||
}
|
||||
giftResp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
Page: 1,
|
||||
PageSize: 500,
|
||||
ActiveOnly: true,
|
||||
RegionId: snapshot.GetVisibleRegionId(),
|
||||
FilterRegion: true,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
gifts := make([]giftConfigData, 0, len(giftResp.GetGifts()))
|
||||
for _, gift := range giftResp.GetGifts() {
|
||||
gifts = append(gifts, giftFromProto(gift))
|
||||
}
|
||||
gifts = filterGiftPanelGifts(gifts, giftTypes)
|
||||
giftTypes := panelConfig.GiftTypes
|
||||
gifts := panelConfig.Gifts
|
||||
bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
@ -753,6 +731,52 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) {
|
||||
app := appcode.FromContext(request.Context())
|
||||
if h.giftPanelCache != nil {
|
||||
if cached, ok := h.giftPanelCache.Get(app, regionID, time.Now()); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: app,
|
||||
ActiveOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
return roomGiftPanelConfig{}, err
|
||||
}
|
||||
giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
|
||||
for _, item := range giftTypeResp.GetGiftTypes() {
|
||||
giftTypes = append(giftTypes, giftTypeFromProto(item))
|
||||
}
|
||||
giftResp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: app,
|
||||
Page: 1,
|
||||
PageSize: 500,
|
||||
ActiveOnly: true,
|
||||
RegionId: regionID,
|
||||
FilterRegion: true,
|
||||
})
|
||||
if err != nil {
|
||||
return roomGiftPanelConfig{}, err
|
||||
}
|
||||
gifts := make([]giftConfigData, 0, len(giftResp.GetGifts()))
|
||||
for _, gift := range giftResp.GetGifts() {
|
||||
gifts = append(gifts, giftFromProto(gift))
|
||||
}
|
||||
config := roomGiftPanelConfig{
|
||||
GiftTypes: giftTypes,
|
||||
// 礼物类型禁用后不应出现在房间面板;缓存保存已过滤的稳定物料,背包礼物仍按用户每次实时拼接。
|
||||
Gifts: filterGiftPanelGifts(gifts, giftTypes),
|
||||
}
|
||||
if h.giftPanelCache != nil {
|
||||
h.giftPanelCache.Set(app, regionID, config, time.Now())
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
|
||||
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
|
||||
return nil, nil
|
||||
@ -1557,7 +1581,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
senderCountryID, senderRegionID, err := h.resolveRoomActorCountryRegion(request)
|
||||
userSnapshots, err := h.resolveGiftUserSnapshots(request, targetUserIDs)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
@ -1568,7 +1592,6 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
return
|
||||
}
|
||||
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
|
||||
senderDisplayProfile, targetDisplayProfiles := h.resolveGiftDisplayProfiles(request, targetUserIDs)
|
||||
|
||||
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
|
||||
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
|
||||
@ -1578,16 +1601,16 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
GiftId: body.GiftID,
|
||||
GiftCount: body.GiftCount,
|
||||
PoolId: body.PoolID,
|
||||
SenderCountryId: senderCountryID,
|
||||
SenderRegionId: senderRegionID,
|
||||
SenderCountryId: userSnapshots.senderCountryID,
|
||||
SenderRegionId: userSnapshots.senderRegionID,
|
||||
TargetIsHost: firstTargetScope.GetTargetIsHost(),
|
||||
TargetHostRegionId: firstTargetScope.GetTargetHostRegionId(),
|
||||
TargetAgencyOwnerUserId: firstTargetScope.GetTargetAgencyOwnerUserId(),
|
||||
TargetHostScopes: targetHostScopes,
|
||||
EntitlementId: strings.TrimSpace(body.EntitlementID),
|
||||
Source: strings.TrimSpace(body.Source),
|
||||
SenderDisplayProfile: senderDisplayProfile,
|
||||
TargetDisplayProfiles: targetDisplayProfiles,
|
||||
SenderDisplayProfile: userSnapshots.senderDisplayProfile,
|
||||
TargetDisplayProfiles: userSnapshots.targetDisplayProfiles,
|
||||
})
|
||||
httpkit.Write(writer, request, resp, err)
|
||||
}
|
||||
@ -1613,6 +1636,49 @@ func (h *Handler) resolveRoomActorCountryRegion(request *http.Request) (int64, i
|
||||
return resp.GetUser().GetCountryId(), resp.GetUser().GetRegionId(), nil
|
||||
}
|
||||
|
||||
type giftUserSnapshots struct {
|
||||
senderCountryID int64
|
||||
senderRegionID int64
|
||||
senderDisplayProfile *roomv1.SendGiftDisplayProfile
|
||||
targetDisplayProfiles []*roomv1.SendGiftDisplayProfile
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, error) {
|
||||
if h.userProfileClient == nil {
|
||||
return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "user service is not configured"))
|
||||
}
|
||||
senderUserID := auth.UserIDFromContext(request.Context())
|
||||
userIDs := uniquePositiveUserIDs(append([]int64{senderUserID}, targetUserIDs...))
|
||||
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return giftUserSnapshots{}, err
|
||||
}
|
||||
sender := resp.GetUsers()[senderUserID]
|
||||
if sender == nil {
|
||||
return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.NotFound, "user not found"))
|
||||
}
|
||||
if sender.GetCountryId() <= 0 || sender.GetRegionId() <= 0 {
|
||||
return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "user country and region are required"))
|
||||
}
|
||||
|
||||
targets := make([]*roomv1.SendGiftDisplayProfile, 0, len(targetUserIDs))
|
||||
for _, targetUserID := range targetUserIDs {
|
||||
if profile := giftDisplayProfileFromUser(resp.GetUsers()[targetUserID]); profile != nil {
|
||||
// 目标展示快照不是结算前置条件;缺失 target 资料时只让客户端走最终昵称头像兜底。
|
||||
targets = append(targets, profile)
|
||||
}
|
||||
}
|
||||
return giftUserSnapshots{
|
||||
senderCountryID: sender.GetCountryId(),
|
||||
senderRegionID: sender.GetRegionId(),
|
||||
senderDisplayProfile: giftDisplayProfileFromUser(sender),
|
||||
targetDisplayProfiles: targets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) {
|
||||
if targetUserID <= 0 || h.userHostClient == nil {
|
||||
// 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed,避免客户端伪造主播入账。
|
||||
@ -1637,6 +1703,38 @@ func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) {
|
||||
if len(targetUserIDs) == 0 {
|
||||
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
|
||||
}
|
||||
resp, err := h.userHostClient.BatchGetHostProfiles(request.Context(), &userv1.BatchGetHostProfilesRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserIds: targetUserIDs,
|
||||
})
|
||||
if err != nil {
|
||||
if status.Code(err) == codes.Unimplemented {
|
||||
// 滚动发布期间旧 user-service 还没有 batch RPC 时,临时退回单查;其他错误必须阻断送礼。
|
||||
return h.resolveGiftTargetHostScopesBySingleLookup(request, targetUserIDs)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
profiles := resp.GetHostProfiles()
|
||||
scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
|
||||
for _, targetUserID := range targetUserIDs {
|
||||
// 批量送礼必须按接收方分别固化主播快照;缺失 profile 表示该 target 当前不是主播。
|
||||
scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, profiles[targetUserID]))
|
||||
}
|
||||
return scopes, nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGiftTargetHostScopesBySingleLookup(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) {
|
||||
scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
|
||||
for _, targetUserID := range targetUserIDs {
|
||||
targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, targetUserID)
|
||||
@ -1654,31 +1752,16 @@ func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserI
|
||||
return scopes, nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGiftDisplayProfiles(request *http.Request, targetUserIDs []int64) (*roomv1.SendGiftDisplayProfile, []*roomv1.SendGiftDisplayProfile) {
|
||||
if h.userProfileClient == nil {
|
||||
return nil, nil
|
||||
func giftTargetHostScopeFromProfile(targetUserID int64, profile *userv1.HostProfile) *roomv1.SendGiftTargetHostScope {
|
||||
scope := &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
|
||||
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
|
||||
// 只有 active host 且带区域才能进入工资政策链路;disabled/缺区域按非主播处理,不影响正常送礼。
|
||||
return scope
|
||||
}
|
||||
senderUserID := auth.UserIDFromContext(request.Context())
|
||||
userIDs := uniquePositiveUserIDs(append([]int64{senderUserID}, targetUserIDs...))
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
// 展示快照只影响 IM 昵称头像兜底,不能因为增强资料读取失败阻断已经通过国家/主播校验的送礼主链路。
|
||||
return nil, nil
|
||||
}
|
||||
sender := giftDisplayProfileFromUser(resp.GetUsers()[senderUserID])
|
||||
targets := make([]*roomv1.SendGiftDisplayProfile, 0, len(targetUserIDs))
|
||||
for _, targetUserID := range targetUserIDs {
|
||||
if profile := giftDisplayProfileFromUser(resp.GetUsers()[targetUserID]); profile != nil {
|
||||
targets = append(targets, profile)
|
||||
}
|
||||
}
|
||||
return sender, targets
|
||||
scope.TargetIsHost = true
|
||||
scope.TargetHostRegionId = profile.GetRegionId()
|
||||
scope.TargetAgencyOwnerUserId = profile.GetCurrentAgencyOwnerUserId()
|
||||
return scope
|
||||
}
|
||||
|
||||
func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfile {
|
||||
|
||||
@ -274,6 +274,7 @@ func (h *Handler) confirmGooglePayment(writer http.ResponseWriter, request *http
|
||||
CommandId: commandID,
|
||||
UserId: userID,
|
||||
RegionId: profileResp.GetUser().GetRegionId(),
|
||||
CountryId: profileResp.GetUser().GetCountryId(),
|
||||
ProductId: productID,
|
||||
ProductCode: productCode,
|
||||
PackageName: packageName,
|
||||
|
||||
@ -34,6 +34,7 @@ type h5RechargeTargetContext struct {
|
||||
DisplayUserID string
|
||||
Username string
|
||||
Avatar string
|
||||
CountryID int64
|
||||
CountryCode string
|
||||
CountryName string
|
||||
RegionID int64
|
||||
@ -229,6 +230,7 @@ func (h *Handler) createH5RechargeOrder(writer http.ResponseWriter, request *htt
|
||||
CommandId: commandID,
|
||||
TargetUserId: target.UserID,
|
||||
TargetRegionId: target.RegionID,
|
||||
TargetCountryId: target.CountryID,
|
||||
TargetCountryCode: target.CountryCode,
|
||||
AudienceType: target.AudienceType,
|
||||
ProductId: productID,
|
||||
@ -555,6 +557,7 @@ func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *h
|
||||
DisplayUserID: user.GetDisplayUserId(),
|
||||
Username: user.GetUsername(),
|
||||
Avatar: user.GetAvatar(),
|
||||
CountryID: user.GetCountryId(),
|
||||
CountryCode: h5RechargeCountryCodeFromUser(user),
|
||||
CountryName: firstNonEmptyString(user.GetCountryDisplayName(), user.GetCountryName()),
|
||||
RegionID: user.GetRegionId(),
|
||||
|
||||
@ -52,6 +52,7 @@ presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
room_rocket_launch_scan_interval: "1s"
|
||||
room_list_cache_refresh_interval: "5m"
|
||||
rocketmq:
|
||||
# Docker 本地使用 compose RocketMQ 验证 owner outbox 到下游读模型的真实链路。
|
||||
enabled: true
|
||||
@ -93,7 +94,8 @@ outbox_worker:
|
||||
publish_mode: "mq"
|
||||
poll_interval: "1s"
|
||||
batch_size: 100
|
||||
publish_timeout: "3s"
|
||||
concurrency: 16
|
||||
publish_timeout: "15s"
|
||||
retry_strategy: "exponential_backoff"
|
||||
max_retry_count: 10
|
||||
initial_backoff: "5s"
|
||||
@ -102,10 +104,16 @@ robot_outbox_worker:
|
||||
# Docker 本地单独投递机器人展示事件,避免压住主 room_outbox fanout。
|
||||
enabled: true
|
||||
publish_mode: "mq"
|
||||
poll_interval: "1s"
|
||||
batch_size: 30
|
||||
publish_timeout: "3s"
|
||||
poll_interval: "200ms"
|
||||
batch_size: 100
|
||||
concurrency: 1
|
||||
publish_timeout: "15s"
|
||||
retry_strategy: "exponential_backoff"
|
||||
max_retry_count: 10
|
||||
initial_backoff: "5s"
|
||||
max_backoff: "5m"
|
||||
stale_discard_after: "60s"
|
||||
cleanup_interval: "30s"
|
||||
cleanup_active_after: "5m"
|
||||
cleanup_terminal_after: "1h"
|
||||
cleanup_batch_size: 50000
|
||||
|
||||
@ -61,6 +61,7 @@ presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
room_rocket_launch_scan_interval: "1s"
|
||||
room_list_cache_refresh_interval: "5m"
|
||||
rocketmq:
|
||||
# 线上建议开启:room_outbox 只投 MQ,activity/notice/IM bridge 各自用 consumer group 解耦消费。
|
||||
enabled: true
|
||||
@ -103,7 +104,8 @@ outbox_worker:
|
||||
publish_mode: "mq"
|
||||
poll_interval: "1s"
|
||||
batch_size: 100
|
||||
publish_timeout: "3s"
|
||||
concurrency: 16
|
||||
publish_timeout: "15s"
|
||||
retry_strategy: "exponential_backoff"
|
||||
max_retry_count: 10
|
||||
initial_backoff: "5s"
|
||||
@ -112,10 +114,16 @@ robot_outbox_worker:
|
||||
# 机器人房间展示事件独立投递,只服务 IM 展示,不投 activity/statistics 主业务消费者。
|
||||
enabled: true
|
||||
publish_mode: "mq"
|
||||
poll_interval: "1s"
|
||||
batch_size: 30
|
||||
publish_timeout: "3s"
|
||||
poll_interval: "200ms"
|
||||
batch_size: 100
|
||||
concurrency: 1
|
||||
publish_timeout: "15s"
|
||||
retry_strategy: "exponential_backoff"
|
||||
max_retry_count: 10
|
||||
initial_backoff: "5s"
|
||||
max_backoff: "5m"
|
||||
stale_discard_after: "60s"
|
||||
cleanup_interval: "30s"
|
||||
cleanup_active_after: "5m"
|
||||
cleanup_terminal_after: "1h"
|
||||
cleanup_batch_size: 50000
|
||||
|
||||
@ -55,6 +55,7 @@ presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
room_rocket_launch_scan_interval: "1s"
|
||||
room_list_cache_refresh_interval: "5m"
|
||||
rocketmq:
|
||||
# 本地默认走 RocketMQ fanout,和 Docker/testbox/线上保持同一条 outbox 链路。
|
||||
enabled: true
|
||||
@ -96,7 +97,8 @@ outbox_worker:
|
||||
publish_mode: "mq"
|
||||
poll_interval: "1s"
|
||||
batch_size: 100
|
||||
publish_timeout: "3s"
|
||||
concurrency: 16
|
||||
publish_timeout: "15s"
|
||||
retry_strategy: "exponential_backoff"
|
||||
max_retry_count: 10
|
||||
initial_backoff: "5s"
|
||||
@ -105,10 +107,16 @@ robot_outbox_worker:
|
||||
# 机器人展示事件走独立 outbox/topic,只投房间 IM,不占用真人 room_outbox 主流程。
|
||||
enabled: true
|
||||
publish_mode: "mq"
|
||||
poll_interval: "1s"
|
||||
batch_size: 30
|
||||
publish_timeout: "3s"
|
||||
poll_interval: "200ms"
|
||||
batch_size: 100
|
||||
concurrency: 1
|
||||
publish_timeout: "15s"
|
||||
retry_strategy: "exponential_backoff"
|
||||
max_retry_count: 10
|
||||
initial_backoff: "5s"
|
||||
max_backoff: "5m"
|
||||
stale_discard_after: "60s"
|
||||
cleanup_interval: "30s"
|
||||
cleanup_active_after: "5m"
|
||||
cleanup_terminal_after: "1h"
|
||||
cleanup_batch_size: 50000
|
||||
|
||||
@ -249,6 +249,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
RobotOutboxPublisher: robotOutboxPublisher,
|
||||
HumanRobotPoolProvider: integration.NewGRPCHumanRoomRobotPoolProvider(robotConn, userConn),
|
||||
RoomGiftLeaderboard: roomservice.NewRedisRoomGiftLeaderboardStore(redisClient),
|
||||
RoomListCache: roomservice.NewRedisRoomListCacheStore(redisClient),
|
||||
LuckyGiftSendLocker: roomservice.NewRedisLuckyGiftSendLocker(redisClient),
|
||||
LuckyGiftSendLockTTL: cfg.LuckyGiftSendLockTTL,
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient)
|
||||
@ -449,29 +450,51 @@ func (a *App) Run() error {
|
||||
a.service.RunRoomRocketLaunchWorker(ctx, a.cfg.RoomRocketLaunchScanInterval)
|
||||
})
|
||||
}
|
||||
a.workers.Go(func(ctx context.Context) {
|
||||
// 火箭燃料进度允许 1 秒展示延迟;100ms 扫描让到期 bucket 尽快写回 outbox,后续仍走原 MQ/IM 补偿链路。
|
||||
a.service.RunRoomRocketProgressFlushWorker(ctx, 100*time.Millisecond)
|
||||
})
|
||||
if a.cfg.RoomListCacheRefreshInterval > 0 {
|
||||
// Redis 发现页缓存是 MySQL owner 表的展示读模型,启动后先 warmup,再周期性修复 best-effort 增量丢失。
|
||||
a.workers.Go(func(ctx context.Context) {
|
||||
a.service.RunRoomListCacheRefreshWorker(ctx, a.cfg.RoomListCacheRefreshInterval)
|
||||
})
|
||||
}
|
||||
if a.cfg.OutboxWorker.Enabled {
|
||||
a.workers.Go(func(ctx context.Context) {
|
||||
a.service.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
|
||||
PollInterval: a.cfg.OutboxWorker.PollInterval,
|
||||
BatchSize: a.cfg.OutboxWorker.BatchSize,
|
||||
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
|
||||
RetryStrategy: a.cfg.OutboxWorker.RetryStrategy,
|
||||
MaxRetryCount: a.cfg.OutboxWorker.MaxRetryCount,
|
||||
InitialBackoff: a.cfg.OutboxWorker.InitialBackoff,
|
||||
MaxBackoff: a.cfg.OutboxWorker.MaxBackoff,
|
||||
PollInterval: a.cfg.OutboxWorker.PollInterval,
|
||||
BatchSize: a.cfg.OutboxWorker.BatchSize,
|
||||
Concurrency: a.cfg.OutboxWorker.Concurrency,
|
||||
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
|
||||
RetryStrategy: a.cfg.OutboxWorker.RetryStrategy,
|
||||
MaxRetryCount: a.cfg.OutboxWorker.MaxRetryCount,
|
||||
InitialBackoff: a.cfg.OutboxWorker.InitialBackoff,
|
||||
MaxBackoff: a.cfg.OutboxWorker.MaxBackoff,
|
||||
StaleDiscardAfter: a.cfg.OutboxWorker.StaleDiscardAfter,
|
||||
CleanupInterval: a.cfg.OutboxWorker.CleanupInterval,
|
||||
CleanupActiveAfter: a.cfg.OutboxWorker.CleanupActiveAfter,
|
||||
CleanupTerminalAfter: a.cfg.OutboxWorker.CleanupTerminalAfter,
|
||||
CleanupBatchSize: a.cfg.OutboxWorker.CleanupBatchSize,
|
||||
})
|
||||
})
|
||||
}
|
||||
if a.cfg.RobotOutboxWorker.Enabled {
|
||||
a.workers.Go(func(ctx context.Context) {
|
||||
a.service.RunRobotOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
|
||||
PollInterval: a.cfg.RobotOutboxWorker.PollInterval,
|
||||
BatchSize: a.cfg.RobotOutboxWorker.BatchSize,
|
||||
PublishTimeout: a.cfg.RobotOutboxWorker.PublishTimeout,
|
||||
RetryStrategy: a.cfg.RobotOutboxWorker.RetryStrategy,
|
||||
MaxRetryCount: a.cfg.RobotOutboxWorker.MaxRetryCount,
|
||||
InitialBackoff: a.cfg.RobotOutboxWorker.InitialBackoff,
|
||||
MaxBackoff: a.cfg.RobotOutboxWorker.MaxBackoff,
|
||||
PollInterval: a.cfg.RobotOutboxWorker.PollInterval,
|
||||
BatchSize: a.cfg.RobotOutboxWorker.BatchSize,
|
||||
Concurrency: a.cfg.RobotOutboxWorker.Concurrency,
|
||||
PublishTimeout: a.cfg.RobotOutboxWorker.PublishTimeout,
|
||||
RetryStrategy: a.cfg.RobotOutboxWorker.RetryStrategy,
|
||||
MaxRetryCount: a.cfg.RobotOutboxWorker.MaxRetryCount,
|
||||
InitialBackoff: a.cfg.RobotOutboxWorker.InitialBackoff,
|
||||
MaxBackoff: a.cfg.RobotOutboxWorker.MaxBackoff,
|
||||
StaleDiscardAfter: a.cfg.RobotOutboxWorker.StaleDiscardAfter,
|
||||
CleanupInterval: a.cfg.RobotOutboxWorker.CleanupInterval,
|
||||
CleanupActiveAfter: a.cfg.RobotOutboxWorker.CleanupActiveAfter,
|
||||
CleanupTerminalAfter: a.cfg.RobotOutboxWorker.CleanupTerminalAfter,
|
||||
CleanupBatchSize: a.cfg.RobotOutboxWorker.CleanupBatchSize,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -84,6 +84,8 @@ type Config struct {
|
||||
MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"`
|
||||
// RoomRocketLaunchScanInterval 是本节点扫描到点发射火箭的周期。
|
||||
RoomRocketLaunchScanInterval time.Duration `yaml:"room_rocket_launch_scan_interval"`
|
||||
// RoomListCacheRefreshInterval 是用 MySQL owner 表重建 Redis 发现页展示读模型的周期。
|
||||
RoomListCacheRefreshInterval time.Duration `yaml:"room_list_cache_refresh_interval"`
|
||||
// RocketMQ 承载 room_outbox 事件分发和语音房火箭延迟发射唤醒。
|
||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||
// OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
|
||||
@ -104,6 +106,8 @@ type OutboxWorkerConfig struct {
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
// BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。
|
||||
BatchSize int `yaml:"batch_size"`
|
||||
// Concurrency 是本进程内并发抢占 outbox 的 worker 数;多机多 worker 依赖 MySQL 原子 claim 防重复。
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
// PublishTimeout 是单条 outbox 投递腾讯云 IM 的最大耗时。
|
||||
PublishTimeout time.Duration `yaml:"publish_timeout"`
|
||||
// RetryStrategy 当前只接受 exponential_backoff,避免配置错误静默改变投递语义。
|
||||
@ -114,6 +118,16 @@ type OutboxWorkerConfig struct {
|
||||
InitialBackoff time.Duration `yaml:"initial_backoff"`
|
||||
// MaxBackoff 是指数退避的最大等待时间。
|
||||
MaxBackoff time.Duration `yaml:"max_backoff"`
|
||||
// StaleDiscardAfter 只用于 robot outbox;超过该窗口的展示事件直接删除,不再补发 MQ/IM。
|
||||
StaleDiscardAfter time.Duration `yaml:"stale_discard_after"`
|
||||
// CleanupInterval 控制 robot outbox 后台清理频率;非正数表示不启动清理。
|
||||
CleanupInterval time.Duration `yaml:"cleanup_interval"`
|
||||
// CleanupActiveAfter 控制 pending/retryable/delivering 机器人展示事件的最大保留时长。
|
||||
CleanupActiveAfter time.Duration `yaml:"cleanup_active_after"`
|
||||
// CleanupTerminalAfter 控制 delivered/failed 机器人展示事件的最大保留时长。
|
||||
CleanupTerminalAfter time.Duration `yaml:"cleanup_terminal_after"`
|
||||
// CleanupBatchSize 控制单轮清理最多删除多少行,避免长事务拖慢主库。
|
||||
CleanupBatchSize int `yaml:"cleanup_batch_size"`
|
||||
}
|
||||
|
||||
// RocketMQConfig 描述 room-service 使用的 RocketMQ 集群和业务 topic。
|
||||
@ -279,6 +293,7 @@ func Default() Config {
|
||||
MicPublishTimeout: 15 * time.Second,
|
||||
MicPublishScanInterval: time.Second,
|
||||
RoomRocketLaunchScanInterval: time.Second,
|
||||
RoomListCacheRefreshInterval: 5 * time.Minute,
|
||||
RocketMQ: defaultRocketMQConfig(),
|
||||
OutboxWorker: defaultOutboxWorkerConfig(),
|
||||
RobotOutboxWorker: defaultOutboxWorkerConfig(),
|
||||
@ -297,6 +312,7 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
|
||||
PublishMode: OutboxPublishModeDirect,
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 100,
|
||||
Concurrency: 1,
|
||||
PublishTimeout: 3 * time.Second,
|
||||
RetryStrategy: "exponential_backoff",
|
||||
MaxRetryCount: 10,
|
||||
@ -387,6 +403,9 @@ func Normalize(cfg Config) (Config, error) {
|
||||
if cfg.LuckyGiftSendLockTTL <= 0 {
|
||||
cfg.LuckyGiftSendLockTTL = 5 * time.Second
|
||||
}
|
||||
if cfg.RoomListCacheRefreshInterval <= 0 {
|
||||
cfg.RoomListCacheRefreshInterval = 5 * time.Minute
|
||||
}
|
||||
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
|
||||
if cfg.WalletServiceAddr == "" {
|
||||
cfg.WalletServiceAddr = "127.0.0.1:13004"
|
||||
@ -457,6 +476,9 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
|
||||
if cfg.BatchSize <= 0 {
|
||||
cfg.BatchSize = defaults.BatchSize
|
||||
}
|
||||
if cfg.Concurrency <= 0 {
|
||||
cfg.Concurrency = defaults.Concurrency
|
||||
}
|
||||
if cfg.PublishTimeout <= 0 {
|
||||
cfg.PublishTimeout = defaults.PublishTimeout
|
||||
}
|
||||
@ -472,6 +494,10 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
|
||||
if cfg.MaxBackoff < cfg.InitialBackoff {
|
||||
cfg.MaxBackoff = cfg.InitialBackoff
|
||||
}
|
||||
if cfg.CleanupBatchSize <= 0 && (cfg.CleanupActiveAfter > 0 || cfg.CleanupTerminalAfter > 0) {
|
||||
// 清理开启时必须有批量上限;默认和线上一次性清理批大小保持一致。
|
||||
cfg.CleanupBatchSize = 50000
|
||||
}
|
||||
cfg.RetryStrategy = strings.TrimSpace(cfg.RetryStrategy)
|
||||
if cfg.RetryStrategy == "" {
|
||||
cfg.RetryStrategy = defaults.RetryStrategy
|
||||
|
||||
@ -29,6 +29,9 @@ func TestLoad(t *testing.T) {
|
||||
if cfg.LeaseTTL != 10*time.Second {
|
||||
t.Fatalf("unexpected lease ttl: %s", cfg.LeaseTTL)
|
||||
}
|
||||
if cfg.RoomListCacheRefreshInterval != 5*time.Minute {
|
||||
t.Fatalf("unexpected room list cache refresh interval: %s", cfg.RoomListCacheRefreshInterval)
|
||||
}
|
||||
|
||||
// SendGift 主链路依赖 wallet-service,默认地址必须与本地编排一致。
|
||||
if cfg.WalletServiceAddr != "127.0.0.1:13004" {
|
||||
@ -37,10 +40,10 @@ func TestLoad(t *testing.T) {
|
||||
if cfg.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.SecretKey == "" || cfg.TencentIM.AdminIdentifier != "administrator" {
|
||||
t.Fatalf("local room-service must use test Tencent IM account: %+v", cfg.TencentIM)
|
||||
}
|
||||
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.PublishTimeout != 3*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
|
||||
}
|
||||
if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.PollInterval != time.Second || cfg.RobotOutboxWorker.BatchSize != 30 || cfg.RobotOutboxWorker.PublishTimeout != 3*time.Second || cfg.RobotOutboxWorker.RetryStrategy != "exponential_backoff" || cfg.RobotOutboxWorker.MaxRetryCount != 10 || cfg.RobotOutboxWorker.InitialBackoff != 5*time.Second || cfg.RobotOutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.PollInterval != 200*time.Millisecond || cfg.RobotOutboxWorker.BatchSize != 100 || cfg.RobotOutboxWorker.Concurrency != 1 || cfg.RobotOutboxWorker.PublishTimeout != 15*time.Second || cfg.RobotOutboxWorker.RetryStrategy != "exponential_backoff" || cfg.RobotOutboxWorker.MaxRetryCount != 10 || cfg.RobotOutboxWorker.InitialBackoff != 5*time.Second || cfg.RobotOutboxWorker.MaxBackoff != 5*time.Minute || cfg.RobotOutboxWorker.StaleDiscardAfter != 60*time.Second || cfg.RobotOutboxWorker.CleanupInterval != 30*time.Second || cfg.RobotOutboxWorker.CleanupActiveAfter != 5*time.Minute || cfg.RobotOutboxWorker.CleanupTerminalAfter != time.Hour || cfg.RobotOutboxWorker.CleanupBatchSize != 50000 {
|
||||
t.Fatalf("unexpected robot outbox worker config: %+v", cfg.RobotOutboxWorker)
|
||||
}
|
||||
// 本地默认也走 room_outbox MQ、IM bridge 和火箭延迟唤醒,避免本地直投和线上 MQ 语义分叉。
|
||||
@ -74,13 +77,16 @@ func TestLoadTencentExample(t *testing.T) {
|
||||
if cfg.TencentIM.RequestTimeout != 5*time.Second {
|
||||
t.Fatalf("unexpected request timeout: %s", cfg.TencentIM.RequestTimeout)
|
||||
}
|
||||
if cfg.RoomListCacheRefreshInterval != 5*time.Minute {
|
||||
t.Fatalf("unexpected room list cache refresh interval: %s", cfg.RoomListCacheRefreshInterval)
|
||||
}
|
||||
if cfg.AdvertiseAddr == "" || cfg.AdvertiseAddr == "room-service.internal:13001" {
|
||||
t.Fatalf("tencent example must use per-instance advertise_addr, got %q", cfg.AdvertiseAddr)
|
||||
}
|
||||
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
|
||||
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
|
||||
t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker)
|
||||
}
|
||||
if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.BatchSize != 30 {
|
||||
if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.PollInterval != 200*time.Millisecond || cfg.RobotOutboxWorker.BatchSize != 100 || cfg.RobotOutboxWorker.Concurrency != 1 || cfg.RobotOutboxWorker.PublishTimeout != 15*time.Second || cfg.RobotOutboxWorker.StaleDiscardAfter != 60*time.Second || cfg.RobotOutboxWorker.CleanupActiveAfter != 5*time.Minute || cfg.RobotOutboxWorker.CleanupTerminalAfter != time.Hour {
|
||||
t.Fatalf("tencent example must configure robot outbox worker: %+v", cfg.RobotOutboxWorker)
|
||||
}
|
||||
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RobotRoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled {
|
||||
@ -100,6 +106,9 @@ func TestLoadDockerConfigStartsRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
|
||||
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ {
|
||||
t.Fatalf("docker config should publish room outbox through MQ, got %q", cfg.OutboxWorker.PublishMode)
|
||||
}
|
||||
if cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second {
|
||||
t.Fatalf("docker config must run room outbox with 16 workers and a longer claim window: %+v", cfg.OutboxWorker)
|
||||
}
|
||||
if !cfg.RocketMQ.RoomOutbox.Enabled {
|
||||
t.Fatalf("docker config must enable room outbox MQ")
|
||||
}
|
||||
@ -148,6 +157,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.OutboxWorker.PollInterval = 0
|
||||
cfg.OutboxWorker.BatchSize = 0
|
||||
cfg.OutboxWorker.Concurrency = 0
|
||||
cfg.OutboxWorker.PublishTimeout = 0
|
||||
cfg.OutboxWorker.RetryStrategy = ""
|
||||
cfg.OutboxWorker.MaxRetryCount = 0
|
||||
@ -158,7 +168,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize failed: %v", err)
|
||||
}
|
||||
if normalized.OutboxWorker.PublishMode != OutboxPublishModeDirect || normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
if normalized.OutboxWorker.PublishMode != OutboxPublishModeDirect || normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.Concurrency != 1 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker)
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,6 +111,7 @@ func (s *Service) AdminCreateRoomPin(ctx context.Context, req *roomv1.AdminCreat
|
||||
if !ok {
|
||||
return nil, xerr.New(xerr.Conflict, "room is not active or not found")
|
||||
}
|
||||
s.projectRoomPinCacheBestEffort(ctx, roomListPinEntryFromAdmin(pin))
|
||||
return &roomv1.AdminCreateRoomPinResponse{Pin: roomPinToAdminProto(pin), ServerTimeMs: nowMS}, nil
|
||||
}
|
||||
|
||||
@ -126,9 +127,34 @@ func (s *Service) AdminCancelRoomPin(ctx context.Context, req *roomv1.AdminCance
|
||||
if !ok {
|
||||
return nil, xerr.New(xerr.NotFound, "room pin not found")
|
||||
}
|
||||
s.projectRoomPinCacheBestEffort(ctx, roomListPinEntryFromAdmin(pin))
|
||||
return &roomv1.AdminCancelRoomPinResponse{Pin: roomPinToAdminProto(pin), ServerTimeMs: nowMS}, nil
|
||||
}
|
||||
|
||||
func (s *Service) projectRoomPinCacheBestEffort(ctx context.Context, pin RoomListPinEntry) {
|
||||
if s == nil || s.roomListCache == nil {
|
||||
return
|
||||
}
|
||||
if err := s.roomListCache.UpsertRoomPin(ctx, pin); err != nil {
|
||||
// 后台置顶 owner 已经写入 MySQL;缓存失败只影响短暂展示顺序,后续 refresh 会从 owner 表重建。
|
||||
logx.Warn(ctx, "room_pin_cache_project_failed", slog.Int64("pin_id", pin.ID), slog.String("room_id", pin.RoomID), slog.String("pin_type", pin.PinType), slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func roomListPinEntryFromAdmin(pin AdminRoomPinEntry) RoomListPinEntry {
|
||||
return RoomListPinEntry{
|
||||
ID: pin.ID,
|
||||
AppCode: pin.AppCode,
|
||||
VisibleRegionID: pin.VisibleRegionID,
|
||||
PinType: pin.PinType,
|
||||
RoomID: pin.RoomID,
|
||||
Weight: pin.Weight,
|
||||
Status: pin.Status,
|
||||
PinnedAtMS: pin.PinnedAtMS,
|
||||
ExpiresAtMS: pin.ExpiresAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func seatConfigToAdminProto(config RoomSeatConfig) *roomv1.AdminRoomSeatConfig {
|
||||
return &roomv1.AdminRoomSeatConfig{
|
||||
CandidateSeatCounts: append([]int32(nil), adminSeatCandidates...),
|
||||
|
||||
@ -325,17 +325,26 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
|
||||
} else {
|
||||
records = append(records, heatEvent, rankEvent)
|
||||
}
|
||||
var rocketProgress *roomRocketProgressPending
|
||||
if !cmd.RobotGift {
|
||||
// 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人通道投递。
|
||||
if rocketApply.progressEvent != nil {
|
||||
progressEvent, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent)
|
||||
progressRecord, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if robotOutboxGift {
|
||||
robotRecords = append(robotRecords, progressEvent)
|
||||
} else {
|
||||
records = append(records, progressEvent)
|
||||
progressRecord.AppCode = appcode.FromContext(ctx)
|
||||
if progressRecord.Envelope != nil {
|
||||
progressRecord.Envelope.AppCode = progressRecord.AppCode
|
||||
}
|
||||
// 燃料变化是高频展示事件,不逐笔写 outbox;命令提交成功后由内存合并器按 1 秒窗口只落最大进度。
|
||||
rocketProgress = &roomRocketProgressPending{
|
||||
record: progressRecord,
|
||||
robotLane: robotOutboxGift,
|
||||
rocketID: rocketApply.progressEvent.GetRocketId(),
|
||||
level: rocketApply.progressEvent.GetLevel(),
|
||||
currentFuel: rocketApply.progressEvent.GetCurrentFuel(),
|
||||
roomVersion: current.Version,
|
||||
}
|
||||
}
|
||||
if rocketApply.ignited != nil {
|
||||
@ -390,6 +399,7 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
|
||||
commandPayload: commandPayload,
|
||||
walletDebitMS: walletDebitMS,
|
||||
robotOutboxRecords: robotRecords,
|
||||
roomRocketProgress: rocketProgress,
|
||||
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RoomID: current.RoomID,
|
||||
|
||||
@ -77,7 +77,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{
|
||||
entries, err := s.listRoomListEntries(ctx, RoomListQuery{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
|
||||
AllVisibleRegions: allVisibleRegions,
|
||||
@ -102,6 +102,28 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
|
||||
return roomListResponseFromEntries(tab, query, countryCode, viewerCountryCode, allVisibleRegions, entries, limit), nil
|
||||
}
|
||||
|
||||
func (s *Service) listRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, error) {
|
||||
if s != nil && s.roomListCache != nil && roomListCacheEligible(query) {
|
||||
entries, ok, err := s.roomListCache.ListRoomListEntries(ctx, query)
|
||||
if err == nil && ok {
|
||||
return entries, nil
|
||||
}
|
||||
if err != nil {
|
||||
// 缓存只是展示读模型,Redis 抖动或扫描保护不能影响发现页可用性;记录原因后走 MySQL 权威投影。
|
||||
logx.Warn(ctx, "room_list_cache_fallback", slog.String("tab", query.Tab), slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
return s.repository.ListRoomListEntries(ctx, query)
|
||||
}
|
||||
|
||||
func roomListCacheEligible(query RoomListQuery) bool {
|
||||
// 搜索、全区白名单和内部定向查询仍走 MySQL,避免为了低频条件维护过宽的 Redis 索引。
|
||||
if strings.TrimSpace(query.Query) != "" || query.AllVisibleRegions || query.OwnerUserID > 0 {
|
||||
return false
|
||||
}
|
||||
return query.Tab == roomListTabHot || query.Tab == roomListTabNew
|
||||
}
|
||||
|
||||
// ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
|
||||
func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
@ -260,6 +282,24 @@ func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv
|
||||
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
|
||||
// 列表读模型是最终一致;失败不改变命令成功语义。
|
||||
logx.Error(ctx, "room_list_upsert_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()), slog.Int64("region_id", meta.VisibleRegionID))
|
||||
return
|
||||
}
|
||||
s.projectRoomListCacheBestEffort(ctx, entry)
|
||||
}
|
||||
|
||||
func (s *Service) projectRoomListCacheBestEffort(ctx context.Context, entry RoomListEntry) {
|
||||
if s == nil || s.roomListCache == nil {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if strings.TrimSpace(entry.Status) == "active" {
|
||||
err = s.roomListCache.UpsertRoomListEntry(ctx, entry)
|
||||
} else {
|
||||
err = s.roomListCache.DeleteRoomListEntry(ctx, entry.AppCode, entry.RoomID)
|
||||
}
|
||||
if err != nil {
|
||||
// Redis 列表缓存可由 MySQL 扫描重建,写失败不能回滚已经提交的房间命令。
|
||||
logx.Warn(ctx, "room_list_cache_project_failed", slog.String("room_id", entry.RoomID), slog.String("status", entry.Status), slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/room-service/internal/integration"
|
||||
roomservice "hyapp/services/room-service/internal/room/service"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
func TestListRoomsUsesCacheWhenEligible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
cache := &fakeRoomListCache{entries: []roomservice.RoomListEntry{{
|
||||
AppCode: appcode.Default,
|
||||
RoomID: "room-cache-hit",
|
||||
RoomShortID: "cache-hit",
|
||||
VisibleRegionID: 7001,
|
||||
OwnerUserID: 6001,
|
||||
Title: "cache",
|
||||
Mode: "voice",
|
||||
Status: "active",
|
||||
SortScore: 99,
|
||||
}}}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-list-cache-hit-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
RoomListCache: cache,
|
||||
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||
|
||||
resp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||
ViewerUserId: 4001,
|
||||
VisibleRegionId: 7001,
|
||||
Tab: "hot",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list rooms failed: %v", err)
|
||||
}
|
||||
if cache.listCalls != 1 {
|
||||
t.Fatalf("eligible public list must query cache once, calls=%d", cache.listCalls)
|
||||
}
|
||||
if got := roomIDs(resp.GetRooms()); len(got) != 1 || got[0] != "room-cache-hit" {
|
||||
t.Fatalf("list response must come from cache, got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoomsFallsBackToMySQLWhenCacheMisses(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
cache := &fakeRoomListCache{ok: false}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-list-cache-fallback-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
RoomListCache: cache,
|
||||
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||
createPinnedListRoom(t, ctx, svc, "room-mysql-fallback", "mysql-fallback", 6101, 7101, "US")
|
||||
|
||||
resp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||
ViewerUserId: 4001,
|
||||
VisibleRegionId: 7101,
|
||||
Tab: "new",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list rooms fallback failed: %v", err)
|
||||
}
|
||||
if cache.listCalls == 0 {
|
||||
t.Fatalf("eligible public list should try cache before MySQL")
|
||||
}
|
||||
if got := roomIDs(resp.GetRooms()); len(got) != 1 || got[0] != "room-mysql-fallback" {
|
||||
t.Fatalf("fallback response must come from MySQL, got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRoomListCache struct {
|
||||
entries []roomservice.RoomListEntry
|
||||
ok bool
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (c *fakeRoomListCache) ListRoomListEntries(context.Context, roomservice.RoomListQuery) ([]roomservice.RoomListEntry, bool, error) {
|
||||
c.listCalls++
|
||||
if c.ok || c.entries != nil {
|
||||
return c.entries, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (c *fakeRoomListCache) UpsertRoomListEntry(context.Context, roomservice.RoomListEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeRoomListCache) DeleteRoomListEntry(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeRoomListCache) UpsertRoomPin(context.Context, roomservice.RoomListPinEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeRoomListCache) RebuildRoomList(context.Context, string, []roomservice.RoomListEntry, []roomservice.RoomListPinEntry, int64) error {
|
||||
return nil
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
@ -27,6 +28,8 @@ type OutboxWorkerOptions struct {
|
||||
PollInterval time.Duration
|
||||
// BatchSize 是单轮最多处理条数;非正数会被归一,避免全表扫描。
|
||||
BatchSize int
|
||||
// Concurrency 是本进程内独立抢占 pending outbox 的 worker 数;每条 worker 都依赖 MySQL SKIP LOCKED 隔离批次。
|
||||
Concurrency int
|
||||
// PublishTimeout 限制单条外部投递耗时,shutdown 时当前事件最多阻塞该时长。
|
||||
PublishTimeout time.Duration
|
||||
// RetryStrategy 当前只支持 exponential_backoff。
|
||||
@ -37,6 +40,20 @@ type OutboxWorkerOptions struct {
|
||||
InitialBackoff time.Duration
|
||||
// MaxBackoff 是指数退避上限。
|
||||
MaxBackoff time.Duration
|
||||
// WorkerID 写入 outbox worker_id,空值时使用节点 ID;并发启动时会自动带上序号。
|
||||
WorkerID string
|
||||
// WorkerName 写入日志 worker 字段,空值时使用主通道或机器人通道的稳定名称。
|
||||
WorkerName string
|
||||
// StaleDiscardAfter 只用于 robot outbox;超过该窗口的展示事件直接删除,不再补发 MQ/IM。
|
||||
StaleDiscardAfter time.Duration
|
||||
// CleanupInterval 控制 robot outbox 后台清理频率;非正数表示不启动清理。
|
||||
CleanupInterval time.Duration
|
||||
// CleanupActiveAfter 控制 pending/retryable/delivering 机器人展示事件的最大保留时长。
|
||||
CleanupActiveAfter time.Duration
|
||||
// CleanupTerminalAfter 控制 delivered/failed 机器人展示事件的最大保留时长。
|
||||
CleanupTerminalAfter time.Duration
|
||||
// CleanupBatchSize 控制单轮清理最多删除多少行,避免长事务拖慢主库。
|
||||
CleanupBatchSize int
|
||||
}
|
||||
|
||||
func defaultOutboxWorkerOptions() OutboxWorkerOptions {
|
||||
@ -44,6 +61,7 @@ func defaultOutboxWorkerOptions() OutboxWorkerOptions {
|
||||
return OutboxWorkerOptions{
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 100,
|
||||
Concurrency: 1,
|
||||
PublishTimeout: 3 * time.Second,
|
||||
RetryStrategy: outboxRetryExponentialBackoff,
|
||||
MaxRetryCount: 10,
|
||||
@ -63,6 +81,10 @@ func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptio
|
||||
// 非正批量按安全默认值处理,避免 repository 扫描不受控。
|
||||
options.BatchSize = defaults.BatchSize
|
||||
}
|
||||
if options.Concurrency <= 0 {
|
||||
// 并发数为 0 等价于历史单 worker,避免老测试或手工构造配置意外关闭补偿。
|
||||
options.Concurrency = defaults.Concurrency
|
||||
}
|
||||
if options.PublishTimeout <= 0 {
|
||||
// 单条外部投递必须有 deadline,shutdown 才能等待有界。
|
||||
options.PublishTimeout = defaults.PublishTimeout
|
||||
@ -79,6 +101,10 @@ func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptio
|
||||
if options.MaxBackoff < options.InitialBackoff {
|
||||
options.MaxBackoff = options.InitialBackoff
|
||||
}
|
||||
if options.CleanupBatchSize <= 0 && (options.CleanupActiveAfter > 0 || options.CleanupTerminalAfter > 0) {
|
||||
// robot outbox 清理开启时必须有批量上限;默认 5 万和线上一次性清理批量保持一致。
|
||||
options.CleanupBatchSize = 50000
|
||||
}
|
||||
options.RetryStrategy = strings.TrimSpace(options.RetryStrategy)
|
||||
if options.RetryStrategy == "" {
|
||||
// 空策略按指数退避处理,保持和配置默认值一致。
|
||||
@ -105,7 +131,31 @@ func (s *Service) runOutboxWorker(ctx context.Context, options OutboxWorkerOptio
|
||||
logx.Error(ctx, "worker_stopped", fmt.Errorf("unsupported retry_strategy"), slog.String("worker", workerName), slog.String("node_id", s.nodeID))
|
||||
return
|
||||
}
|
||||
if options.Concurrency == 1 {
|
||||
// 单 worker 保持历史 worker_id 和日志名称,避免线上排障查询字段无意义变化。
|
||||
options.WorkerID = firstNonEmpty(strings.TrimSpace(options.WorkerID), s.nodeID)
|
||||
options.WorkerName = firstNonEmpty(strings.TrimSpace(options.WorkerName), workerName)
|
||||
s.runOutboxWorkerLoop(ctx, options, process)
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for index := 1; index <= options.Concurrency; index++ {
|
||||
workerOptions := options
|
||||
// 多 worker 共用同一个进程上下文,但必须写不同 worker_id,方便从 delivering 记录定位卡住的协程。
|
||||
workerOptions.WorkerName = fmt.Sprintf("%s-%02d", workerName, index)
|
||||
workerOptions.WorkerID = fmt.Sprintf("%s/%s", s.nodeID, workerOptions.WorkerName)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.runOutboxWorkerLoop(ctx, workerOptions, process)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) runOutboxWorkerLoop(ctx context.Context, options OutboxWorkerOptions, process func(context.Context, OutboxWorkerOptions) error) {
|
||||
workerName := firstNonEmpty(strings.TrimSpace(options.WorkerName), outboxWorkerName)
|
||||
// 启动后先执行一轮,避免服务刚恢复时还要等一个 poll interval 才补偿历史事件。
|
||||
if err := process(ctx, options); err != nil && ctx.Err() == nil {
|
||||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", workerName), slog.String("node_id", s.nodeID), slog.Int("batch_size", options.BatchSize))
|
||||
@ -128,12 +178,17 @@ func (s *Service) runOutboxWorker(ctx context.Context, options OutboxWorkerOptio
|
||||
|
||||
// ProcessPendingOutbox 把待投递事件补偿性地推送给异步消费者。
|
||||
func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorkerOptions) error {
|
||||
return s.processPendingOutbox(ctx, options, outboxWorkerName, false)
|
||||
workerName := firstNonEmpty(strings.TrimSpace(options.WorkerName), outboxWorkerName)
|
||||
return s.processPendingOutbox(ctx, options, workerName, false)
|
||||
}
|
||||
|
||||
// ProcessPendingRobotOutbox 把机器人展示事件补偿性地推送给独立机器人异步消费者。
|
||||
func (s *Service) ProcessPendingRobotOutbox(ctx context.Context, options OutboxWorkerOptions) error {
|
||||
return s.processPendingOutbox(ctx, options, robotOutboxWorkerName, true)
|
||||
workerName := firstNonEmpty(strings.TrimSpace(options.WorkerName), robotOutboxWorkerName)
|
||||
if err := s.maybeCleanupRobotOutbox(ctx, normalizeOutboxWorkerOptions(options)); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.processPendingOutbox(ctx, options, workerName, true)
|
||||
}
|
||||
|
||||
func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorkerOptions, workerName string, robotLane bool) error {
|
||||
@ -155,6 +210,25 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if robotLane && staleRobotOutboxRecord(record, options.StaleDiscardAfter) {
|
||||
// 机器人展示事件超过 TTL 后再补发会制造“迟到礼物”体验,直接删除并释放 MQ/IM 资源。
|
||||
deleteCtx, deleteCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
|
||||
deleteErr := s.repository.DeleteRobotOutbox(deleteCtx, record.EventID)
|
||||
deleteCancel()
|
||||
if deleteErr != nil {
|
||||
return deleteErr
|
||||
}
|
||||
logx.Warn(ctx, "robot_outbox_stale_discarded",
|
||||
slog.String("worker", workerName),
|
||||
slog.String("node_id", s.nodeID),
|
||||
slog.String("event_id", record.EventID),
|
||||
slog.String("event_type", record.EventType),
|
||||
slog.String("room_id", record.RoomID),
|
||||
slog.Int64("created_at_ms", record.CreatedAtMS),
|
||||
slog.Int64("stale_discard_after_ms", options.StaleDiscardAfter.Milliseconds()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if record.RetryCount >= options.MaxRetryCount {
|
||||
// 已达到最大失败次数的历史事件直接转死信,防止每轮 worker 继续打外部系统。
|
||||
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
|
||||
@ -223,12 +297,62 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) maybeCleanupRobotOutbox(ctx context.Context, options OutboxWorkerOptions) error {
|
||||
if s == nil || s.repository == nil || options.CleanupInterval <= 0 || (options.CleanupActiveAfter <= 0 && options.CleanupTerminalAfter <= 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
s.robotOutboxCleanupMu.Lock()
|
||||
if !s.robotOutboxLastCleanup.IsZero() && now.Sub(s.robotOutboxLastCleanup) < options.CleanupInterval {
|
||||
s.robotOutboxCleanupMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
// 先记录本次尝试时间,避免多 worker 或异常重试在同一秒内反复打 DELETE。
|
||||
s.robotOutboxLastCleanup = now
|
||||
s.robotOutboxCleanupMu.Unlock()
|
||||
|
||||
var activeBeforeMS int64
|
||||
if options.CleanupActiveAfter > 0 {
|
||||
activeBeforeMS = now.Add(-options.CleanupActiveAfter).UnixMilli()
|
||||
}
|
||||
var terminalBeforeMS int64
|
||||
if options.CleanupTerminalAfter > 0 {
|
||||
terminalBeforeMS = now.Add(-options.CleanupTerminalAfter).UnixMilli()
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(ctx)), options.PublishTimeout)
|
||||
activeDeleted, terminalDeleted, err := s.repository.CleanupRobotOutbox(cleanupCtx, activeBeforeMS, terminalBeforeMS, options.CleanupBatchSize)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activeDeleted > 0 || terminalDeleted > 0 {
|
||||
logx.Info(ctx, "robot_outbox_cleanup_deleted",
|
||||
slog.String("worker", robotOutboxWorkerName),
|
||||
slog.String("node_id", s.nodeID),
|
||||
slog.Int64("active_deleted", activeDeleted),
|
||||
slog.Int64("terminal_deleted", terminalDeleted),
|
||||
slog.Int("batch_size", options.CleanupBatchSize),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func staleRobotOutboxRecord(record outbox.Record, ttl time.Duration) bool {
|
||||
if ttl <= 0 || record.CreatedAtMS <= 0 {
|
||||
return false
|
||||
}
|
||||
// 使用当前 UTC 毫秒判断 TTL,和 outbox created_at_ms 的持久化口径保持一致。
|
||||
return time.Now().UTC().UnixMilli()-record.CreatedAtMS >= ttl.Milliseconds()
|
||||
}
|
||||
|
||||
func (s *Service) claimPendingOutbox(ctx context.Context, robotLane bool, options OutboxWorkerOptions) ([]outbox.Record, error) {
|
||||
lockUntilMS := time.Now().UTC().Add(options.PublishTimeout).UnixMilli()
|
||||
workerID := firstNonEmpty(strings.TrimSpace(options.WorkerID), s.nodeID)
|
||||
if robotLane {
|
||||
return s.repository.ClaimPendingRobotOutbox(ctx, s.nodeID, options.BatchSize, lockUntilMS)
|
||||
return s.repository.ClaimPendingRobotOutbox(ctx, workerID, options.BatchSize, lockUntilMS)
|
||||
}
|
||||
return s.repository.ClaimPendingOutbox(ctx, s.nodeID, options.BatchSize, lockUntilMS)
|
||||
return s.repository.ClaimPendingOutbox(ctx, workerID, options.BatchSize, lockUntilMS)
|
||||
}
|
||||
|
||||
func (s *Service) markOutboxDelivered(ctx context.Context, robotLane bool, eventID string) error {
|
||||
@ -306,3 +430,12 @@ func trimOutboxError(value string) string {
|
||||
// V1 只保留错误前缀,避免外部响应过大撑爆日志或 TEXT 字段查询成本。
|
||||
return value[:512]
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -0,0 +1,154 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/room-service/internal/integration"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
roomservice "hyapp/services/room-service/internal/room/service"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
type recordingOutboxPublisher struct {
|
||||
mu sync.Mutex
|
||||
envelopes []*roomeventsv1.EventEnvelope
|
||||
}
|
||||
|
||||
func (p *recordingOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.envelopes = append(p.envelopes, envelope)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *recordingOutboxPublisher) count() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.envelopes)
|
||||
}
|
||||
|
||||
func TestRobotOutboxWorkerDropsStaleRecordsWithoutPublishing(t *testing.T) {
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
repository := mysqltest.NewRepository(t)
|
||||
publisher := &recordingOutboxPublisher{}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-robot-stale-worker",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RobotOutboxPublisher: publisher,
|
||||
}, router.NewMemoryDirectory(), repository, nil, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||
|
||||
stale := robotOutboxRecordForTest(t, "room-robot-stale", time.Now().UTC().Add(-2*time.Minute))
|
||||
if err := repository.SaveRobotOutbox(ctx, []outbox.Record{stale}); err != nil {
|
||||
t.Fatalf("save stale robot outbox failed: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.ProcessPendingRobotOutbox(ctx, roomservice.OutboxWorkerOptions{
|
||||
BatchSize: 10,
|
||||
PublishTimeout: time.Second,
|
||||
StaleDiscardAfter: 60 * time.Second,
|
||||
}); err != nil {
|
||||
t.Fatalf("process stale robot outbox failed: %v", err)
|
||||
}
|
||||
if publisher.count() != 0 {
|
||||
t.Fatalf("stale robot outbox must not publish, got %d publishes", publisher.count())
|
||||
}
|
||||
if _, exists := repository.RobotOutboxRecord(stale.EventID); exists {
|
||||
t.Fatalf("stale robot outbox must be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotOutboxWorkerPublishesFreshRecords(t *testing.T) {
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
repository := mysqltest.NewRepository(t)
|
||||
publisher := &recordingOutboxPublisher{}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-robot-fresh-worker",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RobotOutboxPublisher: publisher,
|
||||
}, router.NewMemoryDirectory(), repository, nil, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||
|
||||
fresh := robotOutboxRecordForTest(t, "room-robot-fresh", time.Now().UTC())
|
||||
if err := repository.SaveRobotOutbox(ctx, []outbox.Record{fresh}); err != nil {
|
||||
t.Fatalf("save fresh robot outbox failed: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.ProcessPendingRobotOutbox(ctx, roomservice.OutboxWorkerOptions{
|
||||
BatchSize: 10,
|
||||
PublishTimeout: time.Second,
|
||||
StaleDiscardAfter: 60 * time.Second,
|
||||
}); err != nil {
|
||||
t.Fatalf("process fresh robot outbox failed: %v", err)
|
||||
}
|
||||
if publisher.count() != 1 {
|
||||
t.Fatalf("fresh robot outbox must publish once, got %d publishes", publisher.count())
|
||||
}
|
||||
record, exists := repository.RobotOutboxRecord(fresh.EventID)
|
||||
if !exists || record.Status != outbox.StatusDelivered {
|
||||
t.Fatalf("fresh robot outbox must be delivered, exists=%v record=%+v", exists, record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupRobotOutboxDeletesExpiredActiveAndTerminalRecords(t *testing.T) {
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := time.Now().UTC()
|
||||
oldCreated := now.Add(-10 * time.Minute)
|
||||
freshCreated := now.Add(-30 * time.Second)
|
||||
oldUpdatedMS := now.Add(-2 * time.Hour).UnixMilli()
|
||||
freshUpdatedMS := now.UnixMilli()
|
||||
expiredLockMS := now.Add(-time.Minute).UnixMilli()
|
||||
futureLockMS := now.Add(time.Hour).UnixMilli()
|
||||
|
||||
oldPending := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated)
|
||||
oldRetryable := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated.Add(time.Millisecond))
|
||||
oldDeliveringExpired := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated.Add(2*time.Millisecond))
|
||||
oldDeliveringLocked := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated.Add(3*time.Millisecond))
|
||||
oldDelivered := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated.Add(4*time.Millisecond))
|
||||
freshPending := robotOutboxRecordForTest(t, "room-robot-cleanup", freshCreated)
|
||||
freshDelivered := robotOutboxRecordForTest(t, "room-robot-cleanup", freshCreated.Add(time.Millisecond))
|
||||
records := []outbox.Record{oldPending, oldRetryable, oldDeliveringExpired, oldDeliveringLocked, oldDelivered, freshPending, freshDelivered}
|
||||
if err := repository.SaveRobotOutbox(ctx, records); err != nil {
|
||||
t.Fatalf("save cleanup robot outbox records failed: %v", err)
|
||||
}
|
||||
repository.SetRobotOutboxStatus(oldRetryable.EventID, outbox.StatusRetryable, oldUpdatedMS, nil)
|
||||
repository.SetRobotOutboxStatus(oldDeliveringExpired.EventID, outbox.StatusDelivering, oldUpdatedMS, &expiredLockMS)
|
||||
repository.SetRobotOutboxStatus(oldDeliveringLocked.EventID, outbox.StatusDelivering, oldUpdatedMS, &futureLockMS)
|
||||
repository.SetRobotOutboxStatus(oldDelivered.EventID, outbox.StatusDelivered, oldUpdatedMS, nil)
|
||||
repository.SetRobotOutboxStatus(freshDelivered.EventID, outbox.StatusDelivered, freshUpdatedMS, nil)
|
||||
|
||||
activeDeleted, terminalDeleted, err := repository.CleanupRobotOutbox(ctx, now.Add(-5*time.Minute).UnixMilli(), now.Add(-time.Hour).UnixMilli(), 50000)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup robot outbox failed: %v", err)
|
||||
}
|
||||
if activeDeleted != 3 || terminalDeleted != 1 {
|
||||
t.Fatalf("cleanup counts mismatch active=%d terminal=%d", activeDeleted, terminalDeleted)
|
||||
}
|
||||
for _, record := range []outbox.Record{oldPending, oldRetryable, oldDeliveringExpired, oldDelivered} {
|
||||
if _, exists := repository.RobotOutboxRecord(record.EventID); exists {
|
||||
t.Fatalf("expired robot outbox %s should be deleted", record.EventID)
|
||||
}
|
||||
}
|
||||
for _, record := range []outbox.Record{oldDeliveringLocked, freshPending, freshDelivered} {
|
||||
if _, exists := repository.RobotOutboxRecord(record.EventID); !exists {
|
||||
t.Fatalf("fresh or locked robot outbox %s should remain", record.EventID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func robotOutboxRecordForTest(t *testing.T, roomID string, occurredAt time.Time) outbox.Record {
|
||||
t.Helper()
|
||||
record, err := outbox.Build(roomID, "RoomHeatChanged", 1, occurredAt, &roomeventsv1.RoomHeatChanged{
|
||||
Delta: 1,
|
||||
CurrentHeat: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build robot outbox record failed: %v", err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
@ -77,6 +77,8 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// robot outbox 只是 App 展示补偿,进入持久化前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
|
||||
robotOutboxRecords := s.sampleRobotOutboxRecords(result.robotOutboxRecords)
|
||||
saveStartedAt := time.Now()
|
||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
||||
Command: CommandRecord{
|
||||
@ -92,7 +94,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
CreatedAtMS: now.UnixMilli(),
|
||||
},
|
||||
OutboxRecords: outboxRecords,
|
||||
RobotOutboxRecords: result.robotOutboxRecords,
|
||||
RobotOutboxRecords: robotOutboxRecords,
|
||||
RoomStatus: result.roomStatus,
|
||||
RoomSeatCount: result.roomSeatCount,
|
||||
RoomPasswordHash: result.roomPasswordHash,
|
||||
@ -107,6 +109,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
saveMutationMS += elapsedMS(saveStartedAt)
|
||||
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 IM/activity,避免外部慢调用占住房间串行执行队列。
|
||||
result.outboxRecords = outboxRecords
|
||||
result.robotOutboxRecords = robotOutboxRecords
|
||||
|
||||
// 持久化成功后再替换内存状态,避免内存提交领先于恢复来源。
|
||||
current.ReplaceWith(nextState)
|
||||
@ -146,6 +149,10 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
}
|
||||
|
||||
result := value.(mutationResult)
|
||||
if result.applied && result.roomRocketProgress != nil && s.roomRocketProgressCoalescer != nil {
|
||||
// 火箭进度合并必须发生在命令日志、快照来源和非火箭 outbox 已提交之后;未提交命令不能泄露展示事件。
|
||||
s.roomRocketProgressCoalescer.enqueue(*result.roomRocketProgress)
|
||||
}
|
||||
|
||||
if (result.applied || result.forceSnapshot) && result.snapshot != nil && (result.forceSnapshot || shouldSnapshot(result.snapshot, s.snapshotEveryN)) {
|
||||
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。
|
||||
|
||||
@ -180,6 +180,28 @@ type RoomListEntry struct {
|
||||
PinnedUntilMS int64
|
||||
}
|
||||
|
||||
// RoomListPinEntry 是公共发现页置顶读模型的最小事实。
|
||||
type RoomListPinEntry struct {
|
||||
// ID 是 room_region_pins 的自增主键,缓存增量更新和删除都用它定位旧 zset member。
|
||||
ID int64
|
||||
// AppCode 限定置顶所属 App,避免多 App 共用 Redis key 时互相污染。
|
||||
AppCode string
|
||||
// VisibleRegionID 是置顶作用域;global 统一写 0,region/country 使用房间区域。
|
||||
VisibleRegionID int64
|
||||
// PinType 区分 global、region、country 三种公共列表置顶语义。
|
||||
PinType string
|
||||
// RoomID 是置顶命中的房间卡片。
|
||||
RoomID string
|
||||
// Weight 是置顶同桶排序的第一关键字,值越大越靠前。
|
||||
Weight int64
|
||||
// Status 保留 owner 表状态;非 active 必须从缓存索引移除。
|
||||
Status string
|
||||
// PinnedAtMS 是置顶生效开始时间,未来置顶可以先写缓存但读时不能提前展示。
|
||||
PinnedAtMS int64
|
||||
// ExpiresAtMS 是置顶失效时间,读缓存时必须按当前 UTC 毫秒过滤。
|
||||
ExpiresAtMS int64
|
||||
}
|
||||
|
||||
type AdminRoomListEntry struct {
|
||||
RoomID string
|
||||
RoomShortID string
|
||||
@ -217,6 +239,7 @@ type AdminRoomListQuery struct {
|
||||
}
|
||||
|
||||
type AdminRoomPinEntry struct {
|
||||
AppCode string
|
||||
ID int64
|
||||
VisibleRegionID int64
|
||||
PinType string
|
||||
@ -501,6 +524,25 @@ type RoomListQuery struct {
|
||||
NowMS int64
|
||||
}
|
||||
|
||||
// RoomListCacheEntryScanQuery 是缓存全量刷新时按主键批量扫描 MySQL 列表投影的游标。
|
||||
type RoomListCacheEntryScanQuery struct {
|
||||
// AfterAppCode 和 AfterRoomID 组成严格递增游标,避免 OFFSET 在大表上反复扫描。
|
||||
AfterAppCode string
|
||||
AfterRoomID string
|
||||
// Limit 是单批扫描数量,由 worker 固定小批量推进。
|
||||
Limit int
|
||||
}
|
||||
|
||||
// RoomListPinScanQuery 是缓存全量刷新时扫描有效置顶事实的游标。
|
||||
type RoomListPinScanQuery struct {
|
||||
// AfterID 是 room_region_pins 自增主键游标。
|
||||
AfterID int64
|
||||
// Limit 是单批扫描数量。
|
||||
Limit int
|
||||
// NowMS 用于排除已过期置顶;未来置顶保留,读缓存时按 pinned_at_ms 再判断。
|
||||
NowMS int64
|
||||
}
|
||||
|
||||
// RoomListEntriesByIDQuery 是榜单等定向补全房间卡片时的批量读取条件。
|
||||
type RoomListEntriesByIDQuery struct {
|
||||
// AppCode 限定 App 边界,避免一个榜单读到其他 App 的同名 room_id。
|
||||
@ -626,6 +668,23 @@ type Repository interface {
|
||||
RobotRoomStore
|
||||
}
|
||||
|
||||
// RoomListCacheStore 是公共发现页 Redis 展示读模型边界。
|
||||
//
|
||||
// MySQL 仍然是房间列表和置顶事实 owner;缓存只服务高频 hot/new 发现页,
|
||||
// 因此所有写入都必须 best-effort,任何错误都回落到 MySQL 查询。
|
||||
type RoomListCacheStore interface {
|
||||
// ListRoomListEntries 尝试从缓存返回一页公共列表;ok=false 表示缓存未就绪或扫描保护触发,调用方应走 MySQL。
|
||||
ListRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, bool, error)
|
||||
// UpsertRoomListEntry 根据最新列表投影刷新缓存;非 active 房间必须从所有展示 zset 移除。
|
||||
UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error
|
||||
// DeleteRoomListEntry 从缓存删除指定房间的卡片和所有展示索引。
|
||||
DeleteRoomListEntry(ctx context.Context, appCode string, roomID string) error
|
||||
// UpsertRoomPin 根据后台置顶事实刷新 pin 索引;非 active 或已过期置顶由实现移除。
|
||||
UpsertRoomPin(ctx context.Context, pin RoomListPinEntry) error
|
||||
// RebuildRoomList 用 MySQL 扫描结果重建某个 App 的缓存,并在成功后标记 ready。
|
||||
RebuildRoomList(ctx context.Context, appCode string, entries []RoomListEntry, pins []RoomListPinEntry, nowMS int64) error
|
||||
}
|
||||
|
||||
// RoomMetaStore 保存房间基础元数据和房主背景素材;它是 Room Cell 恢复前的轻量入口。
|
||||
type RoomMetaStore interface {
|
||||
// SaveRoomMeta 保存房间基础元数据,创建房间时必须先建立它。
|
||||
@ -678,6 +737,8 @@ type RoomConfigStore interface {
|
||||
type OutboxStore interface {
|
||||
// SaveOutbox 写入房间外事件,必须和成功命令保持同一提交语义。
|
||||
SaveOutbox(ctx context.Context, records []outbox.Record) error
|
||||
// SaveRobotOutbox 写入机器人房间展示事件;火箭进度合并器 flush 后仍按 lane 回到独立 robot outbox。
|
||||
SaveRobotOutbox(ctx context.Context, records []outbox.Record) error
|
||||
// ListPendingOutbox 扫描待补偿投递事件。
|
||||
ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error)
|
||||
// ListPendingRobotOutbox 扫描机器人房间待补偿展示事件。
|
||||
@ -698,6 +759,10 @@ type OutboxStore interface {
|
||||
MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error
|
||||
// MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。
|
||||
MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error
|
||||
// DeleteRobotOutbox 直接删除机器人展示事件;过期展示没有补偿价值,不能再占用 MQ/IM 通道。
|
||||
DeleteRobotOutbox(ctx context.Context, eventID string) error
|
||||
// CleanupRobotOutbox 批量删除过期机器人展示事件;非终态和终态分开计数,便于上线后验证清理效果。
|
||||
CleanupRobotOutbox(ctx context.Context, activeBeforeMS int64, terminalBeforeMS int64, limit int) (activeDeleted int64, terminalDeleted int64, err error)
|
||||
}
|
||||
|
||||
// RoomListStore 管理发现页、Mine 页和关系房间流读模型;它不读取 Room Cell 内存。
|
||||
@ -722,6 +787,10 @@ type RoomListStore interface {
|
||||
ListRoomFollowEntries(ctx context.Context, query RoomFollowQuery) ([]RoomListEntry, error)
|
||||
// ListRoomRelatedFeedEntries 根据 user-service 关系事实查询 active 房间卡片,不持久化关系状态。
|
||||
ListRoomRelatedFeedEntries(ctx context.Context, query RoomRelatedFeedQuery) ([]RoomListEntry, error)
|
||||
// ListRoomListCacheEntries 按主键小批量扫描 active 列表投影,只用于 Redis 展示读模型重建。
|
||||
ListRoomListCacheEntries(ctx context.Context, query RoomListCacheEntryScanQuery) ([]RoomListEntry, error)
|
||||
// ListRoomListCachePins 按主键小批量扫描 active 置顶事实,只用于 Redis 展示读模型重建。
|
||||
ListRoomListCachePins(ctx context.Context, query RoomListPinScanQuery) ([]RoomListPinEntry, error)
|
||||
}
|
||||
|
||||
// PresenceStore 管理当前房间 presence 投影;权威状态仍然来自 Room Cell 快照。
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
)
|
||||
|
||||
const (
|
||||
// robotOutboxSampleWindow 是 v1 展示降采样窗口:同房间同事件类型 2 秒内只保留一条 robot outbox。
|
||||
robotOutboxSampleWindow = 2 * time.Second
|
||||
)
|
||||
|
||||
type robotOutboxSampleKey struct {
|
||||
appCode string
|
||||
roomID string
|
||||
eventType string
|
||||
}
|
||||
|
||||
type robotOutboxSampler struct {
|
||||
mu sync.Mutex
|
||||
lastWindow map[robotOutboxSampleKey]int64
|
||||
}
|
||||
|
||||
func newRobotOutboxSampler() *robotOutboxSampler {
|
||||
return &robotOutboxSampler{lastWindow: make(map[robotOutboxSampleKey]int64)}
|
||||
}
|
||||
|
||||
func (s *Service) sampleRobotOutboxRecords(records []outbox.Record) []outbox.Record {
|
||||
if len(records) == 0 || s == nil || s.robotOutboxSampler == nil {
|
||||
return records
|
||||
}
|
||||
return s.robotOutboxSampler.filter(records, robotOutboxSampleWindow)
|
||||
}
|
||||
|
||||
func (s *robotOutboxSampler) filter(records []outbox.Record, window time.Duration) []outbox.Record {
|
||||
if s == nil || window <= 0 || len(records) == 0 {
|
||||
return records
|
||||
}
|
||||
|
||||
windowMS := window.Milliseconds()
|
||||
out := make([]outbox.Record, 0, len(records))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var newestWindow int64
|
||||
for _, record := range records {
|
||||
key := robotOutboxSampleKey{
|
||||
appCode: appcode.Normalize(record.AppCode),
|
||||
roomID: record.RoomID,
|
||||
eventType: record.EventType,
|
||||
}
|
||||
createdAtMS := record.CreatedAtMS
|
||||
if createdAtMS <= 0 && record.Envelope != nil {
|
||||
createdAtMS = record.Envelope.GetOccurredAtMs()
|
||||
}
|
||||
windowStartMS := createdAtMS / windowMS * windowMS
|
||||
if windowStartMS > newestWindow {
|
||||
newestWindow = windowStartMS
|
||||
}
|
||||
if last, exists := s.lastWindow[key]; exists && windowStartMS <= last {
|
||||
// 展示事件过密时只丢 robot outbox 记录;Room Cell 命令、房间热度、榜单和火箭状态已经在进入这里前完成计算。
|
||||
continue
|
||||
}
|
||||
s.lastWindow[key] = windowStartMS
|
||||
out = append(out, record)
|
||||
}
|
||||
|
||||
if newestWindow > 0 && len(s.lastWindow) > 1024 {
|
||||
// 机器人房数量和事件类型有限,超过阈值说明进程运行很久;保留最近几个窗口即可避免 map 常驻增长。
|
||||
s.pruneLocked(newestWindow - 4*windowMS)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *robotOutboxSampler) pruneLocked(beforeWindowMS int64) {
|
||||
for key, windowStartMS := range s.lastWindow {
|
||||
if windowStartMS < beforeWindowMS {
|
||||
delete(s.lastWindow, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
740
services/room-service/internal/room/service/room_list_cache.go
Normal file
740
services/room-service/internal/room/service/room_list_cache.go
Normal file
@ -0,0 +1,740 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"hyapp/pkg/appcode"
|
||||
)
|
||||
|
||||
const (
|
||||
roomListCacheReadyValue = "1"
|
||||
roomListCacheCountryAll = "_"
|
||||
roomListCacheBucketOnline = "online"
|
||||
roomListCacheBucketEmpty = "empty"
|
||||
roomListCacheDefaultScanSize = 128
|
||||
roomListCacheMaxScanSize = 1024
|
||||
roomListCacheMaxInt64 = int64(^uint64(0) >> 1)
|
||||
)
|
||||
|
||||
// RedisRoomListCacheStore 用 Redis zset/hash 承载发现页 hot/new 展示读模型。
|
||||
//
|
||||
// 该实现只保存可重建的列表投影,不参与 Room Cell 权威状态、command log 或 MySQL owner 表语义。
|
||||
// 一旦缓存未 ready、扫描保护触发或 Redis 报错,Service 会回退到 MySQL 复杂 SQL,保证功能正确性优先。
|
||||
type RedisRoomListCacheStore struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
type roomListCacheMemberRef struct {
|
||||
Key string `json:"key"`
|
||||
Member string `json:"member"`
|
||||
}
|
||||
|
||||
type roomListCachePinRow struct {
|
||||
Pin RoomListPinEntry
|
||||
Member string
|
||||
}
|
||||
|
||||
type roomListCacheCandidate struct {
|
||||
Entry RoomListEntry
|
||||
SortValue int64
|
||||
}
|
||||
|
||||
// NewRedisRoomListCacheStore 创建公共发现页 Redis 展示读模型。
|
||||
func NewRedisRoomListCacheStore(client *redis.Client) *RedisRoomListCacheStore {
|
||||
return &RedisRoomListCacheStore{client: client}
|
||||
}
|
||||
|
||||
// ListRoomListEntries 从 Redis 读取一页公共发现列表;ok=false 表示调用方必须回退 MySQL。
|
||||
func (s *RedisRoomListCacheStore) ListRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, bool, error) {
|
||||
if s == nil || s.client == nil || !roomListCacheEligible(query) {
|
||||
return nil, false, nil
|
||||
}
|
||||
query.AppCode = appcode.Normalize(query.AppCode)
|
||||
if query.AppCode == "" {
|
||||
query.AppCode = appcode.Default
|
||||
}
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = defaultRoomListLimit
|
||||
}
|
||||
if query.NowMS <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
ready, err := s.client.Exists(ctx, roomListCacheReadyKey(query.AppCode)).Result()
|
||||
if err != nil || ready == 0 {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
scanSize := roomListCacheScanSize(query.Limit)
|
||||
candidates := make(map[string]roomListCacheCandidate, scanSize)
|
||||
truncated := false
|
||||
|
||||
pinRows, pinTruncated, err := s.listRoomListCachePins(ctx, query, scanSize)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
truncated = truncated || pinTruncated
|
||||
pinRoomIDs := make([]string, 0, len(pinRows))
|
||||
for _, row := range pinRows {
|
||||
pinRoomIDs = append(pinRoomIDs, row.Pin.RoomID)
|
||||
}
|
||||
pinCards, err := s.roomListCacheCards(ctx, query.AppCode, pinRoomIDs)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
for _, row := range pinRows {
|
||||
card, ok := pinCards[row.Pin.RoomID]
|
||||
if !ok || !roomListCacheCardMatches(query, card) || !roomListCachePinApplies(query, row.Pin, card) {
|
||||
continue
|
||||
}
|
||||
rank := int64(0)
|
||||
if row.Pin.PinType == roomPinTypeCountry {
|
||||
rank = 1
|
||||
}
|
||||
card.IsPinned = true
|
||||
card.PinListRank = rank
|
||||
card.PinWeight = row.Pin.Weight
|
||||
card.PinnedUntilMS = row.Pin.ExpiresAtMS
|
||||
roomListCacheKeepBest(candidates, roomListCacheCandidate{Entry: card, SortValue: roomListCacheSortValue(query.Tab, card)})
|
||||
}
|
||||
|
||||
normalIDs, normalTruncated, err := s.listRoomListCacheNormalRoomIDs(ctx, query, scanSize)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
truncated = truncated || normalTruncated
|
||||
normalCards, err := s.roomListCacheCards(ctx, query.AppCode, normalIDs)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
for _, roomID := range normalIDs {
|
||||
card, ok := normalCards[roomID]
|
||||
if !ok || !roomListCacheCardMatches(query, card) {
|
||||
continue
|
||||
}
|
||||
card.IsPinned = false
|
||||
card.PinListRank = roomListCacheNormalRank(query, card)
|
||||
card.PinWeight = 0
|
||||
card.PinnedUntilMS = 0
|
||||
roomListCacheKeepBest(candidates, roomListCacheCandidate{Entry: card, SortValue: roomListCacheSortValue(query.Tab, card)})
|
||||
}
|
||||
|
||||
ordered := make([]roomListCacheCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
ordered = append(ordered, candidate)
|
||||
}
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return roomListCacheCandidateBefore(ordered[i], ordered[j])
|
||||
})
|
||||
|
||||
out := make([]RoomListEntry, 0, query.Limit)
|
||||
for _, candidate := range ordered {
|
||||
if !roomListCacheAfterCursor(query, candidate.Entry, candidate.SortValue) {
|
||||
continue
|
||||
}
|
||||
out = append(out, candidate.Entry)
|
||||
if len(out) >= query.Limit {
|
||||
return out, true, nil
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
// 当前 Redis 查询只扫描每个桶的前 N 个 member;深分页或极端同桶数据量交给 MySQL 保证正确性。
|
||||
return nil, false, nil
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// UpsertRoomListEntry 刷新单个房间卡片和它所在的 hot/new zset 桶。
|
||||
func (s *RedisRoomListCacheStore) UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error {
|
||||
if s == nil || s.client == nil {
|
||||
return nil
|
||||
}
|
||||
app := appcode.Normalize(entry.AppCode)
|
||||
if app == "" {
|
||||
app = appcode.Default
|
||||
}
|
||||
roomID := strings.TrimSpace(entry.RoomID)
|
||||
if roomID == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(entry.Status) != "active" {
|
||||
return s.DeleteRoomListEntry(ctx, app, roomID)
|
||||
}
|
||||
|
||||
if err := s.removeRoomListEntryIndexes(ctx, app, roomID); err != nil {
|
||||
return err
|
||||
}
|
||||
entry.AppCode = app
|
||||
entry.OwnerCountryCode = normalizeRoomCountryCode(entry.OwnerCountryCode)
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refs := roomListCacheEntryRefs(app, entry)
|
||||
rawRefs, err := json.Marshal(refs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pipe := s.client.Pipeline()
|
||||
pipe.HSet(ctx, roomListCacheCardKey(app), roomID, payload)
|
||||
pipe.HSet(ctx, roomListCacheEntryIndexKey(app), roomID, rawRefs)
|
||||
for _, ref := range refs {
|
||||
pipe.SAdd(ctx, roomListCacheKnownKeysKey(app), ref.Key)
|
||||
pipe.ZAdd(ctx, ref.Key, redis.Z{Score: roomListCacheEntryScore(ref.Key, entry), Member: ref.Member})
|
||||
}
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRoomListEntry 删除单个房间缓存投影。
|
||||
func (s *RedisRoomListCacheStore) DeleteRoomListEntry(ctx context.Context, appCode string, roomID string) error {
|
||||
if s == nil || s.client == nil {
|
||||
return nil
|
||||
}
|
||||
app := appcode.Normalize(appCode)
|
||||
if app == "" {
|
||||
app = appcode.Default
|
||||
}
|
||||
roomID = strings.TrimSpace(roomID)
|
||||
if roomID == "" {
|
||||
return nil
|
||||
}
|
||||
if err := s.removeRoomListEntryIndexes(ctx, app, roomID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.client.Pipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.HDel(ctx, roomListCacheCardKey(app), roomID)
|
||||
pipe.HDel(ctx, roomListCacheEntryIndexKey(app), roomID)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertRoomPin 刷新单条置顶缓存;非 active、已过期或字段无效时移除旧索引。
|
||||
func (s *RedisRoomListCacheStore) UpsertRoomPin(ctx context.Context, pin RoomListPinEntry) error {
|
||||
if s == nil || s.client == nil {
|
||||
return nil
|
||||
}
|
||||
app := appcode.Normalize(pin.AppCode)
|
||||
if app == "" {
|
||||
app = appcode.Default
|
||||
}
|
||||
pin.AppCode = app
|
||||
if pin.ID <= 0 || strings.TrimSpace(pin.RoomID) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := s.removeRoomListPinIndexes(ctx, app, pin.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
pin.PinType = roomListCacheNormalizePinType(pin.PinType)
|
||||
if strings.TrimSpace(pin.Status) != "active" || pin.PinType == "" || pin.ExpiresAtMS <= pin.PinnedAtMS {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ref := roomListCachePinRef(app, pin)
|
||||
rawRefs, err := json.Marshal([]roomListCacheMemberRef{ref})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pipe := s.client.Pipeline()
|
||||
pipe.SAdd(ctx, roomListCacheKnownKeysKey(app), ref.Key)
|
||||
pipe.HSet(ctx, roomListCachePinPayloadKey(app), strconv.FormatInt(pin.ID, 10), payload)
|
||||
pipe.HSet(ctx, roomListCachePinIndexKey(app), strconv.FormatInt(pin.ID, 10), rawRefs)
|
||||
pipe.ZAdd(ctx, ref.Key, redis.Z{Score: -float64(pin.Weight), Member: ref.Member})
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// RebuildRoomList 清空某个 App 的旧缓存后,用 MySQL 扫描结果重建并标记 ready。
|
||||
func (s *RedisRoomListCacheStore) RebuildRoomList(ctx context.Context, appCode string, entries []RoomListEntry, pins []RoomListPinEntry, _ int64) error {
|
||||
if s == nil || s.client == nil {
|
||||
return nil
|
||||
}
|
||||
app := appcode.Normalize(appCode)
|
||||
if app == "" {
|
||||
app = appcode.Default
|
||||
}
|
||||
knownKeys, err := s.client.SMembers(ctx, roomListCacheKnownKeysKey(app)).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteKeys := append([]string{}, knownKeys...)
|
||||
deleteKeys = append(deleteKeys,
|
||||
roomListCacheCardKey(app),
|
||||
roomListCacheEntryIndexKey(app),
|
||||
roomListCachePinPayloadKey(app),
|
||||
roomListCachePinIndexKey(app),
|
||||
roomListCacheReadyKey(app),
|
||||
roomListCacheKnownKeysKey(app),
|
||||
)
|
||||
if len(deleteKeys) > 0 {
|
||||
if err := s.client.Del(ctx, deleteKeys...).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.AppCode == app {
|
||||
if err := s.UpsertRoomListEntry(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pin := range pins {
|
||||
if appcode.Normalize(pin.AppCode) == app {
|
||||
if err := s.UpsertRoomPin(ctx, pin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.client.Set(ctx, roomListCacheReadyKey(app), roomListCacheReadyValue, 0).Err()
|
||||
}
|
||||
|
||||
func (s *RedisRoomListCacheStore) removeRoomListEntryIndexes(ctx context.Context, app string, roomID string) error {
|
||||
raw, err := s.client.HGet(ctx, roomListCacheEntryIndexKey(app), roomID).Result()
|
||||
if err == redis.Nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var refs []roomListCacheMemberRef
|
||||
if err := json.Unmarshal([]byte(raw), &refs); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
pipe := s.client.Pipeline()
|
||||
for _, ref := range refs {
|
||||
if strings.TrimSpace(ref.Key) != "" && strings.TrimSpace(ref.Member) != "" {
|
||||
pipe.ZRem(ctx, ref.Key, ref.Member)
|
||||
}
|
||||
}
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *RedisRoomListCacheStore) removeRoomListPinIndexes(ctx context.Context, app string, pinID int64) error {
|
||||
field := strconv.FormatInt(pinID, 10)
|
||||
raw, err := s.client.HGet(ctx, roomListCachePinIndexKey(app), field).Result()
|
||||
if err == redis.Nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var refs []roomListCacheMemberRef
|
||||
if err := json.Unmarshal([]byte(raw), &refs); err != nil {
|
||||
return err
|
||||
}
|
||||
pipe := s.client.Pipeline()
|
||||
for _, ref := range refs {
|
||||
if strings.TrimSpace(ref.Key) != "" && strings.TrimSpace(ref.Member) != "" {
|
||||
pipe.ZRem(ctx, ref.Key, ref.Member)
|
||||
}
|
||||
}
|
||||
pipe.HDel(ctx, roomListCachePinIndexKey(app), field)
|
||||
pipe.HDel(ctx, roomListCachePinPayloadKey(app), field)
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *RedisRoomListCacheStore) listRoomListCachePins(ctx context.Context, query RoomListQuery, scanSize int) ([]roomListCachePinRow, bool, error) {
|
||||
keys := []string{
|
||||
roomListCachePinKey(query.AppCode, roomPinTypeGlobal, 0),
|
||||
roomListCachePinKey(query.AppCode, roomPinTypeRegion, normalizeVisibleRegionID(query.VisibleRegionID)),
|
||||
}
|
||||
if roomListCacheSortCountry(query) != "" {
|
||||
keys = append(keys, roomListCachePinKey(query.AppCode, roomPinTypeCountry, normalizeVisibleRegionID(query.VisibleRegionID)))
|
||||
}
|
||||
|
||||
pinIDs := make([]string, 0, len(keys)*scanSize)
|
||||
membersByID := make(map[string]string, len(keys)*scanSize)
|
||||
truncated := false
|
||||
for _, key := range keys {
|
||||
rows, err := s.client.ZRangeWithScores(ctx, key, 0, int64(scanSize-1)).Result()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
total, err := s.client.ZCard(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
truncated = truncated || total > int64(len(rows))
|
||||
for _, row := range rows {
|
||||
member, ok := row.Member.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pinID := roomListCachePinIDFromMember(member)
|
||||
if pinID == "" {
|
||||
continue
|
||||
}
|
||||
pinIDs = append(pinIDs, pinID)
|
||||
membersByID[pinID] = member
|
||||
}
|
||||
}
|
||||
if len(pinIDs) == 0 {
|
||||
return nil, truncated, nil
|
||||
}
|
||||
rawPins, err := s.client.HMGet(ctx, roomListCachePinPayloadKey(query.AppCode), pinIDs...).Result()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out := make([]roomListCachePinRow, 0, len(rawPins))
|
||||
for index, raw := range rawPins {
|
||||
text, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
var pin RoomListPinEntry
|
||||
if err := json.Unmarshal([]byte(text), &pin); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if pin.Status != "active" || pin.PinnedAtMS > query.NowMS || pin.ExpiresAtMS <= query.NowMS {
|
||||
continue
|
||||
}
|
||||
pinID := pinIDs[index]
|
||||
out = append(out, roomListCachePinRow{Pin: pin, Member: membersByID[pinID]})
|
||||
}
|
||||
return out, truncated, nil
|
||||
}
|
||||
|
||||
func (s *RedisRoomListCacheStore) listRoomListCacheNormalRoomIDs(ctx context.Context, query RoomListQuery, scanSize int) ([]string, bool, error) {
|
||||
keys := roomListCacheNormalKeys(query)
|
||||
seen := make(map[string]struct{}, len(keys)*scanSize)
|
||||
out := make([]string, 0, len(keys)*scanSize)
|
||||
truncated := false
|
||||
for _, key := range keys {
|
||||
rows, err := s.client.ZRange(ctx, key, 0, int64(scanSize-1)).Result()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
total, err := s.client.ZCard(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
truncated = truncated || total > int64(len(rows))
|
||||
for _, roomID := range rows {
|
||||
roomID = strings.TrimSpace(roomID)
|
||||
if roomID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[roomID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[roomID] = struct{}{}
|
||||
out = append(out, roomID)
|
||||
}
|
||||
}
|
||||
return out, truncated, nil
|
||||
}
|
||||
|
||||
func (s *RedisRoomListCacheStore) roomListCacheCards(ctx context.Context, app string, roomIDs []string) (map[string]RoomListEntry, error) {
|
||||
roomIDs = uniqueRoomListCacheIDs(roomIDs)
|
||||
cards := make(map[string]RoomListEntry, len(roomIDs))
|
||||
if len(roomIDs) == 0 {
|
||||
return cards, nil
|
||||
}
|
||||
fields := make([]string, 0, len(roomIDs))
|
||||
for _, roomID := range roomIDs {
|
||||
fields = append(fields, roomID)
|
||||
}
|
||||
rawCards, err := s.client.HMGet(ctx, roomListCacheCardKey(app), fields...).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index, raw := range rawCards {
|
||||
text, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
var entry RoomListEntry
|
||||
if err := json.Unmarshal([]byte(text), &entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards[fields[index]] = entry
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func uniqueRoomListCacheIDs(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomListCacheKeepBest(candidates map[string]roomListCacheCandidate, candidate roomListCacheCandidate) {
|
||||
current, exists := candidates[candidate.Entry.RoomID]
|
||||
if !exists || roomListCacheCandidateBefore(candidate, current) {
|
||||
candidates[candidate.Entry.RoomID] = candidate
|
||||
}
|
||||
}
|
||||
|
||||
func roomListCacheCandidateBefore(left roomListCacheCandidate, right roomListCacheCandidate) bool {
|
||||
if left.Entry.PinListRank != right.Entry.PinListRank {
|
||||
return left.Entry.PinListRank < right.Entry.PinListRank
|
||||
}
|
||||
if left.Entry.PinWeight != right.Entry.PinWeight {
|
||||
return left.Entry.PinWeight > right.Entry.PinWeight
|
||||
}
|
||||
if left.Entry.PinnedUntilMS != right.Entry.PinnedUntilMS {
|
||||
return left.Entry.PinnedUntilMS > right.Entry.PinnedUntilMS
|
||||
}
|
||||
if left.SortValue != right.SortValue {
|
||||
return left.SortValue > right.SortValue
|
||||
}
|
||||
return left.Entry.RoomID < right.Entry.RoomID
|
||||
}
|
||||
|
||||
func roomListCacheAfterCursor(query RoomListQuery, entry RoomListEntry, sortValue int64) bool {
|
||||
if strings.TrimSpace(query.CursorRoomID) == "" {
|
||||
return true
|
||||
}
|
||||
if entry.PinListRank != query.CursorPinListRank {
|
||||
return entry.PinListRank > query.CursorPinListRank
|
||||
}
|
||||
if entry.PinWeight != query.CursorPinWeight {
|
||||
return entry.PinWeight < query.CursorPinWeight
|
||||
}
|
||||
if entry.PinnedUntilMS != query.CursorPinnedUntilMS {
|
||||
return entry.PinnedUntilMS < query.CursorPinnedUntilMS
|
||||
}
|
||||
cursorSort := query.CursorSortScore
|
||||
if query.Tab == roomListTabNew {
|
||||
cursorSort = query.CursorCreatedAtMS
|
||||
}
|
||||
if sortValue != cursorSort {
|
||||
return sortValue < cursorSort
|
||||
}
|
||||
return entry.RoomID > query.CursorRoomID
|
||||
}
|
||||
|
||||
func roomListCacheCardMatches(query RoomListQuery, entry RoomListEntry) bool {
|
||||
if appcode.Normalize(entry.AppCode) != appcode.Normalize(query.AppCode) || entry.Status != "active" {
|
||||
return false
|
||||
}
|
||||
if normalizeVisibleRegionID(entry.VisibleRegionID) != normalizeVisibleRegionID(query.VisibleRegionID) {
|
||||
return false
|
||||
}
|
||||
if country := normalizeRoomCountryCode(query.CountryCode); country != "" && normalizeRoomCountryCode(entry.OwnerCountryCode) != country {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func roomListCachePinApplies(query RoomListQuery, pin RoomListPinEntry, entry RoomListEntry) bool {
|
||||
switch pin.PinType {
|
||||
case roomPinTypeGlobal:
|
||||
return pin.VisibleRegionID == 0
|
||||
case roomPinTypeRegion:
|
||||
return normalizeVisibleRegionID(pin.VisibleRegionID) == normalizeVisibleRegionID(query.VisibleRegionID)
|
||||
case roomPinTypeCountry:
|
||||
return normalizeVisibleRegionID(pin.VisibleRegionID) == normalizeVisibleRegionID(query.VisibleRegionID) && normalizeRoomCountryCode(entry.OwnerCountryCode) == roomListCacheSortCountry(query)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func roomListCacheNormalRank(query RoomListQuery, entry RoomListEntry) int64 {
|
||||
sortCountry := roomListCacheSortCountry(query)
|
||||
if sortCountry == "" {
|
||||
if entry.OnlineCount > 0 {
|
||||
return 2
|
||||
}
|
||||
return 4
|
||||
}
|
||||
entryCountry := normalizeRoomCountryCode(entry.OwnerCountryCode)
|
||||
if entryCountry == sortCountry && entry.OnlineCount > 0 {
|
||||
return 2
|
||||
}
|
||||
if entryCountry != sortCountry && entry.OnlineCount > 0 {
|
||||
return 3
|
||||
}
|
||||
if entryCountry == sortCountry {
|
||||
return 4
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
func roomListCacheSortCountry(query RoomListQuery) string {
|
||||
if country := normalizeRoomCountryCode(query.CountryCode); country != "" {
|
||||
return country
|
||||
}
|
||||
return normalizeRoomCountryCode(query.ViewerCountryCode)
|
||||
}
|
||||
|
||||
func roomListCacheSortValue(tab string, entry RoomListEntry) int64 {
|
||||
if tab == roomListTabNew {
|
||||
return entry.CreatedAtMS
|
||||
}
|
||||
return entry.SortScore
|
||||
}
|
||||
|
||||
func roomListCacheScanSize(limit int) int {
|
||||
if limit <= 0 {
|
||||
return roomListCacheDefaultScanSize
|
||||
}
|
||||
scanSize := limit * 10
|
||||
if scanSize < roomListCacheDefaultScanSize {
|
||||
scanSize = roomListCacheDefaultScanSize
|
||||
}
|
||||
if scanSize > roomListCacheMaxScanSize {
|
||||
scanSize = roomListCacheMaxScanSize
|
||||
}
|
||||
return scanSize
|
||||
}
|
||||
|
||||
func roomListCacheEntryRefs(app string, entry RoomListEntry) []roomListCacheMemberRef {
|
||||
bucket := roomListCacheBucketEmpty
|
||||
if entry.OnlineCount > 0 {
|
||||
bucket = roomListCacheBucketOnline
|
||||
}
|
||||
refs := make([]roomListCacheMemberRef, 0, 4)
|
||||
for _, tab := range []string{roomListTabHot, roomListTabNew} {
|
||||
refs = append(refs, roomListCacheMemberRef{
|
||||
Key: roomListCacheBaseKey(app, tab, entry.VisibleRegionID, roomListCacheCountryAll, bucket),
|
||||
Member: entry.RoomID,
|
||||
})
|
||||
if country := normalizeRoomCountryCode(entry.OwnerCountryCode); country != "" {
|
||||
refs = append(refs, roomListCacheMemberRef{
|
||||
Key: roomListCacheBaseKey(app, tab, entry.VisibleRegionID, country, bucket),
|
||||
Member: entry.RoomID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func roomListCacheEntryScore(key string, entry RoomListEntry) float64 {
|
||||
if strings.Contains(key, ":new:") {
|
||||
return -float64(entry.CreatedAtMS)
|
||||
}
|
||||
return -float64(entry.SortScore)
|
||||
}
|
||||
|
||||
func roomListCacheNormalKeys(query RoomListQuery) []string {
|
||||
app := appcode.Normalize(query.AppCode)
|
||||
regionID := normalizeVisibleRegionID(query.VisibleRegionID)
|
||||
sortCountry := roomListCacheSortCountry(query)
|
||||
if filterCountry := normalizeRoomCountryCode(query.CountryCode); filterCountry != "" {
|
||||
return []string{
|
||||
roomListCacheBaseKey(app, query.Tab, regionID, filterCountry, roomListCacheBucketOnline),
|
||||
roomListCacheBaseKey(app, query.Tab, regionID, filterCountry, roomListCacheBucketEmpty),
|
||||
}
|
||||
}
|
||||
if sortCountry != "" {
|
||||
return []string{
|
||||
roomListCacheBaseKey(app, query.Tab, regionID, sortCountry, roomListCacheBucketOnline),
|
||||
roomListCacheBaseKey(app, query.Tab, regionID, roomListCacheCountryAll, roomListCacheBucketOnline),
|
||||
roomListCacheBaseKey(app, query.Tab, regionID, sortCountry, roomListCacheBucketEmpty),
|
||||
roomListCacheBaseKey(app, query.Tab, regionID, roomListCacheCountryAll, roomListCacheBucketEmpty),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
roomListCacheBaseKey(app, query.Tab, regionID, roomListCacheCountryAll, roomListCacheBucketOnline),
|
||||
roomListCacheBaseKey(app, query.Tab, regionID, roomListCacheCountryAll, roomListCacheBucketEmpty),
|
||||
}
|
||||
}
|
||||
|
||||
func roomListCachePinRef(app string, pin RoomListPinEntry) roomListCacheMemberRef {
|
||||
return roomListCacheMemberRef{
|
||||
Key: roomListCachePinKey(app, pin.PinType, pin.VisibleRegionID),
|
||||
Member: roomListCachePinMember(pin),
|
||||
}
|
||||
}
|
||||
|
||||
func roomListCachePinMember(pin RoomListPinEntry) string {
|
||||
// zset score 只表达 weight desc;member 前缀把 expires_at_ms desc 固定成 lex asc,便于 Redis 先取高价值候选。
|
||||
invertedExpires := roomListCacheMaxInt64 - pin.ExpiresAtMS
|
||||
return fmt.Sprintf("%019d|%020d|%s", invertedExpires, pin.ID, pin.RoomID)
|
||||
}
|
||||
|
||||
func roomListCachePinIDFromMember(member string) string {
|
||||
parts := strings.SplitN(member, "|", 3)
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
id, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
func roomListCacheNormalizePinType(pinType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(pinType)) {
|
||||
case roomPinTypeGlobal:
|
||||
return roomPinTypeGlobal
|
||||
case roomPinTypeRegion:
|
||||
return roomPinTypeRegion
|
||||
case roomPinTypeCountry:
|
||||
return roomPinTypeCountry
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func roomListCachePrefix(app string) string {
|
||||
return "room:list_cache:" + appcode.Normalize(app)
|
||||
}
|
||||
|
||||
func roomListCacheReadyKey(app string) string {
|
||||
return roomListCachePrefix(app) + ":ready"
|
||||
}
|
||||
|
||||
func roomListCacheKnownKeysKey(app string) string {
|
||||
return roomListCachePrefix(app) + ":known_keys"
|
||||
}
|
||||
|
||||
func roomListCacheCardKey(app string) string {
|
||||
return roomListCachePrefix(app) + ":cards"
|
||||
}
|
||||
|
||||
func roomListCacheEntryIndexKey(app string) string {
|
||||
return roomListCachePrefix(app) + ":entry_indexes"
|
||||
}
|
||||
|
||||
func roomListCachePinPayloadKey(app string) string {
|
||||
return roomListCachePrefix(app) + ":pin_payloads"
|
||||
}
|
||||
|
||||
func roomListCachePinIndexKey(app string) string {
|
||||
return roomListCachePrefix(app) + ":pin_indexes"
|
||||
}
|
||||
|
||||
func roomListCacheBaseKey(app string, tab string, regionID int64, country string, bucket string) string {
|
||||
country = normalizeRoomCountryCode(country)
|
||||
if country == "" {
|
||||
country = roomListCacheCountryAll
|
||||
}
|
||||
return fmt.Sprintf("%s:base:%s:%d:%s:%s", roomListCachePrefix(app), tab, normalizeVisibleRegionID(regionID), country, bucket)
|
||||
}
|
||||
|
||||
func roomListCachePinKey(app string, pinType string, regionID int64) string {
|
||||
if pinType == roomPinTypeGlobal {
|
||||
regionID = 0
|
||||
}
|
||||
return fmt.Sprintf("%s:pin:%s:%d", roomListCachePrefix(app), pinType, normalizeVisibleRegionID(regionID))
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
roomListCacheRefreshBatchSize = 500
|
||||
roomListCacheRefreshMaxRows = 200000
|
||||
)
|
||||
|
||||
// RunRoomListCacheRefreshWorker 周期性用 MySQL owner 表重建 Redis 发现页展示读模型。
|
||||
func (s *Service) RunRoomListCacheRefreshWorker(ctx context.Context, interval time.Duration) {
|
||||
if s == nil || s.roomListCache == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
s.refreshRoomListCacheOnce(ctx)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.refreshRoomListCacheOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) refreshRoomListCacheOnce(ctx context.Context) {
|
||||
if err := s.RefreshRoomListCache(ctx); err != nil {
|
||||
logx.Warn(ctx, "room_list_cache_refresh_failed", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshRoomListCache 从 MySQL 扫描 active 房间和有效置顶后按 App 重建缓存。
|
||||
func (s *Service) RefreshRoomListCache(ctx context.Context) error {
|
||||
if s == nil || s.repository == nil || s.roomListCache == nil {
|
||||
return nil
|
||||
}
|
||||
nowMS := s.clock.Now().UTC().UnixMilli()
|
||||
entriesByApp := make(map[string][]RoomListEntry)
|
||||
pinsByApp := make(map[string][]RoomListPinEntry)
|
||||
apps := make(map[string]struct{})
|
||||
|
||||
var afterAppCode string
|
||||
var afterRoomID string
|
||||
scannedEntries := 0
|
||||
for {
|
||||
entries, err := s.repository.ListRoomListCacheEntries(ctx, RoomListCacheEntryScanQuery{
|
||||
AfterAppCode: afterAppCode,
|
||||
AfterRoomID: afterRoomID,
|
||||
Limit: roomListCacheRefreshBatchSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
break
|
||||
}
|
||||
for _, entry := range entries {
|
||||
app := appcode.Normalize(entry.AppCode)
|
||||
if app == "" {
|
||||
app = appcode.Default
|
||||
}
|
||||
entry.AppCode = app
|
||||
entriesByApp[app] = append(entriesByApp[app], entry)
|
||||
apps[app] = struct{}{}
|
||||
afterAppCode = entry.AppCode
|
||||
afterRoomID = entry.RoomID
|
||||
}
|
||||
scannedEntries += len(entries)
|
||||
if scannedEntries > roomListCacheRefreshMaxRows {
|
||||
// 刷新异常放大时不能用半截数据标记 ready;保留旧缓存或 MySQL 兜底更安全。
|
||||
return fmt.Errorf("room list cache entry refresh exceeded %d rows", roomListCacheRefreshMaxRows)
|
||||
}
|
||||
if len(entries) < roomListCacheRefreshBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var afterPinID int64
|
||||
scannedPins := 0
|
||||
for {
|
||||
pins, err := s.repository.ListRoomListCachePins(ctx, RoomListPinScanQuery{
|
||||
AfterID: afterPinID,
|
||||
Limit: roomListCacheRefreshBatchSize,
|
||||
NowMS: nowMS,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pins) == 0 {
|
||||
break
|
||||
}
|
||||
for _, pin := range pins {
|
||||
app := appcode.Normalize(pin.AppCode)
|
||||
if app == "" {
|
||||
app = appcode.Default
|
||||
}
|
||||
pin.AppCode = app
|
||||
pinsByApp[app] = append(pinsByApp[app], pin)
|
||||
apps[app] = struct{}{}
|
||||
afterPinID = pin.ID
|
||||
}
|
||||
scannedPins += len(pins)
|
||||
if scannedPins > roomListCacheRefreshMaxRows {
|
||||
return fmt.Errorf("room list cache pin refresh exceeded %d rows", roomListCacheRefreshMaxRows)
|
||||
}
|
||||
if len(pins) < roomListCacheRefreshBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for app := range apps {
|
||||
if err := s.roomListCache.RebuildRoomList(ctx, app, entriesByApp[app], pinsByApp[app], nowMS); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
logx.Info(ctx, "room_list_cache_refresh_completed", slog.Int("app_count", len(apps)), slog.Int("entry_count", scannedEntries), slog.Int("pin_count", scannedPins))
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRoomListCacheCandidateOrderMatchesPublicHotBuckets(t *testing.T) {
|
||||
query := RoomListQuery{Tab: roomListTabHot, ViewerCountryCode: "US"}
|
||||
candidates := []roomListCacheCandidate{
|
||||
{Entry: roomListCacheTestEntry("other-empty", "AE", 0, 200000, 0), SortValue: 200000},
|
||||
{Entry: roomListCacheTestEntry("viewer-online", "US", 1, 100, 0), SortValue: 100},
|
||||
{Entry: roomListCachePinnedTestEntry("country-pin", 1, 88, 9000, 2), SortValue: 2},
|
||||
{Entry: roomListCacheTestEntry("other-online", "AE", 1, 10000, 0), SortValue: 10000},
|
||||
{Entry: roomListCachePinnedTestEntry("region-pin", 0, 99, 10000, 1), SortValue: 1},
|
||||
{Entry: roomListCacheTestEntry("viewer-empty", "US", 0, 100000, 0), SortValue: 100000},
|
||||
}
|
||||
for index := range candidates {
|
||||
if !candidates[index].Entry.IsPinned {
|
||||
candidates[index].Entry.PinListRank = roomListCacheNormalRank(query, candidates[index].Entry)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
return roomListCacheCandidateBefore(candidates[i], candidates[j])
|
||||
})
|
||||
|
||||
got := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
got = append(got, candidate.Entry.RoomID)
|
||||
}
|
||||
want := []string{"region-pin", "country-pin", "viewer-online", "other-online", "viewer-empty", "other-empty"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("order length mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("order mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomListCacheCursorContinuesAfterPinnedBoundary(t *testing.T) {
|
||||
query := RoomListQuery{
|
||||
Tab: roomListTabHot,
|
||||
CursorRoomID: "region-pin",
|
||||
CursorPinListRank: 0,
|
||||
CursorPinWeight: 99,
|
||||
CursorPinnedUntilMS: 10000,
|
||||
CursorSortScore: 1,
|
||||
}
|
||||
before := roomListCachePinnedTestEntry("region-pin", 0, 99, 10000, 1)
|
||||
after := roomListCachePinnedTestEntry("country-pin", 1, 88, 9000, 2)
|
||||
if roomListCacheAfterCursor(query, before, before.SortScore) {
|
||||
t.Fatalf("cursor row itself must not be returned again")
|
||||
}
|
||||
if !roomListCacheAfterCursor(query, after, after.SortScore) {
|
||||
t.Fatalf("next pin rank must continue after cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomListCacheCountryPinOnlyAppliesToSelectedCountry(t *testing.T) {
|
||||
query := RoomListQuery{VisibleRegionID: 9001, CountryCode: "AE", ViewerCountryCode: "US", NowMS: 1000}
|
||||
pin := RoomListPinEntry{PinType: roomPinTypeCountry, VisibleRegionID: 9001, RoomID: "room-us", Status: "active", PinnedAtMS: 1, ExpiresAtMS: 2000}
|
||||
usRoom := roomListCacheTestEntry("room-us", "US", 1, 10, 0)
|
||||
aeRoom := roomListCacheTestEntry("room-ae", "AE", 1, 10, 0)
|
||||
if roomListCachePinApplies(query, pin, usRoom) {
|
||||
t.Fatalf("country pin must not apply to a room outside selected country")
|
||||
}
|
||||
if !roomListCachePinApplies(query, pin, aeRoom) {
|
||||
t.Fatalf("country pin must apply to matching selected country")
|
||||
}
|
||||
}
|
||||
|
||||
func roomListCacheTestEntry(roomID string, country string, online int32, sortScore int64, createdAtMS int64) RoomListEntry {
|
||||
return RoomListEntry{
|
||||
AppCode: "lalu",
|
||||
RoomID: roomID,
|
||||
VisibleRegionID: 9001,
|
||||
OwnerCountryCode: country,
|
||||
Status: "active",
|
||||
OnlineCount: online,
|
||||
SortScore: sortScore,
|
||||
CreatedAtMS: createdAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func roomListCachePinnedTestEntry(roomID string, rank int64, weight int64, expiresAtMS int64, sortScore int64) RoomListEntry {
|
||||
entry := roomListCacheTestEntry(roomID, "US", 1, sortScore, 0)
|
||||
entry.IsPinned = true
|
||||
entry.PinListRank = rank
|
||||
entry.PinWeight = weight
|
||||
entry.PinnedUntilMS = expiresAtMS
|
||||
return entry
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
)
|
||||
|
||||
const (
|
||||
// roomRocketProgressCoalesceWindow 固定按 UTC 毫秒切 1 秒桶,把高频燃料变化压缩成客户端可接受的展示粒度。
|
||||
roomRocketProgressCoalesceWindow = time.Second
|
||||
// roomRocketProgressFlushInterval 控制到期 bucket 的扫描频率;100ms 让实际延迟稳定落在 1 秒窗口附近。
|
||||
roomRocketProgressFlushInterval = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
type roomRocketProgressPending struct {
|
||||
record outbox.Record
|
||||
robotLane bool
|
||||
rocketID string
|
||||
level int32
|
||||
currentFuel int64
|
||||
roomVersion int64
|
||||
}
|
||||
|
||||
type roomRocketProgressKey struct {
|
||||
appCode string
|
||||
roomID string
|
||||
rocketID string
|
||||
level int32
|
||||
robotLane bool
|
||||
windowStartMS int64
|
||||
}
|
||||
|
||||
type roomRocketProgressEntry struct {
|
||||
key roomRocketProgressKey
|
||||
record outbox.Record
|
||||
roomVersion int64
|
||||
currentFuel int64
|
||||
flushAtMS int64
|
||||
}
|
||||
|
||||
type roomRocketProgressCoalescer struct {
|
||||
mu sync.Mutex
|
||||
repository Repository
|
||||
pending map[roomRocketProgressKey]roomRocketProgressEntry
|
||||
}
|
||||
|
||||
func newRoomRocketProgressCoalescer(repository Repository) *roomRocketProgressCoalescer {
|
||||
return &roomRocketProgressCoalescer{
|
||||
repository: repository,
|
||||
pending: make(map[roomRocketProgressKey]roomRocketProgressEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *roomRocketProgressCoalescer) enqueue(progress roomRocketProgressPending) {
|
||||
if c == nil || progress.record.Envelope == nil || progress.rocketID == "" || progress.level <= 0 {
|
||||
// 入队只接受已经构造成 outbox envelope 的火箭进度;异常输入说明调用方没有走标准 SendGift 构造链路。
|
||||
return
|
||||
}
|
||||
|
||||
record := progress.record
|
||||
record.AppCode = appcode.Normalize(record.AppCode)
|
||||
record.Envelope.AppCode = record.AppCode
|
||||
if record.CreatedAtMS <= 0 {
|
||||
record.CreatedAtMS = record.Envelope.GetOccurredAtMs()
|
||||
}
|
||||
if progress.roomVersion <= 0 {
|
||||
progress.roomVersion = record.Envelope.GetRoomVersion()
|
||||
}
|
||||
windowStartMS := roomRocketProgressWindowStartMS(record.CreatedAtMS)
|
||||
key := roomRocketProgressKey{
|
||||
appCode: record.AppCode,
|
||||
roomID: record.RoomID,
|
||||
rocketID: progress.rocketID,
|
||||
level: progress.level,
|
||||
robotLane: progress.robotLane,
|
||||
windowStartMS: windowStartMS,
|
||||
}
|
||||
entry := roomRocketProgressEntry{
|
||||
key: key,
|
||||
record: record,
|
||||
roomVersion: progress.roomVersion,
|
||||
currentFuel: progress.currentFuel,
|
||||
flushAtMS: windowStartMS + roomRocketProgressCoalesceWindow.Milliseconds(),
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.mergeLocked(entry)
|
||||
}
|
||||
|
||||
func (c *roomRocketProgressCoalescer) flushDue(ctx context.Context, nowMS int64) error {
|
||||
if c == nil || c.repository == nil {
|
||||
return nil
|
||||
}
|
||||
due := c.takeDue(nowMS)
|
||||
if len(due) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := c.saveDue(ctx, due); err != nil {
|
||||
// DB 或上下文失败时把事件放回队列,下一轮继续尝试;新进度已经入队时不会被旧版本覆盖。
|
||||
c.requeue(due, nowMS+roomRocketProgressFlushInterval.Milliseconds())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *roomRocketProgressCoalescer) takeDue(nowMS int64) []roomRocketProgressEntry {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
due := make([]roomRocketProgressEntry, 0)
|
||||
for key, entry := range c.pending {
|
||||
if entry.flushAtMS > nowMS {
|
||||
continue
|
||||
}
|
||||
due = append(due, entry)
|
||||
delete(c.pending, key)
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
func (c *roomRocketProgressCoalescer) requeue(entries []roomRocketProgressEntry, nextFlushAtMS int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for _, entry := range entries {
|
||||
entry.flushAtMS = nextFlushAtMS
|
||||
c.mergeLocked(entry)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *roomRocketProgressCoalescer) mergeLocked(entry roomRocketProgressEntry) {
|
||||
current, exists := c.pending[entry.key]
|
||||
if !exists || roomRocketProgressEntryNewer(entry, current) {
|
||||
c.pending[entry.key] = entry
|
||||
}
|
||||
}
|
||||
|
||||
func (c *roomRocketProgressCoalescer) saveDue(ctx context.Context, entries []roomRocketProgressEntry) error {
|
||||
normal := make(map[string][]outbox.Record)
|
||||
robot := make(map[string][]outbox.Record)
|
||||
for _, entry := range entries {
|
||||
record := entry.record
|
||||
record.AppCode = appcode.Normalize(record.AppCode)
|
||||
if record.Envelope != nil {
|
||||
record.Envelope.AppCode = record.AppCode
|
||||
}
|
||||
if entry.key.robotLane {
|
||||
robot[record.AppCode] = append(robot[record.AppCode], record)
|
||||
continue
|
||||
}
|
||||
normal[record.AppCode] = append(normal[record.AppCode], record)
|
||||
}
|
||||
|
||||
var joined error
|
||||
for scopedApp, records := range normal {
|
||||
// repository 会按 context app_code 二次归一,分 app 批量写避免跨租户记录被同一个上下文覆盖。
|
||||
joined = errors.Join(joined, c.repository.SaveOutbox(appcode.WithContext(ctx, scopedApp), records))
|
||||
}
|
||||
for scopedApp, records := range robot {
|
||||
joined = errors.Join(joined, c.repository.SaveRobotOutbox(appcode.WithContext(ctx, scopedApp), records))
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
func roomRocketProgressEntryNewer(candidate roomRocketProgressEntry, current roomRocketProgressEntry) bool {
|
||||
if candidate.roomVersion != current.roomVersion {
|
||||
return candidate.roomVersion > current.roomVersion
|
||||
}
|
||||
if candidate.currentFuel != current.currentFuel {
|
||||
return candidate.currentFuel > current.currentFuel
|
||||
}
|
||||
return candidate.record.CreatedAtMS >= current.record.CreatedAtMS
|
||||
}
|
||||
|
||||
func roomRocketProgressWindowStartMS(createdAtMS int64) int64 {
|
||||
windowMS := roomRocketProgressCoalesceWindow.Milliseconds()
|
||||
if createdAtMS <= 0 {
|
||||
return 0
|
||||
}
|
||||
return createdAtMS / windowMS * windowMS
|
||||
}
|
||||
|
||||
// FlushRoomRocketProgress 把已经超过 1 秒窗口的火箭进度写回 outbox;测试和后台 worker 共用同一条路径。
|
||||
func (s *Service) FlushRoomRocketProgress(ctx context.Context) error {
|
||||
if s == nil || s.roomRocketProgressCoalescer == nil {
|
||||
return nil
|
||||
}
|
||||
return s.roomRocketProgressCoalescer.flushDue(ctx, s.clock.Now().UTC().UnixMilli())
|
||||
}
|
||||
|
||||
// RunRoomRocketProgressFlushWorker 周期性把内存合并后的火箭进度落入 outbox,实际 MQ/IM 投递仍由原 outbox worker 负责。
|
||||
func (s *Service) RunRoomRocketProgressFlushWorker(ctx context.Context, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = roomRocketProgressFlushInterval
|
||||
}
|
||||
if err := s.FlushRoomRocketProgress(ctx); err != nil && ctx.Err() == nil {
|
||||
logx.Warn(ctx, "room_rocket_progress_flush_failed", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.FlushRoomRocketProgress(ctx); err != nil && ctx.Err() == nil {
|
||||
logx.Warn(ctx, "room_rocket_progress_flush_failed", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -249,6 +249,7 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
|
||||
t.Fatalf("top1 and igniter must lock at ignited start: %+v", pending)
|
||||
}
|
||||
|
||||
flushRocketProgress(t, ctx, svc, now)
|
||||
progressEvents := rocketProgressEvents(t, ctx, repository)
|
||||
if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 250 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 150 {
|
||||
t.Fatalf("progress event must expose effective fuel and discarded overflow: %+v", progressEvents)
|
||||
@ -276,6 +277,7 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
|
||||
if len(nextResp.GetRocket().GetPendingLaunches()) != 1 {
|
||||
t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket())
|
||||
}
|
||||
flushRocketProgress(t, ctx, svc, now)
|
||||
progressEvents = rocketProgressEvents(t, ctx, repository)
|
||||
if len(progressEvents) != 2 || progressEvents[1].GetLevel() != 2 || progressEvents[1].GetEffectiveAddedFuel() != 50 || progressEvents[1].GetOverflowFuel() != 0 {
|
||||
t.Fatalf("second progress event must target level 2: %+v", progressEvents)
|
||||
@ -285,6 +287,58 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 30, 0, 0, time.UTC)}
|
||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-progress-10", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"},
|
||||
{BillingReceiptId: "receipt-progress-20", GiftPointAdded: 20, HeatValue: 20, GiftTypeCode: "normal"},
|
||||
{BillingReceiptId: "receipt-progress-30", GiftPointAdded: 30, HeatValue: 30, GiftTypeCode: "normal"},
|
||||
}}
|
||||
svc := newRocketTestService(t, repository, wallet, now)
|
||||
writeRocketConfig(t, ctx, repository, rocketConfigForTest(1000, 1_000))
|
||||
|
||||
roomID := "room-rocket-progress-coalesce"
|
||||
ownerID := int64(111)
|
||||
viewerID := int64(222)
|
||||
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, viewerID)
|
||||
before := outboxEventCounts(t, ctx, repository)
|
||||
|
||||
for index := 1; index <= 3; index++ {
|
||||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, fmt.Sprintf("progress-coalesce-%d", index)),
|
||||
TargetType: "user",
|
||||
TargetUserId: viewerID,
|
||||
GiftId: fmt.Sprintf("gift-progress-%d", index),
|
||||
GiftCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("send coalesced progress gift %d failed: %v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
beforeFlush := outboxEventCounts(t, ctx, repository)
|
||||
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged"} {
|
||||
if delta := beforeFlush[eventType] - before[eventType]; delta != 3 {
|
||||
t.Fatalf("%s must stay per gift before progress flush, delta=%d counts=%+v", eventType, delta, beforeFlush)
|
||||
}
|
||||
}
|
||||
if delta := beforeFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 {
|
||||
t.Fatalf("rocket progress must not write per gift before flush, delta=%d counts=%+v", delta, beforeFlush)
|
||||
}
|
||||
|
||||
flushRocketProgress(t, ctx, svc, now)
|
||||
afterFlush := outboxEventCounts(t, ctx, repository)
|
||||
if delta := afterFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 1 {
|
||||
t.Fatalf("rocket progress must coalesce to one outbox row, delta=%d counts=%+v", delta, afterFlush)
|
||||
}
|
||||
progressEvents := rocketProgressEvents(t, ctx, repository)
|
||||
if len(progressEvents) != 1 || progressEvents[0].GetCurrentFuel() != 60 || progressEvents[0].GetEffectiveAddedFuel() != 30 {
|
||||
t.Fatalf("coalesced progress must keep latest max fuel event: %+v", progressEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomRocketIgnitedOutboxSchedulesDelayedLaunch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -745,6 +799,15 @@ func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repos
|
||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||
}
|
||||
|
||||
func flushRocketProgress(t testing.TB, ctx context.Context, svc *roomservice.Service, clock *fixedRoomRocketClock) {
|
||||
t.Helper()
|
||||
|
||||
clock.now = clock.now.Add(time.Second)
|
||||
if err := svc.FlushRoomRocketProgress(ctx); err != nil {
|
||||
t.Fatalf("flush rocket progress failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func rocketConfigForTest(threshold int64, launchDelayMS int64) roomservice.RoomRocketConfig {
|
||||
levels := make([]roomservice.RoomRocketLevelConfig, 0, 5)
|
||||
for level := int32(1); level <= 5; level++ {
|
||||
|
||||
@ -41,6 +41,8 @@ type Config struct {
|
||||
HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider
|
||||
// RoomGiftLeaderboard 是跨房间贡献榜读模型,SendGift 提交后 best-effort 写入。
|
||||
RoomGiftLeaderboard RoomGiftLeaderboardStore
|
||||
// RoomListCache 是公共发现页 Redis 展示读模型;未配置或未就绪时 ListRooms 直接读 MySQL。
|
||||
RoomListCache RoomListCacheStore
|
||||
// LuckyGiftSendLocker 串行化同一用户的幸运礼物扣费、抽奖和同步返奖链路。
|
||||
LuckyGiftSendLocker LuckyGiftSendLocker
|
||||
// LuckyGiftSendLockTTL 是幸运礼物短锁过期时间。
|
||||
@ -83,8 +85,14 @@ type Service struct {
|
||||
humanRobotPoolProvider integration.HumanRoomRobotPoolProvider
|
||||
// roomRocketLaunchScheduler 在 ignited outbox 投递成功后安排 launch_at_ms 唤醒。
|
||||
roomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
|
||||
// roomRocketProgressCoalescer 把高频燃料变化压成每秒每轮一条 outbox,降低 room_outbox/MQ/IM 写放大。
|
||||
roomRocketProgressCoalescer *roomRocketProgressCoalescer
|
||||
// robotOutboxSampler 把同房间同展示类型在短窗口内收敛成一条 robot outbox,房间命令和状态仍完整执行。
|
||||
robotOutboxSampler *robotOutboxSampler
|
||||
// roomGiftLeaderboard 只保存房间维度贡献值 zset,不承载 Room Cell 核心状态。
|
||||
roomGiftLeaderboard RoomGiftLeaderboardStore
|
||||
// roomListCache 只承载发现页展示顺序;MySQL 仍然是房间列表和置顶事实 owner。
|
||||
roomListCache RoomListCacheStore
|
||||
// luckyGiftSendLocker 只保护同一用户幸运礼物扣费到同步返奖的小事务链路。
|
||||
luckyGiftSendLocker LuckyGiftSendLocker
|
||||
// luckyGiftSendLockTTL 控制 Redis 短锁自动释放窗口。
|
||||
@ -107,6 +115,10 @@ type Service struct {
|
||||
humanRobotRuntimeMu sync.Mutex
|
||||
// humanRobotRuntimes 保存 room_id -> runtime,避免同一真人房间被重复补机器人。
|
||||
humanRobotRuntimes map[string]robotRoomRuntime
|
||||
// robotOutboxCleanupMu 保护清理 worker 的最近执行时间,避免并发 worker 同一时刻重复清理。
|
||||
robotOutboxCleanupMu sync.Mutex
|
||||
// robotOutboxLastCleanup 记录本进程上一轮 robot outbox 清理时间。
|
||||
robotOutboxLastCleanup time.Time
|
||||
}
|
||||
|
||||
type robotRoomRuntime struct {
|
||||
@ -156,6 +168,8 @@ type mutationResult struct {
|
||||
outboxRecords []outbox.Record
|
||||
// robotOutboxRecords 是机器人房间必要展示事实,只进入 robot outbox/worker,不占主 room_outbox。
|
||||
robotOutboxRecords []outbox.Record
|
||||
// roomRocketProgress 是提交成功后才进入内存合并器的火箭进度展示事件,不和送礼命令事务逐笔写 outbox。
|
||||
roomRocketProgress *roomRocketProgressPending
|
||||
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
|
||||
roomStatus string
|
||||
// roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。
|
||||
@ -217,29 +231,32 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
}
|
||||
|
||||
return &Service{
|
||||
nodeID: cfg.NodeID,
|
||||
leaseTTL: cfg.LeaseTTL,
|
||||
rankLimit: rankLimit,
|
||||
snapshotEveryN: snapshotEveryN,
|
||||
presenceStaleAfter: presenceStaleAfter,
|
||||
micPublishTimeout: micPublishTimeout,
|
||||
clock: usedClock,
|
||||
directory: directory,
|
||||
repository: repository,
|
||||
wallet: wallet,
|
||||
luckyGift: luckyGiftClient,
|
||||
syncPublisher: syncPublisher,
|
||||
outboxPublisher: outboxPublisher,
|
||||
robotOutboxPublisher: cfg.RobotOutboxPublisher,
|
||||
humanRobotPoolProvider: cfg.HumanRobotPoolProvider,
|
||||
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
|
||||
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
|
||||
luckyGiftSendLocker: cfg.LuckyGiftSendLocker,
|
||||
luckyGiftSendLockTTL: luckyGiftSendLockTTL,
|
||||
rtcUserRemover: cfg.RTCUserRemover,
|
||||
cells: make(map[string]*cell.RoomCell),
|
||||
robotRuntimes: make(map[string]robotRoomRuntime),
|
||||
humanRobotRuntimes: make(map[string]robotRoomRuntime),
|
||||
nodeID: cfg.NodeID,
|
||||
leaseTTL: cfg.LeaseTTL,
|
||||
rankLimit: rankLimit,
|
||||
snapshotEveryN: snapshotEveryN,
|
||||
presenceStaleAfter: presenceStaleAfter,
|
||||
micPublishTimeout: micPublishTimeout,
|
||||
clock: usedClock,
|
||||
directory: directory,
|
||||
repository: repository,
|
||||
wallet: wallet,
|
||||
luckyGift: luckyGiftClient,
|
||||
syncPublisher: syncPublisher,
|
||||
outboxPublisher: outboxPublisher,
|
||||
robotOutboxPublisher: cfg.RobotOutboxPublisher,
|
||||
humanRobotPoolProvider: cfg.HumanRobotPoolProvider,
|
||||
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
|
||||
roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(repository),
|
||||
robotOutboxSampler: newRobotOutboxSampler(),
|
||||
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
|
||||
roomListCache: cfg.RoomListCache,
|
||||
luckyGiftSendLocker: cfg.LuckyGiftSendLocker,
|
||||
luckyGiftSendLockTTL: luckyGiftSendLockTTL,
|
||||
rtcUserRemover: cfg.RTCUserRemover,
|
||||
cells: make(map[string]*cell.RoomCell),
|
||||
robotRuntimes: make(map[string]robotRoomRuntime),
|
||||
humanRobotRuntimes: make(map[string]robotRoomRuntime),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -153,6 +153,71 @@ func TestRobotGiftSkipsMainHeatAndRankOutbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotGiftDownsamplesRobotOutboxWithoutSkippingRoomState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)}
|
||||
wallet := &rocketTestWallet{}
|
||||
svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-outbox-sampler")
|
||||
|
||||
roomID := "robot-room-outbox-sampler"
|
||||
senderID := int64(63101)
|
||||
targetID := int64(63102)
|
||||
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, targetID)
|
||||
beforeMain := outboxEventCounts(t, ctx, repository)
|
||||
beforeRobot := robotOutboxEventCounts(t, ctx, repository)
|
||||
|
||||
firstResp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
|
||||
Meta: rocketMeta(roomID, senderID, "robot-sampler-first"),
|
||||
TargetUserID: targetID,
|
||||
GiftID: "robot-sampler-gift",
|
||||
GiftCount: 1,
|
||||
RobotUserIDs: []int64{senderID, targetID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first robot send gift failed: %v", err)
|
||||
}
|
||||
secondResp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
|
||||
Meta: rocketMeta(roomID, senderID, "robot-sampler-second"),
|
||||
TargetUserID: targetID,
|
||||
GiftID: "robot-sampler-gift",
|
||||
GiftCount: 1,
|
||||
RobotUserIDs: []int64{senderID, targetID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second robot send gift failed: %v", err)
|
||||
}
|
||||
|
||||
if firstResp.GetRoomHeat() != 1 || secondResp.GetRoomHeat() != 2 {
|
||||
t.Fatalf("robot gift sampler must not skip room heat updates: first=%d second=%d", firstResp.GetRoomHeat(), secondResp.GetRoomHeat())
|
||||
}
|
||||
if len(secondResp.GetGiftRank()) == 0 || secondResp.GetGiftRank()[0].GetUserId() != senderID || secondResp.GetGiftRank()[0].GetGiftValue() != 2 {
|
||||
t.Fatalf("robot gift sampler must not skip room rank updates: %+v", secondResp.GetGiftRank())
|
||||
}
|
||||
if user := onlineUserByID(secondResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 2 {
|
||||
t.Fatalf("robot gift sampler must not skip target gift value: %+v", user)
|
||||
}
|
||||
|
||||
afterMain := outboxEventCounts(t, ctx, repository)
|
||||
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged"} {
|
||||
if delta := afterMain[eventType] - beforeMain[eventType]; delta != 0 {
|
||||
t.Fatalf("robot sampler must not write %s to main outbox, delta=%d counts=%+v", eventType, delta, afterMain)
|
||||
}
|
||||
}
|
||||
afterRobot := robotOutboxEventCounts(t, ctx, repository)
|
||||
wantRobotDeltas := map[string]int{
|
||||
"RoomGiftSent": 1,
|
||||
"RoomHeatChanged": 1,
|
||||
"RoomRankChanged": 1,
|
||||
}
|
||||
for eventType, want := range wantRobotDeltas {
|
||||
if delta := afterRobot[eventType] - beforeRobot[eventType]; delta != want {
|
||||
t.Fatalf("robot sampler %s delta=%d want=%d counts=%+v", eventType, delta, want, afterRobot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealRoomRobotGiftKeepsRoomContributionAndUsesRobotOutbox(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -222,6 +287,7 @@ func TestRealRoomRobotGiftKeepsRoomContributionAndUsesRobotOutbox(t *testing.T)
|
||||
if len(leaderboard.increments) != 1 || leaderboard.increments[0].RoomID != roomID || leaderboard.increments[0].CoinSpent != 100 {
|
||||
t.Fatalf("real room robot gift must keep cross-room contribution increment: %+v", leaderboard.increments)
|
||||
}
|
||||
flushRocketProgress(t, ctx, svc, now)
|
||||
|
||||
afterMain := outboxEventCounts(t, ctx, repository)
|
||||
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} {
|
||||
|
||||
@ -422,7 +422,7 @@ func (r *Repository) adminGetRoomPin(ctx context.Context, where string, args ...
|
||||
|
||||
func adminRoomPinSelectSQL() string {
|
||||
return `
|
||||
SELECT p.id, p.visible_region_id, p.pin_type, p.room_id, p.weight, p.status,
|
||||
SELECT p.app_code, p.id, p.visible_region_id, p.pin_type, p.room_id, p.weight, p.status,
|
||||
p.pinned_at_ms, p.expires_at_ms, p.cancelled_at_ms,
|
||||
p.created_by_admin_id, p.cancelled_by_admin_id, p.created_at_ms, p.updated_at_ms,
|
||||
r.room_short_id, r.visible_region_id, r.title, r.cover_url, r.owner_user_id, r.status
|
||||
@ -470,6 +470,6 @@ func adminRoomPinWhere(ctx context.Context, query roomservice.AdminRoomPinQuery)
|
||||
|
||||
func scanAdminRoomPin(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomPinEntry, error) {
|
||||
var item roomservice.AdminRoomPinEntry
|
||||
err := scanner.Scan(&item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus)
|
||||
err := scanner.Scan(&item.AppCode, &item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus)
|
||||
return item, err
|
||||
}
|
||||
|
||||
@ -15,6 +15,16 @@ import (
|
||||
|
||||
// SaveOutbox 追加待投递 outbox 事件。
|
||||
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
|
||||
return r.saveOutboxInTable(ctx, roomOutboxTable, records)
|
||||
}
|
||||
|
||||
// SaveRobotOutbox 追加机器人房间展示 outbox 事件。
|
||||
func (r *Repository) SaveRobotOutbox(ctx context.Context, records []outbox.Record) error {
|
||||
return r.saveOutboxInTable(ctx, roomRobotOutboxTable, records)
|
||||
}
|
||||
|
||||
func (r *Repository) saveOutboxInTable(ctx context.Context, table string, records []outbox.Record) error {
|
||||
table = roomOutboxTableName(table)
|
||||
if len(records) == 0 {
|
||||
// 没有事件时保持无副作用,方便领域层统一调用。
|
||||
return nil
|
||||
@ -39,7 +49,7 @@ func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) er
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
|
||||
`INSERT INTO `+table+` (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE event_id = event_id`,
|
||||
record.AppCode,
|
||||
@ -351,6 +361,106 @@ func (r *Repository) markOutboxDeadInTable(ctx context.Context, table string, ev
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRobotOutbox 直接删除一条机器人展示事件。
|
||||
func (r *Repository) DeleteRobotOutbox(ctx context.Context, eventID string) error {
|
||||
// robot outbox 只承载 App 展示补偿;超过业务展示窗口后删除比标记 failed 更能降低后续扫描成本。
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM `+roomRobotOutboxTable+`
|
||||
WHERE app_code = ? AND event_id = ?`,
|
||||
appcode.FromContext(ctx),
|
||||
eventID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanupRobotOutbox 批量删除机器人展示 outbox 的过期记录,不做归档。
|
||||
func (r *Repository) CleanupRobotOutbox(ctx context.Context, activeBeforeMS int64, terminalBeforeMS int64, limit int) (int64, int64, error) {
|
||||
if limit <= 0 {
|
||||
// 清理必须有批量上限,避免误配置导致一次 DELETE 长时间持锁。
|
||||
limit = 50000
|
||||
}
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
var activeDeleted int64
|
||||
var terminalDeleted int64
|
||||
if activeBeforeMS > 0 {
|
||||
// pending/retryable 没有正在处理的 worker,超过展示窗口后可以直接删除。
|
||||
deleted, err := r.deleteRobotOutboxBatch(ctx, `
|
||||
DELETE FROM `+roomRobotOutboxTable+`
|
||||
WHERE app_code = ?
|
||||
AND status IN (?, ?)
|
||||
AND created_at_ms < ?
|
||||
ORDER BY created_at_ms ASC, event_id ASC
|
||||
LIMIT ?`,
|
||||
appCode,
|
||||
outbox.StatusPending,
|
||||
outbox.StatusRetryable,
|
||||
activeBeforeMS,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return activeDeleted, terminalDeleted, err
|
||||
}
|
||||
activeDeleted += deleted
|
||||
|
||||
// delivering 可能正被其他 worker 处理,只删除锁已过期或历史异常无锁的旧记录。
|
||||
deleted, err = r.deleteRobotOutboxBatch(ctx, `
|
||||
DELETE FROM `+roomRobotOutboxTable+`
|
||||
WHERE app_code = ?
|
||||
AND status = ?
|
||||
AND created_at_ms < ?
|
||||
AND (lock_until_ms IS NULL OR lock_until_ms <= ?)
|
||||
ORDER BY created_at_ms ASC, event_id ASC
|
||||
LIMIT ?`,
|
||||
appCode,
|
||||
outbox.StatusDelivering,
|
||||
activeBeforeMS,
|
||||
nowMS,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return activeDeleted, terminalDeleted, err
|
||||
}
|
||||
activeDeleted += deleted
|
||||
}
|
||||
|
||||
if terminalBeforeMS > 0 {
|
||||
// delivered/failed 对机器人展示没有审计价值,保留短窗口方便排障后即可删除。
|
||||
deleted, err := r.deleteRobotOutboxBatch(ctx, `
|
||||
DELETE FROM `+roomRobotOutboxTable+`
|
||||
WHERE app_code = ?
|
||||
AND status IN (?, ?)
|
||||
AND updated_at_ms < ?
|
||||
ORDER BY updated_at_ms ASC, event_id ASC
|
||||
LIMIT ?`,
|
||||
appCode,
|
||||
outbox.StatusDelivered,
|
||||
outbox.StatusFailed,
|
||||
terminalBeforeMS,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return activeDeleted, terminalDeleted, err
|
||||
}
|
||||
terminalDeleted += deleted
|
||||
}
|
||||
|
||||
return activeDeleted, terminalDeleted, nil
|
||||
}
|
||||
|
||||
func (r *Repository) deleteRobotOutboxBatch(ctx context.Context, query string, args ...any) (int64, error) {
|
||||
result, err := r.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func roomOutboxTableName(table string) string {
|
||||
switch table {
|
||||
case roomRobotOutboxTable:
|
||||
|
||||
@ -176,6 +176,104 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListRoomListCacheEntries 按 app_code/room_id 主键批量扫描 active 列表投影,供 Redis 展示读模型重建。
|
||||
func (r *Repository) ListRoomListCacheEntries(ctx context.Context, query roomservice.RoomListCacheEntryScanQuery) ([]roomservice.RoomListEntry, error) {
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 500
|
||||
}
|
||||
if query.Limit > 2000 {
|
||||
query.Limit = 2000
|
||||
}
|
||||
where := []string{"status = ?"}
|
||||
args := []any{"active"}
|
||||
if strings.TrimSpace(query.AfterAppCode) != "" || strings.TrimSpace(query.AfterRoomID) != "" {
|
||||
// 复合主键游标必须严格前进,避免刷新 worker 用 OFFSET 反复扫描前段数据。
|
||||
where = append(where, "(app_code > ? OR (app_code = ? AND room_id > ?))")
|
||||
args = append(args, strings.TrimSpace(query.AfterAppCode), strings.TrimSpace(query.AfterAppCode), strings.TrimSpace(query.AfterRoomID))
|
||||
}
|
||||
args = append(args, query.Limit)
|
||||
rows, err := r.db.QueryContext(ctx, roomListSelectColumns+"\n\tWHERE "+strings.Join(where, " AND ")+"\n\tORDER BY app_code ASC, room_id ASC\n\tLIMIT ?", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
entry, err := scanRoomListEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// ListRoomListCachePins 按 pin id 批量扫描仍可能生效的置顶事实,供 Redis 展示读模型重建。
|
||||
func (r *Repository) ListRoomListCachePins(ctx context.Context, query roomservice.RoomListPinScanQuery) ([]roomservice.RoomListPinEntry, error) {
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 500
|
||||
}
|
||||
if query.Limit > 2000 {
|
||||
query.Limit = 2000
|
||||
}
|
||||
nowMS := query.NowMS
|
||||
if nowMS <= 0 {
|
||||
nowMS = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, app_code, visible_region_id, pin_type, room_id, weight, status, pinned_at_ms, expires_at_ms
|
||||
FROM room_region_pins
|
||||
WHERE id > ? AND status = ? AND expires_at_ms > ?
|
||||
ORDER BY id ASC
|
||||
LIMIT ?`, query.AfterID, "active", nowMS, query.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
pins := make([]roomservice.RoomListPinEntry, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
var pin roomservice.RoomListPinEntry
|
||||
if err := rows.Scan(&pin.ID, &pin.AppCode, &pin.VisibleRegionID, &pin.PinType, &pin.RoomID, &pin.Weight, &pin.Status, &pin.PinnedAtMS, &pin.ExpiresAtMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pins = append(pins, pin)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pins, nil
|
||||
}
|
||||
|
||||
func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomListEntry, error) {
|
||||
var entry roomservice.RoomListEntry
|
||||
err := scanner.Scan(
|
||||
&entry.AppCode,
|
||||
&entry.RoomID,
|
||||
&entry.RoomShortID,
|
||||
&entry.VisibleRegionID,
|
||||
&entry.OwnerCountryCode,
|
||||
&entry.OwnerUserID,
|
||||
&entry.Title,
|
||||
&entry.CoverURL,
|
||||
&entry.Mode,
|
||||
&entry.Status,
|
||||
&entry.Locked,
|
||||
&entry.Heat,
|
||||
&entry.OnlineCount,
|
||||
&entry.SeatCount,
|
||||
&entry.OccupiedSeatCount,
|
||||
&entry.SortScore,
|
||||
&entry.CreatedAtMS,
|
||||
&entry.UpdatedAtMS,
|
||||
)
|
||||
return entry, err
|
||||
}
|
||||
|
||||
func uniqueRoomListIDs(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
|
||||
@ -142,9 +142,22 @@ func (r *Repository) SetRoomCreatedAt(roomID string, createdAtMS int64) {
|
||||
func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
|
||||
r.t.Helper()
|
||||
|
||||
return r.outboxRecord("room_outbox", eventID)
|
||||
}
|
||||
|
||||
// RobotOutboxRecord reads one robot outbox row, including delivered rows that normal scans skip.
|
||||
func (r *Repository) RobotOutboxRecord(eventID string) (outbox.Record, bool) {
|
||||
r.t.Helper()
|
||||
|
||||
return r.outboxRecord("room_robot_outbox", eventID)
|
||||
}
|
||||
|
||||
func (r *Repository) outboxRecord(table string, eventID string) (outbox.Record, bool) {
|
||||
r.t.Helper()
|
||||
|
||||
row := r.schema.DB.QueryRowContext(context.Background(), `
|
||||
SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
|
||||
FROM room_outbox
|
||||
FROM `+table+`
|
||||
WHERE app_code = ? AND event_id = ?
|
||||
`, appcode.Default, eventID)
|
||||
|
||||
@ -170,6 +183,23 @@ func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
|
||||
return record, true
|
||||
}
|
||||
|
||||
// SetRobotOutboxStatus rewrites robot outbox lifecycle fields for cleanup and worker tests.
|
||||
func (r *Repository) SetRobotOutboxStatus(eventID string, status string, updatedAtMS int64, lockUntilMS *int64) {
|
||||
r.t.Helper()
|
||||
|
||||
var lockUntil any
|
||||
if lockUntilMS != nil {
|
||||
lockUntil = *lockUntilMS
|
||||
}
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
UPDATE room_robot_outbox
|
||||
SET status = ?, updated_at_ms = ?, lock_until_ms = ?
|
||||
WHERE app_code = ? AND event_id = ?
|
||||
`, status, updatedAtMS, lockUntil, appcode.Default, eventID); err != nil {
|
||||
r.t.Fatalf("update robot outbox status failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func initDBPath(t testing.TB) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ type Repository interface {
|
||||
DeleteAgency(ctx context.Context, command DeleteAgencyCommand) (hostdomain.Agency, error)
|
||||
SetAgencyJoinEnabled(ctx context.Context, command SetAgencyJoinEnabledCommand) (hostdomain.Agency, error)
|
||||
GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error)
|
||||
BatchGetHostProfiles(ctx context.Context, userIDs []int64) (map[int64]hostdomain.HostProfile, error)
|
||||
GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error)
|
||||
GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error)
|
||||
ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error)
|
||||
@ -599,6 +600,32 @@ func (s *Service) GetHostProfile(ctx context.Context, userID int64) (hostdomain.
|
||||
return s.repository.GetHostProfile(ctx, userID)
|
||||
}
|
||||
|
||||
// BatchGetHostProfiles 批量读取主播身份事实,缺失用户不返回占位对象。
|
||||
func (s *Service) BatchGetHostProfiles(ctx context.Context, userIDs []int64) (map[int64]hostdomain.HostProfile, error) {
|
||||
if len(userIDs) == 0 {
|
||||
// 空批量请求是合法的无操作,调用方可以直接按空 map 组装结果。
|
||||
return map[int64]hostdomain.HostProfile{}, nil
|
||||
}
|
||||
uniqueUserIDs := make([]int64, 0, len(userIDs))
|
||||
seen := make(map[int64]bool, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID <= 0 {
|
||||
// 任一非法 ID 都表示调用方组装的批量请求不可信,不能静默跳过。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "user_ids contains invalid value")
|
||||
}
|
||||
if seen[userID] {
|
||||
continue
|
||||
}
|
||||
seen[userID] = true
|
||||
uniqueUserIDs = append(uniqueUserIDs, userID)
|
||||
}
|
||||
if s.repository == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
|
||||
}
|
||||
|
||||
return s.repository.BatchGetHostProfiles(ctx, uniqueUserIDs)
|
||||
}
|
||||
|
||||
// GetBDProfile 按指定 role 读取普通 BD 或 BD Leader 身份事实。
|
||||
func (s *Service) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) {
|
||||
if s.repository == nil {
|
||||
|
||||
@ -330,6 +330,45 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchGetHostProfilesReturnsExistingProfilesOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedActiveUser(t, repository, 801, 10)
|
||||
seedActiveUser(t, repository, 802, 20)
|
||||
seedActiveUser(t, repository, 803, 30)
|
||||
repository.PutHostProfile(hostdomain.HostProfile{UserID: 801, Status: hostdomain.HostStatusActive, Source: "batch-test"})
|
||||
repository.PutHostProfile(hostdomain.HostProfile{UserID: 802, Status: hostdomain.HostStatusDisabled, Source: "batch-test"})
|
||||
svc := newHostService(repository, 4000, 5000)
|
||||
|
||||
profiles, err := svc.BatchGetHostProfiles(ctx, []int64{801, 802, 803, 801})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchGetHostProfiles failed: %v", err)
|
||||
}
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("batch host profiles should only include existing rows, got %+v", profiles)
|
||||
}
|
||||
if profiles[801].UserID != 801 || profiles[801].Status != hostdomain.HostStatusActive || profiles[801].RegionID != 10 {
|
||||
t.Fatalf("active host profile mismatch: %+v", profiles[801])
|
||||
}
|
||||
if profiles[802].UserID != 802 || profiles[802].Status != hostdomain.HostStatusDisabled || profiles[802].RegionID != 20 {
|
||||
t.Fatalf("disabled host profile should still be returned for gateway-side filtering: %+v", profiles[802])
|
||||
}
|
||||
if _, ok := profiles[803]; ok {
|
||||
t.Fatalf("missing host profile must not be returned as placeholder: %+v", profiles[803])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchGetHostProfilesRejectsInvalidUserID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newHostService(repository, 4000, 5000)
|
||||
|
||||
_, err := svc.BatchGetHostProfiles(ctx, []int64{801, 0})
|
||||
if xerr.CodeOf(err) != xerr.InvalidArgument {
|
||||
t.Fatalf("invalid batch user id should fail with INVALID_ARGUMENT: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -100,6 +100,41 @@ func (r *Repository) GetHostProfile(ctx context.Context, userID int64) (hostdoma
|
||||
return queryHostProfile(ctx, r.db, "WHERE user_id = ?", userID)
|
||||
}
|
||||
|
||||
// BatchGetHostProfiles 批量读取 Host 身份事实,缺失 user_id 不填充空 profile。
|
||||
func (r *Repository) BatchGetHostProfiles(ctx context.Context, userIDs []int64) (map[int64]hostdomain.HostProfile, error) {
|
||||
if len(userIDs) == 0 {
|
||||
return map[int64]hostdomain.HostProfile{}, nil
|
||||
}
|
||||
placeholders := make([]string, 0, len(userIDs))
|
||||
args := make([]any, 0, len(userIDs)+1)
|
||||
args = append(args, appcode.FromContext(ctx))
|
||||
for _, userID := range userIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, userID)
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM host_profiles
|
||||
WHERE app_code = ? AND user_id IN (%s)
|
||||
`, hostProfileColumns, strings.Join(placeholders, ",")), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[int64]hostdomain.HostProfile, len(userIDs))
|
||||
for rows.Next() {
|
||||
// 返回 map 以 user_id 为键,gateway 可以精确区分“非主播/缺失”和“主播但状态不可入账”。
|
||||
profile, err := scanHostProfile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[profile.UserID] = profile
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetBDProfile 按 role 读取普通 BD 或 BD Leader 身份事实。
|
||||
func (r *Repository) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) {
|
||||
if role == hostdomain.BDRoleLeader {
|
||||
|
||||
@ -187,6 +187,43 @@ func TestBDLeaderSelfInviteFlowThroughGRPCUsesSplitIdentityTables(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchGetHostProfilesThroughGRPCReturnsProfilesByUserID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.PutRegion(userdomain.Region{RegionID: 710, RegionCode: "HOST-BATCH-1", Name: "Host Batch One"})
|
||||
repository.PutRegion(userdomain.Region{RegionID: 720, RegionCode: "HOST-BATCH-2", Name: "Host Batch Two"})
|
||||
seedActiveRPCUser(t, repository, 11001, 710)
|
||||
seedActiveRPCUser(t, repository, 11002, 720)
|
||||
seedActiveRPCUser(t, repository, 11003, 720)
|
||||
repository.PutHostProfile(hostdomain.HostProfile{UserID: 11001, Status: hostdomain.HostStatusActive, Source: "grpc-batch"})
|
||||
repository.PutHostProfile(hostdomain.HostProfile{UserID: 11002, Status: hostdomain.HostStatusDisabled, Source: "grpc-batch"})
|
||||
hostSvc := hostservice.New(repository.HostRepository())
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(repository), hostSvc)
|
||||
meta := &userv1.RequestMeta{AppCode: "lalu", RequestId: "req-host-batch"}
|
||||
|
||||
resp, err := server.BatchGetHostProfiles(ctx, &userv1.BatchGetHostProfilesRequest{Meta: meta, UserIds: []int64{11001, 11002, 11003}})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchGetHostProfiles RPC failed: %v", err)
|
||||
}
|
||||
profiles := resp.GetHostProfiles()
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("batch host profiles should omit missing rows, got %+v", profiles)
|
||||
}
|
||||
if profiles[11001].GetStatus() != hostdomain.HostStatusActive || profiles[11001].GetRegionId() != 710 {
|
||||
t.Fatalf("active host profile mismatch: %+v", profiles[11001])
|
||||
}
|
||||
if profiles[11002].GetStatus() != hostdomain.HostStatusDisabled || profiles[11002].GetRegionId() != 720 {
|
||||
t.Fatalf("disabled host profile should be returned for caller-side policy filtering: %+v", profiles[11002])
|
||||
}
|
||||
if profiles[11003] != nil {
|
||||
t.Fatalf("missing host profile must not be returned as placeholder: %+v", profiles[11003])
|
||||
}
|
||||
|
||||
if _, err := server.BatchGetHostProfiles(ctx, &userv1.BatchGetHostProfilesRequest{Meta: meta, UserIds: []int64{11001, 0}}); xerr.ReasonFromGRPC(err) != xerr.InvalidArgument {
|
||||
t.Fatalf("invalid batch user id should be converted to INVALID_ARGUMENT: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedActiveRPCUser(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64) {
|
||||
t.Helper()
|
||||
repository.PutUser(userdomain.User{
|
||||
|
||||
@ -300,6 +300,25 @@ func (s *Server) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileR
|
||||
return &userv1.GetHostProfileResponse{HostProfile: toProtoHostProfile(profile)}, nil
|
||||
}
|
||||
|
||||
// BatchGetHostProfiles 一次返回多个用户的主播身份事实,缺失用户不返回占位对象。
|
||||
func (s *Server) BatchGetHostProfiles(ctx context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
|
||||
}
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
profiles, err := s.hostSvc.BatchGetHostProfiles(ctx, req.GetUserIds())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
|
||||
result := make(map[int64]*userv1.HostProfile, len(profiles))
|
||||
for userID, profile := range profiles {
|
||||
// map key 固定使用 user_id,调用方按原 target 顺序重新组装业务快照。
|
||||
result[userID] = toProtoHostProfile(profile)
|
||||
}
|
||||
return &userv1.BatchGetHostProfilesResponse{HostProfiles: result}, nil
|
||||
}
|
||||
|
||||
// GetBDProfile 按调用方声明的角色返回普通 BD 或 BD Leader 身份事实。
|
||||
func (s *Server) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
|
||||
@ -585,6 +585,7 @@ CREATE TABLE IF NOT EXISTS external_recharge_orders (
|
||||
command_id VARCHAR(128) NOT NULL COMMENT 'H5 幂等命令 ID',
|
||||
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
|
||||
target_region_id BIGINT NOT NULL COMMENT '目标区域 ID',
|
||||
target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '目标国家 ID',
|
||||
target_country_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '目标国家码',
|
||||
audience_type VARCHAR(32) NOT NULL COMMENT '购买对象',
|
||||
product_id BIGINT NOT NULL COMMENT '商品 ID',
|
||||
@ -617,6 +618,15 @@ CREATE TABLE IF NOT EXISTS external_recharge_orders (
|
||||
KEY idx_external_recharge_orders_tx (app_code, provider_code, tx_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表';
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'external_recharge_orders' AND COLUMN_NAME = 'target_country_id') = 0,
|
||||
'ALTER TABLE external_recharge_orders ADD COLUMN target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT ''目标国家 ID'' AFTER target_region_id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT IGNORE INTO third_party_payment_channels (app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms)
|
||||
|
||||
@ -63,6 +63,7 @@ type GooglePaymentCommand struct {
|
||||
CommandID string
|
||||
UserID int64
|
||||
RegionID int64
|
||||
CountryID int64
|
||||
ProductID int64
|
||||
ProductCode string
|
||||
PackageName string
|
||||
@ -206,6 +207,7 @@ type ExternalRechargeOrder struct {
|
||||
CommandID string
|
||||
TargetUserID int64
|
||||
TargetRegionID int64
|
||||
TargetCountryID int64
|
||||
TargetCountryCode string
|
||||
AudienceType string
|
||||
ProductID int64
|
||||
@ -238,6 +240,7 @@ type CreateExternalRechargeOrderCommand struct {
|
||||
CommandID string
|
||||
TargetUserID int64
|
||||
TargetRegionID int64
|
||||
TargetCountryID int64
|
||||
TargetCountryCode string
|
||||
AudienceType string
|
||||
ProductID int64
|
||||
|
||||
@ -2694,6 +2694,12 @@ func TestH5MifaPayStatusQueryCreditsOrder(t *testing.T) {
|
||||
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 {
|
||||
t.Fatalf("mifapay query credit should write one recharge record, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("external_recharge_orders", "order_id = ? AND target_country_id = ?", order.OrderID, order.TargetCountryID); got != 1 {
|
||||
t.Fatalf("mifapay local order should persist target country id, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ?", credited.TransactionID, "WalletRechargeRecorded", order.TargetCountryID, order.TargetCountryID, order.TargetRegionID); got != 1 {
|
||||
t.Fatalf("mifapay recharge fact should carry target country/region for BI, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH5MifaPayCoinSellerCreditsSellerStockUSDTEquivalent(t *testing.T) {
|
||||
@ -2859,6 +2865,7 @@ func createRedirectedMifaPayOrder(t *testing.T, repository *mysqltest.Repository
|
||||
CommandID: commandID,
|
||||
TargetUserID: targetUserID,
|
||||
TargetRegionID: targetRegionID,
|
||||
TargetCountryID: 63,
|
||||
TargetCountryCode: "SA",
|
||||
AudienceType: ledger.RechargeAudienceNormal,
|
||||
ProductID: product.ProductID,
|
||||
@ -3834,6 +3841,21 @@ func TestGiftConfigRegionFilter(t *testing.T) {
|
||||
t.Fatalf("create gift config %s failed: %v", command.GiftID, err)
|
||||
}
|
||||
}
|
||||
if _, err := svc.UpdateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
|
||||
GiftID: "regional-saudi",
|
||||
ResourceID: saudiResource.ResourceID,
|
||||
Status: resourcedomain.StatusActive,
|
||||
Name: "Regional Saudi",
|
||||
PriceVersion: "v2",
|
||||
CoinPrice: 22,
|
||||
HeatValue: 22,
|
||||
OperatorUserID: 90001,
|
||||
RegionIDs: []int64{1002, 1001},
|
||||
}); err != nil {
|
||||
t.Fatalf("update regional saudi gift price failed: %v", err)
|
||||
}
|
||||
repository.InsertRawGiftPrice("regional-saudi", "v8-inactive", "inactive", 0)
|
||||
repository.InsertRawGiftPrice("regional-saudi", "v9-future", "active", time.Now().Add(time.Hour).UnixMilli())
|
||||
|
||||
items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
|
||||
Keyword: "regional-",
|
||||
@ -3849,6 +3871,16 @@ func TestGiftConfigRegionFilter(t *testing.T) {
|
||||
if total != 2 || !giftIDsContain(items, "regional-global") || !giftIDsContain(items, "regional-saudi") || giftIDsContain(items, "regional-egypt") {
|
||||
t.Fatalf("region gift list mismatch total=%d items=%+v", total, items)
|
||||
}
|
||||
saudiGift, ok := giftConfigByID(items, "regional-saudi")
|
||||
if !ok {
|
||||
t.Fatalf("regional saudi gift must be returned: %+v", items)
|
||||
}
|
||||
if saudiGift.PriceVersion != "v2" || saudiGift.CoinPrice != 22 || saudiGift.GiftPointAmount != 0 || saudiGift.HeatValue != 22 {
|
||||
t.Fatalf("batch latest gift price mismatch: %+v", saudiGift)
|
||||
}
|
||||
if !int64sEqual(saudiGift.RegionIDs, []int64{1001, 1002}) {
|
||||
t.Fatalf("batch region ids must return full configured regions, got %+v", saudiGift.RegionIDs)
|
||||
}
|
||||
|
||||
repository.SetBalance(51001, 100)
|
||||
if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||||
@ -5712,6 +5744,7 @@ func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
|
||||
CommandID: "google-pay-product-name",
|
||||
UserID: 53001,
|
||||
RegionID: 1001,
|
||||
CountryID: 199,
|
||||
ProductID: product.ProductID,
|
||||
ProductCode: googleProductID,
|
||||
PackageName: "com.org.laluparty",
|
||||
@ -5736,6 +5769,9 @@ func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
|
||||
if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ? AND total_coin_amount = ? AND total_usd_minor_amount = ?", int64(53001), int64(1500), int64(150)); got != 1 {
|
||||
t.Fatalf("google payment should aggregate recharge USD minor from micro amount, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(199), int64(199), int64(1001), ledger.RechargeChannelGoogle); got != 1 {
|
||||
t.Fatalf("google recharge fact should carry target country/region for BI, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmGooglePaymentAcceptsLegacyProductCodeInput(t *testing.T) {
|
||||
@ -6024,6 +6060,27 @@ func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func giftConfigByID(items []resourcedomain.GiftConfig, giftID string) (resourcedomain.GiftConfig, bool) {
|
||||
for _, item := range items {
|
||||
if item.GiftID == giftID {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return resourcedomain.GiftConfig{}, false
|
||||
}
|
||||
|
||||
func int64sEqual(left []int64, right []int64) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func giftTypeCodesContain(items []resourcedomain.GiftTypeConfig, typeCode string) bool {
|
||||
for _, item := range items {
|
||||
if item.TypeCode == typeCode {
|
||||
|
||||
@ -29,12 +29,12 @@ func (r *Repository) CreateExternalRechargeOrder(ctx context.Context, command le
|
||||
order := externalRechargeOrderFromCommand(command, product, method, providerAmountMinor, receiveAddress, time.Now().UnixMilli())
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO external_recharge_orders (
|
||||
app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type,
|
||||
app_code, order_id, command_id, target_user_id, target_region_id, target_country_id, target_country_code, audience_type,
|
||||
product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id,
|
||||
country_code, currency_code, provider_amount_minor, pay_way, pay_type, receive_address, status,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryCode,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryID, order.TargetCountryCode,
|
||||
order.AudienceType, order.ProductID, order.ProductCode, order.ProductName, order.CoinAmount, order.USDMinorAmount,
|
||||
order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor,
|
||||
order.PayWay, order.PayType, order.ReceiveAddress, order.Status, order.CreatedAtMS, order.UpdatedAtMS,
|
||||
@ -64,12 +64,12 @@ func (r *Repository) CreateTemporaryExternalRechargeOrder(ctx context.Context, c
|
||||
order := temporaryExternalRechargeOrderFromCommand(command, method, providerAmountMinor, nowMS)
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO external_recharge_orders (
|
||||
app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type,
|
||||
app_code, order_id, command_id, target_user_id, target_region_id, target_country_id, target_country_code, audience_type,
|
||||
product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id,
|
||||
country_code, currency_code, provider_amount_minor, pay_way, pay_type, receive_address, status,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryCode,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryID, order.TargetCountryCode,
|
||||
order.AudienceType, order.ProductID, order.ProductCode, order.ProductName, order.CoinAmount, order.USDMinorAmount,
|
||||
order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor,
|
||||
order.PayWay, order.PayType, order.ReceiveAddress, order.Status, order.CreatedAtMS, order.UpdatedAtMS,
|
||||
@ -180,6 +180,7 @@ func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode st
|
||||
ProviderCode: order.ProviderCode,
|
||||
PaymentMethodID: order.PaymentMethodID,
|
||||
TargetUserID: order.TargetUserID,
|
||||
TargetCountryID: order.TargetCountryID,
|
||||
TargetRegionID: order.TargetRegionID,
|
||||
Amount: order.CoinAmount,
|
||||
TargetAssetType: assetType,
|
||||
@ -449,7 +450,7 @@ func (r *Repository) lockExternalRechargeOrder(ctx context.Context, tx *sql.Tx,
|
||||
|
||||
func externalRechargeOrderSelectSQL() string {
|
||||
return `
|
||||
SELECT app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type,
|
||||
SELECT app_code, order_id, command_id, target_user_id, target_region_id, target_country_id, target_country_code, audience_type,
|
||||
product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id,
|
||||
country_code, currency_code, provider_amount_minor, pay_way, pay_type, COALESCE(pay_url, ''),
|
||||
provider_order_id, tx_hash, receive_address, status, failure_reason, wallet_transaction_id,
|
||||
@ -466,6 +467,7 @@ func scanExternalRechargeOrder(scanner scanTarget) (ledger.ExternalRechargeOrder
|
||||
&order.CommandID,
|
||||
&order.TargetUserID,
|
||||
&order.TargetRegionID,
|
||||
&order.TargetCountryID,
|
||||
&order.TargetCountryCode,
|
||||
&order.AudienceType,
|
||||
&order.ProductID,
|
||||
@ -512,6 +514,7 @@ func externalRechargeOrderFromCommand(command ledger.CreateExternalRechargeOrder
|
||||
CommandID: strings.TrimSpace(command.CommandID),
|
||||
TargetUserID: command.TargetUserID,
|
||||
TargetRegionID: command.TargetRegionID,
|
||||
TargetCountryID: command.TargetCountryID,
|
||||
TargetCountryCode: strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)),
|
||||
AudienceType: ledger.NormalizeRechargeAudienceType(command.AudienceType),
|
||||
ProductID: product.ProductID,
|
||||
|
||||
@ -39,28 +39,36 @@ func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.L
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
items := make([]resourcedomain.GiftConfig, 0, query.PageSize)
|
||||
giftIDs := make([]string, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item, err := scanGiftConfig(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
item.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, item.GiftID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs)
|
||||
if priceErr == nil {
|
||||
item.PriceVersion = price.PriceVersion
|
||||
item.ChargeAssetType = price.ChargeAssetType
|
||||
item.CoinPrice = price.CoinPrice
|
||||
item.GiftPointAmount = 0
|
||||
item.HeatValue = price.CoinPrice
|
||||
}
|
||||
items = append(items, item)
|
||||
giftIDs = append(giftIDs, item.GiftID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
regionsByGiftID, err := r.listGiftConfigRegionIDsByGiftIDs(ctx, r.db, giftIDs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pricesByGiftID, err := r.latestGiftPrices(ctx, r.db, giftIDs, nowMs)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for index := range items {
|
||||
items[index].RegionIDs = regionsByGiftID[items[index].GiftID]
|
||||
if price, ok := pricesByGiftID[items[index].GiftID]; ok {
|
||||
applyGiftPriceToConfig(&items[index], price)
|
||||
}
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
@ -75,13 +83,7 @@ func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resource
|
||||
}
|
||||
price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli())
|
||||
if priceErr == nil {
|
||||
gift.PriceVersion = price.PriceVersion
|
||||
gift.ChargeAssetType = price.ChargeAssetType
|
||||
gift.CoinPrice = price.CoinPrice
|
||||
|
||||
gift.GiftPointAmount = 0
|
||||
|
||||
gift.HeatValue = price.CoinPrice
|
||||
applyGiftPriceToConfig(&gift, price)
|
||||
}
|
||||
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID)
|
||||
if err != nil {
|
||||
@ -102,11 +104,7 @@ func (r *Repository) getGiftConfigForUpdateTx(ctx context.Context, tx *sql.Tx, g
|
||||
|
||||
price, priceErr := r.latestGiftPrice(ctx, tx, gift.GiftID, time.Now().UnixMilli())
|
||||
if priceErr == nil {
|
||||
gift.PriceVersion = price.PriceVersion
|
||||
gift.ChargeAssetType = price.ChargeAssetType
|
||||
gift.CoinPrice = price.CoinPrice
|
||||
gift.GiftPointAmount = 0
|
||||
gift.HeatValue = price.CoinPrice
|
||||
applyGiftPriceToConfig(&gift, price)
|
||||
}
|
||||
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID)
|
||||
if err != nil {
|
||||
@ -137,7 +135,7 @@ func (r *Repository) replaceGiftConfigRegionsTx(ctx context.Context, tx *sql.Tx,
|
||||
|
||||
func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) {
|
||||
rows, err := querier.QueryContext(ctx, `
|
||||
SELECT region_id
|
||||
SELECT region_id
|
||||
FROM gift_config_regions
|
||||
WHERE app_code = ? AND gift_id = ?
|
||||
ORDER BY region_id ASC`,
|
||||
@ -158,6 +156,38 @@ func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQue
|
||||
return regionIDs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) listGiftConfigRegionIDsByGiftIDs(ctx context.Context, querier sqlQuerier, giftIDs []string) (map[string][]int64, error) {
|
||||
regionsByGiftID := make(map[string][]int64, len(giftIDs))
|
||||
if len(giftIDs) == 0 {
|
||||
return regionsByGiftID, nil
|
||||
}
|
||||
for _, giftID := range giftIDs {
|
||||
// 每个礼物都预置空切片,保持旧版逐个查询时“无区域行返回空列表”的响应语义。
|
||||
regionsByGiftID[giftID] = []int64{}
|
||||
}
|
||||
args := append([]any{appcode.FromContext(ctx)}, stringAnyArgs(giftIDs)...)
|
||||
rows, err := querier.QueryContext(ctx, `
|
||||
SELECT gift_id, region_id
|
||||
FROM gift_config_regions
|
||||
WHERE app_code = ? AND gift_id IN (`+placeholders(len(giftIDs))+`)
|
||||
ORDER BY gift_id ASC, region_id ASC`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var giftID string
|
||||
var regionID int64
|
||||
if err := rows.Scan(&giftID, ®ionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
regionsByGiftID[giftID] = append(regionsByGiftID[giftID], regionID)
|
||||
}
|
||||
return regionsByGiftID, rows.Err()
|
||||
}
|
||||
|
||||
func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
|
||||
var gift resourcedomain.GiftConfig
|
||||
var resource resourcedomain.Resource
|
||||
@ -269,6 +299,7 @@ func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) res
|
||||
if strings.TrimSpace(query.GiftTypeCode) != "" {
|
||||
query.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(query.GiftTypeCode)
|
||||
}
|
||||
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
|
||||
// 房间礼物面板会一次预加载完整配置;礼物列表单独允许 500,避免复用资源列表 100 上限导致面板天然少数据。
|
||||
query.Page, query.PageSize = normalizePageWithMax(query.Page, query.PageSize, 500)
|
||||
return query
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||||
)
|
||||
|
||||
func TestNormalizeGiftConfigListQueryAllowsPanelPageSize(t *testing.T) {
|
||||
query := normalizeGiftConfigListQuery(resourcedomain.ListGiftConfigsQuery{
|
||||
Page: 1,
|
||||
PageSize: 500,
|
||||
})
|
||||
if query.PageSize != 500 {
|
||||
t.Fatalf("gift panel list page size must allow 500, got %d", query.PageSize)
|
||||
}
|
||||
|
||||
query = normalizeGiftConfigListQuery(resourcedomain.ListGiftConfigsQuery{
|
||||
Page: 1,
|
||||
PageSize: 501,
|
||||
})
|
||||
if query.PageSize != 500 {
|
||||
t.Fatalf("gift list page size must be capped at 500, got %d", query.PageSize)
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,61 @@ func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier,
|
||||
return price, nil
|
||||
}
|
||||
|
||||
func (r *Repository) latestGiftPrices(ctx context.Context, querier sqlQuerier, giftIDs []string, nowMs int64) (map[string]giftPrice, error) {
|
||||
pricesByGiftID := make(map[string]giftPrice, len(giftIDs))
|
||||
if len(giftIDs) == 0 {
|
||||
return pricesByGiftID, nil
|
||||
}
|
||||
args := append([]any{appcode.FromContext(ctx)}, stringAnyArgs(giftIDs)...)
|
||||
args = append(args, nowMs, nowMs)
|
||||
rows, err := querier.QueryContext(ctx, `
|
||||
SELECT gp.gift_id, gp.price_version, COALESCE(gp.charge_asset_type, 'COIN'), gp.coin_price, gp.gift_point_amount, gp.heat_value
|
||||
FROM wallet_gift_prices gp
|
||||
WHERE gp.app_code = ? AND gp.gift_id IN (`+placeholders(len(giftIDs))+`)
|
||||
AND gp.status = 'active' AND gp.effective_at_ms <= ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM wallet_gift_prices newer
|
||||
WHERE newer.app_code = gp.app_code
|
||||
AND newer.gift_id = gp.gift_id
|
||||
AND newer.status = 'active'
|
||||
AND newer.effective_at_ms <= ?
|
||||
AND (
|
||||
newer.effective_at_ms > gp.effective_at_ms
|
||||
OR (newer.effective_at_ms = gp.effective_at_ms AND newer.price_version > gp.price_version)
|
||||
)
|
||||
)
|
||||
ORDER BY gp.gift_id ASC`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var price giftPrice
|
||||
if err := rows.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
|
||||
pricesByGiftID[price.GiftID] = price
|
||||
}
|
||||
return pricesByGiftID, rows.Err()
|
||||
}
|
||||
|
||||
func applyGiftPriceToConfig(gift *resourcedomain.GiftConfig, price giftPrice) {
|
||||
if gift == nil {
|
||||
return
|
||||
}
|
||||
gift.PriceVersion = price.PriceVersion
|
||||
gift.ChargeAssetType = price.ChargeAssetType
|
||||
gift.CoinPrice = price.CoinPrice
|
||||
// GIFT_POINT 已下线;列表继续把历史价格行里的 gift_point_amount 压成 0,避免旧数据重新暴露给客户端。
|
||||
gift.GiftPointAmount = 0
|
||||
// 历史列表口径把热度值等同于金币价格,即使价格表里仍保留 heat_value,也不在展示面板引入第二套价值口径。
|
||||
gift.HeatValue = price.CoinPrice
|
||||
}
|
||||
|
||||
func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`
|
||||
|
||||
@ -86,6 +86,27 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go
|
||||
RechargeSequence: rechargeSequence,
|
||||
CreatedAtMS: nowMs,
|
||||
}
|
||||
// 充值事实事件只消费 coinSellerTransferMetadata 的统一维度字段;Google 支付审计仍保留 googlePaymentMetadata,
|
||||
// 但 WalletRechargeRecorded 必须带 country_id/target_country_id,统计服务不能再从 region 或用户库反推国家。
|
||||
rechargeMetadata := coinSellerTransferMetadata{
|
||||
AppCode: command.AppCode,
|
||||
PaymentOrderID: paymentOrderID,
|
||||
ProviderCode: ledger.PaymentProviderGooglePlay,
|
||||
TargetUserID: command.UserID,
|
||||
TargetCountryID: command.CountryID,
|
||||
TargetRegionID: command.RegionID,
|
||||
Amount: product.CoinAmount,
|
||||
TargetAssetType: ledger.AssetCoin,
|
||||
TargetBalanceAfter: balanceAfter,
|
||||
RechargeSequence: rechargeSequence,
|
||||
RechargeUSDMinor: rechargeUSDMinor,
|
||||
RechargeCurrencyCode: product.CurrencyCode,
|
||||
RechargePolicyID: 0,
|
||||
RechargePolicyVersion: product.PolicyVersion,
|
||||
RechargePolicyCoinAmount: product.CoinAmount,
|
||||
RechargePolicyUSDMinorAmount: rechargeUSDMinor,
|
||||
RechargeType: ledger.RechargeChannelGoogle,
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGooglePlayRecharge, requestHash, externalGooglePaymentRef(purchase, tokenHash), metadata, nowMs); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
@ -104,20 +125,7 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go
|
||||
}); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if err := r.insertRechargeRecord(ctx, tx, transactionID, coinSellerTransferMetadata{
|
||||
AppCode: command.AppCode,
|
||||
TargetUserID: command.UserID,
|
||||
TargetRegionID: command.RegionID,
|
||||
Amount: product.CoinAmount,
|
||||
TargetAssetType: ledger.AssetCoin,
|
||||
TargetBalanceAfter: balanceAfter,
|
||||
RechargeSequence: rechargeSequence,
|
||||
RechargeUSDMinor: rechargeUSDMinor,
|
||||
RechargeCurrencyCode: product.CurrencyCode,
|
||||
RechargePolicyVersion: product.PolicyVersion,
|
||||
RechargePolicyCoinAmount: product.CoinAmount,
|
||||
RechargePolicyUSDMinorAmount: rechargeUSDMinor,
|
||||
}, nowMs); err != nil {
|
||||
if err := r.insertRechargeRecord(ctx, tx, transactionID, rechargeMetadata, nowMs); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if err := r.insertGooglePaymentOrder(ctx, tx, paymentOrderID, command, product, purchase, tokenHash, transactionID, nowMs); err != nil {
|
||||
@ -126,7 +134,7 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go
|
||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, product.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
|
||||
googlePaymentCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
|
||||
rechargeRecordedEvent(transactionID, command.CommandID, command.UserID, product.CoinAmount, metadata, nowMs),
|
||||
rechargeRecordedEvent(transactionID, command.CommandID, command.UserID, product.CoinAmount, rechargeMetadata, nowMs),
|
||||
}); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
|
||||
@ -16,14 +16,21 @@ func (r *Repository) countResourceRows(ctx context.Context, table string, where
|
||||
}
|
||||
|
||||
func normalizePage(page int32, pageSize int32) (int32, int32) {
|
||||
return normalizePageWithMax(page, pageSize, 100)
|
||||
}
|
||||
|
||||
func normalizePageWithMax(page int32, pageSize int32, maxPageSize int32) (int32, int32) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
if maxPageSize <= 0 {
|
||||
maxPageSize = 100
|
||||
}
|
||||
if pageSize > maxPageSize {
|
||||
pageSize = maxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
@ -115,6 +115,7 @@ func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
|
||||
command_id VARCHAR(128) NOT NULL COMMENT '客户端命令幂等 ID',
|
||||
target_user_id BIGINT NOT NULL COMMENT '充值目标用户 ID',
|
||||
target_region_id BIGINT NOT NULL COMMENT '充值目标区域 ID',
|
||||
target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '充值目标国家 ID',
|
||||
target_country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '充值目标国家码',
|
||||
audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '商品适用用户类型',
|
||||
product_id BIGINT NOT NULL COMMENT '充值商品 ID',
|
||||
@ -148,6 +149,11 @@ func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE external_recharge_orders
|
||||
ADD COLUMN target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '充值目标国家 ID' AFTER target_region_id`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
return seedThirdPartyPaymentDefaults(ctx, db)
|
||||
}
|
||||
|
||||
|
||||
@ -693,7 +693,7 @@ func initDBPath(t testing.TB) string {
|
||||
|
||||
func validateTableName(table string) string {
|
||||
switch table {
|
||||
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records",
|
||||
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_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records",
|
||||
|
||||
@ -120,6 +120,7 @@ func (s *Server) CreateH5RechargeOrder(ctx context.Context, req *walletv1.Create
|
||||
CommandID: req.GetCommandId(),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
TargetRegionID: req.GetTargetRegionId(),
|
||||
TargetCountryID: req.GetTargetCountryId(),
|
||||
TargetCountryCode: req.GetTargetCountryCode(),
|
||||
AudienceType: req.GetAudienceType(),
|
||||
ProductID: req.GetProductId(),
|
||||
@ -305,6 +306,7 @@ func externalRechargeOrderToProto(order ledger.ExternalRechargeOrder) *walletv1.
|
||||
AppCode: order.AppCode,
|
||||
TargetUserId: order.TargetUserID,
|
||||
TargetRegionId: order.TargetRegionID,
|
||||
TargetCountryId: order.TargetCountryID,
|
||||
TargetCountryCode: order.TargetCountryCode,
|
||||
AudienceType: order.AudienceType,
|
||||
ProductId: order.ProductID,
|
||||
|
||||
@ -31,6 +31,7 @@ func (s *Server) ConfirmGooglePayment(ctx context.Context, req *walletv1.Confirm
|
||||
CommandID: req.GetCommandId(),
|
||||
UserID: req.GetUserId(),
|
||||
RegionID: req.GetRegionId(),
|
||||
CountryID: req.GetCountryId(),
|
||||
ProductID: req.GetProductId(),
|
||||
ProductCode: req.GetProductCode(),
|
||||
PackageName: req.GetPackageName(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user