fix: harden room gift panel aggregation
This commit is contained in:
parent
c09e7be659
commit
23376ab87f
@ -7,11 +7,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/logx"
|
||||||
|
|
||||||
rocketmq "github.com/apache/rocketmq-client-go/v2"
|
rocketmq "github.com/apache/rocketmq-client-go/v2"
|
||||||
"github.com/apache/rocketmq-client-go/v2/consumer"
|
"github.com/apache/rocketmq-client-go/v2/consumer"
|
||||||
"github.com/apache/rocketmq-client-go/v2/primitive"
|
"github.com/apache/rocketmq-client-go/v2/primitive"
|
||||||
@ -178,6 +181,7 @@ func (p *Producer) SendSync(ctx context.Context, message Message) error {
|
|||||||
type Consumer struct {
|
type Consumer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
client rocketmq.PushConsumer
|
client rocketmq.PushConsumer
|
||||||
|
group string
|
||||||
started bool
|
started bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,7 +222,7 @@ func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Consumer{client: client}, nil
|
return &Consumer{client: client, group: group}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe binds a topic/tag selector to a handler. Empty tag subscribes all.
|
// Subscribe binds a topic/tag selector to a handler. Empty tag subscribes all.
|
||||||
@ -242,7 +246,7 @@ func (c *Consumer) Subscribe(topic string, tag string, handler Handler) error {
|
|||||||
if message == nil {
|
if message == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err := handler(ctx, ConsumedMessage{
|
consumed := ConsumedMessage{
|
||||||
Topic: message.Topic,
|
Topic: message.Topic,
|
||||||
Tag: message.GetTags(),
|
Tag: message.GetTags(),
|
||||||
Keys: message.GetKeys(),
|
Keys: message.GetKeys(),
|
||||||
@ -250,9 +254,23 @@ func (c *Consumer) Subscribe(topic string, tag string, handler Handler) error {
|
|||||||
Body: message.Body,
|
Body: message.Body,
|
||||||
Properties: message.GetProperties(),
|
Properties: message.GetProperties(),
|
||||||
ReconsumeTimes: message.ReconsumeTimes,
|
ReconsumeTimes: message.ReconsumeTimes,
|
||||||
})
|
}
|
||||||
if err != nil {
|
if err := handler(ctx, consumed); err != nil {
|
||||||
return consumer.ConsumeRetryLater, err
|
// rocketmq-client-go v2.1.2 在 callback error 非空时会把同一 pull batch 的 MessageExt
|
||||||
|
// 整批交给日志格式化;其 Message.String 未持有 properties 锁,而并行消费 goroutine
|
||||||
|
// 同时写 CONSUME_START_TIME 会触发 runtime fatal,进程无法通过 recover 自救。这里仅记录
|
||||||
|
// 已复制出的标量身份,不传 Message、Body 或 Properties,避免日志再次进入不安全 String。
|
||||||
|
logx.Error(ctx, "rocketmq_consume_handler_failed", err,
|
||||||
|
slog.String("consumer_group", c.group),
|
||||||
|
slog.String("topic", consumed.Topic),
|
||||||
|
slog.String("tag", consumed.Tag),
|
||||||
|
slog.String("keys", consumed.Keys),
|
||||||
|
slog.String("msg_id", consumed.MsgID),
|
||||||
|
slog.Int("reconsume_times", int(consumed.ReconsumeTimes)),
|
||||||
|
)
|
||||||
|
// ConsumeRetryLater 才是 SDK 的 send-back/retry 决策信号;callback error 仅驱动其
|
||||||
|
// 危险的整批错误日志。返回 nil error 不会确认消息,仍保留原有重试与死信策略。
|
||||||
|
return consumer.ConsumeRetryLater, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return consumer.ConsumeSuccess, nil
|
return consumer.ConsumeSuccess, nil
|
||||||
|
|||||||
94
pkg/rocketmqx/rocketmqx_test.go
Normal file
94
pkg/rocketmqx/rocketmqx_test.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package rocketmqx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hyapp/pkg/logx"
|
||||||
|
|
||||||
|
"github.com/apache/rocketmq-client-go/v2/consumer"
|
||||||
|
"github.com/apache/rocketmq-client-go/v2/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakePushConsumer struct {
|
||||||
|
callback func(context.Context, ...*primitive.MessageExt) (consumer.ConsumeResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePushConsumer) Start() error { return nil }
|
||||||
|
func (f *fakePushConsumer) Shutdown() error { return nil }
|
||||||
|
func (f *fakePushConsumer) Subscribe(_ string, _ consumer.MessageSelector, callback func(context.Context, ...*primitive.MessageExt) (consumer.ConsumeResult, error)) error {
|
||||||
|
f.callback = callback
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakePushConsumer) Unsubscribe(string) error { return nil }
|
||||||
|
func (f *fakePushConsumer) Suspend() {}
|
||||||
|
func (f *fakePushConsumer) Resume() {}
|
||||||
|
func (f *fakePushConsumer) GetOffsetDiffMap() map[string]int64 { return nil }
|
||||||
|
|
||||||
|
func TestSubscribeHandlerErrorReturnsRetryLaterWithoutSDKError(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := logx.Init(logx.Config{Service: "rocketmqx-test", Env: "test", Format: "json", Output: &output}); err != nil {
|
||||||
|
t.Fatalf("init test logger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakePushConsumer{}
|
||||||
|
consumerWrapper := &Consumer{client: client, group: "group-safe-log"}
|
||||||
|
handlerErr := errors.New("projection unavailable")
|
||||||
|
if err := consumerWrapper.Subscribe("topic-safe-log", "tag-safe-log", func(context.Context, ConsumedMessage) error {
|
||||||
|
return handlerErr
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("subscribe: %v", err)
|
||||||
|
}
|
||||||
|
if client.callback == nil {
|
||||||
|
t.Fatal("subscribe callback was not captured")
|
||||||
|
}
|
||||||
|
|
||||||
|
extended := &primitive.MessageExt{MsgId: "msg-safe-log", ReconsumeTimes: 3}
|
||||||
|
// 直接初始化嵌入的 Message,避免复制其 RWMutex;Queue 仍故意保持 nil,确保日志路径
|
||||||
|
// 一旦把 MessageExt 交给 String 格式化就会在测试中立即暴露。
|
||||||
|
extended.Topic = "topic-safe-log"
|
||||||
|
extended.Body = []byte("sensitive-body-must-not-be-logged")
|
||||||
|
extended.WithTag("tag-safe-log")
|
||||||
|
extended.WithKeys([]string{"event-safe-log"})
|
||||||
|
extended.WithProperty("private-property", "secret-value-must-not-be-logged")
|
||||||
|
// Queue 故意保持 nil:MessageExt.String 会解引用 Queue 并 panic。callback 能完成并写日志,
|
||||||
|
// 因而同时锁定包装层只记录复制后的安全标量,绝不把 MessageExt 交给格式化器。
|
||||||
|
result, callbackErr := client.callback(context.Background(), extended)
|
||||||
|
if result != consumer.ConsumeRetryLater {
|
||||||
|
t.Fatalf("consume result = %v, want ConsumeRetryLater", result)
|
||||||
|
}
|
||||||
|
if callbackErr != nil {
|
||||||
|
t.Fatalf("callback error = %v, want nil so SDK does not format the shared message batch", callbackErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLog := strings.TrimSpace(output.String())
|
||||||
|
var record map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(rawLog), &record); err != nil {
|
||||||
|
t.Fatalf("decode safe consume log %q: %v", rawLog, err)
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"msg": "rocketmq_consume_handler_failed",
|
||||||
|
"consumer_group": "group-safe-log",
|
||||||
|
"topic": "topic-safe-log",
|
||||||
|
"tag": "tag-safe-log",
|
||||||
|
"keys": "event-safe-log",
|
||||||
|
"msg_id": "msg-safe-log",
|
||||||
|
"error": handlerErr.Error(),
|
||||||
|
} {
|
||||||
|
if got := record[key]; got != want {
|
||||||
|
t.Errorf("log field %s = %#v, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := record["reconsume_times"]; got != float64(3) {
|
||||||
|
t.Errorf("log field reconsume_times = %#v, want 3", got)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"sensitive-body-must-not-be-logged", "private-property", "secret-value-must-not-be-logged"} {
|
||||||
|
if strings.Contains(rawLog, forbidden) {
|
||||||
|
t.Errorf("safe consume log leaked forbidden message data %q: %s", forbidden, rawLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,287 @@
|
|||||||
|
package roomapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/logx"
|
||||||
|
"hyapp/pkg/roomid"
|
||||||
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
|
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Flutter 在 15 秒终止请求;gateway 留出 5 秒给 DNS/TLS/传输和客户端解码,必须先返回带 request_id 的稳定错误。
|
||||||
|
roomGiftPanelTotalBudget = 10 * time.Second
|
||||||
|
// 用户主资料是收礼人头像昵称的来源,单独给足正常批量查询时间,但不允许占满整个面板预算。
|
||||||
|
roomGiftPanelRecipientCoreBudget = 1500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
type roomGiftPanelCatalogState struct {
|
||||||
|
version int64
|
||||||
|
cacheable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomGiftPanelStageResult[T any] struct {
|
||||||
|
value T
|
||||||
|
degraded bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRoomGiftPanelStage 只负责请求内并发和观测;业务 owner 仍由各下游服务保持。
|
||||||
|
// 结果通道有缓冲,handler 因总预算先返回时,已响应的 gRPC goroutine 不会卡在投递结果上。
|
||||||
|
func startRoomGiftPanelStage[T any](group *sync.WaitGroup, ctx context.Context, requestID string, roomID string, stage string, fn func() (T, bool, error)) <-chan roomGiftPanelStageResult[T] {
|
||||||
|
resultCh := make(chan roomGiftPanelStageResult[T], 1)
|
||||||
|
group.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
startedAt := time.Now()
|
||||||
|
value, degraded, err := fn()
|
||||||
|
logRoomGiftPanelStage(ctx, requestID, roomID, stage, startedAt, err, degraded)
|
||||||
|
resultCh <- roomGiftPanelStageResult[T]{value: value, degraded: degraded, err: err}
|
||||||
|
}()
|
||||||
|
return resultCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitRoomGiftPanelStage[T any](ctx context.Context, resultCh <-chan roomGiftPanelStageResult[T]) (T, bool, error) {
|
||||||
|
select {
|
||||||
|
case result := <-resultCh:
|
||||||
|
return result.value, result.degraded, result.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
var zero T
|
||||||
|
return zero, false, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logRoomGiftPanelStage(ctx context.Context, requestID string, roomID string, stage string, startedAt time.Time, err error, degraded bool) {
|
||||||
|
logCtx := logx.With(ctx,
|
||||||
|
slog.String("request_id", requestID),
|
||||||
|
slog.String("room_id", roomID),
|
||||||
|
slog.String("app_code", appcode.FromContext(ctx)),
|
||||||
|
)
|
||||||
|
attrs := []slog.Attr{
|
||||||
|
slog.String("stage", stage),
|
||||||
|
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
|
||||||
|
slog.Bool("success", err == nil),
|
||||||
|
slog.Bool("degraded", degraded),
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err != nil && degraded:
|
||||||
|
attrs = append(attrs, slog.String("error", err.Error()))
|
||||||
|
logx.Warn(logCtx, "gateway_room_gift_panel_stage", attrs...)
|
||||||
|
case err != nil:
|
||||||
|
logx.Error(logCtx, "gateway_room_gift_panel_stage", err, attrs...)
|
||||||
|
case degraded:
|
||||||
|
logx.Warn(logCtx, "gateway_room_gift_panel_stage", attrs...)
|
||||||
|
default:
|
||||||
|
logx.Info(logCtx, "gateway_room_gift_panel_stage", attrs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveRoomGiftPanel 在单个明确预算内聚合 owner service 读模型。
|
||||||
|
// 第一阶段只并发互不依赖的 RPC;依赖 visible_region_id 的礼物配置在快照返回后启动,避免猜测区域。
|
||||||
|
func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.roomQueryClient == nil || h.walletClient == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||||||
|
if !roomid.ValidStringID(roomID) {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalStartedAt := time.Now()
|
||||||
|
requestID := httpkit.RequestIDFromContext(request.Context())
|
||||||
|
totalCtx, cancel := context.WithTimeout(request.Context(), h.roomGiftPanelTotalTimeout())
|
||||||
|
var stageGroup sync.WaitGroup
|
||||||
|
defer func() {
|
||||||
|
// 先取消所有尚未完成的 RPC,再等待 request-scoped worker 退出;HTTP handler 返回时不遗留后台访问。
|
||||||
|
cancel()
|
||||||
|
stageGroup.Wait()
|
||||||
|
}()
|
||||||
|
request = request.WithContext(totalCtx)
|
||||||
|
viewerUserID := auth.UserIDFromContext(totalCtx)
|
||||||
|
app := appcode.FromContext(totalCtx)
|
||||||
|
|
||||||
|
snapshotCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "snapshot", func() (*roomv1.GetRoomSnapshotResponse, bool, error) {
|
||||||
|
resp, err := h.roomQueryClient.GetRoomSnapshot(totalCtx, &roomv1.GetRoomSnapshotRequest{
|
||||||
|
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||||||
|
RoomId: roomID,
|
||||||
|
ViewerUserId: viewerUserID,
|
||||||
|
})
|
||||||
|
return resp, false, err
|
||||||
|
})
|
||||||
|
balanceCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "balance", func() (*walletv1.GetBalancesResponse, bool, error) {
|
||||||
|
resp, err := h.walletClient.GetBalances(totalCtx, &walletv1.GetBalancesRequest{
|
||||||
|
RequestId: requestID,
|
||||||
|
UserId: viewerUserID,
|
||||||
|
AssetTypes: []string{"COIN"},
|
||||||
|
AppCode: app,
|
||||||
|
})
|
||||||
|
return resp, false, err
|
||||||
|
})
|
||||||
|
catalogCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "catalog", func() (roomGiftPanelCatalogState, bool, error) {
|
||||||
|
version, cacheable, err := h.roomGiftCatalogVersion(request, app)
|
||||||
|
return roomGiftPanelCatalogState{version: version, cacheable: cacheable}, false, err
|
||||||
|
})
|
||||||
|
bagCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "bag", func() (*walletv1.ListUserResourcesResponse, bool, error) {
|
||||||
|
resp, err := h.roomGiftBagResources(request, viewerUserID)
|
||||||
|
return resp, false, err
|
||||||
|
})
|
||||||
|
|
||||||
|
snapshotResp, _, err := waitRoomGiftPanelStage(totalCtx, snapshotCh)
|
||||||
|
if err != nil {
|
||||||
|
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshot := snapshotResp.GetRoom()
|
||||||
|
recipientUserIDs := roomGiftRecipientUserIDs(snapshot)
|
||||||
|
profileCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "recipient_profile", func() (map[int64]roomDisplayProfileData, bool, error) {
|
||||||
|
profiles, degraded := h.roomGiftRecipientProfileMap(request, requestID, roomID, recipientUserIDs)
|
||||||
|
return profiles, degraded, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
catalog, _, err := waitRoomGiftPanelStage(totalCtx, catalogCh)
|
||||||
|
if err != nil {
|
||||||
|
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "config", func() (roomGiftPanelConfig, bool, error) {
|
||||||
|
config, err := h.roomGiftPanelBaseConfigForVersion(request, app, snapshot.GetVisibleRegionId(), catalog.version, catalog.cacheable)
|
||||||
|
return config, false, err
|
||||||
|
})
|
||||||
|
|
||||||
|
balances, _, err := waitRoomGiftPanelStage(totalCtx, balanceCh)
|
||||||
|
if err != nil {
|
||||||
|
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panelConfig, _, err := waitRoomGiftPanelStage(totalCtx, configCh)
|
||||||
|
if err != nil {
|
||||||
|
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bagResources, _, err := waitRoomGiftPanelStage(totalCtx, bagCh)
|
||||||
|
if err != nil {
|
||||||
|
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipientProfiles, _, err := waitRoomGiftPanelStage(totalCtx, profileCh)
|
||||||
|
if err != nil {
|
||||||
|
// 核心资料的 1.5 秒子预算会在内部降级;这里出现 error 只可能是整个 gift-panel 总预算已耗尽。
|
||||||
|
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
panelGifts := append([]giftConfigData{}, panelConfig.Gifts...)
|
||||||
|
panelGifts = append(panelGifts, roomGiftBagGiftsFromResources(bagResources, panelConfig.Gifts)...)
|
||||||
|
data := roomGiftPanelData{
|
||||||
|
CoinBalance: coinBalanceFromProto(balances),
|
||||||
|
Recipients: roomGiftRecipients(snapshot, recipientProfiles),
|
||||||
|
Tabs: roomGiftTabs(panelGifts, panelConfig.GiftTypes),
|
||||||
|
Gifts: panelGifts,
|
||||||
|
QuantityPresets: []int32{1, 9, 99, 999},
|
||||||
|
}
|
||||||
|
logRoomGiftPanelStage(totalCtx, requestID, roomID, "total", totalStartedAt, nil, false)
|
||||||
|
httpkit.WriteOK(writer, request, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeRoomGiftPanelFailure(writer http.ResponseWriter, request *http.Request, requestID string, roomID string, startedAt time.Time, err error) {
|
||||||
|
logRoomGiftPanelStage(request.Context(), requestID, roomID, "total", startedAt, err, false)
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || status.Code(err) == codes.DeadlineExceeded || status.Code(err) == codes.Canceled || request.Context().Err() != nil {
|
||||||
|
// 明确在 Flutter 15 秒前结束 HTTP 响应;envelope 使用原请求上下文中的 request_id,便于从客户端反查阶段日志。
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) roomGiftPanelTotalTimeout() time.Duration {
|
||||||
|
if h.giftPanelTotalBudget > 0 {
|
||||||
|
return h.giftPanelTotalBudget
|
||||||
|
}
|
||||||
|
return roomGiftPanelTotalBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) roomGiftPanelRecipientCoreTimeout() time.Duration {
|
||||||
|
if h.giftPanelRecipientCoreBudget > 0 {
|
||||||
|
return h.giftPanelRecipientCoreBudget
|
||||||
|
}
|
||||||
|
return roomGiftPanelRecipientCoreBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomGiftRecipientProfileMap 只读取 Flutter gift-panel 实际消费的头像昵称核心资料。
|
||||||
|
// 装扮、徽章和等级字段维持空对象兼容旧 JSON 形状,但不再发起 activity/wallet enrich RPC;
|
||||||
|
// 这些资料属于用户卡片而非送礼决策,删除整条 N+1 链路比等待后再超时降级更可靠。
|
||||||
|
func (h *Handler) roomGiftRecipientProfileMap(request *http.Request, requestID string, roomID string, userIDs []int64) (map[int64]roomDisplayProfileData, bool) {
|
||||||
|
userIDs = uniquePositiveUserIDs(userIDs)
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if h.userProfileClient == nil {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
coreCtx, cancelCore := context.WithTimeout(request.Context(), h.roomGiftPanelRecipientCoreTimeout())
|
||||||
|
defer cancelCore()
|
||||||
|
coreRequest := request.WithContext(coreCtx)
|
||||||
|
startedAt := time.Now()
|
||||||
|
resp, err := h.userProfileClient.BatchGetUsers(coreCtx, &userv1.BatchGetUsersRequest{
|
||||||
|
Meta: httpkit.UserMeta(coreRequest, ""),
|
||||||
|
UserIds: userIDs,
|
||||||
|
})
|
||||||
|
logRoomGiftPanelStage(coreCtx, requestID, roomID, "recipient_profile_core", startedAt, err, err != nil)
|
||||||
|
if err != nil {
|
||||||
|
// 房间目标 user_id 仍来自 Room Cell 快照;资料服务抖动时保留可送礼目标,只降级头像昵称。
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
return roomGiftCoreProfilesFromUsers(userIDs, resp.GetUsers()), false
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomGiftCoreProfilesFromUsers(userIDs []int64, users map[int64]*userv1.User) map[int64]roomDisplayProfileData {
|
||||||
|
profiles := make(map[int64]roomDisplayProfileData, len(users))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
user := users[userID]
|
||||||
|
if user == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 核心层保留历史完整资料字段;可选资源统一初始化为空对象,超时降级不会改变 JSON 类型。
|
||||||
|
profiles[userID] = roomDisplayProfileData{
|
||||||
|
UserID: formatOptionalUserID(user.GetUserId()),
|
||||||
|
Username: user.GetUsername(),
|
||||||
|
Avatar: user.GetAvatar(),
|
||||||
|
DisplayUserID: user.GetDisplayUserId(),
|
||||||
|
PrettyID: user.GetPrettyId(),
|
||||||
|
PrettyDisplayUserID: user.GetPrettyDisplayUserId(),
|
||||||
|
Gender: strings.TrimSpace(user.GetGender()),
|
||||||
|
Age: roomProfileAgeFromBirth(user.GetBirth(), time.Now().UTC()),
|
||||||
|
Country: strings.ToUpper(strings.TrimSpace(user.GetCountry())),
|
||||||
|
CountryName: strings.TrimSpace(user.GetCountryName()),
|
||||||
|
CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()),
|
||||||
|
CountryFlag: roomProfileCountryFlag(user.GetCountry()),
|
||||||
|
VIP: map[string]any{},
|
||||||
|
Level: roomDefaultLevelDisplay(),
|
||||||
|
LevelBadges: map[string]any{},
|
||||||
|
Badges: []map[string]any{},
|
||||||
|
AvatarFrame: map[string]any{},
|
||||||
|
ProfileCard: map[string]any{},
|
||||||
|
Vehicle: map[string]any{},
|
||||||
|
MicSeatAnimation: map[string]any{},
|
||||||
|
Charm: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
@ -0,0 +1,416 @@
|
|||||||
|
package roomapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
|
gatewayclient "hyapp/services/gateway-service/internal/client"
|
||||||
|
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type giftPanelTestRoomQueryClient struct {
|
||||||
|
gatewayclient.RoomQueryClient
|
||||||
|
getSnapshot func(context.Context, *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestRoomQueryClient) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
||||||
|
return f.getSnapshot(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type giftPanelTestWalletClient struct {
|
||||||
|
gatewayclient.WalletClient
|
||||||
|
getBalances func(context.Context, *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
|
||||||
|
getCatalogVersion func(context.Context, *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error)
|
||||||
|
listUserResources func(context.Context, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error)
|
||||||
|
listGiftTypes func(context.Context, *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
|
||||||
|
listGiftConfigs func(context.Context, *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
||||||
|
unusedEnrichRPCCalls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestWalletClient) GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||||
|
return f.getBalances(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestWalletClient) GetGiftCatalogVersion(ctx context.Context, req *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
|
||||||
|
return f.getCatalogVersion(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestWalletClient) ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||||
|
return f.listUserResources(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestWalletClient) ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) {
|
||||||
|
return f.listGiftTypes(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||||
|
return f.listGiftConfigs(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestWalletClient) BatchGetUserEquippedResources(context.Context, *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error) {
|
||||||
|
f.unusedEnrichRPCCalls.Add(1)
|
||||||
|
return &walletv1.BatchGetUserEquippedResourcesResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestWalletClient) GetResource(context.Context, *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {
|
||||||
|
f.unusedEnrichRPCCalls.Add(1)
|
||||||
|
return &walletv1.GetResourceResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type giftPanelTestUserClient struct {
|
||||||
|
gatewayclient.UserProfileClient
|
||||||
|
batchGetUsers func(context.Context, *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error)
|
||||||
|
calls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelTestUserClient) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
|
||||||
|
f.calls.Add(1)
|
||||||
|
return f.batchGetUsers(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type giftPanelUnusedAchievementClient struct {
|
||||||
|
gatewayclient.AchievementClient
|
||||||
|
calls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelUnusedAchievementClient) ListMyBadges(context.Context, *activityv1.ListMyBadgesRequest) (*activityv1.ListMyBadgesResponse, error) {
|
||||||
|
f.calls.Add(1)
|
||||||
|
return &activityv1.ListMyBadgesResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type giftPanelUnusedGrowthClient struct {
|
||||||
|
gatewayclient.GrowthLevelClient
|
||||||
|
calls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *giftPanelUnusedGrowthClient) BatchGetUserLevelDisplayProfiles(context.Context, *activityv1.BatchGetUserLevelDisplayProfilesRequest) (*activityv1.BatchGetUserLevelDisplayProfilesResponse, error) {
|
||||||
|
f.calls.Add(1)
|
||||||
|
return &activityv1.BatchGetUserLevelDisplayProfilesResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type giftPanelStageGate struct {
|
||||||
|
started chan string
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGiftPanelStageGate() *giftPanelStageGate {
|
||||||
|
return &giftPanelStageGate{started: make(chan string, 16), release: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *giftPanelStageGate) wait(ctx context.Context, stage string) error {
|
||||||
|
select {
|
||||||
|
case g.started <- stage:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-g.release:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitGiftPanelStages(t *testing.T, started <-chan string, expected ...string) {
|
||||||
|
t.Helper()
|
||||||
|
want := make(map[string]bool, len(expected))
|
||||||
|
for _, stage := range expected {
|
||||||
|
want[stage] = true
|
||||||
|
}
|
||||||
|
deadline := time.NewTimer(time.Second)
|
||||||
|
defer deadline.Stop()
|
||||||
|
for len(want) > 0 {
|
||||||
|
select {
|
||||||
|
case stage := <-started:
|
||||||
|
delete(want, stage)
|
||||||
|
case <-deadline.C:
|
||||||
|
t.Fatalf("gift-panel stages did not start concurrently; missing=%v", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type giftPanelBlockingTracker struct {
|
||||||
|
active atomic.Int32
|
||||||
|
canceled atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *giftPanelBlockingTracker) wait(ctx context.Context) error {
|
||||||
|
t.active.Add(1)
|
||||||
|
defer t.active.Add(-1)
|
||||||
|
<-ctx.Done()
|
||||||
|
t.canceled.Add(1)
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomGiftPanelProductionBudgetsStayBelowFlutterTimeout(t *testing.T) {
|
||||||
|
handler := New(Config{})
|
||||||
|
if got := handler.roomGiftPanelTotalTimeout(); got != 10*time.Second || got >= 15*time.Second {
|
||||||
|
t.Fatalf("gift-panel total budget must leave client transport headroom: %s", got)
|
||||||
|
}
|
||||||
|
if got := handler.roomGiftPanelRecipientCoreTimeout(); got != 1500*time.Millisecond || got >= handler.roomGiftPanelTotalTimeout() {
|
||||||
|
t.Fatalf("recipient core budget must remain a short degradable stage: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomGiftPanelRunsIndependentStagesConcurrentlyAndSkipsUnusedEnrichment(t *testing.T) {
|
||||||
|
phaseOne := newGiftPanelStageGate()
|
||||||
|
phaseTwo := newGiftPanelStageGate()
|
||||||
|
roomID := "lalu_room_gift_parallel"
|
||||||
|
queryClient := &giftPanelTestRoomQueryClient{getSnapshot: func(ctx context.Context, _ *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
||||||
|
if err := phaseOne.wait(ctx, "snapshot"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return giftPanelTestSnapshot(roomID), nil
|
||||||
|
}}
|
||||||
|
walletClient := giftPanelWorkingWalletClient()
|
||||||
|
walletClient.getBalances = func(ctx context.Context, _ *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||||
|
if err := phaseOne.wait(ctx, "balance"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}}, nil
|
||||||
|
}
|
||||||
|
walletClient.getCatalogVersion = func(ctx context.Context, _ *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
|
||||||
|
if err := phaseOne.wait(ctx, "catalog"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &walletv1.GetGiftCatalogVersionResponse{Version: 1}, nil
|
||||||
|
}
|
||||||
|
walletClient.listUserResources = func(ctx context.Context, _ *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||||
|
if err := phaseOne.wait(ctx, "bag"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &walletv1.ListUserResourcesResponse{}, nil
|
||||||
|
}
|
||||||
|
walletClient.listGiftTypes = func(ctx context.Context, _ *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) {
|
||||||
|
if err := phaseTwo.wait(ctx, "gift_types"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{TypeCode: "normal", TabKey: "Gift", Status: "active"}}}, nil
|
||||||
|
}
|
||||||
|
walletClient.listGiftConfigs = func(ctx context.Context, _ *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||||
|
if err := phaseTwo.wait(ctx, "gifts"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return giftPanelTestGiftConfigs(), nil
|
||||||
|
}
|
||||||
|
userClient := &giftPanelTestUserClient{batchGetUsers: func(ctx context.Context, _ *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
|
||||||
|
if err := phaseTwo.wait(ctx, "recipient_core"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return giftPanelTestUsers(), nil
|
||||||
|
}}
|
||||||
|
achievementClient := &giftPanelUnusedAchievementClient{}
|
||||||
|
growthClient := &giftPanelUnusedGrowthClient{}
|
||||||
|
handler := New(Config{
|
||||||
|
RoomQueryClient: queryClient,
|
||||||
|
WalletClient: walletClient,
|
||||||
|
UserProfileClient: userClient,
|
||||||
|
AchievementClient: achievementClient,
|
||||||
|
GrowthLevelClient: growthClient,
|
||||||
|
})
|
||||||
|
handler.giftPanelRecipientCoreBudget = 3 * time.Second
|
||||||
|
|
||||||
|
responseCh := make(chan *httptest.ResponseRecorder, 1)
|
||||||
|
go func() { responseCh <- serveGiftPanelForTest(handler, roomID) }()
|
||||||
|
awaitGiftPanelStages(t, phaseOne.started, "snapshot", "balance", "catalog", "bag")
|
||||||
|
close(phaseOne.release)
|
||||||
|
awaitGiftPanelStages(t, phaseTwo.started, "gift_types", "gifts", "recipient_core")
|
||||||
|
close(phaseTwo.release)
|
||||||
|
|
||||||
|
recorder := <-responseCh
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("gift-panel parallel response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if userClient.calls.Load() != 1 {
|
||||||
|
t.Fatalf("recipient core must use one BatchGetUsers call, got=%d", userClient.calls.Load())
|
||||||
|
}
|
||||||
|
if achievementClient.calls.Load() != 0 || growthClient.calls.Load() != 0 || walletClient.unusedEnrichRPCCalls.Load() != 0 {
|
||||||
|
t.Fatalf("gift-panel must not call unused enrich RPCs: badge=%d level=%d wallet=%d", achievementClient.calls.Load(), growthClient.calls.Load(), walletClient.unusedEnrichRPCCalls.Load())
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Recipients []roomGiftRecipientData `json:"recipients"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||||
|
t.Fatalf("decode gift-panel response: %v", err)
|
||||||
|
}
|
||||||
|
if envelope.Code != httpkit.CodeOK || len(envelope.Data.Recipients) != 3 {
|
||||||
|
t.Fatalf("gift-panel recipients mismatch: %+v", envelope)
|
||||||
|
}
|
||||||
|
profile := envelope.Data.Recipients[1].Profile
|
||||||
|
if profile == nil || profile.Username != "Host 101" || profile.Avatar != "https://cdn.example/101.png" {
|
||||||
|
t.Fatalf("gift-panel core profile must survive without enrich: %+v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomGiftPanelTotalBudgetReturnsTraceableUpstreamErrorAndStopsWorkers(t *testing.T) {
|
||||||
|
tracker := &giftPanelBlockingTracker{}
|
||||||
|
queryClient := &giftPanelTestRoomQueryClient{getSnapshot: func(ctx context.Context, _ *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
||||||
|
return nil, tracker.wait(ctx)
|
||||||
|
}}
|
||||||
|
walletClient := giftPanelWorkingWalletClient()
|
||||||
|
walletClient.getBalances = func(ctx context.Context, _ *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||||
|
return nil, tracker.wait(ctx)
|
||||||
|
}
|
||||||
|
walletClient.getCatalogVersion = func(ctx context.Context, _ *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
|
||||||
|
return nil, tracker.wait(ctx)
|
||||||
|
}
|
||||||
|
walletClient.listUserResources = func(ctx context.Context, _ *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||||
|
return nil, tracker.wait(ctx)
|
||||||
|
}
|
||||||
|
handler := New(Config{RoomQueryClient: queryClient, WalletClient: walletClient})
|
||||||
|
handler.giftPanelTotalBudget = 40 * time.Millisecond
|
||||||
|
|
||||||
|
startedAt := time.Now()
|
||||||
|
recorder := serveGiftPanelForTest(handler, "lalu_room_gift_timeout")
|
||||||
|
if elapsed := time.Since(startedAt); elapsed > 500*time.Millisecond {
|
||||||
|
t.Fatalf("gift-panel budget did not stop response promptly: elapsed=%s", elapsed)
|
||||||
|
}
|
||||||
|
if recorder.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("gift-panel timeout status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
var envelope httpkit.ResponseEnvelope
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||||
|
t.Fatalf("decode timeout envelope: %v", err)
|
||||||
|
}
|
||||||
|
if envelope.Code != httpkit.CodeUpstreamError || envelope.RequestID == "" || envelope.RequestID != recorder.Header().Get("X-Request-ID") {
|
||||||
|
t.Fatalf("timeout envelope must expose server request_id: envelope=%+v header=%q", envelope, recorder.Header().Get("X-Request-ID"))
|
||||||
|
}
|
||||||
|
if tracker.canceled.Load() != 4 || tracker.active.Load() != 0 {
|
||||||
|
t.Fatalf("all request workers must observe cancellation and exit: canceled=%d active=%d", tracker.canceled.Load(), tracker.active.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomGiftPanelRecipientCoreTimeoutDegradesWithoutFailingPanel(t *testing.T) {
|
||||||
|
tracker := &giftPanelBlockingTracker{}
|
||||||
|
handler := New(Config{
|
||||||
|
RoomQueryClient: &giftPanelTestRoomQueryClient{getSnapshot: func(context.Context, *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
|
||||||
|
return giftPanelTestSnapshot("lalu_room_gift_profile_timeout"), nil
|
||||||
|
}},
|
||||||
|
WalletClient: giftPanelWorkingWalletClient(),
|
||||||
|
UserProfileClient: &giftPanelTestUserClient{batchGetUsers: func(ctx context.Context, _ *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
|
||||||
|
return nil, tracker.wait(ctx)
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
handler.giftPanelRecipientCoreBudget = 35 * time.Millisecond
|
||||||
|
|
||||||
|
recorder := serveGiftPanelForTest(handler, "lalu_room_gift_profile_timeout")
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("recipient core timeout must degrade, not fail panel: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if tracker.canceled.Load() != 1 || tracker.active.Load() != 0 {
|
||||||
|
t.Fatalf("recipient core worker must stop at its child deadline: canceled=%d active=%d", tracker.canceled.Load(), tracker.active.Load())
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Recipients []roomGiftRecipientData `json:"recipients"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||||
|
t.Fatalf("decode degraded panel: %v", err)
|
||||||
|
}
|
||||||
|
if envelope.Code != httpkit.CodeOK || len(envelope.Data.Recipients) != 3 || envelope.Data.Recipients[1].UserID == "" || envelope.Data.Recipients[1].Profile != nil {
|
||||||
|
t.Fatalf("degraded panel must preserve Room Cell recipient IDs only: %+v", envelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomGiftPanelConfigFailureCancelsSiblingRPCBeforeReturning(t *testing.T) {
|
||||||
|
tracker := &giftPanelBlockingTracker{}
|
||||||
|
giftStarted := make(chan struct{})
|
||||||
|
walletClient := giftPanelWorkingWalletClient()
|
||||||
|
walletClient.listGiftConfigs = func(ctx context.Context, _ *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||||
|
close(giftStarted)
|
||||||
|
return nil, tracker.wait(ctx)
|
||||||
|
}
|
||||||
|
walletClient.listGiftTypes = func(context.Context, *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) {
|
||||||
|
<-giftStarted
|
||||||
|
return nil, errors.New("gift type test failure")
|
||||||
|
}
|
||||||
|
handler := New(Config{WalletClient: walletClient})
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/lalu_room_gift_config_failure/gift-panel", nil)
|
||||||
|
request.SetPathValue("room_id", "lalu_room_gift_config_failure")
|
||||||
|
request = request.WithContext(appcode.WithContext(request.Context(), "lalu"))
|
||||||
|
|
||||||
|
_, err := handler.roomGiftPanelBaseConfigForVersion(request, "lalu", 1001, 1, false)
|
||||||
|
if err == nil || err.Error() != "gift type test failure" {
|
||||||
|
t.Fatalf("config error mismatch: %v", err)
|
||||||
|
}
|
||||||
|
if tracker.canceled.Load() != 1 || tracker.active.Load() != 0 {
|
||||||
|
t.Fatalf("failed config sibling must observe cancellation before return: canceled=%d active=%d", tracker.canceled.Load(), tracker.active.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveGiftPanelForTest(handler *Handler, roomID string) *httptest.ResponseRecorder {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/"+roomID+"/gift-panel", nil)
|
||||||
|
request.SetPathValue("room_id", roomID)
|
||||||
|
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "lalu"), 42))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
httpkit.WithRequestID(http.HandlerFunc(handler.getRoomGiftPanel)).ServeHTTP(recorder, request)
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func giftPanelTestSnapshot(roomID string) *roomv1.GetRoomSnapshotResponse {
|
||||||
|
return &roomv1.GetRoomSnapshotResponse{Room: &roomv1.RoomSnapshot{
|
||||||
|
RoomId: roomID,
|
||||||
|
OwnerUserId: 101,
|
||||||
|
VisibleRegionId: 1001,
|
||||||
|
MicSeats: []*roomv1.SeatState{
|
||||||
|
{SeatNo: 1, UserId: 101},
|
||||||
|
{SeatNo: 2, UserId: 102},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func giftPanelWorkingWalletClient() *giftPanelTestWalletClient {
|
||||||
|
return &giftPanelTestWalletClient{
|
||||||
|
getBalances: func(context.Context, *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||||
|
return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}}, nil
|
||||||
|
},
|
||||||
|
getCatalogVersion: func(context.Context, *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
|
||||||
|
return &walletv1.GetGiftCatalogVersionResponse{Version: 1}, nil
|
||||||
|
},
|
||||||
|
listUserResources: func(context.Context, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||||
|
return &walletv1.ListUserResourcesResponse{}, nil
|
||||||
|
},
|
||||||
|
listGiftTypes: func(context.Context, *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) {
|
||||||
|
return &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{TypeCode: "normal", TabKey: "Gift", Status: "active"}}}, nil
|
||||||
|
},
|
||||||
|
listGiftConfigs: func(context.Context, *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||||
|
return giftPanelTestGiftConfigs(), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func giftPanelTestGiftConfigs() *walletv1.ListGiftConfigsResponse {
|
||||||
|
return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
|
||||||
|
GiftId: "rose",
|
||||||
|
ResourceId: 11,
|
||||||
|
Resource: &walletv1.Resource{ResourceId: 11, ResourceType: "gift", Status: "active", Name: "Rose"},
|
||||||
|
Status: "active",
|
||||||
|
Name: "Rose",
|
||||||
|
GiftTypeCode: "normal",
|
||||||
|
CoinPrice: 100,
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func giftPanelTestUsers() *userv1.BatchGetUsersResponse {
|
||||||
|
return &userv1.BatchGetUsersResponse{Users: map[int64]*userv1.User{
|
||||||
|
101: {UserId: 101, Username: "Host 101", Avatar: "https://cdn.example/101.png", DisplayUserId: "100101"},
|
||||||
|
102: {UserId: 102, Username: "Host 102", Avatar: "https://cdn.example/102.png", DisplayUserId: "100102"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
@ -27,6 +27,11 @@ type Handler struct {
|
|||||||
growthLevelClient client.GrowthLevelClient
|
growthLevelClient client.GrowthLevelClient
|
||||||
achievementClient client.AchievementClient
|
achievementClient client.AchievementClient
|
||||||
rtcTokenConfig tencentrtc.TokenConfig
|
rtcTokenConfig tencentrtc.TokenConfig
|
||||||
|
|
||||||
|
// gift-panel 的预算保留在 handler 内,生产使用固定安全值;测试可缩短预算验证真实超时分支,
|
||||||
|
// 避免把 Flutter 的 15 秒客户端超时当成后端取消机制。
|
||||||
|
giftPanelTotalBudget time.Duration
|
||||||
|
giftPanelRecipientCoreBudget time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -47,20 +52,22 @@ type Config struct {
|
|||||||
|
|
||||||
func New(config Config) *Handler {
|
func New(config Config) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
roomClient: config.RoomClient,
|
roomClient: config.RoomClient,
|
||||||
roomGuardClient: config.RoomGuardClient,
|
roomGuardClient: config.RoomGuardClient,
|
||||||
roomQueryClient: config.RoomQueryClient,
|
roomQueryClient: config.RoomQueryClient,
|
||||||
userProfileClient: config.UserProfileClient,
|
userProfileClient: config.UserProfileClient,
|
||||||
userCountryClient: config.UserCountryClient,
|
userCountryClient: config.UserCountryClient,
|
||||||
userRegionClient: config.UserRegionClient,
|
userRegionClient: config.UserRegionClient,
|
||||||
userSocialClient: config.UserSocialClient,
|
userSocialClient: config.UserSocialClient,
|
||||||
userHostClient: config.UserHostClient,
|
userHostClient: config.UserHostClient,
|
||||||
appConfigReader: config.AppConfigReader,
|
appConfigReader: config.AppConfigReader,
|
||||||
walletClient: config.WalletClient,
|
walletClient: config.WalletClient,
|
||||||
giftPanelCache: newRoomGiftPanelConfigCache(45 * time.Second),
|
giftPanelCache: newRoomGiftPanelConfigCache(45 * time.Second),
|
||||||
growthLevelClient: config.GrowthLevelClient,
|
growthLevelClient: config.GrowthLevelClient,
|
||||||
achievementClient: config.AchievementClient,
|
achievementClient: config.AchievementClient,
|
||||||
rtcTokenConfig: config.RTCTokenConfig,
|
rtcTokenConfig: config.RTCTokenConfig,
|
||||||
|
giftPanelTotalBudget: roomGiftPanelTotalBudget,
|
||||||
|
giftPanelRecipientCoreBudget: roomGiftPanelRecipientCoreBudget,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package roomapi
|
package roomapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -8,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
@ -723,59 +725,7 @@ func (h *Handler) getRoomContributionRank(writer http.ResponseWriter, request *h
|
|||||||
|
|
||||||
// getRoomGiftPanel 返回房间送礼面板初始化数据。
|
// getRoomGiftPanel 返回房间送礼面板初始化数据。
|
||||||
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.roomQueryClient == nil || h.walletClient == nil {
|
h.serveRoomGiftPanel(writer, request)
|
||||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
|
||||||
if !roomid.ValidStringID(roomID) {
|
|
||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
viewerUserID := auth.UserIDFromContext(request.Context())
|
|
||||||
snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
|
|
||||||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
|
||||||
RoomId: roomID,
|
|
||||||
ViewerUserId: viewerUserID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snapshot := snapshotResp.GetRoom()
|
|
||||||
balances, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
|
|
||||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
|
||||||
UserId: viewerUserID,
|
|
||||||
AssetTypes: []string{"COIN"},
|
|
||||||
AppCode: appcode.FromContext(request.Context()),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
panelConfig, err := h.roomGiftPanelBaseConfig(request, snapshot.GetVisibleRegionId())
|
|
||||||
if err != nil {
|
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
giftTypes := panelConfig.GiftTypes
|
|
||||||
gifts := panelConfig.Gifts
|
|
||||||
bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts)
|
|
||||||
if err != nil {
|
|
||||||
httpkit.WriteRPCError(writer, request, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
panelGifts := append([]giftConfigData{}, gifts...)
|
|
||||||
panelGifts = append(panelGifts, bagGifts...)
|
|
||||||
recipientProfiles := h.roomDisplayProfileMap(request, roomGiftRecipientUserIDs(snapshot))
|
|
||||||
httpkit.WriteOK(writer, request, roomGiftPanelData{
|
|
||||||
CoinBalance: coinBalanceFromProto(balances),
|
|
||||||
Recipients: roomGiftRecipients(snapshot, recipientProfiles),
|
|
||||||
Tabs: roomGiftTabs(panelGifts, giftTypes),
|
|
||||||
Gifts: panelGifts,
|
|
||||||
QuantityPresets: []int32{1, 9, 99, 999},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) {
|
func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) {
|
||||||
@ -784,35 +734,73 @@ func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return roomGiftPanelConfig{}, err
|
return roomGiftPanelConfig{}, err
|
||||||
}
|
}
|
||||||
|
return h.roomGiftPanelBaseConfigForVersion(request, app, regionID, catalogVersion, cacheable)
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomGiftPanelBaseConfigForVersion 使用已并行取得的 owner 版本加载稳定配置。
|
||||||
|
// 类型与礼物列表互不依赖,因此 cache miss 时同时查询;区域过滤仍只由 wallet-service 解释。
|
||||||
|
func (h *Handler) roomGiftPanelBaseConfigForVersion(request *http.Request, app string, regionID int64, catalogVersion int64, cacheable bool) (roomGiftPanelConfig, error) {
|
||||||
|
requestID := httpkit.RequestIDFromContext(request.Context())
|
||||||
|
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||||||
if cacheable && h.giftPanelCache != nil {
|
if cacheable && h.giftPanelCache != nil {
|
||||||
|
cacheStartedAt := time.Now()
|
||||||
if cached, ok := h.giftPanelCache.Get(app, regionID, catalogVersion, time.Now()); ok {
|
if cached, ok := h.giftPanelCache.Get(app, regionID, catalogVersion, time.Now()); ok {
|
||||||
|
logRoomGiftPanelStage(request.Context(), requestID, roomID, "config_cache_hit", cacheStartedAt, nil, false)
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{
|
|
||||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
// 任一配置 RPC 失败就取消同批 sibling,并在 Wait 后返回;这样总预算取消时不会遗留继续占连接的查询 goroutine。
|
||||||
AppCode: app,
|
configCtx, cancelConfig := context.WithCancel(request.Context())
|
||||||
ActiveOnly: true,
|
configRequest := request.WithContext(configCtx)
|
||||||
})
|
var waitGroup sync.WaitGroup
|
||||||
if err != nil {
|
var giftTypeResp *walletv1.ListGiftTypeConfigsResponse
|
||||||
return roomGiftPanelConfig{}, err
|
var giftTypeErr error
|
||||||
|
var giftResp *walletv1.ListGiftConfigsResponse
|
||||||
|
var giftErr error
|
||||||
|
waitGroup.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
startedAt := time.Now()
|
||||||
|
giftTypeResp, giftTypeErr = h.walletClient.ListGiftTypeConfigs(configCtx, &walletv1.ListGiftTypeConfigsRequest{
|
||||||
|
RequestId: requestID,
|
||||||
|
AppCode: app,
|
||||||
|
ActiveOnly: true,
|
||||||
|
})
|
||||||
|
logRoomGiftPanelStage(configRequest.Context(), requestID, roomID, "gift_types", startedAt, giftTypeErr, false)
|
||||||
|
if giftTypeErr != nil {
|
||||||
|
cancelConfig()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
startedAt := time.Now()
|
||||||
|
giftResp, giftErr = h.walletClient.ListGiftConfigs(configCtx, &walletv1.ListGiftConfigsRequest{
|
||||||
|
RequestId: requestID,
|
||||||
|
AppCode: app,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 500,
|
||||||
|
ActiveOnly: true,
|
||||||
|
RegionId: regionID,
|
||||||
|
FilterRegion: true,
|
||||||
|
})
|
||||||
|
logRoomGiftPanelStage(configRequest.Context(), requestID, roomID, "gifts", startedAt, giftErr, false)
|
||||||
|
if giftErr != nil {
|
||||||
|
cancelConfig()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
waitGroup.Wait()
|
||||||
|
cancelConfig()
|
||||||
|
if giftTypeErr != nil {
|
||||||
|
return roomGiftPanelConfig{}, giftTypeErr
|
||||||
|
}
|
||||||
|
if giftErr != nil {
|
||||||
|
return roomGiftPanelConfig{}, giftErr
|
||||||
}
|
}
|
||||||
giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
|
giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
|
||||||
for _, item := range giftTypeResp.GetGiftTypes() {
|
for _, item := range giftTypeResp.GetGiftTypes() {
|
||||||
giftTypes = append(giftTypes, giftTypeFromProto(item))
|
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()))
|
gifts := make([]giftConfigData, 0, len(giftResp.GetGifts()))
|
||||||
for _, gift := range giftResp.GetGifts() {
|
for _, gift := range giftResp.GetGifts() {
|
||||||
gifts = append(gifts, giftFromProto(gift))
|
gifts = append(gifts, giftFromProto(gift))
|
||||||
@ -848,15 +836,29 @@ func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gi
|
|||||||
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
|
if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
|
resp, err := h.roomGiftBagResources(request, viewerUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return roomGiftBagGiftsFromResources(resp, gifts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) roomGiftBagResources(request *http.Request, viewerUserID int64) (*walletv1.ListUserResourcesResponse, error) {
|
||||||
|
if viewerUserID <= 0 || h.walletClient == nil {
|
||||||
|
return &walletv1.ListUserResourcesResponse{}, nil
|
||||||
|
}
|
||||||
|
return h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
|
||||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||||
AppCode: appcode.FromContext(request.Context()),
|
AppCode: appcode.FromContext(request.Context()),
|
||||||
UserId: viewerUserID,
|
UserId: viewerUserID,
|
||||||
ResourceType: "gift",
|
ResourceType: "gift",
|
||||||
ActiveOnly: true,
|
ActiveOnly: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
|
func roomGiftBagGiftsFromResources(resp *walletv1.ListUserResourcesResponse, gifts []giftConfigData) []giftConfigData {
|
||||||
|
if resp == nil || len(gifts) == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
giftByResourceID := make(map[int64]giftConfigData, len(gifts))
|
giftByResourceID := make(map[int64]giftConfigData, len(gifts))
|
||||||
for _, gift := range gifts {
|
for _, gift := range gifts {
|
||||||
@ -885,7 +887,7 @@ func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gi
|
|||||||
gift.RemainingQuantity = remaining
|
gift.RemainingQuantity = remaining
|
||||||
bagGifts = append(bagGifts, gift)
|
bagGifts = append(bagGifts, gift)
|
||||||
}
|
}
|
||||||
return bagGifts, nil
|
return bagGifts
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRoomRocket 返回房间火箭物料配置和当前进度。
|
// getRoomRocket 返回房间火箭物料配置和当前进度。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user