幸运礼物

This commit is contained in:
zhx 2026-05-26 02:39:00 +08:00
parent 144cd3d781
commit a0ee5962df
25 changed files with 1516 additions and 580 deletions

View File

@ -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" +

View File

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

View File

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

View File

@ -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(),

View File

@ -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);

View File

@ -112,6 +112,7 @@ type DrawResult struct {
RuleVersion int64
ExperiencePool string
SelectedTierID string
MultiplierPPM int64
BaseRewardCoins int64
RoomAtmosphereRewardCoins int64
ActivitySubsidyCoins int64

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -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)

View File

@ -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"))

View File

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

View File

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

View File

@ -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 {

View File

@ -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 中有对应群组。

View File

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

View File

@ -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 {

View 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)
}
}

View File

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

View File

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

View File

@ -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)

View File

@ -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)

View File

@ -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)