包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
542 lines
14 KiB
Go
542 lines
14 KiB
Go
package points
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
|
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
|
)
|
|
|
|
// GetDashboard returns user points dashboard
|
|
// GET /api/points/dashboard?wallet_address=0x...
|
|
func GetDashboard(c *gin.Context) {
|
|
db := common.GetDB()
|
|
|
|
user, found := getUserByWallet(c)
|
|
if !found {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"success": false,
|
|
"error": "User not found",
|
|
})
|
|
return
|
|
}
|
|
|
|
vipLevel := user.VIPLevel
|
|
if vipLevel < 1 {
|
|
vipLevel = 1
|
|
}
|
|
|
|
var currentTier models.VIPTier
|
|
db.Where("tier_level = ?", vipLevel).First(¤tTier)
|
|
|
|
var nextTier models.VIPTier
|
|
var pointsToNextTier int64
|
|
var nextTierName string
|
|
|
|
if db.Where("tier_level = ?", vipLevel+1).First(&nextTier).Error == nil {
|
|
pointsToNextTier = nextTier.MinPoints - user.TotalPoints
|
|
nextTierName = nextTier.TierName
|
|
} else {
|
|
pointsToNextTier = 0
|
|
nextTierName = "Max Level"
|
|
}
|
|
|
|
var season models.Season
|
|
db.Where("status = ?", "active").First(&season)
|
|
|
|
now := time.Now()
|
|
timeRemaining := season.EndTime.Sub(now)
|
|
daysRemaining := int(timeRemaining.Hours() / 24)
|
|
hoursRemaining := int(timeRemaining.Hours()) % 24
|
|
|
|
var totalUsers int64
|
|
db.Model(&models.User{}).Count(&totalUsers)
|
|
topPercentage := "N/A"
|
|
globalRank := 0
|
|
if user.GlobalRank != nil {
|
|
globalRank = *user.GlobalRank
|
|
if totalUsers > 0 {
|
|
percentage := float64(*user.GlobalRank) / float64(totalUsers) * 100
|
|
topPercentage = fmt.Sprintf("%.0f%%", percentage)
|
|
}
|
|
}
|
|
|
|
response := models.DashboardResponse{
|
|
TotalPoints: user.TotalPoints,
|
|
GlobalRank: globalRank,
|
|
TopPercentage: topPercentage,
|
|
MemberTier: user.MemberTier,
|
|
VIPLevel: vipLevel,
|
|
PointsToNextTier: pointsToNextTier,
|
|
NextTier: nextTierName,
|
|
Season: models.SeasonInfo{
|
|
SeasonNumber: season.SeasonNumber,
|
|
SeasonName: season.SeasonName,
|
|
IsLive: season.Status == "active",
|
|
EndTime: season.EndTime,
|
|
DaysRemaining: daysRemaining,
|
|
HoursRemaining: hoursRemaining,
|
|
},
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": response,
|
|
})
|
|
}
|
|
|
|
// GetLeaderboard returns top performers leaderboard
|
|
// GET /api/points/leaderboard?limit=5&wallet_address=0x...
|
|
func GetLeaderboard(c *gin.Context) {
|
|
db := common.GetDB()
|
|
|
|
limitStr := c.DefaultQuery("limit", "5")
|
|
limit, _ := strconv.Atoi(limitStr)
|
|
|
|
var topUsers []models.User
|
|
db.Where("global_rank IS NOT NULL").
|
|
Order("global_rank ASC").
|
|
Limit(limit).
|
|
Find(&topUsers)
|
|
|
|
var leaderboard []models.LeaderboardUser
|
|
for _, u := range topUsers {
|
|
rank := 0
|
|
if u.GlobalRank != nil {
|
|
rank = *u.GlobalRank
|
|
}
|
|
leaderboard = append(leaderboard, models.LeaderboardUser{
|
|
Rank: rank,
|
|
WalletAddress: formatAddress(u.WalletAddress),
|
|
Points: u.TotalPoints,
|
|
})
|
|
}
|
|
|
|
currentUser, _ := getUserByWallet(c)
|
|
myRank := 0
|
|
if currentUser.GlobalRank != nil {
|
|
myRank = *currentUser.GlobalRank
|
|
}
|
|
|
|
response := models.LeaderboardResponse{
|
|
TopUsers: leaderboard,
|
|
MyRank: myRank,
|
|
MyPoints: currentUser.TotalPoints,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": response,
|
|
})
|
|
}
|
|
|
|
// GetInviteCode returns user's invite code information
|
|
// GET /api/points/invite-code?wallet_address=0x...
|
|
func GetInviteCode(c *gin.Context) {
|
|
db := common.GetDB()
|
|
|
|
user, found := getUserByWallet(c)
|
|
if !found {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"success": false,
|
|
"error": "User not found",
|
|
})
|
|
return
|
|
}
|
|
|
|
var inviteCode models.InviteCode
|
|
err := db.Where("wallet_address = ?", user.WalletAddress).First(&inviteCode).Error
|
|
|
|
if err != nil {
|
|
// Create a system-generated invite code
|
|
code := strings.ToUpper(common.RandString(8))
|
|
defaultExpiry := time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
|
|
inviteCode = models.InviteCode{
|
|
WalletAddress: user.WalletAddress,
|
|
Code: code,
|
|
MaxUses: -1,
|
|
UsedCount: 0,
|
|
IsActive: true,
|
|
ExpiresAt: models.NullTime{Time: &defaultExpiry},
|
|
}
|
|
|
|
if err := db.Create(&inviteCode).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": "Failed to create invite code",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Also sync to users.invite_code
|
|
db.Model(&user).Update("invite_code", code)
|
|
}
|
|
|
|
response := models.InviteCodeResponse{
|
|
Code: inviteCode.Code,
|
|
UsedCount: inviteCode.UsedCount,
|
|
MaxUses: inviteCode.MaxUses,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": response,
|
|
})
|
|
}
|
|
|
|
// BindInvite binds an invite code to current user
|
|
// POST /api/points/bind-invite
|
|
func BindInvite(c *gin.Context) {
|
|
db := common.GetDB()
|
|
|
|
var req struct {
|
|
Code string `json:"code" binding:"required"`
|
|
Signature string `json:"signature" binding:"required"`
|
|
WalletAddress string `json:"wallet_address"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"success": false,
|
|
"error": "Invalid request body",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Look up user by wallet_address
|
|
var user models.User
|
|
walletAddr := strings.ToLower(strings.TrimSpace(req.WalletAddress))
|
|
if walletAddr == "" {
|
|
// Try query param fallback
|
|
walletAddr = strings.ToLower(strings.TrimSpace(c.Query("wallet_address")))
|
|
}
|
|
|
|
if walletAddr == "" || db.Where("wallet_address = ?", walletAddr).First(&user).Error != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"success": false,
|
|
"error": "User not found. Please connect your wallet first.",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if user already has a referrer
|
|
if user.ReferrerWallet != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"success": false,
|
|
"error": "Already bound to a referrer",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Find invite code
|
|
var inviteCode models.InviteCode
|
|
if err := db.Where("code = ? AND is_active = ?", req.Code, true).First(&inviteCode).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"success": false,
|
|
"error": "Invalid invite code",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if max uses reached
|
|
if inviteCode.MaxUses != -1 && inviteCode.UsedCount >= inviteCode.MaxUses {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"success": false,
|
|
"error": "Invite code max uses reached",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Prevent self-referral
|
|
if inviteCode.WalletAddress == user.WalletAddress {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"success": false,
|
|
"error": "Cannot use your own invite code",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Create invitation record
|
|
invitation := models.Invitation{
|
|
ReferrerWallet: inviteCode.WalletAddress,
|
|
RefereeWallet: user.WalletAddress,
|
|
InviteCode: req.Code,
|
|
BindSignature: req.Signature,
|
|
Status: "active",
|
|
ReferrerRewardPoints: 100,
|
|
RefereeRewardPoints: 50,
|
|
BoundAt: time.Now(),
|
|
}
|
|
|
|
if err := db.Create(&invitation).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": "Failed to bind invite code",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Update user referrer
|
|
db.Model(&user).Update("referrer_wallet", inviteCode.WalletAddress)
|
|
|
|
// Update invite code used count
|
|
db.Model(&inviteCode).Update("used_count", inviteCode.UsedCount+1)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Invite code bound successfully",
|
|
})
|
|
}
|
|
|
|
// GetTeamTVL returns team TVL statistics
|
|
// GET /api/points/team?wallet_address=0x...
|
|
func GetTeamTVL(c *gin.Context) {
|
|
db := common.GetDB()
|
|
|
|
user, _ := getUserByWallet(c)
|
|
|
|
var team models.UserTeam
|
|
if err := db.Where("wallet_address = ?", user.WalletAddress).First(&team).Error; err != nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": models.TeamTVLResponse{
|
|
CurrentTVL: "$0",
|
|
TargetTVL: "$10M",
|
|
ProgressPercent: 0,
|
|
TotalMembers: 0,
|
|
Roles: []models.RoleCount{},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
progressPercent := 0.0
|
|
if team.TeamTargetTVLUSD > 0 {
|
|
progressPercent = (team.TeamTVLUSD / team.TeamTargetTVLUSD) * 100
|
|
}
|
|
|
|
response := models.TeamTVLResponse{
|
|
CurrentTVL: formatUSD(team.TeamTVLUSD),
|
|
TargetTVL: formatUSD(team.TeamTargetTVLUSD),
|
|
ProgressPercent: math.Round(progressPercent*100) / 100,
|
|
TotalMembers: team.TeamMembersCount,
|
|
Roles: []models.RoleCount{
|
|
{
|
|
Icon: "/icons/user/icon-whale.svg",
|
|
Label: "Whales",
|
|
Current: team.WhalesCount,
|
|
Target: team.WhalesTarget,
|
|
},
|
|
{
|
|
Icon: "/icons/user/icon-trader.svg",
|
|
Label: "Traders",
|
|
Current: team.TradersCount,
|
|
Target: team.TradersTarget,
|
|
},
|
|
{
|
|
Icon: "/icons/user/icon-user.svg",
|
|
Label: "Users",
|
|
Current: team.UsersCount,
|
|
Target: team.UsersTarget,
|
|
},
|
|
},
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": response,
|
|
})
|
|
}
|
|
|
|
// GetActivities returns user points activities
|
|
// GET /api/points/activities?wallet_address=0x...&type=all&page=1&pageSize=10
|
|
func GetActivities(c *gin.Context) {
|
|
db := common.GetDB()
|
|
|
|
activityType := c.DefaultQuery("type", "all")
|
|
pageStr := c.DefaultQuery("page", "1")
|
|
pageSizeStr := c.DefaultQuery("pageSize", "10")
|
|
|
|
page, _ := strconv.Atoi(pageStr)
|
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
|
offset := (page - 1) * pageSize
|
|
|
|
user, _ := getUserByWallet(c)
|
|
|
|
query := db.Model(&models.UserPointsRecord{}).Where("wallet_address = ?", user.WalletAddress)
|
|
|
|
if activityType == "referrals" {
|
|
query = query.Where("source_type = ?", "invitation")
|
|
} else if activityType == "deposits" {
|
|
query = query.Where("source_type IN ?", []string{"deposit", "holding"})
|
|
}
|
|
|
|
var total int64
|
|
query.Count(&total)
|
|
|
|
var records []models.UserPointsRecord
|
|
query.Order("created_at DESC").
|
|
Limit(pageSize).
|
|
Offset(offset).
|
|
Find(&records)
|
|
|
|
activities := make([]models.ActivityRecord, 0)
|
|
for _, record := range records {
|
|
activity := models.ActivityRecord{
|
|
Type: record.SourceType,
|
|
UserAddress: formatAddress(user.WalletAddress),
|
|
Points: record.PointsChange,
|
|
CreatedAt: record.CreatedAt,
|
|
}
|
|
|
|
if record.SourceType == "invitation" && record.SourceID != nil {
|
|
var invitation models.Invitation
|
|
if db.Where("id = ?", *record.SourceID).First(&invitation).Error == nil {
|
|
activity.FriendAddress = formatAddress(invitation.RefereeWallet)
|
|
activity.InviteCode = invitation.InviteCode
|
|
}
|
|
}
|
|
|
|
activities = append(activities, activity)
|
|
}
|
|
|
|
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
|
|
|
|
response := models.ActivitiesResponse{
|
|
Activities: activities,
|
|
Pagination: models.PaginationInfo{
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
Total: int(total),
|
|
TotalPage: totalPages,
|
|
},
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": response,
|
|
})
|
|
}
|
|
|
|
// Helper function to format wallet address for display
|
|
func formatAddress(address string) string {
|
|
if len(address) < 10 {
|
|
return address
|
|
}
|
|
return address[:6] + "..." + address[len(address)-4:]
|
|
}
|
|
|
|
// Helper function to format USD values
|
|
func formatUSD(value float64) string {
|
|
if value >= 1000000 {
|
|
return fmt.Sprintf("$%.1fM", value/1000000)
|
|
} else if value >= 1000 {
|
|
return fmt.Sprintf("$%.1fK", value/1000)
|
|
}
|
|
return fmt.Sprintf("$%.2f", value)
|
|
}
|
|
|
|
// getUserByWallet looks up a user by wallet_address query param,
|
|
// falls back to demo user if not provided
|
|
func getUserByWallet(c *gin.Context) (models.User, bool) {
|
|
db := common.GetDB()
|
|
var user models.User
|
|
walletAddress := strings.ToLower(strings.TrimSpace(c.Query("wallet_address")))
|
|
if walletAddress != "" {
|
|
if err := db.Where("wallet_address = ?", walletAddress).First(&user).Error; err == nil {
|
|
return user, true
|
|
}
|
|
}
|
|
// Fall back to first demo user
|
|
err := db.Order("created_at ASC").First(&user).Error
|
|
return user, err == nil
|
|
}
|
|
|
|
// walletAddressToInt64 is no longer needed (no auth_user_id in new schema)
|
|
// kept for reference only
|
|
|
|
// WalletRegister creates or finds a user by wallet address
|
|
// POST /api/points/wallet-register
|
|
func WalletRegister(c *gin.Context) {
|
|
db := common.GetDB()
|
|
|
|
var req struct {
|
|
WalletAddress string `json:"wallet_address"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.WalletAddress) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"success": false,
|
|
"error": "wallet_address is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
walletAddress := strings.ToLower(strings.TrimSpace(req.WalletAddress))
|
|
|
|
// Check if user already exists
|
|
var user models.User
|
|
err := db.Where("wallet_address = ?", walletAddress).First(&user).Error
|
|
if err != nil {
|
|
// Create new user with system-generated invite code
|
|
code := strings.ToUpper(common.RandString(8))
|
|
user = models.User{
|
|
WalletAddress: walletAddress,
|
|
InviteCode: code,
|
|
MemberTier: "Bronze",
|
|
VIPLevel: 1,
|
|
TotalPoints: 0,
|
|
}
|
|
if createErr := db.Create(&user).Error; createErr != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"success": false,
|
|
"error": "Failed to create user",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Create invite_codes record
|
|
defaultExpiry := time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
|
|
inviteCodeRecord := models.InviteCode{
|
|
WalletAddress: walletAddress,
|
|
Code: code,
|
|
MaxUses: -1,
|
|
UsedCount: 0,
|
|
IsActive: true,
|
|
ExpiresAt: models.NullTime{Time: &defaultExpiry},
|
|
}
|
|
db.Create(&inviteCodeRecord)
|
|
}
|
|
|
|
// Get invite code from invite_codes table
|
|
var inviteCodeRecord models.InviteCode
|
|
inviteCode := user.InviteCode
|
|
usedCount := 0
|
|
if db.Where("wallet_address = ?", user.WalletAddress).First(&inviteCodeRecord).Error == nil {
|
|
inviteCode = inviteCodeRecord.Code
|
|
usedCount = inviteCodeRecord.UsedCount
|
|
}
|
|
|
|
globalRank := 0
|
|
if user.GlobalRank != nil {
|
|
globalRank = *user.GlobalRank
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": gin.H{
|
|
"walletAddress": user.WalletAddress,
|
|
"inviteCode": inviteCode,
|
|
"usedCount": usedCount,
|
|
"memberTier": user.MemberTier,
|
|
"vipLevel": user.VIPLevel,
|
|
"totalPoints": user.TotalPoints,
|
|
"globalRank": globalRank,
|
|
},
|
|
})
|
|
}
|