修复性能问题

This commit is contained in:
zhx 2026-06-26 21:31:14 +08:00
parent 902ca5dc3c
commit 2254097ea4
56 changed files with 6247 additions and 2929 deletions

File diff suppressed because it is too large Load Diff

View File

@ -313,6 +313,15 @@ message GetHostProfileResponse {
HostProfile host_profile = 1; HostProfile host_profile = 1;
} }
message BatchGetHostProfilesRequest {
RequestMeta meta = 1;
repeated int64 user_ids = 2;
}
message BatchGetHostProfilesResponse {
map<int64, HostProfile> host_profiles = 1;
}
message GetBDProfileRequest { message GetBDProfileRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
int64 user_id = 2; int64 user_id = 2;
@ -526,6 +535,7 @@ service UserHostService {
rpc GetRoleInvitation(GetRoleInvitationRequest) returns (GetRoleInvitationResponse); rpc GetRoleInvitation(GetRoleInvitationRequest) returns (GetRoleInvitationResponse);
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse); rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse); rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc BatchGetHostProfiles(BatchGetHostProfilesRequest) returns (BatchGetHostProfilesResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse); rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse); rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse); rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse);

View File

@ -33,6 +33,7 @@ const (
UserHostService_GetRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleInvitation" UserHostService_GetRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleInvitation"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation" UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile" 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_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile" UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion" 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) GetRoleInvitation(ctx context.Context, in *GetRoleInvitationRequest, opts ...grpc.CallOption) (*GetRoleInvitationResponse, error)
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, 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) GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error) GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, 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 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) { func (c *userHostServiceClient) GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetBDProfileResponse) out := new(GetBDProfileResponse)
@ -321,6 +333,7 @@ type UserHostServiceServer interface {
GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error) GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error)
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error) GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
BatchGetHostProfiles(context.Context, *BatchGetHostProfilesRequest) (*BatchGetHostProfilesResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error) GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error) GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error) ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
@ -381,6 +394,9 @@ func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context,
func (UnimplementedUserHostServiceServer) GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error) { func (UnimplementedUserHostServiceServer) GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetHostProfile not implemented") 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) { func (UnimplementedUserHostServiceServer) GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetBDProfile not implemented") 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) 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) { func _UserHostService_GetBDProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetBDProfileRequest) in := new(GetBDProfileRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -885,6 +919,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetHostProfile", MethodName: "GetHostProfile",
Handler: _UserHostService_GetHostProfile_Handler, Handler: _UserHostService_GetHostProfile_Handler,
}, },
{
MethodName: "BatchGetHostProfiles",
Handler: _UserHostService_BatchGetHostProfiles_Handler,
},
{ {
MethodName: "GetBDProfile", MethodName: "GetBDProfile",
Handler: _UserHostService_GetBDProfile_Handler, Handler: _UserHostService_GetBDProfile_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -1134,6 +1134,8 @@ message ConfirmGooglePaymentRequest {
string purchase_token = 9; string purchase_token = 9;
string order_id = 10; string order_id = 10;
int64 purchase_time_ms = 11; int64 purchase_time_ms = 11;
// country_id gateway user-service BI
int64 country_id = 12;
} }
message ConfirmGooglePaymentResponse { message ConfirmGooglePaymentResponse {
@ -1340,6 +1342,8 @@ message ExternalRechargeOrder {
int64 created_at_ms = 25; int64 created_at_ms = 25;
int64 updated_at_ms = 26; int64 updated_at_ms = 26;
bool idempotent_replay = 27; bool idempotent_replay = 27;
// target_country_id
int64 target_country_id = 28;
} }
message CreateH5RechargeOrderRequest { message CreateH5RechargeOrderRequest {
@ -1361,6 +1365,8 @@ message CreateH5RechargeOrderRequest {
string payer_name = 15; string payer_name = 15;
string payer_account = 16; string payer_account = 16;
string payer_email = 17; string payer_email = 17;
// target_country_id gateway user-service H5
int64 target_country_id = 18;
} }
message CreateTemporaryRechargeOrderRequest { message CreateTemporaryRechargeOrderRequest {

View File

@ -106,6 +106,7 @@ type UserHostClient interface {
ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error)
ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, 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) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error) GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, 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) 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) { func (c *grpcUserHostClient) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
return c.client.GetBDProfile(ctx, req) return c.client.GetBDProfile(ctx, req)
} }

View File

