init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
541
webapp-back/points/routers.go
Normal file
541
webapp-back/points/routers.go
Normal file
@@ -0,0 +1,541 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user