幸运礼物

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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -10879,6 +10880,13 @@ func (x *LuckyGiftDrawResult) GetPoolId() string {
return "" return ""
} }
func (x *LuckyGiftDrawResult) GetMultiplierPpm() int64 {
if x != nil {
return x.MultiplierPpm
}
return 0
}
type ExecuteLuckyGiftDrawRequest struct { type ExecuteLuckyGiftDrawRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
LuckyGift *LuckyGiftMeta `protobuf:"bytes,1,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"` 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" + "\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" + "\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" +
"\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\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" + "\x13LuckyGiftDrawResult\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" + "\n" +
@ -12696,7 +12704,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\x0estage_feedback\x18\x11 \x01(\bR\rstageFeedback\x12'\n" + "\x0estage_feedback\x18\x11 \x01(\bR\rstageFeedback\x12'\n" +
"\x0fhigh_multiplier\x18\x12 \x01(\bR\x0ehighMultiplier\x12\"\n" + "\x0fhigh_multiplier\x18\x12 \x01(\bR\x0ehighMultiplier\x12\"\n" +
"\rcreated_at_ms\x18\x13 \x01(\x03R\vcreatedAtMs\x12\x17\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" + "\x1bExecuteLuckyGiftDrawRequest\x12?\n" +
"\n" + "\n" +
"lucky_gift\x18\x01 \x01(\v2 .hyapp.activity.v1.LuckyGiftMetaR\tluckyGift\"^\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; bool high_multiplier = 18;
int64 created_at_ms = 19; int64 created_at_ms = 19;
string pool_id = 20; string pool_id = 20;
int64 multiplier_ppm = 21;
} }
message ExecuteLuckyGiftDrawRequest { 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; 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 // RoomTreasureRewardItem
message RoomTreasureRewardItem { message RoomTreasureRewardItem {
string reward_item_id = 1; string reward_item_id = 1;
@ -695,6 +716,7 @@ message SendGiftResponse {
repeated RankItem gift_rank = 4; repeated RankItem gift_rank = 4;
RoomSnapshot room = 5; RoomSnapshot room = 5;
RoomTreasureState treasure = 6; RoomTreasureState treasure = 6;
LuckyGiftDrawResult lucky_gift = 7;
} }
// CheckSpeakPermissionRequest IM gateway // CheckSpeakPermissionRequest IM gateway

View File

@ -88,6 +88,7 @@ type drawDTO struct {
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"` ExperiencePool string `json:"experience_pool"`
SelectedTierID string `json:"selected_tier_id"` SelectedTierID string `json:"selected_tier_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
BaseRewardCoins int64 `json:"base_reward_coins"` BaseRewardCoins int64 `json:"base_reward_coins"`
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"` RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"` ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
@ -346,6 +347,7 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
RuleVersion: draw.GetRuleVersion(), RuleVersion: draw.GetRuleVersion(),
ExperiencePool: draw.GetExperiencePool(), ExperiencePool: draw.GetExperiencePool(),
SelectedTierID: draw.GetSelectedTierId(), SelectedTierID: draw.GetSelectedTierId(),
MultiplierPPM: draw.GetMultiplierPpm(),
BaseRewardCoins: draw.GetBaseRewardCoins(), BaseRewardCoins: draw.GetBaseRewardCoins(),
RoomAtmosphereRewardCoins: draw.GetRoomAtmosphereRewardCoins(), RoomAtmosphereRewardCoins: draw.GetRoomAtmosphereRewardCoins(),
ActivitySubsidyCoins: draw.GetActivitySubsidyCoins(), 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), 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) 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='消息分发任务表'; ) 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 RuleVersion int64
ExperiencePool string ExperiencePool string
SelectedTierID string SelectedTierID string
MultiplierPPM int64
BaseRewardCoins int64 BaseRewardCoins int64
RoomAtmosphereRewardCoins int64 RoomAtmosphereRewardCoins int64
ActivitySubsidyCoins int64 ActivitySubsidyCoins int64

View File