@ -21,6 +21,8 @@ import (
"time" "time"
jwt "github.com/golang-jwt/jwt/v5" jwt "github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
@ -314,6 +316,7 @@ type fakeUserProfileClient struct {
lastBusinessLookup *userv1.BusinessUserLookupRequest lastBusinessLookup *userv1.BusinessUserLookupRequest
lastStats *userv1.GetMyProfileStatsRequest lastStats *userv1.GetMyProfileStatsRequest
lastBatch *userv1.BatchGetUsersRequest lastBatch *userv1.BatchGetUsersRequest
batchRequests []*userv1.BatchGetUsersRequest
getRequests []*userv1.GetUserRequest getRequests []*userv1.GetUserRequest
lastComplete *userv1.CompleteOnboardingRequest lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest lastUpdate *userv1.UpdateUserProfileRequest
@ -459,9 +462,13 @@ type fakeUserHostClient struct {
listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse
listCoinSellersErr error listCoinSellersErr error
lastHost *userv1.GetHostProfileRequest lastHost *userv1.GetHostProfileRequest
lastBatchHost *userv1.BatchGetHostProfilesRequest
hostCalls int
batchHostCalls int
hostProfile *userv1.HostProfile hostProfile *userv1.HostProfile
hostProfiles map[int64]*userv1.HostProfile hostProfiles map[int64]*userv1.HostProfile
hostErr error hostErr error
batchHostErr error
lastBD *userv1.GetBDProfileRequest lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile bdProfile *userv1.BDProfile
bdErr error bdErr error
@ -577,9 +584,14 @@ type fakeWalletClient struct {
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
lastListGiftConfigs *walletv1.ListGiftConfigsRequest lastListGiftConfigs *walletv1.ListGiftConfigsRequest
listGiftConfigsCalls int
listGiftConfigsResp *walletv1.ListGiftConfigsResponse listGiftConfigsResp *walletv1.ListGiftConfigsResponse
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
listGiftTypesCalls int
listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse
lastListUserResources *walletv1.ListUserResourcesRequest
listUserResourcesCalls int
listUserResourcesResp *walletv1.ListUserResourcesResponse
lastGrantResource *walletv1.GrantResourceRequest lastGrantResource *walletv1.GrantResourceRequest
grantResourceResp *walletv1.ResourceGrantResponse grantResourceResp *walletv1.ResourceGrantResponse
grantResourceErr error 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) { func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
f.lastBatch = req f.lastBatch = req
f.batchRequests = append(f.batchRequests, req)
users := make(map[int64]*userv1.User, len(req.GetUserIds())) users := make(map[int64]*userv1.User, len(req.GetUserIds()))
for _, userID := range req.GetUserIds() { for _, userID := range req.GetUserIds() {
if f.usersByID != nil { if f.usersByID != nil {
@ -863,6 +876,7 @@ func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.Bat
Username: "user-" + strconv.FormatInt(userID, 10), Username: "user-" + strconv.FormatInt(userID, 10),
Gender: "female", Gender: "female",
Birth: "2000-01-02", Birth: "2000-01-02",
CountryId: 840,
Country: "US", Country: "US",
CountryName: "United States", CountryName: "United States",
CountryDisplayName: "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) { func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req f.lastHost = req
f.hostCalls++
if f.hostErr != nil { if f.hostErr != nil {
return nil, f.hostErr return nil, f.hostErr
} }
@ -1550,6 +1565,27 @@ func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHo
return &userv1.GetHostProfileResponse{}, nil 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) { func (f *fakeUserHostClient) GetBDProfile(_ context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
f.lastBD = req f.lastBD = req
if f.bdErr != nil { 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) { func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
f.lastListGiftConfigs = req f.lastListGiftConfigs = req
f.listGiftConfigsCalls++
if f.err != nil { if f.err != nil {
return nil, f.err 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) { func (f *fakeWalletClient) ListGiftTypeConfigs(_ context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) {
f.lastListGiftTypes = req f.lastListGiftTypes = req
f.listGiftTypesCalls++
if f.err != nil { if f.err != nil {
return nil, f.err 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 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 return &walletv1.ListUserResourcesResponse{}, nil
} }
@ -2617,7 +2660,8 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) { func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
roomClient := &fakeRoomClient{} roomClient := &fakeRoomClient{}
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}} 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) handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret")) 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}`))) 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 { if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
} }
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 43 { if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 1 || hostClient.lastBatchHost.GetUserIds()[0] != 43 {
t.Fatalf("gateway must query target host profile before sending gift: %+v", hostClient.lastHost) 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 { 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) 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 { if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) 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 { 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) 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" { 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) 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) { func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
@ -2734,6 +2787,9 @@ func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) 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() scopes := roomClient.lastGift.GetTargetHostScopes()
if len(scopes) != 2 || !scopes[0].GetTargetIsHost() || scopes[0].GetTargetHostRegionId() != 8801 || !scopes[1].GetTargetIsHost() || scopes[1].GetTargetHostRegionId() != 8802 { 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) 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) { func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
previousNow := roomapi.TimeNow previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) } 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) { func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) {
messageClient := &fakeMessageInboxClient{} messageClient := &fakeMessageInboxClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
@ -7686,6 +7936,7 @@ func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T)
if walletClient.lastGoogleConfirm == nil || if walletClient.lastGoogleConfirm == nil ||
walletClient.lastGoogleConfirm.GetUserId() != 42 || walletClient.lastGoogleConfirm.GetUserId() != 42 ||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 || walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
walletClient.lastGoogleConfirm.GetCountryId() != 840 ||
walletClient.lastGoogleConfirm.GetProductId() != 11 || walletClient.lastGoogleConfirm.GetProductId() != 11 ||
walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" || walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" ||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" || walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
@ -7737,6 +7988,7 @@ func TestConfirmGooglePaymentAllowsGoogleProductCodeWithoutProductID(t *testing.
if walletClient.lastGoogleConfirm == nil || if walletClient.lastGoogleConfirm == nil ||
walletClient.lastGoogleConfirm.GetUserId() != 42 || walletClient.lastGoogleConfirm.GetUserId() != 42 ||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 || walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
walletClient.lastGoogleConfirm.GetCountryId() != 840 ||
walletClient.lastGoogleConfirm.GetProductId() != 0 || walletClient.lastGoogleConfirm.GetProductId() != 0 ||
walletClient.lastGoogleConfirm.GetProductCode() != "first_recharge_google_999" || walletClient.lastGoogleConfirm.GetProductCode() != "first_recharge_google_999" ||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" || walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
@ -8079,6 +8331,7 @@ func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) {
DisplayUserId: "163055", DisplayUserId: "163055",
Username: "normal55", Username: "normal55",
Status: userv1.UserStatus_USER_STATUS_ACTIVE, Status: userv1.UserStatus_USER_STATUS_ACTIVE,
CountryId: 818,
Country: "EG", Country: "EG",
CountryDisplayName: "Egypt", CountryDisplayName: "Egypt",
RegionId: 7300, RegionId: 7300,
@ -8103,6 +8356,7 @@ func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) {
walletClient.lastH5CreateOrder.GetCommandId() != "cmd-h5-create-eg" || walletClient.lastH5CreateOrder.GetCommandId() != "cmd-h5-create-eg" ||
walletClient.lastH5CreateOrder.GetTargetUserId() != 55 || walletClient.lastH5CreateOrder.GetTargetUserId() != 55 ||
walletClient.lastH5CreateOrder.GetTargetRegionId() != 7300 || walletClient.lastH5CreateOrder.GetTargetRegionId() != 7300 ||
walletClient.lastH5CreateOrder.GetTargetCountryId() != 818 ||
walletClient.lastH5CreateOrder.GetTargetCountryCode() != "EG" || walletClient.lastH5CreateOrder.GetTargetCountryCode() != "EG" ||
walletClient.lastH5CreateOrder.GetAudienceType() != "normal" || walletClient.lastH5CreateOrder.GetAudienceType() != "normal" ||
walletClient.lastH5CreateOrder.GetProductId() != 93001 || walletClient.lastH5CreateOrder.GetProductId() != 93001 ||

View File

@ -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
}

View File

@ -2,6 +2,7 @@ package roomapi
import ( import (
"net/http" "net/http"
"time"
"hyapp/pkg/tencentrtc" "hyapp/pkg/tencentrtc"
"hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/client"
@ -22,6 +23,7 @@ type Handler struct {
userHostClient client.UserHostClient userHostClient client.UserHostClient
appConfigReader appapi.ConfigReader appConfigReader appapi.ConfigReader
walletClient client.WalletClient walletClient client.WalletClient
giftPanelCache *roomGiftPanelConfigCache
growthLevelClient client.GrowthLevelClient growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient achievementClient client.AchievementClient
rtcTokenConfig tencentrtc.TokenConfig rtcTokenConfig tencentrtc.TokenConfig
@ -55,6 +57,7 @@ func New(config Config) *Handler {
userHostClient: config.UserHostClient, userHostClient: config.UserHostClient,
appConfigReader: config.AppConfigReader, appConfigReader: config.AppConfigReader,
walletClient: config.WalletClient, walletClient: config.WalletClient,
giftPanelCache: newRoomGiftPanelConfigCache(45 * time.Second),
growthLevelClient: config.GrowthLevelClient, growthLevelClient: config.GrowthLevelClient,
achievementClient: config.AchievementClient, achievementClient: config.AchievementClient,
rtcTokenConfig: config.RTCTokenConfig, rtcTokenConfig: config.RTCTokenConfig,

View File

@ -15,6 +15,8 @@ import (
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/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) httpkit.WriteRPCError(writer, request, err)
return return
} }
giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{ panelConfig, err := h.roomGiftPanelBaseConfig(request, snapshot.GetVisibleRegionId())
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ActiveOnly: true,
})
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
return return
} }
giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes())) giftTypes := panelConfig.GiftTypes
for _, item := range giftTypeResp.GetGiftTypes() { gifts := panelConfig.Gifts
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)
bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts) bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts)
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) 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) { func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 { if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
return nil, nil return nil, nil
@ -1557,7 +1581,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }
senderCountryID, senderRegionID, err := h.resolveRoomActorCountryRegion(request) userSnapshots, err := h.resolveGiftUserSnapshots(request, targetUserIDs)
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
return return
@ -1568,7 +1592,6 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
return return
} }
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs)) firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
senderDisplayProfile, targetDisplayProfiles := h.resolveGiftDisplayProfiles(request, targetUserIDs)
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{ resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), 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, GiftId: body.GiftID,
GiftCount: body.GiftCount, GiftCount: body.GiftCount,
PoolId: body.PoolID, PoolId: body.PoolID,
SenderCountryId: senderCountryID, SenderCountryId: userSnapshots.senderCountryID,
SenderRegionId: senderRegionID, SenderRegionId: userSnapshots.senderRegionID,
TargetIsHost: firstTargetScope.GetTargetIsHost(), TargetIsHost: firstTargetScope.GetTargetIsHost(),
TargetHostRegionId: firstTargetScope.GetTargetHostRegionId(), TargetHostRegionId: firstTargetScope.GetTargetHostRegionId(),
TargetAgencyOwnerUserId: firstTargetScope.GetTargetAgencyOwnerUserId(), TargetAgencyOwnerUserId: firstTargetScope.GetTargetAgencyOwnerUserId(),
TargetHostScopes: targetHostScopes, TargetHostScopes: targetHostScopes,
EntitlementId: strings.TrimSpace(body.EntitlementID), EntitlementId: strings.TrimSpace(body.EntitlementID),
Source: strings.TrimSpace(body.Source), Source: strings.TrimSpace(body.Source),
SenderDisplayProfile: senderDisplayProfile, SenderDisplayProfile: userSnapshots.senderDisplayProfile,
TargetDisplayProfiles: targetDisplayProfiles, TargetDisplayProfiles: userSnapshots.targetDisplayProfiles,
}) })
httpkit.Write(writer, request, resp, err) 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 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) { func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) {
if targetUserID <= 0 || h.userHostClient == nil { if targetUserID <= 0 || h.userHostClient == nil {
// 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed避免客户端伪造主播入账。 // 未装配 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) { 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)) scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
for _, targetUserID := range targetUserIDs { for _, targetUserID := range targetUserIDs {
targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, targetUserID) targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, targetUserID)
@ -1654,31 +1752,16 @@ func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserI
return scopes, nil return scopes, nil
} }
func (h *Handler) resolveGiftDisplayProfiles(request *http.Request, targetUserIDs []int64) (*roomv1.SendGiftDisplayProfile, []*roomv1.SendGiftDisplayProfile) { func giftTargetHostScopeFromProfile(targetUserID int64, profile *userv1.HostProfile) *roomv1.SendGiftTargetHostScope {
if h.userProfileClient == nil { scope := &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
return nil, nil if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
// 只有 active host 且带区域才能进入工资政策链路disabled/缺区域按非主播处理,不影响正常送礼。
return scope
} }
senderUserID := auth.UserIDFromContext(request.Context()) scope.TargetIsHost = true
userIDs := uniquePositiveUserIDs(append([]int64{senderUserID}, targetUserIDs...)) scope.TargetHostRegionId = profile.GetRegionId()
if len(userIDs) == 0 { scope.TargetAgencyOwnerUserId = profile.GetCurrentAgencyOwnerUserId()
return nil, nil return scope
}
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
} }
func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfile { func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfile {

View File

@ -274,6 +274,7 @@ func (h *Handler) confirmGooglePayment(writer http.ResponseWriter, request *http
CommandId: commandID, CommandId: commandID,
UserId: userID, UserId: userID,
RegionId: profileResp.GetUser().GetRegionId(), RegionId: profileResp.GetUser().GetRegionId(),
CountryId: profileResp.GetUser().GetCountryId(),
ProductId: productID, ProductId: productID,
ProductCode: productCode, ProductCode: productCode,
PackageName: packageName, PackageName: packageName,

View File

@ -34,6 +34,7 @@ type h5RechargeTargetContext struct {
DisplayUserID string DisplayUserID string
Username string Username string
Avatar string Avatar string
CountryID int64
CountryCode string CountryCode string
CountryName string CountryName string
RegionID int64 RegionID int64
@ -229,6 +230,7 @@ func (h *Handler) createH5RechargeOrder(writer http.ResponseWriter, request *htt
CommandId: commandID, CommandId: commandID,
TargetUserId: target.UserID, TargetUserId: target.UserID,
TargetRegionId: target.RegionID, TargetRegionId: target.RegionID,
TargetCountryId: target.CountryID,
TargetCountryCode: target.CountryCode, TargetCountryCode: target.CountryCode,
AudienceType: target.AudienceType, AudienceType: target.AudienceType,
ProductId: productID, ProductId: productID,
@ -555,6 +557,7 @@ func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *h
DisplayUserID: user.GetDisplayUserId(), DisplayUserID: user.GetDisplayUserId(),
Username: user.GetUsername(), Username: user.GetUsername(),
Avatar: user.GetAvatar(), Avatar: user.GetAvatar(),
CountryID: user.GetCountryId(),
CountryCode: h5RechargeCountryCodeFromUser(user), CountryCode: h5RechargeCountryCodeFromUser(user),
CountryName: firstNonEmptyString(user.GetCountryDisplayName(), user.GetCountryName()), CountryName: firstNonEmptyString(user.GetCountryDisplayName(), user.GetCountryName()),
RegionID: user.GetRegionId(), RegionID: user.GetRegionId(),

View File

@ -52,6 +52,7 @@ presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s" mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s" mic_publish_scan_interval: "1s"
room_rocket_launch_scan_interval: "1s" room_rocket_launch_scan_interval: "1s"
room_list_cache_refresh_interval: "5m"
rocketmq: rocketmq:
# Docker 本地使用 compose RocketMQ 验证 owner outbox 到下游读模型的真实链路。 # Docker 本地使用 compose RocketMQ 验证 owner outbox 到下游读模型的真实链路。
enabled: true enabled: true
@ -93,7 +94,8 @@ outbox_worker:
publish_mode: "mq" publish_mode: "mq"
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 100
publish_timeout: "3s" concurrency: 16
publish_timeout: "15s"
retry_strategy: "exponential_backoff" retry_strategy: "exponential_backoff"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
@ -102,10 +104,16 @@ robot_outbox_worker:
# Docker 本地单独投递机器人展示事件,避免压住主 room_outbox fanout。 # Docker 本地单独投递机器人展示事件,避免压住主 room_outbox fanout。
enabled: true enabled: true
publish_mode: "mq" publish_mode: "mq"
poll_interval: "1s" poll_interval: "200ms"
batch_size: 30 batch_size: 100
publish_timeout: "3s" concurrency: 1
publish_timeout: "15s"
retry_strategy: "exponential_backoff" retry_strategy: "exponential_backoff"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
max_backoff: "5m" max_backoff: "5m"
stale_discard_after: "60s"
cleanup_interval: "30s"
cleanup_active_after: "5m"
cleanup_terminal_after: "1h"
cleanup_batch_size: 50000

View File

@ -61,6 +61,7 @@ presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s" mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s" mic_publish_scan_interval: "1s"
room_rocket_launch_scan_interval: "1s" room_rocket_launch_scan_interval: "1s"
room_list_cache_refresh_interval: "5m"
rocketmq: rocketmq:
# 线上建议开启room_outbox 只投 MQactivity/notice/IM bridge 各自用 consumer group 解耦消费。 # 线上建议开启room_outbox 只投 MQactivity/notice/IM bridge 各自用 consumer group 解耦消费。
enabled: true enabled: true
@ -103,7 +104,8 @@ outbox_worker:
publish_mode: "mq" publish_mode: "mq"
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 100
publish_timeout: "3s" concurrency: 16
publish_timeout: "15s"
retry_strategy: "exponential_backoff" retry_strategy: "exponential_backoff"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
@ -112,10 +114,16 @@ robot_outbox_worker:
# 机器人房间展示事件独立投递,只服务 IM 展示,不投 activity/statistics 主业务消费者。 # 机器人房间展示事件独立投递,只服务 IM 展示,不投 activity/statistics 主业务消费者。
enabled: true enabled: true
publish_mode: "mq" publish_mode: "mq"
poll_interval: "1s" poll_interval: "200ms"
batch_size: 30 batch_size: 100
publish_timeout: "3s" concurrency: 1
publish_timeout: "15s"
retry_strategy: "exponential_backoff" retry_strategy: "exponential_backoff"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
max_backoff: "5m" max_backoff: "5m"
stale_discard_after: "60s"
cleanup_interval: "30s"
cleanup_active_after: "5m"
cleanup_terminal_after: "1h"
cleanup_batch_size: 50000

View File

@ -55,6 +55,7 @@ presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s" mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s" mic_publish_scan_interval: "1s"
room_rocket_launch_scan_interval: "1s" room_rocket_launch_scan_interval: "1s"
room_list_cache_refresh_interval: "5m"
rocketmq: rocketmq:
# 本地默认走 RocketMQ fanout和 Docker/testbox/线上保持同一条 outbox 链路。 # 本地默认走 RocketMQ fanout和 Docker/testbox/线上保持同一条 outbox 链路。
enabled: true enabled: true
@ -96,7 +97,8 @@ outbox_worker:
publish_mode: "mq" publish_mode: "mq"
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 100
publish_timeout: "3s" concurrency: 16
publish_timeout: "15s"
retry_strategy: "exponential_backoff" retry_strategy: "exponential_backoff"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
@ -105,10 +107,16 @@ robot_outbox_worker:
# 机器人展示事件走独立 outbox/topic只投房间 IM不占用真人 room_outbox 主流程。 # 机器人展示事件走独立 outbox/topic只投房间 IM不占用真人 room_outbox 主流程。
enabled: true enabled: true
publish_mode: "mq" publish_mode: "mq"
poll_interval: "1s" poll_interval: "200ms"
batch_size: 30 batch_size: 100
publish_timeout: "3s" concurrency: 1
publish_timeout: "15s"
retry_strategy: "exponential_backoff" retry_strategy: "exponential_backoff"
max_retry_count: 10 max_retry_count: 10
initial_backoff: "5s" initial_backoff: "5s"
max_backoff: "5m" max_backoff: "5m"
stale_discard_after: "60s"
cleanup_interval: "30s"
cleanup_active_after: "5m"
cleanup_terminal_after: "1h"
cleanup_batch_size: 50000

View File

@ -249,6 +249,7 @@ func New(cfg config.Config) (*App, error) {
RobotOutboxPublisher: robotOutboxPublisher, RobotOutboxPublisher: robotOutboxPublisher,
HumanRobotPoolProvider: integration.NewGRPCHumanRoomRobotPoolProvider(robotConn, userConn), HumanRobotPoolProvider: integration.NewGRPCHumanRoomRobotPoolProvider(robotConn, userConn),
RoomGiftLeaderboard: roomservice.NewRedisRoomGiftLeaderboardStore(redisClient), RoomGiftLeaderboard: roomservice.NewRedisRoomGiftLeaderboardStore(redisClient),
RoomListCache: roomservice.NewRedisRoomListCacheStore(redisClient),
LuckyGiftSendLocker: roomservice.NewRedisLuckyGiftSendLocker(redisClient), LuckyGiftSendLocker: roomservice.NewRedisLuckyGiftSendLocker(redisClient),
LuckyGiftSendLockTTL: cfg.LuckyGiftSendLockTTL, LuckyGiftSendLockTTL: cfg.LuckyGiftSendLockTTL,
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient) }, 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.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 { if a.cfg.OutboxWorker.Enabled {
a.workers.Go(func(ctx context.Context) { a.workers.Go(func(ctx context.Context) {
a.service.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{ a.service.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
PollInterval: a.cfg.OutboxWorker.PollInterval, PollInterval: a.cfg.OutboxWorker.PollInterval,
BatchSize: a.cfg.OutboxWorker.BatchSize, BatchSize: a.cfg.OutboxWorker.BatchSize,
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout, Concurrency: a.cfg.OutboxWorker.Concurrency,
RetryStrategy: a.cfg.OutboxWorker.RetryStrategy, PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
MaxRetryCount: a.cfg.OutboxWorker.MaxRetryCount, RetryStrategy: a.cfg.OutboxWorker.RetryStrategy,
InitialBackoff: a.cfg.OutboxWorker.InitialBackoff, MaxRetryCount: a.cfg.OutboxWorker.MaxRetryCount,
MaxBackoff: a.cfg.OutboxWorker.MaxBackoff, 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 { if a.cfg.RobotOutboxWorker.Enabled {
a.workers.Go(func(ctx context.Context) { a.workers.Go(func(ctx context.Context) {
a.service.RunRobotOutboxWorker(ctx, roomservice.OutboxWorkerOptions{ a.service.RunRobotOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
PollInterval: a.cfg.RobotOutboxWorker.PollInterval, PollInterval: a.cfg.RobotOutboxWorker.PollInterval,
BatchSize: a.cfg.RobotOutboxWorker.BatchSize, BatchSize: a.cfg.RobotOutboxWorker.BatchSize,
PublishTimeout: a.cfg.RobotOutboxWorker.PublishTimeout, Concurrency: a.cfg.RobotOutboxWorker.Concurrency,
RetryStrategy: a.cfg.RobotOutboxWorker.RetryStrategy, PublishTimeout: a.cfg.RobotOutboxWorker.PublishTimeout,
MaxRetryCount: a.cfg.RobotOutboxWorker.MaxRetryCount, RetryStrategy: a.cfg.RobotOutboxWorker.RetryStrategy,
InitialBackoff: a.cfg.RobotOutboxWorker.InitialBackoff, MaxRetryCount: a.cfg.RobotOutboxWorker.MaxRetryCount,
MaxBackoff: a.cfg.RobotOutboxWorker.MaxBackoff, 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,
}) })
}) })
} }

View File

@ -84,6 +84,8 @@ type Config struct {
MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"` MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"`
// RoomRocketLaunchScanInterval 是本节点扫描到点发射火箭的周期。 // RoomRocketLaunchScanInterval 是本节点扫描到点发射火箭的周期。
RoomRocketLaunchScanInterval time.Duration `yaml:"room_rocket_launch_scan_interval"` 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 承载 room_outbox 事件分发和语音房火箭延迟发射唤醒。
RocketMQ RocketMQConfig `yaml:"rocketmq"` RocketMQ RocketMQConfig `yaml:"rocketmq"`
// OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。 // OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
@ -104,6 +106,8 @@ type OutboxWorkerConfig struct {
PollInterval time.Duration `yaml:"poll_interval"` PollInterval time.Duration `yaml:"poll_interval"`
// BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。 // BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。
BatchSize int `yaml:"batch_size"` BatchSize int `yaml:"batch_size"`
// Concurrency 是本进程内并发抢占 outbox 的 worker 数;多机多 worker 依赖 MySQL 原子 claim 防重复。
Concurrency int `yaml:"concurrency"`
// PublishTimeout 是单条 outbox 投递腾讯云 IM 的最大耗时。 // PublishTimeout 是单条 outbox 投递腾讯云 IM 的最大耗时。
PublishTimeout time.Duration `yaml:"publish_timeout"` PublishTimeout time.Duration `yaml:"publish_timeout"`
// RetryStrategy 当前只接受 exponential_backoff避免配置错误静默改变投递语义。 // RetryStrategy 当前只接受 exponential_backoff避免配置错误静默改变投递语义。
@ -114,6 +118,16 @@ type OutboxWorkerConfig struct {
InitialBackoff time.Duration `yaml:"initial_backoff"` InitialBackoff time.Duration `yaml:"initial_backoff"`
// MaxBackoff 是指数退避的最大等待时间。 // MaxBackoff 是指数退避的最大等待时间。
MaxBackoff time.Duration `yaml:"max_backoff"` 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。 // RocketMQConfig 描述 room-service 使用的 RocketMQ 集群和业务 topic。
@ -279,6 +293,7 @@ func Default() Config {
MicPublishTimeout: 15 * time.Second, MicPublishTimeout: 15 * time.Second,
MicPublishScanInterval: time.Second, MicPublishScanInterval: time.Second,
RoomRocketLaunchScanInterval: time.Second, RoomRocketLaunchScanInterval: time.Second,
RoomListCacheRefreshInterval: 5 * time.Minute,
RocketMQ: defaultRocketMQConfig(), RocketMQ: defaultRocketMQConfig(),
OutboxWorker: defaultOutboxWorkerConfig(), OutboxWorker: defaultOutboxWorkerConfig(),
RobotOutboxWorker: defaultOutboxWorkerConfig(), RobotOutboxWorker: defaultOutboxWorkerConfig(),
@ -297,6 +312,7 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
PublishMode: OutboxPublishModeDirect, PublishMode: OutboxPublishModeDirect,
PollInterval: time.Second, PollInterval: time.Second,
BatchSize: 100, BatchSize: 100,
Concurrency: 1,
PublishTimeout: 3 * time.Second, PublishTimeout: 3 * time.Second,
RetryStrategy: "exponential_backoff", RetryStrategy: "exponential_backoff",
MaxRetryCount: 10, MaxRetryCount: 10,
@ -387,6 +403,9 @@ func Normalize(cfg Config) (Config, error) {
if cfg.LuckyGiftSendLockTTL <= 0 { if cfg.LuckyGiftSendLockTTL <= 0 {
cfg.LuckyGiftSendLockTTL = 5 * time.Second cfg.LuckyGiftSendLockTTL = 5 * time.Second
} }
if cfg.RoomListCacheRefreshInterval <= 0 {
cfg.RoomListCacheRefreshInterval = 5 * time.Minute
}
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr) cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
if cfg.WalletServiceAddr == "" { if cfg.WalletServiceAddr == "" {
cfg.WalletServiceAddr = "127.0.0.1:13004" cfg.WalletServiceAddr = "127.0.0.1:13004"
@ -457,6 +476,9 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
if cfg.BatchSize <= 0 { if cfg.BatchSize <= 0 {
cfg.BatchSize = defaults.BatchSize cfg.BatchSize = defaults.BatchSize
} }
if cfg.Concurrency <= 0 {
cfg.Concurrency = defaults.Concurrency
}
if cfg.PublishTimeout <= 0 { if cfg.PublishTimeout <= 0 {
cfg.PublishTimeout = defaults.PublishTimeout cfg.PublishTimeout = defaults.PublishTimeout
} }
@ -472,6 +494,10 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
if cfg.MaxBackoff < cfg.InitialBackoff { if cfg.MaxBackoff < cfg.InitialBackoff {
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) cfg.RetryStrategy = strings.TrimSpace(cfg.RetryStrategy)
if cfg.RetryStrategy == "" { if cfg.RetryStrategy == "" {
cfg.RetryStrategy = defaults.RetryStrategy cfg.RetryStrategy = defaults.RetryStrategy

View File

@ -29,6 +29,9 @@ func TestLoad(t *testing.T) {
if cfg.LeaseTTL != 10*time.Second { if cfg.LeaseTTL != 10*time.Second {
t.Fatalf("unexpected lease ttl: %s", cfg.LeaseTTL) 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默认地址必须与本地编排一致。 // SendGift 主链路依赖 wallet-service默认地址必须与本地编排一致。
if cfg.WalletServiceAddr != "127.0.0.1:13004" { 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" { 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) 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) 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) t.Fatalf("unexpected robot outbox worker config: %+v", cfg.RobotOutboxWorker)
} }
// 本地默认也走 room_outbox MQ、IM bridge 和火箭延迟唤醒,避免本地直投和线上 MQ 语义分叉。 // 本地默认也走 room_outbox MQ、IM bridge 和火箭延迟唤醒,避免本地直投和线上 MQ 语义分叉。
@ -74,13 +77,16 @@ func TestLoadTencentExample(t *testing.T) {
if cfg.TencentIM.RequestTimeout != 5*time.Second { if cfg.TencentIM.RequestTimeout != 5*time.Second {
t.Fatalf("unexpected request timeout: %s", cfg.TencentIM.RequestTimeout) 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" { if cfg.AdvertiseAddr == "" || cfg.AdvertiseAddr == "room-service.internal:13001" {
t.Fatalf("tencent example must use per-instance advertise_addr, got %q", cfg.AdvertiseAddr) 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) 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) 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 { 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 { if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ {
t.Fatalf("docker config should publish room outbox through MQ, got %q", cfg.OutboxWorker.PublishMode) 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 { if !cfg.RocketMQ.RoomOutbox.Enabled {
t.Fatalf("docker config must enable room outbox MQ") t.Fatalf("docker config must enable room outbox MQ")
} }
@ -148,6 +157,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
cfg := Default() cfg := Default()
cfg.OutboxWorker.PollInterval = 0 cfg.OutboxWorker.PollInterval = 0
cfg.OutboxWorker.BatchSize = 0 cfg.OutboxWorker.BatchSize = 0
cfg.OutboxWorker.Concurrency = 0
cfg.OutboxWorker.PublishTimeout = 0 cfg.OutboxWorker.PublishTimeout = 0
cfg.OutboxWorker.RetryStrategy = "" cfg.OutboxWorker.RetryStrategy = ""
cfg.OutboxWorker.MaxRetryCount = 0 cfg.OutboxWorker.MaxRetryCount = 0
@ -158,7 +168,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Normalize failed: %v", err) 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) t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker)
} }
} }

View File

@ -111,6 +111,7 @@ func (s *Service) AdminCreateRoomPin(ctx context.Context, req *roomv1.AdminCreat
if !ok { if !ok {
return nil, xerr.New(xerr.Conflict, "room is not active or not found") 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 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 { if !ok {
return nil, xerr.New(xerr.NotFound, "room pin not found") return nil, xerr.New(xerr.NotFound, "room pin not found")
} }
s.projectRoomPinCacheBestEffort(ctx, roomListPinEntryFromAdmin(pin))
return &roomv1.AdminCancelRoomPinResponse{Pin: roomPinToAdminProto(pin), ServerTimeMs: nowMS}, nil 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 { func seatConfigToAdminProto(config RoomSeatConfig) *roomv1.AdminRoomSeatConfig {
return &roomv1.AdminRoomSeatConfig{ return &roomv1.AdminRoomSeatConfig{
CandidateSeatCounts: append([]int32(nil), adminSeatCandidates...), CandidateSeatCounts: append([]int32(nil), adminSeatCandidates...),

View File

@ -325,17 +325,26 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
} else { } else {
records = append(records, heatEvent, rankEvent) records = append(records, heatEvent, rankEvent)
} }
var rocketProgress *roomRocketProgressPending
if !cmd.RobotGift { if !cmd.RobotGift {
// 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人通道投递。 // 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人通道投递。
if rocketApply.progressEvent != nil { 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 { if err != nil {
return mutationResult{}, nil, err return mutationResult{}, nil, err
} }
if robotOutboxGift { progressRecord.AppCode = appcode.FromContext(ctx)
robotRecords = append(robotRecords, progressEvent) if progressRecord.Envelope != nil {
} else { progressRecord.Envelope.AppCode = progressRecord.AppCode
records = append(records, progressEvent) }
// 燃料变化是高频展示事件,不逐笔写 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 { if rocketApply.ignited != nil {
@ -390,6 +399,7 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
commandPayload: commandPayload, commandPayload: commandPayload,
walletDebitMS: walletDebitMS, walletDebitMS: walletDebitMS,
robotOutboxRecords: robotRecords, robotOutboxRecords: robotRecords,
roomRocketProgress: rocketProgress,
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{ roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
AppCode: appcode.FromContext(ctx), AppCode: appcode.FromContext(ctx),
RoomID: current.RoomID, RoomID: current.RoomID,

View File

@ -77,7 +77,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
return nil, err return nil, err
} }
entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{ entries, err := s.listRoomListEntries(ctx, RoomListQuery{
AppCode: appcode.FromContext(ctx), AppCode: appcode.FromContext(ctx),
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()), VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
AllVisibleRegions: allVisibleRegions, 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 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 房间流。 // ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) { func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) 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 { 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)) 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()))
} }
} }

View File

@ -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
}

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"sync"
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -27,6 +28,8 @@ type OutboxWorkerOptions struct {
PollInterval time.Duration PollInterval time.Duration
// BatchSize 是单轮最多处理条数;非正数会被归一,避免全表扫描。 // BatchSize 是单轮最多处理条数;非正数会被归一,避免全表扫描。
BatchSize int BatchSize int
// Concurrency 是本进程内独立抢占 pending outbox 的 worker 数;每条 worker 都依赖 MySQL SKIP LOCKED 隔离批次。
Concurrency int
// PublishTimeout 限制单条外部投递耗时shutdown 时当前事件最多阻塞该时长。 // PublishTimeout 限制单条外部投递耗时shutdown 时当前事件最多阻塞该时长。
PublishTimeout time.Duration PublishTimeout time.Duration
// RetryStrategy 当前只支持 exponential_backoff。 // RetryStrategy 当前只支持 exponential_backoff。
@ -37,6 +40,20 @@ type OutboxWorkerOptions struct {
InitialBackoff time.Duration InitialBackoff time.Duration
// MaxBackoff 是指数退避上限。 // MaxBackoff 是指数退避上限。
MaxBackoff time.Duration 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 { func defaultOutboxWorkerOptions() OutboxWorkerOptions {
@ -44,6 +61,7 @@ func defaultOutboxWorkerOptions() OutboxWorkerOptions {
return OutboxWorkerOptions{ return OutboxWorkerOptions{
PollInterval: time.Second, PollInterval: time.Second,
BatchSize: 100, BatchSize: 100,
Concurrency: 1,
PublishTimeout: 3 * time.Second, PublishTimeout: 3 * time.Second,
RetryStrategy: outboxRetryExponentialBackoff, RetryStrategy: outboxRetryExponentialBackoff,
MaxRetryCount: 10, MaxRetryCount: 10,
@ -63,6 +81,10 @@ func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptio
// 非正批量按安全默认值处理,避免 repository 扫描不受控。 // 非正批量按安全默认值处理,避免 repository 扫描不受控。
options.BatchSize = defaults.BatchSize options.BatchSize = defaults.BatchSize
} }
if options.Concurrency <= 0 {
// 并发数为 0 等价于历史单 worker避免老测试或手工构造配置意外关闭补偿。
options.Concurrency = defaults.Concurrency
}
if options.PublishTimeout <= 0 { if options.PublishTimeout <= 0 {
// 单条外部投递必须有 deadlineshutdown 才能等待有界。 // 单条外部投递必须有 deadlineshutdown 才能等待有界。
options.PublishTimeout = defaults.PublishTimeout options.PublishTimeout = defaults.PublishTimeout
@ -79,6 +101,10 @@ func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptio
if options.MaxBackoff < options.InitialBackoff { if options.MaxBackoff < options.InitialBackoff {
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) options.RetryStrategy = strings.TrimSpace(options.RetryStrategy)
if 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)) logx.Error(ctx, "worker_stopped", fmt.Errorf("unsupported retry_strategy"), slog.String("worker", workerName), slog.String("node_id", s.nodeID))
return 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 才补偿历史事件。 // 启动后先执行一轮,避免服务刚恢复时还要等一个 poll interval 才补偿历史事件。
if err := process(ctx, options); err != nil && ctx.Err() == nil { 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)) 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 把待投递事件补偿性地推送给异步消费者。 // ProcessPendingOutbox 把待投递事件补偿性地推送给异步消费者。
func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorkerOptions) error { 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 把机器人展示事件补偿性地推送给独立机器人异步消费者。 // ProcessPendingRobotOutbox 把机器人展示事件补偿性地推送给独立机器人异步消费者。
func (s *Service) ProcessPendingRobotOutbox(ctx context.Context, options OutboxWorkerOptions) error { 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 { 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() return ctx.Err()
default: 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 { if record.RetryCount >= options.MaxRetryCount {
// 已达到最大失败次数的历史事件直接转死信,防止每轮 worker 继续打外部系统。 // 已达到最大失败次数的历史事件直接转死信,防止每轮 worker 继续打外部系统。
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) 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 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) { func (s *Service) claimPendingOutbox(ctx context.Context, robotLane bool, options OutboxWorkerOptions) ([]outbox.Record, error) {
lockUntilMS := time.Now().UTC().Add(options.PublishTimeout).UnixMilli() lockUntilMS := time.Now().UTC().Add(options.PublishTimeout).UnixMilli()
workerID := firstNonEmpty(strings.TrimSpace(options.WorkerID), s.nodeID)
if robotLane { 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 { func (s *Service) markOutboxDelivered(ctx context.Context, robotLane bool, eventID string) error {
@ -306,3 +430,12 @@ func trimOutboxError(value string) string {
// V1 只保留错误前缀,避免外部响应过大撑爆日志或 TEXT 字段查询成本。 // V1 只保留错误前缀,避免外部响应过大撑爆日志或 TEXT 字段查询成本。
return value[:512] return value[:512]
} }
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@ -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
}

View File

@ -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 { if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err return nil, err
} }
// robot outbox 只是 App 展示补偿,进入持久化前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
robotOutboxRecords := s.sampleRobotOutboxRecords(result.robotOutboxRecords)
saveStartedAt := time.Now() saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{ if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{ Command: CommandRecord{
@ -92,7 +94,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
CreatedAtMS: now.UnixMilli(), CreatedAtMS: now.UnixMilli(),
}, },
OutboxRecords: outboxRecords, OutboxRecords: outboxRecords,
RobotOutboxRecords: result.robotOutboxRecords, RobotOutboxRecords: robotOutboxRecords,
RoomStatus: result.roomStatus, RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount, RoomSeatCount: result.roomSeatCount,
RoomPasswordHash: result.roomPasswordHash, RoomPasswordHash: result.roomPasswordHash,
@ -107,6 +109,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
saveMutationMS += elapsedMS(saveStartedAt) saveMutationMS += elapsedMS(saveStartedAt)
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 IM/activity避免外部慢调用占住房间串行执行队列。 // 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 IM/activity避免外部慢调用占住房间串行执行队列。
result.outboxRecords = outboxRecords result.outboxRecords = outboxRecords
result.robotOutboxRecords = robotOutboxRecords
// 持久化成功后再替换内存状态,避免内存提交领先于恢复来源。 // 持久化成功后再替换内存状态,避免内存提交领先于恢复来源。
current.ReplaceWith(nextState) current.ReplaceWith(nextState)
@ -146,6 +149,10 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
} }
result := value.(mutationResult) 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)) { if (result.applied || result.forceSnapshot) && result.snapshot != nil && (result.forceSnapshot || shouldSnapshot(result.snapshot, s.snapshotEveryN)) {
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。 // 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。

View File

@ -180,6 +180,28 @@ type RoomListEntry struct {
PinnedUntilMS int64 PinnedUntilMS int64
} }
// RoomListPinEntry 是公共发现页置顶读模型的最小事实。
type RoomListPinEntry struct {
// ID 是 room_region_pins 的自增主键,缓存增量更新和删除都用它定位旧 zset member。
ID int64
// AppCode 限定置顶所属 App避免多 App 共用 Redis key 时互相污染。
AppCode string
// VisibleRegionID 是置顶作用域global 统一写 0region/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 { type AdminRoomListEntry struct {
RoomID string RoomID string
RoomShortID string RoomShortID string
@ -217,6 +239,7 @@ type AdminRoomListQuery struct {
} }
type AdminRoomPinEntry struct { type AdminRoomPinEntry struct {
AppCode string
ID int64 ID int64
VisibleRegionID int64 VisibleRegionID int64
PinType string PinType string
@ -501,6 +524,25 @@ type RoomListQuery struct {
NowMS int64 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 是榜单等定向补全房间卡片时的批量读取条件。 // RoomListEntriesByIDQuery 是榜单等定向补全房间卡片时的批量读取条件。
type RoomListEntriesByIDQuery struct { type RoomListEntriesByIDQuery struct {
// AppCode 限定 App 边界,避免一个榜单读到其他 App 的同名 room_id。 // AppCode 限定 App 边界,避免一个榜单读到其他 App 的同名 room_id。
@ -626,6 +668,23 @@ type Repository interface {
RobotRoomStore 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 恢复前的轻量入口。 // RoomMetaStore 保存房间基础元数据和房主背景素材;它是 Room Cell 恢复前的轻量入口。
type RoomMetaStore interface { type RoomMetaStore interface {
// SaveRoomMeta 保存房间基础元数据,创建房间时必须先建立它。 // SaveRoomMeta 保存房间基础元数据,创建房间时必须先建立它。
@ -678,6 +737,8 @@ type RoomConfigStore interface {
type OutboxStore interface { type OutboxStore interface {
// SaveOutbox 写入房间外事件,必须和成功命令保持同一提交语义。 // SaveOutbox 写入房间外事件,必须和成功命令保持同一提交语义。
SaveOutbox(ctx context.Context, records []outbox.Record) error SaveOutbox(ctx context.Context, records []outbox.Record) error
// SaveRobotOutbox 写入机器人房间展示事件;火箭进度合并器 flush 后仍按 lane 回到独立 robot outbox。
SaveRobotOutbox(ctx context.Context, records []outbox.Record) error
// ListPendingOutbox 扫描待补偿投递事件。 // ListPendingOutbox 扫描待补偿投递事件。
ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error)
// ListPendingRobotOutbox 扫描机器人房间待补偿展示事件。 // ListPendingRobotOutbox 扫描机器人房间待补偿展示事件。
@ -698,6 +759,10 @@ type OutboxStore interface {
MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error
// MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。 // MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。
MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error 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 内存。 // RoomListStore 管理发现页、Mine 页和关系房间流读模型;它不读取 Room Cell 内存。
@ -722,6 +787,10 @@ type RoomListStore interface {
ListRoomFollowEntries(ctx context.Context, query RoomFollowQuery) ([]RoomListEntry, error) ListRoomFollowEntries(ctx context.Context, query RoomFollowQuery) ([]RoomListEntry, error)
// ListRoomRelatedFeedEntries 根据 user-service 关系事实查询 active 房间卡片,不持久化关系状态。 // ListRoomRelatedFeedEntries 根据 user-service 关系事实查询 active 房间卡片,不持久化关系状态。
ListRoomRelatedFeedEntries(ctx context.Context, query RoomRelatedFeedQuery) ([]RoomListEntry, error) 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 快照。 // PresenceStore 管理当前房间 presence 投影;权威状态仍然来自 Room Cell 快照。

View File

@ -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)
}
}
}

View 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 descmember 前缀把 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))
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()))
}
}
}
}

View File

@ -249,6 +249,7 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
t.Fatalf("top1 and igniter must lock at ignited start: %+v", pending) t.Fatalf("top1 and igniter must lock at ignited start: %+v", pending)
} }
flushRocketProgress(t, ctx, svc, now)
progressEvents := rocketProgressEvents(t, ctx, repository) progressEvents := rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 250 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 150 { 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) 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 { if len(nextResp.GetRocket().GetPendingLaunches()) != 1 {
t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket()) t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket())
} }
flushRocketProgress(t, ctx, svc, now)
progressEvents = rocketProgressEvents(t, ctx, repository) progressEvents = rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 2 || progressEvents[1].GetLevel() != 2 || progressEvents[1].GetEffectiveAddedFuel() != 50 || progressEvents[1].GetOverflowFuel() != 0 { 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) 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) { func TestRoomRocketIgnitedOutboxSchedulesDelayedLaunch(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
@ -745,6 +799,15 @@ func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repos
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) }, 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 { func rocketConfigForTest(threshold int64, launchDelayMS int64) roomservice.RoomRocketConfig {
levels := make([]roomservice.RoomRocketLevelConfig, 0, 5) levels := make([]roomservice.RoomRocketLevelConfig, 0, 5)
for level := int32(1); level <= 5; level++ { for level := int32(1); level <= 5; level++ {

View File

@ -41,6 +41,8 @@ type Config struct {
HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider
// RoomGiftLeaderboard 是跨房间贡献榜读模型SendGift 提交后 best-effort 写入。 // RoomGiftLeaderboard 是跨房间贡献榜读模型SendGift 提交后 best-effort 写入。
RoomGiftLeaderboard RoomGiftLeaderboardStore RoomGiftLeaderboard RoomGiftLeaderboardStore
// RoomListCache 是公共发现页 Redis 展示读模型;未配置或未就绪时 ListRooms 直接读 MySQL。
RoomListCache RoomListCacheStore
// LuckyGiftSendLocker 串行化同一用户的幸运礼物扣费、抽奖和同步返奖链路。 // LuckyGiftSendLocker 串行化同一用户的幸运礼物扣费、抽奖和同步返奖链路。
LuckyGiftSendLocker LuckyGiftSendLocker LuckyGiftSendLocker LuckyGiftSendLocker
// LuckyGiftSendLockTTL 是幸运礼物短锁过期时间。 // LuckyGiftSendLockTTL 是幸运礼物短锁过期时间。
@ -83,8 +85,14 @@ type Service struct {
humanRobotPoolProvider integration.HumanRoomRobotPoolProvider humanRobotPoolProvider integration.HumanRoomRobotPoolProvider
// roomRocketLaunchScheduler 在 ignited outbox 投递成功后安排 launch_at_ms 唤醒。 // roomRocketLaunchScheduler 在 ignited outbox 投递成功后安排 launch_at_ms 唤醒。
roomRocketLaunchScheduler integration.RoomRocketLaunchScheduler roomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
// roomRocketProgressCoalescer 把高频燃料变化压成每秒每轮一条 outbox降低 room_outbox/MQ/IM 写放大。
roomRocketProgressCoalescer *roomRocketProgressCoalescer
// robotOutboxSampler 把同房间同展示类型在短窗口内收敛成一条 robot outbox房间命令和状态仍完整执行。
robotOutboxSampler *robotOutboxSampler
// roomGiftLeaderboard 只保存房间维度贡献值 zset不承载 Room Cell 核心状态。 // roomGiftLeaderboard 只保存房间维度贡献值 zset不承载 Room Cell 核心状态。
roomGiftLeaderboard RoomGiftLeaderboardStore roomGiftLeaderboard RoomGiftLeaderboardStore
// roomListCache 只承载发现页展示顺序MySQL 仍然是房间列表和置顶事实 owner。
roomListCache RoomListCacheStore
// luckyGiftSendLocker 只保护同一用户幸运礼物扣费到同步返奖的小事务链路。 // luckyGiftSendLocker 只保护同一用户幸运礼物扣费到同步返奖的小事务链路。
luckyGiftSendLocker LuckyGiftSendLocker luckyGiftSendLocker LuckyGiftSendLocker
// luckyGiftSendLockTTL 控制 Redis 短锁自动释放窗口。 // luckyGiftSendLockTTL 控制 Redis 短锁自动释放窗口。
@ -107,6 +115,10 @@ type Service struct {
humanRobotRuntimeMu sync.Mutex humanRobotRuntimeMu sync.Mutex
// humanRobotRuntimes 保存 room_id -> runtime避免同一真人房间被重复补机器人。 // humanRobotRuntimes 保存 room_id -> runtime避免同一真人房间被重复补机器人。
humanRobotRuntimes map[string]robotRoomRuntime humanRobotRuntimes map[string]robotRoomRuntime
// robotOutboxCleanupMu 保护清理 worker 的最近执行时间,避免并发 worker 同一时刻重复清理。
robotOutboxCleanupMu sync.Mutex
// robotOutboxLastCleanup 记录本进程上一轮 robot outbox 清理时间。
robotOutboxLastCleanup time.Time
} }
type robotRoomRuntime struct { type robotRoomRuntime struct {
@ -156,6 +168,8 @@ type mutationResult struct {
outboxRecords []outbox.Record outboxRecords []outbox.Record
// robotOutboxRecords 是机器人房间必要展示事实,只进入 robot outbox/worker不占主 room_outbox。 // robotOutboxRecords 是机器人房间必要展示事实,只进入 robot outbox/worker不占主 room_outbox。
robotOutboxRecords []outbox.Record robotOutboxRecords []outbox.Record
// roomRocketProgress 是提交成功后才进入内存合并器的火箭进度展示事件,不和送礼命令事务逐笔写 outbox。
roomRocketProgress *roomRocketProgressPending
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。 // roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
roomStatus string roomStatus string
// roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。 // roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。
@ -217,29 +231,32 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
} }
return &Service{ return &Service{
nodeID: cfg.NodeID, nodeID: cfg.NodeID,
leaseTTL: cfg.LeaseTTL, leaseTTL: cfg.LeaseTTL,
rankLimit: rankLimit, rankLimit: rankLimit,
snapshotEveryN: snapshotEveryN, snapshotEveryN: snapshotEveryN,
presenceStaleAfter: presenceStaleAfter, presenceStaleAfter: presenceStaleAfter,
micPublishTimeout: micPublishTimeout, micPublishTimeout: micPublishTimeout,
clock: usedClock, clock: usedClock,
directory: directory, directory: directory,
repository: repository, repository: repository,
wallet: wallet, wallet: wallet,
luckyGift: luckyGiftClient, luckyGift: luckyGiftClient,
syncPublisher: syncPublisher, syncPublisher: syncPublisher,
outboxPublisher: outboxPublisher, outboxPublisher: outboxPublisher,
robotOutboxPublisher: cfg.RobotOutboxPublisher, robotOutboxPublisher: cfg.RobotOutboxPublisher,
humanRobotPoolProvider: cfg.HumanRobotPoolProvider, humanRobotPoolProvider: cfg.HumanRobotPoolProvider,
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler, roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
roomGiftLeaderboard: cfg.RoomGiftLeaderboard, roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(repository),
luckyGiftSendLocker: cfg.LuckyGiftSendLocker, robotOutboxSampler: newRobotOutboxSampler(),
luckyGiftSendLockTTL: luckyGiftSendLockTTL, roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
rtcUserRemover: cfg.RTCUserRemover, roomListCache: cfg.RoomListCache,
cells: make(map[string]*cell.RoomCell), luckyGiftSendLocker: cfg.LuckyGiftSendLocker,
robotRuntimes: make(map[string]robotRoomRuntime), luckyGiftSendLockTTL: luckyGiftSendLockTTL,
humanRobotRuntimes: make(map[string]robotRoomRuntime), rtcUserRemover: cfg.RTCUserRemover,
cells: make(map[string]*cell.RoomCell),
robotRuntimes: make(map[string]robotRoomRuntime),
humanRobotRuntimes: make(map[string]robotRoomRuntime),
} }
} }

View File

@ -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) { func TestRealRoomRobotGiftKeepsRoomContributionAndUsesRobotOutbox(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) 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 { 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) 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) afterMain := outboxEventCounts(t, ctx, repository)
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} { for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} {

View File

@ -422,7 +422,7 @@ func (r *Repository) adminGetRoomPin(ctx context.Context, where string, args ...
func adminRoomPinSelectSQL() string { func adminRoomPinSelectSQL() string {
return ` 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.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, 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 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) { func scanAdminRoomPin(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomPinEntry, error) {
var item roomservice.AdminRoomPinEntry 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 return item, err
} }

View File

@ -15,6 +15,16 @@ import (
// SaveOutbox 追加待投递 outbox 事件。 // SaveOutbox 追加待投递 outbox 事件。
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error { 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 { if len(records) == 0 {
// 没有事件时保持无副作用,方便领域层统一调用。 // 没有事件时保持无副作用,方便领域层统一调用。
return nil return nil
@ -39,7 +49,7 @@ func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) er
} }
if _, err := tx.ExecContext(ctx, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`, ON DUPLICATE KEY UPDATE event_id = event_id`,
record.AppCode, record.AppCode,
@ -351,6 +361,106 @@ func (r *Repository) markOutboxDeadInTable(ctx context.Context, table string, ev
return err 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 { func roomOutboxTableName(table string) string {
switch table { switch table {
case roomRobotOutboxTable: case roomRobotOutboxTable:

View File

@ -176,6 +176,104 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser
return result, nil 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 { func uniqueRoomListIDs(values []string) []string {
seen := make(map[string]struct{}, len(values)) seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values)) out := make([]string, 0, len(values))

View File

@ -142,9 +142,22 @@ func (r *Repository) SetRoomCreatedAt(roomID string, createdAtMS int64) {
func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) { func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
r.t.Helper() 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(), ` 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, '') 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 = ? WHERE app_code = ? AND event_id = ?
`, appcode.Default, eventID) `, appcode.Default, eventID)
@ -170,6 +183,23 @@ func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
return record, true 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 { func initDBPath(t testing.TB) string {
t.Helper() t.Helper()

View File

@ -37,6 +37,7 @@ type Repository interface {
DeleteAgency(ctx context.Context, command DeleteAgencyCommand) (hostdomain.Agency, error) DeleteAgency(ctx context.Context, command DeleteAgencyCommand) (hostdomain.Agency, error)
SetAgencyJoinEnabled(ctx context.Context, command SetAgencyJoinEnabledCommand) (hostdomain.Agency, error) SetAgencyJoinEnabled(ctx context.Context, command SetAgencyJoinEnabledCommand) (hostdomain.Agency, error)
GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, 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) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error)
GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error) GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, 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) 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 身份事实。 // GetBDProfile 按指定 role 读取普通 BD 或 BD Leader 身份事实。
func (s *Service) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) { func (s *Service) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) {
if s.repository == nil { if s.repository == nil {

View File

@ -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) { func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)

View File

@ -100,6 +100,41 @@ func (r *Repository) GetHostProfile(ctx context.Context, userID int64) (hostdoma
return queryHostProfile(ctx, r.db, "WHERE user_id = ?", userID) 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 身份事实。 // GetBDProfile 按 role 读取普通 BD 或 BD Leader 身份事实。
func (r *Repository) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) { func (r *Repository) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) {
if role == hostdomain.BDRoleLeader { if role == hostdomain.BDRoleLeader {

View File

@ -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) { func seedActiveRPCUser(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64) {
t.Helper() t.Helper()
repository.PutUser(userdomain.User{ repository.PutUser(userdomain.User{

View File

@ -300,6 +300,25 @@ func (s *Server) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileR
return &userv1.GetHostProfileResponse{HostProfile: toProtoHostProfile(profile)}, nil 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 身份事实。 // GetBDProfile 按调用方声明的角色返回普通 BD 或 BD Leader 身份事实。
func (s *Server) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) { func (s *Server) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
if s.hostSvc == nil { if s.hostSvc == nil {

View File

@ -585,6 +585,7 @@ CREATE TABLE IF NOT EXISTS external_recharge_orders (
command_id VARCHAR(128) NOT NULL COMMENT 'H5 幂等命令 ID', command_id VARCHAR(128) NOT NULL COMMENT 'H5 幂等命令 ID',
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID', target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
target_region_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 '目标国家码', target_country_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '目标国家码',
audience_type VARCHAR(32) NOT NULL COMMENT '购买对象', audience_type VARCHAR(32) NOT NULL COMMENT '购买对象',
product_id BIGINT NOT NULL COMMENT '商品 ID', 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) KEY idx_external_recharge_orders_tx (app_code, provider_code, tx_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'; ) 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); 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) INSERT IGNORE INTO third_party_payment_channels (app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms)

View File

@ -63,6 +63,7 @@ type GooglePaymentCommand struct {
CommandID string CommandID string
UserID int64 UserID int64
RegionID int64 RegionID int64
CountryID int64
ProductID int64 ProductID int64
ProductCode string ProductCode string
PackageName string PackageName string
@ -206,6 +207,7 @@ type ExternalRechargeOrder struct {
CommandID string CommandID string
TargetUserID int64 TargetUserID int64
TargetRegionID int64 TargetRegionID int64
TargetCountryID int64
TargetCountryCode string TargetCountryCode string
AudienceType string AudienceType string
ProductID int64 ProductID int64
@ -238,6 +240,7 @@ type CreateExternalRechargeOrderCommand struct {
CommandID string CommandID string
TargetUserID int64 TargetUserID int64
TargetRegionID int64 TargetRegionID int64
TargetCountryID int64
TargetCountryCode string TargetCountryCode string
AudienceType string AudienceType string
ProductID int64 ProductID int64

View File

@ -2694,6 +2694,12 @@ func TestH5MifaPayStatusQueryCreditsOrder(t *testing.T) {
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 { 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) 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) { func TestH5MifaPayCoinSellerCreditsSellerStockUSDTEquivalent(t *testing.T) {
@ -2859,6 +2865,7 @@ func createRedirectedMifaPayOrder(t *testing.T, repository *mysqltest.Repository
CommandID: commandID, CommandID: commandID,
TargetUserID: targetUserID, TargetUserID: targetUserID,
TargetRegionID: targetRegionID, TargetRegionID: targetRegionID,
TargetCountryID: 63,
TargetCountryCode: "SA", TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceNormal, AudienceType: ledger.RechargeAudienceNormal,
ProductID: product.ProductID, ProductID: product.ProductID,
@ -3834,6 +3841,21 @@ func TestGiftConfigRegionFilter(t *testing.T) {
t.Fatalf("create gift config %s failed: %v", command.GiftID, err) 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{ items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
Keyword: "regional-", 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") { 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) 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) repository.SetBalance(51001, 100)
if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
@ -5712,6 +5744,7 @@ func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
CommandID: "google-pay-product-name", CommandID: "google-pay-product-name",
UserID: 53001, UserID: 53001,
RegionID: 1001, RegionID: 1001,
CountryID: 199,
ProductID: product.ProductID, ProductID: product.ProductID,
ProductCode: googleProductID, ProductCode: googleProductID,
PackageName: "com.org.laluparty", 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 { 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) 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) { func TestConfirmGooglePaymentAcceptsLegacyProductCodeInput(t *testing.T) {
@ -6024,6 +6060,27 @@ func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool {
return false 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 { func giftTypeCodesContain(items []resourcedomain.GiftTypeConfig, typeCode string) bool {
for _, item := range items { for _, item := range items {
if item.TypeCode == typeCode { if item.TypeCode == typeCode {

View File

@ -29,12 +29,12 @@ func (r *Repository) CreateExternalRechargeOrder(ctx context.Context, command le
order := externalRechargeOrderFromCommand(command, product, method, providerAmountMinor, receiveAddress, time.Now().UnixMilli()) order := externalRechargeOrderFromCommand(command, product, method, providerAmountMinor, receiveAddress, time.Now().UnixMilli())
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
INSERT INTO external_recharge_orders ( 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, 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, country_code, currency_code, provider_amount_minor, pay_way, pay_type, receive_address, status,
created_at_ms, updated_at_ms created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryCode, 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.AudienceType, order.ProductID, order.ProductCode, order.ProductName, order.CoinAmount, order.USDMinorAmount,
order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor, order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor,
order.PayWay, order.PayType, order.ReceiveAddress, order.Status, order.CreatedAtMS, order.UpdatedAtMS, 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) order := temporaryExternalRechargeOrderFromCommand(command, method, providerAmountMinor, nowMS)
_, err := r.db.ExecContext(ctx, ` _, err := r.db.ExecContext(ctx, `
INSERT INTO external_recharge_orders ( 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, 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, country_code, currency_code, provider_amount_minor, pay_way, pay_type, receive_address, status,
created_at_ms, updated_at_ms created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryCode, 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.AudienceType, order.ProductID, order.ProductCode, order.ProductName, order.CoinAmount, order.USDMinorAmount,
order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor, order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor,
order.PayWay, order.PayType, order.ReceiveAddress, order.Status, order.CreatedAtMS, order.UpdatedAtMS, 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, ProviderCode: order.ProviderCode,
PaymentMethodID: order.PaymentMethodID, PaymentMethodID: order.PaymentMethodID,
TargetUserID: order.TargetUserID, TargetUserID: order.TargetUserID,
TargetCountryID: order.TargetCountryID,
TargetRegionID: order.TargetRegionID, TargetRegionID: order.TargetRegionID,
Amount: order.CoinAmount, Amount: order.CoinAmount,
TargetAssetType: assetType, TargetAssetType: assetType,
@ -449,7 +450,7 @@ func (r *Repository) lockExternalRechargeOrder(ctx context.Context, tx *sql.Tx,
func externalRechargeOrderSelectSQL() string { func externalRechargeOrderSelectSQL() string {
return ` 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, 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, ''), 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, 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.CommandID,
&order.TargetUserID, &order.TargetUserID,
&order.TargetRegionID, &order.TargetRegionID,
&order.TargetCountryID,
&order.TargetCountryCode, &order.TargetCountryCode,
&order.AudienceType, &order.AudienceType,
&order.ProductID, &order.ProductID,
@ -512,6 +514,7 @@ func externalRechargeOrderFromCommand(command ledger.CreateExternalRechargeOrder
CommandID: strings.TrimSpace(command.CommandID), CommandID: strings.TrimSpace(command.CommandID),
TargetUserID: command.TargetUserID, TargetUserID: command.TargetUserID,
TargetRegionID: command.TargetRegionID, TargetRegionID: command.TargetRegionID,
TargetCountryID: command.TargetCountryID,
TargetCountryCode: strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)), TargetCountryCode: strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)),
AudienceType: ledger.NormalizeRechargeAudienceType(command.AudienceType), AudienceType: ledger.NormalizeRechargeAudienceType(command.AudienceType),
ProductID: product.ProductID, ProductID: product.ProductID,

View File

@ -39,28 +39,36 @@ func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.L
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
items := make([]resourcedomain.GiftConfig, 0, query.PageSize) items := make([]resourcedomain.GiftConfig, 0, query.PageSize)
giftIDs := make([]string, 0, query.PageSize)
for rows.Next() { for rows.Next() {
item, err := scanGiftConfig(rows) item, err := scanGiftConfig(rows)
if err != nil { if err != nil {
return nil, 0, err 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) items = append(items, item)
giftIDs = append(giftIDs, item.GiftID)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, 0, err 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 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()) price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli())
if priceErr == nil { if priceErr == nil {
gift.PriceVersion = price.PriceVersion applyGiftPriceToConfig(&gift, price)
gift.ChargeAssetType = price.ChargeAssetType
gift.CoinPrice = price.CoinPrice
gift.GiftPointAmount = 0
gift.HeatValue = price.CoinPrice
} }
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID) gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID)
if err != nil { 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()) price, priceErr := r.latestGiftPrice(ctx, tx, gift.GiftID, time.Now().UnixMilli())
if priceErr == nil { if priceErr == nil {
gift.PriceVersion = price.PriceVersion applyGiftPriceToConfig(&gift, price)
gift.ChargeAssetType = price.ChargeAssetType
gift.CoinPrice = price.CoinPrice
gift.GiftPointAmount = 0
gift.HeatValue = price.CoinPrice
} }
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID) gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID)
if err != nil { 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) { func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) {
rows, err := querier.QueryContext(ctx, ` rows, err := querier.QueryContext(ctx, `
SELECT region_id SELECT region_id
FROM gift_config_regions FROM gift_config_regions
WHERE app_code = ? AND gift_id = ? WHERE app_code = ? AND gift_id = ?
ORDER BY region_id ASC`, ORDER BY region_id ASC`,
@ -158,6 +156,38 @@ func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQue
return regionIDs, rows.Err() 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, &regionID); err != nil {
return nil, err
}
regionsByGiftID[giftID] = append(regionsByGiftID[giftID], regionID)
}
return regionsByGiftID, rows.Err()
}
func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) { func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
var gift resourcedomain.GiftConfig var gift resourcedomain.GiftConfig
var resource resourcedomain.Resource var resource resourcedomain.Resource
@ -269,6 +299,7 @@ func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) res
if strings.TrimSpace(query.GiftTypeCode) != "" { if strings.TrimSpace(query.GiftTypeCode) != "" {
query.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(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 return query
} }

View File

@ -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)
}
}

View File

@ -52,6 +52,61 @@ func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier,
return price, nil 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) { func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) {
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+` row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`

View File

@ -86,6 +86,27 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go
RechargeSequence: rechargeSequence, RechargeSequence: rechargeSequence,
CreatedAtMS: nowMs, 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 { if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGooglePlayRecharge, requestHash, externalGooglePaymentRef(purchase, tokenHash), metadata, nowMs); err != nil {
return ledger.GooglePaymentReceipt{}, err return ledger.GooglePaymentReceipt{}, err
} }
@ -104,20 +125,7 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go
}); err != nil { }); err != nil {
return ledger.GooglePaymentReceipt{}, err return ledger.GooglePaymentReceipt{}, err
} }
if err := r.insertRechargeRecord(ctx, tx, transactionID, coinSellerTransferMetadata{ if err := r.insertRechargeRecord(ctx, tx, transactionID, rechargeMetadata, nowMs); err != nil {
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 {
return ledger.GooglePaymentReceipt{}, err return ledger.GooglePaymentReceipt{}, err
} }
if err := r.insertGooglePaymentOrder(ctx, tx, paymentOrderID, command, product, purchase, tokenHash, transactionID, nowMs); err != nil { 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{ 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), 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), 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 { }); err != nil {
return ledger.GooglePaymentReceipt{}, err return ledger.GooglePaymentReceipt{}, err
} }

View File

@ -16,14 +16,21 @@ func (r *Repository) countResourceRows(ctx context.Context, table string, where
} }
func normalizePage(page int32, pageSize int32) (int32, int32) { 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 { if page < 1 {
page = 1 page = 1
} }
if pageSize < 1 { if pageSize < 1 {
pageSize = 20 pageSize = 20
} }
if pageSize > 100 { if maxPageSize <= 0 {
pageSize = 100 maxPageSize = 100
}
if pageSize > maxPageSize {
pageSize = maxPageSize
} }
return page, pageSize return page, pageSize
} }

