红包字段返回

This commit is contained in:
zhx 2026-06-01 17:25:47 +08:00
parent 10bcfadbc4
commit db5d6de329
3 changed files with 260 additions and 10 deletions

View File

@ -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 最新查询结果为准。

View File

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

View File

@ -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-serviceclaim 流程复用当前用户资料,避免同一用户发抢自己的红包时重复 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)
}