修复性能问题

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

View File

@ -33,6 +33,7 @@ const (
UserHostService_GetRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleInvitation"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_BatchGetHostProfiles_FullMethodName = "/hyapp.user.v1.UserHostService/BatchGetHostProfiles"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion"
@ -63,6 +64,7 @@ type UserHostServiceClient interface {
GetRoleInvitation(ctx context.Context, in *GetRoleInvitationRequest, opts ...grpc.CallOption) (*GetRoleInvitationResponse, error)
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
BatchGetHostProfiles(ctx context.Context, in *BatchGetHostProfilesRequest, opts ...grpc.CallOption) (*BatchGetHostProfilesResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error)
@ -221,6 +223,16 @@ func (c *userHostServiceClient) GetHostProfile(ctx context.Context, in *GetHostP
return out, nil
}
func (c *userHostServiceClient) BatchGetHostProfiles(ctx context.Context, in *BatchGetHostProfilesRequest, opts ...grpc.CallOption) (*BatchGetHostProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetHostProfilesResponse)
err := c.cc.Invoke(ctx, UserHostService_BatchGetHostProfiles_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetBDProfileResponse)
@ -321,6 +333,7 @@ type UserHostServiceServer interface {
GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error)
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
BatchGetHostProfiles(context.Context, *BatchGetHostProfilesRequest) (*BatchGetHostProfilesResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
@ -381,6 +394,9 @@ func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context,
func (UnimplementedUserHostServiceServer) GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetHostProfile not implemented")
}
func (UnimplementedUserHostServiceServer) BatchGetHostProfiles(context.Context, *BatchGetHostProfilesRequest) (*BatchGetHostProfilesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetHostProfiles not implemented")
}
func (UnimplementedUserHostServiceServer) GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetBDProfile not implemented")
}
@ -678,6 +694,24 @@ func _UserHostService_GetHostProfile_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _UserHostService_BatchGetHostProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetHostProfilesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).BatchGetHostProfiles(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_BatchGetHostProfiles_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).BatchGetHostProfiles(ctx, req.(*BatchGetHostProfilesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_GetBDProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetBDProfileRequest)
if err := dec(in); err != nil {
@ -885,6 +919,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetHostProfile",
Handler: _UserHostService_GetHostProfile_Handler,
},
{
MethodName: "BatchGetHostProfiles",
Handler: _UserHostService_BatchGetHostProfiles_Handler,
},
{
MethodName: "GetBDProfile",
Handler: _UserHostService_GetBDProfile_Handler,

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -106,6 +106,7 @@ type UserHostClient interface {
ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error)
ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
BatchGetHostProfiles(ctx context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error)
@ -505,6 +506,10 @@ func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.Get
return c.client.GetHostProfile(ctx, req)
}
func (c *grpcUserHostClient) BatchGetHostProfiles(ctx context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error) {
return c.client.BatchGetHostProfiles(ctx, req)
}
func (c *grpcUserHostClient) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
return c.client.GetBDProfile(ctx, req)
}

View File