@ -415,6 +415,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
RuleVersion: config.RuleVersion, RuleVersion: config.RuleVersion,
ExperiencePool: experiencePool, ExperiencePool: experiencePool,
SelectedTierID: candidate.TierID, SelectedTierID: candidate.TierID,
MultiplierPPM: candidate.MultiplierPPM,
BaseRewardCoins: candidate.BaseReward, BaseRewardCoins: candidate.BaseReward,
RoomAtmosphereRewardCoins: candidate.RoomReward, RoomAtmosphereRewardCoins: candidate.RoomReward,
ActivitySubsidyCoins: candidate.ActivityReward, ActivitySubsidyCoins: candidate.ActivityReward,
@ -604,6 +605,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id, 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, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status, 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 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() { for rows.Next() {
var item domain.DrawResult var item domain.DrawResult
if err := rows.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID, 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.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex, &item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil { &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, ` row := tx.QueryRowContext(ctx, `
SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id, 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, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status, 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 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 var item domain.DrawResult
if err := row.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID, 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.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex, &item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil { &item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {

View File

@ -270,6 +270,7 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR
RuleVersion: result.RuleVersion, RuleVersion: result.RuleVersion,
ExperiencePool: result.ExperiencePool, ExperiencePool: result.ExperiencePool,
SelectedTierId: result.SelectedTierID, SelectedTierId: result.SelectedTierID,
MultiplierPpm: result.MultiplierPPM,
BaseRewardCoins: result.BaseRewardCoins, BaseRewardCoins: result.BaseRewardCoins,
RoomAtmosphereRewardCoins: result.RoomAtmosphereRewardCoins, RoomAtmosphereRewardCoins: result.RoomAtmosphereRewardCoins,
ActivitySubsidyCoins: result.ActivitySubsidyCoins, ActivitySubsidyCoins: result.ActivitySubsidyCoins,

View File

@ -66,6 +66,8 @@ type UserCountryQueryClient interface {
// UserHostClient 抽象 gateway 对 user-service host domain 身份查询能力的依赖。 // UserHostClient 抽象 gateway 对 user-service host domain 身份查询能力的依赖。
type UserHostClient interface { 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) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, 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) 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) { func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
return c.client.GetHostProfile(ctx, req) return c.client.GetHostProfile(ctx, req)
} }

View File

@ -221,6 +221,10 @@ func RequireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
// RoomMeta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。 // RoomMeta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。
// request_id 只做链路追踪command_id 必须来自客户端用户动作gateway 只裁剪空白。 // request_id 只做链路追踪command_id 必须来自客户端用户动作gateway 只裁剪空白。
func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta { 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{ return &roomv1.RequestMeta{
RequestId: RequestIDFromContext(request.Context()), RequestId: RequestIDFromContext(request.Context()),
CommandId: strings.TrimSpace(commandID), CommandId: strings.TrimSpace(commandID),
@ -228,7 +232,7 @@ func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.Re
RoomId: roomID, RoomId: roomID,
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
GatewayNodeId: "gateway-local", GatewayNodeId: "gateway-local",
SessionId: idgen.New("sess"), SessionId: sessionID,
SentAtMs: time.Now().UnixMilli(), SentAtMs: time.Now().UnixMilli(),
} }
} }

View File

@ -67,6 +67,8 @@ type UserHandlers struct {
GetMyIdentity http.HandlerFunc GetMyIdentity http.HandlerFunc
GetMyHostIdentity http.HandlerFunc GetMyHostIdentity http.HandlerFunc
GetMyRoleSummary http.HandlerFunc GetMyRoleSummary http.HandlerFunc
SearchHostAgencies http.HandlerFunc
ApplyToHostAgency http.HandlerFunc
CompleteMyOnboarding http.HandlerFunc CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc UpdateMyProfile http.HandlerFunc
ChangeMyCountry http.HandlerFunc ChangeMyCountry http.HandlerFunc
@ -268,6 +270,8 @@ func (r routes) registerUserRoutes() {
r.auth("/users/me/identity", "", h.GetMyIdentity) r.auth("/users/me/identity", "", h.GetMyIdentity)
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity) r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
r.profile("/users/me/role-summary", "", h.GetMyRoleSummary) 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.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile) r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/country/change", "", h.ChangeMyCountry) r.profile("/users/me/country/change", "", h.ChangeMyCountry)

View File

@ -52,6 +52,7 @@ type fakeRoomClient struct {
lastKick *roomv1.KickUserRequest lastKick *roomv1.KickUserRequest
lastUnban *roomv1.UnbanUserRequest lastUnban *roomv1.UnbanUserRequest
lastGift *roomv1.SendGiftRequest lastGift *roomv1.SendGiftRequest
sendGiftResp *roomv1.SendGiftResponse
lastFollowRoom *roomv1.FollowRoomRequest lastFollowRoom *roomv1.FollowRoomRequest
lastUnfollowRoom *roomv1.UnfollowRoomRequest lastUnfollowRoom *roomv1.UnfollowRoomRequest
createErr error 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) { func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
f.lastGift = req f.lastGift = req
if f.sendGiftResp != nil {
return f.sendGiftResp, nil
}
return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil
} }
@ -315,21 +319,27 @@ type fakeUserCountryQueryClient struct {
} }
type fakeUserHostClient struct { type fakeUserHostClient struct {
last *userv1.GetCoinSellerProfileRequest lastSearchAgencies *userv1.SearchAgenciesRequest
profile *userv1.CoinSellerProfile searchAgenciesResp *userv1.SearchAgenciesResponse
err error searchAgenciesErr error
lastHost *userv1.GetHostProfileRequest lastApplyAgency *userv1.ApplyToAgencyRequest
hostProfile *userv1.HostProfile applyAgencyResp *userv1.ApplyToAgencyResponse
hostErr error applyAgencyErr error
lastBD *userv1.GetBDProfileRequest last *userv1.GetCoinSellerProfileRequest
bdProfile *userv1.BDProfile profile *userv1.CoinSellerProfile
bdErr error err error
lastRoleSummary *userv1.GetUserRoleSummaryRequest lastHost *userv1.GetHostProfileRequest
roleSummary *userv1.UserRoleSummary hostProfile *userv1.HostProfile
roleSummaryErr error hostErr error
lastCapability *userv1.CheckBusinessCapabilityRequest lastBD *userv1.GetBDProfileRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse bdProfile *userv1.BDProfile
capabilityErr error bdErr error
lastRoleSummary *userv1.GetUserRoleSummaryRequest
roleSummary *userv1.UserRoleSummary
roleSummaryErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
} }
type fakeWalletClient struct { 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 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) { func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req f.lastHost = req
if f.hostErr != nil { 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) { func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
previousNow := roomapi.TimeNow previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) } 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) { func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
client := &fakeRoomClient{} client := &fakeRoomClient{}
router := NewHandler(client).Routes(auth.NewVerifier("secret")) router := NewHandler(client).Routes(auth.NewVerifier("secret"))

View File

@ -48,6 +48,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
GetMyIdentity: h.getMyIdentity, GetMyIdentity: h.getMyIdentity,
GetMyHostIdentity: h.getMyHostIdentity, GetMyHostIdentity: h.getMyHostIdentity,
GetMyRoleSummary: h.getMyRoleSummary, GetMyRoleSummary: h.getMyRoleSummary,
SearchHostAgencies: h.searchHostAgencies,
ApplyToHostAgency: h.applyToHostAgency,
CompleteMyOnboarding: h.completeMyOnboarding, CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile, UpdateMyProfile: h.updateMyProfile,
ChangeMyCountry: h.changeMyCountry, 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 return nil, err
} }
var activityConn *grpc.ClientConn 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())) activityConn, err = grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
_ = walletConn.Close() _ = walletConn.Close()
@ -189,6 +189,10 @@ func New(cfg config.Config) (*App, error) {
treasureOpenScheduler = integration.NewRocketMQTreasureOpenScheduler(treasureProducer, cfg.RocketMQ.TreasureOpen.Topic) treasureOpenScheduler = integration.NewRocketMQTreasureOpenScheduler(treasureProducer, cfg.RocketMQ.TreasureOpen.Topic)
} }
outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...) outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
var luckyGiftClient integration.LuckyGiftClient
if activityConn != nil {
luckyGiftClient = integration.NewGRPCLuckyGiftClient(activityConn)
}
// 领域服务只依赖接口App 层负责选择 MySQL/Redis/gRPC 具体实现。 // 领域服务只依赖接口App 层负责选择 MySQL/Redis/gRPC 具体实现。
svc := roomservice.New(roomservice.Config{ svc := roomservice.New(roomservice.Config{
@ -200,7 +204,7 @@ func New(cfg config.Config) (*App, error) {
MicPublishTimeout: cfg.MicPublishTimeout, MicPublishTimeout: cfg.MicPublishTimeout,
RTCUserRemover: rtcUserRemover, RTCUserRemover: rtcUserRemover,
RoomTreasureOpenScheduler: treasureOpenScheduler, RoomTreasureOpenScheduler: treasureOpenScheduler,
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher) }, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient)
if cfg.RocketMQ.TreasureOpen.Enabled { if cfg.RocketMQ.TreasureOpen.Enabled {
treasureConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ConsumerGroup, cfg.RocketMQ.TreasureOpen.ConsumerMaxReconsumeTimes)) treasureConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ConsumerGroup, cfg.RocketMQ.TreasureOpen.ConsumerMaxReconsumeTimes))
if err != nil { if err != nil {

View File

@ -4,6 +4,7 @@ package integration
import ( import (
"context" "context"
activityv1 "hyapp.local/api/proto/activity/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
@ -17,6 +18,14 @@ type WalletClient interface {
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) 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 直接桥接能力;房间命令主链路不再调用它。 // RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。
type RoomEventPublisher interface { type RoomEventPublisher interface {
// EnsureRoomGroup 确保房间在腾讯云 IM 中有对应群组。 // EnsureRoomGroup 确保房间在腾讯云 IM 中有对应群组。

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"google.golang.org/grpc" "google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/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 负责展开资产和权益。 // 宝箱奖励以 resource group 为后台配置单位wallet-service 负责展开资产和权益。
return c.client.GrantResourceGroup(ctx, req) 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" "strings"
"time" "time"
activityv1 "hyapp.local/api/proto/activity/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/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 { if !exists {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found") 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) treasureConfig, err := s.roomTreasureConfig(ctx)
if err != nil { if err != nil {
return mutationResult{}, nil, err return mutationResult{}, nil, err
@ -98,6 +119,33 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
settledCommand.HeatValue = heatValue settledCommand.HeatValue = heatValue
settledCommand.PriceVersion = billing.GetPriceVersion() settledCommand.PriceVersion = billing.GetPriceVersion()
settledCommand.GiftTypeCode = billing.GetGiftTypeCode() 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 同步更新热度和本地礼物榜。 // 扣费成功后Room Cell 同步更新热度和本地礼物榜。
current.Heat += heatValue current.Heat += heatValue
@ -167,6 +215,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
billingReceiptID: billing.GetBillingReceiptId(), billingReceiptID: billing.GetBillingReceiptId(),
roomHeat: current.Heat, roomHeat: current.Heat,
giftRank: cloneProtoRank(current.GiftRank), giftRank: cloneProtoRank(current.GiftRank),
luckyGift: luckyGift,
commandPayload: commandPayload, commandPayload: commandPayload,
walletDebitMS: walletDebitMS, walletDebitMS: walletDebitMS,
syncEvent: &tencentim.RoomEvent{ syncEvent: &tencentim.RoomEvent{
@ -202,9 +251,71 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
GiftRank: result.giftRank, GiftRank: result.giftRank,
Room: result.snapshot, Room: result.snapshot,
Treasure: result.snapshot.GetTreasure(), Treasure: result.snapshot.GetTreasure(),
LuckyGift: result.luckyGift,
}, nil }, 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 { func normalizeGiftTargetType(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
switch 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 repository Repository
// wallet 是 SendGift 的同步扣费依赖,扣费失败不能进入 Room Cell 状态变更。 // wallet 是 SendGift 的同步扣费依赖,扣费失败不能进入 Room Cell 状态变更。
wallet integration.WalletClient wallet integration.WalletClient
// luckyGift 是幸运礼物同步检查和抽奖依赖;未配置时幸运礼物按未启用处理。
luckyGift integration.LuckyGiftClient
// syncPublisher 是 room-service 到腾讯云 IM 的低时延房间系统事件桥。 // syncPublisher 是 room-service 到腾讯云 IM 的低时延房间系统事件桥。
syncPublisher integration.RoomEventPublisher syncPublisher integration.RoomEventPublisher
// outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。 // outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。
@ -97,6 +99,8 @@ type mutationResult struct {
roomHeat int64 roomHeat int64
// giftRank 是 SendGift 后的本地礼物榜投影。 // giftRank 是 SendGift 后的本地礼物榜投影。
giftRank []*roomv1.RankItem giftRank []*roomv1.RankItem
// luckyGift 是扣费成功后同步执行的幸运礼物抽奖结果;普通礼物保持 nil。
luckyGift *roomv1.LuckyGiftDrawResult
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log用于恢复。 // commandPayload 允许少数命令把外部依赖的结算快照写入 command log用于恢复。
commandPayload []byte commandPayload []byte
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。 // syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。
@ -117,7 +121,7 @@ type mutationResult struct {
} }
// New 初始化 room-service 领域服务。 // 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 usedClock := cfg.Clock
if usedClock == nil { if usedClock == nil {
// 生产默认系统时钟,测试可注入固定时钟保证断言稳定。 // 生产默认系统时钟,测试可注入固定时钟保证断言稳定。
@ -148,6 +152,11 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
micPublishTimeout = 15 * time.Second micPublishTimeout = 15 * time.Second
} }
var luckyGiftClient integration.LuckyGiftClient
if len(luckyGift) > 0 {
luckyGiftClient = luckyGift[0]
}
return &Service{ return &Service{
nodeID: cfg.NodeID, nodeID: cfg.NodeID,
leaseTTL: cfg.LeaseTTL, leaseTTL: cfg.LeaseTTL,
@ -159,6 +168,7 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
directory: directory, directory: directory,
repository: repository, repository: repository,
wallet: wallet, wallet: wallet,
luckyGift: luckyGiftClient,
syncPublisher: syncPublisher, syncPublisher: syncPublisher,
outboxPublisher: outboxPublisher, outboxPublisher: outboxPublisher,
roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler, roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler,

View File

@ -400,6 +400,13 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID { if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
t.Fatalf("created agency should be searchable: %+v", agencies) 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{ disabledJoin, err := svc.SetAgencyJoinEnabled(ctx, hostservice.SetAgencyJoinEnabledInput{
CommandID: "admin-disable-agency-join-901", CommandID: "admin-disable-agency-join-901",

View File

@ -30,8 +30,13 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea
`, agencyColumns) `, agencyColumns)
args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive} args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive}
if strings.TrimSpace(command.Keyword) != "" { if strings.TrimSpace(command.Keyword) != "" {
query += " AND name LIKE ?" keyword := strings.TrimSpace(command.Keyword)
args = append(args, "%"+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 ?" query += " ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?"
args = append(args, command.PageSize) 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 固定首版经理中心只允许单资源赠送。 // TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。
func TestManagerCenterCannotGrantResourceGroup(t *testing.T) { func TestManagerCenterCannotGrantResourceGroup(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)

View File

@ -146,7 +146,7 @@ func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.
nowMs, nowMs,
) )
if err != nil { if err != nil {
return resourcedomain.Resource{}, err return resourcedomain.Resource{}, mapResourceWriteError(err)
} }
resourceID, err := result.LastInsertId() resourceID, err := result.LastInsertId()
if err != nil { if err != nil {
@ -225,7 +225,7 @@ func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.
command.ResourceID, command.ResourceID,
) )
if err != nil { if err != nil {
return resourcedomain.Resource{}, err return resourcedomain.Resource{}, mapResourceWriteError(err)
} }
if affected, err := result.RowsAffected(); err != nil { if affected, err := result.RowsAffected(); err != nil {
return resourcedomain.Resource{}, err return resourcedomain.Resource{}, err
@ -2215,6 +2215,13 @@ func resourceOffset(page int32, pageSize int32) int32 {
return (page - 1) * pageSize 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 { func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand {
command.ResourceCode = strings.TrimSpace(command.ResourceCode) command.ResourceCode = strings.TrimSpace(command.ResourceCode)
command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType) command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)