diff --git a/docs/flutter对接/语音房红包Flutter对接.md b/docs/flutter对接/语音房红包Flutter对接.md index baf133d3..8508376c 100644 --- a/docs/flutter对接/语音房红包Flutter对接.md +++ b/docs/flutter对接/语音房红包Flutter对接.md @@ -152,6 +152,9 @@ Content-Type: application/json "roomId": "9001", "regionId": 310, "senderUserId": 10001, + "senderAccount": "10001", + "senderNickname": "Tom", + "senderAvatar": "https://example.com/avatar1.png", "packetMode": "DELAYED", "totalAmount": 15000, "totalCount": 5, @@ -262,6 +265,12 @@ Content-Type: application/json }, "packet": { "packetNo": "VRRP2026052712340001", + "roomId": "9001", + "regionId": 310, + "senderUserId": 10001, + "senderAccount": "10001", + "senderNickname": "Tom", + "senderAvatar": "https://example.com/avatar1.png", "remainingAmount": 12000, "remainingCount": 4, "status": "ACTIVE", @@ -276,6 +285,7 @@ Content-Type: application/json 处理规则: +- 领取成功响应里的 `packet` 结构和详情接口一致,示例只保留弹窗刷新需要的关键字段。 - Flutter 只提交 `packetNo`。当前服务端按 `packetNo + 当前用户` 保证同一用户重复领取返回同一结果。 - 如果返回 `voice_room_red_packet_room_presence_required`,提示用户需要在房间内操作,并刷新房间在线态。 - 如果返回 `voice_room_red_packet_not_started`,展示倒计时,不要重试抢。 @@ -301,6 +311,9 @@ GET /api/v1/app/voice-room/red-packet/detail?packetNo=VRRP2026052712340001 "roomId": "9001", "regionId": 310, "senderUserId": 10001, + "senderAccount": "10001", + "senderNickname": "Tom", + "senderAvatar": "https://example.com/avatar1.png", "packetMode": "DELAYED", "totalAmount": 15000, "totalCount": 5, @@ -322,7 +335,7 @@ GET /api/v1/app/voice-room/red-packet/detail?packetNo=VRRP2026052712340001 } ``` -详情接口里的 `claims` 不作为二级领取记录页的资料来源。需要头像昵称账号时使用 `claim-records`,因为该接口会按当前页批量补用户资料。 +详情和领取成功响应里的 `packet` 会补发送者 `senderAccount/senderNickname/senderAvatar`。详情接口里的 `claims` 不作为二级领取记录页的资料来源。需要领取用户头像昵称账号时使用 `claim-records`,因为该接口会按当前页批量补用户资料。 ## 9. 领取记录 @@ -555,9 +568,9 @@ class VoiceRoomRedPacket { final String packetMode; final String status; final String senderUserId; - final String account; - final String nickname; - final String avatar; + final String senderAccount; + final String senderNickname; + final String senderAvatar; final int totalAmount; final int totalCount; final int remainingAmount; @@ -572,6 +585,7 @@ class VoiceRoomRedPacket { 解析建议: - 对 `senderUserId/userId` 这类 ID,兼容 number 和 string,统一转 string。 +- HTTP 发送成功、详情、领取成功里的 `packet` 使用 `senderAccount/senderNickname/senderAvatar`;IM `CREATED/OPEN_DUE` 使用 `actualAccount/userNickname/userAvatar`,落本地模型时归一到发送者字段。 - 对金额和时间,兼容 number 和 string,统一转 int。 - `CREATED` IM 里的 `remainAmount/remainCount` 对应 HTTP 里的 `remainingAmount/remainingCount`。 - 如果 IM 和 HTTP 字段冲突,以 HTTP 最新查询结果为准。 diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index c5ba937e..8b4a7dd2 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -313,6 +313,7 @@ type fakeUserProfileClient struct { countryErr error regionID int64 regionByUserID map[int64]int64 + usersByID map[int64]*userv1.User } type fakeTencentIMAccountImporter struct { @@ -457,8 +458,12 @@ type fakeWalletClient struct { lastGrantResource *walletv1.GrantResourceRequest grantResourceResp *walletv1.ResourceGrantResponse grantResourceErr error + lastListRedPackets *walletv1.ListRedPacketsRequest + listRedPacketsResp *walletv1.ListRedPacketsResponse lastGetRedPacket *walletv1.GetRedPacketRequest getRedPacketResp *walletv1.GetRedPacketResponse + lastClaimRedPacket *walletv1.ClaimRedPacketRequest + claimRedPacketResp *walletv1.ClaimRedPacketResponse } type fakeMessageInboxClient struct { @@ -573,6 +578,11 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe if f.getErr != nil { return nil, f.getErr } + if f.usersByID != nil { + if user := f.usersByID[req.GetUserId()]; user != nil { + return &userv1.GetUserResponse{User: user}, nil + } + } regionID := f.regionID if f.regionByUserID != nil { if mapped := f.regionByUserID[req.GetUserId()]; mapped != 0 { @@ -1380,11 +1390,22 @@ func (f *fakeWalletClient) CreateRedPacket(context.Context, *walletv1.CreateRedP return &walletv1.CreateRedPacketResponse{}, nil } -func (f *fakeWalletClient) ClaimRedPacket(context.Context, *walletv1.ClaimRedPacketRequest) (*walletv1.ClaimRedPacketResponse, error) { +func (f *fakeWalletClient) ClaimRedPacket(_ context.Context, req *walletv1.ClaimRedPacketRequest) (*walletv1.ClaimRedPacketResponse, error) { + f.lastClaimRedPacket = req + if f.err != nil { + return nil, f.err + } + if f.claimRedPacketResp != nil { + return f.claimRedPacketResp, nil + } return &walletv1.ClaimRedPacketResponse{}, nil } -func (f *fakeWalletClient) ListRedPackets(context.Context, *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) { +func (f *fakeWalletClient) ListRedPackets(_ context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) { + f.lastListRedPackets = req + if f.listRedPacketsResp != nil { + return f.listRedPacketsResp, nil + } return &walletv1.ListRedPacketsResponse{}, nil } @@ -4146,6 +4167,169 @@ func TestVoiceRoomRedPacketClaimRecordsIncludesUserProfiles(t *testing.T) { } } +func TestVoiceRoomRedPacketDetailIncludesSenderProfile(t *testing.T) { + walletClient := &fakeWalletClient{getRedPacketResp: &walletv1.GetRedPacketResponse{ + ServerTimeMs: 3000, + Packet: &walletv1.RedPacket{ + PacketId: "packet-1", + RoomId: "room-1", + RegionId: 1001, + SenderUserId: 10002, + PacketType: "normal", + TotalAmount: 1000, + PacketCount: 5, + RemainingCount: 3, + Status: "active", + }, + }} + profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ + 10002: &userv1.User{UserId: 10002, DisplayUserId: "880002", Username: "sender-name", Avatar: "https://cdn.example/sender.png", RegionId: 1001}, + }} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) + handler.SetWalletClient(walletClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/detail?packetNo=packet-1", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-red-packet-detail") + 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 profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 10002 { + t.Fatalf("detail must fetch sender profile: %+v", profileClient.lastGet) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode red-packet detail failed: %v", err) + } + data, ok := response.Data.(map[string]any) + packet, packetOK := data["packet"].(map[string]any) + if response.Code != httpkit.CodeOK || !ok || !packetOK { + t.Fatalf("detail response mismatch: %+v", response) + } + if packet["senderAccount"] != "880002" || packet["senderNickname"] != "sender-name" || packet["senderAvatar"] != "https://cdn.example/sender.png" { + t.Fatalf("detail packet should include sender display fields: %+v", packet) + } +} + +func TestVoiceRoomRedPacketClaimIncludesSenderProfile(t *testing.T) { + packetBeforeClaim := &walletv1.RedPacket{ + PacketId: "packet-1", + RoomId: "room-1", + RegionId: 1001, + SenderUserId: 10002, + PacketType: "normal", + TotalAmount: 1000, + PacketCount: 5, + RemainingCount: 3, + Status: "active", + } + walletClient := &fakeWalletClient{ + getRedPacketResp: &walletv1.GetRedPacketResponse{Packet: packetBeforeClaim}, + claimRedPacketResp: &walletv1.ClaimRedPacketResponse{ + ServerTimeMs: 4000, + BalanceAfter: 700, + Packet: &walletv1.RedPacket{ + PacketId: "packet-1", + RoomId: "room-1", + RegionId: 1001, + SenderUserId: 10002, + PacketType: "normal", + TotalAmount: 1000, + PacketCount: 5, + RemainingCount: 2, + Status: "active", + }, + Claim: &walletv1.RedPacketClaim{ClaimId: "claim-1", PacketId: "packet-1", UserId: 42, Amount: 200, Status: "claimed", CreatedAtMs: 3500}, + }, + } + profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ + 42: &userv1.User{UserId: 42, DisplayUserId: "880042", Username: "claimer-name", Avatar: "https://cdn.example/claimer.png", RegionId: 1001}, + 10002: &userv1.User{UserId: 10002, DisplayUserId: "880002", Username: "sender-name", Avatar: "https://cdn.example/sender.png", RegionId: 1001}, + }} + guardClient := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 9}} + handler := NewHandlerWithConfig(&fakeRoomClient{}, guardClient, nil, nil, TencentIMConfig{}, profileClient) + handler.SetWalletClient(walletClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/api/v1/app/voice-room/red-packet/claim", bytes.NewReader([]byte(`{"packetNo":"packet-1"}`))) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-red-packet-claim") + 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 guardClient.lastPresence == nil || guardClient.lastPresence.GetRoomId() != "room-1" || guardClient.lastPresence.GetUserId() != 42 { + t.Fatalf("claim must verify room presence before wallet mutation: %+v", guardClient.lastPresence) + } + if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 10002 { + t.Fatalf("claim must fetch claimer then sender profiles before response: %+v", profileClient.getRequests) + } + if walletClient.lastClaimRedPacket == nil || walletClient.lastClaimRedPacket.GetPacketId() != "packet-1" || walletClient.lastClaimRedPacket.GetUserId() != 42 { + t.Fatalf("claim request mismatch: %+v", walletClient.lastClaimRedPacket) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode red-packet claim failed: %v", err) + } + data, ok := response.Data.(map[string]any) + packet, packetOK := data["packet"].(map[string]any) + claim, claimOK := data["claim"].(map[string]any) + if response.Code != httpkit.CodeOK || !ok || !packetOK || !claimOK { + t.Fatalf("claim response mismatch: %+v", response) + } + if packet["senderAccount"] != "880002" || packet["senderNickname"] != "sender-name" || packet["senderAvatar"] != "https://cdn.example/sender.png" { + t.Fatalf("claim packet should include sender display fields: %+v", packet) + } + if claim["account"] != "880042" || claim["nickname"] != "claimer-name" || claim["avatar"] != "https://cdn.example/claimer.png" { + t.Fatalf("claim should keep claimant display fields: %+v", claim) + } +} + +func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) { + walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{ + ServerTimeMs: 4000, + Packets: []*walletv1.RedPacket{ + {PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, PacketType: "normal", Status: "active"}, + {PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, PacketType: "normal", Status: "finished"}, + }, + }} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001}) + handler.SetWalletClient(walletClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/active?roomId=room-1", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-red-packet-active") + 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 walletClient.lastListRedPackets == nil || walletClient.lastListRedPackets.GetRoomId() != "room-1" || walletClient.lastListRedPackets.GetViewerUserId() != 42 { + t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode active red-packet response failed: %v", err) + } + data, ok := response.Data.(map[string]any) + packets, packetsOK := data["packets"].([]any) + if response.Code != httpkit.CodeOK || !ok || !packetsOK || len(packets) != 1 { + t.Fatalf("active red-packet response mismatch: %+v", response) + } + first, ok := packets[0].(map[string]any) + if !ok || first["packetNo"] != "packet-active" || first["status"] != "ACTIVE" { + t.Fatalf("active packet item mismatch: %+v", first) + } +} + func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) { walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{ Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 120, FrozenAmount: 3, Version: 2}}, diff --git a/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go b/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go index 1a5aed08..8f38ab75 100644 --- a/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go @@ -265,7 +265,7 @@ func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *ht return } httpkit.WriteOK(writer, request, map[string]any{ - "packet": voiceRoomPacketFromProto(resp.GetPacket()), + "packet": voiceRoomPacketFromProtoWithSender(resp.GetPacket(), user), "balanceAfter": resp.GetBalanceAfter(), "serverTime": resp.GetServerTimeMs(), }) @@ -346,6 +346,11 @@ func (h *Handler) claimVoiceRoomRedPacket(writer http.ResponseWriter, request *h httpkit.WriteError(writer, request, http.StatusForbidden, "voice_room_region_not_match", "permission denied") return } + sender, err := h.redPacketSenderProfile(request, packet, user) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } commandID := fmt.Sprintf("red_packet_claim:%s:%d", packetNo, auth.UserIDFromContext(request.Context())) resp, err := h.walletClient.ClaimRedPacket(request.Context(), &walletv1.ClaimRedPacketRequest{ AppCode: appcode.FromContext(request.Context()), @@ -359,14 +364,14 @@ func (h *Handler) claimVoiceRoomRedPacket(writer http.ResponseWriter, request *h } httpkit.WriteOK(writer, request, map[string]any{ "claim": voiceRoomClaimFromProtoWithUser(resp.GetClaim(), user), - "packet": voiceRoomPacketFromProto(resp.GetPacket()), + "packet": voiceRoomPacketFromProtoWithSender(resp.GetPacket(), sender), "balanceAfter": resp.GetBalanceAfter(), "serverTime": resp.GetServerTimeMs(), }) } func (h *Handler) getVoiceRoomRedPacket(writer http.ResponseWriter, request *http.Request) { - if h.walletClient == nil { + if h.walletClient == nil || h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } @@ -388,8 +393,13 @@ func (h *Handler) getVoiceRoomRedPacket(writer http.ResponseWriter, request *htt writeRedPacketRPCError(writer, request, err) return } + sender, err := h.redPacketSenderProfile(request, resp.GetPacket(), nil) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } httpkit.WriteOK(writer, request, map[string]any{ - "packet": voiceRoomPacketFromProto(resp.GetPacket()), + "packet": voiceRoomPacketFromProtoWithSender(resp.GetPacket(), sender), "serverTime": resp.GetServerTimeMs(), }) } @@ -484,6 +494,25 @@ func (h *Handler) redPacketClaimProfiles(request *http.Request, claims []*wallet return resp.GetUsers(), nil } +func (h *Handler) redPacketSenderProfile(request *http.Request, packet *walletv1.RedPacket, currentUser *userv1.User) (*userv1.User, error) { + senderUserID := packet.GetSenderUserId() + if senderUserID <= 0 { + return nil, nil + } + // 发送者资料属于 user-service;claim 流程复用当前用户资料,避免同一用户发抢自己的红包时重复 RPC。 + if currentUser != nil && currentUser.GetUserId() == senderUserID { + return currentUser, nil + } + resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ + Meta: httpkit.UserMeta(request, ""), + UserId: senderUserID, + }) + if err != nil { + return nil, err + } + return resp.GetUser(), nil +} + func (h *Handler) listSentRedPackets(writer http.ResponseWriter, request *http.Request) { if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") @@ -938,6 +967,29 @@ func voiceRoomPacketFromProto(packet *walletv1.RedPacket) map[string]any { } } +func voiceRoomPacketFromProtoWithSender(packet *walletv1.RedPacket, sender *userv1.User) map[string]any { + data := voiceRoomPacketFromProto(packet) + if packet == nil { + return data + } + account := "" + nickname := "" + avatar := "" + if sender != nil { + account = sender.GetDisplayUserId() + nickname = sender.GetUsername() + avatar = sender.GetAvatar() + } + if account == "" && packet.GetSenderUserId() > 0 { + account = strconv.FormatInt(packet.GetSenderUserId(), 10) + } + // wallet 只拥有红包和账务事实;HTTP 展示字段在 gateway 出口由 user-service 资料补齐。 + data["senderAccount"] = account + data["senderNickname"] = nickname + data["senderAvatar"] = avatar + return data +} + func voiceRoomClaimFromProto(claim *walletv1.RedPacketClaim) map[string]any { return voiceRoomClaimFromProtoWithUser(claim, nil) }