@ -21,6 +21,8 @@ import (
"time"
jwt "github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
@ -314,6 +316,7 @@ type fakeUserProfileClient struct {
lastBusinessLookup *userv1.BusinessUserLookupRequest
lastStats *userv1.GetMyProfileStatsRequest
lastBatch *userv1.BatchGetUsersRequest
batchRequests []*userv1.BatchGetUsersRequest
getRequests []*userv1.GetUserRequest
lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest
@ -459,9 +462,13 @@ type fakeUserHostClient struct {
listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse
listCoinSellersErr error
lastHost *userv1.GetHostProfileRequest
lastBatchHost *userv1.BatchGetHostProfilesRequest
hostCalls int
batchHostCalls int
hostProfile *userv1.HostProfile
hostProfiles map[int64]*userv1.HostProfile
hostErr error
batchHostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
bdErr error
@ -577,9 +584,14 @@ type fakeWalletClient struct {
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
listGiftConfigsCalls int
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
listGiftTypesCalls int
listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse
lastListUserResources *walletv1.ListUserResourcesRequest
listUserResourcesCalls int
listUserResourcesResp *walletv1.ListUserResourcesResponse
lastGrantResource *walletv1.GrantResourceRequest
grantResourceResp *walletv1.ResourceGrantResponse
grantResourceErr error
@ -848,6 +860,7 @@ func (f *fakeUserProfileClient) GetMyProfileStats(_ context.Context, req *userv1
func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
f.lastBatch = req
f.batchRequests = append(f.batchRequests, req)
users := make(map[int64]*userv1.User, len(req.GetUserIds()))
for _, userID := range req.GetUserIds() {
if f.usersByID != nil {
@ -863,6 +876,7 @@ func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.Bat
Username: "user-" + strconv.FormatInt(userID, 10),
Gender: "female",
Birth: "2000-01-02",
CountryId: 840,
Country: "US",
CountryName: "United States",
CountryDisplayName: "United States",
@ -1537,6 +1551,7 @@ func (f *fakeUserHostClient) ProcessRoleInvitation(_ context.Context, req *userv
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req
f.hostCalls++
if f.hostErr != nil {
return nil, f.hostErr
}
@ -1550,6 +1565,27 @@ func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHo
return &userv1.GetHostProfileResponse{}, nil
}
func (f *fakeUserHostClient) BatchGetHostProfiles(_ context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error) {
f.lastBatchHost = req
f.batchHostCalls++
if f.batchHostErr != nil {
return nil, f.batchHostErr
}
profiles := make(map[int64]*userv1.HostProfile, len(req.GetUserIds()))
for _, userID := range req.GetUserIds() {
if f.hostProfiles != nil {
if profile := f.hostProfiles[userID]; profile != nil {
profiles[userID] = profile
}
continue
}
if f.hostProfile != nil && (f.hostProfile.GetUserId() == 0 || f.hostProfile.GetUserId() == userID) {
profiles[userID] = f.hostProfile
}
}
return &userv1.BatchGetHostProfilesResponse{HostProfiles: profiles}, nil
}
func (f *fakeUserHostClient) GetBDProfile(_ context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
f.lastBD = req
if f.bdErr != nil {
@ -2113,6 +2149,7 @@ func (f *fakeWalletClient) PurchaseResourceShopItem(_ context.Context, req *wall
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
f.lastListGiftConfigs = req
f.listGiftConfigsCalls++
if f.err != nil {
return nil, f.err
}
@ -2124,6 +2161,7 @@ func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.List
func (f *fakeWalletClient) ListGiftTypeConfigs(_ context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) {
f.lastListGiftTypes = req
f.listGiftTypesCalls++
if f.err != nil {
return nil, f.err
}
@ -2144,7 +2182,12 @@ func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantR
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-1", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(), GrantSource: req.GetGrantSource(), GrantSubjectId: strconv.FormatInt(req.GetResourceId(), 10), Status: "succeeded", OperatorUserId: req.GetOperatorUserId()}}, nil
}
func (f *fakeWalletClient) ListUserResources(context.Context, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
func (f *fakeWalletClient) ListUserResources(_ context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
f.lastListUserResources = req
f.listUserResourcesCalls++
if f.listUserResourcesResp != nil {
return f.listUserResourcesResp, nil
}
return &walletv1.ListUserResourcesResponse{}, nil
}
@ -2617,7 +2660,8 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
roomClient := &fakeRoomClient{}
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
profileClient := &fakeUserProfileClient{regionID: 1001}
handler := NewHandlerWithClients(roomClient, nil, nil, profileClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host","target_user_id":43,"gift_id":"rose","gift_count":2}`)))
@ -2629,8 +2673,11 @@ func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 43 {
t.Fatalf("gateway must query target host profile before sending gift: %+v", hostClient.lastHost)
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 1 || hostClient.lastBatchHost.GetUserIds()[0] != 43 {
t.Fatalf("gateway must batch query target host profile before sending gift: batch=%+v batch_calls=%d single_calls=%d", hostClient.lastBatchHost, hostClient.batchHostCalls, hostClient.hostCalls)
}
if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 {
t.Fatalf("sender country/profile must come from one BatchGetUsers call: get=%+v batches=%d", profileClient.lastGet, len(profileClient.batchRequests))
}
if roomClient.lastGift == nil || !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 || roomClient.lastGift.GetTargetAgencyOwnerUserId() != 30001 {
t.Fatalf("gateway must inject active host period scope: %+v", roomClient.lastGift)
@ -2652,6 +2699,9 @@ func TestSendGiftDoesNotInjectHostPeriodScopeForNonHost(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 {
t.Fatalf("non-host lookup must still use batch host query: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
}
if roomClient.lastGift == nil || roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 0 {
t.Fatalf("non-host target must not receive host period scope: %+v", roomClient.lastGift)
}
@ -2714,6 +2764,9 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
if len(targets) != 1 || targets[0].GetUserId() != 43 || targets[0].GetUsername() != "Robot Receiver" || targets[0].GetAvatar() == "" || targets[0].GetDisplayUserId() != "100043" {
t.Fatalf("gateway must inject target display profile snapshots: %+v", targets)
}
if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 || len(profileClient.lastBatch.GetUserIds()) != 2 || profileClient.lastBatch.GetUserIds()[0] != 42 || profileClient.lastBatch.GetUserIds()[1] != 43 {
t.Fatalf("gift user snapshots must use one batch profile request: get=%+v batch=%+v calls=%d", profileClient.lastGet, profileClient.lastBatch, len(profileClient.batchRequests))
}
}
func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
@ -2734,6 +2787,9 @@ func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 2 || hostClient.lastBatchHost.GetUserIds()[0] != 43 || hostClient.lastBatchHost.GetUserIds()[1] != 44 {
t.Fatalf("gateway must resolve multi-target host profiles in one batch: batch=%+v batch_calls=%d single_calls=%d", hostClient.lastBatchHost, hostClient.batchHostCalls, hostClient.hostCalls)
}
scopes := roomClient.lastGift.GetTargetHostScopes()
if len(scopes) != 2 || !scopes[0].GetTargetIsHost() || scopes[0].GetTargetHostRegionId() != 8801 || !scopes[1].GetTargetIsHost() || scopes[1].GetTargetHostRegionId() != 8802 {
t.Fatalf("gateway must inject each target host scope: %+v", scopes)
@ -2743,6 +2799,77 @@ func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
}
}
func TestSendGiftFallsBackToSingleHostLookupWhenBatchRPCIsUnimplemented(t *testing.T) {
roomClient := &fakeRoomClient{}
hostClient := &fakeUserHostClient{
batchHostErr: status.Error(codes.Unimplemented, "unknown method BatchGetHostProfiles"),
hostProfiles: map[int64]*userv1.HostProfile{
43: {UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001},
44: {UserId: 44, Status: "active", RegionId: 8802, CurrentAgencyOwnerUserId: 30002},
},
}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host-fallback","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 2 {
t.Fatalf("unimplemented batch RPC should fall back to one single lookup per target: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
}
scopes := roomClient.lastGift.GetTargetHostScopes()
if len(scopes) != 2 || scopes[0].GetTargetHostRegionId() != 8801 || scopes[1].GetTargetHostRegionId() != 8802 {
t.Fatalf("fallback must preserve each target host scope: %+v", scopes)
}
}
func TestSendGiftPropagatesBatchHostProfileErrors(t *testing.T) {
roomClient := &fakeRoomClient{}
hostClient := &fakeUserHostClient{batchHostErr: xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host profile lookup failed"))}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host-error","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code == http.StatusOK || roomClient.lastGift != nil {
t.Fatalf("batch host lookup errors must block SendGift: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String())
}
if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 {
t.Fatalf("non-unimplemented batch errors must not fall back to single lookup: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
}
}
func TestSendGiftRequiresSenderCountryRegionFromBatchUserProfile(t *testing.T) {
roomClient := &fakeRoomClient{}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, RegionId: 1001},
43: {UserId: 43, Status: userv1.UserStatus_USER_STATUS_ACTIVE, CountryId: 840, RegionId: 1001},
}}
router := NewHandlerWithClients(roomClient, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-sender-country","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || roomClient.lastGift != nil {
t.Fatalf("sender missing country must block SendGift before room RPC: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String())
}
if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 {
t.Fatalf("sender country validation must use batch profile path: get=%+v batches=%d", profileClient.lastGet, len(profileClient.batchRequests))
}
}
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
@ -7030,6 +7157,129 @@ func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
}
}
func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
giftResource := &walletv1.Resource{
ResourceId: 11,
ResourceCode: "gift_rose",
ResourceType: "gift",
Name: "Rose",
Status: "active",
UsageScopes: []string{"gift"},
AssetUrl: "https://cdn.example/rose.svga",
SortOrder: 1,
}
walletClient := &fakeWalletClient{
resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
TypeCode: "normal",
Name: "普通礼物",
TabKey: "Gift",
Status: "active",
SortOrder: 10,
}}},
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
GiftId: "rose",
ResourceId: giftResource.GetResourceId(),
Resource: giftResource,
Status: "active",
Name: "Rose",
GiftTypeCode: "normal",
PriceVersion: "v1",
ChargeAssetType: "COIN",
CoinPrice: 100,
HeatValue: 100,
RegionIds: []int64{0, 1001},
}}, Total: 1},
listUserResourcesResp: &walletv1.ListUserResourcesResponse{Resources: []*walletv1.UserResourceEntitlement{{
EntitlementId: "ent-rose",
UserId: 42,
ResourceId: giftResource.GetResourceId(),
Resource: giftResource,
Status: "active",
Quantity: 2,
RemainingQuantity: 2,
}}},
}
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: &roomv1.RoomSnapshot{
RoomId: "room-gift-cache",
OwnerUserId: 88,
Status: "active",
VisibleRegionId: 1001,
}}}
handler := NewHandler(&fakeRoomClient{})
handler.SetRoomQueryClient(queryClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
for attempt := 0; attempt < 2; attempt++ {
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-cache/gift-panel", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("gift panel status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String())
}
}
if walletClient.listGiftTypesCalls != 1 || walletClient.listGiftConfigsCalls != 1 {
t.Fatalf("gift panel config should be cached, typeCalls=%d giftCalls=%d", walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
}
if len(walletClient.balanceRequests) != 2 || walletClient.listUserResourcesCalls != 2 {
t.Fatalf("gift panel realtime data must not be cached, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
}
if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetRegionId() != 1001 || walletClient.lastListGiftConfigs.GetPageSize() != 500 || !walletClient.lastListGiftConfigs.GetFilterRegion() {
t.Fatalf("gift panel list request mismatch: %+v", walletClient.lastListGiftConfigs)
}
if walletClient.lastListUserResources == nil || walletClient.lastListUserResources.GetUserId() != 42 || walletClient.lastListUserResources.GetResourceType() != "gift" || !walletClient.lastListUserResources.GetActiveOnly() {
t.Fatalf("gift panel bag request mismatch: %+v", walletClient.lastListUserResources)
}
}
func TestRoomGiftPanelCacheSeparatesVisibleRegion(t *testing.T) {
walletClient := &fakeWalletClient{
resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{TypeCode: "normal", TabKey: "Gift", Status: "active"}}},
listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
GiftId: "rose",
ResourceId: 11,
Resource: &walletv1.Resource{ResourceId: 11, ResourceCode: "gift_rose", ResourceType: "gift", Name: "Rose", Status: "active"},
Status: "active",
Name: "Rose",
GiftTypeCode: "normal",
CoinPrice: 100,
}}, Total: 1},
}
snapshot := &roomv1.RoomSnapshot{
RoomId: "room-gift-region-cache",
OwnerUserId: 88,
Status: "active",
VisibleRegionId: 1001,
}
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: snapshot}}
handler := NewHandler(&fakeRoomClient{})
handler.SetRoomQueryClient(queryClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-region-cache/gift-panel", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("first gift panel status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
snapshot.VisibleRegionId = 1002
request = httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-region-cache/gift-panel", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder = httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("second gift panel status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 || walletClient.lastListGiftConfigs.GetRegionId() != 1002 {
t.Fatalf("gift panel config cache must be separated by visible region, typeCalls=%d giftCalls=%d lastGift=%+v", walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls, walletClient.lastListGiftConfigs)
}
}
func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) {
messageClient := &fakeMessageInboxClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
@ -7686,6 +7936,7 @@ func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T)
if walletClient.lastGoogleConfirm == nil ||
walletClient.lastGoogleConfirm.GetUserId() != 42 ||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
walletClient.lastGoogleConfirm.GetCountryId() != 840 ||
walletClient.lastGoogleConfirm.GetProductId() != 11 ||
walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" ||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
@ -7737,6 +7988,7 @@ func TestConfirmGooglePaymentAllowsGoogleProductCodeWithoutProductID(t *testing.
if walletClient.lastGoogleConfirm == nil ||
walletClient.lastGoogleConfirm.GetUserId() != 42 ||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
walletClient.lastGoogleConfirm.GetCountryId() != 840 ||
walletClient.lastGoogleConfirm.GetProductId() != 0 ||
walletClient.lastGoogleConfirm.GetProductCode() != "first_recharge_google_999" ||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
@ -8079,6 +8331,7 @@ func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) {
DisplayUserId: "163055",
Username: "normal55",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
CountryId: 818,
Country: "EG",
CountryDisplayName: "Egypt",
RegionId: 7300,
@ -8103,6 +8356,7 @@ func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) {
walletClient.lastH5CreateOrder.GetCommandId() != "cmd-h5-create-eg" ||
walletClient.lastH5CreateOrder.GetTargetUserId() != 55 ||
walletClient.lastH5CreateOrder.GetTargetRegionId() != 7300 ||
walletClient.lastH5CreateOrder.GetTargetCountryId() != 818 ||
walletClient.lastH5CreateOrder.GetTargetCountryCode() != "EG" ||
walletClient.lastH5CreateOrder.GetAudienceType() != "normal" ||
walletClient.lastH5CreateOrder.GetProductId() != 93001 ||

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

View File

@ -15,6 +15,8 @@ import (
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
@ -705,37 +707,13 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
httpkit.WriteRPCError(writer, request, err)
return
}
giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ActiveOnly: true,
})
panelConfig, err := h.roomGiftPanelBaseConfig(request, snapshot.GetVisibleRegionId())
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
for _, item := range giftTypeResp.GetGiftTypes() {
giftTypes = append(giftTypes, giftTypeFromProto(item))
}
giftResp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
Page: 1,
PageSize: 500,
ActiveOnly: true,
RegionId: snapshot.GetVisibleRegionId(),
FilterRegion: true,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
gifts := make([]giftConfigData, 0, len(giftResp.GetGifts()))
for _, gift := range giftResp.GetGifts() {
gifts = append(gifts, giftFromProto(gift))
}
gifts = filterGiftPanelGifts(gifts, giftTypes)
giftTypes := panelConfig.GiftTypes
gifts := panelConfig.Gifts
bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
@ -753,6 +731,52 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
})
}
func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) {
app := appcode.FromContext(request.Context())
if h.giftPanelCache != nil {
if cached, ok := h.giftPanelCache.Get(app, regionID, time.Now()); ok {
return cached, nil
}
}
giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: app,
ActiveOnly: true,
})
if err != nil {
return roomGiftPanelConfig{}, err
}
giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
for _, item := range giftTypeResp.GetGiftTypes() {
giftTypes = append(giftTypes, giftTypeFromProto(item))
}
giftResp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: app,
Page: 1,
PageSize: 500,
ActiveOnly: true,
RegionId: regionID,
FilterRegion: true,
})
if err != nil {
return roomGiftPanelConfig{}, err
}
gifts := make([]giftConfigData, 0, len(giftResp.GetGifts()))
for _, gift := range giftResp.GetGifts() {
gifts = append(gifts, giftFromProto(gift))
}
config := roomGiftPanelConfig{
GiftTypes: giftTypes,
// 礼物类型禁用后不应出现在房间面板;缓存保存已过滤的稳定物料,背包礼物仍按用户每次实时拼接。
Gifts: filterGiftPanelGifts(gifts, giftTypes),
}
if h.giftPanelCache != nil {
h.giftPanelCache.Set(app, regionID, config, time.Now())
}
return config, nil
}
func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
return nil, nil
@ -1557,7 +1581,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
senderCountryID, senderRegionID, err := h.resolveRoomActorCountryRegion(request)
userSnapshots, err := h.resolveGiftUserSnapshots(request, targetUserIDs)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
@ -1568,7 +1592,6 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
return
}
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
senderDisplayProfile, targetDisplayProfiles := h.resolveGiftDisplayProfiles(request, targetUserIDs)
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
@ -1578,16 +1601,16 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
GiftId: body.GiftID,
GiftCount: body.GiftCount,
PoolId: body.PoolID,
SenderCountryId: senderCountryID,
SenderRegionId: senderRegionID,
SenderCountryId: userSnapshots.senderCountryID,
SenderRegionId: userSnapshots.senderRegionID,
TargetIsHost: firstTargetScope.GetTargetIsHost(),
TargetHostRegionId: firstTargetScope.GetTargetHostRegionId(),
TargetAgencyOwnerUserId: firstTargetScope.GetTargetAgencyOwnerUserId(),
TargetHostScopes: targetHostScopes,
EntitlementId: strings.TrimSpace(body.EntitlementID),
Source: strings.TrimSpace(body.Source),
SenderDisplayProfile: senderDisplayProfile,
TargetDisplayProfiles: targetDisplayProfiles,
SenderDisplayProfile: userSnapshots.senderDisplayProfile,
TargetDisplayProfiles: userSnapshots.targetDisplayProfiles,
})
httpkit.Write(writer, request, resp, err)
}
@ -1613,6 +1636,49 @@ func (h *Handler) resolveRoomActorCountryRegion(request *http.Request) (int64, i
return resp.GetUser().GetCountryId(), resp.GetUser().GetRegionId(), nil
}
type giftUserSnapshots struct {
senderCountryID int64
senderRegionID int64
senderDisplayProfile *roomv1.SendGiftDisplayProfile
targetDisplayProfiles []*roomv1.SendGiftDisplayProfile
}
func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, error) {
if h.userProfileClient == nil {
return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "user service is not configured"))
}
senderUserID := auth.UserIDFromContext(request.Context())
userIDs := uniquePositiveUserIDs(append([]int64{senderUserID}, targetUserIDs...))
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
return giftUserSnapshots{}, err
}
sender := resp.GetUsers()[senderUserID]
if sender == nil {
return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.NotFound, "user not found"))
}
if sender.GetCountryId() <= 0 || sender.GetRegionId() <= 0 {
return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "user country and region are required"))
}
targets := make([]*roomv1.SendGiftDisplayProfile, 0, len(targetUserIDs))
for _, targetUserID := range targetUserIDs {
if profile := giftDisplayProfileFromUser(resp.GetUsers()[targetUserID]); profile != nil {
// 目标展示快照不是结算前置条件;缺失 target 资料时只让客户端走最终昵称头像兜底。
targets = append(targets, profile)
}
}
return giftUserSnapshots{
senderCountryID: sender.GetCountryId(),
senderRegionID: sender.GetRegionId(),
senderDisplayProfile: giftDisplayProfileFromUser(sender),
targetDisplayProfiles: targets,
}, nil
}
func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) {
if targetUserID <= 0 || h.userHostClient == nil {
// 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed避免客户端伪造主播入账。
@ -1637,6 +1703,38 @@ func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID
}
func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) {
if len(targetUserIDs) == 0 {
return []*roomv1.SendGiftTargetHostScope{}, nil
}
if h.userHostClient == nil {
scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
for _, targetUserID := range targetUserIDs {
// 未装配 host client 时送礼仍可继续,但所有 target 都 fail-closed 为非主播。
scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, nil))
}
return scopes, nil
}
resp, err := h.userHostClient.BatchGetHostProfiles(request.Context(), &userv1.BatchGetHostProfilesRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: targetUserIDs,
})
if err != nil {
if status.Code(err) == codes.Unimplemented {
// 滚动发布期间旧 user-service 还没有 batch RPC 时,临时退回单查;其他错误必须阻断送礼。
return h.resolveGiftTargetHostScopesBySingleLookup(request, targetUserIDs)
}
return nil, err
}
profiles := resp.GetHostProfiles()
scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
for _, targetUserID := range targetUserIDs {
// 批量送礼必须按接收方分别固化主播快照;缺失 profile 表示该 target 当前不是主播。
scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, profiles[targetUserID]))
}
return scopes, nil
}
func (h *Handler) resolveGiftTargetHostScopesBySingleLookup(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) {
scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
for _, targetUserID := range targetUserIDs {
targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, targetUserID)
@ -1654,31 +1752,16 @@ func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserI
return scopes, nil
}
func (h *Handler) resolveGiftDisplayProfiles(request *http.Request, targetUserIDs []int64) (*roomv1.SendGiftDisplayProfile, []*roomv1.SendGiftDisplayProfile) {
if h.userProfileClient == nil {
return nil, nil
func giftTargetHostScopeFromProfile(targetUserID int64, profile *userv1.HostProfile) *roomv1.SendGiftTargetHostScope {
scope := &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
// 只有 active host 且带区域才能进入工资政策链路disabled/缺区域按非主播处理,不影响正常送礼。
return scope
}
senderUserID := auth.UserIDFromContext(request.Context())
userIDs := uniquePositiveUserIDs(append([]int64{senderUserID}, targetUserIDs...))
if len(userIDs) == 0 {
return nil, nil
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
// 展示快照只影响 IM 昵称头像兜底,不能因为增强资料读取失败阻断已经通过国家/主播校验的送礼主链路。
return nil, nil
}
sender := giftDisplayProfileFromUser(resp.GetUsers()[senderUserID])
targets := make([]*roomv1.SendGiftDisplayProfile, 0, len(targetUserIDs))
for _, targetUserID := range targetUserIDs {
if profile := giftDisplayProfileFromUser(resp.GetUsers()[targetUserID]); profile != nil {
targets = append(targets, profile)
}
}
return sender, targets
scope.TargetIsHost = true
scope.TargetHostRegionId = profile.GetRegionId()
scope.TargetAgencyOwnerUserId = profile.GetCurrentAgencyOwnerUserId()
return scope
}
func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfile {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -249,6 +249,7 @@ func New(cfg config.Config) (*App, error) {
RobotOutboxPublisher: robotOutboxPublisher,
HumanRobotPoolProvider: integration.NewGRPCHumanRoomRobotPoolProvider(robotConn, userConn),
RoomGiftLeaderboard: roomservice.NewRedisRoomGiftLeaderboardStore(redisClient),
RoomListCache: roomservice.NewRedisRoomListCacheStore(redisClient),
LuckyGiftSendLocker: roomservice.NewRedisLuckyGiftSendLocker(redisClient),
LuckyGiftSendLockTTL: cfg.LuckyGiftSendLockTTL,
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient)
@ -449,29 +450,51 @@ func (a *App) Run() error {
a.service.RunRoomRocketLaunchWorker(ctx, a.cfg.RoomRocketLaunchScanInterval)
})
}
a.workers.Go(func(ctx context.Context) {
// 火箭燃料进度允许 1 秒展示延迟100ms 扫描让到期 bucket 尽快写回 outbox后续仍走原 MQ/IM 补偿链路。
a.service.RunRoomRocketProgressFlushWorker(ctx, 100*time.Millisecond)
})
if a.cfg.RoomListCacheRefreshInterval > 0 {
// Redis 发现页缓存是 MySQL owner 表的展示读模型,启动后先 warmup再周期性修复 best-effort 增量丢失。
a.workers.Go(func(ctx context.Context) {
a.service.RunRoomListCacheRefreshWorker(ctx, a.cfg.RoomListCacheRefreshInterval)
})
}
if a.cfg.OutboxWorker.Enabled {
a.workers.Go(func(ctx context.Context) {
a.service.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
PollInterval: a.cfg.OutboxWorker.PollInterval,
BatchSize: a.cfg.OutboxWorker.BatchSize,
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
RetryStrategy: a.cfg.OutboxWorker.RetryStrategy,
MaxRetryCount: a.cfg.OutboxWorker.MaxRetryCount,
InitialBackoff: a.cfg.OutboxWorker.InitialBackoff,
MaxBackoff: a.cfg.OutboxWorker.MaxBackoff,
PollInterval: a.cfg.OutboxWorker.PollInterval,
BatchSize: a.cfg.OutboxWorker.BatchSize,
Concurrency: a.cfg.OutboxWorker.Concurrency,
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
RetryStrategy: a.cfg.OutboxWorker.RetryStrategy,
MaxRetryCount: a.cfg.OutboxWorker.MaxRetryCount,
InitialBackoff: a.cfg.OutboxWorker.InitialBackoff,
MaxBackoff: a.cfg.OutboxWorker.MaxBackoff,
StaleDiscardAfter: a.cfg.OutboxWorker.StaleDiscardAfter,
CleanupInterval: a.cfg.OutboxWorker.CleanupInterval,
CleanupActiveAfter: a.cfg.OutboxWorker.CleanupActiveAfter,
CleanupTerminalAfter: a.cfg.OutboxWorker.CleanupTerminalAfter,
CleanupBatchSize: a.cfg.OutboxWorker.CleanupBatchSize,
})
})
}
if a.cfg.RobotOutboxWorker.Enabled {
a.workers.Go(func(ctx context.Context) {
a.service.RunRobotOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
PollInterval: a.cfg.RobotOutboxWorker.PollInterval,
BatchSize: a.cfg.RobotOutboxWorker.BatchSize,
PublishTimeout: a.cfg.RobotOutboxWorker.PublishTimeout,
RetryStrategy: a.cfg.RobotOutboxWorker.RetryStrategy,
MaxRetryCount: a.cfg.RobotOutboxWorker.MaxRetryCount,
InitialBackoff: a.cfg.RobotOutboxWorker.InitialBackoff,
MaxBackoff: a.cfg.RobotOutboxWorker.MaxBackoff,
PollInterval: a.cfg.RobotOutboxWorker.PollInterval,
BatchSize: a.cfg.RobotOutboxWorker.BatchSize,
Concurrency: a.cfg.RobotOutboxWorker.Concurrency,
PublishTimeout: a.cfg.RobotOutboxWorker.PublishTimeout,
RetryStrategy: a.cfg.RobotOutboxWorker.RetryStrategy,
MaxRetryCount: a.cfg.RobotOutboxWorker.MaxRetryCount,
InitialBackoff: a.cfg.RobotOutboxWorker.InitialBackoff,
MaxBackoff: a.cfg.RobotOutboxWorker.MaxBackoff,
StaleDiscardAfter: a.cfg.RobotOutboxWorker.StaleDiscardAfter,
CleanupInterval: a.cfg.RobotOutboxWorker.CleanupInterval,
CleanupActiveAfter: a.cfg.RobotOutboxWorker.CleanupActiveAfter,
CleanupTerminalAfter: a.cfg.RobotOutboxWorker.CleanupTerminalAfter,
CleanupBatchSize: a.cfg.RobotOutboxWorker.CleanupBatchSize,
})
})
}

