Add activity reward programs

This commit is contained in:
zhx 2026-06-06 13:50:46 +08:00
parent 471186a73c
commit 02ce8874c3
115 changed files with 19375 additions and 1519 deletions

5
.gitignore vendored
View File

@ -27,4 +27,7 @@ node_modules/
mysql-data/
redis-data/
.waylog/
.waylog/
.tmp/
/tmp/

File diff suppressed because it is too large Load Diff

View File

@ -524,11 +524,14 @@ message ListRegistrationRewardClaimsRequest {
int64 user_id = 3;
int32 page = 4;
int32 page_size = 5;
int64 claimed_start_ms = 6;
int64 claimed_end_ms = 7;
}
message ListRegistrationRewardClaimsResponse {
repeated RegistrationRewardClaim claims = 1;
int64 total = 2;
int64 today_claimed_count = 3;
}
// FirstRechargeRewardTier max_coin_amount=0
@ -647,6 +650,255 @@ message ListFirstRechargeRewardClaimsResponse {
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
message SevenDayCheckInReward {
int32 day_index = 1;
@ -1396,6 +1648,162 @@ message GetLuckyGiftDrawSummaryResponse {
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
service ActivityService {
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
@ -1418,6 +1826,8 @@ service ActivityCronService {
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessLevelRewardBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessAchievementRewardBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessRoomTurnoverRewardSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
rpc ProcessWeeklyStarSettlementBatch(CronBatchRequest) returns (CronBatchResponse);
}
// TaskService App
@ -1451,6 +1861,18 @@ service LuckyGiftService {
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.
service BroadcastService {
rpc EnsureBroadcastGroups(EnsureBroadcastGroupsRequest) returns (EnsureBroadcastGroupsResponse);
@ -1498,6 +1920,38 @@ service AdminFirstRechargeRewardService {
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
service SevenDayCheckInService {
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

View File

@ -1345,6 +1345,30 @@ message CreditLuckyGiftRewardResponse {
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
message ApplyGameCoinChangeRequest {
string request_id = 1;
@ -1627,6 +1651,7 @@ service WalletService {
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
rpc CreditLuckyGiftReward(CreditLuckyGiftRewardRequest) returns (CreditLuckyGiftRewardResponse);
rpc CreditRoomTurnoverReward(CreditRoomTurnoverRewardRequest) returns (CreditRoomTurnoverRewardResponse);
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);

View File

@ -259,6 +259,7 @@ const (
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
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_GetRedPacketConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRedPacketConfig"
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)
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, 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)
GetRedPacketConfig(ctx context.Context, in *GetRedPacketConfigRequest, opts ...grpc.CallOption) (*GetRedPacketConfigResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyGameCoinChangeResponse)
@ -1087,6 +1099,7 @@ type WalletServiceServer interface {
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
CreditLuckyGiftReward(context.Context, *CreditLuckyGiftRewardRequest) (*CreditLuckyGiftRewardResponse, error)
CreditRoomTurnoverReward(context.Context, *CreditRoomTurnoverRewardRequest) (*CreditRoomTurnoverRewardResponse, error)
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
GetRedPacketConfig(context.Context, *GetRedPacketConfigRequest) (*GetRedPacketConfigResponse, 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) {
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) {
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)
}
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) {
in := new(ApplyGameCoinChangeRequest)
if err := dec(in); err != nil {
@ -2773,6 +2807,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CreditLuckyGiftReward",
Handler: _WalletService_CreditLuckyGiftReward_Handler,
},
{
MethodName: "CreditRoomTurnoverReward",
Handler: _WalletService_CreditRoomTurnoverReward_Handler,
},
{
MethodName: "ApplyGameCoinChange",
Handler: _WalletService_ApplyGameCoinChange_Handler,

View File

@ -32,6 +32,7 @@ import (
authmodule "hyapp-admin-server/internal/modules/auth"
coinledgermodule "hyapp-admin-server/internal/modules/coinledger"
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
@ -54,6 +55,7 @@ import (
resourcemodule "hyapp-admin-server/internal/modules/resource"
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
roomrocketmodule "hyapp-admin-server/internal/modules/roomrocket"
roomturnoverrewardmodule "hyapp-admin-server/internal/modules/roomturnoverreward"
searchmodule "hyapp-admin-server/internal/modules/search"
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
teamsalarypolicymodule "hyapp-admin-server/internal/modules/teamsalarypolicy"
@ -61,6 +63,7 @@ import (
uploadmodule "hyapp-admin-server/internal/modules/upload"
userleaderboardmodule "hyapp-admin-server/internal/modules/userleaderboard"
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/tencentcos"
"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),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), 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),
Dashboard: dashboardmodule.New(store, cfg),
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),
RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler),
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Search: searchmodule.New(store),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
TeamSalaryPolicy: teamsalarypolicymodule.New(store, auditHandler),
@ -256,6 +261,7 @@ func main() {
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
}
engine := router.New(cfg, auth, handlers)

View File

@ -21,9 +21,23 @@ type Client interface {
GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error)
UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, 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)
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, 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)
UpsertLuckyGiftConfig(ctx context.Context, req *activityv1.UpsertLuckyGiftConfigRequest) (*activityv1.UpsertLuckyGiftConfigResponse, error)
ListLuckyGiftConfigs(ctx context.Context, req *activityv1.ListLuckyGiftConfigsRequest) (*activityv1.ListLuckyGiftConfigsResponse, error)
@ -41,7 +55,10 @@ type GRPCClient struct {
achievementClient activityv1.AdminAchievementServiceClient
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
firstRechargeRewardClient activityv1.AdminFirstRechargeRewardServiceClient
cumulativeRechargeClient activityv1.AdminCumulativeRechargeRewardServiceClient
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
roomTurnoverRewardClient activityv1.AdminRoomTurnoverRewardServiceClient
weeklyStarClient activityv1.AdminWeeklyStarServiceClient
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
broadcastClient activityv1.BroadcastServiceClient
growthClient activityv1.AdminGrowthLevelServiceClient
@ -53,7 +70,10 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
firstRechargeRewardClient: activityv1.NewAdminFirstRechargeRewardServiceClient(conn),
cumulativeRechargeClient: activityv1.NewAdminCumulativeRechargeRewardServiceClient(conn),
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
roomTurnoverRewardClient: activityv1.NewAdminRoomTurnoverRewardServiceClient(conn),
weeklyStarClient: activityv1.NewAdminWeeklyStarServiceClient(conn),
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
@ -104,6 +124,18 @@ func (c *GRPCClient) ListFirstRechargeRewardClaims(ctx context.Context, req *act
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) {
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)
}
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) {
return c.luckyGiftClient.GetLuckyGiftConfig(ctx, req)
}

View File

@ -393,7 +393,7 @@ type BDProfile struct {
ParentLeaderUserID int64 `json:"parentLeaderUserId,string"`
PositionAlias string `json:"positionAlias"`
Status string `json:"status"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedByUserID int64 `json:"createdByUserId,string"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
DisplayUserID string `json:"displayUserId"`
@ -410,7 +410,7 @@ type BDProfile struct {
}
type Agency struct {
AgencyID int64 `json:"agencyId"`
AgencyID int64 `json:"agencyId,string"`
OwnerUserID int64 `json:"ownerUserId,string"`
RegionID int64 `json:"regionId"`
ParentBDUserID int64 `json:"parentBdUserId,string"`
@ -418,7 +418,7 @@ type Agency struct {
Status string `json:"status"`
JoinEnabled bool `json:"joinEnabled"`
MaxHosts int32 `json:"maxHosts"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedByUserID int64 `json:"createdByUserId,string"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
OwnerDisplayUserID string `json:"ownerDisplayUserId"`
@ -434,8 +434,8 @@ type HostProfile struct {
UserID int64 `json:"userId,string"`
Status string `json:"status"`
RegionID int64 `json:"regionId"`
CurrentAgencyID int64 `json:"currentAgencyId"`
CurrentMembershipID int64 `json:"currentMembershipId"`
CurrentAgencyID int64 `json:"currentAgencyId,string"`
CurrentMembershipID int64 `json:"currentMembershipId,string"`
Source string `json:"source"`
FirstBecameHostAtMs int64 `json:"firstBecameHostAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
@ -456,14 +456,14 @@ type CoinSellerProfile struct {
UserID int64 `json:"userId,string"`
Status string `json:"status"`
MerchantAssetType string `json:"merchantAssetType"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedByUserID int64 `json:"createdByUserId,string"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type AgencyMembership struct {
MembershipID int64 `json:"membershipId"`
AgencyID int64 `json:"agencyId"`
MembershipID int64 `json:"membershipId,string"`
AgencyID int64 `json:"agencyId,string"`
HostUserID int64 `json:"hostUserId,string"`
RegionID int64 `json:"regionId"`
MembershipType string `json:"membershipType"`

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

View File

@ -154,13 +154,19 @@ func (h *Handler) setUserStatus(c *gin.Context, status string, action string, su
func parseListQuery(c *gin.Context) listQuery {
options := shared.ListOptions(c)
regionID, regionIDSet := queryOptionalInt64(c, "region_id", "regionId")
return normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
Country: firstQuery(c, "country", "country_code", "countryCode"),
Keyword: options.Keyword,
RegionID: regionID,
RegionIDSet: regionIDSet,
Status: options.Status,
SortBy: firstQuery(c, "sort_by", "sortBy"),
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"),
})
}

View File

@ -3,10 +3,15 @@ package appuser
type listQuery struct {
Page int
PageSize int
Country string
Keyword string
RegionID int64
RegionIDSet bool
Status string
SortBy string
SortDirection string
StartMs int64
EndMs int64
}
type loginLogQuery struct {

View File

@ -73,17 +73,7 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in
}
query = normalizeListQuery(query)
appCode := appctx.FromContext(ctx)
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)
}
whereSQL, args := appUserListWhereSQL(appCode, query)
total, err := countRows(ctx, s.userDB, whereSQL, args...)
if err != nil {
@ -562,13 +552,73 @@ func normalizeListQuery(query listQuery) listQuery {
if query.PageSize > 100 {
query.PageSize = 100
}
query.Country = normalizeCountryCode(query.Country)
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.SortBy = normalizeAppUserSortBy(query.SortBy)
query.SortDirection = normalizeSortDirection(query.SortDirection)
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 {
switch strings.ToLower(strings.TrimSpace(value)) {
case "coin", "coins":

View File

@ -1,6 +1,9 @@
package appuser
import "testing"
import (
"strings"
"testing"
)
func TestNormalizeListQuerySort(t *testing.T) {
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" {
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) {

View File

@ -7,6 +7,12 @@ type coinLedgerUserDTO struct {
Avatar string `json:"avatar"`
}
type CSVExport struct {
FileName string
Content []byte
Count int
}
type coinLedgerEntryDTO struct {
EntryID int64 `json:"entryId"`
TransactionID string `json:"transactionId"`
@ -26,20 +32,22 @@ type coinLedgerEntryDTO struct {
}
type coinSellerLedgerDTO struct {
EntryID int64 `json:"entryId"`
TransactionID string `json:"transactionId"`
CommandID string `json:"commandId"`
LedgerType string `json:"ledgerType"`
BizType string `json:"bizType"`
Seller coinLedgerUserDTO `json:"seller"`
Receiver coinLedgerUserDTO `json:"receiver"`
Amount int64 `json:"amount"`
Direction string `json:"direction"`
AvailableDelta int64 `json:"availableDelta"`
SellerBalanceAfter int64 `json:"sellerBalanceAfter"`
CounterpartyUserID string `json:"counterpartyUserId"`
Metadata map[string]any `json:"metadata"`
CreatedAtMS int64 `json:"createdAtMs"`
EntryID int64 `json:"entryId"`
TransactionID string `json:"transactionId"`
CommandID string `json:"commandId"`
LedgerType string `json:"ledgerType"`
BizType string `json:"bizType"`
Seller coinLedgerUserDTO `json:"seller"`
Receiver coinLedgerUserDTO `json:"receiver"`
Amount int64 `json:"amount"`
Direction string `json:"direction"`
AvailableDelta int64 `json:"availableDelta"`
SellerBalanceAfter int64 `json:"sellerBalanceAfter"`
CounterpartyUserID string `json:"counterpartyUserId"`
OperatorUserID string `json:"operatorUserId"`
Operator coinAdjustmentOperatorDTO `json:"operator"`
Metadata map[string]any `json:"metadata"`
CreatedAtMS int64 `json:"createdAtMs"`
}
type coinAdjustmentOperatorDTO struct {

View File

@ -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})
}
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) {
query, ok := parseListQuery(c)
if !ok {

View File

@ -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-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/target", middleware.RequirePermission("coin-adjustment:create"), h.LookupCoinAdjustmentTarget)
protected.POST("/admin/operations/coin-adjustments", middleware.RequirePermission("coin-adjustment:create"), h.CreateCoinAdjustment)

View File

@ -1,8 +1,10 @@
package coinledger
import (
"bytes"
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"errors"
"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) {
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)
if s == nil || s.walletDB == nil {
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
}
var total int64
if err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL,
args...,
).Scan(&total); err != nil {
return nil, 0, err
if paginated {
if err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL,
args...,
).Scan(&total); err != nil {
return nil, 0, err
}
}
// 明细查询只取币商侧 COIN_SELLER_COIN 分录available_delta 表示币商库存可用变化,
// 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,
e.available_delta, e.available_after, e.counterparty_user_id,
COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
FROM wallet_entries e
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
`+whereSQL+`
ORDER BY e.created_at_ms DESC, e.entry_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, offset(query.Page, query.PageSize))...,
)
` + whereSQL + `
ORDER BY e.created_at_ms DESC, e.entry_id DESC`
queryArgs := args
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 {
return nil, 0, err
}
@ -207,6 +250,7 @@ func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, quer
items := make([]coinSellerLedgerDTO, 0, query.PageSize)
profileIDs := make([]int64, 0, query.PageSize*2)
operatorIDs := make([]int64, 0, query.PageSize)
for rows.Next() {
var item coinSellerLedgerDTO
var sellerUserID int64
@ -230,6 +274,7 @@ func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, quer
if err != nil {
return nil, 0, err
}
operatorUserID := metadataInt64(metadata, "operator_user_id", "operatorUserId")
// 收款人按产品口径取实际收款人:币商转用户优先取 metadata.target_user_id老数据没有 metadata 时再用 counterparty_user_id
// 后台入账和工资转币商的实际收款人都是币商本人,所以不能被 counterparty 字段误导成付款方或操作方。
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.Amount = absInt64(item.AvailableDelta)
item.CounterpartyUserID = formatOptionalID(counterpartyUserID)
item.OperatorUserID = formatOptionalID(operatorUserID)
item.Metadata = metadata
item.Seller = coinLedgerUserDTO{UserID: strconv.FormatInt(sellerUserID, 10)}
item.Receiver = coinLedgerUserDTO{UserID: strconv.FormatInt(receiverUserID, 10)}
items = append(items, item)
profileIDs = append(profileIDs, sellerUserID, receiverUserID)
if operatorUserID > 0 && item.LedgerType == coinSellerLedgerTypeAdminStockCredit {
operatorIDs = append(operatorIDs, operatorUserID)
}
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if !paginated {
total = int64(len(items))
}
profiles, err := s.userProfiles(ctx, appCode, profileIDs)
if err != nil {
return nil, 0, err
}
operators, err := s.adminProfiles(ctx, operatorIDs)
if err != nil {
return nil, 0, err
}
for i := range items {
sellerUserID, _ := strconv.ParseInt(items[i].Seller.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 {
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
}
@ -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 {
if bizType == coinSellerTransferBizType {
// 新数据把真实收款用户写在 metadata.target_user_id优先使用它避免 counterparty 语义随老交易实现变化。
@ -846,6 +945,16 @@ func metadataInt64(metadata map[string]any, keys ...string) int64 {
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) {
switch typed := value.(type) {
case float64:

View File

@ -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) {
_, _, err := coinSellerLedgerWhere("lalu", coinSellerLedgerQuery{LedgerType: "bad_type"}, 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) {
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 {

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import (
const (
adapterBaishunV1 = "baishun_v1"
adapterZeeOneV1 = "zeeone_v1"
adapterVivaGamesV1 = "vivagames_v1"
defaultGameStatus = "disabled"
defaultGameCategory = "casino"
defaultLaunchMode = "full_screen"
@ -131,6 +132,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
return fetchBaishunGameSyncPlan(ctx, platform, req)
case adapterZeeOneV1:
return fetchZeeOneGameSyncPlan(platform, req)
case adapterVivaGamesV1:
return fetchVivaGamesGameSyncPlan(platform, req)
default:
// 不在配置文件写死游戏:每家厂商新增“列表 API”时只加一个 adapter fetcher后台入口保持不变。
return providerGameSyncPlan{}, fmt.Errorf("当前适配器暂未接入厂商游戏列表 API: %s", platform.GetAdapterType())
@ -177,6 +180,14 @@ type zeeoneSyncConfig struct {
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) {
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
@ -257,6 +268,67 @@ func zeeoneGameNameFromURL(raw string) string {
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 {
tokens := splitGameNameTokens(value)
if len(tokens) == 0 {

View File

@ -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) {
games := []catalogRequest{
{ProviderGameID: "1001", GameName: "one"},

View File

@ -27,15 +27,17 @@ type CoinSellerListItem struct {
Status string `json:"status"`
MerchantAssetType string `json:"merchantAssetType"`
MerchantBalance int64 `json:"merchantBalance"`
Contact string `json:"contact"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
// TotalRechargeUSDTMicro 是币商 USDT 进货累计付款微单位,只统计 counts_as_seller_recharge 的库存记录。
TotalRechargeUSDTMicro int64 `json:"totalRechargeUsdtMicro"`
Contact string `json:"contact"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
RegionID int64 `json:"regionId"`
RegionName string `json:"regionName"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
}
type CoinSellerSalaryRateTier struct {
@ -615,6 +617,13 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
if err != nil {
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(`
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
COALESCE(csp.contact_info, ''),
@ -624,8 +633,8 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
COALESCE(r.name, '')
%s
ORDER BY csp.created_at_ms DESC, csp.user_id DESC
LIMIT ? OFFSET ?
`, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
%s
`, whereSQL, limitSQL), queryArgs...)
if err != nil {
return nil, 0, err
}
@ -661,8 +670,17 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
if err != nil {
return nil, 0, err
}
rechargeTotals, err := r.coinSellerRechargeTotals(ctx, appCode, userIDs)
if err != nil {
return nil, 0, err
}
for _, item := range items {
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
}
@ -851,6 +869,41 @@ func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs
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) {
result := make(map[int64]int64, len(userIDs))
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()
}
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) {
sort.SliceStable(items, func(i, j int) bool {
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 {
if page < 1 {
page = 1

View File

@ -18,6 +18,9 @@ const (
coinSellerStockTypePurchase = "usdt_purchase"
coinSellerStockTypeCompensate = "coin_compensation"
paidCurrencyUSDT = "USDT"
sortByDiamond = "diamond"
sortByMerchantBalance = "merchant_balance"
sortByTotalRechargeUSDT = "total_recharge_usdt"
bdLeaderPositionAliasMaxRunes = 64
)
@ -524,7 +527,9 @@ func normalizeListQuery(query listQuery) listQuery {
query.Keyword = strings.TrimSpace(query.Keyword)
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy))
if query.SortBy != "diamond" {
switch query.SortBy {
case sortByDiamond, sortByMerchantBalance, sortByTotalRechargeUSDT:
default:
query.SortBy = ""
query.SortDirection = ""
return query

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

View File

@ -3,6 +3,7 @@ package registrationreward
import (
"context"
"database/sql"
"strconv"
"strings"
"time"
@ -72,6 +73,14 @@ type userDTO struct {
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) {
resp, err := h.activity.GetRegistrationRewardConfig(c.Request.Context(), &activityv1.GetRegistrationRewardConfigRequest{Meta: h.meta(c)})
if err != nil {
@ -107,21 +116,29 @@ func (h *Handler) UpdateRegistrationRewardConfig(c *gin.Context) {
func (h *Handler) ListRegistrationRewardClaims(c *gin.Context) {
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))
if !ok {
response.ServerError(c, "查询用户信息失败")
return
}
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
}
resp, err := h.activity.ListRegistrationRewardClaims(c.Request.Context(), &activityv1.ListRegistrationRewardClaimsRequest{
Meta: h.meta(c),
Status: strings.TrimSpace(options.Status),
UserId: userID,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
Meta: h.meta(c),
Status: strings.TrimSpace(options.Status),
UserId: userID,
ClaimedStartMs: claimedStartMS,
ClaimedEndMs: claimedEndMS,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
if err != nil {
response.ServerError(c, "获取注册奖励领取记录失败")
@ -135,7 +152,26 @@ func (h *Handler) ListRegistrationRewardClaims(c *gin.Context) {
response.ServerError(c, "补全用户信息失败")
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 {
@ -303,3 +339,17 @@ func int64Args(values []int64) []any {
}
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
}

View 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) {
// 列表筛选沿用通用 ListOptionsstatus 过滤结算状态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(),
}
}

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

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

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

View File

@ -124,6 +124,10 @@ var defaultPermissions = []model.Permission{
{Name: "VIP 配置查看", Code: "vip-config:view", Kind: "menu"},
{Name: "VIP 配置更新", Code: "vip-config:update", 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:create", Kind: "button"},
{Name: "角色更新", Code: "role:update", Kind: "button"},
@ -174,18 +178,18 @@ func (s *Store) seedMenus() error {
gameID := uint(0)
menus := []model.Menu{
{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: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 40, Visible: false},
{Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 60, Visible: true},
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 65, Visible: true},
{Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 66, Visible: true},
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 67, Visible: true},
{Title: "运营管理", Code: "operations", Path: "", Icon: "operations", PermissionCode: "", Sort: 68, Visible: true},
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 69, Visible: true},
{Title: "活动管理", Code: "activities", Path: "", Icon: "campaign", PermissionCode: "", Sort: 70, Visible: true},
{Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 71, Visible: true},
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 72, Visible: true},
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true},
{Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 20, Visible: true},
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 30, Visible: true},
{Title: "房间管理", Code: "rooms", Path: "", Icon: "room", PermissionCode: "", Sort: 40, Visible: true},
{Title: "APP配置", Code: "app-config", Path: "", Icon: "settings", PermissionCode: "", Sort: 50, Visible: true},
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 60, Visible: true},
{Title: "运营管理", Code: "operations", Path: "", Icon: "operations", PermissionCode: "", Sort: 70, Visible: true},
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 80, Visible: true},
{Title: "活动管理", Code: "activities", Path: "", Icon: "campaign", PermissionCode: "", Sort: 90, Visible: true},
{Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 100, Visible: true},
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 110, Visible: true},
{Title: "后台设置", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 120, Visible: true},
{Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 130, Visible: false},
}
for _, menu := range menus {
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: "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: "周星配置", 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: &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},
@ -513,12 +518,13 @@ func defaultRolePermissionCodes(code string) []string {
"room-rocket:view", "room-rocket:update",
"red-packet:view", "red-packet:update",
"vip-config:view", "vip-config:update", "vip-config:grant",
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"log:view",
"job:view", "job:cancel", "export:create",
"upload:create",
}
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":
return []string{
"overview:view",
@ -561,6 +567,7 @@ func defaultRolePermissionCodes(code string) []string {
"room-rocket:view",
"red-packet:view",
"vip-config:view",
"weekly-star:view",
"role:view",
"permission:view",
"menu:view",
@ -602,9 +609,10 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"room-rocket:view", "room-rocket:update",
"red-packet:view", "red-packet:update",
"vip-config:view", "vip-config:update", "vip-config:grant",
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
}
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:
return nil
}

View File

@ -12,6 +12,7 @@ import (
authroutes "hyapp-admin-server/internal/modules/auth"
"hyapp-admin-server/internal/modules/coinledger"
"hyapp-admin-server/internal/modules/countryregion"
"hyapp-admin-server/internal/modules/cumulativerechargereward"
"hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/firstrechargereward"
@ -34,6 +35,7 @@ import (
resourcemodule "hyapp-admin-server/internal/modules/resource"
"hyapp-admin-server/internal/modules/roomadmin"
"hyapp-admin-server/internal/modules/roomrocket"
"hyapp-admin-server/internal/modules/roomturnoverreward"
"hyapp-admin-server/internal/modules/search"
"hyapp-admin-server/internal/modules/sevendaycheckin"
"hyapp-admin-server/internal/modules/teamsalarypolicy"
@ -41,6 +43,7 @@ import (
"hyapp-admin-server/internal/modules/upload"
"hyapp-admin-server/internal/modules/userleaderboard"
"hyapp-admin-server/internal/modules/vipconfig"
"hyapp-admin-server/internal/modules/weeklystar"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/service"
@ -57,6 +60,7 @@ type Handlers struct {
Auth *authroutes.Handler
CoinLedger *coinledger.Handler
CountryRegion *countryregion.Handler
CumulativeRecharge *cumulativerechargereward.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
FirstRechargeReward *firstrechargereward.Handler
@ -79,6 +83,7 @@ type Handlers struct {
Resource *resourcemodule.Handler
RoomAdmin *roomadmin.Handler
RoomRocket *roomrocket.Handler
RoomTurnoverReward *roomturnoverreward.Handler
Search *search.Handler
SevenDayCheckIn *sevendaycheckin.Handler
TeamSalaryPolicy *teamsalarypolicy.Handler
@ -86,6 +91,7 @@ type Handlers struct {
Upload *upload.Handler
UserLeaderboard *userleaderboard.Handler
VIPConfig *vipconfig.Handler
WeeklyStar *weeklystar.Handler
}
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)
appconfig.RegisterRoutes(protected, h.AppConfig)
countryregion.RegisterRoutes(protected, h.CountryRegion)
cumulativerechargereward.RegisterRoutes(protected, h.CumulativeRecharge)
dailytask.RegisterRoutes(protected, h.DailyTask)
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
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)
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
roomrocket.RegisterRoutes(protected, h.RoomRocket)
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
dashboard.RegisterRoutes(protected, h.Dashboard)
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
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)
userleaderboard.RegisterRoutes(protected, h.UserLeaderboard)
vipconfig.RegisterRoutes(protected, h.VIPConfig)
weeklystar.RegisterRoutes(protected, h.WeeklyStar)
return engine
}

View File

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

View 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';

View File

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

View 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'
);

View File

@ -12,9 +12,12 @@ health_http_addr: ":13106"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC"
user_service_addr: "user-service:13005"
wallet_service_addr: "wallet-service:13004"
room_service_addr: "room-service:13001"
mysql_auto_migrate: true
first_recharge_reward_worker:
enabled: false
cumulative_recharge_reward_worker:
enabled: true
red_packet_broadcast_worker:
enabled: true
lucky_gift_worker:
@ -64,6 +67,7 @@ rocketmq:
topic: "hyapp_wallet_outbox"
realtime_topic: "hyapp_wallet_realtime_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"
consumer_max_reconsume_times: 16
user_outbox:

View File

@ -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"
user_service_addr: "user-service.internal:13005"
wallet_service_addr: "wallet-service.internal:13004"
room_service_addr: "room-service.internal:13001"
mysql_auto_migrate: false
first_recharge_reward_worker:
enabled: true
cumulative_recharge_reward_worker:
enabled: true
red_packet_broadcast_worker:
enabled: true
lucky_gift_worker:
@ -63,6 +66,7 @@ rocketmq:
topic: "hyapp_wallet_outbox"
realtime_topic: "hyapp_wallet_realtime_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"
consumer_max_reconsume_times: 16
user_outbox:

View File

@ -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"
user_service_addr: "127.0.0.1:13005"
wallet_service_addr: "127.0.0.1:13004"
room_service_addr: "127.0.0.1:13001"
mysql_auto_migrate: true
first_recharge_reward_worker:
enabled: false
cumulative_recharge_reward_worker:
enabled: false
red_packet_broadcast_worker:
enabled: false
lucky_gift_worker:
@ -63,6 +66,7 @@ rocketmq:
topic: "hyapp_wallet_outbox"
realtime_topic: ""
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"
consumer_max_reconsume_times: 16
user_outbox:

View File

@ -183,6 +183,7 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records (
device_id VARCHAR(128) NOT NULL COMMENT '设备 ID',
room_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',
gift_id VARCHAR(96) NOT NULL COMMENT '礼物 ID',
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_gift (app_code, gift_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_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),
@ -239,6 +241,24 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats (
PRIMARY KEY (app_code, pool_id, gift_id)
) 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 (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
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)
) 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 (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' 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_2x', 2000000, 100000, '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 '区域 ID0 表示默认配置',
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 奖励结算';

View File

@ -2,7 +2,6 @@ package app
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net"
@ -10,39 +9,21 @@ import (
"time"
"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/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"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"
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"
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"
sevendaycheckinservice "hyapp/services/activity-service/internal/service/sevendaycheckin"
taskservice "hyapp/services/activity-service/internal/service/task"
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 {
server *grpc.Server
listener net.Listener
@ -52,6 +33,7 @@ type App struct {
broadcast *broadcastservice.Service
luckyGift *luckygiftservice.Service
firstRechargeReward *firstrechargeservice.Service
cumulativeRecharge *cumulativerechargeservice.Service
broadcastWorkerEnabled bool
luckyGiftWorkerEnabled bool
workerNodeID string
@ -59,6 +41,7 @@ type App struct {
mqConsumers []*rocketmqx.Consumer
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
roomConn *grpc.ClientConn
workerCtx context.Context
workerStop context.CancelFunc
workerWG sync.WaitGroup
@ -70,254 +53,71 @@ func New(cfg config.Config) (*App, error) {
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// activity-service 的活动状态、消费幂等和 outbox 都必须使用 MySQL。
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
repository, err := openRepository(startupCtx, cfg)
if err != nil {
return nil, err
}
if cfg.MySQLAutoMigrate {
if err := repository.Migrate(startupCtx); err != nil {
_ = repository.Close()
return nil, err
var listener net.Listener
var clients externalClients
var mqConsumers []*rocketmqx.Consumer
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 {
_ = repository.Close()
cleanup()
return nil, err
}
userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
clients, err = dialExternalClients(cfg)
if err != nil {
_ = listener.Close()
_ = repository.Close()
return nil, err
}
walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = userConn.Close()
_ = listener.Close()
_ = repository.Close()
cleanup()
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
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)
services, err := buildServiceBundle(cfg, repository, clients)
if err != nil {
shutdownConsumers(mqConsumers)
_ = walletConn.Close()
_ = userConn.Close()
_ = listener.Close()
_ = repository.Close()
cleanup()
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{
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
mysqlRepo: repository,
broadcast: broadcastSvc,
luckyGift: luckyGiftSvc,
firstRechargeReward: firstRechargeRewardSvc,
broadcast: services.broadcast,
luckyGift: services.luckyGift,
firstRechargeReward: services.firstRechargeReward,
cumulativeRecharge: services.cumulativeRecharge,
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
workerNodeID: cfg.NodeID,
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
mqConsumers: mqConsumers,
userConn: userConn,
walletConn: walletConn,
userConn: clients.userConn,
walletConn: clients.walletConn,
roomConn: clients.roomConn,
workerCtx: workerCtx,
workerStop: workerStop,
}, nil
@ -372,6 +172,9 @@ func (a *App) Close() {
if a.walletConn != nil {
_ = a.walletConn.Close()
}
if a.roomConn != nil {
_ = a.roomConn.Close()
}
if a.mysqlRepo != nil {
// MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。
_ = 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,
}
}

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

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

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

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

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

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

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

View File

@ -25,8 +25,12 @@ type Config struct {
UserServiceAddr string `yaml:"user_service_addr"`
// WalletServiceAddr 是任务奖励入账依赖activity 只发命令,不直接写余额。
WalletServiceAddr string `yaml:"wallet_service_addr"`
// RoomServiceAddr 是房间流水奖励结算读取房主快照的内部 gRPC 地址。
RoomServiceAddr string `yaml:"room_service_addr"`
// FirstRechargeRewardWorker 控制首冲奖励对 wallet_outbox 充值事实的本地消费。
FirstRechargeRewardWorker FirstRechargeRewardWorkerConfig `yaml:"first_recharge_reward_worker"`
// CumulativeRechargeRewardWorker 控制累充奖励对 wallet_outbox 充值事实的本地消费。
CumulativeRechargeRewardWorker CumulativeRechargeRewardWorkerConfig `yaml:"cumulative_recharge_reward_worker"`
// RedPacketBroadcastWorker 控制红包创建事实转区域飘屏 outbox 的本地消费。
RedPacketBroadcastWorker RedPacketBroadcastWorkerConfig `yaml:"red_packet_broadcast_worker"`
// LuckyGiftWorker 控制幸运礼物 draw outbox 的返奖和房间 IM 补偿。
@ -61,6 +65,12 @@ type FirstRechargeRewardWorkerConfig struct {
Enabled bool `yaml:"enabled"`
}
// CumulativeRechargeRewardWorkerConfig 保存累充奖励钱包充值事实消费策略。
type CumulativeRechargeRewardWorkerConfig struct {
// Enabled 控制是否启动 wallet_outbox MQ 消费;关闭后仍可通过 gRPC 消费入口补偿。
Enabled bool `yaml:"enabled"`
}
// RedPacketBroadcastWorkerConfig 保存红包创建事实消费策略。
type RedPacketBroadcastWorkerConfig struct {
// Enabled 控制是否启动 wallet_outbox MQ 消费。
@ -150,6 +160,7 @@ type WalletOutboxMQConfig struct {
Topic string `yaml:"topic"`
RealtimeTopic string `yaml:"realtime_topic"`
FirstRechargeConsumerGroup string `yaml:"first_recharge_consumer_group"`
CumulativeRechargeConsumerGroup string `yaml:"cumulative_recharge_consumer_group"`
RedPacketBroadcastConsumerGroup string `yaml:"red_packet_broadcast_consumer_group"`
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",
UserServiceAddr: "127.0.0.1:13005",
WalletServiceAddr: "127.0.0.1:13004",
RoomServiceAddr: "127.0.0.1:13001",
FirstRechargeRewardWorker: FirstRechargeRewardWorkerConfig{
Enabled: false,
},
CumulativeRechargeRewardWorker: CumulativeRechargeRewardWorkerConfig{
Enabled: false,
},
RedPacketBroadcastWorker: RedPacketBroadcastWorkerConfig{
Enabled: false,
},
@ -238,6 +253,7 @@ func defaultRocketMQConfig() RocketMQConfig {
Topic: "hyapp_wallet_outbox",
RealtimeTopic: "",
FirstRechargeConsumerGroup: "hyapp-activity-first-recharge-wallet-outbox",
CumulativeRechargeConsumerGroup: "hyapp-activity-cumulative-recharge-wallet-outbox",
RedPacketBroadcastConsumerGroup: "hyapp-activity-red-packet-wallet-outbox",
ConsumerMaxReconsumeTimes: 16,
},
@ -266,6 +282,9 @@ func Load(path string) (Config, error) {
if strings.TrimSpace(cfg.WalletServiceAddr) == "" {
cfg.WalletServiceAddr = Default().WalletServiceAddr
}
if strings.TrimSpace(cfg.RoomServiceAddr) == "" {
cfg.RoomServiceAddr = Default().RoomServiceAddr
}
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "activity-service"
@ -342,7 +361,7 @@ func Load(path string) (Config, error) {
return Config{}, err
}
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")
}
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 == "" {
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 == "" {
cfg.WalletOutbox.RedPacketBroadcastConsumerGroup = defaults.WalletOutbox.RedPacketBroadcastConsumerGroup
}

View File

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

View File

@ -77,10 +77,12 @@ type PrepareResult struct {
Reason string
}
// ClaimQuery 是后台领取记录分页查询条件;用户搜索由 admin-server 先解析成 user_id
// ClaimQuery 是后台领取记录分页查询条件;用户搜索由 admin-server 先解析成 user_id,领取时间筛选按 claimed_at_ms 优先、created_at_ms 兜底的页面展示口径执行
type ClaimQuery struct {
Status string
UserID int64
Page int32
PageSize int32
Status string
UserID int64
ClaimedStartMS int64
ClaimedEndMS int64
Page int32
PageSize int32
}

View File

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

View File

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

View File

@ -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 idMQ 重投或上次发放失败时复用同一命令,交给 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
}

View File

@ -36,6 +36,7 @@ type Repository interface {
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
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
MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error
// 批量抽奖只产生一条聚合 outboxworker 处理后需要用 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 {
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)
}
select {

View File

@ -491,6 +491,10 @@ func (r *fakeLuckyGiftRepository) RefreshLuckyGiftAdminStatsSnapshots(context.Co
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 {
r.grantedDrawID = drawID
return nil

View File

@ -26,6 +26,7 @@ type Repository interface {
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
ListRegistrationRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
CountRegistrationRewardGrantedByDay(ctx context.Context, rewardDay string) (int64, error)
}
// WalletClient 是注册奖励发放的唯一外部副作用;金币和资源组都依赖 wallet-service 幂等命令。
@ -176,9 +177,9 @@ func (s *Service) IssueRegistrationReward(ctx context.Context, command domain.Is
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 {
return nil, 0, err
return nil, 0, 0, err
}
query.Status = strings.TrimSpace(query.Status)
if query.Page <= 0 {
@ -190,7 +191,16 @@ func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]do
if 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 {

View File

@ -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 已把失败原因写回 settlementbatch 继续处理后续房间,避免单房间阻塞整周结算。
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
}

View File

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

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

View File

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

View File

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

View File

@ -184,7 +184,7 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand
return domain.CheckResult{
Enabled: config.Enabled,
Reason: luckyEnabledReason(config.Enabled),
PoolID: config.GiftID,
PoolID: config.PoolID,
GiftID: cmd.GiftID,
GiftPrice: config.GiftPrice,
RuleVersion: config.RuleVersion,
@ -466,7 +466,7 @@ func (r *Repository) stageLuckyBatchDraw(appCode string, baseConfig domain.Confi
return domain.DrawResult{
DrawID: drawID,
CommandID: cmd.CommandID,
PoolID: config.GiftID,
PoolID: config.PoolID,
GiftID: cmd.GiftID,
RuleVersion: config.RuleVersion,
ExperiencePool: experiencePool,
@ -627,15 +627,15 @@ func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, rec
var sqlBuilder strings.Builder
sqlBuilder.WriteString(`
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,
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
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,
paid_at_ms, created_at_ms, updated_at_ms
) VALUES `)
args := make([]any, 0, len(batch)*31)
rowPlaceholders := "(" + luckySQLPlaceholders(31) + ")"
args := make([]any, 0, len(batch)*32)
rowPlaceholders := "(" + luckySQLPlaceholders(32) + ")"
for index, record := range batch {
if index > 0 {
sqlBuilder.WriteString(",")
@ -741,6 +741,7 @@ type luckyDrawStatsRecord struct {
DrawID string
PoolID string
GiftID string
VisibleRegionID int64
UserID int64
RoomID string
TotalSpentCoins int64
@ -875,6 +876,148 @@ func (r *Repository) RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, no
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) {
var cursor luckyDrawStatsCursor
err := tx.QueryRowContext(ctx, `
@ -909,12 +1052,38 @@ func (r *Repository) lockLuckyDrawStatsCursor(ctx context.Context, tx *sql.Tx, a
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 语义漂移。
func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input)
return []any{
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.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
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{
DrawID: drawID,
CommandID: cmd.CommandID,
PoolID: config.GiftID,
PoolID: config.PoolID,
GiftID: cmd.GiftID,
RuleVersion: config.RuleVersion,
ExperiencePool: experiencePool,
@ -1370,15 +1539,15 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
})
if _, err := tx.ExecContext(ctx, `
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,
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
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,
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.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.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
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,
"draw_id": input.DrawID,
"command_id": input.Command.CommandID,
"pool_id": input.Config.GiftID,
"pool_id": input.Config.PoolID,
"user_id": input.Command.UserID,
"sender_user_id": input.Command.UserID,
"target_user_id": input.Command.TargetUserID,

View File

@ -117,6 +117,14 @@ func TestRefreshLuckyGiftAdminStatsSnapshotsAdvancesCursorByBatch(t *testing.T)
assertLuckyStatsTotal(t, repository, "", 1, 100, 300)
assertLuckyStatsStatus(t, repository, "", 0, 1, 0)
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)
if err != nil {
@ -128,6 +136,14 @@ func TestRefreshLuckyGiftAdminStatsSnapshotsAdvancesCursorByBatch(t *testing.T)
assertLuckyStatsTotal(t, repository, "", 2, 250, 300)
assertLuckyStatsStatus(t, repository, "", 1, 1, 0)
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) {
@ -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) {
t.Helper()
var createdAtMS int64

View File

@ -13,6 +13,8 @@ import (
domain "hyapp/services/activity-service/internal/domain/registrationreward"
)
const registrationRewardClaimDisplayTimeSQL = "COALESCE(NULLIF(claimed_at_ms, 0), created_at_ms)"
// GetRegistrationRewardConfig 读取注册奖励当前配置;未配置是正常后台首屏状态。
func (r *Repository) GetRegistrationRewardConfig(ctx context.Context) (domain.Config, bool, error) {
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)
rows, err := r.db.QueryContext(ctx, registrationRewardClaimSelectSQL()+`
`+where+`
ORDER BY created_at_ms DESC, claim_id DESC
ORDER BY `+registrationRewardClaimDisplayTimeSQL+` DESC, claim_id DESC
LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
@ -344,6 +346,28 @@ func (r *Repository) ListRegistrationRewardClaims(ctx context.Context, query dom
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) {
row := tx.QueryRowContext(ctx, registrationRewardConfigSelectSQL()+`
WHERE app_code = ?
@ -571,6 +595,14 @@ func registrationRewardClaimWhere(ctx context.Context, query domain.ClaimQuery)
conditions = append(conditions, "user_id = ?")
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
}

View File

@ -45,6 +45,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureLuckyDrawPoolID(ctx); err != nil {
return err
}
if err := r.ensureLuckyDrawVisibleRegion(ctx); err != nil {
return err
}
if err := r.ensureLuckyGiftOutboxColumns(ctx); err != nil {
return err
}
@ -63,12 +66,21 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureLuckyGiftRuleVersionTables(ctx); err != nil {
return err
}
if err := r.ensureWeeklyStarTables(ctx); err != nil {
return err
}
if err := r.ensureLuckyUserStateFlowColumns(ctx); err != nil {
return err
}
if err := r.ensureDefaultLuckyGiftRules(ctx); err != nil {
return err
}
if err := r.ensureRoomTurnoverRewardTables(ctx); err != nil {
return err
}
if err := r.ensureCumulativeRechargeRewardTables(ctx); err != nil {
return err
}
return nil
}
@ -93,6 +105,23 @@ func (r *Repository) ensureLuckyDrawPoolStatsTables(ctx context.Context) error {
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, gift_id)
) 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 (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
@ -216,6 +245,31 @@ func (r *Repository) ensureLuckyDrawPoolID(ctx context.Context) error {
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 {
additions := []struct {
name string

View File

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

View File

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

View File

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

View File

@ -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 '区域 ID0 表示默认配置',
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
}

View File

@ -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_msH5 历史榜和后台结算记录才会把这个周期视为已完结。
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]
}

View File

@ -9,6 +9,8 @@ import (
"hyapp/pkg/xerr"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
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。
@ -17,15 +19,22 @@ type BroadcastServer struct {
activityv1.UnimplementedBroadcastServiceServer
activityv1.UnimplementedRoomEventConsumerServiceServer
svc *broadcastservice.Service
growth *growthservice.Service
svc *broadcastservice.Service
growth *growthservice.Service
roomReward *roomturnoverrewardservice.Service
weeklyStar *weeklystarservice.Service
}
// 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}
if len(growthSvc) > 0 {
server.growth = growthSvc[0]
server.growth = growthSvc
server.weeklyStar = weeklyStarSvc
if len(roomRewardSvc) > 0 {
server.roomReward = roomRewardSvc[0]
}
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) {
// 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())
result, err := s.svc.HandleRoomEvent(ctx, req.GetEnvelope())
if err != nil {
@ -110,6 +121,16 @@ func (s *BroadcastServer) ConsumeRoomEvent(ctx context.Context, req *activityv1.
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{
EventId: result.EventID,
Status: result.Status,

View File

@ -10,6 +10,8 @@ import (
achievementservice "hyapp/services/activity-service/internal/service/achievement"
growthservice "hyapp/services/activity-service/internal/service/growth"
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.
@ -19,14 +21,18 @@ type CronServer struct {
message *messageservice.Service
growth *growthservice.Service
achievement *achievementservice.Service
weeklyStar *weeklystarservice.Service
roomReward *roomturnoverrewardservice.Service
}
// 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.growth = growthSvc
if len(achievementSvc) > 0 {
server.achievement = achievementSvc[0]
server.achievement = achievementSvc
server.weeklyStar = weeklyStarSvc
if len(roomRewardSvc) > 0 {
server.roomReward = roomRewardSvc[0]
}
return server
}
@ -94,6 +100,45 @@ func (s *CronServer) ProcessAchievementRewardBatch(ctx context.Context, req *act
}, 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 {
if value <= 0 {
return 0

View File

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

View File

@ -84,16 +84,22 @@ func (s *AdminRegistrationRewardServer) UpdateRegistrationRewardConfig(ctx conte
func (s *AdminRegistrationRewardServer) ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
claims, total, err := s.svc.ListClaims(ctx, domain.ClaimQuery{
Status: req.GetStatus(),
UserID: req.GetUserId(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
claims, total, todayClaimedCount, err := s.svc.ListClaims(ctx, domain.ClaimQuery{
Status: req.GetStatus(),
UserID: req.GetUserId(),
ClaimedStartMS: req.GetClaimedStartMs(),
ClaimedEndMS: req.GetClaimedEndMs(),
Page: req.GetPage(),
PageSize: req.GetPageSize(),
})
if err != nil {
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 {
resp.Claims = append(resp.Claims, registrationRewardClaimToProto(claim))
}

View File

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

View File

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

View File

@ -43,6 +43,20 @@ tasks:
lock_ttl: "30s"
batch_size: 100
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:
enabled: true
interval: "5s"

View File

@ -43,6 +43,20 @@ tasks:
lock_ttl: "30s"
batch_size: 100
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:
enabled: true
interval: "5s"

View File

@ -43,6 +43,20 @@ tasks:
lock_ttl: "30s"
batch_size: 100
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:
enabled: true
interval: "5s"

View File

@ -119,6 +119,8 @@ func New(cfg config.Config) (*App, error) {
"message_fanout": activityCron.ProcessMessageFanoutBatch,
"growth_level_reward": activityCron.ProcessLevelRewardBatch,
"achievement_reward": activityCron.ProcessAchievementRewardBatch,
"weekly_star_settlement": activityCron.ProcessWeeklyStarSettlementBatch,
"room_turnover_reward_settlement": activityCron.ProcessRoomTurnoverRewardSettlementBatch,
"game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch,
"host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch,
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,

View File

@ -164,6 +164,22 @@ func defaultTasks() map[string]TaskConfig {
BatchSize: 100,
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": {
Enabled: true,
Interval: "5s",

View File

@ -45,6 +45,25 @@ func (c *ActivityCronClient) ProcessAchievementRewardBatch(ctx context.Context,
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 {
return &activityv1.CronBatchRequest{
Meta: &activityv1.RequestMeta{

View File

@ -13,11 +13,12 @@ const (
LaunchModeH5Popup = "h5_popup"
SessionActive = "active"
AdapterDemo = "demo"
AdapterYomiV4 = "yomi_v4"
AdapterLeaderCCV1 = "leadercc_v1"
AdapterZeeOneV1 = "zeeone_v1"
AdapterBaishunV1 = "baishun_v1"
AdapterDemo = "demo"
AdapterYomiV4 = "yomi_v4"
AdapterLeaderCCV1 = "leadercc_v1"
AdapterZeeOneV1 = "zeeone_v1"
AdapterBaishunV1 = "baishun_v1"
AdapterVivaGamesV1 = "vivagames_v1"
OrderStatusWalletApplying = "wallet_applying"
OrderStatusSucceeded = "succeeded"

View File

@ -203,7 +203,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
token := strings.TrimSpace(command.AccessToken)
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")
}
token = "gt_" + randomHex(24)
@ -218,6 +218,8 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
sessionTTL = maxDuration(sessionTTL, zeeoneConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
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()
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)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterBaishunV1):
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:
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
}
}
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 strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
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) {
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 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
base = "https://game.example.local/h5"
}
@ -727,6 +740,29 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
parsed.RawQuery = query.Encode()
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 和订单链路。
query.Set("session_id", session.SessionID)
query.Set("session_token", token)

View File

@ -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) {
repo := &fakeRepository{
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) {
secret := "baishun-test-app-key"
userID := int64(312900923573673984)
@ -1458,3 +1622,20 @@ func baishunTestBody(t *testing.T, secret string, body string, timestamp int64)
}
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
}

View 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 文档没有游戏列表 APIgame_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(&params); 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"
}
}

View File

@ -99,8 +99,11 @@ func New(cfg config.Config) (*App, error) {
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn)
var firstRechargeRewardClient client.FirstRechargeRewardClient = client.NewGRPCFirstRechargeRewardClient(activityConn)
var cumulativeRechargeRewardClient client.CumulativeRechargeRewardClient = client.NewGRPCCumulativeRechargeRewardClient(activityConn)
var sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(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 gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
@ -163,8 +166,11 @@ func New(cfg config.Config) (*App, error) {
handler.SetAchievementClient(achievementClient)
handler.SetRegistrationRewardClient(registrationRewardClient)
handler.SetFirstRechargeRewardClient(firstRechargeRewardClient)
handler.SetCumulativeRechargeRewardClient(cumulativeRechargeRewardClient)
handler.SetSevenDayCheckInClient(sevenDayCheckInClient)
handler.SetLuckyGiftClient(luckyGiftClient)
handler.SetRoomTurnoverRewardClient(roomTurnoverRewardClient)
handler.SetWeeklyStarClient(weeklyStarClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
handler.SetLeaderboardWalletDB(leaderboardWalletDB)

View File

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

View File

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

View File

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

View File

@ -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-servicegateway 只补展示依赖。
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_idgateway 负责补资源组展示素材,避免活动服务依赖 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(),
}
}

View File

@ -19,8 +19,11 @@ type Handler struct {
walletDB *sql.DB
registrationReward client.RegistrationRewardClient
firstRechargeReward client.FirstRechargeRewardClient
cumulativeRecharge client.CumulativeRechargeRewardClient
sevenDayCheckIn client.SevenDayCheckInClient
luckyGift client.LuckyGiftClient
roomTurnoverReward client.RoomTurnoverRewardClient
weeklyStar client.WeeklyStarClient
}
type Config struct {
@ -33,8 +36,11 @@ type Config struct {
WalletDB *sql.DB
RegistrationReward client.RegistrationRewardClient
FirstRechargeReward client.FirstRechargeRewardClient
CumulativeRecharge client.CumulativeRechargeRewardClient
SevenDayCheckIn client.SevenDayCheckInClient
LuckyGift client.LuckyGiftClient
RoomTurnoverReward client.RoomTurnoverRewardClient
WeeklyStar client.WeeklyStarClient
}
func New(config Config) *Handler {
@ -48,21 +54,29 @@ func New(config Config) *Handler {
walletDB: config.WalletDB,
registrationReward: config.RegistrationReward,
firstRechargeReward: config.FirstRechargeReward,
cumulativeRecharge: config.CumulativeRecharge,
sevenDayCheckIn: config.SevenDayCheckIn,
luckyGift: config.LuckyGift,
roomTurnoverReward: config.RoomTurnoverReward,
weeklyStar: config.WeeklyStar,
}
}
func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
return httproutes.TaskHandlers{
ListTaskTabs: h.listTaskTabs,
ClaimTaskReward: h.claimTaskReward,
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
GetFirstRechargeRewardStatus: h.getFirstRechargeRewardStatus,
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
SignSevenDayCheckIn: h.signSevenDayCheckIn,
ListUserLeaderboards: h.listUserLeaderboards,
CheckLuckyGift: h.checkLuckyGift,
ListTaskTabs: h.listTaskTabs,
ClaimTaskReward: h.claimTaskReward,
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
GetFirstRechargeRewardStatus: h.getFirstRechargeRewardStatus,
GetCumulativeRechargeRewardStatus: h.getCumulativeRechargeRewardStatus,
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
SignSevenDayCheckIn: h.signSevenDayCheckIn,
ListUserLeaderboards: h.listUserLeaderboards,
CheckLuckyGift: h.checkLuckyGift,
GetRoomTurnoverRewardStatus: h.getRoomTurnoverRewardStatus,
GetWeeklyStarCurrent: h.getWeeklyStarCurrent,
ListWeeklyStarLeaderboard: h.listWeeklyStarLeaderboard,
ListWeeklyStarHistory: h.listWeeklyStarHistory,
}
}

View File

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

View File

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

View File

@ -40,8 +40,11 @@ type Handler struct {
achievementClient client.AchievementClient
registrationReward client.RegistrationRewardClient
firstRechargeReward client.FirstRechargeRewardClient
cumulativeRecharge client.CumulativeRechargeRewardClient
sevenDayCheckIn client.SevenDayCheckInClient
luckyGift client.LuckyGiftClient
roomTurnoverReward client.RoomTurnoverRewardClient
weeklyStar client.WeeklyStarClient
broadcastClient client.BroadcastClient
gameClient client.GameClient
appConfigReader appapi.ConfigReader
@ -212,6 +215,11 @@ func (h *Handler) SetFirstRechargeRewardClient(firstRechargeReward client.FirstR
h.firstRechargeReward = firstRechargeReward
}
// SetCumulativeRechargeRewardClient 注入 activity-service 累充奖励查询 client。
func (h *Handler) SetCumulativeRechargeRewardClient(cumulativeRecharge client.CumulativeRechargeRewardClient) {
h.cumulativeRecharge = cumulativeRecharge
}
// SetSevenDayCheckInClient 注入 activity-service 七日签到 client。
func (h *Handler) SetSevenDayCheckInClient(sevenDayCheckIn client.SevenDayCheckInClient) {
h.sevenDayCheckIn = sevenDayCheckIn
@ -222,6 +230,16 @@ func (h *Handler) SetLuckyGiftClient(luckyGift client.LuckyGiftClient) {
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。
func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient

View File

@ -159,14 +159,19 @@ type MessageHandlers struct {
}
type TaskHandlers struct {
ListTaskTabs http.HandlerFunc
ClaimTaskReward http.HandlerFunc
GetRegistrationRewardEligibility http.HandlerFunc
GetFirstRechargeRewardStatus http.HandlerFunc
GetSevenDayCheckInStatus http.HandlerFunc
SignSevenDayCheckIn http.HandlerFunc
ListUserLeaderboards http.HandlerFunc
CheckLuckyGift http.HandlerFunc
ListTaskTabs http.HandlerFunc
ClaimTaskReward http.HandlerFunc
GetRegistrationRewardEligibility http.HandlerFunc
GetFirstRechargeRewardStatus http.HandlerFunc
GetCumulativeRechargeRewardStatus http.HandlerFunc
GetSevenDayCheckInStatus http.HandlerFunc
SignSevenDayCheckIn http.HandlerFunc
ListUserLeaderboards http.HandlerFunc
CheckLuckyGift http.HandlerFunc
GetRoomTurnoverRewardStatus http.HandlerFunc
GetWeeklyStarCurrent http.HandlerFunc
ListWeeklyStarLeaderboard http.HandlerFunc
ListWeeklyStarHistory http.HandlerFunc
}
type LevelHandlers struct {
@ -409,9 +414,14 @@ func (r routes) registerTaskRoutes() {
h := r.config.Task
r.profile("/activities/registration-reward/eligibility", http.MethodGet, h.GetRegistrationRewardEligibility)
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/sign", http.MethodPost, h.SignSevenDayCheckIn)
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 规则、抽奖和返奖实现。
r.profile("/activities/lucky-gifts/check", http.MethodPost, h.CheckLuckyGift)
r.profile("/tasks/tabs", http.MethodGet, h.ListTaskTabs)

View File

@ -74,8 +74,11 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
WalletDB: h.leaderboardWalletDB,
RegistrationReward: h.registrationReward,
FirstRechargeReward: h.firstRechargeReward,
CumulativeRecharge: h.cumulativeRecharge,
SevenDayCheckIn: h.sevenDayCheckIn,
LuckyGift: h.luckyGift,
RoomTurnoverReward: h.roomTurnoverReward,
WeeklyStar: h.weeklyStar,
})
gameAPI := gameapi.New(gameapi.Config{
GameClient: h.gameClient,

Some files were not shown because too many files have changed in this diff Show More