fix batch gift host scope propagation

This commit is contained in:
zhx 2026-06-21 22:02:48 +08:00
parent ef3dacd1a3
commit d15cd0a7fd
7 changed files with 1222 additions and 989 deletions

File diff suppressed because it is too large Load Diff

View File

@ -997,6 +997,17 @@ message SystemEvictUserResponse {
string rtc_kick_error = 6;
}
// SendGiftTargetHostScope gateway
message SendGiftTargetHostScope {
int64 target_user_id = 1;
// target_is_host gateway user-service active host profile
bool target_is_host = 2;
// target_host_region_id Host & Agency
int64 target_host_region_id = 3;
// target_agency_owner_user_id
int64 target_agency_owner_user_id = 4;
}
// SendGiftRequest
message SendGiftRequest {
RequestMeta meta = 1;
@ -1022,6 +1033,8 @@ message SendGiftRequest {
string entitlement_id = 13;
// source bag
string source = 14;
// target_host_scopes target
repeated SendGiftTargetHostScope target_host_scopes = 15;
}
// SendGiftResponse

View File

@ -442,6 +442,7 @@ type fakeUserHostClient struct {
listCoinSellersErr error
lastHost *userv1.GetHostProfileRequest
hostProfile *userv1.HostProfile
hostProfiles map[int64]*userv1.HostProfile
hostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
@ -1394,6 +1395,9 @@ func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHo
if f.hostErr != nil {
return nil, f.hostErr
}
if f.hostProfiles != nil {
return &userv1.GetHostProfileResponse{HostProfile: f.hostProfiles[req.GetUserId()]}, nil
}
if f.hostProfile != nil {
return &userv1.GetHostProfileResponse{HostProfile: f.hostProfile}, nil
}
@ -2433,6 +2437,33 @@ func TestSendGiftForwardsMultipleTargetUserIDs(t *testing.T) {
}
}
func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
roomClient := &fakeRoomClient{}
hostClient := &fakeUserHostClient{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-multi-host","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())
}
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)
}
if !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 {
t.Fatalf("legacy first-target host scope must remain populated: %+v", roomClient.lastGift)
}
}
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }

View File

