492 lines
17 KiB
Go
492 lines
17 KiB
Go
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, ",")
|
||
}
|