init: 初始化 AssetX 项目仓库

包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
This commit is contained in:
2026-03-27 11:26:43 +00:00
commit 2ee4553b71
634 changed files with 988255 additions and 0 deletions

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

View 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
View 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
View 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(),
})
}

View 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)
}

View 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)
}

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

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

View 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)
}
}

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

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

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

View 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)
}

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