View File

@ -115,6 +115,7 @@ func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
command_id VARCHAR(128) NOT NULL COMMENT '客户端命令幂等 ID', command_id VARCHAR(128) NOT NULL COMMENT '客户端命令幂等 ID',
target_user_id BIGINT NOT NULL COMMENT '充值目标用户 ID', target_user_id BIGINT NOT NULL COMMENT '充值目标用户 ID',
target_region_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 '充值目标国家码', target_country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '充值目标国家码',
audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '商品适用用户类型', audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '商品适用用户类型',
product_id BIGINT NOT NULL COMMENT '充值商品 ID', 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 { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil {
return err 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) return seedThirdPartyPaymentDefaults(ctx, db)
} }

View File

@ -693,7 +693,7 @@ func initDBPath(t testing.TB) string {
func validateTableName(table string) string { func validateTableName(table string) string {
switch table { 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", "wallet_user_recharge_stats", "external_recharge_orders", "third_party_payment_methods",
"host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies", "host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies",
"host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records", "host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records",

View File

@ -120,6 +120,7 @@ func (s *Server) CreateH5RechargeOrder(ctx context.Context, req *walletv1.Create
CommandID: req.GetCommandId(), CommandID: req.GetCommandId(),
TargetUserID: req.GetTargetUserId(), TargetUserID: req.GetTargetUserId(),
TargetRegionID: req.GetTargetRegionId(), TargetRegionID: req.GetTargetRegionId(),
TargetCountryID: req.GetTargetCountryId(),
TargetCountryCode: req.GetTargetCountryCode(), TargetCountryCode: req.GetTargetCountryCode(),
AudienceType: req.GetAudienceType(), AudienceType: req.GetAudienceType(),
ProductID: req.GetProductId(), ProductID: req.GetProductId(),
@ -305,6 +306,7 @@ func externalRechargeOrderToProto(order ledger.ExternalRechargeOrder) *walletv1.
AppCode: order.AppCode, AppCode: order.AppCode,
TargetUserId: order.TargetUserID, TargetUserId: order.TargetUserID,
TargetRegionId: order.TargetRegionID, TargetRegionId: order.TargetRegionID,
TargetCountryId: order.TargetCountryID,
TargetCountryCode: order.TargetCountryCode, TargetCountryCode: order.TargetCountryCode,
AudienceType: order.AudienceType, AudienceType: order.AudienceType,
ProductId: order.ProductID, ProductId: order.ProductID,

View File

@ -31,6 +31,7 @@ func (s *Server) ConfirmGooglePayment(ctx context.Context, req *walletv1.Confirm
CommandID: req.GetCommandId(), CommandID: req.GetCommandId(),
UserID: req.GetUserId(), UserID: req.GetUserId(),
RegionID: req.GetRegionId(), RegionID: req.GetRegionId(),
CountryID: req.GetCountryId(),
ProductID: req.GetProductId(), ProductID: req.GetProductId(),
ProductCode: req.GetProductCode(), ProductCode: req.GetProductCode(),
PackageName: req.GetPackageName(), PackageName: req.GetPackageName(),