Add activity reward programs
This commit is contained in:
parent
471186a73c
commit
02ce8874c3
3
.gitignore
vendored
3
.gitignore
vendored
@ -28,3 +28,6 @@ mysql-data/
|
|||||||
redis-data/
|
redis-data/
|
||||||
|
|
||||||
.waylog/
|
.waylog/
|
||||||
|
|
||||||
|
.tmp/
|
||||||
|
/tmp/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -524,11 +524,14 @@ message ListRegistrationRewardClaimsRequest {
|
|||||||
int64 user_id = 3;
|
int64 user_id = 3;
|
||||||
int32 page = 4;
|
int32 page = 4;
|
||||||
int32 page_size = 5;
|
int32 page_size = 5;
|
||||||
|
int64 claimed_start_ms = 6;
|
||||||
|
int64 claimed_end_ms = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListRegistrationRewardClaimsResponse {
|
message ListRegistrationRewardClaimsResponse {
|
||||||
repeated RegistrationRewardClaim claims = 1;
|
repeated RegistrationRewardClaim claims = 1;
|
||||||
int64 total = 2;
|
int64 total = 2;
|
||||||
|
int64 today_claimed_count = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FirstRechargeRewardTier 是首冲奖励的一个金币充值档位;max_coin_amount=0 表示无上限。
|
// FirstRechargeRewardTier 是首冲奖励的一个金币充值档位;max_coin_amount=0 表示无上限。
|
||||||
@ -647,6 +650,255 @@ message ListFirstRechargeRewardClaimsResponse {
|
|||||||
int64 total = 2;
|
int64 total = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardTier 是累充奖励的单个 USD 档位;threshold_usd_minor 以美分存储。
|
||||||
|
message CumulativeRechargeRewardTier {
|
||||||
|
int64 tier_id = 1;
|
||||||
|
string tier_code = 2;
|
||||||
|
string tier_name = 3;
|
||||||
|
int64 threshold_usd_minor = 4;
|
||||||
|
int64 resource_group_id = 5;
|
||||||
|
string status = 6;
|
||||||
|
int32 sort_order = 7;
|
||||||
|
int64 created_at_ms = 8;
|
||||||
|
int64 updated_at_ms = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardConfig 是当前 App 的累充奖励配置。
|
||||||
|
message CumulativeRechargeRewardConfig {
|
||||||
|
string app_code = 1;
|
||||||
|
bool enabled = 2;
|
||||||
|
repeated CumulativeRechargeRewardTier tiers = 3;
|
||||||
|
int64 updated_by_admin_id = 4;
|
||||||
|
int64 created_at_ms = 5;
|
||||||
|
int64 updated_at_ms = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardGrant 是用户在一个 UTC 自然周内达成某个档位后的发放事实。
|
||||||
|
message CumulativeRechargeRewardGrant {
|
||||||
|
string grant_id = 1;
|
||||||
|
string app_code = 2;
|
||||||
|
string cycle_key = 3;
|
||||||
|
int64 user_id = 4;
|
||||||
|
string event_id = 5;
|
||||||
|
string transaction_id = 6;
|
||||||
|
string command_id = 7;
|
||||||
|
int64 tier_id = 8;
|
||||||
|
string tier_code = 9;
|
||||||
|
int64 threshold_usd_minor = 10;
|
||||||
|
int64 resource_group_id = 11;
|
||||||
|
int64 reached_usd_minor = 12;
|
||||||
|
int64 qualifying_usd_minor = 13;
|
||||||
|
int64 recharge_coin_amount = 14;
|
||||||
|
string recharge_type = 15;
|
||||||
|
string status = 16;
|
||||||
|
string wallet_command_id = 17;
|
||||||
|
string wallet_grant_id = 18;
|
||||||
|
string failure_reason = 19;
|
||||||
|
int64 granted_at_ms = 20;
|
||||||
|
int64 created_at_ms = 21;
|
||||||
|
int64 updated_at_ms = 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardProgress 是用户在当前 UTC 周期内的累充累计投影。
|
||||||
|
message CumulativeRechargeRewardProgress {
|
||||||
|
string app_code = 1;
|
||||||
|
string cycle_key = 2;
|
||||||
|
int64 user_id = 3;
|
||||||
|
int64 total_usd_minor = 4;
|
||||||
|
int64 total_coin_amount = 5;
|
||||||
|
int64 first_recharged_at_ms = 6;
|
||||||
|
int64 last_recharged_at_ms = 7;
|
||||||
|
int64 updated_at_ms = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardStatus 是 App 累充奖励 H5 可直接展示的当前状态。
|
||||||
|
message CumulativeRechargeRewardStatus {
|
||||||
|
CumulativeRechargeRewardConfig config = 1;
|
||||||
|
CumulativeRechargeRewardProgress progress = 2;
|
||||||
|
repeated CumulativeRechargeRewardGrant grants = 3;
|
||||||
|
string cycle_key = 4;
|
||||||
|
int64 period_start_ms = 5;
|
||||||
|
int64 period_end_ms = 6;
|
||||||
|
int64 server_time_ms = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetCumulativeRechargeRewardStatusRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetCumulativeRechargeRewardStatusResponse {
|
||||||
|
CumulativeRechargeRewardStatus status = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeCumulativeRechargeRewardRequest 是 wallet 充值事实消费入口;所有成功充值都会按 UTC 周期累计。
|
||||||
|
message ConsumeCumulativeRechargeRewardRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string event_id = 2;
|
||||||
|
string transaction_id = 3;
|
||||||
|
string command_id = 4;
|
||||||
|
int64 user_id = 5;
|
||||||
|
int64 recharge_coin_amount = 6;
|
||||||
|
int64 recharge_sequence = 7;
|
||||||
|
string recharge_type = 8;
|
||||||
|
int64 qualifying_usd_minor = 9;
|
||||||
|
string payload_json = 10;
|
||||||
|
int64 occurred_at_ms = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ConsumeCumulativeRechargeRewardResponse {
|
||||||
|
repeated CumulativeRechargeRewardGrant grants = 1;
|
||||||
|
bool consumed = 2;
|
||||||
|
string reason = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetCumulativeRechargeRewardConfigRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetCumulativeRechargeRewardConfigResponse {
|
||||||
|
CumulativeRechargeRewardConfig config = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateCumulativeRechargeRewardConfigRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
bool enabled = 2;
|
||||||
|
repeated CumulativeRechargeRewardTier tiers = 3;
|
||||||
|
int64 operator_admin_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateCumulativeRechargeRewardConfigResponse {
|
||||||
|
CumulativeRechargeRewardConfig config = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCumulativeRechargeRewardGrantsRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string status = 2;
|
||||||
|
int64 user_id = 3;
|
||||||
|
string cycle_key = 4;
|
||||||
|
int32 page = 5;
|
||||||
|
int32 page_size = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListCumulativeRechargeRewardGrantsResponse {
|
||||||
|
repeated CumulativeRechargeRewardGrant grants = 1;
|
||||||
|
int64 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomTurnoverRewardTier 是房间流水奖励的单个档位;达到 threshold_coin_spent 后可命中奖励。
|
||||||
|
message RoomTurnoverRewardTier {
|
||||||
|
int64 tier_id = 1;
|
||||||
|
string tier_code = 2;
|
||||||
|
string tier_name = 3;
|
||||||
|
int64 threshold_coin_spent = 4;
|
||||||
|
int64 reward_coin_amount = 5;
|
||||||
|
string status = 6;
|
||||||
|
int32 sort_order = 7;
|
||||||
|
int64 created_at_ms = 8;
|
||||||
|
int64 updated_at_ms = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomTurnoverRewardConfig 是当前 App 的房间流水奖励配置;结算只命中最高有效档。
|
||||||
|
message RoomTurnoverRewardConfig {
|
||||||
|
string app_code = 1;
|
||||||
|
bool enabled = 2;
|
||||||
|
repeated RoomTurnoverRewardTier tiers = 3;
|
||||||
|
int64 updated_by_admin_id = 4;
|
||||||
|
int64 created_at_ms = 5;
|
||||||
|
int64 updated_at_ms = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomTurnoverRewardSettlement 是某个房间单个 UTC 周期的结算事实。
|
||||||
|
message RoomTurnoverRewardSettlement {
|
||||||
|
string settlement_id = 1;
|
||||||
|
string app_code = 2;
|
||||||
|
string room_id = 3;
|
||||||
|
int64 owner_user_id = 4;
|
||||||
|
int64 period_start_ms = 5;
|
||||||
|
int64 period_end_ms = 6;
|
||||||
|
int64 coin_spent = 7;
|
||||||
|
int64 tier_id = 8;
|
||||||
|
string tier_code = 9;
|
||||||
|
int64 threshold_coin_spent = 10;
|
||||||
|
int64 reward_coin_amount = 11;
|
||||||
|
string status = 12;
|
||||||
|
string wallet_command_id = 13;
|
||||||
|
string wallet_transaction_id = 14;
|
||||||
|
string failure_reason = 15;
|
||||||
|
int64 settled_at_ms = 16;
|
||||||
|
int64 created_at_ms = 17;
|
||||||
|
int64 updated_at_ms = 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomTurnoverRewardStatus 给 App H5 返回当前 UTC 周期、房间流水、预计奖励和上一期结算。
|
||||||
|
message RoomTurnoverRewardStatus {
|
||||||
|
RoomTurnoverRewardConfig config = 1;
|
||||||
|
string room_id = 2;
|
||||||
|
int64 owner_user_id = 3;
|
||||||
|
int64 period_start_ms = 4;
|
||||||
|
int64 period_end_ms = 5;
|
||||||
|
int64 current_coin_spent = 6;
|
||||||
|
int64 expected_reward_coin_amount = 7;
|
||||||
|
RoomTurnoverRewardTier matched_tier = 8;
|
||||||
|
RoomTurnoverRewardSettlement latest_settlement = 9;
|
||||||
|
int64 server_time_ms = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRoomTurnoverRewardStatusRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
string room_id = 3;
|
||||||
|
int64 owner_user_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRoomTurnoverRewardStatusResponse {
|
||||||
|
RoomTurnoverRewardStatus status = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRoomTurnoverRewardConfigRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRoomTurnoverRewardConfigResponse {
|
||||||
|
RoomTurnoverRewardConfig config = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateRoomTurnoverRewardConfigRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
bool enabled = 2;
|
||||||
|
repeated RoomTurnoverRewardTier tiers = 3;
|
||||||
|
int64 operator_admin_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateRoomTurnoverRewardConfigResponse {
|
||||||
|
RoomTurnoverRewardConfig config = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListRoomTurnoverRewardSettlementsRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string status = 2;
|
||||||
|
string room_id = 3;
|
||||||
|
int64 owner_user_id = 4;
|
||||||
|
int64 period_start_ms = 5;
|
||||||
|
int32 page = 6;
|
||||||
|
int32 page_size = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListRoomTurnoverRewardSettlementsResponse {
|
||||||
|
repeated RoomTurnoverRewardSettlement settlements = 1;
|
||||||
|
int64 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RetryRoomTurnoverRewardSettlementRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string settlement_id = 2;
|
||||||
|
int64 operator_admin_id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RetryRoomTurnoverRewardSettlementResponse {
|
||||||
|
RoomTurnoverRewardSettlement settlement = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// SevenDayCheckInReward 是七日签到配置中某一天的资源组奖励。
|
// SevenDayCheckInReward 是七日签到配置中某一天的资源组奖励。
|
||||||
message SevenDayCheckInReward {
|
message SevenDayCheckInReward {
|
||||||
int32 day_index = 1;
|
int32 day_index = 1;
|
||||||
@ -1396,6 +1648,162 @@ message GetLuckyGiftDrawSummaryResponse {
|
|||||||
LuckyGiftDrawSummary summary = 1;
|
LuckyGiftDrawSummary summary = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WeeklyStarGift 是周星周期内参与积分的指定礼物。
|
||||||
|
message WeeklyStarGift {
|
||||||
|
string gift_id = 1;
|
||||||
|
int32 sort_order = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeeklyStarReward 是周星 Top 排名对应的资源组奖励。
|
||||||
|
message WeeklyStarReward {
|
||||||
|
int32 rank_no = 1;
|
||||||
|
int64 resource_group_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeeklyStarCycle 是一个按 UTC epoch ms 生效的周星配置周期。
|
||||||
|
message WeeklyStarCycle {
|
||||||
|
string cycle_id = 1;
|
||||||
|
string activity_code = 2;
|
||||||
|
int64 region_id = 3;
|
||||||
|
string title = 4;
|
||||||
|
string status = 5;
|
||||||
|
int64 start_ms = 6;
|
||||||
|
int64 end_ms = 7;
|
||||||
|
repeated WeeklyStarGift gifts = 8;
|
||||||
|
repeated WeeklyStarReward rewards = 9;
|
||||||
|
int64 created_by_admin_id = 10;
|
||||||
|
int64 updated_by_admin_id = 11;
|
||||||
|
int64 settled_at_ms = 12;
|
||||||
|
int64 created_at_ms = 13;
|
||||||
|
int64 updated_at_ms = 14;
|
||||||
|
string app_code = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeeklyStarLeaderboardEntry 是一个周期内用户维度的积分排名。
|
||||||
|
message WeeklyStarLeaderboardEntry {
|
||||||
|
int32 rank_no = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
int64 score = 3;
|
||||||
|
int64 first_scored_at_ms = 4;
|
||||||
|
int64 last_scored_at_ms = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeeklyStarSettlement 是周期结束后 Top 奖励发放的幂等记录。
|
||||||
|
message WeeklyStarSettlement {
|
||||||
|
string settlement_id = 1;
|
||||||
|
string cycle_id = 2;
|
||||||
|
int32 rank_no = 3;
|
||||||
|
int64 user_id = 4;
|
||||||
|
int64 score = 5;
|
||||||
|
int64 resource_group_id = 6;
|
||||||
|
string wallet_command_id = 7;
|
||||||
|
string wallet_grant_id = 8;
|
||||||
|
string status = 9;
|
||||||
|
string failure_reason = 10;
|
||||||
|
int32 attempt_count = 11;
|
||||||
|
int64 created_at_ms = 12;
|
||||||
|
int64 updated_at_ms = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWeeklyStarCyclesRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
int64 region_id = 2;
|
||||||
|
string status = 3;
|
||||||
|
int64 start_ms = 4;
|
||||||
|
int64 end_ms = 5;
|
||||||
|
int32 page = 6;
|
||||||
|
int32 page_size = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWeeklyStarCyclesResponse {
|
||||||
|
repeated WeeklyStarCycle cycles = 1;
|
||||||
|
int64 total = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetWeeklyStarCycleRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string cycle_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetWeeklyStarCycleResponse {
|
||||||
|
WeeklyStarCycle cycle = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpsertWeeklyStarCycleRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
WeeklyStarCycle cycle = 2;
|
||||||
|
int64 operator_admin_id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpsertWeeklyStarCycleResponse {
|
||||||
|
WeeklyStarCycle cycle = 1;
|
||||||
|
bool created = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetWeeklyStarCycleStatusRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string cycle_id = 2;
|
||||||
|
string status = 3;
|
||||||
|
int64 operator_admin_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetWeeklyStarCycleStatusResponse {
|
||||||
|
WeeklyStarCycle cycle = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWeeklyStarLeaderboardRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string cycle_id = 2;
|
||||||
|
int64 region_id = 3;
|
||||||
|
int32 page_size = 4;
|
||||||
|
string page_token = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWeeklyStarLeaderboardResponse {
|
||||||
|
WeeklyStarCycle cycle = 1;
|
||||||
|
repeated WeeklyStarLeaderboardEntry entries = 2;
|
||||||
|
string next_page_token = 3;
|
||||||
|
int64 total = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWeeklyStarSettlementsRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
string cycle_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWeeklyStarSettlementsResponse {
|
||||||
|
repeated WeeklyStarSettlement settlements = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetWeeklyStarCurrentRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
int64 region_id = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetWeeklyStarCurrentResponse {
|
||||||
|
WeeklyStarCycle cycle = 1;
|
||||||
|
repeated WeeklyStarLeaderboardEntry top_entries = 2;
|
||||||
|
WeeklyStarLeaderboardEntry my_entry = 3;
|
||||||
|
int64 server_time_ms = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWeeklyStarHistoryRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
int64 region_id = 2;
|
||||||
|
int32 limit = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WeeklyStarHistoryCycle {
|
||||||
|
WeeklyStarCycle cycle = 1;
|
||||||
|
repeated WeeklyStarLeaderboardEntry top_entries = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListWeeklyStarHistoryResponse {
|
||||||
|
repeated WeeklyStarHistoryCycle cycles = 1;
|
||||||
|
int64 server_time_ms = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// ActivityService 只暴露同步查询,不把事件消费伪装成 RPC。
|
// ActivityService 只暴露同步查询,不把事件消费伪装成 RPC。
|
||||||
service ActivityService {
|
service ActivityService {
|
||||||
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
|
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
|
||||||
@ -1418,6 +1826,8 @@ service ActivityCronService {
|
|||||||
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
|
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||||
rpc ProcessLevelRewardBatch(CronBatchRequest) returns (CronBatchResponse);
|
rpc ProcessLevelRewardBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||||
rpc ProcessAchievementRewardBatch(CronBatchRequest) returns (CronBatchResponse);
|
rpc ProcessAchievementRewardBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||||
|
rpc ProcessRoomTurnoverRewardSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||||
|
rpc ProcessWeeklyStarSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskService 拥有 App 任务查询、事件进度消费和领奖状态。
|
// TaskService 拥有 App 任务查询、事件进度消费和领奖状态。
|
||||||
@ -1451,6 +1861,18 @@ service LuckyGiftService {
|
|||||||
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
|
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoomTurnoverRewardService owns App reads for the weekly room turnover reward activity.
|
||||||
|
service RoomTurnoverRewardService {
|
||||||
|
rpc GetRoomTurnoverRewardStatus(GetRoomTurnoverRewardStatusRequest) returns (GetRoomTurnoverRewardStatusResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeeklyStarService owns App reads for the weekly specified-gift score activity.
|
||||||
|
service WeeklyStarService {
|
||||||
|
rpc GetWeeklyStarCurrent(GetWeeklyStarCurrentRequest) returns (GetWeeklyStarCurrentResponse);
|
||||||
|
rpc ListWeeklyStarLeaderboard(ListWeeklyStarLeaderboardRequest) returns (ListWeeklyStarLeaderboardResponse);
|
||||||
|
rpc ListWeeklyStarHistory(ListWeeklyStarHistoryRequest) returns (ListWeeklyStarHistoryResponse);
|
||||||
|
}
|
||||||
|
|
||||||
// BroadcastService owns server-side Tencent IM global/region broadcast delivery.
|
// BroadcastService owns server-side Tencent IM global/region broadcast delivery.
|
||||||
service BroadcastService {
|
service BroadcastService {
|
||||||
rpc EnsureBroadcastGroups(EnsureBroadcastGroupsRequest) returns (EnsureBroadcastGroupsResponse);
|
rpc EnsureBroadcastGroups(EnsureBroadcastGroupsRequest) returns (EnsureBroadcastGroupsResponse);
|
||||||
@ -1498,6 +1920,38 @@ service AdminFirstRechargeRewardService {
|
|||||||
rpc ListFirstRechargeRewardClaims(ListFirstRechargeRewardClaimsRequest) returns (ListFirstRechargeRewardClaimsResponse);
|
rpc ListFirstRechargeRewardClaims(ListFirstRechargeRewardClaimsRequest) returns (ListFirstRechargeRewardClaimsResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardService 拥有 App 累充奖励查询和 wallet 充值事实消费入口。
|
||||||
|
service CumulativeRechargeRewardService {
|
||||||
|
rpc GetCumulativeRechargeRewardStatus(GetCumulativeRechargeRewardStatusRequest) returns (GetCumulativeRechargeRewardStatusResponse);
|
||||||
|
rpc ConsumeCumulativeRechargeReward(ConsumeCumulativeRechargeRewardRequest) returns (ConsumeCumulativeRechargeRewardResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCumulativeRechargeRewardService 是后台活动管理访问累充奖励配置和发放记录的唯一入口。
|
||||||
|
service AdminCumulativeRechargeRewardService {
|
||||||
|
rpc GetCumulativeRechargeRewardConfig(GetCumulativeRechargeRewardConfigRequest) returns (GetCumulativeRechargeRewardConfigResponse);
|
||||||
|
rpc UpdateCumulativeRechargeRewardConfig(UpdateCumulativeRechargeRewardConfigRequest) returns (UpdateCumulativeRechargeRewardConfigResponse);
|
||||||
|
rpc ListCumulativeRechargeRewardGrants(ListCumulativeRechargeRewardGrantsRequest) returns (ListCumulativeRechargeRewardGrantsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRoomTurnoverRewardService 是后台活动管理访问房间流水奖励配置和结算记录的唯一入口。
|
||||||
|
service AdminRoomTurnoverRewardService {
|
||||||
|
rpc GetRoomTurnoverRewardConfig(GetRoomTurnoverRewardConfigRequest) returns (GetRoomTurnoverRewardConfigResponse);
|
||||||
|
rpc UpdateRoomTurnoverRewardConfig(UpdateRoomTurnoverRewardConfigRequest) returns (UpdateRoomTurnoverRewardConfigResponse);
|
||||||
|
rpc ListRoomTurnoverRewardSettlements(ListRoomTurnoverRewardSettlementsRequest) returns (ListRoomTurnoverRewardSettlementsResponse);
|
||||||
|
rpc RetryRoomTurnoverRewardSettlement(RetryRoomTurnoverRewardSettlementRequest) returns (RetryRoomTurnoverRewardSettlementResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWeeklyStarService 是后台活动管理访问周星周期、榜单和结算结果的唯一入口。
|
||||||
|
service AdminWeeklyStarService {
|
||||||
|
rpc ListWeeklyStarCycles(ListWeeklyStarCyclesRequest) returns (ListWeeklyStarCyclesResponse);
|
||||||
|
rpc CreateWeeklyStarCycle(UpsertWeeklyStarCycleRequest) returns (UpsertWeeklyStarCycleResponse);
|
||||||
|
rpc GetWeeklyStarCycle(GetWeeklyStarCycleRequest) returns (GetWeeklyStarCycleResponse);
|
||||||
|
rpc UpdateWeeklyStarCycle(UpsertWeeklyStarCycleRequest) returns (UpsertWeeklyStarCycleResponse);
|
||||||
|
rpc SetWeeklyStarCycleStatus(SetWeeklyStarCycleStatusRequest) returns (SetWeeklyStarCycleStatusResponse);
|
||||||
|
rpc ListWeeklyStarLeaderboard(ListWeeklyStarLeaderboardRequest) returns (ListWeeklyStarLeaderboardResponse);
|
||||||
|
rpc ListWeeklyStarSettlements(ListWeeklyStarSettlementsRequest) returns (ListWeeklyStarSettlementsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
// SevenDayCheckInService 拥有 App 七日签到查询和签到命令。
|
// SevenDayCheckInService 拥有 App 七日签到查询和签到命令。
|
||||||
service SevenDayCheckInService {
|
service SevenDayCheckInService {
|
||||||
rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse);
|
rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1345,6 +1345,30 @@ message CreditLuckyGiftRewardResponse {
|
|||||||
int64 granted_at_ms = 4;
|
int64 granted_at_ms = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreditRoomTurnoverRewardRequest 是 activity-service 每周房间流水奖励结算后的金币入账命令。
|
||||||
|
message CreditRoomTurnoverRewardRequest {
|
||||||
|
string command_id = 1;
|
||||||
|
string app_code = 2;
|
||||||
|
int64 target_user_id = 3;
|
||||||
|
int64 amount = 4;
|
||||||
|
string settlement_id = 5;
|
||||||
|
string room_id = 6;
|
||||||
|
int64 period_start_ms = 7;
|
||||||
|
int64 period_end_ms = 8;
|
||||||
|
int64 coin_spent = 9;
|
||||||
|
int64 tier_id = 10;
|
||||||
|
string tier_code = 11;
|
||||||
|
string reason = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreditRoomTurnoverRewardResponse 返回房间流水奖励入账流水和用户 COIN 账后余额。
|
||||||
|
message CreditRoomTurnoverRewardResponse {
|
||||||
|
string transaction_id = 1;
|
||||||
|
AssetBalance balance = 2;
|
||||||
|
int64 amount = 3;
|
||||||
|
int64 granted_at_ms = 4;
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyGameCoinChangeRequest 是 game-service 唯一可以调用的钱包游戏改账入口。
|
// ApplyGameCoinChangeRequest 是 game-service 唯一可以调用的钱包游戏改账入口。
|
||||||
message ApplyGameCoinChangeRequest {
|
message ApplyGameCoinChangeRequest {
|
||||||
string request_id = 1;
|
string request_id = 1;
|
||||||
@ -1627,6 +1651,7 @@ service WalletService {
|
|||||||
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
|
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
|
||||||
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
|
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
|
||||||
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
|
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
|
||||||
|
rpc CreditRoomTurnoverReward(CreditRoomTurnoverRewardRequest) returns (CreditRoomTurnoverRewardResponse);
|
||||||
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
|
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
|
||||||
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
|
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
|
||||||
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);
|
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);
|
||||||
|
|||||||
@ -259,6 +259,7 @@ const (
|
|||||||
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
|
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
|
||||||
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
|
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
|
||||||
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
|
WalletService_CreditLuckyGiftReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditLuckyGiftReward"
|
||||||
|
WalletService_CreditRoomTurnoverReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditRoomTurnoverReward"
|
||||||
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
|
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
|
||||||
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
|
WalletService_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
|
||||||
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
|
WalletService_UpdateRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRedPacketConfig"
|
||||||
@ -334,6 +335,7 @@ type WalletServiceClient interface {
|
|||||||
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
|
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
|
||||||
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
|
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
|
||||||
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error)
|
CreditLuckyGiftReward(ctx context.Context, in *CreditLuckyGiftRewardRequest, opts ...grpc.CallOption) (*CreditLuckyGiftRewardResponse, error)
|
||||||
|
CreditRoomTurnoverReward(ctx context.Context, in *CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*CreditRoomTurnoverRewardResponse, error)
|
||||||
ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error)
|
ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error)
|
||||||
GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error)
|
GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, error)
|
||||||
UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error)
|
UpdateRedPacketConfig(ctx context.Context, in *UpdateRedPacketConfigRequest, opts ...grpc.CallOption) (*UpdateRedPacketConfigResponse, error)
|
||||||
@ -933,6 +935,16 @@ func (c *walletServiceClient) CreditLuckyGiftReward(ctx context.Context, in *Cre
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *walletServiceClient) CreditRoomTurnoverReward(ctx context.Context, in *CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*CreditRoomTurnoverRewardResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CreditRoomTurnoverRewardResponse)
|
||||||
|
err := c.cc.Invoke(ctx, WalletService_CreditRoomTurnoverReward_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *walletServiceClient) ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error) {
|
func (c *walletServiceClient) ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ApplyGameCoinChangeResponse)
|
out := new(ApplyGameCoinChangeResponse)
|
||||||
@ -1087,6 +1099,7 @@ type WalletServiceServer interface {
|
|||||||
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
|
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
|
||||||
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
|
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
|
||||||
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
|
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
|
||||||
|
CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error)
|
||||||
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
|
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
|
||||||
GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error)
|
GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, error)
|
||||||
UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error)
|
UpdateRedPacketConfig(context.Context, *UpdateRedPacketConfigRequest) (*UpdateRedPacketConfigResponse, error)
|
||||||
@ -1280,6 +1293,9 @@ func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *Credi
|
|||||||
func (UnimplementedWalletServiceServer) CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error) {
|
func (UnimplementedWalletServiceServer) CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method CreditLuckyGiftReward not implemented")
|
return nil, status.Error(codes.Unimplemented, "method CreditLuckyGiftReward not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedWalletServiceServer) CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method CreditRoomTurnoverReward not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedWalletServiceServer) ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error) {
|
func (UnimplementedWalletServiceServer) ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method ApplyGameCoinChange not implemented")
|
return nil, status.Error(codes.Unimplemented, "method ApplyGameCoinChange not implemented")
|
||||||
}
|
}
|
||||||
@ -2372,6 +2388,24 @@ func _WalletService_CreditLuckyGiftReward_Handler(srv interface{}, ctx context.C
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _WalletService_CreditRoomTurnoverReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CreditRoomTurnoverRewardRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(WalletServiceServer).CreditRoomTurnoverReward(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: WalletService_CreditRoomTurnoverReward_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(WalletServiceServer).CreditRoomTurnoverReward(ctx, req.(*CreditRoomTurnoverRewardRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _WalletService_ApplyGameCoinChange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _WalletService_ApplyGameCoinChange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ApplyGameCoinChangeRequest)
|
in := new(ApplyGameCoinChangeRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -2773,6 +2807,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "CreditLuckyGiftReward",
|
MethodName: "CreditLuckyGiftReward",
|
||||||
Handler: _WalletService_CreditLuckyGiftReward_Handler,
|
Handler: _WalletService_CreditLuckyGiftReward_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CreditRoomTurnoverReward",
|
||||||
|
Handler: _WalletService_CreditRoomTurnoverReward_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ApplyGameCoinChange",
|
MethodName: "ApplyGameCoinChange",
|
||||||
Handler: _WalletService_ApplyGameCoinChange_Handler,
|
Handler: _WalletService_ApplyGameCoinChange_Handler,
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import (
|
|||||||
authmodule "hyapp-admin-server/internal/modules/auth"
|
authmodule "hyapp-admin-server/internal/modules/auth"
|
||||||
coinledgermodule "hyapp-admin-server/internal/modules/coinledger"
|
coinledgermodule "hyapp-admin-server/internal/modules/coinledger"
|
||||||
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
|
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
|
||||||
|
cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
|
||||||
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
||||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
@ -54,6 +55,7 @@ import (
|
|||||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||||
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
|
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
|
||||||
roomrocketmodule "hyapp-admin-server/internal/modules/roomrocket"
|
roomrocketmodule "hyapp-admin-server/internal/modules/roomrocket"
|
||||||
|
roomturnoverrewardmodule "hyapp-admin-server/internal/modules/roomturnoverreward"
|
||||||
searchmodule "hyapp-admin-server/internal/modules/search"
|
searchmodule "hyapp-admin-server/internal/modules/search"
|
||||||
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
|
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
|
||||||
teamsalarypolicymodule "hyapp-admin-server/internal/modules/teamsalarypolicy"
|
teamsalarypolicymodule "hyapp-admin-server/internal/modules/teamsalarypolicy"
|
||||||
@ -61,6 +63,7 @@ import (
|
|||||||
uploadmodule "hyapp-admin-server/internal/modules/upload"
|
uploadmodule "hyapp-admin-server/internal/modules/upload"
|
||||||
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
|
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
|
||||||
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
|
vipconfigmodule "hyapp-admin-server/internal/modules/vipconfig"
|
||||||
|
weeklystarmodule "hyapp-admin-server/internal/modules/weeklystar"
|
||||||
"hyapp-admin-server/internal/platform/logging"
|
"hyapp-admin-server/internal/platform/logging"
|
||||||
"hyapp-admin-server/internal/platform/tencentcos"
|
"hyapp-admin-server/internal/platform/tencentcos"
|
||||||
"hyapp-admin-server/internal/repository"
|
"hyapp-admin-server/internal/repository"
|
||||||
@ -227,6 +230,7 @@ func main() {
|
|||||||
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
|
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
|
||||||
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
||||||
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
|
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
|
||||||
|
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
Dashboard: dashboardmodule.New(store, cfg),
|
Dashboard: dashboardmodule.New(store, cfg),
|
||||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
@ -249,6 +253,7 @@ func main() {
|
|||||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
|
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
|
||||||
RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler),
|
RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler),
|
||||||
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
|
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
|
||||||
|
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
Search: searchmodule.New(store),
|
Search: searchmodule.New(store),
|
||||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
|
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
|
||||||
@ -256,6 +261,7 @@ func main() {
|
|||||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
|
||||||
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||||
|
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
}
|
}
|
||||||
engine := router.New(cfg, auth, handlers)
|
engine := router.New(cfg, auth, handlers)
|
||||||
|
|
||||||
|
|||||||
@ -21,9 +21,23 @@ type Client interface {
|
|||||||
GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error)
|
GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error)
|
||||||
UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error)
|
UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error)
|
||||||
ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error)
|
ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error)
|
||||||
|
GetCumulativeRechargeRewardConfig(ctx context.Context, req *activityv1.GetCumulativeRechargeRewardConfigRequest) (*activityv1.GetCumulativeRechargeRewardConfigResponse, error)
|
||||||
|
UpdateCumulativeRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateCumulativeRechargeRewardConfigRequest) (*activityv1.UpdateCumulativeRechargeRewardConfigResponse, error)
|
||||||
|
ListCumulativeRechargeRewardGrants(ctx context.Context, req *activityv1.ListCumulativeRechargeRewardGrantsRequest) (*activityv1.ListCumulativeRechargeRewardGrantsResponse, error)
|
||||||
GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error)
|
GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error)
|
||||||
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error)
|
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error)
|
||||||
ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error)
|
ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error)
|
||||||
|
GetRoomTurnoverRewardConfig(ctx context.Context, req *activityv1.GetRoomTurnoverRewardConfigRequest) (*activityv1.GetRoomTurnoverRewardConfigResponse, error)
|
||||||
|
UpdateRoomTurnoverRewardConfig(ctx context.Context, req *activityv1.UpdateRoomTurnoverRewardConfigRequest) (*activityv1.UpdateRoomTurnoverRewardConfigResponse, error)
|
||||||
|
ListRoomTurnoverRewardSettlements(ctx context.Context, req *activityv1.ListRoomTurnoverRewardSettlementsRequest) (*activityv1.ListRoomTurnoverRewardSettlementsResponse, error)
|
||||||
|
RetryRoomTurnoverRewardSettlement(ctx context.Context, req *activityv1.RetryRoomTurnoverRewardSettlementRequest) (*activityv1.RetryRoomTurnoverRewardSettlementResponse, error)
|
||||||
|
ListWeeklyStarCycles(ctx context.Context, req *activityv1.ListWeeklyStarCyclesRequest) (*activityv1.ListWeeklyStarCyclesResponse, error)
|
||||||
|
CreateWeeklyStarCycle(ctx context.Context, req *activityv1.UpsertWeeklyStarCycleRequest) (*activityv1.UpsertWeeklyStarCycleResponse, error)
|
||||||
|
GetWeeklyStarCycle(ctx context.Context, req *activityv1.GetWeeklyStarCycleRequest) (*activityv1.GetWeeklyStarCycleResponse, error)
|
||||||
|
UpdateWeeklyStarCycle(ctx context.Context, req *activityv1.UpsertWeeklyStarCycleRequest) (*activityv1.UpsertWeeklyStarCycleResponse, error)
|
||||||
|
SetWeeklyStarCycleStatus(ctx context.Context, req *activityv1.SetWeeklyStarCycleStatusRequest) (*activityv1.SetWeeklyStarCycleStatusResponse, error)
|
||||||
|
ListWeeklyStarLeaderboard(ctx context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error)
|
||||||
|
ListWeeklyStarSettlements(ctx context.Context, req *activityv1.ListWeeklyStarSettlementsRequest) (*activityv1.ListWeeklyStarSettlementsResponse, error)
|
||||||
GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error)
|
GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error)
|
||||||
UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error)
|
UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error)
|
||||||
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
|
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
|
||||||
@ -41,7 +55,10 @@ type GRPCClient struct {
|
|||||||
achievementClient activityv1.AdminAchievementServiceClient
|
achievementClient activityv1.AdminAchievementServiceClient
|
||||||
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
|
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
|
||||||
firstRechargeRewardClient activityv1.AdminFirstRechargeRewardServiceClient
|
firstRechargeRewardClient activityv1.AdminFirstRechargeRewardServiceClient
|
||||||
|
cumulativeRechargeClient activityv1.AdminCumulativeRechargeRewardServiceClient
|
||||||
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
|
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
|
||||||
|
roomTurnoverRewardClient activityv1.AdminRoomTurnoverRewardServiceClient
|
||||||
|
weeklyStarClient activityv1.AdminWeeklyStarServiceClient
|
||||||
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
|
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
|
||||||
broadcastClient activityv1.BroadcastServiceClient
|
broadcastClient activityv1.BroadcastServiceClient
|
||||||
growthClient activityv1.AdminGrowthLevelServiceClient
|
growthClient activityv1.AdminGrowthLevelServiceClient
|
||||||
@ -53,7 +70,10 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
|
|||||||
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
|
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
|
||||||
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
|
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
|
||||||
firstRechargeRewardClient: activityv1.NewAdminFirstRechargeRewardServiceClient(conn),
|
firstRechargeRewardClient: activityv1.NewAdminFirstRechargeRewardServiceClient(conn),
|
||||||
|
cumulativeRechargeClient: activityv1.NewAdminCumulativeRechargeRewardServiceClient(conn),
|
||||||
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
|
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
|
||||||
|
roomTurnoverRewardClient: activityv1.NewAdminRoomTurnoverRewardServiceClient(conn),
|
||||||
|
weeklyStarClient: activityv1.NewAdminWeeklyStarServiceClient(conn),
|
||||||
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
|
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
|
||||||
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
|
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
|
||||||
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
|
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
|
||||||
@ -104,6 +124,18 @@ func (c *GRPCClient) ListFirstRechargeRewardClaims(ctx context.Context, req *act
|
|||||||
return c.firstRechargeRewardClient.ListFirstRechargeRewardClaims(ctx, req)
|
return c.firstRechargeRewardClient.ListFirstRechargeRewardClaims(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) GetCumulativeRechargeRewardConfig(ctx context.Context, req *activityv1.GetCumulativeRechargeRewardConfigRequest) (*activityv1.GetCumulativeRechargeRewardConfigResponse, error) {
|
||||||
|
return c.cumulativeRechargeClient.GetCumulativeRechargeRewardConfig(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) UpdateCumulativeRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateCumulativeRechargeRewardConfigRequest) (*activityv1.UpdateCumulativeRechargeRewardConfigResponse, error) {
|
||||||
|
return c.cumulativeRechargeClient.UpdateCumulativeRechargeRewardConfig(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) ListCumulativeRechargeRewardGrants(ctx context.Context, req *activityv1.ListCumulativeRechargeRewardGrantsRequest) (*activityv1.ListCumulativeRechargeRewardGrantsResponse, error) {
|
||||||
|
return c.cumulativeRechargeClient.ListCumulativeRechargeRewardGrants(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GRPCClient) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error) {
|
func (c *GRPCClient) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error) {
|
||||||
return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req)
|
return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req)
|
||||||
}
|
}
|
||||||
@ -116,6 +148,50 @@ func (c *GRPCClient) ListSevenDayCheckInClaims(ctx context.Context, req *activit
|
|||||||
return c.sevenDayCheckInClient.ListSevenDayCheckInClaims(ctx, req)
|
return c.sevenDayCheckInClient.ListSevenDayCheckInClaims(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) GetRoomTurnoverRewardConfig(ctx context.Context, req *activityv1.GetRoomTurnoverRewardConfigRequest) (*activityv1.GetRoomTurnoverRewardConfigResponse, error) {
|
||||||
|
return c.roomTurnoverRewardClient.GetRoomTurnoverRewardConfig(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) UpdateRoomTurnoverRewardConfig(ctx context.Context, req *activityv1.UpdateRoomTurnoverRewardConfigRequest) (*activityv1.UpdateRoomTurnoverRewardConfigResponse, error) {
|
||||||
|
return c.roomTurnoverRewardClient.UpdateRoomTurnoverRewardConfig(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) ListRoomTurnoverRewardSettlements(ctx context.Context, req *activityv1.ListRoomTurnoverRewardSettlementsRequest) (*activityv1.ListRoomTurnoverRewardSettlementsResponse, error) {
|
||||||
|
return c.roomTurnoverRewardClient.ListRoomTurnoverRewardSettlements(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) RetryRoomTurnoverRewardSettlement(ctx context.Context, req *activityv1.RetryRoomTurnoverRewardSettlementRequest) (*activityv1.RetryRoomTurnoverRewardSettlementResponse, error) {
|
||||||
|
return c.roomTurnoverRewardClient.RetryRoomTurnoverRewardSettlement(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) ListWeeklyStarCycles(ctx context.Context, req *activityv1.ListWeeklyStarCyclesRequest) (*activityv1.ListWeeklyStarCyclesResponse, error) {
|
||||||
|
return c.weeklyStarClient.ListWeeklyStarCycles(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) CreateWeeklyStarCycle(ctx context.Context, req *activityv1.UpsertWeeklyStarCycleRequest) (*activityv1.UpsertWeeklyStarCycleResponse, error) {
|
||||||
|
return c.weeklyStarClient.CreateWeeklyStarCycle(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) GetWeeklyStarCycle(ctx context.Context, req *activityv1.GetWeeklyStarCycleRequest) (*activityv1.GetWeeklyStarCycleResponse, error) {
|
||||||
|
return c.weeklyStarClient.GetWeeklyStarCycle(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) UpdateWeeklyStarCycle(ctx context.Context, req *activityv1.UpsertWeeklyStarCycleRequest) (*activityv1.UpsertWeeklyStarCycleResponse, error) {
|
||||||
|
return c.weeklyStarClient.UpdateWeeklyStarCycle(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) SetWeeklyStarCycleStatus(ctx context.Context, req *activityv1.SetWeeklyStarCycleStatusRequest) (*activityv1.SetWeeklyStarCycleStatusResponse, error) {
|
||||||
|
return c.weeklyStarClient.SetWeeklyStarCycleStatus(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) ListWeeklyStarLeaderboard(ctx context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error) {
|
||||||
|
return c.weeklyStarClient.ListWeeklyStarLeaderboard(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) ListWeeklyStarSettlements(ctx context.Context, req *activityv1.ListWeeklyStarSettlementsRequest) (*activityv1.ListWeeklyStarSettlementsResponse, error) {
|
||||||
|
return c.weeklyStarClient.ListWeeklyStarSettlements(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GRPCClient) GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error) {
|
func (c *GRPCClient) GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error) {
|
||||||
return c.luckyGiftClient.GetLuckyGiftConfig(ctx, req)
|
return c.luckyGiftClient.GetLuckyGiftConfig(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -393,7 +393,7 @@ type BDProfile struct {
|
|||||||
ParentLeaderUserID int64 `json:"parentLeaderUserId,string"`
|
ParentLeaderUserID int64 `json:"parentLeaderUserId,string"`
|
||||||
PositionAlias string `json:"positionAlias"`
|
PositionAlias string `json:"positionAlias"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedByUserID int64 `json:"createdByUserId"`
|
CreatedByUserID int64 `json:"createdByUserId,string"`
|
||||||
CreatedAtMs int64 `json:"createdAtMs"`
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
DisplayUserID string `json:"displayUserId"`
|
DisplayUserID string `json:"displayUserId"`
|
||||||
@ -410,7 +410,7 @@ type BDProfile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Agency struct {
|
type Agency struct {
|
||||||
AgencyID int64 `json:"agencyId"`
|
AgencyID int64 `json:"agencyId,string"`
|
||||||
OwnerUserID int64 `json:"ownerUserId,string"`
|
OwnerUserID int64 `json:"ownerUserId,string"`
|
||||||
RegionID int64 `json:"regionId"`
|
RegionID int64 `json:"regionId"`
|
||||||
ParentBDUserID int64 `json:"parentBdUserId,string"`
|
ParentBDUserID int64 `json:"parentBdUserId,string"`
|
||||||
@ -418,7 +418,7 @@ type Agency struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
JoinEnabled bool `json:"joinEnabled"`
|
JoinEnabled bool `json:"joinEnabled"`
|
||||||
MaxHosts int32 `json:"maxHosts"`
|
MaxHosts int32 `json:"maxHosts"`
|
||||||
CreatedByUserID int64 `json:"createdByUserId"`
|
CreatedByUserID int64 `json:"createdByUserId,string"`
|
||||||
CreatedAtMs int64 `json:"createdAtMs"`
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
OwnerDisplayUserID string `json:"ownerDisplayUserId"`
|
OwnerDisplayUserID string `json:"ownerDisplayUserId"`
|
||||||
@ -434,8 +434,8 @@ type HostProfile struct {
|
|||||||
UserID int64 `json:"userId,string"`
|
UserID int64 `json:"userId,string"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
RegionID int64 `json:"regionId"`
|
RegionID int64 `json:"regionId"`
|
||||||
CurrentAgencyID int64 `json:"currentAgencyId"`
|
CurrentAgencyID int64 `json:"currentAgencyId,string"`
|
||||||
CurrentMembershipID int64 `json:"currentMembershipId"`
|
CurrentMembershipID int64 `json:"currentMembershipId,string"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
FirstBecameHostAtMs int64 `json:"firstBecameHostAtMs"`
|
FirstBecameHostAtMs int64 `json:"firstBecameHostAtMs"`
|
||||||
CreatedAtMs int64 `json:"createdAtMs"`
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
@ -456,14 +456,14 @@ type CoinSellerProfile struct {
|
|||||||
UserID int64 `json:"userId,string"`
|
UserID int64 `json:"userId,string"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
MerchantAssetType string `json:"merchantAssetType"`
|
MerchantAssetType string `json:"merchantAssetType"`
|
||||||
CreatedByUserID int64 `json:"createdByUserId"`
|
CreatedByUserID int64 `json:"createdByUserId,string"`
|
||||||
CreatedAtMs int64 `json:"createdAtMs"`
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgencyMembership struct {
|
type AgencyMembership struct {
|
||||||
MembershipID int64 `json:"membershipId"`
|
MembershipID int64 `json:"membershipId,string"`
|
||||||
AgencyID int64 `json:"agencyId"`
|
AgencyID int64 `json:"agencyId,string"`
|
||||||
HostUserID int64 `json:"hostUserId,string"`
|
HostUserID int64 `json:"hostUserId,string"`
|
||||||
RegionID int64 `json:"regionId"`
|
RegionID int64 `json:"regionId"`
|
||||||
MembershipType string `json:"membershipType"`
|
MembershipType string `json:"membershipType"`
|
||||||
|
|||||||
34
server/admin/internal/integration/userclient/client_test.go
Normal file
34
server/admin/internal/integration/userclient/client_test.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package userclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgencyJSONKeepsSnowflakeIDsAsStrings(t *testing.T) {
|
||||||
|
agency := Agency{
|
||||||
|
AgencyID: 321170072154411009,
|
||||||
|
OwnerUserID: 316033326332776448,
|
||||||
|
ParentBDUserID: 320470609978986496,
|
||||||
|
CreatedByUserID: 320470609978986496,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(agency)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal agency: %v", err)
|
||||||
|
}
|
||||||
|
body := string(payload)
|
||||||
|
|
||||||
|
// 后台前端运行在浏览器里,雪花 ID 超过 JavaScript 安全整数上限;这里必须以字符串返回,避免 321170072154411009 被解析成 321170072154411000 后再提交给删除接口。
|
||||||
|
for _, want := range []string{
|
||||||
|
`"agencyId":"321170072154411009"`,
|
||||||
|
`"ownerUserId":"316033326332776448"`,
|
||||||
|
`"parentBdUserId":"320470609978986496"`,
|
||||||
|
`"createdByUserId":"320470609978986496"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("agency json should contain %s, got %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -154,13 +154,19 @@ func (h *Handler) setUserStatus(c *gin.Context, status string, action string, su
|
|||||||
|
|
||||||
func parseListQuery(c *gin.Context) listQuery {
|
func parseListQuery(c *gin.Context) listQuery {
|
||||||
options := shared.ListOptions(c)
|
options := shared.ListOptions(c)
|
||||||
|
regionID, regionIDSet := queryOptionalInt64(c, "region_id", "regionId")
|
||||||
return normalizeListQuery(listQuery{
|
return normalizeListQuery(listQuery{
|
||||||
Page: options.Page,
|
Page: options.Page,
|
||||||
PageSize: options.PageSize,
|
PageSize: options.PageSize,
|
||||||
|
Country: firstQuery(c, "country", "country_code", "countryCode"),
|
||||||
Keyword: options.Keyword,
|
Keyword: options.Keyword,
|
||||||
|
RegionID: regionID,
|
||||||
|
RegionIDSet: regionIDSet,
|
||||||
Status: options.Status,
|
Status: options.Status,
|
||||||
SortBy: firstQuery(c, "sort_by", "sortBy"),
|
SortBy: firstQuery(c, "sort_by", "sortBy"),
|
||||||
SortDirection: firstQuery(c, "sort_direction", "sortDirection", "order"),
|
SortDirection: firstQuery(c, "sort_direction", "sortDirection", "order"),
|
||||||
|
StartMs: queryInt64(c, "start_ms", "startMs", "start_at_ms", "startAtMs"),
|
||||||
|
EndMs: queryInt64(c, "end_ms", "endMs", "end_at_ms", "endAtMs"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,15 @@ package appuser
|
|||||||
type listQuery struct {
|
type listQuery struct {
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
|
Country string
|
||||||
Keyword string
|
Keyword string
|
||||||
|
RegionID int64
|
||||||
|
RegionIDSet bool
|
||||||
Status string
|
Status string
|
||||||
SortBy string
|
SortBy string
|
||||||
SortDirection string
|
SortDirection string
|
||||||
|
StartMs int64
|
||||||
|
EndMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginLogQuery struct {
|
type loginLogQuery struct {
|
||||||
|
|||||||
@ -73,17 +73,7 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
|
|||||||
}
|
}
|
||||||
query = normalizeListQuery(query)
|
query = normalizeListQuery(query)
|
||||||
appCode := appctx.FromContext(ctx)
|
appCode := appctx.FromContext(ctx)
|
||||||
whereSQL := "FROM users u WHERE u.app_code = ?"
|
whereSQL, args := appUserListWhereSQL(appCode, query)
|
||||||
args := []any{appCode}
|
|
||||||
if query.Status != "" {
|
|
||||||
whereSQL += " AND u.status = ?"
|
|
||||||
args = append(args, query.Status)
|
|
||||||
}
|
|
||||||
if query.Keyword != "" {
|
|
||||||
like := "%" + query.Keyword + "%"
|
|
||||||
whereSQL += " AND (CAST(u.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)"
|
|
||||||
args = append(args, like, like, like)
|
|
||||||
}
|
|
||||||
|
|
||||||
total, err := countRows(ctx, s.userDB, whereSQL, args...)
|
total, err := countRows(ctx, s.userDB, whereSQL, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -562,13 +552,73 @@ func normalizeListQuery(query listQuery) listQuery {
|
|||||||
if query.PageSize > 100 {
|
if query.PageSize > 100 {
|
||||||
query.PageSize = 100
|
query.PageSize = 100
|
||||||
}
|
}
|
||||||
|
query.Country = normalizeCountryCode(query.Country)
|
||||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||||
|
if query.RegionID < 0 {
|
||||||
|
query.RegionID = 0
|
||||||
|
query.RegionIDSet = false
|
||||||
|
}
|
||||||
|
if query.StartMs < 0 {
|
||||||
|
query.StartMs = 0
|
||||||
|
}
|
||||||
|
if query.EndMs < 0 {
|
||||||
|
query.EndMs = 0
|
||||||
|
}
|
||||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||||
query.SortBy = normalizeAppUserSortBy(query.SortBy)
|
query.SortBy = normalizeAppUserSortBy(query.SortBy)
|
||||||
query.SortDirection = normalizeSortDirection(query.SortDirection)
|
query.SortDirection = normalizeSortDirection(query.SortDirection)
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appUserListWhereSQL(appCode string, query listQuery) (string, []any) {
|
||||||
|
whereSQL := "FROM users u WHERE u.app_code = ?"
|
||||||
|
args := []any{appCode}
|
||||||
|
if query.Status != "" {
|
||||||
|
whereSQL += " AND u.status = ?"
|
||||||
|
args = append(args, query.Status)
|
||||||
|
}
|
||||||
|
if query.Keyword != "" {
|
||||||
|
like := "%" + query.Keyword + "%"
|
||||||
|
whereSQL += " AND (CAST(u.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)"
|
||||||
|
args = append(args, like, like, like)
|
||||||
|
}
|
||||||
|
if query.Country != "" {
|
||||||
|
// 国家筛选用 users.country 的 ISO code 精确命中;前端传展示名时不会被猜测转换,避免误筛到错误国家。
|
||||||
|
whereSQL += " AND UPPER(u.country) = ?"
|
||||||
|
args = append(args, query.Country)
|
||||||
|
}
|
||||||
|
if query.RegionIDSet {
|
||||||
|
if query.RegionID == 0 {
|
||||||
|
// 列表展示里无区域或无有效业务区域都会显示为 GLOBAL;筛选 GLOBAL 时保持同一套展示口径。
|
||||||
|
whereSQL += " AND (COALESCE(u.region_id, 0) = 0 OR NOT " + appUserValidRegionExistsSQL("u") + ")"
|
||||||
|
} else {
|
||||||
|
whereSQL += " AND u.region_id = ?"
|
||||||
|
args = append(args, query.RegionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if query.StartMs > 0 {
|
||||||
|
// 时间区间筛选的是用户创建时间,和列表“创建 / 活跃”列的第一行保持一致。
|
||||||
|
whereSQL += " AND u.created_at_ms >= ?"
|
||||||
|
args = append(args, query.StartMs)
|
||||||
|
}
|
||||||
|
if query.EndMs > 0 {
|
||||||
|
whereSQL += " AND u.created_at_ms < ?"
|
||||||
|
args = append(args, query.EndMs)
|
||||||
|
}
|
||||||
|
return whereSQL, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func appUserValidRegionExistsSQL(userAlias string) string {
|
||||||
|
return fmt.Sprintf(`EXISTS (
|
||||||
|
SELECT 1 FROM regions rg
|
||||||
|
WHERE rg.app_code = %[1]s.app_code
|
||||||
|
AND rg.region_id = %[1]s.region_id
|
||||||
|
AND rg.status = 'active'
|
||||||
|
AND rg.region_code NOT IN ('Australia and New Zealand', 'Caribbean', 'Melanesia', 'Micronesia', 'Polynesia', 'Southern Africa', 'UNSPECIFIED')
|
||||||
|
LIMIT 1
|
||||||
|
)`, userAlias)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeAppUserSortBy(value string) string {
|
func normalizeAppUserSortBy(value string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case "coin", "coins":
|
case "coin", "coins":
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package appuser
|
package appuser
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNormalizeListQuerySort(t *testing.T) {
|
func TestNormalizeListQuerySort(t *testing.T) {
|
||||||
query := normalizeListQuery(listQuery{Page: 0, PageSize: 1000, SortBy: "coins", SortDirection: "asc"})
|
query := normalizeListQuery(listQuery{Page: 0, PageSize: 1000, SortBy: "coins", SortDirection: "asc"})
|
||||||
@ -12,6 +15,46 @@ func TestNormalizeListQuerySort(t *testing.T) {
|
|||||||
if query.SortBy != "created_at" || query.SortDirection != "desc" {
|
if query.SortBy != "created_at" || query.SortDirection != "desc" {
|
||||||
t.Fatalf("created sort default mismatch: %+v", query)
|
t.Fatalf("created sort default mismatch: %+v", query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query = normalizeListQuery(listQuery{Country: " ph ", RegionID: -3, RegionIDSet: true, StartMs: -1, EndMs: -2})
|
||||||
|
if query.Country != "PH" || query.RegionIDSet || query.StartMs != 0 || query.EndMs != 0 {
|
||||||
|
t.Fatalf("filter normalization mismatch: %+v", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppUserListWhereSQLFilters(t *testing.T) {
|
||||||
|
whereSQL, args := appUserListWhereSQL("lalu", normalizeListQuery(listQuery{
|
||||||
|
Country: "ph",
|
||||||
|
Keyword: "hunter",
|
||||||
|
RegionID: 3,
|
||||||
|
RegionIDSet: true,
|
||||||
|
StartMs: 1000,
|
||||||
|
EndMs: 2000,
|
||||||
|
Status: "active",
|
||||||
|
}))
|
||||||
|
for _, want := range []string{
|
||||||
|
"u.status = ?",
|
||||||
|
"CAST(u.user_id AS CHAR) LIKE ?",
|
||||||
|
"UPPER(u.country) = ?",
|
||||||
|
"u.region_id = ?",
|
||||||
|
"u.created_at_ms >= ?",
|
||||||
|
"u.created_at_ms < ?",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(whereSQL, want) {
|
||||||
|
t.Fatalf("where sql missing %q: %s", want, whereSQL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) != 9 || args[0] != "lalu" || args[5] != "PH" || args[6] != int64(3) || args[7] != int64(1000) || args[8] != int64(2000) {
|
||||||
|
t.Fatalf("where args mismatch: %+v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
whereSQL, args = appUserListWhereSQL("lalu", normalizeListQuery(listQuery{RegionID: 0, RegionIDSet: true}))
|
||||||
|
if !strings.Contains(whereSQL, "COALESCE(u.region_id, 0) = 0") || !strings.Contains(whereSQL, "NOT EXISTS") {
|
||||||
|
t.Fatalf("global region sql mismatch: %s", whereSQL)
|
||||||
|
}
|
||||||
|
if len(args) != 1 || args[0] != "lalu" {
|
||||||
|
t.Fatalf("global region args mismatch: %+v", args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppUserOrderSQL(t *testing.T) {
|
func TestAppUserOrderSQL(t *testing.T) {
|
||||||
|
|||||||
@ -7,6 +7,12 @@ type coinLedgerUserDTO struct {
|
|||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CSVExport struct {
|
||||||
|
FileName string
|
||||||
|
Content []byte
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
type coinLedgerEntryDTO struct {
|
type coinLedgerEntryDTO struct {
|
||||||
EntryID int64 `json:"entryId"`
|
EntryID int64 `json:"entryId"`
|
||||||
TransactionID string `json:"transactionId"`
|
TransactionID string `json:"transactionId"`
|
||||||
@ -26,20 +32,22 @@ type coinLedgerEntryDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type coinSellerLedgerDTO struct {
|
type coinSellerLedgerDTO struct {
|
||||||
EntryID int64 `json:"entryId"`
|
EntryID int64 `json:"entryId"`
|
||||||
TransactionID string `json:"transactionId"`
|
TransactionID string `json:"transactionId"`
|
||||||
CommandID string `json:"commandId"`
|
CommandID string `json:"commandId"`
|
||||||
LedgerType string `json:"ledgerType"`
|
LedgerType string `json:"ledgerType"`
|
||||||
BizType string `json:"bizType"`
|
BizType string `json:"bizType"`
|
||||||
Seller coinLedgerUserDTO `json:"seller"`
|
Seller coinLedgerUserDTO `json:"seller"`
|
||||||
Receiver coinLedgerUserDTO `json:"receiver"`
|
Receiver coinLedgerUserDTO `json:"receiver"`
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
Direction string `json:"direction"`
|
Direction string `json:"direction"`
|
||||||
AvailableDelta int64 `json:"availableDelta"`
|
AvailableDelta int64 `json:"availableDelta"`
|
||||||
SellerBalanceAfter int64 `json:"sellerBalanceAfter"`
|
SellerBalanceAfter int64 `json:"sellerBalanceAfter"`
|
||||||
CounterpartyUserID string `json:"counterpartyUserId"`
|
CounterpartyUserID string `json:"counterpartyUserId"`
|
||||||
Metadata map[string]any `json:"metadata"`
|
OperatorUserID string `json:"operatorUserId"`
|
||||||
CreatedAtMS int64 `json:"createdAtMs"`
|
Operator coinAdjustmentOperatorDTO `json:"operator"`
|
||||||
|
Metadata map[string]any `json:"metadata"`
|
||||||
|
CreatedAtMS int64 `json:"createdAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type coinAdjustmentOperatorDTO struct {
|
type coinAdjustmentOperatorDTO struct {
|
||||||
|
|||||||
@ -55,6 +55,28 @@ func (h *Handler) ListCoinSellerLedger(c *gin.Context) {
|
|||||||
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ExportCoinSellerLedger(c *gin.Context) {
|
||||||
|
query, ok := parseCoinSellerLedgerQuery(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
export, err := h.service.ExportCoinSellerLedger(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errInvalidCoinSellerLedgerType) {
|
||||||
|
response.BadRequest(c, "流水类型不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ServerError(c, "导出币商流水失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shared.OperationLog(c, h.audit, "export-coin-seller-ledger", "wallet_entries", "success", fmt.Sprintf("%d entries", export.Count))
|
||||||
|
c.Header("Content-Disposition", "attachment; filename="+export.FileName)
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Writer.WriteHeader(200)
|
||||||
|
_, _ = c.Writer.Write(export.Content)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) ListCoinAdjustments(c *gin.Context) {
|
func (h *Handler) ListCoinAdjustments(c *gin.Context) {
|
||||||
query, ok := parseListQuery(c)
|
query, ok := parseListQuery(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
|||||||
|
|
||||||
protected.GET("/admin/operations/coin-ledger", middleware.RequirePermission("coin-ledger:view"), h.ListCoinLedger)
|
protected.GET("/admin/operations/coin-ledger", middleware.RequirePermission("coin-ledger:view"), h.ListCoinLedger)
|
||||||
protected.GET("/admin/operations/coin-seller-ledger", middleware.RequirePermission("coin-seller-ledger:view"), h.ListCoinSellerLedger)
|
protected.GET("/admin/operations/coin-seller-ledger", middleware.RequirePermission("coin-seller-ledger:view"), h.ListCoinSellerLedger)
|
||||||
|
protected.GET("/admin/operations/coin-seller-ledger/export", middleware.RequirePermission("coin-seller-ledger:view"), h.ExportCoinSellerLedger)
|
||||||
protected.GET("/admin/operations/coin-adjustments", middleware.RequirePermission("coin-adjustment:view"), h.ListCoinAdjustments)
|
protected.GET("/admin/operations/coin-adjustments", middleware.RequirePermission("coin-adjustment:view"), h.ListCoinAdjustments)
|
||||||
protected.GET("/admin/operations/coin-adjustments/target", middleware.RequirePermission("coin-adjustment:create"), h.LookupCoinAdjustmentTarget)
|
protected.GET("/admin/operations/coin-adjustments/target", middleware.RequirePermission("coin-adjustment:create"), h.LookupCoinAdjustmentTarget)
|
||||||
protected.POST("/admin/operations/coin-adjustments", middleware.RequirePermission("coin-adjustment:create"), h.CreateCoinAdjustment)
|
protected.POST("/admin/operations/coin-adjustments", middleware.RequirePermission("coin-adjustment:create"), h.CreateCoinAdjustment)
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
package coinledger
|
package coinledger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -154,6 +156,41 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, query coinSellerLedgerQuery) ([]coinSellerLedgerDTO, int64, error) {
|
func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, query coinSellerLedgerQuery) ([]coinSellerLedgerDTO, int64, error) {
|
||||||
|
return s.listCoinSellerLedger(ctx, appCode, query, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ExportCoinSellerLedger(ctx context.Context, appCode string, query coinSellerLedgerQuery) (CSVExport, error) {
|
||||||
|
items, total, err := s.listCoinSellerLedger(ctx, appCode, query, false)
|
||||||
|
if err != nil {
|
||||||
|
return CSVExport{}, err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := csv.NewWriter(&buf)
|
||||||
|
_ = writer.Write([]string{"币商名称", "币商短ID", "币商长ID", "流水类型", "转账金额", "收款人名称", "收款人短ID", "收款人长ID", "操作人", "操作人ID", "币商余额", "转账时间", "交易ID", "命令ID"})
|
||||||
|
for _, item := range items {
|
||||||
|
operatorName, operatorID := coinSellerLedgerOperatorExportFields(item)
|
||||||
|
_ = writer.Write([]string{
|
||||||
|
item.Seller.Username,
|
||||||
|
item.Seller.DisplayUserID,
|
||||||
|
item.Seller.UserID,
|
||||||
|
coinSellerLedgerLabel(item),
|
||||||
|
strconv.FormatInt(signedCoinSellerLedgerAmount(item), 10),
|
||||||
|
item.Receiver.Username,
|
||||||
|
item.Receiver.DisplayUserID,
|
||||||
|
item.Receiver.UserID,
|
||||||
|
operatorName,
|
||||||
|
operatorID,
|
||||||
|
strconv.FormatInt(item.SellerBalanceAfter, 10),
|
||||||
|
time.UnixMilli(item.CreatedAtMS).UTC().Format(time.RFC3339),
|
||||||
|
item.TransactionID,
|
||||||
|
item.CommandID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writer.Flush()
|
||||||
|
return CSVExport{FileName: "hyapp-coin-seller-ledger.csv", Content: buf.Bytes(), Count: int(total)}, writer.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) listCoinSellerLedger(ctx context.Context, appCode string, query coinSellerLedgerQuery, paginated bool) ([]coinSellerLedgerDTO, int64, error) {
|
||||||
query = normalizeCoinSellerLedgerQuery(query)
|
query = normalizeCoinSellerLedgerQuery(query)
|
||||||
if s == nil || s.walletDB == nil {
|
if s == nil || s.walletDB == nil {
|
||||||
return nil, 0, fmt.Errorf("wallet mysql is not configured")
|
return nil, 0, fmt.Errorf("wallet mysql is not configured")
|
||||||
@ -177,29 +214,35 @@ func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, quer
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
var total int64
|
var total int64
|
||||||
if err := s.walletDB.QueryRowContext(ctx, `
|
if paginated {
|
||||||
SELECT COUNT(*)
|
if err := s.walletDB.QueryRowContext(ctx, `
|
||||||
FROM wallet_entries e
|
SELECT COUNT(*)
|
||||||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
FROM wallet_entries e
|
||||||
`+whereSQL,
|
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||||||
args...,
|
`+whereSQL,
|
||||||
).Scan(&total); err != nil {
|
args...,
|
||||||
return nil, 0, err
|
).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 明细查询只取币商侧 COIN_SELLER_COIN 分录:available_delta 表示币商库存可用变化,
|
// 明细查询只取币商侧 COIN_SELLER_COIN 分录:available_delta 表示币商库存可用变化,
|
||||||
// available_after 表示该币商分录落账后的库存余额,receiver 再按 biz_type 从 metadata/counterparty 补出。
|
// available_after 表示该币商分录落账后的库存余额,receiver 再按 biz_type 从 metadata/counterparty 补出。
|
||||||
rows, err := s.walletDB.QueryContext(ctx, `
|
querySQL := `
|
||||||
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id, wt.biz_type,
|
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id, wt.biz_type,
|
||||||
e.available_delta, e.available_after, e.counterparty_user_id,
|
e.available_delta, e.available_after, e.counterparty_user_id,
|
||||||
COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
|
COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
|
||||||
FROM wallet_entries e
|
FROM wallet_entries e
|
||||||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||||||
`+whereSQL+`
|
` + whereSQL + `
|
||||||
ORDER BY e.created_at_ms DESC, e.entry_id DESC
|
ORDER BY e.created_at_ms DESC, e.entry_id DESC`
|
||||||
LIMIT ? OFFSET ?`,
|
queryArgs := args
|
||||||
append(args, query.PageSize, offset(query.Page, query.PageSize))...,
|
if paginated {
|
||||||
)
|
querySQL += `
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
queryArgs = append(queryArgs, query.PageSize, offset(query.Page, query.PageSize))
|
||||||
|
}
|
||||||
|
rows, err := s.walletDB.QueryContext(ctx, querySQL, queryArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@ -207,6 +250,7 @@ func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, quer
|
|||||||
|
|
||||||
items := make([]coinSellerLedgerDTO, 0, query.PageSize)
|
items := make([]coinSellerLedgerDTO, 0, query.PageSize)
|
||||||
profileIDs := make([]int64, 0, query.PageSize*2)
|
profileIDs := make([]int64, 0, query.PageSize*2)
|
||||||
|
operatorIDs := make([]int64, 0, query.PageSize)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item coinSellerLedgerDTO
|
var item coinSellerLedgerDTO
|
||||||
var sellerUserID int64
|
var sellerUserID int64
|
||||||
@ -230,6 +274,7 @@ func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, quer
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
operatorUserID := metadataInt64(metadata, "operator_user_id", "operatorUserId")
|
||||||
// 收款人按产品口径取实际收款人:币商转用户优先取 metadata.target_user_id,老数据没有 metadata 时再用 counterparty_user_id;
|
// 收款人按产品口径取实际收款人:币商转用户优先取 metadata.target_user_id,老数据没有 metadata 时再用 counterparty_user_id;
|
||||||
// 后台入账和工资转币商的实际收款人都是币商本人,所以不能被 counterparty 字段误导成付款方或操作方。
|
// 后台入账和工资转币商的实际收款人都是币商本人,所以不能被 counterparty 字段误导成付款方或操作方。
|
||||||
receiverUserID := coinSellerLedgerReceiverUserID(item.BizType, sellerUserID, counterpartyUserID, metadata)
|
receiverUserID := coinSellerLedgerReceiverUserID(item.BizType, sellerUserID, counterpartyUserID, metadata)
|
||||||
@ -237,20 +282,31 @@ func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, quer
|
|||||||
item.Direction = directionForDelta(item.AvailableDelta)
|
item.Direction = directionForDelta(item.AvailableDelta)
|
||||||
item.Amount = absInt64(item.AvailableDelta)
|
item.Amount = absInt64(item.AvailableDelta)
|
||||||
item.CounterpartyUserID = formatOptionalID(counterpartyUserID)
|
item.CounterpartyUserID = formatOptionalID(counterpartyUserID)
|
||||||
|
item.OperatorUserID = formatOptionalID(operatorUserID)
|
||||||
item.Metadata = metadata
|
item.Metadata = metadata
|
||||||
item.Seller = coinLedgerUserDTO{UserID: strconv.FormatInt(sellerUserID, 10)}
|
item.Seller = coinLedgerUserDTO{UserID: strconv.FormatInt(sellerUserID, 10)}
|
||||||
item.Receiver = coinLedgerUserDTO{UserID: strconv.FormatInt(receiverUserID, 10)}
|
item.Receiver = coinLedgerUserDTO{UserID: strconv.FormatInt(receiverUserID, 10)}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
profileIDs = append(profileIDs, sellerUserID, receiverUserID)
|
profileIDs = append(profileIDs, sellerUserID, receiverUserID)
|
||||||
|
if operatorUserID > 0 && item.LedgerType == coinSellerLedgerTypeAdminStockCredit {
|
||||||
|
operatorIDs = append(operatorIDs, operatorUserID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
if !paginated {
|
||||||
|
total = int64(len(items))
|
||||||
|
}
|
||||||
|
|
||||||
profiles, err := s.userProfiles(ctx, appCode, profileIDs)
|
profiles, err := s.userProfiles(ctx, appCode, profileIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
operators, err := s.adminProfiles(ctx, operatorIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
for i := range items {
|
for i := range items {
|
||||||
sellerUserID, _ := strconv.ParseInt(items[i].Seller.UserID, 10, 64)
|
sellerUserID, _ := strconv.ParseInt(items[i].Seller.UserID, 10, 64)
|
||||||
receiverUserID, _ := strconv.ParseInt(items[i].Receiver.UserID, 10, 64)
|
receiverUserID, _ := strconv.ParseInt(items[i].Receiver.UserID, 10, 64)
|
||||||
@ -262,6 +318,10 @@ func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, quer
|
|||||||
if profile, ok := profiles[receiverUserID]; ok {
|
if profile, ok := profiles[receiverUserID]; ok {
|
||||||
items[i].Receiver = userDTOFromProfile(profile)
|
items[i].Receiver = userDTOFromProfile(profile)
|
||||||
}
|
}
|
||||||
|
operatorUserID, _ := strconv.ParseInt(items[i].OperatorUserID, 10, 64)
|
||||||
|
if operator, ok := operators[operatorUserID]; ok {
|
||||||
|
items[i].Operator = operator
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
@ -759,6 +819,45 @@ func coinSellerLedgerTypeForBizType(bizType string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
|
||||||
|
switch item.LedgerType {
|
||||||
|
case coinSellerLedgerTypeAdminStockCredit:
|
||||||
|
return "后台入账"
|
||||||
|
case coinSellerLedgerTypeSellerTransfer:
|
||||||
|
return "币商转用户"
|
||||||
|
case coinSellerLedgerTypeSalaryTransferIncome:
|
||||||
|
return "工资转币商"
|
||||||
|
default:
|
||||||
|
switch item.BizType {
|
||||||
|
case coinSellerStockPurchaseBizType:
|
||||||
|
return "币商进货"
|
||||||
|
case coinSellerCoinCompensationBizType:
|
||||||
|
return "金币补偿"
|
||||||
|
case coinSellerTransferBizType:
|
||||||
|
return "币商转用户"
|
||||||
|
case salaryTransferToCoinSellerBizType:
|
||||||
|
return "工资转币商"
|
||||||
|
default:
|
||||||
|
return firstNonEmpty(item.LedgerType, item.BizType, "-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedCoinSellerLedgerAmount(item coinSellerLedgerDTO) int64 {
|
||||||
|
if item.Direction == directionOut || item.AvailableDelta < 0 {
|
||||||
|
return -absInt64(item.Amount)
|
||||||
|
}
|
||||||
|
return absInt64(item.Amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerLedgerOperatorExportFields(item coinSellerLedgerDTO) (string, string) {
|
||||||
|
if item.LedgerType != coinSellerLedgerTypeAdminStockCredit {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
operatorID := firstNonEmpty(item.Operator.AdminID, item.OperatorUserID)
|
||||||
|
return firstNonEmpty(item.Operator.Username, item.Operator.Name, operatorID), operatorID
|
||||||
|
}
|
||||||
|
|
||||||
func coinSellerLedgerReceiverUserID(bizType string, sellerUserID int64, counterpartyUserID int64, metadata map[string]any) int64 {
|
func coinSellerLedgerReceiverUserID(bizType string, sellerUserID int64, counterpartyUserID int64, metadata map[string]any) int64 {
|
||||||
if bizType == coinSellerTransferBizType {
|
if bizType == coinSellerTransferBizType {
|
||||||
// 新数据把真实收款用户写在 metadata.target_user_id,优先使用它,避免 counterparty 语义随老交易实现变化。
|
// 新数据把真实收款用户写在 metadata.target_user_id,优先使用它,避免 counterparty 语义随老交易实现变化。
|
||||||
@ -846,6 +945,16 @@ func metadataInt64(metadata map[string]any, keys ...string) int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func parseFlexibleUserID(value any) (int64, error) {
|
func parseFlexibleUserID(value any) (int64, error) {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
|
|||||||
@ -59,6 +59,19 @@ func TestCoinSellerLedgerWhereMapsAdminStockCredit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoinSellerLedgerWhereEmptyTypeUsesAllPublicTypes(t *testing.T) {
|
||||||
|
where, args, err := coinSellerLedgerWhere("lalu", coinSellerLedgerQuery{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("coin seller ledger where failed: %v", err)
|
||||||
|
}
|
||||||
|
if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?)"; where != want {
|
||||||
|
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
|
||||||
|
}
|
||||||
|
if len(args) != 6 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinSellerTransferBizType || args[5] != salaryTransferToCoinSellerBizType {
|
||||||
|
t.Fatalf("args mismatch: %#v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoinSellerLedgerWhereRejectsInvalidType(t *testing.T) {
|
func TestCoinSellerLedgerWhereRejectsInvalidType(t *testing.T) {
|
||||||
_, _, err := coinSellerLedgerWhere("lalu", coinSellerLedgerQuery{LedgerType: "bad_type"}, nil)
|
_, _, err := coinSellerLedgerWhere("lalu", coinSellerLedgerQuery{LedgerType: "bad_type"}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -79,6 +92,24 @@ func TestCoinSellerLedgerReceiverUserIDUsesActualReceiver(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoinSellerLedgerOperatorExportFieldsOnlyForAdminStock(t *testing.T) {
|
||||||
|
adminItem := coinSellerLedgerDTO{
|
||||||
|
LedgerType: coinSellerLedgerTypeAdminStockCredit,
|
||||||
|
OperatorUserID: "7",
|
||||||
|
Operator: coinAdjustmentOperatorDTO{AdminID: "7", Username: "hyappadmin", Name: "Admin"},
|
||||||
|
}
|
||||||
|
operatorName, operatorID := coinSellerLedgerOperatorExportFields(adminItem)
|
||||||
|
if operatorName != "hyappadmin" || operatorID != "7" {
|
||||||
|
t.Fatalf("operator fields mismatch: name=%q id=%q", operatorName, operatorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
transferItem := coinSellerLedgerDTO{LedgerType: coinSellerLedgerTypeSellerTransfer, OperatorUserID: "7"}
|
||||||
|
operatorName, operatorID = coinSellerLedgerOperatorExportFields(transferItem)
|
||||||
|
if operatorName != "" || operatorID != "" {
|
||||||
|
t.Fatalf("seller transfer should not export operator: name=%q id=%q", operatorName, operatorID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeCoinSellerLedgerQueryCapsPageSize(t *testing.T) {
|
func TestNormalizeCoinSellerLedgerQueryCapsPageSize(t *testing.T) {
|
||||||
query := normalizeCoinSellerLedgerQuery(coinSellerLedgerQuery{Page: -1, PageSize: 1000, SellerKeyword: " 164425 ", LedgerType: " seller_transfer "})
|
query := normalizeCoinSellerLedgerQuery(coinSellerLedgerQuery{Page: -1, PageSize: 1000, SellerKeyword: " 164425 ", LedgerType: " seller_transfer "})
|
||||||
if query.Page != 1 || query.PageSize != 100 || query.SellerKeyword != "164425" || query.LedgerType != coinSellerLedgerTypeSellerTransfer {
|
if query.Page != 1 || query.PageSize != 100 || query.SellerKeyword != "164425" || query.LedgerType != coinSellerLedgerTypeSellerTransfer {
|
||||||
|
|||||||
@ -0,0 +1,356 @@
|
|||||||
|
package cumulativerechargereward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/appctx"
|
||||||
|
"hyapp-admin-server/internal/integration/activityclient"
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
|
"hyapp-admin-server/internal/response"
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
activity activityclient.Client
|
||||||
|
userDB *sql.DB
|
||||||
|
audit shared.OperationLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||||
|
return &Handler{activity: activity, userDB: userDB, audit: audit}
|
||||||
|
}
|
||||||
|
|
||||||
|
type configRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Tiers []tierDTO `json:"tiers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type configDTO struct {
|
||||||
|
AppCode string `json:"app_code"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Tiers []tierDTO `json:"tiers"`
|
||||||
|
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierDTO struct {
|
||||||
|
TierID int64 `json:"tier_id"`
|
||||||
|
TierCode string `json:"tier_code"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
ThresholdUSDMinor int64 `json:"threshold_usd_minor"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type grantDTO struct {
|
||||||
|
GrantID string `json:"grant_id"`
|
||||||
|
AppCode string `json:"app_code"`
|
||||||
|
CycleKey string `json:"cycle_key"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
User *userDTO `json:"user,omitempty"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
TransactionID string `json:"transaction_id"`
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
TierID int64 `json:"tier_id"`
|
||||||
|
TierCode string `json:"tier_code"`
|
||||||
|
ThresholdUSDMinor int64 `json:"threshold_usd_minor"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
ReachedUSDMinor int64 `json:"reached_usd_minor"`
|
||||||
|
QualifyingUSDMinor int64 `json:"qualifying_usd_minor"`
|
||||||
|
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
|
||||||
|
RechargeType string `json:"recharge_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
WalletCommandID string `json:"wallet_command_id"`
|
||||||
|
WalletGrantID string `json:"wallet_grant_id"`
|
||||||
|
FailureReason string `json:"failure_reason"`
|
||||||
|
GrantedAtMS int64 `json:"granted_at_ms"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userDTO struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
DisplayUserID string `json:"display_user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetConfig(c *gin.Context) {
|
||||||
|
resp, err := h.activity.GetCumulativeRechargeRewardConfig(c.Request.Context(), &activityv1.GetCumulativeRechargeRewardConfigRequest{Meta: h.meta(c)})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取累充奖励配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, configFromProto(resp.GetConfig()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||||
|
var req configRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "累充奖励配置参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// admin-server 只做输入透传和操作人注入,档位有效性、重复门槛和启用规则统一交给 activity-service 校验。
|
||||||
|
tiers := make([]*activityv1.CumulativeRechargeRewardTier, 0, len(req.Tiers))
|
||||||
|
for _, tier := range req.Tiers {
|
||||||
|
tiers = append(tiers, &activityv1.CumulativeRechargeRewardTier{
|
||||||
|
TierId: tier.TierID,
|
||||||
|
TierCode: strings.TrimSpace(tier.TierCode),
|
||||||
|
TierName: strings.TrimSpace(tier.TierName),
|
||||||
|
ThresholdUsdMinor: tier.ThresholdUSDMinor,
|
||||||
|
ResourceGroupId: tier.ResourceGroupID,
|
||||||
|
Status: strings.TrimSpace(tier.Status),
|
||||||
|
SortOrder: tier.SortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resp, err := h.activity.UpdateCumulativeRechargeRewardConfig(c.Request.Context(), &activityv1.UpdateCumulativeRechargeRewardConfigRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
Tiers: tiers,
|
||||||
|
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := configFromProto(resp.GetConfig())
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "update-cumulative-recharge-reward", "cumulative_recharge_reward_configs", item.AppCode, "success", "")
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListGrants(c *gin.Context) {
|
||||||
|
options := shared.ListOptions(c)
|
||||||
|
// 发放记录主表只存 user_id;后台 keyword 先在 users 表解析成精确用户,再下推给 activity-service 查询。
|
||||||
|
userID, matched, ok := h.resolveGrantUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
|
||||||
|
if !ok {
|
||||||
|
response.ServerError(c, "查询用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if options.Keyword != "" && !matched {
|
||||||
|
response.OK(c, response.Page{Items: []grantDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.activity.ListCumulativeRechargeRewardGrants(c.Request.Context(), &activityv1.ListCumulativeRechargeRewardGrantsRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
Status: strings.TrimSpace(options.Status),
|
||||||
|
UserId: userID,
|
||||||
|
CycleKey: strings.TrimSpace(c.Query("cycle_key")),
|
||||||
|
Page: int32(options.Page),
|
||||||
|
PageSize: int32(options.PageSize),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取累充奖励发放记录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]grantDTO, 0, len(resp.GetGrants()))
|
||||||
|
for _, grant := range resp.GetGrants() {
|
||||||
|
items = append(items, grantFromProto(grant))
|
||||||
|
}
|
||||||
|
if err := h.fillGrantUsers(c.Request.Context(), items); err != nil {
|
||||||
|
response.ServerError(c, "补全用户信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
||||||
|
return &activityv1.RequestMeta{
|
||||||
|
RequestId: middleware.CurrentRequestID(c),
|
||||||
|
Caller: "admin-server",
|
||||||
|
AppCode: appctx.FromContext(c.Request.Context()),
|
||||||
|
SentAtMs: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) resolveGrantUserID(ctx context.Context, keyword string) (int64, bool, bool) {
|
||||||
|
if keyword == "" {
|
||||||
|
return 0, false, true
|
||||||
|
}
|
||||||
|
if h.userDB == nil {
|
||||||
|
return 0, false, true
|
||||||
|
}
|
||||||
|
// keyword 支持真实 user_id、短 ID 和昵称模糊匹配;排序优先精确 ID,避免昵称碰撞查到错误用户。
|
||||||
|
appCode := appctx.FromContext(ctx)
|
||||||
|
rows, err := h.userDB.QueryContext(ctx, `
|
||||||
|
SELECT user_id
|
||||||
|
FROM users
|
||||||
|
WHERE app_code = ?
|
||||||
|
AND (
|
||||||
|
CAST(user_id AS CHAR) = ?
|
||||||
|
OR current_display_user_id = ?
|
||||||
|
OR username LIKE ?
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN CAST(user_id AS CHAR) = ? THEN 0
|
||||||
|
WHEN current_display_user_id = ? THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
user_id DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
return 0, false, rows.Err() == nil
|
||||||
|
}
|
||||||
|
var userID int64
|
||||||
|
if err := rows.Scan(&userID); err != nil {
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
return userID, true, rows.Err() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) fillGrantUsers(ctx context.Context, grants []grantDTO) error {
|
||||||
|
if h.userDB == nil || len(grants) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 列表补用户资料是展示增强,找不到用户时仍返回 grant 原始 user_id,避免审计记录因为用户资料缺失而不可见。
|
||||||
|
ids := collectGrantUserIDs(grants)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
|
||||||
|
rows, err := h.userDB.QueryContext(ctx, `
|
||||||
|
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||||||
|
FROM users
|
||||||
|
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
users := make(map[int64]userDTO, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
var user userDTO
|
||||||
|
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users[user.UserID] = user
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for index := range grants {
|
||||||
|
if user, ok := users[grants[index].UserID]; ok {
|
||||||
|
grants[index].User = &user
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grants[index].User = &userDTO{UserID: grants[index].UserID}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFromProto(config *activityv1.CumulativeRechargeRewardConfig) configDTO {
|
||||||
|
if config == nil {
|
||||||
|
return configDTO{Tiers: []tierDTO{}}
|
||||||
|
}
|
||||||
|
tiers := make([]tierDTO, 0, len(config.GetTiers()))
|
||||||
|
for _, tier := range config.GetTiers() {
|
||||||
|
tiers = append(tiers, tierFromProto(tier))
|
||||||
|
}
|
||||||
|
return configDTO{
|
||||||
|
AppCode: config.GetAppCode(),
|
||||||
|
Enabled: config.GetEnabled(),
|
||||||
|
Tiers: tiers,
|
||||||
|
UpdatedByAdminID: config.GetUpdatedByAdminId(),
|
||||||
|
CreatedAtMS: config.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: config.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tierFromProto(tier *activityv1.CumulativeRechargeRewardTier) tierDTO {
|
||||||
|
if tier == nil {
|
||||||
|
return tierDTO{}
|
||||||
|
}
|
||||||
|
return tierDTO{
|
||||||
|
TierID: tier.GetTierId(),
|
||||||
|
TierCode: tier.GetTierCode(),
|
||||||
|
TierName: tier.GetTierName(),
|
||||||
|
ThresholdUSDMinor: tier.GetThresholdUsdMinor(),
|
||||||
|
ResourceGroupID: tier.GetResourceGroupId(),
|
||||||
|
Status: tier.GetStatus(),
|
||||||
|
SortOrder: tier.GetSortOrder(),
|
||||||
|
CreatedAtMS: tier.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: tier.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantFromProto(grant *activityv1.CumulativeRechargeRewardGrant) grantDTO {
|
||||||
|
if grant == nil {
|
||||||
|
return grantDTO{}
|
||||||
|
}
|
||||||
|
return grantDTO{
|
||||||
|
GrantID: grant.GetGrantId(),
|
||||||
|
AppCode: grant.GetAppCode(),
|
||||||
|
CycleKey: grant.GetCycleKey(),
|
||||||
|
UserID: grant.GetUserId(),
|
||||||
|
EventID: grant.GetEventId(),
|
||||||
|
TransactionID: grant.GetTransactionId(),
|
||||||
|
CommandID: grant.GetCommandId(),
|
||||||
|
TierID: grant.GetTierId(),
|
||||||
|
TierCode: grant.GetTierCode(),
|
||||||
|
ThresholdUSDMinor: grant.GetThresholdUsdMinor(),
|
||||||
|
ResourceGroupID: grant.GetResourceGroupId(),
|
||||||
|
ReachedUSDMinor: grant.GetReachedUsdMinor(),
|
||||||
|
QualifyingUSDMinor: grant.GetQualifyingUsdMinor(),
|
||||||
|
RechargeCoinAmount: grant.GetRechargeCoinAmount(),
|
||||||
|
RechargeType: grant.GetRechargeType(),
|
||||||
|
Status: grant.GetStatus(),
|
||||||
|
WalletCommandID: grant.GetWalletCommandId(),
|
||||||
|
WalletGrantID: grant.GetWalletGrantId(),
|
||||||
|
FailureReason: grant.GetFailureReason(),
|
||||||
|
GrantedAtMS: grant.GetGrantedAtMs(),
|
||||||
|
CreatedAtMS: grant.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: grant.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectGrantUserIDs(grants []grantDTO) []int64 {
|
||||||
|
seen := make(map[int64]struct{}, len(grants))
|
||||||
|
ids := make([]int64, 0, len(grants))
|
||||||
|
for _, grant := range grants {
|
||||||
|
if grant.UserID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[grant.UserID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[grant.UserID] = struct{}{}
|
||||||
|
ids = append(ids, grant.UserID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Args(ids []int64) []any {
|
||||||
|
args := make([]any, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholders(count int) string {
|
||||||
|
if count <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, count)
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = "?"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package cumulativerechargereward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
protected.GET("/admin/activity/cumulative-recharge-reward/config", middleware.RequirePermission("cumulative-recharge-reward:view"), h.GetConfig)
|
||||||
|
protected.PUT("/admin/activity/cumulative-recharge-reward/config", middleware.RequirePermission("cumulative-recharge-reward:update"), h.UpdateConfig)
|
||||||
|
protected.GET("/admin/activity/cumulative-recharge-reward/grants", middleware.RequirePermission("cumulative-recharge-reward:view"), h.ListGrants)
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
adapterBaishunV1 = "baishun_v1"
|
adapterBaishunV1 = "baishun_v1"
|
||||||
adapterZeeOneV1 = "zeeone_v1"
|
adapterZeeOneV1 = "zeeone_v1"
|
||||||
|
adapterVivaGamesV1 = "vivagames_v1"
|
||||||
defaultGameStatus = "disabled"
|
defaultGameStatus = "disabled"
|
||||||
defaultGameCategory = "casino"
|
defaultGameCategory = "casino"
|
||||||
defaultLaunchMode = "full_screen"
|
defaultLaunchMode = "full_screen"
|
||||||
@ -131,6 +132,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
|
|||||||
return fetchBaishunGameSyncPlan(ctx, platform, req)
|
return fetchBaishunGameSyncPlan(ctx, platform, req)
|
||||||
case adapterZeeOneV1:
|
case adapterZeeOneV1:
|
||||||
return fetchZeeOneGameSyncPlan(platform, req)
|
return fetchZeeOneGameSyncPlan(platform, req)
|
||||||
|
case adapterVivaGamesV1:
|
||||||
|
return fetchVivaGamesGameSyncPlan(platform, req)
|
||||||
default:
|
default:
|
||||||
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher,后台入口保持不变。
|
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher,后台入口保持不变。
|
||||||
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
|
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
|
||||||
@ -177,6 +180,14 @@ type zeeoneSyncConfig struct {
|
|||||||
GameCovers map[string]string `json:"game_covers"`
|
GameCovers map[string]string `json:"game_covers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type vivaGamesSyncConfig struct {
|
||||||
|
// VIVAGAMES 文档说明游戏 URL 由厂商提供,后台用这个映射把 URL 转成可勾选的本地目录候选项。
|
||||||
|
GameURLs map[string]string `json:"game_urls"`
|
||||||
|
GameNames map[string]string `json:"game_names"`
|
||||||
|
GameIcons map[string]string `json:"game_icons"`
|
||||||
|
GameCovers map[string]string `json:"game_covers"`
|
||||||
|
}
|
||||||
|
|
||||||
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||||
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
|
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -257,6 +268,67 @@ func zeeoneGameNameFromURL(raw string) string {
|
|||||||
return titleGameName(candidate)
|
return titleGameName(candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchVivaGamesGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
|
||||||
|
config, err := decodeVivaGamesSyncConfig(platform.GetAdapterConfigJson())
|
||||||
|
if err != nil {
|
||||||
|
return providerGameSyncPlan{}, err
|
||||||
|
}
|
||||||
|
games, gameURLs := vivaGamesCatalogItems(platform.GetPlatformCode(), config, req)
|
||||||
|
if len(games) == 0 {
|
||||||
|
return providerGameSyncPlan{}, fmt.Errorf("VIVAGAMES 游戏列表为空:请在 adapterConfigJson.game_urls 配置游戏 ID 到 H5 URL 的映射")
|
||||||
|
}
|
||||||
|
return providerGameSyncPlan{
|
||||||
|
Games: games,
|
||||||
|
GameURLs: gameURLs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeVivaGamesSyncConfig(raw string) (vivaGamesSyncConfig, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return vivaGamesSyncConfig{}, nil
|
||||||
|
}
|
||||||
|
var config vivaGamesSyncConfig
|
||||||
|
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
||||||
|
return vivaGamesSyncConfig{}, fmt.Errorf("VIVAGAMES 适配器配置 JSON 不合法")
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesCatalogItems(platformCode string, config vivaGamesSyncConfig, req syncGamesRequest) ([]catalogRequest, map[string]string) {
|
||||||
|
keys := make([]string, 0, len(config.GameURLs))
|
||||||
|
for providerGameID, launchURL := range config.GameURLs {
|
||||||
|
if strings.TrimSpace(providerGameID) != "" && strings.TrimSpace(launchURL) != "" {
|
||||||
|
keys = append(keys, strings.TrimSpace(providerGameID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
items := make([]catalogRequest, 0, len(keys))
|
||||||
|
gameURLs := make(map[string]string, len(keys))
|
||||||
|
for index, providerGameID := range keys {
|
||||||
|
launchURL := strings.TrimSpace(config.GameURLs[providerGameID])
|
||||||
|
gameURLs[providerGameID] = launchURL
|
||||||
|
name := firstNonEmpty(config.GameNames[providerGameID], zeeoneGameNameFromURL(launchURL), providerGameID)
|
||||||
|
iconURL := strings.TrimSpace(config.GameIcons[providerGameID])
|
||||||
|
coverURL := firstNonEmpty(config.GameCovers[providerGameID], iconURL)
|
||||||
|
items = append(items, catalogRequest{
|
||||||
|
GameID: stableGameID(platformCode, providerGameID),
|
||||||
|
PlatformCode: strings.TrimSpace(platformCode),
|
||||||
|
ProviderGameID: providerGameID,
|
||||||
|
GameName: name,
|
||||||
|
Category: defaulted(req.Category, defaultGameCategory),
|
||||||
|
IconURL: iconURL,
|
||||||
|
CoverURL: coverURL,
|
||||||
|
LaunchMode: defaulted(req.LaunchMode, defaultLaunchMode),
|
||||||
|
Orientation: defaultOrientation,
|
||||||
|
MinCoin: req.MinCoin,
|
||||||
|
Status: defaulted(req.Status, defaultGameStatus),
|
||||||
|
SortOrder: int32((index + 1) * 10),
|
||||||
|
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterVivaGamesV1}, req.Tags...)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, gameURLs
|
||||||
|
}
|
||||||
|
|
||||||
func titleGameName(value string) string {
|
func titleGameName(value string) string {
|
||||||
tokens := splitGameNameTokens(value)
|
tokens := splitGameNameTokens(value)
|
||||||
if len(tokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
|
|||||||
@ -105,6 +105,37 @@ func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetchVivaGamesGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
|
||||||
|
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
|
||||||
|
PlatformCode: "vivagames",
|
||||||
|
AdapterType: adapterVivaGamesV1,
|
||||||
|
AdapterConfigJson: `{
|
||||||
|
"game_urls": {
|
||||||
|
"2": "https://dev-rich2.s3.ap-southeast-1.amazonaws.com/richForever.html?game_id=2&isShow=1"
|
||||||
|
},
|
||||||
|
"game_names": {
|
||||||
|
"2": "Rich Forever"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}, syncGamesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Games) != 1 {
|
||||||
|
t.Fatalf("games len = %d, want 1", len(plan.Games))
|
||||||
|
}
|
||||||
|
game := plan.Games[0]
|
||||||
|
if game.GameID != "vivagames_2" || game.ProviderGameID != "2" || game.GameName != "Rich Forever" {
|
||||||
|
t.Fatalf("vivagames game mismatch: %+v", game)
|
||||||
|
}
|
||||||
|
if plan.GameURLs["2"] != "https://dev-rich2.s3.ap-southeast-1.amazonaws.com/richForever.html?game_id=2&isShow=1" {
|
||||||
|
t.Fatalf("game url mismatch: %+v", plan.GameURLs)
|
||||||
|
}
|
||||||
|
if game.Status != "disabled" || game.LaunchMode != "full_screen" || game.Orientation != "portrait" {
|
||||||
|
t.Fatalf("default fields mismatch: %+v", game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSelectProviderGamesKeepsOnlySelectedIDs(t *testing.T) {
|
func TestSelectProviderGamesKeepsOnlySelectedIDs(t *testing.T) {
|
||||||
games := []catalogRequest{
|
games := []catalogRequest{
|
||||||
{ProviderGameID: "1001", GameName: "one"},
|
{ProviderGameID: "1001", GameName: "one"},
|
||||||
|
|||||||
@ -27,15 +27,17 @@ type CoinSellerListItem struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
MerchantAssetType string `json:"merchantAssetType"`
|
MerchantAssetType string `json:"merchantAssetType"`
|
||||||
MerchantBalance int64 `json:"merchantBalance"`
|
MerchantBalance int64 `json:"merchantBalance"`
|
||||||
Contact string `json:"contact"`
|
// TotalRechargeUSDTMicro 是币商 USDT 进货累计付款微单位,只统计 counts_as_seller_recharge 的库存记录。
|
||||||
DisplayUserID string `json:"displayUserId"`
|
TotalRechargeUSDTMicro int64 `json:"totalRechargeUsdtMicro"`
|
||||||
Username string `json:"username"`
|
Contact string `json:"contact"`
|
||||||
Avatar string `json:"avatar"`
|
DisplayUserID string `json:"displayUserId"`
|
||||||
RegionID int64 `json:"regionId"`
|
Username string `json:"username"`
|
||||||
RegionName string `json:"regionName"`
|
Avatar string `json:"avatar"`
|
||||||
CreatedByUserID int64 `json:"createdByUserId"`
|
RegionID int64 `json:"regionId"`
|
||||||
CreatedAtMs int64 `json:"createdAtMs"`
|
RegionName string `json:"regionName"`
|
||||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
CreatedByUserID int64 `json:"createdByUserId"`
|
||||||
|
CreatedAtMs int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoinSellerSalaryRateTier struct {
|
type CoinSellerSalaryRateTier struct {
|
||||||
@ -615,6 +617,13 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
limitSQL := "LIMIT ? OFFSET ?"
|
||||||
|
queryArgs := append(args, query.PageSize, offset(query.Page, query.PageSize))
|
||||||
|
if isCoinSellerComputedSort(query.SortBy) {
|
||||||
|
// 币商余额和累充 USDT 都来自 wallet-service 聚合,必须先读取所有匹配币商再排序分页,避免只排序当前页。
|
||||||
|
limitSQL = ""
|
||||||
|
queryArgs = args
|
||||||
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||||
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
|
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
|
||||||
COALESCE(csp.contact_info, ''),
|
COALESCE(csp.contact_info, ''),
|
||||||
@ -624,8 +633,8 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
|||||||
COALESCE(r.name, '')
|
COALESCE(r.name, '')
|
||||||
%s
|
%s
|
||||||
ORDER BY csp.created_at_ms DESC, csp.user_id DESC
|
ORDER BY csp.created_at_ms DESC, csp.user_id DESC
|
||||||
LIMIT ? OFFSET ?
|
%s
|
||||||
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
`, whereSQL, limitSQL), queryArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@ -661,8 +670,17 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
rechargeTotals, err := r.coinSellerRechargeTotals(ctx, appCode, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
item.MerchantBalance = balances[item.UserID]
|
item.MerchantBalance = balances[item.UserID]
|
||||||
|
item.TotalRechargeUSDTMicro = rechargeTotals[item.UserID]
|
||||||
|
}
|
||||||
|
if isCoinSellerComputedSort(query.SortBy) {
|
||||||
|
sortCoinSellerListItems(items, query.SortBy, query.SortDirection)
|
||||||
|
items = paginateCoinSellerListItems(items, query.Page, query.PageSize)
|
||||||
}
|
}
|
||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
@ -851,6 +869,41 @@ func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// coinSellerRechargeTotals 从币商进货专表汇总 USDT 付款,金币补偿不会计入累充口径。
|
||||||
|
func (r *Reader) coinSellerRechargeTotals(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
|
||||||
|
result := make(map[int64]int64, len(userIDs))
|
||||||
|
if r == nil || r.walletDB == nil || len(userIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
placeholders := sqlPlaceholders(len(userIDs))
|
||||||
|
args := []any{appCode, paidCurrencyUSDT}
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
rows, err := r.walletDB.QueryContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT seller_user_id, COALESCE(SUM(paid_amount_micro), 0)
|
||||||
|
FROM coin_seller_stock_records
|
||||||
|
WHERE app_code = ?
|
||||||
|
AND counts_as_seller_recharge = TRUE
|
||||||
|
AND paid_currency_code = ?
|
||||||
|
AND seller_user_id IN (%s)
|
||||||
|
GROUP BY seller_user_id
|
||||||
|
`, placeholders), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var userID int64
|
||||||
|
var amount int64
|
||||||
|
if err := rows.Scan(&userID, &amount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[userID] = amount
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Reader) hostPeriodDiamonds(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
|
func (r *Reader) hostPeriodDiamonds(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
|
||||||
result := make(map[int64]int64, len(userIDs))
|
result := make(map[int64]int64, len(userIDs))
|
||||||
if r == nil || r.walletDB == nil || len(userIDs) == 0 {
|
if r == nil || r.walletDB == nil || len(userIDs) == 0 {
|
||||||
@ -881,6 +934,39 @@ func (r *Reader) hostPeriodDiamonds(ctx context.Context, appCode string, userIDs
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCoinSellerComputedSort(sortBy string) bool {
|
||||||
|
return sortBy == sortByMerchantBalance || sortBy == sortByTotalRechargeUSDT
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCoinSellerListItems 只处理 wallet 聚合字段排序,基础身份字段仍使用 SQL 默认创建时间排序。
|
||||||
|
func sortCoinSellerListItems(items []*CoinSellerListItem, sortBy string, direction string) {
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
left := coinSellerSortValue(items[i], sortBy)
|
||||||
|
right := coinSellerSortValue(items[j], sortBy)
|
||||||
|
if left == right {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if direction == "asc" {
|
||||||
|
return left < right
|
||||||
|
}
|
||||||
|
return left > right
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerSortValue(item *CoinSellerListItem, sortBy string) int64 {
|
||||||
|
if item == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch sortBy {
|
||||||
|
case sortByTotalRechargeUSDT:
|
||||||
|
return item.TotalRechargeUSDTMicro
|
||||||
|
case sortByMerchantBalance:
|
||||||
|
return item.MerchantBalance
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string) {
|
func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string) {
|
||||||
sort.SliceStable(items, func(i, j int) bool {
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
if items[i].Diamond == items[j].Diamond {
|
if items[i].Diamond == items[j].Diamond {
|
||||||
@ -893,6 +979,24 @@ func sortHostProfilesByDiamond(items []*userclient.HostProfile, direction string
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func paginateCoinSellerListItems(items []*CoinSellerListItem, page int, pageSize int) []*CoinSellerListItem {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
if start >= len(items) {
|
||||||
|
return []*CoinSellerListItem{}
|
||||||
|
}
|
||||||
|
end := start + pageSize
|
||||||
|
if end > len(items) {
|
||||||
|
end = len(items)
|
||||||
|
}
|
||||||
|
return items[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
func paginateHostProfiles(items []*userclient.HostProfile, page int, pageSize int) []*userclient.HostProfile {
|
func paginateHostProfiles(items []*userclient.HostProfile, page int, pageSize int) []*userclient.HostProfile {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
|
|||||||
@ -18,6 +18,9 @@ const (
|
|||||||
coinSellerStockTypePurchase = "usdt_purchase"
|
coinSellerStockTypePurchase = "usdt_purchase"
|
||||||
coinSellerStockTypeCompensate = "coin_compensation"
|
coinSellerStockTypeCompensate = "coin_compensation"
|
||||||
paidCurrencyUSDT = "USDT"
|
paidCurrencyUSDT = "USDT"
|
||||||
|
sortByDiamond = "diamond"
|
||||||
|
sortByMerchantBalance = "merchant_balance"
|
||||||
|
sortByTotalRechargeUSDT = "total_recharge_usdt"
|
||||||
bdLeaderPositionAliasMaxRunes = 64
|
bdLeaderPositionAliasMaxRunes = 64
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -524,7 +527,9 @@ func normalizeListQuery(query listQuery) listQuery {
|
|||||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||||
query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy))
|
query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy))
|
||||||
if query.SortBy != "diamond" {
|
switch query.SortBy {
|
||||||
|
case sortByDiamond, sortByMerchantBalance, sortByTotalRechargeUSDT:
|
||||||
|
default:
|
||||||
query.SortBy = ""
|
query.SortBy = ""
|
||||||
query.SortDirection = ""
|
query.SortDirection = ""
|
||||||
return query
|
return query
|
||||||
|
|||||||
32
server/admin/internal/modules/hostorg/service_test.go
Normal file
32
server/admin/internal/modules/hostorg/service_test.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package hostorg
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestNormalizeListQueryAllowsCoinSellerComputedSorts 锁定币商余额和累充 USDT 排序字段会被后台列表查询接受。
|
||||||
|
func TestNormalizeListQueryAllowsCoinSellerComputedSorts(t *testing.T) {
|
||||||
|
for _, sortBy := range []string{sortByMerchantBalance, sortByTotalRechargeUSDT} {
|
||||||
|
query := normalizeListQuery(listQuery{Page: 1, PageSize: 50, SortBy: sortBy, SortDirection: "asc"})
|
||||||
|
if query.SortBy != sortBy || query.SortDirection != "asc" {
|
||||||
|
t.Fatalf("expected sort %s asc to be kept, got sort_by=%q direction=%q", sortBy, query.SortBy, query.SortDirection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSortCoinSellerListItemsComputedFields 验证 wallet 聚合字段排序只改变展示顺序,不改动币商身份数据。
|
||||||
|
func TestSortCoinSellerListItemsComputedFields(t *testing.T) {
|
||||||
|
items := []*CoinSellerListItem{
|
||||||
|
{UserID: 1, MerchantBalance: 100, TotalRechargeUSDTMicro: 30_000_000},
|
||||||
|
{UserID: 2, MerchantBalance: 300, TotalRechargeUSDTMicro: 10_000_000},
|
||||||
|
{UserID: 3, MerchantBalance: 200, TotalRechargeUSDTMicro: 20_000_000},
|
||||||
|
}
|
||||||
|
|
||||||
|
sortCoinSellerListItems(items, sortByMerchantBalance, "desc")
|
||||||
|
if got := []int64{items[0].UserID, items[1].UserID, items[2].UserID}; got[0] != 2 || got[1] != 3 || got[2] != 1 {
|
||||||
|
t.Fatalf("merchant balance desc order mismatch: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
sortCoinSellerListItems(items, sortByTotalRechargeUSDT, "asc")
|
||||||
|
if got := []int64{items[0].UserID, items[1].UserID, items[2].UserID}; got[0] != 2 || got[1] != 3 || got[2] != 1 {
|
||||||
|
t.Fatalf("total recharge usdt asc order mismatch: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package registrationreward
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -72,6 +73,14 @@ type userDTO struct {
|
|||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type claimsPageDTO struct {
|
||||||
|
Items []claimDTO `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
TodayClaimedCount int64 `json:"today_claimed_count"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) GetRegistrationRewardConfig(c *gin.Context) {
|
func (h *Handler) GetRegistrationRewardConfig(c *gin.Context) {
|
||||||
resp, err := h.activity.GetRegistrationRewardConfig(c.Request.Context(), &activityv1.GetRegistrationRewardConfigRequest{Meta: h.meta(c)})
|
resp, err := h.activity.GetRegistrationRewardConfig(c.Request.Context(), &activityv1.GetRegistrationRewardConfigRequest{Meta: h.meta(c)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -107,21 +116,29 @@ func (h *Handler) UpdateRegistrationRewardConfig(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *Handler) ListRegistrationRewardClaims(c *gin.Context) {
|
func (h *Handler) ListRegistrationRewardClaims(c *gin.Context) {
|
||||||
options := shared.ListOptions(c)
|
options := shared.ListOptions(c)
|
||||||
|
claimedStartMS := queryInt64(c, "claimed_start_ms", "claimedStartMs", "start_ms", "startMs")
|
||||||
|
claimedEndMS := queryInt64(c, "claimed_end_ms", "claimedEndMs", "end_ms", "endMs")
|
||||||
userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
|
userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
|
||||||
if !ok {
|
if !ok {
|
||||||
response.ServerError(c, "查询用户信息失败")
|
response.ServerError(c, "查询用户信息失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if options.Keyword != "" && !matched {
|
if options.Keyword != "" && !matched {
|
||||||
response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
|
todayClaimedCount, ok := h.loadTodayClaimedCount(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, claimsPageDTO{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0, TodayClaimedCount: todayClaimedCount})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, err := h.activity.ListRegistrationRewardClaims(c.Request.Context(), &activityv1.ListRegistrationRewardClaimsRequest{
|
resp, err := h.activity.ListRegistrationRewardClaims(c.Request.Context(), &activityv1.ListRegistrationRewardClaimsRequest{
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
Status: strings.TrimSpace(options.Status),
|
Status: strings.TrimSpace(options.Status),
|
||||||
UserId: userID,
|
UserId: userID,
|
||||||
Page: int32(options.Page),
|
ClaimedStartMs: claimedStartMS,
|
||||||
PageSize: int32(options.PageSize),
|
ClaimedEndMs: claimedEndMS,
|
||||||
|
Page: int32(options.Page),
|
||||||
|
PageSize: int32(options.PageSize),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, "获取注册奖励领取记录失败")
|
response.ServerError(c, "获取注册奖励领取记录失败")
|
||||||
@ -135,7 +152,26 @@ func (h *Handler) ListRegistrationRewardClaims(c *gin.Context) {
|
|||||||
response.ServerError(c, "补全用户信息失败")
|
response.ServerError(c, "补全用户信息失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
response.OK(c, claimsPageDTO{
|
||||||
|
Items: items,
|
||||||
|
Page: options.Page,
|
||||||
|
PageSize: options.PageSize,
|
||||||
|
Total: resp.GetTotal(),
|
||||||
|
TodayClaimedCount: resp.GetTodayClaimedCount(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loadTodayClaimedCount(c *gin.Context) (int64, bool) {
|
||||||
|
resp, err := h.activity.ListRegistrationRewardClaims(c.Request.Context(), &activityv1.ListRegistrationRewardClaimsRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取注册奖励今日领取数量失败")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return resp.GetTodayClaimedCount(), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
||||||
@ -303,3 +339,17 @@ func int64Args(values []int64) []any {
|
|||||||
}
|
}
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryInt64(c *gin.Context, names ...string) int64 {
|
||||||
|
for _, name := range names {
|
||||||
|
value := strings.TrimSpace(c.Query(name))
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err == nil && parsed > 0 {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
227
server/admin/internal/modules/roomturnoverreward/handler.go
Normal file
227
server/admin/internal/modules/roomturnoverreward/handler.go
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
package roomturnoverreward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/appctx"
|
||||||
|
"hyapp-admin-server/internal/integration/activityclient"
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
|
"hyapp-admin-server/internal/response"
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
activity activityclient.Client
|
||||||
|
audit shared.OperationLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(activity activityclient.Client, audit shared.OperationLogger) *Handler {
|
||||||
|
return &Handler{activity: activity, audit: audit}
|
||||||
|
}
|
||||||
|
|
||||||
|
type configRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Tiers []tierDTO `json:"tiers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type configDTO struct {
|
||||||
|
AppCode string `json:"app_code"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Tiers []tierDTO `json:"tiers"`
|
||||||
|
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierDTO struct {
|
||||||
|
TierID int64 `json:"tier_id"`
|
||||||
|
TierCode string `json:"tier_code"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
ThresholdCoinSpent int64 `json:"threshold_coin_spent"`
|
||||||
|
RewardCoinAmount int64 `json:"reward_coin_amount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type settlementDTO struct {
|
||||||
|
SettlementID string `json:"settlement_id"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
OwnerUserID int64 `json:"owner_user_id"`
|
||||||
|
PeriodStartMS int64 `json:"period_start_ms"`
|
||||||
|
PeriodEndMS int64 `json:"period_end_ms"`
|
||||||
|
CoinSpent int64 `json:"coin_spent"`
|
||||||
|
TierID int64 `json:"tier_id"`
|
||||||
|
TierCode string `json:"tier_code"`
|
||||||
|
ThresholdCoinSpent int64 `json:"threshold_coin_spent"`
|
||||||
|
RewardCoinAmount int64 `json:"reward_coin_amount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
WalletCommandID string `json:"wallet_command_id"`
|
||||||
|
WalletTransactionID string `json:"wallet_transaction_id"`
|
||||||
|
FailureReason string `json:"failure_reason"`
|
||||||
|
SettledAtMS int64 `json:"settled_at_ms"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetConfig(c *gin.Context) {
|
||||||
|
// 后台服务只透传到 activity-service 获取当前 App 配置;活动规则 owner 不在 admin-server 内部复制。
|
||||||
|
resp, err := h.activity.GetRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.GetRoomTurnoverRewardConfigRequest{Meta: h.meta(c)})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取房间流水奖励配置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, configFromProto(resp.GetConfig()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||||
|
var req configRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "房间流水奖励配置参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tiers := make([]*activityv1.RoomTurnoverRewardTier, 0, len(req.Tiers))
|
||||||
|
for _, tier := range req.Tiers {
|
||||||
|
// 后台只做输入清洗和操作者透传;阈值递增、奖励金额和启用状态等业务校验统一由 activity-service 执行。
|
||||||
|
tiers = append(tiers, &activityv1.RoomTurnoverRewardTier{
|
||||||
|
TierId: tier.TierID,
|
||||||
|
TierCode: strings.TrimSpace(tier.TierCode),
|
||||||
|
TierName: strings.TrimSpace(tier.TierName),
|
||||||
|
ThresholdCoinSpent: tier.ThresholdCoinSpent,
|
||||||
|
RewardCoinAmount: tier.RewardCoinAmount,
|
||||||
|
Status: strings.TrimSpace(tier.Status),
|
||||||
|
SortOrder: tier.SortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resp, err := h.activity.UpdateRoomTurnoverRewardConfig(c.Request.Context(), &activityv1.UpdateRoomTurnoverRewardConfigRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
Tiers: tiers,
|
||||||
|
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := configFromProto(resp.GetConfig())
|
||||||
|
// 配置修改必须留下后台审计资源 ID,后续排查结算金额变化时可以回到具体 App 配置记录。
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "update-room-turnover-reward", "room_turnover_reward_configs", item.AppCode, "success", "")
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListSettlements(c *gin.Context) {
|
||||||
|
// 列表筛选沿用通用 ListOptions:status 过滤结算状态,keyword 用作 room_id 精确查询,避免后台接口引入模糊扫表。
|
||||||
|
options := shared.ListOptions(c)
|
||||||
|
resp, err := h.activity.ListRoomTurnoverRewardSettlements(c.Request.Context(), &activityv1.ListRoomTurnoverRewardSettlementsRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
Status: strings.TrimSpace(options.Status),
|
||||||
|
RoomId: strings.TrimSpace(options.Keyword),
|
||||||
|
Page: int32(options.Page),
|
||||||
|
PageSize: int32(options.PageSize),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取房间流水奖励结算记录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]settlementDTO, 0, len(resp.GetSettlements()))
|
||||||
|
for _, settlement := range resp.GetSettlements() {
|
||||||
|
items = append(items, settlementFromProto(settlement))
|
||||||
|
}
|
||||||
|
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RetrySettlement(c *gin.Context) {
|
||||||
|
settlementID := strings.TrimSpace(c.Param("settlement_id"))
|
||||||
|
if settlementID == "" {
|
||||||
|
response.BadRequest(c, "结算记录 ID 不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// retry 只把指定 settlement 交还 activity-service;是否重新取房主、是否调用钱包和是否幂等都由活动服务闭环处理。
|
||||||
|
resp, err := h.activity.RetryRoomTurnoverRewardSettlement(c.Request.Context(), &activityv1.RetryRoomTurnoverRewardSettlementRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
SettlementId: settlementID,
|
||||||
|
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := settlementFromProto(resp.GetSettlement())
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "retry-room-turnover-reward", "room_turnover_reward_settlements", settlementID, "success", "")
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
||||||
|
return &activityv1.RequestMeta{
|
||||||
|
// 使用后台请求 ID 串起 admin-server、activity-service 和 wallet-service 日志,AppCode 来自当前后台上下文。
|
||||||
|
RequestId: middleware.CurrentRequestID(c),
|
||||||
|
Caller: "admin-server",
|
||||||
|
AppCode: appctx.FromContext(c.Request.Context()),
|
||||||
|
SentAtMs: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFromProto(config *activityv1.RoomTurnoverRewardConfig) configDTO {
|
||||||
|
if config == nil {
|
||||||
|
return configDTO{Tiers: []tierDTO{}}
|
||||||
|
}
|
||||||
|
tiers := make([]tierDTO, 0, len(config.GetTiers()))
|
||||||
|
for _, tier := range config.GetTiers() {
|
||||||
|
tiers = append(tiers, tierFromProto(tier))
|
||||||
|
}
|
||||||
|
return configDTO{
|
||||||
|
AppCode: config.GetAppCode(),
|
||||||
|
Enabled: config.GetEnabled(),
|
||||||
|
Tiers: tiers,
|
||||||
|
UpdatedByAdminID: config.GetUpdatedByAdminId(),
|
||||||
|
CreatedAtMS: config.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: config.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tierFromProto(tier *activityv1.RoomTurnoverRewardTier) tierDTO {
|
||||||
|
if tier == nil {
|
||||||
|
return tierDTO{}
|
||||||
|
}
|
||||||
|
return tierDTO{
|
||||||
|
TierID: tier.GetTierId(),
|
||||||
|
TierCode: tier.GetTierCode(),
|
||||||
|
TierName: tier.GetTierName(),
|
||||||
|
ThresholdCoinSpent: tier.GetThresholdCoinSpent(),
|
||||||
|
RewardCoinAmount: tier.GetRewardCoinAmount(),
|
||||||
|
Status: tier.GetStatus(),
|
||||||
|
SortOrder: tier.GetSortOrder(),
|
||||||
|
CreatedAtMS: tier.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: tier.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settlementFromProto(settlement *activityv1.RoomTurnoverRewardSettlement) settlementDTO {
|
||||||
|
if settlement == nil {
|
||||||
|
return settlementDTO{}
|
||||||
|
}
|
||||||
|
return settlementDTO{
|
||||||
|
SettlementID: settlement.GetSettlementId(),
|
||||||
|
RoomID: settlement.GetRoomId(),
|
||||||
|
OwnerUserID: settlement.GetOwnerUserId(),
|
||||||
|
PeriodStartMS: settlement.GetPeriodStartMs(),
|
||||||
|
PeriodEndMS: settlement.GetPeriodEndMs(),
|
||||||
|
CoinSpent: settlement.GetCoinSpent(),
|
||||||
|
TierID: settlement.GetTierId(),
|
||||||
|
TierCode: settlement.GetTierCode(),
|
||||||
|
ThresholdCoinSpent: settlement.GetThresholdCoinSpent(),
|
||||||
|
RewardCoinAmount: settlement.GetRewardCoinAmount(),
|
||||||
|
Status: settlement.GetStatus(),
|
||||||
|
WalletCommandID: settlement.GetWalletCommandId(),
|
||||||
|
WalletTransactionID: settlement.GetWalletTransactionId(),
|
||||||
|
FailureReason: settlement.GetFailureReason(),
|
||||||
|
SettledAtMS: settlement.GetSettledAtMs(),
|
||||||
|
CreatedAtMS: settlement.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: settlement.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
17
server/admin/internal/modules/roomturnoverreward/routes.go
Normal file
17
server/admin/internal/modules/roomturnoverreward/routes.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package roomturnoverreward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
protected.GET("/admin/activity/room-turnover-reward/config", middleware.RequirePermission("room-turnover-reward:view"), h.GetConfig)
|
||||||
|
protected.PUT("/admin/activity/room-turnover-reward/config", middleware.RequirePermission("room-turnover-reward:update"), h.UpdateConfig)
|
||||||
|
protected.GET("/admin/activity/room-turnover-reward/settlements", middleware.RequirePermission("room-turnover-reward:view"), h.ListSettlements)
|
||||||
|
protected.POST("/admin/activity/room-turnover-reward/settlements/:settlement_id/retry", middleware.RequirePermission("room-turnover-reward:retry"), h.RetrySettlement)
|
||||||
|
}
|
||||||
350
server/admin/internal/modules/weeklystar/handler.go
Normal file
350
server/admin/internal/modules/weeklystar/handler.go
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
package weeklystar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/appctx"
|
||||||
|
"hyapp-admin-server/internal/integration/activityclient"
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
|
"hyapp-admin-server/internal/response"
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
activity activityclient.Client
|
||||||
|
audit shared.OperationLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(activity activityclient.Client, audit shared.OperationLogger) *Handler {
|
||||||
|
return &Handler{activity: activity, audit: audit}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cycleRequest struct {
|
||||||
|
CycleID string `json:"cycle_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RegionID int64 `json:"region_id"`
|
||||||
|
StartMS int64 `json:"start_ms"`
|
||||||
|
EndMS int64 `json:"end_ms"`
|
||||||
|
GiftIDs []string `json:"gift_ids"`
|
||||||
|
Gifts []giftDTO `json:"gifts"`
|
||||||
|
Rewards []rewardDTO `json:"rewards"`
|
||||||
|
Top1ResourceGroupID int64 `json:"top1_resource_group_id"`
|
||||||
|
Top2ResourceGroupID int64 `json:"top2_resource_group_id"`
|
||||||
|
Top3ResourceGroupID int64 `json:"top3_resource_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusRequest struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cycleDTO struct {
|
||||||
|
AppCode string `json:"app_code,omitempty"`
|
||||||
|
CycleID string `json:"cycle_id"`
|
||||||
|
ActivityCode string `json:"activity_code"`
|
||||||
|
RegionID int64 `json:"region_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartMS int64 `json:"start_ms"`
|
||||||
|
EndMS int64 `json:"end_ms"`
|
||||||
|
Gifts []giftDTO `json:"gifts"`
|
||||||
|
Rewards []rewardDTO `json:"rewards"`
|
||||||
|
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||||
|
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||||
|
SettledAtMS int64 `json:"settled_at_ms"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type giftDTO struct {
|
||||||
|
GiftID string `json:"gift_id"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rewardDTO struct {
|
||||||
|
RankNo int32 `json:"rank_no"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type leaderboardEntryDTO struct {
|
||||||
|
RankNo int32 `json:"rank_no"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Score int64 `json:"score"`
|
||||||
|
FirstScoredAtMS int64 `json:"first_scored_at_ms"`
|
||||||
|
LastScoredAtMS int64 `json:"last_scored_at_ms"`
|
||||||
|
DisplayUserID string `json:"display_user_id,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Avatar string `json:"avatar,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type settlementDTO struct {
|
||||||
|
SettlementID string `json:"settlement_id"`
|
||||||
|
CycleID string `json:"cycle_id"`
|
||||||
|
RankNo int32 `json:"rank_no"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Score int64 `json:"score"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
WalletCommandID string `json:"wallet_command_id"`
|
||||||
|
WalletGrantID string `json:"wallet_grant_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
FailureReason string `json:"failure_reason"`
|
||||||
|
AttemptCount int32 `json:"attempt_count"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListCycles(c *gin.Context) {
|
||||||
|
options := shared.ListOptions(c)
|
||||||
|
resp, err := h.activity.ListWeeklyStarCycles(c.Request.Context(), &activityv1.ListWeeklyStarCyclesRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
Status: strings.TrimSpace(options.Status),
|
||||||
|
StartMs: parseInt64Query(c, "start_ms"),
|
||||||
|
EndMs: parseInt64Query(c, "end_ms"),
|
||||||
|
RegionId: parseInt64Query(c, "region_id"),
|
||||||
|
Page: int32(options.Page),
|
||||||
|
PageSize: int32(options.PageSize),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取周星周期失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]cycleDTO, 0, len(resp.GetCycles()))
|
||||||
|
for _, cycle := range resp.GetCycles() {
|
||||||
|
items = append(items, cycleFromProto(cycle))
|
||||||
|
}
|
||||||
|
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateCycle(c *gin.Context) {
|
||||||
|
var req cycleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "周星周期参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.activity.CreateWeeklyStarCycle(c.Request.Context(), &activityv1.UpsertWeeklyStarCycleRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
Cycle: req.toProto(),
|
||||||
|
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := cycleFromProto(resp.GetCycle())
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "create-weekly-star-cycle", "weekly_star_cycles", item.CycleID, "success", "")
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetCycle(c *gin.Context) {
|
||||||
|
cycleID := strings.TrimSpace(c.Param("cycle_id"))
|
||||||
|
resp, err := h.activity.GetWeeklyStarCycle(c.Request.Context(), &activityv1.GetWeeklyStarCycleRequest{Meta: h.meta(c), CycleId: cycleID})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取周星周期详情失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, cycleFromProto(resp.GetCycle()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateCycle(c *gin.Context) {
|
||||||
|
var req cycleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "周星周期参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.CycleID = strings.TrimSpace(c.Param("cycle_id"))
|
||||||
|
resp, err := h.activity.UpdateWeeklyStarCycle(c.Request.Context(), &activityv1.UpsertWeeklyStarCycleRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
Cycle: req.toProto(),
|
||||||
|
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := cycleFromProto(resp.GetCycle())
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "update-weekly-star-cycle", "weekly_star_cycles", item.CycleID, "success", "")
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SetCycleStatus(c *gin.Context) {
|
||||||
|
var req statusRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "周星周期状态参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cycleID := strings.TrimSpace(c.Param("cycle_id"))
|
||||||
|
resp, err := h.activity.SetWeeklyStarCycleStatus(c.Request.Context(), &activityv1.SetWeeklyStarCycleStatusRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
CycleId: cycleID,
|
||||||
|
Status: strings.TrimSpace(req.Status),
|
||||||
|
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := cycleFromProto(resp.GetCycle())
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "set-weekly-star-cycle-status", "weekly_star_cycles", item.CycleID, "success", "")
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListLeaderboard(c *gin.Context) {
|
||||||
|
resp, err := h.activity.ListWeeklyStarLeaderboard(c.Request.Context(), &activityv1.ListWeeklyStarLeaderboardRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
CycleId: strings.TrimSpace(c.Param("cycle_id")),
|
||||||
|
PageSize: int32(parseIntQuery(c, "page_size", 50)),
|
||||||
|
PageToken: strings.TrimSpace(c.Query("page_token")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取周星排行榜失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]leaderboardEntryDTO, 0, len(resp.GetEntries()))
|
||||||
|
for _, entry := range resp.GetEntries() {
|
||||||
|
items = append(items, leaderboardEntryFromProto(entry))
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"cycle": cycleFromProto(resp.GetCycle()), "items": items, "total": resp.GetTotal(), "next_page_token": resp.GetNextPageToken()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListSettlements(c *gin.Context) {
|
||||||
|
resp, err := h.activity.ListWeeklyStarSettlements(c.Request.Context(), &activityv1.ListWeeklyStarSettlementsRequest{
|
||||||
|
Meta: h.meta(c),
|
||||||
|
CycleId: strings.TrimSpace(c.Param("cycle_id")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.ServerError(c, "获取周星结算记录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]settlementDTO, 0, len(resp.GetSettlements()))
|
||||||
|
for _, settlement := range resp.GetSettlements() {
|
||||||
|
items = append(items, settlementFromProto(settlement))
|
||||||
|
}
|
||||||
|
response.OK(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
||||||
|
return &activityv1.RequestMeta{
|
||||||
|
RequestId: middleware.CurrentRequestID(c),
|
||||||
|
Caller: "admin-server",
|
||||||
|
AppCode: appctx.FromContext(c.Request.Context()),
|
||||||
|
SentAtMs: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r cycleRequest) toProto() *activityv1.WeeklyStarCycle {
|
||||||
|
gifts := make([]*activityv1.WeeklyStarGift, 0, 3)
|
||||||
|
if len(r.Gifts) > 0 {
|
||||||
|
for _, gift := range r.Gifts {
|
||||||
|
gifts = append(gifts, &activityv1.WeeklyStarGift{GiftId: strings.TrimSpace(gift.GiftID), SortOrder: gift.SortOrder})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for index, giftID := range r.GiftIDs {
|
||||||
|
gifts = append(gifts, &activityv1.WeeklyStarGift{GiftId: strings.TrimSpace(giftID), SortOrder: int32(index + 1)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rewards := make([]*activityv1.WeeklyStarReward, 0, 3)
|
||||||
|
if len(r.Rewards) > 0 {
|
||||||
|
for _, reward := range r.Rewards {
|
||||||
|
rewards = append(rewards, &activityv1.WeeklyStarReward{RankNo: reward.RankNo, ResourceGroupId: reward.ResourceGroupID})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rewards = append(rewards,
|
||||||
|
&activityv1.WeeklyStarReward{RankNo: 1, ResourceGroupId: r.Top1ResourceGroupID},
|
||||||
|
&activityv1.WeeklyStarReward{RankNo: 2, ResourceGroupId: r.Top2ResourceGroupID},
|
||||||
|
&activityv1.WeeklyStarReward{RankNo: 3, ResourceGroupId: r.Top3ResourceGroupID},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return &activityv1.WeeklyStarCycle{
|
||||||
|
CycleId: strings.TrimSpace(r.CycleID),
|
||||||
|
RegionId: r.RegionID,
|
||||||
|
Title: strings.TrimSpace(r.Title),
|
||||||
|
Status: strings.TrimSpace(r.Status),
|
||||||
|
StartMs: r.StartMS,
|
||||||
|
EndMs: r.EndMS,
|
||||||
|
Gifts: gifts,
|
||||||
|
Rewards: rewards,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cycleFromProto(cycle *activityv1.WeeklyStarCycle) cycleDTO {
|
||||||
|
if cycle == nil {
|
||||||
|
return cycleDTO{Gifts: []giftDTO{}, Rewards: []rewardDTO{}}
|
||||||
|
}
|
||||||
|
gifts := make([]giftDTO, 0, len(cycle.GetGifts()))
|
||||||
|
for _, gift := range cycle.GetGifts() {
|
||||||
|
gifts = append(gifts, giftDTO{GiftID: gift.GetGiftId(), SortOrder: gift.GetSortOrder()})
|
||||||
|
}
|
||||||
|
rewards := make([]rewardDTO, 0, len(cycle.GetRewards()))
|
||||||
|
for _, reward := range cycle.GetRewards() {
|
||||||
|
rewards = append(rewards, rewardDTO{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()})
|
||||||
|
}
|
||||||
|
return cycleDTO{
|
||||||
|
AppCode: cycle.GetAppCode(),
|
||||||
|
CycleID: cycle.GetCycleId(),
|
||||||
|
ActivityCode: cycle.GetActivityCode(),
|
||||||
|
RegionID: cycle.GetRegionId(),
|
||||||
|
Title: cycle.GetTitle(),
|
||||||
|
Status: cycle.GetStatus(),
|
||||||
|
StartMS: cycle.GetStartMs(),
|
||||||
|
EndMS: cycle.GetEndMs(),
|
||||||
|
Gifts: gifts,
|
||||||
|
Rewards: rewards,
|
||||||
|
CreatedByAdminID: cycle.GetCreatedByAdminId(),
|
||||||
|
UpdatedByAdminID: cycle.GetUpdatedByAdminId(),
|
||||||
|
SettledAtMS: cycle.GetSettledAtMs(),
|
||||||
|
CreatedAtMS: cycle.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: cycle.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func leaderboardEntryFromProto(entry *activityv1.WeeklyStarLeaderboardEntry) leaderboardEntryDTO {
|
||||||
|
if entry == nil {
|
||||||
|
return leaderboardEntryDTO{}
|
||||||
|
}
|
||||||
|
return leaderboardEntryDTO{
|
||||||
|
RankNo: entry.GetRankNo(),
|
||||||
|
UserID: entry.GetUserId(),
|
||||||
|
Score: entry.GetScore(),
|
||||||
|
FirstScoredAtMS: entry.GetFirstScoredAtMs(),
|
||||||
|
LastScoredAtMS: entry.GetLastScoredAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settlementFromProto(settlement *activityv1.WeeklyStarSettlement) settlementDTO {
|
||||||
|
if settlement == nil {
|
||||||
|
return settlementDTO{}
|
||||||
|
}
|
||||||
|
return settlementDTO{
|
||||||
|
SettlementID: settlement.GetSettlementId(),
|
||||||
|
CycleID: settlement.GetCycleId(),
|
||||||
|
RankNo: settlement.GetRankNo(),
|
||||||
|
UserID: settlement.GetUserId(),
|
||||||
|
Score: settlement.GetScore(),
|
||||||
|
ResourceGroupID: settlement.GetResourceGroupId(),
|
||||||
|
WalletCommandID: settlement.GetWalletCommandId(),
|
||||||
|
WalletGrantID: settlement.GetWalletGrantId(),
|
||||||
|
Status: settlement.GetStatus(),
|
||||||
|
FailureReason: settlement.GetFailureReason(),
|
||||||
|
AttemptCount: settlement.GetAttemptCount(),
|
||||||
|
CreatedAtMS: settlement.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: settlement.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64Query(c *gin.Context, key string) int64 {
|
||||||
|
value, _ := strconv.ParseInt(strings.TrimSpace(c.Query(key)), 10, 64)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntQuery(c *gin.Context, key string, fallback int) int {
|
||||||
|
value, err := strconv.Atoi(strings.TrimSpace(c.Query(key)))
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
20
server/admin/internal/modules/weeklystar/routes.go
Normal file
20
server/admin/internal/modules/weeklystar/routes.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package weeklystar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
protected.GET("/admin/activity/weekly-star/cycles", middleware.RequirePermission("weekly-star:view"), h.ListCycles)
|
||||||
|
protected.POST("/admin/activity/weekly-star/cycles", middleware.RequirePermission("weekly-star:create"), h.CreateCycle)
|
||||||
|
protected.GET("/admin/activity/weekly-star/cycles/:cycle_id", middleware.RequirePermission("weekly-star:view"), h.GetCycle)
|
||||||
|
protected.PUT("/admin/activity/weekly-star/cycles/:cycle_id", middleware.RequirePermission("weekly-star:update"), h.UpdateCycle)
|
||||||
|
protected.PATCH("/admin/activity/weekly-star/cycles/:cycle_id/status", middleware.RequirePermission("weekly-star:update"), h.SetCycleStatus)
|
||||||
|
protected.GET("/admin/activity/weekly-star/cycles/:cycle_id/leaderboard", middleware.RequirePermission("weekly-star:view"), h.ListLeaderboard)
|
||||||
|
protected.GET("/admin/activity/weekly-star/cycles/:cycle_id/settlements", middleware.RequirePermission("weekly-star:view"), h.ListSettlements)
|
||||||
|
}
|
||||||
@ -124,6 +124,10 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "VIP 配置查看", Code: "vip-config:view", Kind: "menu"},
|
{Name: "VIP 配置查看", Code: "vip-config:view", Kind: "menu"},
|
||||||
{Name: "VIP 配置更新", Code: "vip-config:update", Kind: "button"},
|
{Name: "VIP 配置更新", Code: "vip-config:update", Kind: "button"},
|
||||||
{Name: "VIP 赠送", Code: "vip-config:grant", Kind: "button"},
|
{Name: "VIP 赠送", Code: "vip-config:grant", Kind: "button"},
|
||||||
|
{Name: "周星配置查看", Code: "weekly-star:view", Kind: "menu"},
|
||||||
|
{Name: "周星配置创建", Code: "weekly-star:create", Kind: "button"},
|
||||||
|
{Name: "周星配置更新", Code: "weekly-star:update", Kind: "button"},
|
||||||
|
{Name: "周星结算查看", Code: "weekly-star:settle", Kind: "button"},
|
||||||
{Name: "角色查看", Code: "role:view", Kind: "menu"},
|
{Name: "角色查看", Code: "role:view", Kind: "menu"},
|
||||||
{Name: "角色创建", Code: "role:create", Kind: "button"},
|
{Name: "角色创建", Code: "role:create", Kind: "button"},
|
||||||
{Name: "角色更新", Code: "role:update", Kind: "button"},
|
{Name: "角色更新", Code: "role:update", Kind: "button"},
|
||||||
@ -174,18 +178,18 @@ func (s *Store) seedMenus() error {
|
|||||||
gameID := uint(0)
|
gameID := uint(0)
|
||||||
menus := []model.Menu{
|
menus := []model.Menu{
|
||||||
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true},
|
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true},
|
||||||
{Title: "后台设置", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
|
{Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 20, Visible: true},
|
||||||
{Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 40, Visible: false},
|
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 30, Visible: true},
|
||||||
{Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 60, Visible: true},
|
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 40, Visible: true},
|
||||||
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 65, Visible: true},
|
{Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 50, Visible: true},
|
||||||
{Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 66, Visible: true},
|
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 60, Visible: true},
|
||||||
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 67, Visible: true},
|
{Title: "运营管理", Code: "operations", Path: "", Icon: "operations", PermissionCode: "", Sort: 70, Visible: true},
|
||||||
{Title: "运营管理", Code: "operations", Path: "", Icon: "operations", PermissionCode: "", Sort: 68, Visible: true},
|
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 80, Visible: true},
|
||||||
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 69, Visible: true},
|
{Title: "活动管理", Code: "activities", Path: "", Icon: "campaign", PermissionCode: "", Sort: 90, Visible: true},
|
||||||
{Title: "活动管理", Code: "activities", Path: "", Icon: "campaign", PermissionCode: "", Sort: 70, Visible: true},
|
{Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 100, Visible: true},
|
||||||
{Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 71, Visible: true},
|
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 110, Visible: true},
|
||||||
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 72, Visible: true},
|
{Title: "后台设置", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 120, Visible: true},
|
||||||
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true},
|
{Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 130, Visible: false},
|
||||||
}
|
}
|
||||||
for _, menu := range menus {
|
for _, menu := range menus {
|
||||||
syncedMenu, err := s.syncDefaultMenu(s.db, menu)
|
syncedMenu, err := s.syncDefaultMenu(s.db, menu)
|
||||||
@ -267,6 +271,7 @@ func (s *Store) seedMenus() error {
|
|||||||
{ParentID: &activityID, Title: "用户榜单", Code: "user-leaderboard", Path: "/activities/user-leaderboards", Icon: "leaderboard", PermissionCode: "user-leaderboard:view", Sort: 74, Visible: true},
|
{ParentID: &activityID, Title: "用户榜单", Code: "user-leaderboard", Path: "/activities/user-leaderboards", Icon: "leaderboard", PermissionCode: "user-leaderboard:view", Sort: 74, Visible: true},
|
||||||
{ParentID: &activityID, Title: "红包配置", Code: "red-packet", Path: "/activities/red-packets", Icon: "redeem", PermissionCode: "red-packet:view", Sort: 75, Visible: true},
|
{ParentID: &activityID, Title: "红包配置", Code: "red-packet", Path: "/activities/red-packets", Icon: "redeem", PermissionCode: "red-packet:view", Sort: 75, Visible: true},
|
||||||
{ParentID: &activityID, Title: "VIP配置", Code: "vip-config", Path: "/activities/vip-config", Icon: "workspace_premium", PermissionCode: "vip-config:view", Sort: 76, Visible: true},
|
{ParentID: &activityID, Title: "VIP配置", Code: "vip-config", Path: "/activities/vip-config", Icon: "workspace_premium", PermissionCode: "vip-config:view", Sort: 76, Visible: true},
|
||||||
|
{ParentID: &activityID, Title: "周星配置", Code: "weekly-star", Path: "/activities/weekly-star", Icon: "star", PermissionCode: "weekly-star:view", Sort: 78, Visible: true},
|
||||||
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
|
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
|
||||||
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
|
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
|
||||||
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
|
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
|
||||||
@ -513,12 +518,13 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"room-rocket:view", "room-rocket:update",
|
"room-rocket:view", "room-rocket:update",
|
||||||
"red-packet:view", "red-packet:update",
|
"red-packet:view", "red-packet:update",
|
||||||
"vip-config:view", "vip-config:update", "vip-config:grant",
|
"vip-config:view", "vip-config:update", "vip-config:grant",
|
||||||
|
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
|
||||||
"log:view",
|
"log:view",
|
||||||
"job:view", "job:cancel", "export:create",
|
"job:view", "job:cancel", "export:create",
|
||||||
"upload:create",
|
"upload:create",
|
||||||
}
|
}
|
||||||
case "auditor":
|
case "auditor":
|
||||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
|
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "vip-config:view", "weekly-star:view", "role:view", "permission:view", "job:view"}
|
||||||
case "readonly":
|
case "readonly":
|
||||||
return []string{
|
return []string{
|
||||||
"overview:view",
|
"overview:view",
|
||||||
@ -561,6 +567,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"room-rocket:view",
|
"room-rocket:view",
|
||||||
"red-packet:view",
|
"red-packet:view",
|
||||||
"vip-config:view",
|
"vip-config:view",
|
||||||
|
"weekly-star:view",
|
||||||
"role:view",
|
"role:view",
|
||||||
"permission:view",
|
"permission:view",
|
||||||
"menu:view",
|
"menu:view",
|
||||||
@ -602,9 +609,10 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"room-rocket:view", "room-rocket:update",
|
"room-rocket:view", "room-rocket:update",
|
||||||
"red-packet:view", "red-packet:update",
|
"red-packet:view", "red-packet:update",
|
||||||
"vip-config:view", "vip-config:update", "vip-config:grant",
|
"vip-config:view", "vip-config:update", "vip-config:grant",
|
||||||
|
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
|
||||||
}
|
}
|
||||||
case "auditor", "readonly":
|
case "auditor", "readonly":
|
||||||
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "vip-config:view"}
|
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "vip-config:view", "weekly-star:view"}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
authroutes "hyapp-admin-server/internal/modules/auth"
|
authroutes "hyapp-admin-server/internal/modules/auth"
|
||||||
"hyapp-admin-server/internal/modules/coinledger"
|
"hyapp-admin-server/internal/modules/coinledger"
|
||||||
"hyapp-admin-server/internal/modules/countryregion"
|
"hyapp-admin-server/internal/modules/countryregion"
|
||||||
|
"hyapp-admin-server/internal/modules/cumulativerechargereward"
|
||||||
"hyapp-admin-server/internal/modules/dailytask"
|
"hyapp-admin-server/internal/modules/dailytask"
|
||||||
"hyapp-admin-server/internal/modules/dashboard"
|
"hyapp-admin-server/internal/modules/dashboard"
|
||||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
@ -34,6 +35,7 @@ import (
|
|||||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||||
"hyapp-admin-server/internal/modules/roomadmin"
|
"hyapp-admin-server/internal/modules/roomadmin"
|
||||||
"hyapp-admin-server/internal/modules/roomrocket"
|
"hyapp-admin-server/internal/modules/roomrocket"
|
||||||
|
"hyapp-admin-server/internal/modules/roomturnoverreward"
|
||||||
"hyapp-admin-server/internal/modules/search"
|
"hyapp-admin-server/internal/modules/search"
|
||||||
"hyapp-admin-server/internal/modules/sevendaycheckin"
|
"hyapp-admin-server/internal/modules/sevendaycheckin"
|
||||||
"hyapp-admin-server/internal/modules/teamsalarypolicy"
|
"hyapp-admin-server/internal/modules/teamsalarypolicy"
|
||||||
@ -41,6 +43,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/upload"
|
"hyapp-admin-server/internal/modules/upload"
|
||||||
"hyapp-admin-server/internal/modules/userleaderboard"
|
"hyapp-admin-server/internal/modules/userleaderboard"
|
||||||
"hyapp-admin-server/internal/modules/vipconfig"
|
"hyapp-admin-server/internal/modules/vipconfig"
|
||||||
|
"hyapp-admin-server/internal/modules/weeklystar"
|
||||||
"hyapp-admin-server/internal/platform/logging"
|
"hyapp-admin-server/internal/platform/logging"
|
||||||
"hyapp-admin-server/internal/service"
|
"hyapp-admin-server/internal/service"
|
||||||
|
|
||||||
@ -57,6 +60,7 @@ type Handlers struct {
|
|||||||
Auth *authroutes.Handler
|
Auth *authroutes.Handler
|
||||||
CoinLedger *coinledger.Handler
|
CoinLedger *coinledger.Handler
|
||||||
CountryRegion *countryregion.Handler
|
CountryRegion *countryregion.Handler
|
||||||
|
CumulativeRecharge *cumulativerechargereward.Handler
|
||||||
DailyTask *dailytask.Handler
|
DailyTask *dailytask.Handler
|
||||||
Dashboard *dashboard.Handler
|
Dashboard *dashboard.Handler
|
||||||
FirstRechargeReward *firstrechargereward.Handler
|
FirstRechargeReward *firstrechargereward.Handler
|
||||||
@ -79,6 +83,7 @@ type Handlers struct {
|
|||||||
Resource *resourcemodule.Handler
|
Resource *resourcemodule.Handler
|
||||||
RoomAdmin *roomadmin.Handler
|
RoomAdmin *roomadmin.Handler
|
||||||
RoomRocket *roomrocket.Handler
|
RoomRocket *roomrocket.Handler
|
||||||
|
RoomTurnoverReward *roomturnoverreward.Handler
|
||||||
Search *search.Handler
|
Search *search.Handler
|
||||||
SevenDayCheckIn *sevendaycheckin.Handler
|
SevenDayCheckIn *sevendaycheckin.Handler
|
||||||
TeamSalaryPolicy *teamsalarypolicy.Handler
|
TeamSalaryPolicy *teamsalarypolicy.Handler
|
||||||
@ -86,6 +91,7 @@ type Handlers struct {
|
|||||||
Upload *upload.Handler
|
Upload *upload.Handler
|
||||||
UserLeaderboard *userleaderboard.Handler
|
UserLeaderboard *userleaderboard.Handler
|
||||||
VIPConfig *vipconfig.Handler
|
VIPConfig *vipconfig.Handler
|
||||||
|
WeeklyStar *weeklystar.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||||
@ -112,6 +118,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
|||||||
appregistry.RegisterRoutes(protected, h.AppRegistry)
|
appregistry.RegisterRoutes(protected, h.AppRegistry)
|
||||||
appconfig.RegisterRoutes(protected, h.AppConfig)
|
appconfig.RegisterRoutes(protected, h.AppConfig)
|
||||||
countryregion.RegisterRoutes(protected, h.CountryRegion)
|
countryregion.RegisterRoutes(protected, h.CountryRegion)
|
||||||
|
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
|
||||||
dailytask.RegisterRoutes(protected, h.DailyTask)
|
dailytask.RegisterRoutes(protected, h.DailyTask)
|
||||||
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
||||||
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
|
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
|
||||||
@ -120,6 +127,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
|||||||
giftdiamond.RegisterRoutes(protected, h.GiftDiamond)
|
giftdiamond.RegisterRoutes(protected, h.GiftDiamond)
|
||||||
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
|
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
|
||||||
roomrocket.RegisterRoutes(protected, h.RoomRocket)
|
roomrocket.RegisterRoutes(protected, h.RoomRocket)
|
||||||
|
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
|
||||||
dashboard.RegisterRoutes(protected, h.Dashboard)
|
dashboard.RegisterRoutes(protected, h.Dashboard)
|
||||||
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
|
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
|
||||||
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
|
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
|
||||||
@ -136,6 +144,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
|||||||
teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement)
|
teamsalarysettlement.RegisterRoutes(protected, h.TeamSalarySettlement)
|
||||||
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
|
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
|
||||||
vipconfig.RegisterRoutes(protected, h.VIPConfig)
|
vipconfig.RegisterRoutes(protected, h.VIPConfig)
|
||||||
|
weeklystar.RegisterRoutes(protected, h.WeeklyStar)
|
||||||
|
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 房间流水奖励由 activity-service 统计和结算;后台只维护配置、查看记录和失败重试入口。
|
||||||
|
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('房间流水奖励查看', 'room-turnover-reward:view', 'menu', '', @now_ms, @now_ms),
|
||||||
|
('房间流水奖励更新', 'room-turnover-reward:update', 'button', '', @now_ms, @now_ms),
|
||||||
|
('房间流水奖励重试', 'room-turnover-reward:retry', 'button', '', @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
|
||||||
|
(NULL, '活动管理', 'activities', '', 'campaign', '', 70, TRUE, @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||||
|
SELECT parent.id, '房间流水奖励', 'room-turnover-reward', '/activities/room-turnover-reward', 'paid', 'room-turnover-reward:view', 77, TRUE, @now_ms, @now_ms
|
||||||
|
FROM admin_menus parent
|
||||||
|
WHERE parent.code = 'activities'
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
parent_id = VALUES(parent_id),
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'platform-admin'
|
||||||
|
AND admin_permission.code IN ('room-turnover-reward:view', 'room-turnover-reward:update', 'room-turnover-reward:retry');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'ops-admin'
|
||||||
|
AND admin_permission.code IN ('room-turnover-reward:view', 'room-turnover-reward:update', 'room-turnover-reward:retry');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||||
|
AND admin_permission.code = 'room-turnover-reward:view';
|
||||||
61
server/admin/migrations/037_weekly_star_navigation.sql
Normal file
61
server/admin/migrations/037_weekly_star_navigation.sql
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 周星配置由 activity-service 拥有周期、榜单和结算事实;后台只提供配置和查看入口。
|
||||||
|
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('周星配置查看', 'weekly-star:view', 'menu', '', @now_ms, @now_ms),
|
||||||
|
('周星配置创建', 'weekly-star:create', 'button', '', @now_ms, @now_ms),
|
||||||
|
('周星配置更新', 'weekly-star:update', 'button', '', @now_ms, @now_ms),
|
||||||
|
('周星结算查看', 'weekly-star:settle', 'button', '', @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
|
||||||
|
(NULL, '活动管理', 'activities', '', 'campaign', '', 70, TRUE, @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||||
|
SELECT parent.id, '周星配置', 'weekly-star', '/activities/weekly-star', 'star', 'weekly-star:view', 78, TRUE, @now_ms, @now_ms
|
||||||
|
FROM admin_menus parent
|
||||||
|
WHERE parent.code = 'activities'
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
parent_id = VALUES(parent_id),
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'platform-admin'
|
||||||
|
AND admin_permission.code IN ('weekly-star:view', 'weekly-star:create', 'weekly-star:update', 'weekly-star:settle');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'ops-admin'
|
||||||
|
AND admin_permission.code IN ('weekly-star:view', 'weekly-star:create', 'weekly-star:update', 'weekly-star:settle');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||||
|
AND admin_permission.code = 'weekly-star:view';
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 累充奖励事实由 activity-service 按 UTC 周期累计和发奖;后台只维护后续事件使用的档位配置并查看发放记录。
|
||||||
|
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('累充奖励查看', 'cumulative-recharge-reward:view', 'menu', '', @now_ms, @now_ms),
|
||||||
|
('累充奖励更新', 'cumulative-recharge-reward:update', 'button', '', @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
|
||||||
|
(NULL, '活动管理', 'activities', '', 'campaign', '', 70, TRUE, @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||||
|
SELECT parent.id, '累充奖励配置', 'cumulative-recharge-reward', '/activities/cumulative-recharge-reward', 'workspace_premium', 'cumulative-recharge-reward:view', 79, TRUE, @now_ms, @now_ms
|
||||||
|
FROM admin_menus parent
|
||||||
|
WHERE parent.code = 'activities'
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
parent_id = VALUES(parent_id),
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'platform-admin'
|
||||||
|
AND admin_permission.code IN ('cumulative-recharge-reward:view', 'cumulative-recharge-reward:update');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'ops-admin'
|
||||||
|
AND admin_permission.code IN ('cumulative-recharge-reward:view', 'cumulative-recharge-reward:update');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||||
|
AND admin_permission.code = 'cumulative-recharge-reward:view';
|
||||||
39
server/admin/migrations/039_admin_parent_menu_order.sql
Normal file
39
server/admin/migrations/039_admin_parent_menu_order.sql
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 左侧父级菜单顺序直接影响后台入口效率:团队管理靠近用户管理,后台设置固定沉到底部。
|
||||||
|
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
UPDATE admin_menus
|
||||||
|
SET sort = CASE code
|
||||||
|
WHEN 'overview' THEN 10
|
||||||
|
WHEN 'app-users' THEN 20
|
||||||
|
WHEN 'host-org' THEN 30
|
||||||
|
WHEN 'rooms' THEN 40
|
||||||
|
WHEN 'app-config' THEN 50
|
||||||
|
WHEN 'resources' THEN 60
|
||||||
|
WHEN 'operations' THEN 70
|
||||||
|
WHEN 'payment' THEN 80
|
||||||
|
WHEN 'activities' THEN 90
|
||||||
|
WHEN 'games' THEN 100
|
||||||
|
WHEN 'geo' THEN 110
|
||||||
|
WHEN 'system' THEN 120
|
||||||
|
WHEN 'logs' THEN 130
|
||||||
|
ELSE sort
|
||||||
|
END,
|
||||||
|
updated_at_ms = @now_ms
|
||||||
|
WHERE parent_id IS NULL
|
||||||
|
AND code IN (
|
||||||
|
'overview',
|
||||||
|
'app-users',
|
||||||
|
'host-org',
|
||||||
|
'rooms',
|
||||||
|
'app-config',
|
||||||
|
'resources',
|
||||||
|
'operations',
|
||||||
|
'payment',
|
||||||
|
'activities',
|
||||||
|
'games',
|
||||||
|
'geo',
|
||||||
|
'system',
|
||||||
|
'logs'
|
||||||
|
);
|
||||||
@ -12,9 +12,12 @@ health_http_addr: ":13106"
|
|||||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
|
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||||
user_service_addr: "user-service:13005"
|
user_service_addr: "user-service:13005"
|
||||||
wallet_service_addr: "wallet-service:13004"
|
wallet_service_addr: "wallet-service:13004"
|
||||||
|
room_service_addr: "room-service:13001"
|
||||||
mysql_auto_migrate: true
|
mysql_auto_migrate: true
|
||||||
first_recharge_reward_worker:
|
first_recharge_reward_worker:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
cumulative_recharge_reward_worker:
|
||||||
|
enabled: true
|
||||||
red_packet_broadcast_worker:
|
red_packet_broadcast_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
lucky_gift_worker:
|
lucky_gift_worker:
|
||||||
@ -64,6 +67,7 @@ rocketmq:
|
|||||||
topic: "hyapp_wallet_outbox"
|
topic: "hyapp_wallet_outbox"
|
||||||
realtime_topic: "hyapp_wallet_realtime_outbox"
|
realtime_topic: "hyapp_wallet_realtime_outbox"
|
||||||
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
||||||
|
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
|
||||||
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
||||||
consumer_max_reconsume_times: 16
|
consumer_max_reconsume_times: 16
|
||||||
user_outbox:
|
user_outbox:
|
||||||
|
|||||||
@ -12,9 +12,12 @@ health_http_addr: ":13106"
|
|||||||
mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC&tls=false"
|
mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC&tls=false"
|
||||||
user_service_addr: "user-service.internal:13005"
|
user_service_addr: "user-service.internal:13005"
|
||||||
wallet_service_addr: "wallet-service.internal:13004"
|
wallet_service_addr: "wallet-service.internal:13004"
|
||||||
|
room_service_addr: "room-service.internal:13001"
|
||||||
mysql_auto_migrate: false
|
mysql_auto_migrate: false
|
||||||
first_recharge_reward_worker:
|
first_recharge_reward_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
cumulative_recharge_reward_worker:
|
||||||
|
enabled: true
|
||||||
red_packet_broadcast_worker:
|
red_packet_broadcast_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
lucky_gift_worker:
|
lucky_gift_worker:
|
||||||
@ -63,6 +66,7 @@ rocketmq:
|
|||||||
topic: "hyapp_wallet_outbox"
|
topic: "hyapp_wallet_outbox"
|
||||||
realtime_topic: "hyapp_wallet_realtime_outbox"
|
realtime_topic: "hyapp_wallet_realtime_outbox"
|
||||||
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
||||||
|
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
|
||||||
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
||||||
consumer_max_reconsume_times: 16
|
consumer_max_reconsume_times: 16
|
||||||
user_outbox:
|
user_outbox:
|
||||||
|
|||||||
@ -12,9 +12,12 @@ health_http_addr: ":13106"
|
|||||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
|
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||||
user_service_addr: "127.0.0.1:13005"
|
user_service_addr: "127.0.0.1:13005"
|
||||||
wallet_service_addr: "127.0.0.1:13004"
|
wallet_service_addr: "127.0.0.1:13004"
|
||||||
|
room_service_addr: "127.0.0.1:13001"
|
||||||
mysql_auto_migrate: true
|
mysql_auto_migrate: true
|
||||||
first_recharge_reward_worker:
|
first_recharge_reward_worker:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
cumulative_recharge_reward_worker:
|
||||||
|
enabled: false
|
||||||
red_packet_broadcast_worker:
|
red_packet_broadcast_worker:
|
||||||
enabled: false
|
enabled: false
|
||||||
lucky_gift_worker:
|
lucky_gift_worker:
|
||||||
@ -63,6 +66,7 @@ rocketmq:
|
|||||||
topic: "hyapp_wallet_outbox"
|
topic: "hyapp_wallet_outbox"
|
||||||
realtime_topic: ""
|
realtime_topic: ""
|
||||||
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
||||||
|
cumulative_recharge_consumer_group: "hyapp-activity-cumulative-recharge-wallet-outbox"
|
||||||
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
||||||
consumer_max_reconsume_times: 16
|
consumer_max_reconsume_times: 16
|
||||||
user_outbox:
|
user_outbox:
|
||||||
|
|||||||
@ -183,6 +183,7 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records (
|
|||||||
device_id VARCHAR(128) NOT NULL COMMENT '设备 ID',
|
device_id VARCHAR(128) NOT NULL COMMENT '设备 ID',
|
||||||
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||||
anchor_id VARCHAR(96) NOT NULL COMMENT '主播关联 ID',
|
anchor_id VARCHAR(96) NOT NULL COMMENT '主播关联 ID',
|
||||||
|
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域 ID',
|
||||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
gift_id VARCHAR(96) NOT NULL COMMENT '礼物 ID',
|
gift_id VARCHAR(96) NOT NULL COMMENT '礼物 ID',
|
||||||
coin_spent BIGINT NOT NULL COMMENT '消耗金币',
|
coin_spent BIGINT NOT NULL COMMENT '消耗金币',
|
||||||
@ -213,6 +214,7 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records (
|
|||||||
KEY idx_lucky_draw_user (app_code, user_id, created_at_ms),
|
KEY idx_lucky_draw_user (app_code, user_id, created_at_ms),
|
||||||
KEY idx_lucky_draw_gift (app_code, gift_id, created_at_ms),
|
KEY idx_lucky_draw_gift (app_code, gift_id, created_at_ms),
|
||||||
KEY idx_lucky_draw_room (app_code, room_id, created_at_ms),
|
KEY idx_lucky_draw_room (app_code, room_id, created_at_ms),
|
||||||
|
KEY idx_lucky_draw_region_created (app_code, visible_region_id, created_at_ms, draw_id),
|
||||||
KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms),
|
KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms),
|
||||||
KEY idx_lucky_draw_created (app_code, created_at_ms, draw_id),
|
KEY idx_lucky_draw_created (app_code, created_at_ms, draw_id),
|
||||||
KEY idx_lucky_draw_pool_status_summary (app_code, pool_id, reward_status, created_at_ms, draw_id),
|
KEY idx_lucky_draw_pool_status_summary (app_code, pool_id, reward_status, created_at_ms, draw_id),
|
||||||
@ -239,6 +241,24 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats (
|
|||||||
PRIMARY KEY (app_code, pool_id, gift_id)
|
PRIMARY KEY (app_code, pool_id, gift_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物池级汇总统计';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物池级汇总统计';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lucky_draw_pool_day_stats (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
stat_day DATE NOT NULL COMMENT 'UTC 统计日',
|
||||||
|
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域 ID',
|
||||||
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
|
gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总',
|
||||||
|
draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数',
|
||||||
|
turnover_coin BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
|
||||||
|
payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '用户可见返奖金币',
|
||||||
|
base_reward_coin BIGINT NOT NULL DEFAULT 0 COMMENT '基础 RTP 返奖',
|
||||||
|
room_atmosphere_reward_coin BIGINT NOT NULL DEFAULT 0 COMMENT '房间气氛支出',
|
||||||
|
activity_subsidy_coin BIGINT NOT NULL DEFAULT 0 COMMENT '活动补贴支出',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, stat_day, visible_region_id, pool_id, gift_id),
|
||||||
|
KEY idx_lucky_draw_pool_day_overview (app_code, stat_day, pool_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 Databi 日维度汇总';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_users (
|
CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_users (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
@ -483,6 +503,92 @@ CREATE TABLE IF NOT EXISTS first_recharge_reward_claims (
|
|||||||
KEY idx_first_recharge_reward_status (app_code, status, updated_at_ms)
|
KEY idx_first_recharge_reward_status (app_code, status, updated_at_ms)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励领取表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励领取表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_configs (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用',
|
||||||
|
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员 ID',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励配置表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_tiers (
|
||||||
|
tier_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '累充奖励档位 ID',
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
tier_code VARCHAR(64) NOT NULL COMMENT '档位编码',
|
||||||
|
tier_name VARCHAR(96) NOT NULL COMMENT '档位名称',
|
||||||
|
threshold_usd_minor BIGINT NOT NULL COMMENT '达标美元金额,美分',
|
||||||
|
resource_group_id BIGINT NOT NULL COMMENT '奖励资源分组 ID',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
UNIQUE KEY uk_cumulative_recharge_reward_tier_code (app_code, tier_code),
|
||||||
|
KEY idx_cumulative_recharge_reward_tier_sort (app_code, status, sort_order, threshold_usd_minor)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励档位表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_events (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
event_id VARCHAR(128) NOT NULL COMMENT 'wallet_outbox 事件 ID',
|
||||||
|
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然周',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
transaction_id VARCHAR(128) NOT NULL COMMENT '钱包交易 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '钱包命令 ID',
|
||||||
|
recharge_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '充值来源类型',
|
||||||
|
recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次到账金币',
|
||||||
|
qualifying_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '本次计入累充的美元美分',
|
||||||
|
payload_json JSON NULL COMMENT 'wallet 事件 payload',
|
||||||
|
occurred_at_ms BIGINT NOT NULL COMMENT '充值发生时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, event_id),
|
||||||
|
KEY idx_cumulative_recharge_reward_event_user_cycle (app_code, cycle_key, user_id, occurred_at_ms),
|
||||||
|
KEY idx_cumulative_recharge_reward_event_transaction (app_code, transaction_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励充值事件幂等表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_progress (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然周',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
total_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '周期累计美元美分',
|
||||||
|
total_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '周期累计到账金币',
|
||||||
|
first_recharged_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '周期首笔充值时间',
|
||||||
|
last_recharged_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '周期最近充值时间',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_key, user_id),
|
||||||
|
KEY idx_cumulative_recharge_reward_progress_total (app_code, cycle_key, total_usd_minor)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励用户周期累计表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_grants (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
grant_id VARCHAR(128) NOT NULL COMMENT '发放 ID',
|
||||||
|
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然周',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
event_id VARCHAR(128) NOT NULL COMMENT '触发发放的 wallet_outbox 事件 ID',
|
||||||
|
transaction_id VARCHAR(128) NOT NULL COMMENT '触发发放的钱包交易 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '触发发放的钱包命令 ID',
|
||||||
|
tier_id BIGINT NOT NULL COMMENT '命中奖励档位 ID',
|
||||||
|
tier_code VARCHAR(64) NOT NULL COMMENT '命中奖励档位编码',
|
||||||
|
threshold_usd_minor BIGINT NOT NULL COMMENT '命中档位门槛,美分',
|
||||||
|
resource_group_id BIGINT NOT NULL COMMENT '奖励资源分组 ID',
|
||||||
|
reached_usd_minor BIGINT NOT NULL COMMENT '命中时累计金额,美分',
|
||||||
|
qualifying_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '触发事件计入金额,美分',
|
||||||
|
recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '触发事件到账金币',
|
||||||
|
recharge_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '触发事件充值来源类型',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||||
|
wallet_command_id VARCHAR(128) NOT NULL COMMENT '钱包发奖命令 ID',
|
||||||
|
wallet_grant_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包资源发放 ID',
|
||||||
|
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||||
|
granted_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发放时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, grant_id),
|
||||||
|
UNIQUE KEY uk_cumulative_recharge_reward_user_tier (app_code, cycle_key, user_id, tier_id),
|
||||||
|
UNIQUE KEY uk_cumulative_recharge_reward_wallet_command (app_code, wallet_command_id),
|
||||||
|
KEY idx_cumulative_recharge_reward_grant_event (app_code, event_id, status),
|
||||||
|
KEY idx_cumulative_recharge_reward_grant_user (app_code, cycle_key, user_id, created_at_ms),
|
||||||
|
KEY idx_cumulative_recharge_reward_grant_status (app_code, status, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励发放表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS seven_day_checkin_configs (
|
CREATE TABLE IF NOT EXISTS seven_day_checkin_configs (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用',
|
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用',
|
||||||
@ -993,3 +1099,169 @@ INSERT IGNORE INTO lucky_gift_stage_tiers (
|
|||||||
('lalu', 'super_lucky', 1, 'advanced', 'advanced_1x', 1000000, 600000, 'base_rtp', false, 'none', true),
|
('lalu', 'super_lucky', 1, 'advanced', 'advanced_1x', 1000000, 600000, 'base_rtp', false, 'none', true),
|
||||||
('lalu', 'super_lucky', 1, 'advanced', 'advanced_2x', 2000000, 100000, 'base_rtp', false, 'none', true),
|
('lalu', 'super_lucky', 1, 'advanced', 'advanced_2x', 2000000, 100000, 'base_rtp', false, 'none', true),
|
||||||
('lalu', 'super_lucky', 1, 'advanced', 'advanced_5x', 5000000, 30000, 'base_rtp', false, 'none', true);
|
('lalu', 'super_lucky', 1, 'advanced', 'advanced_5x', 5000000, 30000, 'base_rtp', false, 'none', true);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_turnover_reward_configs (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
|
||||||
|
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励配置表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers (
|
||||||
|
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位 ID',
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
tier_code VARCHAR(64) NOT NULL COMMENT '档位编码',
|
||||||
|
tier_name VARCHAR(128) NOT NULL COMMENT '档位名称',
|
||||||
|
threshold_coin_spent BIGINT NOT NULL COMMENT '命中该档需要的房间送礼金币流水',
|
||||||
|
reward_coin_amount BIGINT NOT NULL COMMENT '奖励房主金币数',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT 'active/inactive',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (tier_id),
|
||||||
|
UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, tier_code),
|
||||||
|
KEY idx_room_turnover_reward_tiers_match (app_code, status, threshold_coin_spent)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励档位表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_turnover_reward_event_consumption (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
event_id VARCHAR(128) NOT NULL COMMENT 'room-service outbox event_id',
|
||||||
|
event_type VARCHAR(96) NOT NULL COMMENT '事件类型',
|
||||||
|
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||||
|
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||||
|
coin_spent BIGINT NOT NULL COMMENT '本事件送礼金币消耗',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '消费状态',
|
||||||
|
consumed_at_ms BIGINT NOT NULL COMMENT '消费时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, event_id),
|
||||||
|
KEY idx_room_turnover_reward_event_period (app_code, period_start_ms, room_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励事件消费幂等表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_turnover_reward_progress (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||||
|
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||||
|
period_end_ms BIGINT NOT NULL COMMENT 'UTC 周期结束',
|
||||||
|
coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '本周期房间送礼金币流水',
|
||||||
|
last_event_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后事件时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, room_id, period_start_ms),
|
||||||
|
KEY idx_room_turnover_reward_progress_period (app_code, period_start_ms, coin_spent)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励周期聚合表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
settlement_id VARCHAR(96) NOT NULL COMMENT '结算 ID',
|
||||||
|
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||||
|
owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算收款房主',
|
||||||
|
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||||
|
period_end_ms BIGINT NOT NULL COMMENT 'UTC 周期结束',
|
||||||
|
coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '周期房间流水',
|
||||||
|
tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '命中档位 ID',
|
||||||
|
tier_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '命中档位编码',
|
||||||
|
threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中档位阈值',
|
||||||
|
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '发放金币',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/granted/failed',
|
||||||
|
wallet_command_id VARCHAR(160) NOT NULL DEFAULT '' COMMENT 'wallet-service 幂等命令 ID',
|
||||||
|
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
|
||||||
|
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||||
|
settled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '成功发放时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, settlement_id),
|
||||||
|
UNIQUE KEY uk_room_turnover_reward_settlement_room_period (app_code, room_id, period_start_ms),
|
||||||
|
KEY idx_room_turnover_reward_settlement_status (app_code, status, period_start_ms, created_at_ms),
|
||||||
|
KEY idx_room_turnover_reward_settlement_owner (app_code, owner_user_id, period_start_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励每周结算表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_star_cycles (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
activity_code VARCHAR(64) NOT NULL DEFAULT 'weekly_star' COMMENT '活动编码',
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域 ID,0 表示默认配置',
|
||||||
|
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '周期标题',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '状态',
|
||||||
|
start_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 生效开始,包含',
|
||||||
|
end_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 生效结束,不包含',
|
||||||
|
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员',
|
||||||
|
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员',
|
||||||
|
settled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算完成时间',
|
||||||
|
locked_by VARCHAR(96) NOT NULL DEFAULT '' COMMENT '结算 worker 锁',
|
||||||
|
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算锁过期时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_id),
|
||||||
|
KEY idx_weekly_star_cycle_current (app_code, activity_code, status, region_id, start_ms, end_ms),
|
||||||
|
KEY idx_weekly_star_cycle_due (app_code, activity_code, status, end_ms, lock_until_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星指定礼物积分周期';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_star_cycle_gifts (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
gift_id VARCHAR(96) NOT NULL COMMENT '参与计分礼物 ID',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_id, gift_id),
|
||||||
|
KEY idx_weekly_star_gift_match (app_code, gift_id, cycle_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星周期指定礼物';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_star_cycle_rewards (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
rank_no INT NOT NULL COMMENT '排名,1/2/3',
|
||||||
|
resource_group_id BIGINT NOT NULL COMMENT '奖励资源组 ID',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_id, rank_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星周期 Top 奖励';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_star_user_scores (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
score BIGINT NOT NULL DEFAULT 0 COMMENT '累计积分,等于活动礼物消耗金币',
|
||||||
|
first_scored_at_ms BIGINT NOT NULL COMMENT '首次得分时间',
|
||||||
|
last_scored_at_ms BIGINT NOT NULL COMMENT '最近得分时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_id, user_id),
|
||||||
|
KEY idx_weekly_star_score_rank (app_code, cycle_id, score, first_scored_at_ms, user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星用户积分榜';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_star_score_events (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
source_event_id VARCHAR(128) NOT NULL COMMENT 'room-service outbox event_id',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
|
||||||
|
gift_id VARCHAR(96) NOT NULL COMMENT '礼物 ID',
|
||||||
|
score_delta BIGINT NOT NULL COMMENT '本次增加积分',
|
||||||
|
occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, source_event_id),
|
||||||
|
KEY idx_weekly_star_score_event_cycle (app_code, cycle_id, user_id, occurred_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星送礼积分事件幂等表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_star_settlements (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
settlement_id VARCHAR(96) NOT NULL COMMENT '周星结算 ID',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
rank_no INT NOT NULL COMMENT '排名,1/2/3',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '获奖用户 ID',
|
||||||
|
score BIGINT NOT NULL DEFAULT 0 COMMENT '结算积分',
|
||||||
|
resource_group_id BIGINT NOT NULL COMMENT '奖励资源组 ID',
|
||||||
|
wallet_command_id VARCHAR(128) NOT NULL COMMENT 'wallet 幂等命令',
|
||||||
|
wallet_grant_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'wallet grant ID',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '发放状态',
|
||||||
|
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||||
|
attempt_count INT NOT NULL DEFAULT 0 COMMENT '尝试次数',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, settlement_id),
|
||||||
|
UNIQUE KEY uk_weekly_star_settlement_rank (app_code, cycle_id, rank_no),
|
||||||
|
UNIQUE KEY uk_weekly_star_wallet_command (app_code, wallet_command_id),
|
||||||
|
KEY idx_weekly_star_settlement_cycle (app_code, cycle_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星 Top 奖励结算';
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@ -10,39 +9,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
|
||||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
||||||
"hyapp/pkg/appcode"
|
|
||||||
"hyapp/pkg/grpchealth"
|
"hyapp/pkg/grpchealth"
|
||||||
"hyapp/pkg/grpcshutdown"
|
"hyapp/pkg/grpcshutdown"
|
||||||
"hyapp/pkg/healthhttp"
|
"hyapp/pkg/healthhttp"
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
"hyapp/pkg/rocketmqx"
|
"hyapp/pkg/rocketmqx"
|
||||||
"hyapp/pkg/roommq"
|
|
||||||
"hyapp/pkg/tencentim"
|
|
||||||
"hyapp/pkg/usermq"
|
|
||||||
"hyapp/pkg/walletmq"
|
|
||||||
"hyapp/services/activity-service/internal/client"
|
|
||||||
"hyapp/services/activity-service/internal/config"
|
"hyapp/services/activity-service/internal/config"
|
||||||
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
|
|
||||||
firstrechargedomain "hyapp/services/activity-service/internal/domain/firstrecharge"
|
|
||||||
achievementservice "hyapp/services/activity-service/internal/service/achievement"
|
|
||||||
activityservice "hyapp/services/activity-service/internal/service/activity"
|
|
||||||
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
||||||
|
cumulativerechargeservice "hyapp/services/activity-service/internal/service/cumulativerecharge"
|
||||||
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
|
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
|
||||||
growthservice "hyapp/services/activity-service/internal/service/growth"
|
|
||||||
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
|
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
|
||||||
messageservice "hyapp/services/activity-service/internal/service/message"
|
|
||||||
registrationrewardservice "hyapp/services/activity-service/internal/service/registrationreward"
|
|
||||||
sevendaycheckinservice "hyapp/services/activity-service/internal/service/sevendaycheckin"
|
|
||||||
taskservice "hyapp/services/activity-service/internal/service/task"
|
|
||||||
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
||||||
grpcserver "hyapp/services/activity-service/internal/transport/grpc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// App 装配 activity-service gRPC 入口和底座依赖。
|
// App 装配 activity-service 的 gRPC 入口、健康检查、后台 worker 和外部连接。
|
||||||
|
// 具体业务模块的创建和注册都放在独立文件里,避免启动入口继续膨胀成一个难维护的大函数。
|
||||||
type App struct {
|
type App struct {
|
||||||
server *grpc.Server
|
server *grpc.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
@ -52,6 +33,7 @@ type App struct {
|
|||||||
broadcast *broadcastservice.Service
|
broadcast *broadcastservice.Service
|
||||||
luckyGift *luckygiftservice.Service
|
luckyGift *luckygiftservice.Service
|
||||||
firstRechargeReward *firstrechargeservice.Service
|
firstRechargeReward *firstrechargeservice.Service
|
||||||
|
cumulativeRecharge *cumulativerechargeservice.Service
|
||||||
broadcastWorkerEnabled bool
|
broadcastWorkerEnabled bool
|
||||||
luckyGiftWorkerEnabled bool
|
luckyGiftWorkerEnabled bool
|
||||||
workerNodeID string
|
workerNodeID string
|
||||||
@ -59,6 +41,7 @@ type App struct {
|
|||||||
mqConsumers []*rocketmqx.Consumer
|
mqConsumers []*rocketmqx.Consumer
|
||||||
userConn *grpc.ClientConn
|
userConn *grpc.ClientConn
|
||||||
walletConn *grpc.ClientConn
|
walletConn *grpc.ClientConn
|
||||||
|
roomConn *grpc.ClientConn
|
||||||
workerCtx context.Context
|
workerCtx context.Context
|
||||||
workerStop context.CancelFunc
|
workerStop context.CancelFunc
|
||||||
workerWG sync.WaitGroup
|
workerWG sync.WaitGroup
|
||||||
@ -70,254 +53,71 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// activity-service 的活动状态、消费幂等和 outbox 都必须使用 MySQL。
|
repository, err := openRepository(startupCtx, cfg)
|
||||||
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if cfg.MySQLAutoMigrate {
|
var listener net.Listener
|
||||||
if err := repository.Migrate(startupCtx); err != nil {
|
var clients externalClients
|
||||||
_ = repository.Close()
|
var mqConsumers []*rocketmqx.Consumer
|
||||||
return nil, err
|
cleanup := func() {
|
||||||
|
shutdownConsumers(mqConsumers)
|
||||||
|
clients.Close()
|
||||||
|
if listener != nil {
|
||||||
|
_ = listener.Close()
|
||||||
}
|
}
|
||||||
|
_ = repository.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", cfg.GRPCAddr)
|
listener, err = listenGRPC(cfg.GRPCAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = repository.Close()
|
cleanup()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
clients, err = dialExternalClients(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = listener.Close()
|
cleanup()
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
if err != nil {
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
|
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
|
||||||
svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
|
services, err := buildServiceBundle(cfg, repository, clients)
|
||||||
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(client.NewGRPCUserTargetSource(userConn)))
|
|
||||||
taskSvc := taskservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
|
|
||||||
registrationRewardSvc := registrationrewardservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
|
|
||||||
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
|
|
||||||
growthSvc := growthservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
|
|
||||||
achievementSvc := achievementservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
|
|
||||||
var tencentClient *tencentim.RESTClient
|
|
||||||
var broadcastPublisher broadcastservice.Publisher
|
|
||||||
if cfg.TencentIM.Enabled {
|
|
||||||
tencentClient, err = tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
|
||||||
if err != nil {
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
broadcastPublisher = tencentClient
|
|
||||||
}
|
|
||||||
if cfg.Broadcast.Enabled && broadcastPublisher == nil {
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, errors.New("broadcast worker requires tencent_im.enabled")
|
|
||||||
}
|
|
||||||
broadcastSvc := broadcastservice.New(broadcastservice.Config{
|
|
||||||
NodeID: cfg.NodeID,
|
|
||||||
SuperGiftMinValue: cfg.Broadcast.SuperGiftMinValue,
|
|
||||||
WorkerBatchSize: cfg.Broadcast.WorkerBatchSize,
|
|
||||||
WorkerLockTTL: cfg.Broadcast.WorkerLockTTL,
|
|
||||||
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
|
|
||||||
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
|
|
||||||
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
|
|
||||||
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
|
|
||||||
}, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn))
|
|
||||||
broadcastSvc.SetSenderProfileSource(client.NewGRPCUserProfileSource(userConn))
|
|
||||||
luckyGiftSvc := luckygiftservice.New(repository,
|
|
||||||
luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)),
|
|
||||||
luckygiftservice.WithRoomPublisher(tencentClient),
|
|
||||||
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
|
|
||||||
)
|
|
||||||
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc))
|
|
||||||
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc))
|
|
||||||
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(messageSvc, growthSvc, achievementSvc))
|
|
||||||
activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(taskSvc))
|
|
||||||
activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(taskSvc))
|
|
||||||
activityv1.RegisterGrowthLevelServiceServer(server, grpcserver.NewGrowthLevelServer(growthSvc))
|
|
||||||
activityv1.RegisterAdminGrowthLevelServiceServer(server, grpcserver.NewAdminGrowthLevelServer(growthSvc))
|
|
||||||
activityv1.RegisterAchievementServiceServer(server, grpcserver.NewAchievementServer(achievementSvc))
|
|
||||||
activityv1.RegisterAdminAchievementServiceServer(server, grpcserver.NewAdminAchievementServer(achievementSvc))
|
|
||||||
activityv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(luckyGiftSvc))
|
|
||||||
activityv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(luckyGiftSvc))
|
|
||||||
activityv1.RegisterRegistrationRewardServiceServer(server, grpcserver.NewRegistrationRewardServer(registrationRewardSvc))
|
|
||||||
activityv1.RegisterAdminRegistrationRewardServiceServer(server, grpcserver.NewAdminRegistrationRewardServer(registrationRewardSvc))
|
|
||||||
activityv1.RegisterSevenDayCheckInServiceServer(server, grpcserver.NewSevenDayCheckInServer(sevenDayCheckInSvc))
|
|
||||||
activityv1.RegisterAdminSevenDayCheckInServiceServer(server, grpcserver.NewAdminSevenDayCheckInServer(sevenDayCheckInSvc))
|
|
||||||
broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc)
|
|
||||||
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
|
|
||||||
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
|
|
||||||
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletv1.NewWalletServiceClient(walletConn))
|
|
||||||
activityv1.RegisterFirstRechargeRewardServiceServer(server, grpcserver.NewFirstRechargeRewardServer(firstRechargeRewardSvc))
|
|
||||||
activityv1.RegisterAdminFirstRechargeRewardServiceServer(server, grpcserver.NewAdminFirstRechargeRewardServer(firstRechargeRewardSvc))
|
|
||||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
|
|
||||||
if cfg.RocketMQ.RoomOutbox.Enabled {
|
|
||||||
consumer, err := rocketmqx.NewConsumer(roomOutboxConsumerConfig(cfg.RocketMQ))
|
|
||||||
if err != nil {
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
|
||||||
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
|
||||||
if _, err := broadcastSvc.HandleRoomEvent(eventCtx, envelope); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = growthSvc.HandleRoomEvent(eventCtx, envelope)
|
|
||||||
return err
|
|
||||||
}); err != nil {
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mqConsumers = append(mqConsumers, consumer)
|
|
||||||
}
|
|
||||||
if cfg.RocketMQ.UserOutbox.Enabled && broadcastPublisher == nil {
|
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, errors.New("rocketmq user_outbox consumer requires tencent_im.enabled")
|
|
||||||
}
|
|
||||||
if cfg.RocketMQ.UserOutbox.Enabled {
|
|
||||||
consumer, err := rocketmqx.NewConsumer(userOutboxConsumerConfig(cfg.RocketMQ))
|
|
||||||
if err != nil {
|
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
|
||||||
outboxMessage, err := usermq.DecodeUserOutboxMessage(message.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return consumeUserRegionChangedForBroadcast(ctx, broadcastSvc, outboxMessage)
|
|
||||||
}); err != nil {
|
|
||||||
_ = consumer.Shutdown()
|
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mqConsumers = append(mqConsumers, consumer)
|
|
||||||
}
|
|
||||||
if cfg.FirstRechargeRewardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
|
||||||
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.FirstRechargeConsumerGroup))
|
|
||||||
if err != nil {
|
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
|
||||||
event, ok, err := firstRechargeEventFromWalletMessage(message.Body)
|
|
||||||
if err != nil || !ok {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return firstRechargeRewardSvc.ConsumeWalletRechargeEvent(appcode.WithContext(ctx, event.AppCode), event)
|
|
||||||
}); err != nil {
|
|
||||||
_ = consumer.Shutdown()
|
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mqConsumers = append(mqConsumers, consumer)
|
|
||||||
}
|
|
||||||
if cfg.RedPacketBroadcastWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
|
||||||
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.RedPacketBroadcastConsumerGroup))
|
|
||||||
if err != nil {
|
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := consumer.Subscribe(redPacketWalletOutboxTopic(cfg.RocketMQ.WalletOutbox), walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
|
||||||
event, ok, err := redPacketEventFromWalletMessage(message.Body)
|
|
||||||
if err != nil || !ok {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return broadcastSvc.ConsumeRedPacketWalletEvent(appcode.WithContext(ctx, event.AppCode), event)
|
|
||||||
}); err != nil {
|
|
||||||
_ = consumer.Shutdown()
|
|
||||||
shutdownConsumers(mqConsumers)
|
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mqConsumers = append(mqConsumers, consumer)
|
|
||||||
}
|
|
||||||
healthDependencies := []grpchealth.Dependency{{
|
|
||||||
Name: "mysql",
|
|
||||||
Check: repository.Ping,
|
|
||||||
}}
|
|
||||||
health := grpchealth.NewServingChecker("activity-service", healthDependencies...)
|
|
||||||
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
|
|
||||||
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
shutdownConsumers(mqConsumers)
|
cleanup()
|
||||||
_ = walletConn.Close()
|
|
||||||
_ = userConn.Close()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = repository.Close()
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
workerCtx, workerStop := context.WithCancel(context.Background())
|
registerGRPCServers(server, services)
|
||||||
|
|
||||||
|
mqConsumers, err = buildMQConsumers(cfg, services)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
health, healthHTTP, err := newHealthServers(cfg, server, repository)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
workerCtx, workerStop := context.WithCancel(context.Background())
|
||||||
return &App{
|
return &App{
|
||||||
server: server,
|
server: server,
|
||||||
listener: listener,
|
listener: listener,
|
||||||
health: health,
|
health: health,
|
||||||
healthHTTP: healthHTTP,
|
healthHTTP: healthHTTP,
|
||||||
mysqlRepo: repository,
|
mysqlRepo: repository,
|
||||||
broadcast: broadcastSvc,
|
broadcast: services.broadcast,
|
||||||
luckyGift: luckyGiftSvc,
|
luckyGift: services.luckyGift,
|
||||||
firstRechargeReward: firstRechargeRewardSvc,
|
firstRechargeReward: services.firstRechargeReward,
|
||||||
|
cumulativeRecharge: services.cumulativeRecharge,
|
||||||
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
|
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
|
||||||
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
|
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
|
||||||
workerNodeID: cfg.NodeID,
|
workerNodeID: cfg.NodeID,
|
||||||
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
|
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
|
||||||
mqConsumers: mqConsumers,
|
mqConsumers: mqConsumers,
|
||||||
userConn: userConn,
|
userConn: clients.userConn,
|
||||||
walletConn: walletConn,
|
walletConn: clients.walletConn,
|
||||||
|
roomConn: clients.roomConn,
|
||||||
workerCtx: workerCtx,
|
workerCtx: workerCtx,
|
||||||
workerStop: workerStop,
|
workerStop: workerStop,
|
||||||
}, nil
|
}, nil
|
||||||
@ -372,6 +172,9 @@ func (a *App) Close() {
|
|||||||
if a.walletConn != nil {
|
if a.walletConn != nil {
|
||||||
_ = a.walletConn.Close()
|
_ = a.walletConn.Close()
|
||||||
}
|
}
|
||||||
|
if a.roomConn != nil {
|
||||||
|
_ = a.roomConn.Close()
|
||||||
|
}
|
||||||
if a.mysqlRepo != nil {
|
if a.mysqlRepo != nil {
|
||||||
// MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。
|
// MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。
|
||||||
_ = a.mysqlRepo.Close()
|
_ = a.mysqlRepo.Close()
|
||||||
@ -416,247 +219,3 @@ func (a *App) shutdownMQ() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func roomOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
|
||||||
return rocketMQConsumerConfig(cfg, cfg.RoomOutbox.ConsumerGroup, cfg.RoomOutbox.ConsumerMaxReconsumeTimes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func userOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
|
||||||
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.ConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketmqx.ConsumerConfig {
|
|
||||||
return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func redPacketWalletOutboxTopic(cfg config.WalletOutboxMQConfig) string {
|
|
||||||
// 钱包实时 topic 显式配置后,红包 worker 只消费实时通道,避免继续被普通账务 topic 的高频消息拖慢。
|
|
||||||
if cfg.RealtimeTopic != "" {
|
|
||||||
return cfg.RealtimeTopic
|
|
||||||
}
|
|
||||||
return cfg.Topic
|
|
||||||
}
|
|
||||||
|
|
||||||
func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsume int32) rocketmqx.ConsumerConfig {
|
|
||||||
return rocketmqx.ConsumerConfig{
|
|
||||||
EndpointConfig: rocketmqx.EndpointConfig{
|
|
||||||
NameServers: cfg.NameServers,
|
|
||||||
NameServerDomain: cfg.NameServerDomain,
|
|
||||||
AccessKey: cfg.AccessKey,
|
|
||||||
SecretKey: cfg.SecretKey,
|
|
||||||
SecurityToken: cfg.SecurityToken,
|
|
||||||
Namespace: cfg.Namespace,
|
|
||||||
VIPChannel: cfg.VIPChannel,
|
|
||||||
},
|
|
||||||
GroupName: group,
|
|
||||||
MaxReconsumeTimes: maxReconsume,
|
|
||||||
ConsumeRetryDelay: time.Second,
|
|
||||||
ConsumePullBatch: 32,
|
|
||||||
ConsumePullTimeout: 15 * time.Minute,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
|
|
||||||
for _, consumer := range consumers {
|
|
||||||
_ = consumer.Shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type userRegionChangedPayload struct {
|
|
||||||
UserID int64 `json:"user_id"`
|
|
||||||
OldRegionID int64 `json:"old_region_id"`
|
|
||||||
NewRegionID int64 `json:"new_region_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func consumeUserRegionChangedForBroadcast(ctx context.Context, service *broadcastservice.Service, message usermq.UserOutboxMessage) error {
|
|
||||||
if message.EventType != "UserRegionChanged" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var payload userRegionChangedPayload
|
|
||||||
if err := json.Unmarshal([]byte(message.PayloadJSON), &payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if payload.UserID <= 0 || payload.OldRegionID <= 0 || payload.OldRegionID == payload.NewRegionID {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// 区域广播群成员关系是当前读模型;用户切区后只移除旧群,下一次 UserSig 会按新 users.region_id 返回新群。
|
|
||||||
_, _, err := service.RemoveRegionBroadcastMember(appcode.WithContext(ctx, message.AppCode), payload.UserID, payload.OldRegionID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.RechargeEvent, bool, error) {
|
|
||||||
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
|
||||||
if err != nil {
|
|
||||||
return firstrechargedomain.RechargeEvent{}, false, err
|
|
||||||
}
|
|
||||||
if message.EventType != firstrechargedomain.RechargeEventWalletRecorded {
|
|
||||||
return firstrechargedomain.RechargeEvent{}, false, nil
|
|
||||||
}
|
|
||||||
sequence, rechargeType := rechargeFactFromWalletPayload(message.PayloadJSON)
|
|
||||||
return firstrechargedomain.RechargeEvent{
|
|
||||||
AppCode: appcode.Normalize(message.AppCode),
|
|
||||||
EventID: message.EventID,
|
|
||||||
EventType: message.EventType,
|
|
||||||
TransactionID: message.TransactionID,
|
|
||||||
CommandID: message.CommandID,
|
|
||||||
UserID: message.UserID,
|
|
||||||
RechargeCoinAmount: message.AvailableDelta,
|
|
||||||
RechargeSequence: sequence,
|
|
||||||
RechargeType: rechargeType,
|
|
||||||
PayloadJSON: message.PayloadJSON,
|
|
||||||
OccurredAtMS: message.OccurredAtMS,
|
|
||||||
}, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWalletEvent, bool, error) {
|
|
||||||
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
|
||||||
if err != nil {
|
|
||||||
return broadcastdomain.RedPacketWalletEvent{}, false, err
|
|
||||||
}
|
|
||||||
if message.EventType != "WalletRedPacketCreated" && message.EventType != "WalletRedPacketClaimed" && message.EventType != "WalletRedPacketRefunded" {
|
|
||||||
return broadcastdomain.RedPacketWalletEvent{}, false, nil
|
|
||||||
}
|
|
||||||
fields := redPacketFieldsFromWalletPayload(message.PayloadJSON, message.EventType)
|
|
||||||
if fields.PacketID == "" {
|
|
||||||
return broadcastdomain.RedPacketWalletEvent{}, false, nil
|
|
||||||
}
|
|
||||||
return broadcastdomain.RedPacketWalletEvent{
|
|
||||||
AppCode: appcode.Normalize(message.AppCode),
|
|
||||||
EventID: message.EventID,
|
|
||||||
EventType: message.EventType,
|
|
||||||
TransactionID: message.TransactionID,
|
|
||||||
CommandID: message.CommandID,
|
|
||||||
PayloadJSON: message.PayloadJSON,
|
|
||||||
PacketID: fields.PacketID,
|
|
||||||
PacketType: fields.PacketType,
|
|
||||||
RoomID: fields.RoomID,
|
|
||||||
RegionID: fields.RegionID,
|
|
||||||
RegionCode: fields.RegionCode,
|
|
||||||
SenderUserID: fields.SenderUserID,
|
|
||||||
TotalAmount: fields.TotalAmount,
|
|
||||||
TotalCount: fields.TotalCount,
|
|
||||||
RemainingAmount: fields.RemainingAmount,
|
|
||||||
RemainingCount: fields.RemainingCount,
|
|
||||||
RefundedAmount: fields.RefundedAmount,
|
|
||||||
Status: fields.Status,
|
|
||||||
OpenAtMS: fields.OpenAtMS,
|
|
||||||
ExpiresAtMS: fields.ExpiresAtMS,
|
|
||||||
CreatedAtMS: message.OccurredAtMS,
|
|
||||||
}, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rechargeFactFromWalletPayload(payload string) (int64, string) {
|
|
||||||
var decoded map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
|
||||||
return 0, firstrechargedomain.RechargeTypeCoinSeller
|
|
||||||
}
|
|
||||||
var sequence int64
|
|
||||||
switch value := decoded["recharge_sequence"].(type) {
|
|
||||||
case float64:
|
|
||||||
sequence = int64(value)
|
|
||||||
case int64:
|
|
||||||
sequence = value
|
|
||||||
case json.Number:
|
|
||||||
sequence, _ = value.Int64()
|
|
||||||
}
|
|
||||||
rechargeType := firstrechargedomain.RechargeTypeCoinSeller
|
|
||||||
if value, ok := decoded["recharge_type"].(string); ok && value != "" {
|
|
||||||
rechargeType = value
|
|
||||||
}
|
|
||||||
return sequence, rechargeType
|
|
||||||
}
|
|
||||||
|
|
||||||
type redPacketPayloadFields struct {
|
|
||||||
PacketID string
|
|
||||||
PacketType string
|
|
||||||
RoomID string
|
|
||||||
RegionID int64
|
|
||||||
RegionCode string
|
|
||||||
SenderUserID int64
|
|
||||||
TotalAmount int64
|
|
||||||
TotalCount int32
|
|
||||||
RemainingAmount int64
|
|
||||||
RemainingCount int32
|
|
||||||
RefundedAmount int64
|
|
||||||
Status string
|
|
||||||
OpenAtMS int64
|
|
||||||
ExpiresAtMS int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacketPayloadFields {
|
|
||||||
var decoded map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
|
||||||
return redPacketPayloadFields{}
|
|
||||||
}
|
|
||||||
packetID := stringFromDecoded(decoded, "packet_id")
|
|
||||||
if packetID == "" {
|
|
||||||
packetID = stringFromDecoded(decoded, "packetId")
|
|
||||||
}
|
|
||||||
packetType := stringFromDecoded(decoded, "packet_type")
|
|
||||||
roomID := stringFromDecoded(decoded, "room_id")
|
|
||||||
regionID := int64FromDecoded(decoded, "region_id")
|
|
||||||
status := stringFromDecoded(decoded, "status")
|
|
||||||
openAtMS := int64FromDecoded(decoded, "open_at_ms")
|
|
||||||
expiresAtMS := int64FromDecoded(decoded, "expires_at_ms")
|
|
||||||
if eventType == "WalletRedPacketClaimed" || eventType == "WalletRedPacketRefunded" {
|
|
||||||
status = stringFromDecoded(decoded, "packet_status")
|
|
||||||
}
|
|
||||||
totalCount := int32FromDecoded(decoded, "packet_count")
|
|
||||||
remainingCount := int32FromDecoded(decoded, "remaining_count")
|
|
||||||
if remainingCount == 0 && eventType == "WalletRedPacketCreated" {
|
|
||||||
remainingCount = totalCount
|
|
||||||
}
|
|
||||||
return redPacketPayloadFields{
|
|
||||||
PacketID: packetID,
|
|
||||||
PacketType: packetType,
|
|
||||||
RoomID: roomID,
|
|
||||||
RegionID: regionID,
|
|
||||||
RegionCode: stringFromDecoded(decoded, "region_code"),
|
|
||||||
SenderUserID: int64FromDecoded(decoded, "sender_user_id"),
|
|
||||||
TotalAmount: int64FromDecoded(decoded, "total_amount"),
|
|
||||||
TotalCount: totalCount,
|
|
||||||
RemainingAmount: int64FromDecoded(decoded, "remaining_amount"),
|
|
||||||
RemainingCount: remainingCount,
|
|
||||||
RefundedAmount: int64FromDecoded(decoded, "refund_amount"),
|
|
||||||
Status: status,
|
|
||||||
OpenAtMS: openAtMS,
|
|
||||||
ExpiresAtMS: expiresAtMS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringFromDecoded(decoded map[string]any, key string) string {
|
|
||||||
value, _ := decoded[key].(string)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func int64FromDecoded(decoded map[string]any, key string) int64 {
|
|
||||||
switch value := decoded[key].(type) {
|
|
||||||
case float64:
|
|
||||||
return int64(value)
|
|
||||||
case int64:
|
|
||||||
return value
|
|
||||||
case json.Number:
|
|
||||||
parsed, _ := value.Int64()
|
|
||||||
return parsed
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func int32FromDecoded(decoded map[string]any, key string) int32 {
|
|
||||||
return int32(int64FromDecoded(decoded, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luckygiftservice.WorkerOptions {
|
|
||||||
return luckygiftservice.WorkerOptions{
|
|
||||||
WorkerID: nodeID + "-lucky-gift",
|
|
||||||
PollInterval: cfg.WorkerPollInterval,
|
|
||||||
BatchSize: cfg.WorkerBatchSize,
|
|
||||||
Concurrency: cfg.WorkerConcurrency,
|
|
||||||
LockTTL: cfg.WorkerLockTTL,
|
|
||||||
MaxRetry: cfg.WorkerMaxRetry,
|
|
||||||
PublishTimeout: cfg.PublishTimeout,
|
|
||||||
StatsInterval: cfg.StatsRefreshInterval,
|
|
||||||
StatsBatchSize: cfg.StatsBatchSize,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
71
services/activity-service/internal/app/dependencies.go
Normal file
71
services/activity-service/internal/app/dependencies.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"hyapp/services/activity-service/internal/config"
|
||||||
|
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type externalClients struct {
|
||||||
|
userConn *grpc.ClientConn
|
||||||
|
walletConn *grpc.ClientConn
|
||||||
|
roomConn *grpc.ClientConn
|
||||||
|
}
|
||||||
|
|
||||||
|
func openRepository(ctx context.Context, cfg config.Config) (*mysqlstorage.Repository, error) {
|
||||||
|
// activity-service 的活动配置、消费幂等、排行榜和 outbox 都落在 MySQL。
|
||||||
|
// migrate 放在启动阶段执行,保证本地新增活动模块时不需要手工补表后才能启动。
|
||||||
|
repository, err := mysqlstorage.Open(ctx, cfg.MySQLDSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg.MySQLAutoMigrate {
|
||||||
|
if err := repository.Migrate(ctx); err != nil {
|
||||||
|
_ = repository.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repository, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenGRPC(addr string) (net.Listener, error) {
|
||||||
|
return net.Listen("tcp", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialExternalClients(cfg config.Config) (externalClients, error) {
|
||||||
|
// 三个下游连接在 App 级别统一持有;各业务 service 只拿生成后的 typed client。
|
||||||
|
// 这样 Close 时可以按连接维度回收资源,新增模块也不会重复创建 gRPC 连接池。
|
||||||
|
var clients externalClients
|
||||||
|
var err error
|
||||||
|
clients.userConn, err = grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
return externalClients{}, err
|
||||||
|
}
|
||||||
|
clients.walletConn, err = grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
clients.Close()
|
||||||
|
return externalClients{}, err
|
||||||
|
}
|
||||||
|
clients.roomConn, err = grpc.Dial(cfg.RoomServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
clients.Close()
|
||||||
|
return externalClients{}, err
|
||||||
|
}
|
||||||
|
return clients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c externalClients) Close() {
|
||||||
|
if c.roomConn != nil {
|
||||||
|
_ = c.roomConn.Close()
|
||||||
|
}
|
||||||
|
if c.walletConn != nil {
|
||||||
|
_ = c.walletConn.Close()
|
||||||
|
}
|
||||||
|
if c.userConn != nil {
|
||||||
|
_ = c.userConn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
services/activity-service/internal/app/grpc_registration.go
Normal file
40
services/activity-service/internal/app/grpc_registration.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
grpcserver "hyapp/services/activity-service/internal/transport/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
|
||||||
|
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(services.activity))
|
||||||
|
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(services.message))
|
||||||
|
// cron 入口只做调度适配;具体状态机仍在各 activity service 内部。
|
||||||
|
// weekly-star 和 room-turnover 都依赖 wallet 发奖,所以这里必须把同一批已装配的 service 注入 cron server。
|
||||||
|
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(services.message, services.growth, services.achievement, services.weeklyStar, services.roomTurnoverReward))
|
||||||
|
activityv1.RegisterTaskServiceServer(server, grpcserver.NewTaskServer(services.task))
|
||||||
|
activityv1.RegisterAdminTaskServiceServer(server, grpcserver.NewAdminTaskServer(services.task))
|
||||||
|
activityv1.RegisterGrowthLevelServiceServer(server, grpcserver.NewGrowthLevelServer(services.growth))
|
||||||
|
activityv1.RegisterAdminGrowthLevelServiceServer(server, grpcserver.NewAdminGrowthLevelServer(services.growth))
|
||||||
|
activityv1.RegisterAchievementServiceServer(server, grpcserver.NewAchievementServer(services.achievement))
|
||||||
|
activityv1.RegisterAdminAchievementServiceServer(server, grpcserver.NewAdminAchievementServer(services.achievement))
|
||||||
|
activityv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(services.luckyGift))
|
||||||
|
activityv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(services.luckyGift))
|
||||||
|
activityv1.RegisterRegistrationRewardServiceServer(server, grpcserver.NewRegistrationRewardServer(services.registrationReward))
|
||||||
|
activityv1.RegisterAdminRegistrationRewardServiceServer(server, grpcserver.NewAdminRegistrationRewardServer(services.registrationReward))
|
||||||
|
activityv1.RegisterSevenDayCheckInServiceServer(server, grpcserver.NewSevenDayCheckInServer(services.sevenDayCheckIn))
|
||||||
|
activityv1.RegisterAdminSevenDayCheckInServiceServer(server, grpcserver.NewAdminSevenDayCheckInServer(services.sevenDayCheckIn))
|
||||||
|
activityv1.RegisterWeeklyStarServiceServer(server, grpcserver.NewWeeklyStarServer(services.weeklyStar))
|
||||||
|
activityv1.RegisterAdminWeeklyStarServiceServer(server, grpcserver.NewAdminWeeklyStarServer(services.weeklyStar))
|
||||||
|
activityv1.RegisterRoomTurnoverRewardServiceServer(server, grpcserver.NewRoomTurnoverRewardServer(services.roomTurnoverReward))
|
||||||
|
activityv1.RegisterAdminRoomTurnoverRewardServiceServer(server, grpcserver.NewAdminRoomTurnoverRewardServer(services.roomTurnoverReward))
|
||||||
|
// BroadcastServer 同时承载 BroadcastService 和 RoomEventConsumerService。
|
||||||
|
// gRPC 直连投递和 MQ 消费都必须经过相同的 room event fanout 顺序,否则本地验证会通过但线上 MQ 路径漏积分。
|
||||||
|
broadcastServer := grpcserver.NewBroadcastServer(services.broadcast, services.growth, services.weeklyStar, services.roomTurnoverReward)
|
||||||
|
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
|
||||||
|
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
|
||||||
|
activityv1.RegisterFirstRechargeRewardServiceServer(server, grpcserver.NewFirstRechargeRewardServer(services.firstRechargeReward))
|
||||||
|
activityv1.RegisterAdminFirstRechargeRewardServiceServer(server, grpcserver.NewAdminFirstRechargeRewardServer(services.firstRechargeReward))
|
||||||
|
activityv1.RegisterCumulativeRechargeRewardServiceServer(server, grpcserver.NewCumulativeRechargeRewardServer(services.cumulativeRecharge))
|
||||||
|
activityv1.RegisterAdminCumulativeRechargeRewardServiceServer(server, grpcserver.NewAdminCumulativeRechargeRewardServer(services.cumulativeRecharge))
|
||||||
|
}
|
||||||
24
services/activity-service/internal/app/health.go
Normal file
24
services/activity-service/internal/app/health.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||||
|
"hyapp/pkg/grpchealth"
|
||||||
|
"hyapp/pkg/healthhttp"
|
||||||
|
"hyapp/services/activity-service/internal/config"
|
||||||
|
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newHealthServers(cfg config.Config, server *grpc.Server, repository *mysqlstorage.Repository) (*grpchealth.ServingChecker, *healthhttp.Server, error) {
|
||||||
|
// gRPC health 和 HTTP ready 共用同一个 checker,避免一个入口显示 ready、另一个入口还在 draining。
|
||||||
|
health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{
|
||||||
|
Name: "mysql",
|
||||||
|
Check: repository.Ping,
|
||||||
|
})
|
||||||
|
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
|
||||||
|
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return health, healthHTTP, nil
|
||||||
|
}
|
||||||
236
services/activity-service/internal/app/mq.go
Normal file
236
services/activity-service/internal/app/mq.go
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/rocketmqx"
|
||||||
|
"hyapp/pkg/roommq"
|
||||||
|
"hyapp/pkg/usermq"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
|
"hyapp/services/activity-service/internal/config"
|
||||||
|
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.Consumer, error) {
|
||||||
|
mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
|
||||||
|
if cfg.RocketMQ.RoomOutbox.Enabled {
|
||||||
|
consumer, err := newRoomOutboxConsumer(cfg, services)
|
||||||
|
if err != nil {
|
||||||
|
shutdownConsumers(mqConsumers)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mqConsumers = append(mqConsumers, consumer)
|
||||||
|
}
|
||||||
|
if cfg.RocketMQ.UserOutbox.Enabled && !services.broadcastPublisherAvailable {
|
||||||
|
shutdownConsumers(mqConsumers)
|
||||||
|
return nil, errors.New("rocketmq user_outbox consumer requires tencent_im.enabled")
|
||||||
|
}
|
||||||
|
if cfg.RocketMQ.UserOutbox.Enabled {
|
||||||
|
consumer, err := newUserOutboxConsumer(cfg, services.broadcast)
|
||||||
|
if err != nil {
|
||||||
|
shutdownConsumers(mqConsumers)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mqConsumers = append(mqConsumers, consumer)
|
||||||
|
}
|
||||||
|
if cfg.FirstRechargeRewardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
|
consumer, err := newFirstRechargeWalletConsumer(cfg, services)
|
||||||
|
if err != nil {
|
||||||
|
shutdownConsumers(mqConsumers)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mqConsumers = append(mqConsumers, consumer)
|
||||||
|
}
|
||||||
|
if cfg.CumulativeRechargeRewardWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
|
consumer, err := newCumulativeRechargeWalletConsumer(cfg, services)
|
||||||
|
if err != nil {
|
||||||
|
shutdownConsumers(mqConsumers)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mqConsumers = append(mqConsumers, consumer)
|
||||||
|
}
|
||||||
|
if cfg.RedPacketBroadcastWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
|
consumer, err := newRedPacketWalletConsumer(cfg, services)
|
||||||
|
if err != nil {
|
||||||
|
shutdownConsumers(mqConsumers)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mqConsumers = append(mqConsumers, consumer)
|
||||||
|
}
|
||||||
|
return mqConsumers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRoomOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
|
||||||
|
consumer, err := rocketmqx.NewConsumer(roomOutboxConsumerConfig(cfg.RocketMQ))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||||
|
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 一个 room outbox envelope 代表房间状态已经提交;下游模块各自写幂等消费表。
|
||||||
|
// 任何模块失败都返回错误给 MQ,让同一个 envelope 重试,避免“播报成功但活动漏计分”的半完成状态。
|
||||||
|
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
||||||
|
if _, err := services.broadcast.HandleRoomEvent(eventCtx, envelope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := services.growth.HandleRoomEvent(eventCtx, envelope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := services.weeklyStar.HandleRoomEvent(eventCtx, envelope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = services.roomTurnoverReward.HandleRoomEvent(eventCtx, envelope)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
_ = consumer.Shutdown()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return consumer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUserOutboxConsumer(cfg config.Config, broadcastSvc *broadcastservice.Service) (*rocketmqx.Consumer, error) {
|
||||||
|
consumer, err := rocketmqx.NewConsumer(userOutboxConsumerConfig(cfg.RocketMQ))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||||
|
outboxMessage, err := usermq.DecodeUserOutboxMessage(message.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return consumeUserRegionChangedForBroadcast(ctx, broadcastSvc, outboxMessage)
|
||||||
|
}); err != nil {
|
||||||
|
_ = consumer.Shutdown()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return consumer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFirstRechargeWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
|
||||||
|
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.FirstRechargeConsumerGroup))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||||
|
event, ok, err := firstRechargeEventFromWalletMessage(message.Body)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return services.firstRechargeReward.ConsumeWalletRechargeEvent(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
|
}); err != nil {
|
||||||
|
_ = consumer.Shutdown()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return consumer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCumulativeRechargeWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
|
||||||
|
// 累充奖励使用独立 consumer group,避免和首充奖励共享消费位点后互相影响重放和排查。
|
||||||
|
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.CumulativeRechargeConsumerGroup))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||||
|
event, ok, err := cumulativeRechargeEventFromWalletMessage(message.Body)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return services.cumulativeRecharge.ConsumeWalletRechargeEvent(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
|
}); err != nil {
|
||||||
|
_ = consumer.Shutdown()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return consumer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRedPacketWalletConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
|
||||||
|
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.RedPacketBroadcastConsumerGroup))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := consumer.Subscribe(redPacketWalletOutboxTopic(cfg.RocketMQ.WalletOutbox), walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||||
|
event, ok, err := redPacketEventFromWalletMessage(message.Body)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return services.broadcast.ConsumeRedPacketWalletEvent(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
|
}); err != nil {
|
||||||
|
_ = consumer.Shutdown()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return consumer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||||||
|
return rocketMQConsumerConfig(cfg, cfg.RoomOutbox.ConsumerGroup, cfg.RoomOutbox.ConsumerMaxReconsumeTimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||||||
|
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.ConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketmqx.ConsumerConfig {
|
||||||
|
return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redPacketWalletOutboxTopic(cfg config.WalletOutboxMQConfig) string {
|
||||||
|
// 钱包实时 topic 显式配置后,红包 worker 只消费实时通道,避免继续被普通账务 topic 的高频消息拖慢。
|
||||||
|
if cfg.RealtimeTopic != "" {
|
||||||
|
return cfg.RealtimeTopic
|
||||||
|
}
|
||||||
|
return cfg.Topic
|
||||||
|
}
|
||||||
|
|
||||||
|
func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsume int32) rocketmqx.ConsumerConfig {
|
||||||
|
return rocketmqx.ConsumerConfig{
|
||||||
|
EndpointConfig: rocketmqx.EndpointConfig{
|
||||||
|
NameServers: cfg.NameServers,
|
||||||
|
NameServerDomain: cfg.NameServerDomain,
|
||||||
|
AccessKey: cfg.AccessKey,
|
||||||
|
SecretKey: cfg.SecretKey,
|
||||||
|
SecurityToken: cfg.SecurityToken,
|
||||||
|
Namespace: cfg.Namespace,
|
||||||
|
VIPChannel: cfg.VIPChannel,
|
||||||
|
},
|
||||||
|
GroupName: group,
|
||||||
|
MaxReconsumeTimes: maxReconsume,
|
||||||
|
ConsumeRetryDelay: time.Second,
|
||||||
|
ConsumePullBatch: 32,
|
||||||
|
ConsumePullTimeout: 15 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
|
||||||
|
for _, consumer := range consumers {
|
||||||
|
_ = consumer.Shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRegionChangedPayload struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
OldRegionID int64 `json:"old_region_id"`
|
||||||
|
NewRegionID int64 `json:"new_region_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeUserRegionChangedForBroadcast(ctx context.Context, service *broadcastservice.Service, message usermq.UserOutboxMessage) error {
|
||||||
|
if message.EventType != "UserRegionChanged" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var payload userRegionChangedPayload
|
||||||
|
if err := json.Unmarshal([]byte(message.PayloadJSON), &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if payload.UserID <= 0 || payload.OldRegionID <= 0 || payload.OldRegionID == payload.NewRegionID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 区域广播群成员关系是当前读模型;用户切区后只移除旧群,下一次 UserSig 会按新 users.region_id 返回新群。
|
||||||
|
_, _, err := service.RemoveRegionBroadcastMember(appcode.WithContext(ctx, message.AppCode), payload.UserID, payload.OldRegionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
110
services/activity-service/internal/app/services.go
Normal file
110
services/activity-service/internal/app/services.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/tencentim"
|
||||||
|
activityclient "hyapp/services/activity-service/internal/client"
|
||||||
|
"hyapp/services/activity-service/internal/config"
|
||||||
|
achievementservice "hyapp/services/activity-service/internal/service/achievement"
|
||||||
|
activityservice "hyapp/services/activity-service/internal/service/activity"
|
||||||
|
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
||||||
|
cumulativerechargeservice "hyapp/services/activity-service/internal/service/cumulativerecharge"
|
||||||
|
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
|
||||||
|
growthservice "hyapp/services/activity-service/internal/service/growth"
|
||||||
|
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
|
||||||
|
messageservice "hyapp/services/activity-service/internal/service/message"
|
||||||
|
registrationrewardservice "hyapp/services/activity-service/internal/service/registrationreward"
|
||||||
|
roomturnoverrewardservice "hyapp/services/activity-service/internal/service/roomturnoverreward"
|
||||||
|
sevendaycheckinservice "hyapp/services/activity-service/internal/service/sevendaycheckin"
|
||||||
|
taskservice "hyapp/services/activity-service/internal/service/task"
|
||||||
|
weeklystarservice "hyapp/services/activity-service/internal/service/weeklystar"
|
||||||
|
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceBundle struct {
|
||||||
|
activity *activityservice.Service
|
||||||
|
message *messageservice.Service
|
||||||
|
task *taskservice.Service
|
||||||
|
registrationReward *registrationrewardservice.Service
|
||||||
|
sevenDayCheckIn *sevendaycheckinservice.Service
|
||||||
|
growth *growthservice.Service
|
||||||
|
achievement *achievementservice.Service
|
||||||
|
weeklyStar *weeklystarservice.Service
|
||||||
|
roomTurnoverReward *roomturnoverrewardservice.Service
|
||||||
|
broadcast *broadcastservice.Service
|
||||||
|
luckyGift *luckygiftservice.Service
|
||||||
|
firstRechargeReward *firstrechargeservice.Service
|
||||||
|
cumulativeRecharge *cumulativerechargeservice.Service
|
||||||
|
broadcastPublisherAvailable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository, clients externalClients) (*serviceBundle, error) {
|
||||||
|
walletClient := walletv1.NewWalletServiceClient(clients.walletConn)
|
||||||
|
roomClient := roomv1.NewRoomQueryServiceClient(clients.roomConn)
|
||||||
|
|
||||||
|
activitySvc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
|
||||||
|
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(activityclient.NewGRPCUserTargetSource(clients.userConn)))
|
||||||
|
taskSvc := taskservice.New(repository, walletClient)
|
||||||
|
registrationRewardSvc := registrationrewardservice.New(repository, walletClient)
|
||||||
|
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient)
|
||||||
|
growthSvc := growthservice.New(repository, walletClient)
|
||||||
|
achievementSvc := achievementservice.New(repository, walletClient)
|
||||||
|
weeklyStarSvc := weeklystarservice.New(repository, walletClient)
|
||||||
|
roomTurnoverRewardSvc := roomturnoverrewardservice.New(repository, walletClient, roomClient)
|
||||||
|
|
||||||
|
// Tencent IM 是区域广播、房间礼物播报和红包播报的外部发布器。
|
||||||
|
// 如果配置要求启动广播 worker,就必须先确认 publisher 可用,避免 worker 启动后只写 outbox 不发消息。
|
||||||
|
var tencentClient *tencentim.RESTClient
|
||||||
|
var broadcastPublisher broadcastservice.Publisher
|
||||||
|
if cfg.TencentIM.Enabled {
|
||||||
|
client, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tencentClient = client
|
||||||
|
broadcastPublisher = client
|
||||||
|
}
|
||||||
|
if cfg.Broadcast.Enabled && broadcastPublisher == nil {
|
||||||
|
return nil, errors.New("broadcast worker requires tencent_im.enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastSvc := broadcastservice.New(broadcastservice.Config{
|
||||||
|
NodeID: cfg.NodeID,
|
||||||
|
SuperGiftMinValue: cfg.Broadcast.SuperGiftMinValue,
|
||||||
|
WorkerBatchSize: cfg.Broadcast.WorkerBatchSize,
|
||||||
|
WorkerLockTTL: cfg.Broadcast.WorkerLockTTL,
|
||||||
|
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
|
||||||
|
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
|
||||||
|
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
|
||||||
|
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
|
||||||
|
}, repository, broadcastPublisher, activityclient.NewGRPCRegionSource(clients.userConn))
|
||||||
|
broadcastSvc.SetSenderProfileSource(activityclient.NewGRPCUserProfileSource(clients.userConn))
|
||||||
|
|
||||||
|
luckyGiftSvc := luckygiftservice.New(repository,
|
||||||
|
luckygiftservice.WithWallet(walletClient),
|
||||||
|
luckygiftservice.WithRoomPublisher(tencentClient),
|
||||||
|
luckygiftservice.WithRegionBroadcaster(broadcastSvc),
|
||||||
|
)
|
||||||
|
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletClient)
|
||||||
|
cumulativeRechargeSvc := cumulativerechargeservice.New(repository, walletClient)
|
||||||
|
|
||||||
|
return &serviceBundle{
|
||||||
|
activity: activitySvc,
|
||||||
|
message: messageSvc,
|
||||||
|
task: taskSvc,
|
||||||
|
registrationReward: registrationRewardSvc,
|
||||||
|
sevenDayCheckIn: sevenDayCheckInSvc,
|
||||||
|
growth: growthSvc,
|
||||||
|
achievement: achievementSvc,
|
||||||
|
weeklyStar: weeklyStarSvc,
|
||||||
|
roomTurnoverReward: roomTurnoverRewardSvc,
|
||||||
|
broadcast: broadcastSvc,
|
||||||
|
luckyGift: luckyGiftSvc,
|
||||||
|
firstRechargeReward: firstRechargeRewardSvc,
|
||||||
|
cumulativeRecharge: cumulativeRechargeSvc,
|
||||||
|
broadcastPublisherAvailable: broadcastPublisher != nil,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
296
services/activity-service/internal/app/wallet_events.go
Normal file
296
services/activity-service/internal/app/wallet_events.go
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/walletmq"
|
||||||
|
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
|
||||||
|
cumulativerechargedomain "hyapp/services/activity-service/internal/domain/cumulativerecharge"
|
||||||
|
firstrechargedomain "hyapp/services/activity-service/internal/domain/firstrecharge"
|
||||||
|
)
|
||||||
|
|
||||||
|
func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.RechargeEvent, bool, error) {
|
||||||
|
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return firstrechargedomain.RechargeEvent{}, false, err
|
||||||
|
}
|
||||||
|
if message.EventType != firstrechargedomain.RechargeEventWalletRecorded {
|
||||||
|
return firstrechargedomain.RechargeEvent{}, false, nil
|
||||||
|
}
|
||||||
|
sequence, rechargeType := rechargeFactFromWalletPayload(message.PayloadJSON)
|
||||||
|
return firstrechargedomain.RechargeEvent{
|
||||||
|
AppCode: appcode.Normalize(message.AppCode),
|
||||||
|
EventID: message.EventID,
|
||||||
|
EventType: message.EventType,
|
||||||
|
TransactionID: message.TransactionID,
|
||||||
|
CommandID: message.CommandID,
|
||||||
|
UserID: message.UserID,
|
||||||
|
RechargeCoinAmount: message.AvailableDelta,
|
||||||
|
RechargeSequence: sequence,
|
||||||
|
RechargeType: rechargeType,
|
||||||
|
PayloadJSON: message.PayloadJSON,
|
||||||
|
OccurredAtMS: message.OccurredAtMS,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeEventFromWalletMessage(body []byte) (cumulativerechargedomain.RechargeEvent, bool, error) {
|
||||||
|
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return cumulativerechargedomain.RechargeEvent{}, false, err
|
||||||
|
}
|
||||||
|
if message.EventType != cumulativerechargedomain.RechargeEventWalletRecorded {
|
||||||
|
return cumulativerechargedomain.RechargeEvent{}, false, nil
|
||||||
|
}
|
||||||
|
// WalletRechargeRecorded 是累充唯一事实源;payload 里没有可折算 USD 金额时直接跳过,避免把普通钱包流水计入活动。
|
||||||
|
fields := cumulativeRechargeFactFromWalletPayload(message.PayloadJSON, message.AvailableDelta)
|
||||||
|
if fields.qualifyingUSDMinor <= 0 {
|
||||||
|
return cumulativerechargedomain.RechargeEvent{}, false, nil
|
||||||
|
}
|
||||||
|
return cumulativerechargedomain.RechargeEvent{
|
||||||
|
AppCode: appcode.Normalize(message.AppCode),
|
||||||
|
EventID: message.EventID,
|
||||||
|
EventType: message.EventType,
|
||||||
|
TransactionID: message.TransactionID,
|
||||||
|
CommandID: message.CommandID,
|
||||||
|
UserID: message.UserID,
|
||||||
|
RechargeCoinAmount: fields.coinAmount,
|
||||||
|
RechargeSequence: fields.sequence,
|
||||||
|
RechargeType: fields.rechargeType,
|
||||||
|
QualifyingUSDMinor: fields.qualifyingUSDMinor,
|
||||||
|
PayloadJSON: message.PayloadJSON,
|
||||||
|
OccurredAtMS: message.OccurredAtMS,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWalletEvent, bool, error) {
|
||||||
|
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return broadcastdomain.RedPacketWalletEvent{}, false, err
|
||||||
|
}
|
||||||
|
if message.EventType != "WalletRedPacketCreated" && message.EventType != "WalletRedPacketClaimed" && message.EventType != "WalletRedPacketRefunded" {
|
||||||
|
return broadcastdomain.RedPacketWalletEvent{}, false, nil
|
||||||
|
}
|
||||||
|
fields := redPacketFieldsFromWalletPayload(message.PayloadJSON, message.EventType)
|
||||||
|
if fields.PacketID == "" {
|
||||||
|
return broadcastdomain.RedPacketWalletEvent{}, false, nil
|
||||||
|
}
|
||||||
|
return broadcastdomain.RedPacketWalletEvent{
|
||||||
|
AppCode: appcode.Normalize(message.AppCode),
|
||||||
|
EventID: message.EventID,
|
||||||
|
EventType: message.EventType,
|
||||||
|
TransactionID: message.TransactionID,
|
||||||
|
CommandID: message.CommandID,
|
||||||
|
PayloadJSON: message.PayloadJSON,
|
||||||
|
PacketID: fields.PacketID,
|
||||||
|
PacketType: fields.PacketType,
|
||||||
|
RoomID: fields.RoomID,
|
||||||
|
RegionID: fields.RegionID,
|
||||||
|
RegionCode: fields.RegionCode,
|
||||||
|
SenderUserID: fields.SenderUserID,
|
||||||
|
TotalAmount: fields.TotalAmount,
|
||||||
|
TotalCount: fields.TotalCount,
|
||||||
|
RemainingAmount: fields.RemainingAmount,
|
||||||
|
RemainingCount: fields.RemainingCount,
|
||||||
|
RefundedAmount: fields.RefundedAmount,
|
||||||
|
Status: fields.Status,
|
||||||
|
OpenAtMS: fields.OpenAtMS,
|
||||||
|
ExpiresAtMS: fields.ExpiresAtMS,
|
||||||
|
CreatedAtMS: message.OccurredAtMS,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rechargeFactFromWalletPayload(payload string) (int64, string) {
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||||
|
return 0, firstrechargedomain.RechargeTypeCoinSeller
|
||||||
|
}
|
||||||
|
var sequence int64
|
||||||
|
switch value := decoded["recharge_sequence"].(type) {
|
||||||
|
case float64:
|
||||||
|
sequence = int64(value)
|
||||||
|
case int64:
|
||||||
|
sequence = value
|
||||||
|
case json.Number:
|
||||||
|
sequence, _ = value.Int64()
|
||||||
|
}
|
||||||
|
rechargeType := firstrechargedomain.RechargeTypeCoinSeller
|
||||||
|
if value, ok := decoded["recharge_type"].(string); ok && value != "" {
|
||||||
|
rechargeType = value
|
||||||
|
}
|
||||||
|
return sequence, rechargeType
|
||||||
|
}
|
||||||
|
|
||||||
|
type cumulativeRechargePayloadFields struct {
|
||||||
|
sequence int64
|
||||||
|
rechargeType string
|
||||||
|
coinAmount int64
|
||||||
|
qualifyingUSDMinor int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeFactFromWalletPayload(payload string, availableDelta int64) cumulativeRechargePayloadFields {
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||||
|
decoded = map[string]any{}
|
||||||
|
}
|
||||||
|
// 来源字段兼容 wallet 历史 payload;没有明确来源时按币商转账处理,因为币商链路通常只带金币增量。
|
||||||
|
rechargeType := firstNonEmptyString(
|
||||||
|
stringFromDecoded(decoded, "recharge_type"),
|
||||||
|
stringFromDecoded(decoded, "channel"),
|
||||||
|
stringFromDecoded(decoded, "provider"),
|
||||||
|
cumulativerechargedomain.RechargeTypeCoinSeller,
|
||||||
|
)
|
||||||
|
// coinAmount 用于审计和币商折算,优先使用 payload 明细,最后回退 wallet outbox 的 available_delta。
|
||||||
|
coinAmount := firstNonZeroInt64(
|
||||||
|
int64FromDecoded(decoded, "coin_amount"),
|
||||||
|
int64FromDecoded(decoded, "amount"),
|
||||||
|
int64FromDecoded(decoded, "available_delta"),
|
||||||
|
availableDelta,
|
||||||
|
)
|
||||||
|
qualifyingUSDMinor := cumulativeRechargeUSDMinorFromPayload(decoded, rechargeType, coinAmount)
|
||||||
|
return cumulativeRechargePayloadFields{
|
||||||
|
sequence: int64FromDecoded(decoded, "recharge_sequence"),
|
||||||
|
rechargeType: rechargeType,
|
||||||
|
coinAmount: coinAmount,
|
||||||
|
qualifyingUSDMinor: qualifyingUSDMinor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeUSDMinorFromPayload(decoded map[string]any, rechargeType string, coinAmount int64) int64 {
|
||||||
|
if isCumulativeCoinSellerRecharge(rechargeType) {
|
||||||
|
// 币商给用户转账按 90000 coins = 1 USD 折算;这里返回美分并向下取整,尾差只留在原始金币字段审计。
|
||||||
|
return coinAmount * 100 / 90000
|
||||||
|
}
|
||||||
|
// Google/Mifapay 充值优先使用支付侧传入的美元美分,避免用金币包配置反推造成汇率误差。
|
||||||
|
if value := firstNonZeroInt64(
|
||||||
|
int64FromDecoded(decoded, "recharge_usd_minor"),
|
||||||
|
int64FromDecoded(decoded, "usd_minor_amount"),
|
||||||
|
int64FromDecoded(decoded, "exchange_usd_minor_amount"),
|
||||||
|
int64FromDecoded(decoded, "amount_usd_minor"),
|
||||||
|
); value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if isCumulativeGoogleRecharge(rechargeType) {
|
||||||
|
// Google Billing 常见 amount_micro 是 USD 微单位,除以 10000 后得到美分。
|
||||||
|
return int64FromDecoded(decoded, "amount_micro") / 10000
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCumulativeCoinSellerRecharge(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "coin_seller_transfer", "coin_seller":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCumulativeGoogleRecharge(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "google", "google_play":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type redPacketPayloadFields struct {
|
||||||
|
PacketID string
|
||||||
|
PacketType string
|
||||||
|
RoomID string
|
||||||
|
RegionID int64
|
||||||
|
RegionCode string
|
||||||
|
SenderUserID int64
|
||||||
|
TotalAmount int64
|
||||||
|
TotalCount int32
|
||||||
|
RemainingAmount int64
|
||||||
|
RemainingCount int32
|
||||||
|
RefundedAmount int64
|
||||||
|
Status string
|
||||||
|
OpenAtMS int64
|
||||||
|
ExpiresAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacketPayloadFields {
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||||
|
return redPacketPayloadFields{}
|
||||||
|
}
|
||||||
|
packetID := stringFromDecoded(decoded, "packet_id")
|
||||||
|
if packetID == "" {
|
||||||
|
packetID = stringFromDecoded(decoded, "packetId")
|
||||||
|
}
|
||||||
|
packetType := stringFromDecoded(decoded, "packet_type")
|
||||||
|
roomID := stringFromDecoded(decoded, "room_id")
|
||||||
|
regionID := int64FromDecoded(decoded, "region_id")
|
||||||
|
status := stringFromDecoded(decoded, "status")
|
||||||
|
openAtMS := int64FromDecoded(decoded, "open_at_ms")
|
||||||
|
expiresAtMS := int64FromDecoded(decoded, "expires_at_ms")
|
||||||
|
if eventType == "WalletRedPacketClaimed" || eventType == "WalletRedPacketRefunded" {
|
||||||
|
status = stringFromDecoded(decoded, "packet_status")
|
||||||
|
}
|
||||||
|
totalCount := int32FromDecoded(decoded, "packet_count")
|
||||||
|
remainingCount := int32FromDecoded(decoded, "remaining_count")
|
||||||
|
if remainingCount == 0 && eventType == "WalletRedPacketCreated" {
|
||||||
|
remainingCount = totalCount
|
||||||
|
}
|
||||||
|
return redPacketPayloadFields{
|
||||||
|
PacketID: packetID,
|
||||||
|
PacketType: packetType,
|
||||||
|
RoomID: roomID,
|
||||||
|
RegionID: regionID,
|
||||||
|
RegionCode: stringFromDecoded(decoded, "region_code"),
|
||||||
|
SenderUserID: int64FromDecoded(decoded, "sender_user_id"),
|
||||||
|
TotalAmount: int64FromDecoded(decoded, "total_amount"),
|
||||||
|
TotalCount: totalCount,
|
||||||
|
RemainingAmount: int64FromDecoded(decoded, "remaining_amount"),
|
||||||
|
RemainingCount: remainingCount,
|
||||||
|
RefundedAmount: int64FromDecoded(decoded, "refund_amount"),
|
||||||
|
Status: status,
|
||||||
|
OpenAtMS: openAtMS,
|
||||||
|
ExpiresAtMS: expiresAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromDecoded(decoded map[string]any, key string) string {
|
||||||
|
value, _ := decoded[key].(string)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyString(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value = strings.TrimSpace(value); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonZeroInt64(values ...int64) int64 {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64FromDecoded(decoded map[string]any, key string) int64 {
|
||||||
|
switch value := decoded[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(value)
|
||||||
|
case int64:
|
||||||
|
return value
|
||||||
|
case json.Number:
|
||||||
|
parsed, _ := value.Int64()
|
||||||
|
return parsed
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func int32FromDecoded(decoded map[string]any, key string) int32 {
|
||||||
|
return int32(int64FromDecoded(decoded, key))
|
||||||
|
}
|
||||||
20
services/activity-service/internal/app/workers.go
Normal file
20
services/activity-service/internal/app/workers.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hyapp/services/activity-service/internal/config"
|
||||||
|
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
|
||||||
|
)
|
||||||
|
|
||||||
|
func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luckygiftservice.WorkerOptions {
|
||||||
|
return luckygiftservice.WorkerOptions{
|
||||||
|
WorkerID: nodeID + "-lucky-gift",
|
||||||
|
PollInterval: cfg.WorkerPollInterval,
|
||||||
|
BatchSize: cfg.WorkerBatchSize,
|
||||||
|
Concurrency: cfg.WorkerConcurrency,
|
||||||
|
LockTTL: cfg.WorkerLockTTL,
|
||||||
|
MaxRetry: cfg.WorkerMaxRetry,
|
||||||
|
PublishTimeout: cfg.PublishTimeout,
|
||||||
|
StatsInterval: cfg.StatsRefreshInterval,
|
||||||
|
StatsBatchSize: cfg.StatsBatchSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,8 +25,12 @@ type Config struct {
|
|||||||
UserServiceAddr string `yaml:"user_service_addr"`
|
UserServiceAddr string `yaml:"user_service_addr"`
|
||||||
// WalletServiceAddr 是任务奖励入账依赖;activity 只发命令,不直接写余额。
|
// WalletServiceAddr 是任务奖励入账依赖;activity 只发命令,不直接写余额。
|
||||||
WalletServiceAddr string `yaml:"wallet_service_addr"`
|
WalletServiceAddr string `yaml:"wallet_service_addr"`
|
||||||
|
// RoomServiceAddr 是房间流水奖励结算读取房主快照的内部 gRPC 地址。
|
||||||
|
RoomServiceAddr string `yaml:"room_service_addr"`
|
||||||
// FirstRechargeRewardWorker 控制首冲奖励对 wallet_outbox 充值事实的本地消费。
|
// FirstRechargeRewardWorker 控制首冲奖励对 wallet_outbox 充值事实的本地消费。
|
||||||
FirstRechargeRewardWorker FirstRechargeRewardWorkerConfig `yaml:"first_recharge_reward_worker"`
|
FirstRechargeRewardWorker FirstRechargeRewardWorkerConfig `yaml:"first_recharge_reward_worker"`
|
||||||
|
// CumulativeRechargeRewardWorker 控制累充奖励对 wallet_outbox 充值事实的本地消费。
|
||||||
|
CumulativeRechargeRewardWorker CumulativeRechargeRewardWorkerConfig `yaml:"cumulative_recharge_reward_worker"`
|
||||||
// RedPacketBroadcastWorker 控制红包创建事实转区域飘屏 outbox 的本地消费。
|
// RedPacketBroadcastWorker 控制红包创建事实转区域飘屏 outbox 的本地消费。
|
||||||
RedPacketBroadcastWorker RedPacketBroadcastWorkerConfig `yaml:"red_packet_broadcast_worker"`
|
RedPacketBroadcastWorker RedPacketBroadcastWorkerConfig `yaml:"red_packet_broadcast_worker"`
|
||||||
// LuckyGiftWorker 控制幸运礼物 draw outbox 的返奖和房间 IM 补偿。
|
// LuckyGiftWorker 控制幸运礼物 draw outbox 的返奖和房间 IM 补偿。
|
||||||
@ -61,6 +65,12 @@ type FirstRechargeRewardWorkerConfig struct {
|
|||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardWorkerConfig 保存累充奖励钱包充值事实消费策略。
|
||||||
|
type CumulativeRechargeRewardWorkerConfig struct {
|
||||||
|
// Enabled 控制是否启动 wallet_outbox MQ 消费;关闭后仍可通过 gRPC 消费入口补偿。
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
// RedPacketBroadcastWorkerConfig 保存红包创建事实消费策略。
|
// RedPacketBroadcastWorkerConfig 保存红包创建事实消费策略。
|
||||||
type RedPacketBroadcastWorkerConfig struct {
|
type RedPacketBroadcastWorkerConfig struct {
|
||||||
// Enabled 控制是否启动 wallet_outbox MQ 消费。
|
// Enabled 控制是否启动 wallet_outbox MQ 消费。
|
||||||
@ -150,6 +160,7 @@ type WalletOutboxMQConfig struct {
|
|||||||
Topic string `yaml:"topic"`
|
Topic string `yaml:"topic"`
|
||||||
RealtimeTopic string `yaml:"realtime_topic"`
|
RealtimeTopic string `yaml:"realtime_topic"`
|
||||||
FirstRechargeConsumerGroup string `yaml:"first_recharge_consumer_group"`
|
FirstRechargeConsumerGroup string `yaml:"first_recharge_consumer_group"`
|
||||||
|
CumulativeRechargeConsumerGroup string `yaml:"cumulative_recharge_consumer_group"`
|
||||||
RedPacketBroadcastConsumerGroup string `yaml:"red_packet_broadcast_consumer_group"`
|
RedPacketBroadcastConsumerGroup string `yaml:"red_packet_broadcast_consumer_group"`
|
||||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||||
}
|
}
|
||||||
@ -173,9 +184,13 @@ func Default() Config {
|
|||||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC",
|
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||||
UserServiceAddr: "127.0.0.1:13005",
|
UserServiceAddr: "127.0.0.1:13005",
|
||||||
WalletServiceAddr: "127.0.0.1:13004",
|
WalletServiceAddr: "127.0.0.1:13004",
|
||||||
|
RoomServiceAddr: "127.0.0.1:13001",
|
||||||
FirstRechargeRewardWorker: FirstRechargeRewardWorkerConfig{
|
FirstRechargeRewardWorker: FirstRechargeRewardWorkerConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
|
CumulativeRechargeRewardWorker: CumulativeRechargeRewardWorkerConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
RedPacketBroadcastWorker: RedPacketBroadcastWorkerConfig{
|
RedPacketBroadcastWorker: RedPacketBroadcastWorkerConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
@ -238,6 +253,7 @@ func defaultRocketMQConfig() RocketMQConfig {
|
|||||||
Topic: "hyapp_wallet_outbox",
|
Topic: "hyapp_wallet_outbox",
|
||||||
RealtimeTopic: "",
|
RealtimeTopic: "",
|
||||||
FirstRechargeConsumerGroup: "hyapp-activity-first-recharge-wallet-outbox",
|
FirstRechargeConsumerGroup: "hyapp-activity-first-recharge-wallet-outbox",
|
||||||
|
CumulativeRechargeConsumerGroup: "hyapp-activity-cumulative-recharge-wallet-outbox",
|
||||||
RedPacketBroadcastConsumerGroup: "hyapp-activity-red-packet-wallet-outbox",
|
RedPacketBroadcastConsumerGroup: "hyapp-activity-red-packet-wallet-outbox",
|
||||||
ConsumerMaxReconsumeTimes: 16,
|
ConsumerMaxReconsumeTimes: 16,
|
||||||
},
|
},
|
||||||
@ -266,6 +282,9 @@ func Load(path string) (Config, error) {
|
|||||||
if strings.TrimSpace(cfg.WalletServiceAddr) == "" {
|
if strings.TrimSpace(cfg.WalletServiceAddr) == "" {
|
||||||
cfg.WalletServiceAddr = Default().WalletServiceAddr
|
cfg.WalletServiceAddr = Default().WalletServiceAddr
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cfg.RoomServiceAddr) == "" {
|
||||||
|
cfg.RoomServiceAddr = Default().RoomServiceAddr
|
||||||
|
}
|
||||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||||
if cfg.ServiceName == "" {
|
if cfg.ServiceName == "" {
|
||||||
cfg.ServiceName = "activity-service"
|
cfg.ServiceName = "activity-service"
|
||||||
@ -342,7 +361,7 @@ func Load(path string) (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
cfg.RocketMQ = rocketMQ
|
cfg.RocketMQ = rocketMQ
|
||||||
if (cfg.FirstRechargeRewardWorker.Enabled || cfg.RedPacketBroadcastWorker.Enabled) && !cfg.RocketMQ.WalletOutbox.Enabled {
|
if (cfg.FirstRechargeRewardWorker.Enabled || cfg.CumulativeRechargeRewardWorker.Enabled || cfg.RedPacketBroadcastWorker.Enabled) && !cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
return Config{}, errors.New("wallet outbox workers require rocketmq.wallet_outbox.enabled")
|
return Config{}, errors.New("wallet outbox workers require rocketmq.wallet_outbox.enabled")
|
||||||
}
|
}
|
||||||
if cfg.LuckyGiftWorker.Enabled && !cfg.TencentIM.Enabled {
|
if cfg.LuckyGiftWorker.Enabled && !cfg.TencentIM.Enabled {
|
||||||
@ -376,6 +395,9 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
|||||||
if cfg.WalletOutbox.FirstRechargeConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.FirstRechargeConsumerGroup); cfg.WalletOutbox.FirstRechargeConsumerGroup == "" {
|
if cfg.WalletOutbox.FirstRechargeConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.FirstRechargeConsumerGroup); cfg.WalletOutbox.FirstRechargeConsumerGroup == "" {
|
||||||
cfg.WalletOutbox.FirstRechargeConsumerGroup = defaults.WalletOutbox.FirstRechargeConsumerGroup
|
cfg.WalletOutbox.FirstRechargeConsumerGroup = defaults.WalletOutbox.FirstRechargeConsumerGroup
|
||||||
}
|
}
|
||||||
|
if cfg.WalletOutbox.CumulativeRechargeConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.CumulativeRechargeConsumerGroup); cfg.WalletOutbox.CumulativeRechargeConsumerGroup == "" {
|
||||||
|
cfg.WalletOutbox.CumulativeRechargeConsumerGroup = defaults.WalletOutbox.CumulativeRechargeConsumerGroup
|
||||||
|
}
|
||||||
if cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.RedPacketBroadcastConsumerGroup); cfg.WalletOutbox.RedPacketBroadcastConsumerGroup == "" {
|
if cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = strings.TrimSpace(cfg.WalletOutbox.RedPacketBroadcastConsumerGroup); cfg.WalletOutbox.RedPacketBroadcastConsumerGroup == "" {
|
||||||
cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = defaults.WalletOutbox.RedPacketBroadcastConsumerGroup
|
cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = defaults.WalletOutbox.RedPacketBroadcastConsumerGroup
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,130 @@
|
|||||||
|
package cumulativerecharge
|
||||||
|
|
||||||
|
const (
|
||||||
|
TierStatusActive = "active"
|
||||||
|
TierStatusInactive = "inactive"
|
||||||
|
|
||||||
|
GrantStatusPending = "pending"
|
||||||
|
GrantStatusGranted = "granted"
|
||||||
|
GrantStatusFailed = "failed"
|
||||||
|
|
||||||
|
ReasonEligible = "eligible"
|
||||||
|
ReasonNotConfigured = "not_configured"
|
||||||
|
ReasonDisabled = "disabled"
|
||||||
|
ReasonNoQualifyingValue = "no_qualifying_value"
|
||||||
|
ReasonAlreadyConsumed = "already_consumed"
|
||||||
|
ReasonNoTierMatched = "no_tier_matched"
|
||||||
|
ReasonPendingReward = "pending_reward"
|
||||||
|
|
||||||
|
RechargeEventWalletRecorded = "WalletRechargeRecorded"
|
||||||
|
RechargeTypeCoinSeller = "coin_seller_transfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier 是累充奖励的 USD 档位,金额统一用美分保存,避免后台小数金额和发放判断出现精度漂移。
|
||||||
|
type Tier struct {
|
||||||
|
TierID int64
|
||||||
|
TierCode string
|
||||||
|
TierName string
|
||||||
|
ThresholdUSDMinor int64
|
||||||
|
ResourceGroupID int64
|
||||||
|
Status string
|
||||||
|
SortOrder int32
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config 是当前 App 的累充奖励配置;activity-service 保存档位快照并负责后续充值事件判定。
|
||||||
|
type Config struct {
|
||||||
|
AppCode string
|
||||||
|
Enabled bool
|
||||||
|
Tiers []Tier
|
||||||
|
UpdatedByAdminID int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress 是用户在一个 UTC 自然周内的累充累计投影。
|
||||||
|
type Progress struct {
|
||||||
|
AppCode string
|
||||||
|
CycleKey string
|
||||||
|
UserID int64
|
||||||
|
TotalUSDMinor int64
|
||||||
|
TotalCoinAmount int64
|
||||||
|
FirstRechargedAtMS int64
|
||||||
|
LastRechargedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant 是用户命中某个累充档位后的资源组发放事实;event 字段用于 MQ 重试复用原命令。
|
||||||
|
type Grant struct {
|
||||||
|
GrantID string
|
||||||
|
AppCode string
|
||||||
|
CycleKey string
|
||||||
|
UserID int64
|
||||||
|
EventID string
|
||||||
|
TransactionID string
|
||||||
|
CommandID string
|
||||||
|
TierID int64
|
||||||
|
TierCode string
|
||||||
|
ThresholdUSDMinor int64
|
||||||
|
ResourceGroupID int64
|
||||||
|
ReachedUSDMinor int64
|
||||||
|
QualifyingUSDMinor int64
|
||||||
|
RechargeCoinAmount int64
|
||||||
|
RechargeType string
|
||||||
|
Status string
|
||||||
|
WalletCommandID string
|
||||||
|
WalletGrantID string
|
||||||
|
FailureReason string
|
||||||
|
GrantedAtMS int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RechargeEvent 是 wallet_outbox 里进入累充奖励的成功充值事实。
|
||||||
|
type RechargeEvent struct {
|
||||||
|
AppCode string
|
||||||
|
EventID string
|
||||||
|
EventType string
|
||||||
|
TransactionID string
|
||||||
|
CommandID string
|
||||||
|
UserID int64
|
||||||
|
RechargeCoinAmount int64
|
||||||
|
RechargeSequence int64
|
||||||
|
RechargeType string
|
||||||
|
QualifyingUSDMinor int64
|
||||||
|
PayloadJSON string
|
||||||
|
OccurredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle 表达 UTC 自然周周期边界。
|
||||||
|
type Cycle struct {
|
||||||
|
Key string
|
||||||
|
StartMS int64
|
||||||
|
EndMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusResult 是 App H5 页面读取的完整累充状态。
|
||||||
|
type StatusResult struct {
|
||||||
|
Config Config
|
||||||
|
Progress Progress
|
||||||
|
Grants []Grant
|
||||||
|
Cycle Cycle
|
||||||
|
ServerTimeMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareResult 表达一次充值事实在 MySQL 事务内是否产生了需要执行的发放命令。
|
||||||
|
type PrepareResult struct {
|
||||||
|
Grants []Grant
|
||||||
|
Consumed bool
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantQuery 是后台发放记录分页查询条件。
|
||||||
|
type GrantQuery struct {
|
||||||
|
Status string
|
||||||
|
UserID int64
|
||||||
|
CycleKey string
|
||||||
|
Page int32
|
||||||
|
PageSize int32
|
||||||
|
}
|
||||||
@ -77,10 +77,12 @@ type PrepareResult struct {
|
|||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimQuery 是后台领取记录分页查询条件;用户搜索由 admin-server 先解析成 user_id。
|
// ClaimQuery 是后台领取记录分页查询条件;用户搜索由 admin-server 先解析成 user_id,领取时间筛选按 claimed_at_ms 优先、created_at_ms 兜底的页面展示口径执行。
|
||||||
type ClaimQuery struct {
|
type ClaimQuery struct {
|
||||||
Status string
|
Status string
|
||||||
UserID int64
|
UserID int64
|
||||||
Page int32
|
ClaimedStartMS int64
|
||||||
PageSize int32
|
ClaimedEndMS int64
|
||||||
|
Page int32
|
||||||
|
PageSize int32
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,120 @@
|
|||||||
|
package roomturnoverreward
|
||||||
|
|
||||||
|
const (
|
||||||
|
TierStatusActive = "active"
|
||||||
|
TierStatusInactive = "inactive"
|
||||||
|
|
||||||
|
SettlementStatusPending = "pending"
|
||||||
|
SettlementStatusGranted = "granted"
|
||||||
|
SettlementStatusFailed = "failed"
|
||||||
|
|
||||||
|
EventStatusConsumed = "consumed"
|
||||||
|
EventStatusDuplicate = "duplicate"
|
||||||
|
EventStatusSkipped = "skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier 是后台配置的房间流水奖励档位,结算只发命中的最高有效档。
|
||||||
|
type Tier struct {
|
||||||
|
TierID int64
|
||||||
|
TierCode string
|
||||||
|
TierName string
|
||||||
|
ThresholdCoinSpent int64
|
||||||
|
RewardCoinAmount int64
|
||||||
|
Status string
|
||||||
|
SortOrder int32
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config 是当前 App 的房间流水奖励配置。
|
||||||
|
type Config struct {
|
||||||
|
AppCode string
|
||||||
|
Enabled bool
|
||||||
|
Tiers []Tier
|
||||||
|
UpdatedByAdminID int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress 是单房间单 UTC 周期的金币流水聚合。
|
||||||
|
type Progress struct {
|
||||||
|
AppCode string
|
||||||
|
RoomID string
|
||||||
|
PeriodStartMS int64
|
||||||
|
PeriodEndMS int64
|
||||||
|
CoinSpent int64
|
||||||
|
LastEventAtMS int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomGiftEvent 是 room-service RoomGiftSent 事实映射后的最小聚合输入。
|
||||||
|
type RoomGiftEvent struct {
|
||||||
|
EventID string
|
||||||
|
EventType string
|
||||||
|
AppCode string
|
||||||
|
RoomID string
|
||||||
|
CoinSpent int64
|
||||||
|
OccurredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventResult 表达房间礼物事件是否参与流水聚合。
|
||||||
|
type EventResult struct {
|
||||||
|
EventID string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settlement 是某个房间单个 UTC 周期的奖励发放事实。
|
||||||
|
type Settlement struct {
|
||||||
|
SettlementID string
|
||||||
|
AppCode string
|
||||||
|
RoomID string
|
||||||
|
OwnerUserID int64
|
||||||
|
PeriodStartMS int64
|
||||||
|
PeriodEndMS int64
|
||||||
|
CoinSpent int64
|
||||||
|
TierID int64
|
||||||
|
TierCode string
|
||||||
|
ThresholdCoinSpent int64
|
||||||
|
RewardCoinAmount int64
|
||||||
|
Status string
|
||||||
|
WalletCommandID string
|
||||||
|
WalletTransactionID string
|
||||||
|
FailureReason string
|
||||||
|
SettledAtMS int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusResult 是 H5 查询当前用户房间流水奖励活动时的展示投影。
|
||||||
|
type StatusResult struct {
|
||||||
|
Config Config
|
||||||
|
RoomID string
|
||||||
|
OwnerUserID int64
|
||||||
|
PeriodStartMS int64
|
||||||
|
PeriodEndMS int64
|
||||||
|
CurrentCoinSpent int64
|
||||||
|
ExpectedRewardCoinAmount int64
|
||||||
|
MatchedTier Tier
|
||||||
|
LatestSettlement Settlement
|
||||||
|
ServerTimeMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettlementQuery 是后台结算记录分页筛选条件。
|
||||||
|
type SettlementQuery struct {
|
||||||
|
Status string
|
||||||
|
RoomID string
|
||||||
|
OwnerUserID int64
|
||||||
|
PeriodStartMS int64
|
||||||
|
Page int32
|
||||||
|
PageSize int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchResult 汇总 cron 一次处理的 settlement 创建和发币结果。
|
||||||
|
type BatchResult struct {
|
||||||
|
ClaimedCount int32
|
||||||
|
ProcessedCount int32
|
||||||
|
SuccessCount int32
|
||||||
|
FailureCount int32
|
||||||
|
HasMore bool
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
package weeklystar
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActivityCode 是通用指定礼物积分活动框架中用于承载周星的活动编码。
|
||||||
|
ActivityCode = "weekly_star"
|
||||||
|
|
||||||
|
StatusDraft = "draft"
|
||||||
|
StatusActive = "active"
|
||||||
|
StatusDisabled = "disabled"
|
||||||
|
StatusSettling = "settling"
|
||||||
|
StatusSettled = "settled"
|
||||||
|
|
||||||
|
EventStatusConsumed = "consumed"
|
||||||
|
EventStatusSkipped = "skipped"
|
||||||
|
EventStatusDuplicate = "duplicate"
|
||||||
|
|
||||||
|
SettlementStatusPending = "pending"
|
||||||
|
SettlementStatusRunning = "running"
|
||||||
|
SettlementStatusGranted = "granted"
|
||||||
|
SettlementStatusFailed = "failed"
|
||||||
|
|
||||||
|
GrantSourceWeeklyStar = "weekly_star"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gift describes one gift that can add score inside a weekly star cycle.
|
||||||
|
type Gift struct {
|
||||||
|
AppCode string
|
||||||
|
CycleID string
|
||||||
|
GiftID string
|
||||||
|
SortOrder int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reward maps one weekly star rank to the resource group granted at settlement time.
|
||||||
|
type Reward struct {
|
||||||
|
AppCode string
|
||||||
|
CycleID string
|
||||||
|
RankNo int32
|
||||||
|
ResourceGroupID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle is the region-scoped UTC time window that controls weekly star scoring and rewards.
|
||||||
|
type Cycle struct {
|
||||||
|
AppCode string
|
||||||
|
CycleID string
|
||||||
|
ActivityCode string
|
||||||
|
RegionID int64
|
||||||
|
Title string
|
||||||
|
Status string
|
||||||
|
StartMS int64
|
||||||
|
EndMS int64
|
||||||
|
CreatedByAdminID int64
|
||||||
|
UpdatedByAdminID int64
|
||||||
|
SettledAtMS int64
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
Gifts []Gift
|
||||||
|
Rewards []Reward
|
||||||
|
}
|
||||||
|
|
||||||
|
// CycleCommand is the admin mutation input after transport normalization.
|
||||||
|
type CycleCommand struct {
|
||||||
|
Cycle Cycle
|
||||||
|
OperatorAdminID int64
|
||||||
|
RequireNewRecord bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListQuery filters admin cycle list reads. Region 0 is a real filter for default cycles.
|
||||||
|
type ListQuery struct {
|
||||||
|
RegionID *int64
|
||||||
|
Status string
|
||||||
|
StartMS int64
|
||||||
|
EndMS int64
|
||||||
|
Page int32
|
||||||
|
PageSize int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiftEvent is the committed room-service gift fact consumed by weekly star.
|
||||||
|
type GiftEvent struct {
|
||||||
|
EventID string
|
||||||
|
UserID int64
|
||||||
|
GiftID string
|
||||||
|
ScoreDelta int64
|
||||||
|
RegionID int64
|
||||||
|
OccurredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventResult describes idempotent score consumption for one source event.
|
||||||
|
type EventResult struct {
|
||||||
|
EventID string
|
||||||
|
Status string
|
||||||
|
CycleID string
|
||||||
|
ScoreDelta int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaderboardEntry is the score row plus deterministic rank within a cycle.
|
||||||
|
type LeaderboardEntry struct {
|
||||||
|
RankNo int32
|
||||||
|
UserID int64
|
||||||
|
Score int64
|
||||||
|
FirstScoredAtMS int64
|
||||||
|
LastScoredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settlement is one resource-group grant job for a settled Top rank.
|
||||||
|
type Settlement struct {
|
||||||
|
AppCode string
|
||||||
|
SettlementID string
|
||||||
|
CycleID string
|
||||||
|
RankNo int32
|
||||||
|
UserID int64
|
||||||
|
Score int64
|
||||||
|
ResourceGroupID int64
|
||||||
|
WalletCommandID string
|
||||||
|
WalletGrantID string
|
||||||
|
Status string
|
||||||
|
FailureReason string
|
||||||
|
AttemptCount int32
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryCycle groups a completed cycle with its Top entries for H5 history reads.
|
||||||
|
type HistoryCycle struct {
|
||||||
|
Cycle Cycle
|
||||||
|
TopEntries []LeaderboardEntry
|
||||||
|
}
|
||||||
@ -0,0 +1,356 @@
|
|||||||
|
package cumulativerecharge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/cumulativerecharge"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
grantSource = "cumulative_recharge_reward"
|
||||||
|
grantReason = "cumulative_recharge_reward"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository 是累充奖励配置、周期累计、event 幂等和发放事实的唯一持久化边界。
|
||||||
|
type Repository interface {
|
||||||
|
GetCumulativeRechargeRewardConfig(ctx context.Context) (domain.Config, bool, error)
|
||||||
|
UpdateCumulativeRechargeRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
|
||||||
|
GetCumulativeRechargeRewardProgress(ctx context.Context, cycleKey string, userID int64) (domain.Progress, bool, error)
|
||||||
|
ListCumulativeRechargeRewardGrantsByUserCycle(ctx context.Context, cycleKey string, userID int64) ([]domain.Grant, error)
|
||||||
|
PrepareCumulativeRechargeRewardGrants(ctx context.Context, event domain.RechargeEvent, cycle domain.Cycle, nowMS int64) (domain.PrepareResult, error)
|
||||||
|
MarkCumulativeRechargeRewardGrantGranted(ctx context.Context, grantID string, walletGrantID string, grantedAtMS int64) (domain.Grant, error)
|
||||||
|
MarkCumulativeRechargeRewardGrantFailed(ctx context.Context, grantID string, failureReason string, nowMS int64) error
|
||||||
|
ListCumulativeRechargeRewardGrants(ctx context.Context, query domain.GrantQuery) ([]domain.Grant, int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletClient 是累充奖励唯一外部副作用;资源组展开仍由 wallet-service 原子完成。
|
||||||
|
type WalletClient interface {
|
||||||
|
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 承载累充奖励 App 查询、后台配置和 wallet 充值事实消费用例。
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
wallet WalletClient
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, wallet WalletClient) *Service {
|
||||||
|
return &Service{repository: repository, wallet: wallet, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetClock(now func() time.Time) {
|
||||||
|
if now != nil {
|
||||||
|
s.now = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus 返回当前 UTC 自然周内的用户累计、档位和发放状态;未配置时返回空配置而不是错误。
|
||||||
|
func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.StatusResult, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.StatusResult{}, err
|
||||||
|
}
|
||||||
|
if userID <= 0 {
|
||||||
|
return domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||||
|
}
|
||||||
|
now := s.now().UTC()
|
||||||
|
cycle := CycleForTime(now)
|
||||||
|
result := domain.StatusResult{Cycle: cycle, ServerTimeMS: now.UnixMilli()}
|
||||||
|
|
||||||
|
// H5 只展示当前生效配置里的 active 档位;inactive 档位保留给后台编辑和历史 grant 审计。
|
||||||
|
config, exists, err := s.repository.GetCumulativeRechargeRewardConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StatusResult{}, err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
config.Tiers = activeTiers(config.Tiers)
|
||||||
|
result.Config = config
|
||||||
|
} else {
|
||||||
|
result.Config = domain.Config{AppCode: appcode.FromContext(ctx)}
|
||||||
|
}
|
||||||
|
progress, _, err := s.repository.GetCumulativeRechargeRewardProgress(ctx, cycle.Key, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StatusResult{}, err
|
||||||
|
}
|
||||||
|
if progress.UserID == 0 {
|
||||||
|
progress = domain.Progress{AppCode: appcode.FromContext(ctx), CycleKey: cycle.Key, UserID: userID}
|
||||||
|
}
|
||||||
|
result.Progress = progress
|
||||||
|
grants, err := s.repository.ListCumulativeRechargeRewardGrantsByUserCycle(ctx, cycle.Key, userID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StatusResult{}, err
|
||||||
|
}
|
||||||
|
result.Grants = grants
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
config, exists, err := s.repository.GetCumulativeRechargeRewardConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return domain.Config{AppCode: appcode.FromContext(ctx)}, nil
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
// 后台配置是“未来事件规则”:保存时不扫描历史充值,也不改写已经创建的 grant 快照。
|
||||||
|
config.AppCode = appcode.FromContext(ctx)
|
||||||
|
config.Tiers = normalizeTiers(config.Tiers)
|
||||||
|
if err := validateConfig(config); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
return s.repository.UpdateCumulativeRechargeRewardConfig(ctx, config, s.now().UTC().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume 处理成功充值事实;一次充值可能让用户跨过多个累充档位,所以会返回多条 grant。
|
||||||
|
func (s *Service) Consume(ctx context.Context, event domain.RechargeEvent) ([]domain.Grant, bool, string, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return nil, false, "", err
|
||||||
|
}
|
||||||
|
event = normalizeRechargeEvent(event)
|
||||||
|
if err := validateRechargeEvent(event); err != nil {
|
||||||
|
return nil, false, "", err
|
||||||
|
}
|
||||||
|
cycle := CycleForTime(time.UnixMilli(event.OccurredAtMS).UTC())
|
||||||
|
|
||||||
|
// Prepare 在一个 MySQL 事务里完成 event 幂等、周期累计和待发放 grant 创建;
|
||||||
|
// 这里不直接发资源,避免外部 wallet 副作用进入数据库事务导致长锁或半提交。
|
||||||
|
prepared, err := s.repository.PrepareCumulativeRechargeRewardGrants(ctx, event, cycle, s.now().UTC().UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, "", err
|
||||||
|
}
|
||||||
|
if len(prepared.Grants) == 0 {
|
||||||
|
return nil, prepared.Consumed, prepared.Reason, nil
|
||||||
|
}
|
||||||
|
if s.wallet == nil {
|
||||||
|
return nil, false, "", xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
granted := make([]domain.Grant, 0, len(prepared.Grants))
|
||||||
|
for _, grant := range prepared.Grants {
|
||||||
|
// 每个档位使用稳定 wallet command id;MQ 重投或上次发放失败时复用同一命令,交给 wallet-service 幂等。
|
||||||
|
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||||||
|
CommandId: grant.WalletCommandID,
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
TargetUserId: grant.UserID,
|
||||||
|
GroupId: grant.ResourceGroupID,
|
||||||
|
Reason: grantReason,
|
||||||
|
OperatorUserId: grant.UserID,
|
||||||
|
GrantSource: grantSource,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// wallet 调用失败只把 grant 标记为 failed,不删除记录;下一次同 event 重试会重新置回 pending 后继续发放。
|
||||||
|
_ = s.repository.MarkCumulativeRechargeRewardGrantFailed(ctx, grant.GrantID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||||||
|
return granted, false, prepared.Reason, err
|
||||||
|
}
|
||||||
|
walletGrantID := ""
|
||||||
|
if resp.GetGrant() != nil {
|
||||||
|
walletGrantID = resp.GetGrant().GetGrantId()
|
||||||
|
}
|
||||||
|
// 只有拿到 wallet 回执后才落 granted,后台记录因此可以区分“已命中但未到账”和“已到账”。
|
||||||
|
updated, err := s.repository.MarkCumulativeRechargeRewardGrantGranted(ctx, grant.GrantID, walletGrantID, s.now().UTC().UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
return granted, false, prepared.Reason, err
|
||||||
|
}
|
||||||
|
granted = append(granted, updated)
|
||||||
|
}
|
||||||
|
return granted, true, domain.ReasonEligible, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListGrants(ctx context.Context, query domain.GrantQuery) ([]domain.Grant, int64, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
query.Status = strings.TrimSpace(query.Status)
|
||||||
|
query.CycleKey = strings.TrimSpace(query.CycleKey)
|
||||||
|
if query.Page <= 0 {
|
||||||
|
query.Page = 1
|
||||||
|
}
|
||||||
|
if query.PageSize <= 0 {
|
||||||
|
query.PageSize = 20
|
||||||
|
}
|
||||||
|
if query.PageSize > 100 {
|
||||||
|
query.PageSize = 100
|
||||||
|
}
|
||||||
|
return s.repository.ListCumulativeRechargeRewardGrants(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeWalletRechargeEvent handles one MQ-delivered wallet recharge fact.
|
||||||
|
func (s *Service) ConsumeWalletRechargeEvent(ctx context.Context, event domain.RechargeEvent) error {
|
||||||
|
if s == nil || s.repository == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "cumulative recharge reward repository is not configured")
|
||||||
|
}
|
||||||
|
if event.EventType != domain.RechargeEventWalletRecorded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, _, _, err := s.Consume(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requireRepository() error {
|
||||||
|
if s == nil || s.repository == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "cumulative recharge reward repository is not configured")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CycleForTime returns the UTC natural-week cycle that contains value.
|
||||||
|
func CycleForTime(value time.Time) domain.Cycle {
|
||||||
|
utc := value.UTC()
|
||||||
|
dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
// Go 的 Weekday 从 Sunday 开始,这里转换成 Monday=0,用于计算 UTC 自然周起点。
|
||||||
|
daysSinceMonday := (int(dayStart.Weekday()) + 6) % 7
|
||||||
|
start := dayStart.AddDate(0, 0, -daysSinceMonday)
|
||||||
|
end := start.AddDate(0, 0, 7)
|
||||||
|
isoYear, isoWeek := start.ISOWeek()
|
||||||
|
return domain.Cycle{
|
||||||
|
Key: fmt.Sprintf("%04d-W%02d", isoYear, isoWeek),
|
||||||
|
StartMS: start.UnixMilli(),
|
||||||
|
EndMS: end.UnixMilli() - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTiers(tiers []domain.Tier) []domain.Tier {
|
||||||
|
items := make([]domain.Tier, 0, len(tiers))
|
||||||
|
for _, tier := range tiers {
|
||||||
|
status := strings.ToLower(strings.TrimSpace(tier.Status))
|
||||||
|
if status == "" {
|
||||||
|
status = domain.TierStatusActive
|
||||||
|
}
|
||||||
|
items = append(items, domain.Tier{
|
||||||
|
TierID: tier.TierID,
|
||||||
|
TierCode: strings.TrimSpace(tier.TierCode),
|
||||||
|
TierName: strings.TrimSpace(tier.TierName),
|
||||||
|
ThresholdUSDMinor: tier.ThresholdUSDMinor,
|
||||||
|
ResourceGroupID: tier.ResourceGroupID,
|
||||||
|
Status: status,
|
||||||
|
SortOrder: tier.SortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 排序同时服务后台回显和 H5 展示;sort_order 相同则按门槛金额升序保证跨档发放顺序稳定。
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].SortOrder == items[j].SortOrder {
|
||||||
|
return items[i].ThresholdUSDMinor < items[j].ThresholdUSDMinor
|
||||||
|
}
|
||||||
|
return items[i].SortOrder < items[j].SortOrder
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfig(config domain.Config) error {
|
||||||
|
if config.UpdatedByAdminID <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(config.Tiers))
|
||||||
|
active := make([]domain.Tier, 0, len(config.Tiers))
|
||||||
|
for _, tier := range config.Tiers {
|
||||||
|
// tier_code 是后台编辑和审计展示标识,不参与唯一发放;真正的发放唯一性由 tier_id 绑定。
|
||||||
|
if tier.TierCode == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "tier_code is required")
|
||||||
|
}
|
||||||
|
if _, exists := seen[tier.TierCode]; exists {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "tier_code is duplicated")
|
||||||
|
}
|
||||||
|
seen[tier.TierCode] = struct{}{}
|
||||||
|
if tier.TierName == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "tier_name is required")
|
||||||
|
}
|
||||||
|
if tier.ThresholdUSDMinor <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "threshold_usd_minor must be greater than zero")
|
||||||
|
}
|
||||||
|
if tier.ResourceGroupID <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "resource_group_id is required")
|
||||||
|
}
|
||||||
|
switch tier.Status {
|
||||||
|
case domain.TierStatusActive:
|
||||||
|
active = append(active, tier)
|
||||||
|
case domain.TierStatusInactive:
|
||||||
|
default:
|
||||||
|
return xerr.New(xerr.InvalidArgument, "tier status is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Enabled && len(active) == 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier")
|
||||||
|
}
|
||||||
|
// 同一个生效配置里 active 门槛不能重复,否则用户达标后无法用金额判断哪个档位先展示。
|
||||||
|
if hasDuplicateThreshold(active) {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "active tier threshold_usd_minor is duplicated")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDuplicateThreshold(tiers []domain.Tier) bool {
|
||||||
|
seen := make(map[int64]struct{}, len(tiers))
|
||||||
|
for _, tier := range tiers {
|
||||||
|
if _, exists := seen[tier.ThresholdUSDMinor]; exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
seen[tier.ThresholdUSDMinor] = struct{}{}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRechargeEvent(event domain.RechargeEvent) domain.RechargeEvent {
|
||||||
|
event.AppCode = appcode.Normalize(event.AppCode)
|
||||||
|
event.EventID = strings.TrimSpace(event.EventID)
|
||||||
|
event.EventType = strings.TrimSpace(event.EventType)
|
||||||
|
event.TransactionID = strings.TrimSpace(event.TransactionID)
|
||||||
|
event.CommandID = strings.TrimSpace(event.CommandID)
|
||||||
|
event.RechargeType = strings.ToLower(strings.TrimSpace(event.RechargeType))
|
||||||
|
if event.RechargeType == "" {
|
||||||
|
event.RechargeType = domain.RechargeTypeCoinSeller
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(event.PayloadJSON) == "" {
|
||||||
|
event.PayloadJSON = "{}"
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRechargeEvent(event domain.RechargeEvent) error {
|
||||||
|
if event.AppCode == "" || event.EventID == "" || event.TransactionID == "" || event.CommandID == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "recharge event identity is incomplete")
|
||||||
|
}
|
||||||
|
if event.UserID <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||||
|
}
|
||||||
|
if event.QualifyingUSDMinor <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "qualifying_usd_minor must be greater than zero")
|
||||||
|
}
|
||||||
|
if event.OccurredAtMS <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeTiers(tiers []domain.Tier) []domain.Tier {
|
||||||
|
items := make([]domain.Tier, 0, len(tiers))
|
||||||
|
for _, tier := range tiers {
|
||||||
|
if tier.Status == domain.TierStatusActive {
|
||||||
|
items = append(items, tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].SortOrder == items[j].SortOrder {
|
||||||
|
return items[i].ThresholdUSDMinor < items[j].ThresholdUSDMinor
|
||||||
|
}
|
||||||
|
return items[i].SortOrder < items[j].SortOrder
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
}
|
||||||
@ -36,6 +36,7 @@ type Repository interface {
|
|||||||
MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error
|
MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error
|
||||||
MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error
|
MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error
|
||||||
RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error)
|
RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error)
|
||||||
|
RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error)
|
||||||
MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error
|
MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error
|
||||||
MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error
|
MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error
|
||||||
// 批量抽奖只产生一条聚合 outbox;worker 处理后需要用 draw_ids 一次性收敛所有明细状态。
|
// 批量抽奖只产生一条聚合 outbox;worker 处理后需要用 draw_ids 一次性收敛所有明细状态。
|
||||||
@ -172,6 +173,9 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
|
|||||||
if _, err := s.repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
|
if _, err := s.repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
|
||||||
logx.Error(ctx, "lucky_gift_admin_stats_refresh_failed", err, slog.String("worker_id", options.WorkerID))
|
logx.Error(ctx, "lucky_gift_admin_stats_refresh_failed", err, slog.String("worker_id", options.WorkerID))
|
||||||
}
|
}
|
||||||
|
if _, err := s.repository.RefreshLuckyGiftDatabiStatsSnapshots(ctx, now.UnixMilli(), options.StatsBatchSize); err != nil && ctx.Err() == nil {
|
||||||
|
logx.Error(ctx, "lucky_gift_databi_stats_refresh_failed", err, slog.String("worker_id", options.WorkerID))
|
||||||
|
}
|
||||||
nextStatsRefresh = now.Add(options.StatsInterval)
|
nextStatsRefresh = now.Add(options.StatsInterval)
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@ -491,6 +491,10 @@ func (r *fakeLuckyGiftRepository) RefreshLuckyGiftAdminStatsSnapshots(context.Co
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeLuckyGiftRepository) RefreshLuckyGiftDatabiStatsSnapshots(context.Context, int64, int) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawGranted(_ context.Context, _ string, drawID string, _ string, _ int64) error {
|
func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawGranted(_ context.Context, _ string, drawID string, _ string, _ int64) error {
|
||||||
r.grantedDrawID = drawID
|
r.grantedDrawID = drawID
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -26,6 +26,7 @@ type Repository interface {
|
|||||||
MarkRegistrationRewardClaimGranted(ctx context.Context, claimID string, walletTransactionID string, resourceGrantID string, grantedAtMS int64) (domain.Claim, error)
|
MarkRegistrationRewardClaimGranted(ctx context.Context, claimID string, walletTransactionID string, resourceGrantID string, grantedAtMS int64) (domain.Claim, error)
|
||||||
MarkRegistrationRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
|
MarkRegistrationRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
|
||||||
ListRegistrationRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
|
ListRegistrationRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
|
||||||
|
CountRegistrationRewardGrantedByDay(ctx context.Context, rewardDay string) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WalletClient 是注册奖励发放的唯一外部副作用;金币和资源组都依赖 wallet-service 幂等命令。
|
// WalletClient 是注册奖励发放的唯一外部副作用;金币和资源组都依赖 wallet-service 幂等命令。
|
||||||
@ -176,9 +177,9 @@ func (s *Service) IssueRegistrationReward(ctx context.Context, command domain.Is
|
|||||||
return claim, eligibility, true, nil
|
return claim, eligibility, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
|
func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, int64, error) {
|
||||||
if err := s.requireRepository(); err != nil {
|
if err := s.requireRepository(); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, 0, err
|
||||||
}
|
}
|
||||||
query.Status = strings.TrimSpace(query.Status)
|
query.Status = strings.TrimSpace(query.Status)
|
||||||
if query.Page <= 0 {
|
if query.Page <= 0 {
|
||||||
@ -190,7 +191,16 @@ func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]do
|
|||||||
if query.PageSize > 100 {
|
if query.PageSize > 100 {
|
||||||
query.PageSize = 100
|
query.PageSize = 100
|
||||||
}
|
}
|
||||||
return s.repository.ListRegistrationRewardClaims(ctx, query)
|
claims, total, err := s.repository.ListRegistrationRewardClaims(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
// 今日已领数量用于后台配置页展示,它必须使用同一套 UTC reward_day 口径,避免后台本地时区和发放额度统计出现错位。
|
||||||
|
todayClaimedCount, err := s.repository.CountRegistrationRewardGrantedByDay(ctx, rewardDay(s.now()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
return claims, total, todayClaimedCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) requireRepository() error {
|
func (s *Service) requireRepository() error {
|
||||||
|
|||||||
@ -0,0 +1,517 @@
|
|||||||
|
package roomturnoverreward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rewardReason = "room_turnover_reward"
|
||||||
|
|
||||||
|
// Repository 是房间流水奖励配置、周期聚合和结算记录的唯一持久化边界。
|
||||||
|
type Repository interface {
|
||||||
|
GetRoomTurnoverRewardConfig(ctx context.Context) (domain.Config, bool, error)
|
||||||
|
UpdateRoomTurnoverRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
|
||||||
|
ConsumeRoomTurnoverGiftEvent(ctx context.Context, event domain.RoomGiftEvent, periodStartMS int64, periodEndMS int64, nowMS int64) (domain.EventResult, error)
|
||||||
|
GetRoomTurnoverRewardProgress(ctx context.Context, roomID string, periodStartMS int64) (domain.Progress, bool, error)
|
||||||
|
GetLatestRoomTurnoverRewardSettlement(ctx context.Context, roomID string) (domain.Settlement, bool, error)
|
||||||
|
ListRoomTurnoverRewardSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error)
|
||||||
|
ListUnsettledRoomTurnoverRewardProgress(ctx context.Context, periodStartMS int64, limit int) ([]domain.Progress, error)
|
||||||
|
InsertRoomTurnoverRewardSettlement(ctx context.Context, settlement domain.Settlement, nowMS int64) (domain.Settlement, bool, error)
|
||||||
|
ListPendingRoomTurnoverRewardSettlements(ctx context.Context, limit int) ([]domain.Settlement, error)
|
||||||
|
GetRoomTurnoverRewardSettlement(ctx context.Context, settlementID string) (domain.Settlement, bool, error)
|
||||||
|
ResetRoomTurnoverRewardSettlementForRetry(ctx context.Context, settlementID string, ownerUserID int64, nowMS int64) (domain.Settlement, error)
|
||||||
|
MarkRoomTurnoverRewardSettlementGranted(ctx context.Context, settlementID string, walletTransactionID string, grantedAtMS int64) (domain.Settlement, error)
|
||||||
|
MarkRoomTurnoverRewardSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletClient 是房间流水奖励唯一外部发币副作用。
|
||||||
|
type WalletClient interface {
|
||||||
|
CreditRoomTurnoverReward(ctx context.Context, req *walletv1.CreditRoomTurnoverRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomQueryClient 只读取 room-service 的房主快照,不拥有房间状态。
|
||||||
|
type RoomQueryClient interface {
|
||||||
|
AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service 承载房间流水奖励 App 查询、后台配置、事件聚合和每周结算。
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
wallet WalletClient
|
||||||
|
room RoomQueryClient
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, wallet WalletClient, room RoomQueryClient) *Service {
|
||||||
|
return &Service{repository: repository, wallet: wallet, room: room, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetClock(now func() time.Time) {
|
||||||
|
if now != nil {
|
||||||
|
s.now = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return domain.Config{AppCode: appcode.FromContext(ctx)}, nil
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
config.AppCode = appcode.FromContext(ctx)
|
||||||
|
config.Tiers = normalizeTiers(config.Tiers)
|
||||||
|
if err := validateConfig(config); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
return s.repository.UpdateRoomTurnoverRewardConfig(ctx, config, s.now().UTC().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetStatus(ctx context.Context, userID int64, roomID string, ownerUserID int64) (domain.StatusResult, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.StatusResult{}, err
|
||||||
|
}
|
||||||
|
if userID <= 0 {
|
||||||
|
return domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||||
|
}
|
||||||
|
// App 展示和每周结算必须使用同一套 UTC 周窗口;这里用 service clock 统一可测试时间源,避免 H5、本地测试和 cron 看到不同周期。
|
||||||
|
now := s.now().UTC()
|
||||||
|
start, end := WeekWindow(now)
|
||||||
|
result := domain.StatusResult{
|
||||||
|
RoomID: strings.TrimSpace(roomID),
|
||||||
|
OwnerUserID: ownerUserID,
|
||||||
|
PeriodStartMS: start.UnixMilli(),
|
||||||
|
PeriodEndMS: end.UnixMilli(),
|
||||||
|
ServerTimeMS: now.UnixMilli(),
|
||||||
|
}
|
||||||
|
config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StatusResult{}, err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
config.Tiers = sortTiersForDisplay(config.Tiers)
|
||||||
|
result.Config = config
|
||||||
|
} else {
|
||||||
|
// 未配置不是异常:后台首次进入或活动未上线时,H5 仍需要周期时间和空档位来渲染默认态。
|
||||||
|
result.Config = domain.Config{AppCode: appcode.FromContext(ctx)}
|
||||||
|
}
|
||||||
|
if result.RoomID == "" {
|
||||||
|
// 当前用户没有自己的房间时不能查询流水聚合;直接返回配置和周期,让 H5 显示无房间/未参与状态。
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
progress, found, err := s.repository.GetRoomTurnoverRewardProgress(ctx, result.RoomID, result.PeriodStartMS)
|
||||||
|
if err != nil {
|
||||||
|
return domain.StatusResult{}, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
result.CurrentCoinSpent = progress.CoinSpent
|
||||||
|
}
|
||||||
|
if tier, matched := MatchHighestTier(activeTiers(result.Config.Tiers), result.CurrentCoinSpent); matched {
|
||||||
|
result.MatchedTier = tier
|
||||||
|
result.ExpectedRewardCoinAmount = tier.RewardCoinAmount
|
||||||
|
}
|
||||||
|
if settlement, found, err := s.repository.GetLatestRoomTurnoverRewardSettlement(ctx, result.RoomID); err != nil {
|
||||||
|
return domain.StatusResult{}, err
|
||||||
|
} else if found {
|
||||||
|
// 最近结算不限定当前周期;周切换后当前流水会清零,但 H5 仍要展示上一周期是否已经发奖或失败。
|
||||||
|
result.LatestSettlement = settlement
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRoomEvent 把 room-service 已提交送礼事实聚合到对应 UTC 周期房间流水。
|
||||||
|
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (domain.EventResult, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
if envelope == nil {
|
||||||
|
return domain.EventResult{}, xerr.New(xerr.InvalidArgument, "room event envelope is required")
|
||||||
|
}
|
||||||
|
if envelope.GetEventType() != "RoomGiftSent" {
|
||||||
|
// activity-service 订阅的是 room outbox 大类事件;非送礼事实不参与流水奖励,直接标记 skipped,避免无意义重试。
|
||||||
|
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
|
||||||
|
}
|
||||||
|
var gift roomeventsv1.RoomGiftSent
|
||||||
|
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(envelope.GetRoomId()) == "" || gift.GetCoinSpent() <= 0 {
|
||||||
|
// 缺房间或 0 金币不会形成有效房间贡献;这类事件本身没有可补偿数据,跳过比失败重放更安全。
|
||||||
|
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
|
||||||
|
}
|
||||||
|
occurred := time.UnixMilli(envelope.GetOccurredAtMs()).UTC()
|
||||||
|
if envelope.GetOccurredAtMs() <= 0 {
|
||||||
|
// 老事件缺 occurred_at 时使用消费时间兜底,只影响本地兼容,正常 outbox 事件仍以事实发生时间归属周期。
|
||||||
|
occurred = s.now().UTC()
|
||||||
|
}
|
||||||
|
start, end := WeekWindow(occurred)
|
||||||
|
// AppCode 以事件信封为准,防止消费者默认上下文把不同 App 的房间流水写进同一组配置和聚合表。
|
||||||
|
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
||||||
|
return s.repository.ConsumeRoomTurnoverGiftEvent(eventCtx, domain.RoomGiftEvent{
|
||||||
|
EventID: envelope.GetEventId(),
|
||||||
|
EventType: envelope.GetEventType(),
|
||||||
|
AppCode: envelope.GetAppCode(),
|
||||||
|
RoomID: strings.TrimSpace(envelope.GetRoomId()),
|
||||||
|
CoinSpent: gift.GetCoinSpent(),
|
||||||
|
OccurredAtMS: occurred.UnixMilli(),
|
||||||
|
}, start.UnixMilli(), end.UnixMilli(), s.now().UTC().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
query.Status = strings.TrimSpace(query.Status)
|
||||||
|
query.RoomID = strings.TrimSpace(query.RoomID)
|
||||||
|
if query.Page <= 0 {
|
||||||
|
query.Page = 1
|
||||||
|
}
|
||||||
|
if query.PageSize <= 0 {
|
||||||
|
query.PageSize = 20
|
||||||
|
}
|
||||||
|
if query.PageSize > 100 {
|
||||||
|
query.PageSize = 100
|
||||||
|
}
|
||||||
|
return s.repository.ListRoomTurnoverRewardSettlements(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessSettlementBatch 先为上一 UTC 周期补齐 settlement,再处理 pending 发币。
|
||||||
|
func (s *Service) ProcessSettlementBatch(ctx context.Context, batchSize int) (domain.BatchResult, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.BatchResult{}, err
|
||||||
|
}
|
||||||
|
if batchSize <= 0 {
|
||||||
|
batchSize = 100
|
||||||
|
}
|
||||||
|
config, exists, err := s.repository.GetRoomTurnoverRewardConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.BatchResult{}, err
|
||||||
|
}
|
||||||
|
if !exists || !config.Enabled {
|
||||||
|
// 活动未配置或关闭时不创建 settlement,也不触碰历史 pending;运营重新开启后再由 cron 正常补偿。
|
||||||
|
return domain.BatchResult{}, nil
|
||||||
|
}
|
||||||
|
// 只结算上一个完整 UTC 周期;当前周仍在持续接收 RoomGiftSent,不能提前冻结或清空。
|
||||||
|
periodStart, periodEnd := PreviousWeekWindow(s.now().UTC())
|
||||||
|
result := domain.BatchResult{}
|
||||||
|
progresses, err := s.repository.ListUnsettledRoomTurnoverRewardProgress(ctx, periodStart.UnixMilli(), batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return domain.BatchResult{}, err
|
||||||
|
}
|
||||||
|
nowMS := s.now().UTC().UnixMilli()
|
||||||
|
for _, progress := range progresses {
|
||||||
|
// settlement 先固化当周流水和命中档位快照,后续运营改配置不会改变已经进入结算队列的周期结果。
|
||||||
|
settlement := s.settlementFromProgress(ctx, progress, periodEnd.UnixMilli(), activeTiers(config.Tiers), nowMS)
|
||||||
|
if settlement.TierID <= 0 {
|
||||||
|
// 有流水但未达档位也要落失败记录,后台可以解释为什么该房间本周期没有发奖。
|
||||||
|
settlement.Status = domain.SettlementStatusFailed
|
||||||
|
settlement.FailureReason = "tier_not_matched"
|
||||||
|
}
|
||||||
|
if settlement.TierID > 0 && settlement.OwnerUserID <= 0 {
|
||||||
|
// 房主归属以结算时 room-service 快照为准;activity-service 不复制房间 owner 状态,只在需要发奖时同步读取。
|
||||||
|
ownerID, ownerErr := s.fetchRoomOwner(ctx, progress.RoomID)
|
||||||
|
if ownerErr != nil || ownerID <= 0 {
|
||||||
|
settlement.Status = domain.SettlementStatusFailed
|
||||||
|
settlement.FailureReason = "room_owner_not_found"
|
||||||
|
} else {
|
||||||
|
settlement.OwnerUserID = ownerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, created, err := s.repository.InsertRoomTurnoverRewardSettlement(ctx, settlement, nowMS); err != nil {
|
||||||
|
return result, err
|
||||||
|
} else if created {
|
||||||
|
// Insert 使用 room_id + period_start_ms 唯一键兜底;重复 cron 只会读回旧记录,不会重复认领同一周期。
|
||||||
|
result.ClaimedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining := batchSize - int(result.ClaimedCount)
|
||||||
|
if remaining <= 0 {
|
||||||
|
remaining = batchSize
|
||||||
|
}
|
||||||
|
pending, err := s.repository.ListPendingRoomTurnoverRewardSettlements(ctx, remaining)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
for _, settlement := range pending {
|
||||||
|
result.ProcessedCount++
|
||||||
|
if _, err := s.grantSettlement(ctx, settlement); err != nil {
|
||||||
|
// grantSettlement 已把失败原因写回 settlement;batch 继续处理后续房间,避免单房间阻塞整周结算。
|
||||||
|
result.FailureCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.SuccessCount++
|
||||||
|
}
|
||||||
|
result.HasMore = len(progresses) >= batchSize || len(pending) >= remaining
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RetrySettlement(ctx context.Context, settlementID string) (domain.Settlement, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
settlementID = strings.TrimSpace(settlementID)
|
||||||
|
if settlementID == "" {
|
||||||
|
return domain.Settlement{}, xerr.New(xerr.InvalidArgument, "settlement_id is required")
|
||||||
|
}
|
||||||
|
settlement, exists, err := s.repository.GetRoomTurnoverRewardSettlement(ctx, settlementID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return domain.Settlement{}, xerr.New(xerr.NotFound, "room turnover reward settlement not found")
|
||||||
|
}
|
||||||
|
if settlement.Status == domain.SettlementStatusGranted {
|
||||||
|
// 已发奖记录直接返回,后台重复点击 retry 不会再次调用钱包。
|
||||||
|
return settlement, nil
|
||||||
|
}
|
||||||
|
if settlement.TierID <= 0 {
|
||||||
|
// tier_not_matched 不是外部依赖失败,而是该房间当周没有达到任何档位;retry 不能把这类 0 档位记录重置成 granted。
|
||||||
|
return settlement, xerr.New(xerr.InvalidArgument, "room turnover reward settlement is not grantable")
|
||||||
|
}
|
||||||
|
ownerID := settlement.OwnerUserID
|
||||||
|
if ownerID <= 0 {
|
||||||
|
// 失败记录可能是当时没读到房主;retry 时重新取 room-service 快照,允许房间修复后继续发奖。
|
||||||
|
ownerID, err = s.fetchRoomOwner(ctx, settlement.RoomID)
|
||||||
|
if err != nil || ownerID <= 0 {
|
||||||
|
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, "room_owner_not_found", s.now().UTC().UnixMilli())
|
||||||
|
return domain.Settlement{}, xerr.New(xerr.NotFound, "room owner not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settlement, err = s.repository.ResetRoomTurnoverRewardSettlementForRetry(ctx, settlement.SettlementID, ownerID, s.now().UTC().UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
return s.grantSettlement(ctx, settlement)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) settlementFromProgress(ctx context.Context, progress domain.Progress, periodEndMS int64, tiers []domain.Tier, nowMS int64) domain.Settlement {
|
||||||
|
tier, matched := MatchHighestTier(tiers, progress.CoinSpent)
|
||||||
|
settlement := domain.Settlement{
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
RoomID: progress.RoomID,
|
||||||
|
PeriodStartMS: progress.PeriodStartMS,
|
||||||
|
PeriodEndMS: periodEndMS,
|
||||||
|
CoinSpent: progress.CoinSpent,
|
||||||
|
Status: domain.SettlementStatusPending,
|
||||||
|
CreatedAtMS: nowMS,
|
||||||
|
UpdatedAtMS: nowMS,
|
||||||
|
}
|
||||||
|
if matched {
|
||||||
|
// 结算记录保存档位快照;后续配置变更不反推历史奖励金额。
|
||||||
|
settlement.TierID = tier.TierID
|
||||||
|
settlement.TierCode = tier.TierCode
|
||||||
|
settlement.ThresholdCoinSpent = tier.ThresholdCoinSpent
|
||||||
|
settlement.RewardCoinAmount = tier.RewardCoinAmount
|
||||||
|
}
|
||||||
|
return settlement
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) grantSettlement(ctx context.Context, settlement domain.Settlement) (domain.Settlement, error) {
|
||||||
|
if settlement.Status == domain.SettlementStatusGranted {
|
||||||
|
// 发奖入口保持幂等,避免 cron 和人工 retry 同时进来时重复入账。
|
||||||
|
return settlement, nil
|
||||||
|
}
|
||||||
|
if settlement.OwnerUserID <= 0 || settlement.RewardCoinAmount < 0 || settlement.WalletCommandID == "" {
|
||||||
|
err := xerr.New(xerr.InvalidArgument, "room turnover reward settlement is not grantable")
|
||||||
|
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
if settlement.RewardCoinAmount == 0 {
|
||||||
|
// 0 金币档位是合法配置,用于运营只记录达标但不发币的周期;钱包不接受 0 金额命令,所以这里直接收敛结算状态。
|
||||||
|
nowMS := s.now().UTC().UnixMilli()
|
||||||
|
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, "", nowMS)
|
||||||
|
}
|
||||||
|
if s.wallet == nil {
|
||||||
|
err := xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||||||
|
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
// 钱包 command_id 使用 app + 周期 + room_id,保证同一房间同一周期无论 cron 重跑还是 retry,都只产生一笔账务交易。
|
||||||
|
resp, err := s.wallet.CreditRoomTurnoverReward(ctx, &walletv1.CreditRoomTurnoverRewardRequest{
|
||||||
|
CommandId: settlement.WalletCommandID,
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
TargetUserId: settlement.OwnerUserID,
|
||||||
|
Amount: settlement.RewardCoinAmount,
|
||||||
|
SettlementId: settlement.SettlementID,
|
||||||
|
RoomId: settlement.RoomID,
|
||||||
|
PeriodStartMs: settlement.PeriodStartMS,
|
||||||
|
PeriodEndMs: settlement.PeriodEndMS,
|
||||||
|
CoinSpent: settlement.CoinSpent,
|
||||||
|
TierId: settlement.TierID,
|
||||||
|
TierCode: settlement.TierCode,
|
||||||
|
Reason: rewardReason,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.MarkRoomTurnoverRewardSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
return s.repository.MarkRoomTurnoverRewardSettlementGranted(ctx, settlement.SettlementID, resp.GetTransactionId(), resp.GetGrantedAtMs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) {
|
||||||
|
if s.room == nil {
|
||||||
|
return 0, xerr.New(xerr.Unavailable, "room query client is not configured")
|
||||||
|
}
|
||||||
|
roomID = strings.TrimSpace(roomID)
|
||||||
|
if roomID == "" {
|
||||||
|
return 0, xerr.New(xerr.InvalidArgument, "room_id is required")
|
||||||
|
}
|
||||||
|
resp, err := s.room.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{
|
||||||
|
// 这里不是用户请求链路,使用可读的内部 request_id 方便按房间追踪结算时的 owner 查询。
|
||||||
|
RequestId: "room-turnover-reward-owner:" + roomID,
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
SentAtMs: s.now().UTC().UnixMilli(),
|
||||||
|
},
|
||||||
|
RoomId: roomID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return resp.GetRoom().GetOwnerUserId(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requireRepository() error {
|
||||||
|
if s == nil || s.repository == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "room turnover reward repository is not configured")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTiers(tiers []domain.Tier) []domain.Tier {
|
||||||
|
items := make([]domain.Tier, 0, len(tiers))
|
||||||
|
for _, tier := range tiers {
|
||||||
|
status := strings.ToLower(strings.TrimSpace(tier.Status))
|
||||||
|
if status == "" {
|
||||||
|
// 后台表单可以省略 status;默认 active 保持配置体验简单,但 validate 仍会拦截非法状态。
|
||||||
|
status = domain.TierStatusActive
|
||||||
|
}
|
||||||
|
items = append(items, domain.Tier{
|
||||||
|
TierID: tier.TierID,
|
||||||
|
TierCode: strings.TrimSpace(tier.TierCode),
|
||||||
|
TierName: strings.TrimSpace(tier.TierName),
|
||||||
|
ThresholdCoinSpent: tier.ThresholdCoinSpent,
|
||||||
|
RewardCoinAmount: tier.RewardCoinAmount,
|
||||||
|
Status: status,
|
||||||
|
SortOrder: tier.SortOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sortTiersForDisplay(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortTiersForDisplay(tiers []domain.Tier) []domain.Tier {
|
||||||
|
items := append([]domain.Tier(nil), tiers...)
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].SortOrder == items[j].SortOrder {
|
||||||
|
return items[i].ThresholdCoinSpent < items[j].ThresholdCoinSpent
|
||||||
|
}
|
||||||
|
return items[i].SortOrder < items[j].SortOrder
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfig(config domain.Config) error {
|
||||||
|
if config.UpdatedByAdminID <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||||||
|
}
|
||||||
|
// tier_code 是后台配置稳定标识,阈值和奖励可以修改,但同一 App 内不能出现两个相同编码。
|
||||||
|
seen := make(map[string]struct{}, len(config.Tiers))
|
||||||
|
activeCount := 0
|
||||||
|
for _, tier := range config.Tiers {
|
||||||
|
if tier.TierCode == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "tier_code is required")
|
||||||
|
}
|
||||||
|
if _, exists := seen[tier.TierCode]; exists {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "tier_code is duplicated")
|
||||||
|
}
|
||||||
|
seen[tier.TierCode] = struct{}{}
|
||||||
|
if tier.TierName == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "tier_name is required")
|
||||||
|
}
|
||||||
|
if tier.ThresholdCoinSpent <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "threshold_coin_spent must be greater than zero")
|
||||||
|
}
|
||||||
|
if tier.RewardCoinAmount < 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "reward_coin_amount must not be negative")
|
||||||
|
}
|
||||||
|
switch tier.Status {
|
||||||
|
case domain.TierStatusActive:
|
||||||
|
activeCount++
|
||||||
|
case domain.TierStatusInactive:
|
||||||
|
default:
|
||||||
|
return xerr.New(xerr.InvalidArgument, "tier status is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Enabled && activeCount == 0 {
|
||||||
|
// 开启活动但没有有效档位会让 H5 显示可参与却永远无法发奖,直接在配置入口拒绝。
|
||||||
|
return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeTiers(tiers []domain.Tier) []domain.Tier {
|
||||||
|
out := make([]domain.Tier, 0, len(tiers))
|
||||||
|
for _, tier := range tiers {
|
||||||
|
if tier.Status == domain.TierStatusActive {
|
||||||
|
out = append(out, tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchHighestTier 返回达到流水阈值的最高有效档。
|
||||||
|
func MatchHighestTier(tiers []domain.Tier, coinSpent int64) (domain.Tier, bool) {
|
||||||
|
var matched domain.Tier
|
||||||
|
found := false
|
||||||
|
for _, tier := range tiers {
|
||||||
|
if tier.Status != domain.TierStatusActive || tier.ThresholdCoinSpent <= 0 || tier.RewardCoinAmount < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if coinSpent >= tier.ThresholdCoinSpent && (!found || tier.ThresholdCoinSpent > matched.ThresholdCoinSpent) {
|
||||||
|
matched = tier
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeekWindow 返回给定时间所在 UTC 周一 00:00 到下一周一 00:00 的左闭右开窗口。
|
||||||
|
func WeekWindow(now time.Time) (time.Time, time.Time) {
|
||||||
|
utc := now.UTC()
|
||||||
|
dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
weekday := int(dayStart.Weekday())
|
||||||
|
if weekday == 0 {
|
||||||
|
weekday = 7
|
||||||
|
}
|
||||||
|
start := dayStart.AddDate(0, 0, 1-weekday)
|
||||||
|
return start, start.AddDate(0, 0, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviousWeekWindow 返回当前时间之前的完整 UTC 周期。
|
||||||
|
func PreviousWeekWindow(now time.Time) (time.Time, time.Time) {
|
||||||
|
start, _ := WeekWindow(now)
|
||||||
|
previousStart := start.AddDate(0, 0, -7)
|
||||||
|
return previousStart, start
|
||||||
|
}
|
||||||
@ -0,0 +1,219 @@
|
|||||||
|
package roomturnoverreward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWeekWindowUsesUTCMondayBoundary(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
now string
|
||||||
|
wantStart string
|
||||||
|
wantEnd string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "monday zero belongs to new week",
|
||||||
|
now: "2026-06-01T00:00:00Z",
|
||||||
|
wantStart: "2026-06-01T00:00:00Z",
|
||||||
|
wantEnd: "2026-06-08T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sunday belongs to current week",
|
||||||
|
now: "2026-06-07T23:59:59Z",
|
||||||
|
wantStart: "2026-06-01T00:00:00Z",
|
||||||
|
wantEnd: "2026-06-08T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cross month keeps utc week",
|
||||||
|
now: "2026-07-01T12:30:00Z",
|
||||||
|
wantStart: "2026-06-29T00:00:00Z",
|
||||||
|
wantEnd: "2026-07-06T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cross year keeps utc week",
|
||||||
|
now: "2027-01-01T08:00:00Z",
|
||||||
|
wantStart: "2026-12-28T00:00:00Z",
|
||||||
|
wantEnd: "2027-01-04T00:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
now := mustParseTime(t, tc.now)
|
||||||
|
start, end := WeekWindow(now)
|
||||||
|
if got := start.Format(time.RFC3339); got != tc.wantStart {
|
||||||
|
t.Fatalf("unexpected start: got %s want %s", got, tc.wantStart)
|
||||||
|
}
|
||||||
|
if got := end.Format(time.RFC3339); got != tc.wantEnd {
|
||||||
|
t.Fatalf("unexpected end: got %s want %s", got, tc.wantEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviousWeekWindowReturnsCompletedUTCWeek(t *testing.T) {
|
||||||
|
start, end := PreviousWeekWindow(mustParseTime(t, "2026-06-08T00:00:00Z"))
|
||||||
|
if got := start.Format(time.RFC3339); got != "2026-06-01T00:00:00Z" {
|
||||||
|
t.Fatalf("unexpected previous start: %s", got)
|
||||||
|
}
|
||||||
|
if got := end.Format(time.RFC3339); got != "2026-06-08T00:00:00Z" {
|
||||||
|
t.Fatalf("unexpected previous end: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchHighestTierReturnsHighestThreshold(t *testing.T) {
|
||||||
|
tiers := []domain.Tier{
|
||||||
|
{TierID: 1, TierCode: "bronze", ThresholdCoinSpent: 100, RewardCoinAmount: 10, Status: domain.TierStatusActive},
|
||||||
|
{TierID: 2, TierCode: "silver", ThresholdCoinSpent: 500, RewardCoinAmount: 60, Status: domain.TierStatusActive},
|
||||||
|
{TierID: 3, TierCode: "gold", ThresholdCoinSpent: 1000, RewardCoinAmount: 160, Status: domain.TierStatusActive},
|
||||||
|
{TierID: 4, TierCode: "hidden", ThresholdCoinSpent: 700, RewardCoinAmount: 90, Status: domain.TierStatusInactive},
|
||||||
|
}
|
||||||
|
|
||||||
|
tier, ok := MatchHighestTier(tiers, 850)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a matched tier")
|
||||||
|
}
|
||||||
|
if tier.TierCode != "silver" {
|
||||||
|
t.Fatalf("unexpected tier: got %s want silver", tier.TierCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetrySettlementRejectsTierNotMatchedWithoutGrantSideEffects(t *testing.T) {
|
||||||
|
repo := &retryBoundaryRepository{
|
||||||
|
settlement: domain.Settlement{
|
||||||
|
SettlementID: "settlement-tier-not-matched",
|
||||||
|
RoomID: "room-1",
|
||||||
|
Status: domain.SettlementStatusFailed,
|
||||||
|
FailureReason: "tier_not_matched",
|
||||||
|
WalletCommandID: "room_turnover_reward:lalu:1:room-1",
|
||||||
|
RewardCoinAmount: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
room := &retryBoundaryRoom{ownerID: 10001}
|
||||||
|
wallet := &retryBoundaryWallet{}
|
||||||
|
svc := New(repo, wallet, room)
|
||||||
|
|
||||||
|
got, err := svc.RetrySettlement(context.Background(), repo.settlement.SettlementID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected retry to reject tier_not_matched settlement")
|
||||||
|
}
|
||||||
|
if got.Status != domain.SettlementStatusFailed || got.FailureReason != "tier_not_matched" {
|
||||||
|
t.Fatalf("retry should return original failed settlement, got %+v", got)
|
||||||
|
}
|
||||||
|
if repo.resetCalls != 0 || repo.grantCalls != 0 || repo.failCalls != 0 {
|
||||||
|
t.Fatalf("tier_not_matched retry must not mutate settlement: reset=%d grant=%d fail=%d", repo.resetCalls, repo.grantCalls, repo.failCalls)
|
||||||
|
}
|
||||||
|
if room.calls != 0 {
|
||||||
|
t.Fatalf("tier_not_matched retry must not fetch room owner, calls=%d", room.calls)
|
||||||
|
}
|
||||||
|
if wallet.calls != 0 {
|
||||||
|
t.Fatalf("tier_not_matched retry must not call wallet, calls=%d", wallet.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseTime(t *testing.T, value string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse time %q: %v", value, err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
type retryBoundaryRepository struct {
|
||||||
|
settlement domain.Settlement
|
||||||
|
resetCalls int
|
||||||
|
grantCalls int
|
||||||
|
failCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) GetRoomTurnoverRewardConfig(context.Context) (domain.Config, bool, error) {
|
||||||
|
panic("unexpected GetRoomTurnoverRewardConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) UpdateRoomTurnoverRewardConfig(context.Context, domain.Config, int64) (domain.Config, error) {
|
||||||
|
panic("unexpected UpdateRoomTurnoverRewardConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) ConsumeRoomTurnoverGiftEvent(context.Context, domain.RoomGiftEvent, int64, int64, int64) (domain.EventResult, error) {
|
||||||
|
panic("unexpected ConsumeRoomTurnoverGiftEvent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) GetRoomTurnoverRewardProgress(context.Context, string, int64) (domain.Progress, bool, error) {
|
||||||
|
panic("unexpected GetRoomTurnoverRewardProgress")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) GetLatestRoomTurnoverRewardSettlement(context.Context, string) (domain.Settlement, bool, error) {
|
||||||
|
panic("unexpected GetLatestRoomTurnoverRewardSettlement")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) ListRoomTurnoverRewardSettlements(context.Context, domain.SettlementQuery) ([]domain.Settlement, int64, error) {
|
||||||
|
panic("unexpected ListRoomTurnoverRewardSettlements")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) ListUnsettledRoomTurnoverRewardProgress(context.Context, int64, int) ([]domain.Progress, error) {
|
||||||
|
panic("unexpected ListUnsettledRoomTurnoverRewardProgress")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) InsertRoomTurnoverRewardSettlement(context.Context, domain.Settlement, int64) (domain.Settlement, bool, error) {
|
||||||
|
panic("unexpected InsertRoomTurnoverRewardSettlement")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) ListPendingRoomTurnoverRewardSettlements(context.Context, int) ([]domain.Settlement, error) {
|
||||||
|
panic("unexpected ListPendingRoomTurnoverRewardSettlements")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) GetRoomTurnoverRewardSettlement(_ context.Context, settlementID string) (domain.Settlement, bool, error) {
|
||||||
|
if settlementID != r.settlement.SettlementID {
|
||||||
|
return domain.Settlement{}, false, nil
|
||||||
|
}
|
||||||
|
return r.settlement, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) ResetRoomTurnoverRewardSettlementForRetry(_ context.Context, _ string, ownerUserID int64, _ int64) (domain.Settlement, error) {
|
||||||
|
r.resetCalls++
|
||||||
|
r.settlement.Status = domain.SettlementStatusPending
|
||||||
|
r.settlement.OwnerUserID = ownerUserID
|
||||||
|
r.settlement.FailureReason = ""
|
||||||
|
return r.settlement, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) MarkRoomTurnoverRewardSettlementGranted(_ context.Context, _ string, walletTransactionID string, _ int64) (domain.Settlement, error) {
|
||||||
|
r.grantCalls++
|
||||||
|
r.settlement.Status = domain.SettlementStatusGranted
|
||||||
|
r.settlement.WalletTransactionID = walletTransactionID
|
||||||
|
return r.settlement, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRepository) MarkRoomTurnoverRewardSettlementFailed(_ context.Context, _ string, reason string, _ int64) error {
|
||||||
|
r.failCalls++
|
||||||
|
r.settlement.Status = domain.SettlementStatusFailed
|
||||||
|
r.settlement.FailureReason = reason
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type retryBoundaryRoom struct {
|
||||||
|
ownerID int64
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *retryBoundaryRoom) AdminGetRoom(context.Context, *roomv1.AdminGetRoomRequest, ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error) {
|
||||||
|
r.calls++
|
||||||
|
return &roomv1.AdminGetRoomResponse{Room: &roomv1.AdminRoomListItem{OwnerUserId: r.ownerID}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type retryBoundaryWallet struct {
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *retryBoundaryWallet) CreditRoomTurnoverReward(context.Context, *walletv1.CreditRoomTurnoverRewardRequest, ...grpc.CallOption) (*walletv1.CreditRoomTurnoverRewardResponse, error) {
|
||||||
|
w.calls++
|
||||||
|
return &walletv1.CreditRoomTurnoverRewardResponse{TransactionId: "wallet-tx-1", GrantedAtMs: 1234}, nil
|
||||||
|
}
|
||||||
359
services/activity-service/internal/service/weeklystar/service.go
Normal file
359
services/activity-service/internal/service/weeklystar/service.go
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
package weeklystar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/logx"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/weeklystar"
|
||||||
|
)
|
||||||
|
|
||||||
|
const weeklyStarRewardReason = "weekly star reward"
|
||||||
|
|
||||||
|
// Repository is the storage boundary for weekly-star configuration, score and settlement facts.
|
||||||
|
type Repository interface {
|
||||||
|
ListWeeklyStarCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error)
|
||||||
|
GetWeeklyStarCycle(ctx context.Context, cycleID string) (domain.Cycle, error)
|
||||||
|
UpsertWeeklyStarCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error)
|
||||||
|
SetWeeklyStarCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error)
|
||||||
|
FindCurrentWeeklyStarCycle(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error)
|
||||||
|
ConsumeWeeklyStarGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error)
|
||||||
|
ListWeeklyStarLeaderboard(ctx context.Context, cycleID string, regionID int64, pageSize int32, pageToken string, nowMS int64) (domain.Cycle, []domain.LeaderboardEntry, string, int64, error)
|
||||||
|
GetWeeklyStarUserEntry(ctx context.Context, cycleID string, userID int64) (domain.LeaderboardEntry, bool, error)
|
||||||
|
ListWeeklyStarHistory(ctx context.Context, regionID int64, limit int32, nowMS int64) ([]domain.HistoryCycle, error)
|
||||||
|
ListWeeklyStarSettlements(ctx context.Context, cycleID string) ([]domain.Settlement, error)
|
||||||
|
ClaimDueWeeklyStarCycles(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.Cycle, error)
|
||||||
|
PrepareWeeklyStarSettlements(ctx context.Context, cycleID string, nowMS int64) ([]domain.Settlement, error)
|
||||||
|
MarkWeeklyStarSettlementGranted(ctx context.Context, settlementID string, walletGrantID string, nowMS int64) error
|
||||||
|
MarkWeeklyStarSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error
|
||||||
|
FinishWeeklyStarCycleIfComplete(ctx context.Context, cycleID string, nowMS int64) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalletClient is the small wallet-service surface needed by weekly-star settlement.
|
||||||
|
type WalletClient interface {
|
||||||
|
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service owns weekly-star business rules; transports and cron only adapt inputs into this API.
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
wallet WalletClient
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates the weekly-star service with activity-service storage as the source of truth.
|
||||||
|
func New(repository Repository, wallet WalletClient) *Service {
|
||||||
|
return &Service{
|
||||||
|
repository: repository,
|
||||||
|
wallet: wallet,
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCycles returns configured cycles for admin pages without leaking storage paging rules to transport.
|
||||||
|
func (s *Service) ListCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
query.Status = normalizeStatus(query.Status)
|
||||||
|
return s.repository.ListWeeklyStarCycles(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCycle returns a single admin cycle, including the three gifts and Top rewards.
|
||||||
|
func (s *Service) GetCycle(ctx context.Context, cycleID string) (domain.Cycle, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
cycleID = strings.TrimSpace(cycleID)
|
||||||
|
if cycleID == "" {
|
||||||
|
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "cycle_id is required")
|
||||||
|
}
|
||||||
|
return s.repository.GetWeeklyStarCycle(ctx, cycleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCycle validates the full weekly-star contract and stores a new UTC cycle.
|
||||||
|
func (s *Service) CreateCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
|
||||||
|
command.RequireNewRecord = true
|
||||||
|
return s.upsertCycle(ctx, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCycle replaces an existing weekly-star cycle after the same create-time validations pass.
|
||||||
|
func (s *Service) UpdateCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
|
||||||
|
command.RequireNewRecord = false
|
||||||
|
return s.upsertCycle(ctx, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCycleStatus switches admin-visible status; active cycles are the only ones matched by gift events.
|
||||||
|
func (s *Service) SetCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64) (domain.Cycle, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
status = normalizeStatus(status)
|
||||||
|
if !validStatus(status) || status == domain.StatusSettling {
|
||||||
|
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "status is invalid")
|
||||||
|
}
|
||||||
|
if operatorAdminID <= 0 {
|
||||||
|
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||||||
|
}
|
||||||
|
return s.repository.SetWeeklyStarCycleStatus(ctx, strings.TrimSpace(cycleID), status, operatorAdminID, s.now().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrent resolves the current cycle by region, using the storage-level concrete-region-first/default fallback.
|
||||||
|
func (s *Service) GetCurrent(ctx context.Context, userID int64, regionID int64) (domain.Cycle, []domain.LeaderboardEntry, domain.LeaderboardEntry, bool, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Cycle{}, nil, domain.LeaderboardEntry{}, false, err
|
||||||
|
}
|
||||||
|
cycle, ok, err := s.repository.FindCurrentWeeklyStarCycle(ctx, regionID, s.now().UnixMilli())
|
||||||
|
if err != nil || !ok {
|
||||||
|
return cycle, nil, domain.LeaderboardEntry{}, false, err
|
||||||
|
}
|
||||||
|
_, top, _, _, err := s.repository.ListWeeklyStarLeaderboard(ctx, cycle.CycleID, regionID, 50, "", s.now().UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
return domain.Cycle{}, nil, domain.LeaderboardEntry{}, false, err
|
||||||
|
}
|
||||||
|
myEntry, found, err := s.repository.GetWeeklyStarUserEntry(ctx, cycle.CycleID, userID)
|
||||||
|
return cycle, top, myEntry, found, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLeaderboard returns deterministic score order for either a concrete cycle or current region cycle.
|
||||||
|
func (s *Service) ListLeaderboard(ctx context.Context, cycleID string, regionID int64, pageSize int32, pageToken string) (domain.Cycle, []domain.LeaderboardEntry, string, int64, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Cycle{}, nil, "", 0, err
|
||||||
|
}
|
||||||
|
return s.repository.ListWeeklyStarLeaderboard(ctx, strings.TrimSpace(cycleID), regionID, pageSize, strings.TrimSpace(pageToken), s.now().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListHistory returns settled cycles for the user region with default-cycle fallback handled by storage.
|
||||||
|
func (s *Service) ListHistory(ctx context.Context, regionID int64, limit int32) ([]domain.HistoryCycle, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.repository.ListWeeklyStarHistory(ctx, regionID, limit, s.now().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSettlements returns admin-visible settlement grant facts for one cycle.
|
||||||
|
func (s *Service) ListSettlements(ctx context.Context, cycleID string) ([]domain.Settlement, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cycleID = strings.TrimSpace(cycleID)
|
||||||
|
if cycleID == "" {
|
||||||
|
return nil, xerr.New(xerr.InvalidArgument, "cycle_id is required")
|
||||||
|
}
|
||||||
|
return s.repository.ListWeeklyStarSettlements(ctx, cycleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRoomEvent consumes room-service committed gift facts; score always equals the actual coin_spent.
|
||||||
|
// 这里不读取 gift 配置价格,也不相信客户端传入积分,因为钱包扣费后的 room outbox 才是送礼成功事实。
|
||||||
|
// 非 RoomGiftSent、无用户、无礼物或无扣费金额的 envelope 直接跳过,避免让普通房间事件污染活动积分表。
|
||||||
|
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
|
||||||
|
if s == nil || s.repository == nil || envelope == nil || envelope.GetEventType() != "RoomGiftSent" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var gift roomeventsv1.RoomGiftSent
|
||||||
|
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if gift.GetSenderUserId() <= 0 || gift.GetGiftId() == "" || gift.GetCoinSpent() <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
// region_id 和 occurred_at_ms 都来自 room-service 的稳定事实;repository 会按事件发生时间匹配 [start_ms,end_ms) 周期。
|
||||||
|
// source_event_id 是幂等键,同一个 room outbox 重试时只会插入一次 score event,并且不会重复累加榜单分数。
|
||||||
|
event := domain.GiftEvent{
|
||||||
|
EventID: strings.TrimSpace(envelope.GetEventId()),
|
||||||
|
UserID: gift.GetSenderUserId(),
|
||||||
|
GiftID: strings.TrimSpace(gift.GetGiftId()),
|
||||||
|
ScoreDelta: gift.GetCoinSpent(),
|
||||||
|
RegionID: gift.GetVisibleRegionId(),
|
||||||
|
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||||
|
}
|
||||||
|
result, err := s.repository.ConsumeWeeklyStarGiftEvent(appcode.WithContext(ctx, envelope.GetAppCode()), event, s.now().UnixMilli())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if result.Status == domain.EventStatusConsumed {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessSettlementBatch locks due cycles, materializes Top1/Top2/Top3, and grants each resource group idempotently.
|
||||||
|
// 状态机是 active -> settling -> settled:先锁到期周期,再把榜单快照固化为 settlement,最后逐条调用 wallet 发资源组。
|
||||||
|
// wallet_command_id 使用 cycle/rank/user 组成,cron 重试同一 settlement 时只会命中 wallet 幂等,不会重复发奖励。
|
||||||
|
func (s *Service) ProcessSettlementBatch(ctx context.Context, runID string, workerID string, batchSize int32, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return 0, 0, 0, 0, false, err
|
||||||
|
}
|
||||||
|
if s.wallet == nil {
|
||||||
|
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||||||
|
}
|
||||||
|
if batchSize <= 0 {
|
||||||
|
batchSize = 50
|
||||||
|
}
|
||||||
|
if lockTTL <= 0 {
|
||||||
|
lockTTL = 30 * time.Second
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(workerID) == "" {
|
||||||
|
workerID = "activity-weekly-star"
|
||||||
|
}
|
||||||
|
cycles, err := s.repository.ClaimDueWeeklyStarCycles(ctx, workerID, s.now().UnixMilli(), lockTTL, batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, 0, false, err
|
||||||
|
}
|
||||||
|
for _, cycle := range cycles {
|
||||||
|
settlements, prepareErr := s.repository.PrepareWeeklyStarSettlements(ctx, cycle.CycleID, s.now().UnixMilli())
|
||||||
|
if prepareErr != nil {
|
||||||
|
logx.Error(ctx, "weekly_star_prepare_settlements_failed", prepareErr, slog.String("cycle_id", cycle.CycleID), slog.String("run_id", runID), slog.String("worker_id", workerID))
|
||||||
|
failure++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(settlements) == 0 {
|
||||||
|
// 没有人得分的周期不会生成 settlement;只要没有 pending 记录,就可以安全标记为 settled。
|
||||||
|
if _, finishErr := s.repository.FinishWeeklyStarCycleIfComplete(ctx, cycle.CycleID, s.now().UnixMilli()); finishErr != nil {
|
||||||
|
failure++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
success++
|
||||||
|
processed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, settlement := range settlements {
|
||||||
|
processed++
|
||||||
|
// activity-service 只决定获奖人和资源组,真实发放由 wallet-service 原子展开资源组并写 grant/账务流水。
|
||||||
|
// 如果 wallet 临时失败,settlement 会退回 failed 状态,下一轮 cron 继续用同一个 wallet_command_id 重试。
|
||||||
|
resp, grantErr := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||||||
|
CommandId: settlement.WalletCommandID,
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
TargetUserId: settlement.UserID,
|
||||||
|
GroupId: settlement.ResourceGroupID,
|
||||||
|
Reason: weeklyStarRewardReason,
|
||||||
|
OperatorUserId: settlement.UserID,
|
||||||
|
GrantSource: domain.GrantSourceWeeklyStar,
|
||||||
|
})
|
||||||
|
if grantErr != nil {
|
||||||
|
logx.Error(ctx, "weekly_star_grant_resource_group_failed", grantErr, slog.String("cycle_id", cycle.CycleID), slog.String("settlement_id", settlement.SettlementID), slog.String("wallet_command_id", settlement.WalletCommandID))
|
||||||
|
failure++
|
||||||
|
_ = s.repository.MarkWeeklyStarSettlementFailed(ctx, settlement.SettlementID, xerr.MessageOf(grantErr), s.now().UnixMilli())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
walletGrantID := ""
|
||||||
|
if resp.GetGrant() != nil {
|
||||||
|
walletGrantID = resp.GetGrant().GetGrantId()
|
||||||
|
}
|
||||||
|
if markErr := s.repository.MarkWeeklyStarSettlementGranted(ctx, settlement.SettlementID, walletGrantID, s.now().UnixMilli()); markErr != nil {
|
||||||
|
logx.Error(ctx, "weekly_star_mark_settlement_granted_failed", markErr, slog.String("cycle_id", cycle.CycleID), slog.String("settlement_id", settlement.SettlementID), slog.String("wallet_grant_id", walletGrantID))
|
||||||
|
failure++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
if _, finishErr := s.repository.FinishWeeklyStarCycleIfComplete(ctx, cycle.CycleID, s.now().UnixMilli()); finishErr != nil {
|
||||||
|
// Finish 会再次检查所有 settlement 是否 granted,防止部分奖励失败时把周期提前关掉。
|
||||||
|
failure++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return int32(len(cycles)), processed, success, failure, len(cycles) == int(batchSize), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) upsertCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
|
||||||
|
if err := s.requireRepository(); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
command.Cycle = normalizeCycle(command.Cycle)
|
||||||
|
command.OperatorAdminID = command.OperatorAdminID
|
||||||
|
if command.OperatorAdminID <= 0 {
|
||||||
|
return domain.Cycle{}, false, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||||||
|
}
|
||||||
|
if err := validateCycle(command.Cycle); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
return s.repository.UpsertWeeklyStarCycle(ctx, command, s.now().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requireRepository() error {
|
||||||
|
if s == nil || s.repository == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "weekly star repository is not configured")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCycle(cycle domain.Cycle) domain.Cycle {
|
||||||
|
cycle.ActivityCode = domain.ActivityCode
|
||||||
|
cycle.CycleID = strings.TrimSpace(cycle.CycleID)
|
||||||
|
cycle.Title = strings.TrimSpace(cycle.Title)
|
||||||
|
cycle.Status = normalizeStatus(cycle.Status)
|
||||||
|
if cycle.Status == "" {
|
||||||
|
cycle.Status = domain.StatusDraft
|
||||||
|
}
|
||||||
|
seenGifts := map[string]bool{}
|
||||||
|
gifts := make([]domain.Gift, 0, len(cycle.Gifts))
|
||||||
|
for _, gift := range cycle.Gifts {
|
||||||
|
gift.GiftID = strings.TrimSpace(gift.GiftID)
|
||||||
|
if gift.GiftID == "" || seenGifts[gift.GiftID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenGifts[gift.GiftID] = true
|
||||||
|
gifts = append(gifts, gift)
|
||||||
|
}
|
||||||
|
sort.SliceStable(gifts, func(i, j int) bool { return gifts[i].SortOrder < gifts[j].SortOrder })
|
||||||
|
for index := range gifts {
|
||||||
|
if gifts[index].SortOrder <= 0 {
|
||||||
|
gifts[index].SortOrder = int32(index + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cycle.Gifts = gifts
|
||||||
|
sort.SliceStable(cycle.Rewards, func(i, j int) bool { return cycle.Rewards[i].RankNo < cycle.Rewards[j].RankNo })
|
||||||
|
return cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCycle(cycle domain.Cycle) error {
|
||||||
|
if cycle.Title == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "title is required")
|
||||||
|
}
|
||||||
|
if cycle.RegionID < 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "region_id is invalid")
|
||||||
|
}
|
||||||
|
if cycle.StartMS <= 0 || cycle.EndMS <= cycle.StartMS {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "start_ms and end_ms are invalid")
|
||||||
|
}
|
||||||
|
if !validStatus(cycle.Status) || cycle.Status == domain.StatusSettling || cycle.Status == domain.StatusSettled {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "status is invalid")
|
||||||
|
}
|
||||||
|
if len(cycle.Gifts) != 3 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "weekly star requires exactly 3 gifts")
|
||||||
|
}
|
||||||
|
rankToGroup := map[int32]int64{}
|
||||||
|
for _, reward := range cycle.Rewards {
|
||||||
|
if reward.RankNo >= 1 && reward.RankNo <= 3 && reward.ResourceGroupID > 0 {
|
||||||
|
rankToGroup[reward.RankNo] = reward.ResourceGroupID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rank := int32(1); rank <= 3; rank++ {
|
||||||
|
if rankToGroup[rank] <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "top1/top2/top3 resource groups are required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStatus(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validStatus(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case domain.StatusDraft, domain.StatusActive, domain.StatusDisabled, domain.StatusSettling, domain.StatusSettled:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (r *Repository) ensureCumulativeRechargeRewardTables(ctx context.Context) error {
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_configs (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用累充奖励',
|
||||||
|
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最近更新管理员',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励配置'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_tiers (
|
||||||
|
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位自增 ID',
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
tier_code VARCHAR(64) NOT NULL COMMENT '档位编码',
|
||||||
|
tier_name VARCHAR(128) NOT NULL COMMENT '档位展示名称',
|
||||||
|
threshold_usd_minor BIGINT NOT NULL COMMENT '达标美元金额,美分',
|
||||||
|
resource_group_id BIGINT NOT NULL COMMENT '达标后发放资源组 ID',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT 'active/inactive',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示排序',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (tier_id),
|
||||||
|
UNIQUE KEY uk_cumulative_recharge_reward_tier_code (app_code, tier_code),
|
||||||
|
KEY idx_cumulative_recharge_reward_tier_sort (app_code, status, sort_order, threshold_usd_minor)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励档位'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_events (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
event_id VARCHAR(128) NOT NULL COMMENT 'wallet_outbox event_id',
|
||||||
|
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然周,如 2026-W23',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '充值用户',
|
||||||
|
transaction_id VARCHAR(128) NOT NULL COMMENT '钱包交易 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '钱包命令 ID',
|
||||||
|
recharge_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '充值来源',
|
||||||
|
recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次到账金币',
|
||||||
|
qualifying_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '本次计入累充的美元美分',
|
||||||
|
payload_json JSON NULL COMMENT '原 wallet payload',
|
||||||
|
occurred_at_ms BIGINT NOT NULL COMMENT '充值发生时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, event_id),
|
||||||
|
KEY idx_cumulative_recharge_reward_event_user_cycle (app_code, cycle_key, user_id, occurred_at_ms),
|
||||||
|
KEY idx_cumulative_recharge_reward_event_transaction (app_code, transaction_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励充值事件幂等表'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_progress (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然周,如 2026-W23',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
total_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '周期累计美元美分',
|
||||||
|
total_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '周期累计到账金币',
|
||||||
|
first_recharged_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '周期首笔充值时间',
|
||||||
|
last_recharged_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '周期最近充值时间',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_key, user_id),
|
||||||
|
KEY idx_cumulative_recharge_reward_progress_total (app_code, cycle_key, total_usd_minor)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励用户周期累计'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_grants (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
grant_id VARCHAR(128) NOT NULL COMMENT '累充奖励发放 ID',
|
||||||
|
cycle_key VARCHAR(16) NOT NULL COMMENT 'UTC 自然周,如 2026-W23',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
event_id VARCHAR(128) NOT NULL COMMENT '触发发放的 wallet event_id',
|
||||||
|
transaction_id VARCHAR(128) NOT NULL COMMENT '触发发放的钱包交易 ID',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '触发发放的钱包命令 ID',
|
||||||
|
tier_id BIGINT NOT NULL COMMENT '命中档位 ID',
|
||||||
|
tier_code VARCHAR(64) NOT NULL COMMENT '命中档位编码',
|
||||||
|
threshold_usd_minor BIGINT NOT NULL COMMENT '命中档位门槛,美分',
|
||||||
|
resource_group_id BIGINT NOT NULL COMMENT '发放资源组 ID',
|
||||||
|
reached_usd_minor BIGINT NOT NULL COMMENT '命中时周期累计金额,美分',
|
||||||
|
qualifying_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '触发事件计入金额,美分',
|
||||||
|
recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '触发事件到账金币',
|
||||||
|
recharge_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '触发事件充值来源',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/granted/failed',
|
||||||
|
wallet_command_id VARCHAR(128) NOT NULL COMMENT 'wallet 资源组发放命令',
|
||||||
|
wallet_grant_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'wallet 发放回执',
|
||||||
|
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||||
|
granted_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发放成功时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, grant_id),
|
||||||
|
UNIQUE KEY uk_cumulative_recharge_reward_user_tier (app_code, cycle_key, user_id, tier_id),
|
||||||
|
UNIQUE KEY uk_cumulative_recharge_reward_wallet_command (app_code, wallet_command_id),
|
||||||
|
KEY idx_cumulative_recharge_reward_grant_event (app_code, event_id, status),
|
||||||
|
KEY idx_cumulative_recharge_reward_grant_user (app_code, cycle_key, user_id, created_at_ms),
|
||||||
|
KEY idx_cumulative_recharge_reward_grant_status (app_code, status, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='累充奖励资源组发放记录'`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if _, err := r.db.ExecContext(ctx, statement); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,696 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/idgen"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/cumulativerecharge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCumulativeRechargeRewardConfig 读取累充奖励当前配置;未配置时后台可展示空草稿。
|
||||||
|
func (r *Repository) GetCumulativeRechargeRewardConfig(ctx context.Context) (domain.Config, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRowContext(ctx, cumulativeRechargeRewardConfigSelectSQL()+`
|
||||||
|
WHERE app_code = ?`,
|
||||||
|
appcode.FromContext(ctx),
|
||||||
|
)
|
||||||
|
config, err := scanCumulativeRechargeRewardConfig(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Config{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, false, err
|
||||||
|
}
|
||||||
|
tiers, err := r.listCumulativeRechargeRewardTiers(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, false, err
|
||||||
|
}
|
||||||
|
config.Tiers = tiers
|
||||||
|
return config, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCumulativeRechargeRewardConfig 原子替换配置档位;已创建的 grant 保留命中时的档位快照。
|
||||||
|
func (r *Repository) UpdateCumulativeRechargeRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO cumulative_recharge_reward_configs (
|
||||||
|
app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
enabled = VALUES(enabled),
|
||||||
|
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
appcode.FromContext(ctx), config.Enabled, config.UpdatedByAdminID, nowMS, nowMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
// 配置保存采用整组替换,避免后台删除/重排档位时保留脏行;历史 grant 已保存 tier 快照,不依赖当前档位表。
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
DELETE FROM cumulative_recharge_reward_tiers
|
||||||
|
WHERE app_code = ?`,
|
||||||
|
appcode.FromContext(ctx),
|
||||||
|
); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
for i := range config.Tiers {
|
||||||
|
tier := config.Tiers[i]
|
||||||
|
tier.CreatedAtMS = nowMS
|
||||||
|
tier.UpdatedAtMS = nowMS
|
||||||
|
if tier.TierID > 0 {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO cumulative_recharge_reward_tiers (
|
||||||
|
tier_id, app_code, tier_code, tier_name, threshold_usd_minor,
|
||||||
|
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
tier.TierID, appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.ThresholdUSDMinor,
|
||||||
|
tier.ResourceGroupID, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO cumulative_recharge_reward_tiers (
|
||||||
|
app_code, tier_code, tier_name, threshold_usd_minor,
|
||||||
|
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.ThresholdUSDMinor,
|
||||||
|
tier.ResourceGroupID, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
tierID, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
config.Tiers[i].TierID = tierID
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
updated, exists, err := r.GetCumulativeRechargeRewardConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return domain.Config{}, xerr.New(xerr.NotFound, "cumulative recharge reward config not found")
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCumulativeRechargeRewardProgress 读取用户当前周期累计;缺失代表本周还没有符合条件的充值。
|
||||||
|
func (r *Repository) GetCumulativeRechargeRewardProgress(ctx context.Context, cycleKey string, userID int64) (domain.Progress, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Progress{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRowContext(ctx, cumulativeRechargeRewardProgressSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND cycle_key = ? AND user_id = ?`,
|
||||||
|
appcode.FromContext(ctx), cycleKey, userID,
|
||||||
|
)
|
||||||
|
progress, err := scanCumulativeRechargeRewardProgress(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Progress{}, false, nil
|
||||||
|
}
|
||||||
|
return progress, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCumulativeRechargeRewardGrantsByUserCycle 返回 H5 当前周期需要展示的用户发放状态。
|
||||||
|
func (r *Repository) ListCumulativeRechargeRewardGrantsByUserCycle(ctx context.Context, cycleKey string, userID int64) ([]domain.Grant, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, cumulativeRechargeRewardGrantSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND cycle_key = ? AND user_id = ?
|
||||||
|
ORDER BY threshold_usd_minor ASC, created_at_ms ASC, grant_id ASC`,
|
||||||
|
appcode.FromContext(ctx), cycleKey, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
grants, _, err := scanCumulativeRechargeRewardGrants(rows, 0)
|
||||||
|
return grants, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareCumulativeRechargeRewardGrants 在 activity 事务内完成 event 幂等、周期累计和待发放 grant 创建。
|
||||||
|
func (r *Repository) PrepareCumulativeRechargeRewardGrants(ctx context.Context, event domain.RechargeEvent, cycle domain.Cycle, nowMS int64) (domain.PrepareResult, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.PrepareResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// event_id 是 wallet_outbox 事实源幂等键;重复 MQ 消息不能再次增加 progress。
|
||||||
|
inserted, err := r.insertCumulativeRechargeRewardEvent(ctx, tx, event, cycle.Key, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
if !inserted {
|
||||||
|
// 重复 event 只恢复该 event 上还没成功的 grant,不重新计算累计,也不根据新配置补发历史档位。
|
||||||
|
grants, err := r.pendingCumulativeRechargeRewardGrantsByEventForUpdate(ctx, tx, event.EventID, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
if len(grants) > 0 {
|
||||||
|
return domain.PrepareResult{Grants: grants, Consumed: true, Reason: domain.ReasonPendingReward}, nil
|
||||||
|
}
|
||||||
|
return domain.PrepareResult{Consumed: true, Reason: domain.ReasonAlreadyConsumed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config, exists, err := r.getCumulativeRechargeRewardConfigForUpdate(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
// 未配置时只记录 event 幂等事实,保证之后同一 event 不会因为补配置而被误当成新充值。
|
||||||
|
return domain.PrepareResult{Consumed: true, Reason: domain.ReasonNotConfigured}, nil
|
||||||
|
}
|
||||||
|
if !config.Enabled {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
// 禁用期间的充值不会进入累计;这是配置只影响后续事件的明确边界。
|
||||||
|
return domain.PrepareResult{Consumed: true, Reason: domain.ReasonDisabled}, nil
|
||||||
|
}
|
||||||
|
tiers, err := r.listCumulativeRechargeRewardTiersForUpdate(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
// 只有配置存在且启用后才累加 progress,避免后台开启活动时追溯禁用期事件。
|
||||||
|
progress, err := r.upsertCumulativeRechargeRewardProgress(ctx, tx, event, cycle.Key, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
// 当前充值可能一次跨过多个档位,所有命中的 active 档位都会在同一事务里创建 pending grant。
|
||||||
|
grants, err := r.prepareMatchedCumulativeRechargeRewardGrants(ctx, tx, event, progress, tiers, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.PrepareResult{}, err
|
||||||
|
}
|
||||||
|
if len(grants) == 0 {
|
||||||
|
return domain.PrepareResult{Consumed: true, Reason: domain.ReasonNoTierMatched}, nil
|
||||||
|
}
|
||||||
|
return domain.PrepareResult{Grants: grants, Consumed: true, Reason: domain.ReasonEligible}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkCumulativeRechargeRewardGrantGranted 写回 wallet-service 资源组发放回执。
|
||||||
|
func (r *Repository) MarkCumulativeRechargeRewardGrantGranted(ctx context.Context, grantID string, walletGrantID string, grantedAtMS int64) (domain.Grant, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Grant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE cumulative_recharge_reward_grants
|
||||||
|
SET status = 'granted',
|
||||||
|
wallet_grant_id = ?,
|
||||||
|
failure_reason = '',
|
||||||
|
granted_at_ms = ?,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND grant_id = ? AND status <> 'granted'`,
|
||||||
|
walletGrantID, grantedAtMS, grantedAtMS, appcode.FromContext(ctx), grantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Grant{}, err
|
||||||
|
}
|
||||||
|
grant, exists, err := r.getCumulativeRechargeRewardGrantByID(ctx, grantID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Grant{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return domain.Grant{}, xerr.New(xerr.NotFound, "cumulative recharge reward grant not found")
|
||||||
|
}
|
||||||
|
return grant, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkCumulativeRechargeRewardGrantFailed 保留 grant,后续同一 wallet event 重试会复用原钱包命令。
|
||||||
|
func (r *Repository) MarkCumulativeRechargeRewardGrantFailed(ctx context.Context, grantID string, failureReason string, nowMS int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE cumulative_recharge_reward_grants
|
||||||
|
SET status = 'failed', failure_reason = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND grant_id = ? AND status <> 'granted'`,
|
||||||
|
truncateTaskFailure(failureReason), nowMS, appcode.FromContext(ctx), grantID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCumulativeRechargeRewardGrants 返回后台发放记录,按创建时间倒序。
|
||||||
|
func (r *Repository) ListCumulativeRechargeRewardGrants(ctx context.Context, query domain.GrantQuery) ([]domain.Grant, int64, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
where, args := cumulativeRechargeRewardGrantWhere(ctx, query)
|
||||||
|
var total int64
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM cumulative_recharge_reward_grants `+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
pageSize := normalizePageSize(query.PageSize)
|
||||||
|
page := query.Page
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
args = append(args, pageSize, (page-1)*pageSize)
|
||||||
|
rows, err := r.db.QueryContext(ctx, cumulativeRechargeRewardGrantSelectSQL()+`
|
||||||
|
`+where+`
|
||||||
|
ORDER BY created_at_ms DESC, grant_id DESC
|
||||||
|
LIMIT ? OFFSET ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanCumulativeRechargeRewardGrants(rows, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) insertCumulativeRechargeRewardEvent(ctx context.Context, tx *sql.Tx, event domain.RechargeEvent, cycleKey string, nowMS int64) (bool, error) {
|
||||||
|
// INSERT IGNORE 让 wallet event 成为幂等边界:插入成功才允许进入累计和档位判定。
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT IGNORE INTO cumulative_recharge_reward_events (
|
||||||
|
app_code, event_id, cycle_key, user_id, transaction_id, command_id,
|
||||||
|
recharge_type, recharge_coin_amount, qualifying_usd_minor, payload_json,
|
||||||
|
occurred_at_ms, created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
appcode.FromContext(ctx), event.EventID, cycleKey, event.UserID, event.TransactionID, event.CommandID,
|
||||||
|
event.RechargeType, event.RechargeCoinAmount, event.QualifyingUSDMinor, event.PayloadJSON, event.OccurredAtMS, nowMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return affected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) upsertCumulativeRechargeRewardProgress(ctx context.Context, tx *sql.Tx, event domain.RechargeEvent, cycleKey string, nowMS int64) (domain.Progress, error) {
|
||||||
|
// progress 是按 app + UTC 周 + user 聚合的金额投影;event 表先幂等后,这里可以安全做增量累加。
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO cumulative_recharge_reward_progress (
|
||||||
|
app_code, cycle_key, user_id, total_usd_minor, total_coin_amount,
|
||||||
|
first_recharged_at_ms, last_recharged_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
total_usd_minor = total_usd_minor + VALUES(total_usd_minor),
|
||||||
|
total_coin_amount = total_coin_amount + VALUES(total_coin_amount),
|
||||||
|
last_recharged_at_ms = GREATEST(last_recharged_at_ms, VALUES(last_recharged_at_ms)),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
appcode.FromContext(ctx), cycleKey, event.UserID, event.QualifyingUSDMinor, event.RechargeCoinAmount,
|
||||||
|
event.OccurredAtMS, event.OccurredAtMS, nowMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Progress{}, err
|
||||||
|
}
|
||||||
|
row := tx.QueryRowContext(ctx, cumulativeRechargeRewardProgressSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND cycle_key = ? AND user_id = ?
|
||||||
|
FOR UPDATE`,
|
||||||
|
appcode.FromContext(ctx), cycleKey, event.UserID,
|
||||||
|
)
|
||||||
|
return scanCumulativeRechargeRewardProgress(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) prepareMatchedCumulativeRechargeRewardGrants(ctx context.Context, tx *sql.Tx, event domain.RechargeEvent, progress domain.Progress, tiers []domain.Tier, nowMS int64) ([]domain.Grant, error) {
|
||||||
|
prepared := make([]domain.Grant, 0)
|
||||||
|
for _, tier := range tiers {
|
||||||
|
// 只有 active 且资源组有效的档位可以发放;inactive 档位仍保留给后台展示和历史审计。
|
||||||
|
if tier.Status != domain.TierStatusActive || tier.ResourceGroupID <= 0 || tier.ThresholdUSDMinor <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if progress.TotalUSDMinor < tier.ThresholdUSDMinor {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 唯一键 app/cycle/user/tier 保证每个 UTC 周每个档位最多创建一条 grant。
|
||||||
|
existing, exists, err := r.getCumulativeRechargeRewardGrantByTierForUpdate(ctx, tx, progress.CycleKey, event.UserID, tier.TierID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
if existing.Status == domain.GrantStatusFailed {
|
||||||
|
// failed grant 代表 wallet 副作用没有确认成功,可以更新触发事件快照后再次尝试同一档位。
|
||||||
|
if err := r.updateCumulativeRechargeRewardGrantPending(ctx, tx, existing.GrantID, event, progress.TotalUSDMinor, nowMS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
existing.Status = domain.GrantStatusPending
|
||||||
|
existing.EventID = event.EventID
|
||||||
|
existing.TransactionID = event.TransactionID
|
||||||
|
existing.CommandID = event.CommandID
|
||||||
|
existing.ReachedUSDMinor = progress.TotalUSDMinor
|
||||||
|
existing.QualifyingUSDMinor = event.QualifyingUSDMinor
|
||||||
|
existing.RechargeCoinAmount = event.RechargeCoinAmount
|
||||||
|
existing.RechargeType = event.RechargeType
|
||||||
|
existing.FailureReason = ""
|
||||||
|
existing.UpdatedAtMS = nowMS
|
||||||
|
prepared = append(prepared, existing)
|
||||||
|
}
|
||||||
|
if existing.Status == domain.GrantStatusPending {
|
||||||
|
// pending grant 可能来自上次 MQ 消费已建单但还未完成 wallet 发放,直接返回给调用方继续执行。
|
||||||
|
prepared = append(prepared, existing)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grant := domain.Grant{
|
||||||
|
GrantID: idgen.New("crgrant"),
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
CycleKey: progress.CycleKey,
|
||||||
|
UserID: event.UserID,
|
||||||
|
EventID: event.EventID,
|
||||||
|
TransactionID: event.TransactionID,
|
||||||
|
CommandID: event.CommandID,
|
||||||
|
TierID: tier.TierID,
|
||||||
|
TierCode: tier.TierCode,
|
||||||
|
ThresholdUSDMinor: tier.ThresholdUSDMinor,
|
||||||
|
ResourceGroupID: tier.ResourceGroupID,
|
||||||
|
ReachedUSDMinor: progress.TotalUSDMinor,
|
||||||
|
QualifyingUSDMinor: event.QualifyingUSDMinor,
|
||||||
|
RechargeCoinAmount: event.RechargeCoinAmount,
|
||||||
|
RechargeType: event.RechargeType,
|
||||||
|
Status: domain.GrantStatusPending,
|
||||||
|
CreatedAtMS: nowMS,
|
||||||
|
UpdatedAtMS: nowMS,
|
||||||
|
}
|
||||||
|
// wallet_command_id 从 grant_id 派生,数据库唯一键和 wallet 幂等命令共同防止重复到账。
|
||||||
|
grant.WalletCommandID = walletCumulativeRechargeRewardCommandID(grant.GrantID)
|
||||||
|
if err := r.insertCumulativeRechargeRewardGrant(ctx, tx, grant); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prepared = append(prepared, grant)
|
||||||
|
}
|
||||||
|
return prepared, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) pendingCumulativeRechargeRewardGrantsByEventForUpdate(ctx context.Context, tx *sql.Tx, eventID string, nowMS int64) ([]domain.Grant, error) {
|
||||||
|
// 重复 event 只查同一 event 创建过的 pending/failed grant,避免借 MQ 重投触发新配置下的额外发放。
|
||||||
|
rows, err := tx.QueryContext(ctx, cumulativeRechargeRewardGrantSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND event_id = ? AND status IN ('pending', 'failed')
|
||||||
|
ORDER BY threshold_usd_minor ASC, grant_id ASC
|
||||||
|
FOR UPDATE`,
|
||||||
|
appcode.FromContext(ctx), eventID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
grants, _, err := scanCumulativeRechargeRewardGrants(rows, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index := range grants {
|
||||||
|
if grants[index].Status != domain.GrantStatusFailed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// failed 回到 pending 时不生成新 grant_id 或新 wallet_command_id,重试路径仍保持幂等。
|
||||||
|
if err := r.updateCumulativeRechargeRewardGrantPendingByID(ctx, tx, grants[index].GrantID, nowMS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
grants[index].Status = domain.GrantStatusPending
|
||||||
|
grants[index].FailureReason = ""
|
||||||
|
grants[index].UpdatedAtMS = nowMS
|
||||||
|
}
|
||||||
|
return grants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getCumulativeRechargeRewardConfigForUpdate(ctx context.Context, tx *sql.Tx) (domain.Config, bool, error) {
|
||||||
|
row := tx.QueryRowContext(ctx, cumulativeRechargeRewardConfigSelectSQL()+`
|
||||||
|
WHERE app_code = ?
|
||||||
|
FOR UPDATE`,
|
||||||
|
appcode.FromContext(ctx),
|
||||||
|
)
|
||||||
|
config, err := scanCumulativeRechargeRewardConfig(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Config{}, false, nil
|
||||||
|
}
|
||||||
|
return config, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) listCumulativeRechargeRewardTiers(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
|
||||||
|
query := cumulativeRechargeRewardTierSelectSQL() + `
|
||||||
|
WHERE app_code = ?
|
||||||
|
ORDER BY sort_order ASC, threshold_usd_minor ASC, tier_id ASC`
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
rows, err = tx.QueryContext(ctx, query, appcode.FromContext(ctx))
|
||||||
|
} else {
|
||||||
|
rows, err = r.db.QueryContext(ctx, query, appcode.FromContext(ctx))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanCumulativeRechargeRewardTiers(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) listCumulativeRechargeRewardTiersForUpdate(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
|
||||||
|
rows, err := tx.QueryContext(ctx, cumulativeRechargeRewardTierSelectSQL()+`
|
||||||
|
WHERE app_code = ?
|
||||||
|
ORDER BY sort_order ASC, threshold_usd_minor ASC, tier_id ASC
|
||||||
|
FOR UPDATE`,
|
||||||
|
appcode.FromContext(ctx),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanCumulativeRechargeRewardTiers(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getCumulativeRechargeRewardGrantByTierForUpdate(ctx context.Context, tx *sql.Tx, cycleKey string, userID int64, tierID int64) (domain.Grant, bool, error) {
|
||||||
|
row := tx.QueryRowContext(ctx, cumulativeRechargeRewardGrantSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND cycle_key = ? AND user_id = ? AND tier_id = ?
|
||||||
|
FOR UPDATE`,
|
||||||
|
appcode.FromContext(ctx), cycleKey, userID, tierID,
|
||||||
|
)
|
||||||
|
grant, err := scanCumulativeRechargeRewardGrant(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Grant{}, false, nil
|
||||||
|
}
|
||||||
|
return grant, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getCumulativeRechargeRewardGrantByID(ctx context.Context, grantID string) (domain.Grant, bool, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, cumulativeRechargeRewardGrantSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND grant_id = ?`,
|
||||||
|
appcode.FromContext(ctx), grantID,
|
||||||
|
)
|
||||||
|
grant, err := scanCumulativeRechargeRewardGrant(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Grant{}, false, nil
|
||||||
|
}
|
||||||
|
return grant, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) insertCumulativeRechargeRewardGrant(ctx context.Context, tx *sql.Tx, grant domain.Grant) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO cumulative_recharge_reward_grants (
|
||||||
|
app_code, grant_id, cycle_key, user_id, event_id, transaction_id, command_id,
|
||||||
|
tier_id, tier_code, threshold_usd_minor, resource_group_id, reached_usd_minor,
|
||||||
|
qualifying_usd_minor, recharge_coin_amount, recharge_type, status, wallet_command_id,
|
||||||
|
wallet_grant_id, failure_reason, granted_at_ms, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', 0, ?, ?)`,
|
||||||
|
appcode.FromContext(ctx), grant.GrantID, grant.CycleKey, grant.UserID, grant.EventID, grant.TransactionID, grant.CommandID,
|
||||||
|
grant.TierID, grant.TierCode, grant.ThresholdUSDMinor, grant.ResourceGroupID, grant.ReachedUSDMinor,
|
||||||
|
grant.QualifyingUSDMinor, grant.RechargeCoinAmount, grant.RechargeType, grant.Status, grant.WalletCommandID,
|
||||||
|
grant.CreatedAtMS, grant.UpdatedAtMS,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) updateCumulativeRechargeRewardGrantPending(ctx context.Context, tx *sql.Tx, grantID string, event domain.RechargeEvent, reachedUSDMinor int64, nowMS int64) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE cumulative_recharge_reward_grants
|
||||||
|
SET event_id = ?,
|
||||||
|
transaction_id = ?,
|
||||||
|
command_id = ?,
|
||||||
|
reached_usd_minor = ?,
|
||||||
|
qualifying_usd_minor = ?,
|
||||||
|
recharge_coin_amount = ?,
|
||||||
|
recharge_type = ?,
|
||||||
|
status = 'pending',
|
||||||
|
wallet_grant_id = '',
|
||||||
|
failure_reason = '',
|
||||||
|
granted_at_ms = 0,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND grant_id = ? AND status = 'failed'`,
|
||||||
|
event.EventID, event.TransactionID, event.CommandID, reachedUSDMinor, event.QualifyingUSDMinor,
|
||||||
|
event.RechargeCoinAmount, event.RechargeType, nowMS, appcode.FromContext(ctx), grantID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) updateCumulativeRechargeRewardGrantPendingByID(ctx context.Context, tx *sql.Tx, grantID string, nowMS int64) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE cumulative_recharge_reward_grants
|
||||||
|
SET status = 'pending',
|
||||||
|
wallet_grant_id = '',
|
||||||
|
failure_reason = '',
|
||||||
|
granted_at_ms = 0,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND grant_id = ? AND status = 'failed'`,
|
||||||
|
nowMS, appcode.FromContext(ctx), grantID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCumulativeRechargeRewardConfig(row rowScanner) (domain.Config, error) {
|
||||||
|
var config domain.Config
|
||||||
|
err := row.Scan(&config.AppCode, &config.Enabled, &config.UpdatedByAdminID, &config.CreatedAtMS, &config.UpdatedAtMS)
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCumulativeRechargeRewardTiers(rows *sql.Rows) ([]domain.Tier, error) {
|
||||||
|
items := make([]domain.Tier, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item domain.Tier
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.TierID,
|
||||||
|
&item.TierCode,
|
||||||
|
&item.TierName,
|
||||||
|
&item.ThresholdUSDMinor,
|
||||||
|
&item.ResourceGroupID,
|
||||||
|
&item.Status,
|
||||||
|
&item.SortOrder,
|
||||||
|
&item.CreatedAtMS,
|
||||||
|
&item.UpdatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCumulativeRechargeRewardProgress(row rowScanner) (domain.Progress, error) {
|
||||||
|
var item domain.Progress
|
||||||
|
err := row.Scan(
|
||||||
|
&item.AppCode,
|
||||||
|
&item.CycleKey,
|
||||||
|
&item.UserID,
|
||||||
|
&item.TotalUSDMinor,
|
||||||
|
&item.TotalCoinAmount,
|
||||||
|
&item.FirstRechargedAtMS,
|
||||||
|
&item.LastRechargedAtMS,
|
||||||
|
&item.UpdatedAtMS,
|
||||||
|
)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCumulativeRechargeRewardGrants(rows *sql.Rows, total int64) ([]domain.Grant, int64, error) {
|
||||||
|
items := make([]domain.Grant, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanCumulativeRechargeRewardGrant(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCumulativeRechargeRewardGrant(row rowScanner) (domain.Grant, error) {
|
||||||
|
var item domain.Grant
|
||||||
|
err := row.Scan(
|
||||||
|
&item.GrantID,
|
||||||
|
&item.AppCode,
|
||||||
|
&item.CycleKey,
|
||||||
|
&item.UserID,
|
||||||
|
&item.EventID,
|
||||||
|
&item.TransactionID,
|
||||||
|
&item.CommandID,
|
||||||
|
&item.TierID,
|
||||||
|
&item.TierCode,
|
||||||
|
&item.ThresholdUSDMinor,
|
||||||
|
&item.ResourceGroupID,
|
||||||
|
&item.ReachedUSDMinor,
|
||||||
|
&item.QualifyingUSDMinor,
|
||||||
|
&item.RechargeCoinAmount,
|
||||||
|
&item.RechargeType,
|
||||||
|
&item.Status,
|
||||||
|
&item.WalletCommandID,
|
||||||
|
&item.WalletGrantID,
|
||||||
|
&item.FailureReason,
|
||||||
|
&item.GrantedAtMS,
|
||||||
|
&item.CreatedAtMS,
|
||||||
|
&item.UpdatedAtMS,
|
||||||
|
)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardConfigSelectSQL() string {
|
||||||
|
return `SELECT app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||||
|
FROM cumulative_recharge_reward_configs `
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardTierSelectSQL() string {
|
||||||
|
return `SELECT tier_id, tier_code, tier_name, threshold_usd_minor,
|
||||||
|
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
|
||||||
|
FROM cumulative_recharge_reward_tiers `
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardProgressSelectSQL() string {
|
||||||
|
return `SELECT app_code, cycle_key, user_id, total_usd_minor, total_coin_amount,
|
||||||
|
first_recharged_at_ms, last_recharged_at_ms, updated_at_ms
|
||||||
|
FROM cumulative_recharge_reward_progress `
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardGrantSelectSQL() string {
|
||||||
|
return `SELECT grant_id, app_code, cycle_key, user_id, event_id, transaction_id, command_id,
|
||||||
|
tier_id, tier_code, threshold_usd_minor, resource_group_id, reached_usd_minor,
|
||||||
|
qualifying_usd_minor, recharge_coin_amount, recharge_type, status, wallet_command_id,
|
||||||
|
wallet_grant_id, failure_reason, granted_at_ms, created_at_ms, updated_at_ms
|
||||||
|
FROM cumulative_recharge_reward_grants `
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardGrantWhere(ctx context.Context, query domain.GrantQuery) (string, []any) {
|
||||||
|
conditions := []string{"app_code = ?"}
|
||||||
|
args := []any{appcode.FromContext(ctx)}
|
||||||
|
if query.Status != "" {
|
||||||
|
conditions = append(conditions, "status = ?")
|
||||||
|
args = append(args, query.Status)
|
||||||
|
}
|
||||||
|
if query.UserID > 0 {
|
||||||
|
conditions = append(conditions, "user_id = ?")
|
||||||
|
args = append(args, query.UserID)
|
||||||
|
}
|
||||||
|
if query.CycleKey != "" {
|
||||||
|
conditions = append(conditions, "cycle_key = ?")
|
||||||
|
args = append(args, query.CycleKey)
|
||||||
|
}
|
||||||
|
return "WHERE " + strings.Join(conditions, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletCumulativeRechargeRewardCommandID(grantID string) string {
|
||||||
|
// 前缀区分累充奖励和其他 wallet 资源组发放来源,便于钱包侧排查命令来源。
|
||||||
|
return fmt.Sprintf("wcr_%s", grantID)
|
||||||
|
}
|
||||||
@ -184,7 +184,7 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand
|
|||||||
return domain.CheckResult{
|
return domain.CheckResult{
|
||||||
Enabled: config.Enabled,
|
Enabled: config.Enabled,
|
||||||
Reason: luckyEnabledReason(config.Enabled),
|
Reason: luckyEnabledReason(config.Enabled),
|
||||||
PoolID: config.GiftID,
|
PoolID: config.PoolID,
|
||||||
GiftID: cmd.GiftID,
|
GiftID: cmd.GiftID,
|
||||||
GiftPrice: config.GiftPrice,
|
GiftPrice: config.GiftPrice,
|
||||||
RuleVersion: config.RuleVersion,
|
RuleVersion: config.RuleVersion,
|
||||||
@ -466,7 +466,7 @@ func (r *Repository) stageLuckyBatchDraw(appCode string, baseConfig domain.Confi
|
|||||||
return domain.DrawResult{
|
return domain.DrawResult{
|
||||||
DrawID: drawID,
|
DrawID: drawID,
|
||||||
CommandID: cmd.CommandID,
|
CommandID: cmd.CommandID,
|
||||||
PoolID: config.GiftID,
|
PoolID: config.PoolID,
|
||||||
GiftID: cmd.GiftID,
|
GiftID: cmd.GiftID,
|
||||||
RuleVersion: config.RuleVersion,
|
RuleVersion: config.RuleVersion,
|
||||||
ExperiencePool: experiencePool,
|
ExperiencePool: experiencePool,
|
||||||
@ -627,15 +627,15 @@ func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, rec
|
|||||||
var sqlBuilder strings.Builder
|
var sqlBuilder strings.Builder
|
||||||
sqlBuilder.WriteString(`
|
sqlBuilder.WriteString(`
|
||||||
INSERT INTO lucky_draw_records (
|
INSERT INTO lucky_draw_records (
|
||||||
app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, pool_id, gift_id,
|
app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, visible_region_id, pool_id, gift_id,
|
||||||
coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
|
coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
|
||||||
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||||
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
|
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
|
||||||
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, reward_transaction_id, reward_failure_reason,
|
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, reward_transaction_id, reward_failure_reason,
|
||||||
paid_at_ms, created_at_ms, updated_at_ms
|
paid_at_ms, created_at_ms, updated_at_ms
|
||||||
) VALUES `)
|
) VALUES `)
|
||||||
args := make([]any, 0, len(batch)*31)
|
args := make([]any, 0, len(batch)*32)
|
||||||
rowPlaceholders := "(" + luckySQLPlaceholders(31) + ")"
|
rowPlaceholders := "(" + luckySQLPlaceholders(32) + ")"
|
||||||
for index, record := range batch {
|
for index, record := range batch {
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
sqlBuilder.WriteString(",")
|
sqlBuilder.WriteString(",")
|
||||||
@ -741,6 +741,7 @@ type luckyDrawStatsRecord struct {
|
|||||||
DrawID string
|
DrawID string
|
||||||
PoolID string
|
PoolID string
|
||||||
GiftID string
|
GiftID string
|
||||||
|
VisibleRegionID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
RoomID string
|
RoomID string
|
||||||
TotalSpentCoins int64
|
TotalSpentCoins int64
|
||||||
@ -875,6 +876,148 @@ func (r *Repository) RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, no
|
|||||||
return len(records), nil
|
return len(records), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type luckyDrawPoolDayStatKey struct {
|
||||||
|
AppCode string
|
||||||
|
StatDay string
|
||||||
|
VisibleRegionID int64
|
||||||
|
PoolID string
|
||||||
|
GiftID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type luckyDrawPoolDayStatDelta struct {
|
||||||
|
DrawCount int64
|
||||||
|
TurnoverCoin int64
|
||||||
|
PayoutCoin int64
|
||||||
|
BaseRewardCoin int64
|
||||||
|
RoomAtmosphereRewardCoin int64
|
||||||
|
ActivitySubsidyCoin int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshLuckyGiftDatabiStatsSnapshots 用独立游标把抽奖事实小批量转成 Databi 日维度汇总。
|
||||||
|
// Databi 页面只读 lucky_draw_pool_day_stats,不直接扫 lucky_draw_records,避免页面访问把事实表打成查询热点。
|
||||||
|
func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
if batchSize <= 0 {
|
||||||
|
batchSize = 5000
|
||||||
|
}
|
||||||
|
if batchSize > 50000 {
|
||||||
|
batchSize = 50000
|
||||||
|
}
|
||||||
|
appCode := appcode.FromContext(ctx)
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
cursor, err := r.lockLuckyDrawDatabiStatsCursor(ctx, tx, appCode, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT app_code, draw_id, visible_region_id, pool_id, gift_id, user_id, room_id, coin_spent,
|
||||||
|
effective_reward_coins, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||||
|
reward_status,
|
||||||
|
created_at_ms
|
||||||
|
FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created)
|
||||||
|
WHERE app_code = ?
|
||||||
|
AND (created_at_ms > ? OR (created_at_ms = ? AND draw_id > ?))
|
||||||
|
ORDER BY created_at_ms ASC, draw_id ASC
|
||||||
|
LIMIT ?`, appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
records := make([]luckyDrawStatsRecord, 0, batchSize)
|
||||||
|
for rows.Next() {
|
||||||
|
var record luckyDrawStatsRecord
|
||||||
|
if err := rows.Scan(&record.AppCode, &record.DrawID, &record.VisibleRegionID, &record.PoolID, &record.GiftID, &record.UserID, &record.RoomID,
|
||||||
|
&record.TotalSpentCoins, &record.TotalRewardCoins, &record.BaseRewardCoins,
|
||||||
|
&record.RoomAtmosphereRewardCoins, &record.ActivitySubsidyCoins, &record.RewardStatus, &record.CreatedAtMS); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
grouped := map[luckyDrawPoolDayStatKey]*luckyDrawPoolDayStatDelta{}
|
||||||
|
last := records[len(records)-1]
|
||||||
|
for _, record := range records {
|
||||||
|
regionID := record.VisibleRegionID
|
||||||
|
if regionID < 0 {
|
||||||
|
regionID = 0
|
||||||
|
}
|
||||||
|
day := luckyDrawStatDay(record.CreatedAtMS)
|
||||||
|
keys := []luckyDrawPoolDayStatKey{{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: ""}}
|
||||||
|
if giftID := strings.TrimSpace(record.GiftID); giftID != "" {
|
||||||
|
keys = append(keys, luckyDrawPoolDayStatKey{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: giftID})
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
delta := grouped[key]
|
||||||
|
if delta == nil {
|
||||||
|
delta = &luckyDrawPoolDayStatDelta{}
|
||||||
|
grouped[key] = delta
|
||||||
|
}
|
||||||
|
delta.DrawCount++
|
||||||
|
delta.TurnoverCoin += record.TotalSpentCoins
|
||||||
|
delta.PayoutCoin += record.TotalRewardCoins
|
||||||
|
delta.BaseRewardCoin += record.BaseRewardCoins
|
||||||
|
delta.RoomAtmosphereRewardCoin += record.RoomAtmosphereRewardCoins
|
||||||
|
delta.ActivitySubsidyCoin += record.ActivitySubsidyCoins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, delta := range grouped {
|
||||||
|
if err := r.upsertLuckyDrawPoolDayStatsDelta(ctx, tx, key, *delta, nowMS); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE lucky_draw_pool_stat_cursors
|
||||||
|
SET last_draw_created_at_ms = ?, last_draw_id = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND cursor_name = 'draw_records_databi_day'`,
|
||||||
|
last.CreatedAtMS, last.DrawID, nowMS, appCode,
|
||||||
|
); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(records), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) upsertLuckyDrawPoolDayStatsDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolDayStatKey, delta luckyDrawPoolDayStatDelta, nowMS int64) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_draw_pool_day_stats (
|
||||||
|
app_code, stat_day, visible_region_id, pool_id, gift_id, draw_count, turnover_coin, payout_coin,
|
||||||
|
base_reward_coin, room_atmosphere_reward_coin, activity_subsidy_coin, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
draw_count = draw_count + VALUES(draw_count),
|
||||||
|
turnover_coin = turnover_coin + VALUES(turnover_coin),
|
||||||
|
payout_coin = payout_coin + VALUES(payout_coin),
|
||||||
|
base_reward_coin = base_reward_coin + VALUES(base_reward_coin),
|
||||||
|
room_atmosphere_reward_coin = room_atmosphere_reward_coin + VALUES(room_atmosphere_reward_coin),
|
||||||
|
activity_subsidy_coin = activity_subsidy_coin + VALUES(activity_subsidy_coin),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
key.AppCode, key.StatDay, key.VisibleRegionID, key.PoolID, key.GiftID, delta.DrawCount, delta.TurnoverCoin, delta.PayoutCoin,
|
||||||
|
delta.BaseRewardCoin, delta.RoomAtmosphereRewardCoin, delta.ActivitySubsidyCoin, nowMS, nowMS,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func luckyDrawStatDay(ms int64) string {
|
||||||
|
return time.UnixMilli(ms).UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) lockLuckyDrawStatsCursor(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64) (luckyDrawStatsCursor, bool, error) {
|
func (r *Repository) lockLuckyDrawStatsCursor(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64) (luckyDrawStatsCursor, bool, error) {
|
||||||
var cursor luckyDrawStatsCursor
|
var cursor luckyDrawStatsCursor
|
||||||
err := tx.QueryRowContext(ctx, `
|
err := tx.QueryRowContext(ctx, `
|
||||||
@ -909,12 +1052,38 @@ func (r *Repository) lockLuckyDrawStatsCursor(ctx context.Context, tx *sql.Tx, a
|
|||||||
return cursor, true, nil
|
return cursor, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) lockLuckyDrawDatabiStatsCursor(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64) (luckyDrawStatsCursor, error) {
|
||||||
|
var cursor luckyDrawStatsCursor
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT last_draw_created_at_ms, last_draw_id
|
||||||
|
FROM lucky_draw_pool_stat_cursors
|
||||||
|
WHERE app_code = ? AND cursor_name = 'draw_records_databi_day'
|
||||||
|
FOR UPDATE`, appCode,
|
||||||
|
).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID)
|
||||||
|
if err == nil {
|
||||||
|
return cursor, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return luckyDrawStatsCursor{}, err
|
||||||
|
}
|
||||||
|
// Databi 日汇总是后加的统计口径,首次启动必须从 0 游标小批量回补;不要像后台全量汇总那样跳到最新记录。
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO lucky_draw_pool_stat_cursors (
|
||||||
|
app_code, cursor_name, last_draw_created_at_ms, last_draw_id, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, 'draw_records_databi_day', 0, '', ?, ?)`,
|
||||||
|
appCode, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return luckyDrawStatsCursor{}, err
|
||||||
|
}
|
||||||
|
return cursor, nil
|
||||||
|
}
|
||||||
|
|
||||||
// luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序;集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。
|
// luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序;集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。
|
||||||
func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
|
func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
|
||||||
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input)
|
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input)
|
||||||
return []any{
|
return []any{
|
||||||
input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID,
|
input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID,
|
||||||
input.Command.RoomID, input.Command.AnchorID, input.Config.GiftID, input.Command.GiftID, input.Command.CoinSpent,
|
input.Command.RoomID, input.Command.AnchorID, input.Command.VisibleRegionID, input.Config.PoolID, input.Command.GiftID, input.Command.CoinSpent,
|
||||||
input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
|
input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
|
||||||
input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
|
input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
|
||||||
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
|
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
|
||||||
@ -1102,7 +1271,7 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx,
|
|||||||
return domain.DrawResult{
|
return domain.DrawResult{
|
||||||
DrawID: drawID,
|
DrawID: drawID,
|
||||||
CommandID: cmd.CommandID,
|
CommandID: cmd.CommandID,
|
||||||
PoolID: config.GiftID,
|
PoolID: config.PoolID,
|
||||||
GiftID: cmd.GiftID,
|
GiftID: cmd.GiftID,
|
||||||
RuleVersion: config.RuleVersion,
|
RuleVersion: config.RuleVersion,
|
||||||
ExperiencePool: experiencePool,
|
ExperiencePool: experiencePool,
|
||||||
@ -1370,15 +1539,15 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
|
|||||||
})
|
})
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO lucky_draw_records (
|
INSERT INTO lucky_draw_records (
|
||||||
app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, pool_id, gift_id,
|
app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, visible_region_id, pool_id, gift_id,
|
||||||
coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
|
coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
|
||||||
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||||
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
|
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
|
||||||
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, reward_transaction_id, reward_failure_reason,
|
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, reward_transaction_id, reward_failure_reason,
|
||||||
paid_at_ms, created_at_ms, updated_at_ms
|
paid_at_ms, created_at_ms, updated_at_ms
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID,
|
input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID,
|
||||||
input.Command.RoomID, input.Command.AnchorID, input.Config.GiftID, input.Command.GiftID, input.Command.CoinSpent,
|
input.Command.RoomID, input.Command.AnchorID, input.Command.VisibleRegionID, input.Config.PoolID, input.Command.GiftID, input.Command.CoinSpent,
|
||||||
input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
|
input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
|
||||||
input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
|
input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
|
||||||
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
|
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
|
||||||
@ -1395,7 +1564,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
|
|||||||
"app_code": input.AppCode,
|
"app_code": input.AppCode,
|
||||||
"draw_id": input.DrawID,
|
"draw_id": input.DrawID,
|
||||||
"command_id": input.Command.CommandID,
|
"command_id": input.Command.CommandID,
|
||||||
"pool_id": input.Config.GiftID,
|
"pool_id": input.Config.PoolID,
|
||||||
"user_id": input.Command.UserID,
|
"user_id": input.Command.UserID,
|
||||||
"sender_user_id": input.Command.UserID,
|
"sender_user_id": input.Command.UserID,
|
||||||
"target_user_id": input.Command.TargetUserID,
|
"target_user_id": input.Command.TargetUserID,
|
||||||
|
|||||||
@ -117,6 +117,14 @@ func TestRefreshLuckyGiftAdminStatsSnapshotsAdvancesCursorByBatch(t *testing.T)
|
|||||||
assertLuckyStatsTotal(t, repository, "", 1, 100, 300)
|
assertLuckyStatsTotal(t, repository, "", 1, 100, 300)
|
||||||
assertLuckyStatsStatus(t, repository, "", 0, 1, 0)
|
assertLuckyStatsStatus(t, repository, "", 0, 1, 0)
|
||||||
assertLuckyStatsCursor(t, repository, 100, "draw_001")
|
assertLuckyStatsCursor(t, repository, 100, "draw_001")
|
||||||
|
processed, err = repository.RefreshLuckyGiftDatabiStatsSnapshots(ctx, 310, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first databi refresh failed: %v", err)
|
||||||
|
}
|
||||||
|
if processed != 1 {
|
||||||
|
t.Fatalf("first databi refresh should process one row, got %d", processed)
|
||||||
|
}
|
||||||
|
assertLuckyDatabiDayStatsTotal(t, repository, "1970-01-01", "", 1, 100, 300)
|
||||||
|
|
||||||
processed, err = repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, 400, 10)
|
processed, err = repository.RefreshLuckyGiftAdminStatsSnapshots(ctx, 400, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -128,6 +136,14 @@ func TestRefreshLuckyGiftAdminStatsSnapshotsAdvancesCursorByBatch(t *testing.T)
|
|||||||
assertLuckyStatsTotal(t, repository, "", 2, 250, 300)
|
assertLuckyStatsTotal(t, repository, "", 2, 250, 300)
|
||||||
assertLuckyStatsStatus(t, repository, "", 1, 1, 0)
|
assertLuckyStatsStatus(t, repository, "", 1, 1, 0)
|
||||||
assertLuckyStatsCursor(t, repository, 200, "draw_002")
|
assertLuckyStatsCursor(t, repository, 200, "draw_002")
|
||||||
|
processed, err = repository.RefreshLuckyGiftDatabiStatsSnapshots(ctx, 410, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second databi refresh failed: %v", err)
|
||||||
|
}
|
||||||
|
if processed != 1 {
|
||||||
|
t.Fatalf("second databi refresh should process remaining row, got %d", processed)
|
||||||
|
}
|
||||||
|
assertLuckyDatabiDayStatsTotal(t, repository, "1970-01-01", "", 2, 250, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedLuckyDrawRecordForStats(t *testing.T, repository *Repository, drawID string, commandID string, status string, createdAtMS int64, spent int64, reward int64) {
|
func seedLuckyDrawRecordForStats(t *testing.T, repository *Repository, drawID string, commandID string, status string, createdAtMS int64, spent int64, reward int64) {
|
||||||
@ -183,6 +199,21 @@ func assertLuckyStatsStatus(t *testing.T, repository *Repository, giftID string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertLuckyDatabiDayStatsTotal(t *testing.T, repository *Repository, day string, giftID string, wantDraws int64, wantSpent int64, wantReward int64) {
|
||||||
|
t.Helper()
|
||||||
|
var draws, spent, reward int64
|
||||||
|
if err := repository.db.QueryRowContext(context.Background(), `
|
||||||
|
SELECT draw_count, turnover_coin, payout_coin
|
||||||
|
FROM lucky_draw_pool_day_stats
|
||||||
|
WHERE app_code = 'lalu' AND stat_day = ? AND visible_region_id = 0 AND pool_id = 'lucky' AND gift_id = ?`, day, giftID,
|
||||||
|
).Scan(&draws, &spent, &reward); err != nil {
|
||||||
|
t.Fatalf("read databi day stats total: %v", err)
|
||||||
|
}
|
||||||
|
if draws != wantDraws || spent != wantSpent || reward != wantReward {
|
||||||
|
t.Fatalf("databi day stats mismatch: draws=%d spent=%d reward=%d want draws=%d spent=%d reward=%d", draws, spent, reward, wantDraws, wantSpent, wantReward)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertLuckyStatsCursor(t *testing.T, repository *Repository, wantCreatedAtMS int64, wantDrawID string) {
|
func assertLuckyStatsCursor(t *testing.T, repository *Repository, wantCreatedAtMS int64, wantDrawID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var createdAtMS int64
|
var createdAtMS int64
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import (
|
|||||||
domain "hyapp/services/activity-service/internal/domain/registrationreward"
|
domain "hyapp/services/activity-service/internal/domain/registrationreward"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const registrationRewardClaimDisplayTimeSQL = "COALESCE(NULLIF(claimed_at_ms, 0), created_at_ms)"
|
||||||
|
|
||||||
// GetRegistrationRewardConfig 读取注册奖励当前配置;未配置是正常后台首屏状态。
|
// GetRegistrationRewardConfig 读取注册奖励当前配置;未配置是正常后台首屏状态。
|
||||||
func (r *Repository) GetRegistrationRewardConfig(ctx context.Context) (domain.Config, bool, error) {
|
func (r *Repository) GetRegistrationRewardConfig(ctx context.Context) (domain.Config, bool, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
@ -335,7 +337,7 @@ func (r *Repository) ListRegistrationRewardClaims(ctx context.Context, query dom
|
|||||||
args = append(args, pageSize, (page-1)*pageSize)
|
args = append(args, pageSize, (page-1)*pageSize)
|
||||||
rows, err := r.db.QueryContext(ctx, registrationRewardClaimSelectSQL()+`
|
rows, err := r.db.QueryContext(ctx, registrationRewardClaimSelectSQL()+`
|
||||||
`+where+`
|
`+where+`
|
||||||
ORDER BY created_at_ms DESC, claim_id DESC
|
ORDER BY `+registrationRewardClaimDisplayTimeSQL+` DESC, claim_id DESC
|
||||||
LIMIT ? OFFSET ?`, args...)
|
LIMIT ? OFFSET ?`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@ -344,6 +346,28 @@ func (r *Repository) ListRegistrationRewardClaims(ctx context.Context, query dom
|
|||||||
return scanRegistrationRewardClaims(rows, total)
|
return scanRegistrationRewardClaims(rows, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountRegistrationRewardGrantedByDay 读取 UTC 当天已发放份数;后台只展示成功发放数量,不把 pending 预留量算作已领取。
|
||||||
|
func (r *Repository) CountRegistrationRewardGrantedByDay(ctx context.Context, rewardDay string) (int64, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
rewardDay = strings.TrimSpace(rewardDay)
|
||||||
|
if rewardDay == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT granted_count
|
||||||
|
FROM registration_reward_daily_counters
|
||||||
|
WHERE app_code = ? AND reward_day = ?`,
|
||||||
|
appcode.FromContext(ctx), rewardDay,
|
||||||
|
).Scan(&count)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) getRegistrationRewardConfigForUpdate(ctx context.Context, tx *sql.Tx) (domain.Config, bool, error) {
|
func (r *Repository) getRegistrationRewardConfigForUpdate(ctx context.Context, tx *sql.Tx) (domain.Config, bool, error) {
|
||||||
row := tx.QueryRowContext(ctx, registrationRewardConfigSelectSQL()+`
|
row := tx.QueryRowContext(ctx, registrationRewardConfigSelectSQL()+`
|
||||||
WHERE app_code = ?
|
WHERE app_code = ?
|
||||||
@ -571,6 +595,14 @@ func registrationRewardClaimWhere(ctx context.Context, query domain.ClaimQuery)
|
|||||||
conditions = append(conditions, "user_id = ?")
|
conditions = append(conditions, "user_id = ?")
|
||||||
args = append(args, query.UserID)
|
args = append(args, query.UserID)
|
||||||
}
|
}
|
||||||
|
if query.ClaimedStartMS > 0 {
|
||||||
|
conditions = append(conditions, registrationRewardClaimDisplayTimeSQL+" >= ?")
|
||||||
|
args = append(args, query.ClaimedStartMS)
|
||||||
|
}
|
||||||
|
if query.ClaimedEndMS > 0 {
|
||||||
|
conditions = append(conditions, registrationRewardClaimDisplayTimeSQL+" <= ?")
|
||||||
|
args = append(args, query.ClaimedEndMS)
|
||||||
|
}
|
||||||
return "WHERE " + strings.Join(conditions, " AND "), args
|
return "WHERE " + strings.Join(conditions, " AND "), args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
if err := r.ensureLuckyDrawPoolID(ctx); err != nil {
|
if err := r.ensureLuckyDrawPoolID(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := r.ensureLuckyDrawVisibleRegion(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := r.ensureLuckyGiftOutboxColumns(ctx); err != nil {
|
if err := r.ensureLuckyGiftOutboxColumns(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -63,12 +66,21 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
|
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := r.ensureWeeklyStarTables(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := r.ensureLuckyUserStateFlowColumns(ctx); err != nil {
|
if err := r.ensureLuckyUserStateFlowColumns(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
|
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := r.ensureRoomTurnoverRewardTables(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.ensureCumulativeRechargeRewardTables(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +105,23 @@ func (r *Repository) ensureLuckyDrawPoolStatsTables(ctx context.Context) error {
|
|||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, pool_id, gift_id)
|
PRIMARY KEY (app_code, pool_id, gift_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物池级汇总统计'`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物池级汇总统计'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS lucky_draw_pool_day_stats (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
stat_day DATE NOT NULL COMMENT 'UTC 统计日',
|
||||||
|
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域 ID',
|
||||||
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
|
gift_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '礼物 ID,空值表示奖池汇总',
|
||||||
|
draw_count BIGINT NOT NULL DEFAULT 0 COMMENT '抽奖次数',
|
||||||
|
turnover_coin BIGINT NOT NULL DEFAULT 0 COMMENT '消耗金币',
|
||||||
|
payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '用户可见返奖金币',
|
||||||
|
base_reward_coin BIGINT NOT NULL DEFAULT 0 COMMENT '基础 RTP 返奖',
|
||||||
|
room_atmosphere_reward_coin BIGINT NOT NULL DEFAULT 0 COMMENT '房间气氛支出',
|
||||||
|
activity_subsidy_coin BIGINT NOT NULL DEFAULT 0 COMMENT '活动补贴支出',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, stat_day, visible_region_id, pool_id, gift_id),
|
||||||
|
KEY idx_lucky_draw_pool_day_overview (app_code, stat_day, pool_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 Databi 日维度汇总'`,
|
||||||
`CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_users (
|
`CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_users (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
@ -216,6 +245,31 @@ func (r *Repository) ensureLuckyDrawPoolID(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ensureLuckyDrawVisibleRegion(ctx context.Context) error {
|
||||||
|
const table = "lucky_draw_records"
|
||||||
|
hasVisibleRegion, err := r.columnExists(ctx, table, "visible_region_id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasVisibleRegion {
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
ALTER TABLE lucky_draw_records
|
||||||
|
ADD COLUMN visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域 ID' AFTER anchor_id`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasIndex, err := r.indexExists(ctx, table, "idx_lucky_draw_region_created")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasIndex {
|
||||||
|
if _, err := r.db.ExecContext(ctx, `CREATE INDEX idx_lucky_draw_region_created ON lucky_draw_records (app_code, visible_region_id, created_at_ms, draw_id)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) ensureLuckyGiftOutboxColumns(ctx context.Context) error {
|
func (r *Repository) ensureLuckyGiftOutboxColumns(ctx context.Context) error {
|
||||||
additions := []struct {
|
additions := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@ -19,6 +19,15 @@ func TestMigrateAddsLuckyDrawPoolIDToLegacyTable(t *testing.T) {
|
|||||||
if _, err := schema.DB.ExecContext(context.Background(), `ALTER TABLE lucky_draw_records DROP COLUMN pool_id`); err != nil {
|
if _, err := schema.DB.ExecContext(context.Background(), `ALTER TABLE lucky_draw_records DROP COLUMN pool_id`); err != nil {
|
||||||
t.Fatalf("drop legacy pool column: %v", err)
|
t.Fatalf("drop legacy pool column: %v", err)
|
||||||
}
|
}
|
||||||
|
if _, err := schema.DB.ExecContext(context.Background(), `ALTER TABLE lucky_draw_records DROP INDEX idx_lucky_draw_region_created`); err != nil {
|
||||||
|
t.Fatalf("drop legacy region index: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := schema.DB.ExecContext(context.Background(), `ALTER TABLE lucky_draw_records DROP COLUMN visible_region_id`); err != nil {
|
||||||
|
t.Fatalf("drop legacy region column: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := schema.DB.ExecContext(context.Background(), `DROP TABLE lucky_draw_pool_day_stats`); err != nil {
|
||||||
|
t.Fatalf("drop legacy databi day stats table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
repository, err := Open(context.Background(), schema.DSN)
|
repository, err := Open(context.Background(), schema.DSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -34,4 +43,23 @@ func TestMigrateAddsLuckyDrawPoolIDToLegacyTable(t *testing.T) {
|
|||||||
if hasIndex, err := repository.indexExists(context.Background(), "lucky_draw_records", "idx_lucky_draw_pool"); err != nil || !hasIndex {
|
if hasIndex, err := repository.indexExists(context.Background(), "lucky_draw_records", "idx_lucky_draw_pool"); err != nil || !hasIndex {
|
||||||
t.Fatalf("idx_lucky_draw_pool missing after migrate: has=%v err=%v", hasIndex, err)
|
t.Fatalf("idx_lucky_draw_pool missing after migrate: has=%v err=%v", hasIndex, err)
|
||||||
}
|
}
|
||||||
|
if hasColumn, err := repository.columnExists(context.Background(), "lucky_draw_records", "visible_region_id"); err != nil || !hasColumn {
|
||||||
|
t.Fatalf("visible_region_id column missing after migrate: has=%v err=%v", hasColumn, err)
|
||||||
|
}
|
||||||
|
if hasIndex, err := repository.indexExists(context.Background(), "lucky_draw_records", "idx_lucky_draw_region_created"); err != nil || !hasIndex {
|
||||||
|
t.Fatalf("idx_lucky_draw_region_created missing after migrate: has=%v err=%v", hasIndex, err)
|
||||||
|
}
|
||||||
|
if hasTable, err := activityTableExists(context.Background(), repository, "lucky_draw_pool_day_stats"); err != nil || !hasTable {
|
||||||
|
t.Fatalf("lucky_draw_pool_day_stats table missing after migrate: has=%v err=%v", hasTable, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityTableExists(ctx context.Context, repository *Repository, tableName string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := repository.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ?`, tableName,
|
||||||
|
).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (r *Repository) ensureRoomTurnoverRewardTables(ctx context.Context) error {
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_turnover_reward_configs (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
|
||||||
|
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励配置表'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_turnover_reward_tiers (
|
||||||
|
tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '档位 ID',
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
tier_code VARCHAR(64) NOT NULL COMMENT '档位编码',
|
||||||
|
tier_name VARCHAR(128) NOT NULL COMMENT '档位名称',
|
||||||
|
threshold_coin_spent BIGINT NOT NULL COMMENT '命中该档需要的房间送礼金币流水',
|
||||||
|
reward_coin_amount BIGINT NOT NULL COMMENT '奖励房主金币数',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT 'active/inactive',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (tier_id),
|
||||||
|
UNIQUE KEY uk_room_turnover_reward_tier_code (app_code, tier_code),
|
||||||
|
KEY idx_room_turnover_reward_tiers_match (app_code, status, threshold_coin_spent)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励档位表'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_turnover_reward_event_consumption (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
event_id VARCHAR(128) NOT NULL COMMENT 'room-service outbox event_id',
|
||||||
|
event_type VARCHAR(96) NOT NULL COMMENT '事件类型',
|
||||||
|
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||||
|
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||||
|
coin_spent BIGINT NOT NULL COMMENT '本事件送礼金币消耗',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '消费状态',
|
||||||
|
consumed_at_ms BIGINT NOT NULL COMMENT '消费时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, event_id),
|
||||||
|
KEY idx_room_turnover_reward_event_period (app_code, period_start_ms, room_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励事件消费幂等表'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_turnover_reward_progress (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||||
|
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||||
|
period_end_ms BIGINT NOT NULL COMMENT 'UTC 周期结束',
|
||||||
|
coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '本周期房间送礼金币流水',
|
||||||
|
last_event_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后事件时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, room_id, period_start_ms),
|
||||||
|
KEY idx_room_turnover_reward_progress_period (app_code, period_start_ms, coin_spent)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励周期聚合表'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_turnover_reward_settlements (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
settlement_id VARCHAR(96) NOT NULL COMMENT '结算 ID',
|
||||||
|
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||||
|
owner_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '结算收款房主',
|
||||||
|
period_start_ms BIGINT NOT NULL COMMENT 'UTC 周期开始',
|
||||||
|
period_end_ms BIGINT NOT NULL COMMENT 'UTC 周期结束',
|
||||||
|
coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '周期房间流水',
|
||||||
|
tier_id BIGINT NOT NULL DEFAULT 0 COMMENT '命中档位 ID',
|
||||||
|
tier_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '命中档位编码',
|
||||||
|
threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中档位阈值',
|
||||||
|
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '发放金币',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/granted/failed',
|
||||||
|
wallet_command_id VARCHAR(160) NOT NULL DEFAULT '' COMMENT 'wallet-service 幂等命令 ID',
|
||||||
|
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
|
||||||
|
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||||
|
settled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '成功发放时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, settlement_id),
|
||||||
|
UNIQUE KEY uk_room_turnover_reward_settlement_room_period (app_code, room_id, period_start_ms),
|
||||||
|
KEY idx_room_turnover_reward_settlement_status (app_code, status, period_start_ms, created_at_ms),
|
||||||
|
KEY idx_room_turnover_reward_settlement_owner (app_code, owner_user_id, period_start_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间流水奖励每周结算表'`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if _, err := r.db.ExecContext(ctx, statement); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,562 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/idgen"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetRoomTurnoverRewardConfig 读取房间流水奖励当前配置;未配置是后台首屏正常状态。
|
||||||
|
func (r *Repository) GetRoomTurnoverRewardConfig(ctx context.Context) (domain.Config, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRowContext(ctx, roomTurnoverRewardConfigSelectSQL()+`
|
||||||
|
WHERE app_code = ?`,
|
||||||
|
appcode.FromContext(ctx),
|
||||||
|
)
|
||||||
|
config, err := scanRoomTurnoverRewardConfig(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Config{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, false, err
|
||||||
|
}
|
||||||
|
tiers, err := r.listRoomTurnoverRewardTiers(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, false, err
|
||||||
|
}
|
||||||
|
config.Tiers = tiers
|
||||||
|
return config, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRoomTurnoverRewardConfig 原子替换配置档位;已生成的 settlement 保留命中档位快照。
|
||||||
|
func (r *Repository) UpdateRoomTurnoverRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO room_turnover_reward_configs (
|
||||||
|
app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
enabled = VALUES(enabled),
|
||||||
|
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
appcode.FromContext(ctx), config.Enabled, config.UpdatedByAdminID, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
DELETE FROM room_turnover_reward_tiers
|
||||||
|
WHERE app_code = ?`,
|
||||||
|
appcode.FromContext(ctx),
|
||||||
|
); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
// 配置保存采用“配置行 upsert + 档位整组替换”,让后台表单提交成为完整快照,避免删除档位后旧行继续参与后续匹配。
|
||||||
|
for i := range config.Tiers {
|
||||||
|
tier := config.Tiers[i]
|
||||||
|
tier.CreatedAtMS = nowMS
|
||||||
|
tier.UpdatedAtMS = nowMS
|
||||||
|
if tier.TierID > 0 {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO room_turnover_reward_tiers (
|
||||||
|
tier_id, app_code, tier_code, tier_name, threshold_coin_spent,
|
||||||
|
reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
tier.TierID, appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.ThresholdCoinSpent,
|
||||||
|
tier.RewardCoinAmount, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO room_turnover_reward_tiers (
|
||||||
|
app_code, tier_code, tier_name, threshold_coin_spent,
|
||||||
|
reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.ThresholdCoinSpent,
|
||||||
|
tier.RewardCoinAmount, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
tierID, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
config.Tiers[i].TierID = tierID
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
updated, exists, err := r.GetRoomTurnoverRewardConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return domain.Config{}, xerr.New(xerr.NotFound, "room turnover reward config not found")
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeRoomTurnoverGiftEvent 幂等消费 RoomGiftSent,并累加到房间所在 UTC 周期流水。
|
||||||
|
func (r *Repository) ConsumeRoomTurnoverGiftEvent(ctx context.Context, event domain.RoomGiftEvent, periodStartMS int64, periodEndMS int64, nowMS int64) (domain.EventResult, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
result, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT IGNORE INTO room_turnover_reward_event_consumption (
|
||||||
|
app_code, event_id, event_type, room_id, period_start_ms, coin_spent,
|
||||||
|
status, consumed_at_ms, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 'consumed', ?, ?, ?)`,
|
||||||
|
appcode.FromContext(ctx), event.EventID, event.EventType, event.RoomID, periodStartMS, event.CoinSpent, nowMS, nowMS, nowMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
// event_id 是 room outbox 事实幂等键;重复投递只提交幂等事务,不再累加 progress,保证贡献值不会翻倍。
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusDuplicate}, nil
|
||||||
|
}
|
||||||
|
// progress 是 App H5 和结算任务共用的读模型;同一事务先落消费幂等,再按 UTC 周期原子累加房间流水。
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO room_turnover_reward_progress (
|
||||||
|
app_code, room_id, period_start_ms, period_end_ms, coin_spent,
|
||||||
|
last_event_at_ms, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
coin_spent = coin_spent + VALUES(coin_spent),
|
||||||
|
period_end_ms = VALUES(period_end_ms),
|
||||||
|
last_event_at_ms = GREATEST(last_event_at_ms, VALUES(last_event_at_ms)),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
appcode.FromContext(ctx), event.RoomID, periodStartMS, periodEndMS, event.CoinSpent, event.OccurredAtMS, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoomTurnoverRewardProgress 返回指定房间和 UTC 周期的当前流水。
|
||||||
|
func (r *Repository) GetRoomTurnoverRewardProgress(ctx context.Context, roomID string, periodStartMS int64) (domain.Progress, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Progress{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRowContext(ctx, roomTurnoverRewardProgressSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND room_id = ? AND period_start_ms = ?`,
|
||||||
|
appcode.FromContext(ctx), strings.TrimSpace(roomID), periodStartMS,
|
||||||
|
)
|
||||||
|
item, err := scanRoomTurnoverRewardProgress(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Progress{}, false, nil
|
||||||
|
}
|
||||||
|
return item, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetLatestRoomTurnoverRewardSettlement(ctx context.Context, roomID string) (domain.Settlement, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Settlement{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
// 最新结算给 H5 展示上一周期结果;按 period_start_ms 倒序而不是 status 排序,保证失败和成功都能被用户看到。
|
||||||
|
row := r.db.QueryRowContext(ctx, roomTurnoverRewardSettlementSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND room_id = ?
|
||||||
|
ORDER BY period_start_ms DESC, created_at_ms DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
appcode.FromContext(ctx), strings.TrimSpace(roomID),
|
||||||
|
)
|
||||||
|
item, err := scanRoomTurnoverRewardSettlement(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Settlement{}, false, nil
|
||||||
|
}
|
||||||
|
return item, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoomTurnoverRewardSettlements 返回后台结算记录页;查询只命中 settlement 快照,不回扫事件或 progress。
|
||||||
|
func (r *Repository) ListRoomTurnoverRewardSettlements(ctx context.Context, query domain.SettlementQuery) ([]domain.Settlement, int64, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
// 后台列表只在 settlement 表上分页筛选;流水明细来自周期聚合快照,避免管理页回扫礼物事件。
|
||||||
|
where := []string{"app_code = ?"}
|
||||||
|
args := []any{appcode.FromContext(ctx)}
|
||||||
|
if query.Status != "" {
|
||||||
|
where = append(where, "status = ?")
|
||||||
|
args = append(args, query.Status)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(query.RoomID) != "" {
|
||||||
|
where = append(where, "room_id = ?")
|
||||||
|
args = append(args, strings.TrimSpace(query.RoomID))
|
||||||
|
}
|
||||||
|
if query.OwnerUserID > 0 {
|
||||||
|
where = append(where, "owner_user_id = ?")
|
||||||
|
args = append(args, query.OwnerUserID)
|
||||||
|
}
|
||||||
|
if query.PeriodStartMS > 0 {
|
||||||
|
where = append(where, "period_start_ms = ?")
|
||||||
|
args = append(args, query.PeriodStartMS)
|
||||||
|
}
|
||||||
|
clause := "WHERE " + strings.Join(where, " AND ")
|
||||||
|
var total int64
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_turnover_reward_settlements `+clause, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
pageSize := int(query.PageSize)
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
page := int(query.Page)
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
args = append(args, pageSize, (page-1)*pageSize)
|
||||||
|
rows, err := r.db.QueryContext(ctx, roomTurnoverRewardSettlementSelectSQL()+clause+`
|
||||||
|
ORDER BY period_start_ms DESC, created_at_ms DESC
|
||||||
|
LIMIT ? OFFSET ?`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items, err := scanRoomTurnoverRewardSettlements(rows)
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnsettledRoomTurnoverRewardProgress 找出上一周期有流水但还没有 settlement 的房间,用于 cron 补齐结算记录。
|
||||||
|
func (r *Repository) ListUnsettledRoomTurnoverRewardProgress(ctx context.Context, periodStartMS int64, limit int) ([]domain.Progress, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
// LEFT JOIN settlement 只认领还没有结算快照的 progress;唯一键再兜底一次,防止并发 worker 同时扫描到同一房间。
|
||||||
|
rows, err := r.db.QueryContext(ctx, roomTurnoverRewardProgressSelectSQL()+`
|
||||||
|
LEFT JOIN room_turnover_reward_settlements settlement
|
||||||
|
ON settlement.app_code = progress.app_code
|
||||||
|
AND settlement.room_id = progress.room_id
|
||||||
|
AND settlement.period_start_ms = progress.period_start_ms
|
||||||
|
WHERE progress.app_code = ? AND progress.period_start_ms = ?
|
||||||
|
AND progress.coin_spent > 0 AND settlement.settlement_id IS NULL
|
||||||
|
ORDER BY progress.coin_spent DESC, progress.room_id ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
appcode.FromContext(ctx), periodStartMS, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanRoomTurnoverRewardProgressRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertRoomTurnoverRewardSettlement 创建单房间单周期 settlement;已存在时读回原记录并返回 created=false。
|
||||||
|
func (r *Repository) InsertRoomTurnoverRewardSettlement(ctx context.Context, settlement domain.Settlement, nowMS int64) (domain.Settlement, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Settlement{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
if settlement.SettlementID == "" {
|
||||||
|
settlement.SettlementID = idgen.New("rtrsettle")
|
||||||
|
}
|
||||||
|
if settlement.WalletCommandID == "" {
|
||||||
|
settlement.WalletCommandID = walletRoomTurnoverRewardCommandID(settlement.AppCode, settlement.PeriodStartMS, settlement.RoomID)
|
||||||
|
}
|
||||||
|
settlement.CreatedAtMS = nowMS
|
||||||
|
settlement.UpdatedAtMS = nowMS
|
||||||
|
if settlement.Status == "" {
|
||||||
|
settlement.Status = domain.SettlementStatusPending
|
||||||
|
}
|
||||||
|
// room_id + period_start_ms 是业务唯一键;cron 重跑、并发 worker 或人工补偿都只能为同一房间周期保留一条 settlement。
|
||||||
|
result, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT IGNORE INTO room_turnover_reward_settlements (
|
||||||
|
app_code, settlement_id, room_id, owner_user_id, period_start_ms, period_end_ms,
|
||||||
|
coin_spent, tier_id, tier_code, threshold_coin_spent, reward_coin_amount,
|
||||||
|
status, wallet_command_id, wallet_transaction_id, failure_reason,
|
||||||
|
settled_at_ms, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, 0, ?, ?)`,
|
||||||
|
appcode.FromContext(ctx), settlement.SettlementID, settlement.RoomID, settlement.OwnerUserID,
|
||||||
|
settlement.PeriodStartMS, settlement.PeriodEndMS, settlement.CoinSpent, settlement.TierID,
|
||||||
|
settlement.TierCode, settlement.ThresholdCoinSpent, settlement.RewardCoinAmount,
|
||||||
|
settlement.Status, settlement.WalletCommandID, settlement.FailureReason, nowMS, nowMS,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Settlement{}, false, err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return domain.Settlement{}, false, err
|
||||||
|
}
|
||||||
|
item, exists, err := r.getRoomTurnoverRewardSettlementByRoomPeriod(ctx, settlement.RoomID, settlement.PeriodStartMS)
|
||||||
|
return item, affected > 0 && exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPendingRoomTurnoverRewardSettlements 返回等待发奖的 settlement;按老周期优先处理,避免历史失败恢复后长期排队。
|
||||||
|
func (r *Repository) ListPendingRoomTurnoverRewardSettlements(ctx context.Context, limit int) ([]domain.Settlement, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, roomTurnoverRewardSettlementSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND status = ?
|
||||||
|
ORDER BY period_start_ms ASC, created_at_ms ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
appcode.FromContext(ctx), domain.SettlementStatusPending, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanRoomTurnoverRewardSettlements(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoomTurnoverRewardSettlement 按后台传入的 settlement_id 读取单条结算记录。
|
||||||
|
func (r *Repository) GetRoomTurnoverRewardSettlement(ctx context.Context, settlementID string) (domain.Settlement, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Settlement{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRowContext(ctx, roomTurnoverRewardSettlementSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND settlement_id = ?`,
|
||||||
|
appcode.FromContext(ctx), strings.TrimSpace(settlementID),
|
||||||
|
)
|
||||||
|
item, err := scanRoomTurnoverRewardSettlement(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Settlement{}, false, nil
|
||||||
|
}
|
||||||
|
return item, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetRoomTurnoverRewardSettlementForRetry 把失败记录重新放回 pending,并在 retry 时写入最新可用房主。
|
||||||
|
func (r *Repository) ResetRoomTurnoverRewardSettlementForRetry(ctx context.Context, settlementID string, ownerUserID int64, nowMS int64) (domain.Settlement, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Settlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE room_turnover_reward_settlements
|
||||||
|
SET status = ?, owner_user_id = ?, failure_reason = '', updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND settlement_id = ? AND status <> ?`,
|
||||||
|
domain.SettlementStatusPending, ownerUserID, nowMS, appcode.FromContext(ctx), strings.TrimSpace(settlementID), domain.SettlementStatusGranted,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
// retry 只能把未发奖记录重新放回 pending;已经 granted 的记录不会被改回去,防止后台误点造成二次发奖。
|
||||||
|
item, exists, err := r.GetRoomTurnoverRewardSettlement(ctx, settlementID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return domain.Settlement{}, xerr.New(xerr.NotFound, "room turnover reward settlement not found")
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRoomTurnoverRewardSettlementGranted 记录钱包成功入账结果,是 activity 侧发奖状态的最终成功态。
|
||||||
|
func (r *Repository) MarkRoomTurnoverRewardSettlementGranted(ctx context.Context, settlementID string, walletTransactionID string, grantedAtMS int64) (domain.Settlement, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Settlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
// 只有钱包 RPC 成功后才写 granted,并保存 wallet_transaction_id,后台和对账都以这条链路追踪真实入账。
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE room_turnover_reward_settlements
|
||||||
|
SET status = ?, wallet_transaction_id = ?, failure_reason = '', settled_at_ms = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND settlement_id = ?`,
|
||||||
|
domain.SettlementStatusGranted, walletTransactionID, grantedAtMS, grantedAtMS, appcode.FromContext(ctx), strings.TrimSpace(settlementID),
|
||||||
|
); err != nil {
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
item, exists, err := r.GetRoomTurnoverRewardSettlement(ctx, settlementID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Settlement{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return domain.Settlement{}, xerr.New(xerr.NotFound, "room turnover reward settlement not found")
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRoomTurnoverRewardSettlementFailed 写入可展示、可 retry 的失败原因。
|
||||||
|
func (r *Repository) MarkRoomTurnoverRewardSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
// 失败标记不会覆盖 granted,避免钱包已成功但 activity-service 更新重试时被后续错误回写成失败。
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE room_turnover_reward_settlements
|
||||||
|
SET status = ?, failure_reason = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND settlement_id = ? AND status <> ?`,
|
||||||
|
domain.SettlementStatusFailed, trimFailureReason(reason), nowMS, appcode.FromContext(ctx), strings.TrimSpace(settlementID), domain.SettlementStatusGranted,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getRoomTurnoverRewardSettlementByRoomPeriod(ctx context.Context, roomID string, periodStartMS int64) (domain.Settlement, bool, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, roomTurnoverRewardSettlementSelectSQL()+`
|
||||||
|
WHERE app_code = ? AND room_id = ? AND period_start_ms = ?`,
|
||||||
|
appcode.FromContext(ctx), strings.TrimSpace(roomID), periodStartMS,
|
||||||
|
)
|
||||||
|
item, err := scanRoomTurnoverRewardSettlement(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Settlement{}, false, nil
|
||||||
|
}
|
||||||
|
return item, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardConfigSelectSQL() string {
|
||||||
|
return `SELECT app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||||
|
FROM room_turnover_reward_configs `
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardTierSelectSQL() string {
|
||||||
|
return `SELECT tier_id, app_code, tier_code, tier_name, threshold_coin_spent,
|
||||||
|
reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms
|
||||||
|
FROM room_turnover_reward_tiers `
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardProgressSelectSQL() string {
|
||||||
|
return `SELECT progress.app_code, progress.room_id, progress.period_start_ms, progress.period_end_ms,
|
||||||
|
progress.coin_spent, progress.last_event_at_ms, progress.created_at_ms, progress.updated_at_ms
|
||||||
|
FROM room_turnover_reward_progress progress `
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardSettlementSelectSQL() string {
|
||||||
|
return `SELECT app_code, settlement_id, room_id, owner_user_id, period_start_ms, period_end_ms,
|
||||||
|
coin_spent, tier_id, tier_code, threshold_coin_spent, reward_coin_amount,
|
||||||
|
status, wallet_command_id, wallet_transaction_id, failure_reason,
|
||||||
|
settled_at_ms, created_at_ms, updated_at_ms
|
||||||
|
FROM room_turnover_reward_settlements `
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomTurnoverRewardConfigScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRoomTurnoverRewardConfig(scanner roomTurnoverRewardConfigScanner) (domain.Config, error) {
|
||||||
|
var item domain.Config
|
||||||
|
err := scanner.Scan(&item.AppCode, &item.Enabled, &item.UpdatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) listRoomTurnoverRewardTiers(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
|
||||||
|
query := roomTurnoverRewardTierSelectSQL() + `
|
||||||
|
WHERE app_code = ?
|
||||||
|
ORDER BY sort_order ASC, threshold_coin_spent ASC, tier_id ASC`
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
rows, err = tx.QueryContext(ctx, query, appcode.FromContext(ctx))
|
||||||
|
} else {
|
||||||
|
rows, err = r.db.QueryContext(ctx, query, appcode.FromContext(ctx))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := make([]domain.Tier, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item domain.Tier
|
||||||
|
var app string
|
||||||
|
if err := rows.Scan(&item.TierID, &app, &item.TierCode, &item.TierName, &item.ThresholdCoinSpent, &item.RewardCoinAmount, &item.Status, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomTurnoverRewardProgressScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRoomTurnoverRewardProgress(scanner roomTurnoverRewardProgressScanner) (domain.Progress, error) {
|
||||||
|
var item domain.Progress
|
||||||
|
err := scanner.Scan(&item.AppCode, &item.RoomID, &item.PeriodStartMS, &item.PeriodEndMS, &item.CoinSpent, &item.LastEventAtMS, &item.CreatedAtMS, &item.UpdatedAtMS)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRoomTurnoverRewardProgressRows(rows *sql.Rows) ([]domain.Progress, error) {
|
||||||
|
items := make([]domain.Progress, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanRoomTurnoverRewardProgress(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomTurnoverRewardSettlementScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRoomTurnoverRewardSettlement(scanner roomTurnoverRewardSettlementScanner) (domain.Settlement, error) {
|
||||||
|
var item domain.Settlement
|
||||||
|
err := scanner.Scan(
|
||||||
|
&item.AppCode, &item.SettlementID, &item.RoomID, &item.OwnerUserID,
|
||||||
|
&item.PeriodStartMS, &item.PeriodEndMS, &item.CoinSpent,
|
||||||
|
&item.TierID, &item.TierCode, &item.ThresholdCoinSpent, &item.RewardCoinAmount,
|
||||||
|
&item.Status, &item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason,
|
||||||
|
&item.SettledAtMS, &item.CreatedAtMS, &item.UpdatedAtMS,
|
||||||
|
)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRoomTurnoverRewardSettlements(rows *sql.Rows) ([]domain.Settlement, error) {
|
||||||
|
items := make([]domain.Settlement, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanRoomTurnoverRewardSettlement(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletRoomTurnoverRewardCommandID(app string, periodStartMS int64, roomID string) string {
|
||||||
|
return "room_turnover_reward:" + appcode.Normalize(app) + ":" + int64String(periodStartMS) + ":" + strings.TrimSpace(roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64String(value int64) string {
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimFailureReason(reason string) string {
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if len(reason) > 512 {
|
||||||
|
return reason[:512]
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (r *Repository) ensureWeeklyStarTables(ctx context.Context) error {
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS weekly_star_cycles (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
activity_code VARCHAR(64) NOT NULL DEFAULT 'weekly_star' COMMENT '活动编码',
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域 ID,0 表示默认配置',
|
||||||
|
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '周期标题',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '状态',
|
||||||
|
start_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 生效开始,包含',
|
||||||
|
end_ms BIGINT NOT NULL COMMENT 'UTC epoch ms 生效结束,不包含',
|
||||||
|
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员',
|
||||||
|
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员',
|
||||||
|
settled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算完成时间',
|
||||||
|
locked_by VARCHAR(96) NOT NULL DEFAULT '' COMMENT '结算 worker 锁',
|
||||||
|
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '结算锁过期时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_id),
|
||||||
|
KEY idx_weekly_star_cycle_current (app_code, activity_code, status, region_id, start_ms, end_ms),
|
||||||
|
KEY idx_weekly_star_cycle_due (app_code, activity_code, status, end_ms, lock_until_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星指定礼物积分周期'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS weekly_star_cycle_gifts (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
gift_id VARCHAR(96) NOT NULL COMMENT '参与计分礼物 ID',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_id, gift_id),
|
||||||
|
KEY idx_weekly_star_gift_match (app_code, gift_id, cycle_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星周期指定礼物'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS weekly_star_cycle_rewards (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
rank_no INT NOT NULL COMMENT '排名,1/2/3',
|
||||||
|
resource_group_id BIGINT NOT NULL COMMENT '奖励资源组 ID',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_id, rank_no)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星周期 Top 奖励'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS weekly_star_user_scores (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
score BIGINT NOT NULL DEFAULT 0 COMMENT '累计积分,等于活动礼物消耗金币',
|
||||||
|
first_scored_at_ms BIGINT NOT NULL COMMENT '首次得分时间',
|
||||||
|
last_scored_at_ms BIGINT NOT NULL COMMENT '最近得分时间',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, cycle_id, user_id),
|
||||||
|
KEY idx_weekly_star_score_rank (app_code, cycle_id, score, first_scored_at_ms, user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星用户积分榜'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS weekly_star_score_events (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
source_event_id VARCHAR(128) NOT NULL COMMENT 'room-service outbox event_id',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
|
||||||
|
gift_id VARCHAR(96) NOT NULL COMMENT '礼物 ID',
|
||||||
|
score_delta BIGINT NOT NULL COMMENT '本次增加积分',
|
||||||
|
occurred_at_ms BIGINT NOT NULL COMMENT '事件发生时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, source_event_id),
|
||||||
|
KEY idx_weekly_star_score_event_cycle (app_code, cycle_id, user_id, occurred_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星送礼积分事件幂等表'`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS weekly_star_settlements (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
|
settlement_id VARCHAR(96) NOT NULL COMMENT '周星结算 ID',
|
||||||
|
cycle_id VARCHAR(96) NOT NULL COMMENT '周星周期 ID',
|
||||||
|
rank_no INT NOT NULL COMMENT '排名,1/2/3',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '获奖用户 ID',
|
||||||
|
score BIGINT NOT NULL DEFAULT 0 COMMENT '结算积分',
|
||||||
|
resource_group_id BIGINT NOT NULL COMMENT '奖励资源组 ID',
|
||||||
|
wallet_command_id VARCHAR(128) NOT NULL COMMENT 'wallet 幂等命令',
|
||||||
|
wallet_grant_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'wallet grant ID',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '发放状态',
|
||||||
|
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||||
|
attempt_count INT NOT NULL DEFAULT 0 COMMENT '尝试次数',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, settlement_id),
|
||||||
|
UNIQUE KEY uk_weekly_star_settlement_rank (app_code, cycle_id, rank_no),
|
||||||
|
UNIQUE KEY uk_weekly_star_wallet_command (app_code, wallet_command_id),
|
||||||
|
KEY idx_weekly_star_settlement_cycle (app_code, cycle_id, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='周星 Top 奖励结算'`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if _, err := r.db.ExecContext(ctx, statement); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,876 @@
|
|||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/idgen"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/weeklystar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListWeeklyStarCycles returns admin-configured cycles with gifts and rewards attached for editing.
|
||||||
|
func (r *Repository) ListWeeklyStarCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
where, args := weeklyStarCycleWhere(ctx, query)
|
||||||
|
var total int64
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM weekly_star_cycles `+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
pageSize := normalizePageSize(query.PageSize)
|
||||||
|
page := query.Page
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
|
||||||
|
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
|
||||||
|
FROM weekly_star_cycles `+where+`
|
||||||
|
ORDER BY start_ms DESC, region_id ASC, cycle_id DESC
|
||||||
|
LIMIT ? OFFSET ?`, append(args, int(pageSize), int((page-1)*pageSize))...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
cycles := make([]domain.Cycle, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
cycle, scanErr := scanWeeklyStarCycle(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, 0, scanErr
|
||||||
|
}
|
||||||
|
cycles = append(cycles, cycle)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := r.attachWeeklyStarChildren(ctx, cycles); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return cycles, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWeeklyStarCycle returns the authoritative cycle row and its editable child rows.
|
||||||
|
func (r *Repository) GetWeeklyStarCycle(ctx context.Context, cycleID string) (domain.Cycle, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Cycle{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
cycle, err := r.getWeeklyStarCycle(ctx, r.db, cycleID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
cycles := []domain.Cycle{cycle}
|
||||||
|
if err := r.attachWeeklyStarChildren(ctx, cycles); err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
return cycles[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertWeeklyStarCycle replaces a full cycle configuration in one transaction so gifts and rewards stay consistent.
|
||||||
|
func (r *Repository) UpsertWeeklyStarCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
cycle := command.Cycle
|
||||||
|
cycle.AppCode = appcode.FromContext(ctx)
|
||||||
|
cycle.ActivityCode = domain.ActivityCode
|
||||||
|
if cycle.Status == "" {
|
||||||
|
cycle.Status = domain.StatusDraft
|
||||||
|
}
|
||||||
|
created := strings.TrimSpace(cycle.CycleID) == ""
|
||||||
|
if !created {
|
||||||
|
if _, err := r.getWeeklyStarCycle(ctx, tx, cycle.CycleID); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
} else if !command.RequireNewRecord {
|
||||||
|
return domain.Cycle{}, false, xerr.New(xerr.InvalidArgument, "cycle_id is required")
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
cycle.CycleID = idgen.New("wstar")
|
||||||
|
cycle.CreatedAtMS = nowMS
|
||||||
|
cycle.CreatedByAdminID = command.OperatorAdminID
|
||||||
|
}
|
||||||
|
cycle.UpdatedAtMS = nowMS
|
||||||
|
cycle.UpdatedByAdminID = command.OperatorAdminID
|
||||||
|
if err := r.checkWeeklyStarCycleOverlap(ctx, tx, cycle); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO weekly_star_cycles (
|
||||||
|
app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
|
||||||
|
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||||
|
cycle.AppCode, cycle.CycleID, cycle.ActivityCode, cycle.RegionID, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS,
|
||||||
|
cycle.CreatedByAdminID, cycle.UpdatedByAdminID, cycle.CreatedAtMS, cycle.UpdatedAtMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE weekly_star_cycles
|
||||||
|
SET region_id = ?, title = ?, status = ?, start_ms = ?, end_ms = ?,
|
||||||
|
updated_by_admin_id = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND cycle_id = ?`,
|
||||||
|
cycle.RegionID, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS,
|
||||||
|
cycle.UpdatedByAdminID, cycle.UpdatedAtMS, cycle.AppCode, cycle.CycleID,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM weekly_star_cycle_gifts WHERE app_code = ? AND cycle_id = ?`, cycle.AppCode, cycle.CycleID); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
for index, gift := range cycle.Gifts {
|
||||||
|
sortOrder := gift.SortOrder
|
||||||
|
if sortOrder <= 0 {
|
||||||
|
sortOrder = int32(index + 1)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO weekly_star_cycle_gifts (app_code, cycle_id, gift_id, sort_order, created_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
cycle.AppCode, cycle.CycleID, gift.GiftID, sortOrder, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM weekly_star_cycle_rewards WHERE app_code = ? AND cycle_id = ?`, cycle.AppCode, cycle.CycleID); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
for _, reward := range cycle.Rewards {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO weekly_star_cycle_rewards (app_code, cycle_id, rank_no, resource_group_id, created_at_ms, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
cycle.AppCode, cycle.CycleID, reward.RankNo, reward.ResourceGroupID, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
stored, err := r.GetWeeklyStarCycle(ctx, cycle.CycleID)
|
||||||
|
return stored, created, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWeeklyStarCycleStatus changes visibility without mutating gifts, rewards, or score facts.
|
||||||
|
func (r *Repository) SetWeeklyStarCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Cycle{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
cycle, err := r.getWeeklyStarCycle(ctx, tx, cycleID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
next := cycle
|
||||||
|
next.Status = status
|
||||||
|
if err := r.checkWeeklyStarCycleOverlap(ctx, tx, next); err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE weekly_star_cycles
|
||||||
|
SET status = ?, updated_by_admin_id = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND cycle_id = ?`,
|
||||||
|
status, operatorAdminID, nowMS, appcode.FromContext(ctx), cycleID,
|
||||||
|
); err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.Cycle{}, err
|
||||||
|
}
|
||||||
|
return r.GetWeeklyStarCycle(ctx, cycleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindCurrentWeeklyStarCycle resolves the active concrete-region cycle first and falls back to region 0.
|
||||||
|
func (r *Repository) FindCurrentWeeklyStarCycle(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
|
||||||
|
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
|
||||||
|
FROM weekly_star_cycles
|
||||||
|
WHERE app_code = ? AND activity_code = ? AND status = ? AND start_ms <= ? AND end_ms > ?
|
||||||
|
AND region_id IN (?, 0)
|
||||||
|
ORDER BY CASE WHEN region_id = ? THEN 0 ELSE 1 END, start_ms DESC, updated_at_ms DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, nowMS, nowMS, regionID, regionID,
|
||||||
|
)
|
||||||
|
cycle, err := scanWeeklyStarCycle(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Cycle{}, false, nil
|
||||||
|
}
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
cycles := []domain.Cycle{cycle}
|
||||||
|
if err := r.attachWeeklyStarChildren(ctx, cycles); err != nil {
|
||||||
|
return domain.Cycle{}, false, err
|
||||||
|
}
|
||||||
|
return cycles[0], true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeWeeklyStarGiftEvent stores source-event idempotency before adding the score delta.
|
||||||
|
func (r *Repository) ConsumeWeeklyStarGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
cycle, ok, err := r.findCurrentWeeklyStarCycleForGift(ctx, tx, event.RegionID, event.GiftID, event.OccurredAtMS)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO weekly_star_score_events (
|
||||||
|
app_code, source_event_id, cycle_id, user_id, gift_id, score_delta, occurred_at_ms, created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
appcode.FromContext(ctx), event.EventID, cycle.CycleID, event.UserID, event.GiftID, event.ScoreDelta, event.OccurredAtMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
if isMySQLDuplicate(err) {
|
||||||
|
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusDuplicate, CycleID: cycle.CycleID}, nil
|
||||||
|
}
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO weekly_star_user_scores (
|
||||||
|
app_code, cycle_id, user_id, score, first_scored_at_ms, last_scored_at_ms, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
score = score + VALUES(score),
|
||||||
|
first_scored_at_ms = LEAST(first_scored_at_ms, VALUES(first_scored_at_ms)),
|
||||||
|
last_scored_at_ms = GREATEST(last_scored_at_ms, VALUES(last_scored_at_ms)),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`,
|
||||||
|
appcode.FromContext(ctx), cycle.CycleID, event.UserID, event.ScoreDelta, event.OccurredAtMS, event.OccurredAtMS, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.EventResult{}, err
|
||||||
|
}
|
||||||
|
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed, CycleID: cycle.CycleID, ScoreDelta: event.ScoreDelta}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWeeklyStarLeaderboard returns ranked score rows with stable pagination by offset token.
|
||||||
|
func (r *Repository) ListWeeklyStarLeaderboard(ctx context.Context, cycleID string, regionID int64, pageSize int32, pageToken string, nowMS int64) (domain.Cycle, []domain.LeaderboardEntry, string, int64, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.Cycle{}, nil, "", 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
var cycle domain.Cycle
|
||||||
|
var err error
|
||||||
|
if strings.TrimSpace(cycleID) == "" {
|
||||||
|
var ok bool
|
||||||
|
cycle, ok, err = r.FindCurrentWeeklyStarCycle(ctx, regionID, nowMS)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return domain.Cycle{}, nil, "", 0, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cycle, err = r.GetWeeklyStarCycle(ctx, cycleID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Cycle{}, nil, "", 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size := int(normalizePageSize(pageSize))
|
||||||
|
offset := parseWeeklyStarPageToken(pageToken)
|
||||||
|
var total int64
|
||||||
|
if err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM weekly_star_user_scores WHERE app_code = ? AND cycle_id = ?`,
|
||||||
|
appcode.FromContext(ctx), cycle.CycleID,
|
||||||
|
).Scan(&total); err != nil {
|
||||||
|
return domain.Cycle{}, nil, "", 0, err
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT user_id, score, first_scored_at_ms, last_scored_at_ms
|
||||||
|
FROM weekly_star_user_scores
|
||||||
|
WHERE app_code = ? AND cycle_id = ?
|
||||||
|
ORDER BY score DESC, first_scored_at_ms ASC, user_id ASC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
appcode.FromContext(ctx), cycle.CycleID, size, offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Cycle{}, nil, "", 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
entries := make([]domain.LeaderboardEntry, 0, size)
|
||||||
|
for rows.Next() {
|
||||||
|
var entry domain.LeaderboardEntry
|
||||||
|
entry.RankNo = int32(offset + len(entries) + 1)
|
||||||
|
if err := rows.Scan(&entry.UserID, &entry.Score, &entry.FirstScoredAtMS, &entry.LastScoredAtMS); err != nil {
|
||||||
|
return domain.Cycle{}, nil, "", 0, err
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return domain.Cycle{}, nil, "", 0, err
|
||||||
|
}
|
||||||
|
nextToken := ""
|
||||||
|
if int64(offset+len(entries)) < total {
|
||||||
|
nextToken = strconv.Itoa(offset + len(entries))
|
||||||
|
}
|
||||||
|
return cycle, entries, nextToken, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWeeklyStarUserEntry computes the user's rank using the same score/first-time/user-id ordering as the leaderboard.
|
||||||
|
func (r *Repository) GetWeeklyStarUserEntry(ctx context.Context, cycleID string, userID int64) (domain.LeaderboardEntry, bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return domain.LeaderboardEntry{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
var entry domain.LeaderboardEntry
|
||||||
|
if err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT user_id, score, first_scored_at_ms, last_scored_at_ms
|
||||||
|
FROM weekly_star_user_scores
|
||||||
|
WHERE app_code = ? AND cycle_id = ? AND user_id = ?`,
|
||||||
|
appcode.FromContext(ctx), cycleID, userID,
|
||||||
|
).Scan(&entry.UserID, &entry.Score, &entry.FirstScoredAtMS, &entry.LastScoredAtMS); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.LeaderboardEntry{}, false, nil
|
||||||
|
}
|
||||||
|
return domain.LeaderboardEntry{}, false, err
|
||||||
|
}
|
||||||
|
if err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) + 1
|
||||||
|
FROM weekly_star_user_scores
|
||||||
|
WHERE app_code = ? AND cycle_id = ?
|
||||||
|
AND (score > ? OR (score = ? AND first_scored_at_ms < ?) OR (score = ? AND first_scored_at_ms = ? AND user_id < ?))`,
|
||||||
|
appcode.FromContext(ctx), cycleID, entry.Score, entry.Score, entry.FirstScoredAtMS, entry.Score, entry.FirstScoredAtMS, userID,
|
||||||
|
).Scan(&entry.RankNo); err != nil {
|
||||||
|
return domain.LeaderboardEntry{}, false, err
|
||||||
|
}
|
||||||
|
return entry, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWeeklyStarHistory returns completed cycles, prioritizing a concrete region over default when both exist.
|
||||||
|
func (r *Repository) ListWeeklyStarHistory(ctx context.Context, regionID int64, limit int32, nowMS int64) ([]domain.HistoryCycle, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 50 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
|
||||||
|
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
|
||||||
|
FROM weekly_star_cycles
|
||||||
|
WHERE app_code = ? AND activity_code = ? AND status = ? AND region_id IN (?, 0)
|
||||||
|
ORDER BY end_ms DESC, CASE WHEN region_id = ? THEN 0 ELSE 1 END, cycle_id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
appcode.FromContext(ctx), domain.ActivityCode, domain.StatusSettled, regionID, regionID, int(limit),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
history := make([]domain.HistoryCycle, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
cycle, scanErr := scanWeeklyStarCycle(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
_, entries, _, _, listErr := r.ListWeeklyStarLeaderboard(ctx, cycle.CycleID, regionID, 3, "", nowMS)
|
||||||
|
if listErr != nil {
|
||||||
|
return nil, listErr
|
||||||
|
}
|
||||||
|
history = append(history, domain.HistoryCycle{Cycle: cycle, TopEntries: entries})
|
||||||
|
}
|
||||||
|
return history, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWeeklyStarSettlements returns the wallet grant jobs created for a cycle settlement.
|
||||||
|
func (r *Repository) ListWeeklyStarSettlements(ctx context.Context, cycleID string) ([]domain.Settlement, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT app_code, settlement_id, cycle_id, rank_no, user_id, score, resource_group_id,
|
||||||
|
wallet_command_id, wallet_grant_id, status, failure_reason, attempt_count, created_at_ms, updated_at_ms
|
||||||
|
FROM weekly_star_settlements
|
||||||
|
WHERE app_code = ? AND cycle_id = ?
|
||||||
|
ORDER BY rank_no ASC`,
|
||||||
|
appcode.FromContext(ctx), cycleID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
settlements := make([]domain.Settlement, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
settlement, scanErr := scanWeeklyStarSettlement(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
settlements = append(settlements, settlement)
|
||||||
|
}
|
||||||
|
return settlements, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimDueWeeklyStarCycles locks ended cycles so only one cron worker prepares their Top rewards.
|
||||||
|
// 这里允许认领 active 到期周期,也允许认领锁过期的 settling 周期;后者用于 worker 崩溃后的自动恢复。
|
||||||
|
// FOR UPDATE SKIP LOCKED 保证多 cron worker 并发时互不等待,每个周期最多被一个事务改成 settling。
|
||||||
|
func (r *Repository) ClaimDueWeeklyStarCycles(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.Cycle, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
if batchSize <= 0 {
|
||||||
|
batchSize = 50
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
|
||||||
|
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
|
||||||
|
FROM weekly_star_cycles
|
||||||
|
WHERE app_code = ? AND activity_code = ? AND end_ms <= ?
|
||||||
|
AND ((status = ?) OR (status = ? AND lock_until_ms <= ?))
|
||||||
|
ORDER BY end_ms ASC, cycle_id ASC
|
||||||
|
LIMIT ? FOR UPDATE SKIP LOCKED`,
|
||||||
|
appcode.FromContext(ctx), domain.ActivityCode, nowMS, domain.StatusActive, domain.StatusSettling, nowMS, int(batchSize),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// go-sql-driver/mysql 不支持在同一个连接还持有未关闭 rows 时继续执行下一条语句。
|
||||||
|
// 所以先把待认领周期完整读入内存并显式 Close,再执行后续 UPDATE,避免出现 driver: bad connection。
|
||||||
|
cycles := make([]domain.Cycle, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
cycle, scanErr := scanWeeklyStarCycle(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
cycles = append(cycles, cycle)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lockUntil := nowMS + lockTTL.Milliseconds()
|
||||||
|
for _, cycle := range cycles {
|
||||||
|
// 只更新本事务已经 FOR UPDATE 锁住的行;lock_until_ms 是恢复窗口,不是业务结束时间。
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE weekly_star_cycles
|
||||||
|
SET status = ?, locked_by = ?, lock_until_ms = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND cycle_id = ?`,
|
||||||
|
domain.StatusSettling, workerID, lockUntil, nowMS, appcode.FromContext(ctx), cycle.CycleID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cycles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareWeeklyStarSettlements materializes Top1/Top2/Top3 exactly once before wallet grants are attempted.
|
||||||
|
// 第一次进入时把当前榜单固化为 weekly_star_settlements,之后重试只读取 pending/failed 记录。
|
||||||
|
// 这保证周期结束后的 Top3 快照不会因为迟到事件、重跑 cron 或后台刷新而变化。
|
||||||
|
func (r *Repository) PrepareWeeklyStarSettlements(ctx context.Context, cycleID string, nowMS int64) ([]domain.Settlement, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var existing int
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM weekly_star_settlements WHERE app_code = ? AND cycle_id = ?`,
|
||||||
|
appcode.FromContext(ctx), cycleID,
|
||||||
|
).Scan(&existing); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing == 0 {
|
||||||
|
// settlement 表以 cycle_id+rank_no 做唯一约束;即使 cron 重入,也只会固化一次 Top 排名。
|
||||||
|
if err := r.insertWeeklyStarSettlements(ctx, tx, cycleID, nowMS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT app_code, settlement_id, cycle_id, rank_no, user_id, score, resource_group_id,
|
||||||
|
wallet_command_id, wallet_grant_id, status, failure_reason, attempt_count, created_at_ms, updated_at_ms
|
||||||
|
FROM weekly_star_settlements
|
||||||
|
WHERE app_code = ? AND cycle_id = ? AND status IN (?, ?)
|
||||||
|
ORDER BY rank_no ASC FOR UPDATE`,
|
||||||
|
appcode.FromContext(ctx), cycleID, domain.SettlementStatusPending, domain.SettlementStatusFailed,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 这里同样必须先读完并关闭 rows,再把选中的 settlement 更新为 running。
|
||||||
|
// 否则同一事务中的 UPDATE 会复用被 rows 占用的 MySQL 连接,导致 driver: bad connection。
|
||||||
|
settlements := make([]domain.Settlement, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
settlement, scanErr := scanWeeklyStarSettlement(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
settlements = append(settlements, settlement)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, settlement := range settlements {
|
||||||
|
// running 只表示本轮 cron 正在尝试发放;失败会回到 failed,成功会变成 granted。
|
||||||
|
// attempt_count 在状态切换时递增,方便后台排查反复失败的奖励。
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE weekly_star_settlements
|
||||||
|
SET status = ?, attempt_count = attempt_count + 1, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND settlement_id = ?`,
|
||||||
|
domain.SettlementStatusRunning, nowMS, appcode.FromContext(ctx), settlement.SettlementID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return settlements, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkWeeklyStarSettlementGranted records the wallet grant id after wallet-service idempotency succeeds.
|
||||||
|
// wallet_grant_id 是后续审计和客服排障的跨服务凭证,不能只依赖 activity 侧 settlement_id。
|
||||||
|
func (r *Repository) MarkWeeklyStarSettlementGranted(ctx context.Context, settlementID string, walletGrantID string, nowMS int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE weekly_star_settlements
|
||||||
|
SET status = ?, wallet_grant_id = ?, failure_reason = '', updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND settlement_id = ?`,
|
||||||
|
domain.SettlementStatusGranted, walletGrantID, nowMS, appcode.FromContext(ctx), settlementID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkWeeklyStarSettlementFailed unlocks one grant job for a later cron retry.
|
||||||
|
// failed 不是终态;下一轮 PrepareWeeklyStarSettlements 会重新锁定它,并用同一 wallet_command_id 继续重试。
|
||||||
|
func (r *Repository) MarkWeeklyStarSettlementFailed(ctx context.Context, settlementID string, reason string, nowMS int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE weekly_star_settlements
|
||||||
|
SET status = ?, failure_reason = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND settlement_id = ?`,
|
||||||
|
domain.SettlementStatusFailed, truncateWeeklyStarFailure(reason), nowMS, appcode.FromContext(ctx), settlementID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishWeeklyStarCycleIfComplete marks a cycle settled only after every materialized grant succeeded.
|
||||||
|
// 只要存在非 granted 的 settlement,就释放 cycle 锁但保持 settling,等待下一轮 cron 补偿。
|
||||||
|
// 全部发放成功后才写 settled_at_ms,H5 历史榜和后台结算记录才会把这个周期视为已完结。
|
||||||
|
func (r *Repository) FinishWeeklyStarCycleIfComplete(ctx context.Context, cycleID string, nowMS int64) (bool, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
var pending int
|
||||||
|
if err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(*) FROM weekly_star_settlements
|
||||||
|
WHERE app_code = ? AND cycle_id = ? AND status <> ?`,
|
||||||
|
appcode.FromContext(ctx), cycleID, domain.SettlementStatusGranted,
|
||||||
|
).Scan(&pending); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if pending > 0 {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE weekly_star_cycles
|
||||||
|
SET locked_by = '', lock_until_ms = 0, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND cycle_id = ?`,
|
||||||
|
nowMS, appcode.FromContext(ctx), cycleID,
|
||||||
|
)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE weekly_star_cycles
|
||||||
|
SET status = ?, settled_at_ms = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND cycle_id = ?`,
|
||||||
|
domain.StatusSettled, nowMS, nowMS, appcode.FromContext(ctx), cycleID,
|
||||||
|
)
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) checkWeeklyStarCycleOverlap(ctx context.Context, tx *sql.Tx, cycle domain.Cycle) error {
|
||||||
|
if cycle.Status != domain.StatusActive {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 同一 app/activity/region 下 active 周期不能时间重叠;region_id=0 的默认周期可以和具体区域周期并存。
|
||||||
|
// 具体区域优先级由读取 current 时的 ORDER BY 决定,这里只约束同一区域自身不重叠。
|
||||||
|
var overlapID string
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT cycle_id
|
||||||
|
FROM weekly_star_cycles
|
||||||
|
WHERE app_code = ? AND activity_code = ? AND region_id = ? AND status = ? AND cycle_id <> ?
|
||||||
|
AND start_ms < ? AND end_ms > ?
|
||||||
|
LIMIT 1`,
|
||||||
|
cycle.AppCode, domain.ActivityCode, cycle.RegionID, domain.StatusActive, cycle.CycleID, cycle.EndMS, cycle.StartMS,
|
||||||
|
).Scan(&overlapID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return xerr.New(xerr.Conflict, "weekly star active cycle overlaps existing cycle")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) findCurrentWeeklyStarCycleForGift(ctx context.Context, tx *sql.Tx, regionID int64, giftID string, occurredAtMS int64) (domain.Cycle, bool, error) {
|
||||||
|
// 送礼事件按 occurred_at_ms 命中周期,而不是按消费时的系统时间命中。
|
||||||
|
// 这样 MQ 延迟或手动补偿不会把旧周期的礼物误计到当前周期;具体区域优先于 region_id=0 默认配置。
|
||||||
|
row := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT c.app_code, c.cycle_id, c.activity_code, c.region_id, c.title, c.status, c.start_ms, c.end_ms,
|
||||||
|
c.created_by_admin_id, c.updated_by_admin_id, c.settled_at_ms, c.created_at_ms, c.updated_at_ms
|
||||||
|
FROM weekly_star_cycles c
|
||||||
|
JOIN weekly_star_cycle_gifts g ON g.app_code = c.app_code AND g.cycle_id = c.cycle_id
|
||||||
|
WHERE c.app_code = ? AND c.activity_code = ? AND c.status = ? AND c.start_ms <= ? AND c.end_ms > ?
|
||||||
|
AND c.region_id IN (?, 0) AND g.gift_id = ?
|
||||||
|
ORDER BY CASE WHEN c.region_id = ? THEN 0 ELSE 1 END, c.start_ms DESC, c.updated_at_ms DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, occurredAtMS, occurredAtMS, regionID, giftID, regionID,
|
||||||
|
)
|
||||||
|
cycle, err := scanWeeklyStarCycle(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Cycle{}, false, nil
|
||||||
|
}
|
||||||
|
return cycle, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) insertWeeklyStarSettlements(ctx context.Context, tx *sql.Tx, cycleID string, nowMS int64) error {
|
||||||
|
// 排名规则必须和榜单查询一致:score 高优先,首次得分早优先,user_id 小优先。
|
||||||
|
// 子查询先取 Top3,再用同一排序条件反算 rank_no,确保 Top1/2/3 对应正确资源组。
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT s.user_id, s.score, s.first_scored_at_ms, r.rank_no, r.resource_group_id
|
||||||
|
FROM (
|
||||||
|
SELECT user_id, score, first_scored_at_ms
|
||||||
|
FROM weekly_star_user_scores
|
||||||
|
WHERE app_code = ? AND cycle_id = ?
|
||||||
|
ORDER BY score DESC, first_scored_at_ms ASC, user_id ASC
|
||||||
|
LIMIT 3
|
||||||
|
) s
|
||||||
|
JOIN weekly_star_cycle_rewards r ON r.app_code = ? AND r.cycle_id = ? AND r.rank_no =
|
||||||
|
(SELECT COUNT(*) + 1 FROM weekly_star_user_scores x
|
||||||
|
WHERE x.app_code = ? AND x.cycle_id = ?
|
||||||
|
AND (x.score > s.score OR (x.score = s.score AND x.first_scored_at_ms < s.first_scored_at_ms) OR (x.score = s.score AND x.first_scored_at_ms = s.first_scored_at_ms AND x.user_id < s.user_id)))
|
||||||
|
ORDER BY r.rank_no ASC`,
|
||||||
|
appcode.FromContext(ctx), cycleID, appcode.FromContext(ctx), cycleID, appcode.FromContext(ctx), cycleID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type topRow struct {
|
||||||
|
userID int64
|
||||||
|
score int64
|
||||||
|
rankNo int32
|
||||||
|
resourceGroupID int64
|
||||||
|
}
|
||||||
|
// 先缓存 Top3 再插入 settlement,原因同上:同一 MySQL 事务里不能边遍历 rows 边继续写表。
|
||||||
|
topRows := make([]topRow, 0, 3)
|
||||||
|
for rows.Next() {
|
||||||
|
var firstScoredAtMS int64
|
||||||
|
var item topRow
|
||||||
|
if err := rows.Scan(&item.userID, &item.score, &firstScoredAtMS, &item.rankNo, &item.resourceGroupID); err != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
topRows = append(topRows, item)
|
||||||
|
_ = firstScoredAtMS
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
_ = rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range topRows {
|
||||||
|
settlementID := idgen.New("wstars")
|
||||||
|
// wallet_command_id 必须稳定包含 cycle/rank/user;结算重跑或 wallet 超时重试时依赖它防重发。
|
||||||
|
commandID := fmt.Sprintf("weekly_star:%s:%d:%d", cycleID, item.rankNo, item.userID)
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO weekly_star_settlements (
|
||||||
|
app_code, settlement_id, cycle_id, rank_no, user_id, score, resource_group_id,
|
||||||
|
wallet_command_id, wallet_grant_id, status, failure_reason, attempt_count, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, '', 0, ?, ?)`,
|
||||||
|
appcode.FromContext(ctx), settlementID, cycleID, item.rankNo, item.userID, item.score, item.resourceGroupID,
|
||||||
|
commandID, domain.SettlementStatusPending, nowMS, nowMS,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getWeeklyStarCycle(ctx context.Context, q interface {
|
||||||
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||||
|
}, cycleID string) (domain.Cycle, error) {
|
||||||
|
row := q.QueryRowContext(ctx, `
|
||||||
|
SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
|
||||||
|
created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
|
||||||
|
FROM weekly_star_cycles
|
||||||
|
WHERE app_code = ? AND cycle_id = ?`,
|
||||||
|
appcode.FromContext(ctx), strings.TrimSpace(cycleID),
|
||||||
|
)
|
||||||
|
cycle, err := scanWeeklyStarCycle(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.Cycle{}, xerr.New(xerr.NotFound, "weekly star cycle not found")
|
||||||
|
}
|
||||||
|
return cycle, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) attachWeeklyStarChildren(ctx context.Context, cycles []domain.Cycle) error {
|
||||||
|
if len(cycles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for index := range cycles {
|
||||||
|
gifts, err := r.listWeeklyStarGifts(ctx, cycles[index].CycleID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rewards, err := r.listWeeklyStarRewards(ctx, cycles[index].CycleID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cycles[index].Gifts = gifts
|
||||||
|
cycles[index].Rewards = rewards
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) listWeeklyStarGifts(ctx context.Context, cycleID string) ([]domain.Gift, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT app_code, cycle_id, gift_id, sort_order
|
||||||
|
FROM weekly_star_cycle_gifts
|
||||||
|
WHERE app_code = ? AND cycle_id = ?
|
||||||
|
ORDER BY sort_order ASC, gift_id ASC`,
|
||||||
|
appcode.FromContext(ctx), cycleID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
gifts := make([]domain.Gift, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var gift domain.Gift
|
||||||
|
if err := rows.Scan(&gift.AppCode, &gift.CycleID, &gift.GiftID, &gift.SortOrder); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gifts = append(gifts, gift)
|
||||||
|
}
|
||||||
|
return gifts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) listWeeklyStarRewards(ctx context.Context, cycleID string) ([]domain.Reward, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT app_code, cycle_id, rank_no, resource_group_id
|
||||||
|
FROM weekly_star_cycle_rewards
|
||||||
|
WHERE app_code = ? AND cycle_id = ?
|
||||||
|
ORDER BY rank_no ASC`,
|
||||||
|
appcode.FromContext(ctx), cycleID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
rewards := make([]domain.Reward, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var reward domain.Reward
|
||||||
|
if err := rows.Scan(&reward.AppCode, &reward.CycleID, &reward.RankNo, &reward.ResourceGroupID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rewards = append(rewards, reward)
|
||||||
|
}
|
||||||
|
return rewards, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarCycleScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWeeklyStarCycle(row weeklyStarCycleScanner) (domain.Cycle, error) {
|
||||||
|
var cycle domain.Cycle
|
||||||
|
err := row.Scan(&cycle.AppCode, &cycle.CycleID, &cycle.ActivityCode, &cycle.RegionID, &cycle.Title, &cycle.Status,
|
||||||
|
&cycle.StartMS, &cycle.EndMS, &cycle.CreatedByAdminID, &cycle.UpdatedByAdminID, &cycle.SettledAtMS, &cycle.CreatedAtMS, &cycle.UpdatedAtMS)
|
||||||
|
return cycle, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWeeklyStarSettlement(row weeklyStarCycleScanner) (domain.Settlement, error) {
|
||||||
|
var settlement domain.Settlement
|
||||||
|
err := row.Scan(&settlement.AppCode, &settlement.SettlementID, &settlement.CycleID, &settlement.RankNo, &settlement.UserID,
|
||||||
|
&settlement.Score, &settlement.ResourceGroupID, &settlement.WalletCommandID, &settlement.WalletGrantID, &settlement.Status,
|
||||||
|
&settlement.FailureReason, &settlement.AttemptCount, &settlement.CreatedAtMS, &settlement.UpdatedAtMS)
|
||||||
|
return settlement, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarCycleWhere(ctx context.Context, query domain.ListQuery) (string, []any) {
|
||||||
|
conditions := []string{"app_code = ?", "activity_code = ?"}
|
||||||
|
args := []any{appcode.FromContext(ctx), domain.ActivityCode}
|
||||||
|
if query.RegionID != nil {
|
||||||
|
conditions = append(conditions, "region_id = ?")
|
||||||
|
args = append(args, *query.RegionID)
|
||||||
|
}
|
||||||
|
if query.Status != "" {
|
||||||
|
conditions = append(conditions, "status = ?")
|
||||||
|
args = append(args, query.Status)
|
||||||
|
}
|
||||||
|
if query.StartMS > 0 {
|
||||||
|
conditions = append(conditions, "end_ms > ?")
|
||||||
|
args = append(args, query.StartMS)
|
||||||
|
}
|
||||||
|
if query.EndMS > 0 {
|
||||||
|
conditions = append(conditions, "start_ms < ?")
|
||||||
|
args = append(args, query.EndMS)
|
||||||
|
}
|
||||||
|
return "WHERE " + strings.Join(conditions, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWeeklyStarPageToken(value string) int {
|
||||||
|
offset, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil || offset < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateWeeklyStarFailure(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) <= 512 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:512]
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ import (
|
|||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
||||||
growthservice "hyapp/services/activity-service/internal/service/growth"
|
growthservice "hyapp/services/activity-service/internal/service/growth"
|
||||||
|
roomturnoverrewardservice "hyapp/services/activity-service/internal/service/roomturnoverreward"
|
||||||
|
weeklystarservice "hyapp/services/activity-service/internal/service/weeklystar"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BroadcastServer 把 IM 播报能力暴露为内部 gRPC。
|
// BroadcastServer 把 IM 播报能力暴露为内部 gRPC。
|
||||||
@ -17,15 +19,22 @@ type BroadcastServer struct {
|
|||||||
activityv1.UnimplementedBroadcastServiceServer
|
activityv1.UnimplementedBroadcastServiceServer
|
||||||
activityv1.UnimplementedRoomEventConsumerServiceServer
|
activityv1.UnimplementedRoomEventConsumerServiceServer
|
||||||
|
|
||||||
svc *broadcastservice.Service
|
svc *broadcastservice.Service
|
||||||
growth *growthservice.Service
|
growth *growthservice.Service
|
||||||
|
roomReward *roomturnoverrewardservice.Service
|
||||||
|
weeklyStar *weeklystarservice.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBroadcastServer 创建播报和 room event 消费共用的 gRPC adapter。
|
// NewBroadcastServer 创建播报和 room event 消费共用的 gRPC adapter。
|
||||||
func NewBroadcastServer(svc *broadcastservice.Service, growthSvc ...*growthservice.Service) *BroadcastServer {
|
// room-service 的 outbox 事件只投递一次,但 activity-service 内部有多条独立投影:
|
||||||
|
// 播报负责 IM 展示,growth 负责等级进度,weekly-star 负责指定礼物榜单,roomReward 负责房间流水。
|
||||||
|
// 这里把这些模块都挂到同一个 gRPC 入口,保证本地直连投递和 MQ 投递使用同一组业务副作用。
|
||||||
|
func NewBroadcastServer(svc *broadcastservice.Service, growthSvc *growthservice.Service, weeklyStarSvc *weeklystarservice.Service, roomRewardSvc ...*roomturnoverrewardservice.Service) *BroadcastServer {
|
||||||
server := &BroadcastServer{svc: svc}
|
server := &BroadcastServer{svc: svc}
|
||||||
if len(growthSvc) > 0 {
|
server.growth = growthSvc
|
||||||
server.growth = growthSvc[0]
|
server.weeklyStar = weeklyStarSvc
|
||||||
|
if len(roomRewardSvc) > 0 {
|
||||||
|
server.roomReward = roomRewardSvc[0]
|
||||||
}
|
}
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
@ -99,7 +108,9 @@ func (s *BroadcastServer) ProcessBroadcastOutboxBatch(ctx context.Context, req *
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *BroadcastServer) ConsumeRoomEvent(ctx context.Context, req *activityv1.ConsumeRoomEventRequest) (*activityv1.ConsumeRoomEventResponse, error) {
|
func (s *BroadcastServer) ConsumeRoomEvent(ctx context.Context, req *activityv1.ConsumeRoomEventRequest) (*activityv1.ConsumeRoomEventResponse, error) {
|
||||||
// room-service 只推送已提交的 outbox envelope;播报和等级都以同一个 event_id 幂等消费。
|
// room-service 只推送已提交的 outbox envelope;每个下游模块都以同一个 event_id 做自己的幂等表。
|
||||||
|
// 调用顺序保持和 MQ consumer 一致:先处理可见播报,再处理用户成长,再处理运营活动统计。
|
||||||
|
// 任一模块返回错误时直接失败,让 room-service/MQ 重试同一个 envelope,而不是吞掉部分副作用。
|
||||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
result, err := s.svc.HandleRoomEvent(ctx, req.GetEnvelope())
|
result, err := s.svc.HandleRoomEvent(ctx, req.GetEnvelope())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -110,6 +121,16 @@ func (s *BroadcastServer) ConsumeRoomEvent(ctx context.Context, req *activityv1.
|
|||||||
return nil, xerr.ToGRPCError(err)
|
return nil, xerr.ToGRPCError(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.weeklyStar != nil {
|
||||||
|
if _, err := s.weeklyStar.HandleRoomEvent(ctx, req.GetEnvelope()); err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.roomReward != nil {
|
||||||
|
if _, err := s.roomReward.HandleRoomEvent(ctx, req.GetEnvelope()); err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return &activityv1.ConsumeRoomEventResponse{
|
return &activityv1.ConsumeRoomEventResponse{
|
||||||
EventId: result.EventID,
|
EventId: result.EventID,
|
||||||
Status: result.Status,
|
Status: result.Status,
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import (
|
|||||||
achievementservice "hyapp/services/activity-service/internal/service/achievement"
|
achievementservice "hyapp/services/activity-service/internal/service/achievement"
|
||||||
growthservice "hyapp/services/activity-service/internal/service/growth"
|
growthservice "hyapp/services/activity-service/internal/service/growth"
|
||||||
messageservice "hyapp/services/activity-service/internal/service/message"
|
messageservice "hyapp/services/activity-service/internal/service/message"
|
||||||
|
roomturnoverrewardservice "hyapp/services/activity-service/internal/service/roomturnoverreward"
|
||||||
|
weeklystarservice "hyapp/services/activity-service/internal/service/weeklystar"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CronServer exposes activity-service owned background batches to cron-service.
|
// CronServer exposes activity-service owned background batches to cron-service.
|
||||||
@ -19,14 +21,18 @@ type CronServer struct {
|
|||||||
message *messageservice.Service
|
message *messageservice.Service
|
||||||
growth *growthservice.Service
|
growth *growthservice.Service
|
||||||
achievement *achievementservice.Service
|
achievement *achievementservice.Service
|
||||||
|
weeklyStar *weeklystarservice.Service
|
||||||
|
roomReward *roomturnoverrewardservice.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCronServer creates the internal cron adapter without exposing message storage.
|
// NewCronServer creates the internal cron adapter without exposing message storage.
|
||||||
func NewCronServer(messageSvc *messageservice.Service, growthSvc *growthservice.Service, achievementSvc ...*achievementservice.Service) *CronServer {
|
func NewCronServer(messageSvc *messageservice.Service, growthSvc *growthservice.Service, achievementSvc *achievementservice.Service, weeklyStarSvc *weeklystarservice.Service, roomRewardSvc ...*roomturnoverrewardservice.Service) *CronServer {
|
||||||
server := &CronServer{message: messageSvc}
|
server := &CronServer{message: messageSvc}
|
||||||
server.growth = growthSvc
|
server.growth = growthSvc
|
||||||
if len(achievementSvc) > 0 {
|
server.achievement = achievementSvc
|
||||||
server.achievement = achievementSvc[0]
|
server.weeklyStar = weeklyStarSvc
|
||||||
|
if len(roomRewardSvc) > 0 {
|
||||||
|
server.roomReward = roomRewardSvc[0]
|
||||||
}
|
}
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
@ -94,6 +100,45 @@ func (s *CronServer) ProcessAchievementRewardBatch(ctx context.Context, req *act
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessWeeklyStarSettlementBatch claims ended weekly-star cycles and grants Top rewards.
|
||||||
|
func (s *CronServer) ProcessWeeklyStarSettlementBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.CronBatchResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
if s.weeklyStar == nil {
|
||||||
|
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "weekly star service is not configured"))
|
||||||
|
}
|
||||||
|
claimed, processed, success, failure, hasMore, err := s.weeklyStar.ProcessSettlementBatch(ctx, req.GetRunId(), req.GetWorkerId(), req.GetBatchSize(), durationFromMillis(req.GetLockTtlMs()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.CronBatchResponse{
|
||||||
|
ClaimedCount: claimed,
|
||||||
|
ProcessedCount: processed,
|
||||||
|
SuccessCount: success,
|
||||||
|
FailureCount: failure,
|
||||||
|
HasMore: hasMore,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessRoomTurnoverRewardSettlementBatch closes the previous UTC week and grants pending room rewards.
|
||||||
|
func (s *CronServer) ProcessRoomTurnoverRewardSettlementBatch(ctx context.Context, req *activityv1.CronBatchRequest) (*activityv1.CronBatchResponse, error) {
|
||||||
|
// cron-service 只负责调度,activity-service 才拥有上一 UTC 周期认领 settlement 和发奖状态机。
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
if s.roomReward == nil {
|
||||||
|
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room turnover reward service is not configured"))
|
||||||
|
}
|
||||||
|
result, err := s.roomReward.ProcessSettlementBatch(ctx, int(req.GetBatchSize()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.CronBatchResponse{
|
||||||
|
ClaimedCount: result.ClaimedCount,
|
||||||
|
ProcessedCount: result.ProcessedCount,
|
||||||
|
SuccessCount: result.SuccessCount,
|
||||||
|
FailureCount: result.FailureCount,
|
||||||
|
HasMore: result.HasMore,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func durationFromMillis(value int64) time.Duration {
|
func durationFromMillis(value int64) time.Duration {
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -0,0 +1,212 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/cumulativerecharge"
|
||||||
|
service "hyapp/services/activity-service/internal/service/cumulativerecharge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardServer 暴露 App 累充奖励查询和内部充值事实消费入口。
|
||||||
|
type CumulativeRechargeRewardServer struct {
|
||||||
|
activityv1.UnimplementedCumulativeRechargeRewardServiceServer
|
||||||
|
|
||||||
|
svc *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCumulativeRechargeRewardServer(svc *service.Service) *CumulativeRechargeRewardServer {
|
||||||
|
return &CumulativeRechargeRewardServer{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CumulativeRechargeRewardServer) GetCumulativeRechargeRewardStatus(ctx context.Context, req *activityv1.GetCumulativeRechargeRewardStatusRequest) (*activityv1.GetCumulativeRechargeRewardStatusResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
status, err := s.svc.GetStatus(ctx, req.GetUserId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.GetCumulativeRechargeRewardStatusResponse{Status: cumulativeRechargeRewardStatusToProto(status)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CumulativeRechargeRewardServer) ConsumeCumulativeRechargeReward(ctx context.Context, req *activityv1.ConsumeCumulativeRechargeRewardRequest) (*activityv1.ConsumeCumulativeRechargeRewardResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
grants, consumed, reason, err := s.svc.Consume(ctx, domain.RechargeEvent{
|
||||||
|
AppCode: req.GetMeta().GetAppCode(),
|
||||||
|
EventID: req.GetEventId(),
|
||||||
|
TransactionID: req.GetTransactionId(),
|
||||||
|
CommandID: req.GetCommandId(),
|
||||||
|
UserID: req.GetUserId(),
|
||||||
|
RechargeCoinAmount: req.GetRechargeCoinAmount(),
|
||||||
|
RechargeSequence: req.GetRechargeSequence(),
|
||||||
|
RechargeType: req.GetRechargeType(),
|
||||||
|
QualifyingUSDMinor: req.GetQualifyingUsdMinor(),
|
||||||
|
PayloadJSON: req.GetPayloadJson(),
|
||||||
|
OccurredAtMS: req.GetOccurredAtMs(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
resp := &activityv1.ConsumeCumulativeRechargeRewardResponse{
|
||||||
|
Grants: make([]*activityv1.CumulativeRechargeRewardGrant, 0, len(grants)),
|
||||||
|
Consumed: consumed,
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
for _, grant := range grants {
|
||||||
|
resp.Grants = append(resp.Grants, cumulativeRechargeRewardGrantToProto(grant))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCumulativeRechargeRewardServer 暴露后台配置和发放记录查询入口。
|
||||||
|
type AdminCumulativeRechargeRewardServer struct {
|
||||||
|
activityv1.UnimplementedAdminCumulativeRechargeRewardServiceServer
|
||||||
|
|
||||||
|
svc *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminCumulativeRechargeRewardServer(svc *service.Service) *AdminCumulativeRechargeRewardServer {
|
||||||
|
return &AdminCumulativeRechargeRewardServer{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminCumulativeRechargeRewardServer) GetCumulativeRechargeRewardConfig(ctx context.Context, req *activityv1.GetCumulativeRechargeRewardConfigRequest) (*activityv1.GetCumulativeRechargeRewardConfigResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
config, err := s.svc.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.GetCumulativeRechargeRewardConfigResponse{Config: cumulativeRechargeRewardConfigToProto(config)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminCumulativeRechargeRewardServer) UpdateCumulativeRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateCumulativeRechargeRewardConfigRequest) (*activityv1.UpdateCumulativeRechargeRewardConfigResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
tiers := make([]domain.Tier, 0, len(req.GetTiers()))
|
||||||
|
for _, tier := range req.GetTiers() {
|
||||||
|
tiers = append(tiers, domain.Tier{
|
||||||
|
TierID: tier.GetTierId(),
|
||||||
|
TierCode: tier.GetTierCode(),
|
||||||
|
TierName: tier.GetTierName(),
|
||||||
|
ThresholdUSDMinor: tier.GetThresholdUsdMinor(),
|
||||||
|
ResourceGroupID: tier.GetResourceGroupId(),
|
||||||
|
Status: tier.GetStatus(),
|
||||||
|
SortOrder: tier.GetSortOrder(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
config, err := s.svc.UpdateConfig(ctx, domain.Config{
|
||||||
|
Enabled: req.GetEnabled(),
|
||||||
|
Tiers: tiers,
|
||||||
|
UpdatedByAdminID: req.GetOperatorAdminId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.UpdateCumulativeRechargeRewardConfigResponse{Config: cumulativeRechargeRewardConfigToProto(config)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminCumulativeRechargeRewardServer) ListCumulativeRechargeRewardGrants(ctx context.Context, req *activityv1.ListCumulativeRechargeRewardGrantsRequest) (*activityv1.ListCumulativeRechargeRewardGrantsResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
grants, total, err := s.svc.ListGrants(ctx, domain.GrantQuery{
|
||||||
|
Status: req.GetStatus(),
|
||||||
|
UserID: req.GetUserId(),
|
||||||
|
CycleKey: req.GetCycleKey(),
|
||||||
|
Page: req.GetPage(),
|
||||||
|
PageSize: req.GetPageSize(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
resp := &activityv1.ListCumulativeRechargeRewardGrantsResponse{Grants: make([]*activityv1.CumulativeRechargeRewardGrant, 0, len(grants)), Total: total}
|
||||||
|
for _, grant := range grants {
|
||||||
|
resp.Grants = append(resp.Grants, cumulativeRechargeRewardGrantToProto(grant))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardStatusToProto(status domain.StatusResult) *activityv1.CumulativeRechargeRewardStatus {
|
||||||
|
resp := &activityv1.CumulativeRechargeRewardStatus{
|
||||||
|
Config: cumulativeRechargeRewardConfigToProto(status.Config),
|
||||||
|
Progress: cumulativeRechargeRewardProgressToProto(status.Progress),
|
||||||
|
Grants: make([]*activityv1.CumulativeRechargeRewardGrant, 0, len(status.Grants)),
|
||||||
|
CycleKey: status.Cycle.Key,
|
||||||
|
PeriodStartMs: status.Cycle.StartMS,
|
||||||
|
PeriodEndMs: status.Cycle.EndMS,
|
||||||
|
ServerTimeMs: status.ServerTimeMS,
|
||||||
|
}
|
||||||
|
for _, grant := range status.Grants {
|
||||||
|
resp.Grants = append(resp.Grants, cumulativeRechargeRewardGrantToProto(grant))
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardConfigToProto(config domain.Config) *activityv1.CumulativeRechargeRewardConfig {
|
||||||
|
resp := &activityv1.CumulativeRechargeRewardConfig{
|
||||||
|
AppCode: config.AppCode,
|
||||||
|
Enabled: config.Enabled,
|
||||||
|
Tiers: make([]*activityv1.CumulativeRechargeRewardTier, 0, len(config.Tiers)),
|
||||||
|
UpdatedByAdminId: config.UpdatedByAdminID,
|
||||||
|
CreatedAtMs: config.CreatedAtMS,
|
||||||
|
UpdatedAtMs: config.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
for _, tier := range config.Tiers {
|
||||||
|
resp.Tiers = append(resp.Tiers, cumulativeRechargeRewardTierToProto(tier))
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardTierToProto(tier domain.Tier) *activityv1.CumulativeRechargeRewardTier {
|
||||||
|
return &activityv1.CumulativeRechargeRewardTier{
|
||||||
|
TierId: tier.TierID,
|
||||||
|
TierCode: tier.TierCode,
|
||||||
|
TierName: tier.TierName,
|
||||||
|
ThresholdUsdMinor: tier.ThresholdUSDMinor,
|
||||||
|
ResourceGroupId: tier.ResourceGroupID,
|
||||||
|
Status: tier.Status,
|
||||||
|
SortOrder: tier.SortOrder,
|
||||||
|
CreatedAtMs: tier.CreatedAtMS,
|
||||||
|
UpdatedAtMs: tier.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardProgressToProto(progress domain.Progress) *activityv1.CumulativeRechargeRewardProgress {
|
||||||
|
return &activityv1.CumulativeRechargeRewardProgress{
|
||||||
|
AppCode: progress.AppCode,
|
||||||
|
CycleKey: progress.CycleKey,
|
||||||
|
UserId: progress.UserID,
|
||||||
|
TotalUsdMinor: progress.TotalUSDMinor,
|
||||||
|
TotalCoinAmount: progress.TotalCoinAmount,
|
||||||
|
FirstRechargedAtMs: progress.FirstRechargedAtMS,
|
||||||
|
LastRechargedAtMs: progress.LastRechargedAtMS,
|
||||||
|
UpdatedAtMs: progress.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardGrantToProto(grant domain.Grant) *activityv1.CumulativeRechargeRewardGrant {
|
||||||
|
if grant.GrantID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &activityv1.CumulativeRechargeRewardGrant{
|
||||||
|
GrantId: grant.GrantID,
|
||||||
|
AppCode: grant.AppCode,
|
||||||
|
CycleKey: grant.CycleKey,
|
||||||
|
UserId: grant.UserID,
|
||||||
|
EventId: grant.EventID,
|
||||||
|
TransactionId: grant.TransactionID,
|
||||||
|
CommandId: grant.CommandID,
|
||||||
|
TierId: grant.TierID,
|
||||||
|
TierCode: grant.TierCode,
|
||||||
|
ThresholdUsdMinor: grant.ThresholdUSDMinor,
|
||||||
|
ResourceGroupId: grant.ResourceGroupID,
|
||||||
|
ReachedUsdMinor: grant.ReachedUSDMinor,
|
||||||
|
QualifyingUsdMinor: grant.QualifyingUSDMinor,
|
||||||
|
RechargeCoinAmount: grant.RechargeCoinAmount,
|
||||||
|
RechargeType: grant.RechargeType,
|
||||||
|
Status: grant.Status,
|
||||||
|
WalletCommandId: grant.WalletCommandID,
|
||||||
|
WalletGrantId: grant.WalletGrantID,
|
||||||
|
FailureReason: grant.FailureReason,
|
||||||
|
GrantedAtMs: grant.GrantedAtMS,
|
||||||
|
CreatedAtMs: grant.CreatedAtMS,
|
||||||
|
UpdatedAtMs: grant.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -84,16 +84,22 @@ func (s *AdminRegistrationRewardServer) UpdateRegistrationRewardConfig(ctx conte
|
|||||||
|
|
||||||
func (s *AdminRegistrationRewardServer) ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error) {
|
func (s *AdminRegistrationRewardServer) ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
claims, total, err := s.svc.ListClaims(ctx, domain.ClaimQuery{
|
claims, total, todayClaimedCount, err := s.svc.ListClaims(ctx, domain.ClaimQuery{
|
||||||
Status: req.GetStatus(),
|
Status: req.GetStatus(),
|
||||||
UserID: req.GetUserId(),
|
UserID: req.GetUserId(),
|
||||||
Page: req.GetPage(),
|
ClaimedStartMS: req.GetClaimedStartMs(),
|
||||||
PageSize: req.GetPageSize(),
|
ClaimedEndMS: req.GetClaimedEndMs(),
|
||||||
|
Page: req.GetPage(),
|
||||||
|
PageSize: req.GetPageSize(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.ToGRPCError(err)
|
return nil, xerr.ToGRPCError(err)
|
||||||
}
|
}
|
||||||
resp := &activityv1.ListRegistrationRewardClaimsResponse{Claims: make([]*activityv1.RegistrationRewardClaim, 0, len(claims)), Total: total}
|
resp := &activityv1.ListRegistrationRewardClaimsResponse{
|
||||||
|
Claims: make([]*activityv1.RegistrationRewardClaim, 0, len(claims)),
|
||||||
|
Total: total,
|
||||||
|
TodayClaimedCount: todayClaimedCount,
|
||||||
|
}
|
||||||
for _, claim := range claims {
|
for _, claim := range claims {
|
||||||
resp.Claims = append(resp.Claims, registrationRewardClaimToProto(claim))
|
resp.Claims = append(resp.Claims, registrationRewardClaimToProto(claim))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,185 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/roomturnoverreward"
|
||||||
|
service "hyapp/services/activity-service/internal/service/roomturnoverreward"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoomTurnoverRewardServer 暴露 App 房间流水奖励查询入口。
|
||||||
|
type RoomTurnoverRewardServer struct {
|
||||||
|
activityv1.UnimplementedRoomTurnoverRewardServiceServer
|
||||||
|
|
||||||
|
svc *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoomTurnoverRewardServer(svc *service.Service) *RoomTurnoverRewardServer {
|
||||||
|
return &RoomTurnoverRewardServer{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RoomTurnoverRewardServer) GetRoomTurnoverRewardStatus(ctx context.Context, req *activityv1.GetRoomTurnoverRewardStatusRequest) (*activityv1.GetRoomTurnoverRewardStatusResponse, error) {
|
||||||
|
// AppCode 从 protobuf meta 写入 context,下面 service 和 repository 都按这个上下文隔离配置、流水和结算记录。
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
status, err := s.svc.GetStatus(ctx, req.GetUserId(), req.GetRoomId(), req.GetOwnerUserId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.GetRoomTurnoverRewardStatusResponse{Status: roomTurnoverRewardStatusToProto(status)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRoomTurnoverRewardServer 暴露后台配置、结算记录和 retry 入口。
|
||||||
|
type AdminRoomTurnoverRewardServer struct {
|
||||||
|
activityv1.UnimplementedAdminRoomTurnoverRewardServiceServer
|
||||||
|
|
||||||
|
svc *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminRoomTurnoverRewardServer(svc *service.Service) *AdminRoomTurnoverRewardServer {
|
||||||
|
return &AdminRoomTurnoverRewardServer{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminRoomTurnoverRewardServer) GetRoomTurnoverRewardConfig(ctx context.Context, req *activityv1.GetRoomTurnoverRewardConfigRequest) (*activityv1.GetRoomTurnoverRewardConfigResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
config, err := s.svc.GetConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.GetRoomTurnoverRewardConfigResponse{Config: roomTurnoverRewardConfigToProto(config)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminRoomTurnoverRewardServer) UpdateRoomTurnoverRewardConfig(ctx context.Context, req *activityv1.UpdateRoomTurnoverRewardConfigRequest) (*activityv1.UpdateRoomTurnoverRewardConfigResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
tiers := make([]domain.Tier, 0, len(req.GetTiers()))
|
||||||
|
for _, tier := range req.GetTiers() {
|
||||||
|
// protobuf 层只做字段转换,具体默认值、排序和合法性都留给 service,避免入口之间校验规则分叉。
|
||||||
|
tiers = append(tiers, domain.Tier{
|
||||||
|
TierID: tier.GetTierId(),
|
||||||
|
TierCode: tier.GetTierCode(),
|
||||||
|
TierName: tier.GetTierName(),
|
||||||
|
ThresholdCoinSpent: tier.GetThresholdCoinSpent(),
|
||||||
|
RewardCoinAmount: tier.GetRewardCoinAmount(),
|
||||||
|
Status: tier.GetStatus(),
|
||||||
|
SortOrder: tier.GetSortOrder(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
config, err := s.svc.UpdateConfig(ctx, domain.Config{
|
||||||
|
Enabled: req.GetEnabled(),
|
||||||
|
Tiers: tiers,
|
||||||
|
UpdatedByAdminID: req.GetOperatorAdminId(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.UpdateRoomTurnoverRewardConfigResponse{Config: roomTurnoverRewardConfigToProto(config)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminRoomTurnoverRewardServer) ListRoomTurnoverRewardSettlements(ctx context.Context, req *activityv1.ListRoomTurnoverRewardSettlementsRequest) (*activityv1.ListRoomTurnoverRewardSettlementsResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
// 查询条件完整透传到 domain query,分页上限由 service 统一收敛,后台和潜在内部工具不会各自实现分页规则。
|
||||||
|
items, total, err := s.svc.ListSettlements(ctx, domain.SettlementQuery{
|
||||||
|
Status: req.GetStatus(),
|
||||||
|
RoomID: req.GetRoomId(),
|
||||||
|
OwnerUserID: req.GetOwnerUserId(),
|
||||||
|
PeriodStartMS: req.GetPeriodStartMs(),
|
||||||
|
Page: req.GetPage(),
|
||||||
|
PageSize: req.GetPageSize(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
resp := &activityv1.ListRoomTurnoverRewardSettlementsResponse{
|
||||||
|
Settlements: make([]*activityv1.RoomTurnoverRewardSettlement, 0, len(items)),
|
||||||
|
Total: total,
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
resp.Settlements = append(resp.Settlements, roomTurnoverRewardSettlementToProto(item))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminRoomTurnoverRewardServer) RetryRoomTurnoverRewardSettlement(ctx context.Context, req *activityv1.RetryRoomTurnoverRewardSettlementRequest) (*activityv1.RetryRoomTurnoverRewardSettlementResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
// operator_admin_id 当前只由 admin-server 审计使用;retry 的业务幂等仍由 settlement 和 wallet command_id 保证。
|
||||||
|
settlement, err := s.svc.RetrySettlement(ctx, req.GetSettlementId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.RetryRoomTurnoverRewardSettlementResponse{Settlement: roomTurnoverRewardSettlementToProto(settlement)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardStatusToProto(status domain.StatusResult) *activityv1.RoomTurnoverRewardStatus {
|
||||||
|
return &activityv1.RoomTurnoverRewardStatus{
|
||||||
|
Config: roomTurnoverRewardConfigToProto(status.Config),
|
||||||
|
RoomId: status.RoomID,
|
||||||
|
OwnerUserId: status.OwnerUserID,
|
||||||
|
PeriodStartMs: status.PeriodStartMS,
|
||||||
|
PeriodEndMs: status.PeriodEndMS,
|
||||||
|
CurrentCoinSpent: status.CurrentCoinSpent,
|
||||||
|
ExpectedRewardCoinAmount: status.ExpectedRewardCoinAmount,
|
||||||
|
MatchedTier: roomTurnoverRewardTierToProto(status.MatchedTier),
|
||||||
|
LatestSettlement: roomTurnoverRewardSettlementToProto(status.LatestSettlement),
|
||||||
|
ServerTimeMs: status.ServerTimeMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardConfigToProto(config domain.Config) *activityv1.RoomTurnoverRewardConfig {
|
||||||
|
resp := &activityv1.RoomTurnoverRewardConfig{
|
||||||
|
AppCode: config.AppCode,
|
||||||
|
Enabled: config.Enabled,
|
||||||
|
Tiers: make([]*activityv1.RoomTurnoverRewardTier, 0, len(config.Tiers)),
|
||||||
|
UpdatedByAdminId: config.UpdatedByAdminID,
|
||||||
|
CreatedAtMs: config.CreatedAtMS,
|
||||||
|
UpdatedAtMs: config.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
for _, tier := range config.Tiers {
|
||||||
|
resp.Tiers = append(resp.Tiers, roomTurnoverRewardTierToProto(tier))
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardTierToProto(tier domain.Tier) *activityv1.RoomTurnoverRewardTier {
|
||||||
|
if tier.TierID == 0 && tier.TierCode == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &activityv1.RoomTurnoverRewardTier{
|
||||||
|
TierId: tier.TierID,
|
||||||
|
TierCode: tier.TierCode,
|
||||||
|
TierName: tier.TierName,
|
||||||
|
ThresholdCoinSpent: tier.ThresholdCoinSpent,
|
||||||
|
RewardCoinAmount: tier.RewardCoinAmount,
|
||||||
|
Status: tier.Status,
|
||||||
|
SortOrder: tier.SortOrder,
|
||||||
|
CreatedAtMs: tier.CreatedAtMS,
|
||||||
|
UpdatedAtMs: tier.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardSettlementToProto(item domain.Settlement) *activityv1.RoomTurnoverRewardSettlement {
|
||||||
|
if item.SettlementID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &activityv1.RoomTurnoverRewardSettlement{
|
||||||
|
SettlementId: item.SettlementID,
|
||||||
|
AppCode: item.AppCode,
|
||||||
|
RoomId: item.RoomID,
|
||||||
|
OwnerUserId: item.OwnerUserID,
|
||||||
|
PeriodStartMs: item.PeriodStartMS,
|
||||||
|
PeriodEndMs: item.PeriodEndMS,
|
||||||
|
CoinSpent: item.CoinSpent,
|
||||||
|
TierId: item.TierID,
|
||||||
|
TierCode: item.TierCode,
|
||||||
|
ThresholdCoinSpent: item.ThresholdCoinSpent,
|
||||||
|
RewardCoinAmount: item.RewardCoinAmount,
|
||||||
|
Status: item.Status,
|
||||||
|
WalletCommandId: item.WalletCommandID,
|
||||||
|
WalletTransactionId: item.WalletTransactionID,
|
||||||
|
FailureReason: item.FailureReason,
|
||||||
|
SettledAtMs: item.SettledAtMS,
|
||||||
|
CreatedAtMs: item.CreatedAtMS,
|
||||||
|
UpdatedAtMs: item.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,250 @@
|
|||||||
|
package grpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
domain "hyapp/services/activity-service/internal/domain/weeklystar"
|
||||||
|
service "hyapp/services/activity-service/internal/service/weeklystar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeeklyStarServer exposes App/H5 weekly-star reads.
|
||||||
|
type WeeklyStarServer struct {
|
||||||
|
activityv1.UnimplementedWeeklyStarServiceServer
|
||||||
|
svc *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWeeklyStarServer(svc *service.Service) *WeeklyStarServer {
|
||||||
|
return &WeeklyStarServer{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeeklyStarServer) GetWeeklyStarCurrent(ctx context.Context, req *activityv1.GetWeeklyStarCurrentRequest) (*activityv1.GetWeeklyStarCurrentResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
cycle, topEntries, myEntry, found, err := s.svc.GetCurrent(ctx, req.GetUserId(), req.GetRegionId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
resp := &activityv1.GetWeeklyStarCurrentResponse{
|
||||||
|
Cycle: weeklyStarCycleToProto(cycle),
|
||||||
|
TopEntries: weeklyStarEntriesToProto(topEntries),
|
||||||
|
ServerTimeMs: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
resp.MyEntry = weeklyStarEntryToProto(myEntry)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeeklyStarServer) ListWeeklyStarLeaderboard(ctx context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
cycle, entries, nextToken, total, err := s.svc.ListLeaderboard(ctx, req.GetCycleId(), req.GetRegionId(), req.GetPageSize(), req.GetPageToken())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.ListWeeklyStarLeaderboardResponse{
|
||||||
|
Cycle: weeklyStarCycleToProto(cycle),
|
||||||
|
Entries: weeklyStarEntriesToProto(entries),
|
||||||
|
NextPageToken: nextToken,
|
||||||
|
Total: total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WeeklyStarServer) ListWeeklyStarHistory(ctx context.Context, req *activityv1.ListWeeklyStarHistoryRequest) (*activityv1.ListWeeklyStarHistoryResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
cycles, err := s.svc.ListHistory(ctx, req.GetRegionId(), req.GetLimit())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
resp := &activityv1.ListWeeklyStarHistoryResponse{Cycles: make([]*activityv1.WeeklyStarHistoryCycle, 0, len(cycles)), ServerTimeMs: time.Now().UnixMilli()}
|
||||||
|
for _, cycle := range cycles {
|
||||||
|
resp.Cycles = append(resp.Cycles, &activityv1.WeeklyStarHistoryCycle{
|
||||||
|
Cycle: weeklyStarCycleToProto(cycle.Cycle),
|
||||||
|
TopEntries: weeklyStarEntriesToProto(cycle.TopEntries),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWeeklyStarServer exposes admin weekly-star configuration and audit reads.
|
||||||
|
type AdminWeeklyStarServer struct {
|
||||||
|
activityv1.UnimplementedAdminWeeklyStarServiceServer
|
||||||
|
svc *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminWeeklyStarServer(svc *service.Service) *AdminWeeklyStarServer {
|
||||||
|
return &AdminWeeklyStarServer{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminWeeklyStarServer) ListWeeklyStarCycles(ctx context.Context, req *activityv1.ListWeeklyStarCyclesRequest) (*activityv1.ListWeeklyStarCyclesResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
var regionID *int64
|
||||||
|
if req.GetRegionId() >= 0 && req.RegionId != 0 {
|
||||||
|
value := req.GetRegionId()
|
||||||
|
regionID = &value
|
||||||
|
}
|
||||||
|
cycles, total, err := s.svc.ListCycles(ctx, domain.ListQuery{
|
||||||
|
RegionID: regionID,
|
||||||
|
Status: req.GetStatus(),
|
||||||
|
StartMS: req.GetStartMs(),
|
||||||
|
EndMS: req.GetEndMs(),
|
||||||
|
Page: req.GetPage(),
|
||||||
|
PageSize: req.GetPageSize(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
resp := &activityv1.ListWeeklyStarCyclesResponse{Cycles: make([]*activityv1.WeeklyStarCycle, 0, len(cycles)), Total: total}
|
||||||
|
for _, cycle := range cycles {
|
||||||
|
resp.Cycles = append(resp.Cycles, weeklyStarCycleToProto(cycle))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminWeeklyStarServer) CreateWeeklyStarCycle(ctx context.Context, req *activityv1.UpsertWeeklyStarCycleRequest) (*activityv1.UpsertWeeklyStarCycleResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
cycle, created, err := s.svc.CreateCycle(ctx, domain.CycleCommand{Cycle: weeklyStarCycleFromProto(req.GetCycle()), OperatorAdminID: req.GetOperatorAdminId()})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.UpsertWeeklyStarCycleResponse{Cycle: weeklyStarCycleToProto(cycle), Created: created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminWeeklyStarServer) GetWeeklyStarCycle(ctx context.Context, req *activityv1.GetWeeklyStarCycleRequest) (*activityv1.GetWeeklyStarCycleResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
cycle, err := s.svc.GetCycle(ctx, req.GetCycleId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.GetWeeklyStarCycleResponse{Cycle: weeklyStarCycleToProto(cycle)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminWeeklyStarServer) UpdateWeeklyStarCycle(ctx context.Context, req *activityv1.UpsertWeeklyStarCycleRequest) (*activityv1.UpsertWeeklyStarCycleResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
cycle, created, err := s.svc.UpdateCycle(ctx, domain.CycleCommand{Cycle: weeklyStarCycleFromProto(req.GetCycle()), OperatorAdminID: req.GetOperatorAdminId()})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.UpsertWeeklyStarCycleResponse{Cycle: weeklyStarCycleToProto(cycle), Created: created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminWeeklyStarServer) SetWeeklyStarCycleStatus(ctx context.Context, req *activityv1.SetWeeklyStarCycleStatusRequest) (*activityv1.SetWeeklyStarCycleStatusResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
cycle, err := s.svc.SetCycleStatus(ctx, req.GetCycleId(), req.GetStatus(), req.GetOperatorAdminId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return &activityv1.SetWeeklyStarCycleStatusResponse{Cycle: weeklyStarCycleToProto(cycle)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminWeeklyStarServer) ListWeeklyStarLeaderboard(ctx context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error) {
|
||||||
|
return NewWeeklyStarServer(s.svc).ListWeeklyStarLeaderboard(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminWeeklyStarServer) ListWeeklyStarSettlements(ctx context.Context, req *activityv1.ListWeeklyStarSettlementsRequest) (*activityv1.ListWeeklyStarSettlementsResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
settlements, err := s.svc.ListSettlements(ctx, req.GetCycleId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
resp := &activityv1.ListWeeklyStarSettlementsResponse{Settlements: make([]*activityv1.WeeklyStarSettlement, 0, len(settlements))}
|
||||||
|
for _, settlement := range settlements {
|
||||||
|
resp.Settlements = append(resp.Settlements, weeklyStarSettlementToProto(settlement))
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle) domain.Cycle {
|
||||||
|
if item == nil {
|
||||||
|
return domain.Cycle{}
|
||||||
|
}
|
||||||
|
cycle := domain.Cycle{
|
||||||
|
CycleID: item.GetCycleId(),
|
||||||
|
ActivityCode: item.GetActivityCode(),
|
||||||
|
RegionID: item.GetRegionId(),
|
||||||
|
Title: item.GetTitle(),
|
||||||
|
Status: item.GetStatus(),
|
||||||
|
StartMS: item.GetStartMs(),
|
||||||
|
EndMS: item.GetEndMs(),
|
||||||
|
CreatedByAdminID: item.GetCreatedByAdminId(),
|
||||||
|
UpdatedByAdminID: item.GetUpdatedByAdminId(),
|
||||||
|
}
|
||||||
|
for _, gift := range item.GetGifts() {
|
||||||
|
cycle.Gifts = append(cycle.Gifts, domain.Gift{GiftID: gift.GetGiftId(), SortOrder: gift.GetSortOrder()})
|
||||||
|
}
|
||||||
|
for _, reward := range item.GetRewards() {
|
||||||
|
cycle.Rewards = append(cycle.Rewards, domain.Reward{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()})
|
||||||
|
}
|
||||||
|
return cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarCycleToProto(item domain.Cycle) *activityv1.WeeklyStarCycle {
|
||||||
|
if item.CycleID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp := &activityv1.WeeklyStarCycle{
|
||||||
|
AppCode: item.AppCode,
|
||||||
|
CycleId: item.CycleID,
|
||||||
|
ActivityCode: item.ActivityCode,
|
||||||
|
RegionId: item.RegionID,
|
||||||
|
Title: item.Title,
|
||||||
|
Status: item.Status,
|
||||||
|
StartMs: item.StartMS,
|
||||||
|
EndMs: item.EndMS,
|
||||||
|
Gifts: make([]*activityv1.WeeklyStarGift, 0, len(item.Gifts)),
|
||||||
|
Rewards: make([]*activityv1.WeeklyStarReward, 0, len(item.Rewards)),
|
||||||
|
CreatedByAdminId: item.CreatedByAdminID,
|
||||||
|
UpdatedByAdminId: item.UpdatedByAdminID,
|
||||||
|
SettledAtMs: item.SettledAtMS,
|
||||||
|
CreatedAtMs: item.CreatedAtMS,
|
||||||
|
UpdatedAtMs: item.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
for _, gift := range item.Gifts {
|
||||||
|
resp.Gifts = append(resp.Gifts, &activityv1.WeeklyStarGift{GiftId: gift.GiftID, SortOrder: gift.SortOrder})
|
||||||
|
}
|
||||||
|
for _, reward := range item.Rewards {
|
||||||
|
resp.Rewards = append(resp.Rewards, &activityv1.WeeklyStarReward{RankNo: reward.RankNo, ResourceGroupId: reward.ResourceGroupID})
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarEntryToProto(item domain.LeaderboardEntry) *activityv1.WeeklyStarLeaderboardEntry {
|
||||||
|
if item.UserID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &activityv1.WeeklyStarLeaderboardEntry{
|
||||||
|
RankNo: item.RankNo,
|
||||||
|
UserId: item.UserID,
|
||||||
|
Score: item.Score,
|
||||||
|
FirstScoredAtMs: item.FirstScoredAtMS,
|
||||||
|
LastScoredAtMs: item.LastScoredAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarEntriesToProto(items []domain.LeaderboardEntry) []*activityv1.WeeklyStarLeaderboardEntry {
|
||||||
|
resp := make([]*activityv1.WeeklyStarLeaderboardEntry, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
resp = append(resp, weeklyStarEntryToProto(item))
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarSettlementToProto(item domain.Settlement) *activityv1.WeeklyStarSettlement {
|
||||||
|
return &activityv1.WeeklyStarSettlement{
|
||||||
|
SettlementId: item.SettlementID,
|
||||||
|
CycleId: item.CycleID,
|
||||||
|
RankNo: item.RankNo,
|
||||||
|
UserId: item.UserID,
|
||||||
|
Score: item.Score,
|
||||||
|
ResourceGroupId: item.ResourceGroupID,
|
||||||
|
WalletCommandId: item.WalletCommandID,
|
||||||
|
WalletGrantId: item.WalletGrantID,
|
||||||
|
Status: item.Status,
|
||||||
|
FailureReason: item.FailureReason,
|
||||||
|
AttemptCount: item.AttemptCount,
|
||||||
|
CreatedAtMs: item.CreatedAtMS,
|
||||||
|
UpdatedAtMs: item.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,6 +43,20 @@ tasks:
|
|||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu"]
|
app_codes: ["lalu"]
|
||||||
|
weekly_star_settlement:
|
||||||
|
enabled: true
|
||||||
|
interval: "5s"
|
||||||
|
timeout: "20s"
|
||||||
|
lock_ttl: "1m"
|
||||||
|
batch_size: 50
|
||||||
|
app_codes: ["lalu"]
|
||||||
|
room_turnover_reward_settlement:
|
||||||
|
enabled: true
|
||||||
|
interval: "5m"
|
||||||
|
timeout: "30s"
|
||||||
|
lock_ttl: "5m"
|
||||||
|
batch_size: 100
|
||||||
|
app_codes: ["lalu"]
|
||||||
game_level_event_relay:
|
game_level_event_relay:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
|
|||||||
@ -43,6 +43,20 @@ tasks:
|
|||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu"]
|
app_codes: ["lalu"]
|
||||||
|
weekly_star_settlement:
|
||||||
|
enabled: true
|
||||||
|
interval: "5s"
|
||||||
|
timeout: "20s"
|
||||||
|
lock_ttl: "1m"
|
||||||
|
batch_size: 50
|
||||||
|
app_codes: ["lalu"]
|
||||||
|
room_turnover_reward_settlement:
|
||||||
|
enabled: true
|
||||||
|
interval: "5m"
|
||||||
|
timeout: "30s"
|
||||||
|
lock_ttl: "5m"
|
||||||
|
batch_size: 100
|
||||||
|
app_codes: ["lalu"]
|
||||||
game_level_event_relay:
|
game_level_event_relay:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
|
|||||||
@ -43,6 +43,20 @@ tasks:
|
|||||||
lock_ttl: "30s"
|
lock_ttl: "30s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
app_codes: ["lalu"]
|
app_codes: ["lalu"]
|
||||||
|
weekly_star_settlement:
|
||||||
|
enabled: true
|
||||||
|
interval: "5s"
|
||||||
|
timeout: "20s"
|
||||||
|
lock_ttl: "1m"
|
||||||
|
batch_size: 50
|
||||||
|
app_codes: ["lalu"]
|
||||||
|
room_turnover_reward_settlement:
|
||||||
|
enabled: true
|
||||||
|
interval: "5m"
|
||||||
|
timeout: "30s"
|
||||||
|
lock_ttl: "5m"
|
||||||
|
batch_size: 100
|
||||||
|
app_codes: ["lalu"]
|
||||||
game_level_event_relay:
|
game_level_event_relay:
|
||||||
enabled: true
|
enabled: true
|
||||||
interval: "5s"
|
interval: "5s"
|
||||||
|
|||||||
@ -119,6 +119,8 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
"message_fanout": activityCron.ProcessMessageFanoutBatch,
|
"message_fanout": activityCron.ProcessMessageFanoutBatch,
|
||||||
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
|
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
|
||||||
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
|
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
|
||||||
|
"weekly_star_settlement": activityCron.ProcessWeeklyStarSettlementBatch,
|
||||||
|
"room_turnover_reward_settlement": activityCron.ProcessRoomTurnoverRewardSettlementBatch,
|
||||||
"game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch,
|
"game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch,
|
||||||
"host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch,
|
"host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch,
|
||||||
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,
|
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,
|
||||||
|
|||||||
@ -164,6 +164,22 @@ func defaultTasks() map[string]TaskConfig {
|
|||||||
BatchSize: 100,
|
BatchSize: 100,
|
||||||
AppCodes: []string{defaultAppCode},
|
AppCodes: []string{defaultAppCode},
|
||||||
},
|
},
|
||||||
|
"weekly_star_settlement": {
|
||||||
|
Enabled: true,
|
||||||
|
Interval: "5s",
|
||||||
|
Timeout: "20s",
|
||||||
|
LockTTL: "1m",
|
||||||
|
BatchSize: 50,
|
||||||
|
AppCodes: []string{defaultAppCode},
|
||||||
|
},
|
||||||
|
"room_turnover_reward_settlement": {
|
||||||
|
Enabled: true,
|
||||||
|
Interval: "5m",
|
||||||
|
Timeout: "30s",
|
||||||
|
LockTTL: "5m",
|
||||||
|
BatchSize: 100,
|
||||||
|
AppCodes: []string{defaultAppCode},
|
||||||
|
},
|
||||||
"game_level_event_relay": {
|
"game_level_event_relay": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Interval: "5s",
|
Interval: "5s",
|
||||||
|
|||||||
@ -45,6 +45,25 @@ func (c *ActivityCronClient) ProcessAchievementRewardBatch(ctx context.Context,
|
|||||||
return activityCronBatchResult(resp), nil
|
return activityCronBatchResult(resp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessWeeklyStarSettlementBatch handles ended weekly-star cycle settlements.
|
||||||
|
func (c *ActivityCronClient) ProcessWeeklyStarSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
|
||||||
|
resp, err := c.client.ProcessWeeklyStarSettlementBatch(ctx, activityCronBatchRequest(req))
|
||||||
|
if err != nil {
|
||||||
|
return scheduler.BatchResult{}, err
|
||||||
|
}
|
||||||
|
return activityCronBatchResult(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessRoomTurnoverRewardSettlementBatch handles ended room-turnover-reward cycle settlements.
|
||||||
|
func (c *ActivityCronClient) ProcessRoomTurnoverRewardSettlementBatch(ctx context.Context, req scheduler.BatchRequest) (scheduler.BatchResult, error) {
|
||||||
|
// cron-service 不直连 activity MySQL;它只把 run_id、worker_id 和 batch_size 传给活动服务,让 owner service 自己处理幂等和状态推进。
|
||||||
|
resp, err := c.client.ProcessRoomTurnoverRewardSettlementBatch(ctx, activityCronBatchRequest(req))
|
||||||
|
if err != nil {
|
||||||
|
return scheduler.BatchResult{}, err
|
||||||
|
}
|
||||||
|
return activityCronBatchResult(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
func activityCronBatchRequest(req scheduler.BatchRequest) *activityv1.CronBatchRequest {
|
func activityCronBatchRequest(req scheduler.BatchRequest) *activityv1.CronBatchRequest {
|
||||||
return &activityv1.CronBatchRequest{
|
return &activityv1.CronBatchRequest{
|
||||||
Meta: &activityv1.RequestMeta{
|
Meta: &activityv1.RequestMeta{
|
||||||
|
|||||||
@ -13,11 +13,12 @@ const (
|
|||||||
LaunchModeH5Popup = "h5_popup"
|
LaunchModeH5Popup = "h5_popup"
|
||||||
SessionActive = "active"
|
SessionActive = "active"
|
||||||
|
|
||||||
AdapterDemo = "demo"
|
AdapterDemo = "demo"
|
||||||
AdapterYomiV4 = "yomi_v4"
|
AdapterYomiV4 = "yomi_v4"
|
||||||
AdapterLeaderCCV1 = "leadercc_v1"
|
AdapterLeaderCCV1 = "leadercc_v1"
|
||||||
AdapterZeeOneV1 = "zeeone_v1"
|
AdapterZeeOneV1 = "zeeone_v1"
|
||||||
AdapterBaishunV1 = "baishun_v1"
|
AdapterBaishunV1 = "baishun_v1"
|
||||||
|
AdapterVivaGamesV1 = "vivagames_v1"
|
||||||
|
|
||||||
OrderStatusWalletApplying = "wallet_applying"
|
OrderStatusWalletApplying = "wallet_applying"
|
||||||
OrderStatusSucceeded = "succeeded"
|
OrderStatusSucceeded = "succeeded"
|
||||||
|
|||||||
@ -203,7 +203,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
|||||||
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
||||||
token := strings.TrimSpace(command.AccessToken)
|
token := strings.TrimSpace(command.AccessToken)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||||
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
|
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
|
||||||
}
|
}
|
||||||
token = "gt_" + randomHex(24)
|
token = "gt_" + randomHex(24)
|
||||||
@ -218,6 +218,8 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
|||||||
sessionTTL = maxDuration(sessionTTL, zeeoneConfigFromPlatform(game).TokenTTL())
|
sessionTTL = maxDuration(sessionTTL, zeeoneConfigFromPlatform(game).TokenTTL())
|
||||||
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
|
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
|
||||||
sessionTTL = maxDuration(sessionTTL, baishunConfigFromPlatform(game).TokenTTL())
|
sessionTTL = maxDuration(sessionTTL, baishunConfigFromPlatform(game).TokenTTL())
|
||||||
|
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||||
|
sessionTTL = maxDuration(sessionTTL, vivaGamesConfigFromPlatform(game).TokenTTL())
|
||||||
}
|
}
|
||||||
expiresAt := now.Add(sessionTTL).UnixMilli()
|
expiresAt := now.Add(sessionTTL).UnixMilli()
|
||||||
session := gamedomain.LaunchSession{
|
session := gamedomain.LaunchSession{
|
||||||
@ -288,6 +290,8 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
|
|||||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleZeeOneOperation(ctx, app, platform, req, operation, requestHash)
|
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleZeeOneOperation(ctx, app, platform, req, operation, requestHash)
|
||||||
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterBaishunV1):
|
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterBaishunV1):
|
||||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleBaishunOperation(ctx, app, platform, req, operation, requestHash)
|
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleBaishunOperation(ctx, app, platform, req, operation, requestHash)
|
||||||
|
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterVivaGamesV1):
|
||||||
|
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleVivaGamesOperation(ctx, app, platform, req, operation, requestHash)
|
||||||
default:
|
default:
|
||||||
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
|
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
|
||||||
}
|
}
|
||||||
@ -616,6 +620,12 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
|||||||
base = configuredBase
|
base = configuredBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||||
|
// VIVAGAMES 文档说明游戏 URL 由厂商提供;后台按 game_id 保存 URL,启动时只拼客户端 getConfig 需要的热配置。
|
||||||
|
if configuredBase := vivaGamesLaunchBaseURL(game, vivaGamesConfigFromPlatform(game)); configuredBase != "" {
|
||||||
|
base = configuredBase
|
||||||
|
}
|
||||||
|
}
|
||||||
if base == "" {
|
if base == "" {
|
||||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
|
||||||
return "", xerr.New(xerr.InvalidArgument, "yomi auth url is required")
|
return "", xerr.New(xerr.InvalidArgument, "yomi auth url is required")
|
||||||
@ -626,6 +636,9 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
|||||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
|
||||||
return "", xerr.New(xerr.InvalidArgument, "baishun launch url is required")
|
return "", xerr.New(xerr.InvalidArgument, "baishun launch url is required")
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||||
|
return "", xerr.New(xerr.InvalidArgument, "vivagames launch url is required")
|
||||||
|
}
|
||||||
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
|
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
|
||||||
base = "https://game.example.local/h5"
|
base = "https://game.example.local/h5"
|
||||||
}
|
}
|
||||||
@ -727,6 +740,29 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
|||||||
parsed.RawQuery = query.Encode()
|
parsed.RawQuery = query.Encode()
|
||||||
return parsed.String(), nil
|
return parsed.String(), nil
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
|
||||||
|
// VIVAGAMES H5 通过 NativeBridge.getConfig 取 appId/userId/code;这里把同一份配置拼到 URL,供 App 壳层回写给 H5。
|
||||||
|
config := vivaGamesConfigFromPlatform(game)
|
||||||
|
query.Set("bridge_keys", "appId,userId,code,metadata,language,coinType,amountRate,bgmSwitch,gsp,currencyIcon,gameConfig,gameId")
|
||||||
|
if config.AppID > 0 {
|
||||||
|
query.Set("appId", strconv.FormatInt(config.AppID, 10))
|
||||||
|
}
|
||||||
|
query.Set("userId", externalUserID(session, config.UIDMode))
|
||||||
|
query.Set("code", token)
|
||||||
|
query.Set("gameId", strings.TrimSpace(session.ProviderGameID))
|
||||||
|
query.Set("metadata", config.MetadataValue(session))
|
||||||
|
query.Set("language", config.LanguageValue())
|
||||||
|
query.Set("coinType", strconv.FormatInt(config.CoinType, 10))
|
||||||
|
query.Set("amountRate", strconv.FormatInt(config.AmountRateValue(), 10))
|
||||||
|
query.Set("bgmSwitch", strconv.FormatInt(config.BGMSwitchValue(), 10))
|
||||||
|
query.Set("gsp", strconv.FormatInt(config.GSPValue(), 10))
|
||||||
|
if config.CurrencyIcon != "" {
|
||||||
|
query.Set("currencyIcon", config.CurrencyIcon)
|
||||||
|
query.Set("gameConfig", vivaGamesGameConfigQuery(config.CurrencyIcon))
|
||||||
|
}
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
// demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。
|
// demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。
|
||||||
query.Set("session_id", session.SessionID)
|
query.Set("session_id", session.SessionID)
|
||||||
query.Set("session_token", token)
|
query.Set("session_token", token)
|
||||||
|
|||||||
@ -335,6 +335,81 @@ func TestLaunchGameBuildsBaishunURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLaunchGameBuildsVivaGamesURL(t *testing.T) {
|
||||||
|
repo := &fakeRepository{
|
||||||
|
launchable: gamedomain.LaunchableGame{
|
||||||
|
CatalogItem: gamedomain.CatalogItem{
|
||||||
|
AppCode: "lalu",
|
||||||
|
GameID: "vivagames_2",
|
||||||
|
PlatformCode: "vivagames",
|
||||||
|
ProviderGameID: "2",
|
||||||
|
GameName: "Viva Demo",
|
||||||
|
Status: gamedomain.StatusActive,
|
||||||
|
Orientation: "portrait",
|
||||||
|
},
|
||||||
|
PlatformStatus: gamedomain.StatusActive,
|
||||||
|
APIBaseURL: "https://fallback.example.invalid/game.html",
|
||||||
|
AdapterType: gamedomain.AdapterVivaGamesV1,
|
||||||
|
AdapterConfigJSON: `{
|
||||||
|
"app_id":1012127974,
|
||||||
|
"default_lang":"en-US",
|
||||||
|
"token_ttl_seconds":86400,
|
||||||
|
"uid_mode":"display_user_id",
|
||||||
|
"amount_rate":1,
|
||||||
|
"bgm_switch":0,
|
||||||
|
"gsp":101,
|
||||||
|
"currency_icon":"https://cdn.example/coin.png",
|
||||||
|
"game_urls":{"2":"https://dev-rich2.s3.ap-southeast-1.amazonaws.com/richForever.html?game_id=2&isShow=1"}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
|
||||||
|
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||||
|
|
||||||
|
result, err := svc.LaunchGame(context.Background(), LaunchCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
UserID: 42,
|
||||||
|
DisplayUserID: "420001",
|
||||||
|
GameID: "vivagames_2",
|
||||||
|
RoomID: "room_1",
|
||||||
|
AccessToken: "app_access_token_viva",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LaunchGame failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.ExpiresAtMS != 1700086400000 {
|
||||||
|
t.Fatalf("vivagames launch must use adapter token ttl, got %+v", result)
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(result.LaunchURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse vivagames launch url failed: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://dev-rich2.s3.ap-southeast-1.amazonaws.com/richForever.html" {
|
||||||
|
t.Fatalf("vivagames launch url must use per-game configured url: %s", result.LaunchURL)
|
||||||
|
}
|
||||||
|
query := parsed.Query()
|
||||||
|
expected := map[string]string{
|
||||||
|
"appId": "1012127974",
|
||||||
|
"userId": "420001",
|
||||||
|
"code": "app_access_token_viva",
|
||||||
|
"gameId": "2",
|
||||||
|
"metadata": `{"room_id":"room_1"}`,
|
||||||
|
"language": "en-US",
|
||||||
|
"coinType": "0",
|
||||||
|
"amountRate": "1",
|
||||||
|
"bgmSwitch": "0",
|
||||||
|
"gsp": "101",
|
||||||
|
"currencyIcon": "https://cdn.example/coin.png",
|
||||||
|
"gameConfig": `{"currencyIcon":"https://cdn.example/coin.png"}`,
|
||||||
|
"bridge_keys": "appId,userId,code,metadata,language,coinType,amountRate,bgmSwitch,gsp,currencyIcon,gameConfig,gameId",
|
||||||
|
}
|
||||||
|
for key, want := range expected {
|
||||||
|
if got := query.Get(key); got != want {
|
||||||
|
t.Fatalf("vivagames launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchGameRequiresBaishunLaunchURL(t *testing.T) {
|
func TestLaunchGameRequiresBaishunLaunchURL(t *testing.T) {
|
||||||
repo := &fakeRepository{
|
repo := &fakeRepository{
|
||||||
launchable: gamedomain.LaunchableGame{
|
launchable: gamedomain.LaunchableGame{
|
||||||
@ -1002,6 +1077,95 @@ func TestHandleBaishunSSTokenUserInfoUpdateAndChangeBalance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleVivaGamesTokenUserInfoUpdateAndChangeBalance(t *testing.T) {
|
||||||
|
secret := "vivagames-test-app-key"
|
||||||
|
token := "app_access_token_viva"
|
||||||
|
repo := &fakeRepository{
|
||||||
|
platform: gamedomain.Platform{
|
||||||
|
PlatformCode: "vivagames",
|
||||||
|
AdapterType: gamedomain.AdapterVivaGamesV1,
|
||||||
|
CallbackSecretCiphertext: secret,
|
||||||
|
AdapterConfigJSON: `{"app_id":1012127974,"uid_mode":"display_user_id"}`,
|
||||||
|
},
|
||||||
|
session: gamedomain.LaunchSession{
|
||||||
|
AppCode: "lalu",
|
||||||
|
SessionID: "game_sess_viva",
|
||||||
|
UserID: 42,
|
||||||
|
DisplayUserID: "420001",
|
||||||
|
RoomID: "room_1",
|
||||||
|
PlatformCode: "vivagames",
|
||||||
|
GameID: "vivagames_2",
|
||||||
|
ProviderGameID: "2",
|
||||||
|
LaunchTokenHash: stableHash(token),
|
||||||
|
Status: gamedomain.SessionActive,
|
||||||
|
ExpiresAtMS: 1700086400000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wallet := &fakeWallet{balanceAfter: 1880}
|
||||||
|
user := &fakeUser{}
|
||||||
|
svc := New(Config{}, repo, wallet, user)
|
||||||
|
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||||
|
|
||||||
|
raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{RequestId: "req-viva-token", AppCode: "lalu"},
|
||||||
|
PlatformCode: "vivagames",
|
||||||
|
Operation: "get-token",
|
||||||
|
RawBody: vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","code":"app_access_token_viva"}`, 1700000000),
|
||||||
|
RemoteAddr: "3.1.174.194:443",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleCallback get-token failed: %v", err)
|
||||||
|
}
|
||||||
|
if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"token":"app_access_token_viva"`) || !strings.Contains(string(raw), `"expire_at":1700086400000`) {
|
||||||
|
t.Fatalf("vivagames get-token response mismatch: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{RequestId: "req-viva-userinfo", AppCode: "lalu"},
|
||||||
|
PlatformCode: "vivagames",
|
||||||
|
Operation: "get-user-info",
|
||||||
|
RawBody: vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva","client_ip":"110.86.1.130","game_id":2}`, 1700000000),
|
||||||
|
RemoteAddr: "3.1.174.194:443",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleCallback get-user-info failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"user_id":"420001"`) || !strings.Contains(string(raw), `"avatar_url":"https://cdn.example/avatar.png"`) || !strings.Contains(string(raw), `"balance":1880`) {
|
||||||
|
t.Fatalf("vivagames userinfo response mismatch: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{RequestId: "req-viva-update", AppCode: "lalu"},
|
||||||
|
PlatformCode: "vivagames",
|
||||||
|
Operation: "update-token",
|
||||||
|
RawBody: vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva"}`, 1700000000),
|
||||||
|
RemoteAddr: "3.1.174.194:443",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleCallback update-token failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"expire_at":1700086400000`) {
|
||||||
|
t.Fatalf("vivagames update-token response mismatch: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||||
|
Meta: &gamev1.RequestMeta{RequestId: "req-viva-change", AppCode: "lalu"},
|
||||||
|
PlatformCode: "vivagames",
|
||||||
|
Operation: "change-balance",
|
||||||
|
RawBody: vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva","order_id":"viva_order_1","game_round_id":"round_1","game_id":2,"total_bet":120,"change_amount":-120,"change_reason":"bet","change_time_at":1700000000,"metadata":"{\"room_id\":\"room_1\"}"}`, 1700000000),
|
||||||
|
RemoteAddr: "3.1.174.194:443",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HandleCallback change-balance failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"balance":1880`) {
|
||||||
|
t.Fatalf("vivagames change response mismatch: %s", raw)
|
||||||
|
}
|
||||||
|
if wallet.lastApply.GetCommandId() != "game:vivagames:viva_order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "vivagames_2" {
|
||||||
|
t.Fatalf("vivagames wallet command mismatch: %+v", wallet.lastApply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
|
func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
|
||||||
secret := "baishun-test-app-key"
|
secret := "baishun-test-app-key"
|
||||||
userID := int64(312900923573673984)
|
userID := int64(312900923573673984)
|
||||||
@ -1458,3 +1622,20 @@ func baishunTestBody(t *testing.T, secret string, body string, timestamp int64)
|
|||||||
}
|
}
|
||||||
return encoded
|
return encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func vivaGamesTestBody(t *testing.T, secret string, body string, timestamp int64) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var payload map[string]any
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(body))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode vivagames test body: %v", err)
|
||||||
|
}
|
||||||
|
payload["timestamp"] = json.Number(strconv.FormatInt(timestamp, 10))
|
||||||
|
payload["sign"] = vivaGamesSign(payload, secret)
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal vivagames test body: %v", err)
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|||||||
494
services/game-service/internal/service/game/vivagames_adapter.go
Normal file
494
services/game-service/internal/service/game/vivagames_adapter.go
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gamev1 "hyapp.local/api/proto/game/v1"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VIVAGAMES 文档定义 code=0 成功;游戏侧按 JSON code 判断业务结果,HTTP 200 只表示回调已被接收。
|
||||||
|
vivaGamesCodeOK = 0
|
||||||
|
vivaGamesCodeAppNotFound = 1001
|
||||||
|
vivaGamesCodeInvalidArgument = 1002
|
||||||
|
vivaGamesCodeSignatureInvalid = 1003
|
||||||
|
vivaGamesCodeCodeInvalid = 1004
|
||||||
|
vivaGamesCodeTokenInvalid = 1005
|
||||||
|
vivaGamesCodeInternalError = 1006
|
||||||
|
vivaGamesCodeGameMaintenance = 1007
|
||||||
|
vivaGamesCodeInsufficientBalance = 1008
|
||||||
|
vivaGamesCodeUserDisabled = 1009
|
||||||
|
vivaGamesCodeRequestExpired = 1010
|
||||||
|
|
||||||
|
vivaGamesDefaultTokenTTL = 24 * time.Hour
|
||||||
|
vivaGamesSignatureValidWindow = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type vivaGamesAdapterConfig struct {
|
||||||
|
// app_id 来自 VIVAGAMES 后台;callbackSecret 存放 PDF 签名算法使用的 AppKey。
|
||||||
|
AppID int64 `json:"app_id"`
|
||||||
|
AppIDAlias int64 `json:"appId"`
|
||||||
|
AppSecret string `json:"app_secret"`
|
||||||
|
UIDMode string `json:"uid_mode"`
|
||||||
|
DefaultLang string `json:"default_lang"`
|
||||||
|
// token_ttl_seconds 控制启动 session 至少保留多久,避免 H5 游戏中途回调时 token 已过期。
|
||||||
|
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
|
||||||
|
|
||||||
|
// 以下字段直接对应 NativeBridge.getConfig,后台热改后下一次启动生效。
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
CoinType int64 `json:"coin_type"`
|
||||||
|
AmountRate int64 `json:"amount_rate"`
|
||||||
|
BGMSwitch *int64 `json:"bgm_switch"`
|
||||||
|
GSP int64 `json:"gsp"`
|
||||||
|
CurrencyIcon string `json:"currency_icon"`
|
||||||
|
|
||||||
|
// VIVAGAMES 文档没有游戏列表 API,game_urls 用后台配置保存厂商提供的逐游戏 H5 URL。
|
||||||
|
GameURLs map[string]string `json:"game_urls"`
|
||||||
|
LaunchURLTemplate string `json:"launch_url_template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesConfigFromPlatform(value any) vivaGamesAdapterConfig {
|
||||||
|
var raw string
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case gamedomain.Platform:
|
||||||
|
raw = typed.AdapterConfigJSON
|
||||||
|
case gamedomain.LaunchableGame:
|
||||||
|
raw = typed.AdapterConfigJSON
|
||||||
|
}
|
||||||
|
var config vivaGamesAdapterConfig
|
||||||
|
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
|
||||||
|
if config.AppID == 0 {
|
||||||
|
config.AppID = config.AppIDAlias
|
||||||
|
}
|
||||||
|
config.AppSecret = strings.TrimSpace(config.AppSecret)
|
||||||
|
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
|
||||||
|
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
|
||||||
|
config.Metadata = strings.TrimSpace(config.Metadata)
|
||||||
|
config.CurrencyIcon = strings.TrimSpace(config.CurrencyIcon)
|
||||||
|
config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate)
|
||||||
|
config.GameURLs = normalizeStringMap(config.GameURLs)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c vivaGamesAdapterConfig) TokenTTL() time.Duration {
|
||||||
|
if c.TokenTTLSeconds > 0 {
|
||||||
|
return time.Duration(c.TokenTTLSeconds) * time.Second
|
||||||
|
}
|
||||||
|
return vivaGamesDefaultTokenTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c vivaGamesAdapterConfig) LanguageValue() string {
|
||||||
|
if c.DefaultLang != "" {
|
||||||
|
return c.DefaultLang
|
||||||
|
}
|
||||||
|
return "en-US"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c vivaGamesAdapterConfig) AmountRateValue() int64 {
|
||||||
|
if c.AmountRate > 0 {
|
||||||
|
return c.AmountRate
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c vivaGamesAdapterConfig) BGMSwitchValue() int64 {
|
||||||
|
if c.BGMSwitch != nil {
|
||||||
|
return *c.BGMSwitch
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c vivaGamesAdapterConfig) GSPValue() int64 {
|
||||||
|
if c.GSP > 0 {
|
||||||
|
return c.GSP
|
||||||
|
}
|
||||||
|
return 101
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c vivaGamesAdapterConfig) MetadataValue(session gamedomain.LaunchSession) string {
|
||||||
|
if c.Metadata != "" {
|
||||||
|
return c.Metadata
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(session.RoomID) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(map[string]string{"room_id": strings.TrimSpace(session.RoomID)})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesLaunchBaseURL(game gamedomain.LaunchableGame, config vivaGamesAdapterConfig) string {
|
||||||
|
for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
|
||||||
|
if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.LaunchURLTemplate == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"{provider_game_id}", strings.TrimSpace(game.ProviderGameID),
|
||||||
|
"{providerGameId}", strings.TrimSpace(game.ProviderGameID),
|
||||||
|
"{game_id}", strings.TrimSpace(game.GameID),
|
||||||
|
"{gameId}", strings.TrimSpace(game.GameID),
|
||||||
|
)
|
||||||
|
return strings.TrimSpace(replacer.Replace(config.LaunchURLTemplate))
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesGameConfigQuery(currencyIcon string) string {
|
||||||
|
raw, err := json.Marshal(map[string]string{"currencyIcon": strings.TrimSpace(currencyIcon)})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleVivaGamesOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
|
||||||
|
if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeSignatureInvalid, "ip restricted", map[string]any{})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeSignatureInvalid), false, "", nil
|
||||||
|
}
|
||||||
|
config := vivaGamesConfigFromPlatform(platform)
|
||||||
|
body, signed, decoded, expired, providerRequestID := s.decodeVivaGamesBody(req, platform.CallbackSecretCiphertext)
|
||||||
|
if !decoded {
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeInvalidArgument, "invalid argument", map[string]any{})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeInvalidArgument), false, providerRequestID, nil
|
||||||
|
}
|
||||||
|
if expired {
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeRequestExpired, "request expired", map[string]any{})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeRequestExpired), false, providerRequestID, nil
|
||||||
|
}
|
||||||
|
if !signed {
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeSignatureInvalid, "sign error", map[string]any{})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeSignatureInvalid), false, providerRequestID, nil
|
||||||
|
}
|
||||||
|
appID := body.AppIDInt64()
|
||||||
|
if appID <= 0 {
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeInvalidArgument, "invalid app_id", map[string]any{})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeInvalidArgument), signed, providerRequestID, nil
|
||||||
|
}
|
||||||
|
if config.AppID > 0 && appID != config.AppID {
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeAppNotFound, "appId not found", map[string]any{})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeAppNotFound), signed, providerRequestID, nil
|
||||||
|
}
|
||||||
|
switch operation {
|
||||||
|
case "get-token", "get_token", "gettoken", "api/get-token", "api/get_token", "v1/api/get-token":
|
||||||
|
return s.handleVivaGamesGetToken(ctx, app, config, req, body)
|
||||||
|
case "get-user-info", "get_user_info", "userinfo", "user_info", "api/get-user-info", "api/get_user_info", "v1/api/get-user-info":
|
||||||
|
return s.handleVivaGamesUserInfo(ctx, app, config, req, body)
|
||||||
|
case "update-token", "update_token", "updatetoken", "api/update-token", "api/update_token", "v1/api/update-token":
|
||||||
|
return s.handleVivaGamesUpdateToken(ctx, app, config, body)
|
||||||
|
case "change-balance", "change_balance", "changebalance", "api/change-balance", "api/change_balance", "v1/api/change-balance":
|
||||||
|
return s.handleVivaGamesChangeBalance(ctx, app, config, req, body, requestHash)
|
||||||
|
default:
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeOK), signed, providerRequestID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type vivaGamesBody struct {
|
||||||
|
AppID json.Number `json:"app_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ClientIP string `json:"client_ip"`
|
||||||
|
GameID json.Number `json:"game_id"`
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
GameRoundID string `json:"game_round_id"`
|
||||||
|
TotalBet int64 `json:"total_bet"`
|
||||||
|
ChangeAmount int64 `json:"change_amount"`
|
||||||
|
ChangeReason string `json:"change_reason"`
|
||||||
|
ChangeTimeAt int64 `json:"change_time_at"`
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
ExtendType string `json:"extend_type"`
|
||||||
|
Extend string `json:"extend"`
|
||||||
|
IgnoreExpire bool `json:"ignore_expire"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Signature string `json:"sign"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) decodeVivaGamesBody(req *gamev1.CallbackRequest, secret string) (vivaGamesBody, bool, bool, bool, string) {
|
||||||
|
var body vivaGamesBody
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(req.GetRawBody())))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&body); err != nil {
|
||||||
|
return vivaGamesBody{}, false, false, false, ""
|
||||||
|
}
|
||||||
|
body.normalize()
|
||||||
|
providerRequestID := strings.TrimSpace(body.OrderID)
|
||||||
|
params, err := vivaGamesParamsForSign(req.GetRawBody())
|
||||||
|
if err != nil {
|
||||||
|
return vivaGamesBody{}, false, false, false, providerRequestID
|
||||||
|
}
|
||||||
|
signed, expired := s.vivaGamesSignatureValid(params, body.Signature, body.Timestamp, secret)
|
||||||
|
return body, signed, true, expired, providerRequestID
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesParamsForSign(raw []byte) (map[string]any, error) {
|
||||||
|
var params map[string]any
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(¶ms); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
delete(params, "sign")
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) vivaGamesSignatureValid(params map[string]any, signature string, timestamp int64, secret string) (bool, bool) {
|
||||||
|
secret = strings.TrimSpace(secret)
|
||||||
|
if secret == "" || strings.TrimSpace(signature) == "" || timestamp <= 0 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
requestAt := time.Unix(timestamp, 0)
|
||||||
|
now := s.now()
|
||||||
|
if requestAt.After(now.Add(vivaGamesSignatureValidWindow)) || now.Sub(requestAt) > vivaGamesSignatureValidWindow {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
expected := vivaGamesSign(params, secret)
|
||||||
|
return strings.EqualFold(strings.TrimSpace(signature), expected), false
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesSign(params map[string]any, secret string) string {
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
for key, value := range params {
|
||||||
|
if value == nil || strings.TrimSpace(key) == "" || strings.EqualFold(key, "sign") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys)+1)
|
||||||
|
for _, key := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%s", key, vivaGamesSignValue(params[key])))
|
||||||
|
}
|
||||||
|
parts = append(parts, "key="+strings.TrimSpace(secret))
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join(parts, "&")))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesSignValue(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case json.Number:
|
||||||
|
return typed.String()
|
||||||
|
case string:
|
||||||
|
return typed
|
||||||
|
case bool:
|
||||||
|
if typed {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(typed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (body *vivaGamesBody) normalize() {
|
||||||
|
body.UserID = strings.TrimSpace(body.UserID)
|
||||||
|
body.Code = strings.TrimSpace(body.Code)
|
||||||
|
body.Token = strings.TrimSpace(body.Token)
|
||||||
|
body.ClientIP = strings.TrimSpace(body.ClientIP)
|
||||||
|
body.OrderID = strings.TrimSpace(body.OrderID)
|
||||||
|
body.GameRoundID = strings.TrimSpace(body.GameRoundID)
|
||||||
|
body.ChangeReason = strings.ToLower(strings.TrimSpace(body.ChangeReason))
|
||||||
|
body.Metadata = strings.TrimSpace(body.Metadata)
|
||||||
|
body.ExtendType = strings.TrimSpace(body.ExtendType)
|
||||||
|
body.Extend = strings.TrimSpace(body.Extend)
|
||||||
|
body.Signature = strings.TrimSpace(body.Signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (body vivaGamesBody) AppIDInt64() int64 {
|
||||||
|
value, _ := strconv.ParseInt(strings.TrimSpace(body.AppID.String()), 10, 64)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (body vivaGamesBody) GameIDInt64() int64 {
|
||||||
|
value, _ := strconv.ParseInt(strings.TrimSpace(body.GameID.String()), 10, 64)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleVivaGamesGetToken(ctx context.Context, app string, config vivaGamesAdapterConfig, req *gamev1.CallbackRequest, body vivaGamesBody) ([]byte, string, string, bool, string, error) {
|
||||||
|
if body.UserID == "" || body.Code == "" {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeInvalidArgument, "invalid argument", true, "")
|
||||||
|
}
|
||||||
|
session, err := s.validVivaGamesSession(ctx, app, body.Code, false)
|
||||||
|
if err != nil || !vivaGamesSessionMatches(session, config, body.UserID, 0) {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeCodeInvalid, "code error", true, "")
|
||||||
|
}
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{
|
||||||
|
"token": body.Code,
|
||||||
|
"expire_at": session.ExpiresAtMS,
|
||||||
|
})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeOK), true, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleVivaGamesUserInfo(ctx context.Context, app string, config vivaGamesAdapterConfig, req *gamev1.CallbackRequest, body vivaGamesBody) ([]byte, string, string, bool, string, error) {
|
||||||
|
if body.UserID == "" || body.Token == "" {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeInvalidArgument, "invalid argument", true, "")
|
||||||
|
}
|
||||||
|
session, err := s.validVivaGamesSession(ctx, app, body.Token, false)
|
||||||
|
if err != nil || !vivaGamesSessionMatches(session, config, body.UserID, body.GameIDInt64()) {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeTokenInvalid, "token error", true, "")
|
||||||
|
}
|
||||||
|
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeFromError(err), vivaGamesMessageFromError(err), true, "")
|
||||||
|
}
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{
|
||||||
|
"user_id": externalUserID(session, config.UIDMode),
|
||||||
|
"nickname": snapshot.Nickname,
|
||||||
|
"avatar_url": snapshot.Avatar,
|
||||||
|
"balance": snapshot.Balance,
|
||||||
|
"status": 0,
|
||||||
|
})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeOK), true, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleVivaGamesUpdateToken(ctx context.Context, app string, config vivaGamesAdapterConfig, body vivaGamesBody) ([]byte, string, string, bool, string, error) {
|
||||||
|
if body.UserID == "" || body.Token == "" {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeInvalidArgument, "invalid argument", true, "")
|
||||||
|
}
|
||||||
|
session, err := s.validVivaGamesSession(ctx, app, body.Token, false)
|
||||||
|
if err != nil || !vivaGamesSessionMatches(session, config, body.UserID, 0) {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeTokenInvalid, "token error", true, "")
|
||||||
|
}
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{
|
||||||
|
"token": body.Token,
|
||||||
|
"expire_at": session.ExpiresAtMS,
|
||||||
|
})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeOK), true, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleVivaGamesChangeBalance(ctx context.Context, app string, config vivaGamesAdapterConfig, req *gamev1.CallbackRequest, body vivaGamesBody, requestHash string) ([]byte, string, string, bool, string, error) {
|
||||||
|
if body.UserID == "" || body.Token == "" || body.OrderID == "" || body.GameIDInt64() <= 0 || body.ChangeReason == "" {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeInvalidArgument, "invalid argument", true, body.OrderID)
|
||||||
|
}
|
||||||
|
session, err := s.validVivaGamesSession(ctx, app, body.Token, body.IgnoreExpire)
|
||||||
|
if err != nil || !vivaGamesSessionMatches(session, config, body.UserID, body.GameIDInt64()) {
|
||||||
|
return vivaGamesAdapterError(vivaGamesCodeTokenInvalid, "token error", true, body.OrderID)
|
||||||
|
}
|
||||||
|
if session.ProviderGameID == "" {
|
||||||
|
session.ProviderGameID = strconv.FormatInt(body.GameIDInt64(), 10)
|
||||||
|
}
|
||||||
|
if session.GameID == "" {
|
||||||
|
session.GameID = session.ProviderGameID
|
||||||
|
}
|
||||||
|
opType, amount := vivaGamesOpType(body.ChangeAmount)
|
||||||
|
result, err := s.applyYomiCoinChange(ctx, app, req, session, body.OrderID, body.GameRoundID, opType, amount, "", requestHash)
|
||||||
|
if err != nil {
|
||||||
|
code := vivaGamesCodeFromError(err)
|
||||||
|
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
|
||||||
|
raw, contentType := vivaGamesJSON(code, vivaGamesMessageFromError(err), map[string]any{"balance": balance})
|
||||||
|
return raw, contentType, strconv.Itoa(code), true, body.OrderID, nil
|
||||||
|
}
|
||||||
|
raw, contentType := vivaGamesJSON(vivaGamesCodeOK, "", map[string]any{"balance": result.BalanceAfter})
|
||||||
|
return raw, contentType, strconv.Itoa(vivaGamesCodeOK), true, body.OrderID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validVivaGamesSession(ctx context.Context, app string, token string, ignoreExpire bool) (gamedomain.LaunchSession, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||||
|
}
|
||||||
|
// VIVAGAMES token 直接复用 App access token;先解析 JWT 可以避免同一登录 token 多游戏启动时命中过期旧 session。
|
||||||
|
if strings.Count(token, ".") == 2 && !ignoreExpire {
|
||||||
|
if session, err := s.appSessionFromAccessToken(app, token); err == nil {
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session, err := s.repository.GetLaunchSessionByToken(ctx, app, token); err == nil {
|
||||||
|
if session.Status != gamedomain.SessionActive {
|
||||||
|
return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
|
||||||
|
}
|
||||||
|
if !ignoreExpire && session.ExpiresAtMS <= s.now().UnixMilli() {
|
||||||
|
return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
if strings.Count(token, ".") == 2 {
|
||||||
|
return s.appSessionFromAccessToken(app, token)
|
||||||
|
}
|
||||||
|
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesSessionMatches(session gamedomain.LaunchSession, config vivaGamesAdapterConfig, userID string, gameID int64) bool {
|
||||||
|
if gameID > 0 && strings.TrimSpace(session.ProviderGameID) != "" && strconv.FormatInt(gameID, 10) != strings.TrimSpace(session.ProviderGameID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
return userID == externalUserID(session, config.UIDMode) ||
|
||||||
|
userID == strings.TrimSpace(session.DisplayUserID) ||
|
||||||
|
userID == strconv.FormatInt(session.UserID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesOpType(changeAmount int64) (string, int64) {
|
||||||
|
if changeAmount < 0 {
|
||||||
|
return "debit", -changeAmount
|
||||||
|
}
|
||||||
|
return "credit", changeAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
|
||||||
|
raw, contentType := vivaGamesJSON(code, message, map[string]any{})
|
||||||
|
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesJSON(code int, message string, data any) ([]byte, string) {
|
||||||
|
if data == nil {
|
||||||
|
data = map[string]any{}
|
||||||
|
}
|
||||||
|
return jsonResponse(map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"msg": strings.TrimSpace(message),
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesCodeFromError(err error) int {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return vivaGamesCodeOK
|
||||||
|
case xerr.IsCode(err, xerr.InsufficientBalance):
|
||||||
|
return vivaGamesCodeInsufficientBalance
|
||||||
|
case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized):
|
||||||
|
return vivaGamesCodeTokenInvalid
|
||||||
|
case xerr.IsCode(err, xerr.InvalidArgument):
|
||||||
|
return vivaGamesCodeInvalidArgument
|
||||||
|
case xerr.IsCode(err, xerr.Conflict):
|
||||||
|
return vivaGamesCodeGameMaintenance
|
||||||
|
case xerr.IsCode(err, xerr.NotFound):
|
||||||
|
return vivaGamesCodeTokenInvalid
|
||||||
|
default:
|
||||||
|
return vivaGamesCodeInternalError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vivaGamesMessageFromError(err error) string {
|
||||||
|
switch vivaGamesCodeFromError(err) {
|
||||||
|
case vivaGamesCodeInsufficientBalance:
|
||||||
|
return "balance not enough"
|
||||||
|
case vivaGamesCodeTokenInvalid:
|
||||||
|
return "token error"
|
||||||
|
case vivaGamesCodeInvalidArgument:
|
||||||
|
return "invalid argument"
|
||||||
|
case vivaGamesCodeGameMaintenance:
|
||||||
|
return "game maintenance"
|
||||||
|
default:
|
||||||
|
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
|
||||||
|
return xerr.MessageOf(err)
|
||||||
|
}
|
||||||
|
return "internal error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -99,8 +99,11 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
|
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
|
||||||
var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn)
|
var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn)
|
||||||
var firstRechargeRewardClient client.FirstRechargeRewardClient = client.NewGRPCFirstRechargeRewardClient(activityConn)
|
var firstRechargeRewardClient client.FirstRechargeRewardClient = client.NewGRPCFirstRechargeRewardClient(activityConn)
|
||||||
|
var cumulativeRechargeRewardClient client.CumulativeRechargeRewardClient = client.NewGRPCCumulativeRechargeRewardClient(activityConn)
|
||||||
var sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(activityConn)
|
var sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(activityConn)
|
||||||
var luckyGiftClient client.LuckyGiftClient = client.NewGRPCLuckyGiftClient(activityConn)
|
var luckyGiftClient client.LuckyGiftClient = client.NewGRPCLuckyGiftClient(activityConn)
|
||||||
|
var roomTurnoverRewardClient client.RoomTurnoverRewardClient = client.NewGRPCRoomTurnoverRewardClient(activityConn)
|
||||||
|
var weeklyStarClient client.WeeklyStarClient = client.NewGRPCWeeklyStarClient(activityConn)
|
||||||
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
|
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
|
||||||
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
|
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
|
||||||
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
|
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
|
||||||
@ -163,8 +166,11 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
handler.SetAchievementClient(achievementClient)
|
handler.SetAchievementClient(achievementClient)
|
||||||
handler.SetRegistrationRewardClient(registrationRewardClient)
|
handler.SetRegistrationRewardClient(registrationRewardClient)
|
||||||
handler.SetFirstRechargeRewardClient(firstRechargeRewardClient)
|
handler.SetFirstRechargeRewardClient(firstRechargeRewardClient)
|
||||||
|
handler.SetCumulativeRechargeRewardClient(cumulativeRechargeRewardClient)
|
||||||
handler.SetSevenDayCheckInClient(sevenDayCheckInClient)
|
handler.SetSevenDayCheckInClient(sevenDayCheckInClient)
|
||||||
handler.SetLuckyGiftClient(luckyGiftClient)
|
handler.SetLuckyGiftClient(luckyGiftClient)
|
||||||
|
handler.SetRoomTurnoverRewardClient(roomTurnoverRewardClient)
|
||||||
|
handler.SetWeeklyStarClient(weeklyStarClient)
|
||||||
handler.SetBroadcastClient(broadcastClient)
|
handler.SetBroadcastClient(broadcastClient)
|
||||||
handler.SetGameClient(gameClient)
|
handler.SetGameClient(gameClient)
|
||||||
handler.SetLeaderboardWalletDB(leaderboardWalletDB)
|
handler.SetLeaderboardWalletDB(leaderboardWalletDB)
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CumulativeRechargeRewardClient abstracts gateway reads from activity-service cumulative recharge reward APIs.
|
||||||
|
type CumulativeRechargeRewardClient interface {
|
||||||
|
GetCumulativeRechargeRewardStatus(ctx context.Context, req *activityv1.GetCumulativeRechargeRewardStatusRequest) (*activityv1.GetCumulativeRechargeRewardStatusResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type grpcCumulativeRechargeRewardClient struct {
|
||||||
|
client activityv1.CumulativeRechargeRewardServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGRPCCumulativeRechargeRewardClient(conn *grpc.ClientConn) CumulativeRechargeRewardClient {
|
||||||
|
return &grpcCumulativeRechargeRewardClient{client: activityv1.NewCumulativeRechargeRewardServiceClient(conn)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcCumulativeRechargeRewardClient) GetCumulativeRechargeRewardStatus(ctx context.Context, req *activityv1.GetCumulativeRechargeRewardStatusRequest) (*activityv1.GetCumulativeRechargeRewardStatusResponse, error) {
|
||||||
|
return c.client.GetCumulativeRechargeRewardStatus(ctx, req)
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoomTurnoverRewardClient abstracts gateway reads from activity-service room turnover reward APIs.
|
||||||
|
type RoomTurnoverRewardClient interface {
|
||||||
|
GetRoomTurnoverRewardStatus(ctx context.Context, req *activityv1.GetRoomTurnoverRewardStatusRequest) (*activityv1.GetRoomTurnoverRewardStatusResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type grpcRoomTurnoverRewardClient struct {
|
||||||
|
client activityv1.RoomTurnoverRewardServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGRPCRoomTurnoverRewardClient(conn *grpc.ClientConn) RoomTurnoverRewardClient {
|
||||||
|
return &grpcRoomTurnoverRewardClient{client: activityv1.NewRoomTurnoverRewardServiceClient(conn)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcRoomTurnoverRewardClient) GetRoomTurnoverRewardStatus(ctx context.Context, req *activityv1.GetRoomTurnoverRewardStatusRequest) (*activityv1.GetRoomTurnoverRewardStatusResponse, error) {
|
||||||
|
return c.client.GetRoomTurnoverRewardStatus(ctx, req)
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeeklyStarClient abstracts gateway access to activity-service weekly-star APIs.
|
||||||
|
type WeeklyStarClient interface {
|
||||||
|
GetWeeklyStarCurrent(ctx context.Context, req *activityv1.GetWeeklyStarCurrentRequest) (*activityv1.GetWeeklyStarCurrentResponse, error)
|
||||||
|
ListWeeklyStarLeaderboard(ctx context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error)
|
||||||
|
ListWeeklyStarHistory(ctx context.Context, req *activityv1.ListWeeklyStarHistoryRequest) (*activityv1.ListWeeklyStarHistoryResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type grpcWeeklyStarClient struct {
|
||||||
|
client activityv1.WeeklyStarServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGRPCWeeklyStarClient(conn *grpc.ClientConn) WeeklyStarClient {
|
||||||
|
return &grpcWeeklyStarClient{client: activityv1.NewWeeklyStarServiceClient(conn)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcWeeklyStarClient) GetWeeklyStarCurrent(ctx context.Context, req *activityv1.GetWeeklyStarCurrentRequest) (*activityv1.GetWeeklyStarCurrentResponse, error) {
|
||||||
|
return c.client.GetWeeklyStarCurrent(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcWeeklyStarClient) ListWeeklyStarLeaderboard(ctx context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error) {
|
||||||
|
return c.client.ListWeeklyStarLeaderboard(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcWeeklyStarClient) ListWeeklyStarHistory(ctx context.Context, req *activityv1.ListWeeklyStarHistoryRequest) (*activityv1.ListWeeklyStarHistoryResponse, error) {
|
||||||
|
return c.client.ListWeeklyStarHistory(ctx, req)
|
||||||
|
}
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
package activityapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
|
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cumulativeRechargeRewardUserData struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
DisplayUserID string `json:"display_user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cumulativeRechargeRewardPeriodData struct {
|
||||||
|
StartMS int64 `json:"start_ms"`
|
||||||
|
EndMS int64 `json:"end_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cumulativeRechargeRewardTierData struct {
|
||||||
|
TierID int64 `json:"tier_id"`
|
||||||
|
TierCode string `json:"tier_code"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
ThresholdUSDMinor int64 `json:"threshold_usd_minor"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
ResourceGroup *checkinResourceGroupData `json:"resource_group,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
Reached bool `json:"reached"`
|
||||||
|
Grant *cumulativeRechargeRewardGrantData `json:"grant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cumulativeRechargeRewardProgressData struct {
|
||||||
|
TotalUSDMinor int64 `json:"total_usd_minor"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cumulativeRechargeRewardGrantData struct {
|
||||||
|
GrantID string `json:"grant_id"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
TransactionID string `json:"transaction_id"`
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
TierID int64 `json:"tier_id"`
|
||||||
|
TierCode string `json:"tier_code"`
|
||||||
|
ThresholdUSDMinor int64 `json:"threshold_usd_minor"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
ReachedUSDMinor int64 `json:"reached_usd_minor"`
|
||||||
|
QualifyingUSDMinor int64 `json:"qualifying_usd_minor"`
|
||||||
|
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
|
||||||
|
RechargeType string `json:"recharge_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
WalletCommandID string `json:"wallet_command_id"`
|
||||||
|
WalletGrantID string `json:"wallet_grant_id"`
|
||||||
|
FailureReason string `json:"failure_reason"`
|
||||||
|
GrantedAtMS int64 `json:"granted_at_ms"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cumulativeRechargeRewardStatusData struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
User cumulativeRechargeRewardUserData `json:"user"`
|
||||||
|
CycleKey string `json:"cycle_key"`
|
||||||
|
Period cumulativeRechargeRewardPeriodData `json:"period"`
|
||||||
|
Tiers []cumulativeRechargeRewardTierData `json:"tiers"`
|
||||||
|
Progress cumulativeRechargeRewardProgressData `json:"progress"`
|
||||||
|
Grants []cumulativeRechargeRewardGrantData `json:"grants"`
|
||||||
|
ServerTimeMS int64 `json:"server_time_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCumulativeRechargeRewardStatus 返回当前用户本 UTC 周期累充面板;累计与发放事实只读 activity-service,gateway 只补展示依赖。
|
||||||
|
func (h *Handler) getCumulativeRechargeRewardStatus(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.cumulativeRecharge == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := auth.UserIDFromContext(request.Context())
|
||||||
|
resp, err := h.cumulativeRecharge.GetCumulativeRechargeRewardStatus(request.Context(), &activityv1.GetCumulativeRechargeRewardStatusRequest{
|
||||||
|
Meta: httpkit.ActivityMeta(request),
|
||||||
|
UserId: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := resp.GetStatus()
|
||||||
|
// activity-service 只保存 resource_group_id;gateway 负责补资源组展示素材,避免活动服务依赖 H5 展示结构。
|
||||||
|
groups, ok := h.resolveCheckinResourceGroups(writer, request, cumulativeRechargeRewardGroupIDs(status))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userData := cumulativeRechargeRewardUserData{UserID: userID}
|
||||||
|
if h.userProfileClient != nil {
|
||||||
|
// 用户资料是 H5 展示信息,读取失败时不影响活动金额和发放状态返回。
|
||||||
|
if userResp, userErr := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
UserIds: []int64{userID},
|
||||||
|
}); userErr == nil {
|
||||||
|
userData = cumulativeRechargeRewardUserFromProto(userResp.GetUsers()[userID], userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpkit.WriteOK(writer, request, cumulativeRechargeRewardStatusFromProto(status, userData, groups))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardStatusFromProto(item *activityv1.CumulativeRechargeRewardStatus, user cumulativeRechargeRewardUserData, groups map[int64]checkinResourceGroupData) cumulativeRechargeRewardStatusData {
|
||||||
|
if item == nil {
|
||||||
|
return cumulativeRechargeRewardStatusData{
|
||||||
|
User: user,
|
||||||
|
Tiers: []cumulativeRechargeRewardTierData{},
|
||||||
|
Grants: []cumulativeRechargeRewardGrantData{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grants := make([]cumulativeRechargeRewardGrantData, 0, len(item.GetGrants()))
|
||||||
|
grantsByTierID := make(map[int64]cumulativeRechargeRewardGrantData, len(item.GetGrants()))
|
||||||
|
for _, grant := range item.GetGrants() {
|
||||||
|
// grant 按 tier_id 回填到档位,H5 可以直接渲染每个档位的 reached/granted/failed 状态。
|
||||||
|
converted := cumulativeRechargeRewardGrantFromProto(grant)
|
||||||
|
grants = append(grants, converted)
|
||||||
|
grantsByTierID[converted.TierID] = converted
|
||||||
|
}
|
||||||
|
tiers := make([]cumulativeRechargeRewardTierData, 0, len(item.GetConfig().GetTiers()))
|
||||||
|
for _, tier := range item.GetConfig().GetTiers() {
|
||||||
|
converted := cumulativeRechargeRewardTierFromProto(tier, item.GetProgress().GetTotalUsdMinor(), groups)
|
||||||
|
if grant, ok := grantsByTierID[converted.TierID]; ok {
|
||||||
|
converted.Grant = &grant
|
||||||
|
}
|
||||||
|
tiers = append(tiers, converted)
|
||||||
|
}
|
||||||
|
return cumulativeRechargeRewardStatusData{
|
||||||
|
Enabled: item.GetConfig().GetEnabled(),
|
||||||
|
User: user,
|
||||||
|
CycleKey: item.GetCycleKey(),
|
||||||
|
Period: cumulativeRechargeRewardPeriodData{
|
||||||
|
StartMS: item.GetPeriodStartMs(),
|
||||||
|
EndMS: item.GetPeriodEndMs(),
|
||||||
|
},
|
||||||
|
Tiers: tiers,
|
||||||
|
Progress: cumulativeRechargeRewardProgressData{
|
||||||
|
TotalUSDMinor: item.GetProgress().GetTotalUsdMinor(),
|
||||||
|
UpdatedAtMS: item.GetProgress().GetUpdatedAtMs(),
|
||||||
|
},
|
||||||
|
Grants: grants,
|
||||||
|
ServerTimeMS: item.GetServerTimeMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardTierFromProto(item *activityv1.CumulativeRechargeRewardTier, totalUSDMinor int64, groups map[int64]checkinResourceGroupData) cumulativeRechargeRewardTierData {
|
||||||
|
if item == nil {
|
||||||
|
return cumulativeRechargeRewardTierData{}
|
||||||
|
}
|
||||||
|
data := cumulativeRechargeRewardTierData{
|
||||||
|
TierID: item.GetTierId(),
|
||||||
|
TierCode: item.GetTierCode(),
|
||||||
|
TierName: item.GetTierName(),
|
||||||
|
ThresholdUSDMinor: item.GetThresholdUsdMinor(),
|
||||||
|
ResourceGroupID: item.GetResourceGroupId(),
|
||||||
|
Status: item.GetStatus(),
|
||||||
|
SortOrder: item.GetSortOrder(),
|
||||||
|
Reached: totalUSDMinor >= item.GetThresholdUsdMinor(),
|
||||||
|
}
|
||||||
|
if group, ok := groups[item.GetResourceGroupId()]; ok {
|
||||||
|
data.ResourceGroup = &group
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardGrantFromProto(item *activityv1.CumulativeRechargeRewardGrant) cumulativeRechargeRewardGrantData {
|
||||||
|
if item == nil {
|
||||||
|
return cumulativeRechargeRewardGrantData{}
|
||||||
|
}
|
||||||
|
return cumulativeRechargeRewardGrantData{
|
||||||
|
GrantID: item.GetGrantId(),
|
||||||
|
EventID: item.GetEventId(),
|
||||||
|
TransactionID: item.GetTransactionId(),
|
||||||
|
CommandID: item.GetCommandId(),
|
||||||
|
UserID: item.GetUserId(),
|
||||||
|
TierID: item.GetTierId(),
|
||||||
|
TierCode: item.GetTierCode(),
|
||||||
|
ThresholdUSDMinor: item.GetThresholdUsdMinor(),
|
||||||
|
ResourceGroupID: item.GetResourceGroupId(),
|
||||||
|
ReachedUSDMinor: item.GetReachedUsdMinor(),
|
||||||
|
QualifyingUSDMinor: item.GetQualifyingUsdMinor(),
|
||||||
|
RechargeCoinAmount: item.GetRechargeCoinAmount(),
|
||||||
|
RechargeType: item.GetRechargeType(),
|
||||||
|
Status: item.GetStatus(),
|
||||||
|
WalletCommandID: item.GetWalletCommandId(),
|
||||||
|
WalletGrantID: item.GetWalletGrantId(),
|
||||||
|
FailureReason: item.GetFailureReason(),
|
||||||
|
GrantedAtMS: item.GetGrantedAtMs(),
|
||||||
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardGroupIDs(item *activityv1.CumulativeRechargeRewardStatus) []int64 {
|
||||||
|
if item == nil || item.GetConfig() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(item.GetConfig().GetTiers()))
|
||||||
|
for _, tier := range item.GetConfig().GetTiers() {
|
||||||
|
if tier.GetResourceGroupId() > 0 {
|
||||||
|
ids = append(ids, tier.GetResourceGroupId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func cumulativeRechargeRewardUserFromProto(item *userv1.User, fallbackUserID int64) cumulativeRechargeRewardUserData {
|
||||||
|
if item == nil {
|
||||||
|
return cumulativeRechargeRewardUserData{UserID: fallbackUserID}
|
||||||
|
}
|
||||||
|
return cumulativeRechargeRewardUserData{
|
||||||
|
UserID: item.GetUserId(),
|
||||||
|
DisplayUserID: item.GetDisplayUserId(),
|
||||||
|
Username: item.GetUsername(),
|
||||||
|
Avatar: item.GetAvatar(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,8 +19,11 @@ type Handler struct {
|
|||||||
walletDB *sql.DB
|
walletDB *sql.DB
|
||||||
registrationReward client.RegistrationRewardClient
|
registrationReward client.RegistrationRewardClient
|
||||||
firstRechargeReward client.FirstRechargeRewardClient
|
firstRechargeReward client.FirstRechargeRewardClient
|
||||||
|
cumulativeRecharge client.CumulativeRechargeRewardClient
|
||||||
sevenDayCheckIn client.SevenDayCheckInClient
|
sevenDayCheckIn client.SevenDayCheckInClient
|
||||||
luckyGift client.LuckyGiftClient
|
luckyGift client.LuckyGiftClient
|
||||||
|
roomTurnoverReward client.RoomTurnoverRewardClient
|
||||||
|
weeklyStar client.WeeklyStarClient
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -33,8 +36,11 @@ type Config struct {
|
|||||||
WalletDB *sql.DB
|
WalletDB *sql.DB
|
||||||
RegistrationReward client.RegistrationRewardClient
|
RegistrationReward client.RegistrationRewardClient
|
||||||
FirstRechargeReward client.FirstRechargeRewardClient
|
FirstRechargeReward client.FirstRechargeRewardClient
|
||||||
|
CumulativeRecharge client.CumulativeRechargeRewardClient
|
||||||
SevenDayCheckIn client.SevenDayCheckInClient
|
SevenDayCheckIn client.SevenDayCheckInClient
|
||||||
LuckyGift client.LuckyGiftClient
|
LuckyGift client.LuckyGiftClient
|
||||||
|
RoomTurnoverReward client.RoomTurnoverRewardClient
|
||||||
|
WeeklyStar client.WeeklyStarClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(config Config) *Handler {
|
func New(config Config) *Handler {
|
||||||
@ -48,21 +54,29 @@ func New(config Config) *Handler {
|
|||||||
walletDB: config.WalletDB,
|
walletDB: config.WalletDB,
|
||||||
registrationReward: config.RegistrationReward,
|
registrationReward: config.RegistrationReward,
|
||||||
firstRechargeReward: config.FirstRechargeReward,
|
firstRechargeReward: config.FirstRechargeReward,
|
||||||
|
cumulativeRecharge: config.CumulativeRecharge,
|
||||||
sevenDayCheckIn: config.SevenDayCheckIn,
|
sevenDayCheckIn: config.SevenDayCheckIn,
|
||||||
luckyGift: config.LuckyGift,
|
luckyGift: config.LuckyGift,
|
||||||
|
roomTurnoverReward: config.RoomTurnoverReward,
|
||||||
|
weeklyStar: config.WeeklyStar,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
|
func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
|
||||||
return httproutes.TaskHandlers{
|
return httproutes.TaskHandlers{
|
||||||
ListTaskTabs: h.listTaskTabs,
|
ListTaskTabs: h.listTaskTabs,
|
||||||
ClaimTaskReward: h.claimTaskReward,
|
ClaimTaskReward: h.claimTaskReward,
|
||||||
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
|
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
|
||||||
GetFirstRechargeRewardStatus: h.getFirstRechargeRewardStatus,
|
GetFirstRechargeRewardStatus: h.getFirstRechargeRewardStatus,
|
||||||
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
|
GetCumulativeRechargeRewardStatus: h.getCumulativeRechargeRewardStatus,
|
||||||
SignSevenDayCheckIn: h.signSevenDayCheckIn,
|
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
|
||||||
ListUserLeaderboards: h.listUserLeaderboards,
|
SignSevenDayCheckIn: h.signSevenDayCheckIn,
|
||||||
CheckLuckyGift: h.checkLuckyGift,
|
ListUserLeaderboards: h.listUserLeaderboards,
|
||||||
|
CheckLuckyGift: h.checkLuckyGift,
|
||||||
|
GetRoomTurnoverRewardStatus: h.getRoomTurnoverRewardStatus,
|
||||||
|
GetWeeklyStarCurrent: h.getWeeklyStarCurrent,
|
||||||
|
ListWeeklyStarLeaderboard: h.listWeeklyStarLeaderboard,
|
||||||
|
ListWeeklyStarHistory: h.listWeeklyStarHistory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,237 @@
|
|||||||
|
package activityapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
|
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roomTurnoverRewardTierData struct {
|
||||||
|
TierID int64 `json:"tier_id"`
|
||||||
|
TierCode string `json:"tier_code"`
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
ThresholdCoinSpent int64 `json:"threshold_coin_spent"`
|
||||||
|
RewardCoinAmount int64 `json:"reward_coin_amount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomTurnoverRewardUserData struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
DisplayUserID string `json:"display_user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomTurnoverRewardRoomData struct {
|
||||||
|
HasRoom bool `json:"has_room"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
OwnerUserID int64 `json:"owner_user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomTurnoverRewardPeriodData struct {
|
||||||
|
StartMS int64 `json:"start_ms"`
|
||||||
|
EndMS int64 `json:"end_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomTurnoverRewardSettlementData struct {
|
||||||
|
SettlementID string `json:"settlement_id"`
|
||||||
|
RoomID string `json:"room_id"`
|
||||||
|
OwnerUserID int64 `json:"owner_user_id"`
|
||||||
|
PeriodStartMS int64 `json:"period_start_ms"`
|
||||||
|
PeriodEndMS int64 `json:"period_end_ms"`
|
||||||
|
CoinSpent int64 `json:"coin_spent"`
|
||||||
|
TierID int64 `json:"tier_id"`
|
||||||
|
TierCode string `json:"tier_code"`
|
||||||
|
ThresholdCoinSpent int64 `json:"threshold_coin_spent"`
|
||||||
|
RewardCoinAmount int64 `json:"reward_coin_amount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
WalletCommandID string `json:"wallet_command_id"`
|
||||||
|
WalletTransactionID string `json:"wallet_transaction_id"`
|
||||||
|
FailureReason string `json:"failure_reason"`
|
||||||
|
SettledAtMS int64 `json:"settled_at_ms"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type roomTurnoverRewardStatusData struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
User roomTurnoverRewardUserData `json:"user"`
|
||||||
|
Room roomTurnoverRewardRoomData `json:"room"`
|
||||||
|
Period roomTurnoverRewardPeriodData `json:"period"`
|
||||||
|
Tiers []roomTurnoverRewardTierData `json:"tiers"`
|
||||||
|
CurrentCoinSpent int64 `json:"current_coin_spent"`
|
||||||
|
ExpectedRewardCoinAmount int64 `json:"expected_reward_coin_amount"`
|
||||||
|
MatchedTier *roomTurnoverRewardTierData `json:"matched_tier,omitempty"`
|
||||||
|
LatestSettlement *roomTurnoverRewardSettlementData `json:"latest_settlement,omitempty"`
|
||||||
|
ServerTimeMS int64 `json:"server_time_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getRoomTurnoverRewardStatus(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.roomTurnoverReward == nil || h.roomQueryClient == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := auth.UserIDFromContext(request.Context())
|
||||||
|
roomData := roomTurnoverRewardRoomData{OwnerUserID: userID}
|
||||||
|
// App 接口按“当前登录用户自己的房间”展示活动进度;gateway 只编排 room-service 查询,不在本服务缓存房间归属。
|
||||||
|
roomResp, err := h.roomQueryClient.GetMyRoom(request.Context(), &roomv1.GetMyRoomRequest{
|
||||||
|
Meta: httpkit.RoomMeta(request, "", ""),
|
||||||
|
OwnerUserId: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if roomResp.GetHasRoom() {
|
||||||
|
roomData = roomTurnoverRewardRoomFromProto(roomResp.GetRoom(), userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// activity-service 只需要房间 ID 和 owner_user_id 计算当前周期流水与预计奖励;用户头像、昵称等展示字段由 gateway 统一补齐。
|
||||||
|
resp, err := h.roomTurnoverReward.GetRoomTurnoverRewardStatus(request.Context(), &activityv1.GetRoomTurnoverRewardStatusRequest{
|
||||||
|
Meta: httpkit.ActivityMeta(request),
|
||||||
|
UserId: userID,
|
||||||
|
RoomId: roomData.RoomID,
|
||||||
|
OwnerUserId: roomData.OwnerUserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := roomTurnoverRewardUserData{UserID: userID}
|
||||||
|
if h.userProfileClient != nil {
|
||||||
|
// 用户资料只服务 H5 展示;读取失败时奖励状态仍应可用,避免头像依赖放大活动接口故障面。
|
||||||
|
if userResp, userErr := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
UserIds: []int64{userID},
|
||||||
|
}); userErr == nil {
|
||||||
|
userData = roomTurnoverRewardUserFromProto(userResp.GetUsers()[userID], userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpkit.WriteOK(writer, request, roomTurnoverRewardStatusFromProto(resp.GetStatus(), roomData, userData))
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardStatusFromProto(item *activityv1.RoomTurnoverRewardStatus, room roomTurnoverRewardRoomData, user roomTurnoverRewardUserData) roomTurnoverRewardStatusData {
|
||||||
|
if item == nil {
|
||||||
|
return roomTurnoverRewardStatusData{
|
||||||
|
User: user,
|
||||||
|
Room: room,
|
||||||
|
Tiers: []roomTurnoverRewardTierData{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tiers := make([]roomTurnoverRewardTierData, 0, len(item.GetConfig().GetTiers()))
|
||||||
|
for _, tier := range item.GetConfig().GetTiers() {
|
||||||
|
tiers = append(tiers, roomTurnoverRewardTierFromProto(tier))
|
||||||
|
}
|
||||||
|
var matched *roomTurnoverRewardTierData
|
||||||
|
if item.GetMatchedTier() != nil {
|
||||||
|
// matched_tier 使用指针是为了区分“未达档位”和“达到了 ID 为 0 的空值”,前端可直接按 null 判断未命中。
|
||||||
|
converted := roomTurnoverRewardTierFromProto(item.GetMatchedTier())
|
||||||
|
matched = &converted
|
||||||
|
}
|
||||||
|
var latest *roomTurnoverRewardSettlementData
|
||||||
|
if item.GetLatestSettlement() != nil {
|
||||||
|
// latest_settlement 允许跨周期展示上一周发奖状态;当前周期流水清零后,H5 仍能提示最近一次结算结果。
|
||||||
|
converted := roomTurnoverRewardSettlementFromProto(item.GetLatestSettlement())
|
||||||
|
latest = &converted
|
||||||
|
}
|
||||||
|
room.RoomID = item.GetRoomId()
|
||||||
|
room.OwnerUserID = item.GetOwnerUserId()
|
||||||
|
if room.OwnerUserID == 0 {
|
||||||
|
// 没有房间或 activity-service 未返回 owner 时,保留当前登录用户作为展示兜底,避免 H5 出现空 owner。
|
||||||
|
room.OwnerUserID = user.UserID
|
||||||
|
}
|
||||||
|
return roomTurnoverRewardStatusData{
|
||||||
|
Enabled: item.GetConfig().GetEnabled(),
|
||||||
|
User: user,
|
||||||
|
Room: room,
|
||||||
|
Period: roomTurnoverRewardPeriodData{
|
||||||
|
StartMS: item.GetPeriodStartMs(),
|
||||||
|
EndMS: item.GetPeriodEndMs(),
|
||||||
|
},
|
||||||
|
Tiers: tiers,
|
||||||
|
CurrentCoinSpent: item.GetCurrentCoinSpent(),
|
||||||
|
ExpectedRewardCoinAmount: item.GetExpectedRewardCoinAmount(),
|
||||||
|
MatchedTier: matched,
|
||||||
|
LatestSettlement: latest,
|
||||||
|
ServerTimeMS: item.GetServerTimeMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardRoomFromProto(item *roomv1.RoomListItem, fallbackOwnerUserID int64) roomTurnoverRewardRoomData {
|
||||||
|
if item == nil {
|
||||||
|
return roomTurnoverRewardRoomData{OwnerUserID: fallbackOwnerUserID}
|
||||||
|
}
|
||||||
|
ownerID := item.GetOwnerUserId()
|
||||||
|
if ownerID == 0 {
|
||||||
|
ownerID = fallbackOwnerUserID
|
||||||
|
}
|
||||||
|
return roomTurnoverRewardRoomData{
|
||||||
|
HasRoom: true,
|
||||||
|
RoomID: item.GetRoomId(),
|
||||||
|
OwnerUserID: ownerID,
|
||||||
|
Title: item.GetTitle(),
|
||||||
|
CoverURL: item.GetCoverUrl(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardUserFromProto(item *userv1.User, fallbackUserID int64) roomTurnoverRewardUserData {
|
||||||
|
if item == nil {
|
||||||
|
return roomTurnoverRewardUserData{UserID: fallbackUserID}
|
||||||
|
}
|
||||||
|
return roomTurnoverRewardUserData{
|
||||||
|
UserID: item.GetUserId(),
|
||||||
|
DisplayUserID: item.GetDisplayUserId(),
|
||||||
|
Username: item.GetUsername(),
|
||||||
|
Avatar: item.GetAvatar(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardTierFromProto(item *activityv1.RoomTurnoverRewardTier) roomTurnoverRewardTierData {
|
||||||
|
if item == nil {
|
||||||
|
return roomTurnoverRewardTierData{}
|
||||||
|
}
|
||||||
|
return roomTurnoverRewardTierData{
|
||||||
|
TierID: item.GetTierId(),
|
||||||
|
TierCode: item.GetTierCode(),
|
||||||
|
TierName: item.GetTierName(),
|
||||||
|
ThresholdCoinSpent: item.GetThresholdCoinSpent(),
|
||||||
|
RewardCoinAmount: item.GetRewardCoinAmount(),
|
||||||
|
Status: item.GetStatus(),
|
||||||
|
SortOrder: item.GetSortOrder(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomTurnoverRewardSettlementFromProto(item *activityv1.RoomTurnoverRewardSettlement) roomTurnoverRewardSettlementData {
|
||||||
|
if item == nil {
|
||||||
|
return roomTurnoverRewardSettlementData{}
|
||||||
|
}
|
||||||
|
return roomTurnoverRewardSettlementData{
|
||||||
|
SettlementID: item.GetSettlementId(),
|
||||||
|
RoomID: item.GetRoomId(),
|
||||||
|
OwnerUserID: item.GetOwnerUserId(),
|
||||||
|
PeriodStartMS: item.GetPeriodStartMs(),
|
||||||
|
PeriodEndMS: item.GetPeriodEndMs(),
|
||||||
|
CoinSpent: item.GetCoinSpent(),
|
||||||
|
TierID: item.GetTierId(),
|
||||||
|
TierCode: item.GetTierCode(),
|
||||||
|
ThresholdCoinSpent: item.GetThresholdCoinSpent(),
|
||||||
|
RewardCoinAmount: item.GetRewardCoinAmount(),
|
||||||
|
Status: item.GetStatus(),
|
||||||
|
WalletCommandID: item.GetWalletCommandId(),
|
||||||
|
WalletTransactionID: item.GetWalletTransactionId(),
|
||||||
|
FailureReason: item.GetFailureReason(),
|
||||||
|
SettledAtMS: item.GetSettledAtMs(),
|
||||||
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,282 @@
|
|||||||
|
package activityapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
|
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type weeklyStarCycleData struct {
|
||||||
|
CycleID string `json:"cycle_id"`
|
||||||
|
ActivityCode string `json:"activity_code"`
|
||||||
|
RegionID int64 `json:"region_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartMS int64 `json:"start_ms"`
|
||||||
|
EndMS int64 `json:"end_ms"`
|
||||||
|
Gifts []weeklyStarGiftData `json:"gifts"`
|
||||||
|
Rewards []weeklyStarRewardData `json:"rewards"`
|
||||||
|
SettledAtMS int64 `json:"settled_at_ms,omitempty"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarGiftData struct {
|
||||||
|
GiftID string `json:"gift_id"`
|
||||||
|
SortOrder int32 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarRewardData struct {
|
||||||
|
RankNo int32 `json:"rank_no"`
|
||||||
|
ResourceGroupID int64 `json:"resource_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarEntryData struct {
|
||||||
|
RankNo int32 `json:"rank_no"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Score int64 `json:"score"`
|
||||||
|
FirstScoredAtMS int64 `json:"first_scored_at_ms"`
|
||||||
|
LastScoredAtMS int64 `json:"last_scored_at_ms"`
|
||||||
|
User *weeklyStarUserData `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarUserData struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
DisplayUserID string `json:"display_user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
RegionID int64 `json:"region_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarCurrentData struct {
|
||||||
|
Cycle *weeklyStarCycleData `json:"cycle,omitempty"`
|
||||||
|
TopEntries []weeklyStarEntryData `json:"top_entries"`
|
||||||
|
MyEntry *weeklyStarEntryData `json:"my_entry,omitempty"`
|
||||||
|
ServerTimeMS int64 `json:"server_time_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarLeaderboardData struct {
|
||||||
|
Cycle *weeklyStarCycleData `json:"cycle,omitempty"`
|
||||||
|
Entries []weeklyStarEntryData `json:"entries"`
|
||||||
|
NextPageToken string `json:"next_page_token,omitempty"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarHistoryData struct {
|
||||||
|
Cycles []weeklyStarHistoryCycleData `json:"cycles"`
|
||||||
|
ServerTimeMS int64 `json:"server_time_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type weeklyStarHistoryCycleData struct {
|
||||||
|
Cycle *weeklyStarCycleData `json:"cycle,omitempty"`
|
||||||
|
TopEntries []weeklyStarEntryData `json:"top_entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWeeklyStarCurrent returns the current region/default weekly-star cycle for the logged-in user.
|
||||||
|
func (h *Handler) getWeeklyStarCurrent(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.weeklyStar == nil || h.userProfileClient == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := auth.UserIDFromContext(request.Context())
|
||||||
|
regionID, ok := h.resolveWeeklyStarRegionID(writer, request, userID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.weeklyStar.GetWeeklyStarCurrent(request.Context(), &activityv1.GetWeeklyStarCurrentRequest{
|
||||||
|
Meta: httpkit.ActivityMeta(request),
|
||||||
|
UserId: userID,
|
||||||
|
RegionId: regionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users := h.resolveWeeklyStarUsers(request, weeklyStarUserIDs(resp.GetTopEntries(), resp.GetMyEntry()))
|
||||||
|
data := weeklyStarCurrentData{
|
||||||
|
Cycle: weeklyStarCycleFromProto(resp.GetCycle()),
|
||||||
|
TopEntries: weeklyStarEntriesFromProto(resp.GetTopEntries(), users),
|
||||||
|
ServerTimeMS: resp.GetServerTimeMs(),
|
||||||
|
}
|
||||||
|
if resp.GetMyEntry() != nil {
|
||||||
|
entry := weeklyStarEntryFromProto(resp.GetMyEntry(), users)
|
||||||
|
data.MyEntry = &entry
|
||||||
|
}
|
||||||
|
httpkit.WriteOK(writer, request, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listWeeklyStarLeaderboard pages through the selected cycle or current region cycle.
|
||||||
|
func (h *Handler) listWeeklyStarLeaderboard(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.weeklyStar == nil || h.userProfileClient == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := auth.UserIDFromContext(request.Context())
|
||||||
|
regionID, ok := h.resolveWeeklyStarRegionID(writer, request, userID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.weeklyStar.ListWeeklyStarLeaderboard(request.Context(), &activityv1.ListWeeklyStarLeaderboardRequest{
|
||||||
|
Meta: httpkit.ActivityMeta(request),
|
||||||
|
CycleId: strings.TrimSpace(request.URL.Query().Get("cycle_id")),
|
||||||
|
RegionId: regionID,
|
||||||
|
PageSize: int32(queryInt(request, "page_size", 50)),
|
||||||
|
PageToken: strings.TrimSpace(request.URL.Query().Get("page_token")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users := h.resolveWeeklyStarUsers(request, weeklyStarUserIDs(resp.GetEntries(), nil))
|
||||||
|
httpkit.WriteOK(writer, request, weeklyStarLeaderboardData{
|
||||||
|
Cycle: weeklyStarCycleFromProto(resp.GetCycle()),
|
||||||
|
Entries: weeklyStarEntriesFromProto(resp.GetEntries(), users),
|
||||||
|
NextPageToken: resp.GetNextPageToken(),
|
||||||
|
Total: resp.GetTotal(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// listWeeklyStarHistory returns recent settled cycles for the current user's region with default fallback.
|
||||||
|
func (h *Handler) listWeeklyStarHistory(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.weeklyStar == nil || h.userProfileClient == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := auth.UserIDFromContext(request.Context())
|
||||||
|
regionID, ok := h.resolveWeeklyStarRegionID(writer, request, userID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := h.weeklyStar.ListWeeklyStarHistory(request.Context(), &activityv1.ListWeeklyStarHistoryRequest{
|
||||||
|
Meta: httpkit.ActivityMeta(request),
|
||||||
|
RegionId: regionID,
|
||||||
|
Limit: int32(queryInt(request, "limit", 10)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0)
|
||||||
|
for _, cycle := range resp.GetCycles() {
|
||||||
|
ids = append(ids, weeklyStarUserIDs(cycle.GetTopEntries(), nil)...)
|
||||||
|
}
|
||||||
|
users := h.resolveWeeklyStarUsers(request, ids)
|
||||||
|
cycles := make([]weeklyStarHistoryCycleData, 0, len(resp.GetCycles()))
|
||||||
|
for _, item := range resp.GetCycles() {
|
||||||
|
cycles = append(cycles, weeklyStarHistoryCycleData{
|
||||||
|
Cycle: weeklyStarCycleFromProto(item.GetCycle()),
|
||||||
|
TopEntries: weeklyStarEntriesFromProto(item.GetTopEntries(), users),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
httpkit.WriteOK(writer, request, weeklyStarHistoryData{Cycles: cycles, ServerTimeMS: resp.GetServerTimeMs()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) resolveWeeklyStarRegionID(writer http.ResponseWriter, request *http.Request, userID int64) (int64, bool) {
|
||||||
|
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
UserId: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return resp.GetUser().GetRegionId(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) resolveWeeklyStarUsers(request *http.Request, ids []int64) map[int64]weeklyStarUserData {
|
||||||
|
users := make(map[int64]weeklyStarUserData)
|
||||||
|
ids = uniquePositiveIDs(ids)
|
||||||
|
if len(ids) == 0 || h.userProfileClient == nil {
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
UserIds: ids,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
for _, item := range resp.GetUsers() {
|
||||||
|
users[item.GetUserId()] = weeklyStarUserData{
|
||||||
|
UserID: strconv.FormatInt(item.GetUserId(), 10),
|
||||||
|
DisplayUserID: item.GetDisplayUserId(),
|
||||||
|
Username: item.GetUsername(),
|
||||||
|
Avatar: item.GetAvatar(),
|
||||||
|
RegionID: item.GetRegionId(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle) *weeklyStarCycleData {
|
||||||
|
if item == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cycle := &weeklyStarCycleData{
|
||||||
|
CycleID: item.GetCycleId(),
|
||||||
|
ActivityCode: item.GetActivityCode(),
|
||||||
|
RegionID: item.GetRegionId(),
|
||||||
|
Title: item.GetTitle(),
|
||||||
|
Status: item.GetStatus(),
|
||||||
|
StartMS: item.GetStartMs(),
|
||||||
|
EndMS: item.GetEndMs(),
|
||||||
|
Gifts: make([]weeklyStarGiftData, 0, len(item.GetGifts())),
|
||||||
|
Rewards: make([]weeklyStarRewardData, 0, len(item.GetRewards())),
|
||||||
|
SettledAtMS: item.GetSettledAtMs(),
|
||||||
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
|
}
|
||||||
|
for _, gift := range item.GetGifts() {
|
||||||
|
cycle.Gifts = append(cycle.Gifts, weeklyStarGiftData{GiftID: gift.GetGiftId(), SortOrder: gift.GetSortOrder()})
|
||||||
|
}
|
||||||
|
for _, reward := range item.GetRewards() {
|
||||||
|
cycle.Rewards = append(cycle.Rewards, weeklyStarRewardData{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()})
|
||||||
|
}
|
||||||
|
return cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarEntriesFromProto(items []*activityv1.WeeklyStarLeaderboardEntry, users map[int64]weeklyStarUserData) []weeklyStarEntryData {
|
||||||
|
entries := make([]weeklyStarEntryData, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
entries = append(entries, weeklyStarEntryFromProto(item, users))
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarEntryFromProto(item *activityv1.WeeklyStarLeaderboardEntry, users map[int64]weeklyStarUserData) weeklyStarEntryData {
|
||||||
|
entry := weeklyStarEntryData{
|
||||||
|
RankNo: item.GetRankNo(),
|
||||||
|
UserID: strconv.FormatInt(item.GetUserId(), 10),
|
||||||
|
Score: item.GetScore(),
|
||||||
|
FirstScoredAtMS: item.GetFirstScoredAtMs(),
|
||||||
|
LastScoredAtMS: item.GetLastScoredAtMs(),
|
||||||
|
}
|
||||||
|
if user, ok := users[item.GetUserId()]; ok {
|
||||||
|
entry.User = &user
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyStarUserIDs(items []*activityv1.WeeklyStarLeaderboardEntry, extra *activityv1.WeeklyStarLeaderboardEntry) []int64 {
|
||||||
|
ids := make([]int64, 0, len(items)+1)
|
||||||
|
for _, item := range items {
|
||||||
|
ids = append(ids, item.GetUserId())
|
||||||
|
}
|
||||||
|
if extra != nil {
|
||||||
|
ids = append(ids, extra.GetUserId())
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryInt(request *http.Request, key string, fallback int) int {
|
||||||
|
value, err := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get(key)))
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@ -40,8 +40,11 @@ type Handler struct {
|
|||||||
achievementClient client.AchievementClient
|
achievementClient client.AchievementClient
|
||||||
registrationReward client.RegistrationRewardClient
|
registrationReward client.RegistrationRewardClient
|
||||||
firstRechargeReward client.FirstRechargeRewardClient
|
firstRechargeReward client.FirstRechargeRewardClient
|
||||||
|
cumulativeRecharge client.CumulativeRechargeRewardClient
|
||||||
sevenDayCheckIn client.SevenDayCheckInClient
|
sevenDayCheckIn client.SevenDayCheckInClient
|
||||||
luckyGift client.LuckyGiftClient
|
luckyGift client.LuckyGiftClient
|
||||||
|
roomTurnoverReward client.RoomTurnoverRewardClient
|
||||||
|
weeklyStar client.WeeklyStarClient
|
||||||
broadcastClient client.BroadcastClient
|
broadcastClient client.BroadcastClient
|
||||||
gameClient client.GameClient
|
gameClient client.GameClient
|
||||||
appConfigReader appapi.ConfigReader
|
appConfigReader appapi.ConfigReader
|
||||||
@ -212,6 +215,11 @@ func (h *Handler) SetFirstRechargeRewardClient(firstRechargeReward client.FirstR
|
|||||||
h.firstRechargeReward = firstRechargeReward
|
h.firstRechargeReward = firstRechargeReward
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCumulativeRechargeRewardClient 注入 activity-service 累充奖励查询 client。
|
||||||
|
func (h *Handler) SetCumulativeRechargeRewardClient(cumulativeRecharge client.CumulativeRechargeRewardClient) {
|
||||||
|
h.cumulativeRecharge = cumulativeRecharge
|
||||||
|
}
|
||||||
|
|
||||||
// SetSevenDayCheckInClient 注入 activity-service 七日签到 client。
|
// SetSevenDayCheckInClient 注入 activity-service 七日签到 client。
|
||||||
func (h *Handler) SetSevenDayCheckInClient(sevenDayCheckIn client.SevenDayCheckInClient) {
|
func (h *Handler) SetSevenDayCheckInClient(sevenDayCheckIn client.SevenDayCheckInClient) {
|
||||||
h.sevenDayCheckIn = sevenDayCheckIn
|
h.sevenDayCheckIn = sevenDayCheckIn
|
||||||
@ -222,6 +230,16 @@ func (h *Handler) SetLuckyGiftClient(luckyGift client.LuckyGiftClient) {
|
|||||||
h.luckyGift = luckyGift
|
h.luckyGift = luckyGift
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRoomTurnoverRewardClient 注入 activity-service 房间流水奖励查询 client。
|
||||||
|
func (h *Handler) SetRoomTurnoverRewardClient(roomTurnoverReward client.RoomTurnoverRewardClient) {
|
||||||
|
h.roomTurnoverReward = roomTurnoverReward
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWeeklyStarClient 注入 activity-service 周星活动 client。
|
||||||
|
func (h *Handler) SetWeeklyStarClient(weeklyStar client.WeeklyStarClient) {
|
||||||
|
h.weeklyStar = weeklyStar
|
||||||
|
}
|
||||||
|
|
||||||
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
|
// SetBroadcastClient 注入 activity-service 播报群成员关系 client。
|
||||||
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
|
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
|
||||||
h.broadcastClient = broadcastClient
|
h.broadcastClient = broadcastClient
|
||||||
|
|||||||
@ -159,14 +159,19 @@ type MessageHandlers struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaskHandlers struct {
|
type TaskHandlers struct {
|
||||||
ListTaskTabs http.HandlerFunc
|
ListTaskTabs http.HandlerFunc
|
||||||
ClaimTaskReward http.HandlerFunc
|
ClaimTaskReward http.HandlerFunc
|
||||||
GetRegistrationRewardEligibility http.HandlerFunc
|
GetRegistrationRewardEligibility http.HandlerFunc
|
||||||
GetFirstRechargeRewardStatus http.HandlerFunc
|
GetFirstRechargeRewardStatus http.HandlerFunc
|
||||||
GetSevenDayCheckInStatus http.HandlerFunc
|
GetCumulativeRechargeRewardStatus http.HandlerFunc
|
||||||
SignSevenDayCheckIn http.HandlerFunc
|
GetSevenDayCheckInStatus http.HandlerFunc
|
||||||
ListUserLeaderboards http.HandlerFunc
|
SignSevenDayCheckIn http.HandlerFunc
|
||||||
CheckLuckyGift http.HandlerFunc
|
ListUserLeaderboards http.HandlerFunc
|
||||||
|
CheckLuckyGift http.HandlerFunc
|
||||||
|
GetRoomTurnoverRewardStatus http.HandlerFunc
|
||||||
|
GetWeeklyStarCurrent http.HandlerFunc
|
||||||
|
ListWeeklyStarLeaderboard http.HandlerFunc
|
||||||
|
ListWeeklyStarHistory http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type LevelHandlers struct {
|
type LevelHandlers struct {
|
||||||
@ -409,9 +414,14 @@ func (r routes) registerTaskRoutes() {
|
|||||||
h := r.config.Task
|
h := r.config.Task
|
||||||
r.profile("/activities/registration-reward/eligibility", http.MethodGet, h.GetRegistrationRewardEligibility)
|
r.profile("/activities/registration-reward/eligibility", http.MethodGet, h.GetRegistrationRewardEligibility)
|
||||||
r.profile("/activities/first-recharge-reward", http.MethodGet, h.GetFirstRechargeRewardStatus)
|
r.profile("/activities/first-recharge-reward", http.MethodGet, h.GetFirstRechargeRewardStatus)
|
||||||
|
r.profile("/activities/cumulative-recharge-reward", http.MethodGet, h.GetCumulativeRechargeRewardStatus)
|
||||||
r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus)
|
r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus)
|
||||||
r.profile("/activities/seven-day-checkin/sign", http.MethodPost, h.SignSevenDayCheckIn)
|
r.profile("/activities/seven-day-checkin/sign", http.MethodPost, h.SignSevenDayCheckIn)
|
||||||
r.profile("/activities/user-leaderboards", http.MethodGet, h.ListUserLeaderboards)
|
r.profile("/activities/user-leaderboards", http.MethodGet, h.ListUserLeaderboards)
|
||||||
|
r.profile("/activities/room-turnover-reward", http.MethodGet, h.GetRoomTurnoverRewardStatus)
|
||||||
|
r.profile("/activities/weekly-star/current", http.MethodGet, h.GetWeeklyStarCurrent)
|
||||||
|
r.profile("/activities/weekly-star/leaderboard", http.MethodGet, h.ListWeeklyStarLeaderboard)
|
||||||
|
r.profile("/activities/weekly-star/history", http.MethodGet, h.ListWeeklyStarHistory)
|
||||||
// App 侧接口保持稳定;内部仍按 pool_id 走当前 v2 规则、抽奖和返奖实现。
|
// App 侧接口保持稳定;内部仍按 pool_id 走当前 v2 规则、抽奖和返奖实现。
|
||||||
r.profile("/activities/lucky-gifts/check", http.MethodPost, h.CheckLuckyGift)
|
r.profile("/activities/lucky-gifts/check", http.MethodPost, h.CheckLuckyGift)
|
||||||
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)
|
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)
|
||||||
|
|||||||
@ -74,8 +74,11 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
WalletDB: h.leaderboardWalletDB,
|
WalletDB: h.leaderboardWalletDB,
|
||||||
RegistrationReward: h.registrationReward,
|
RegistrationReward: h.registrationReward,
|
||||||
FirstRechargeReward: h.firstRechargeReward,
|
FirstRechargeReward: h.firstRechargeReward,
|
||||||
|
CumulativeRecharge: h.cumulativeRecharge,
|
||||||
SevenDayCheckIn: h.sevenDayCheckIn,
|
SevenDayCheckIn: h.sevenDayCheckIn,
|
||||||
LuckyGift: h.luckyGift,
|
LuckyGift: h.luckyGift,
|
||||||
|
RoomTurnoverReward: h.roomTurnoverReward,
|
||||||
|
WeeklyStar: h.weeklyStar,
|
||||||
})
|
})
|
||||||
gameAPI := gameapi.New(gameapi.Config{
|
gameAPI := gameapi.New(gameapi.Config{
|
||||||
GameClient: h.gameClient,
|
GameClient: h.gameClient,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user