package inviteactivityreward 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"` PerInviteInviterRewardCoinAmount int64 `json:"per_invite_inviter_reward_coin_amount"` PerInviteInviteeRewardCoinAmount int64 `json:"per_invite_invitee_reward_coin_amount"` Tiers []tierDTO `json:"tiers"` } type configDTO struct { AppCode string `json:"app_code"` Enabled bool `json:"enabled"` PerInviteInviterRewardCoinAmount int64 `json:"per_invite_inviter_reward_coin_amount"` PerInviteInviteeRewardCoinAmount int64 `json:"per_invite_invitee_reward_coin_amount"` 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"` RewardType string `json:"reward_type"` TierCode string `json:"tier_code"` TierName string `json:"tier_name"` ThresholdCoinAmount int64 `json:"threshold_coin_amount"` ThresholdValidInviteCount int64 `json:"threshold_valid_invite_count"` 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 claimDTO struct { ClaimID string `json:"claim_id"` AppCode string `json:"app_code"` CycleKey string `json:"cycle_key"` UserID int64 `json:"user_id,string"` User *userDTO `json:"user,omitempty"` RewardType string `json:"reward_type"` CommandID string `json:"command_id"` TierID int64 `json:"tier_id"` TierCode string `json:"tier_code"` TierName string `json:"tier_name"` ThresholdCoinAmount int64 `json:"threshold_coin_amount"` ThresholdValidInviteCount int64 `json:"threshold_valid_invite_count"` ReachedValue int64 `json:"reached_value"` 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"` GrantedAtMS int64 `json:"granted_at_ms"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type summaryDTO struct { AppCode string `json:"app_code"` CycleKey string `json:"cycle_key"` UserID int64 `json:"user_id,string"` User *userDTO `json:"user,omitempty"` InviteCount int64 `json:"invite_count"` ValidInviteCount int64 `json:"valid_invite_count"` TotalRechargeCoinAmount int64 `json:"total_recharge_coin_amount"` TotalRewardCoinAmount int64 `json:"total_reward_coin_amount"` UpdatedAtMS int64 `json:"updated_at_ms"` Invitees []inviteeDTO `json:"invitees"` } type inviteeDTO struct { UserID int64 `json:"user_id,string"` User *userDTO `json:"user,omitempty"` RechargeCoinAmount int64 `json:"recharge_coin_amount"` InvitedAtMS int64 `json:"invited_at_ms"` } type userDTO struct { UserID int64 `json:"user_id,string"` 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.GetInviteActivityRewardConfig(c.Request.Context(), &activityv1.GetInviteActivityRewardConfigRequest{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.InviteActivityRewardTier, 0, len(req.Tiers)) for _, tier := range req.Tiers { // admin-server 只裁剪字符串和注入操作人;档位门槛、奖励金额、最高档领取语义都由 activity-service 统一校验。 tiers = append(tiers, &activityv1.InviteActivityRewardTier{ TierId: tier.TierID, RewardType: strings.TrimSpace(tier.RewardType), TierCode: strings.TrimSpace(tier.TierCode), TierName: strings.TrimSpace(tier.TierName), ThresholdCoinAmount: tier.ThresholdCoinAmount, ThresholdValidInviteCount: tier.ThresholdValidInviteCount, RewardCoinAmount: tier.RewardCoinAmount, Status: strings.TrimSpace(tier.Status), SortOrder: tier.SortOrder, }) } resp, err := h.activity.UpdateInviteActivityRewardConfig(c.Request.Context(), &activityv1.UpdateInviteActivityRewardConfigRequest{ Meta: h.meta(c), Enabled: req.Enabled, PerInviteInviterRewardCoinAmount: req.PerInviteInviterRewardCoinAmount, PerInviteInviteeRewardCoinAmount: req.PerInviteInviteeRewardCoinAmount, 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-invite-activity-reward", "invite_activity_reward_configs", item.AppCode, "success", "") response.OK(c, item) } func (h *Handler) ListClaims(c *gin.Context) { options := shared.ListOptions(c) userFilter := shared.UserIdentityFilterFromQuery(c, "") userID, matched, ok := h.resolveUserID(c.Request.Context(), strings.TrimSpace(options.Keyword), userFilter) if !ok { response.ServerError(c, "查询用户信息失败") return } if (options.Keyword != "" || !userFilter.IsEmpty()) && !matched { response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } resp, err := h.activity.ListInviteActivityRewardClaims(c.Request.Context(), &activityv1.ListInviteActivityRewardClaimsRequest{ Meta: h.meta(c), Status: strings.TrimSpace(options.Status), RewardType: strings.TrimSpace(c.Query("reward_type")), 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([]claimDTO, 0, len(resp.GetClaims())) for _, claim := range resp.GetClaims() { items = append(items, claimFromProto(claim)) } if err := h.fillUsers(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) ListSummaries(c *gin.Context) { options := shared.ListOptions(c) userFilter := shared.UserIdentityFilterFromQuery(c, "") userID, matched, ok := h.resolveUserID(c.Request.Context(), strings.TrimSpace(options.Keyword), userFilter) if !ok { response.ServerError(c, "查询用户信息失败") return } if (options.Keyword != "" || !userFilter.IsEmpty()) && !matched { response.OK(c, response.Page{Items: []summaryDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } resp, err := h.activity.ListInviteActivityRewardSummaries(c.Request.Context(), &activityv1.ListInviteActivityRewardSummariesRequest{ Meta: h.meta(c), 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([]summaryDTO, 0, len(resp.GetSummaries())) for _, summary := range resp.GetSummaries() { items = append(items, summaryFromProto(summary)) } if err := h.fillSummaryUsers(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) resolveUserID(ctx context.Context, keyword string, filter shared.UserIdentityFilter) (int64, bool, bool) { if filter.IsEmpty() && keyword == "" { return 0, false, true } if !filter.IsEmpty() { userID, matched, err := shared.ResolveUserIdentityFilter(ctx, h.userDB, appctx.FromContext(ctx), filter, time.Now().UnixMilli()) return userID, matched, err == nil } // 邀请奖励汇总页只把内部 user_id 交给 activity-service;展示 ID 的兼容规则集中在 shared 解析器里。 userID, matched, err := shared.ResolveExactUserID(ctx, h.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) if err != nil { return 0, false, false } return userID, matched, true } func (h *Handler) fillUsers(ctx context.Context, claims []claimDTO) error { if len(claims) == 0 { return nil } ids := collectUserIDs(claims) if len(ids) == 0 { return nil } users, err := h.loadUsers(ctx, ids) if err != nil { return err } for index := range claims { if user, ok := users[claims[index].UserID]; ok { claims[index].User = &user continue } claims[index].User = &userDTO{UserID: claims[index].UserID} } return nil } func (h *Handler) fillSummaryUsers(ctx context.Context, summaries []summaryDTO) error { if len(summaries) == 0 { return nil } ids := collectSummaryUserIDs(summaries) if len(ids) == 0 { return nil } users, err := h.loadUsers(ctx, ids) if err != nil { return err } for summaryIndex := range summaries { if user, ok := users[summaries[summaryIndex].UserID]; ok { summaries[summaryIndex].User = &user } else { summaries[summaryIndex].User = &userDTO{UserID: summaries[summaryIndex].UserID} } for inviteeIndex := range summaries[summaryIndex].Invitees { userID := summaries[summaryIndex].Invitees[inviteeIndex].UserID if user, ok := users[userID]; ok { summaries[summaryIndex].Invitees[inviteeIndex].User = &user continue } summaries[summaryIndex].Invitees[inviteeIndex].User = &userDTO{UserID: userID} } } return nil } func (h *Handler) loadUsers(ctx context.Context, ids []int64) (map[int64]userDTO, error) { if h.userDB == nil || len(ids) == 0 { return map[int64]userDTO{}, 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 nil, 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 nil, err } users[user.UserID] = user } if err := rows.Err(); err != nil { return nil, err } return users, nil } func configFromProto(config *activityv1.InviteActivityRewardConfig) 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(), PerInviteInviterRewardCoinAmount: config.GetPerInviteInviterRewardCoinAmount(), PerInviteInviteeRewardCoinAmount: config.GetPerInviteInviteeRewardCoinAmount(), Tiers: tiers, UpdatedByAdminID: config.GetUpdatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), UpdatedAtMS: config.GetUpdatedAtMs(), } } func tierFromProto(tier *activityv1.InviteActivityRewardTier) tierDTO { if tier == nil { return tierDTO{} } return tierDTO{ TierID: tier.GetTierId(), RewardType: tier.GetRewardType(), TierCode: tier.GetTierCode(), TierName: tier.GetTierName(), ThresholdCoinAmount: tier.GetThresholdCoinAmount(), ThresholdValidInviteCount: tier.GetThresholdValidInviteCount(), RewardCoinAmount: tier.GetRewardCoinAmount(), Status: tier.GetStatus(), SortOrder: tier.GetSortOrder(), CreatedAtMS: tier.GetCreatedAtMs(), UpdatedAtMS: tier.GetUpdatedAtMs(), } } func claimFromProto(claim *activityv1.InviteActivityRewardClaim) claimDTO { if claim == nil { return claimDTO{} } return claimDTO{ ClaimID: claim.GetClaimId(), AppCode: claim.GetAppCode(), CycleKey: claim.GetCycleKey(), UserID: claim.GetUserId(), RewardType: claim.GetRewardType(), CommandID: claim.GetCommandId(), TierID: claim.GetTierId(), TierCode: claim.GetTierCode(), TierName: claim.GetTierName(), ThresholdCoinAmount: claim.GetThresholdCoinAmount(), ThresholdValidInviteCount: claim.GetThresholdValidInviteCount(), ReachedValue: claim.GetReachedValue(), RewardCoinAmount: claim.GetRewardCoinAmount(), Status: claim.GetStatus(), WalletCommandID: claim.GetWalletCommandId(), WalletTransactionID: claim.GetWalletTransactionId(), FailureReason: claim.GetFailureReason(), GrantedAtMS: claim.GetGrantedAtMs(), CreatedAtMS: claim.GetCreatedAtMs(), UpdatedAtMS: claim.GetUpdatedAtMs(), } } func summaryFromProto(summary *activityv1.InviteActivityRewardSummary) summaryDTO { if summary == nil { return summaryDTO{Invitees: []inviteeDTO{}} } invitees := make([]inviteeDTO, 0, len(summary.GetInvitees())) for _, invitee := range summary.GetInvitees() { invitees = append(invitees, inviteeFromProto(invitee)) } return summaryDTO{ AppCode: summary.GetAppCode(), CycleKey: summary.GetCycleKey(), UserID: summary.GetUserId(), InviteCount: summary.GetInviteCount(), ValidInviteCount: summary.GetValidInviteCount(), TotalRechargeCoinAmount: summary.GetTotalRechargeCoinAmount(), TotalRewardCoinAmount: summary.GetTotalRewardCoinAmount(), UpdatedAtMS: summary.GetUpdatedAtMs(), Invitees: invitees, } } func inviteeFromProto(invitee *activityv1.InviteActivityRewardInviteeSummary) inviteeDTO { if invitee == nil { return inviteeDTO{} } return inviteeDTO{ UserID: invitee.GetInvitedUserId(), RechargeCoinAmount: invitee.GetRechargeCoinAmount(), InvitedAtMS: invitee.GetInvitedAtMs(), } } func collectUserIDs(claims []claimDTO) []int64 { seen := make(map[int64]struct{}, len(claims)) ids := make([]int64, 0, len(claims)) for _, claim := range claims { if claim.UserID <= 0 { continue } if _, ok := seen[claim.UserID]; ok { continue } seen[claim.UserID] = struct{}{} ids = append(ids, claim.UserID) } return ids } func collectSummaryUserIDs(summaries []summaryDTO) []int64 { seen := make(map[int64]struct{}, len(summaries)) ids := make([]int64, 0, len(summaries)) for _, summary := range summaries { if summary.UserID > 0 { if _, ok := seen[summary.UserID]; !ok { seen[summary.UserID] = struct{}{} ids = append(ids, summary.UserID) } } for _, invitee := range summary.Invitees { if invitee.UserID <= 0 { continue } if _, ok := seen[invitee.UserID]; ok { continue } seen[invitee.UserID] = struct{}{} ids = append(ids, invitee.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, ",") }