幸运礼物
This commit is contained in:
parent
144cd3d781
commit
a0ee5962df
@ -10705,6 +10705,7 @@ type LuckyGiftDrawResult struct {
|
||||
HighMultiplier bool `protobuf:"varint,18,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
|
||||
CreatedAtMs int64 `protobuf:"varint,19,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||
PoolId string `protobuf:"bytes,20,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||
MultiplierPpm int64 `protobuf:"varint,21,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -10879,6 +10880,13 @@ func (x *LuckyGiftDrawResult) GetPoolId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawResult) GetMultiplierPpm() int64 {
|
||||
if x != nil {
|
||||
return x.MultiplierPpm
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ExecuteLuckyGiftDrawRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
LuckyGift *LuckyGiftMeta `protobuf:"bytes,1,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"`
|
||||
@ -12673,7 +12681,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
|
||||
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" +
|
||||
"\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" +
|
||||
"\apool_id\x18\b \x01(\tR\x06poolId\"\xce\x06\n" +
|
||||
"\apool_id\x18\b \x01(\tR\x06poolId\"\xf5\x06\n" +
|
||||
"\x13LuckyGiftDrawResult\x12\x17\n" +
|
||||
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
||||
"\n" +
|
||||
@ -12696,7 +12704,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x0estage_feedback\x18\x11 \x01(\bR\rstageFeedback\x12'\n" +
|
||||
"\x0fhigh_multiplier\x18\x12 \x01(\bR\x0ehighMultiplier\x12\"\n" +
|
||||
"\rcreated_at_ms\x18\x13 \x01(\x03R\vcreatedAtMs\x12\x17\n" +
|
||||
"\apool_id\x18\x14 \x01(\tR\x06poolId\"^\n" +
|
||||
"\apool_id\x18\x14 \x01(\tR\x06poolId\x12%\n" +
|
||||
"\x0emultiplier_ppm\x18\x15 \x01(\x03R\rmultiplierPpm\"^\n" +
|
||||
"\x1bExecuteLuckyGiftDrawRequest\x12?\n" +
|
||||
"\n" +
|
||||
"lucky_gift\x18\x01 \x01(\v2 .hyapp.activity.v1.LuckyGiftMetaR\tluckyGift\"^\n" +
|
||||
|
||||
@ -1218,6 +1218,7 @@ message LuckyGiftDrawResult {
|
||||
bool high_multiplier = 18;
|
||||
int64 created_at_ms = 19;
|
||||
string pool_id = 20;
|
||||
int64 multiplier_ppm = 21;
|
||||
}
|
||||
|
||||
message ExecuteLuckyGiftDrawRequest {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -63,6 +63,27 @@ message RankItem {
|
||||
int64 updated_at_ms = 4;
|
||||
}
|
||||
|
||||
// LuckyGiftDrawResult 是 SendGift 同步返回给当前送礼用户的幸运礼物抽奖表现。
|
||||
message LuckyGiftDrawResult {
|
||||
bool enabled = 1;
|
||||
string draw_id = 2;
|
||||
string command_id = 3;
|
||||
string pool_id = 4;
|
||||
string gift_id = 5;
|
||||
int64 rule_version = 6;
|
||||
string experience_pool = 7;
|
||||
string selected_tier_id = 8;
|
||||
int64 multiplier_ppm = 9;
|
||||
int64 base_reward_coins = 10;
|
||||
int64 room_atmosphere_reward_coins = 11;
|
||||
int64 activity_subsidy_coins = 12;
|
||||
int64 effective_reward_coins = 13;
|
||||
string reward_status = 14;
|
||||
bool stage_feedback = 15;
|
||||
bool high_multiplier = 16;
|
||||
int64 created_at_ms = 17;
|
||||
}
|
||||
|
||||
// RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。
|
||||
message RoomTreasureRewardItem {
|
||||
string reward_item_id = 1;
|
||||
@ -695,6 +716,7 @@ message SendGiftResponse {
|
||||
repeated RankItem gift_rank = 4;
|
||||
RoomSnapshot room = 5;
|
||||
RoomTreasureState treasure = 6;
|
||||
LuckyGiftDrawResult lucky_gift = 7;
|
||||
}
|
||||
|
||||
// CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。
|
||||
|
||||
@ -88,6 +88,7 @@ type drawDTO struct {
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
ExperiencePool string `json:"experience_pool"`
|
||||
SelectedTierID string `json:"selected_tier_id"`
|
||||
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||
BaseRewardCoins int64 `json:"base_reward_coins"`
|
||||
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
|
||||
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
|
||||
@ -346,6 +347,7 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
|
||||
RuleVersion: draw.GetRuleVersion(),
|
||||
ExperiencePool: draw.GetExperiencePool(),
|
||||
SelectedTierID: draw.GetSelectedTierId(),
|
||||
MultiplierPPM: draw.GetMultiplierPpm(),
|
||||
BaseRewardCoins: draw.GetBaseRewardCoins(),
|
||||
RoomAtmosphereRewardCoins: draw.GetRoomAtmosphereRewardCoins(),
|
||||
ActivitySubsidyCoins: draw.GetActivitySubsidyCoins(),
|
||||
|
||||
@ -860,3 +860,30 @@ CREATE TABLE IF NOT EXISTS message_fanout_jobs (
|
||||
UNIQUE KEY uk_message_fanout_command (app_code, command_id),
|
||||
KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息分发任务表';
|
||||
|
||||
-- 本地开发必须开箱即可验证幸运礼物链路;INSERT IGNORE 只补缺省奖池,不覆盖后台已发布规则。
|
||||
SET @lucky_seed_now_ms := 1779259000000;
|
||||
SET @lucky_seed_tiers := JSON_ARRAY(
|
||||
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true)
|
||||
);
|
||||
INSERT IGNORE INTO lucky_gift_rules (
|
||||
app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm,
|
||||
global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit,
|
||||
high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm,
|
||||
gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool,
|
||||
platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap,
|
||||
user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap,
|
||||
room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve,
|
||||
activity_budget, activity_daily_limit, large_tier_enabled, tiers_json,
|
||||
updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 'lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, @lucky_seed_tiers, 0, @lucky_seed_now_ms, @lucky_seed_now_ms),
|
||||
('lalu', 'super_lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, @lucky_seed_tiers, 0, @lucky_seed_now_ms, @lucky_seed_now_ms);
|
||||
|
||||
@ -112,6 +112,7 @@ type DrawResult struct {
|
||||
RuleVersion int64
|
||||
ExperiencePool string
|
||||
SelectedTierID string
|
||||
MultiplierPPM int64
|
||||
BaseRewardCoins int64
|
||||
RoomAtmosphereRewardCoins int64
|
||||
ActivitySubsidyCoins int64
|
||||
|
||||
@ -415,6 +415,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
||||
RuleVersion: config.RuleVersion,
|
||||
ExperiencePool: experiencePool,
|
||||
SelectedTierID: candidate.TierID,
|
||||
MultiplierPPM: candidate.MultiplierPPM,
|
||||
BaseRewardCoins: candidate.BaseReward,
|
||||
RoomAtmosphereRewardCoins: candidate.RoomReward,
|
||||
ActivitySubsidyCoins: candidate.ActivityReward,
|
||||
@ -604,6 +605,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
|
||||
args = append(args, limit, offset)
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
|
||||
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
|
||||
rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
|
||||
@ -619,6 +621,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
|
||||
for rows.Next() {
|
||||
var item domain.DrawResult
|
||||
if err := rows.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
|
||||
&item.MultiplierPPM,
|
||||
&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
|
||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
||||
@ -1256,6 +1259,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC
|
||||
}
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
|
||||
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
|
||||
rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
|
||||
@ -1265,6 +1269,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC
|
||||
)
|
||||
var item domain.DrawResult
|
||||
if err := row.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
|
||||
&item.MultiplierPPM,
|
||||
&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
|
||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
||||
|
||||
@ -270,6 +270,7 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR
|
||||
RuleVersion: result.RuleVersion,
|
||||
ExperiencePool: result.ExperiencePool,
|
||||
SelectedTierId: result.SelectedTierID,
|
||||
MultiplierPpm: result.MultiplierPPM,
|
||||
BaseRewardCoins: result.BaseRewardCoins,
|
||||
RoomAtmosphereRewardCoins: result.RoomAtmosphereRewardCoins,
|
||||
ActivitySubsidyCoins: result.ActivitySubsidyCoins,
|
||||
|
||||
@ -66,6 +66,8 @@ type UserCountryQueryClient interface {
|
||||
|
||||
// UserHostClient 抽象 gateway 对 user-service host domain 身份查询能力的依赖。
|
||||
type UserHostClient interface {
|
||||
SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error)
|
||||
ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error)
|
||||
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
|
||||
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
|
||||
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
|
||||
@ -294,6 +296,14 @@ func (c *grpcUserCountryQueryClient) ListLoginRiskBlockedCountries(ctx context.C
|
||||
return c.client.ListLoginRiskBlockedCountries(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) {
|
||||
return c.client.SearchAgencies(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) {
|
||||
return c.client.ApplyToAgency(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
|
||||
return c.client.GetHostProfile(ctx, req)
|
||||
}
|
||||
|
||||
@ -221,6 +221,10 @@ func RequireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
|
||||
// RoomMeta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。
|
||||
// request_id 只做链路追踪;command_id 必须来自客户端用户动作,gateway 只裁剪空白。
|
||||
func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
|
||||
sessionID := auth.SessionIDFromContext(request.Context())
|
||||
if sessionID == "" {
|
||||
sessionID = idgen.New("sess")
|
||||
}
|
||||
return &roomv1.RequestMeta{
|
||||
RequestId: RequestIDFromContext(request.Context()),
|
||||
CommandId: strings.TrimSpace(commandID),
|
||||
@ -228,7 +232,7 @@ func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.Re
|
||||
RoomId: roomID,
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
GatewayNodeId: "gateway-local",
|
||||
SessionId: idgen.New("sess"),
|
||||
SessionId: sessionID,
|
||||
SentAtMs: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +67,8 @@ type UserHandlers struct {
|
||||
GetMyIdentity http.HandlerFunc
|
||||
GetMyHostIdentity http.HandlerFunc
|
||||
GetMyRoleSummary http.HandlerFunc
|
||||
SearchHostAgencies http.HandlerFunc
|
||||
ApplyToHostAgency http.HandlerFunc
|
||||
CompleteMyOnboarding http.HandlerFunc
|
||||
UpdateMyProfile http.HandlerFunc
|
||||
ChangeMyCountry http.HandlerFunc
|
||||
@ -268,6 +270,8 @@ func (r routes) registerUserRoutes() {
|
||||
r.auth("/users/me/identity", "", h.GetMyIdentity)
|
||||
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
|
||||
r.profile("/users/me/role-summary", "", h.GetMyRoleSummary)
|
||||
r.profile("/host/agencies/search", http.MethodGet, h.SearchHostAgencies)
|
||||
r.profile("/host/agency-applications", http.MethodPost, h.ApplyToHostAgency)
|
||||
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
|
||||
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
|
||||
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
|
||||
|
||||
@ -52,6 +52,7 @@ type fakeRoomClient struct {
|
||||
lastKick *roomv1.KickUserRequest
|
||||
lastUnban *roomv1.UnbanUserRequest
|
||||
lastGift *roomv1.SendGiftRequest
|
||||
sendGiftResp *roomv1.SendGiftResponse
|
||||
lastFollowRoom *roomv1.FollowRoomRequest
|
||||
lastUnfollowRoom *roomv1.UnfollowRoomRequest
|
||||
createErr error
|
||||
@ -219,6 +220,9 @@ func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserReque
|
||||
|
||||
func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
|
||||
f.lastGift = req
|
||||
if f.sendGiftResp != nil {
|
||||
return f.sendGiftResp, nil
|
||||
}
|
||||
return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil
|
||||
}
|
||||
|
||||
@ -315,21 +319,27 @@ type fakeUserCountryQueryClient struct {
|
||||
}
|
||||
|
||||
type fakeUserHostClient struct {
|
||||
last *userv1.GetCoinSellerProfileRequest
|
||||
profile *userv1.CoinSellerProfile
|
||||
err error
|
||||
lastHost *userv1.GetHostProfileRequest
|
||||
hostProfile *userv1.HostProfile
|
||||
hostErr error
|
||||
lastBD *userv1.GetBDProfileRequest
|
||||
bdProfile *userv1.BDProfile
|
||||
bdErr error
|
||||
lastRoleSummary *userv1.GetUserRoleSummaryRequest
|
||||
roleSummary *userv1.UserRoleSummary
|
||||
roleSummaryErr error
|
||||
lastCapability *userv1.CheckBusinessCapabilityRequest
|
||||
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
||||
capabilityErr error
|
||||
lastSearchAgencies *userv1.SearchAgenciesRequest
|
||||
searchAgenciesResp *userv1.SearchAgenciesResponse
|
||||
searchAgenciesErr error
|
||||
lastApplyAgency *userv1.ApplyToAgencyRequest
|
||||
applyAgencyResp *userv1.ApplyToAgencyResponse
|
||||
applyAgencyErr error
|
||||
last *userv1.GetCoinSellerProfileRequest
|
||||
profile *userv1.CoinSellerProfile
|
||||
err error
|
||||
lastHost *userv1.GetHostProfileRequest
|
||||
hostProfile *userv1.HostProfile
|
||||
hostErr error
|
||||
lastBD *userv1.GetBDProfileRequest
|
||||
bdProfile *userv1.BDProfile
|
||||
bdErr error
|
||||
lastRoleSummary *userv1.GetUserRoleSummaryRequest
|
||||
roleSummary *userv1.UserRoleSummary
|
||||
roleSummaryErr error
|
||||
lastCapability *userv1.CheckBusinessCapabilityRequest
|
||||
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
||||
capabilityErr error
|
||||
}
|
||||
|
||||
type fakeWalletClient struct {
|
||||
@ -903,6 +913,28 @@ func (f *fakeBroadcastClient) RemoveRegionBroadcastMember(_ context.Context, req
|
||||
return &activityv1.RemoveRegionBroadcastMemberResponse{GroupId: "hy_lalu_bc_r_1001", Removed: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) SearchAgencies(_ context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) {
|
||||
f.lastSearchAgencies = req
|
||||
if f.searchAgenciesErr != nil {
|
||||
return nil, f.searchAgenciesErr
|
||||
}
|
||||
if f.searchAgenciesResp != nil {
|
||||
return f.searchAgenciesResp, nil
|
||||
}
|
||||
return &userv1.SearchAgenciesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) ApplyToAgency(_ context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) {
|
||||
f.lastApplyAgency = req
|
||||
if f.applyAgencyErr != nil {
|
||||
return nil, f.applyAgencyErr
|
||||
}
|
||||
if f.applyAgencyResp != nil {
|
||||
return f.applyAgencyResp, nil
|
||||
}
|
||||
return &userv1.ApplyToAgencyResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
|
||||
f.lastHost = req
|
||||
if f.hostErr != nil {
|
||||
@ -1528,6 +1560,55 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
|
||||
BillingReceiptId: "receipt-lucky",
|
||||
RoomHeat: 100,
|
||||
LuckyGift: &roomv1.LuckyGiftDrawResult{
|
||||
Enabled: true,
|
||||
DrawId: "lucky_draw_test",
|
||||
CommandId: "cmd-gift-lucky",
|
||||
PoolId: "super_lucky",
|
||||
GiftId: "rose",
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
SelectedTierId: "novice_2x",
|
||||
MultiplierPpm: 2_000_000,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "pending",
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
},
|
||||
}}
|
||||
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-lucky","target_user_id":43,"gift_id":"rose","gift_count":1,"pool_id":"super_lucky"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
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())
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
LuckyGift struct {
|
||||
DrawID string `json:"draw_id"`
|
||||
PoolID string `json:"pool_id"`
|
||||
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
|
||||
} `json:"lucky_gift"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 2_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 200 {
|
||||
t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
|
||||
previousNow := roomapi.TimeNow
|
||||
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
||||
@ -2513,6 +2594,77 @@ func TestGetMyHostIdentityTreatsMissingProfilesAsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{searchAgenciesResp: &userv1.SearchAgenciesResponse{Agencies: []*userv1.Agency{
|
||||
{
|
||||
AgencyId: 7001,
|
||||
OwnerUserId: 901,
|
||||
RegionId: 30,
|
||||
Name: "Admin Seed Agency",
|
||||
Status: "active",
|
||||
JoinEnabled: true,
|
||||
MaxHosts: 100,
|
||||
CreatedAtMs: 1700000000000,
|
||||
UpdatedAtMs: 1700000001000,
|
||||
},
|
||||
}}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=99", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-host-agency-search")
|
||||
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 hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 20 {
|
||||
t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data := response.Data.(map[string]any)
|
||||
items := data["items"].([]any)
|
||||
first := items[0].(map[string]any)
|
||||
if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || data["page_size"] != float64(20) {
|
||||
t.Fatalf("host agency search response mismatch: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{
|
||||
ApplicationId: 8001,
|
||||
CommandId: "cmd-apply-agency",
|
||||
ApplicantUserId: 42,
|
||||
AgencyId: 7001,
|
||||
RegionId: 30,
|
||||
Status: "pending",
|
||||
CreatedAtMs: 1700000000000,
|
||||
UpdatedAtMs: 1700000001000,
|
||||
}}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/host/agency-applications", bytes.NewReader([]byte(`{"command_id":"cmd-apply-agency","agency_id":"7001"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-host-agency-apply")
|
||||
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 hostClient.lastApplyAgency == nil || hostClient.lastApplyAgency.GetUserId() != 42 || hostClient.lastApplyAgency.GetCommandId() != "cmd-apply-agency" || hostClient.lastApplyAgency.GetAgencyId() != 7001 {
|
||||
t.Fatalf("host agency apply request mismatch: %+v", hostClient.lastApplyAgency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
|
||||
@ -48,6 +48,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
GetMyIdentity: h.getMyIdentity,
|
||||
GetMyHostIdentity: h.getMyHostIdentity,
|
||||
GetMyRoleSummary: h.getMyRoleSummary,
|
||||
SearchHostAgencies: h.searchHostAgencies,
|
||||
ApplyToHostAgency: h.applyToHostAgency,
|
||||
CompleteMyOnboarding: h.completeMyOnboarding,
|
||||
UpdateMyProfile: h.updateMyProfile,
|
||||
ChangeMyCountry: h.changeMyCountry,
|
||||
|
||||
@ -0,0 +1,185 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type hostAgencyData struct {
|
||||
AgencyID string `json:"agency_id"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
ParentBDUserID string `json:"parent_bd_user_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
JoinEnabled bool `json:"join_enabled"`
|
||||
MaxHosts int32 `json:"max_hosts"`
|
||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type hostAgencyApplicationData struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
AgencyID string `json:"agency_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
Status string `json:"status"`
|
||||
ReviewedByUserID string `json:"reviewed_by_user_id,omitempty"`
|
||||
ReviewReason string `json:"review_reason,omitempty"`
|
||||
ReviewedAtMS int64 `json:"reviewed_at_ms,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
func (h *Handler) searchHostAgencies(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
keyword := strings.TrimSpace(request.URL.Query().Get("short_id"))
|
||||
if keyword == "" {
|
||||
keyword = strings.TrimSpace(request.URL.Query().Get("keyword"))
|
||||
}
|
||||
if keyword == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
if pageSize > 20 {
|
||||
pageSize = 20
|
||||
}
|
||||
resp, err := h.userHostClient.SearchAgencies(request.Context(), &userv1.SearchAgenciesRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
Keyword: keyword,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
items := make([]hostAgencyData, 0, len(resp.GetAgencies()))
|
||||
for _, agency := range resp.GetAgencies() {
|
||||
items = append(items, hostAgencyFromProto(agency))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) applyToHostAgency(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CommandID string `json:"command_id"`
|
||||
CommandIDAlt string `json:"commandId"`
|
||||
AgencyID json.RawMessage `json:"agency_id"`
|
||||
AgencyIDAlt json.RawMessage `json:"agencyId"`
|
||||
}
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(body.CommandID)
|
||||
if commandID == "" {
|
||||
commandID = strings.TrimSpace(body.CommandIDAlt)
|
||||
}
|
||||
agencyID, ok := firstRawInt64(body.AgencyID, body.AgencyIDAlt)
|
||||
if commandID == "" || !ok || agencyID <= 0 {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.userHostClient.ApplyToAgency(request.Context(), &userv1.ApplyToAgencyRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
CommandId: commandID,
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
AgencyId: agencyID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"application": hostAgencyApplicationFromProto(resp.GetApplication())})
|
||||
}
|
||||
|
||||
func hostAgencyFromProto(agency *userv1.Agency) hostAgencyData {
|
||||
if agency == nil {
|
||||
return hostAgencyData{}
|
||||
}
|
||||
return hostAgencyData{
|
||||
AgencyID: int64String(agency.GetAgencyId()),
|
||||
OwnerUserID: int64String(agency.GetOwnerUserId()),
|
||||
RegionID: agency.GetRegionId(),
|
||||
ParentBDUserID: int64String(agency.GetParentBdUserId()),
|
||||
Name: agency.GetName(),
|
||||
Status: agency.GetStatus(),
|
||||
JoinEnabled: agency.GetJoinEnabled(),
|
||||
MaxHosts: agency.GetMaxHosts(),
|
||||
CreatedByUserID: int64String(agency.GetCreatedByUserId()),
|
||||
CreatedAtMS: agency.GetCreatedAtMs(),
|
||||
UpdatedAtMS: agency.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func hostAgencyApplicationFromProto(application *userv1.AgencyApplication) hostAgencyApplicationData {
|
||||
if application == nil {
|
||||
return hostAgencyApplicationData{}
|
||||
}
|
||||
return hostAgencyApplicationData{
|
||||
ApplicationID: int64String(application.GetApplicationId()),
|
||||
CommandID: application.GetCommandId(),
|
||||
ApplicantUserID: int64String(application.GetApplicantUserId()),
|
||||
AgencyID: int64String(application.GetAgencyId()),
|
||||
RegionID: application.GetRegionId(),
|
||||
Status: application.GetStatus(),
|
||||
ReviewedByUserID: int64String(application.GetReviewedByUserId()),
|
||||
ReviewReason: application.GetReviewReason(),
|
||||
ReviewedAtMS: application.GetReviewedAtMs(),
|
||||
CreatedAtMS: application.GetCreatedAtMs(),
|
||||
UpdatedAtMS: application.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func int64String(value int64) string {
|
||||
if value <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func firstRawInt64(values ...json.RawMessage) (int64, bool) {
|
||||
for _, raw := range values {
|
||||
value, ok := parseRawInt64(raw)
|
||||
if ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func parseRawInt64(raw json.RawMessage) (int64, bool) {
|
||||
text := strings.TrimSpace(string(raw))
|
||||
if text == "" || text == "null" {
|
||||
return 0, false
|
||||
}
|
||||
if strings.HasPrefix(text, `"`) {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
text = strings.TrimSpace(value)
|
||||
}
|
||||
value, err := strconv.ParseInt(text, 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
@ -84,7 +84,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
var activityConn *grpc.ClientConn
|
||||
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
||||
if cfg.ActivityServiceAddr != "" {
|
||||
activityConn, err = grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
_ = walletConn.Close()
|
||||
@ -189,6 +189,10 @@ func New(cfg config.Config) (*App, error) {
|
||||
treasureOpenScheduler = integration.NewRocketMQTreasureOpenScheduler(treasureProducer, cfg.RocketMQ.TreasureOpen.Topic)
|
||||
}
|
||||
outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
|
||||
var luckyGiftClient integration.LuckyGiftClient
|
||||
if activityConn != nil {
|
||||
luckyGiftClient = integration.NewGRPCLuckyGiftClient(activityConn)
|
||||
}
|
||||
|
||||
// 领域服务只依赖接口,App 层负责选择 MySQL/Redis/gRPC 具体实现。
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
@ -200,7 +204,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
MicPublishTimeout: cfg.MicPublishTimeout,
|
||||
RTCUserRemover: rtcUserRemover,
|
||||
RoomTreasureOpenScheduler: treasureOpenScheduler,
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient)
|
||||
if cfg.RocketMQ.TreasureOpen.Enabled {
|
||||
treasureConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ConsumerGroup, cfg.RocketMQ.TreasureOpen.ConsumerMaxReconsumeTimes))
|
||||
if err != nil {
|
||||
|
||||
@ -4,6 +4,7 @@ package integration
|
||||
import (
|
||||
"context"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/tencentim"
|
||||
@ -17,6 +18,14 @@ type WalletClient interface {
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
}
|
||||
|
||||
// LuckyGiftClient 抽象 room-service 对 activity-service 幸运礼物抽奖边界的同步依赖。
|
||||
type LuckyGiftClient interface {
|
||||
// CheckLuckyGift 在扣费前确认奖池规则可用;失败时不能扣费。
|
||||
CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error)
|
||||
// ExecuteLuckyGiftDraw 在钱包扣费成功后按 command_id 幂等落抽奖事实。
|
||||
ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error)
|
||||
}
|
||||
|
||||
// RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。
|
||||
type RoomEventPublisher interface {
|
||||
// EnsureRoomGroup 确保房间在腾讯云 IM 中有对应群组。
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
@ -32,3 +33,20 @@ func (c *grpcWalletClient) GrantResourceGroup(ctx context.Context, req *walletv1
|
||||
// 宝箱奖励以 resource group 为后台配置单位,wallet-service 负责展开资产和权益。
|
||||
return c.client.GrantResourceGroup(ctx, req)
|
||||
}
|
||||
|
||||
type grpcLuckyGiftClient struct {
|
||||
client activityv1.LuckyGiftServiceClient
|
||||
}
|
||||
|
||||
// NewGRPCLuckyGiftClient 用 gRPC 连接创建 activity-service 幸运礼物客户端。
|
||||
func NewGRPCLuckyGiftClient(conn *grpc.ClientConn) LuckyGiftClient {
|
||||
return &grpcLuckyGiftClient{client: activityv1.NewLuckyGiftServiceClient(conn)}
|
||||
}
|
||||
|
||||
func (c *grpcLuckyGiftClient) CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
|
||||
return c.client.CheckLuckyGift(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcLuckyGiftClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
|
||||
return c.client.ExecuteLuckyGiftDraw(ctx, req)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
@ -66,6 +67,26 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
if !exists {
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
|
||||
}
|
||||
luckyEnabled := false
|
||||
if cmd.PoolID != "" {
|
||||
if s.luckyGift == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift service is not configured")
|
||||
}
|
||||
checkResp, err := s.luckyGift.CheckLuckyGift(ctx, &activityv1.CheckLuckyGiftRequest{
|
||||
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
|
||||
UserId: cmd.ActorUserID(),
|
||||
RoomId: cmd.RoomID(),
|
||||
GiftId: cmd.GiftID,
|
||||
PoolId: cmd.PoolID,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if checkResp == nil || !checkResp.GetEnabled() {
|
||||
return mutationResult{}, nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
|
||||
}
|
||||
luckyEnabled = true
|
||||
}
|
||||
treasureConfig, err := s.roomTreasureConfig(ctx)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
@ -98,6 +119,33 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
settledCommand.HeatValue = heatValue
|
||||
settledCommand.PriceVersion = billing.GetPriceVersion()
|
||||
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
|
||||
if !luckyEnabled {
|
||||
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
|
||||
}
|
||||
var luckyGift *roomv1.LuckyGiftDrawResult
|
||||
if luckyEnabled {
|
||||
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
|
||||
LuckyGift: &activityv1.LuckyGiftMeta{
|
||||
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
|
||||
CommandId: cmd.ID(),
|
||||
UserId: cmd.ActorUserID(),
|
||||
DeviceId: luckyGiftDeviceID(cmd),
|
||||
RoomId: cmd.RoomID(),
|
||||
AnchorId: luckyGiftAnchorID(roomMeta),
|
||||
GiftId: cmd.GiftID,
|
||||
CoinSpent: billing.GetCoinSpent(),
|
||||
PaidAtMs: now.UTC().UnixMilli(),
|
||||
PoolId: cmd.PoolID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if drawResp == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
|
||||
}
|
||||
luckyGift = luckyGiftResultFromProto(drawResp.GetResult())
|
||||
}
|
||||
|
||||
// 扣费成功后,Room Cell 同步更新热度和本地礼物榜。
|
||||
current.Heat += heatValue
|
||||
@ -167,6 +215,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
billingReceiptID: billing.GetBillingReceiptId(),
|
||||
roomHeat: current.Heat,
|
||||
giftRank: cloneProtoRank(current.GiftRank),
|
||||
luckyGift: luckyGift,
|
||||
commandPayload: commandPayload,
|
||||
walletDebitMS: walletDebitMS,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
@ -202,9 +251,71 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
GiftRank: result.giftRank,
|
||||
Room: result.snapshot,
|
||||
Treasure: result.snapshot.GetTreasure(),
|
||||
LuckyGift: result.luckyGift,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
|
||||
if s.luckyGift == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(giftTypeCode) {
|
||||
case "lucky", "super_lucky":
|
||||
return true
|
||||
default:
|
||||
return strings.TrimSpace(poolID) != ""
|
||||
}
|
||||
}
|
||||
|
||||
func activityMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *activityv1.RequestMeta {
|
||||
return &activityv1.RequestMeta{
|
||||
RequestId: meta.GetRequestId(),
|
||||
Caller: "room-service",
|
||||
GatewayNodeId: meta.GetGatewayNodeId(),
|
||||
SentAtMs: time.Now().UTC().UnixMilli(),
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func luckyGiftDeviceID(cmd command.SendGift) string {
|
||||
if value := strings.TrimSpace(cmd.SessionID); value != "" {
|
||||
return value
|
||||
}
|
||||
return cmd.ID()
|
||||
}
|
||||
|
||||
func luckyGiftAnchorID(roomMeta RoomMeta) string {
|
||||
if roomMeta.OwnerUserID <= 0 {
|
||||
return roomMeta.RoomID
|
||||
}
|
||||
return fmt.Sprintf("%d", roomMeta.OwnerUserID)
|
||||
}
|
||||
|
||||
func luckyGiftResultFromProto(result *activityv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return &roomv1.LuckyGiftDrawResult{
|
||||
Enabled: true,
|
||||
DrawId: result.GetDrawId(),
|
||||
CommandId: result.GetCommandId(),
|
||||
PoolId: result.GetPoolId(),
|
||||
GiftId: result.GetGiftId(),
|
||||
RuleVersion: result.GetRuleVersion(),
|
||||
ExperiencePool: result.GetExperiencePool(),
|
||||
SelectedTierId: result.GetSelectedTierId(),
|
||||
MultiplierPpm: result.GetMultiplierPpm(),
|
||||
BaseRewardCoins: result.GetBaseRewardCoins(),
|
||||
RoomAtmosphereRewardCoins: result.GetRoomAtmosphereRewardCoins(),
|
||||
ActivitySubsidyCoins: result.GetActivitySubsidyCoins(),
|
||||
EffectiveRewardCoins: result.GetEffectiveRewardCoins(),
|
||||
RewardStatus: result.GetRewardStatus(),
|
||||
StageFeedback: result.GetStageFeedback(),
|
||||
HighMultiplier: result.GetHighMultiplier(),
|
||||
CreatedAtMs: result.GetCreatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGiftTargetType(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch raw {
|
||||
|
||||
110
services/room-service/internal/room/service/lucky_gift_test.go
Normal file
110
services/room-service/internal/room/service/lucky_gift_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/room-service/internal/integration"
|
||||
roomservice "hyapp/services/room-service/internal/room/service"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
type luckyGiftTestClient struct {
|
||||
checks []*activityv1.CheckLuckyGiftRequest
|
||||
draws []*activityv1.ExecuteLuckyGiftDrawRequest
|
||||
}
|
||||
|
||||
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
|
||||
c.checks = append(c.checks, req)
|
||||
return &activityv1.CheckLuckyGiftResponse{
|
||||
Enabled: true,
|
||||
Reason: "enabled",
|
||||
GiftId: req.GetGiftId(),
|
||||
PoolId: req.GetPoolId(),
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
|
||||
c.draws = append(c.draws, req)
|
||||
meta := req.GetLuckyGift()
|
||||
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: &activityv1.LuckyGiftDrawResult{
|
||||
DrawId: "lucky_draw_test",
|
||||
CommandId: meta.GetCommandId(),
|
||||
PoolId: meta.GetPoolId(),
|
||||
GiftId: meta.GetGiftId(),
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
SelectedTierId: "novice_2x",
|
||||
MultiplierPpm: 2_000_000,
|
||||
BaseRewardCoins: 200,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "pending",
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{{
|
||||
BillingReceiptId: "receipt-lucky",
|
||||
CoinSpent: 100,
|
||||
GiftPointAdded: 100,
|
||||
HeatValue: 100,
|
||||
GiftTypeCode: "super_lucky",
|
||||
}}}
|
||||
luckyGift := &luckyGiftTestClient{}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-lucky-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
|
||||
|
||||
roomID := "room-lucky-gift"
|
||||
ownerID := int64(101)
|
||||
viewerID := int64(202)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||
joinTreasureRoom(t, ctx, svc, roomID, viewerID)
|
||||
|
||||
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: &roomv1.RequestMeta{
|
||||
RequestId: "req-lucky",
|
||||
CommandId: "cmd-lucky",
|
||||
ActorUserId: ownerID,
|
||||
RoomId: roomID,
|
||||
SessionId: "device-session-1",
|
||||
AppCode: appcode.Default,
|
||||
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
||||
},
|
||||
TargetType: "user",
|
||||
TargetUserId: viewerID,
|
||||
GiftId: "rose",
|
||||
GiftCount: 1,
|
||||
PoolId: "super_lucky",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send lucky gift failed: %v", err)
|
||||
}
|
||||
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 {
|
||||
t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
|
||||
}
|
||||
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" || luckyGift.checks[0].GetGiftId() != "rose" {
|
||||
t.Fatalf("lucky check request mismatch: %+v", luckyGift.checks)
|
||||
}
|
||||
if len(luckyGift.draws) != 1 {
|
||||
t.Fatalf("expected one lucky draw, got %d", len(luckyGift.draws))
|
||||
}
|
||||
drawMeta := luckyGift.draws[0].GetLuckyGift()
|
||||
if drawMeta.GetCommandId() != "cmd-lucky" || drawMeta.GetCoinSpent() != 100 || drawMeta.GetDeviceId() != "device-session-1" || drawMeta.GetAnchorId() != "101" {
|
||||
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
|
||||
}
|
||||
}
|
||||
@ -60,6 +60,8 @@ type Service struct {
|
||||
repository Repository
|
||||
// wallet 是 SendGift 的同步扣费依赖,扣费失败不能进入 Room Cell 状态变更。
|
||||
wallet integration.WalletClient
|
||||
// luckyGift 是幸运礼物同步检查和抽奖依赖;未配置时幸运礼物按未启用处理。
|
||||
luckyGift integration.LuckyGiftClient
|
||||
// syncPublisher 是 room-service 到腾讯云 IM 的低时延房间系统事件桥。
|
||||
syncPublisher integration.RoomEventPublisher
|
||||
// outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。
|
||||
@ -97,6 +99,8 @@ type mutationResult struct {
|
||||
roomHeat int64
|
||||
// giftRank 是 SendGift 后的本地礼物榜投影。
|
||||
giftRank []*roomv1.RankItem
|
||||
// luckyGift 是扣费成功后同步执行的幸运礼物抽奖结果;普通礼物保持 nil。
|
||||
luckyGift *roomv1.LuckyGiftDrawResult
|
||||
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log,用于恢复。
|
||||
commandPayload []byte
|
||||
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。
|
||||
@ -117,7 +121,7 @@ type mutationResult struct {
|
||||
}
|
||||
|
||||
// New 初始化 room-service 领域服务。
|
||||
func New(cfg Config, directory router.Directory, repository Repository, wallet integration.WalletClient, syncPublisher integration.RoomEventPublisher, outboxPublisher integration.OutboxPublisher) *Service {
|
||||
func New(cfg Config, directory router.Directory, repository Repository, wallet integration.WalletClient, syncPublisher integration.RoomEventPublisher, outboxPublisher integration.OutboxPublisher, luckyGift ...integration.LuckyGiftClient) *Service {
|
||||
usedClock := cfg.Clock
|
||||
if usedClock == nil {
|
||||
// 生产默认系统时钟,测试可注入固定时钟保证断言稳定。
|
||||
@ -148,6 +152,11 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
micPublishTimeout = 15 * time.Second
|
||||
}
|
||||
|
||||
var luckyGiftClient integration.LuckyGiftClient
|
||||
if len(luckyGift) > 0 {
|
||||
luckyGiftClient = luckyGift[0]
|
||||
}
|
||||
|
||||
return &Service{
|
||||
nodeID: cfg.NodeID,
|
||||
leaseTTL: cfg.LeaseTTL,
|
||||
@ -159,6 +168,7 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
directory: directory,
|
||||
repository: repository,
|
||||
wallet: wallet,
|
||||
luckyGift: luckyGiftClient,
|
||||
syncPublisher: syncPublisher,
|
||||
outboxPublisher: outboxPublisher,
|
||||
roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler,
|
||||
|
||||
@ -400,6 +400,13 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
|
||||
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
|
||||
t.Fatalf("created agency should be searchable: %+v", agencies)
|
||||
}
|
||||
agencies, err = svc.SearchAgencies(ctx, 902, displayID(901), 20)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchAgencies by owner display id failed: %v", err)
|
||||
}
|
||||
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
|
||||
t.Fatalf("created agency should be searchable by owner display id: %+v", agencies)
|
||||
}
|
||||
|
||||
disabledJoin, err := svc.SetAgencyJoinEnabled(ctx, hostservice.SetAgencyJoinEnabledInput{
|
||||
CommandID: "admin-disable-agency-join-901",
|
||||
|
||||
@ -30,8 +30,13 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea
|
||||
`, agencyColumns)
|
||||
args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive}
|
||||
if strings.TrimSpace(command.Keyword) != "" {
|
||||
query += " AND name LIKE ?"
|
||||
args = append(args, "%"+strings.TrimSpace(command.Keyword)+"%")
|
||||
keyword := strings.TrimSpace(command.Keyword)
|
||||
query += ` AND (name LIKE ? OR owner_user_id IN (
|
||||
SELECT user_id
|
||||
FROM users
|
||||
WHERE app_code = ? AND current_display_user_id = ?
|
||||
))`
|
||||
args = append(args, "%"+keyword+"%", appcode.FromContext(ctx), keyword)
|
||||
}
|
||||
query += " ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?"
|
||||
args = append(args, command.PageSize)
|
||||
|
||||
@ -1417,6 +1417,31 @@ func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateResourceRejectsDuplicateResourceCodeAsConflict(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
ctx := context.Background()
|
||||
|
||||
command := resourcedomain.ResourceCommand{
|
||||
ResourceCode: "duplicate_badge",
|
||||
ResourceType: resourcedomain.TypeBadge,
|
||||
Name: "Duplicate Badge",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Grantable: true,
|
||||
ManagerGrantEnabled: true,
|
||||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||||
UsageScopes: []string{"profile"},
|
||||
OperatorUserID: 90001,
|
||||
}
|
||||
if _, err := svc.CreateResource(ctx, command); err != nil {
|
||||
t.Fatalf("first CreateResource failed: %v", err)
|
||||
}
|
||||
command.Name = "Duplicate Badge Again"
|
||||
if _, err := svc.CreateResource(ctx, command); !xerr.IsCode(err, xerr.Conflict) {
|
||||
t.Fatalf("duplicate resource_code should be conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。
|
||||
func TestManagerCenterCannotGrantResourceGroup(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -146,7 +146,7 @@ func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.
|
||||
nowMs,
|
||||
)
|
||||
if err != nil {
|
||||
return resourcedomain.Resource{}, err
|
||||
return resourcedomain.Resource{}, mapResourceWriteError(err)
|
||||
}
|
||||
resourceID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
@ -225,7 +225,7 @@ func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.
|
||||
command.ResourceID,
|
||||
)
|
||||
if err != nil {
|
||||
return resourcedomain.Resource{}, err
|
||||
return resourcedomain.Resource{}, mapResourceWriteError(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil {
|
||||
return resourcedomain.Resource{}, err
|
||||
@ -2215,6 +2215,13 @@ func resourceOffset(page int32, pageSize int32) int32 {
|
||||
return (page - 1) * pageSize
|
||||
}
|
||||
|
||||
func mapResourceWriteError(err error) error {
|
||||
if isMySQLDuplicateError(err) {
|
||||
return xerr.New(xerr.Conflict, "resource_code already exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand {
|
||||
command.ResourceCode = strings.TrimSpace(command.ResourceCode)
|
||||
command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user