2026-06-29 14:26:06 +08:00

492 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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