init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
9
webapp-back/.dockerignore
Normal file
9
webapp-back/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
bin/
|
||||
logs/
|
||||
data/
|
||||
*.log
|
||||
.git
|
||||
assetx-api
|
||||
webapp-back
|
||||
golang-gin-realworld-example-app
|
||||
logo.png
|
||||
9
webapp-back/.env.example
Normal file
9
webapp-back/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Server Configuration
|
||||
PORT=8080
|
||||
GIN_MODE=debug
|
||||
|
||||
# Database Configuration (SQLite)
|
||||
DB_PATH=./data/gorm.db
|
||||
|
||||
# Test Database Path (optional, for running tests)
|
||||
# TEST_DB_PATH=./data/gorm_test.db
|
||||
41
webapp-back/.gitignore
vendored
Normal file
41
webapp-back/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/bower_components
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
*.launch
|
||||
.settings/
|
||||
|
||||
#System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
vendor/*
|
||||
!vendor/vendor.json
|
||||
|
||||
tmp/*
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
data/*.db
|
||||
gorm.db
|
||||
gorm_test.db
|
||||
|
||||
# Test output
|
||||
coverage.txt
|
||||
coverage.out
|
||||
profile.out
|
||||
results.sarif
|
||||
|
||||
# Environment files (keep .env.example)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
bak.*
|
||||
tmp/
|
||||
19
webapp-back/.golangci.yml
Normal file
19
webapp-back/.golangci.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- staticcheck
|
||||
- unused
|
||||
- misspell
|
||||
|
||||
linters-settings:
|
||||
misspell:
|
||||
locale: US
|
||||
|
||||
issues:
|
||||
exclude-use-default: true
|
||||
max-issues-per-linter: 50
|
||||
max-same-issues: 10
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
go: '1.22'
|
||||
114
webapp-back/AGENTS.md
Normal file
114
webapp-back/AGENTS.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Copilot Coding Agent Instructions
|
||||
|
||||
This document provides instructions for AI coding agents working on this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **Golang/Gin** implementation of the [RealWorld](https://github.com/gothinkster/realworld) example application. It demonstrates a fully fledged fullstack application including CRUD operations, authentication, routing, pagination, and more.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Go**: 1.21+ required
|
||||
- **Web Framework**: [Gin](https://github.com/gin-gonic/gin) v1.10+
|
||||
- **ORM**: [GORM v2](https://gorm.io/) (gorm.io/gorm)
|
||||
- **Database**: SQLite (for development/testing)
|
||||
- **Authentication**: JWT using [golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt)
|
||||
- **Validation**: [go-playground/validator/v10](https://github.com/go-playground/validator)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── hello.go # Main entry point
|
||||
├── common/ # Shared utilities
|
||||
│ ├── database.go # Database connection manager
|
||||
│ ├── utils.go # Helper functions (JWT, validation, etc.)
|
||||
│ └── unit_test.go # Common package tests
|
||||
├── users/ # User module
|
||||
│ ├── models.go # User data models & DB operations
|
||||
│ ├── serializers.go # Response formatting
|
||||
│ ├── routers.go # Route handlers
|
||||
│ ├── middlewares.go # Auth middleware
|
||||
│ ├── validators.go # Input validation
|
||||
│ └── unit_test.go # User package tests
|
||||
├── articles/ # Articles module
|
||||
│ ├── models.go # Article data models & DB operations
|
||||
│ ├── serializers.go # Response formatting
|
||||
│ ├── routers.go # Route handlers
|
||||
│ ├── validators.go # Input validation
|
||||
│ └── unit_test.go # Article package tests
|
||||
└── scripts/ # Build/test scripts
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
go mod download
|
||||
|
||||
# Build
|
||||
go build ./...
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Run tests with coverage
|
||||
go test -coverprofile=coverage.out ./...
|
||||
|
||||
# Format code
|
||||
go fmt ./...
|
||||
|
||||
# Run linter
|
||||
golangci-lint run
|
||||
|
||||
# Start the server
|
||||
go run hello.go
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
1. **Error Handling**: Always handle errors explicitly. Do not ignore errors with `_`.
|
||||
2. **GORM v2 Patterns**:
|
||||
- Use `Preload()` instead of `Related()` for eager loading
|
||||
- Use `Association().Find()` for many-to-many relationships
|
||||
- Use `Updates()` instead of `Update()` for struct/map updates
|
||||
- Use pointers with `Delete()`: `Delete(&Model{})`
|
||||
- Count returns `int64`, handle overflow when converting to `uint`
|
||||
3. **Validation Tags**: Use `required` instead of deprecated `exists` tag
|
||||
4. **JWT**: Use `jwt.NewWithClaims()` for token creation
|
||||
|
||||
## Testing
|
||||
|
||||
- Tests are in `*_test.go` files alongside source code
|
||||
- Use `common.TestDBInit()` and `common.TestDBFree()` for test database setup/teardown
|
||||
- Run `go test ./...` before committing changes
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The API follows the [RealWorld API Spec](https://realworld-docs.netlify.app/docs/specs/backend-specs/endpoints):
|
||||
|
||||
- `POST /api/users` - Register
|
||||
- `POST /api/users/login` - Login
|
||||
- `GET /api/user` - Get current user
|
||||
- `PUT /api/user` - Update user
|
||||
- `GET /api/profiles/:username` - Get profile
|
||||
- `POST /api/profiles/:username/follow` - Follow user
|
||||
- `DELETE /api/profiles/:username/follow` - Unfollow user
|
||||
- `GET /api/articles` - List articles
|
||||
- `GET /api/articles/feed` - Feed articles
|
||||
- `GET /api/articles/:slug` - Get article
|
||||
- `POST /api/articles` - Create article
|
||||
- `PUT /api/articles/:slug` - Update article
|
||||
- `DELETE /api/articles/:slug` - Delete article
|
||||
- `POST /api/articles/:slug/comments` - Add comment
|
||||
- `GET /api/articles/:slug/comments` - Get comments
|
||||
- `DELETE /api/articles/:slug/comments/:id` - Delete comment
|
||||
- `POST /api/articles/:slug/favorite` - Favorite article
|
||||
- `DELETE /api/articles/:slug/favorite` - Unfavorite article
|
||||
- `GET /api/tags` - Get tags
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- JWT secret is defined in `common/utils.go` - do not expose in production
|
||||
- Always validate user input using validator tags
|
||||
- Use parameterized queries (GORM handles this automatically)
|
||||
16
webapp-back/Dockerfile
Normal file
16
webapp-back/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# ---- Build stage ----
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git gcc musl-dev
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
|
||||
|
||||
# ---- Run stage ----
|
||||
FROM alpine:3.21
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=builder /app/server .
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]
|
||||
21
webapp-back/LICENSE
Normal file
21
webapp-back/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 wangzitian0
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
13
webapp-back/MOBILE_INSTRUCTIONS.md
Normal file
13
webapp-back/MOBILE_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
> *Note: Delete this file before publishing your app!*
|
||||
|
||||
# [Mobile Icons (iOS/Android)](https://github.com/gothinkster/realworld/tree/master/spec/mobile_icons)
|
||||
|
||||
### Using the hosted API
|
||||
|
||||
Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://conduit.productionready.io/api` and you're good to go!
|
||||
|
||||
### Styles/Templates
|
||||
|
||||
Unfortunately, there isn't a common way for us to reuse & share styles/templates for cross-platform mobile apps.
|
||||
|
||||
Instead, we recommend using the Medium.com [iOS](https://itunes.apple.com/us/app/medium/id828256236?mt=8) and [Android](https://play.google.com/store/apps/details?id=com.medium.reader&hl=en) apps as a "north star" regarding general UI functionality/layout, but try not to go too overboard otherwise it will unnecessarily complicate your codebase (in other words, [KISS](https://en.wikipedia.org/wiki/KISS_principle) :)
|
||||
154
webapp-back/SETUP.md
Normal file
154
webapp-back/SETUP.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# AssetX 后端设置指南
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
- Go 1.23+
|
||||
- MySQL 8.0+
|
||||
- Git
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
已配置的 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# MySQL 配置
|
||||
DB_TYPE=mysql
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=123456
|
||||
DB_NAME=assetx
|
||||
|
||||
# 服务器配置
|
||||
PORT=8080
|
||||
GIN_MODE=debug
|
||||
```
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 启动 MySQL
|
||||
sudo service mysql start
|
||||
|
||||
# 运行初始化脚本
|
||||
./init_db.sh
|
||||
```
|
||||
|
||||
### 4. 启动服务器
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
或者直接运行:
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## 🔌 API 端点
|
||||
|
||||
### Public Routes
|
||||
|
||||
- `GET /api/ping` - 健康检查
|
||||
- `GET /api/holders/stats` - 获取所有代币统计
|
||||
- `GET /api/holders/:tokenType` - 获取指定代币的持有者列表
|
||||
|
||||
支持的 tokenType:
|
||||
- `YT-A`
|
||||
- `YT-B`
|
||||
- `YT-C`
|
||||
- `ytLP`
|
||||
- `Lending`
|
||||
|
||||
### Protected Routes (需要管理员权限)
|
||||
|
||||
- `POST /api/holders/update` - 触发区块链数据更新
|
||||
|
||||
## 📊 数据库表
|
||||
|
||||
### holder_snapshots
|
||||
持有者快照表,存储从区块链读取的代币持有者数据。
|
||||
|
||||
```sql
|
||||
CREATE TABLE holder_snapshots (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
holder_address VARCHAR(42) NOT NULL,
|
||||
token_type VARCHAR(50) NOT NULL,
|
||||
token_address VARCHAR(42) NOT NULL,
|
||||
balance VARCHAR(78) NOT NULL,
|
||||
chain_id INT NOT NULL,
|
||||
first_seen BIGINT NOT NULL,
|
||||
last_updated BIGINT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 测试 API
|
||||
curl http://localhost:8080/api/ping
|
||||
|
||||
# 获取统计数据
|
||||
curl http://localhost:8080/api/holders/stats
|
||||
|
||||
# 获取 YT-A 持有者
|
||||
curl http://localhost:8080/api/holders/YT-A
|
||||
```
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### MySQL 连接失败
|
||||
|
||||
```bash
|
||||
# 检查 MySQL 是否运行
|
||||
sudo service mysql status
|
||||
|
||||
# 启动 MySQL
|
||||
sudo service mysql start
|
||||
|
||||
# 测试连接
|
||||
mysql -u root -p123456 -e "SELECT 1"
|
||||
```
|
||||
|
||||
### 数据库不存在
|
||||
|
||||
```bash
|
||||
# 手动创建数据库
|
||||
mysql -u root -p123456 -e "CREATE DATABASE assetx"
|
||||
```
|
||||
|
||||
### GORM 自动迁移
|
||||
|
||||
服务器启动时会自动创建表(如果不存在)。
|
||||
|
||||
## 📝 开发说明
|
||||
|
||||
### 添加新的 API 端点
|
||||
|
||||
1. 在 `holders/` 目录创建处理函数
|
||||
2. 在 `main.go` 注册路由
|
||||
3. 在 `models/` 添加数据模型(如需要)
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
GORM 会自动处理表结构迁移。如需手动迁移:
|
||||
|
||||
```go
|
||||
db.AutoMigrate(&models.YourModel{})
|
||||
```
|
||||
|
||||
## 🔗 相关项目
|
||||
|
||||
- 前端项目: `/home/coder/myprojects/assetx/antdesign`
|
||||
- 数据库 Schema: `/home/coder/myprojects/assetx/database-schema-v1.1-final.sql`
|
||||
111
webapp-back/admin/asset_audit.go
Normal file
111
webapp-back/admin/asset_audit.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListAssetAuditReports(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.AssetAuditReport{})
|
||||
if v := c.Query("asset_id"); v != "" {
|
||||
query = query.Where("asset_id = ?", v)
|
||||
}
|
||||
if v := c.Query("report_type"); v != "" {
|
||||
query = query.Where("report_type = ?", v)
|
||||
}
|
||||
if v := c.Query("auditor_name"); v != "" {
|
||||
query = query.Where("auditor_name LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("is_active"); v != "" {
|
||||
query = query.Where("is_active = ?", v == "true")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.AssetAuditReport
|
||||
if err := query.Order("asset_id ASC, display_order ASC, id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch audit reports")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetAssetAuditReport(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.AssetAuditReport
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Audit report not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func CreateAssetAuditReport(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.AssetAuditReport
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = 0
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "create", "asset_audit_report", "create_audit_report", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateAssetAuditReport(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.AssetAuditReport
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Audit report not found")
|
||||
return
|
||||
}
|
||||
var item models.AssetAuditReport
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
if existing.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
} else {
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "asset_audit_report", "update_audit_report", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteAssetAuditReport(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.AssetAuditReport
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Audit report not found")
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "delete", "asset_audit_report", "delete_audit_report", &item.ID, nil)
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
105
webapp-back/admin/asset_custody.go
Normal file
105
webapp-back/admin/asset_custody.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListAssetCustody(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.AssetCustody{})
|
||||
if v := c.Query("asset_id"); v != "" {
|
||||
query = query.Where("asset_id = ?", v)
|
||||
}
|
||||
if v := c.Query("custodian_name"); v != "" {
|
||||
query = query.Where("custodian_name LIKE ?", "%"+v+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.AssetCustody
|
||||
if err := query.Order("id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch asset custody records")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetAssetCustody(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.AssetCustody
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Asset custody record not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func CreateAssetCustody(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.AssetCustody
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = 0
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "create", "asset_custody", "create_asset_custody", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateAssetCustody(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.AssetCustody
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Asset custody record not found")
|
||||
return
|
||||
}
|
||||
var item models.AssetCustody
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
if existing.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
} else {
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "asset_custody", "update_asset_custody", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteAssetCustody(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.AssetCustody
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Asset custody record not found")
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "delete", "asset_custody", "delete_asset_custody", &item.ID, nil)
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
127
webapp-back/admin/assets.go
Normal file
127
webapp-back/admin/assets.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListAssets(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.Asset{})
|
||||
if v := c.Query("name"); v != "" {
|
||||
query = query.Where("name LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("asset_code"); v != "" {
|
||||
query = query.Where("asset_code LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("category"); v != "" {
|
||||
query = query.Where("category = ?", v)
|
||||
}
|
||||
if v := c.Query("is_active"); v != "" {
|
||||
query = query.Where("is_active = ?", v == "true")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.Asset
|
||||
if err := query.Order("id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch assets")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetAsset(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.Asset
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Asset not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func CreateAsset(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.Asset
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = 0
|
||||
// 合约地址唯一性校验(非空才校验)
|
||||
if item.ContractAddress != "" {
|
||||
var conflict models.Asset
|
||||
if err := db.Where("contract_address = ?", item.ContractAddress).First(&conflict).Error; err == nil {
|
||||
Fail(c, http.StatusConflict, "合约地址已被资产「"+conflict.Name+"」使用,请检查后重试")
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "create", "asset", "create_asset", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateAsset(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.Asset
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Asset not found")
|
||||
return
|
||||
}
|
||||
var item models.Asset
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
// 合约地址唯一性校验(非空且与原值不同时校验)
|
||||
if item.ContractAddress != "" && item.ContractAddress != existing.ContractAddress {
|
||||
var conflict models.Asset
|
||||
if err := db.Where("contract_address = ? AND id != ?", item.ContractAddress, existing.ID).First(&conflict).Error; err == nil {
|
||||
Fail(c, http.StatusConflict, "合约地址已被资产「"+conflict.Name+"」使用,请检查后重试")
|
||||
return
|
||||
}
|
||||
}
|
||||
if existing.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
} else {
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "asset", "update_asset", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteAsset(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.Asset
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Asset not found")
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "delete", "asset", "delete_asset", &item.ID, nil)
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
131
webapp-back/admin/common.go
Normal file
131
webapp-back/admin/common.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/middleware"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// PaginationParams holds parsed ProTable pagination query params
|
||||
type PaginationParams struct {
|
||||
Current int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ParsePagination reads ?current=N&pageSize=M from the request
|
||||
func ParsePagination(c *gin.Context) PaginationParams {
|
||||
current, _ := strconv.Atoi(c.DefaultQuery("current", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if current < 1 {
|
||||
current = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
return PaginationParams{Current: current, PageSize: pageSize}
|
||||
}
|
||||
|
||||
// Offset returns the DB offset for GORM queries
|
||||
func (p PaginationParams) Offset() int {
|
||||
return (p.Current - 1) * p.PageSize
|
||||
}
|
||||
|
||||
// OK responds with {success: true, data: ...}
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
||||
}
|
||||
|
||||
// OKList responds with {success: true, data: [...], total: N} — used by ProTable
|
||||
func OKList(c *gin.Context, data interface{}, total int64) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data, "total": total})
|
||||
}
|
||||
|
||||
// Fail responds with {success: false, message: ...}
|
||||
func Fail(c *gin.Context, status int, msg string) {
|
||||
c.JSON(status, gin.H{"success": false, "message": msg})
|
||||
}
|
||||
|
||||
// flexTimeLayouts is the set of time formats accepted from the frontend.
|
||||
var flexTimeLayouts = []string{
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
// BindJSONFlexTime works like c.ShouldBindJSON but also accepts non-RFC3339
|
||||
// time strings such as "2026-02-28 00:00:00" that Ant Design's DatePicker sends.
|
||||
// It reads the raw body, converts any date-like strings to RFC3339, then unmarshals.
|
||||
func BindJSONFlexTime(c *gin.Context, obj interface{}) error {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return json.Unmarshal(body, obj) // fallback: let normal errors surface
|
||||
}
|
||||
|
||||
for key, val := range raw {
|
||||
var s string
|
||||
if err := json.Unmarshal(val, &s); err != nil {
|
||||
continue // not a string, skip
|
||||
}
|
||||
for _, layout := range flexTimeLayouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
converted, _ := json.Marshal(t) // produces RFC3339
|
||||
raw[key] = converted
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fixed, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(fixed, obj)
|
||||
}
|
||||
|
||||
// LogOp writes a record to operation_logs table
|
||||
func LogOp(c *gin.Context, opType, targetType, action string, targetID *uint, payload interface{}) {
|
||||
db := common.GetDB()
|
||||
user, _ := middleware.GetCurrentUser(c)
|
||||
|
||||
var userID *uint
|
||||
if user != nil {
|
||||
uid := uint(user.UserID)
|
||||
userID = &uid
|
||||
}
|
||||
|
||||
var changesStr string
|
||||
if payload != nil {
|
||||
b, _ := json.Marshal(payload)
|
||||
changesStr = string(b)
|
||||
}
|
||||
|
||||
db.Create(&models.OperationLog{
|
||||
UserID: userID,
|
||||
OperationType: opType,
|
||||
TargetType: targetType,
|
||||
Action: action,
|
||||
TargetID: targetID,
|
||||
Changes: changesStr,
|
||||
IPAddress: c.ClientIP(),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Status: "success",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
85
webapp-back/admin/invite_codes.go
Normal file
85
webapp-back/admin/invite_codes.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListInviteCodes(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.InviteCode{})
|
||||
if v := c.Query("wallet_address"); v != "" {
|
||||
query = query.Where("wallet_address LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("code"); v != "" {
|
||||
query = query.Where("code LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("is_active"); v != "" {
|
||||
query = query.Where("is_active = ?", v == "true")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.InviteCode
|
||||
if err := query.Order("id DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch invite codes")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func ToggleInviteCode(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.InviteCode
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Invite code not found")
|
||||
return
|
||||
}
|
||||
|
||||
item.IsActive = !item.IsActive
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
}
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
LogOp(c, "update", "invite_code", "toggle_invite_code", &item.ID, gin.H{"is_active": item.IsActive})
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateInviteCode(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.InviteCode
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Invite code not found")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ExpiresAt models.NullTime `json:"expires_at"`
|
||||
}
|
||||
if err := BindJSONFlexTime(c, &body); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
existing.ExpiresAt = body.ExpiresAt
|
||||
if existing.CreatedAt.IsZero() {
|
||||
existing.CreatedAt = time.Now()
|
||||
}
|
||||
if err := db.Save(&existing).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "invite_code", "update_invite_code", &existing.ID, gin.H{"expires_at": body.ExpiresAt})
|
||||
OK(c, existing)
|
||||
}
|
||||
121
webapp-back/admin/liquidation.go
Normal file
121
webapp-back/admin/liquidation.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/lending"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// ── Collateral Buyer Bot ───────────────────────────────────────────────────
|
||||
|
||||
func GetBuyerBotStatus(c *gin.Context) {
|
||||
OK(c, lending.GetBuyerBotStatus())
|
||||
}
|
||||
|
||||
func StartBuyerBot(c *gin.Context) {
|
||||
cfg := appcfg.AppConfig
|
||||
if cfg == nil {
|
||||
Fail(c, http.StatusInternalServerError, "Configuration not loaded")
|
||||
return
|
||||
}
|
||||
if cfg.CollateralBuyerPrivateKey == "" {
|
||||
Fail(c, http.StatusBadRequest, "COLLATERAL_BUYER_PRIVATE_KEY not configured on server")
|
||||
return
|
||||
}
|
||||
lending.StartCollateralBuyerBot(cfg)
|
||||
OK(c, gin.H{"message": "Collateral buyer bot start signal sent"})
|
||||
}
|
||||
|
||||
func StopBuyerBot(c *gin.Context) {
|
||||
lending.StopCollateralBuyerBot()
|
||||
OK(c, gin.H{"message": "Collateral buyer bot stop signal sent"})
|
||||
}
|
||||
|
||||
func ListBuyRecords(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.CollateralBuyRecord{})
|
||||
if v := c.Query("chain_id"); v != "" {
|
||||
if id, err := strconv.Atoi(v); err == nil {
|
||||
query = query.Where("chain_id = ?", id)
|
||||
}
|
||||
}
|
||||
if v := c.Query("status"); v != "" {
|
||||
query = query.Where("status = ?", v)
|
||||
}
|
||||
if v := c.Query("asset_addr"); v != "" {
|
||||
query = query.Where("asset_addr = ?", v)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var records []models.CollateralBuyRecord
|
||||
if err := query.Order("created_at DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&records).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch buy records")
|
||||
return
|
||||
}
|
||||
OKList(c, records, total)
|
||||
}
|
||||
|
||||
// GetLiquidationStatus returns the current liquidation bot status
|
||||
func GetLiquidationStatus(c *gin.Context) {
|
||||
status := lending.GetBotStatus()
|
||||
OK(c, status)
|
||||
}
|
||||
|
||||
// StartLiquidationBot starts the liquidation bot
|
||||
func StartLiquidationBot(c *gin.Context) {
|
||||
cfg := appcfg.AppConfig
|
||||
if cfg == nil {
|
||||
Fail(c, http.StatusInternalServerError, "Configuration not loaded")
|
||||
return
|
||||
}
|
||||
if cfg.LiquidatorPrivateKey == "" {
|
||||
Fail(c, http.StatusBadRequest, "LIQUIDATOR_PRIVATE_KEY not configured on server")
|
||||
return
|
||||
}
|
||||
lending.StartLiquidationBot(cfg)
|
||||
OK(c, gin.H{"message": "Liquidation bot start signal sent"})
|
||||
}
|
||||
|
||||
// StopLiquidationBot stops the liquidation bot
|
||||
func StopLiquidationBot(c *gin.Context) {
|
||||
lending.StopLiquidationBot()
|
||||
OK(c, gin.H{"message": "Liquidation bot stop signal sent"})
|
||||
}
|
||||
|
||||
// ListLiquidationRecords returns paginated liquidation history
|
||||
func ListLiquidationRecords(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.LiquidationRecord{})
|
||||
if v := c.Query("chain_id"); v != "" {
|
||||
if id, err := strconv.Atoi(v); err == nil {
|
||||
query = query.Where("chain_id = ?", id)
|
||||
}
|
||||
}
|
||||
if v := c.Query("status"); v != "" {
|
||||
query = query.Where("status = ?", v)
|
||||
}
|
||||
if v := c.Query("liquidator_addr"); v != "" {
|
||||
query = query.Where("liquidator_addr = ?", v)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var records []models.LiquidationRecord
|
||||
if err := query.Order("created_at DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&records).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch liquidation records")
|
||||
return
|
||||
}
|
||||
OKList(c, records, total)
|
||||
}
|
||||
108
webapp-back/admin/points_rules.go
Normal file
108
webapp-back/admin/points_rules.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListPointsRules(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.PointsRule{})
|
||||
if v := c.Query("rule_name"); v != "" {
|
||||
query = query.Where("rule_name LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("rule_type"); v != "" {
|
||||
query = query.Where("rule_type = ?", v)
|
||||
}
|
||||
if v := c.Query("is_active"); v != "" {
|
||||
query = query.Where("is_active = ?", v == "true")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.PointsRule
|
||||
if err := query.Order("priority DESC, id ASC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch points rules")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetPointsRule(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.PointsRule
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Points rule not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func CreatePointsRule(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.PointsRule
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = 0
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "create", "points_rule", "create_points_rule", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdatePointsRule(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.PointsRule
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Points rule not found")
|
||||
return
|
||||
}
|
||||
var item models.PointsRule
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
if existing.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
} else {
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "points_rule", "update_points_rule", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func DeletePointsRule(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.PointsRule
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Points rule not found")
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "delete", "points_rule", "delete_points_rule", &item.ID, nil)
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
108
webapp-back/admin/product_links.go
Normal file
108
webapp-back/admin/product_links.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListProductLinks(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.ProductLink{})
|
||||
if v := c.Query("asset_id"); v != "" {
|
||||
query = query.Where("asset_id = ?", v)
|
||||
}
|
||||
if v := c.Query("link_text"); v != "" {
|
||||
query = query.Where("link_text LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("is_active"); v != "" {
|
||||
query = query.Where("is_active = ?", v == "true")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.ProductLink
|
||||
if err := query.Order("asset_id ASC, display_order ASC, id ASC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch product links")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetProductLink(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.ProductLink
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Product link not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func CreateProductLink(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.ProductLink
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = 0
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "create", "product_link", "create_product_link", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateProductLink(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.ProductLink
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Product link not found")
|
||||
return
|
||||
}
|
||||
var item models.ProductLink
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
if existing.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
} else {
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "product_link", "update_product_link", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteProductLink(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.ProductLink
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Product link not found")
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "delete", "product_link", "delete_product_link", &item.ID, nil)
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
96
webapp-back/admin/routes.go
Normal file
96
webapp-back/admin/routes.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/middleware"
|
||||
)
|
||||
|
||||
// RegisterRoutes mounts all /api/admin/* routes onto the provided parent group.
|
||||
// Call this BEFORE v1.Use(AuthMiddleware) in main.go so auth is applied here only.
|
||||
func RegisterRoutes(v1 *gin.RouterGroup) {
|
||||
g := v1.Group("/admin")
|
||||
g.Use(middleware.AuthMiddleware(true), middleware.RequireAdmin())
|
||||
{
|
||||
// P0 — Assets
|
||||
g.GET("/assets", ListAssets)
|
||||
g.GET("/assets/:id", GetAsset)
|
||||
g.POST("/assets", CreateAsset)
|
||||
g.PUT("/assets/:id", UpdateAsset)
|
||||
g.DELETE("/assets/:id", DeleteAsset)
|
||||
|
||||
// P0 — Asset Custody
|
||||
g.GET("/asset-custody", ListAssetCustody)
|
||||
g.GET("/asset-custody/:id", GetAssetCustody)
|
||||
g.POST("/asset-custody", CreateAssetCustody)
|
||||
g.PUT("/asset-custody/:id", UpdateAssetCustody)
|
||||
g.DELETE("/asset-custody/:id", DeleteAssetCustody)
|
||||
|
||||
// P0 — Asset Audit Reports
|
||||
g.GET("/asset-audit-reports", ListAssetAuditReports)
|
||||
g.GET("/asset-audit-reports/:id", GetAssetAuditReport)
|
||||
g.POST("/asset-audit-reports", CreateAssetAuditReport)
|
||||
g.PUT("/asset-audit-reports/:id", UpdateAssetAuditReport)
|
||||
g.DELETE("/asset-audit-reports/:id", DeleteAssetAuditReport)
|
||||
|
||||
// P1 — Points Rules
|
||||
g.GET("/points-rules", ListPointsRules)
|
||||
g.GET("/points-rules/:id", GetPointsRule)
|
||||
g.POST("/points-rules", CreatePointsRule)
|
||||
g.PUT("/points-rules/:id", UpdatePointsRule)
|
||||
g.DELETE("/points-rules/:id", DeletePointsRule)
|
||||
|
||||
// P1 — Seasons
|
||||
g.GET("/seasons", ListSeasons)
|
||||
g.GET("/seasons/:id", GetSeason)
|
||||
g.POST("/seasons", CreateSeason)
|
||||
g.PUT("/seasons/:id", UpdateSeason)
|
||||
g.DELETE("/seasons/:id", DeleteSeason)
|
||||
|
||||
// P1 — VIP Tiers
|
||||
g.GET("/vip-tiers", ListVIPTiers)
|
||||
g.GET("/vip-tiers/:id", GetVIPTier)
|
||||
g.POST("/vip-tiers", CreateVIPTier)
|
||||
g.PUT("/vip-tiers/:id", UpdateVIPTier)
|
||||
g.DELETE("/vip-tiers/:id", DeleteVIPTier)
|
||||
|
||||
// P2 — Users (list + edit only)
|
||||
g.GET("/users", ListUsers)
|
||||
g.GET("/users/:wallet", GetUser)
|
||||
g.PUT("/users/:wallet", UpdateUser)
|
||||
|
||||
// P2 — Invite Codes
|
||||
g.GET("/invite-codes", ListInviteCodes)
|
||||
g.PATCH("/invite-codes/:id", UpdateInviteCode)
|
||||
g.PUT("/invite-codes/:id/toggle", ToggleInviteCode)
|
||||
|
||||
// P3 — Product Links (per-asset links on product detail page)
|
||||
g.GET("/product-links", ListProductLinks)
|
||||
g.GET("/product-links/:id", GetProductLink)
|
||||
g.POST("/product-links", CreateProductLink)
|
||||
g.PUT("/product-links/:id", UpdateProductLink)
|
||||
g.DELETE("/product-links/:id", DeleteProductLink)
|
||||
|
||||
// System Contracts (central infrastructure contract registry)
|
||||
g.GET("/system-contracts", ListSystemContracts)
|
||||
g.GET("/system-contracts/:id", GetSystemContract)
|
||||
g.POST("/system-contracts", CreateSystemContract)
|
||||
g.PUT("/system-contracts/:id", UpdateSystemContract)
|
||||
g.DELETE("/system-contracts/:id", DeleteSystemContract)
|
||||
|
||||
// File Upload
|
||||
g.POST("/upload", UploadFile)
|
||||
g.DELETE("/upload", DeleteUploadedFile)
|
||||
|
||||
// Liquidation Bot
|
||||
g.GET("/liquidation/status", GetLiquidationStatus)
|
||||
g.POST("/liquidation/start", StartLiquidationBot)
|
||||
g.POST("/liquidation/stop", StopLiquidationBot)
|
||||
g.GET("/liquidation/records", ListLiquidationRecords)
|
||||
|
||||
// Collateral Buyer Bot
|
||||
g.GET("/buyer/status", GetBuyerBotStatus)
|
||||
g.POST("/buyer/start", StartBuyerBot)
|
||||
g.POST("/buyer/stop", StopBuyerBot)
|
||||
g.GET("/buyer/records", ListBuyRecords)
|
||||
}
|
||||
}
|
||||
108
webapp-back/admin/seasons.go
Normal file
108
webapp-back/admin/seasons.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListSeasons(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.Season{})
|
||||
if v := c.Query("status"); v != "" {
|
||||
query = query.Where("status = ?", v)
|
||||
}
|
||||
if v := c.Query("is_active"); v != "" {
|
||||
query = query.Where("is_active = ?", v == "true")
|
||||
}
|
||||
if v := c.Query("season_name"); v != "" {
|
||||
query = query.Where("season_name LIKE ?", "%"+v+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.Season
|
||||
if err := query.Order("season_number DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch seasons")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetSeason(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.Season
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Season not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func CreateSeason(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.Season
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = 0
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "create", "season", "create_season", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateSeason(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.Season
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Season not found")
|
||||
return
|
||||
}
|
||||
var item models.Season
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
if existing.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
} else {
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "season", "update_season", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteSeason(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.Season
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "Season not found")
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "delete", "season", "delete_season", &item.ID, nil)
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
138
webapp-back/admin/system_contracts.go
Normal file
138
webapp-back/admin/system_contracts.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// ContractEntry is the contract address entry returned to the frontend.
|
||||
type ContractEntry struct {
|
||||
Name string `json:"name"`
|
||||
ChainID int `json:"chain_id"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// GetContracts is a public endpoint that returns all active contract addresses.
|
||||
// Merges infrastructure contracts (system_contracts) and token contracts (assets).
|
||||
func GetContracts(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
|
||||
// Infrastructure contracts from system_contracts
|
||||
var sysContracts []models.SystemContract
|
||||
db.Where("is_active = ?", true).Find(&sysContracts)
|
||||
|
||||
// Token contracts from assets
|
||||
var assets []models.Asset
|
||||
db.Where("is_active = ? AND contract_address != ''", true).Find(&assets)
|
||||
|
||||
entries := make([]ContractEntry, 0, len(sysContracts)+len(assets))
|
||||
for _, sc := range sysContracts {
|
||||
if sc.Address != "" {
|
||||
entries = append(entries, ContractEntry{Name: sc.Name, ChainID: sc.ChainID, Address: sc.Address})
|
||||
}
|
||||
}
|
||||
for _, a := range assets {
|
||||
entries = append(entries, ContractEntry{Name: a.AssetCode, ChainID: a.ChainID, Address: a.ContractAddress})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"contracts": entries})
|
||||
}
|
||||
|
||||
// --- Admin CRUD for system_contracts ---
|
||||
|
||||
func ListSystemContracts(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.SystemContract{})
|
||||
if v := c.Query("name"); v != "" {
|
||||
query = query.Where("name LIKE ?", "%"+v+"%")
|
||||
}
|
||||
if v := c.Query("chain_id"); v != "" {
|
||||
query = query.Where("chain_id = ?", v)
|
||||
}
|
||||
if v := c.Query("is_active"); v != "" {
|
||||
query = query.Where("is_active = ?", v == "true")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.SystemContract
|
||||
if err := query.Order("chain_id ASC, name ASC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch system contracts")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetSystemContract(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.SystemContract
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "System contract not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func CreateSystemContract(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.SystemContract
|
||||
if err := c.ShouldBindJSON(&item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = 0
|
||||
now := time.Now()
|
||||
item.CreatedAt = now
|
||||
item.UpdatedAt = now
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "create", "system_contract", "create_system_contract", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateSystemContract(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.SystemContract
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "System contract not found")
|
||||
return
|
||||
}
|
||||
var item models.SystemContract
|
||||
if err := c.ShouldBindJSON(&item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
item.UpdatedAt = time.Now()
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "system_contract", "update_system_contract", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteSystemContract(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.SystemContract
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "System contract not found")
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "delete", "system_contract", "delete_system_contract", &item.ID, nil)
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
99
webapp-back/admin/upload.go
Normal file
99
webapp-back/admin/upload.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var allowedCategories = map[string]bool{
|
||||
"reports": true,
|
||||
"custody": true,
|
||||
"links": true,
|
||||
}
|
||||
|
||||
var allowedExts = map[string]bool{
|
||||
".pdf": true,
|
||||
".png": true,
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".doc": true,
|
||||
".docx": true,
|
||||
".txt": true,
|
||||
".xls": true,
|
||||
".xlsx": true,
|
||||
}
|
||||
|
||||
// UploadFile handles multipart file uploads, saves to ./uploads/{category}/, returns accessible URL.
|
||||
// Query param: category (reports|custody), defaults to "reports".
|
||||
func UploadFile(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
Fail(c, http.StatusBadRequest, "No file provided")
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
if !allowedExts[ext] {
|
||||
Fail(c, http.StatusBadRequest, "File type not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
category := c.DefaultQuery("category", "reports")
|
||||
if !allowedCategories[category] {
|
||||
Fail(c, http.StatusBadRequest, "Invalid category")
|
||||
return
|
||||
}
|
||||
|
||||
dir := "uploads/" + category
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to create upload directory")
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(file.Filename))
|
||||
dst := filepath.Join(dir, filename)
|
||||
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to save file")
|
||||
return
|
||||
}
|
||||
|
||||
url := "/uploads/" + category + "/" + filename
|
||||
OK(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// DeleteUploadedFile deletes a previously uploaded file by its URL path.
|
||||
func DeleteUploadedFile(c *gin.Context) {
|
||||
urlPath := c.Query("path") // e.g. /uploads/reports/xxx.pdf or /uploads/custody/xxx.pdf
|
||||
if !strings.HasPrefix(urlPath, "/uploads/") {
|
||||
Fail(c, http.StatusBadRequest, "Invalid file path")
|
||||
return
|
||||
}
|
||||
if strings.Contains(urlPath, "..") {
|
||||
Fail(c, http.StatusBadRequest, "Invalid file path")
|
||||
return
|
||||
}
|
||||
// Verify the second segment is a known category
|
||||
parts := strings.SplitN(strings.TrimPrefix(urlPath, "/uploads/"), "/", 2)
|
||||
if len(parts) != 2 || !allowedCategories[parts[0]] {
|
||||
Fail(c, http.StatusBadRequest, "Invalid file path")
|
||||
return
|
||||
}
|
||||
|
||||
localPath := strings.TrimPrefix(urlPath, "/")
|
||||
if err := os.Remove(localPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
OK(c, gin.H{"message": "file not found, skipped"})
|
||||
return
|
||||
}
|
||||
Fail(c, http.StatusInternalServerError, "Failed to delete file")
|
||||
return
|
||||
}
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
98
webapp-back/admin/users.go
Normal file
98
webapp-back/admin/users.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListUsers(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.User{})
|
||||
if v := c.Query("wallet_address"); v != "" {
|
||||
query = query.Where("wallet_address LIKE ?", "%"+strings.ToLower(v)+"%")
|
||||
}
|
||||
if v := c.Query("member_tier"); v != "" {
|
||||
query = query.Where("member_tier = ?", v)
|
||||
}
|
||||
if v := c.Query("nickname"); v != "" {
|
||||
query = query.Where("nickname LIKE ?", "%"+v+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.User
|
||||
if err := query.Order("created_at DESC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch users")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetUser(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
wallet := strings.ToLower(c.Param("wallet"))
|
||||
var item models.User
|
||||
if err := db.Where("wallet_address = ?", wallet).First(&item).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
// UpdateUser allows updating limited fields: nickname, member_tier, vip_level, total_points, global_rank
|
||||
func UpdateUser(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
wallet := strings.ToLower(c.Param("wallet"))
|
||||
|
||||
var existing models.User
|
||||
if err := db.Where("wallet_address = ?", wallet).First(&existing).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Nickname string `json:"nickname"`
|
||||
MemberTier string `json:"member_tier"`
|
||||
VIPLevel int `json:"vip_level"`
|
||||
TotalPoints int64 `json:"total_points"`
|
||||
GlobalRank *int `json:"global_rank"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if payload.Nickname != "" {
|
||||
updates["nickname"] = payload.Nickname
|
||||
}
|
||||
if payload.MemberTier != "" {
|
||||
updates["member_tier"] = payload.MemberTier
|
||||
}
|
||||
if payload.VIPLevel > 0 {
|
||||
updates["vip_level"] = payload.VIPLevel
|
||||
}
|
||||
if payload.TotalPoints >= 0 {
|
||||
updates["total_points"] = payload.TotalPoints
|
||||
}
|
||||
if payload.GlobalRank != nil {
|
||||
updates["global_rank"] = payload.GlobalRank
|
||||
}
|
||||
|
||||
if err := db.Model(&existing).Updates(updates).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Reload updated user
|
||||
db.Where("wallet_address = ?", wallet).First(&existing)
|
||||
LogOp(c, "update", "user", "update_user", nil, updates)
|
||||
OK(c, existing)
|
||||
}
|
||||
105
webapp-back/admin/vip_tiers.go
Normal file
105
webapp-back/admin/vip_tiers.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
func ListVIPTiers(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
p := ParsePagination(c)
|
||||
|
||||
query := db.Model(&models.VIPTier{})
|
||||
if v := c.Query("is_active"); v != "" {
|
||||
query = query.Where("is_active = ?", v == "true")
|
||||
}
|
||||
if v := c.Query("tier_name"); v != "" {
|
||||
query = query.Where("tier_name LIKE ?", "%"+v+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.VIPTier
|
||||
if err := query.Order("tier_level ASC").Offset(p.Offset()).Limit(p.PageSize).Find(&items).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "Failed to fetch VIP tiers")
|
||||
return
|
||||
}
|
||||
OKList(c, items, total)
|
||||
}
|
||||
|
||||
func GetVIPTier(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.VIPTier
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "VIP tier not found")
|
||||
return
|
||||
}
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func CreateVIPTier(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.VIPTier
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = 0
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "create", "vip_tier", "create_vip_tier", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func UpdateVIPTier(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var existing models.VIPTier
|
||||
if err := db.First(&existing, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "VIP tier not found")
|
||||
return
|
||||
}
|
||||
var item models.VIPTier
|
||||
if err := BindJSONFlexTime(c, &item); err != nil {
|
||||
Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
if existing.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
} else {
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
if err := db.Save(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "update", "vip_tier", "update_vip_tier", &item.ID, item)
|
||||
OK(c, item)
|
||||
}
|
||||
|
||||
func DeleteVIPTier(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
var item models.VIPTier
|
||||
if err := db.First(&item, c.Param("id")).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "VIP tier not found")
|
||||
return
|
||||
}
|
||||
if err := db.Delete(&item).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
LogOp(c, "delete", "vip_tier", "delete_vip_tier", &item.ID, nil)
|
||||
OK(c, gin.H{"message": "deleted"})
|
||||
}
|
||||
134
webapp-back/alp/routers.go
Normal file
134
webapp-back/alp/routers.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package alp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// GetALPHistory returns historical APR and price data from alp_snapshots.
|
||||
// Each point is computed from consecutive snapshot pairs.
|
||||
// Query param: days=30 (default 30, max 90)
|
||||
func GetALPHistory(c *gin.Context) {
|
||||
database := db.GetDB()
|
||||
|
||||
days := 30
|
||||
if d := c.Query("days"); d != "" {
|
||||
if v := parseInt(d, 30); v > 0 && v <= 90 {
|
||||
days = v
|
||||
}
|
||||
}
|
||||
|
||||
since := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour)
|
||||
|
||||
var snaps []models.ALPSnapshot
|
||||
if err := database.
|
||||
Where("snapshot_time >= ?", since).
|
||||
Order("snapshot_time ASC").
|
||||
Find(&snaps).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
Time string `json:"time"`
|
||||
TS int64 `json:"ts"`
|
||||
PoolAPR float64 `json:"poolAPR"`
|
||||
ALPPrice float64 `json:"alpPrice"`
|
||||
FeeSurplus float64 `json:"feeSurplus"`
|
||||
PoolValue float64 `json:"poolValue"`
|
||||
}
|
||||
|
||||
// Deduplicate: keep the latest snapshot per calendar day (UTC)
|
||||
dayMap := make(map[string]models.ALPSnapshot)
|
||||
dayOrder := make([]string, 0)
|
||||
for _, snap := range snaps {
|
||||
key := snap.SnapshotTime.UTC().Format("2006-01-02")
|
||||
if _, exists := dayMap[key]; !exists {
|
||||
dayOrder = append(dayOrder, key)
|
||||
}
|
||||
dayMap[key] = snap // overwrite keeps the latest of the day
|
||||
}
|
||||
daily := make([]models.ALPSnapshot, 0, len(dayOrder))
|
||||
for _, key := range dayOrder {
|
||||
daily = append(daily, dayMap[key])
|
||||
}
|
||||
|
||||
points := make([]Point, 0, len(daily))
|
||||
for i, snap := range daily {
|
||||
apr := 0.0
|
||||
if i > 0 {
|
||||
prev := daily[i-1]
|
||||
d := snap.SnapshotTime.Sub(prev.SnapshotTime).Hours() / 24
|
||||
if d > 0 && snap.PoolValue > 0 && snap.FeeSurplus > prev.FeeSurplus {
|
||||
apr = (snap.FeeSurplus-prev.FeeSurplus) / snap.PoolValue / d * 365 * 100
|
||||
}
|
||||
}
|
||||
points = append(points, Point{
|
||||
Time: snap.SnapshotTime.Format("01/02"),
|
||||
TS: snap.SnapshotTime.Unix(),
|
||||
PoolAPR: apr,
|
||||
ALPPrice: snap.ALPPrice,
|
||||
FeeSurplus: snap.FeeSurplus,
|
||||
PoolValue: snap.PoolValue,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": points})
|
||||
}
|
||||
|
||||
func parseInt(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return def
|
||||
}
|
||||
v = v*10 + int(c-'0')
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetALPStats returns current ALP pool APR calculated from snapshots.
|
||||
// TVL and ALP price are read directly from chain on the frontend;
|
||||
// this endpoint only provides the APR which requires historical data.
|
||||
func GetALPStats(c *gin.Context) {
|
||||
database := db.GetDB()
|
||||
|
||||
// Latest snapshot
|
||||
var latest models.ALPSnapshot
|
||||
if err := database.Order("snapshot_time DESC").First(&latest).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"poolAPR": 0.0, "rewardAPR": 0.0},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use the oldest available snapshot as reference (maximises data coverage)
|
||||
var past models.ALPSnapshot
|
||||
database.Order("snapshot_time ASC").First(&past)
|
||||
found := past.ID != 0 && past.ID != latest.ID
|
||||
|
||||
poolAPR := 0.0
|
||||
if found && latest.PoolValue > 0 && past.FeeSurplus < latest.FeeSurplus {
|
||||
days := latest.SnapshotTime.Sub(past.SnapshotTime).Hours() / 24
|
||||
if days > 0 {
|
||||
surplusDelta := latest.FeeSurplus - past.FeeSurplus
|
||||
poolAPR = surplusDelta / latest.PoolValue / days * 365 * 100 // annualized %
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"poolAPR": poolAPR,
|
||||
"rewardAPR": 0.0, // placeholder until reward contract is connected
|
||||
},
|
||||
})
|
||||
}
|
||||
171
webapp-back/alp/snapshot.go
Normal file
171
webapp-back/alp/snapshot.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package alp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
ytPoolManagerAddress = "0xb11824eAA659F8A4648711709dA60720d5Cdabd2"
|
||||
usdyAddress = "0x29774970556407fAE16BC07e87704fE0E9559BC4"
|
||||
alpSnapshotInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
const poolManagerABIJSON = `[
|
||||
{
|
||||
"inputs":[{"internalType":"bool","name":"_maximise","type":"bool"}],
|
||||
"name":"getAumInUsdy",
|
||||
"outputs":[{"internalType":"uint256","name":"","type":"uint256"}],
|
||||
"stateMutability":"view","type":"function"
|
||||
},
|
||||
{
|
||||
"inputs":[{"internalType":"bool","name":"_maximise","type":"bool"}],
|
||||
"name":"getPrice",
|
||||
"outputs":[{"internalType":"uint256","name":"","type":"uint256"}],
|
||||
"stateMutability":"view","type":"function"
|
||||
}
|
||||
]`
|
||||
|
||||
const erc20TotalSupplyABIJSON = `[{
|
||||
"inputs":[],
|
||||
"name":"totalSupply",
|
||||
"outputs":[{"internalType":"uint256","name":"","type":"uint256"}],
|
||||
"stateMutability":"view","type":"function"
|
||||
}]`
|
||||
|
||||
// StartALPSnapshot starts the ALP pool snapshot service (interval: 1h).
|
||||
func StartALPSnapshot(cfg *config.Config) {
|
||||
log.Println("=== ALP Snapshot Service Started (interval: 1h) ===")
|
||||
runALPSnapshot(cfg)
|
||||
ticker := time.NewTicker(alpSnapshotInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runALPSnapshot(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func runALPSnapshot(cfg *config.Config) {
|
||||
start := time.Now()
|
||||
log.Printf("[ALPSnapshot] Starting at %s", start.Format("2006-01-02 15:04:05"))
|
||||
|
||||
client, err := ethclient.Dial(cfg.BSCTestnetRPC)
|
||||
if err != nil {
|
||||
log.Printf("[ALPSnapshot] RPC connect error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
poolValue, alpPrice, err := fetchPoolStats(client)
|
||||
if err != nil {
|
||||
log.Printf("[ALPSnapshot] fetchPoolStats error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
usdySupply, err := fetchUSDYSupply(client)
|
||||
if err != nil {
|
||||
log.Printf("[ALPSnapshot] fetchUSDYSupply error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
feeSurplus := poolValue - usdySupply
|
||||
|
||||
snap := models.ALPSnapshot{
|
||||
PoolValue: poolValue,
|
||||
UsdySupply: usdySupply,
|
||||
FeeSurplus: feeSurplus,
|
||||
ALPPrice: alpPrice,
|
||||
SnapshotTime: time.Now().UTC(),
|
||||
}
|
||||
if err := db.GetDB().Create(&snap).Error; err != nil {
|
||||
log.Printf("[ALPSnapshot] DB save error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[ALPSnapshot] poolValue=%.6f USDY, usdySupply=%.6f, feeSurplus=%.6f, alpPrice=%.8f | done in %v",
|
||||
poolValue, usdySupply, feeSurplus, alpPrice, time.Since(start))
|
||||
}
|
||||
|
||||
func fetchPoolStats(client *ethclient.Client) (poolValue float64, alpPrice float64, err error) {
|
||||
pmABI, err := abi.JSON(strings.NewReader(poolManagerABIJSON))
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("parse poolManager ABI: %w", err)
|
||||
}
|
||||
|
||||
pmAddr := common.HexToAddress(ytPoolManagerAddress)
|
||||
ctx := context.Background()
|
||||
|
||||
// getAumInUsdy(true)
|
||||
callData, err := pmABI.Pack("getAumInUsdy", true)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("pack getAumInUsdy: %w", err)
|
||||
}
|
||||
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &pmAddr, Data: callData}, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("call getAumInUsdy: %w", err)
|
||||
}
|
||||
decoded, err := pmABI.Unpack("getAumInUsdy", result)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return 0, 0, fmt.Errorf("unpack getAumInUsdy: %w", err)
|
||||
}
|
||||
poolValue = bigToFloat(decoded[0].(*big.Int), 18)
|
||||
|
||||
// getPrice(false)
|
||||
callData, err = pmABI.Pack("getPrice", false)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("pack getPrice: %w", err)
|
||||
}
|
||||
result, err = client.CallContract(ctx, ethereum.CallMsg{To: &pmAddr, Data: callData}, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("call getPrice: %w", err)
|
||||
}
|
||||
decoded, err = pmABI.Unpack("getPrice", result)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return 0, 0, fmt.Errorf("unpack getPrice: %w", err)
|
||||
}
|
||||
alpPrice = bigToFloat(decoded[0].(*big.Int), 18)
|
||||
|
||||
return poolValue, alpPrice, nil
|
||||
}
|
||||
|
||||
func fetchUSDYSupply(client *ethclient.Client) (float64, error) {
|
||||
supplyABI, err := abi.JSON(strings.NewReader(erc20TotalSupplyABIJSON))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse totalSupply ABI: %w", err)
|
||||
}
|
||||
|
||||
usdyAddr := common.HexToAddress(usdyAddress)
|
||||
callData, err := supplyABI.Pack("totalSupply")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("pack totalSupply: %w", err)
|
||||
}
|
||||
result, err := client.CallContract(context.Background(), ethereum.CallMsg{To: &usdyAddr, Data: callData}, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("call totalSupply: %w", err)
|
||||
}
|
||||
decoded, err := supplyABI.Unpack("totalSupply", result)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return 0, fmt.Errorf("unpack totalSupply: %w", err)
|
||||
}
|
||||
return bigToFloat(decoded[0].(*big.Int), 18), nil
|
||||
}
|
||||
|
||||
func bigToFloat(n *big.Int, decimals int64) float64 {
|
||||
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(decimals), nil)
|
||||
f, _ := new(big.Float).SetPrec(256).Quo(
|
||||
new(big.Float).SetPrec(256).SetInt(n),
|
||||
new(big.Float).SetPrec(256).SetInt(divisor),
|
||||
).Float64()
|
||||
return f
|
||||
}
|
||||
2014
webapp-back/api/Conduit.postman_collection.json
Normal file
2014
webapp-back/api/Conduit.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
18
webapp-back/api/run-api-tests.sh
Normal file
18
webapp-back/api/run-api-tests.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
|
||||
APIURL=${APIURL:-https://api.realworld.io/api}
|
||||
USERNAME=${USERNAME:-u`date +%s`}
|
||||
EMAIL=${EMAIL:-$USERNAME@mail.com}
|
||||
PASSWORD=${PASSWORD:-password}
|
||||
|
||||
DELAY_REQUEST=${DELAY_REQUEST:-"500"}
|
||||
|
||||
npx newman run $SCRIPTDIR/Conduit.postman_collection.json \
|
||||
--delay-request "$DELAY_REQUEST" \
|
||||
--global-var "APIURL=$APIURL" \
|
||||
--global-var "USERNAME=$USERNAME" \
|
||||
--global-var "EMAIL=$EMAIL" \
|
||||
--global-var "PASSWORD=$PASSWORD" \
|
||||
"$@"
|
||||
12
webapp-back/articles/doc.go
Normal file
12
webapp-back/articles/doc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
The article module containing the article CRUD operation and relationship CRUD.
|
||||
|
||||
model.go: definition of orm based data model
|
||||
|
||||
routers.go: router binding and core logic
|
||||
|
||||
serializers.go: definition the schema of return data
|
||||
|
||||
validators.go: definition the validator of form data
|
||||
*/
|
||||
package articles
|
||||
368
webapp-back/articles/models.go
Normal file
368
webapp-back/articles/models.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ArticleModel struct {
|
||||
gorm.Model
|
||||
Slug string `gorm:"uniqueIndex"`
|
||||
Title string
|
||||
Description string `gorm:"size:2048"`
|
||||
Body string `gorm:"size:2048"`
|
||||
Author ArticleUserModel
|
||||
AuthorID uint
|
||||
Tags []TagModel `gorm:"many2many:article_tags;"`
|
||||
Comments []CommentModel `gorm:"ForeignKey:ArticleID"`
|
||||
}
|
||||
|
||||
type ArticleUserModel struct {
|
||||
gorm.Model
|
||||
UserModel users.UserModel
|
||||
UserModelID uint
|
||||
ArticleModels []ArticleModel `gorm:"ForeignKey:AuthorID"`
|
||||
FavoriteModels []FavoriteModel `gorm:"ForeignKey:FavoriteByID"`
|
||||
}
|
||||
|
||||
type FavoriteModel struct {
|
||||
gorm.Model
|
||||
Favorite ArticleModel
|
||||
FavoriteID uint
|
||||
FavoriteBy ArticleUserModel
|
||||
FavoriteByID uint
|
||||
}
|
||||
|
||||
type TagModel struct {
|
||||
gorm.Model
|
||||
Tag string `gorm:"uniqueIndex"`
|
||||
ArticleModels []ArticleModel `gorm:"many2many:article_tags;"`
|
||||
}
|
||||
|
||||
type CommentModel struct {
|
||||
gorm.Model
|
||||
Article ArticleModel
|
||||
ArticleID uint
|
||||
Author ArticleUserModel
|
||||
AuthorID uint
|
||||
Body string `gorm:"size:2048"`
|
||||
}
|
||||
|
||||
func GetArticleUserModel(userModel users.UserModel) ArticleUserModel {
|
||||
var articleUserModel ArticleUserModel
|
||||
if userModel.ID == 0 {
|
||||
return articleUserModel
|
||||
}
|
||||
db := common.GetDB()
|
||||
db.Where(&ArticleUserModel{
|
||||
UserModelID: userModel.ID,
|
||||
}).FirstOrCreate(&articleUserModel)
|
||||
articleUserModel.UserModel = userModel
|
||||
return articleUserModel
|
||||
}
|
||||
|
||||
func (article ArticleModel) favoritesCount() uint {
|
||||
db := common.GetDB()
|
||||
var count int64
|
||||
db.Model(&FavoriteModel{}).Where(FavoriteModel{
|
||||
FavoriteID: article.ID,
|
||||
}).Count(&count)
|
||||
return uint(count)
|
||||
}
|
||||
|
||||
func (article ArticleModel) isFavoriteBy(user ArticleUserModel) bool {
|
||||
db := common.GetDB()
|
||||
var favorite FavoriteModel
|
||||
db.Where(FavoriteModel{
|
||||
FavoriteID: article.ID,
|
||||
FavoriteByID: user.ID,
|
||||
}).First(&favorite)
|
||||
return favorite.ID != 0
|
||||
}
|
||||
|
||||
// BatchGetFavoriteCounts returns a map of article ID to favorite count
|
||||
func BatchGetFavoriteCounts(articleIDs []uint) map[uint]uint {
|
||||
if len(articleIDs) == 0 {
|
||||
return make(map[uint]uint)
|
||||
}
|
||||
db := common.GetDB()
|
||||
|
||||
type result struct {
|
||||
FavoriteID uint
|
||||
Count uint
|
||||
}
|
||||
var results []result
|
||||
db.Model(&FavoriteModel{}).
|
||||
Select("favorite_id, COUNT(*) as count").
|
||||
Where("favorite_id IN ?", articleIDs).
|
||||
Group("favorite_id").
|
||||
Find(&results)
|
||||
|
||||
countMap := make(map[uint]uint)
|
||||
for _, r := range results {
|
||||
countMap[r.FavoriteID] = r.Count
|
||||
}
|
||||
return countMap
|
||||
}
|
||||
|
||||
// BatchGetFavoriteStatus returns a map of article ID to whether the user favorited it
|
||||
func BatchGetFavoriteStatus(articleIDs []uint, userID uint) map[uint]bool {
|
||||
if len(articleIDs) == 0 || userID == 0 {
|
||||
return make(map[uint]bool)
|
||||
}
|
||||
db := common.GetDB()
|
||||
|
||||
var favorites []FavoriteModel
|
||||
db.Where("favorite_id IN ? AND favorite_by_id = ?", articleIDs, userID).Find(&favorites)
|
||||
|
||||
statusMap := make(map[uint]bool)
|
||||
for _, f := range favorites {
|
||||
statusMap[f.FavoriteID] = true
|
||||
}
|
||||
return statusMap
|
||||
}
|
||||
|
||||
func (article ArticleModel) favoriteBy(user ArticleUserModel) error {
|
||||
db := common.GetDB()
|
||||
var favorite FavoriteModel
|
||||
err := db.FirstOrCreate(&favorite, &FavoriteModel{
|
||||
FavoriteID: article.ID,
|
||||
FavoriteByID: user.ID,
|
||||
}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (article ArticleModel) unFavoriteBy(user ArticleUserModel) error {
|
||||
db := common.GetDB()
|
||||
err := db.Where("favorite_id = ? AND favorite_by_id = ?", article.ID, user.ID).Delete(&FavoriteModel{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func SaveOne(data interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Save(data).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func FindOneArticle(condition interface{}) (ArticleModel, error) {
|
||||
db := common.GetDB()
|
||||
var model ArticleModel
|
||||
err := db.Preload("Author.UserModel").Preload("Tags").Where(condition).First(&model).Error
|
||||
return model, err
|
||||
}
|
||||
|
||||
func FindOneComment(condition *CommentModel) (CommentModel, error) {
|
||||
db := common.GetDB()
|
||||
var model CommentModel
|
||||
err := db.Preload("Author.UserModel").Preload("Article").Where(condition).First(&model).Error
|
||||
return model, err
|
||||
}
|
||||
|
||||
func (self *ArticleModel) getComments() error {
|
||||
db := common.GetDB()
|
||||
err := db.Preload("Author.UserModel").Model(self).Association("Comments").Find(&self.Comments)
|
||||
return err
|
||||
}
|
||||
|
||||
func getAllTags() ([]TagModel, error) {
|
||||
db := common.GetDB()
|
||||
var models []TagModel
|
||||
err := db.Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
func FindManyArticle(tag, author, limit, offset, favorited string) ([]ArticleModel, int, error) {
|
||||
db := common.GetDB()
|
||||
var models []ArticleModel
|
||||
var count int
|
||||
|
||||
offset_int, errOffset := strconv.Atoi(offset)
|
||||
if errOffset != nil {
|
||||
offset_int = 0
|
||||
}
|
||||
|
||||
limit_int, errLimit := strconv.Atoi(limit)
|
||||
if errLimit != nil {
|
||||
limit_int = 20
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if tag != "" {
|
||||
var tagModel TagModel
|
||||
tx.Where(TagModel{Tag: tag}).First(&tagModel)
|
||||
if tagModel.ID != 0 {
|
||||
// Get article IDs via association
|
||||
var tempModels []ArticleModel
|
||||
if err := tx.Model(&tagModel).Offset(offset_int).Limit(limit_int).Association("ArticleModels").Find(&tempModels); err != nil {
|
||||
tx.Rollback()
|
||||
return models, count, err
|
||||
}
|
||||
count = int(tx.Model(&tagModel).Association("ArticleModels").Count())
|
||||
// Fetch articles with preloaded associations in single query, ordered by updated_at desc
|
||||
if len(tempModels) > 0 {
|
||||
var ids []uint
|
||||
for _, m := range tempModels {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
|
||||
}
|
||||
}
|
||||
} else if author != "" {
|
||||
var userModel users.UserModel
|
||||
tx.Where(users.UserModel{Username: author}).First(&userModel)
|
||||
articleUserModel := GetArticleUserModel(userModel)
|
||||
|
||||
if articleUserModel.ID != 0 {
|
||||
count = int(tx.Model(&articleUserModel).Association("ArticleModels").Count())
|
||||
// Get article IDs via association
|
||||
var tempModels []ArticleModel
|
||||
if err := tx.Model(&articleUserModel).Offset(offset_int).Limit(limit_int).Association("ArticleModels").Find(&tempModels); err != nil {
|
||||
tx.Rollback()
|
||||
return models, count, err
|
||||
}
|
||||
// Fetch articles with preloaded associations in single query, ordered by updated_at desc
|
||||
if len(tempModels) > 0 {
|
||||
var ids []uint
|
||||
for _, m := range tempModels {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
|
||||
}
|
||||
}
|
||||
} else if favorited != "" {
|
||||
var userModel users.UserModel
|
||||
tx.Where(users.UserModel{Username: favorited}).First(&userModel)
|
||||
articleUserModel := GetArticleUserModel(userModel)
|
||||
if articleUserModel.ID != 0 {
|
||||
var favoriteModels []FavoriteModel
|
||||
tx.Where(FavoriteModel{
|
||||
FavoriteByID: articleUserModel.ID,
|
||||
}).Offset(offset_int).Limit(limit_int).Find(&favoriteModels)
|
||||
|
||||
count = int(tx.Model(&articleUserModel).Association("FavoriteModels").Count())
|
||||
// Batch fetch articles to avoid N+1 query
|
||||
if len(favoriteModels) > 0 {
|
||||
var ids []uint
|
||||
for _, favorite := range favoriteModels {
|
||||
ids = append(ids, favorite.FavoriteID)
|
||||
}
|
||||
tx.Preload("Author.UserModel").Preload("Tags").Where("id IN ?", ids).Order("updated_at desc").Find(&models)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var count64 int64
|
||||
tx.Model(&ArticleModel{}).Count(&count64)
|
||||
count = int(count64)
|
||||
tx.Offset(offset_int).Limit(limit_int).Preload("Author.UserModel").Preload("Tags").Find(&models)
|
||||
}
|
||||
|
||||
err := tx.Commit().Error
|
||||
return models, count, err
|
||||
}
|
||||
|
||||
func (self *ArticleUserModel) GetArticleFeed(limit, offset string) ([]ArticleModel, int, error) {
|
||||
db := common.GetDB()
|
||||
models := make([]ArticleModel, 0)
|
||||
var count int
|
||||
|
||||
offset_int, errOffset := strconv.Atoi(offset)
|
||||
if errOffset != nil {
|
||||
offset_int = 0
|
||||
}
|
||||
limit_int, errLimit := strconv.Atoi(limit)
|
||||
if errLimit != nil {
|
||||
limit_int = 20
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
followings := self.UserModel.GetFollowings()
|
||||
|
||||
// Batch get ArticleUserModel IDs to avoid N+1 query
|
||||
if len(followings) > 0 {
|
||||
var followingUserIDs []uint
|
||||
for _, following := range followings {
|
||||
followingUserIDs = append(followingUserIDs, following.ID)
|
||||
}
|
||||
|
||||
var articleUserModels []ArticleUserModel
|
||||
tx.Where("user_model_id IN ?", followingUserIDs).Find(&articleUserModels)
|
||||
|
||||
var authorIDs []uint
|
||||
for _, aum := range articleUserModels {
|
||||
authorIDs = append(authorIDs, aum.ID)
|
||||
}
|
||||
|
||||
if len(authorIDs) > 0 {
|
||||
var count64 int64
|
||||
tx.Model(&ArticleModel{}).Where("author_id IN ?", authorIDs).Count(&count64)
|
||||
count = int(count64)
|
||||
tx.Preload("Author.UserModel").Preload("Tags").Where("author_id IN ?", authorIDs).Order("updated_at desc").Offset(offset_int).Limit(limit_int).Find(&models)
|
||||
}
|
||||
}
|
||||
|
||||
err := tx.Commit().Error
|
||||
return models, count, err
|
||||
}
|
||||
|
||||
func (model *ArticleModel) setTags(tags []string) error {
|
||||
if len(tags) == 0 {
|
||||
model.Tags = []TagModel{}
|
||||
return nil
|
||||
}
|
||||
|
||||
db := common.GetDB()
|
||||
|
||||
// Batch fetch existing tags
|
||||
var existingTags []TagModel
|
||||
db.Where("tag IN ?", tags).Find(&existingTags)
|
||||
|
||||
// Create a map for quick lookup
|
||||
existingTagMap := make(map[string]TagModel)
|
||||
for _, t := range existingTags {
|
||||
existingTagMap[t.Tag] = t
|
||||
}
|
||||
|
||||
// Create missing tags and build final list
|
||||
var tagList []TagModel
|
||||
for _, tag := range tags {
|
||||
if existing, ok := existingTagMap[tag]; ok {
|
||||
tagList = append(tagList, existing)
|
||||
} else {
|
||||
// Create new tag with race condition handling
|
||||
newTag := TagModel{Tag: tag}
|
||||
if err := db.Create(&newTag).Error; err != nil {
|
||||
// If creation failed (e.g., concurrent insert), try to fetch existing
|
||||
var existing TagModel
|
||||
if err2 := db.Where("tag = ?", tag).First(&existing).Error; err2 == nil {
|
||||
tagList = append(tagList, existing)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
tagList = append(tagList, newTag)
|
||||
}
|
||||
}
|
||||
model.Tags = tagList
|
||||
return nil
|
||||
}
|
||||
|
||||
func (model *ArticleModel) Update(data interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Model(model).Updates(data).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteArticleModel(condition interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Where(condition).Delete(&ArticleModel{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteCommentModel(condition interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Where(condition).Delete(&CommentModel{}).Error
|
||||
return err
|
||||
}
|
||||
251
webapp-back/articles/routers.go
Normal file
251
webapp-back/articles/routers.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func ArticlesRegister(router *gin.RouterGroup) {
|
||||
router.GET("/feed", ArticleFeed)
|
||||
router.POST("", ArticleCreate)
|
||||
router.POST("/", ArticleCreate)
|
||||
router.PUT("/:slug", ArticleUpdate)
|
||||
router.PUT("/:slug/", ArticleUpdate)
|
||||
router.DELETE("/:slug", ArticleDelete)
|
||||
router.POST("/:slug/favorite", ArticleFavorite)
|
||||
router.DELETE("/:slug/favorite", ArticleUnfavorite)
|
||||
router.POST("/:slug/comments", ArticleCommentCreate)
|
||||
router.DELETE("/:slug/comments/:id", ArticleCommentDelete)
|
||||
}
|
||||
|
||||
func ArticlesAnonymousRegister(router *gin.RouterGroup) {
|
||||
router.GET("", ArticleList)
|
||||
router.GET("/", ArticleList)
|
||||
router.GET("/:slug", ArticleRetrieve)
|
||||
router.GET("/:slug/comments", ArticleCommentList)
|
||||
}
|
||||
|
||||
func TagsAnonymousRegister(router *gin.RouterGroup) {
|
||||
router.GET("", TagList)
|
||||
router.GET("/", TagList)
|
||||
}
|
||||
|
||||
func ArticleCreate(c *gin.Context) {
|
||||
articleModelValidator := NewArticleModelValidator()
|
||||
if err := articleModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
//fmt.Println(articleModelValidator.articleModel.Author.UserModel)
|
||||
|
||||
if err := SaveOne(&articleModelValidator.articleModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModelValidator.articleModel}
|
||||
c.JSON(http.StatusCreated, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleList(c *gin.Context) {
|
||||
//condition := ArticleModel{}
|
||||
tag := c.Query("tag")
|
||||
author := c.Query("author")
|
||||
favorited := c.Query("favorited")
|
||||
limit := c.Query("limit")
|
||||
offset := c.Query("offset")
|
||||
articleModels, modelCount, err := FindManyArticle(tag, author, limit, offset, favorited)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
|
||||
return
|
||||
}
|
||||
serializer := ArticlesSerializer{c, articleModels}
|
||||
c.JSON(http.StatusOK, gin.H{"articles": serializer.Response(), "articlesCount": modelCount})
|
||||
}
|
||||
|
||||
func ArticleFeed(c *gin.Context) {
|
||||
limit := c.Query("limit")
|
||||
offset := c.Query("offset")
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
if myUserModel.ID == 0 {
|
||||
c.AbortWithError(http.StatusUnauthorized, errors.New("{error : \"Require auth!\"}"))
|
||||
return
|
||||
}
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
articleModels, modelCount, err := articleUserModel.GetArticleFeed(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
|
||||
return
|
||||
}
|
||||
serializer := ArticlesSerializer{c, articleModels}
|
||||
c.JSON(http.StatusOK, gin.H{"articles": serializer.Response(), "articlesCount": modelCount})
|
||||
}
|
||||
|
||||
func ArticleRetrieve(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModel}
|
||||
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleUpdate(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
// Check if current user is the author
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
if articleModel.AuthorID != articleUserModel.ID {
|
||||
c.JSON(http.StatusForbidden, common.NewError("article", errors.New("you are not the author")))
|
||||
return
|
||||
}
|
||||
|
||||
articleModelValidator := NewArticleModelValidatorFillWith(articleModel)
|
||||
if err := articleModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
|
||||
articleModelValidator.articleModel.ID = articleModel.ID
|
||||
if err := articleModel.Update(articleModelValidator.articleModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModel}
|
||||
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleDelete(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err == nil {
|
||||
// Article exists, check authorization
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
if articleModel.AuthorID != articleUserModel.ID {
|
||||
c.JSON(http.StatusForbidden, common.NewError("article", errors.New("you are not the author")))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Delete regardless of existence (idempotent)
|
||||
if err := DeleteArticleModel(&ArticleModel{Slug: slug}); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"article": "delete success"})
|
||||
}
|
||||
|
||||
func ArticleFavorite(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
if err = articleModel.favoriteBy(GetArticleUserModel(myUserModel)); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModel}
|
||||
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleUnfavorite(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
if err = articleModel.unFavoriteBy(GetArticleUserModel(myUserModel)); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ArticleSerializer{c, articleModel}
|
||||
c.JSON(http.StatusOK, gin.H{"article": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleCommentCreate(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("comment", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
commentModelValidator := NewCommentModelValidator()
|
||||
if err := commentModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
commentModelValidator.commentModel.Article = articleModel
|
||||
|
||||
if err := SaveOne(&commentModelValidator.commentModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := CommentSerializer{c, commentModelValidator.commentModel}
|
||||
c.JSON(http.StatusCreated, gin.H{"comment": serializer.Response()})
|
||||
}
|
||||
|
||||
func ArticleCommentDelete(c *gin.Context) {
|
||||
id64, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("comment", errors.New("Invalid id")))
|
||||
return
|
||||
}
|
||||
id := uint(id64)
|
||||
commentModel, err := FindOneComment(&CommentModel{Model: gorm.Model{ID: id}})
|
||||
if err == nil {
|
||||
// Comment exists, check authorization
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
if commentModel.AuthorID != articleUserModel.ID {
|
||||
c.JSON(http.StatusForbidden, common.NewError("comment", errors.New("you are not the author")))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Delete regardless of existence (idempotent)
|
||||
if err := DeleteCommentModel([]uint{id}); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comment": "delete success"})
|
||||
}
|
||||
|
||||
func ArticleCommentList(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
articleModel, err := FindOneArticle(&ArticleModel{Slug: slug})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("comments", errors.New("Invalid slug")))
|
||||
return
|
||||
}
|
||||
err = articleModel.getComments()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("comments", errors.New("Database error")))
|
||||
return
|
||||
}
|
||||
serializer := CommentsSerializer{c, articleModel.Comments}
|
||||
c.JSON(http.StatusOK, gin.H{"comments": serializer.Response()})
|
||||
}
|
||||
func TagList(c *gin.Context) {
|
||||
tagModels, err := getAllTags()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("articles", errors.New("Invalid param")))
|
||||
return
|
||||
}
|
||||
serializer := TagsSerializer{c, tagModels}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": serializer.Response()})
|
||||
}
|
||||
180
webapp-back/articles/serializers.go
Normal file
180
webapp-back/articles/serializers.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
)
|
||||
|
||||
type TagSerializer struct {
|
||||
C *gin.Context
|
||||
TagModel
|
||||
}
|
||||
|
||||
type TagsSerializer struct {
|
||||
C *gin.Context
|
||||
Tags []TagModel
|
||||
}
|
||||
|
||||
func (s *TagSerializer) Response() string {
|
||||
return s.TagModel.Tag
|
||||
}
|
||||
|
||||
func (s *TagsSerializer) Response() []string {
|
||||
response := []string{}
|
||||
for _, tag := range s.Tags {
|
||||
serializer := TagSerializer{C: s.C, TagModel: tag}
|
||||
response = append(response, serializer.Response())
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
type ArticleUserSerializer struct {
|
||||
C *gin.Context
|
||||
ArticleUserModel
|
||||
}
|
||||
|
||||
func (s *ArticleUserSerializer) Response() users.ProfileResponse {
|
||||
response := users.ProfileSerializer{C: s.C, UserModel: s.ArticleUserModel.UserModel}
|
||||
return response.Response()
|
||||
}
|
||||
|
||||
type ArticleSerializer struct {
|
||||
C *gin.Context
|
||||
ArticleModel
|
||||
}
|
||||
|
||||
type ArticleResponse struct {
|
||||
ID uint `json:"-"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Author users.ProfileResponse `json:"author"`
|
||||
Tags []string `json:"tagList"`
|
||||
Favorite bool `json:"favorited"`
|
||||
FavoritesCount uint `json:"favoritesCount"`
|
||||
}
|
||||
|
||||
type ArticlesSerializer struct {
|
||||
C *gin.Context
|
||||
Articles []ArticleModel
|
||||
}
|
||||
|
||||
func (s *ArticleSerializer) Response() ArticleResponse {
|
||||
myUserModel := s.C.MustGet("my_user_model").(users.UserModel)
|
||||
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
|
||||
response := ArticleResponse{
|
||||
ID: s.ID,
|
||||
Slug: s.Slug,
|
||||
Title: s.Title,
|
||||
Description: s.Description,
|
||||
Body: s.Body,
|
||||
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
//UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
Author: authorSerializer.Response(),
|
||||
Favorite: s.isFavoriteBy(GetArticleUserModel(myUserModel)),
|
||||
FavoritesCount: s.favoritesCount(),
|
||||
}
|
||||
response.Tags = make([]string, 0)
|
||||
for _, tag := range s.Tags {
|
||||
serializer := TagSerializer{C: s.C, TagModel: tag}
|
||||
response.Tags = append(response.Tags, serializer.Response())
|
||||
}
|
||||
sort.Strings(response.Tags)
|
||||
return response
|
||||
}
|
||||
|
||||
// ResponseWithPreloaded creates response using preloaded favorite data to avoid N+1 queries
|
||||
func (s *ArticleSerializer) ResponseWithPreloaded(favorited bool, favoritesCount uint) ArticleResponse {
|
||||
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
|
||||
response := ArticleResponse{
|
||||
ID: s.ID,
|
||||
Slug: s.Slug,
|
||||
Title: s.Title,
|
||||
Description: s.Description,
|
||||
Body: s.Body,
|
||||
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
Author: authorSerializer.Response(),
|
||||
Favorite: favorited,
|
||||
FavoritesCount: favoritesCount,
|
||||
}
|
||||
response.Tags = make([]string, 0)
|
||||
for _, tag := range s.Tags {
|
||||
serializer := TagSerializer{C: s.C, TagModel: tag}
|
||||
response.Tags = append(response.Tags, serializer.Response())
|
||||
}
|
||||
sort.Strings(response.Tags)
|
||||
return response
|
||||
}
|
||||
|
||||
func (s *ArticlesSerializer) Response() []ArticleResponse {
|
||||
response := []ArticleResponse{}
|
||||
if len(s.Articles) == 0 {
|
||||
return response
|
||||
}
|
||||
|
||||
// Batch fetch favorite counts and status
|
||||
var articleIDs []uint
|
||||
for _, article := range s.Articles {
|
||||
articleIDs = append(articleIDs, article.ID)
|
||||
}
|
||||
|
||||
favoriteCounts := BatchGetFavoriteCounts(articleIDs)
|
||||
|
||||
myUserModel := s.C.MustGet("my_user_model").(users.UserModel)
|
||||
articleUserModel := GetArticleUserModel(myUserModel)
|
||||
favoriteStatus := BatchGetFavoriteStatus(articleIDs, articleUserModel.ID)
|
||||
|
||||
for _, article := range s.Articles {
|
||||
serializer := ArticleSerializer{C: s.C, ArticleModel: article}
|
||||
favorited := favoriteStatus[article.ID]
|
||||
count := favoriteCounts[article.ID]
|
||||
response = append(response, serializer.ResponseWithPreloaded(favorited, count))
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
type CommentSerializer struct {
|
||||
C *gin.Context
|
||||
CommentModel
|
||||
}
|
||||
|
||||
type CommentsSerializer struct {
|
||||
C *gin.Context
|
||||
Comments []CommentModel
|
||||
}
|
||||
|
||||
type CommentResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Author users.ProfileResponse `json:"author"`
|
||||
}
|
||||
|
||||
func (s *CommentSerializer) Response() CommentResponse {
|
||||
authorSerializer := ArticleUserSerializer{C: s.C, ArticleUserModel: s.Author}
|
||||
response := CommentResponse{
|
||||
ID: s.ID,
|
||||
Body: s.Body,
|
||||
CreatedAt: s.CreatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999Z"),
|
||||
Author: authorSerializer.Response(),
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func (s *CommentsSerializer) Response() []CommentResponse {
|
||||
response := []CommentResponse{}
|
||||
for _, comment := range s.Comments {
|
||||
serializer := CommentSerializer{C: s.C, CommentModel: comment}
|
||||
response = append(response, serializer.Response())
|
||||
}
|
||||
return response
|
||||
}
|
||||
1605
webapp-back/articles/unit_test.go
Normal file
1605
webapp-back/articles/unit_test.go
Normal file
File diff suppressed because it is too large
Load Diff
72
webapp-back/articles/validators.go
Normal file
72
webapp-back/articles/validators.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package articles
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gosimple/slug"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
)
|
||||
|
||||
type ArticleModelValidator struct {
|
||||
Article struct {
|
||||
Title string `form:"title" json:"title" binding:"required,min=4"`
|
||||
Description string `form:"description" json:"description" binding:"required,max=2048"`
|
||||
Body string `form:"body" json:"body" binding:"required,max=2048"`
|
||||
Tags []string `form:"tagList" json:"tagList"`
|
||||
} `json:"article"`
|
||||
articleModel ArticleModel `json:"-"`
|
||||
}
|
||||
|
||||
func NewArticleModelValidator() ArticleModelValidator {
|
||||
return ArticleModelValidator{}
|
||||
}
|
||||
|
||||
func NewArticleModelValidatorFillWith(articleModel ArticleModel) ArticleModelValidator {
|
||||
articleModelValidator := NewArticleModelValidator()
|
||||
articleModelValidator.Article.Title = articleModel.Title
|
||||
articleModelValidator.Article.Description = articleModel.Description
|
||||
articleModelValidator.Article.Body = articleModel.Body
|
||||
for _, tagModel := range articleModel.Tags {
|
||||
articleModelValidator.Article.Tags = append(articleModelValidator.Article.Tags, tagModel.Tag)
|
||||
}
|
||||
return articleModelValidator
|
||||
}
|
||||
|
||||
func (s *ArticleModelValidator) Bind(c *gin.Context) error {
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
|
||||
err := common.Bind(c, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.articleModel.Slug = slug.Make(s.Article.Title)
|
||||
s.articleModel.Title = s.Article.Title
|
||||
s.articleModel.Description = s.Article.Description
|
||||
s.articleModel.Body = s.Article.Body
|
||||
s.articleModel.Author = GetArticleUserModel(myUserModel)
|
||||
s.articleModel.setTags(s.Article.Tags)
|
||||
return nil
|
||||
}
|
||||
|
||||
type CommentModelValidator struct {
|
||||
Comment struct {
|
||||
Body string `form:"body" json:"body" binding:"required,max=2048"`
|
||||
} `json:"comment"`
|
||||
commentModel CommentModel `json:"-"`
|
||||
}
|
||||
|
||||
func NewCommentModelValidator() CommentModelValidator {
|
||||
return CommentModelValidator{}
|
||||
}
|
||||
|
||||
func (s *CommentModelValidator) Bind(c *gin.Context) error {
|
||||
myUserModel := c.MustGet("my_user_model").(users.UserModel)
|
||||
|
||||
err := common.Bind(c, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.commentModel.Body = s.Comment.Body
|
||||
s.commentModel.Author = GetArticleUserModel(myUserModel)
|
||||
return nil
|
||||
}
|
||||
BIN
webapp-back/bin/api-server
Normal file
BIN
webapp-back/bin/api-server
Normal file
Binary file not shown.
61
webapp-back/bin/check_db_addresses.go
Normal file
61
webapp-back/bin/check_db_addresses.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
)
|
||||
|
||||
type Asset struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
AssetCode string `gorm:"column:asset_code"`
|
||||
Name string `gorm:"column:name"`
|
||||
ContractAddress string `gorm:"column:contract_address"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize database
|
||||
if cfg.DBType == "mysql" {
|
||||
common.InitMySQL()
|
||||
} else {
|
||||
common.Init()
|
||||
}
|
||||
|
||||
db := common.GetDB()
|
||||
|
||||
// Query YT assets
|
||||
var assets []Asset
|
||||
err := db.Table("assets").
|
||||
Where("asset_code IN (?, ?, ?)", "YT-A", "YT-B", "YT-C").
|
||||
Find(&assets).Error
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to query assets: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("=== 数据库中的 YT 合约地址 ===")
|
||||
for _, asset := range assets {
|
||||
fmt.Printf("%s: %s\n", asset.AssetCode, asset.ContractAddress)
|
||||
}
|
||||
|
||||
// Query YTLPToken from system_contracts
|
||||
var ytlp struct {
|
||||
Name string `gorm:"column:name"`
|
||||
Address string `gorm:"column:address"`
|
||||
}
|
||||
err = db.Table("system_contracts").
|
||||
Where("name = ? AND is_active = ?", "YTLPToken", 1).
|
||||
Select("name, address").
|
||||
First(&ytlp).Error
|
||||
if err != nil {
|
||||
log.Printf("Failed to query YTLPToken: %v", err)
|
||||
} else {
|
||||
fmt.Println("\n=== ytLP 地址 ===")
|
||||
fmt.Printf("%s: %s\n", ytlp.Name, ytlp.Address)
|
||||
}
|
||||
|
||||
}
|
||||
BIN
webapp-back/bin/holder-scanner
Normal file
BIN
webapp-back/bin/holder-scanner
Normal file
Binary file not shown.
113
webapp-back/common/database.go
Normal file
113
webapp-back/common/database.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// GetDBPath returns the database path from environment or default.
|
||||
// Exported for use in tests.
|
||||
func GetDBPath() string {
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./data/gorm.db"
|
||||
}
|
||||
return dbPath
|
||||
}
|
||||
|
||||
// GetTestDBPath returns the test database path from environment or default.
|
||||
// Exported for use in tests.
|
||||
func GetTestDBPath() string {
|
||||
testDBPath := os.Getenv("TEST_DB_PATH")
|
||||
if testDBPath == "" {
|
||||
testDBPath = "./data/gorm_test.db"
|
||||
}
|
||||
return testDBPath
|
||||
}
|
||||
|
||||
// ensureDir creates the directory for the database file if it doesn't exist
|
||||
func ensureDir(filePath string) error {
|
||||
dir := filepath.Dir(filePath)
|
||||
if dir != "" && dir != "." {
|
||||
return os.MkdirAll(dir, 0750)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Opening a database and save the reference to `Database` struct.
|
||||
func Init() *gorm.DB {
|
||||
dbPath := GetDBPath()
|
||||
|
||||
// Ensure the directory exists
|
||||
if err := ensureDir(dbPath); err != nil {
|
||||
fmt.Println("db err: (Init - create dir) ", err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Println("db err: (Init) ", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
fmt.Println("db err: (Init - get sql.DB) ", err)
|
||||
} else {
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
}
|
||||
DB = db
|
||||
return DB
|
||||
}
|
||||
|
||||
// This function will create a temporarily database for running testing cases
|
||||
func TestDBInit() *gorm.DB {
|
||||
testDBPath := GetTestDBPath()
|
||||
|
||||
// Ensure the directory exists
|
||||
if err := ensureDir(testDBPath); err != nil {
|
||||
fmt.Println("db err: (TestDBInit - create dir) ", err)
|
||||
}
|
||||
|
||||
test_db, err := gorm.Open(sqlite.Open(testDBPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("db err: (TestDBInit) ", err)
|
||||
}
|
||||
sqlDB, err := test_db.DB()
|
||||
if err != nil {
|
||||
fmt.Println("db err: (TestDBInit - get sql.DB) ", err)
|
||||
} else {
|
||||
sqlDB.SetMaxIdleConns(3)
|
||||
}
|
||||
DB = test_db
|
||||
return DB
|
||||
}
|
||||
|
||||
// Delete the database after running testing cases.
|
||||
func TestDBFree(test_db *gorm.DB) error {
|
||||
sqlDB, err := test_db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
testDBPath := GetTestDBPath()
|
||||
err = os.Remove(testDBPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Using this function to get a connection, you can create your connection pool here.
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
64
webapp-back/common/database_mysql.go
Normal file
64
webapp-back/common/database_mysql.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// InitMySQL initializes MySQL database connection
|
||||
func InitMySQL() *gorm.DB {
|
||||
cfg := config.AppConfig
|
||||
if cfg == nil {
|
||||
log.Fatal("Config not loaded. Please call config.Load() first")
|
||||
}
|
||||
|
||||
// Build DSN
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
cfg.DBUser,
|
||||
cfg.DBPassword,
|
||||
cfg.DBHost,
|
||||
cfg.DBPort,
|
||||
cfg.DBName,
|
||||
)
|
||||
|
||||
// Open database connection
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to MySQL:", err)
|
||||
}
|
||||
|
||||
// Get underlying sql.DB
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get sql.DB:", err)
|
||||
}
|
||||
|
||||
// Set connection pool parameters
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
// Test connection
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
log.Fatal("Failed to ping MySQL:", err)
|
||||
}
|
||||
|
||||
log.Println("✓ MySQL connected successfully")
|
||||
DB = db
|
||||
return DB
|
||||
}
|
||||
|
||||
// InitRedis initializes Redis client (you'll need to add redis package)
|
||||
func InitRedis() {
|
||||
// TODO: Add Redis initialization
|
||||
// You'll need to add github.com/redis/go-redis/v9
|
||||
log.Println("✓ Redis initialization skipped (add go-redis package)")
|
||||
}
|
||||
35
webapp-back/common/test_helpers.go
Normal file
35
webapp-back/common/test_helpers.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// HeaderTokenMock adds authorization token to request header for testing
|
||||
func HeaderTokenMock(req *http.Request, u uint) {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Token %v", GenToken(u)))
|
||||
}
|
||||
|
||||
// ExtractTokenFromHeader extracts JWT token from Authorization header
|
||||
// Used for testing token extraction logic
|
||||
func ExtractTokenFromHeader(authHeader string) string {
|
||||
if len(authHeader) > 6 && authHeader[:6] == "Token " {
|
||||
return authHeader[6:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// VerifyTokenClaims verifies a JWT token and returns claims for testing
|
||||
func VerifyTokenClaims(tokenString string) (jwt.MapClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token.Claims.(jwt.MapClaims), nil
|
||||
}
|
||||
368
webapp-back/common/unit_test.go
Normal file
368
webapp-back/common/unit_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConnectingDatabase(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
db := Init()
|
||||
dbPath := GetDBPath()
|
||||
// Test create & close DB
|
||||
_, err := os.Stat(dbPath)
|
||||
asserts.NoError(err, "Db should exist")
|
||||
sqlDB, err := db.DB()
|
||||
asserts.NoError(err, "Should get sql.DB")
|
||||
asserts.NoError(sqlDB.Ping(), "Db should be able to ping")
|
||||
|
||||
// Test get a connecting from connection pools
|
||||
connection := GetDB()
|
||||
sqlDB, err = connection.DB()
|
||||
asserts.NoError(err, "Should get sql.DB")
|
||||
asserts.NoError(sqlDB.Ping(), "Db should be able to ping")
|
||||
sqlDB.Close()
|
||||
|
||||
// Test DB exceptions
|
||||
os.Chmod(dbPath, 0000)
|
||||
db = Init()
|
||||
sqlDB, err = db.DB()
|
||||
asserts.NoError(err, "Should get sql.DB")
|
||||
asserts.Error(sqlDB.Ping(), "Db should not be able to ping")
|
||||
sqlDB.Close()
|
||||
os.Chmod(dbPath, 0644)
|
||||
}
|
||||
|
||||
func TestConnectingTestDatabase(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
// Test create & close DB
|
||||
db := TestDBInit()
|
||||
testDBPath := GetTestDBPath()
|
||||
_, err := os.Stat(testDBPath)
|
||||
asserts.NoError(err, "Db should exist")
|
||||
sqlDB, err := db.DB()
|
||||
asserts.NoError(err, "Should get sql.DB")
|
||||
asserts.NoError(sqlDB.Ping(), "Db should be able to ping")
|
||||
TestDBFree(db)
|
||||
|
||||
// Test close delete DB
|
||||
db = TestDBInit()
|
||||
TestDBFree(db)
|
||||
_, err = os.Stat(testDBPath)
|
||||
|
||||
asserts.Error(err, "Db should not exist")
|
||||
}
|
||||
|
||||
func TestDBDirCreation(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
// Set a nested path
|
||||
os.Setenv("TEST_DB_PATH", "tmp/nested/test.db")
|
||||
defer os.Unsetenv("TEST_DB_PATH")
|
||||
|
||||
db := TestDBInit()
|
||||
testDBPath := GetTestDBPath()
|
||||
_, err := os.Stat(testDBPath)
|
||||
asserts.NoError(err, "Db should exist in nested directory")
|
||||
TestDBFree(db)
|
||||
|
||||
// Cleanup directory
|
||||
os.RemoveAll("tmp/nested")
|
||||
}
|
||||
|
||||
func TestDBPathOverride(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
customPath := "./custom_test.db"
|
||||
os.Setenv("TEST_DB_PATH", customPath)
|
||||
defer os.Unsetenv("TEST_DB_PATH")
|
||||
|
||||
asserts.Equal(customPath, GetTestDBPath(), "Should use env var")
|
||||
}
|
||||
|
||||
func TestRandString(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
str := RandString(0)
|
||||
asserts.Equal(str, "", "length should be ''")
|
||||
|
||||
str = RandString(10)
|
||||
asserts.Equal(len(str), 10, "length should be 10")
|
||||
for _, ch := range str {
|
||||
asserts.Contains(letters, ch, "char should be a-z|A-Z|0-9")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandInt(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
// Test that RandInt returns a value in valid range
|
||||
val := RandInt()
|
||||
asserts.GreaterOrEqual(val, 0, "RandInt should be >= 0")
|
||||
asserts.Less(val, 1000000, "RandInt should be < 1000000")
|
||||
|
||||
// Test multiple calls return different values (statistically)
|
||||
vals := make(map[int]bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
vals[RandInt()] = true
|
||||
}
|
||||
asserts.Greater(len(vals), 1, "RandInt should return varied values")
|
||||
}
|
||||
|
||||
func TestGenToken(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
token := GenToken(2)
|
||||
|
||||
asserts.IsType(token, string("token"), "token type should be string")
|
||||
asserts.Len(token, 115, "JWT's length should be 115")
|
||||
}
|
||||
|
||||
func TestGenTokenMultipleUsers(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
token1 := GenToken(1)
|
||||
token2 := GenToken(2)
|
||||
token100 := GenToken(100)
|
||||
|
||||
asserts.NotEqual(token1, token2, "Different user IDs should generate different tokens")
|
||||
asserts.NotEqual(token2, token100, "Different user IDs should generate different tokens")
|
||||
// Token length can vary by 1 character due to timestamp changes
|
||||
asserts.GreaterOrEqual(len(token1), 114, "JWT's length should be >= 114 for user 1")
|
||||
asserts.LessOrEqual(len(token1), 120, "JWT's length should be <= 120 for user 1")
|
||||
asserts.GreaterOrEqual(len(token100), 114, "JWT's length should be >= 114 for user 100")
|
||||
asserts.LessOrEqual(len(token100), 120, "JWT's length should be <= 120 for user 100")
|
||||
}
|
||||
|
||||
func TestHeaderTokenMock(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
token := GenToken(5)
|
||||
HeaderTokenMock(req, 5)
|
||||
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
asserts.Equal(fmt.Sprintf("Token %s", token), authHeader, "Authorization header should be set correctly")
|
||||
}
|
||||
|
||||
func TestExtractTokenFromHeader(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
token := "valid.jwt.token"
|
||||
header := fmt.Sprintf("Token %s", token)
|
||||
|
||||
extracted := ExtractTokenFromHeader(header)
|
||||
asserts.Equal(token, extracted, "Should extract token from header")
|
||||
|
||||
invalidHeader := "Bearer " + token
|
||||
extracted = ExtractTokenFromHeader(invalidHeader)
|
||||
asserts.Empty(extracted, "Should return empty for non-Token header")
|
||||
|
||||
shortHeader := "Token"
|
||||
extracted = ExtractTokenFromHeader(shortHeader)
|
||||
asserts.Empty(extracted, "Should return empty for short header")
|
||||
}
|
||||
|
||||
func TestVerifyTokenClaims(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
// Test valid token
|
||||
userID := uint(123)
|
||||
token := GenToken(userID)
|
||||
claims, err := VerifyTokenClaims(token)
|
||||
asserts.NoError(err, "VerifyTokenClaims should not error for valid token")
|
||||
asserts.Equal(float64(userID), claims["id"], "Claims should contain correct user ID")
|
||||
|
||||
// Test invalid token
|
||||
_, err = VerifyTokenClaims("invalid.token.string")
|
||||
asserts.Error(err, "VerifyTokenClaims should error for invalid token")
|
||||
}
|
||||
|
||||
func TestNewValidatorError(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
type Login struct {
|
||||
Username string `form:"username" json:"username" binding:"required,alphanum,min=4,max=255"`
|
||||
Password string `form:"password" json:"password" binding:"required,min=8,max=255"`
|
||||
}
|
||||
|
||||
var requestTests = []struct {
|
||||
bodyData string
|
||||
expectedCode int
|
||||
responseRegexg string
|
||||
msg string
|
||||
}{
|
||||
{
|
||||
`{"username": "wangzitian0","password": "0123456789"}`,
|
||||
http.StatusOK,
|
||||
`{"status":"you are logged in"}`,
|
||||
"valid data and should return StatusCreated",
|
||||
},
|
||||
{
|
||||
`{"username": "wangzitian0","password": "01234567866"}`,
|
||||
http.StatusUnauthorized,
|
||||
`{"errors":{"user":"wrong username or password"}}`,
|
||||
"wrong login status should return StatusUnauthorized",
|
||||
},
|
||||
{
|
||||
`{"username": "wangzitian0","password": "0122"}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Password":"{min: 8}"}}`,
|
||||
"invalid password of too short and should return StatusUnprocessableEntity",
|
||||
},
|
||||
{
|
||||
`{"username": "_wangzitian0","password": "0123456789"}`,
|
||||
http.StatusUnprocessableEntity,
|
||||
`{"errors":{"Username":"{key: alphanum}"}}`,
|
||||
"invalid username of non alphanum and should return StatusUnprocessableEntity",
|
||||
},
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
r.POST("/login", func(c *gin.Context) {
|
||||
var json Login
|
||||
if err := Bind(c, &json); err == nil {
|
||||
if json.Username == "wangzitian0" && json.Password == "0123456789" {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "you are logged in"})
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, NewError("user", errors.New("wrong username or password")))
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusUnprocessableEntity, NewValidatorError(err))
|
||||
}
|
||||
})
|
||||
|
||||
for _, testData := range requestTests {
|
||||
bodyData := testData.bodyData
|
||||
req, err := http.NewRequest("POST", "/login", bytes.NewBufferString(bodyData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
asserts.NoError(err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
asserts.Equal(testData.expectedCode, w.Code, "Response Status - "+testData.msg)
|
||||
asserts.Regexp(testData.responseRegexg, w.Body.String(), "Response Content - "+testData.msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewError(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
db := TestDBInit()
|
||||
defer TestDBFree(db)
|
||||
|
||||
type NonExistentTable struct {
|
||||
Field string
|
||||
}
|
||||
// db.AutoMigrate(NonExistentTable{}) // Intentionally skipped to cause error
|
||||
|
||||
err := db.Find(&NonExistentTable{Field: "value"}).Error
|
||||
if err == nil {
|
||||
err = errors.New("no such table: non_existent_tables")
|
||||
}
|
||||
|
||||
commonError := NewError("database", err)
|
||||
assert.IsType(commonError, commonError, "commonError should have right type")
|
||||
// The exact error message might vary by driver, checking key presence is safer, but keeping original assertion style
|
||||
assert.Contains(commonError.Errors, "database", "commonError should contain database key")
|
||||
}
|
||||
|
||||
func TestDatabaseDirCreation(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
// Test directory creation in Init
|
||||
origDBPath := os.Getenv("DB_PATH")
|
||||
defer os.Setenv("DB_PATH", origDBPath)
|
||||
|
||||
// Create a temp dir path
|
||||
tempDir := "./tmp/test_nested/db"
|
||||
os.Setenv("DB_PATH", tempDir+"/test.db")
|
||||
|
||||
// Clean up before test
|
||||
os.RemoveAll("./tmp/test_nested")
|
||||
|
||||
// Init should create the directory
|
||||
|
||||
db := Init()
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
|
||||
asserts.NoError(err, "Should get sql.DB")
|
||||
|
||||
asserts.NoError(sqlDB.Ping(), "DB should be created in nested directory")
|
||||
|
||||
// Clean up after test
|
||||
|
||||
sqlDB.Close()
|
||||
|
||||
os.RemoveAll("./tmp/test_nested")
|
||||
|
||||
}
|
||||
|
||||
func TestDBInitDirCreation(t *testing.T) {
|
||||
|
||||
asserts := assert.New(t)
|
||||
|
||||
// Test directory creation in TestDBInit
|
||||
|
||||
origTestDBPath := os.Getenv("TEST_DB_PATH")
|
||||
|
||||
defer os.Setenv("TEST_DB_PATH", origTestDBPath)
|
||||
|
||||
// Create a temp dir path
|
||||
|
||||
tempDir := "./tmp/test_nested_testdb"
|
||||
|
||||
os.Setenv("TEST_DB_PATH", tempDir+"/test.db")
|
||||
|
||||
// Clean up before test
|
||||
|
||||
os.RemoveAll(tempDir)
|
||||
|
||||
// TestDBInit should create the directory
|
||||
|
||||
db := TestDBInit()
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
|
||||
asserts.NoError(err, "Should get sql.DB")
|
||||
|
||||
asserts.NoError(sqlDB.Ping(), "Test DB should be created in nested directory")
|
||||
|
||||
// Clean up after test
|
||||
|
||||
TestDBFree(db)
|
||||
|
||||
os.RemoveAll(tempDir)
|
||||
|
||||
}
|
||||
|
||||
func TestDatabaseWithCurrentDirectory(t *testing.T) {
|
||||
asserts := assert.New(t)
|
||||
|
||||
// Test with simple filename (no directory)
|
||||
origDBPath := os.Getenv("DB_PATH")
|
||||
defer os.Setenv("DB_PATH", origDBPath)
|
||||
|
||||
os.Setenv("DB_PATH", "test_simple.db")
|
||||
|
||||
// Init should work without directory creation
|
||||
db := Init()
|
||||
sqlDB, err := db.DB()
|
||||
|
||||
asserts.NoError(err, "Should get sql.DB")
|
||||
asserts.NoError(sqlDB.Ping(), "DB should be created in current directory")
|
||||
|
||||
// Clean up
|
||||
sqlDB.Close()
|
||||
os.Remove("test_simple.db")
|
||||
}
|
||||
99
webapp-back/common/utils.go
Normal file
99
webapp-back/common/utils.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Common tools and helper functions
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
|
||||
// A helper function to generate random string
|
||||
func RandString(n int) string {
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
randIdx, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
b[i] = letters[randIdx.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// A helper function to generate random int
|
||||
func RandInt() int {
|
||||
randNum, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return int(randNum.Int64())
|
||||
}
|
||||
|
||||
// Keep this two config private, it should not expose to open source
|
||||
const JWTSecret = "A String Very Very Very Strong!!@##$!@#$" // #nosec G101
|
||||
const RandomPassword = "A String Very Very Very Random!!@##$!@#4" // #nosec G101
|
||||
|
||||
// A Util function to generate jwt_token which can be used in the request header
|
||||
func GenToken(id uint) string {
|
||||
jwt_token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"id": id,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||
})
|
||||
// Sign and get the complete encoded token as a string
|
||||
token, err := jwt_token.SignedString([]byte(JWTSecret))
|
||||
if err != nil {
|
||||
fmt.Printf("failed to sign JWT token for id %d: %v\n", id, err)
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// My own Error type that will help return my customized Error info
|
||||
//
|
||||
// {"database": {"hello":"no such table", error: "not_exists"}}
|
||||
type CommonError struct {
|
||||
Errors map[string]interface{} `json:"errors"`
|
||||
}
|
||||
|
||||
// To handle the error returned by c.Bind in gin framework
|
||||
// https://github.com/go-playground/validator/blob/v9/_examples/translations/main.go
|
||||
func NewValidatorError(err error) CommonError {
|
||||
res := CommonError{}
|
||||
res.Errors = make(map[string]interface{})
|
||||
errs := err.(validator.ValidationErrors)
|
||||
for _, v := range errs {
|
||||
// can translate each error one at a time.
|
||||
//fmt.Println("gg",v.NameNamespace)
|
||||
if v.Param() != "" {
|
||||
res.Errors[v.Field()] = fmt.Sprintf("{%v: %v}", v.Tag(), v.Param())
|
||||
} else {
|
||||
res.Errors[v.Field()] = fmt.Sprintf("{key: %v}", v.Tag())
|
||||
}
|
||||
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Wrap the error info in an object
|
||||
func NewError(key string, err error) CommonError {
|
||||
res := CommonError{}
|
||||
res.Errors = make(map[string]interface{})
|
||||
res.Errors[key] = err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
// Changed the c.MustBindWith() -> c.ShouldBindWith().
|
||||
// I don't want to auto return 400 when error happened.
|
||||
// origin function is here: https://github.com/gin-gonic/gin/blob/master/context.go
|
||||
func Bind(c *gin.Context, obj interface{}) error {
|
||||
b := binding.Default(c.Request.Method, c.ContentType())
|
||||
return c.ShouldBindWith(obj, b)
|
||||
}
|
||||
108
webapp-back/config/config.go
Normal file
108
webapp-back/config/config.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Server
|
||||
Port string
|
||||
GinMode string
|
||||
|
||||
// Database
|
||||
DBType string
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
|
||||
// Redis
|
||||
RedisHost string
|
||||
RedisPort string
|
||||
RedisPassword string
|
||||
RedisDB string
|
||||
|
||||
// Auth
|
||||
AuthCenterURL string
|
||||
AuthTokenCacheTTL string
|
||||
AllowedOrigins string
|
||||
|
||||
// Web3
|
||||
ArbSepoliaRPC string
|
||||
BSCTestnetRPC string
|
||||
|
||||
// Liquidation Bot
|
||||
LiquidatorPrivateKey string
|
||||
|
||||
// Collateral Buyer Bot
|
||||
CollateralBuyerPrivateKey string
|
||||
CollateralBuyerMaxAmount string
|
||||
CollateralBuyerSlippage int
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
// Load configuration from environment variables
|
||||
func Load() *Config {
|
||||
// Load .env file
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found, using environment variables")
|
||||
}
|
||||
|
||||
AppConfig = &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
GinMode: getEnv("GIN_MODE", "debug"),
|
||||
|
||||
DBType: getEnv("DB_TYPE", "mysql"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "root"),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBName: getEnv("DB_NAME", "assetx"),
|
||||
|
||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
RedisDB: getEnv("REDIS_DB", "0"),
|
||||
|
||||
AuthCenterURL: getEnv("AUTH_CENTER_URL", "https://auth.upay01.com"),
|
||||
AuthTokenCacheTTL: getEnv("AUTH_TOKEN_CACHE_TTL", "300"),
|
||||
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:8000"),
|
||||
|
||||
ArbSepoliaRPC: getEnv("ARB_SEPOLIA_RPC", "https://sepolia-rollup.arbitrum.io/rpc"),
|
||||
BSCTestnetRPC: getEnv("BSC_TESTNET_RPC", "https://api.zan.top/node/v1/bsc/testnet/baf84c429d284bb5b676cb8c9ca21c07"),
|
||||
|
||||
LiquidatorPrivateKey: getEnv("LIQUIDATOR_PRIVATE_KEY", ""),
|
||||
|
||||
CollateralBuyerPrivateKey: getEnv("COLLATERAL_BUYER_PRIVATE_KEY", ""),
|
||||
CollateralBuyerMaxAmount: getEnv("COLLATERAL_BUYER_MAX_AMOUNT", "100"),
|
||||
CollateralBuyerSlippage: getEnvInt("COLLATERAL_BUYER_SLIPPAGE", 2),
|
||||
}
|
||||
|
||||
return AppConfig
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
var i int
|
||||
if _, err := fmt.Sscanf(value, "%d", &i); err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return i
|
||||
}
|
||||
1
webapp-back/data/.gitkeep
Normal file
1
webapp-back/data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Database files are stored here and ignored by git
|
||||
8
webapp-back/doc.go
Normal file
8
webapp-back/doc.go
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Golang Gonic/Gin startup project fork form RealWorld https://realworld.io
|
||||
|
||||
# You can find all the spec and front end demo in the Realworld project
|
||||
|
||||
This project will include objects and relationships' CRUD, you will know how to write a golang/gin app though small perfectly formed.
|
||||
*/
|
||||
package main
|
||||
93
webapp-back/fundmarket/earning.go
Normal file
93
webapp-back/fundmarket/earning.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package fundmarket
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
)
|
||||
|
||||
// GetNetDeposited computes the net USDC deposited by a wallet address across all YT vaults.
|
||||
//
|
||||
// GET /api/fundmarket/net-deposited?address=0x...&chain_id=97
|
||||
//
|
||||
// Net deposited = Σ(amountIn WHERE tokenIn=USDC) - Σ(amountOut WHERE tokenOut=USDC)
|
||||
// (buy YT: USDC → YT, tokenIn=USDC; sell YT: YT → USDC, tokenOut=USDC)
|
||||
//
|
||||
// Returns netDepositedUSD (float64) for the frontend to compute:
|
||||
// Your Total Earning = currentValue (on-chain) - netDepositedUSD
|
||||
func GetNetDeposited(c *gin.Context) {
|
||||
address := strings.ToLower(c.Query("address"))
|
||||
if address == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "address required"})
|
||||
return
|
||||
}
|
||||
|
||||
chainIDStr := c.DefaultQuery("chain_id", "97")
|
||||
database := db.GetDB()
|
||||
|
||||
// Look up USDC address for this chain (token_role = 'stablecoin')
|
||||
var usdcAddr struct {
|
||||
ContractAddress string `gorm:"column:contract_address"`
|
||||
}
|
||||
if err := database.Table("assets").
|
||||
Where("token_role = 'stablecoin' AND chain_id = ?", chainIDStr).
|
||||
Select("contract_address").
|
||||
First(&usdcAddr).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "USDC address not found"})
|
||||
return
|
||||
}
|
||||
usdcAddress := strings.ToLower(usdcAddr.ContractAddress)
|
||||
|
||||
// Sum amountIn where tokenIn = USDC (user bought YT, paid USDC)
|
||||
type sumResult struct {
|
||||
Total string
|
||||
}
|
||||
var buySum sumResult
|
||||
database.Raw(`
|
||||
SELECT COALESCE(SUM(CAST(amount_in AS DECIMAL(65,0))), 0) AS total
|
||||
FROM yt_swap_records
|
||||
WHERE account = ? AND chain_id = ? AND LOWER(token_in) = ?
|
||||
`, address, chainIDStr, usdcAddress).Scan(&buySum)
|
||||
|
||||
// Sum amountOut where tokenOut = USDC (user sold YT, received USDC)
|
||||
var sellSum sumResult
|
||||
database.Raw(`
|
||||
SELECT COALESCE(SUM(CAST(amount_out AS DECIMAL(65,0))), 0) AS total
|
||||
FROM yt_swap_records
|
||||
WHERE account = ? AND chain_id = ? AND LOWER(token_out) = ?
|
||||
`, address, chainIDStr, usdcAddress).Scan(&sellSum)
|
||||
|
||||
buyWei, _ := new(big.Int).SetString(buySum.Total, 10)
|
||||
sellWei, _ := new(big.Int).SetString(sellSum.Total, 10)
|
||||
if buyWei == nil {
|
||||
buyWei = big.NewInt(0)
|
||||
}
|
||||
if sellWei == nil {
|
||||
sellWei = big.NewInt(0)
|
||||
}
|
||||
|
||||
netWei := new(big.Int).Sub(buyWei, sellWei)
|
||||
if netWei.Sign() < 0 {
|
||||
netWei = big.NewInt(0)
|
||||
}
|
||||
|
||||
// Convert from 18-decimal wei to float64 USD
|
||||
divisor := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil))
|
||||
netUSD, _ := new(big.Float).Quo(new(big.Float).SetInt(netWei), divisor).Float64()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"address": address,
|
||||
"chainId": chainIDStr,
|
||||
"usdcAddress": usdcAddress,
|
||||
"buyWei": buyWei.String(),
|
||||
"sellWei": sellWei.String(),
|
||||
"netDepositedWei": netWei.String(),
|
||||
"netDepositedUSD": netUSD,
|
||||
},
|
||||
})
|
||||
}
|
||||
434
webapp-back/fundmarket/routers.go
Normal file
434
webapp-back/fundmarket/routers.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package fundmarket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// GetProducts returns all fund market products
|
||||
func GetProducts(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
|
||||
// Query assets with their performance data
|
||||
// token_list=1: return all active assets (for dropdowns)
|
||||
// default: exclude stablecoins (Fund Market product listing)
|
||||
var assets []models.Asset
|
||||
query := db.Where("is_active = ?", true)
|
||||
if c.Query("token_list") != "1" {
|
||||
query = query.Where("token_role != ?", "stablecoin")
|
||||
}
|
||||
err := query.Find(&assets).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Failed to fetch products",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get performance data for each asset
|
||||
products := make([]models.ProductResponse, 0)
|
||||
for _, asset := range assets {
|
||||
var perf models.AssetPerformance
|
||||
db.Where("asset_id = ?", asset.ID).
|
||||
Order("snapshot_date DESC").
|
||||
First(&perf)
|
||||
|
||||
riskLabel := asset.RiskLabel
|
||||
if riskLabel == "" {
|
||||
riskLabel = "--"
|
||||
}
|
||||
|
||||
circulatingSupplyStr := "--"
|
||||
if perf.CirculatingSupply > 0 {
|
||||
circulatingSupplyStr = formatUSD(perf.CirculatingSupply)
|
||||
}
|
||||
|
||||
poolCapStr := "--"
|
||||
if asset.PoolCapUSD > 0 {
|
||||
poolCapStr = formatUSD(asset.PoolCapUSD)
|
||||
}
|
||||
|
||||
product := models.ProductResponse{
|
||||
ID: int(asset.ID),
|
||||
Name: asset.Name,
|
||||
TokenSymbol: asset.TokenSymbol,
|
||||
Decimals: asset.Decimals,
|
||||
ContractAddress: asset.ContractAddress,
|
||||
ChainID: asset.ChainID,
|
||||
TokenRole: asset.TokenRole,
|
||||
Category: asset.Category,
|
||||
CategoryColor: asset.CategoryColor,
|
||||
IconURL: asset.IconURL,
|
||||
YieldAPY: fmt.Sprintf("%.1f%%", asset.TargetAPY),
|
||||
PoolCap: poolCapStr,
|
||||
Risk: riskLabel,
|
||||
RiskLevel: asset.RiskLevel,
|
||||
CirculatingSupply: circulatingSupplyStr,
|
||||
PoolCapacityPercent: perf.PoolCapacityPercent,
|
||||
}
|
||||
products = append(products, product)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStats returns fund market statistics
|
||||
func GetStats(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
|
||||
// Total Value Locked = sum of latest tvlusd across all active assets
|
||||
var totalTVL float64
|
||||
db.Raw(`
|
||||
SELECT COALESCE(SUM(ap.tvlusd), 0)
|
||||
FROM asset_performance ap
|
||||
INNER JOIN (
|
||||
SELECT asset_id, MAX(snapshot_date) AS latest
|
||||
FROM asset_performance
|
||||
GROUP BY asset_id
|
||||
) latest ON ap.asset_id = latest.asset_id AND ap.snapshot_date = latest.latest
|
||||
INNER JOIN assets a ON a.id = ap.asset_id AND a.is_active = true
|
||||
`).Scan(&totalTVL)
|
||||
|
||||
// Cumulative Yield = sum of cumulative_yield_usd from latest snapshots
|
||||
var totalYield float64
|
||||
db.Raw(`
|
||||
SELECT COALESCE(SUM(ap.cumulative_yield_usd), 0)
|
||||
FROM asset_performance ap
|
||||
INNER JOIN (
|
||||
SELECT asset_id, MAX(snapshot_date) AS latest
|
||||
FROM asset_performance
|
||||
GROUP BY asset_id
|
||||
) latest ON ap.asset_id = latest.asset_id AND ap.snapshot_date = latest.latest
|
||||
`).Scan(&totalYield)
|
||||
|
||||
stats := []models.StatsResponse{
|
||||
{
|
||||
Label: "Total Value Locked",
|
||||
Value: formatUSD(totalTVL),
|
||||
Change: "",
|
||||
IsPositive: true,
|
||||
},
|
||||
{
|
||||
Label: "Cumulative Yield",
|
||||
Value: formatUSD(totalYield),
|
||||
Change: "",
|
||||
IsPositive: true,
|
||||
},
|
||||
{
|
||||
Label: "Your Total Balance",
|
||||
Value: "--",
|
||||
Change: "",
|
||||
IsPositive: true,
|
||||
},
|
||||
{
|
||||
Label: "Your Total Earning",
|
||||
Value: "--",
|
||||
Change: "",
|
||||
IsPositive: true,
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProductByID returns detailed product information by ID
|
||||
func GetProductByID(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
id := c.Param("id")
|
||||
|
||||
var asset models.Asset
|
||||
err := db.Where("id = ? AND is_active = ?", id, true).First(&asset).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Product not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get performance data
|
||||
var perf models.AssetPerformance
|
||||
db.Where("asset_id = ?", asset.ID).
|
||||
Order("snapshot_date DESC").
|
||||
First(&perf)
|
||||
|
||||
// Calculate volume change vs 7-day average (excluding today)
|
||||
var avg7dVolume float64
|
||||
db.Raw(`
|
||||
SELECT AVG(volume_24h_usd) FROM (
|
||||
SELECT volume_24h_usd FROM asset_performance
|
||||
WHERE asset_id = ? AND snapshot_date < CURDATE()
|
||||
ORDER BY snapshot_date DESC
|
||||
LIMIT 7
|
||||
) t
|
||||
`, asset.ID).Scan(&avg7dVolume)
|
||||
volumeChangeVsAvg := 0.0
|
||||
if avg7dVolume > 0 {
|
||||
volumeChangeVsAvg = (perf.Volume24hUSD - avg7dVolume) / avg7dVolume * 100
|
||||
}
|
||||
|
||||
// Get custody info
|
||||
var custody models.AssetCustody
|
||||
hasCustody := db.Where("asset_id = ?", asset.ID).First(&custody).Error == nil
|
||||
|
||||
// Get audit reports
|
||||
var auditReports []models.AssetAuditReport
|
||||
db.Where("asset_id = ? AND is_active = ?", asset.ID, true).
|
||||
Order("report_date DESC").
|
||||
Limit(10).
|
||||
Find(&auditReports)
|
||||
|
||||
// Get product links
|
||||
var productLinks []models.ProductLink
|
||||
db.Where("asset_id = ? AND is_active = ?", asset.ID, true).
|
||||
Order("display_order ASC, id ASC").
|
||||
Find(&productLinks)
|
||||
|
||||
// Build response
|
||||
detail := models.ProductDetailResponse{
|
||||
// Basic Info
|
||||
ID: int(asset.ID),
|
||||
AssetCode: asset.AssetCode,
|
||||
Name: asset.Name,
|
||||
Subtitle: asset.Subtitle,
|
||||
Description: asset.Description,
|
||||
TokenSymbol: asset.TokenSymbol,
|
||||
Decimals: asset.Decimals,
|
||||
|
||||
// Investment Parameters
|
||||
UnderlyingAssets: asset.UnderlyingAssets,
|
||||
PoolCapUSD: asset.PoolCapUSD,
|
||||
RiskLevel: asset.RiskLevel,
|
||||
RiskLabel: asset.RiskLabel,
|
||||
TargetAPY: asset.TargetAPY,
|
||||
|
||||
// Contract Info
|
||||
ContractAddress: asset.ContractAddress,
|
||||
ChainID: asset.ChainID,
|
||||
|
||||
// Display Info
|
||||
Category: asset.Category,
|
||||
CategoryColor: asset.CategoryColor,
|
||||
IconURL: asset.IconURL,
|
||||
|
||||
// Performance Data
|
||||
CurrentAPY: perf.CurrentAPY,
|
||||
TVLUSD: perf.TVLUSD,
|
||||
Volume24hUSD: perf.Volume24hUSD,
|
||||
VolumeChangeVsAvg: volumeChangeVsAvg,
|
||||
CirculatingSupply: perf.CirculatingSupply,
|
||||
PoolCapacityPercent: perf.PoolCapacityPercent,
|
||||
CurrentPrice: perf.YTPrice,
|
||||
}
|
||||
|
||||
// Add custody info if exists
|
||||
if hasCustody {
|
||||
lastAuditDate := ""
|
||||
if custody.LastAuditDate.Time != nil {
|
||||
lastAuditDate = custody.LastAuditDate.Time.Format("02 Jan 2006")
|
||||
}
|
||||
|
||||
var additionalInfo map[string]interface{}
|
||||
if custody.AdditionalInfo != "" {
|
||||
_ = json.Unmarshal([]byte(custody.AdditionalInfo), &additionalInfo)
|
||||
}
|
||||
if additionalInfo == nil {
|
||||
additionalInfo = map[string]interface{}{}
|
||||
}
|
||||
// 从 maturity_date 动态计算 days_remaining,不再依赖存储值
|
||||
if maturityStr, ok := additionalInfo["maturity_date"].(string); ok && maturityStr != "" {
|
||||
if maturityDate, err := time.Parse("2006-01-02", maturityStr); err == nil {
|
||||
days := int(time.Until(maturityDate).Hours() / 24)
|
||||
additionalInfo["days_remaining"] = days
|
||||
}
|
||||
} else {
|
||||
delete(additionalInfo, "days_remaining")
|
||||
}
|
||||
|
||||
detail.Custody = &models.CustodyInfo{
|
||||
CustodianName: custody.CustodianName,
|
||||
CustodyType: custody.CustodyType,
|
||||
CustodyLocation: custody.CustodyLocation,
|
||||
AuditorName: custody.AuditorName,
|
||||
LastAuditDate: lastAuditDate,
|
||||
AuditReportURL: custody.AuditReportURL,
|
||||
AdditionalInfo: additionalInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// Add audit reports
|
||||
detail.AuditReports = make([]models.AuditReportInfo, 0, len(auditReports))
|
||||
for _, report := range auditReports {
|
||||
detail.AuditReports = append(detail.AuditReports, models.AuditReportInfo{
|
||||
ReportType: report.ReportType,
|
||||
ReportTitle: report.ReportTitle,
|
||||
ReportDate: report.ReportDate.Format("02 Jan 2006"),
|
||||
AuditorName: report.AuditorName,
|
||||
Summary: report.Summary,
|
||||
ReportURL: report.ReportURL,
|
||||
})
|
||||
}
|
||||
|
||||
// Add product links
|
||||
detail.ProductLinks = make([]models.ProductLinkInfo, 0, len(productLinks))
|
||||
for _, link := range productLinks {
|
||||
detail.ProductLinks = append(detail.ProductLinks, models.ProductLinkInfo{
|
||||
LinkText: link.LinkText,
|
||||
LinkURL: link.LinkURL,
|
||||
Description: link.Description,
|
||||
DisplayArea: link.DisplayArea,
|
||||
DisplayOrder: link.DisplayOrder,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": detail,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProductHistory returns daily APY/price history for a product (one point per day)
|
||||
func GetProductHistory(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
id := c.Param("id")
|
||||
|
||||
// Pick the last snapshot of each calendar day (UTC), up to 30 days
|
||||
var snapshots []models.APYSnapshot
|
||||
if err := db.Raw(`
|
||||
SELECT ap.* FROM apy_snapshots ap
|
||||
INNER JOIN (
|
||||
SELECT DATE(snapshot_time) AS snap_date, MAX(snapshot_time) AS latest
|
||||
FROM apy_snapshots
|
||||
WHERE asset_id = ?
|
||||
GROUP BY DATE(snapshot_time)
|
||||
ORDER BY snap_date DESC
|
||||
LIMIT 30
|
||||
) d ON DATE(ap.snapshot_time) = d.snap_date AND ap.snapshot_time = d.latest
|
||||
WHERE ap.asset_id = ?
|
||||
ORDER BY ap.snapshot_time ASC
|
||||
`, id, id).Scan(&snapshots).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Failed to fetch history",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type HistoryPoint struct {
|
||||
Time string `json:"time"`
|
||||
APY float64 `json:"apy"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
points := make([]HistoryPoint, len(snapshots))
|
||||
for i, s := range snapshots {
|
||||
points[i] = HistoryPoint{
|
||||
Time: s.SnapshotTime.UTC().Format(time.RFC3339),
|
||||
APY: s.APYValue,
|
||||
Price: s.Price,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": points,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// GetDailyReturns returns daily net return data for a product by month.
|
||||
// Query params: year (int), month (int). Defaults to current month.
|
||||
func GetDailyReturns(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
id := c.Param("id")
|
||||
|
||||
now := time.Now().UTC()
|
||||
year := now.Year()
|
||||
month := int(now.Month())
|
||||
|
||||
if y, err := strconv.Atoi(c.Query("year")); err == nil {
|
||||
year = y
|
||||
}
|
||||
if m, err := strconv.Atoi(c.Query("month")); err == nil && m >= 1 && m <= 12 {
|
||||
month = m
|
||||
}
|
||||
|
||||
startDate := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
||||
endDate := startDate.AddDate(0, 1, 0)
|
||||
// Include one day before month start for delta calculation
|
||||
queryStart := startDate.AddDate(0, 0, -1)
|
||||
|
||||
var perfs []models.AssetPerformance
|
||||
db.Where("asset_id = ? AND snapshot_date >= ? AND snapshot_date < ?", id, queryStart, endDate).
|
||||
Order("snapshot_date ASC").
|
||||
Find(&perfs)
|
||||
|
||||
// Map date string -> performance
|
||||
type perfEntry struct {
|
||||
ytPrice float64
|
||||
hasData bool
|
||||
}
|
||||
perfMap := make(map[string]perfEntry)
|
||||
for _, p := range perfs {
|
||||
perfMap[p.SnapshotDate.Format("2006-01-02")] = perfEntry{ytPrice: p.YTPrice, hasData: true}
|
||||
}
|
||||
|
||||
type DayReturn struct {
|
||||
Date string `json:"date"`
|
||||
YTPrice float64 `json:"ytPrice"`
|
||||
DailyReturn float64 `json:"dailyReturn"`
|
||||
HasData bool `json:"hasData"`
|
||||
}
|
||||
|
||||
today := now.Format("2006-01-02")
|
||||
var results []DayReturn
|
||||
for d := startDate; d.Before(endDate); d = d.AddDate(0, 0, 1) {
|
||||
dateStr := d.Format("2006-01-02")
|
||||
// Skip future days
|
||||
if dateStr > today {
|
||||
break
|
||||
}
|
||||
cur := perfMap[dateStr]
|
||||
prev := perfMap[d.AddDate(0, 0, -1).Format("2006-01-02")]
|
||||
|
||||
dailyReturn := 0.0
|
||||
if cur.hasData && prev.hasData && prev.ytPrice > 0 {
|
||||
dailyReturn = (cur.ytPrice - prev.ytPrice) / prev.ytPrice * 100
|
||||
}
|
||||
|
||||
results = append(results, DayReturn{
|
||||
Date: dateStr,
|
||||
YTPrice: cur.ytPrice,
|
||||
DailyReturn: dailyReturn,
|
||||
HasData: cur.hasData,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": results,
|
||||
})
|
||||
}
|
||||
488
webapp-back/fundmarket/snapshot.go
Normal file
488
webapp-back/fundmarket/snapshot.go
Normal file
@@ -0,0 +1,488 @@
|
||||
package fundmarket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// YTAssetFactory on BSC Testnet
|
||||
ytAssetFactoryAddressBSC = "0x37B2CD7D94ba1400a6FEB34804a32EfD555bbfc8"
|
||||
// YTAssetFactory on Arbitrum Sepolia
|
||||
ytAssetFactoryAddressArb = "0x37B2CD7D94ba1400a6FEB34804a32EfD555bbfc8"
|
||||
|
||||
// fastSnapshotInterval: price / TVL / APY — cheap single RPC call per asset
|
||||
fastSnapshotInterval = 5 * time.Minute
|
||||
// volumeSnapshotInterval: 24h volume via FilterLogs — heavier, runs less often
|
||||
volumeSnapshotInterval = 30 * time.Minute
|
||||
)
|
||||
|
||||
// Buy / Sell events — emitted by YT token contract
|
||||
// Buy(address user, uint256 usdcAmount, uint256 ytAmount)
|
||||
// Sell(address user, uint256 ytAmount, uint256 usdcAmount)
|
||||
const buySellEventABIJSON = `[
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{"indexed": true, "name": "user", "type": "address"},
|
||||
{"indexed": false, "name": "usdcAmount", "type": "uint256"},
|
||||
{"indexed": false, "name": "ytAmount", "type": "uint256"}
|
||||
],
|
||||
"name": "Buy",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{"indexed": true, "name": "user", "type": "address"},
|
||||
{"indexed": false, "name": "ytAmount", "type": "uint256"},
|
||||
{"indexed": false, "name": "usdcAmount", "type": "uint256"}
|
||||
],
|
||||
"name": "Sell",
|
||||
"type": "event"
|
||||
}
|
||||
]`
|
||||
|
||||
// blockTimeByChain returns approximate seconds-per-block for volume estimation
|
||||
func blockTimeByChain(chainID int) float64 {
|
||||
switch chainID {
|
||||
case 97: return 3 // BSC Testnet ~3s/block
|
||||
case 421614: return 0.25 // Arbitrum Sepolia ~0.25s/block
|
||||
default: return 3
|
||||
}
|
||||
}
|
||||
|
||||
// YTAssetFactory.getVaultInfo(address) ABI
|
||||
// Returns: exists(bool), totalAssets, idleAssets, managedAssets, totalSupply,
|
||||
// hardCap, usdcPrice(8dec), ytPrice(30dec), nextRedemptionTime
|
||||
const getVaultInfoABIJSON = `[{
|
||||
"inputs":[{"internalType":"address","name":"_vault","type":"address"}],
|
||||
"name":"getVaultInfo",
|
||||
"outputs":[
|
||||
{"internalType":"bool","name":"exists","type":"bool"},
|
||||
{"internalType":"uint256","name":"totalAssets","type":"uint256"},
|
||||
{"internalType":"uint256","name":"idleAssets","type":"uint256"},
|
||||
{"internalType":"uint256","name":"managedAssets","type":"uint256"},
|
||||
{"internalType":"uint256","name":"totalSupply","type":"uint256"},
|
||||
{"internalType":"uint256","name":"hardCap","type":"uint256"},
|
||||
{"internalType":"uint256","name":"usdcPrice","type":"uint256"},
|
||||
{"internalType":"uint256","name":"ytPrice","type":"uint256"},
|
||||
{"internalType":"uint256","name":"nextRedemptionTime","type":"uint256"}
|
||||
],
|
||||
"stateMutability":"view",
|
||||
"type":"function"
|
||||
}]`
|
||||
|
||||
// StartPriceSnapshot starts two background loops:
|
||||
// - Fast loop (every 5min): price / TVL / APY via getVaultInfo
|
||||
// - Volume loop (every 30min): 24h USDC volume via FilterLogs
|
||||
//
|
||||
// Call as: go fundmarket.StartPriceSnapshot(cfg)
|
||||
func StartPriceSnapshot(cfg *config.Config) {
|
||||
log.Println("=== Price Snapshot Service: fast=5min, volume=30min ===")
|
||||
|
||||
// Fast loop runs in a sub-goroutine so both loops start concurrently.
|
||||
go func() {
|
||||
runFastSnapshot(cfg)
|
||||
t := time.NewTicker(fastSnapshotInterval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
runFastSnapshot(cfg)
|
||||
}
|
||||
}()
|
||||
|
||||
// Volume loop blocks this goroutine (keeps StartPriceSnapshot alive).
|
||||
runVolumeSnapshot(cfg)
|
||||
t := time.NewTicker(volumeSnapshotInterval)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
runVolumeSnapshot(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// loadActiveAssets returns all active assets that have a contract address set.
|
||||
func loadActiveAssets(database *gorm.DB, label string) ([]models.Asset, bool) {
|
||||
var assets []models.Asset
|
||||
if err := database.Where(
|
||||
"is_active = ? AND contract_address IS NOT NULL AND contract_address != ''",
|
||||
true,
|
||||
).Find(&assets).Error; err != nil {
|
||||
log.Printf("[%s] DB query error: %v", label, err)
|
||||
return nil, false
|
||||
}
|
||||
if len(assets) == 0 {
|
||||
log.Printf("[%s] No active assets with contract_address set, skipping", label)
|
||||
return nil, false
|
||||
}
|
||||
return assets, true
|
||||
}
|
||||
|
||||
// ── Fast snapshot (price / TVL / APY) ────────────────────────────────────────
|
||||
|
||||
func runFastSnapshot(cfg *config.Config) {
|
||||
start := time.Now()
|
||||
log.Printf("[FastSnapshot] Starting at %s", start.Format("2006-01-02 15:04:05"))
|
||||
|
||||
database := db.GetDB()
|
||||
assets, ok := loadActiveAssets(database, "FastSnapshot")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
factoryABI, err := abi.JSON(strings.NewReader(getVaultInfoABIJSON))
|
||||
if err != nil {
|
||||
log.Printf("[FastSnapshot] Parse factory ABI error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
|
||||
for _, asset := range assets {
|
||||
rpcURL, factoryAddrStr := getSnapshotClientForChain(asset.ChainID, cfg)
|
||||
if rpcURL == "" {
|
||||
log.Printf("[FastSnapshot] %s: unsupported chain_id=%d, skipping", asset.AssetCode, asset.ChainID)
|
||||
continue
|
||||
}
|
||||
client, err := ethclient.Dial(rpcURL)
|
||||
if err != nil {
|
||||
log.Printf("[FastSnapshot] %s: RPC connect error: %v", asset.AssetCode, err)
|
||||
continue
|
||||
}
|
||||
factoryAddr := common.HexToAddress(factoryAddrStr)
|
||||
if err := snapshotAssetFast(client, database, asset, factoryABI, factoryAddr, today); err != nil {
|
||||
log.Printf("[FastSnapshot] %s error: %v", asset.AssetCode, err)
|
||||
}
|
||||
client.Close()
|
||||
}
|
||||
|
||||
log.Printf("[FastSnapshot] Done in %v", time.Since(start))
|
||||
}
|
||||
|
||||
func snapshotAssetFast(
|
||||
client *ethclient.Client,
|
||||
database *gorm.DB,
|
||||
asset models.Asset,
|
||||
factoryABI abi.ABI,
|
||||
factoryAddr common.Address,
|
||||
today time.Time,
|
||||
) error {
|
||||
ctx := context.Background()
|
||||
vaultAddr := common.HexToAddress(asset.ContractAddress)
|
||||
|
||||
callData, err := factoryABI.Pack("getVaultInfo", vaultAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pack getVaultInfo: %w", err)
|
||||
}
|
||||
result, err := client.CallContract(ctx, ethereum.CallMsg{
|
||||
To: &factoryAddr,
|
||||
Data: callData,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getVaultInfo call: %w", err)
|
||||
}
|
||||
|
||||
decoded, err := factoryABI.Unpack("getVaultInfo", result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unpack getVaultInfo: %w", err)
|
||||
}
|
||||
if len(decoded) < 9 {
|
||||
return fmt.Errorf("getVaultInfo returned %d values, expected 9", len(decoded))
|
||||
}
|
||||
|
||||
info := vaultInfo{
|
||||
exists: decoded[0].(bool),
|
||||
totalAssets: bigIntToFloat(decoded[1].(*big.Int), 18),
|
||||
totalSupply: bigIntToFloat(decoded[4].(*big.Int), 18),
|
||||
hardCap: bigIntToFloat(decoded[5].(*big.Int), 18),
|
||||
usdcPrice: bigIntToFloat(decoded[6].(*big.Int), 8),
|
||||
ytPrice: bigIntToFloat(decoded[7].(*big.Int), 30),
|
||||
}
|
||||
if !info.exists {
|
||||
return fmt.Errorf("vault %s not registered in factory", asset.ContractAddress)
|
||||
}
|
||||
|
||||
poolCapPercent := 0.0
|
||||
if info.hardCap > 0 {
|
||||
poolCapPercent = info.totalSupply / info.hardCap * 100
|
||||
}
|
||||
|
||||
apy := calcAPY(database, asset.ID, info.ytPrice, today)
|
||||
|
||||
log.Printf("[FastSnapshot] %s: ytPrice=%.8f USDC, supply=%.2f, TVL=$%.2f, APY=%.2f%%",
|
||||
asset.AssetCode, info.ytPrice, info.totalSupply, info.totalAssets, apy)
|
||||
|
||||
// Upsert today's row — do NOT touch volume_24h_usd (managed by volume task).
|
||||
todayDate := today.Format("2006-01-02")
|
||||
var perf models.AssetPerformance
|
||||
if err := database.Where("asset_id = ? AND snapshot_date = ?", asset.ID, todayDate).First(&perf).Error; err != nil {
|
||||
// Row doesn't exist yet — create it. volume_24h_usd defaults to 0 until volume task runs.
|
||||
perf = models.AssetPerformance{
|
||||
AssetID: asset.ID,
|
||||
CurrentAPY: apy,
|
||||
TVLUSD: info.totalAssets,
|
||||
CirculatingSupply: info.totalSupply,
|
||||
PoolCapacityPercent: poolCapPercent,
|
||||
YTPrice: info.ytPrice,
|
||||
Volume24hUSD: 0,
|
||||
SnapshotDate: today,
|
||||
}
|
||||
if err := database.Create(&perf).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := database.Model(&perf).Updates(map[string]interface{}{
|
||||
"current_apy": apy,
|
||||
"tvlusd": info.totalAssets,
|
||||
"circulating_supply": info.totalSupply,
|
||||
"pool_capacity_percent": poolCapPercent,
|
||||
"yt_price": info.ytPrice,
|
||||
// volume_24h_usd intentionally omitted
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Sync pool_cap_usd in assets table.
|
||||
if capUSD := info.hardCap * info.ytPrice; capUSD > 0 {
|
||||
if err := database.Model(&models.Asset{}).Where("id = ?", asset.ID).
|
||||
Update("pool_cap_usd", capUSD).Error; err != nil {
|
||||
log.Printf("[FastSnapshot] %s: update pool_cap_usd error: %v", asset.AssetCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Always insert a new record into apy_snapshots for chart history.
|
||||
hourlySnap := models.APYSnapshot{
|
||||
AssetID: asset.ID,
|
||||
ChainID: asset.ChainID,
|
||||
ContractAddress: asset.ContractAddress,
|
||||
APYValue: apy,
|
||||
Price: info.ytPrice,
|
||||
TotalAssets: info.totalAssets,
|
||||
TotalSupply: info.totalSupply,
|
||||
SnapshotTime: time.Now(),
|
||||
}
|
||||
return database.Create(&hourlySnap).Error
|
||||
}
|
||||
|
||||
// ── Volume snapshot (24h FilterLogs) ─────────────────────────────────────────
|
||||
|
||||
func runVolumeSnapshot(cfg *config.Config) {
|
||||
start := time.Now()
|
||||
log.Printf("[VolumeSnapshot] Starting at %s", start.Format("2006-01-02 15:04:05"))
|
||||
|
||||
database := db.GetDB()
|
||||
assets, ok := loadActiveAssets(database, "VolumeSnapshot")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
todayDate := time.Now().Truncate(24 * time.Hour).Format("2006-01-02")
|
||||
|
||||
for _, asset := range assets {
|
||||
rpcURL, _ := getSnapshotClientForChain(asset.ChainID, cfg)
|
||||
if rpcURL == "" {
|
||||
log.Printf("[VolumeSnapshot] %s: unsupported chain_id=%d, skipping", asset.AssetCode, asset.ChainID)
|
||||
continue
|
||||
}
|
||||
client, err := ethclient.Dial(rpcURL)
|
||||
if err != nil {
|
||||
log.Printf("[VolumeSnapshot] %s: RPC connect error: %v", asset.AssetCode, err)
|
||||
continue
|
||||
}
|
||||
|
||||
volume24h := calc24hVolume(client, asset, asset.ChainID)
|
||||
client.Close()
|
||||
|
||||
// Update only volume_24h_usd in today's performance row.
|
||||
res := database.Model(&models.AssetPerformance{}).
|
||||
Where("asset_id = ? AND snapshot_date = ?", asset.ID, todayDate).
|
||||
Update("volume_24h_usd", volume24h)
|
||||
if res.Error != nil {
|
||||
log.Printf("[VolumeSnapshot] %s: DB update error: %v", asset.AssetCode, res.Error)
|
||||
continue
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// Fast snapshot hasn't run yet for today; volume will be set on next cycle.
|
||||
log.Printf("[VolumeSnapshot] %s: no row for today yet, will retry next cycle", asset.AssetCode)
|
||||
continue
|
||||
}
|
||||
log.Printf("[VolumeSnapshot] %s: volume=$%.2f updated", asset.AssetCode, volume24h)
|
||||
}
|
||||
|
||||
log.Printf("[VolumeSnapshot] Done in %v", time.Since(start))
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// getSnapshotClientForChain returns the RPC URL and factory address for the given chain ID
|
||||
func getSnapshotClientForChain(chainID int, cfg *config.Config) (rpcURL, factoryAddr string) {
|
||||
switch chainID {
|
||||
case 97:
|
||||
return cfg.BSCTestnetRPC, ytAssetFactoryAddressBSC
|
||||
case 421614:
|
||||
return cfg.ArbSepoliaRPC, ytAssetFactoryAddressArb
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
// vaultInfo holds decoded output from YTAssetFactory.getVaultInfo()
|
||||
type vaultInfo struct {
|
||||
exists bool
|
||||
totalAssets float64 // USDC, 18 dec on BSC
|
||||
totalSupply float64 // YT tokens, 18 dec
|
||||
hardCap float64 // YT tokens, 18 dec
|
||||
usdcPrice float64 // 8 dec
|
||||
ytPrice float64 // 30 dec
|
||||
}
|
||||
|
||||
// calcAPY returns annualized APY (%) based on daily price change vs yesterday.
|
||||
func calcAPY(database *gorm.DB, assetID uint, currentPrice float64, today time.Time) float64 {
|
||||
yesterday := today.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
var prev models.AssetPerformance
|
||||
if err := database.Where("asset_id = ? AND snapshot_date = ?", assetID, yesterday).First(&prev).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
if prev.YTPrice <= 0 {
|
||||
return 0
|
||||
}
|
||||
dailyReturn := (currentPrice - prev.YTPrice) / prev.YTPrice
|
||||
return dailyReturn * 365 * 100
|
||||
}
|
||||
|
||||
// maxFilterBlockRange is the max block range allowed per FilterLogs call.
|
||||
// Most public RPC nodes cap this at 10,000 blocks.
|
||||
const maxFilterBlockRange = int64(9000)
|
||||
|
||||
// chunkTimeout is the per-chunk RPC call timeout.
|
||||
const chunkTimeout = 15 * time.Second
|
||||
|
||||
// calc24hVolume scans Buy + Sell events on the YT token contract for the last 24h
|
||||
// and returns the total USDC volume. Paginates FilterLogs in 9000-block chunks to
|
||||
// stay within RPC node limits. Each chunk gets its own timeout to prevent a slow
|
||||
// early chunk from cancelling the rest of the scan.
|
||||
func calc24hVolume(client *ethclient.Client, asset models.Asset, chainID int) float64 {
|
||||
bctx, bcancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
currentBlock, err := client.BlockNumber(bctx)
|
||||
bcancel()
|
||||
if err != nil {
|
||||
log.Printf("[Volume] %s: get block number error: %v", asset.AssetCode, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Estimate fromBlock covering 24h + 20% buffer to handle block-time variance.
|
||||
blockTime := blockTimeByChain(chainID)
|
||||
blocksIn24h := int64(float64(86400) / blockTime * 1.2)
|
||||
fromBlock := int64(currentBlock) - blocksIn24h
|
||||
if fromBlock < 0 {
|
||||
fromBlock = 0
|
||||
}
|
||||
|
||||
eventABI, err := abi.JSON(strings.NewReader(buySellEventABIJSON))
|
||||
if err != nil {
|
||||
log.Printf("[Volume] %s: parse event ABI error: %v", asset.AssetCode, err)
|
||||
return 0
|
||||
}
|
||||
buyTopic := eventABI.Events["Buy"].ID
|
||||
sellTopic := eventABI.Events["Sell"].ID
|
||||
|
||||
contractAddr := common.HexToAddress(asset.ContractAddress)
|
||||
usdcDecimals := int64(18)
|
||||
toBlock := int64(currentBlock)
|
||||
|
||||
log.Printf("[Volume] %s: scanning blocks %d-%d (%d chunks), contract=%s",
|
||||
asset.AssetCode, fromBlock, toBlock,
|
||||
(toBlock-fromBlock)/maxFilterBlockRange+1,
|
||||
contractAddr.Hex())
|
||||
|
||||
var totalVolume float64
|
||||
var totalLogs int
|
||||
|
||||
// Paginate in chunks. Each chunk gets its own independent timeout so that a
|
||||
// slow or failing chunk does not cancel subsequent chunks.
|
||||
for start := fromBlock; start <= toBlock; start += maxFilterBlockRange {
|
||||
end := start + maxFilterBlockRange - 1
|
||||
if end > toBlock {
|
||||
end = toBlock
|
||||
}
|
||||
|
||||
query := ethereum.FilterQuery{
|
||||
FromBlock: big.NewInt(start),
|
||||
ToBlock: big.NewInt(end),
|
||||
Addresses: []common.Address{contractAddr},
|
||||
Topics: [][]common.Hash{{buyTopic, sellTopic}},
|
||||
}
|
||||
|
||||
// Retry once on failure, each attempt with its own independent timeout.
|
||||
var chunkLogs []types.Log
|
||||
var fetchErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
chunkCtx, chunkCancel := context.WithTimeout(context.Background(), chunkTimeout)
|
||||
chunkLogs, fetchErr = client.FilterLogs(chunkCtx, query)
|
||||
chunkCancel()
|
||||
if fetchErr == nil {
|
||||
break
|
||||
}
|
||||
log.Printf("[Volume] %s: filter logs [%d-%d] attempt %d error: %v",
|
||||
asset.AssetCode, start, end, attempt+1, fetchErr)
|
||||
}
|
||||
if fetchErr != nil {
|
||||
log.Printf("[Volume] %s: skipping chunk [%d-%d] after retries", asset.AssetCode, start, end)
|
||||
continue
|
||||
}
|
||||
|
||||
totalLogs += len(chunkLogs)
|
||||
for _, vLog := range chunkLogs {
|
||||
var eventDef abi.Event
|
||||
switch vLog.Topics[0] {
|
||||
case buyTopic:
|
||||
eventDef = eventABI.Events["Buy"]
|
||||
case sellTopic:
|
||||
eventDef = eventABI.Events["Sell"]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
decoded, err := eventDef.Inputs.NonIndexed().Unpack(vLog.Data)
|
||||
if err != nil || len(decoded) < 2 {
|
||||
continue
|
||||
}
|
||||
// Buy: [0]=usdcAmount, [1]=ytAmount
|
||||
// Sell: [0]=ytAmount, [1]=usdcAmount
|
||||
var usdcAmt *big.Int
|
||||
if vLog.Topics[0] == buyTopic {
|
||||
usdcAmt = decoded[0].(*big.Int)
|
||||
} else {
|
||||
usdcAmt = decoded[1].(*big.Int)
|
||||
}
|
||||
totalVolume += bigIntToFloat(usdcAmt, usdcDecimals)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[Volume] %s: total logs found=%d, volume=$%.2f", asset.AssetCode, totalLogs, totalVolume)
|
||||
return totalVolume
|
||||
}
|
||||
|
||||
// bigIntToFloat converts *big.Int to float64 by dividing by 10^decimals.
|
||||
func bigIntToFloat(n *big.Int, decimals int64) float64 {
|
||||
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(decimals), nil)
|
||||
f, _ := new(big.Float).SetPrec(256).Quo(
|
||||
new(big.Float).SetPrec(256).SetInt(n),
|
||||
new(big.Float).SetPrec(256).SetInt(divisor),
|
||||
).Float64()
|
||||
return f
|
||||
}
|
||||
80
webapp-back/go.mod
Normal file
80
webapp-back/go.mod
Normal file
@@ -0,0 +1,80 @@
|
||||
module github.com/gothinkster/golang-gin-realworld-example-app
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/ethereum/go-ethereum v1.17.1
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/gosimple/slug v1.15.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.44.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.20.0 // indirect
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/consensys/gnark-crypto v0.18.1 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/gosimple/unidecode v1.0.1 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
12
webapp-back/go.mod.new
Normal file
12
webapp-back/go.mod.new
Normal file
@@ -0,0 +1,12 @@
|
||||
module github.com/gothinkster/golang-gin-realworld-example-app
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/sqlite v1.5.6
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
318
webapp-back/go.sum
Normal file
318
webapp-back/go.sum
Normal file
@@ -0,0 +1,318 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
|
||||
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
|
||||
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
|
||||
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
|
||||
github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw=
|
||||
github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo=
|
||||
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
|
||||
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
|
||||
github.com/consensys/gnark-crypto v0.18.0 h1:vIye/FqI50VeAr0B3dx+YjeIvmc3LWz4yEfbWBpTUf0=
|
||||
github.com/consensys/gnark-crypto v0.18.0/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
|
||||
github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI=
|
||||
github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA=
|
||||
github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
|
||||
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
|
||||
github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9igY7law=
|
||||
github.com/ethereum/go-ethereum v1.16.8/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
|
||||
github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
|
||||
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
|
||||
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
|
||||
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
|
||||
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
|
||||
github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE=
|
||||
github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0=
|
||||
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330=
|
||||
github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg=
|
||||
github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao=
|
||||
github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA=
|
||||
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
|
||||
github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
|
||||
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
|
||||
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
|
||||
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM=
|
||||
github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
|
||||
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
|
||||
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
|
||||
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
|
||||
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
|
||||
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
|
||||
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
|
||||
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw=
|
||||
github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY=
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
157
webapp-back/holders/README-CN.md
Normal file
157
webapp-back/holders/README-CN.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Holder Scanner 使用说明
|
||||
|
||||
## 问题诊断
|
||||
|
||||
你遇到的问题是:**Scanner(扫描器)没有运行**,所以数据库中没有数据。
|
||||
|
||||
当前状态:
|
||||
- ✅ API Server 正常运行
|
||||
- ❌ Scanner 未启动(需要启动才能从区块链获取数据)
|
||||
- ❌ 部署区块号可能不正确(需要更新)
|
||||
|
||||
## 解决步骤
|
||||
|
||||
### 第一步:获取正确的部署区块号
|
||||
|
||||
你数据库中的合约地址是:
|
||||
- YT-A: `0x7f9eEA491eE53045594ee4669327f0355aCd0e58`
|
||||
- YT-B: `0x20B94C5E5b7361552E0548161a58696aA6FeDBd4`
|
||||
- YT-C: `0x0EF308D70cf35460E26a3Eb42F3442Ff28cbE07C`
|
||||
|
||||
**重要**:你需要找到这些合约的**实际部署区块号**。
|
||||
|
||||
#### 方法 1:通过 Arbiscan 查询
|
||||
|
||||
1. 访问 [Arbiscan Sepolia](https://sepolia.arbiscan.io/)
|
||||
2. 搜索合约地址(例如:`0x7f9eEA491eE53045594ee4669327f0355aCd0e58`)
|
||||
3. 查看 "Contract Creation" 或 "Contract Creator" 信息
|
||||
4. 找到 "Block" 号码
|
||||
|
||||
#### 方法 2:使用 Web3 工具查询
|
||||
|
||||
```bash
|
||||
# 使用 cast (foundry) 工具
|
||||
cast block-number --rpc-url https://sepolia-rollup.arbitrum.io/rpc
|
||||
|
||||
# 或者使用 etherscan API
|
||||
curl "https://api-sepolia.arbiscan.io/api?module=contract&action=getcontractcreation&contractaddresses=0x7f9eEA491eE53045594ee4669327f0355aCd0e58"
|
||||
```
|
||||
|
||||
#### 方法 3:临时方案(不推荐)
|
||||
|
||||
如果暂时无法获取准确的部署区块号,可以使用一个较新的区块号(例如最新区块 - 10000),但这会导致:
|
||||
- ⚠️ 可能遗漏早期的持有者数据
|
||||
- ⚠️ 首次扫描时间更短,但数据不完整
|
||||
|
||||
### 第二步:更新 `.env` 配置
|
||||
|
||||
编辑 `.env` 文件,更新部署区块号:
|
||||
|
||||
```bash
|
||||
# Deployment Block Numbers
|
||||
YT_VAULTS_DEPLOY_BLOCK=123456789 # 替换为 YT-A/B/C 的实际部署区块
|
||||
YTLP_DEPLOY_BLOCK=123456789 # 替换为 ytLP 的实际部署区块
|
||||
LENDING_DEPLOY_BLOCK=123456789 # 替换为 Lending 的实际部署区块
|
||||
```
|
||||
|
||||
### 第三步:启动 Scanner
|
||||
|
||||
#### 方法 1:使用快速启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
./run-scanner.sh
|
||||
|
||||
# Windows (Git Bash)
|
||||
bash run-scanner.sh
|
||||
```
|
||||
|
||||
#### 方法 2:手动编译并运行
|
||||
|
||||
```bash
|
||||
# 编译
|
||||
go build -o bin/holder-scanner cmd/scanner/main.go
|
||||
|
||||
# 运行
|
||||
./bin/holder-scanner
|
||||
```
|
||||
|
||||
#### 方法 3:使用 start-holders.sh(交互式)
|
||||
|
||||
```bash
|
||||
./start-holders.sh
|
||||
# 选择: 2. Scanner only
|
||||
```
|
||||
|
||||
### 第四步:验证数据
|
||||
|
||||
启动 Scanner 后,你应该看到类似的日志:
|
||||
|
||||
```
|
||||
=== Holder Scanner Service ===
|
||||
✓ Configuration loaded
|
||||
✓ Database tables checked
|
||||
🔗 Chain ID: 421614
|
||||
📚 从数据库加载合约地址配置...
|
||||
✅ [Scanner] 加载了 3 个 YT 资产
|
||||
✓ YT-A: 0x7f9eEA491eE53045594ee4669327f0355aCd0e58
|
||||
✓ YT-B: 0x20B94C5E5b7361552E0548161a58696aA6FeDBd4
|
||||
✓ YT-C: 0x0EF308D70cf35460E26a3Eb42F3442Ff28cbE07C
|
||||
...
|
||||
📊 开始首次扫描...
|
||||
1. Scanning YT Vaults...
|
||||
正在查询 YT-A (0x7f9eEA491eE53045594ee4669327f0355aCd0e58)...
|
||||
发现 X 个新地址
|
||||
余额>0: X 个持有者 ✅
|
||||
```
|
||||
|
||||
等待首次扫描完成后,再次访问 API:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/holders/YT-A
|
||||
```
|
||||
|
||||
应该能看到持有者数据。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: Scanner 报错 "failed to query logs"
|
||||
**原因**:部署区块号设置太早,或 RPC 节点限制
|
||||
|
||||
**解决**:
|
||||
1. 检查部署区块号是否正确
|
||||
2. 尝试使用更小的 `BatchSize`(在代码中默认 9999)
|
||||
3. 更换 RPC 节点
|
||||
|
||||
### Q2: 扫描速度很慢
|
||||
**原因**:区块范围太大
|
||||
|
||||
**解决**:
|
||||
1. 确保使用准确的部署区块号(不要从太早的区块开始)
|
||||
2. 检查网络连接
|
||||
3. 考虑使用付费的 RPC 节点(更高的速率限制)
|
||||
|
||||
### Q3: 余额都是 0
|
||||
**原因**:合约地址不正确,或合约不是 ERC20
|
||||
|
||||
**解决**:
|
||||
1. 验证合约地址是否正确
|
||||
2. 在区块浏览器上检查合约是否是 ERC20 代币
|
||||
3. 确认合约在对应的链上(Arbitrum Sepolia)
|
||||
|
||||
## 配置参数说明
|
||||
|
||||
| 环境变量 | 说明 | 默认值 |
|
||||
|---------|------|--------|
|
||||
| `CHAIN_ID` | 链 ID(421614=Arbitrum Sepolia, 97=BSC Testnet) | 421614 |
|
||||
| `YT_VAULTS_DEPLOY_BLOCK` | YT 代币合约部署区块号 | 227339300 |
|
||||
| `YTLP_DEPLOY_BLOCK` | ytLP 合约部署区块号 | 227230270 |
|
||||
| `LENDING_DEPLOY_BLOCK` | Lending 合约部署区块号 | 227746053 |
|
||||
|
||||
## 技术支持
|
||||
|
||||
如果遇到问题,请提供:
|
||||
1. Scanner 完整日志
|
||||
2. 合约地址
|
||||
3. 部署区块号
|
||||
4. 错误信息
|
||||
454
webapp-back/holders/README.md
Normal file
454
webapp-back/holders/README.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# Holders API - 持有者统计服务
|
||||
|
||||
## 📋 概述
|
||||
|
||||
这个模块提供了区块链代币持有者的统计和查询功能,包括:
|
||||
- **YT 代币持有者** (YT-A, YT-B, YT-C)
|
||||
- **ytLP 代币持有者**
|
||||
- **Lending 提供者**
|
||||
|
||||
## 🏗️ 架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐
|
||||
│ Frontend │─────▶│ API Server │─────▶│ Database │
|
||||
│ (Ant Design) │ │ (Gin + Gorm) │ │ (MySQL/PG) │
|
||||
└─────────────────┘ └──────────────────┘ └──────────────┘
|
||||
│ ▲
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────┐ │
|
||||
│ Scanner Service │────────────┘
|
||||
│ (Blockchain) │
|
||||
└──────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Arbitrum RPC │
|
||||
│ (Sepolia) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
holders/
|
||||
├── routers.go # API 路由和处理函数
|
||||
├── scanner.go # 区块链扫描器核心逻辑
|
||||
└── README.md # 本文档
|
||||
|
||||
cmd/scanner/
|
||||
└── main.go # 扫描器服务启动入口
|
||||
|
||||
models/
|
||||
└── holder.go # 数据库模型定义
|
||||
```
|
||||
|
||||
## 🔧 安装依赖
|
||||
|
||||
### 1. 安装 Go 依赖
|
||||
|
||||
```bash
|
||||
cd /home/coder/myprojects/assetx/golang-gin-realworld
|
||||
|
||||
# 添加 ethereum 依赖
|
||||
go get github.com/ethereum/go-ethereum
|
||||
go get github.com/ethereum/go-ethereum/ethclient
|
||||
go get github.com/ethereum/go-ethereum/accounts/abi
|
||||
go get github.com/ethereum/go-ethereum/common
|
||||
go get github.com/ethereum/go-ethereum/core/types
|
||||
go get github.com/ethereum/go-ethereum/crypto
|
||||
|
||||
# 更新依赖
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### 2. 数据库迁移
|
||||
|
||||
数据库表会在启动时自动创建(通过 GORM AutoMigrate):
|
||||
|
||||
```sql
|
||||
CREATE TABLE holder_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
holder_address VARCHAR(42) NOT NULL,
|
||||
token_type VARCHAR(50) NOT NULL,
|
||||
token_address VARCHAR(42) NOT NULL,
|
||||
balance VARCHAR(78) NOT NULL,
|
||||
chain_id INTEGER NOT NULL,
|
||||
first_seen BIGINT NOT NULL,
|
||||
last_updated BIGINT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_holder_token (holder_address, token_type),
|
||||
INDEX idx_token_time (token_type, last_updated)
|
||||
);
|
||||
```
|
||||
|
||||
## 🚀 启动服务
|
||||
|
||||
### 方法一:分别启动(推荐开发环境)
|
||||
|
||||
**1. 启动 API 服务器**
|
||||
```bash
|
||||
cd /home/coder/myprojects/assetx/golang-gin-realworld
|
||||
|
||||
# 设置环境变量
|
||||
export DB_TYPE=mysql
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=3306
|
||||
export DB_USER=root
|
||||
export DB_PASSWORD=your_password
|
||||
export DB_NAME=assetx
|
||||
export GIN_MODE=release
|
||||
export PORT=8080
|
||||
|
||||
# 启动 API 服务
|
||||
go run main.go
|
||||
```
|
||||
|
||||
**2. 启动扫描器服务**
|
||||
```bash
|
||||
cd /home/coder/myprojects/assetx/golang-gin-realworld
|
||||
|
||||
# 设置 RPC URL(可选,有默认值)
|
||||
export RPC_URL=https://api.zan.top/node/v1/arb/sepolia/baf84c429d284bb5b676cb8c9ca21c07
|
||||
|
||||
# 启动扫描器
|
||||
go run cmd/scanner/main.go
|
||||
```
|
||||
|
||||
### 方法二:使用 PM2(推荐生产环境)
|
||||
|
||||
**1. 创建 PM2 配置文件**
|
||||
```json
|
||||
// ecosystem.holders.config.js
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'assetx-api',
|
||||
script: './golang-gin-realworld-example-app',
|
||||
env: {
|
||||
DB_TYPE: 'mysql',
|
||||
DB_HOST: 'localhost',
|
||||
DB_PORT: '3306',
|
||||
DB_USER: 'root',
|
||||
DB_PASSWORD: 'your_password',
|
||||
DB_NAME: 'assetx',
|
||||
GIN_MODE: 'release',
|
||||
PORT: '8080'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'holder-scanner',
|
||||
script: 'go',
|
||||
args: 'run cmd/scanner/main.go',
|
||||
env: {
|
||||
DB_TYPE: 'mysql',
|
||||
DB_HOST: 'localhost',
|
||||
DB_PORT: '3306',
|
||||
DB_USER: 'root',
|
||||
DB_PASSWORD: 'your_password',
|
||||
DB_NAME: 'assetx',
|
||||
RPC_URL: 'https://api.zan.top/node/v1/arb/sepolia/baf84c429d284bb5b676cb8c9ca21c07'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
**2. 启动服务**
|
||||
```bash
|
||||
# 先编译 API 服务器
|
||||
go build -o golang-gin-realworld-example-app
|
||||
|
||||
# 使用 PM2 启动
|
||||
pm2 start ecosystem.holders.config.js
|
||||
pm2 logs
|
||||
pm2 status
|
||||
```
|
||||
|
||||
## 📡 API 接口
|
||||
|
||||
### 1. GET /api/holders/stats
|
||||
|
||||
获取所有代币类型的统计信息。
|
||||
|
||||
**请求**
|
||||
```bash
|
||||
curl http://localhost:8080/api/holders/stats
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"token_type": "YT-A",
|
||||
"holder_count": 156,
|
||||
"total_balance": "1250000000000000000000"
|
||||
},
|
||||
{
|
||||
"token_type": "YT-B",
|
||||
"holder_count": 89,
|
||||
"total_balance": "780000000000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. GET /api/holders/:tokenType
|
||||
|
||||
获取特定代币的持有者列表。
|
||||
|
||||
**参数**
|
||||
- `tokenType`: `YT-A` | `YT-B` | `YT-C` | `ytLP` | `Lending`
|
||||
|
||||
**请求**
|
||||
```bash
|
||||
curl http://localhost:8080/api/holders/YT-A
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"holder_address": "0x1234567890abcdef1234567890abcdef12345678",
|
||||
"token_type": "YT-A",
|
||||
"token_address": "0x97204190B35D9895a7a47aa7BaC61ac08De3cF05",
|
||||
"balance": "100000000000000000000",
|
||||
"chain_id": 421614,
|
||||
"first_seen": 1707800000,
|
||||
"last_updated": 1707900000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. POST /api/holders/update
|
||||
|
||||
手动触发数据更新(需要管理员权限)。
|
||||
|
||||
**请求**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/holders/update \
|
||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Update triggered successfully",
|
||||
"timestamp": 1707900000
|
||||
}
|
||||
```
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 扫描器配置
|
||||
|
||||
在 `cmd/scanner/main.go` 中可以修改以下配置:
|
||||
|
||||
```go
|
||||
scannerConfig := holders.Config{
|
||||
RPCURL: "your_rpc_url", // RPC 节点地址
|
||||
PollInterval: 10 * time.Second, // 轮询间隔(10秒)
|
||||
BatchSize: 9999, // 每批次查询的区块数
|
||||
|
||||
// 代币合约地址
|
||||
YTVaults: []holders.VaultConfig{
|
||||
{Name: "YT-A", Address: "0x..."},
|
||||
{Name: "YT-B", Address: "0x..."},
|
||||
{Name: "YT-C", Address: "0x..."},
|
||||
},
|
||||
YTLPAddress: "0x...",
|
||||
LendingAddress: "0x...",
|
||||
|
||||
// 部署区块号(从这里开始扫描)
|
||||
DeploymentBlocks: holders.DeploymentBlocks{
|
||||
YTVaults: 227339300,
|
||||
YTLP: 227230270,
|
||||
Lending: 227746053,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `RPC_URL` | Arbitrum RPC 节点地址 | zan.top URL |
|
||||
| `DB_TYPE` | 数据库类型 | `sqlite` |
|
||||
| `DB_HOST` | 数据库主机 | `localhost` |
|
||||
| `DB_PORT` | 数据库端口 | `3306` |
|
||||
| `DB_USER` | 数据库用户名 | `root` |
|
||||
| `DB_PASSWORD` | 数据库密码 | - |
|
||||
| `DB_NAME` | 数据库名称 | `assetx` |
|
||||
|
||||
## 🔍 工作原理
|
||||
|
||||
### 扫描流程
|
||||
|
||||
1. **初始扫描**
|
||||
- 从合约部署区块开始
|
||||
- 扫描所有历史 Transfer/Supply 事件
|
||||
- 记录所有曾经持有代币的地址
|
||||
- 查询当前余额并保存到数据库
|
||||
|
||||
2. **增量扫描**
|
||||
- 每 10 秒检查一次新区块
|
||||
- 只扫描自上次扫描后的新区块
|
||||
- 更新新地址和余额变化
|
||||
- 持续追踪地址首次出现时间
|
||||
|
||||
3. **数据存储**
|
||||
- 使用 `holder_snapshots` 表存储快照
|
||||
- 余额以 wei 格式字符串存储(避免精度丢失)
|
||||
- 记录首次持有时间和最后更新时间
|
||||
- 支持唯一约束(address + token_type)
|
||||
|
||||
### 性能优化
|
||||
|
||||
- ✅ 批量查询区块事件(每次最多 9999 个区块)
|
||||
- ✅ 增量扫描(只查询新区块)
|
||||
- ✅ 地址缓存(减少重复查询)
|
||||
- ✅ 并发控制(防止重复扫描)
|
||||
- ✅ 速率限制(避免 RPC 限流)
|
||||
|
||||
## 📊 监控和日志
|
||||
|
||||
### 查看扫描器日志
|
||||
|
||||
```bash
|
||||
# 实时查看日志
|
||||
pm2 logs holder-scanner
|
||||
|
||||
# 或者直接运行时查看
|
||||
go run cmd/scanner/main.go
|
||||
```
|
||||
|
||||
### 日志示例
|
||||
|
||||
```
|
||||
=== Holder Scanner Service ===
|
||||
✓ Configuration loaded
|
||||
✓ Database tables checked
|
||||
🚀 Starting blockchain scanner...
|
||||
=== Holder Scanner Started ===
|
||||
RPC: https://api.zan.top/node/v1/arb/sepolia/...
|
||||
Poll Interval: 10s
|
||||
📊 Starting initial scan...
|
||||
Current block: 227950000
|
||||
|
||||
1. Scanning YT Vaults...
|
||||
Scanning YT-A (0x9720...)...
|
||||
Querying blocks 227339300 -> 227950000 (total: 610700 blocks)
|
||||
Querying blocks 227339300 - 227349299...
|
||||
✓ Got 45 events
|
||||
...
|
||||
Found 23 new addresses, total tracking: 156
|
||||
YT-A: 156 holders saved
|
||||
|
||||
2. Scanning ytLP...
|
||||
ytLP: 67 holders saved
|
||||
|
||||
3. Scanning Lending...
|
||||
Lending: 43 holders saved
|
||||
|
||||
📌 Last scanned block: 227950000
|
||||
✓ Initial scan completed in 2m34s
|
||||
|
||||
⏰ Starting polling every 10s...
|
||||
⏰ [15:30:45] No new blocks (current: 227950000)
|
||||
⏰ [15:30:55] Found new blocks
|
||||
🔄 Incremental scan: blocks 227950001 -> 227950015
|
||||
✓ Incremental scan completed in 1.2s
|
||||
```
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
**1. RPC 连接失败**
|
||||
```
|
||||
Error: failed to connect to Ethereum client
|
||||
```
|
||||
**解决**: 检查 `RPC_URL` 是否正确,网络是否通畅
|
||||
|
||||
**2. 数据库连接失败**
|
||||
```
|
||||
Error: failed to connect to database
|
||||
```
|
||||
**解决**: 检查数据库配置和权限
|
||||
|
||||
**3. 查询超时**
|
||||
```
|
||||
Error: context deadline exceeded
|
||||
```
|
||||
**解决**: 减小 `BatchSize` 参数(如改为 5000)
|
||||
|
||||
**4. 内存占用过高**
|
||||
```
|
||||
OOM or high memory usage
|
||||
```
|
||||
**解决**: 增加批次间隔时间或减小批次大小
|
||||
|
||||
### 数据验证
|
||||
|
||||
**检查数据库中的数据**
|
||||
```sql
|
||||
-- 查看所有代币类型的持有者数量
|
||||
SELECT token_type, COUNT(*) as count
|
||||
FROM holder_snapshots
|
||||
GROUP BY token_type;
|
||||
|
||||
-- 查看 YT-A 的前 10 名持有者
|
||||
SELECT holder_address, balance
|
||||
FROM holder_snapshots
|
||||
WHERE token_type = 'YT-A'
|
||||
ORDER BY CAST(balance AS DECIMAL) DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- 查看最近更新的记录
|
||||
SELECT * FROM holder_snapshots
|
||||
ORDER BY last_updated DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## 🔐 安全注意事项
|
||||
|
||||
1. **RPC 密钥保护**
|
||||
- 不要在代码中硬编码 RPC URL
|
||||
- 使用环境变量或配置文件
|
||||
- 限制 RPC API Key 的权限
|
||||
|
||||
2. **数据库安全**
|
||||
- 使用强密码
|
||||
- 限制数据库访问权限
|
||||
- 定期备份数据
|
||||
|
||||
3. **API 权限**
|
||||
- `/update` 接口需要管理员权限
|
||||
- 建议添加请求频率限制
|
||||
- 记录操作日志
|
||||
|
||||
## 📝 TODO
|
||||
|
||||
- [ ] 添加 WebSocket 支持(实时推送)
|
||||
- [ ] 实现手动触发扫描的实际逻辑
|
||||
- [ ] 添加 Prometheus 监控指标
|
||||
- [ ] 支持多链配置
|
||||
- [ ] 添加数据导出功能
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
如有问题请联系开发团队或查看项目文档。
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2024-02-13
|
||||
125
webapp-back/holders/db_config.go
Normal file
125
webapp-back/holders/db_config.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package holders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
)
|
||||
|
||||
// Asset represents a product/asset in the database
|
||||
type Asset struct {
|
||||
ID int64 `gorm:"column:id"`
|
||||
AssetCode string `gorm:"column:asset_code"`
|
||||
Name string `gorm:"column:name"`
|
||||
TokenRole string `gorm:"column:token_role"`
|
||||
ChainID int `gorm:"column:chain_id"`
|
||||
ContractAddress string `gorm:"column:contract_address"`
|
||||
DeployBlock *uint64 `gorm:"column:deploy_block"`
|
||||
}
|
||||
|
||||
|
||||
const zeroAddress = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
// LoadConfigFromDB loads contract addresses and deploy blocks from database based on chain ID
|
||||
func LoadConfigFromDB(chainID int64) (Config, error) {
|
||||
db := common.GetDB()
|
||||
|
||||
log.Printf("📚 [Scanner] 从数据库加载配置 - Chain ID: %d", chainID)
|
||||
|
||||
switch chainID {
|
||||
case 97, 421614:
|
||||
// supported
|
||||
default:
|
||||
return Config{}, fmt.Errorf("unsupported chain ID: %d", chainID)
|
||||
}
|
||||
|
||||
// Load YT assets by token_role filtered by chain_id
|
||||
var assets []Asset
|
||||
if err := db.Table("assets").
|
||||
Where("token_role = ? AND chain_id = ? AND is_active = ?", "yt_token", chainID, true).
|
||||
Find(&assets).Error; err != nil {
|
||||
return Config{}, fmt.Errorf("failed to load assets: %w", err)
|
||||
}
|
||||
|
||||
ytVaults := make([]VaultConfig, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
if asset.ContractAddress == "" || asset.ContractAddress == zeroAddress {
|
||||
log.Printf("⚠️ [Scanner] 跳过 %s (地址未配置)", asset.AssetCode)
|
||||
continue
|
||||
}
|
||||
if asset.DeployBlock == nil || *asset.DeployBlock == 0 {
|
||||
log.Printf("⚠️ [Scanner] 跳过 %s (deploy_block 未配置)", asset.AssetCode)
|
||||
continue
|
||||
}
|
||||
|
||||
ytVaults = append(ytVaults, VaultConfig{
|
||||
Name: asset.AssetCode,
|
||||
Address: asset.ContractAddress,
|
||||
DeployBlock: *asset.DeployBlock,
|
||||
})
|
||||
log.Printf(" ✓ %s: %s (部署区块: %d)", asset.AssetCode, asset.ContractAddress, *asset.DeployBlock)
|
||||
}
|
||||
log.Printf("✅ [Scanner] 加载了 %d 个 YT Vault", len(ytVaults))
|
||||
|
||||
// Load YTLPToken address from system_contracts
|
||||
var ytLPContract struct {
|
||||
Address string `gorm:"column:address"`
|
||||
DeployBlock *uint64 `gorm:"column:deploy_block"`
|
||||
}
|
||||
ytLPAddress := ""
|
||||
var ytLPDeployBlock uint64
|
||||
err := db.Table("system_contracts").
|
||||
Where("name = ? AND chain_id = ? AND is_active = ?", "YTLPToken", chainID, 1).
|
||||
Select("address, deploy_block").
|
||||
First(&ytLPContract).Error
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [Scanner] 未找到 YTLPToken 配置")
|
||||
} else if ytLPContract.Address == "" || ytLPContract.Address == zeroAddress {
|
||||
log.Printf("⚠️ [Scanner] 跳过 ytLP (地址未配置)")
|
||||
} else if ytLPContract.DeployBlock == nil || *ytLPContract.DeployBlock == 0 {
|
||||
log.Printf("⚠️ [Scanner] 跳过 ytLP (deploy_block 未配置)")
|
||||
} else {
|
||||
ytLPAddress = ytLPContract.Address
|
||||
ytLPDeployBlock = *ytLPContract.DeployBlock
|
||||
log.Printf("✅ [Scanner] ytLP: %s (部署区块: %d)", ytLPAddress, ytLPDeployBlock)
|
||||
}
|
||||
|
||||
rpcURL := getRPCURLForChain(chainID)
|
||||
|
||||
config := Config{
|
||||
ChainID: int(chainID),
|
||||
RPCURL: rpcURL,
|
||||
YTVaults: ytVaults,
|
||||
YTLPAddress: ytLPAddress,
|
||||
DeploymentBlocks: DeploymentBlocks{
|
||||
YTLP: ytLPDeployBlock,
|
||||
},
|
||||
PollInterval: 30 * time.Second,
|
||||
BatchSize: 9999,
|
||||
}
|
||||
|
||||
log.Printf("📊 [Scanner] 配置加载完成: YT Vaults=%d, ytLP=%s, RPC=%s",
|
||||
len(config.YTVaults), config.YTLPAddress, config.RPCURL)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// getRPCURLForChain returns the RPC URL for the given chain ID
|
||||
func getRPCURLForChain(chainID int64) string {
|
||||
switch chainID {
|
||||
case 421614:
|
||||
return "https://api.zan.top/node/v1/arb/sepolia/baf84c429d284bb5b676cb8c9ca21c07"
|
||||
case 97:
|
||||
return "https://api.zan.top/node/v1/bsc/testnet/baf84c429d284bb5b676cb8c9ca21c07"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// TableName sets the table name for GORM
|
||||
func (Asset) TableName() string {
|
||||
return "assets"
|
||||
}
|
||||
|
||||
132
webapp-back/holders/routers.go
Normal file
132
webapp-back/holders/routers.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package holders
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// RegisterRoutes registers holders API routes
|
||||
func RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/stats", GetStats)
|
||||
router.GET("/:tokenType", GetHoldersByType)
|
||||
router.POST("/update", UpdateHolders)
|
||||
}
|
||||
|
||||
// GetStats returns aggregated statistics for all token types
|
||||
func GetStats(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
|
||||
log.Printf("📊 [API] 收到获取统计数据请求 - IP: %s", c.ClientIP())
|
||||
|
||||
var stats []models.HolderStats
|
||||
err := db.Raw(`
|
||||
SELECT
|
||||
token_type,
|
||||
COUNT(DISTINCT holder_address) as holder_count
|
||||
FROM holder_snapshots
|
||||
WHERE balance != '0' AND balance != '' AND LENGTH(balance) > 0
|
||||
GROUP BY token_type
|
||||
`).Scan(&stats).Error
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [API] 查询统计数据失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Failed to fetch stats",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [API] 成功返回统计数据 - %d 个token类型", len(stats))
|
||||
for _, stat := range stats {
|
||||
log.Printf(" - %s: %d 个持有者", stat.TokenType, stat.HolderCount)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetHoldersByType returns holders for a specific token type
|
||||
func GetHoldersByType(c *gin.Context) {
|
||||
tokenType := c.Param("tokenType")
|
||||
db := common.GetDB()
|
||||
|
||||
// 记录请求日志
|
||||
log.Printf("🔍 [API] 收到获取持有者请求 - Token类型: %s, IP: %s", tokenType, c.ClientIP())
|
||||
|
||||
var holders []models.HolderSnapshot
|
||||
err := db.Where("token_type = ? AND balance != ? AND balance != '' AND LENGTH(balance) > 0", tokenType, "0").
|
||||
Order("LENGTH(balance) DESC, balance DESC").
|
||||
Find(&holders).Error
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [API] 查询持有者失败 - Token类型: %s, 错误: %v", tokenType, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Failed to fetch holders",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [API] 成功返回 %d 个持有者 - Token类型: %s", len(holders), tokenType)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": holders,
|
||||
})
|
||||
}
|
||||
|
||||
// Global scanner instance
|
||||
var globalScanner *Scanner
|
||||
|
||||
// StartAllScanners 为所有支持的链启动 holder 扫描器(后台常驻)
|
||||
func StartAllScanners() {
|
||||
supportedChains := []int64{97, 421614}
|
||||
for _, chainID := range supportedChains {
|
||||
config, err := LoadConfigFromDB(chainID)
|
||||
if err != nil {
|
||||
log.Printf("[HolderScanner] Chain %d 配置加载失败: %v", chainID, err)
|
||||
continue
|
||||
}
|
||||
if len(config.YTVaults) == 0 && config.YTLPAddress == "" {
|
||||
log.Printf("[HolderScanner] Chain %d 无活跃资产,跳过", chainID)
|
||||
continue
|
||||
}
|
||||
go func(cfg Config) {
|
||||
log.Printf("[HolderScanner] Chain %d 启动,YTVaults=%d ytLP=%s",
|
||||
cfg.ChainID, len(cfg.YTVaults), cfg.YTLPAddress)
|
||||
if err := Start(cfg); err != nil {
|
||||
log.Printf("[HolderScanner] Chain %d 异常退出: %v", cfg.ChainID, err)
|
||||
}
|
||||
}(config)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateHolders triggers blockchain data update (admin only)
|
||||
func UpdateHolders(c *gin.Context) {
|
||||
if globalScanner == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Scanner is running in background. It will process new blocks on next tick.",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := globalScanner.incrementalScan(c.Request.Context()); err != nil {
|
||||
log.Printf("[HolderScanner] Manual update error: %v", err)
|
||||
}
|
||||
}()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Manual scan triggered.",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
616
webapp-back/holders/scanner.go
Normal file
616
webapp-back/holders/scanner.go
Normal file
@@ -0,0 +1,616 @@
|
||||
package holders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
)
|
||||
|
||||
// Config holds scanner configuration
|
||||
type Config struct {
|
||||
ChainID int
|
||||
RPCURL string
|
||||
YTVaults []VaultConfig
|
||||
YTLPAddress string
|
||||
DeploymentBlocks DeploymentBlocks
|
||||
PollInterval time.Duration
|
||||
BatchSize int64
|
||||
}
|
||||
|
||||
type VaultConfig struct {
|
||||
Name string
|
||||
Address string
|
||||
DeployBlock uint64
|
||||
}
|
||||
|
||||
type DeploymentBlocks struct {
|
||||
YTLP uint64
|
||||
}
|
||||
|
||||
// Scanner handles blockchain event scanning
|
||||
type Scanner struct {
|
||||
config Config
|
||||
client *ethclient.Client
|
||||
lastScannedBlock uint64
|
||||
isScanning bool
|
||||
mu sync.Mutex
|
||||
|
||||
// Address tracking
|
||||
ytAddresses map[string]map[string]int64 // vault -> address -> firstSeen
|
||||
lpAddresses map[string]int64 // address -> firstSeen
|
||||
}
|
||||
|
||||
// Event topics
|
||||
var (
|
||||
transferEventTopic = crypto.Keccak256Hash([]byte("Transfer(address,address,uint256)"))
|
||||
)
|
||||
|
||||
// NewScanner creates a new blockchain scanner
|
||||
func NewScanner(config Config) (*Scanner, error) {
|
||||
client, err := ethclient.Dial(config.RPCURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Ethereum client: %w", err)
|
||||
}
|
||||
|
||||
return &Scanner{
|
||||
config: config,
|
||||
client: client,
|
||||
ytAddresses: make(map[string]map[string]int64),
|
||||
lpAddresses: make(map[string]int64),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadStateFromDB loads the last scanned block from database
|
||||
func (s *Scanner) loadStateFromDB() uint64 {
|
||||
database := db.GetDB()
|
||||
var state models.ScannerState
|
||||
result := database.Where("chain_id = ? AND scanner_type = ?", s.config.ChainID, "holder").First(&state)
|
||||
if result.Error != nil {
|
||||
return 0
|
||||
}
|
||||
return state.LastScannedBlock
|
||||
}
|
||||
|
||||
// saveStateToDB persists the last scanned block to database
|
||||
func (s *Scanner) saveStateToDB(block uint64) {
|
||||
database := db.GetDB()
|
||||
state := models.ScannerState{
|
||||
ScannerType: "holder",
|
||||
ChainID: s.config.ChainID,
|
||||
LastScannedBlock: block,
|
||||
}
|
||||
database.Where("chain_id = ? AND scanner_type = ?", s.config.ChainID, "holder").Assign(state).FirstOrCreate(&state)
|
||||
database.Model(&state).Update("last_scanned_block", block)
|
||||
}
|
||||
|
||||
// loadAddressesFromDB restores in-memory address maps from holder_snapshots
|
||||
func (s *Scanner) loadAddressesFromDB() {
|
||||
database := db.GetDB()
|
||||
var snapshots []models.HolderSnapshot
|
||||
database.Where("chain_id = ?", s.config.ChainID).Find(&snapshots)
|
||||
|
||||
for _, snap := range snapshots {
|
||||
switch snap.TokenType {
|
||||
case "ytLP":
|
||||
s.lpAddresses[snap.HolderAddress] = snap.FirstSeen
|
||||
default:
|
||||
for _, vault := range s.config.YTVaults {
|
||||
if vault.Name == snap.TokenType {
|
||||
if s.ytAddresses[vault.Address] == nil {
|
||||
s.ytAddresses[vault.Address] = make(map[string]int64)
|
||||
}
|
||||
s.ytAddresses[vault.Address][snap.HolderAddress] = snap.FirstSeen
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("▶️ 从数据库加载了 %d 个历史地址", len(snapshots))
|
||||
}
|
||||
|
||||
// Start begins the scanning process
|
||||
func Start(config Config) error {
|
||||
scanner, err := NewScanner(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("=== Holder Scanner Started ===")
|
||||
log.Printf("RPC: %s\n", config.RPCURL)
|
||||
log.Printf("Poll Interval: %v\n", config.PollInterval)
|
||||
|
||||
// Check if we can resume from a previous scan
|
||||
lastBlock := scanner.loadStateFromDB()
|
||||
if lastBlock > 0 {
|
||||
log.Printf("▶️ 发现上次扫描记录,从区块 %d 续扫...", lastBlock)
|
||||
scanner.loadAddressesFromDB()
|
||||
scanner.lastScannedBlock = lastBlock
|
||||
} else {
|
||||
// Fresh start: full initial scan from deploy blocks
|
||||
log.Println("📊 首次扫描,从部署区块开始...")
|
||||
startTime := time.Now()
|
||||
if err := scanner.scanAll(context.Background(), true); err != nil {
|
||||
return fmt.Errorf("initial scan failed: %w", err)
|
||||
}
|
||||
log.Printf("✓ 初始扫描完成,耗时 %v\n", time.Since(startTime))
|
||||
}
|
||||
|
||||
// Start polling
|
||||
ticker := time.NewTicker(config.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Printf("⏰ 开始轮询,每 %v 扫一次新区块...\n", config.PollInterval)
|
||||
|
||||
for range ticker.C {
|
||||
if err := scanner.incrementalScan(context.Background()); err != nil {
|
||||
log.Printf("✗ Incremental scan error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// minUint64 returns the smaller of two uint64 values.
|
||||
func minUint64(a, b uint64) uint64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// scanBatch scans ALL contract types for a single block range [fromBlock, toBlock].
|
||||
// The range must be ≤ BatchSize. After this call, every contract has been scanned to toBlock.
|
||||
func (s *Scanner) scanBatch(ctx context.Context, fromBlock, toBlock uint64) {
|
||||
log.Printf(" [Batch] Scanning blocks %d → %d\n", fromBlock, toBlock)
|
||||
s.scanYTVaultTransfers(ctx, fromBlock, toBlock)
|
||||
s.scanYTLPTransfers(ctx, fromBlock, toBlock)
|
||||
s.scanSwapEvents(ctx, fromBlock, toBlock)
|
||||
|
||||
// Checkpoint: all contracts have been scanned up to toBlock
|
||||
s.mu.Lock()
|
||||
s.lastScannedBlock = toBlock
|
||||
s.mu.Unlock()
|
||||
s.saveStateToDB(toBlock)
|
||||
}
|
||||
|
||||
// scanAll performs a full scan from deployment blocks, saving a checkpoint after every batch.
|
||||
// All contract types are scanned together per batch, so the checkpoint is always safe.
|
||||
func (s *Scanner) scanAll(ctx context.Context, isInitial bool) error {
|
||||
s.mu.Lock()
|
||||
if s.isScanning {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("scan already in progress")
|
||||
}
|
||||
s.isScanning = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.isScanning = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
latestBlock, err := s.client.BlockNumber(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Current block: %d\n", latestBlock)
|
||||
|
||||
// Determine the earliest block we need to scan across all contracts
|
||||
startBlock := s.lastScannedBlock + 1
|
||||
if isInitial {
|
||||
startBlock = latestBlock // will be lowered below
|
||||
for _, v := range s.config.YTVaults {
|
||||
startBlock = minUint64(startBlock, v.DeployBlock)
|
||||
}
|
||||
if s.config.YTLPAddress != "" && s.config.DeploymentBlocks.YTLP > 0 {
|
||||
startBlock = minUint64(startBlock, s.config.DeploymentBlocks.YTLP)
|
||||
}
|
||||
}
|
||||
if startBlock > latestBlock {
|
||||
log.Printf("📌 No new blocks to scan (startBlock %d > latestBlock %d)\n", startBlock, latestBlock)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("📊 Scanning blocks %d → %d in batches of %d\n", startBlock, latestBlock, s.config.BatchSize)
|
||||
|
||||
// Outer loop: one checkpoint per batch, all contracts scanned together
|
||||
for batchFrom := startBlock; batchFrom <= latestBlock; {
|
||||
batchTo := minUint64(batchFrom+uint64(s.config.BatchSize)-1, latestBlock)
|
||||
s.scanBatch(ctx, batchFrom, batchTo)
|
||||
batchFrom = batchTo + 1
|
||||
|
||||
// Rate limiting between batches
|
||||
if batchFrom <= latestBlock {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Balance snapshots: run once after all event scanning is done
|
||||
log.Printf("📊 Updating balance snapshots...\n")
|
||||
for _, vault := range s.config.YTVaults {
|
||||
if err := s.saveYTHolders(ctx, vault); err != nil {
|
||||
log.Printf(" [Snapshot] %s error: %v", vault.Name, err)
|
||||
}
|
||||
}
|
||||
if err := s.saveYTLPHolders(ctx); err != nil {
|
||||
log.Printf(" [Snapshot] ytLP error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("📌 Scan complete. Last scanned block: %d\n", latestBlock)
|
||||
return nil
|
||||
}
|
||||
|
||||
// incrementalScan scans new blocks since last scan.
|
||||
// Incremental ranges are small (seconds of blocks), so a single batch suffices.
|
||||
func (s *Scanner) incrementalScan(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.isScanning {
|
||||
s.mu.Unlock()
|
||||
log.Println("⏰ Skipping scan (previous scan still running)")
|
||||
return nil
|
||||
}
|
||||
s.isScanning = true
|
||||
lastBlock := s.lastScannedBlock
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.isScanning = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
latestBlock, err := s.client.BlockNumber(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if latestBlock <= lastBlock {
|
||||
log.Printf("⏰ [%s] No new blocks (current: %d)\n", time.Now().Format("15:04:05"), latestBlock)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("\n%s\n", strings.Repeat("=", 60))
|
||||
log.Printf("⏰ [%s] Found new blocks %d → %d\n", time.Now().Format("15:04:05"), lastBlock+1, latestBlock)
|
||||
log.Printf("%s\n", strings.Repeat("=", 60))
|
||||
|
||||
startTime := time.Now()
|
||||
fromBlock := lastBlock + 1
|
||||
|
||||
// Incremental range may exceed BatchSize if server was down for a while; use batches
|
||||
for batchFrom := fromBlock; batchFrom <= latestBlock; {
|
||||
batchTo := minUint64(batchFrom+uint64(s.config.BatchSize)-1, latestBlock)
|
||||
s.scanBatch(ctx, batchFrom, batchTo)
|
||||
batchFrom = batchTo + 1
|
||||
if batchFrom <= latestBlock {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Update balance snapshots
|
||||
for _, vault := range s.config.YTVaults {
|
||||
if err := s.saveYTHolders(ctx, vault); err != nil {
|
||||
log.Printf(" [Snapshot] %s error: %v", vault.Name, err)
|
||||
}
|
||||
}
|
||||
if err := s.saveYTLPHolders(ctx); err != nil {
|
||||
log.Printf(" [Snapshot] ytLP error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✓ Incremental scan completed in %v\n", time.Since(startTime))
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanYTVaultTransfers scans Transfer events for all YT vaults in the given block range.
|
||||
// fromBlock/toBlock must already be a single batch (≤ BatchSize blocks).
|
||||
// Each vault skips blocks before its own deployBlock.
|
||||
// Does NOT save balance snapshots (call saveYTHolders separately after all batches complete).
|
||||
func (s *Scanner) scanYTVaultTransfers(ctx context.Context, fromBlock, toBlock uint64) error {
|
||||
for _, vault := range s.config.YTVaults {
|
||||
// Skip if vault not yet deployed in this range
|
||||
if vault.DeployBlock > toBlock {
|
||||
continue
|
||||
}
|
||||
effectiveFrom := fromBlock
|
||||
if vault.DeployBlock > effectiveFrom {
|
||||
effectiveFrom = vault.DeployBlock
|
||||
}
|
||||
|
||||
logs, err := s.queryBlockRange(ctx, common.HexToAddress(vault.Address), transferEventTopic, effectiveFrom, toBlock)
|
||||
if err != nil {
|
||||
log.Printf(" [Transfer] %s query error: %v", vault.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.ytAddresses[vault.Address] == nil {
|
||||
s.ytAddresses[vault.Address] = make(map[string]int64)
|
||||
}
|
||||
|
||||
for _, l := range logs {
|
||||
toAddress := common.BytesToAddress(l.Topics[2].Bytes()).Hex()
|
||||
if toAddress == "0x0000000000000000000000000000000000000000" {
|
||||
continue
|
||||
}
|
||||
if _, exists := s.ytAddresses[vault.Address][toAddress]; !exists {
|
||||
block, err := s.client.BlockByNumber(ctx, big.NewInt(int64(l.BlockNumber)))
|
||||
if err == nil {
|
||||
s.ytAddresses[vault.Address][toAddress] = int64(block.Time())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanYTLPTransfers scans Transfer events for the YT LP token in the given block range.
|
||||
// Does NOT save balance snapshots (call saveYTLPHolders separately after all batches complete).
|
||||
func (s *Scanner) scanYTLPTransfers(ctx context.Context, fromBlock, toBlock uint64) error {
|
||||
if s.config.YTLPAddress == "" {
|
||||
return nil
|
||||
}
|
||||
lpDeployBlock := s.config.DeploymentBlocks.YTLP
|
||||
if lpDeployBlock > toBlock {
|
||||
return nil
|
||||
}
|
||||
effectiveFrom := fromBlock
|
||||
if lpDeployBlock > effectiveFrom {
|
||||
effectiveFrom = lpDeployBlock
|
||||
}
|
||||
|
||||
logs, err := s.queryBlockRange(ctx, common.HexToAddress(s.config.YTLPAddress), transferEventTopic, effectiveFrom, toBlock)
|
||||
if err != nil {
|
||||
log.Printf(" [Transfer] ytLP query error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, l := range logs {
|
||||
toAddress := common.BytesToAddress(l.Topics[2].Bytes()).Hex()
|
||||
if toAddress == "0x0000000000000000000000000000000000000000" {
|
||||
continue
|
||||
}
|
||||
if _, exists := s.lpAddresses[toAddress]; !exists {
|
||||
block, err := s.client.BlockByNumber(ctx, big.NewInt(int64(l.BlockNumber)))
|
||||
if err == nil {
|
||||
s.lpAddresses[toAddress] = int64(block.Time())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// queryBlockRange queries logs for a single block range (no internal batching).
|
||||
// The caller is responsible for keeping the range within RPC limits (BatchSize).
|
||||
func (s *Scanner) queryBlockRange(ctx context.Context, contractAddr common.Address, topic common.Hash, fromBlock, toBlock uint64) ([]types.Log, error) {
|
||||
query := ethereum.FilterQuery{
|
||||
FromBlock: big.NewInt(int64(fromBlock)),
|
||||
ToBlock: big.NewInt(int64(toBlock)),
|
||||
Addresses: []common.Address{contractAddr},
|
||||
Topics: [][]common.Hash{{topic}},
|
||||
}
|
||||
logs, err := s.client.FilterLogs(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query logs [%d-%d]: %w", fromBlock, toBlock, err)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// queryLogsInBatches queries logs in batches to avoid RPC limits.
|
||||
// Used only by incremental scans where range size is unpredictable.
|
||||
func (s *Scanner) queryLogsInBatches(ctx context.Context, contractAddr common.Address, topic common.Hash, fromBlock, toBlock uint64) ([]types.Log, error) {
|
||||
var allLogs []types.Log
|
||||
currentBlock := fromBlock
|
||||
|
||||
log.Printf(" Querying blocks %d -> %d (total: %d blocks)\n", fromBlock, toBlock, toBlock-fromBlock+1)
|
||||
|
||||
for currentBlock <= toBlock {
|
||||
endBlock := currentBlock + uint64(s.config.BatchSize)
|
||||
if endBlock > toBlock {
|
||||
endBlock = toBlock
|
||||
}
|
||||
|
||||
log.Printf(" Querying blocks %d - %d...\n", currentBlock, endBlock)
|
||||
|
||||
logs, err := s.queryBlockRange(ctx, contractAddr, topic, currentBlock, endBlock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allLogs = append(allLogs, logs...)
|
||||
log.Printf(" ✓ Got %d events\n", len(logs))
|
||||
|
||||
currentBlock = endBlock + 1
|
||||
|
||||
// Rate limiting
|
||||
if currentBlock <= toBlock {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf(" Total: %d events\n\n", len(allLogs))
|
||||
return allLogs, nil
|
||||
}
|
||||
|
||||
// saveYTHolders queries balances and saves to database
|
||||
func (s *Scanner) saveYTHolders(ctx context.Context, vault VaultConfig) error {
|
||||
// ERC20 balanceOf ABI
|
||||
balanceOfABI, _ := abi.JSON(strings.NewReader(`[{"constant":true,"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]`))
|
||||
|
||||
database := db.GetDB()
|
||||
now := time.Now().Unix()
|
||||
holders := 0
|
||||
totalAddresses := len(s.ytAddresses[vault.Address])
|
||||
|
||||
contractAddr := common.HexToAddress(vault.Address)
|
||||
log.Printf("📞 [%s] 开始查询 %d 个地址的余额", vault.Name, totalAddresses)
|
||||
log.Printf("📍 [%s] 合约地址: %s", vault.Name, vault.Address)
|
||||
|
||||
processedCount := 0
|
||||
errorCount := 0
|
||||
zeroBalanceCount := 0
|
||||
|
||||
for address, firstSeen := range s.ytAddresses[vault.Address] {
|
||||
processedCount++
|
||||
|
||||
// Call balanceOf
|
||||
data, err := balanceOfABI.Pack("balanceOf", common.HexToAddress(address))
|
||||
if err != nil {
|
||||
log.Printf("❌ [%s] Pack balanceOf 失败: %s - %v", vault.Name, address, err)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := s.client.CallContract(ctx, ethereum.CallMsg{
|
||||
To: &contractAddr,
|
||||
Data: data,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
log.Printf("❌ [%s] CallContract 失败: %s - %v", vault.Name, address, err)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
balance := new(big.Int).SetBytes(result)
|
||||
|
||||
// Log balance query result
|
||||
if processedCount <= 5 || balance.Cmp(big.NewInt(0)) > 0 {
|
||||
log.Printf(" [%s] 地址: %s, 余额: %s", vault.Name, address[:10]+"...", balance.String())
|
||||
}
|
||||
|
||||
if balance.Cmp(big.NewInt(0)) == 0 {
|
||||
zeroBalanceCount++
|
||||
continue
|
||||
}
|
||||
|
||||
holders++
|
||||
|
||||
// Upsert to database: create if not exists, only update balance/last_updated if exists
|
||||
holder := models.HolderSnapshot{
|
||||
HolderAddress: address,
|
||||
TokenType: vault.Name,
|
||||
TokenAddress: vault.Address,
|
||||
Balance: balance.String(),
|
||||
ChainID: s.config.ChainID,
|
||||
FirstSeen: firstSeen,
|
||||
LastUpdated: now,
|
||||
}
|
||||
var existing models.HolderSnapshot
|
||||
res := database.Where("holder_address = ? AND token_type = ?", address, vault.Name).First(&existing)
|
||||
if res.Error != nil {
|
||||
database.Create(&holder)
|
||||
} else {
|
||||
database.Model(&existing).Updates(map[string]interface{}{
|
||||
"balance": balance.String(),
|
||||
"last_updated": now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
log.Printf("📊 [%s] 查询完成统计:", vault.Name)
|
||||
log.Printf(" • 总地址数: %d", totalAddresses)
|
||||
log.Printf(" • 已处理: %d", processedCount)
|
||||
log.Printf(" • 余额>0: %d ✅", holders)
|
||||
log.Printf(" • 余额=0: %d", zeroBalanceCount)
|
||||
log.Printf(" • 错误数: %d", errorCount)
|
||||
log.Printf(" • 已保存到数据库: %d", holders)
|
||||
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveYTLPHolders saves ytLP holders to database
|
||||
func (s *Scanner) saveYTLPHolders(ctx context.Context) error {
|
||||
balanceOfABI, _ := abi.JSON(strings.NewReader(`[{"constant":true,"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]`))
|
||||
|
||||
database := db.GetDB()
|
||||
now := time.Now().Unix()
|
||||
holders := 0
|
||||
totalAddresses := len(s.lpAddresses)
|
||||
|
||||
contractAddr := common.HexToAddress(s.config.YTLPAddress)
|
||||
log.Printf("📞 [ytLP] 开始查询 %d 个地址的余额", totalAddresses)
|
||||
log.Printf("📍 [ytLP] 合约地址: %s", s.config.YTLPAddress)
|
||||
|
||||
processedCount := 0
|
||||
errorCount := 0
|
||||
zeroBalanceCount := 0
|
||||
|
||||
for address, firstSeen := range s.lpAddresses {
|
||||
processedCount++
|
||||
|
||||
data, err := balanceOfABI.Pack("balanceOf", common.HexToAddress(address))
|
||||
if err != nil {
|
||||
log.Printf("❌ [ytLP] Pack balanceOf 失败: %s - %v", address, err)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := s.client.CallContract(ctx, ethereum.CallMsg{
|
||||
To: &contractAddr,
|
||||
Data: data,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ytLP] CallContract 失败: %s - %v", address, err)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
balance := new(big.Int).SetBytes(result)
|
||||
|
||||
// Log balance query result
|
||||
if processedCount <= 5 || balance.Cmp(big.NewInt(0)) > 0 {
|
||||
log.Printf(" [ytLP] 地址: %s, 余额: %s", address[:10]+"...", balance.String())
|
||||
}
|
||||
|
||||
if balance.Cmp(big.NewInt(0)) == 0 {
|
||||
zeroBalanceCount++
|
||||
continue
|
||||
}
|
||||
|
||||
holders++
|
||||
|
||||
holder := models.HolderSnapshot{
|
||||
HolderAddress: address,
|
||||
TokenType: "ytLP",
|
||||
TokenAddress: s.config.YTLPAddress,
|
||||
Balance: balance.String(),
|
||||
ChainID: s.config.ChainID,
|
||||
FirstSeen: firstSeen,
|
||||
LastUpdated: now,
|
||||
}
|
||||
var existing models.HolderSnapshot
|
||||
res := database.Where("holder_address = ? AND token_type = ?", address, "ytLP").First(&existing)
|
||||
if res.Error != nil {
|
||||
database.Create(&holder)
|
||||
} else {
|
||||
database.Model(&existing).Updates(map[string]interface{}{
|
||||
"balance": balance.String(),
|
||||
"last_updated": now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
log.Printf("📊 [ytLP] 查询完成统计:")
|
||||
log.Printf(" • 总地址数: %d", totalAddresses)
|
||||
log.Printf(" • 已处理: %d", processedCount)
|
||||
log.Printf(" • 余额>0: %d ✅", holders)
|
||||
log.Printf(" • 余额=0: %d", zeroBalanceCount)
|
||||
log.Printf(" • 错误数: %d", errorCount)
|
||||
log.Printf(" • 已保存到数据库: %d", holders)
|
||||
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
99
webapp-back/holders/swap_scanner.go
Normal file
99
webapp-back/holders/swap_scanner.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package holders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// Swap(address account, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint256 feeBasisPoints)
|
||||
// account/tokenIn/tokenOut are indexed → topics[1..3]
|
||||
// amountIn/amountOut/feeBasisPoints are non-indexed → data[0..95]
|
||||
var swapEventTopic = crypto.Keccak256Hash([]byte("Swap(address,address,address,uint256,uint256,uint256)"))
|
||||
|
||||
// scanSwapEvents scans Swap events from all YT vaults and upserts into yt_swap_records.
|
||||
func (s *Scanner) scanSwapEvents(ctx context.Context, fromBlock, toBlock uint64) error {
|
||||
if toBlock < fromBlock {
|
||||
return nil
|
||||
}
|
||||
|
||||
database := db.GetDB()
|
||||
totalNew := 0
|
||||
|
||||
// Cache block timestamps to avoid repeated RPC calls for the same block
|
||||
blockTimeCache := make(map[uint64]time.Time)
|
||||
|
||||
for _, vault := range s.config.YTVaults {
|
||||
log.Printf(" [SwapScanner] %s blocks %d→%d", vault.Name, fromBlock, toBlock)
|
||||
|
||||
logs, err := s.queryBlockRange(ctx, common.HexToAddress(vault.Address), swapEventTopic, fromBlock, toBlock)
|
||||
if err != nil {
|
||||
log.Printf(" [SwapScanner] Error scanning %s: %v", vault.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, l := range logs {
|
||||
// Validate topics and data length
|
||||
if len(l.Topics) < 4 || len(l.Data) < 64 {
|
||||
continue
|
||||
}
|
||||
|
||||
account := common.BytesToAddress(l.Topics[1].Bytes()).Hex()
|
||||
tokenIn := common.BytesToAddress(l.Topics[2].Bytes()).Hex()
|
||||
tokenOut := common.BytesToAddress(l.Topics[3].Bytes()).Hex()
|
||||
amountIn := new(big.Int).SetBytes(l.Data[0:32])
|
||||
amountOut := new(big.Int).SetBytes(l.Data[32:64])
|
||||
|
||||
// Get block time (cached per block number)
|
||||
blockTime, ok := blockTimeCache[l.BlockNumber]
|
||||
if !ok {
|
||||
blk, err := s.client.BlockByNumber(ctx, big.NewInt(int64(l.BlockNumber)))
|
||||
if err == nil {
|
||||
blockTime = time.Unix(int64(blk.Time()), 0)
|
||||
blockTimeCache[l.BlockNumber] = blockTime
|
||||
}
|
||||
}
|
||||
|
||||
record := models.YTSwapRecord{
|
||||
TxHash: l.TxHash.Hex(),
|
||||
LogIndex: uint(l.Index),
|
||||
ChainID: s.config.ChainID,
|
||||
BlockNumber: l.BlockNumber,
|
||||
BlockTime: blockTime,
|
||||
VaultAddr: vault.Address,
|
||||
Account: account,
|
||||
TokenIn: strings.ToLower(tokenIn),
|
||||
TokenOut: strings.ToLower(tokenOut),
|
||||
AmountIn: amountIn.String(),
|
||||
AmountOut: amountOut.String(),
|
||||
}
|
||||
|
||||
// Skip if already exists (idempotent)
|
||||
var existing models.YTSwapRecord
|
||||
res := database.Where("tx_hash = ? AND log_index = ?", record.TxHash, record.LogIndex).First(&existing)
|
||||
if res.Error == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := database.Create(&record).Error; err != nil {
|
||||
if !strings.Contains(err.Error(), "Duplicate") {
|
||||
log.Printf(" [SwapScanner] Save failed %s:%d: %v", record.TxHash, record.LogIndex, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
totalNew++
|
||||
}
|
||||
}
|
||||
|
||||
if totalNew > 0 {
|
||||
log.Printf(" [SwapScanner] Saved %d new swap records", totalNew)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
55
webapp-back/init_db.sh
Normal file
55
webapp-back/init_db.sh
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🗄️ Initializing AssetX Database..."
|
||||
|
||||
# MySQL connection settings
|
||||
MYSQL_USER="root"
|
||||
MYSQL_PASSWORD="123456"
|
||||
MYSQL_HOST="localhost"
|
||||
MYSQL_PORT="3306"
|
||||
DB_NAME="assetx"
|
||||
|
||||
# Check if MySQL is running
|
||||
if ! mysqladmin ping -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" --silent 2>/dev/null; then
|
||||
echo "❌ MySQL is not running or connection failed"
|
||||
echo " Please start MySQL first: sudo service mysql start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ MySQL is running"
|
||||
|
||||
# Create database if not exists
|
||||
echo "📝 Creating database..."
|
||||
mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "CREATE DATABASE IF NOT EXISTS $DB_NAME DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" 2>/dev/null
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Database '$DB_NAME' created/verified"
|
||||
else
|
||||
echo "❌ Failed to create database"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Import schema if schema file exists
|
||||
SCHEMA_FILE="../database-schema-v1.1-final.sql"
|
||||
if [ -f "$SCHEMA_FILE" ]; then
|
||||
echo "📥 Importing database schema..."
|
||||
mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$DB_NAME" < "$SCHEMA_FILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Schema imported successfully"
|
||||
else
|
||||
echo "⚠️ Schema import failed (might already exist)"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Schema file not found: $SCHEMA_FILE"
|
||||
echo " Tables will be created by GORM auto-migration"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Database initialization complete!"
|
||||
echo ""
|
||||
echo "Connection info:"
|
||||
echo " Host: $MYSQL_HOST:$MYSQL_PORT"
|
||||
echo " Database: $DB_NAME"
|
||||
echo " User: $MYSQL_USER"
|
||||
echo ""
|
||||
174
webapp-back/lending/INTEGRATION_NOTE.md
Normal file
174
webapp-back/lending/INTEGRATION_NOTE.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Integration with Existing Code
|
||||
|
||||
## 发现:holders 包已有类似实现
|
||||
|
||||
在 `holders/db_config.go` 中已经实现了从数据库加载合约地址的完整逻辑:
|
||||
|
||||
### holders/db_config.go 的实现
|
||||
```go
|
||||
// LoadConfigFromDB 从数据库加载配置(已存在)
|
||||
func LoadConfigFromDB(chainID int64) (Config, error) {
|
||||
// 1. 从 assets 表加载 YT-A, YT-B, YT-C
|
||||
var assets []Asset
|
||||
db.Table("assets").
|
||||
Where("asset_code IN (?, ?, ?)", "YT-A", "YT-B", "YT-C").
|
||||
Find(&assets)
|
||||
|
||||
// 2. 根据 chainID 选择合约地址
|
||||
for _, asset := range assets {
|
||||
address := asset.ContractAddressArb
|
||||
if isBSC {
|
||||
address = asset.ContractAddressBsc
|
||||
}
|
||||
ytVaults = append(ytVaults, VaultConfig{
|
||||
Name: asset.AssetCode,
|
||||
Address: address,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 从 lending_markets 表加载 Lending 合约
|
||||
var lendingMarkets []LendingMarket
|
||||
db.Table("lending_markets").
|
||||
Where("is_active = ?", 1).
|
||||
Find(&lendingMarkets)
|
||||
|
||||
lendingAddress := market.ContractAddressArb
|
||||
if isBSC {
|
||||
lendingAddress = market.ContractAddressBsc
|
||||
}
|
||||
|
||||
return Config{
|
||||
YTVaults: ytVaults,
|
||||
LendingAddress: lendingAddress,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### lending/helpers.go 的实现(新建)
|
||||
```go
|
||||
// GetYTTokenInfo 从 assets 表获取 YT token 信息(新实现)
|
||||
func GetYTTokenInfo(assetCode string) (*TokenInfo, error) {
|
||||
var asset models.Asset
|
||||
db.Where("asset_code = ? AND is_active = ?", assetCode, true).
|
||||
First(&asset)
|
||||
|
||||
return &TokenInfo{
|
||||
Symbol: asset.Name,
|
||||
ContractAddressArb: asset.ContractAddressArb,
|
||||
ContractAddressBsc: asset.ContractAddressBsc,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 代码复用建议
|
||||
|
||||
### 选项 1:统一使用 holders 包的结构体(推荐)
|
||||
```go
|
||||
// lending/helpers.go
|
||||
import "github.com/gothinkster/golang-gin-realworld-example-app/holders"
|
||||
|
||||
func GetYTTokensFromHolders(chainID int64) ([]TokenInfo, error) {
|
||||
// 复用 holders.LoadConfigFromDB
|
||||
config, err := holders.LoadConfigFromDB(chainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokens := make([]TokenInfo, len(config.YTVaults))
|
||||
for i, vault := range config.YTVaults {
|
||||
tokens[i] = TokenInfo{
|
||||
Symbol: vault.Name,
|
||||
ContractAddressArb: vault.Address, // 已根据 chainID 选择
|
||||
AssetCode: vault.Name,
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 选项 2:共享数据模型
|
||||
```go
|
||||
// common/models.go - 创建共享的 Asset 模型
|
||||
type Asset struct {
|
||||
ID int64
|
||||
AssetCode string
|
||||
Name string
|
||||
ContractAddressArb string
|
||||
ContractAddressBsc string
|
||||
}
|
||||
|
||||
// holders 和 lending 都使用这个共享模型
|
||||
```
|
||||
|
||||
### 选项 3:保持当前实现(已完成)
|
||||
- ✅ lending 包独立实现
|
||||
- ✅ 功能完整,逻辑清晰
|
||||
- ⚠️ 与 holders 包有重复代码
|
||||
|
||||
## 数据库表结构(一致)
|
||||
|
||||
两个包都使用相同的表:
|
||||
|
||||
### assets 表
|
||||
```sql
|
||||
CREATE TABLE assets (
|
||||
id BIGINT PRIMARY KEY,
|
||||
asset_code VARCHAR(20), -- YT-A, YT-B, YT-C
|
||||
name VARCHAR(255),
|
||||
contract_address_arb VARCHAR(42),
|
||||
contract_address_bsc VARCHAR(42),
|
||||
is_active BOOLEAN
|
||||
)
|
||||
```
|
||||
|
||||
### lending_markets 表
|
||||
```sql
|
||||
CREATE TABLE lending_markets (
|
||||
id BIGINT PRIMARY KEY,
|
||||
market_name VARCHAR(100),
|
||||
contract_address_arb VARCHAR(42),
|
||||
contract_address_bsc VARCHAR(42),
|
||||
is_active BOOLEAN
|
||||
)
|
||||
```
|
||||
|
||||
## Chain ID 处理
|
||||
|
||||
### holders 包
|
||||
- 421614 = Arbitrum Sepolia
|
||||
- 97 = BSC Testnet
|
||||
|
||||
### lending 包(需要添加)
|
||||
```go
|
||||
// helpers.go 添加 chain ID 支持
|
||||
func GetContractAddress(tokenInfo TokenInfo, chainID int) string {
|
||||
switch chainID {
|
||||
case 421614: // Arbitrum Sepolia
|
||||
return tokenInfo.ContractAddressArb
|
||||
case 97: // BSC Testnet
|
||||
return tokenInfo.ContractAddressBsc
|
||||
case 42161: // Arbitrum One (mainnet)
|
||||
return tokenInfo.ContractAddressArb
|
||||
case 56: // BSC Mainnet
|
||||
return tokenInfo.ContractAddressBsc
|
||||
default:
|
||||
return tokenInfo.ContractAddressArb
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
### 已实现功能
|
||||
- ✅ lending 包可以独立从 assets 表读取 YT tokens
|
||||
- ✅ lending 包可以从 lending_markets 表读取市场配置
|
||||
- ✅ USDC 地址已硬编码
|
||||
- ✅ 与 holders 包的实现逻辑一致
|
||||
|
||||
### 建议优化(可选)
|
||||
1. 考虑复用 holders.Asset 结构体
|
||||
2. 添加对 holders.LoadConfigFromDB 的引用
|
||||
3. 统一 chain ID 处理逻辑
|
||||
|
||||
### 当前状态
|
||||
**可以直接使用**,无需修改。与 holders 包的实现独立但兼容。
|
||||
285
webapp-back/lending/README.md
Normal file
285
webapp-back/lending/README.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Lending API Implementation
|
||||
|
||||
## Overview
|
||||
This directory contains the backend implementation for the AssetX Lending Market system.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Query Endpoints (Public)
|
||||
|
||||
#### Get User Position
|
||||
```
|
||||
GET /api/lending/position/:address
|
||||
```
|
||||
Returns user's lending position including:
|
||||
- Supplied USDC balance
|
||||
- Borrowed USDC balance
|
||||
- Collateral balances (YT-A, YT-B, YT-C)
|
||||
- Health Factor
|
||||
- LTV (Loan-to-Value) ratio
|
||||
- Supply/Borrow APY
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"user_address": "0x...",
|
||||
"wallet_address": "0x...",
|
||||
"supplied_balance": "10000.00",
|
||||
"supplied_balance_usd": 10000.00,
|
||||
"borrowed_balance": "1000.00",
|
||||
"borrowed_balance_usd": 1000.00,
|
||||
"collateral_balances": {
|
||||
"YT-A": {
|
||||
"token_symbol": "YT-A",
|
||||
"balance": "1000.00",
|
||||
"balance_usd": 2000000.00,
|
||||
"collateral_value": 1400000.00
|
||||
}
|
||||
},
|
||||
"health_factor": 14.0,
|
||||
"ltv": 10.0,
|
||||
"supply_apy": 6.1,
|
||||
"borrow_apy": 9.1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Lending Stats
|
||||
```
|
||||
GET /api/lending/stats
|
||||
```
|
||||
Returns lending market statistics:
|
||||
- Total supplied/borrowed USD
|
||||
- Total collateral USD
|
||||
- Utilization rate
|
||||
- Average APYs
|
||||
- User counts
|
||||
- Total TVL
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total_supplied_usd": 50000000.00,
|
||||
"total_borrowed_usd": 30000000.00,
|
||||
"total_collateral_usd": 80000000.00,
|
||||
"utilization_rate": 60.0,
|
||||
"avg_supply_apy": 6.1,
|
||||
"avg_borrow_apy": 9.1,
|
||||
"total_users": 1250,
|
||||
"active_borrowers": 450,
|
||||
"total_tvl": 130000000.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Lending Markets
|
||||
```
|
||||
GET /api/lending/markets
|
||||
```
|
||||
Returns lending market configuration from `lending_markets` table.
|
||||
|
||||
#### Get All Tokens Info
|
||||
```
|
||||
GET /api/lending/tokens
|
||||
```
|
||||
Returns information about all supported tokens (USDC + YT tokens from `assets` table).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"stablecoins": [
|
||||
{
|
||||
"symbol": "USDC",
|
||||
"name": "USD Coin",
|
||||
"decimals": 6,
|
||||
"contract_address_arb": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
||||
"contract_address_bsc": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d"
|
||||
}
|
||||
],
|
||||
"yt_tokens": [
|
||||
{
|
||||
"symbol": "YT-A",
|
||||
"name": "YT-A",
|
||||
"decimals": 18,
|
||||
"contract_address_arb": "0x...",
|
||||
"contract_address_bsc": "0x...",
|
||||
"asset_code": "YT-A"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Token Info
|
||||
```
|
||||
GET /api/lending/tokens/:assetCode
|
||||
```
|
||||
Returns information about a specific token (USDC or YT-A/YT-B/YT-C).
|
||||
|
||||
### Transaction Endpoints
|
||||
|
||||
#### Supply USDC
|
||||
```
|
||||
POST /api/lending/supply
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": "1000.00",
|
||||
"tx_hash": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Withdraw USDC
|
||||
```
|
||||
POST /api/lending/withdraw
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": "500.00",
|
||||
"tx_hash": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Supply Collateral
|
||||
```
|
||||
POST /api/lending/supply-collateral
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"asset": "YT-A",
|
||||
"amount": "100.00",
|
||||
"tx_hash": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Withdraw Collateral
|
||||
```
|
||||
POST /api/lending/withdraw-collateral
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"asset": "YT-A",
|
||||
"amount": "50.00",
|
||||
"tx_hash": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Borrow USDC
|
||||
```
|
||||
POST /api/lending/borrow
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": "1000.00",
|
||||
"tx_hash": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Repay USDC
|
||||
```
|
||||
POST /api/lending/repay
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": "500.00",
|
||||
"tx_hash": "0x..."
|
||||
}
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `models.go` - Data models and request/response structures
|
||||
- `handlers.go` - HTTP request handlers
|
||||
- `helpers.go` - Helper functions (token info, calculations, validation)
|
||||
- `tokens.go` - Token information endpoints
|
||||
- `routers.go` - Route documentation (routes registered in main.go)
|
||||
- `README.md` - This file
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
### ✅ Completed
|
||||
- API endpoint structure
|
||||
- Request/response models
|
||||
- Basic validation
|
||||
- Mock data responses
|
||||
- Database integration for token information
|
||||
- USDC configuration (hardcoded as per frontend)
|
||||
- YT token information from `assets` table
|
||||
- Token validation against database
|
||||
- Health factor and LTV calculation formulas
|
||||
- Token info query endpoints
|
||||
|
||||
### ⏳ TODO
|
||||
1. **Blockchain Integration**
|
||||
- Connect to smart contracts
|
||||
- Verify transactions on-chain
|
||||
- Query real-time balances
|
||||
- Calculate health factor from chain data
|
||||
|
||||
2. **Database Integration**
|
||||
- Store transaction records
|
||||
- Update lending_markets table
|
||||
- Track user positions
|
||||
- Generate statistics
|
||||
|
||||
3. **Authentication**
|
||||
- Add wallet signature verification
|
||||
- Implement user session management
|
||||
|
||||
4. **Business Logic**
|
||||
- Interest accrual calculations
|
||||
- Health factor monitoring
|
||||
- Liquidation logic
|
||||
- APY calculations
|
||||
|
||||
## Database Schema
|
||||
|
||||
The lending system uses the following tables from `database-schema-v1.1-final.sql`:
|
||||
|
||||
- `lending_markets` - Lending market configuration
|
||||
- `transactions` - Transaction records
|
||||
- `protocol_stats` - Protocol-level statistics
|
||||
|
||||
Note: User position data is primarily read from blockchain, with caching in database.
|
||||
|
||||
## Integration with Frontend
|
||||
|
||||
Frontend components that use these APIs:
|
||||
|
||||
- `/lending/supply/SupplyPanel.tsx` - Supply USDC
|
||||
- `/lending/supply/WithdrawPanel.tsx` - Withdraw USDC
|
||||
- `/lending/repay/RepayBorrowDebt.tsx` - Borrow/Repay USDC
|
||||
- `/lending/repay/RepaySupplyCollateral.tsx` - Supply/Withdraw Collateral
|
||||
- `/lending/BorrowMarket.tsx` - Display lending positions
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
go run main.go
|
||||
|
||||
# Test endpoints
|
||||
curl http://localhost:8080/api/lending/stats
|
||||
curl http://localhost:8080/api/lending/position/0x1234...
|
||||
|
||||
# Test transactions (requires auth)
|
||||
curl -X POST http://localhost:8080/api/lending/supply \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"amount": "1000", "tx_hash": "0x..."}'
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement blockchain RPC integration
|
||||
2. Add real transaction verification
|
||||
3. Implement health factor calculations
|
||||
4. Add liquidation monitoring
|
||||
5. Integrate with frontend
|
||||
6. Add comprehensive tests
|
||||
7. Deploy to testnet
|
||||
631
webapp-back/lending/collateral_buyer_bot.go
Normal file
631
webapp-back/lending/collateral_buyer_bot.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package lending
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi/bind"
|
||||
ethcommon "github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
dbpkg "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
buyerLoopDelay = 10 * time.Second
|
||||
buyerLookbackBlocks = int64(10000)
|
||||
)
|
||||
|
||||
const collateralBuyerABIStr = `[
|
||||
{"anonymous":false,"inputs":[
|
||||
{"indexed":true,"name":"buyer","type":"address"},
|
||||
{"indexed":true,"name":"asset","type":"address"},
|
||||
{"indexed":false,"name":"baseAmount","type":"uint256"},
|
||||
{"indexed":false,"name":"collateralAmount","type":"uint256"}
|
||||
],"name":"BuyCollateral","type":"event"},
|
||||
{"inputs":[],"name":"getReserves","outputs":[{"name":"","type":"int256"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[],"name":"targetReserves","outputs":[{"name":"","type":"uint104"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[{"name":"asset","type":"address"}],"name":"getCollateralReserves","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[{"name":"asset","type":"address"},{"name":"baseAmount","type":"uint256"}],"name":"quoteCollateral","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[{"name":"asset","type":"address"},{"name":"minAmount","type":"uint256"},{"name":"baseAmount","type":"uint256"},{"name":"recipient","type":"address"}],"name":"buyCollateral","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
||||
{"inputs":[],"name":"baseToken","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[{"name":"","type":"uint256"}],"name":"assetList","outputs":[{"name":"","type":"address"}],"stateMutability":"view","type":"function"}
|
||||
]`
|
||||
|
||||
const erc20BuyerABIStr = `[
|
||||
{"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},
|
||||
{"inputs":[{"name":"owner","type":"address"},{"name":"spender","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"stateMutability":"view","type":"function"}
|
||||
]`
|
||||
|
||||
// BuyerBotStatus is returned by the status API
|
||||
type BuyerBotStatus struct {
|
||||
Running bool `json:"running"`
|
||||
BuyerAddr string `json:"buyer_addr"`
|
||||
ChainID int `json:"chain_id"`
|
||||
ContractAddr string `json:"contract_addr"`
|
||||
TotalBuys int64 `json:"total_buys"`
|
||||
LastCheckTime time.Time `json:"last_check_time"`
|
||||
LastBlockChecked uint64 `json:"last_block_checked"`
|
||||
LoopDelayMs int `json:"loop_delay_ms"`
|
||||
LookbackBlocks int64 `json:"lookback_blocks"`
|
||||
SlippagePct int `json:"slippage_pct"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type collateralBuyerBot struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
buyerAddr string
|
||||
chainID int
|
||||
contractAddr string
|
||||
totalBuys int64
|
||||
lastCheckTime time.Time
|
||||
lastBlockChecked uint64
|
||||
lastErr string
|
||||
slippagePct int
|
||||
}
|
||||
|
||||
var globalBuyer = &collateralBuyerBot{}
|
||||
|
||||
func GetBuyerBotStatus() BuyerBotStatus {
|
||||
globalBuyer.mu.Lock()
|
||||
defer globalBuyer.mu.Unlock()
|
||||
return BuyerBotStatus{
|
||||
Running: globalBuyer.running,
|
||||
BuyerAddr: globalBuyer.buyerAddr,
|
||||
ChainID: globalBuyer.chainID,
|
||||
ContractAddr: globalBuyer.contractAddr,
|
||||
TotalBuys: globalBuyer.totalBuys,
|
||||
LastCheckTime: globalBuyer.lastCheckTime,
|
||||
LastBlockChecked: globalBuyer.lastBlockChecked,
|
||||
LoopDelayMs: int(buyerLoopDelay.Milliseconds()),
|
||||
LookbackBlocks: buyerLookbackBlocks,
|
||||
SlippagePct: globalBuyer.slippagePct,
|
||||
Error: globalBuyer.lastErr,
|
||||
}
|
||||
}
|
||||
|
||||
func StartCollateralBuyerBot(cfg *appcfg.Config) {
|
||||
// 优先用独立私钥,未配置则 fallback 到清算机器人私钥(不修改 cfg 原始值)
|
||||
privKey := cfg.CollateralBuyerPrivateKey
|
||||
if privKey == "" && cfg.LiquidatorPrivateKey != "" {
|
||||
privKey = cfg.LiquidatorPrivateKey
|
||||
log.Println("[BuyerBot] COLLATERAL_BUYER_PRIVATE_KEY 未配置,使用 LIQUIDATOR_PRIVATE_KEY")
|
||||
}
|
||||
if privKey == "" {
|
||||
log.Println("[BuyerBot] 未配置私钥,Bot 未启动")
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.lastErr = "未配置私钥,Bot 未启动"
|
||||
globalBuyer.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
globalBuyer.mu.Lock()
|
||||
if globalBuyer.running {
|
||||
globalBuyer.mu.Unlock()
|
||||
return
|
||||
}
|
||||
globalBuyer.stopChan = make(chan struct{})
|
||||
globalBuyer.running = true
|
||||
globalBuyer.lastErr = ""
|
||||
globalBuyer.mu.Unlock()
|
||||
|
||||
go runBuyerBot(cfg, privKey)
|
||||
log.Println("[BuyerBot] Started")
|
||||
}
|
||||
|
||||
func StopCollateralBuyerBot() {
|
||||
globalBuyer.mu.Lock()
|
||||
defer globalBuyer.mu.Unlock()
|
||||
if !globalBuyer.running {
|
||||
return
|
||||
}
|
||||
close(globalBuyer.stopChan)
|
||||
globalBuyer.running = false
|
||||
log.Println("[BuyerBot] Stop signal sent")
|
||||
}
|
||||
|
||||
func runBuyerBot(cfg *appcfg.Config, privKeyStr string) {
|
||||
defer func() {
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.running = false
|
||||
globalBuyer.mu.Unlock()
|
||||
log.Println("[BuyerBot] Goroutine exited")
|
||||
}()
|
||||
|
||||
privKeyHex := strings.TrimPrefix(privKeyStr, "0x")
|
||||
privateKey, err := crypto.HexToECDSA(privKeyHex)
|
||||
if err != nil {
|
||||
log.Printf("[BuyerBot] Invalid private key: %v", err)
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.lastErr = fmt.Sprintf("invalid private key: %v", err)
|
||||
globalBuyer.running = false
|
||||
globalBuyer.mu.Unlock()
|
||||
return
|
||||
}
|
||||
buyerAddr := crypto.PubkeyToAddress(privateKey.PublicKey)
|
||||
|
||||
lendingAddr, chainID, loadErr := loadBotContracts()
|
||||
if loadErr != nil {
|
||||
log.Printf("[BuyerBot] Load contracts: %v", loadErr)
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.lastErr = loadErr.Error()
|
||||
globalBuyer.running = false
|
||||
globalBuyer.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
rpcURL := getRPCURL(chainID)
|
||||
if rpcURL == "" {
|
||||
log.Printf("[BuyerBot] No RPC URL for chain %d", chainID)
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.lastErr = fmt.Sprintf("no RPC URL for chain %d", chainID)
|
||||
globalBuyer.running = false
|
||||
globalBuyer.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
slippagePct := cfg.CollateralBuyerSlippage
|
||||
if slippagePct <= 0 || slippagePct > 10 {
|
||||
slippagePct = 1
|
||||
}
|
||||
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.buyerAddr = buyerAddr.Hex()
|
||||
globalBuyer.chainID = chainID
|
||||
globalBuyer.contractAddr = lendingAddr
|
||||
globalBuyer.slippagePct = slippagePct
|
||||
globalBuyer.mu.Unlock()
|
||||
|
||||
log.Printf("[BuyerBot] Buyer: %s | Chain: %d | Lending: %s | Slippage: %d%%",
|
||||
buyerAddr.Hex(), chainID, lendingAddr, slippagePct)
|
||||
|
||||
lABI, err := abi.JSON(strings.NewReader(collateralBuyerABIStr))
|
||||
if err != nil {
|
||||
log.Printf("[BuyerBot] ABI parse error: %v", err)
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.lastErr = fmt.Sprintf("ABI parse error: %v", err)
|
||||
globalBuyer.running = false
|
||||
globalBuyer.mu.Unlock()
|
||||
return
|
||||
}
|
||||
processedBlock := loadBuyerBlock(chainID)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-globalBuyer.stopChan:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
caughtUp := buyerTick(rpcURL, lABI, privateKey, buyerAddr,
|
||||
lendingAddr, chainID, slippagePct, &processedBlock)
|
||||
|
||||
if caughtUp {
|
||||
select {
|
||||
case <-globalBuyer.stopChan:
|
||||
return
|
||||
case <-time.After(buyerLoopDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buyerTick(
|
||||
rpcURL string,
|
||||
lABI abi.ABI,
|
||||
privateKey *ecdsa.PrivateKey,
|
||||
buyerAddr ethcommon.Address,
|
||||
lendingAddr string,
|
||||
chainID int,
|
||||
slippagePct int,
|
||||
processedBlock *uint64,
|
||||
) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
||||
if err != nil {
|
||||
log.Printf("[BuyerBot] RPC dial error: %v", err)
|
||||
return true
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
currentBlock, err := client.BlockNumber(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[BuyerBot] BlockNumber error: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
if *processedBlock == 0 {
|
||||
if currentBlock >= uint64(buyerLookbackBlocks) {
|
||||
*processedBlock = currentBlock - uint64(buyerLookbackBlocks)
|
||||
}
|
||||
}
|
||||
|
||||
fromBlock := *processedBlock + 1
|
||||
toBlock := fromBlock + uint64(buyerLookbackBlocks) - 1
|
||||
if toBlock > currentBlock {
|
||||
toBlock = currentBlock
|
||||
}
|
||||
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.lastCheckTime = time.Now()
|
||||
globalBuyer.lastBlockChecked = toBlock
|
||||
globalBuyer.mu.Unlock()
|
||||
|
||||
lendingContract := ethcommon.HexToAddress(lendingAddr)
|
||||
|
||||
// ── 1. 检查国库条件(轮询核心) ──────────────────────────────────────
|
||||
reserves, targReserves, condErr := checkBuyCondition(ctx, client, lABI, lendingContract)
|
||||
if condErr != nil {
|
||||
log.Printf("[BuyerBot] 条件检查失败: %v", condErr)
|
||||
saveBuyerBlock(chainID, toBlock)
|
||||
*processedBlock = toBlock
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
|
||||
if reserves.Cmp(targReserves) >= 0 {
|
||||
// 国库充足,静默推进
|
||||
saveBuyerBlock(chainID, toBlock)
|
||||
*processedBlock = toBlock
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
|
||||
log.Printf("[BuyerBot] 国库不足: reserves=%s < targetReserves=%s,开始购买",
|
||||
reserves.String(), targReserves.String())
|
||||
|
||||
saveBuyerBlock(chainID, toBlock)
|
||||
*processedBlock = toBlock
|
||||
|
||||
// ── 2. 获取 baseToken 及精度 ─────────────────────────────────────────
|
||||
eABI, eABIErr := abi.JSON(strings.NewReader(erc20BuyerABIStr))
|
||||
if eABIErr != nil {
|
||||
log.Printf("[BuyerBot] ERC20 ABI parse error: %v", eABIErr)
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
baseTokenAddr, btErr := callViewAddress(ctx, client, lABI, lendingContract, "baseToken")
|
||||
if btErr != nil {
|
||||
log.Printf("[BuyerBot] baseToken error: %v", btErr)
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
|
||||
// ── 3. 扫描所有已配置资产,过滤出储备 > 0 的 ────────────────────────
|
||||
type assetInfo struct {
|
||||
addr ethcommon.Address
|
||||
reserve *big.Int
|
||||
symbol string
|
||||
}
|
||||
var assetsWithReserves []assetInfo
|
||||
|
||||
allAssets := getAllAssets(ctx, client, lABI, lendingContract)
|
||||
for addr := range allAssets {
|
||||
collRes, err := callViewRaw(ctx, client, lABI, lendingContract, "getCollateralReserves", addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
reserve, ok := collRes[0].(*big.Int)
|
||||
if !ok || reserve.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
sym := getAssetSymbol(ctx, client, eABI, addr)
|
||||
assetsWithReserves = append(assetsWithReserves, assetInfo{addr: addr, reserve: new(big.Int).Set(reserve), symbol: sym})
|
||||
log.Printf("[BuyerBot] 可购买资产: %s 储备=%s", sym, reserve.String())
|
||||
}
|
||||
|
||||
if len(assetsWithReserves) == 0 {
|
||||
log.Println("[BuyerBot] 无可购买资产(储备均为零)")
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
|
||||
// ── 4. 一次性授权检查(allowance < MaxUint256/2 才 approve)──────────
|
||||
maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
|
||||
halfMax := new(big.Int).Rsh(maxUint256, 1)
|
||||
|
||||
allowance, alwErr := callERC20BigInt(ctx, client, eABI, baseTokenAddr, "allowance", buyerAddr, lendingContract)
|
||||
if alwErr != nil {
|
||||
log.Printf("[BuyerBot] allowance error: %v", alwErr)
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
if allowance.Cmp(halfMax) < 0 {
|
||||
log.Printf("[BuyerBot] 授权不足,执行 approve(MaxUint256)...")
|
||||
chainIDBig := big.NewInt(int64(chainID))
|
||||
auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig)
|
||||
if authErr != nil {
|
||||
log.Printf("[BuyerBot] create transactor: %v", authErr)
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
auth.GasLimit = 100000
|
||||
erc20Bound := bind.NewBoundContract(baseTokenAddr, eABI, client, client, client)
|
||||
approveTx, appErr := erc20Bound.Transact(auth, "approve", lendingContract, maxUint256)
|
||||
if appErr != nil {
|
||||
log.Printf("[BuyerBot] approve error: %v", appErr)
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
approveCtx, approveCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
_, waitErr := bind.WaitMined(approveCtx, client, approveTx)
|
||||
approveCancel()
|
||||
if waitErr != nil {
|
||||
log.Printf("[BuyerBot] approve wait: %v", waitErr)
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
log.Printf("[BuyerBot] 授权成功: %s", approveTx.Hash().Hex())
|
||||
} else {
|
||||
log.Println("[BuyerBot] 授权充足,跳过 approve")
|
||||
}
|
||||
|
||||
// ── 5. 逐资产购买 ───────────────────────────────────────────────────
|
||||
chainIDBig := big.NewInt(int64(chainID))
|
||||
successCount := 0
|
||||
|
||||
for _, asset := range assetsWithReserves {
|
||||
// 每次购买前重新读取余额
|
||||
freshBalance, balErr := callERC20BigInt(ctx, client, eABI, baseTokenAddr, "balanceOf", buyerAddr)
|
||||
if balErr != nil {
|
||||
log.Printf("[BuyerBot] balanceOf error: %v", balErr)
|
||||
break
|
||||
}
|
||||
if freshBalance.Sign() == 0 {
|
||||
log.Println("[BuyerBot] 买家余额耗尽,停止购买")
|
||||
break
|
||||
}
|
||||
|
||||
// minAmount = reserve * (100 - slippagePct) / 100
|
||||
minAmount := new(big.Int).Mul(asset.reserve, big.NewInt(int64(100-slippagePct)))
|
||||
minAmount.Div(minAmount, big.NewInt(100))
|
||||
|
||||
log.Printf("[BuyerBot] 购买 %s: balance=%s reserve=%s minAmount=%s",
|
||||
asset.symbol, freshBalance.String(), asset.reserve.String(), minAmount.String())
|
||||
|
||||
actualPaid, actualReceived, txHash, gasUsed, blockNum, buyErr := executeBuy(
|
||||
ctx, client, lABI, privateKey, buyerAddr,
|
||||
lendingContract, asset.addr, minAmount, freshBalance,
|
||||
chainIDBig,
|
||||
)
|
||||
if buyErr != nil {
|
||||
log.Printf("[BuyerBot] 购买 %s 失败(跳过): %v", asset.symbol, buyErr)
|
||||
// 失败时用时间戳生成唯一 key,避免 uniqueIndex 冲突
|
||||
failKey := fmt.Sprintf("FAILED_%s_%d", asset.addr.Hex()[:10], time.Now().UnixNano())
|
||||
saveBuyRecord(chainID, failKey, buyerAddr, asset.addr, asset.symbol,
|
||||
freshBalance, big.NewInt(0), 0, 0, "failed", buyErr.Error())
|
||||
continue // 单资产失败不影响其他资产
|
||||
}
|
||||
|
||||
log.Printf("[BuyerBot] %s 购买成功: 支付=%s 获得=%s tx=%s",
|
||||
asset.symbol, actualPaid.String(), actualReceived.String(), txHash)
|
||||
saveBuyRecord(chainID, txHash, buyerAddr, asset.addr, asset.symbol,
|
||||
actualPaid, actualReceived, gasUsed, blockNum, "success", "")
|
||||
globalBuyer.mu.Lock()
|
||||
globalBuyer.totalBuys++
|
||||
globalBuyer.mu.Unlock()
|
||||
successCount++
|
||||
}
|
||||
|
||||
log.Printf("[BuyerBot] 本轮完成: 成功 %d / %d 个资产", successCount, len(assetsWithReserves))
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
|
||||
// executeBuy 执行一次 buyCollateral 并从事件解析实际支付/获得量
|
||||
func executeBuy(
|
||||
ctx context.Context,
|
||||
client *ethclient.Client,
|
||||
lABI abi.ABI,
|
||||
privateKey *ecdsa.PrivateKey,
|
||||
buyerAddr ethcommon.Address,
|
||||
lendingContract ethcommon.Address,
|
||||
asset ethcommon.Address,
|
||||
minAmount *big.Int,
|
||||
baseAmount *big.Int, // 买家全部余额作为上限
|
||||
chainIDBig *big.Int,
|
||||
) (actualPaid *big.Int, actualReceived *big.Int, txHash string, gasUsed, blockNum uint64, err error) {
|
||||
auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig)
|
||||
if authErr != nil {
|
||||
return nil, nil, "", 0, 0, fmt.Errorf("create transactor: %w", authErr)
|
||||
}
|
||||
auth.GasLimit = 300000
|
||||
|
||||
lendingBound := bind.NewBoundContract(lendingContract, lABI, client, client, client)
|
||||
tx, txErr := lendingBound.Transact(auth, "buyCollateral", asset, minAmount, baseAmount, buyerAddr)
|
||||
if txErr != nil {
|
||||
return nil, nil, "", 0, 0, fmt.Errorf("buyCollateral: %w", txErr)
|
||||
}
|
||||
|
||||
txHash = tx.Hash().Hex()
|
||||
log.Printf("[BuyerBot] 交易已提交: %s", txHash)
|
||||
|
||||
receiptCtx, receiptCancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer receiptCancel()
|
||||
receipt, waitErr := bind.WaitMined(receiptCtx, client, tx)
|
||||
if waitErr != nil {
|
||||
return nil, nil, txHash, 0, 0, fmt.Errorf("wait mined: %w", waitErr)
|
||||
}
|
||||
|
||||
gasUsed = receipt.GasUsed
|
||||
blockNum = receipt.BlockNumber.Uint64()
|
||||
|
||||
if receipt.Status != 1 {
|
||||
return nil, nil, txHash, gasUsed, blockNum,
|
||||
fmt.Errorf("交易回滚 block %d, tx %s", blockNum, txHash)
|
||||
}
|
||||
|
||||
log.Printf("[BuyerBot] 交易确认 Gas=%d Block=%d", gasUsed, blockNum)
|
||||
|
||||
// 从 BuyCollateral 事件解析实际数值
|
||||
eventID := lABI.Events["BuyCollateral"].ID
|
||||
for _, l := range receipt.Logs {
|
||||
if len(l.Topics) < 1 || l.Topics[0] != eventID {
|
||||
continue
|
||||
}
|
||||
decoded, decErr := lABI.Unpack("BuyCollateral", l.Data)
|
||||
if decErr != nil || len(decoded) < 2 {
|
||||
continue
|
||||
}
|
||||
paid, ok1 := decoded[0].(*big.Int)
|
||||
received, ok2 := decoded[1].(*big.Int)
|
||||
if ok1 && ok2 {
|
||||
return paid, received, txHash, gasUsed, blockNum, nil
|
||||
}
|
||||
}
|
||||
|
||||
// BuyCollateral 事件未找到,用余额差估算(实际数量需链上核实)
|
||||
log.Printf("[BuyerBot] 警告: BuyCollateral 事件解析失败 tx=%s,receivedAmount 记录为 0", txHash)
|
||||
return baseAmount, big.NewInt(0), txHash, gasUsed, blockNum, nil
|
||||
}
|
||||
|
||||
// ── 合约枚举 / 条件检查 helpers ──────────────────────────────────────────────
|
||||
|
||||
// getAllAssets 遍历 assetList(0,1,...) 直到 revert
|
||||
func getAllAssets(ctx context.Context, client *ethclient.Client, lABI abi.ABI,
|
||||
contract ethcommon.Address) map[ethcommon.Address]struct{} {
|
||||
result := make(map[ethcommon.Address]struct{})
|
||||
zero := ethcommon.Address{}
|
||||
for i := 0; i < 50; i++ {
|
||||
res, err := callViewRaw(ctx, client, lABI, contract, "assetList", big.NewInt(int64(i)))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
addr, ok := res[0].(ethcommon.Address)
|
||||
if !ok || addr == zero {
|
||||
break
|
||||
}
|
||||
result[addr] = struct{}{}
|
||||
}
|
||||
log.Printf("[BuyerBot] assetList: %d 个资产", len(result))
|
||||
return result
|
||||
}
|
||||
|
||||
// checkBuyCondition 返回 (reserves int256, targetReserves, error)
|
||||
func checkBuyCondition(ctx context.Context, client *ethclient.Client, lABI abi.ABI,
|
||||
addr ethcommon.Address) (*big.Int, *big.Int, error) {
|
||||
res, err := callViewRaw(ctx, client, lABI, addr, "getReserves")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getReserves: %w", err)
|
||||
}
|
||||
reserves, ok := res[0].(*big.Int)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("getReserves type %T", res[0])
|
||||
}
|
||||
res2, err := callViewRaw(ctx, client, lABI, addr, "targetReserves")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("targetReserves: %w", err)
|
||||
}
|
||||
targReserves, ok2 := res2[0].(*big.Int)
|
||||
if !ok2 {
|
||||
return nil, nil, fmt.Errorf("targetReserves type %T", res2[0])
|
||||
}
|
||||
return reserves, targReserves, nil
|
||||
}
|
||||
|
||||
// ── ABI call helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
func callViewAddress(ctx context.Context, client *ethclient.Client, contractABI abi.ABI,
|
||||
addr ethcommon.Address, method string, args ...interface{}) (ethcommon.Address, error) {
|
||||
res, err := callViewRaw(ctx, client, contractABI, addr, method, args...)
|
||||
if err != nil {
|
||||
return ethcommon.Address{}, err
|
||||
}
|
||||
v, ok := res[0].(ethcommon.Address)
|
||||
if !ok {
|
||||
return ethcommon.Address{}, fmt.Errorf("%s type %T", method, res[0])
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func callERC20BigInt(ctx context.Context, client *ethclient.Client, eABI abi.ABI,
|
||||
addr ethcommon.Address, method string, args ...interface{}) (*big.Int, error) {
|
||||
data, err := eABI.Pack(method, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pack %s: %w", method, err)
|
||||
}
|
||||
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &addr, Data: data}, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("call %s: %w", method, err)
|
||||
}
|
||||
decoded, err := eABI.Unpack(method, result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unpack %s: %w", method, err)
|
||||
}
|
||||
v, ok := decoded[0].(*big.Int)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s type %T", method, decoded[0])
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func getAssetSymbol(ctx context.Context, client *ethclient.Client, eABI abi.ABI, asset ethcommon.Address) string {
|
||||
data, _ := eABI.Pack("symbol")
|
||||
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &asset, Data: data}, nil)
|
||||
if err != nil {
|
||||
return asset.Hex()[:10]
|
||||
}
|
||||
decoded, err := eABI.Unpack("symbol", result)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return asset.Hex()[:10]
|
||||
}
|
||||
s, ok := decoded[0].(string)
|
||||
if !ok {
|
||||
return asset.Hex()[:10]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── DB helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
func loadBuyerBlock(chainID int) uint64 {
|
||||
db := dbpkg.GetDB()
|
||||
var state models.ScannerState
|
||||
if err := db.Where("chain_id = ? AND scanner_type = ?", chainID, "collateral_buyer").First(&state).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
return state.LastScannedBlock
|
||||
}
|
||||
|
||||
func saveBuyerBlock(chainID int, block uint64) {
|
||||
db := dbpkg.GetDB()
|
||||
state := models.ScannerState{
|
||||
ScannerType: "collateral_buyer",
|
||||
ChainID: chainID,
|
||||
LastScannedBlock: block,
|
||||
}
|
||||
db.Where("chain_id = ? AND scanner_type = ?", chainID, "collateral_buyer").Assign(state).FirstOrCreate(&state)
|
||||
db.Model(&state).Updates(map[string]interface{}{
|
||||
"last_scanned_block": block,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func saveBuyRecord(chainID int, txHash string, buyerAddr, assetAddr ethcommon.Address,
|
||||
assetSymbol string, paidAmount, receivedAmount *big.Int, gasUsed, blockNum uint64, status, errMsg string) {
|
||||
record := models.CollateralBuyRecord{
|
||||
ChainID: chainID,
|
||||
TxHash: txHash,
|
||||
BuyerAddr: buyerAddr.Hex(),
|
||||
AssetAddr: assetAddr.Hex(),
|
||||
AssetSymbol: assetSymbol,
|
||||
PaidAmount: paidAmount.String(),
|
||||
ReceivedAmount: receivedAmount.String(),
|
||||
GasUsed: gasUsed,
|
||||
BlockNumber: blockNum,
|
||||
Status: status,
|
||||
ErrorMessage: errMsg,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
db := dbpkg.GetDB()
|
||||
if dbErr := db.Create(&record).Error; dbErr != nil {
|
||||
log.Printf("[BuyerBot] Save buy record error: %v", dbErr)
|
||||
}
|
||||
}
|
||||
342
webapp-back/lending/handlers.go
Normal file
342
webapp-back/lending/handlers.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package lending
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// GetUserPosition returns user's lending position
|
||||
// GET /api/lending/position/:address
|
||||
func GetUserPosition(c *gin.Context) {
|
||||
address := c.Param("address")
|
||||
|
||||
if address == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "wallet address is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
config := GetLendingMarketConfig()
|
||||
position := &UserPosition{
|
||||
UserAddress: address,
|
||||
WalletAddress: address,
|
||||
SuppliedBalance: "0",
|
||||
SuppliedBalanceUSD: 0,
|
||||
BorrowedBalance: "0",
|
||||
BorrowedBalanceUSD: 0,
|
||||
CollateralBalances: map[string]CollateralInfo{},
|
||||
HealthFactor: 0,
|
||||
LTV: 0,
|
||||
SupplyAPY: config["base_supply_apy"].(float64),
|
||||
BorrowAPY: config["base_borrow_apy"].(float64),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": position,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLendingStats returns lending market statistics
|
||||
// GET /api/lending/stats
|
||||
func GetLendingStats(c *gin.Context) {
|
||||
config := GetLendingMarketConfig()
|
||||
stats := &LendingStats{
|
||||
TotalSuppliedUSD: 0,
|
||||
TotalBorrowedUSD: 0,
|
||||
TotalCollateralUSD: 0,
|
||||
UtilizationRate: 0,
|
||||
AvgSupplyAPY: config["base_supply_apy"].(float64),
|
||||
AvgBorrowAPY: config["base_borrow_apy"].(float64),
|
||||
TotalUsers: 0,
|
||||
ActiveBorrowers: 0,
|
||||
TotalTVL: 0,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLendingMarkets returns lending market configuration.
|
||||
// Contract addresses come from system_contracts; static config from GetLendingMarketConfig.
|
||||
// GET /api/lending/markets
|
||||
func GetLendingMarkets(c *gin.Context) {
|
||||
db := common.GetDB()
|
||||
|
||||
var lc struct {
|
||||
Address string `gorm:"column:address"`
|
||||
ChainID int `gorm:"column:chain_id"`
|
||||
}
|
||||
db.Table("system_contracts").
|
||||
Where("name = ? AND is_active = ?", "lendingProxy", true).
|
||||
Select("address, chain_id").
|
||||
First(&lc)
|
||||
|
||||
config := GetLendingMarketConfig()
|
||||
config["contract_address"] = lc.Address
|
||||
config["chain_id"] = lc.ChainID
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": []interface{}{config},
|
||||
})
|
||||
}
|
||||
|
||||
// SupplyUSDC handles USDC supply (deposit) transaction
|
||||
// POST /api/lending/supply
|
||||
func SupplyUSDC(c *gin.Context) {
|
||||
var req SupplyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Validate transaction on blockchain
|
||||
// TODO: Update user position
|
||||
// TODO: Record transaction in database
|
||||
|
||||
c.JSON(http.StatusOK, SupplyResponse{
|
||||
Success: true,
|
||||
Message: "USDC supplied successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawUSDC handles USDC withdrawal transaction
|
||||
// POST /api/lending/withdraw
|
||||
func WithdrawUSDC(c *gin.Context) {
|
||||
var req WithdrawRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Validate transaction on blockchain
|
||||
// TODO: Update user position
|
||||
// TODO: Record transaction in database
|
||||
|
||||
c.JSON(http.StatusOK, WithdrawResponse{
|
||||
Success: true,
|
||||
Message: "USDC withdrawn successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// SupplyCollateral handles collateral supply transaction
|
||||
// POST /api/lending/supply-collateral
|
||||
func SupplyCollateral(c *gin.Context) {
|
||||
var req SupplyCollateralRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate asset type - check against assets table
|
||||
if !ValidateYTToken(req.Asset) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "invalid collateral asset, must be a valid YT token (YT-A, YT-B, YT-C)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get token info from database
|
||||
tokenInfo, err := GetYTTokenInfo(req.Asset, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "failed to fetch token information",
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = tokenInfo // Will be used for blockchain validation
|
||||
|
||||
// TODO: Validate transaction on blockchain using tokenInfo.ContractAddress
|
||||
// TODO: Update user collateral position
|
||||
// TODO: Record transaction in database
|
||||
|
||||
c.JSON(http.StatusOK, SupplyCollateralResponse{
|
||||
Success: true,
|
||||
Message: "Collateral supplied successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawCollateral handles collateral withdrawal transaction
|
||||
// POST /api/lending/withdraw-collateral
|
||||
func WithdrawCollateral(c *gin.Context) {
|
||||
var req WithdrawCollateralRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate asset type - check against assets table
|
||||
if !ValidateYTToken(req.Asset) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "invalid collateral asset, must be a valid YT token (YT-A, YT-B, YT-C)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get token info from database
|
||||
tokenInfo, err := GetYTTokenInfo(req.Asset, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "failed to fetch token information",
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = tokenInfo // Will be used for blockchain validation
|
||||
|
||||
// TODO: Validate transaction on blockchain using tokenInfo.ContractAddress
|
||||
// TODO: Check if withdrawal is safe (health factor)
|
||||
// TODO: Update user collateral position
|
||||
// TODO: Record transaction in database
|
||||
|
||||
c.JSON(http.StatusOK, WithdrawCollateralResponse{
|
||||
Success: true,
|
||||
Message: "Collateral withdrawn successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// BorrowUSDC handles USDC borrow transaction
|
||||
// POST /api/lending/borrow
|
||||
func BorrowUSDC(c *gin.Context) {
|
||||
var req BorrowRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Validate transaction on blockchain
|
||||
// TODO: Check collateral sufficiency
|
||||
// TODO: Update user borrow position
|
||||
// TODO: Record transaction in database
|
||||
|
||||
c.JSON(http.StatusOK, BorrowResponse{
|
||||
Success: true,
|
||||
Message: "USDC borrowed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// RepayUSDC handles USDC repayment transaction
|
||||
// POST /api/lending/repay
|
||||
func RepayUSDC(c *gin.Context) {
|
||||
var req RepayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Validate transaction on blockchain
|
||||
// TODO: Update user borrow position
|
||||
// TODO: Record transaction in database
|
||||
|
||||
c.JSON(http.StatusOK, RepayResponse{
|
||||
Success: true,
|
||||
Message: "USDC repaid successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetLendingAPYHistory returns historical supply/borrow APY snapshots
|
||||
// GET /api/lending/apy-history?period=1W&chain_id=97
|
||||
func GetLendingAPYHistory(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "1W")
|
||||
chainId := parseChainID(c)
|
||||
|
||||
database := common.GetDB()
|
||||
|
||||
var usdcAsset models.Asset
|
||||
if err := database.Where("asset_code = ?", "USDC").First(&usdcAsset).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "USDC asset not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var since time.Time
|
||||
switch period {
|
||||
case "1M":
|
||||
since = now.AddDate(0, -1, 0)
|
||||
case "1Y":
|
||||
since = now.AddDate(-1, 0, 0)
|
||||
default: // 1W
|
||||
since = now.AddDate(0, 0, -7)
|
||||
}
|
||||
|
||||
query := database.Where("asset_id = ? AND snapshot_time >= ?", usdcAsset.ID, since).
|
||||
Order("snapshot_time ASC")
|
||||
if chainId != 0 {
|
||||
query = query.Where("chain_id = ?", chainId)
|
||||
}
|
||||
|
||||
var snapshots []models.APYSnapshot
|
||||
if err := query.Find(&snapshots).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "failed to fetch APY history"})
|
||||
return
|
||||
}
|
||||
|
||||
type DataPoint struct {
|
||||
Time string `json:"time"`
|
||||
SupplyAPY float64 `json:"supply_apy"`
|
||||
BorrowAPY float64 `json:"borrow_apy"`
|
||||
}
|
||||
|
||||
points := make([]DataPoint, 0, len(snapshots))
|
||||
for _, s := range snapshots {
|
||||
points = append(points, DataPoint{
|
||||
Time: s.SnapshotTime.UTC().Format(time.RFC3339),
|
||||
SupplyAPY: s.SupplyAPY,
|
||||
BorrowAPY: s.BorrowAPY,
|
||||
})
|
||||
}
|
||||
|
||||
var currentSupplyAPY, currentBorrowAPY float64
|
||||
if len(snapshots) > 0 {
|
||||
last := snapshots[len(snapshots)-1]
|
||||
currentSupplyAPY = last.SupplyAPY
|
||||
currentBorrowAPY = last.BorrowAPY
|
||||
}
|
||||
|
||||
// APY change vs first point in period
|
||||
var apyChange float64
|
||||
if len(snapshots) > 1 {
|
||||
apyChange = snapshots[len(snapshots)-1].SupplyAPY - snapshots[0].SupplyAPY
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"history": points,
|
||||
"current_supply_apy": currentSupplyAPY,
|
||||
"current_borrow_apy": currentBorrowAPY,
|
||||
"apy_change": apyChange,
|
||||
"period": period,
|
||||
},
|
||||
})
|
||||
}
|
||||
245
webapp-back/lending/helpers.go
Normal file
245
webapp-back/lending/helpers.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package lending
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
ethcommon "github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
// TokenInfo represents token contract information
|
||||
type TokenInfo struct {
|
||||
Symbol string
|
||||
Name string
|
||||
Decimals int
|
||||
ContractAddress string
|
||||
ChainID int
|
||||
AssetCode string
|
||||
}
|
||||
|
||||
// getRPCURL returns the RPC URL for a given chain ID from config
|
||||
func getRPCURL(chainId int) string {
|
||||
cfg := appcfg.AppConfig
|
||||
switch chainId {
|
||||
case 97: // BSC Testnet
|
||||
if cfg != nil && cfg.BSCTestnetRPC != "" {
|
||||
return cfg.BSCTestnetRPC
|
||||
}
|
||||
case 421614: // Arbitrum Sepolia
|
||||
if cfg != nil && cfg.ArbSepoliaRPC != "" {
|
||||
return cfg.ArbSepoliaRPC
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getContractAddressByChain returns the contract address from TokenInfo (chain-agnostic now)
|
||||
func getContractAddressByChain(info TokenInfo, chainId int) string {
|
||||
return info.ContractAddress
|
||||
}
|
||||
|
||||
// fetchDecimalsOnChain calls decimals() on an ERC20 contract via RPC
|
||||
// Returns error if chain call fails so caller can fall back to DB
|
||||
func fetchDecimalsOnChain(contractAddress, rpcURL string) (int, error) {
|
||||
if rpcURL == "" || contractAddress == "" {
|
||||
return 0, fmt.Errorf("missing rpcURL or contractAddress")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("dial rpc: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// decimals() selector = keccak256("decimals()")[0:4] = 313ce567
|
||||
data, _ := hex.DecodeString("313ce567")
|
||||
addr := ethcommon.HexToAddress(contractAddress)
|
||||
|
||||
result, err := client.CallContract(ctx, ethereum.CallMsg{
|
||||
To: &addr,
|
||||
Data: data,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("call contract: %w", err)
|
||||
}
|
||||
if len(result) < 1 {
|
||||
return 0, fmt.Errorf("empty result")
|
||||
}
|
||||
|
||||
decimals := new(big.Int).SetBytes(result[len(result)-1:])
|
||||
return int(decimals.Int64()), nil
|
||||
}
|
||||
|
||||
// resolveDecimals tries chain first (if chainId != 0), falls back to dbDecimals
|
||||
func resolveDecimals(contractAddress string, chainId, dbDecimals int) int {
|
||||
if chainId == 0 {
|
||||
return dbDecimals
|
||||
}
|
||||
rpcURL := getRPCURL(chainId)
|
||||
if rpcURL == "" {
|
||||
return dbDecimals
|
||||
}
|
||||
decimals, err := fetchDecimalsOnChain(contractAddress, rpcURL)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [Lending] 链上获取精度失败 %s (chain %d): %v,使用数据库配置 %d", contractAddress, chainId, err, dbDecimals)
|
||||
return dbDecimals
|
||||
}
|
||||
return decimals
|
||||
}
|
||||
|
||||
// GetTokenInfoFromDB queries any token from assets table by asset_code
|
||||
func GetTokenInfoFromDB(assetCode string) (*TokenInfo, error) {
|
||||
db := common.GetDB()
|
||||
var asset models.Asset
|
||||
if err := db.Where("asset_code = ? AND is_active = ?", assetCode, true).First(&asset).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenInfo{
|
||||
Symbol: asset.TokenSymbol,
|
||||
Name: asset.Name,
|
||||
Decimals: asset.Decimals,
|
||||
ContractAddress: asset.ContractAddress,
|
||||
ChainID: asset.ChainID,
|
||||
AssetCode: asset.AssetCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUSDCInfo returns USDC token information.
|
||||
// chainId != 0: tries on-chain decimals() first, falls back to DB.
|
||||
// chainId == 0: DB only.
|
||||
func GetUSDCInfo(chainId int) TokenInfo {
|
||||
info, err := GetTokenInfoFromDB("USDC")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [Lending] USDC 不在 assets 表: %v,使用默认值", err)
|
||||
return TokenInfo{AssetCode: "USDC", Symbol: "USDC", Name: "USD Coin", Decimals: 18}
|
||||
}
|
||||
contractAddr := getContractAddressByChain(*info, chainId)
|
||||
info.Decimals = resolveDecimals(contractAddr, chainId, info.Decimals)
|
||||
return *info
|
||||
}
|
||||
|
||||
// GetYTTokenInfo returns YT token information.
|
||||
// chainId != 0: tries on-chain decimals() first, falls back to DB.
|
||||
func GetYTTokenInfo(assetCode string, chainId int) (*TokenInfo, error) {
|
||||
db := common.GetDB()
|
||||
var asset models.Asset
|
||||
if err := db.Where("asset_code = ? AND is_active = ?", assetCode, true).First(&asset).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := &TokenInfo{
|
||||
Symbol: asset.TokenSymbol,
|
||||
Name: asset.Name,
|
||||
Decimals: asset.Decimals,
|
||||
ContractAddress: asset.ContractAddress,
|
||||
ChainID: asset.ChainID,
|
||||
AssetCode: asset.AssetCode,
|
||||
}
|
||||
info.Decimals = resolveDecimals(info.ContractAddress, info.ChainID, info.Decimals)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetAllStablecoins returns all active stablecoin tokens (token_role = 'stablecoin').
|
||||
func GetAllStablecoins(chainId int) ([]TokenInfo, error) {
|
||||
db := common.GetDB()
|
||||
var assets []models.Asset
|
||||
if err := db.Where("token_role = ? AND is_active = ?", "stablecoin", true).Find(&assets).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens := make([]TokenInfo, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
info := TokenInfo{
|
||||
Symbol: asset.TokenSymbol,
|
||||
Name: asset.Name,
|
||||
Decimals: asset.Decimals,
|
||||
ContractAddress: asset.ContractAddress,
|
||||
ChainID: asset.ChainID,
|
||||
AssetCode: asset.AssetCode,
|
||||
}
|
||||
info.Decimals = resolveDecimals(info.ContractAddress, chainId, info.Decimals)
|
||||
tokens = append(tokens, info)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// GetAllYTTokens returns all active YT tokens.
|
||||
// chainId != 0: tries on-chain decimals() first, falls back to DB.
|
||||
func GetAllYTTokens(chainId int) ([]TokenInfo, error) {
|
||||
db := common.GetDB()
|
||||
var assets []models.Asset
|
||||
if err := db.Where("token_role = ? AND is_active = ?", "yt_token", true).Find(&assets).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokens := make([]TokenInfo, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
info := TokenInfo{
|
||||
Symbol: asset.TokenSymbol,
|
||||
Name: asset.Name,
|
||||
Decimals: asset.Decimals,
|
||||
ContractAddress: asset.ContractAddress,
|
||||
ChainID: asset.ChainID,
|
||||
AssetCode: asset.AssetCode,
|
||||
}
|
||||
info.Decimals = resolveDecimals(info.ContractAddress, info.ChainID, info.Decimals)
|
||||
tokens = append(tokens, info)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// ValidateYTToken checks if the given asset code is a valid YT token
|
||||
func ValidateYTToken(assetCode string) bool {
|
||||
db := common.GetDB()
|
||||
var count int64
|
||||
db.Model(&models.Asset{}).
|
||||
Where("asset_code = ? AND token_role = ? AND is_active = ?", assetCode, "yt_token", true).
|
||||
Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// GetLendingMarketConfig returns lending market configuration
|
||||
func GetLendingMarketConfig() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"market_name": "AssetX Lending Market",
|
||||
"base_supply_apy": 6.1,
|
||||
"base_borrow_apy": 9.1,
|
||||
"borrow_collateral_factor": 0.7,
|
||||
"liquidate_collateral_factor": 0.75,
|
||||
"liquidation_penalty": 0.1,
|
||||
"kink_rate": 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateHealthFactor calculates health factor
|
||||
func CalculateHealthFactor(collateralValueUSD, borrowedValueUSD float64) float64 {
|
||||
if borrowedValueUSD == 0 {
|
||||
return 999999.0
|
||||
}
|
||||
config := GetLendingMarketConfig()
|
||||
liquidationThreshold := config["liquidate_collateral_factor"].(float64)
|
||||
return (collateralValueUSD * liquidationThreshold) / borrowedValueUSD
|
||||
}
|
||||
|
||||
// CalculateLTV calculates Loan-to-Value ratio
|
||||
func CalculateLTV(collateralValueUSD, borrowedValueUSD float64) float64 {
|
||||
if collateralValueUSD == 0 {
|
||||
return 0
|
||||
}
|
||||
return (borrowedValueUSD / collateralValueUSD) * 100
|
||||
}
|
||||
|
||||
// GetContractAddress returns contract address for the given chain
|
||||
func GetContractAddress(token TokenInfo, chainID int) string {
|
||||
return getContractAddressByChain(token, chainID)
|
||||
}
|
||||
692
webapp-back/lending/liquidation_bot.go
Normal file
692
webapp-back/lending/liquidation_bot.go
Normal file
@@ -0,0 +1,692 @@
|
||||
package lending
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi/bind"
|
||||
ethcommon "github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
dbpkg "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
botLoopDelay = 5 * time.Second
|
||||
botLookbackBlocks = int64(10000)
|
||||
)
|
||||
|
||||
// minimal ABI used by the liquidation bot
|
||||
const lendingBotABI = `[
|
||||
{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isLiquidatable","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[{"internalType":"address","name":"absorber","type":"address"},{"internalType":"address[]","name":"accounts","type":"address[]"}],"name":"absorbMultiple","outputs":[],"stateMutability":"nonpayable","type":"function"},
|
||||
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"src","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Withdraw","type":"event"},
|
||||
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"dst","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Supply","type":"event"},
|
||||
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"dst","type":"address"},{"indexed":true,"internalType":"address","name":"asset","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"SupplyCollateral","type":"event"},
|
||||
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"src","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":true,"internalType":"address","name":"asset","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"WithdrawCollateral","type":"event"}
|
||||
]`
|
||||
|
||||
// BotStatus is returned by the status API
|
||||
type BotStatus struct {
|
||||
Running bool `json:"running"`
|
||||
LiquidatorAddr string `json:"liquidator_addr"`
|
||||
ChainID int `json:"chain_id"`
|
||||
ContractAddr string `json:"contract_addr"`
|
||||
TotalLiquidations int64 `json:"total_liquidations"`
|
||||
LastCheckTime time.Time `json:"last_check_time"`
|
||||
LastBlockChecked uint64 `json:"last_block_checked"`
|
||||
LoopDelayMs int `json:"loop_delay_ms"`
|
||||
LookbackBlocks int64 `json:"lookback_blocks"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// liquidationBot holds mutable bot state
|
||||
type liquidationBot struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
liquidatorAddr string
|
||||
chainID int
|
||||
contractAddr string
|
||||
totalLiquidations int64
|
||||
lastCheckTime time.Time
|
||||
lastBlockChecked uint64
|
||||
lastErr string
|
||||
}
|
||||
|
||||
var globalBot = &liquidationBot{}
|
||||
|
||||
// GetBotStatus returns a snapshot of the current bot state
|
||||
func GetBotStatus() BotStatus {
|
||||
globalBot.mu.Lock()
|
||||
defer globalBot.mu.Unlock()
|
||||
return BotStatus{
|
||||
Running: globalBot.running,
|
||||
LiquidatorAddr: globalBot.liquidatorAddr,
|
||||
ChainID: globalBot.chainID,
|
||||
ContractAddr: globalBot.contractAddr,
|
||||
TotalLiquidations: globalBot.totalLiquidations,
|
||||
LastCheckTime: globalBot.lastCheckTime,
|
||||
LastBlockChecked: globalBot.lastBlockChecked,
|
||||
LoopDelayMs: int(botLoopDelay.Milliseconds()),
|
||||
LookbackBlocks: botLookbackBlocks,
|
||||
Error: globalBot.lastErr,
|
||||
}
|
||||
}
|
||||
|
||||
// StartLiquidationBot starts the bot goroutine; idempotent if already running
|
||||
func StartLiquidationBot(cfg *appcfg.Config) {
|
||||
if cfg.LiquidatorPrivateKey == "" {
|
||||
log.Println("[LiquidationBot] LIQUIDATOR_PRIVATE_KEY not set, bot disabled")
|
||||
globalBot.mu.Lock()
|
||||
globalBot.lastErr = "LIQUIDATOR_PRIVATE_KEY not configured"
|
||||
globalBot.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
globalBot.mu.Lock()
|
||||
if globalBot.running {
|
||||
globalBot.mu.Unlock()
|
||||
return
|
||||
}
|
||||
globalBot.stopChan = make(chan struct{})
|
||||
globalBot.running = true
|
||||
globalBot.lastErr = ""
|
||||
globalBot.mu.Unlock()
|
||||
|
||||
go runBot(cfg)
|
||||
log.Println("[LiquidationBot] Started")
|
||||
}
|
||||
|
||||
// StopLiquidationBot signals the bot to stop
|
||||
func StopLiquidationBot() {
|
||||
globalBot.mu.Lock()
|
||||
defer globalBot.mu.Unlock()
|
||||
if !globalBot.running {
|
||||
return
|
||||
}
|
||||
close(globalBot.stopChan)
|
||||
globalBot.running = false
|
||||
log.Println("[LiquidationBot] Stop signal sent")
|
||||
}
|
||||
|
||||
func runBot(cfg *appcfg.Config) {
|
||||
defer func() {
|
||||
globalBot.mu.Lock()
|
||||
globalBot.running = false
|
||||
globalBot.mu.Unlock()
|
||||
log.Println("[LiquidationBot] Goroutine exited")
|
||||
}()
|
||||
|
||||
// Parse private key
|
||||
privKeyHex := strings.TrimPrefix(cfg.LiquidatorPrivateKey, "0x")
|
||||
privateKey, err := crypto.HexToECDSA(privKeyHex)
|
||||
if err != nil {
|
||||
log.Printf("[LiquidationBot] Invalid private key: %v", err)
|
||||
globalBot.mu.Lock()
|
||||
globalBot.lastErr = fmt.Sprintf("invalid private key: %v", err)
|
||||
globalBot.running = false
|
||||
globalBot.mu.Unlock()
|
||||
return
|
||||
}
|
||||
liquidatorAddr := crypto.PubkeyToAddress(privateKey.PublicKey)
|
||||
|
||||
// Load contract addresses from DB
|
||||
lendingAddr, chainID, loadErr := loadBotContracts()
|
||||
if loadErr != nil {
|
||||
log.Printf("[LiquidationBot] Load contracts: %v", loadErr)
|
||||
globalBot.mu.Lock()
|
||||
globalBot.lastErr = loadErr.Error()
|
||||
globalBot.running = false
|
||||
globalBot.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
rpcURL := getRPCURL(chainID)
|
||||
if rpcURL == "" {
|
||||
log.Printf("[LiquidationBot] No RPC URL for chain %d", chainID)
|
||||
globalBot.mu.Lock()
|
||||
globalBot.lastErr = fmt.Sprintf("no RPC URL for chain %d", chainID)
|
||||
globalBot.running = false
|
||||
globalBot.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
globalBot.mu.Lock()
|
||||
globalBot.liquidatorAddr = liquidatorAddr.Hex()
|
||||
globalBot.chainID = chainID
|
||||
globalBot.contractAddr = lendingAddr
|
||||
globalBot.mu.Unlock()
|
||||
|
||||
log.Printf("[LiquidationBot] Liquidator: %s | Chain: %d | Lending: %s",
|
||||
liquidatorAddr.Hex(), chainID, lendingAddr)
|
||||
|
||||
// Parse ABI
|
||||
lABI, err := abi.JSON(strings.NewReader(lendingBotABI))
|
||||
if err != nil {
|
||||
log.Printf("[LiquidationBot] ABI parse error: %v", err)
|
||||
globalBot.mu.Lock()
|
||||
globalBot.lastErr = fmt.Sprintf("ABI parse error: %v", err)
|
||||
globalBot.running = false
|
||||
globalBot.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Load last processed block from DB; 0 means first run
|
||||
processedBlock := loadLiquidationBlock(chainID)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-globalBot.stopChan:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
caughtUp := botTick(rpcURL, lABI, privateKey, liquidatorAddr, lendingAddr, chainID, &processedBlock)
|
||||
|
||||
if caughtUp {
|
||||
// Normal pace
|
||||
select {
|
||||
case <-globalBot.stopChan:
|
||||
return
|
||||
case <-time.After(botLoopDelay):
|
||||
}
|
||||
}
|
||||
// Not caught up: loop immediately to process next chunk
|
||||
}
|
||||
}
|
||||
|
||||
// botTick processes one chunk of blocks. Returns true when caught up to chain head.
|
||||
func botTick(
|
||||
rpcURL string,
|
||||
lABI abi.ABI,
|
||||
privateKey *ecdsa.PrivateKey,
|
||||
liquidatorAddr ethcommon.Address,
|
||||
lendingAddr string,
|
||||
chainID int,
|
||||
processedBlock *uint64,
|
||||
) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
||||
if err != nil {
|
||||
log.Printf("[LiquidationBot] RPC dial error: %v", err)
|
||||
return true
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
currentBlock, err := client.BlockNumber(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[LiquidationBot] BlockNumber error: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
// On first run (processedBlock == 0), start from current - lookback
|
||||
if *processedBlock == 0 {
|
||||
if currentBlock >= uint64(botLookbackBlocks) {
|
||||
*processedBlock = currentBlock - uint64(botLookbackBlocks)
|
||||
}
|
||||
}
|
||||
|
||||
if currentBlock <= *processedBlock {
|
||||
return true // already up to date
|
||||
}
|
||||
|
||||
// Scan at most botLookbackBlocks blocks per tick
|
||||
fromBlock := *processedBlock + 1
|
||||
toBlock := fromBlock + uint64(botLookbackBlocks) - 1
|
||||
if toBlock > currentBlock {
|
||||
toBlock = currentBlock
|
||||
}
|
||||
|
||||
globalBot.mu.Lock()
|
||||
globalBot.lastCheckTime = time.Now()
|
||||
globalBot.lastBlockChecked = toBlock
|
||||
globalBot.mu.Unlock()
|
||||
|
||||
lendingContract := ethcommon.HexToAddress(lendingAddr)
|
||||
|
||||
// ── 1. 扫描事件,将新地址存入 known_borrowers(永久积累)──────────────
|
||||
log.Printf("[LiquidationBot] Scanning %d~%d (chain head: %d)", fromBlock, toBlock, currentBlock)
|
||||
newAddrs := queryUniqueAddresses(ctx, client, lABI, lendingContract, fromBlock, toBlock)
|
||||
if len(newAddrs) > 0 {
|
||||
saveKnownBorrowers(chainID, newAddrs)
|
||||
}
|
||||
|
||||
// ── 2. 对所有历史地址检查 isLiquidatable(含价格变动导致健康因子归零的账户)
|
||||
allBorrowers := loadKnownBorrowers(chainID)
|
||||
liquidated, txHash, gasUsed, blockNum, execErr := checkAndAbsorb(
|
||||
ctx, client, lABI, privateKey, liquidatorAddr, lendingContract, chainID, allBorrowers,
|
||||
)
|
||||
|
||||
// Always save progress, even if no liquidation happened
|
||||
saveLiquidationBlock(chainID, toBlock)
|
||||
*processedBlock = toBlock
|
||||
|
||||
if execErr != nil {
|
||||
log.Printf("[LiquidationBot] Execution error: %v", execErr)
|
||||
if len(liquidated) > 0 {
|
||||
saveLiquidationRecord(chainID, txHash, liquidatorAddr, liquidated, gasUsed, blockNum, "failed", execErr.Error())
|
||||
}
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
|
||||
if len(liquidated) > 0 {
|
||||
saveLiquidationRecord(chainID, txHash, liquidatorAddr, liquidated, gasUsed, blockNum, "success", "")
|
||||
globalBot.mu.Lock()
|
||||
globalBot.totalLiquidations += int64(len(liquidated))
|
||||
globalBot.mu.Unlock()
|
||||
}
|
||||
|
||||
return toBlock >= currentBlock
|
||||
}
|
||||
|
||||
const isLiquidatableMaxConcurrent = 10 // 并行检查上限,作为 Multicall3 失败时的兜底
|
||||
const multicallBatchSize = 500 // 每批 Multicall3 最多打包的地址数
|
||||
|
||||
// Multicall3 已部署在所有主流 EVM 链的固定地址
|
||||
var multicall3Addr = ethcommon.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11")
|
||||
|
||||
const multicall3ABIStr = `[{
|
||||
"inputs": [{"components": [
|
||||
{"internalType":"address","name":"target","type":"address"},
|
||||
{"internalType":"bool","name":"allowFailure","type":"bool"},
|
||||
{"internalType":"bytes","name":"callData","type":"bytes"}
|
||||
],"name":"calls","type":"tuple[]"}],
|
||||
"name":"aggregate3",
|
||||
"outputs": [{"components": [
|
||||
{"internalType":"bool","name":"success","type":"bool"},
|
||||
{"internalType":"bytes","name":"returnData","type":"bytes"}
|
||||
],"name":"returnData","type":"tuple[]"}],
|
||||
"stateMutability":"payable","type":"function"
|
||||
}]`
|
||||
|
||||
// mc3Call 是 Multicall3.aggregate3 的输入结构体(字段名需与 ABI 组件名 lowercase 匹配)
|
||||
type mc3Call struct {
|
||||
Target ethcommon.Address
|
||||
AllowFailure bool
|
||||
CallData []byte
|
||||
}
|
||||
|
||||
// multicallIsLiquidatable 用 Multicall3 批量检查,每批 500 个地址只发 1 个 RPC
|
||||
// 失败时自动降级到并发 goroutine 方案
|
||||
func multicallIsLiquidatable(
|
||||
ctx context.Context,
|
||||
client *ethclient.Client,
|
||||
lABI abi.ABI,
|
||||
lendingContract ethcommon.Address,
|
||||
addresses []ethcommon.Address,
|
||||
) []ethcommon.Address {
|
||||
m3ABI, err := abi.JSON(strings.NewReader(multicall3ABIStr))
|
||||
if err != nil {
|
||||
log.Printf("[LiquidationBot] multicall3 ABI parse error: %v,降级到并发模式", err)
|
||||
return parallelIsLiquidatable(ctx, client, lABI, lendingContract, addresses)
|
||||
}
|
||||
|
||||
var liquidatable []ethcommon.Address
|
||||
totalBatches := (len(addresses) + multicallBatchSize - 1) / multicallBatchSize
|
||||
|
||||
for i := 0; i < len(addresses); i += multicallBatchSize {
|
||||
end := i + multicallBatchSize
|
||||
if end > len(addresses) {
|
||||
end = len(addresses)
|
||||
}
|
||||
batch := addresses[i:end]
|
||||
batchIdx := i/multicallBatchSize + 1
|
||||
|
||||
// 构造调用列表
|
||||
calls := make([]mc3Call, len(batch))
|
||||
for j, addr := range batch {
|
||||
data, packErr := lABI.Pack("isLiquidatable", addr)
|
||||
if packErr != nil {
|
||||
continue
|
||||
}
|
||||
calls[j] = mc3Call{Target: lendingContract, AllowFailure: true, CallData: data}
|
||||
}
|
||||
|
||||
packed, packErr := m3ABI.Pack("aggregate3", calls)
|
||||
if packErr != nil {
|
||||
log.Printf("[LiquidationBot] multicall3 pack error batch %d/%d: %v", batchIdx, totalBatches, packErr)
|
||||
continue
|
||||
}
|
||||
|
||||
mc3 := multicall3Addr
|
||||
raw, callErr := client.CallContract(ctx, ethereum.CallMsg{To: &mc3, Data: packed}, nil)
|
||||
if callErr != nil {
|
||||
log.Printf("[LiquidationBot] multicall3 rpc error batch %d/%d: %v", batchIdx, totalBatches, callErr)
|
||||
continue
|
||||
}
|
||||
|
||||
unpacked, unpackErr := m3ABI.Unpack("aggregate3", raw)
|
||||
if unpackErr != nil || len(unpacked) == 0 {
|
||||
log.Printf("[LiquidationBot] multicall3 unpack error batch %d/%d: %v", batchIdx, totalBatches, unpackErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// go-ethereum 返回动态 struct 类型,通过反射访问字段
|
||||
rv := reflect.ValueOf(unpacked[0])
|
||||
for j := 0; j < rv.Len() && j < len(batch); j++ {
|
||||
elem := rv.Index(j)
|
||||
if !elem.FieldByName("Success").Bool() {
|
||||
continue
|
||||
}
|
||||
retData := elem.FieldByName("ReturnData").Bytes()
|
||||
// isLiquidatable 返回 bool,ABI 编码为 32 字节,最后一字节 1=true
|
||||
if len(retData) >= 32 && retData[31] == 1 {
|
||||
log.Printf("[LiquidationBot] Liquidatable: %s", batch[j].Hex())
|
||||
liquidatable = append(liquidatable, batch[j])
|
||||
}
|
||||
}
|
||||
log.Printf("[LiquidationBot] Multicall3 batch %d/%d (%d addresses)", batchIdx, totalBatches, len(batch))
|
||||
}
|
||||
return liquidatable
|
||||
}
|
||||
|
||||
// parallelIsLiquidatable 是 Multicall3 不可用时的兜底方案
|
||||
func parallelIsLiquidatable(
|
||||
ctx context.Context,
|
||||
client *ethclient.Client,
|
||||
lABI abi.ABI,
|
||||
lendingContract ethcommon.Address,
|
||||
addresses []ethcommon.Address,
|
||||
) []ethcommon.Address {
|
||||
type result struct {
|
||||
addr ethcommon.Address
|
||||
isLiq bool
|
||||
}
|
||||
resultCh := make(chan result, len(addresses))
|
||||
sem := make(chan struct{}, isLiquidatableMaxConcurrent)
|
||||
var wg sync.WaitGroup
|
||||
for _, addr := range addresses {
|
||||
addr := addr
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
isLiq, _ := callViewBool(ctx, client, lABI, lendingContract, "isLiquidatable", addr)
|
||||
resultCh <- result{addr, isLiq}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
var liquidatable []ethcommon.Address
|
||||
for r := range resultCh {
|
||||
if r.isLiq {
|
||||
log.Printf("[LiquidationBot] Liquidatable: %s", r.addr.Hex())
|
||||
liquidatable = append(liquidatable, r.addr)
|
||||
}
|
||||
}
|
||||
return liquidatable
|
||||
}
|
||||
|
||||
// checkAndAbsorb 检查所有已知地址,对可清算账户执行 absorbMultiple
|
||||
func checkAndAbsorb(
|
||||
ctx context.Context,
|
||||
client *ethclient.Client,
|
||||
lABI abi.ABI,
|
||||
privateKey *ecdsa.PrivateKey,
|
||||
liquidatorAddr ethcommon.Address,
|
||||
lendingContract ethcommon.Address,
|
||||
chainID int,
|
||||
addresses []ethcommon.Address,
|
||||
) (liquidated []ethcommon.Address, txHash string, gasUsed uint64, blockNum uint64, err error) {
|
||||
|
||||
if len(addresses) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[LiquidationBot] Checking %d known borrowers via Multicall3...", len(addresses))
|
||||
toAbsorb := multicallIsLiquidatable(ctx, client, lABI, lendingContract, addresses)
|
||||
|
||||
if len(toAbsorb) == 0 {
|
||||
log.Println("[LiquidationBot] No liquidatable accounts")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[LiquidationBot] Liquidating %d accounts...", len(toAbsorb))
|
||||
|
||||
chainIDBig := big.NewInt(int64(chainID))
|
||||
boundContract := bind.NewBoundContract(lendingContract, lABI, client, client, client)
|
||||
|
||||
auth, authErr := bind.NewKeyedTransactorWithChainID(privateKey, chainIDBig)
|
||||
if authErr != nil {
|
||||
err = fmt.Errorf("create transactor: %w", authErr)
|
||||
return
|
||||
}
|
||||
auth.GasLimit = uint64(300000 + 150000*uint64(len(toAbsorb)))
|
||||
|
||||
tx, txErr := boundContract.Transact(auth, "absorbMultiple", liquidatorAddr, toAbsorb)
|
||||
if txErr != nil {
|
||||
err = fmt.Errorf("absorbMultiple: %w", txErr)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[LiquidationBot] Tx sent: %s", tx.Hash().Hex())
|
||||
txHash = tx.Hash().Hex()
|
||||
liquidated = toAbsorb
|
||||
|
||||
receiptCtx, receiptCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer receiptCancel()
|
||||
|
||||
receipt, waitErr := bind.WaitMined(receiptCtx, client, tx)
|
||||
if waitErr != nil {
|
||||
log.Printf("[LiquidationBot] Wait receipt error: %v", waitErr)
|
||||
return
|
||||
}
|
||||
|
||||
gasUsed = receipt.GasUsed
|
||||
blockNum = receipt.BlockNumber.Uint64()
|
||||
|
||||
if receipt.Status == 1 {
|
||||
log.Printf("[LiquidationBot] Success! Gas: %d | Block: %d", gasUsed, blockNum)
|
||||
} else {
|
||||
err = fmt.Errorf("transaction reverted at block %d", blockNum)
|
||||
log.Printf("[LiquidationBot] Tx reverted: %s", txHash)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// queryUniqueAddresses queries 4 event types and returns deduplicated addresses
|
||||
func queryUniqueAddresses(ctx context.Context, client *ethclient.Client, lABI abi.ABI, contractAddr ethcommon.Address, fromBlock, toBlock uint64) []ethcommon.Address {
|
||||
query := ethereum.FilterQuery{
|
||||
FromBlock: new(big.Int).SetUint64(fromBlock),
|
||||
ToBlock: new(big.Int).SetUint64(toBlock),
|
||||
Addresses: []ethcommon.Address{contractAddr},
|
||||
Topics: [][]ethcommon.Hash{{
|
||||
lABI.Events["Withdraw"].ID,
|
||||
lABI.Events["Supply"].ID,
|
||||
lABI.Events["SupplyCollateral"].ID,
|
||||
lABI.Events["WithdrawCollateral"].ID,
|
||||
}},
|
||||
}
|
||||
|
||||
logs, err := client.FilterLogs(ctx, query)
|
||||
if err != nil {
|
||||
log.Printf("[LiquidationBot] FilterLogs error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[ethcommon.Address]struct{})
|
||||
for _, l := range logs {
|
||||
// topic[1] = first indexed addr, topic[2] = second indexed addr
|
||||
if len(l.Topics) >= 2 {
|
||||
seen[ethcommon.BytesToAddress(l.Topics[1].Bytes())] = struct{}{}
|
||||
}
|
||||
if len(l.Topics) >= 3 {
|
||||
seen[ethcommon.BytesToAddress(l.Topics[2].Bytes())] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
addrs := make([]ethcommon.Address, 0, len(seen))
|
||||
for addr := range seen {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
log.Printf("[LiquidationBot] Events [%d~%d]: %d logs → %d unique addresses",
|
||||
fromBlock, toBlock, len(logs), len(addrs))
|
||||
return addrs
|
||||
}
|
||||
|
||||
// callViewBool calls a view function and returns bool result
|
||||
func callViewBool(ctx context.Context, client *ethclient.Client, contractABI abi.ABI, addr ethcommon.Address, method string, args ...interface{}) (bool, error) {
|
||||
res, err := callViewRaw(ctx, client, contractABI, addr, method, args...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return false, fmt.Errorf("empty result")
|
||||
}
|
||||
v, ok := res[0].(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("unexpected type %T", res[0])
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// callViewRaw calls a view function and returns decoded values
|
||||
func callViewRaw(ctx context.Context, client *ethclient.Client, contractABI abi.ABI, addr ethcommon.Address, method string, args ...interface{}) ([]interface{}, error) {
|
||||
data, err := contractABI.Pack(method, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pack %s: %w", method, err)
|
||||
}
|
||||
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &addr, Data: data}, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("call %s: %w", method, err)
|
||||
}
|
||||
decoded, err := contractABI.Unpack(method, result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unpack %s: %w", method, err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// saveKnownBorrowers 批量 upsert:新地址插入,已存在的只更新 last_seen_at
|
||||
// 使用单条 SQL 替代 N+1 查询,几千地址也只需 ceil(N/200) 次 DB 请求
|
||||
func saveKnownBorrowers(chainID int, addrs []ethcommon.Address) {
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
records := make([]models.KnownBorrower, len(addrs))
|
||||
for i, addr := range addrs {
|
||||
records[i] = models.KnownBorrower{
|
||||
ChainID: chainID,
|
||||
Address: addr.Hex(),
|
||||
FirstSeenAt: now,
|
||||
LastSeenAt: now,
|
||||
}
|
||||
}
|
||||
database := dbpkg.GetDB()
|
||||
if err := database.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "chain_id"}, {Name: "address"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{"last_seen_at": now}),
|
||||
}).CreateInBatches(records, 200).Error; err != nil {
|
||||
log.Printf("[LiquidationBot] saveKnownBorrowers error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadKnownBorrowers returns all addresses ever seen for a chain
|
||||
func loadKnownBorrowers(chainID int) []ethcommon.Address {
|
||||
database := dbpkg.GetDB()
|
||||
var borrowers []models.KnownBorrower
|
||||
database.Where("chain_id = ?", chainID).Find(&borrowers)
|
||||
addrs := make([]ethcommon.Address, len(borrowers))
|
||||
for i, b := range borrowers {
|
||||
addrs[i] = ethcommon.HexToAddress(b.Address)
|
||||
}
|
||||
if len(addrs) > 0 {
|
||||
log.Printf("[LiquidationBot] Loaded %d known borrowers from DB", len(addrs))
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
// loadLiquidationBlock reads the last processed block from scanner_state
|
||||
func loadLiquidationBlock(chainID int) uint64 {
|
||||
database := dbpkg.GetDB()
|
||||
var state models.ScannerState
|
||||
if err := database.Where("chain_id = ? AND scanner_type = ?", chainID, "liquidation").First(&state).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
return state.LastScannedBlock
|
||||
}
|
||||
|
||||
// saveLiquidationBlock persists the last processed block to scanner_state
|
||||
func saveLiquidationBlock(chainID int, block uint64) {
|
||||
database := dbpkg.GetDB()
|
||||
state := models.ScannerState{
|
||||
ScannerType: "liquidation",
|
||||
ChainID: chainID,
|
||||
LastScannedBlock: block,
|
||||
}
|
||||
database.Where("chain_id = ? AND scanner_type = ?", chainID, "liquidation").Assign(state).FirstOrCreate(&state)
|
||||
database.Model(&state).Updates(map[string]interface{}{
|
||||
"last_scanned_block": block,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// loadBotContracts loads lendingProxy address from system_contracts
|
||||
func loadBotContracts() (lendingAddr string, chainID int, err error) {
|
||||
database := dbpkg.GetDB()
|
||||
|
||||
type row struct {
|
||||
ChainID int
|
||||
Address string
|
||||
}
|
||||
var r row
|
||||
if dbErr := database.Table("system_contracts").
|
||||
Where("name = ? AND is_active = ?", "lendingProxy", true).
|
||||
Select("chain_id, address").
|
||||
First(&r).Error; dbErr != nil {
|
||||
err = fmt.Errorf("lendingProxy not found in system_contracts: %w", dbErr)
|
||||
return
|
||||
}
|
||||
if r.Address == "" {
|
||||
err = fmt.Errorf("lendingProxy address is empty")
|
||||
return
|
||||
}
|
||||
lendingAddr = r.Address
|
||||
chainID = r.ChainID
|
||||
return
|
||||
}
|
||||
|
||||
// saveLiquidationRecord persists a liquidation event to DB
|
||||
func saveLiquidationRecord(chainID int, txHash string, liquidatorAddr ethcommon.Address, accounts []ethcommon.Address, gasUsed, blockNum uint64, status, errMsg string) {
|
||||
addrs := make([]string, len(accounts))
|
||||
for i, a := range accounts {
|
||||
addrs[i] = a.Hex()
|
||||
}
|
||||
addrsJSON, _ := json.Marshal(addrs)
|
||||
|
||||
record := models.LiquidationRecord{
|
||||
ChainID: chainID,
|
||||
TxHash: txHash,
|
||||
LiquidatorAddr: liquidatorAddr.Hex(),
|
||||
AccountCount: len(accounts),
|
||||
Accounts: string(addrsJSON),
|
||||
GasUsed: gasUsed,
|
||||
BlockNumber: blockNum,
|
||||
Status: status,
|
||||
ErrorMessage: errMsg,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
database := dbpkg.GetDB()
|
||||
if dbErr := database.Create(&record).Error; dbErr != nil {
|
||||
log.Printf("[LiquidationBot] Save record error: %v", dbErr)
|
||||
}
|
||||
}
|
||||
113
webapp-back/lending/models.go
Normal file
113
webapp-back/lending/models.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package lending
|
||||
|
||||
|
||||
|
||||
// UserPosition represents user's lending position (from chain)
|
||||
type UserPosition struct {
|
||||
UserAddress string `json:"user_address"`
|
||||
WalletAddress string `json:"wallet_address"`
|
||||
SuppliedBalance string `json:"supplied_balance"` // USDC supplied
|
||||
SuppliedBalanceUSD float64 `json:"supplied_balance_usd"` // USD value
|
||||
BorrowedBalance string `json:"borrowed_balance"` // USDC borrowed
|
||||
BorrowedBalanceUSD float64 `json:"borrowed_balance_usd"` // USD value
|
||||
CollateralBalances map[string]CollateralInfo `json:"collateral_balances"` // YT-A, YT-B, YT-C
|
||||
HealthFactor float64 `json:"health_factor"`
|
||||
LTV float64 `json:"ltv"` // Loan-to-Value ratio
|
||||
SupplyAPY float64 `json:"supply_apy"`
|
||||
BorrowAPY float64 `json:"borrow_apy"`
|
||||
}
|
||||
|
||||
// CollateralInfo represents collateral asset information
|
||||
type CollateralInfo struct {
|
||||
TokenSymbol string `json:"token_symbol"` // YT-A, YT-B, YT-C
|
||||
Balance string `json:"balance"` // Token balance
|
||||
BalanceUSD float64 `json:"balance_usd"` // USD value
|
||||
CollateralValue float64 `json:"collateral_value"` // Collateral value (with factor)
|
||||
}
|
||||
|
||||
// LendingStats represents lending market statistics
|
||||
type LendingStats struct {
|
||||
TotalSuppliedUSD float64 `json:"total_supplied_usd"`
|
||||
TotalBorrowedUSD float64 `json:"total_borrowed_usd"`
|
||||
TotalCollateralUSD float64 `json:"total_collateral_usd"`
|
||||
UtilizationRate float64 `json:"utilization_rate"`
|
||||
AvgSupplyAPY float64 `json:"avg_supply_apy"`
|
||||
AvgBorrowAPY float64 `json:"avg_borrow_apy"`
|
||||
TotalUsers int `json:"total_users"`
|
||||
ActiveBorrowers int `json:"active_borrowers"`
|
||||
TotalTVL float64 `json:"total_tvl"`
|
||||
}
|
||||
|
||||
// Supply request/response
|
||||
type SupplyRequest struct {
|
||||
Amount string `json:"amount" binding:"required"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
}
|
||||
|
||||
type SupplyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *UserPosition `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Withdraw request/response
|
||||
type WithdrawRequest struct {
|
||||
Amount string `json:"amount" binding:"required"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
}
|
||||
|
||||
type WithdrawResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *UserPosition `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// SupplyCollateral request/response
|
||||
type SupplyCollateralRequest struct {
|
||||
Asset string `json:"asset" binding:"required"` // YT-A, YT-B, YT-C
|
||||
Amount string `json:"amount" binding:"required"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
}
|
||||
|
||||
type SupplyCollateralResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *UserPosition `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// WithdrawCollateral request/response
|
||||
type WithdrawCollateralRequest struct {
|
||||
Asset string `json:"asset" binding:"required"` // YT-A, YT-B, YT-C
|
||||
Amount string `json:"amount" binding:"required"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
}
|
||||
|
||||
type WithdrawCollateralResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *UserPosition `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Borrow request/response
|
||||
type BorrowRequest struct {
|
||||
Amount string `json:"amount" binding:"required"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
}
|
||||
|
||||
type BorrowResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *UserPosition `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Repay request/response
|
||||
type RepayRequest struct {
|
||||
Amount string `json:"amount" binding:"required"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
}
|
||||
|
||||
type RepayResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *UserPosition `json:"data,omitempty"`
|
||||
}
|
||||
14
webapp-back/lending/routers.go
Normal file
14
webapp-back/lending/routers.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package lending
|
||||
|
||||
// Routing is now handled in main.go
|
||||
// All lending routes are registered directly in main.go:
|
||||
//
|
||||
// GET /api/lending/position/:address - Get user lending position
|
||||
// GET /api/lending/stats - Get lending market statistics
|
||||
// GET /api/lending/markets - Get lending markets configuration
|
||||
// POST /api/lending/supply - Supply USDC
|
||||
// POST /api/lending/withdraw - Withdraw USDC
|
||||
// POST /api/lending/supply-collateral - Supply collateral (YT tokens)
|
||||
// POST /api/lending/withdraw-collateral - Withdraw collateral
|
||||
// POST /api/lending/borrow - Borrow USDC
|
||||
// POST /api/lending/repay - Repay USDC
|
||||
208
webapp-back/lending/snapshot.go
Normal file
208
webapp-back/lending/snapshot.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package lending
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
ethcommon "github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/ethclient"
|
||||
appcfg "github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
db "github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const lendingSnapshotInterval = 15 * time.Minute
|
||||
|
||||
// Only the 4 view functions needed for APY snapshot
|
||||
const lendingRateABIJSON = `[
|
||||
{"inputs":[],"name":"getSupplyRate","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[],"name":"getBorrowRate","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[],"name":"getTotalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
|
||||
{"inputs":[],"name":"getTotalBorrow","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}
|
||||
]`
|
||||
|
||||
// StartLendingAPYSnapshot starts the hourly lending APY snapshot background service.
|
||||
// Call as: go lending.StartLendingAPYSnapshot(cfg)
|
||||
func StartLendingAPYSnapshot(cfg *appcfg.Config) {
|
||||
log.Println("=== Lending APY Snapshot Service Started (interval: 15min) ===")
|
||||
runLendingSnapshot(cfg)
|
||||
ticker := time.NewTicker(lendingSnapshotInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runLendingSnapshot(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func runLendingSnapshot(cfg *appcfg.Config) {
|
||||
start := time.Now()
|
||||
log.Printf("[LendingSnapshot] Starting at %s", start.Format("2006-01-02 15:04:05"))
|
||||
|
||||
database := db.GetDB()
|
||||
|
||||
// Load lendingProxy contracts from system_contracts
|
||||
var lendingContracts []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
ChainID int `gorm:"column:chain_id"`
|
||||
Address string `gorm:"column:address"`
|
||||
}
|
||||
if err := database.Table("system_contracts").
|
||||
Where("name = ? AND is_active = ?", "lendingProxy", true).
|
||||
Select("name, chain_id, address").
|
||||
Find(&lendingContracts).Error; err != nil {
|
||||
log.Printf("[LendingSnapshot] Load lendingProxy error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(lendingContracts) == 0 {
|
||||
log.Println("[LendingSnapshot] No lendingProxy in system_contracts, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
var usdcAsset models.Asset
|
||||
if err := database.Where("asset_code = ?", "USDC").First(&usdcAsset).Error; err != nil {
|
||||
log.Printf("[LendingSnapshot] USDC asset not found in assets table: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
parsedABI, err := abi.JSON(strings.NewReader(lendingRateABIJSON))
|
||||
if err != nil {
|
||||
log.Printf("[LendingSnapshot] Parse ABI error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, lc := range lendingContracts {
|
||||
if lc.Address == "" {
|
||||
log.Printf("[LendingSnapshot] lendingProxy chain_id=%d has no address, skipping", lc.ChainID)
|
||||
continue
|
||||
}
|
||||
rpcURL := getRPCURL(lc.ChainID)
|
||||
if rpcURL == "" {
|
||||
log.Printf("[LendingSnapshot] lendingProxy unsupported chain_id=%d, skipping", lc.ChainID)
|
||||
continue
|
||||
}
|
||||
if err := snapshotLendingMarket(database, parsedABI, rpcURL, lc.Address, lc.ChainID, usdcAsset); err != nil {
|
||||
log.Printf("[LendingSnapshot] lendingProxy chain_id=%d error: %v", lc.ChainID, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[LendingSnapshot] Done in %v", time.Since(start))
|
||||
}
|
||||
|
||||
func snapshotLendingMarket(
|
||||
database *gorm.DB,
|
||||
parsedABI abi.ABI,
|
||||
rpcURL, contractAddr string,
|
||||
chainID int,
|
||||
usdcAsset models.Asset,
|
||||
) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := ethclient.DialContext(ctx, rpcURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial rpc: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
addr := ethcommon.HexToAddress(contractAddr)
|
||||
|
||||
// Generic call helper: packs, calls, unpacks, returns *big.Int
|
||||
call := func(name string) (*big.Int, error) {
|
||||
data, err := parsedABI.Pack(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pack %s: %w", name, err)
|
||||
}
|
||||
result, err := client.CallContract(ctx, ethereum.CallMsg{To: &addr, Data: data}, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("call %s: %w", name, err)
|
||||
}
|
||||
decoded, err := parsedABI.Unpack(name, result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unpack %s: %w", name, err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return nil, fmt.Errorf("%s: empty result", name)
|
||||
}
|
||||
switch v := decoded[0].(type) {
|
||||
case uint64:
|
||||
return new(big.Int).SetUint64(v), nil
|
||||
case *big.Int:
|
||||
return v, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%s: unexpected type %T", name, decoded[0])
|
||||
}
|
||||
}
|
||||
|
||||
supplyRateRaw, err := call("getSupplyRate")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
borrowRateRaw, err := call("getBorrowRate")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalSupplyRaw, err := call("getTotalSupply")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalBorrowRaw, err := call("getTotalBorrow")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// APR% = rate / 1e18 * 100
|
||||
// This contract's getSupplyRate/getBorrowRate already return annualized rate (APR × 1e18),
|
||||
// NOT per-second rate. Do NOT multiply by secondsPerYear.
|
||||
toAPRPct := func(raw *big.Int) float64 {
|
||||
f, _ := new(big.Float).SetPrec(256).Quo(
|
||||
new(big.Float).SetPrec(256).SetInt(raw),
|
||||
new(big.Float).SetPrec(256).SetFloat64(1e18),
|
||||
).Float64()
|
||||
return f * 100
|
||||
}
|
||||
|
||||
usdcDec := int64(usdcAsset.Decimals)
|
||||
totalSupply := lendingBigIntToFloat(totalSupplyRaw, usdcDec)
|
||||
totalBorrow := lendingBigIntToFloat(totalBorrowRaw, usdcDec)
|
||||
supplyAPR := toAPRPct(supplyRateRaw)
|
||||
borrowAPR := toAPRPct(borrowRateRaw)
|
||||
|
||||
log.Printf("[LendingSnapshot] supply=%.4f USDC, borrow=%.4f USDC, utilization=%.2f%%, supplyAPR=%.4f%%, borrowAPR=%.4f%%",
|
||||
totalSupply, totalBorrow,
|
||||
func() float64 {
|
||||
if totalSupply == 0 {
|
||||
return 0
|
||||
}
|
||||
return totalBorrow / totalSupply * 100
|
||||
}(),
|
||||
supplyAPR, borrowAPR,
|
||||
)
|
||||
|
||||
snap := models.APYSnapshot{
|
||||
AssetID: usdcAsset.ID,
|
||||
ChainID: chainID,
|
||||
ContractAddress: contractAddr,
|
||||
SupplyAPY: supplyAPR,
|
||||
BorrowAPY: borrowAPR,
|
||||
TotalAssets: totalSupply, // total USDC deposited
|
||||
TotalSupply: totalBorrow, // total USDC borrowed
|
||||
SnapshotTime: time.Now(),
|
||||
}
|
||||
return database.Create(&snap).Error
|
||||
}
|
||||
|
||||
// lendingBigIntToFloat converts *big.Int to float64 by dividing by 10^decimals
|
||||
func lendingBigIntToFloat(n *big.Int, decimals int64) float64 {
|
||||
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(decimals), nil)
|
||||
f, _ := new(big.Float).SetPrec(256).Quo(
|
||||
new(big.Float).SetPrec(256).SetInt(n),
|
||||
new(big.Float).SetPrec(256).SetInt(divisor),
|
||||
).Float64()
|
||||
return f
|
||||
}
|
||||
80
webapp-back/lending/tokens.go
Normal file
80
webapp-back/lending/tokens.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package lending
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetTokensInfo returns information about all supported tokens
|
||||
// GET /api/lending/tokens?chain_id=97
|
||||
func GetTokensInfo(c *gin.Context) {
|
||||
chainId := parseChainID(c)
|
||||
|
||||
stablecoins, err := GetAllStablecoins(chainId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "failed to fetch stablecoins",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ytTokens, err := GetAllYTTokens(chainId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "failed to fetch YT tokens",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
allTokens := make([]TokenInfo, 0, len(stablecoins)+len(ytTokens))
|
||||
allTokens = append(allTokens, stablecoins...)
|
||||
allTokens = append(allTokens, ytTokens...)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"stablecoins": stablecoins,
|
||||
"yt_tokens": ytTokens,
|
||||
"all_tokens": allTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetTokenInfo returns information about a specific token
|
||||
// GET /api/lending/tokens/:assetCode?chain_id=97
|
||||
func GetTokenInfo(c *gin.Context) {
|
||||
assetCode := c.Param("assetCode")
|
||||
chainId := parseChainID(c)
|
||||
|
||||
info, err := GetTokenInfoFromDB(assetCode)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "token not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
info.Decimals = resolveDecimals(info.ContractAddress, chainId, info.Decimals)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": info,
|
||||
})
|
||||
}
|
||||
|
||||
// parseChainID reads chain_id from query string, returns 0 if not provided
|
||||
func parseChainID(c *gin.Context) int {
|
||||
s := c.Query("chain_id")
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
BIN
webapp-back/logo.png
Normal file
BIN
webapp-back/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
813074
webapp-back/logs/holder-scanner.log
Normal file
813074
webapp-back/logs/holder-scanner.log
Normal file
File diff suppressed because it is too large
Load Diff
200
webapp-back/main.go
Normal file
200
webapp-back/main.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/admin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/alp"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/articles"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/fundmarket"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/holders"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/lending"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/middleware"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/points"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/users"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB) {
|
||||
// Original tables
|
||||
users.AutoMigrate()
|
||||
db.AutoMigrate(&articles.ArticleModel{})
|
||||
db.AutoMigrate(&articles.TagModel{})
|
||||
db.AutoMigrate(&articles.FavoriteModel{})
|
||||
db.AutoMigrate(&articles.ArticleUserModel{})
|
||||
db.AutoMigrate(&articles.CommentModel{})
|
||||
|
||||
// AssetX core tables
|
||||
db.AutoMigrate(&models.User{})
|
||||
db.AutoMigrate(&models.HolderSnapshot{})
|
||||
db.AutoMigrate(&models.Asset{})
|
||||
db.AutoMigrate(&models.AssetPerformance{})
|
||||
db.AutoMigrate(&models.APYSnapshot{})
|
||||
db.AutoMigrate(&models.AssetCustody{})
|
||||
db.AutoMigrate(&models.AssetAuditReport{})
|
||||
|
||||
// Config / content tables
|
||||
db.AutoMigrate(&models.SystemContract{})
|
||||
db.AutoMigrate(&models.ProductLink{})
|
||||
|
||||
// Points system tables
|
||||
db.AutoMigrate(&models.PointsRule{})
|
||||
db.AutoMigrate(&models.Season{})
|
||||
db.AutoMigrate(&models.VIPTier{})
|
||||
db.AutoMigrate(&models.InviteCode{})
|
||||
db.AutoMigrate(&models.Invitation{})
|
||||
db.AutoMigrate(&models.UserPointsSummary{})
|
||||
db.AutoMigrate(&models.UserPointsRecord{})
|
||||
db.AutoMigrate(&models.UserTeam{})
|
||||
|
||||
// ALP pool snapshots
|
||||
db.AutoMigrate(&models.ALPSnapshot{})
|
||||
|
||||
// Liquidation bot records
|
||||
db.AutoMigrate(&models.LiquidationRecord{})
|
||||
db.AutoMigrate(&models.KnownBorrower{})
|
||||
|
||||
// Collateral buyer bot records
|
||||
db.AutoMigrate(&models.CollateralBuyRecord{})
|
||||
|
||||
// Scanner state: drop old single-column unique index, then migrate with composite index
|
||||
db.Exec("ALTER TABLE scanner_state DROP INDEX `uni_chain_id`") // ignore error if not exists
|
||||
db.AutoMigrate(&models.ScannerState{})
|
||||
|
||||
// YT Swap event records (for "Your Total Earning" calculation)
|
||||
db.AutoMigrate(&models.YTSwapRecord{})
|
||||
|
||||
// Audit / ops
|
||||
db.AutoMigrate(&models.OperationLog{})
|
||||
|
||||
log.Println("✓ Database migration completed")
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
log.Println("✓ Configuration loaded")
|
||||
|
||||
var db *gorm.DB
|
||||
if cfg.DBType == "mysql" {
|
||||
db = common.InitMySQL()
|
||||
} else {
|
||||
db = common.Init()
|
||||
}
|
||||
|
||||
Migrate(db)
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
log.Println("failed to get sql.DB:", err)
|
||||
} else {
|
||||
defer sqlDB.Close()
|
||||
}
|
||||
|
||||
common.InitRedis()
|
||||
|
||||
// Start holder scanner (continuous block scanning)
|
||||
go holders.StartAllScanners()
|
||||
|
||||
// Start background snapshot services (run every hour)
|
||||
go fundmarket.StartPriceSnapshot(cfg)
|
||||
go alp.StartALPSnapshot(cfg)
|
||||
go lending.StartLendingAPYSnapshot(cfg)
|
||||
|
||||
// Start liquidation bot (requires LIQUIDATOR_PRIVATE_KEY)
|
||||
go lending.StartLiquidationBot(cfg)
|
||||
|
||||
// Start collateral buyer bot (requires COLLATERAL_BUYER_PRIVATE_KEY)
|
||||
go lending.StartCollateralBuyerBot(cfg)
|
||||
|
||||
if cfg.GinMode == "release" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.Default()
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowAllOrigins: true,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: false,
|
||||
}))
|
||||
|
||||
r.RedirectTrailingSlash = false
|
||||
|
||||
// Serve uploaded files
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
v1 := r.Group("/api")
|
||||
|
||||
// Public routes
|
||||
users.UsersRegister(v1.Group("/users"))
|
||||
articles.ArticlesAnonymousRegister(v1.Group("/articles"))
|
||||
articles.TagsAnonymousRegister(v1.Group("/tags"))
|
||||
users.ProfileRetrieveRegister(v1.Group("/profiles"))
|
||||
|
||||
v1.GET("/holders/stats", holders.GetStats)
|
||||
v1.GET("/holders/:tokenType", holders.GetHoldersByType)
|
||||
|
||||
v1.GET("/contracts", admin.GetContracts)
|
||||
|
||||
v1.GET("/fundmarket/products", fundmarket.GetProducts)
|
||||
v1.GET("/fundmarket/stats", fundmarket.GetStats)
|
||||
v1.GET("/fundmarket/products/:id", fundmarket.GetProductByID)
|
||||
v1.GET("/fundmarket/products/:id/history", fundmarket.GetProductHistory)
|
||||
v1.GET("/fundmarket/products/:id/daily-returns", fundmarket.GetDailyReturns)
|
||||
v1.GET("/fundmarket/net-deposited", fundmarket.GetNetDeposited)
|
||||
|
||||
v1.GET("/alp/stats", alp.GetALPStats)
|
||||
v1.GET("/alp/history", alp.GetALPHistory)
|
||||
|
||||
v1.GET("/lending/position/:address", lending.GetUserPosition)
|
||||
v1.GET("/lending/stats", lending.GetLendingStats)
|
||||
v1.GET("/lending/markets", lending.GetLendingMarkets)
|
||||
v1.GET("/lending/apy-history", lending.GetLendingAPYHistory)
|
||||
v1.GET("/lending/tokens", lending.GetTokensInfo)
|
||||
v1.GET("/lending/tokens/:assetCode", lending.GetTokenInfo)
|
||||
v1.POST("/lending/supply", lending.SupplyUSDC)
|
||||
v1.POST("/lending/withdraw", lending.WithdrawUSDC)
|
||||
v1.POST("/lending/supply-collateral", lending.SupplyCollateral)
|
||||
v1.POST("/lending/withdraw-collateral", lending.WithdrawCollateral)
|
||||
v1.POST("/lending/borrow", lending.BorrowUSDC)
|
||||
v1.POST("/lending/repay", lending.RepayUSDC)
|
||||
|
||||
v1.POST("/points/wallet-register", points.WalletRegister)
|
||||
v1.GET("/points/dashboard", points.GetDashboard)
|
||||
v1.GET("/points/leaderboard", points.GetLeaderboard)
|
||||
v1.GET("/points/invite-code", points.GetInviteCode)
|
||||
v1.POST("/points/bind-invite", points.BindInvite)
|
||||
v1.GET("/points/team", points.GetTeamTVL)
|
||||
v1.GET("/points/activities", points.GetActivities)
|
||||
|
||||
// Admin API (has its own auth + RequireAdmin middleware internally)
|
||||
admin.RegisterRoutes(v1)
|
||||
|
||||
// Protected routes
|
||||
v1.Use(middleware.AuthMiddleware(true))
|
||||
{
|
||||
users.UserRegister(v1.Group("/user"))
|
||||
users.ProfileRegister(v1.Group("/profiles"))
|
||||
articles.ArticlesRegister(v1.Group("/articles"))
|
||||
|
||||
holdersGroup := v1.Group("/holders")
|
||||
holdersGroup.POST("/update", middleware.RequireAdmin(), holders.UpdateHolders)
|
||||
}
|
||||
|
||||
r.GET("/api/ping", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "pong", "version": "1.0.0-assetx"})
|
||||
})
|
||||
|
||||
port := ":" + cfg.Port
|
||||
log.Printf("✓ Server starting on port %s\n", cfg.Port)
|
||||
if err := r.Run(port); err != nil {
|
||||
log.Fatal("failed to start server:", err)
|
||||
}
|
||||
}
|
||||
230
webapp-back/middleware/auth.go
Normal file
230
webapp-back/middleware/auth.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/config"
|
||||
)
|
||||
|
||||
// AuthResponse represents the response from auth center
|
||||
type AuthResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data AuthData `json:"data"`
|
||||
}
|
||||
|
||||
type AuthData struct {
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
TokenInfo TokenInfo `json:"tokenInfo"`
|
||||
UserID int64 `json:"userID"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type TokenInfo struct {
|
||||
Exp string `json:"exp"`
|
||||
Iat string `json:"iat"`
|
||||
Jti string `json:"jti"`
|
||||
}
|
||||
|
||||
// UserContext represents the user data stored in gin context
|
||||
type UserContext struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
// Simple in-memory cache (in production, use Redis)
|
||||
var tokenCache = make(map[string]*CacheEntry)
|
||||
|
||||
type CacheEntry struct {
|
||||
UserData *UserContext
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// hashToken creates SHA256 hash of token
|
||||
func hashToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// validateTokenWithAuthCenter validates token with auth center
|
||||
func validateTokenWithAuthCenter(token string) (*AuthData, error) {
|
||||
cfg := config.AppConfig
|
||||
url := fmt.Sprintf("%s/api/auth/validate-token", cfg.AuthCenterURL)
|
||||
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth service unavailable: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("auth failed: %s", string(body))
|
||||
}
|
||||
|
||||
var authResp AuthResponse
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if !authResp.Success {
|
||||
return nil, fmt.Errorf("token validation failed: %s", authResp.Message)
|
||||
}
|
||||
|
||||
return &authResp.Data, nil
|
||||
}
|
||||
|
||||
// AuthMiddleware validates JWT token from auth center
|
||||
func AuthMiddleware(required bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Extract token from Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
if required {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Authorization header required",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Parse Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
if required {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid authorization header format",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
tokenHash := hashToken(token)
|
||||
|
||||
// Check cache first
|
||||
if cached, exists := tokenCache[tokenHash]; exists {
|
||||
if time.Now().Before(cached.ExpiresAt) {
|
||||
// Cache hit and not expired
|
||||
c.Set("user", cached.UserData)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// Cache expired, remove it
|
||||
delete(tokenCache, tokenHash)
|
||||
}
|
||||
|
||||
// Validate with auth center
|
||||
authData, err := validateTokenWithAuthCenter(token)
|
||||
if err != nil {
|
||||
if required {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Token validation failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Create user context
|
||||
userCtx := &UserContext{
|
||||
UserID: authData.UserID,
|
||||
Username: authData.Username,
|
||||
Email: authData.Email,
|
||||
Roles: authData.Roles,
|
||||
}
|
||||
|
||||
// Cache the result (5 minutes)
|
||||
tokenCache[tokenHash] = &CacheEntry{
|
||||
UserData: userCtx,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
|
||||
// Set user in context
|
||||
c.Set("user", userCtx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUser retrieves user from context
|
||||
func GetCurrentUser(c *gin.Context) (*UserContext, bool) {
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, ok := userVal.(*UserContext)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// RequireAdmin middleware to check admin role
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, exists := GetCurrentUser(c)
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "Authentication required",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
isAdmin := false
|
||||
for _, role := range user.Roles {
|
||||
if role == "admin" {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "Admin access required",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
21
webapp-back/migrations/add_product_links.sql
Normal file
21
webapp-back/migrations/add_product_links.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- ============================================================
|
||||
-- Migration: add product_links table + footer_links.page column
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 新建 product_links 表
|
||||
CREATE TABLE IF NOT EXISTS `product_links` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`asset_id` BIGINT UNSIGNED NOT NULL,
|
||||
`link_text` VARCHAR(100) NOT NULL,
|
||||
`link_url` TEXT NOT NULL,
|
||||
`display_order` INT NOT NULL DEFAULT 0,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_product_links_asset_id` (`asset_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 2. footer_links 增加 page 列(如果还没有)
|
||||
ALTER TABLE `footer_links`
|
||||
ADD COLUMN IF NOT EXISTS `page` VARCHAR(50) NOT NULL DEFAULT '' AFTER `id`;
|
||||
16
webapp-back/migrations/seed_product_links.sql
Normal file
16
webapp-back/migrations/seed_product_links.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Seed: product_links for YT-A(7), YT-B(8), YT-C(9)
|
||||
INSERT INTO `product_links` (`asset_id`, `link_text`, `link_url`, `display_order`, `is_active`) VALUES
|
||||
(7, 'Smart Contract', 'https://www.baidu.com', 1, 1),
|
||||
(7, 'Compliance', 'https://www.baidu.com', 2, 1),
|
||||
(7, 'Proof of Reserves', 'https://www.baidu.com', 3, 1),
|
||||
(7, 'Protocol Information', 'https://www.baidu.com', 4, 1),
|
||||
|
||||
(8, 'Smart Contract', 'https://www.baidu.com', 1, 1),
|
||||
(8, 'Compliance', 'https://www.baidu.com', 2, 1),
|
||||
(8, 'Proof of Reserves', 'https://www.baidu.com', 3, 1),
|
||||
(8, 'Protocol Information', 'https://www.baidu.com', 4, 1),
|
||||
|
||||
(9, 'Smart Contract', 'https://www.baidu.com', 1, 1),
|
||||
(9, 'Compliance', 'https://www.baidu.com', 2, 1),
|
||||
(9, 'Proof of Reserves', 'https://www.baidu.com', 3, 1),
|
||||
(9, 'Protocol Information', 'https://www.baidu.com', 4, 1);
|
||||
205
webapp-back/models/asset.go
Normal file
205
webapp-back/models/asset.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Asset represents the assets table
|
||||
type Asset struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AssetCode string `gorm:"size:20;not null;unique;index" json:"asset_code"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Subtitle string `gorm:"type:text" json:"subtitle"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
TokenSymbol string `gorm:"size:20" json:"token_symbol"`
|
||||
Decimals int `gorm:"default:18" json:"decimals"`
|
||||
ChainID int `gorm:"default:97" json:"chain_id"`
|
||||
ContractAddress string `gorm:"size:42" json:"contract_address"`
|
||||
DeployBlock *uint64 `json:"deploy_block"`
|
||||
Category string `gorm:"size:100" json:"category"`
|
||||
CategoryColor string `gorm:"size:50" json:"category_color"`
|
||||
IconURL string `gorm:"type:text" json:"icon_url"`
|
||||
UnderlyingAssets string `gorm:"size:255" json:"underlying_assets"`
|
||||
TargetAPY float64 `gorm:"type:decimal(10,2)" json:"target_apy"`
|
||||
PoolCapUSD float64 `gorm:"type:decimal(30,2)" json:"pool_cap_usd"`
|
||||
RiskLevel int `json:"risk_level"`
|
||||
RiskLabel string `gorm:"size:50" json:"risk_label"`
|
||||
TokenRole string `gorm:"size:20;default:'product'" json:"token_role"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
IsFeatured bool `gorm:"default:false" json:"is_featured"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Asset) TableName() string {
|
||||
return "assets"
|
||||
}
|
||||
|
||||
// AssetPerformance represents the asset_performance table
|
||||
type AssetPerformance struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AssetID uint `gorm:"not null;index" json:"asset_id"`
|
||||
CurrentAPY float64 `gorm:"type:decimal(10,2)" json:"current_apy"`
|
||||
TVLUSD float64 `gorm:"type:decimal(30,2)" json:"tvl_usd"`
|
||||
TotalInvestedUSD float64 `gorm:"type:decimal(30,2)" json:"total_invested_usd"`
|
||||
InvestorCount int `gorm:"default:0" json:"investor_count"`
|
||||
CumulativeYieldUSD float64 `gorm:"type:decimal(30,2)" json:"cumulative_yield_usd"`
|
||||
Yield24hUSD float64 `gorm:"type:decimal(30,2)" json:"yield_24h_usd"`
|
||||
PoolCapacityPercent float64 `gorm:"type:decimal(10,4)" json:"pool_capacity_percent"`
|
||||
CirculatingSupply float64 `gorm:"type:decimal(30,18)" json:"circulating_supply"`
|
||||
YTPrice float64 `gorm:"column:yt_price;type:decimal(30,18);default:0" json:"yt_price"`
|
||||
Volume24hUSD float64 `gorm:"column:volume_24h_usd;type:decimal(30,2);default:0" json:"volume_24h_usd"`
|
||||
SnapshotDate time.Time `gorm:"not null" json:"snapshot_date"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (AssetPerformance) TableName() string {
|
||||
return "asset_performance"
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ProductResponse is the response format for fund market products
|
||||
type ProductResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenSymbol string `json:"tokenSymbol"`
|
||||
Decimals int `json:"decimals"`
|
||||
ContractAddress string `json:"contractAddress"`
|
||||
ChainID int `json:"chainId"`
|
||||
TokenRole string `json:"token_role"`
|
||||
Category string `json:"category"`
|
||||
CategoryColor string `json:"categoryColor"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
YieldAPY string `json:"yieldAPY"`
|
||||
PoolCap string `json:"poolCap"`
|
||||
Risk string `json:"risk"`
|
||||
RiskLevel int `json:"riskLevel"`
|
||||
CirculatingSupply string `json:"circulatingSupply"`
|
||||
PoolCapacityPercent float64 `json:"poolCapacityPercent"`
|
||||
}
|
||||
|
||||
// StatsResponse is the response format for fund market stats
|
||||
type StatsResponse struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
Change string `json:"change"`
|
||||
IsPositive bool `json:"isPositive"`
|
||||
}
|
||||
|
||||
// AssetCustody represents the asset_custody table
|
||||
type AssetCustody struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AssetID uint `gorm:"not null;index" json:"asset_id"`
|
||||
CustodianName string `gorm:"size:255" json:"custodian_name"`
|
||||
CustodianAddress string `gorm:"type:text" json:"custodian_address"`
|
||||
CustodianLicense string `gorm:"size:255" json:"custodian_license"`
|
||||
CustodyType string `gorm:"size:100" json:"custody_type"`
|
||||
CustodyLocation string `gorm:"size:255" json:"custody_location"`
|
||||
AuditorName string `gorm:"size:255" json:"auditor_name"`
|
||||
LastAuditDate NullTime `json:"last_audit_date"`
|
||||
AuditReportURL string `gorm:"type:text" json:"audit_report_url"`
|
||||
AdditionalInfo string `gorm:"type:json" json:"additional_info"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (AssetCustody) TableName() string {
|
||||
return "asset_custody"
|
||||
}
|
||||
|
||||
// AssetAuditReport represents the asset_audit_reports table
|
||||
type AssetAuditReport struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AssetID uint `gorm:"not null;index" json:"asset_id"`
|
||||
ReportType string `gorm:"size:50;not null" json:"report_type"`
|
||||
ReportTitle string `gorm:"size:255;not null" json:"report_title"`
|
||||
ReportDate time.Time `gorm:"not null" json:"report_date"`
|
||||
ReportURL string `gorm:"type:text" json:"report_url"`
|
||||
AuditorName string `gorm:"size:255" json:"auditor_name"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (AssetAuditReport) TableName() string {
|
||||
return "asset_audit_reports"
|
||||
}
|
||||
|
||||
// ProductDetailResponse is the detailed response for a single product
|
||||
type ProductDetailResponse struct {
|
||||
// Basic Info
|
||||
ID int `json:"id"`
|
||||
AssetCode string `json:"assetCode"`
|
||||
Name string `json:"name"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Description string `json:"description"`
|
||||
TokenSymbol string `json:"tokenSymbol"`
|
||||
Decimals int `json:"decimals"`
|
||||
|
||||
// Investment Parameters
|
||||
UnderlyingAssets string `json:"underlyingAssets"`
|
||||
PoolCapUSD float64 `json:"poolCapUsd"`
|
||||
RiskLevel int `json:"riskLevel"`
|
||||
RiskLabel string `json:"riskLabel"`
|
||||
TargetAPY float64 `json:"targetApy"`
|
||||
|
||||
// Contract Info
|
||||
ContractAddress string `json:"contractAddress"`
|
||||
ChainID int `json:"chainId"`
|
||||
|
||||
// Display Info
|
||||
Category string `json:"category"`
|
||||
CategoryColor string `json:"categoryColor"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
|
||||
// Performance Data (from blockchain/database)
|
||||
CurrentAPY float64 `json:"currentApy"`
|
||||
TVLUSD float64 `json:"tvlUsd"`
|
||||
Volume24hUSD float64 `json:"volume24hUsd"`
|
||||
VolumeChangeVsAvg float64 `json:"volumeChangeVsAvg"`
|
||||
CirculatingSupply float64 `json:"circulatingSupply"`
|
||||
PoolCapacityPercent float64 `json:"poolCapacityPercent"`
|
||||
CurrentPrice float64 `json:"currentPrice"` // e.g., 1.04 USDC per token
|
||||
|
||||
// Custody Info
|
||||
Custody *CustodyInfo `json:"custody,omitempty"`
|
||||
|
||||
// Audit Reports
|
||||
AuditReports []AuditReportInfo `json:"auditReports"`
|
||||
|
||||
// Product Links
|
||||
ProductLinks []ProductLinkInfo `json:"productLinks"`
|
||||
}
|
||||
|
||||
// ProductLinkInfo represents a single product link item
|
||||
type ProductLinkInfo struct {
|
||||
LinkText string `json:"linkText"`
|
||||
LinkURL string `json:"linkUrl"`
|
||||
Description string `json:"description"`
|
||||
DisplayArea string `json:"displayArea"`
|
||||
DisplayOrder int `json:"displayOrder"`
|
||||
}
|
||||
|
||||
// CustodyInfo represents custody information
|
||||
type CustodyInfo struct {
|
||||
CustodianName string `json:"custodianName"`
|
||||
CustodyType string `json:"custodyType"`
|
||||
CustodyLocation string `json:"custodyLocation"`
|
||||
AuditorName string `json:"auditorName"`
|
||||
LastAuditDate string `json:"lastAuditDate"`
|
||||
AuditReportURL string `json:"auditReportUrl,omitempty"`
|
||||
AdditionalInfo map[string]interface{} `json:"additionalInfo,omitempty"`
|
||||
}
|
||||
|
||||
// AuditReportInfo represents audit report information
|
||||
type AuditReportInfo struct {
|
||||
ReportType string `json:"reportType"`
|
||||
ReportTitle string `json:"reportTitle"`
|
||||
ReportDate string `json:"reportDate"`
|
||||
AuditorName string `json:"auditorName"`
|
||||
Summary string `json:"summary"`
|
||||
ReportURL string `json:"reportUrl"`
|
||||
}
|
||||
22
webapp-back/models/collateral_buy.go
Normal file
22
webapp-back/models/collateral_buy.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CollateralBuyRecord records each collateral purchase made by the buyer bot
|
||||
type CollateralBuyRecord struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ChainID int `gorm:"index" json:"chain_id"`
|
||||
TxHash string `gorm:"size:66;uniqueIndex" json:"tx_hash"`
|
||||
BuyerAddr string `gorm:"size:42" json:"buyer_addr"`
|
||||
AssetAddr string `gorm:"size:42;index" json:"asset_addr"`
|
||||
AssetSymbol string `gorm:"size:50" json:"asset_symbol"`
|
||||
PaidAmount string `gorm:"size:78" json:"paid_amount"` // USDC paid, base units
|
||||
ReceivedAmount string `gorm:"size:78" json:"received_amount"` // collateral received, base units
|
||||
GasUsed uint64 `json:"gas_used"`
|
||||
BlockNumber uint64 `json:"block_number"`
|
||||
Status string `gorm:"size:20;default:'success'" json:"status"` // success / failed
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (CollateralBuyRecord) TableName() string { return "collateral_buy_records" }
|
||||
217
webapp-back/models/config.go
Normal file
217
webapp-back/models/config.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
// ALPSnapshot records periodic ALP pool stats for APR calculation
|
||||
type ALPSnapshot struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
PoolValue float64 `gorm:"type:decimal(30,18)" json:"pool_value"` // getAumInUsdy(true), 18 dec
|
||||
UsdySupply float64 `gorm:"type:decimal(30,18)" json:"usdy_supply"` // USDY.totalSupply(), 18 dec
|
||||
FeeSurplus float64 `gorm:"type:decimal(30,18)" json:"fee_surplus"` // poolValue - usdySupply
|
||||
ALPPrice float64 `gorm:"type:decimal(30,18)" json:"alp_price"` // getPrice(false), 30 dec
|
||||
SnapshotTime time.Time `gorm:"not null;index" json:"snapshot_time"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (ALPSnapshot) TableName() string {
|
||||
return "alp_snapshots"
|
||||
}
|
||||
|
||||
// APYSnapshot represents the apy_snapshots table
|
||||
type APYSnapshot struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AssetID uint `gorm:"not null;index:idx_asset_time" json:"asset_id"`
|
||||
ChainID int `gorm:"not null" json:"chain_id"`
|
||||
ContractAddress string `gorm:"size:42" json:"contract_address"`
|
||||
APYValue float64 `gorm:"type:decimal(10,4)" json:"apy_value"`
|
||||
SupplyAPY float64 `gorm:"type:decimal(20,4)" json:"supply_apy"`
|
||||
BorrowAPY float64 `gorm:"type:decimal(20,4)" json:"borrow_apy"`
|
||||
TotalAssets float64 `gorm:"type:decimal(30,18)" json:"total_assets"`
|
||||
TotalSupply float64 `gorm:"type:decimal(30,18)" json:"total_supply"`
|
||||
Price float64 `gorm:"type:decimal(30,18)" json:"price"`
|
||||
SnapshotTime time.Time `gorm:"not null;index:idx_asset_time" json:"snapshot_time"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (APYSnapshot) TableName() string {
|
||||
return "apy_snapshots"
|
||||
}
|
||||
|
||||
// ProductLink represents the product_links table — per-asset links shown on the product detail page
|
||||
type ProductLink struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AssetID uint `gorm:"not null;index" json:"asset_id"`
|
||||
LinkText string `gorm:"size:100;not null" json:"link_text"`
|
||||
LinkURL string `gorm:"type:text;not null" json:"link_url"`
|
||||
Description string `gorm:"size:500" json:"description"`
|
||||
DisplayArea string `gorm:"size:20;default:protocol" json:"display_area"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ProductLink) TableName() string {
|
||||
return "product_links"
|
||||
}
|
||||
|
||||
// KLineData represents the kline_data table
|
||||
type KLineData struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
AssetID uint `gorm:"not null;index:idx_asset_time" json:"asset_id"`
|
||||
ChainID int `gorm:"not null" json:"chain_id"`
|
||||
ContractAddress string `gorm:"size:42" json:"contract_address"`
|
||||
Timeframe string `gorm:"size:10;not null" json:"timeframe"`
|
||||
OpenTime time.Time `gorm:"not null;index:idx_asset_time" json:"open_time"`
|
||||
CloseTime time.Time `gorm:"not null" json:"close_time"`
|
||||
OpenPrice float64 `gorm:"type:decimal(30,18);not null" json:"open_price"`
|
||||
HighPrice float64 `gorm:"type:decimal(30,18);not null" json:"high_price"`
|
||||
LowPrice float64 `gorm:"type:decimal(30,18);not null" json:"low_price"`
|
||||
ClosePrice float64 `gorm:"type:decimal(30,18);not null" json:"close_price"`
|
||||
Volume float64 `gorm:"type:decimal(30,18)" json:"volume"`
|
||||
Trades int `json:"trades"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (KLineData) TableName() string {
|
||||
return "kline_data"
|
||||
}
|
||||
|
||||
|
||||
// PointsRule represents the points_rules table
|
||||
type PointsRule struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
RuleName string `gorm:"size:100;not null;unique" json:"rule_name"`
|
||||
RuleType string `gorm:"size:50;not null" json:"rule_type"`
|
||||
BasePoints int `gorm:"not null" json:"base_points"`
|
||||
Multiplier float64 `gorm:"type:decimal(10,4);default:1.0000" json:"multiplier"`
|
||||
Conditions string `gorm:"type:json" json:"conditions"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
ValidFrom NullTime `json:"valid_from"`
|
||||
ValidUntil NullTime `json:"valid_until"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
Priority int `gorm:"default:0" json:"priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (PointsRule) TableName() string {
|
||||
return "points_rules"
|
||||
}
|
||||
|
||||
// Role represents the roles table
|
||||
type Role struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
RoleName string `gorm:"size:50;not null;unique" json:"role_name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Permissions string `gorm:"type:json" json:"permissions"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Role) TableName() string {
|
||||
return "roles"
|
||||
}
|
||||
|
||||
// UserRole represents the user_roles table
|
||||
type UserRole struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;uniqueIndex:uk_user_role" json:"user_id"`
|
||||
RoleID uint `gorm:"not null;uniqueIndex:uk_user_role" json:"role_id"`
|
||||
AssignedAt time.Time `gorm:"not null" json:"assigned_at"`
|
||||
AssignedBy uint `json:"assigned_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (UserRole) TableName() string {
|
||||
return "user_roles"
|
||||
}
|
||||
|
||||
// Session represents the sessions table
|
||||
type Session struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
SessionToken string `gorm:"size:255;not null;unique" json:"session_token"`
|
||||
WalletAddress string `gorm:"size:42;not null" json:"wallet_address"`
|
||||
SignMessage string `gorm:"type:text" json:"sign_message"`
|
||||
Signature string `gorm:"size:132" json:"signature"`
|
||||
SignatureHash string `gorm:"size:66" json:"signature_hash"`
|
||||
IPAddress string `gorm:"size:45" json:"ip_address"`
|
||||
UserAgent string `gorm:"type:text" json:"user_agent"`
|
||||
ExpiresAt *time.Time `gorm:"index" json:"expires_at"`
|
||||
LastActivityAt *time.Time `json:"last_activity_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Session) TableName() string {
|
||||
return "sessions"
|
||||
}
|
||||
|
||||
// Transaction represents the transactions table
|
||||
type Transaction struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index:idx_user_time" json:"user_id"`
|
||||
TxHash string `gorm:"size:66;not null;unique" json:"tx_hash"`
|
||||
ChainID int `gorm:"not null" json:"chain_id"`
|
||||
BlockNumber int64 `gorm:"not null" json:"block_number"`
|
||||
TxType string `gorm:"size:50;not null" json:"tx_type"`
|
||||
AssetID *uint `json:"asset_id"`
|
||||
FromAddress string `gorm:"size:42;not null" json:"from_address"`
|
||||
ToAddress string `gorm:"size:42;not null" json:"to_address"`
|
||||
Amount float64 `gorm:"type:decimal(30,18)" json:"amount"`
|
||||
TokenSymbol string `gorm:"size:20" json:"token_symbol"`
|
||||
GasUsed int64 `json:"gas_used"`
|
||||
GasPrice float64 `gorm:"type:decimal(30,18)" json:"gas_price"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message"`
|
||||
Metadata string `gorm:"type:json" json:"metadata"`
|
||||
ConfirmedAt time.Time `gorm:"not null;index:idx_user_time" json:"confirmed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Transaction) TableName() string {
|
||||
return "transactions"
|
||||
}
|
||||
|
||||
// OperationLog represents the operation_logs table
|
||||
type OperationLog struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID *uint `gorm:"index" json:"user_id"`
|
||||
OperationType string `gorm:"size:50;not null" json:"operation_type"`
|
||||
TargetType string `gorm:"size:50" json:"target_type"`
|
||||
TargetID *uint `json:"target_id"`
|
||||
Action string `gorm:"size:100;not null" json:"action"`
|
||||
Changes string `gorm:"type:json" json:"changes"`
|
||||
IPAddress string `gorm:"size:45" json:"ip_address"`
|
||||
UserAgent string `gorm:"type:text" json:"user_agent"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
func (OperationLog) TableName() string {
|
||||
return "operation_logs"
|
||||
}
|
||||
|
||||
// UserActivity represents the user_activities table
|
||||
type UserActivity struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index:idx_user_time" json:"user_id"`
|
||||
ActivityType string `gorm:"size:50;not null" json:"activity_type"`
|
||||
ActivityData string `gorm:"type:json" json:"activity_data"`
|
||||
AssetID *uint `json:"asset_id"`
|
||||
Amount float64 `gorm:"type:decimal(30,18)" json:"amount"`
|
||||
PointsEarned int `gorm:"default:0" json:"points_earned"`
|
||||
ReferenceType string `gorm:"size:50" json:"reference_type"`
|
||||
ReferenceID *uint `json:"reference_id"`
|
||||
IPAddress string `gorm:"size:45" json:"ip_address"`
|
||||
CreatedAt time.Time `gorm:"index:idx_user_time" json:"created_at"`
|
||||
}
|
||||
|
||||
func (UserActivity) TableName() string {
|
||||
return "user_activities"
|
||||
}
|
||||
43
webapp-back/models/holder.go
Normal file
43
webapp-back/models/holder.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// HolderSnapshot represents token holders data from blockchain
|
||||
type HolderSnapshot struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
HolderAddress string `gorm:"size:42;not null;index:idx_holder_token" json:"holder_address"`
|
||||
TokenType string `gorm:"size:50;not null;index:idx_holder_token,idx_token_time" json:"token_type"`
|
||||
TokenAddress string `gorm:"size:42;not null" json:"token_address"`
|
||||
Balance string `gorm:"type:varchar(78);not null" json:"balance"`
|
||||
ChainID int `gorm:"not null" json:"chain_id"`
|
||||
FirstSeen int64 `gorm:"not null" json:"first_seen"`
|
||||
LastUpdated int64 `gorm:"not null;index:idx_token_time" json:"last_updated"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (HolderSnapshot) TableName() string {
|
||||
return "holder_snapshots"
|
||||
}
|
||||
|
||||
// HolderStats represents aggregated statistics for each token type
|
||||
type HolderStats struct {
|
||||
TokenType string `json:"token_type"`
|
||||
HolderCount int `json:"holder_count"`
|
||||
TotalBalance string `json:"total_balance,omitempty"`
|
||||
}
|
||||
|
||||
// ScannerState persists scanner progress so it can resume after restart
|
||||
type ScannerState struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
ScannerType string `gorm:"column:scanner_type;not null;default:'holder';uniqueIndex:idx_chain_scanner"`
|
||||
ChainID int `gorm:"column:chain_id;uniqueIndex:idx_chain_scanner;not null"`
|
||||
LastScannedBlock uint64 `gorm:"column:last_scanned_block;not null;default:0"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (ScannerState) TableName() string {
|
||||
return "scanner_state"
|
||||
}
|
||||
17
webapp-back/models/known_borrower.go
Normal file
17
webapp-back/models/known_borrower.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// KnownBorrower stores every address that has ever interacted with the lending contract.
|
||||
// The liquidation bot checks ALL known borrowers for isLiquidatable on every tick,
|
||||
// so accounts whose health factor drops due to oracle price changes are still caught
|
||||
// even if they haven't had a recent on-chain transaction.
|
||||
type KnownBorrower struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ChainID int `gorm:"uniqueIndex:idx_known_borrower" json:"chain_id"`
|
||||
Address string `gorm:"size:42;uniqueIndex:idx_known_borrower" json:"address"`
|
||||
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
func (KnownBorrower) TableName() string { return "known_borrowers" }
|
||||
22
webapp-back/models/liquidation.go
Normal file
22
webapp-back/models/liquidation.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// LiquidationRecord stores each batch liquidation execution
|
||||
type LiquidationRecord struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ChainID int `gorm:"index" json:"chain_id"`
|
||||
TxHash string `gorm:"size:66;uniqueIndex" json:"tx_hash"`
|
||||
LiquidatorAddr string `gorm:"size:42" json:"liquidator_addr"`
|
||||
AccountCount int `json:"account_count"`
|
||||
Accounts string `gorm:"type:text" json:"accounts"` // JSON array of addresses
|
||||
GasUsed uint64 `json:"gas_used"`
|
||||
BlockNumber uint64 `json:"block_number"`
|
||||
Status string `gorm:"size:20;default:'success'" json:"status"` // success / failed
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (LiquidationRecord) TableName() string {
|
||||
return "liquidation_records"
|
||||
}
|
||||
67
webapp-back/models/nulltime.go
Normal file
67
webapp-back/models/nulltime.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NullTime wraps *time.Time with custom JSON and GORM handling:
|
||||
// - JSON marshal: nil → "1970-01-01T00:00:00Z", non-nil → RFC3339
|
||||
// - JSON unmarshal: null / "" / "1970-01-01..." → nil (stored as NULL in DB)
|
||||
// - GORM Scan/Value: nil ↔ NULL column
|
||||
type NullTime struct {
|
||||
Time *time.Time
|
||||
}
|
||||
|
||||
func (nt NullTime) MarshalJSON() ([]byte, error) {
|
||||
if nt.Time == nil {
|
||||
return []byte(`"1970-01-01T00:00:00Z"`), nil
|
||||
}
|
||||
return json.Marshal(nt.Time.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (nt *NullTime) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
nt.Time = nil
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == "" || strings.HasPrefix(s, "1970-01-01") {
|
||||
nt.Time = nil
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nt.Time = &t
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer so GORM writes NULL for nil.
|
||||
func (nt NullTime) Value() (driver.Value, error) {
|
||||
if nt.Time == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return *nt.Time, nil
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner so GORM reads NULL as nil.
|
||||
func (nt *NullTime) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
nt.Time = nil
|
||||
return nil
|
||||
}
|
||||
t, ok := value.(time.Time)
|
||||
if !ok {
|
||||
nt.Time = nil
|
||||
return nil
|
||||
}
|
||||
nt.Time = &t
|
||||
return nil
|
||||
}
|
||||
262
webapp-back/models/points.go
Normal file
262
webapp-back/models/points.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Season represents the seasons table
|
||||
type Season struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SeasonNumber int `gorm:"not null;unique;index" json:"season_number"`
|
||||
SeasonName string `gorm:"size:100;not null" json:"season_name"`
|
||||
StartTime time.Time `gorm:"not null" json:"start_time"`
|
||||
EndTime time.Time `gorm:"not null" json:"end_time"`
|
||||
Status string `gorm:"size:20;default:'upcoming'" json:"status"` // upcoming, active, ended
|
||||
TotalRewards float64 `gorm:"type:decimal(30,2)" json:"total_rewards"`
|
||||
Multiplier float64 `gorm:"type:decimal(10,4);default:1.0000" json:"multiplier"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Season) TableName() string {
|
||||
return "seasons"
|
||||
}
|
||||
|
||||
// VIPTier represents the vip_tiers table
|
||||
type VIPTier struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TierName string `gorm:"size:50;not null" json:"tier_name"`
|
||||
TierLevel int `gorm:"not null;unique;index" json:"tier_level"`
|
||||
MinPoints int64 `gorm:"not null;index" json:"min_points"`
|
||||
MaxPoints *int64 `json:"max_points"`
|
||||
Multiplier float64 `gorm:"type:decimal(10,4);default:1.0000" json:"multiplier"`
|
||||
Perks string `gorm:"type:json" json:"perks"`
|
||||
Icon string `gorm:"size:255" json:"icon"`
|
||||
Color string `gorm:"size:50" json:"color"`
|
||||
DisplayOrder int `gorm:"default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (VIPTier) TableName() string {
|
||||
return "vip_tiers"
|
||||
}
|
||||
|
||||
// UserTeam represents the user_teams table
|
||||
type UserTeam struct {
|
||||
WalletAddress string `gorm:"primaryKey;size:42" json:"wallet_address"`
|
||||
TeamTVLUSD float64 `gorm:"type:decimal(30,2);default:0.00" json:"team_tvl_usd"`
|
||||
TeamTargetTVLUSD float64 `gorm:"type:decimal(30,2);default:10000000.00" json:"team_target_tvl_usd"`
|
||||
TeamMembersCount int `gorm:"default:0" json:"team_members_count"`
|
||||
WhalesCount int `gorm:"default:0" json:"whales_count"`
|
||||
WhalesTarget int `gorm:"default:3" json:"whales_target"`
|
||||
TradersCount int `gorm:"default:0" json:"traders_count"`
|
||||
TradersTarget int `gorm:"default:3" json:"traders_target"`
|
||||
UsersCount int `gorm:"default:0" json:"users_count"`
|
||||
UsersTarget int `gorm:"default:3" json:"users_target"`
|
||||
LastCalculatedAt *time.Time `json:"last_calculated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (UserTeam) TableName() string {
|
||||
return "user_teams"
|
||||
}
|
||||
|
||||
// UserPointsSummary represents the user_points_summary table
|
||||
type UserPointsSummary struct {
|
||||
WalletAddress string `gorm:"primaryKey;size:42" json:"wallet_address"`
|
||||
TotalPoints int64 `gorm:"default:0" json:"total_points"`
|
||||
HoldingPoints int64 `gorm:"default:0" json:"holding_points"`
|
||||
LpPoints int64 `gorm:"default:0" json:"lp_points"`
|
||||
LendingPoints int64 `gorm:"default:0" json:"lending_points"`
|
||||
TradingPoints int64 `gorm:"default:0" json:"trading_points"`
|
||||
InvitationPoints int64 `gorm:"default:0" json:"invitation_points"`
|
||||
BonusPoints int64 `gorm:"default:0" json:"bonus_points"`
|
||||
GlobalRank *int `json:"global_rank"`
|
||||
SeasonRank *int `json:"season_rank"`
|
||||
CurrentSeason int `gorm:"default:1" json:"current_season"`
|
||||
TotalTrades int `gorm:"default:0" json:"total_trades"`
|
||||
TotalHoldingDays int `gorm:"default:0" json:"total_holding_days"`
|
||||
TotalInvites int `gorm:"default:0" json:"total_invites"`
|
||||
LastCalculatedAt *time.Time `json:"last_calculated_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (UserPointsSummary) TableName() string {
|
||||
return "user_points_summary"
|
||||
}
|
||||
|
||||
// UserPointsRecord represents the user_points_records table
|
||||
type UserPointsRecord struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
WalletAddress string `gorm:"size:42;not null;index" json:"wallet_address"`
|
||||
PointsChange int `gorm:"not null" json:"points_change"`
|
||||
PointsBefore int `gorm:"not null" json:"points_before"`
|
||||
PointsAfter int `gorm:"not null" json:"points_after"`
|
||||
SourceType string `gorm:"size:50;not null" json:"source_type"`
|
||||
MultiplierApplied float64 `gorm:"type:decimal(10,4);default:1.0000" json:"multiplier_applied"`
|
||||
SourceID *uint `json:"source_id"`
|
||||
RuleID *uint `json:"rule_id"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Metadata string `gorm:"type:json" json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (UserPointsRecord) TableName() string {
|
||||
return "user_points_records"
|
||||
}
|
||||
|
||||
// Invitation represents the invitations table
|
||||
type Invitation struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ReferrerWallet string `gorm:"size:42;not null;index" json:"referrer_wallet"`
|
||||
RefereeWallet string `gorm:"size:42;not null;unique;index" json:"referee_wallet"`
|
||||
InviteCode string `gorm:"size:20;not null" json:"invite_code"`
|
||||
BindSignature string `gorm:"type:text" json:"bind_signature"`
|
||||
BindHash string `gorm:"size:66" json:"bind_hash"`
|
||||
Status string `gorm:"size:50;default:'active'" json:"status"`
|
||||
ReferrerRewardPoints int `gorm:"default:0" json:"referrer_reward_points"`
|
||||
RefereeRewardPoints int `gorm:"default:0" json:"referee_reward_points"`
|
||||
BoundAt time.Time `gorm:"not null" json:"bound_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Invitation) TableName() string {
|
||||
return "invitations"
|
||||
}
|
||||
|
||||
// InviteCode represents the invite_codes table
|
||||
type InviteCode struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
WalletAddress string `gorm:"size:42;not null;unique;index" json:"wallet_address"`
|
||||
Code string `gorm:"size:20;not null;unique;index" json:"code"`
|
||||
MaxUses int `gorm:"default:-1" json:"max_uses"` // -1 = unlimited
|
||||
UsedCount int `gorm:"default:0" json:"used_count"`
|
||||
ExpiresAt NullTime `json:"expires_at"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (InviteCode) TableName() string {
|
||||
return "invite_codes"
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Response DTOs
|
||||
// =====================
|
||||
|
||||
// DashboardResponse is the response for GET /api/points/dashboard
|
||||
type DashboardResponse struct {
|
||||
TotalPoints int64 `json:"totalPoints"`
|
||||
GlobalRank int `json:"globalRank"`
|
||||
TopPercentage string `json:"topPercentage"`
|
||||
MemberTier string `json:"memberTier"`
|
||||
VIPLevel int `json:"vipLevel"`
|
||||
PointsToNextTier int64 `json:"pointsToNextTier"`
|
||||
NextTier string `json:"nextTier"`
|
||||
Season SeasonInfo `json:"season"`
|
||||
}
|
||||
|
||||
// SeasonInfo contains season information
|
||||
type SeasonInfo struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
SeasonName string `json:"seasonName"`
|
||||
IsLive bool `json:"isLive"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
DaysRemaining int `json:"daysRemaining"`
|
||||
HoursRemaining int `json:"hoursRemaining"`
|
||||
}
|
||||
|
||||
// LeaderboardResponse is the response for GET /api/points/leaderboard
|
||||
type LeaderboardResponse struct {
|
||||
TopUsers []LeaderboardUser `json:"topUsers"`
|
||||
MyRank int `json:"myRank"`
|
||||
MyPoints int64 `json:"myPoints"`
|
||||
}
|
||||
|
||||
// LeaderboardUser represents a user in the leaderboard
|
||||
type LeaderboardUser struct {
|
||||
Rank int `json:"rank"`
|
||||
WalletAddress string `json:"address"`
|
||||
Points int64 `json:"points"`
|
||||
}
|
||||
|
||||
// InviteCodeResponse is the response for GET /api/points/invite-code
|
||||
type InviteCodeResponse struct {
|
||||
Code string `json:"code"`
|
||||
UsedCount int `json:"usedCount"`
|
||||
MaxUses int `json:"maxUses"`
|
||||
}
|
||||
|
||||
// TeamTVLResponse is the response for GET /api/points/team
|
||||
type TeamTVLResponse struct {
|
||||
CurrentTVL string `json:"currentTVL"`
|
||||
TargetTVL string `json:"targetTVL"`
|
||||
ProgressPercent float64 `json:"progressPercent"`
|
||||
TotalMembers int `json:"totalMembers"`
|
||||
Roles []RoleCount `json:"roles"`
|
||||
}
|
||||
|
||||
// RoleCount represents count for each role
|
||||
type RoleCount struct {
|
||||
Icon string `json:"icon"`
|
||||
Label string `json:"label"`
|
||||
Current int `json:"current"`
|
||||
Target int `json:"target"`
|
||||
}
|
||||
|
||||
// ActivitiesResponse is the response for GET /api/points/activities
|
||||
type ActivitiesResponse struct {
|
||||
Activities []ActivityRecord `json:"activities"`
|
||||
Pagination PaginationInfo `json:"pagination"`
|
||||
}
|
||||
|
||||
// ActivityRecord represents a single activity
|
||||
type ActivityRecord struct {
|
||||
Type string `json:"type"`
|
||||
UserAddress string `json:"userAddress"`
|
||||
FriendAddress string `json:"friendAddress,omitempty"`
|
||||
InviteCode string `json:"inviteCode,omitempty"`
|
||||
Points int `json:"points"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// PaginationInfo contains pagination metadata
|
||||
type PaginationInfo struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int `json:"total"`
|
||||
TotalPage int `json:"totalPage"`
|
||||
}
|
||||
|
||||
// BindInviteRequest is the request body for POST /api/points/bind-invite
|
||||
type BindInviteRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
Signature string `json:"signature" binding:"required"`
|
||||
}
|
||||
|
||||
// HoldersSnapshot represents the holders_snapshots table
|
||||
// 每小时快照:记录用户持有 YT/LP/Lending 代币的余额及积分计算结果
|
||||
type HoldersSnapshot struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
WalletAddress string `gorm:"size:42;not null;index" json:"wallet_address"`
|
||||
TokenType string `gorm:"size:20;not null" json:"token_type"`
|
||||
TokenAddress string `gorm:"size:42;not null" json:"token_address"`
|
||||
ChainID int `gorm:"not null" json:"chain_id"`
|
||||
Balance float64 `gorm:"type:decimal(30,18);not null" json:"balance"`
|
||||
BalanceUSD float64 `gorm:"type:decimal(30,2)" json:"balance_usd"`
|
||||
HoldingDurationHours int `json:"holding_duration_hours"`
|
||||
PointsMultiplier float64 `gorm:"type:decimal(10,4);default:1.0000" json:"points_multiplier"`
|
||||
EarnedPoints int `json:"earned_points"`
|
||||
SnapshotTime time.Time `gorm:"not null;index" json:"snapshot_time"`
|
||||
SeasonID *uint `gorm:"index" json:"season_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (HoldersSnapshot) TableName() string {
|
||||
return "holders_snapshots"
|
||||
}
|
||||
20
webapp-back/models/system_contract.go
Normal file
20
webapp-back/models/system_contract.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemContract is the central registry for all infrastructure contract addresses.
|
||||
// One row per (name, chain_id) pair.
|
||||
// Replaces the former alp_pools and lending_markets tables (which had only one row each).
|
||||
type SystemContract struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"size:100;not null;uniqueIndex:uk_name_chain" json:"name"`
|
||||
ChainID int `gorm:"not null;uniqueIndex:uk_name_chain" json:"chain_id"`
|
||||
Address string `gorm:"size:42" json:"address"`
|
||||
DeployBlock *uint64 `json:"deploy_block"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (SystemContract) TableName() string { return "system_contracts" }
|
||||
27
webapp-back/models/user.go
Normal file
27
webapp-back/models/user.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents the users table
|
||||
// Web3 native: wallet_address is the primary key (no numeric id, no auth_user_id)
|
||||
type User struct {
|
||||
WalletAddress string `gorm:"primaryKey;size:42" json:"wallet_address"`
|
||||
Nickname string `gorm:"size:100" json:"nickname"`
|
||||
Avatar string `gorm:"type:text" json:"avatar"`
|
||||
Bio string `gorm:"type:text" json:"bio"`
|
||||
ReferrerWallet *string `gorm:"size:42;index" json:"referrer_wallet,omitempty"`
|
||||
InviteCode string `gorm:"size:20;unique;index" json:"invite_code"`
|
||||
DirectInvitesCount int `gorm:"default:0" json:"direct_invites_count"`
|
||||
MemberTier string `gorm:"size:50;default:'Bronze'" json:"member_tier"`
|
||||
VIPLevel int `gorm:"default:1" json:"vip_level"`
|
||||
TotalPoints int64 `gorm:"default:0" json:"total_points"`
|
||||
GlobalRank *int `gorm:"index" json:"global_rank,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
24
webapp-back/models/yt_swap.go
Normal file
24
webapp-back/models/yt_swap.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// YTSwapRecord records each Swap event emitted by YT Vault contracts.
|
||||
// Swap(address account, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint256 feeBasisPoints)
|
||||
// account/tokenIn/tokenOut are indexed (topics), amountIn/amountOut are in data.
|
||||
type YTSwapRecord struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TxHash string `gorm:"size:66;uniqueIndex:uk_tx_log" json:"tx_hash"`
|
||||
LogIndex uint `gorm:"uniqueIndex:uk_tx_log" json:"log_index"`
|
||||
ChainID int `gorm:"index" json:"chain_id"`
|
||||
BlockNumber uint64 `json:"block_number"`
|
||||
BlockTime time.Time `gorm:"index" json:"block_time"`
|
||||
VaultAddr string `gorm:"size:42;index" json:"vault_addr"`
|
||||
Account string `gorm:"size:42;index" json:"account"`
|
||||
TokenIn string `gorm:"size:42" json:"token_in"`
|
||||
TokenOut string `gorm:"size:42" json:"token_out"`
|
||||
AmountIn string `gorm:"size:78" json:"amount_in"` // wei string, base units
|
||||
AmountOut string `gorm:"size:78" json:"amount_out"` // wei string, base units
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (YTSwapRecord) TableName() string { return "yt_swap_records" }
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
161
webapp-back/readme.md
Normal file
161
webapp-back/readme.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 
|
||||
|
||||
|
||||
[](https://github.com/gothinkster/golang-gin-realworld-example-app/actions/workflows/ci.yml)
|
||||
[](https://coveralls.io/github/gothinkster/golang-gin-realworld-example-app?branch=main)
|
||||
[](https://github.com/gothinkster/golang-gin-realworld-example-app/blob/main/LICENSE)
|
||||
[](https://godoc.org/github.com/gothinkster/golang-gin-realworld-example-app)
|
||||
|
||||
> ### Golang/Gin codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
|
||||
|
||||
|
||||
This codebase was created to demonstrate a fully fledged fullstack application built with **Golang/Gin** including CRUD operations, authentication, routing, pagination, and more.
|
||||
|
||||
## Recent Updates
|
||||
|
||||
This project has been modernized with the following updates:
|
||||
- **Go 1.21+**: Updated from Go 1.15 to require Go 1.21 or higher
|
||||
- **GORM v2**: Migrated from deprecated jinzhu/gorm v1 to gorm.io/gorm v2
|
||||
- **JWT v5**: Updated from deprecated dgrijalva/jwt-go to golang-jwt/jwt/v5 (fixes CVE-2020-26160)
|
||||
- **Validator v10**: Updated validator tags and package to match gin v1.10.0
|
||||
- **Latest Dependencies**: All dependencies updated to their 2025 production-stable versions
|
||||
- **RealWorld API Spec Compliance**:
|
||||
- `GET /profiles/:username` now supports optional authentication (anonymous access allowed)
|
||||
- `POST /users/login` returns 401 Unauthorized on failure (previously 403)
|
||||
- `GET /articles/feed` registered as dedicated authenticated route
|
||||
- `DELETE /articles/:slug` and `DELETE /articles/:slug/comments/:id` return empty response body
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The project maintains high test coverage across all core packages:
|
||||
|
||||
| Package | Coverage |
|
||||
|---------|----------|
|
||||
| `articles` | 93.4% |
|
||||
| `users` | 99.5% |
|
||||
| `common` | 85.7% |
|
||||
| **Total** | **90.0%** |
|
||||
|
||||
To generate a coverage report locally, run:
|
||||
```bash
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
```
|
||||
|
||||
## Dependencies (2025 Stable Versions)
|
||||
|
||||
| Package | Version | Release Date | Known Issues |
|
||||
|---------|---------|--------------|--------------|
|
||||
| [gin-gonic/gin](https://github.com/gin-gonic/gin) | v1.10.0 | 2024-05 | None; v1.11 has experimental HTTP/3 support |
|
||||
| [gorm.io/gorm](https://gorm.io/) | v1.25.12 | 2024-08 | None; v1.30+ has breaking changes |
|
||||
| [golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) | v5.2.1 | 2024-06 | None; v5.3 only bumps Go version requirement |
|
||||
| [go-playground/validator/v10](https://github.com/go-playground/validator) | v10.24.0 | 2024-12 | None; v10.30+ requires Go 1.24 |
|
||||
| [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) | v0.32.0 | 2025-01 | None; keep updated for security fixes |
|
||||
| [gorm.io/driver/sqlite](https://github.com/go-gorm/sqlite) | v1.5.7 | 2024-09 | None; requires cgo; use glebarez/sqlite for pure Go |
|
||||
| [gosimple/slug](https://github.com/gosimple/slug) | v1.15.0 | 2024-12 | None |
|
||||
| [stretchr/testify](https://github.com/stretchr/testify) | v1.10.0 | 2024-10 | None; v2 still in development |
|
||||
|
||||
|
||||
# Directory structure
|
||||
```
|
||||
.
|
||||
├── gorm.db
|
||||
├── hello.go
|
||||
├── common
|
||||
│ ├── utils.go //small tools function
|
||||
│ └── database.go //DB connect manager
|
||||
├── users
|
||||
| ├── models.go //data models define & DB operation
|
||||
| ├── serializers.go //response computing & format
|
||||
| ├── routers.go //business logic & router binding
|
||||
| ├── middlewares.go //put the before & after logic of handle request
|
||||
| └── validators.go //form/json checker
|
||||
├── ...
|
||||
...
|
||||
```
|
||||
|
||||
# Getting started
|
||||
|
||||
## Install Golang
|
||||
|
||||
Make sure you have Go 1.21 or higher installed.
|
||||
|
||||
https://golang.org/doc/install
|
||||
|
||||
## Environment Config
|
||||
|
||||
Environment variables can be set directly in your shell or via a `.env` file (requires a tool like `source` or `direnv`).
|
||||
|
||||
Available environment variables:
|
||||
```bash
|
||||
PORT=8080 # Server port (default: 8080)
|
||||
GIN_MODE=debug # Gin mode: debug or release
|
||||
DB_PATH=./data/gorm.db # SQLite database path (default: ./data/gorm.db)
|
||||
TEST_DB_PATH=./data/test.db # Optional: SQLite database path used for tests
|
||||
```
|
||||
|
||||
Example usage:
|
||||
```bash
|
||||
# Option 1: Set environment variables directly
|
||||
export PORT=3000
|
||||
export DB_PATH=./data/myapp.db
|
||||
go run hello.go
|
||||
|
||||
# Option 2: Inline with command
|
||||
PORT=3000 go run hello.go
|
||||
```
|
||||
|
||||
See `.env.example` for a complete template.
|
||||
|
||||
|
||||
## Install Dependencies
|
||||
From the project root, run:
|
||||
```
|
||||
go build ./...
|
||||
go test ./...
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
## Run the Server
|
||||
```bash
|
||||
# Using default port 8080
|
||||
go run hello.go
|
||||
|
||||
# Using custom port
|
||||
PORT=3000 go run hello.go
|
||||
```
|
||||
|
||||
## Testing
|
||||
From the project root, run:
|
||||
```
|
||||
go test ./...
|
||||
```
|
||||
or
|
||||
```
|
||||
go test ./... -cover
|
||||
```
|
||||
or
|
||||
```
|
||||
go test -v ./... -cover
|
||||
```
|
||||
depending on whether you want to see test coverage and how verbose the output you want.
|
||||
|
||||
## Todo
|
||||
- More elegance config
|
||||
- ProtoBuf support
|
||||
- Code structure optimize (I think some place can use interface)
|
||||
- Continuous integration (done)
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Current test coverage (2026):
|
||||
- **Total**: 89.2%
|
||||
- **articles**: 92.1%
|
||||
- **users**: 99.5%
|
||||
- **common**: 85.7%
|
||||
|
||||
Run coverage report:
|
||||
```bash
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
```
|
||||
22
webapp-back/run-scanner.bat
Normal file
22
webapp-back/run-scanner.bat
Normal file
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo === Holder Scanner 启动脚本 ===
|
||||
echo.
|
||||
|
||||
REM 编译 Scanner
|
||||
echo 📦 正在编译 Scanner...
|
||||
go build -o bin\holder-scanner.exe cmd\scanner\main.go
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo ❌ 编译失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ✓ 编译成功
|
||||
echo.
|
||||
echo 🚀 正在启动 Scanner...
|
||||
echo ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
echo.
|
||||
|
||||
bin\holder-scanner.exe
|
||||
42
webapp-back/run-scanner.sh
Normal file
42
webapp-back/run-scanner.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Holder Scanner 快速启动脚本
|
||||
# Quick start script for Holder Scanner
|
||||
|
||||
echo "=== Holder Scanner 启动脚本 ==="
|
||||
echo ""
|
||||
|
||||
# 加载 .env 文件
|
||||
if [ -f .env ]; then
|
||||
echo "📄 正在加载 .env 配置..."
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
echo "✓ 配置已加载"
|
||||
else
|
||||
echo "⚠️ .env 文件未找到,使用默认配置"
|
||||
fi
|
||||
|
||||
# 显示当前配置
|
||||
echo ""
|
||||
echo "📊 当前配置:"
|
||||
echo " 数据库: $DB_TYPE://$DB_USER@$DB_HOST:$DB_PORT/$DB_NAME"
|
||||
echo " Chain ID: ${CHAIN_ID:-421614}"
|
||||
echo " 部署区块号:"
|
||||
echo " • YT Vaults: ${YT_VAULTS_DEPLOY_BLOCK:-227339300}"
|
||||
echo " • ytLP: ${YTLP_DEPLOY_BLOCK:-227230270}"
|
||||
echo " • Lending: ${LENDING_DEPLOY_BLOCK:-227746053}"
|
||||
echo ""
|
||||
|
||||
# 编译并运行
|
||||
echo "📦 正在编译 Scanner..."
|
||||
go build -o bin/holder-scanner cmd/scanner/main.go
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ 编译成功"
|
||||
echo ""
|
||||
echo "🚀 正在启动 Scanner..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
./bin/holder-scanner
|
||||
else
|
||||
echo "❌ 编译失败"
|
||||
exit 1
|
||||
fi
|
||||
19
webapp-back/scripts/coverage.sh
Normal file
19
webapp-back/scripts/coverage.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# http://stackoverflow.com/a/21142256/2055281
|
||||
|
||||
echo "mode: atomic" > coverage.out
|
||||
|
||||
for d in $(find ./* -maxdepth 10 -type d); do
|
||||
if ls $d/*.go &> /dev/null; then
|
||||
go test -coverprofile=profile.out -covermode=atomic $d
|
||||
if [ -f profile.out ]; then
|
||||
echo "$(pwd)"
|
||||
cat profile.out | grep -v "mode: " >> coverage.out
|
||||
rm profile.out
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
15
webapp-back/scripts/gofmt.sh
Normal file
15
webapp-back/scripts/gofmt.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if all Go files are properly formatted
|
||||
unformatted=$(gofmt -l .)
|
||||
echo "$unformatted"
|
||||
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "There is unformatted code, you should use 'go fmt ./...' to format it."
|
||||
echo "Unformatted files:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
else
|
||||
echo "Codes are formatted."
|
||||
exit 0
|
||||
fi
|
||||
59
webapp-back/scripts/run-api-tests.sh
Normal file
59
webapp-back/scripts/run-api-tests.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# Ensure tmp directory exists
|
||||
mkdir -p ./tmp
|
||||
|
||||
# Clean up database
|
||||
rm -f ./data/gorm.db
|
||||
|
||||
# Download Postman collection
|
||||
echo "Downloading Postman collection..."
|
||||
curl -L -s https://raw.githubusercontent.com/gothinkster/realworld/main/api/Conduit.postman_collection.json -o ./tmp/Conduit.postman_collection.json
|
||||
|
||||
# Build the application
|
||||
echo "Building application..."
|
||||
go build -o app hello.go
|
||||
|
||||
# Start the server
|
||||
echo "Starting server..."
|
||||
PORT=8080 ./app &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Cleanup function to kill server on exit
|
||||
cleanup() {
|
||||
echo "Stopping server..."
|
||||
kill $SERVER_PID
|
||||
rm -f app
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:8080/api/ping > /dev/null; then
|
||||
echo "Server is up!"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run Newman
|
||||
echo "Running API tests..."
|
||||
# Check if newman is available
|
||||
if ! command -v newman &> /dev/null; then
|
||||
echo "newman not found, trying npx..."
|
||||
npx newman run ./tmp/Conduit.postman_collection.json \
|
||||
--global-var "APIURL=http://localhost:8080/api" \
|
||||
--global-var "EMAIL=test@example.com" \
|
||||
--global-var "PASSWORD=password" \
|
||||
--global-var "USERNAME=testuser" \
|
||||
--delay-request 50
|
||||
else
|
||||
newman run ./tmp/Conduit.postman_collection.json \
|
||||
--global-var "APIURL=http://localhost:8080/api" \
|
||||
--global-var "EMAIL=test@example.com" \
|
||||
--global-var "PASSWORD=password" \
|
||||
--global-var "USERNAME=testuser" \
|
||||
--delay-request 50
|
||||
fi
|
||||
31
webapp-back/sql/add_deploy_blocks.sql
Normal file
31
webapp-back/sql/add_deploy_blocks.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- 为 assets 表添加部署区块字段
|
||||
-- Add deployment block fields to assets table
|
||||
|
||||
-- 1. 添加部署区块字段
|
||||
ALTER TABLE `assets`
|
||||
ADD COLUMN `deploy_block_arb` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Arbitrum 部署区块号',
|
||||
ADD COLUMN `deploy_block_bsc` BIGINT UNSIGNED DEFAULT NULL COMMENT 'BSC 部署区块号';
|
||||
|
||||
-- 2. 更新已知的部署区块号
|
||||
UPDATE `assets` SET `deploy_block_arb` = 83949096 WHERE `asset_code` = 'YT-A';
|
||||
UPDATE `assets` SET `deploy_block_arb` = 83949087 WHERE `asset_code` = 'YT-B';
|
||||
-- UPDATE `assets` SET `deploy_block_arb` = 83949000 WHERE `asset_code` = 'YT-C'; -- 请替换为实际区块号
|
||||
|
||||
-- 3. 为 alp_pools 表添加部署区块字段
|
||||
ALTER TABLE `alp_pools`
|
||||
ADD COLUMN `deploy_block_arb` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Arbitrum 部署区块号',
|
||||
ADD COLUMN `deploy_block_bsc` BIGINT UNSIGNED DEFAULT NULL COMMENT 'BSC 部署区块号';
|
||||
|
||||
-- 更新 ytLP 部署区块(如果知道的话)
|
||||
-- UPDATE `alp_pools` SET `deploy_block_arb` = 83949000 WHERE `pool_name` = 'ytLP' AND `is_active` = 1;
|
||||
|
||||
-- 4. 为 lending_markets 表添加部署区块字段
|
||||
ALTER TABLE `lending_markets`
|
||||
ADD COLUMN `deploy_block_arb` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Arbitrum 部署区块号',
|
||||
ADD COLUMN `deploy_block_bsc` BIGINT UNSIGNED DEFAULT NULL COMMENT 'BSC 部署区块号';
|
||||
|
||||
-- 更新 Lending 部署区块(如果知道的话)
|
||||
-- UPDATE `lending_markets` SET `deploy_block_arb` = 83949000 WHERE `is_active` = 1;
|
||||
|
||||
-- 5. 查看更新结果
|
||||
SELECT asset_code, contract_address_arb, deploy_block_arb FROM `assets` WHERE asset_code IN ('YT-A', 'YT-B', 'YT-C');
|
||||
78
webapp-back/start-holders.sh
Normal file
78
webapp-back/start-holders.sh
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Holders 服务启动脚本
|
||||
|
||||
echo "=== Starting Holders Services ==="
|
||||
|
||||
# 检查环境变量
|
||||
if [ -z "$DB_PASSWORD" ]; then
|
||||
echo "⚠️ Warning: DB_PASSWORD not set, using default"
|
||||
export DB_PASSWORD="your_password"
|
||||
fi
|
||||
|
||||
# 设置默认环境变量
|
||||
export DB_TYPE=${DB_TYPE:-mysql}
|
||||
export DB_HOST=${DB_HOST:-localhost}
|
||||
export DB_PORT=${DB_PORT:-3306}
|
||||
export DB_USER=${DB_USER:-root}
|
||||
export DB_NAME=${DB_NAME:-assetx}
|
||||
export GIN_MODE=${GIN_MODE:-release}
|
||||
export PORT=${PORT:-8080}
|
||||
export RPC_URL=${RPC_URL:-https://api.zan.top/node/v1/arb/sepolia/baf84c429d284bb5b676cb8c9ca21c07}
|
||||
|
||||
echo "Configuration:"
|
||||
echo " DB: $DB_TYPE://$DB_USER@$DB_HOST:$DB_PORT/$DB_NAME"
|
||||
echo " API Port: $PORT"
|
||||
echo " RPC: ${RPC_URL:0:50}..."
|
||||
echo ""
|
||||
|
||||
# 创建 bin 目录
|
||||
mkdir -p bin
|
||||
|
||||
# 编译服务
|
||||
echo "📦 Building services..."
|
||||
go build -o bin/api-server main.go
|
||||
go build -o bin/holder-scanner cmd/scanner/main.go
|
||||
echo "✓ Build completed"
|
||||
echo ""
|
||||
|
||||
# 选择启动方式
|
||||
echo "Choose start method:"
|
||||
echo "1. API Server only"
|
||||
echo "2. Scanner only"
|
||||
echo "3. Both (in background)"
|
||||
read -p "Enter choice [1-3]: " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
echo "🚀 Starting API Server..."
|
||||
./bin/api-server
|
||||
;;
|
||||
2)
|
||||
echo "🚀 Starting Scanner..."
|
||||
./bin/holder-scanner
|
||||
;;
|
||||
3)
|
||||
echo "🚀 Starting both services in background..."
|
||||
nohup ./bin/api-server > logs/api-server.log 2>&1 &
|
||||
API_PID=$!
|
||||
echo " API Server started (PID: $API_PID)"
|
||||
|
||||
nohup ./bin/holder-scanner > logs/holder-scanner.log 2>&1 &
|
||||
SCANNER_PID=$!
|
||||
echo " Scanner started (PID: $SCANNER_PID)"
|
||||
|
||||
echo ""
|
||||
echo "Services running:"
|
||||
echo " API: http://localhost:$PORT"
|
||||
echo " Logs: tail -f logs/api-server.log"
|
||||
echo " tail -f logs/holder-scanner.log"
|
||||
echo ""
|
||||
echo "To stop:"
|
||||
echo " kill $API_PID $SCANNER_PID"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
24
webapp-back/start.sh
Normal file
24
webapp-back/start.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "========================================="
|
||||
echo "AssetX API Server"
|
||||
echo "========================================="
|
||||
|
||||
# Load environment variables
|
||||
if [ -f .env ]; then
|
||||
export $(cat .env | grep -v '^#' | xargs)
|
||||
fi
|
||||
|
||||
# Check if MySQL is running (optional)
|
||||
echo "Checking MySQL connection..."
|
||||
mysql -h$DB_HOST -P$DB_PORT -u$DB_USER -p$DB_PASSWORD -e "SELECT 1" > /dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ MySQL connection successful"
|
||||
else
|
||||
echo "⚠ MySQL connection failed - using SQLite"
|
||||
export DB_TYPE=sqlite
|
||||
fi
|
||||
|
||||
# Run the application
|
||||
echo "Starting server on port $PORT..."
|
||||
go run main.go
|
||||
141
webapp-back/tools/verify_contracts.js
Normal file
141
webapp-back/tools/verify_contracts.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// 合约验证和部署区块查询工具
|
||||
// Contract verification and deployment block query tool
|
||||
|
||||
const { ethers } = require("ethers");
|
||||
|
||||
// 配置
|
||||
const RPC_URL = "https://sepolia-rollup.arbitrum.io/rpc";
|
||||
|
||||
// 你数据库中的合约地址
|
||||
const CONTRACTS = {
|
||||
"YT-A": "0x7f9eEA491eE53045594ee4669327f0355aCd0e58",
|
||||
"YT-B": "0x20B94C5E5b7361552E0548161a58696aA6FeDBd4",
|
||||
"YT-C": "0x0EF308D70cf35460E26a3Eb42F3442Ff28cbE07C",
|
||||
"ytLP": "0x102e3F25Ef0ad9b0695C8F2daF8A1262437eEfc3",
|
||||
"Lending": "0xCb4E7B1069F6C26A1c27523ce4c8dfD884552d1D"
|
||||
};
|
||||
|
||||
// ERC20 ABI (只包含必要的函数)
|
||||
const ERC20_ABI = [
|
||||
"function name() view returns (string)",
|
||||
"function symbol() view returns (string)",
|
||||
"function totalSupply() view returns (uint256)",
|
||||
"function balanceOf(address) view returns (uint256)"
|
||||
];
|
||||
|
||||
async function verifyContract(provider, name, address) {
|
||||
console.log(`\n━━━ ${name} ━━━`);
|
||||
console.log(`地址: ${address}`);
|
||||
|
||||
try {
|
||||
// 检查合约代码
|
||||
const code = await provider.getCode(address);
|
||||
if (code === "0x") {
|
||||
console.log("❌ 错误: 该地址没有合约代码(可能是错误的地址)");
|
||||
return null;
|
||||
}
|
||||
console.log("✅ 合约代码存在");
|
||||
|
||||
// 尝试读取 ERC20 信息(仅适用于 YT 和 ytLP)
|
||||
if (name.startsWith("YT-") || name === "ytLP") {
|
||||
try {
|
||||
const contract = new ethers.Contract(address, ERC20_ABI, provider);
|
||||
const [tokenName, symbol, totalSupply] = await Promise.all([
|
||||
contract.name(),
|
||||
contract.symbol(),
|
||||
contract.totalSupply()
|
||||
]);
|
||||
|
||||
console.log(` 名称: ${tokenName}`);
|
||||
console.log(` 符号: ${symbol}`);
|
||||
console.log(` 总供应量: ${ethers.formatEther(totalSupply)} (${totalSupply.toString()})`);
|
||||
} catch (error) {
|
||||
console.log("⚠️ 无法读取 ERC20 信息(可能不是标准 ERC20)");
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试获取创建交易(需要手动在 Arbiscan 查询)
|
||||
console.log(`\n🔍 在 Arbiscan 上查询部署信息:`);
|
||||
console.log(` https://sepolia.arbiscan.io/address/${address}`);
|
||||
|
||||
return { address, hasCode: true };
|
||||
} catch (error) {
|
||||
console.log(`❌ 错误: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function suggestDeploymentBlock(provider) {
|
||||
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.log("📍 部署区块建议");
|
||||
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
||||
|
||||
const currentBlock = await provider.getBlockNumber();
|
||||
console.log(`当前最新区块: ${currentBlock}`);
|
||||
console.log(`\n如果你不知道确切的部署区块号,可以使用以下策略:\n`);
|
||||
console.log(`方案 1 (推荐): 在 Arbiscan 上查询准确的部署区块`);
|
||||
console.log(` - 访问上面的链接,查看 "Contract Creation" 交易`);
|
||||
console.log(` - 使用准确的部署区块可以确保不遗漏任何数据\n`);
|
||||
console.log(`方案 2 (临时): 使用较近的区块号进行测试`);
|
||||
console.log(` - 从较新的区块开始扫描(例如: ${currentBlock - 100000})`);
|
||||
console.log(` - 可以快速测试功能,但可能遗漏早期持有者数据\n`);
|
||||
console.log(`方案 3 (保守): 使用较早的区块号`);
|
||||
console.log(` - 从一个足够早的区块开始(例如: 227000000)`);
|
||||
console.log(` - 可能扫描大量无关区块,但能确保完整性`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== 合约验证工具 ===\n");
|
||||
console.log(`RPC: ${RPC_URL}\n`);
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(RPC_URL);
|
||||
|
||||
// 验证连接
|
||||
try {
|
||||
const network = await provider.getNetwork();
|
||||
console.log(`✅ 已连接到网络: ${network.name} (Chain ID: ${network.chainId})\n`);
|
||||
} catch (error) {
|
||||
console.log(`❌ 无法连接到 RPC: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 验证每个合约
|
||||
const results = {};
|
||||
for (const [name, address] of Object.entries(CONTRACTS)) {
|
||||
const result = await verifyContract(provider, name, address);
|
||||
results[name] = result;
|
||||
}
|
||||
|
||||
// 显示摘要
|
||||
console.log("\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.log("📊 验证摘要");
|
||||
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
||||
|
||||
const validContracts = Object.entries(results).filter(([_, r]) => r?.hasCode);
|
||||
const invalidContracts = Object.entries(results).filter(([_, r]) => !r?.hasCode);
|
||||
|
||||
console.log(`✅ 有效合约: ${validContracts.length}/${Object.keys(CONTRACTS).length}`);
|
||||
validContracts.forEach(([name]) => console.log(` • ${name}`));
|
||||
|
||||
if (invalidContracts.length > 0) {
|
||||
console.log(`\n❌ 无效合约: ${invalidContracts.length}`);
|
||||
invalidContracts.forEach(([name]) => console.log(` • ${name}`));
|
||||
}
|
||||
|
||||
// 部署区块建议
|
||||
await suggestDeploymentBlock(provider);
|
||||
|
||||
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.log("📝 下一步");
|
||||
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
||||
console.log("1. 在 Arbiscan 上查询每个合约的部署区块号");
|
||||
console.log("2. 更新 .env 文件中的部署区块号:");
|
||||
console.log(" YT_VAULTS_DEPLOY_BLOCK=<实际区块号>");
|
||||
console.log(" YTLP_DEPLOY_BLOCK=<实际区块号>");
|
||||
console.log(" LENDING_DEPLOY_BLOCK=<实际区块号>");
|
||||
console.log("3. 运行 Scanner:");
|
||||
console.log(" ./run-scanner.sh");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
12
webapp-back/users/doc.go
Normal file
12
webapp-back/users/doc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
The user module containing the user CRU operation.
|
||||
|
||||
model.go: definition of orm based data model
|
||||
|
||||
routers.go: router binding and core logic
|
||||
|
||||
serializers.go: definition the schema of return data
|
||||
|
||||
validators.go: definition the validator of form data
|
||||
*/
|
||||
package users
|
||||
75
webapp-back/users/middlewares.go
Normal file
75
webapp-back/users/middlewares.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
)
|
||||
|
||||
// Extract token from Authorization header or query parameter
|
||||
func extractToken(c *gin.Context) string {
|
||||
// Check Authorization header first
|
||||
bearerToken := c.GetHeader("Authorization")
|
||||
if len(bearerToken) > 6 && strings.ToUpper(bearerToken[0:6]) == "TOKEN " {
|
||||
return bearerToken[6:]
|
||||
}
|
||||
|
||||
// Check query parameter
|
||||
token := c.Query("access_token")
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// A helper to write user_id and user_model to the context
|
||||
func UpdateContextUserModel(c *gin.Context, my_user_id uint) {
|
||||
var myUserModel UserModel
|
||||
if my_user_id != 0 {
|
||||
db := common.GetDB()
|
||||
db.First(&myUserModel, my_user_id)
|
||||
}
|
||||
c.Set("my_user_id", my_user_id)
|
||||
c.Set("my_user_model", myUserModel)
|
||||
}
|
||||
|
||||
// You can custom middlewares yourself as the doc: https://github.com/gin-gonic/gin#custom-middleware
|
||||
//
|
||||
// r.Use(AuthMiddleware(true))
|
||||
func AuthMiddleware(auto401 bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
UpdateContextUserModel(c, 0)
|
||||
tokenString := extractToken(c)
|
||||
|
||||
if tokenString == "" {
|
||||
if auto401 {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(common.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if auto401 {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
my_user_id := uint(claims["id"].(float64))
|
||||
UpdateContextUserModel(c, my_user_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
154
webapp-back/users/models.go
Normal file
154
webapp-back/users/models.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Models should only be concerned with database schema, more strict checking should be put in validator.
|
||||
//
|
||||
// More detail you can find here: http://jinzhu.me/gorm/models.html#model-definition
|
||||
//
|
||||
// HINT: If you want to split null and "", you should use *string instead of string.
|
||||
type UserModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email;uniqueIndex"`
|
||||
Bio string `gorm:"column:bio;size:1024"`
|
||||
Image *string `gorm:"column:image"`
|
||||
PasswordHash string `gorm:"column:password;not null"`
|
||||
}
|
||||
|
||||
// A hack way to save ManyToMany relationship,
|
||||
// gorm will build the alias as FollowingBy <-> FollowingByID <-> "following_by_id".
|
||||
//
|
||||
// DB schema looks like: id, created_at, updated_at, deleted_at, following_id, followed_by_id.
|
||||
//
|
||||
// Retrieve them by:
|
||||
//
|
||||
// db.Where(FollowModel{ FollowingID: v.ID, FollowedByID: u.ID, }).First(&follow)
|
||||
// db.Where(FollowModel{ FollowedByID: u.ID, }).Find(&follows)
|
||||
//
|
||||
// More details about gorm.Model: http://jinzhu.me/gorm/models.html#conventions
|
||||
type FollowModel struct {
|
||||
gorm.Model
|
||||
Following UserModel
|
||||
FollowingID uint
|
||||
FollowedBy UserModel
|
||||
FollowedByID uint
|
||||
}
|
||||
|
||||
// Migrate the schema of database if needed
|
||||
func AutoMigrate() {
|
||||
db := common.GetDB()
|
||||
|
||||
db.AutoMigrate(&UserModel{})
|
||||
db.AutoMigrate(&FollowModel{})
|
||||
}
|
||||
|
||||
// What's bcrypt? https://en.wikipedia.org/wiki/Bcrypt
|
||||
// Golang bcrypt doc: https://godoc.org/golang.org/x/crypto/bcrypt
|
||||
// You can change the value in bcrypt.DefaultCost to adjust the security index.
|
||||
//
|
||||
// err := userModel.setPassword("password0")
|
||||
func (u *UserModel) setPassword(password string) error {
|
||||
if len(password) == 0 {
|
||||
return errors.New("password should not be empty!")
|
||||
}
|
||||
bytePassword := []byte(password)
|
||||
// Make sure the second param `bcrypt generator cost` between [4, 32)
|
||||
passwordHash, _ := bcrypt.GenerateFromPassword(bytePassword, bcrypt.DefaultCost)
|
||||
u.PasswordHash = string(passwordHash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Database will only save the hashed string, you should check it by util function.
|
||||
//
|
||||
// if err := serModel.checkPassword("password0"); err != nil { password error }
|
||||
func (u *UserModel) checkPassword(password string) error {
|
||||
bytePassword := []byte(password)
|
||||
byteHashedPassword := []byte(u.PasswordHash)
|
||||
return bcrypt.CompareHashAndPassword(byteHashedPassword, bytePassword)
|
||||
}
|
||||
|
||||
// You could input the conditions and it will return an UserModel in database with error info.
|
||||
//
|
||||
// userModel, err := FindOneUser(&UserModel{Username: "username0"})
|
||||
func FindOneUser(condition interface{}) (UserModel, error) {
|
||||
db := common.GetDB()
|
||||
var model UserModel
|
||||
err := db.Where(condition).First(&model).Error
|
||||
return model, err
|
||||
}
|
||||
|
||||
// You could input an UserModel which will be saved in database returning with error info
|
||||
//
|
||||
// if err := SaveOne(&userModel); err != nil { ... }
|
||||
func SaveOne(data interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Save(data).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// You could update properties of an UserModel to database returning with error info.
|
||||
//
|
||||
// err := db.Model(userModel).Updates(UserModel{Username: "wangzitian0"}).Error
|
||||
func (model *UserModel) Update(data interface{}) error {
|
||||
db := common.GetDB()
|
||||
err := db.Model(model).Updates(data).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// You could add a following relationship as userModel1 following userModel2
|
||||
//
|
||||
// err = userModel1.following(userModel2)
|
||||
func (u UserModel) following(v UserModel) error {
|
||||
db := common.GetDB()
|
||||
var follow FollowModel
|
||||
err := db.FirstOrCreate(&follow, &FollowModel{
|
||||
FollowingID: v.ID,
|
||||
FollowedByID: u.ID,
|
||||
}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// You could check whether userModel1 following userModel2
|
||||
//
|
||||
// followingBool = myUserModel.isFollowing(self.UserModel)
|
||||
func (u UserModel) isFollowing(v UserModel) bool {
|
||||
db := common.GetDB()
|
||||
var follow FollowModel
|
||||
db.Where(FollowModel{
|
||||
FollowingID: v.ID,
|
||||
FollowedByID: u.ID,
|
||||
}).First(&follow)
|
||||
return follow.ID != 0
|
||||
}
|
||||
|
||||
// You could delete a following relationship as userModel1 following userModel2
|
||||
//
|
||||
// err = userModel1.unFollowing(userModel2)
|
||||
func (u UserModel) unFollowing(v UserModel) error {
|
||||
db := common.GetDB()
|
||||
err := db.Where("following_id = ? AND followed_by_id = ?", v.ID, u.ID).Delete(&FollowModel{}).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// You could get a following list of userModel
|
||||
//
|
||||
// followings := userModel.GetFollowings()
|
||||
func (u UserModel) GetFollowings() []UserModel {
|
||||
db := common.GetDB()
|
||||
var follows []FollowModel
|
||||
var followings []UserModel
|
||||
db.Preload("Following").Where(FollowModel{
|
||||
FollowedByID: u.ID,
|
||||
}).Find(&follows)
|
||||
for _, follow := range follows {
|
||||
followings = append(followings, follow.Following)
|
||||
}
|
||||
return followings
|
||||
}
|
||||
137
webapp-back/users/routers.go
Normal file
137
webapp-back/users/routers.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func UsersRegister(router *gin.RouterGroup) {
|
||||
router.POST("", UsersRegistration)
|
||||
router.POST("/", UsersRegistration)
|
||||
router.POST("/login", UsersLogin)
|
||||
}
|
||||
|
||||
func UserRegister(router *gin.RouterGroup) {
|
||||
router.GET("", UserRetrieve)
|
||||
router.GET("/", UserRetrieve)
|
||||
router.PUT("", UserUpdate)
|
||||
router.PUT("/", UserUpdate)
|
||||
}
|
||||
|
||||
func ProfileRetrieveRegister(router *gin.RouterGroup) {
|
||||
router.GET("/:username", ProfileRetrieve)
|
||||
}
|
||||
|
||||
func ProfileRegister(router *gin.RouterGroup) {
|
||||
router.POST("/:username/follow", ProfileFollow)
|
||||
router.DELETE("/:username/follow", ProfileUnfollow)
|
||||
}
|
||||
|
||||
func ProfileRetrieve(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
userModel, err := FindOneUser(&UserModel{Username: username})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
|
||||
return
|
||||
}
|
||||
profileSerializer := ProfileSerializer{c, userModel}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": profileSerializer.Response()})
|
||||
}
|
||||
|
||||
func ProfileFollow(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
userModel, err := FindOneUser(&UserModel{Username: username})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
|
||||
return
|
||||
}
|
||||
myUserModel := c.MustGet("my_user_model").(UserModel)
|
||||
err = myUserModel.following(userModel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ProfileSerializer{c, userModel}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": serializer.Response()})
|
||||
}
|
||||
|
||||
func ProfileUnfollow(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
userModel, err := FindOneUser(&UserModel{Username: username})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, common.NewError("profile", errors.New("Invalid username")))
|
||||
return
|
||||
}
|
||||
myUserModel := c.MustGet("my_user_model").(UserModel)
|
||||
|
||||
err = myUserModel.unFollowing(userModel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
serializer := ProfileSerializer{c, userModel}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": serializer.Response()})
|
||||
}
|
||||
|
||||
func UsersRegistration(c *gin.Context) {
|
||||
userModelValidator := NewUserModelValidator()
|
||||
if err := userModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := SaveOne(&userModelValidator.userModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
c.Set("my_user_model", userModelValidator.userModel)
|
||||
serializer := UserSerializer{c}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": serializer.Response()})
|
||||
}
|
||||
|
||||
func UsersLogin(c *gin.Context) {
|
||||
loginValidator := NewLoginValidator()
|
||||
if err := loginValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
userModel, err := FindOneUser(&UserModel{Email: loginValidator.userModel.Email})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, common.NewError("login", errors.New("Not Registered email or invalid password")))
|
||||
return
|
||||
}
|
||||
|
||||
if userModel.checkPassword(loginValidator.User.Password) != nil {
|
||||
c.JSON(http.StatusUnauthorized, common.NewError("login", errors.New("Not Registered email or invalid password")))
|
||||
return
|
||||
}
|
||||
UpdateContextUserModel(c, userModel.ID)
|
||||
serializer := UserSerializer{c}
|
||||
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
|
||||
}
|
||||
|
||||
func UserRetrieve(c *gin.Context) {
|
||||
serializer := UserSerializer{c}
|
||||
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
|
||||
}
|
||||
|
||||
func UserUpdate(c *gin.Context) {
|
||||
myUserModel := c.MustGet("my_user_model").(UserModel)
|
||||
userModelValidator := NewUserModelValidatorFillWith(myUserModel)
|
||||
if err := userModelValidator.Bind(c); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
|
||||
return
|
||||
}
|
||||
|
||||
userModelValidator.userModel.ID = myUserModel.ID
|
||||
if err := myUserModel.Update(userModelValidator.userModel); err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, common.NewError("database", err))
|
||||
return
|
||||
}
|
||||
UpdateContextUserModel(c, myUserModel.ID)
|
||||
serializer := UserSerializer{c}
|
||||
c.JSON(http.StatusOK, gin.H{"user": serializer.Response()})
|
||||
}
|
||||
66
webapp-back/users/serializers.go
Normal file
66
webapp-back/users/serializers.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
||||
)
|
||||
|
||||
type ProfileSerializer struct {
|
||||
C *gin.Context
|
||||
UserModel
|
||||
}
|
||||
|
||||
// Declare your response schema here
|
||||
type ProfileResponse struct {
|
||||
ID uint `json:"-"`
|
||||
Username string `json:"username"`
|
||||
Bio string `json:"bio"`
|
||||
Image string `json:"image"`
|
||||
Following bool `json:"following"`
|
||||
}
|
||||
|
||||
// Put your response logic including wrap the userModel here.
|
||||
func (self *ProfileSerializer) Response() ProfileResponse {
|
||||
myUserModel := self.C.MustGet("my_user_model").(UserModel)
|
||||
image := ""
|
||||
if self.Image != nil {
|
||||
image = *self.Image
|
||||
}
|
||||
profile := ProfileResponse{
|
||||
ID: self.ID,
|
||||
Username: self.Username,
|
||||
Bio: self.Bio,
|
||||
Image: image,
|
||||
Following: myUserModel.isFollowing(self.UserModel),
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
type UserSerializer struct {
|
||||
c *gin.Context
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Bio string `json:"bio"`
|
||||
Image string `json:"image"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (self *UserSerializer) Response() UserResponse {
|
||||
myUserModel := self.c.MustGet("my_user_model").(UserModel)
|
||||
image := ""
|
||||
if myUserModel.Image != nil {
|
||||
image = *myUserModel.Image
|
||||
}
|
||||
user := UserResponse{
|
||||
Username: myUserModel.Username,
|
||||
Email: myUserModel.Email,
|
||||
Bio: myUserModel.Bio,
|
||||
Image: image,
|
||||
Token: common.GenToken(myUserModel.ID),
|
||||
}
|
||||
return user
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user