From f8ec2118b989915f470caff9d7bf5f538fd64c20 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 29 Jun 2026 10:41:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=88=BF=E9=97=B4=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E5=A5=96=E5=8A=B1=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activity-service/internal/app/services.go | 2 +- .../service/roomturnoverreward/service.go | 81 ++++++++++++- .../roomturnoverreward/service_test.go | 111 +++++++++++++++++- .../http/activityapi/achievement_handler.go | 18 ++- .../transport/http/httproutes/router.go | 5 +- .../internal/transport/http/response_test.go | 61 ++++++++++ .../transport/http/userapi/handler.go | 1 + .../transport/http/userapi/user_handler.go | 24 ++++ .../room-service/internal/room/service/mic.go | 10 +- .../internal/room/service/mic_events.go | 9 +- 10 files changed, 307 insertions(+), 15 deletions(-) diff --git a/services/activity-service/internal/app/services.go b/services/activity-service/internal/app/services.go index b986178e..ddf92f32 100644 --- a/services/activity-service/internal/app/services.go +++ b/services/activity-service/internal/app/services.go @@ -67,7 +67,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository, agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient) weeklyStarSvc := weeklystarservice.New(repository, walletClient) cpWeeklyRankSvc := cpweeklyrankservice.New(repository, activityclient.NewGRPCCPWeeklyRankSource(clients.userConn), walletClient) - roomTurnoverRewardSvc := roomturnoverrewardservice.New(repository, walletClient, roomClient) + roomTurnoverRewardSvc := roomturnoverrewardservice.New(repository, walletClient, roomClient, roomturnoverrewardservice.WithNoticeService(messageSvc)) // Tencent IM 是区域广播、房间礼物播报和红包播报的外部发布器。 // 如果配置要求启动广播 worker,就必须先确认 publisher 可用,避免 worker 启动后只写 outbox 不发消息。 diff --git a/services/activity-service/internal/service/roomturnoverreward/service.go b/services/activity-service/internal/service/roomturnoverreward/service.go index 521d989a..28abbae8 100644 --- a/services/activity-service/internal/service/roomturnoverreward/service.go +++ b/services/activity-service/internal/service/roomturnoverreward/service.go @@ -3,6 +3,7 @@ package roomturnoverreward import ( "context" "sort" + "strconv" "strings" "time" @@ -14,9 +15,15 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/roomturnoverreward" + messageservice "hyapp/services/activity-service/internal/service/message" ) -const rewardReason = "room_turnover_reward" +const ( + rewardReason = "room_turnover_reward" + rewardNoticeProducer = "room-turnover-reward" + rewardNoticeEventType = "room_turnover_reward_granted" + rewardNoticeAggregateType = "room_turnover_reward_settlement" +) // Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。 type Repository interface { @@ -40,21 +47,41 @@ type WalletClient interface { CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error) } +// NoticeService 是房间流水奖励发放后的用户站内信副作用;具体写入仍由 message service owner 完成。 +type NoticeService interface { + CreateSystemNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) +} + // RoomQueryClient 只读取 room-service 的房主快照,不拥有房间状态。 type RoomQueryClient interface { AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error) } +// Option 只注入可替换的外部副作用,避免发奖主流程在单测里依赖真实消息和房间客户端。 +type Option func(*Service) + +// WithNoticeService 连接系统消息公共函数;正金额发奖必须能写站内信,防止出现已发币但用户无通知的静默状态。 +func WithNoticeService(notices NoticeService) Option { + return func(s *Service) { + s.notices = notices + } +} + // Service 承载房间贡献奖励 App 查询、后台配置、事件聚合和每周结算。 type Service struct { repository Repository wallet WalletClient room RoomQueryClient + notices NoticeService now func() time.Time } -func New(repository Repository, wallet WalletClient, room RoomQueryClient) *Service { - return &Service{repository: repository, wallet: wallet, room: room, now: time.Now} +func New(repository Repository, wallet WalletClient, room RoomQueryClient, options ...Option) *Service { + service := &Service{repository: repository, wallet: wallet, room: room, now: time.Now} + for _, option := range options { + option(service) + } + return service } func (s *Service) SetClock(now func() time.Time) { @@ -347,6 +374,11 @@ func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlem nowMS := s.now().UTC().UnixMilli() return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, "", nowMS) } + if s.notices == nil { + err := xerr.New(xerr.Unavailable, "room turnover reward notice service is not configured") + _ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli()) + return domain.Settlement{}, err + } if s.wallet == nil { err := xerr.New(xerr.Unavailable, "wallet client is not configured") _ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli()) @@ -371,9 +403,52 @@ func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlem _ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli()) return domain.Settlement{}, err } + // 系统消息用 settlement_id 做生产者幂等键;钱包成功但消息写入失败时,settlement 仍保持可 retry,下一次会用同一钱包 command_id 和同一消息键补齐。 + if err := s.createRewardGrantedNotice(ctx, settlement, resp.GetTransactionId(), resp.GetGrantedAtMs()); err != nil { + _ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli()) + return domain.Settlement{}, err + } return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, resp.GetTransactionId(), resp.GetGrantedAtMs()) } +func (s *Service) createRewardGrantedNotice(ctx context.Context, settlement domain.Settlement, walletTransactionID string, grantedAtMS int64) error { + if s.notices == nil { + return xerr.New(xerr.Unavailable, "room turnover reward notice service is not configured") + } + if grantedAtMS <= 0 { + grantedAtMS = s.now().UTC().UnixMilli() + } + summary := roomTurnoverRewardNoticeSummary(settlement.RewardCoinAmount) + _, err := s.notices.CreateSystemNotice(ctx, messageservice.NoticeCommand{ + TargetUserID: settlement.OwnerUserID, + Producer: rewardNoticeProducer, + ProducerEventID: settlement.SettlementID, + ProducerEventType: rewardNoticeEventType, + AggregateType: rewardNoticeAggregateType, + AggregateID: settlement.SettlementID, + Title: "Room turnover reward", + Summary: summary, + Body: summary, + SentAtMS: grantedAtMS, + Metadata: map[string]any{ + "settlement_id": settlement.SettlementID, + "room_id": settlement.RoomID, + "period_start_ms": settlement.PeriodStartMS, + "period_end_ms": settlement.PeriodEndMS, + "coin_spent": settlement.CoinSpent, + "reward_coin_amount": settlement.RewardCoinAmount, + "tier_id": settlement.TierID, + "tier_code": settlement.TierCode, + "wallet_transaction_id": walletTransactionID, + }, + }) + return err +} + +func roomTurnoverRewardNoticeSummary(amount int64) string { + return "Congratulations! You earned " + strconv.FormatInt(amount, 10) + " coins from this week's room turnover reward." +} + func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) { if s.room == nil { return 0, xerr.New(xerr.Unavailable, "room query client is not configured") diff --git a/services/activity-service/internal/service/roomturnoverreward/service_test.go b/services/activity-service/internal/service/roomturnoverreward/service_test.go index 65b78fcb..2a4636e7 100644 --- a/services/activity-service/internal/service/roomturnoverreward/service_test.go +++ b/services/activity-service/internal/service/roomturnoverreward/service_test.go @@ -2,6 +2,7 @@ package roomturnoverreward import ( "context" + "errors" "testing" "time" @@ -12,6 +13,7 @@ import ( walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" domain "hyapp/services/activity-service/internal/domain/roomturnoverreward" + messageservice "hyapp/services/activity-service/internal/service/message" ) func TestWeekWindowUsesUTCOneMondayBoundary(t *testing.T) { @@ -160,6 +162,96 @@ func TestHandleRoomEventConsumesGiftValueAsHeatValue(t *testing.T) { } } +func TestGrantSettlementCreatesSystemNoticeBeforeMarkGranted(t *testing.T) { + repo := &retryBoundaryRepository{ + settlement: roomTurnoverRewardGrantableSettlement(), + } + wallet := &retryBoundaryWallet{} + notices := &fakeRewardNoticeService{} + svc := New(repo, wallet, nil, WithNoticeService(notices)) + svc.SetClock(func() time.Time { return mustParseTime(t, "2026-06-09T01:00:00Z") }) + + granted, err := svc.grantSettlement(appcode.WithContext(context.Background(), "lalu"), repo.settlement) + if err != nil { + t.Fatalf("grantSettlement returned error: %v", err) + } + if wallet.calls != 1 || repo.grantCalls != 1 || repo.failCalls != 0 { + t.Fatalf("grant path mismatch: wallet=%d grant=%d fail=%d", wallet.calls, repo.grantCalls, repo.failCalls) + } + if notices.calls != 1 { + t.Fatalf("expected one system notice, got %d", notices.calls) + } + if granted.Status != domain.SettlementStatusGranted || granted.WalletTransactionID != "wallet-tx-1" { + t.Fatalf("settlement should be granted with wallet transaction, got %+v", granted) + } + + cmd := notices.command + if cmd.TargetUserID != repo.settlement.OwnerUserID || cmd.Producer != rewardNoticeProducer || cmd.ProducerEventID != repo.settlement.SettlementID { + t.Fatalf("notice idempotency or target mismatch: %+v", cmd) + } + if cmd.ProducerEventType != rewardNoticeEventType || cmd.AggregateType != rewardNoticeAggregateType || cmd.AggregateID != repo.settlement.SettlementID { + t.Fatalf("notice aggregate mismatch: %+v", cmd) + } + wantSummary := "Congratulations! You earned 123 coins from this week's room turnover reward." + if cmd.Title != "Room turnover reward" || cmd.Summary != wantSummary || cmd.Body != wantSummary { + t.Fatalf("notice content mismatch: title=%q summary=%q body=%q", cmd.Title, cmd.Summary, cmd.Body) + } + if cmd.SentAtMS != 1234 { + t.Fatalf("notice sent_at_ms should use wallet grant time, got %d", cmd.SentAtMS) + } + if cmd.Metadata["settlement_id"] != repo.settlement.SettlementID || + cmd.Metadata["room_id"] != repo.settlement.RoomID || + cmd.Metadata["reward_coin_amount"] != repo.settlement.RewardCoinAmount || + cmd.Metadata["wallet_transaction_id"] != "wallet-tx-1" { + t.Fatalf("notice metadata mismatch: %+v", cmd.Metadata) + } +} + +func TestGrantSettlementFailsWhenSystemNoticeCannotBeCreated(t *testing.T) { + repo := &retryBoundaryRepository{ + settlement: roomTurnoverRewardGrantableSettlement(), + } + wallet := &retryBoundaryWallet{} + notices := &fakeRewardNoticeService{err: errors.New("message unavailable")} + svc := New(repo, wallet, nil, WithNoticeService(notices)) + + if _, err := svc.grantSettlement(appcode.WithContext(context.Background(), "lalu"), repo.settlement); err == nil { + t.Fatal("expected grantSettlement to fail when notice creation fails") + } + if wallet.calls != 1 || notices.calls != 1 { + t.Fatalf("wallet should be credited once and notice attempted once, wallet=%d notices=%d", wallet.calls, notices.calls) + } + if repo.grantCalls != 0 || repo.failCalls != 1 || repo.settlement.Status != domain.SettlementStatusFailed { + t.Fatalf("notice failure must not mark granted: grant=%d fail=%d settlement=%+v", repo.grantCalls, repo.failCalls, repo.settlement) + } +} + +func roomTurnoverRewardGrantableSettlement() domain.Settlement { + return domain.Settlement{ + SettlementID: "settlement-room-turnover-1", + AppCode: "lalu", + RoomID: "room-1", + OwnerUserID: 42, + PeriodStartMS: mustUnixMilli("2026-06-01T01:00:00Z"), + PeriodEndMS: mustUnixMilli("2026-06-08T01:00:00Z"), + CoinSpent: 9000, + TierID: 2, + TierCode: "silver", + ThresholdCoinSpent: 5000, + RewardCoinAmount: 123, + Status: domain.SettlementStatusPending, + WalletCommandID: "room_turnover_reward:lalu:20260601:room-1", + } +} + +func mustUnixMilli(value string) int64 { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed.UnixMilli() +} + func mustParseTime(t *testing.T, value string) time.Time { t.Helper() parsed, err := time.Parse(time.RFC3339, value) @@ -314,9 +406,26 @@ func (r *retryBoundaryRoom) AdminGetRoom(context.Context, *roomv1.AdminGetRoomRe type retryBoundaryWallet struct { calls int + req *walletv1.CreditRoomTurnoverRewardRequest } -func (w *retryBoundaryWallet) CreditRoomTurnoverReward(context.Context, *walletv1.CreditRoomTurnoverRewardRequest, ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error) { +func (w *retryBoundaryWallet) CreditRoomTurnoverReward(_ context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error) { w.calls++ + w.req = req return &walletv1.CreditRoomTurnoverRewardResponse{TransactionId: "wallet-tx-1", GrantedAtMs: 1234}, nil } + +type fakeRewardNoticeService struct { + calls int + command messageservice.NoticeCommand + err error +} + +func (s *fakeRewardNoticeService) CreateSystemNotice(_ context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) { + s.calls++ + s.command = cmd + if s.err != nil { + return messageservice.NoticeResult{}, s.err + } + return messageservice.NoticeResult{Created: true}, nil +} diff --git a/services/gateway-service/internal/transport/http/activityapi/achievement_handler.go b/services/gateway-service/internal/transport/http/activityapi/achievement_handler.go index 8e98b9dd..88248caf 100644 --- a/services/gateway-service/internal/transport/http/activityapi/achievement_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/achievement_handler.go @@ -2,6 +2,7 @@ package activityapi import ( "net/http" + "strconv" "strings" activityv1 "hyapp.local/api/proto/activity/v1" @@ -142,9 +143,24 @@ func (h *Handler) listAchievements(writer http.ResponseWriter, request *http.Req httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } + userID := auth.UserIDFromContext(request.Context()) + rawUserID := strings.TrimSpace(request.URL.Query().Get("user_id")) + if rawUserID != "" { + targetUserID, err := strconv.ParseInt(rawUserID, 10, 64) + if err != nil || targetUserID <= 0 { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + // 成就 H5 支持分享/外部入口按目标用户查看;显式 user_id 是查询目标,不代表操作者身份。 + userID = targetUserID + } + if userID <= 0 { + httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeUnauthorized, "unauthorized") + return + } resp, err := h.achievementClient.ListAchievements(request.Context(), &activityv1.ListAchievementsRequest{ Meta: httpkit.ActivityMeta(request), - UserId: auth.UserIDFromContext(request.Context()), + UserId: userID, CollectionType: strings.TrimSpace(request.URL.Query().Get("collection_type")), CollectionId: strings.TrimSpace(request.URL.Query().Get("collection_id")), Page: page, diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 917042d9..c32dd700 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -117,6 +117,7 @@ type UserHandlers struct { ListCPIntimacyLeaderboard http.HandlerFunc BreakCPRelationship http.HandlerFunc GetMyProfile http.HandlerFunc + GetUserProfile http.HandlerFunc GetMyAppearance http.HandlerFunc GetUserAppearance http.HandlerFunc ListMyResources http.HandlerFunc @@ -411,6 +412,8 @@ func (r routes) registerAppRoutes() { func (r routes) registerUserRoutes() { h := r.config.User r.public("/users/by-display-user-id/{display_user_id}", "", h.ResolveDisplayUserID) + r.public("/users/by-id/{user_id}/profile", http.MethodGet, h.GetUserProfile) + r.public("/users/by-id/{user_id}/appearance", http.MethodGet, h.GetUserAppearance) r.profile("/users/profiles:batch", "", h.BatchUserProfiles) r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles) r.profile("/users/me/overview", "", h.GetMyOverview) @@ -575,7 +578,7 @@ func (r routes) registerLevelRoutes() { func (r routes) registerAchievementRoutes() { h := r.config.Achievement - r.profile("/achievements", http.MethodGet, h.ListAchievements) + r.public("/achievements", http.MethodGet, h.ListAchievements) r.profile("/badges/me", http.MethodGet, h.ListMyBadges) r.profile("/badges/display", http.MethodPost, h.SetBadgeDisplay) } diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index bdcf9ff5..db045bd4 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -6473,6 +6473,67 @@ func TestAchievementAndBadgeRoutesEmbedResourceMaterials(t *testing.T) { } } +func TestAchievementListAllowsExplicitUserIDWithoutToken(t *testing.T) { + achievementClient := &fakeAchievementClient{ + listResp: &activityv1.ListAchievementsResponse{ + Total: 0, + ServerTimeMs: 1778256000000, + }, + } + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetAchievementClient(achievementClient) + router := handler.Routes(auth.NewVerifier("secret")) + + request := httptest.NewRequest(http.MethodGet, "/api/v1/achievements?user_id=77&page=3&page_size=6&collection_type=event", nil) + request.Header.Set("X-App-Code", "lalu") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("achievement status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if achievementClient.lastList == nil || achievementClient.lastList.GetUserId() != 77 || achievementClient.lastList.GetPage() != 3 || achievementClient.lastList.GetPageSize() != 6 || achievementClient.lastList.GetCollectionType() != "event" { + t.Fatalf("achievement user_id request mismatch: %+v", achievementClient.lastList) + } + + achievementClient.lastList = nil + request = httptest.NewRequest(http.MethodGet, "/api/v1/achievements?page=1&page_size=6", nil) + request.Header.Set("X-App-Code", "lalu") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("anonymous achievement without user_id must stay unauthorized: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if achievementClient.lastList != nil { + t.Fatalf("anonymous achievement without user_id must not reach upstream: %+v", achievementClient.lastList) + } +} + +func TestPublicUserProfileRouteUsesPathUserID(t *testing.T) { + profileClient := &fakeUserProfileClient{ + usersByID: map[int64]*userv1.User{ + 77: { + UserId: 77, + DisplayUserId: "100077", + Username: "target-user", + Avatar: "https://cdn.example/target.png", + }, + }, + } + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) + router := handler.Routes(auth.NewVerifier("secret")) + + request := httptest.NewRequest(http.MethodGet, "/api/v1/users/by-id/77/profile", nil) + request.Header.Set("X-App-Code", "lalu") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("public profile status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 77 { + t.Fatalf("public profile must use path user_id: %+v", profileClient.lastGet) + } +} + func TestCPWeeklyRankActivityUsesWeeklyScoreAndActivityClient(t *testing.T) { cpWeeklyClient := &fakeCPWeeklyRankClient{ statusResp: &activityv1.GetCPWeeklyRankStatusResponse{ diff --git a/services/gateway-service/internal/transport/http/userapi/handler.go b/services/gateway-service/internal/transport/http/userapi/handler.go index 507bb31e..0dafa5a8 100644 --- a/services/gateway-service/internal/transport/http/userapi/handler.go +++ b/services/gateway-service/internal/transport/http/userapi/handler.go @@ -98,6 +98,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers { ListCPIntimacyLeaderboard: h.listCPIntimacyLeaderboard, BreakCPRelationship: h.breakCPRelationship, GetMyProfile: h.getMyProfile, + GetUserProfile: h.getUserProfile, GetMyAppearance: h.getMyAppearance, GetUserAppearance: h.getUserAppearance, UserSocialAction: h.userSocialAction, diff --git a/services/gateway-service/internal/transport/http/userapi/user_handler.go b/services/gateway-service/internal/transport/http/userapi/user_handler.go index d529fe2e..bf43387f 100644 --- a/services/gateway-service/internal/transport/http/userapi/user_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/user_handler.go @@ -305,6 +305,30 @@ func (h *Handler) getMyProfile(writer http.ResponseWriter, request *http.Request httpkit.WriteOK(writer, request, profileData(resp.GetUser(), 0)) } +func (h *Handler) getUserProfile(writer http.ResponseWriter, request *http.Request) { + if h.userProfileClient == nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + userID, ok := httpkit.PositiveInt64PathValue(request, "user_id") + if !ok { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + + // 成就页的 user_id 模式是只读展示目标用户资料,不能回退到 token 用户,否则分享页会出现资料和成就归属不一致。 + resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ + Meta: httpkit.UserMeta(request, ""), + UserId: userID, + }) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + + httpkit.WriteOK(writer, request, profileData(resp.GetUser(), 0)) +} + // getMyInviteOverview 返回当前登录用户自己的邀请码和邀请计数。 func (h *Handler) getMyInviteOverview(writer http.ResponseWriter, request *http.Request) { if h.userProfileClient == nil { diff --git a/services/room-service/internal/room/service/mic.go b/services/room-service/internal/room/service/mic.go index e820ebd6..4d2c8357 100644 --- a/services/room-service/internal/room/service/mic.go +++ b/services/room-service/internal/room/service/mic.go @@ -75,7 +75,7 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1. targetGiftValue := roomUserGiftValue(current, cmd.ActorUserID()) // RoomMicChanged 是房间外消费者和腾讯云 IM 共同使用的麦位事件源;展示快照固化在事件体,direct IM 和 outbox 补偿共用。 - micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{ + micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.ActorUserID(), FromSeat: 0, @@ -190,7 +190,7 @@ func (s *Service) micDown(ctx context.Context, cmd command.MicDown) (mutationRes current.Version++ seatStatus := current.MicSeats[index].SeatStatus() - micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{ + micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, FromSeat: seat.SeatNo, @@ -293,7 +293,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe seatStatus := current.MicSeats[toIndex].SeatStatus() targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID) - micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{ + micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, FromSeat: fromSeat.SeatNo, @@ -397,7 +397,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM seatStatus := current.MicSeats[index].SeatStatus() targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID) - micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{ + micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, FromSeat: seat.SeatNo, @@ -565,7 +565,7 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) if cmd.Muted { action = "mic_mute" } - micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{ + micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, FromSeat: seat.SeatNo, diff --git a/services/room-service/internal/room/service/mic_events.go b/services/room-service/internal/room/service/mic_events.go index 23290d9f..f695da58 100644 --- a/services/room-service/internal/room/service/mic_events.go +++ b/services/room-service/internal/room/service/mic_events.go @@ -13,12 +13,15 @@ import ( // roomMicChangedEvent 建立 RoomMicChanged 事实;展示快照只来自命令入口, // room-service 不反查 user-service,保证 Room Cell 主链路仍只维护房态。 -func roomMicChangedEvent(event roomeventsv1.RoomMicChanged, profile command.UserDisplayProfile) *roomeventsv1.RoomMicChanged { +func roomMicChangedEvent(event *roomeventsv1.RoomMicChanged, profile command.UserDisplayProfile) *roomeventsv1.RoomMicChanged { + if event == nil { + event = &roomeventsv1.RoomMicChanged{} + } event.TargetNickname = strings.TrimSpace(profile.Nickname) event.TargetAvatar = strings.TrimSpace(profile.Avatar) event.TargetDisplayUserId = strings.TrimSpace(profile.DisplayUserID) event.TargetPrettyDisplayUserId = strings.TrimSpace(profile.PrettyDisplayUserID) - return &event + return event } // roomMicChangedIMAttributes 是 direct IM 和 outbox 补偿的唯一麦位 attributes 映射。 @@ -60,7 +63,7 @@ func roomMicChangedIMAttributes(event *roomeventsv1.RoomMicChanged) map[string]s // RoomMicChanged/down fact, so downstream duration consumers never need to infer // mic closure from leave/kick/close side effects. func buildMicDownEventForSeat(roomID string, roomVersion int64, now time.Time, actorUserID int64, targetUserID int64, seatNo int32, micSessionID string, reason string) (outbox.Record, error) { - return outbox.Build(roomID, "RoomMicChanged", roomVersion, now, roomMicChangedEvent(roomeventsv1.RoomMicChanged{ + return outbox.Build(roomID, "RoomMicChanged", roomVersion, now, roomMicChangedEvent(&roomeventsv1.RoomMicChanged{ ActorUserId: actorUserID, TargetUserId: targetUserID, FromSeat: seatNo,