增加房间流水奖励修复
This commit is contained in:
parent
151cf495e4
commit
f8ec2118b9
@ -67,7 +67,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
|
|||||||
agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient)
|
agencyOpeningSvc := agencyopeningservice.New(repository, walletClient, activityclient.NewGRPCAgencyOpeningSource(clients.userConn), roomClient)
|
||||||
weeklyStarSvc := weeklystarservice.New(repository, walletClient)
|
weeklyStarSvc := weeklystarservice.New(repository, walletClient)
|
||||||
cpWeeklyRankSvc := cpweeklyrankservice.New(repository, activityclient.NewGRPCCPWeeklyRankSource(clients.userConn), 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 是区域广播、房间礼物播报和红包播报的外部发布器。
|
// Tencent IM 是区域广播、房间礼物播报和红包播报的外部发布器。
|
||||||
// 如果配置要求启动广播 worker,就必须先确认 publisher 可用,避免 worker 启动后只写 outbox 不发消息。
|
// 如果配置要求启动广播 worker,就必须先确认 publisher 可用,避免 worker 启动后只写 outbox 不发消息。
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package roomturnoverreward
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -14,9 +15,15 @@ import (
|
|||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
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 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。
|
// Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
@ -40,21 +47,41 @@ type WalletClient interface {
|
|||||||
CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error)
|
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 的房主快照,不拥有房间状态。
|
// RoomQueryClient 只读取 room-service 的房主快照,不拥有房间状态。
|
||||||
type RoomQueryClient interface {
|
type RoomQueryClient interface {
|
||||||
AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
|
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 查询、后台配置、事件聚合和每周结算。
|
// Service 承载房间贡献奖励 App 查询、后台配置、事件聚合和每周结算。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
wallet WalletClient
|
wallet WalletClient
|
||||||
room RoomQueryClient
|
room RoomQueryClient
|
||||||
|
notices NoticeService
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository, wallet WalletClient, room RoomQueryClient) *Service {
|
func New(repository Repository, wallet WalletClient, room RoomQueryClient, options ...Option) *Service {
|
||||||
return &Service{repository: repository, wallet: wallet, room: room, now: time.Now}
|
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) {
|
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()
|
nowMS := s.now().UTC().UnixMilli()
|
||||||
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, "", nowMS)
|
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 {
|
if s.wallet == nil {
|
||||||
err := xerr.New(xerr.Unavailable, "wallet client is not configured")
|
err := xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||||||
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
_ = 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())
|
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||||||
return domain.Settlement{}, err
|
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())
|
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) {
|
func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) {
|
||||||
if s.room == nil {
|
if s.room == nil {
|
||||||
return 0, xerr.New(xerr.Unavailable, "room query client is not configured")
|
return 0, xerr.New(xerr.Unavailable, "room query client is not configured")
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package roomturnoverreward
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ import (
|
|||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
||||||
|
messageservice "hyapp/services/activity-service/internal/service/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWeekWindowUsesUTCOneMondayBoundary(t *testing.T) {
|
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 {
|
func mustParseTime(t *testing.T, value string) time.Time {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
@ -314,9 +406,26 @@ func (r *retryBoundaryRoom) AdminGetRoom(context.Context, *roomv1.AdminGetRoomRe
|
|||||||
|
|
||||||
type retryBoundaryWallet struct {
|
type retryBoundaryWallet struct {
|
||||||
calls int
|
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.calls++
|
||||||
|
w.req = req
|
||||||
return &walletv1.CreditRoomTurnoverRewardResponse{TransactionId: "wallet-tx-1", GrantedAtMs: 1234}, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package activityapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
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")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
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{
|
resp, err := h.achievementClient.ListAchievements(request.Context(), &activityv1.ListAchievementsRequest{
|
||||||
Meta: httpkit.ActivityMeta(request),
|
Meta: httpkit.ActivityMeta(request),
|
||||||
UserId: auth.UserIDFromContext(request.Context()),
|
UserId: userID,
|
||||||
CollectionType: strings.TrimSpace(request.URL.Query().Get("collection_type")),
|
CollectionType: strings.TrimSpace(request.URL.Query().Get("collection_type")),
|
||||||
CollectionId: strings.TrimSpace(request.URL.Query().Get("collection_id")),
|
CollectionId: strings.TrimSpace(request.URL.Query().Get("collection_id")),
|
||||||
Page: page,
|
Page: page,
|
||||||
|
|||||||
@ -117,6 +117,7 @@ type UserHandlers struct {
|
|||||||
ListCPIntimacyLeaderboard http.HandlerFunc
|
ListCPIntimacyLeaderboard http.HandlerFunc
|
||||||
BreakCPRelationship http.HandlerFunc
|
BreakCPRelationship http.HandlerFunc
|
||||||
GetMyProfile http.HandlerFunc
|
GetMyProfile http.HandlerFunc
|
||||||
|
GetUserProfile http.HandlerFunc
|
||||||
GetMyAppearance http.HandlerFunc
|
GetMyAppearance http.HandlerFunc
|
||||||
GetUserAppearance http.HandlerFunc
|
GetUserAppearance http.HandlerFunc
|
||||||
ListMyResources http.HandlerFunc
|
ListMyResources http.HandlerFunc
|
||||||
@ -411,6 +412,8 @@ func (r routes) registerAppRoutes() {
|
|||||||
func (r routes) registerUserRoutes() {
|
func (r routes) registerUserRoutes() {
|
||||||
h := r.config.User
|
h := r.config.User
|
||||||
r.public("/users/by-display-user-id/{display_user_id}", "", h.ResolveDisplayUserID)
|
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/profiles:batch", "", h.BatchUserProfiles)
|
||||||
r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles)
|
r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles)
|
||||||
r.profile("/users/me/overview", "", h.GetMyOverview)
|
r.profile("/users/me/overview", "", h.GetMyOverview)
|
||||||
@ -575,7 +578,7 @@ func (r routes) registerLevelRoutes() {
|
|||||||
|
|
||||||
func (r routes) registerAchievementRoutes() {
|
func (r routes) registerAchievementRoutes() {
|
||||||
h := r.config.Achievement
|
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/me", http.MethodGet, h.ListMyBadges)
|
||||||
r.profile("/badges/display", http.MethodPost, h.SetBadgeDisplay)
|
r.profile("/badges/display", http.MethodPost, h.SetBadgeDisplay)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
func TestCPWeeklyRankActivityUsesWeeklyScoreAndActivityClient(t *testing.T) {
|
||||||
cpWeeklyClient := &fakeCPWeeklyRankClient{
|
cpWeeklyClient := &fakeCPWeeklyRankClient{
|
||||||
statusResp: &activityv1.GetCPWeeklyRankStatusResponse{
|
statusResp: &activityv1.GetCPWeeklyRankStatusResponse{
|
||||||
|
|||||||
@ -98,6 +98,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
|||||||
ListCPIntimacyLeaderboard: h.listCPIntimacyLeaderboard,
|
ListCPIntimacyLeaderboard: h.listCPIntimacyLeaderboard,
|
||||||
BreakCPRelationship: h.breakCPRelationship,
|
BreakCPRelationship: h.breakCPRelationship,
|
||||||
GetMyProfile: h.getMyProfile,
|
GetMyProfile: h.getMyProfile,
|
||||||
|
GetUserProfile: h.getUserProfile,
|
||||||
GetMyAppearance: h.getMyAppearance,
|
GetMyAppearance: h.getMyAppearance,
|
||||||
GetUserAppearance: h.getUserAppearance,
|
GetUserAppearance: h.getUserAppearance,
|
||||||
UserSocialAction: h.userSocialAction,
|
UserSocialAction: h.userSocialAction,
|
||||||
|
|||||||
@ -305,6 +305,30 @@ func (h *Handler) getMyProfile(writer http.ResponseWriter, request *http.Request
|
|||||||
httpkit.WriteOK(writer, request, profileData(resp.GetUser(), 0))
|
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 返回当前登录用户自己的邀请码和邀请计数。
|
// getMyInviteOverview 返回当前登录用户自己的邀请码和邀请计数。
|
||||||
func (h *Handler) getMyInviteOverview(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getMyInviteOverview(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.userProfileClient == nil {
|
if h.userProfileClient == nil {
|
||||||
|
|||||||
@ -75,7 +75,7 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.
|
|||||||
targetGiftValue := roomUserGiftValue(current, cmd.ActorUserID())
|
targetGiftValue := roomUserGiftValue(current, cmd.ActorUserID())
|
||||||
|
|
||||||
// RoomMicChanged 是房间外消费者和腾讯云 IM 共同使用的麦位事件源;展示快照固化在事件体,direct IM 和 outbox 补偿共用。
|
// RoomMicChanged 是房间外消费者和腾讯云 IM 共同使用的麦位事件源;展示快照固化在事件体,direct IM 和 outbox 补偿共用。
|
||||||
micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{
|
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
|
||||||
ActorUserId: cmd.ActorUserID(),
|
ActorUserId: cmd.ActorUserID(),
|
||||||
TargetUserId: cmd.ActorUserID(),
|
TargetUserId: cmd.ActorUserID(),
|
||||||
FromSeat: 0,
|
FromSeat: 0,
|
||||||
@ -190,7 +190,7 @@ func (s *Service) micDown(ctx context.Context, cmd command.MicDown) (mutationRes
|
|||||||
current.Version++
|
current.Version++
|
||||||
seatStatus := current.MicSeats[index].SeatStatus()
|
seatStatus := current.MicSeats[index].SeatStatus()
|
||||||
|
|
||||||
micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{
|
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
|
||||||
ActorUserId: cmd.ActorUserID(),
|
ActorUserId: cmd.ActorUserID(),
|
||||||
TargetUserId: cmd.TargetUserID,
|
TargetUserId: cmd.TargetUserID,
|
||||||
FromSeat: seat.SeatNo,
|
FromSeat: seat.SeatNo,
|
||||||
@ -293,7 +293,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
|
|||||||
seatStatus := current.MicSeats[toIndex].SeatStatus()
|
seatStatus := current.MicSeats[toIndex].SeatStatus()
|
||||||
targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID)
|
targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID)
|
||||||
|
|
||||||
micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{
|
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
|
||||||
ActorUserId: cmd.ActorUserID(),
|
ActorUserId: cmd.ActorUserID(),
|
||||||
TargetUserId: cmd.TargetUserID,
|
TargetUserId: cmd.TargetUserID,
|
||||||
FromSeat: fromSeat.SeatNo,
|
FromSeat: fromSeat.SeatNo,
|
||||||
@ -397,7 +397,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
|
|||||||
seatStatus := current.MicSeats[index].SeatStatus()
|
seatStatus := current.MicSeats[index].SeatStatus()
|
||||||
targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID)
|
targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID)
|
||||||
|
|
||||||
micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{
|
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
|
||||||
ActorUserId: cmd.ActorUserID(),
|
ActorUserId: cmd.ActorUserID(),
|
||||||
TargetUserId: cmd.TargetUserID,
|
TargetUserId: cmd.TargetUserID,
|
||||||
FromSeat: seat.SeatNo,
|
FromSeat: seat.SeatNo,
|
||||||
@ -565,7 +565,7 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest)
|
|||||||
if cmd.Muted {
|
if cmd.Muted {
|
||||||
action = "mic_mute"
|
action = "mic_mute"
|
||||||
}
|
}
|
||||||
micChanged := roomMicChangedEvent(roomeventsv1.RoomMicChanged{
|
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
|
||||||
ActorUserId: cmd.ActorUserID(),
|
ActorUserId: cmd.ActorUserID(),
|
||||||
TargetUserId: cmd.TargetUserID,
|
TargetUserId: cmd.TargetUserID,
|
||||||
FromSeat: seat.SeatNo,
|
FromSeat: seat.SeatNo,
|
||||||
|
|||||||
@ -13,12 +13,15 @@ import (
|
|||||||
|
|
||||||
// roomMicChangedEvent 建立 RoomMicChanged 事实;展示快照只来自命令入口,
|
// roomMicChangedEvent 建立 RoomMicChanged 事实;展示快照只来自命令入口,
|
||||||
// room-service 不反查 user-service,保证 Room Cell 主链路仍只维护房态。
|
// 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.TargetNickname = strings.TrimSpace(profile.Nickname)
|
||||||
event.TargetAvatar = strings.TrimSpace(profile.Avatar)
|
event.TargetAvatar = strings.TrimSpace(profile.Avatar)
|
||||||
event.TargetDisplayUserId = strings.TrimSpace(profile.DisplayUserID)
|
event.TargetDisplayUserId = strings.TrimSpace(profile.DisplayUserID)
|
||||||
event.TargetPrettyDisplayUserId = strings.TrimSpace(profile.PrettyDisplayUserID)
|
event.TargetPrettyDisplayUserId = strings.TrimSpace(profile.PrettyDisplayUserID)
|
||||||
return &event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
// roomMicChangedIMAttributes 是 direct IM 和 outbox 补偿的唯一麦位 attributes 映射。
|
// 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
|
// RoomMicChanged/down fact, so downstream duration consumers never need to infer
|
||||||
// mic closure from leave/kick/close side effects.
|
// 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) {
|
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,
|
ActorUserId: actorUserID,
|
||||||
TargetUserId: targetUserID,
|
TargetUserId: targetUserID,
|
||||||
FromSeat: seatNo,
|
FromSeat: seatNo,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user