字段补充

This commit is contained in:
zhx 2026-06-01 19:42:53 +08:00
parent 05881934ad
commit 6d35f421c0
4 changed files with 141 additions and 9 deletions

View File

@ -205,6 +205,9 @@ GET /api/v1/app/voice-room/red-packet/active?roomId=9001
"roomId": "9001",
"regionId": 310,
"senderUserId": 10001,
"senderAccount": "10001",
"senderNickname": "Tom",
"senderAvatar": "https://example.com/avatar1.png",
"packetMode": "DELAYED",
"totalAmount": 15000,
"totalCount": 5,
@ -585,7 +588,7 @@ class VoiceRoomRedPacket {
解析建议:
- 对 `senderUserId/userId` 这类 ID兼容 number 和 string统一转 string。
- HTTP 发送成功、详情、领取成功里的 `packet` 使用 `senderAccount/senderNickname/senderAvatar`IM `CREATED/OPEN_DUE` 使用 `actualAccount/userNickname/userAvatar`,落本地模型时归一到发送者字段。
- HTTP 发送成功、active、详情、领取成功里的 `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

@ -154,6 +154,82 @@ func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
}
}
func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
nowMS := int64(1_779_999_700_000)
openAtMS := int64(1_780_000_000_000)
repository := newFakeRepository()
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
service.SetClock(func() time.Time { return time.UnixMilli(nowMS) })
service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{
UserID: 10001,
Account: "10001",
Nickname: "Tom",
Avatar: "https://example.com/avatar.png",
RegionCode: "AR",
}})
event := broadcastdomain.RedPacketWalletEvent{
AppCode: "lalu",
EventID: "wallet-red-packet-created-1",
EventType: "WalletRedPacketCreated",
PacketID: "VRRP2026052712340001",
PacketType: "delayed",
RoomID: "9001",
RegionID: 310,
SenderUserID: 10001,
TotalAmount: 15000,
TotalCount: 5,
RemainingAmount: 15000,
RemainingCount: 5,
Status: "waiting_open",
OpenAtMS: openAtMS,
ExpiresAtMS: 1_780_086_400_000,
CreatedAtMS: nowMS,
}
if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil {
t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err)
}
roomCreated := repository.records[event.EventID]
regionCreated := repository.records[event.EventID+":region_created"]
openDue := repository.records[event.EventID+":open_due"]
if roomCreated.GroupID != "9001" || roomCreated.Scope != broadcastdomain.ScopeRoom || roomCreated.NextRetryAtMS != nowMS {
t.Fatalf("room created outbox mismatch: %+v", roomCreated)
}
if regionCreated.GroupID != "hy_lalu_bc_r_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS {
t.Fatalf("region created outbox mismatch: %+v", regionCreated)
}
if openDue.GroupID != "9001" || openDue.Scope != broadcastdomain.ScopeRoom || openDue.NextRetryAtMS != openAtMS {
t.Fatalf("open due outbox mismatch: %+v", openDue)
}
regionData := redPacketPayloadData(t, regionCreated.PayloadJSON)
if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" {
t.Fatalf("region created payload mismatch: %+v", regionData)
}
openDueData := redPacketPayloadData(t, openDue.PayloadJSON)
if openDueData["event"] != "OPEN_DUE" || openDueData["scope"] != "ROOM" || openDueData["claimStartTime"] != float64(openAtMS) {
t.Fatalf("open due payload mismatch: %+v", openDueData)
}
result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
if err != nil {
t.Fatalf("ProcessPendingBroadcasts before open failed: %v", err)
}
if result.SuccessCount != 2 || len(publisher.messages) != 2 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
t.Fatalf("before open only room/region CREATED should be sent: result=%+v messages=%+v", result, publisher.messages)
}
service.SetClock(func() time.Time { return time.UnixMilli(openAtMS) })
result, err = service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
if err != nil {
t.Fatalf("ProcessPendingBroadcasts after open failed: %v", err)
}
if result.SuccessCount != 1 || !containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
t.Fatalf("after open should send OPEN_DUE: result=%+v messages=%+v", result, publisher.messages)
}
}
func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil)
@ -211,6 +287,15 @@ func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any {
return payload.Data
}
func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool {
for _, message := range messages {
if message.EventID == eventID {
return true
}
}
return false
}
type fakeRepository struct {
records map[string]broadcastdomain.OutboxRecord
}
@ -227,7 +312,7 @@ func (r *fakeRepository) SaveBroadcastOutbox(_ context.Context, record broadcast
return true, record, nil
}
func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, _ int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) {
func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) {
records := make([]broadcastdomain.OutboxRecord, 0, limit)
for eventID, record := range r.records {
if len(records) >= limit {
@ -236,6 +321,9 @@ func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID
if record.Status != broadcastdomain.StatusPending && record.Status != broadcastdomain.StatusRetryable {
continue
}
if record.NextRetryAtMS > nowMS {
continue
}
record.Status = broadcastdomain.StatusDelivering
record.LockedBy = workerID
record.LockedUntilMS = lockUntilMS

View File

@ -4295,11 +4295,12 @@ 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"},
{PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active"},
{PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"},
},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
profileClient := &fakeUserProfileClient{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/active?roomId=room-1", nil)
@ -4315,6 +4316,9 @@ func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
if walletClient.lastListRedPackets == nil || walletClient.lastListRedPackets.GetRoomId() != "room-1" || walletClient.lastListRedPackets.GetViewerUserId() != 42 {
t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets)
}
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
t.Fatalf("active red-packet should batch only visible sender profiles: %+v", profileClient.lastBatch)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode active red-packet response failed: %v", err)
@ -4325,7 +4329,7 @@ func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
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" {
if !ok || first["packetNo"] != "packet-active" || first["status"] != "ACTIVE" || first["senderNickname"] != "user-10002" || first["senderAvatar"] != "https://cdn.example/avatar.png" {
t.Fatalf("active packet item mismatch: %+v", first)
}
}

View File

@ -272,7 +272,7 @@ func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *ht
}
func (h *Handler) listActiveRedPackets(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
}
@ -295,12 +295,21 @@ func (h *Handler) listActiveRedPackets(writer http.ResponseWriter, request *http
writeRedPacketRPCError(writer, request, err)
return
}
packets := make([]map[string]any, 0, len(resp.GetPackets()))
activePackets := make([]*walletv1.RedPacket, 0, len(resp.GetPackets()))
for _, packet := range resp.GetPackets() {
if packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open" {
packets = append(packets, voiceRoomPacketFromProto(packet))
activePackets = append(activePackets, packet)
}
}
profiles, err := h.redPacketSenderProfiles(request, activePackets)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
packets := make([]map[string]any, 0, len(activePackets))
for _, packet := range activePackets {
packets = append(packets, voiceRoomPacketFromProtoWithSender(packet, profiles[packet.GetSenderUserId()]))
}
httpkit.WriteOK(writer, request, map[string]any{
"packets": packets,
"serverTime": resp.GetServerTimeMs(),
@ -494,6 +503,34 @@ func (h *Handler) redPacketClaimProfiles(request *http.Request, claims []*wallet
return resp.GetUsers(), nil
}
func (h *Handler) redPacketSenderProfiles(request *http.Request, packets []*walletv1.RedPacket) (map[int64]*userv1.User, error) {
userIDs := make([]int64, 0, len(packets))
seen := make(map[int64]struct{}, len(packets))
for _, packet := range packets {
userID := packet.GetSenderUserId()
if userID <= 0 {
continue
}
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
userIDs = append(userIDs, userID)
}
if len(userIDs) == 0 {
return map[int64]*userv1.User{}, nil
}
// active 列表可能有多个红包,发送者展示资料必须批量拉取,避免房间侧边栏刷新造成 N+1 RPC。
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
return nil, err
}
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 {