View File

@ -84,6 +84,8 @@ type Config struct {
MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"`
// RoomRocketLaunchScanInterval 是本节点扫描到点发射火箭的周期。
RoomRocketLaunchScanInterval time.Duration `yaml:"room_rocket_launch_scan_interval"`
// RoomListCacheRefreshInterval 是用 MySQL owner 表重建 Redis 发现页展示读模型的周期。
RoomListCacheRefreshInterval time.Duration `yaml:"room_list_cache_refresh_interval"`
// RocketMQ 承载 room_outbox 事件分发和语音房火箭延迟发射唤醒。
RocketMQ RocketMQConfig `yaml:"rocketmq"`
// OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
@ -104,6 +106,8 @@ type OutboxWorkerConfig struct {
PollInterval time.Duration `yaml:"poll_interval"`
// BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。
BatchSize int `yaml:"batch_size"`
// Concurrency 是本进程内并发抢占 outbox 的 worker 数;多机多 worker 依赖 MySQL 原子 claim 防重复。
Concurrency int `yaml:"concurrency"`
// PublishTimeout 是单条 outbox 投递腾讯云 IM 的最大耗时。
PublishTimeout time.Duration `yaml:"publish_timeout"`
// RetryStrategy 当前只接受 exponential_backoff避免配置错误静默改变投递语义。
@ -114,6 +118,16 @@ type OutboxWorkerConfig struct {
InitialBackoff time.Duration `yaml:"initial_backoff"`
// MaxBackoff 是指数退避的最大等待时间。
MaxBackoff time.Duration `yaml:"max_backoff"`
// StaleDiscardAfter 只用于 robot outbox超过该窗口的展示事件直接删除不再补发 MQ/IM。
StaleDiscardAfter time.Duration `yaml:"stale_discard_after"`
// CleanupInterval 控制 robot outbox 后台清理频率;非正数表示不启动清理。
CleanupInterval time.Duration `yaml:"cleanup_interval"`
// CleanupActiveAfter 控制 pending/retryable/delivering 机器人展示事件的最大保留时长。
CleanupActiveAfter time.Duration `yaml:"cleanup_active_after"`
// CleanupTerminalAfter 控制 delivered/failed 机器人展示事件的最大保留时长。
CleanupTerminalAfter time.Duration `yaml:"cleanup_terminal_after"`
// CleanupBatchSize 控制单轮清理最多删除多少行,避免长事务拖慢主库。
CleanupBatchSize int `yaml:"cleanup_batch_size"`
}
// RocketMQConfig 描述 room-service 使用的 RocketMQ 集群和业务 topic。
@ -279,6 +293,7 @@ func Default() Config {
MicPublishTimeout: 15 * time.Second,
MicPublishScanInterval: time.Second,
RoomRocketLaunchScanInterval: time.Second,
RoomListCacheRefreshInterval: 5 * time.Minute,
RocketMQ: defaultRocketMQConfig(),
OutboxWorker: defaultOutboxWorkerConfig(),
RobotOutboxWorker: defaultOutboxWorkerConfig(),
@ -297,6 +312,7 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
PublishMode: OutboxPublishModeDirect,
PollInterval: time.Second,
BatchSize: 100,
Concurrency: 1,
PublishTimeout: 3 * time.Second,
RetryStrategy: "exponential_backoff",
MaxRetryCount: 10,
@ -387,6 +403,9 @@ func Normalize(cfg Config) (Config, error) {
if cfg.LuckyGiftSendLockTTL <= 0 {
cfg.LuckyGiftSendLockTTL = 5 * time.Second
}
if cfg.RoomListCacheRefreshInterval <= 0 {
cfg.RoomListCacheRefreshInterval = 5 * time.Minute
}
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
if cfg.WalletServiceAddr == "" {
cfg.WalletServiceAddr = "127.0.0.1:13004"
@ -457,6 +476,9 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
if cfg.BatchSize <= 0 {
cfg.BatchSize = defaults.BatchSize
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = defaults.Concurrency
}
if cfg.PublishTimeout <= 0 {
cfg.PublishTimeout = defaults.PublishTimeout
}
@ -472,6 +494,10 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
if cfg.MaxBackoff < cfg.InitialBackoff {
cfg.MaxBackoff = cfg.InitialBackoff
}
if cfg.CleanupBatchSize <= 0 && (cfg.CleanupActiveAfter > 0 || cfg.CleanupTerminalAfter > 0) {
// 清理开启时必须有批量上限;默认和线上一次性清理批大小保持一致。
cfg.CleanupBatchSize = 50000
}
cfg.RetryStrategy = strings.TrimSpace(cfg.RetryStrategy)
if cfg.RetryStrategy == "" {
cfg.RetryStrategy = defaults.RetryStrategy

View File

@ -29,6 +29,9 @@ func TestLoad(t *testing.T) {
if cfg.LeaseTTL != 10*time.Second {
t.Fatalf("unexpected lease ttl: %s", cfg.LeaseTTL)
}
if cfg.RoomListCacheRefreshInterval != 5*time.Minute {
t.Fatalf("unexpected room list cache refresh interval: %s", cfg.RoomListCacheRefreshInterval)
}
// SendGift 主链路依赖 wallet-service默认地址必须与本地编排一致。
if cfg.WalletServiceAddr != "127.0.0.1:13004" {
@ -37,10 +40,10 @@ func TestLoad(t *testing.T) {
if cfg.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.SecretKey == "" || cfg.TencentIM.AdminIdentifier != "administrator" {
t.Fatalf("local room-service must use test Tencent IM account: %+v", cfg.TencentIM)
}
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.PublishTimeout != 3*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
}
if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.PollInterval != time.Second || cfg.RobotOutboxWorker.BatchSize != 30 || cfg.RobotOutboxWorker.PublishTimeout != 3*time.Second || cfg.RobotOutboxWorker.RetryStrategy != "exponential_backoff" || cfg.RobotOutboxWorker.MaxRetryCount != 10 || cfg.RobotOutboxWorker.InitialBackoff != 5*time.Second || cfg.RobotOutboxWorker.MaxBackoff != 5*time.Minute {
if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.PollInterval != 200*time.Millisecond || cfg.RobotOutboxWorker.BatchSize != 100 || cfg.RobotOutboxWorker.Concurrency != 1 || cfg.RobotOutboxWorker.PublishTimeout != 15*time.Second || cfg.RobotOutboxWorker.RetryStrategy != "exponential_backoff" || cfg.RobotOutboxWorker.MaxRetryCount != 10 || cfg.RobotOutboxWorker.InitialBackoff != 5*time.Second || cfg.RobotOutboxWorker.MaxBackoff != 5*time.Minute || cfg.RobotOutboxWorker.StaleDiscardAfter != 60*time.Second || cfg.RobotOutboxWorker.CleanupInterval != 30*time.Second || cfg.RobotOutboxWorker.CleanupActiveAfter != 5*time.Minute || cfg.RobotOutboxWorker.CleanupTerminalAfter != time.Hour || cfg.RobotOutboxWorker.CleanupBatchSize != 50000 {
t.Fatalf("unexpected robot outbox worker config: %+v", cfg.RobotOutboxWorker)
}
// 本地默认也走 room_outbox MQ、IM bridge 和火箭延迟唤醒,避免本地直投和线上 MQ 语义分叉。
@ -74,13 +77,16 @@ func TestLoadTencentExample(t *testing.T) {
if cfg.TencentIM.RequestTimeout != 5*time.Second {
t.Fatalf("unexpected request timeout: %s", cfg.TencentIM.RequestTimeout)
}
if cfg.RoomListCacheRefreshInterval != 5*time.Minute {
t.Fatalf("unexpected room list cache refresh interval: %s", cfg.RoomListCacheRefreshInterval)
}
if cfg.AdvertiseAddr == "" || cfg.AdvertiseAddr == "room-service.internal:13001" {
t.Fatalf("tencent example must use per-instance advertise_addr, got %q", cfg.AdvertiseAddr)
}
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker)
}
if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.BatchSize != 30 {
if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.PollInterval != 200*time.Millisecond || cfg.RobotOutboxWorker.BatchSize != 100 || cfg.RobotOutboxWorker.Concurrency != 1 || cfg.RobotOutboxWorker.PublishTimeout != 15*time.Second || cfg.RobotOutboxWorker.StaleDiscardAfter != 60*time.Second || cfg.RobotOutboxWorker.CleanupActiveAfter != 5*time.Minute || cfg.RobotOutboxWorker.CleanupTerminalAfter != time.Hour {
t.Fatalf("tencent example must configure robot outbox worker: %+v", cfg.RobotOutboxWorker)
}
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RobotRoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled {
@ -100,6 +106,9 @@ func TestLoadDockerConfigStartsRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ {
t.Fatalf("docker config should publish room outbox through MQ, got %q", cfg.OutboxWorker.PublishMode)
}
if cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second {
t.Fatalf("docker config must run room outbox with 16 workers and a longer claim window: %+v", cfg.OutboxWorker)
}
if !cfg.RocketMQ.RoomOutbox.Enabled {
t.Fatalf("docker config must enable room outbox MQ")
}
@ -148,6 +157,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
cfg := Default()
cfg.OutboxWorker.PollInterval = 0
cfg.OutboxWorker.BatchSize = 0
cfg.OutboxWorker.Concurrency = 0
cfg.OutboxWorker.PublishTimeout = 0
cfg.OutboxWorker.RetryStrategy = ""
cfg.OutboxWorker.MaxRetryCount = 0
@ -158,7 +168,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
if err != nil {
t.Fatalf("Normalize failed: %v", err)
}
if normalized.OutboxWorker.PublishMode != OutboxPublishModeDirect || normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute {
if normalized.OutboxWorker.PublishMode != OutboxPublishModeDirect || normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.Concurrency != 1 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute {
t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker)
}
}

View File

@ -111,6 +111,7 @@ func (s *Service) AdminCreateRoomPin(ctx context.Context, req *roomv1.AdminCreat
if !ok {
return nil, xerr.New(xerr.Conflict, "room is not active or not found")
}
s.projectRoomPinCacheBestEffort(ctx, roomListPinEntryFromAdmin(pin))
return &roomv1.AdminCreateRoomPinResponse{Pin: roomPinToAdminProto(pin), ServerTimeMs: nowMS}, nil
}
@ -126,9 +127,34 @@ func (s *Service) AdminCancelRoomPin(ctx context.Context, req *roomv1.AdminCance
if !ok {
return nil, xerr.New(xerr.NotFound, "room pin not found")
}
s.projectRoomPinCacheBestEffort(ctx, roomListPinEntryFromAdmin(pin))
return &roomv1.AdminCancelRoomPinResponse{Pin: roomPinToAdminProto(pin), ServerTimeMs: nowMS}, nil
}
func (s *Service) projectRoomPinCacheBestEffort(ctx context.Context, pin RoomListPinEntry) {
if s == nil || s.roomListCache == nil {
return
}
if err := s.roomListCache.UpsertRoomPin(ctx, pin); err != nil {
// 后台置顶 owner 已经写入 MySQL缓存失败只影响短暂展示顺序后续 refresh 会从 owner 表重建。
logx.Warn(ctx, "room_pin_cache_project_failed", slog.Int64("pin_id", pin.ID), slog.String("room_id", pin.RoomID), slog.String("pin_type", pin.PinType), slog.String("error", err.Error()))
}
}
func roomListPinEntryFromAdmin(pin AdminRoomPinEntry) RoomListPinEntry {
return RoomListPinEntry{
ID: pin.ID,
AppCode: pin.AppCode,
VisibleRegionID: pin.VisibleRegionID,
PinType: pin.PinType,
RoomID: pin.RoomID,
Weight: pin.Weight,
Status: pin.Status,
PinnedAtMS: pin.PinnedAtMS,
ExpiresAtMS: pin.ExpiresAtMS,
}
}
func seatConfigToAdminProto(config RoomSeatConfig) *roomv1.AdminRoomSeatConfig {
return &roomv1.AdminRoomSeatConfig{
CandidateSeatCounts: append([]int32(nil), adminSeatCandidates...),

View File

@ -325,17 +325,26 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
} else {
records = append(records, heatEvent, rankEvent)
}
var rocketProgress *roomRocketProgressPending
if !cmd.RobotGift {
// 真实房间礼物才广播火箭进度;真人房机器人送礼使用机器人钱包结算,但仍是真实房间贡献,所以火箭事件跟随机器人通道投递。
if rocketApply.progressEvent != nil {
progressEvent, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent)
progressRecord, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, rocketApply.progressEvent)
if err != nil {
return mutationResult{}, nil, err
}
if robotOutboxGift {
robotRecords = append(robotRecords, progressEvent)
} else {
records = append(records, progressEvent)
progressRecord.AppCode = appcode.FromContext(ctx)
if progressRecord.Envelope != nil {
progressRecord.Envelope.AppCode = progressRecord.AppCode
}
// 燃料变化是高频展示事件,不逐笔写 outbox命令提交成功后由内存合并器按 1 秒窗口只落最大进度。
rocketProgress = &roomRocketProgressPending{
record: progressRecord,
robotLane: robotOutboxGift,
rocketID: rocketApply.progressEvent.GetRocketId(),
level: rocketApply.progressEvent.GetLevel(),
currentFuel: rocketApply.progressEvent.GetCurrentFuel(),
roomVersion: current.Version,
}
}
if rocketApply.ignited != nil {
@ -390,6 +399,7 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
commandPayload: commandPayload,
walletDebitMS: walletDebitMS,
robotOutboxRecords: robotRecords,
roomRocketProgress: rocketProgress,
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
AppCode: appcode.FromContext(ctx),
RoomID: current.RoomID,

View File

@ -77,7 +77,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
return nil, err
}
entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{
entries, err := s.listRoomListEntries(ctx, RoomListQuery{
AppCode: appcode.FromContext(ctx),
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
AllVisibleRegions: allVisibleRegions,
@ -102,6 +102,28 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
return roomListResponseFromEntries(tab, query, countryCode, viewerCountryCode, allVisibleRegions, entries, limit), nil
}
func (s *Service) listRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, error) {
if s != nil && s.roomListCache != nil && roomListCacheEligible(query) {
entries, ok, err := s.roomListCache.ListRoomListEntries(ctx, query)
if err == nil && ok {
return entries, nil
}
if err != nil {
// 缓存只是展示读模型Redis 抖动或扫描保护不能影响发现页可用性;记录原因后走 MySQL 权威投影。
logx.Warn(ctx, "room_list_cache_fallback", slog.String("tab", query.Tab), slog.String("error", err.Error()))
}
}
return s.repository.ListRoomListEntries(ctx, query)
}
func roomListCacheEligible(query RoomListQuery) bool {
// 搜索、全区白名单和内部定向查询仍走 MySQL避免为了低频条件维护过宽的 Redis 索引。
if strings.TrimSpace(query.Query) != "" || query.AllVisibleRegions || query.OwnerUserID > 0 {
return false
}
return query.Tab == roomListTabHot || query.Tab == roomListTabNew
}
// ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
@ -260,6 +282,24 @@ func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
// 列表读模型是最终一致;失败不改变命令成功语义。
logx.Error(ctx, "room_list_upsert_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()), slog.Int64("region_id", meta.VisibleRegionID))
return
}
s.projectRoomListCacheBestEffort(ctx, entry)
}
func (s *Service) projectRoomListCacheBestEffort(ctx context.Context, entry RoomListEntry) {
if s == nil || s.roomListCache == nil {
return
}
var err error
if strings.TrimSpace(entry.Status) == "active" {
err = s.roomListCache.UpsertRoomListEntry(ctx, entry)
} else {
err = s.roomListCache.DeleteRoomListEntry(ctx, entry.AppCode, entry.RoomID)
}
if err != nil {
// Redis 列表缓存可由 MySQL 扫描重建,写失败不能回滚已经提交的房间命令。
logx.Warn(ctx, "room_list_cache_project_failed", slog.String("room_id", entry.RoomID), slog.String("status", entry.Status), slog.String("error", err.Error()))
}
}

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"
"log/slog"
"strings"
"sync"
"time"
"hyapp/pkg/appcode"
@ -27,6 +28,8 @@ type OutboxWorkerOptions struct {
PollInterval time.Duration
// BatchSize 是单轮最多处理条数;非正数会被归一,避免全表扫描。
BatchSize int
// Concurrency 是本进程内独立抢占 pending outbox 的 worker 数;每条 worker 都依赖 MySQL SKIP LOCKED 隔离批次。
Concurrency int
// PublishTimeout 限制单条外部投递耗时shutdown 时当前事件最多阻塞该时长。
PublishTimeout time.Duration
// RetryStrategy 当前只支持 exponential_backoff。
@ -37,6 +40,20 @@ type OutboxWorkerOptions struct {
InitialBackoff time.Duration
// MaxBackoff 是指数退避上限。
MaxBackoff time.Duration
// WorkerID 写入 outbox worker_id空值时使用节点 ID并发启动时会自动带上序号。
WorkerID string
// WorkerName 写入日志 worker 字段,空值时使用主通道或机器人通道的稳定名称。
WorkerName string
// StaleDiscardAfter 只用于 robot outbox超过该窗口的展示事件直接删除不再补发 MQ/IM。
StaleDiscardAfter time.Duration
// CleanupInterval 控制 robot outbox 后台清理频率;非正数表示不启动清理。
CleanupInterval time.Duration
// CleanupActiveAfter 控制 pending/retryable/delivering 机器人展示事件的最大保留时长。
CleanupActiveAfter time.Duration
// CleanupTerminalAfter 控制 delivered/failed 机器人展示事件的最大保留时长。
CleanupTerminalAfter time.Duration
// CleanupBatchSize 控制单轮清理最多删除多少行,避免长事务拖慢主库。
CleanupBatchSize int
}
func defaultOutboxWorkerOptions() OutboxWorkerOptions {
@ -44,6 +61,7 @@ func defaultOutboxWorkerOptions() OutboxWorkerOptions {
return OutboxWorkerOptions{
PollInterval: time.Second,
BatchSize: 100,
Concurrency: 1,
PublishTimeout: 3 * time.Second,
RetryStrategy: outboxRetryExponentialBackoff,
MaxRetryCount: 10,
@ -63,6 +81,10 @@ func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptio
// 非正批量按安全默认值处理,避免 repository 扫描不受控。
options.BatchSize = defaults.BatchSize
}
if options.Concurrency <= 0 {
// 并发数为 0 等价于历史单 worker避免老测试或手工构造配置意外关闭补偿。
options.Concurrency = defaults.Concurrency
}
if options.PublishTimeout <= 0 {
// 单条外部投递必须有 deadlineshutdown 才能等待有界。
options.PublishTimeout = defaults.PublishTimeout
@ -79,6 +101,10 @@ func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptio
if options.MaxBackoff < options.InitialBackoff {
options.MaxBackoff = options.InitialBackoff
}
if options.CleanupBatchSize <= 0 && (options.CleanupActiveAfter > 0 || options.CleanupTerminalAfter > 0) {
// robot outbox 清理开启时必须有批量上限;默认 5 万和线上一次性清理批量保持一致。
options.CleanupBatchSize = 50000
}
options.RetryStrategy = strings.TrimSpace(options.RetryStrategy)
if options.RetryStrategy == "" {
// 空策略按指数退避处理,保持和配置默认值一致。
@ -105,7 +131,31 @@ func (s *Service) runOutboxWorker(ctx context.Context, options OutboxWorkerOptio
logx.Error(ctx, "worker_stopped", fmt.Errorf("unsupported retry_strategy"), slog.String("worker", workerName), slog.String("node_id", s.nodeID))
return
}
if options.Concurrency == 1 {
// 单 worker 保持历史 worker_id 和日志名称,避免线上排障查询字段无意义变化。
options.WorkerID = firstNonEmpty(strings.TrimSpace(options.WorkerID), s.nodeID)
options.WorkerName = firstNonEmpty(strings.TrimSpace(options.WorkerName), workerName)
s.runOutboxWorkerLoop(ctx, options, process)
return
}
var wg sync.WaitGroup
for index := 1; index <= options.Concurrency; index++ {
workerOptions := options
// 多 worker 共用同一个进程上下文,但必须写不同 worker_id方便从 delivering 记录定位卡住的协程。
workerOptions.WorkerName = fmt.Sprintf("%s-%02d", workerName, index)
workerOptions.WorkerID = fmt.Sprintf("%s/%s", s.nodeID, workerOptions.WorkerName)
wg.Add(1)
go func() {
defer wg.Done()
s.runOutboxWorkerLoop(ctx, workerOptions, process)
}()
}
wg.Wait()
}
func (s *Service) runOutboxWorkerLoop(ctx context.Context, options OutboxWorkerOptions, process func(context.Context, OutboxWorkerOptions) error) {
workerName := firstNonEmpty(strings.TrimSpace(options.WorkerName), outboxWorkerName)
// 启动后先执行一轮,避免服务刚恢复时还要等一个 poll interval 才补偿历史事件。
if err := process(ctx, options); err != nil && ctx.Err() == nil {
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", workerName), slog.String("node_id", s.nodeID), slog.Int("batch_size", options.BatchSize))
@ -128,12 +178,17 @@ func (s *Service) runOutboxWorker(ctx context.Context, options OutboxWorkerOptio
// ProcessPendingOutbox 把待投递事件补偿性地推送给异步消费者。
func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorkerOptions) error {
return s.processPendingOutbox(ctx, options, outboxWorkerName, false)
workerName := firstNonEmpty(strings.TrimSpace(options.WorkerName), outboxWorkerName)
return s.processPendingOutbox(ctx, options, workerName, false)
}
// ProcessPendingRobotOutbox 把机器人展示事件补偿性地推送给独立机器人异步消费者。
func (s *Service) ProcessPendingRobotOutbox(ctx context.Context, options OutboxWorkerOptions) error {
return s.processPendingOutbox(ctx, options, robotOutboxWorkerName, true)
workerName := firstNonEmpty(strings.TrimSpace(options.WorkerName), robotOutboxWorkerName)
if err := s.maybeCleanupRobotOutbox(ctx, normalizeOutboxWorkerOptions(options)); err != nil {
return err
}
return s.processPendingOutbox(ctx, options, workerName, true)
}
func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorkerOptions, workerName string, robotLane bool) error {
@ -155,6 +210,25 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
return ctx.Err()
default:
}
if robotLane && staleRobotOutboxRecord(record, options.StaleDiscardAfter) {
// 机器人展示事件超过 TTL 后再补发会制造“迟到礼物”体验,直接删除并释放 MQ/IM 资源。
deleteCtx, deleteCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
deleteErr := s.repository.DeleteRobotOutbox(deleteCtx, record.EventID)
deleteCancel()
if deleteErr != nil {
return deleteErr
}
logx.Warn(ctx, "robot_outbox_stale_discarded",
slog.String("worker", workerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.Int64("created_at_ms", record.CreatedAtMS),
slog.Int64("stale_discard_after_ms", options.StaleDiscardAfter.Milliseconds()),
)
continue
}
if record.RetryCount >= options.MaxRetryCount {
// 已达到最大失败次数的历史事件直接转死信,防止每轮 worker 继续打外部系统。
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
@ -223,12 +297,62 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
return nil
}
func (s *Service) maybeCleanupRobotOutbox(ctx context.Context, options OutboxWorkerOptions) error {
if s == nil || s.repository == nil || options.CleanupInterval <= 0 || (options.CleanupActiveAfter <= 0 && options.CleanupTerminalAfter <= 0) {
return nil
}
now := time.Now().UTC()
s.robotOutboxCleanupMu.Lock()
if !s.robotOutboxLastCleanup.IsZero() && now.Sub(s.robotOutboxLastCleanup) < options.CleanupInterval {
s.robotOutboxCleanupMu.Unlock()
return nil
}
// 先记录本次尝试时间,避免多 worker 或异常重试在同一秒内反复打 DELETE。
s.robotOutboxLastCleanup = now
s.robotOutboxCleanupMu.Unlock()
var activeBeforeMS int64
if options.CleanupActiveAfter > 0 {
activeBeforeMS = now.Add(-options.CleanupActiveAfter).UnixMilli()
}
var terminalBeforeMS int64
if options.CleanupTerminalAfter > 0 {
terminalBeforeMS = now.Add(-options.CleanupTerminalAfter).UnixMilli()
}
cleanupCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(ctx)), options.PublishTimeout)
activeDeleted, terminalDeleted, err := s.repository.CleanupRobotOutbox(cleanupCtx, activeBeforeMS, terminalBeforeMS, options.CleanupBatchSize)
cancel()
if err != nil {
return err
}
if activeDeleted > 0 || terminalDeleted > 0 {
logx.Info(ctx, "robot_outbox_cleanup_deleted",
slog.String("worker", robotOutboxWorkerName),
slog.String("node_id", s.nodeID),
slog.Int64("active_deleted", activeDeleted),
slog.Int64("terminal_deleted", terminalDeleted),
slog.Int("batch_size", options.CleanupBatchSize),
)
}
return nil
}
func staleRobotOutboxRecord(record outbox.Record, ttl time.Duration) bool {
if ttl <= 0 || record.CreatedAtMS <= 0 {
return false
}
// 使用当前 UTC 毫秒判断 TTL和 outbox created_at_ms 的持久化口径保持一致。
return time.Now().UTC().UnixMilli()-record.CreatedAtMS >= ttl.Milliseconds()
}
func (s *Service) claimPendingOutbox(ctx context.Context, robotLane bool, options OutboxWorkerOptions) ([]outbox.Record, error) {
lockUntilMS := time.Now().UTC().Add(options.PublishTimeout).UnixMilli()
workerID := firstNonEmpty(strings.TrimSpace(options.WorkerID), s.nodeID)
if robotLane {
return s.repository.ClaimPendingRobotOutbox(ctx, s.nodeID, options.BatchSize, lockUntilMS)
return s.repository.ClaimPendingRobotOutbox(ctx, workerID, options.BatchSize, lockUntilMS)
}
return s.repository.ClaimPendingOutbox(ctx, s.nodeID, options.BatchSize, lockUntilMS)
return s.repository.ClaimPendingOutbox(ctx, workerID, options.BatchSize, lockUntilMS)
}
func (s *Service) markOutboxDelivered(ctx context.Context, robotLane bool, eventID string) error {
@ -306,3 +430,12 @@ func trimOutboxError(value string) string {
// V1 只保留错误前缀,避免外部响应过大撑爆日志或 TEXT 字段查询成本。
return value[:512]
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

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

View File

@ -180,6 +180,28 @@ type RoomListEntry struct {
PinnedUntilMS int64
}
// RoomListPinEntry 是公共发现页置顶读模型的最小事实。
type RoomListPinEntry struct {
// ID 是 room_region_pins 的自增主键,缓存增量更新和删除都用它定位旧 zset member。
ID int64
// AppCode 限定置顶所属 App避免多 App 共用 Redis key 时互相污染。
AppCode string
// VisibleRegionID 是置顶作用域global 统一写 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 {
RoomID string
RoomShortID string
@ -217,6 +239,7 @@ type AdminRoomListQuery struct {
}
type AdminRoomPinEntry struct {
AppCode string
ID int64
VisibleRegionID int64
PinType string
@ -501,6 +524,25 @@ type RoomListQuery struct {
NowMS int64
}
// RoomListCacheEntryScanQuery 是缓存全量刷新时按主键批量扫描 MySQL 列表投影的游标。
type RoomListCacheEntryScanQuery struct {
// AfterAppCode 和 AfterRoomID 组成严格递增游标,避免 OFFSET 在大表上反复扫描。
AfterAppCode string
AfterRoomID string
// Limit 是单批扫描数量,由 worker 固定小批量推进。
Limit int
}
// RoomListPinScanQuery 是缓存全量刷新时扫描有效置顶事实的游标。
type RoomListPinScanQuery struct {
// AfterID 是 room_region_pins 自增主键游标。
AfterID int64
// Limit 是单批扫描数量。
Limit int
// NowMS 用于排除已过期置顶;未来置顶保留,读缓存时按 pinned_at_ms 再判断。
NowMS int64
}
// RoomListEntriesByIDQuery 是榜单等定向补全房间卡片时的批量读取条件。
type RoomListEntriesByIDQuery struct {
// AppCode 限定 App 边界,避免一个榜单读到其他 App 的同名 room_id。
@ -626,6 +668,23 @@ type Repository interface {
RobotRoomStore
}
// RoomListCacheStore 是公共发现页 Redis 展示读模型边界。
//
// MySQL 仍然是房间列表和置顶事实 owner缓存只服务高频 hot/new 发现页,
// 因此所有写入都必须 best-effort任何错误都回落到 MySQL 查询。
type RoomListCacheStore interface {
// ListRoomListEntries 尝试从缓存返回一页公共列表ok=false 表示缓存未就绪或扫描保护触发,调用方应走 MySQL。
ListRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, bool, error)
// UpsertRoomListEntry 根据最新列表投影刷新缓存;非 active 房间必须从所有展示 zset 移除。
UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error
// DeleteRoomListEntry 从缓存删除指定房间的卡片和所有展示索引。
DeleteRoomListEntry(ctx context.Context, appCode string, roomID string) error
// UpsertRoomPin 根据后台置顶事实刷新 pin 索引;非 active 或已过期置顶由实现移除。
UpsertRoomPin(ctx context.Context, pin RoomListPinEntry) error
// RebuildRoomList 用 MySQL 扫描结果重建某个 App 的缓存,并在成功后标记 ready。
RebuildRoomList(ctx context.Context, appCode string, entries []RoomListEntry, pins []RoomListPinEntry, nowMS int64) error
}
// RoomMetaStore 保存房间基础元数据和房主背景素材;它是 Room Cell 恢复前的轻量入口。
type RoomMetaStore interface {
// SaveRoomMeta 保存房间基础元数据,创建房间时必须先建立它。
@ -678,6 +737,8 @@ type RoomConfigStore interface {
type OutboxStore interface {
// SaveOutbox 写入房间外事件,必须和成功命令保持同一提交语义。
SaveOutbox(ctx context.Context, records []outbox.Record) error
// SaveRobotOutbox 写入机器人房间展示事件;火箭进度合并器 flush 后仍按 lane 回到独立 robot outbox。
SaveRobotOutbox(ctx context.Context, records []outbox.Record) error
// ListPendingOutbox 扫描待补偿投递事件。
ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error)
// ListPendingRobotOutbox 扫描机器人房间待补偿展示事件。
@ -698,6 +759,10 @@ type OutboxStore interface {
MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error
// MarkRobotOutboxDead 把超过最大重试次数的机器人展示事件转入 failed。
MarkRobotOutboxDead(ctx context.Context, eventID string, lastErr string) error
// DeleteRobotOutbox 直接删除机器人展示事件;过期展示没有补偿价值,不能再占用 MQ/IM 通道。
DeleteRobotOutbox(ctx context.Context, eventID string) error
// CleanupRobotOutbox 批量删除过期机器人展示事件;非终态和终态分开计数,便于上线后验证清理效果。
CleanupRobotOutbox(ctx context.Context, activeBeforeMS int64, terminalBeforeMS int64, limit int) (activeDeleted int64, terminalDeleted int64, err error)
}
// RoomListStore 管理发现页、Mine 页和关系房间流读模型;它不读取 Room Cell 内存。
@ -722,6 +787,10 @@ type RoomListStore interface {
ListRoomFollowEntries(ctx context.Context, query RoomFollowQuery) ([]RoomListEntry, error)
// ListRoomRelatedFeedEntries 根据 user-service 关系事实查询 active 房间卡片,不持久化关系状态。
ListRoomRelatedFeedEntries(ctx context.Context, query RoomRelatedFeedQuery) ([]RoomListEntry, error)
// ListRoomListCacheEntries 按主键小批量扫描 active 列表投影,只用于 Redis 展示读模型重建。
ListRoomListCacheEntries(ctx context.Context, query RoomListCacheEntryScanQuery) ([]RoomListEntry, error)
// ListRoomListCachePins 按主键小批量扫描 active 置顶事实,只用于 Redis 展示读模型重建。
ListRoomListCachePins(ctx context.Context, query RoomListPinScanQuery) ([]RoomListPinEntry, error)
}
// PresenceStore 管理当前房间 presence 投影;权威状态仍然来自 Room Cell 快照。

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)
}
flushRocketProgress(t, ctx, svc, now)
progressEvents := rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 250 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 150 {
t.Fatalf("progress event must expose effective fuel and discarded overflow: %+v", progressEvents)
@ -276,6 +277,7 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
if len(nextResp.GetRocket().GetPendingLaunches()) != 1 {
t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket())
}
flushRocketProgress(t, ctx, svc, now)
progressEvents = rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 2 || progressEvents[1].GetLevel() != 2 || progressEvents[1].GetEffectiveAddedFuel() != 50 || progressEvents[1].GetOverflowFuel() != 0 {
t.Fatalf("second progress event must target level 2: %+v", progressEvents)
@ -285,6 +287,58 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
}
}
func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 30, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-progress-10", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"},
{BillingReceiptId: "receipt-progress-20", GiftPointAdded: 20, HeatValue: 20, GiftTypeCode: "normal"},
{BillingReceiptId: "receipt-progress-30", GiftPointAdded: 30, HeatValue: 30, GiftTypeCode: "normal"},
}}
svc := newRocketTestService(t, repository, wallet, now)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(1000, 1_000))
roomID := "room-rocket-progress-coalesce"
ownerID := int64(111)
viewerID := int64(222)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, viewerID)
before := outboxEventCounts(t, ctx, repository)
for index := 1; index <= 3; index++ {
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, fmt.Sprintf("progress-coalesce-%d", index)),
TargetType: "user",
TargetUserId: viewerID,
GiftId: fmt.Sprintf("gift-progress-%d", index),
GiftCount: 1,
}); err != nil {
t.Fatalf("send coalesced progress gift %d failed: %v", index, err)
}
}
beforeFlush := outboxEventCounts(t, ctx, repository)
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged"} {
if delta := beforeFlush[eventType] - before[eventType]; delta != 3 {
t.Fatalf("%s must stay per gift before progress flush, delta=%d counts=%+v", eventType, delta, beforeFlush)
}
}
if delta := beforeFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 {
t.Fatalf("rocket progress must not write per gift before flush, delta=%d counts=%+v", delta, beforeFlush)
}
flushRocketProgress(t, ctx, svc, now)
afterFlush := outboxEventCounts(t, ctx, repository)
if delta := afterFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 1 {
t.Fatalf("rocket progress must coalesce to one outbox row, delta=%d counts=%+v", delta, afterFlush)
}
progressEvents := rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 1 || progressEvents[0].GetCurrentFuel() != 60 || progressEvents[0].GetEffectiveAddedFuel() != 30 {
t.Fatalf("coalesced progress must keep latest max fuel event: %+v", progressEvents)
}
}
func TestRoomRocketIgnitedOutboxSchedulesDelayedLaunch(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -745,6 +799,15 @@ func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repos
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
}
func flushRocketProgress(t testing.TB, ctx context.Context, svc *roomservice.Service, clock *fixedRoomRocketClock) {
t.Helper()
clock.now = clock.now.Add(time.Second)
if err := svc.FlushRoomRocketProgress(ctx); err != nil {
t.Fatalf("flush rocket progress failed: %v", err)
}
}
func rocketConfigForTest(threshold int64, launchDelayMS int64) roomservice.RoomRocketConfig {
levels := make([]roomservice.RoomRocketLevelConfig, 0, 5)
for level := int32(1); level <= 5; level++ {

View File

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

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) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -222,6 +287,7 @@ func TestRealRoomRobotGiftKeepsRoomContributionAndUsesRobotOutbox(t *testing.T)
if len(leaderboard.increments) != 1 || leaderboard.increments[0].RoomID != roomID || leaderboard.increments[0].CoinSpent != 100 {
t.Fatalf("real room robot gift must keep cross-room contribution increment: %+v", leaderboard.increments)
}
flushRocketProgress(t, ctx, svc, now)
afterMain := outboxEventCounts(t, ctx, repository)
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} {

View File

@ -422,7 +422,7 @@ func (r *Repository) adminGetRoomPin(ctx context.Context, where string, args ...
func adminRoomPinSelectSQL() string {
return `
SELECT p.id, p.visible_region_id, p.pin_type, p.room_id, p.weight, p.status,
SELECT p.app_code, p.id, p.visible_region_id, p.pin_type, p.room_id, p.weight, p.status,
p.pinned_at_ms, p.expires_at_ms, p.cancelled_at_ms,
p.created_by_admin_id, p.cancelled_by_admin_id, p.created_at_ms, p.updated_at_ms,
r.room_short_id, r.visible_region_id, r.title, r.cover_url, r.owner_user_id, r.status
@ -470,6 +470,6 @@ func adminRoomPinWhere(ctx context.Context, query roomservice.AdminRoomPinQuery)
func scanAdminRoomPin(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomPinEntry, error) {
var item roomservice.AdminRoomPinEntry
err := scanner.Scan(&item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus)
err := scanner.Scan(&item.AppCode, &item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus)
return item, err
}

View File

@ -15,6 +15,16 @@ import (
// SaveOutbox 追加待投递 outbox 事件。
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
return r.saveOutboxInTable(ctx, roomOutboxTable, records)
}
// SaveRobotOutbox 追加机器人房间展示 outbox 事件。
func (r *Repository) SaveRobotOutbox(ctx context.Context, records []outbox.Record) error {
return r.saveOutboxInTable(ctx, roomRobotOutboxTable, records)
}
func (r *Repository) saveOutboxInTable(ctx context.Context, table string, records []outbox.Record) error {
table = roomOutboxTableName(table)
if len(records) == 0 {
// 没有事件时保持无副作用,方便领域层统一调用。
return nil
@ -39,7 +49,7 @@ func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) er
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
`INSERT INTO `+table+` (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`,
record.AppCode,
@ -351,6 +361,106 @@ func (r *Repository) markOutboxDeadInTable(ctx context.Context, table string, ev
return err
}
// DeleteRobotOutbox 直接删除一条机器人展示事件。
func (r *Repository) DeleteRobotOutbox(ctx context.Context, eventID string) error {
// robot outbox 只承载 App 展示补偿;超过业务展示窗口后删除比标记 failed 更能降低后续扫描成本。
_, err := r.db.ExecContext(ctx,
`DELETE FROM `+roomRobotOutboxTable+`
WHERE app_code = ? AND event_id = ?`,
appcode.FromContext(ctx),
eventID,
)
return err
}
// CleanupRobotOutbox 批量删除机器人展示 outbox 的过期记录,不做归档。
func (r *Repository) CleanupRobotOutbox(ctx context.Context, activeBeforeMS int64, terminalBeforeMS int64, limit int) (int64, int64, error) {
if limit <= 0 {
// 清理必须有批量上限,避免误配置导致一次 DELETE 长时间持锁。
limit = 50000
}
appCode := appcode.FromContext(ctx)
nowMS := time.Now().UTC().UnixMilli()
var activeDeleted int64
var terminalDeleted int64
if activeBeforeMS > 0 {
// pending/retryable 没有正在处理的 worker超过展示窗口后可以直接删除。
deleted, err := r.deleteRobotOutboxBatch(ctx, `
DELETE FROM `+roomRobotOutboxTable+`
WHERE app_code = ?
AND status IN (?, ?)
AND created_at_ms < ?
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ?`,
appCode,
outbox.StatusPending,
outbox.StatusRetryable,
activeBeforeMS,
limit,
)
if err != nil {
return activeDeleted, terminalDeleted, err
}
activeDeleted += deleted
// delivering 可能正被其他 worker 处理,只删除锁已过期或历史异常无锁的旧记录。
deleted, err = r.deleteRobotOutboxBatch(ctx, `
DELETE FROM `+roomRobotOutboxTable+`
WHERE app_code = ?
AND status = ?
AND created_at_ms < ?
AND (lock_until_ms IS NULL OR lock_until_ms <= ?)
ORDER BY created_at_ms ASC, event_id ASC
LIMIT ?`,
appCode,
outbox.StatusDelivering,
activeBeforeMS,
nowMS,
limit,
)
if err != nil {
return activeDeleted, terminalDeleted, err
}
activeDeleted += deleted
}
if terminalBeforeMS > 0 {
// delivered/failed 对机器人展示没有审计价值,保留短窗口方便排障后即可删除。
deleted, err := r.deleteRobotOutboxBatch(ctx, `
DELETE FROM `+roomRobotOutboxTable+`
WHERE app_code = ?
AND status IN (?, ?)
AND updated_at_ms < ?
ORDER BY updated_at_ms ASC, event_id ASC
LIMIT ?`,
appCode,
outbox.StatusDelivered,
outbox.StatusFailed,
terminalBeforeMS,
limit,
)
if err != nil {
return activeDeleted, terminalDeleted, err
}
terminalDeleted += deleted
}
return activeDeleted, terminalDeleted, nil
}
func (r *Repository) deleteRobotOutboxBatch(ctx context.Context, query string, args ...any) (int64, error) {
result, err := r.db.ExecContext(ctx, query, args...)
if err != nil {
return 0, err
}
deleted, err := result.RowsAffected()
if err != nil {
return 0, err
}
return deleted, nil
}
func roomOutboxTableName(table string) string {
switch table {
case roomRobotOutboxTable:

View File

@ -176,6 +176,104 @@ func (r *Repository) ListRoomListEntriesByIDs(ctx context.Context, query roomser
return result, nil
}
// ListRoomListCacheEntries 按 app_code/room_id 主键批量扫描 active 列表投影,供 Redis 展示读模型重建。
func (r *Repository) ListRoomListCacheEntries(ctx context.Context, query roomservice.RoomListCacheEntryScanQuery) ([]roomservice.RoomListEntry, error) {
if query.Limit <= 0 {
query.Limit = 500
}
if query.Limit > 2000 {
query.Limit = 2000
}
where := []string{"status = ?"}
args := []any{"active"}
if strings.TrimSpace(query.AfterAppCode) != "" || strings.TrimSpace(query.AfterRoomID) != "" {
// 复合主键游标必须严格前进,避免刷新 worker 用 OFFSET 反复扫描前段数据。
where = append(where, "(app_code > ? OR (app_code = ? AND room_id > ?))")
args = append(args, strings.TrimSpace(query.AfterAppCode), strings.TrimSpace(query.AfterAppCode), strings.TrimSpace(query.AfterRoomID))
}
args = append(args, query.Limit)
rows, err := r.db.QueryContext(ctx, roomListSelectColumns+"\n\tWHERE "+strings.Join(where, " AND ")+"\n\tORDER BY app_code ASC, room_id ASC\n\tLIMIT ?", args...)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
for rows.Next() {
entry, err := scanRoomListEntry(rows)
if err != nil {
return nil, err
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, nil
}
// ListRoomListCachePins 按 pin id 批量扫描仍可能生效的置顶事实,供 Redis 展示读模型重建。
func (r *Repository) ListRoomListCachePins(ctx context.Context, query roomservice.RoomListPinScanQuery) ([]roomservice.RoomListPinEntry, error) {
if query.Limit <= 0 {
query.Limit = 500
}
if query.Limit > 2000 {
query.Limit = 2000
}
nowMS := query.NowMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, app_code, visible_region_id, pin_type, room_id, weight, status, pinned_at_ms, expires_at_ms
FROM room_region_pins
WHERE id > ? AND status = ? AND expires_at_ms > ?
ORDER BY id ASC
LIMIT ?`, query.AfterID, "active", nowMS, query.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
pins := make([]roomservice.RoomListPinEntry, 0, query.Limit)
for rows.Next() {
var pin roomservice.RoomListPinEntry
if err := rows.Scan(&pin.ID, &pin.AppCode, &pin.VisibleRegionID, &pin.PinType, &pin.RoomID, &pin.Weight, &pin.Status, &pin.PinnedAtMS, &pin.ExpiresAtMS); err != nil {
return nil, err
}
pins = append(pins, pin)
}
if err := rows.Err(); err != nil {
return nil, err
}
return pins, nil
}
func scanRoomListEntry(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomListEntry, error) {
var entry roomservice.RoomListEntry
err := scanner.Scan(
&entry.AppCode,
&entry.RoomID,
&entry.RoomShortID,
&entry.VisibleRegionID,
&entry.OwnerCountryCode,
&entry.OwnerUserID,
&entry.Title,
&entry.CoverURL,
&entry.Mode,
&entry.Status,
&entry.Locked,
&entry.Heat,
&entry.OnlineCount,
&entry.SeatCount,
&entry.OccupiedSeatCount,
&entry.SortScore,
&entry.CreatedAtMS,
&entry.UpdatedAtMS,
)
return entry, err
}
func uniqueRoomListIDs(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))

View File

@ -142,9 +142,22 @@ func (r *Repository) SetRoomCreatedAt(roomID string, createdAtMS int64) {
func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
r.t.Helper()
return r.outboxRecord("room_outbox", eventID)
}
// RobotOutboxRecord reads one robot outbox row, including delivered rows that normal scans skip.
func (r *Repository) RobotOutboxRecord(eventID string) (outbox.Record, bool) {
r.t.Helper()
return r.outboxRecord("room_robot_outbox", eventID)
}
func (r *Repository) outboxRecord(table string, eventID string) (outbox.Record, bool) {
r.t.Helper()
row := r.schema.DB.QueryRowContext(context.Background(), `
SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox
FROM `+table+`
WHERE app_code = ? AND event_id = ?
`, appcode.Default, eventID)
@ -170,6 +183,23 @@ func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
return record, true
}
// SetRobotOutboxStatus rewrites robot outbox lifecycle fields for cleanup and worker tests.
func (r *Repository) SetRobotOutboxStatus(eventID string, status string, updatedAtMS int64, lockUntilMS *int64) {
r.t.Helper()
var lockUntil any
if lockUntilMS != nil {
lockUntil = *lockUntilMS
}
if _, err := r.schema.DB.ExecContext(context.Background(), `
UPDATE room_robot_outbox
SET status = ?, updated_at_ms = ?, lock_until_ms = ?
WHERE app_code = ? AND event_id = ?
`, status, updatedAtMS, lockUntil, appcode.Default, eventID); err != nil {
r.t.Fatalf("update robot outbox status failed: %v", err)
}
}
func initDBPath(t testing.TB) string {
t.Helper()

View File

@ -37,6 +37,7 @@ type Repository interface {
DeleteAgency(ctx context.Context, command DeleteAgencyCommand) (hostdomain.Agency, error)
SetAgencyJoinEnabled(ctx context.Context, command SetAgencyJoinEnabledCommand) (hostdomain.Agency, error)
GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error)
BatchGetHostProfiles(ctx context.Context, userIDs []int64) (map[int64]hostdomain.HostProfile, error)
GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error)
GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error)
ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error)
@ -599,6 +600,32 @@ func (s *Service) GetHostProfile(ctx context.Context, userID int64) (hostdomain.
return s.repository.GetHostProfile(ctx, userID)
}
// BatchGetHostProfiles 批量读取主播身份事实,缺失用户不返回占位对象。
func (s *Service) BatchGetHostProfiles(ctx context.Context, userIDs []int64) (map[int64]hostdomain.HostProfile, error) {
if len(userIDs) == 0 {
// 空批量请求是合法的无操作,调用方可以直接按空 map 组装结果。
return map[int64]hostdomain.HostProfile{}, nil
}
uniqueUserIDs := make([]int64, 0, len(userIDs))
seen := make(map[int64]bool, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
// 任一非法 ID 都表示调用方组装的批量请求不可信,不能静默跳过。
return nil, xerr.New(xerr.InvalidArgument, "user_ids contains invalid value")
}
if seen[userID] {
continue
}
seen[userID] = true
uniqueUserIDs = append(uniqueUserIDs, userID)
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
return s.repository.BatchGetHostProfiles(ctx, uniqueUserIDs)
}
// GetBDProfile 按指定 role 读取普通 BD 或 BD Leader 身份事实。
func (s *Service) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) {
if s.repository == nil {

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) {
ctx := context.Background()
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)
}
// BatchGetHostProfiles 批量读取 Host 身份事实,缺失 user_id 不填充空 profile。
func (r *Repository) BatchGetHostProfiles(ctx context.Context, userIDs []int64) (map[int64]hostdomain.HostProfile, error) {
if len(userIDs) == 0 {
return map[int64]hostdomain.HostProfile{}, nil
}
placeholders := make([]string, 0, len(userIDs))
args := make([]any, 0, len(userIDs)+1)
args = append(args, appcode.FromContext(ctx))
for _, userID := range userIDs {
placeholders = append(placeholders, "?")
args = append(args, userID)
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT %s
FROM host_profiles
WHERE app_code = ? AND user_id IN (%s)
`, hostProfileColumns, strings.Join(placeholders, ",")), args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[int64]hostdomain.HostProfile, len(userIDs))
for rows.Next() {
// 返回 map 以 user_id 为键gateway 可以精确区分“非主播/缺失”和“主播但状态不可入账”。
profile, err := scanHostProfile(rows)
if err != nil {
return nil, err
}
result[profile.UserID] = profile
}
return result, rows.Err()
}
// GetBDProfile 按 role 读取普通 BD 或 BD Leader 身份事实。
func (r *Repository) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) {
if role == hostdomain.BDRoleLeader {

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) {
t.Helper()
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
}
// BatchGetHostProfiles 一次返回多个用户的主播身份事实,缺失用户不返回占位对象。
func (s *Server) BatchGetHostProfiles(ctx context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
profiles, err := s.hostSvc.BatchGetHostProfiles(ctx, req.GetUserIds())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
result := make(map[int64]*userv1.HostProfile, len(profiles))
for userID, profile := range profiles {
// map key 固定使用 user_id调用方按原 target 顺序重新组装业务快照。
result[userID] = toProtoHostProfile(profile)
}
return &userv1.BatchGetHostProfilesResponse{HostProfiles: result}, nil
}
// GetBDProfile 按调用方声明的角色返回普通 BD 或 BD Leader 身份事实。
func (s *Server) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
if s.hostSvc == nil {

View File

@ -585,6 +585,7 @@ CREATE TABLE IF NOT EXISTS external_recharge_orders (
command_id VARCHAR(128) NOT NULL COMMENT 'H5 幂等命令 ID',
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
target_region_id BIGINT NOT NULL COMMENT '目标区域 ID',
target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '目标国家 ID',
target_country_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '目标国家码',
audience_type VARCHAR(32) NOT NULL COMMENT '购买对象',
product_id BIGINT NOT NULL COMMENT '商品 ID',
@ -617,6 +618,15 @@ CREATE TABLE IF NOT EXISTS external_recharge_orders (
KEY idx_external_recharge_orders_tx (app_code, provider_code, tx_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表';
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'external_recharge_orders' AND COLUMN_NAME = 'target_country_id') = 0,
'ALTER TABLE external_recharge_orders ADD COLUMN target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT ''目标国家 ID'' AFTER target_region_id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT IGNORE INTO third_party_payment_channels (app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms)

View File

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

View File

@ -2694,6 +2694,12 @@ func TestH5MifaPayStatusQueryCreditsOrder(t *testing.T) {
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 {
t.Fatalf("mifapay query credit should write one recharge record, got %d", got)
}
if got := repository.CountRows("external_recharge_orders", "order_id = ? AND target_country_id = ?", order.OrderID, order.TargetCountryID); got != 1 {
t.Fatalf("mifapay local order should persist target country id, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ?", credited.TransactionID, "WalletRechargeRecorded", order.TargetCountryID, order.TargetCountryID, order.TargetRegionID); got != 1 {
t.Fatalf("mifapay recharge fact should carry target country/region for BI, got %d", got)
}
}
func TestH5MifaPayCoinSellerCreditsSellerStockUSDTEquivalent(t *testing.T) {
@ -2859,6 +2865,7 @@ func createRedirectedMifaPayOrder(t *testing.T, repository *mysqltest.Repository
CommandID: commandID,
TargetUserID: targetUserID,
TargetRegionID: targetRegionID,
TargetCountryID: 63,
TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceNormal,
ProductID: product.ProductID,
@ -3834,6 +3841,21 @@ func TestGiftConfigRegionFilter(t *testing.T) {
t.Fatalf("create gift config %s failed: %v", command.GiftID, err)
}
}
if _, err := svc.UpdateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "regional-saudi",
ResourceID: saudiResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "Regional Saudi",
PriceVersion: "v2",
CoinPrice: 22,
HeatValue: 22,
OperatorUserID: 90001,
RegionIDs: []int64{1002, 1001},
}); err != nil {
t.Fatalf("update regional saudi gift price failed: %v", err)
}
repository.InsertRawGiftPrice("regional-saudi", "v8-inactive", "inactive", 0)
repository.InsertRawGiftPrice("regional-saudi", "v9-future", "active", time.Now().Add(time.Hour).UnixMilli())
items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
Keyword: "regional-",
@ -3849,6 +3871,16 @@ func TestGiftConfigRegionFilter(t *testing.T) {
if total != 2 || !giftIDsContain(items, "regional-global") || !giftIDsContain(items, "regional-saudi") || giftIDsContain(items, "regional-egypt") {
t.Fatalf("region gift list mismatch total=%d items=%+v", total, items)
}
saudiGift, ok := giftConfigByID(items, "regional-saudi")
if !ok {
t.Fatalf("regional saudi gift must be returned: %+v", items)
}
if saudiGift.PriceVersion != "v2" || saudiGift.CoinPrice != 22 || saudiGift.GiftPointAmount != 0 || saudiGift.HeatValue != 22 {
t.Fatalf("batch latest gift price mismatch: %+v", saudiGift)
}
if !int64sEqual(saudiGift.RegionIDs, []int64{1001, 1002}) {
t.Fatalf("batch region ids must return full configured regions, got %+v", saudiGift.RegionIDs)
}
repository.SetBalance(51001, 100)
if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
@ -5712,6 +5744,7 @@ func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
CommandID: "google-pay-product-name",
UserID: 53001,
RegionID: 1001,
CountryID: 199,
ProductID: product.ProductID,
ProductCode: googleProductID,
PackageName: "com.org.laluparty",
@ -5736,6 +5769,9 @@ func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ? AND total_coin_amount = ? AND total_usd_minor_amount = ?", int64(53001), int64(1500), int64(150)); got != 1 {
t.Fatalf("google payment should aggregate recharge USD minor from micro amount, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(199), int64(199), int64(1001), ledger.RechargeChannelGoogle); got != 1 {
t.Fatalf("google recharge fact should carry target country/region for BI, got %d", got)
}
}
func TestConfirmGooglePaymentAcceptsLegacyProductCodeInput(t *testing.T) {
@ -6024,6 +6060,27 @@ func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool {
return false
}
func giftConfigByID(items []resourcedomain.GiftConfig, giftID string) (resourcedomain.GiftConfig, bool) {
for _, item := range items {
if item.GiftID == giftID {
return item, true
}
}
return resourcedomain.GiftConfig{}, false
}
func int64sEqual(left []int64, right []int64) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func giftTypeCodesContain(items []resourcedomain.GiftTypeConfig, typeCode string) bool {
for _, item := range items {
if item.TypeCode == typeCode {

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())
_, err := r.db.ExecContext(ctx, `
INSERT INTO external_recharge_orders (
app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type,
app_code, order_id, command_id, target_user_id, target_region_id, target_country_id, target_country_code, audience_type,
product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id,
country_code, currency_code, provider_amount_minor, pay_way, pay_type, receive_address, status,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryCode,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryID, order.TargetCountryCode,
order.AudienceType, order.ProductID, order.ProductCode, order.ProductName, order.CoinAmount, order.USDMinorAmount,
order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor,
order.PayWay, order.PayType, order.ReceiveAddress, order.Status, order.CreatedAtMS, order.UpdatedAtMS,
@ -64,12 +64,12 @@ func (r *Repository) CreateTemporaryExternalRechargeOrder(ctx context.Context, c
order := temporaryExternalRechargeOrderFromCommand(command, method, providerAmountMinor, nowMS)
_, err := r.db.ExecContext(ctx, `
INSERT INTO external_recharge_orders (
app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type,
app_code, order_id, command_id, target_user_id, target_region_id, target_country_id, target_country_code, audience_type,
product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id,
country_code, currency_code, provider_amount_minor, pay_way, pay_type, receive_address, status,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryCode,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.CommandID, order.TargetUserID, order.TargetRegionID, order.TargetCountryID, order.TargetCountryCode,
order.AudienceType, order.ProductID, order.ProductCode, order.ProductName, order.CoinAmount, order.USDMinorAmount,
order.ProviderCode, order.PaymentMethodID, order.CountryCode, order.CurrencyCode, order.ProviderAmountMinor,
order.PayWay, order.PayType, order.ReceiveAddress, order.Status, order.CreatedAtMS, order.UpdatedAtMS,
@ -180,6 +180,7 @@ func (r *Repository) CreditExternalRechargeOrder(ctx context.Context, appCode st
ProviderCode: order.ProviderCode,
PaymentMethodID: order.PaymentMethodID,
TargetUserID: order.TargetUserID,
TargetCountryID: order.TargetCountryID,
TargetRegionID: order.TargetRegionID,
Amount: order.CoinAmount,
TargetAssetType: assetType,
@ -449,7 +450,7 @@ func (r *Repository) lockExternalRechargeOrder(ctx context.Context, tx *sql.Tx,
func externalRechargeOrderSelectSQL() string {
return `
SELECT app_code, order_id, command_id, target_user_id, target_region_id, target_country_code, audience_type,
SELECT app_code, order_id, command_id, target_user_id, target_region_id, target_country_id, target_country_code, audience_type,
product_id, product_code, product_name, coin_amount, usd_minor_amount, provider_code, payment_method_id,
country_code, currency_code, provider_amount_minor, pay_way, pay_type, COALESCE(pay_url, ''),
provider_order_id, tx_hash, receive_address, status, failure_reason, wallet_transaction_id,
@ -466,6 +467,7 @@ func scanExternalRechargeOrder(scanner scanTarget) (ledger.ExternalRechargeOrder
&order.CommandID,
&order.TargetUserID,
&order.TargetRegionID,
&order.TargetCountryID,
&order.TargetCountryCode,
&order.AudienceType,
&order.ProductID,
@ -512,6 +514,7 @@ func externalRechargeOrderFromCommand(command ledger.CreateExternalRechargeOrder
CommandID: strings.TrimSpace(command.CommandID),
TargetUserID: command.TargetUserID,
TargetRegionID: command.TargetRegionID,
TargetCountryID: command.TargetCountryID,
TargetCountryCode: strings.ToUpper(strings.TrimSpace(command.TargetCountryCode)),
AudienceType: ledger.NormalizeRechargeAudienceType(command.AudienceType),
ProductID: product.ProductID,

View File

@ -39,28 +39,36 @@ func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.L
nowMs := time.Now().UnixMilli()
items := make([]resourcedomain.GiftConfig, 0, query.PageSize)
giftIDs := make([]string, 0, query.PageSize)
for rows.Next() {
item, err := scanGiftConfig(rows)
if err != nil {
return nil, 0, err
}
item.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, item.GiftID)
if err != nil {
return nil, 0, err
}
price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs)
if priceErr == nil {
item.PriceVersion = price.PriceVersion
item.ChargeAssetType = price.ChargeAssetType
item.CoinPrice = price.CoinPrice
item.GiftPointAmount = 0
item.HeatValue = price.CoinPrice
}
items = append(items, item)
giftIDs = append(giftIDs, item.GiftID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if len(items) == 0 {
return items, total, nil
}
regionsByGiftID, err := r.listGiftConfigRegionIDsByGiftIDs(ctx, r.db, giftIDs)
if err != nil {
return nil, 0, err
}
pricesByGiftID, err := r.latestGiftPrices(ctx, r.db, giftIDs, nowMs)
if err != nil {
return nil, 0, err
}
for index := range items {
items[index].RegionIDs = regionsByGiftID[items[index].GiftID]
if price, ok := pricesByGiftID[items[index].GiftID]; ok {
applyGiftPriceToConfig(&items[index], price)
}
}
return items, total, nil
}
@ -75,13 +83,7 @@ func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resource
}
price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli())
if priceErr == nil {
gift.PriceVersion = price.PriceVersion
gift.ChargeAssetType = price.ChargeAssetType
gift.CoinPrice = price.CoinPrice
gift.GiftPointAmount = 0
gift.HeatValue = price.CoinPrice
applyGiftPriceToConfig(&gift, price)
}
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID)
if err != nil {
@ -102,11 +104,7 @@ func (r *Repository) getGiftConfigForUpdateTx(ctx context.Context, tx *sql.Tx, g
price, priceErr := r.latestGiftPrice(ctx, tx, gift.GiftID, time.Now().UnixMilli())
if priceErr == nil {
gift.PriceVersion = price.PriceVersion
gift.ChargeAssetType = price.ChargeAssetType
gift.CoinPrice = price.CoinPrice
gift.GiftPointAmount = 0
gift.HeatValue = price.CoinPrice
applyGiftPriceToConfig(&gift, price)
}
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID)
if err != nil {
@ -137,7 +135,7 @@ func (r *Repository) replaceGiftConfigRegionsTx(ctx context.Context, tx *sql.Tx,
func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) {
rows, err := querier.QueryContext(ctx, `
SELECT region_id
SELECT region_id
FROM gift_config_regions
WHERE app_code = ? AND gift_id = ?
ORDER BY region_id ASC`,
@ -158,6 +156,38 @@ func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQue
return regionIDs, rows.Err()
}
func (r *Repository) listGiftConfigRegionIDsByGiftIDs(ctx context.Context, querier sqlQuerier, giftIDs []string) (map[string][]int64, error) {
regionsByGiftID := make(map[string][]int64, len(giftIDs))
if len(giftIDs) == 0 {
return regionsByGiftID, nil
}
for _, giftID := range giftIDs {
// 每个礼物都预置空切片,保持旧版逐个查询时“无区域行返回空列表”的响应语义。
regionsByGiftID[giftID] = []int64{}
}
args := append([]any{appcode.FromContext(ctx)}, stringAnyArgs(giftIDs)...)
rows, err := querier.QueryContext(ctx, `
SELECT gift_id, region_id
FROM gift_config_regions
WHERE app_code = ? AND gift_id IN (`+placeholders(len(giftIDs))+`)
ORDER BY gift_id ASC, region_id ASC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var giftID string
var regionID int64
if err := rows.Scan(&giftID, &regionID); err != nil {
return nil, err
}
regionsByGiftID[giftID] = append(regionsByGiftID[giftID], regionID)
}
return regionsByGiftID, rows.Err()
}
func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
var gift resourcedomain.GiftConfig
var resource resourcedomain.Resource
@ -269,6 +299,7 @@ func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) res
if strings.TrimSpace(query.GiftTypeCode) != "" {
query.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(query.GiftTypeCode)
}
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
// 房间礼物面板会一次预加载完整配置;礼物列表单独允许 500避免复用资源列表 100 上限导致面板天然少数据。
query.Page, query.PageSize = normalizePageWithMax(query.Page, query.PageSize, 500)
return query
}

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
}
func (r *Repository) latestGiftPrices(ctx context.Context, querier sqlQuerier, giftIDs []string, nowMs int64) (map[string]giftPrice, error) {
pricesByGiftID := make(map[string]giftPrice, len(giftIDs))
if len(giftIDs) == 0 {
return pricesByGiftID, nil
}
args := append([]any{appcode.FromContext(ctx)}, stringAnyArgs(giftIDs)...)
args = append(args, nowMs, nowMs)
rows, err := querier.QueryContext(ctx, `
SELECT gp.gift_id, gp.price_version, COALESCE(gp.charge_asset_type, 'COIN'), gp.coin_price, gp.gift_point_amount, gp.heat_value
FROM wallet_gift_prices gp
WHERE gp.app_code = ? AND gp.gift_id IN (`+placeholders(len(giftIDs))+`)
AND gp.status = 'active' AND gp.effective_at_ms <= ?
AND NOT EXISTS (
SELECT 1
FROM wallet_gift_prices newer
WHERE newer.app_code = gp.app_code
AND newer.gift_id = gp.gift_id
AND newer.status = 'active'
AND newer.effective_at_ms <= ?
AND (
newer.effective_at_ms > gp.effective_at_ms
OR (newer.effective_at_ms = gp.effective_at_ms AND newer.price_version > gp.price_version)
)
)
ORDER BY gp.gift_id ASC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var price giftPrice
if err := rows.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
return nil, err
}
price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
pricesByGiftID[price.GiftID] = price
}
return pricesByGiftID, rows.Err()
}
func applyGiftPriceToConfig(gift *resourcedomain.GiftConfig, price giftPrice) {
if gift == nil {
return
}
gift.PriceVersion = price.PriceVersion
gift.ChargeAssetType = price.ChargeAssetType
gift.CoinPrice = price.CoinPrice
// GIFT_POINT 已下线;列表继续把历史价格行里的 gift_point_amount 压成 0避免旧数据重新暴露给客户端。
gift.GiftPointAmount = 0
// 历史列表口径把热度值等同于金币价格,即使价格表里仍保留 heat_value也不在展示面板引入第二套价值口径。
gift.HeatValue = price.CoinPrice
}
func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) {
nowMs := time.Now().UnixMilli()
row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`

View File

@ -86,6 +86,27 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go
RechargeSequence: rechargeSequence,
CreatedAtMS: nowMs,
}
// 充值事实事件只消费 coinSellerTransferMetadata 的统一维度字段Google 支付审计仍保留 googlePaymentMetadata
// 但 WalletRechargeRecorded 必须带 country_id/target_country_id统计服务不能再从 region 或用户库反推国家。
rechargeMetadata := coinSellerTransferMetadata{
AppCode: command.AppCode,
PaymentOrderID: paymentOrderID,
ProviderCode: ledger.PaymentProviderGooglePlay,
TargetUserID: command.UserID,
TargetCountryID: command.CountryID,
TargetRegionID: command.RegionID,
Amount: product.CoinAmount,
TargetAssetType: ledger.AssetCoin,
TargetBalanceAfter: balanceAfter,
RechargeSequence: rechargeSequence,
RechargeUSDMinor: rechargeUSDMinor,
RechargeCurrencyCode: product.CurrencyCode,
RechargePolicyID: 0,
RechargePolicyVersion: product.PolicyVersion,
RechargePolicyCoinAmount: product.CoinAmount,
RechargePolicyUSDMinorAmount: rechargeUSDMinor,
RechargeType: ledger.RechargeChannelGoogle,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGooglePlayRecharge, requestHash, externalGooglePaymentRef(purchase, tokenHash), metadata, nowMs); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
@ -104,20 +125,7 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go
}); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if err := r.insertRechargeRecord(ctx, tx, transactionID, coinSellerTransferMetadata{
AppCode: command.AppCode,
TargetUserID: command.UserID,
TargetRegionID: command.RegionID,
Amount: product.CoinAmount,
TargetAssetType: ledger.AssetCoin,
TargetBalanceAfter: balanceAfter,
RechargeSequence: rechargeSequence,
RechargeUSDMinor: rechargeUSDMinor,
RechargeCurrencyCode: product.CurrencyCode,
RechargePolicyVersion: product.PolicyVersion,
RechargePolicyCoinAmount: product.CoinAmount,
RechargePolicyUSDMinorAmount: rechargeUSDMinor,
}, nowMs); err != nil {
if err := r.insertRechargeRecord(ctx, tx, transactionID, rechargeMetadata, nowMs); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if err := r.insertGooglePaymentOrder(ctx, tx, paymentOrderID, command, product, purchase, tokenHash, transactionID, nowMs); err != nil {
@ -126,7 +134,7 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, product.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
googlePaymentCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
rechargeRecordedEvent(transactionID, command.CommandID, command.UserID, product.CoinAmount, metadata, nowMs),
rechargeRecordedEvent(transactionID, command.CommandID, command.UserID, product.CoinAmount, rechargeMetadata, nowMs),
}); err != nil {
return ledger.GooglePaymentReceipt{}, err
}

View File

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

View File

@ -115,6 +115,7 @@ func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
command_id VARCHAR(128) NOT NULL COMMENT '客户端命令幂等 ID',
target_user_id BIGINT NOT NULL COMMENT '充值目标用户 ID',
target_region_id BIGINT NOT NULL COMMENT '充值目标区域 ID',
target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '充值目标国家 ID',
target_country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '充值目标国家码',
audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '商品适用用户类型',
product_id BIGINT NOT NULL COMMENT '充值商品 ID',
@ -148,6 +149,11 @@ func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE external_recharge_orders
ADD COLUMN target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '充值目标国家 ID' AFTER target_region_id`); err != nil && !isDuplicateColumnError(err) {
return err
}
return seedThirdPartyPaymentDefaults(ctx, db)
}

View File

@ -693,7 +693,7 @@ func initDBPath(t testing.TB) string {
func validateTableName(table string) string {
switch table {
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records",
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records", "payment_orders",
"wallet_user_recharge_stats", "external_recharge_orders", "third_party_payment_methods",
"host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies",
"host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records",

View File

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

View File

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