@ -1558,11 +1558,12 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
httpkit.WriteRPCError(writer, request, err)
return
}
targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, firstUserID(targetUserIDs))
targetHostScopes, err := h.resolveGiftTargetHostScopes(request, targetUserIDs)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
@ -1574,9 +1575,10 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
PoolId: body.PoolID,
SenderCountryId: senderCountryID,
SenderRegionId: senderRegionID,
TargetIsHost: targetIsHost,
TargetHostRegionId: targetHostRegionID,
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
TargetIsHost: firstTargetScope.GetTargetIsHost(),
TargetHostRegionId: firstTargetScope.GetTargetHostRegionId(),
TargetAgencyOwnerUserId: firstTargetScope.GetTargetAgencyOwnerUserId(),
TargetHostScopes: targetHostScopes,
EntitlementId: strings.TrimSpace(body.EntitlementID),
Source: strings.TrimSpace(body.Source),
})
@ -1627,6 +1629,33 @@ func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID
return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil
}
func (h *Handler) resolveGiftTargetHostScopes(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)
if err != nil {
return nil, err
}
// 批量送礼必须按接收方分别固化主播快照;只解析第一个 target 会让后续主播丢失工资钻石入账。
scopes = append(scopes, &roomv1.SendGiftTargetHostScope{
TargetUserId: targetUserID,
TargetIsHost: targetIsHost,
TargetHostRegionId: targetHostRegionID,
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
})
}
return scopes, nil
}
func firstGiftTargetHostScope(scopes []*roomv1.SendGiftTargetHostScope, targetUserID int64) *roomv1.SendGiftTargetHostScope {
for _, scope := range scopes {
if scope.GetTargetUserId() == targetUserID {
return scope
}
}
return &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
}
// batchRoomDisplayProfiles 返回房间和公屏专用展示资料。
func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {

View File

@ -430,6 +430,8 @@ type SendGift struct {
TargetHostRegionID int64 `json:"target_host_region_id,omitempty"`
// TargetAgencyOwnerUserID 是 gateway 从 user-service active host profile 注入的代理收款人快照。
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"`
// TargetHostScopes 是 gateway 为批量送礼逐接收方固化的主播工资入账快照。
TargetHostScopes []GiftTargetHostScope `json:"target_host_scopes,omitempty"`
// BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。
BillingReceiptID string `json:"billing_receipt_id,omitempty"`
// CoinSpent 是 sender COIN 实际扣减值,来自 wallet-service 服务端价格。
@ -493,6 +495,14 @@ type SendGift struct {
// Type 返回命令类型。
func (SendGift) Type() string { return "send_gift" }
// GiftTargetHostScope 记录一个接收方在 gateway 入站时看到的主播工资入账资格。
type GiftTargetHostScope struct {
TargetUserID int64 `json:"target_user_id"`
TargetIsHost bool `json:"target_is_host,omitempty"`
TargetHostRegionID int64 `json:"target_host_region_id,omitempty"`
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id,omitempty"`
}
// RocketRewardGrant 记录发射命令中已经结算出的资源组发放结果。
type RocketRewardGrant struct {
RewardRole string `json:"reward_role"`

View File

@ -83,6 +83,7 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
TargetHostScopes: giftTargetHostScopesFromProto(req.GetTargetHostScopes()),
RobotGift: robotOptions.Enabled && !robotOptions.RealRoomHeat,
RobotWalletGift: robotOptions.Enabled,
SyntheticLuckyGift: robotOptions.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
@ -482,6 +483,7 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
return billing, []giftTargetBilling{{TargetUserID: cmd.TargetUserID, CommandID: cmd.ID(), Billing: billing}}, nil
}
if len(cmd.TargetUserIDs) == 1 {
targetScope := giftTargetHostScopeFor(cmd, cmd.TargetUserID)
// 单目标保留原 wallet command_id避免改变已有幂等键、账务回执和排障路径。
billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{
CommandId: cmd.ID(),
@ -493,9 +495,9 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
AppCode: appcode.FromContext(ctx),
RegionId: roomMeta.VisibleRegionID,
SenderRegionId: cmd.SenderRegionID,
TargetIsHost: cmd.TargetIsHost,
TargetHostRegionId: cmd.TargetHostRegionID,
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
TargetIsHost: targetScope.TargetIsHost,
TargetHostRegionId: targetScope.TargetHostRegionID,
TargetAgencyOwnerUserId: targetScope.TargetAgencyOwnerUserID,
EntitlementId: cmd.EntitlementID,
ChargeSource: cmd.Source,
})
@ -507,15 +509,16 @@ func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, ro
targets := make([]*walletv1.DebitGiftTarget, 0, len(cmd.TargetUserIDs))
for _, targetUserID := range cmd.TargetUserIDs {
targetScope := giftTargetHostScopeFor(cmd, targetUserID)
target := &walletv1.DebitGiftTarget{
CommandId: giftTargetCommandID(cmd.ID(), targetUserID, len(cmd.TargetUserIDs)),
TargetUserId: targetUserID,
}
if targetUserID == cmd.TargetUserID && cmd.TargetIsHost {
// 现有 room proto 只有单个 host scope多目标时只允许把该快照用于对应的第一个目标不能错误扩散到其他接收方
if targetScope.TargetIsHost {
// 每个目标只使用 gateway 为该 target 固化的主播快照,避免批量送礼时第一个接收方以外的主播丢工资钻石
target.TargetIsHost = true
target.TargetHostRegionId = cmd.TargetHostRegionID
target.TargetAgencyOwnerUserId = cmd.TargetAgencyOwnerUserID
target.TargetHostRegionId = targetScope.TargetHostRegionID
target.TargetAgencyOwnerUserId = targetScope.TargetAgencyOwnerUserID
}
targets = append(targets, target)
}
@ -758,6 +761,43 @@ func normalizeGiftTargetUserIDs(targetUserID int64, targetUserIDs []int64) []int
return ids
}
func giftTargetHostScopesFromProto(scopes []*roomv1.SendGiftTargetHostScope) []command.GiftTargetHostScope {
result := make([]command.GiftTargetHostScope, 0, len(scopes))
seen := make(map[int64]bool, len(scopes))
for _, scope := range scopes {
targetUserID := scope.GetTargetUserId()
if targetUserID <= 0 || seen[targetUserID] {
continue
}
seen[targetUserID] = true
result = append(result, command.GiftTargetHostScope{
TargetUserID: targetUserID,
TargetIsHost: scope.GetTargetIsHost(),
TargetHostRegionID: scope.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: scope.GetTargetAgencyOwnerUserId(),
})
}
return result
}
func giftTargetHostScopeFor(cmd command.SendGift, targetUserID int64) command.GiftTargetHostScope {
for _, scope := range cmd.TargetHostScopes {
if scope.TargetUserID == targetUserID {
return scope
}
}
if targetUserID == cmd.TargetUserID && cmd.TargetIsHost {
// 老 gateway 只会传单目标字段;保留这个兜底,避免滚动发布期间新 room-service 丢旧请求的主播入账。
return command.GiftTargetHostScope{
TargetUserID: targetUserID,
TargetIsHost: true,
TargetHostRegionID: cmd.TargetHostRegionID,
TargetAgencyOwnerUserID: cmd.TargetAgencyOwnerUserID,
}
}
return command.GiftTargetHostScope{TargetUserID: targetUserID}
}
func giftTargetCommandID(commandID string, targetUserID int64, targetCount int) string {
if targetCount <= 1 {
return commandID

View File

@ -131,6 +131,10 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "rose",
GiftCount: 2,
TargetHostScopes: []*roomv1.SendGiftTargetHostScope{
{TargetUserId: firstTargetID, TargetIsHost: true, TargetHostRegionId: 8801, TargetAgencyOwnerUserId: 30001},
{TargetUserId: secondTargetID, TargetIsHost: true, TargetHostRegionId: 8802, TargetAgencyOwnerUserId: 30002},
},
})
if err != nil {
t.Fatalf("send multi-target gift failed: %v", err)
@ -153,6 +157,10 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
if wallet.lastBatch.GetTargets()[0].GetCommandId() != "cmd-gift-multi:target:202" || wallet.lastBatch.GetTargets()[1].GetCommandId() != "cmd-gift-multi:target:303" {
t.Fatalf("wallet batch target command ids mismatch: %+v", wallet.lastBatch.GetTargets())
}
if !wallet.lastBatch.GetTargets()[0].GetTargetIsHost() || wallet.lastBatch.GetTargets()[0].GetTargetHostRegionId() != 8801 || wallet.lastBatch.GetTargets()[0].GetTargetAgencyOwnerUserId() != 30001 ||
!wallet.lastBatch.GetTargets()[1].GetTargetIsHost() || wallet.lastBatch.GetTargets()[1].GetTargetHostRegionId() != 8802 || wallet.lastBatch.GetTargets()[1].GetTargetAgencyOwnerUserId() != 30002 {
t.Fatalf("wallet batch target host scopes mismatch: %+v", wallet.lastBatch.GetTargets())
}
events := roomGiftSentEvents(t, ctx, repository)
if len(events) != 2 {