feat: add admin panel with multi-chain support and notification system
- Add full admin REST API: auth, API keys, chains, chain tokens, wallets, orders, RPC nodes, settings, dashboard stats, notifications - Add JWT-based admin authentication and API key auth middleware - Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket - Add RPC node health check job with automatic failover - Add Telegram notification channel for order events - Add installer for first-run database initialization - Add new DB models: admin_user, api_key, chain, chain_token, rpc_node, settings, notification_channel - Add order statistics aggregation (daily/monthly/by-chain) - Serve built admin SPA from embedded www/ assets - Add comprehensive test coverage for all new features
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CreateApiKeyRequest is the payload for creating an API key.
|
||||
// A single key is valid for both gateway flows (epay/gmpay); there is
|
||||
// no gateway_type. PID is auto-generated (incrementing from 1000);
|
||||
// no manual override.
|
||||
type CreateApiKeyRequest struct {
|
||||
Name string `json:"name" validate:"required|maxLen:128" example:"My API Key"`
|
||||
IpWhitelist string `json:"ip_whitelist" example:""`
|
||||
NotifyUrl string `json:"notify_url" example:"https://example.com/notify"`
|
||||
}
|
||||
|
||||
// CreateApiKeyResponse is the response for a newly created API key.
|
||||
type CreateApiKeyResponse struct {
|
||||
ID uint64 `json:"id" example:"1"`
|
||||
Name string `json:"name" example:"My API Key"`
|
||||
Pid string `json:"pid" example:"1003"`
|
||||
// SecretKey is returned ONCE on creation. After that, fetch via
|
||||
// GET /api-keys/:id/secret or rotate to generate a new one.
|
||||
SecretKey string `json:"secret_key" example:"secret123abc456"`
|
||||
}
|
||||
|
||||
// UpdateApiKeyRequest is the payload for updating an API key.
|
||||
type UpdateApiKeyRequest struct {
|
||||
Name *string `json:"name" example:"Updated Key Name"`
|
||||
IpWhitelist *string `json:"ip_whitelist" example:"10.0.0.1,192.168.0.0/24"`
|
||||
NotifyUrl *string `json:"notify_url" example:"https://example.com/notify"`
|
||||
}
|
||||
|
||||
// ChangeApiKeyStatusRequest is the payload for toggling API key status.
|
||||
type ChangeApiKeyStatusRequest struct {
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `json:"status" validate:"required|in:1,2" enums:"1,2" example:"1"`
|
||||
}
|
||||
|
||||
// ListApiKeys returns all api_keys rows. SecretKey is stripped by the
|
||||
// mdb.ApiKey json tag (`json:"-"`).
|
||||
// @Summary List API keys
|
||||
// @Description Returns all API keys
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ApiKey}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys [get]
|
||||
func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
|
||||
rows, err := data.ListApiKeys()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, rows)
|
||||
}
|
||||
|
||||
// CreateApiKey mints a new universal key. PID is auto-incremented from
|
||||
// the highest existing numeric PID (starting at 1000). Secret is
|
||||
// randomly generated and returned once.
|
||||
// @Summary Create API key
|
||||
// @Description Create a new universal API key. Usable for both gateway flows (epay/gmpay). PID auto-incremented; secret returned once.
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body admin.CreateApiKeyRequest true "API key payload"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.CreateApiKeyResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys [post]
|
||||
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
|
||||
req := new(CreateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
|
||||
// Retry on unique-index violation: two concurrent creates could
|
||||
// both see the same max PID from NextPid() and race on INSERT.
|
||||
// The loser re-reads and retries with the now-higher max.
|
||||
const maxAttempts = 5
|
||||
secret := randomHex(32)
|
||||
var row *mdb.ApiKey
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
pid, err := data.NextPid()
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row = &mdb.ApiKey{
|
||||
Name: req.Name,
|
||||
Pid: strconv.Itoa(pid),
|
||||
SecretKey: secret,
|
||||
IpWhitelist: req.IpWhitelist,
|
||||
NotifyUrl: req.NotifyUrl,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
err = data.CreateApiKey(row)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt == maxAttempts-1 || !isUniqueViolation(err) {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
}
|
||||
return c.SucJson(ctx, CreateApiKeyResponse{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Pid: row.Pid,
|
||||
SecretKey: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// isUniqueViolation matches the driver-specific strings for a unique-
|
||||
// index collision across SQLite / MySQL / PostgreSQL. Good enough for
|
||||
// the PID retry loop; a cleaner approach would be a typed error but
|
||||
// GORM doesn't surface one portably.
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unique") ||
|
||||
strings.Contains(msg, "duplicate") ||
|
||||
strings.Contains(msg, "constraint failed")
|
||||
}
|
||||
|
||||
// UpdateApiKey patches name / ip_whitelist / notify_url. Secret rotation
|
||||
// and enable/disable have their own endpoints.
|
||||
// @Summary Update API key
|
||||
// @Description Patch API key name / ip_whitelist / notify_url
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Param request body admin.UpdateApiKeyRequest true "Fields to update"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id} [patch]
|
||||
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(UpdateApiKeyRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
fields := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
fields["name"] = *req.Name
|
||||
}
|
||||
if req.IpWhitelist != nil {
|
||||
fields["ip_whitelist"] = *req.IpWhitelist
|
||||
}
|
||||
if req.NotifyUrl != nil {
|
||||
fields["notify_url"] = *req.NotifyUrl
|
||||
}
|
||||
if err := data.UpdateApiKeyFields(id, fields); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// ChangeApiKeyStatus toggles enable/disable.
|
||||
// @Summary Change API key status
|
||||
// @Description Toggle enable/disable for an API key (1=enabled, 2=disabled)
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Param request body admin.ChangeApiKeyStatusRequest true "Status payload"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/status [post]
|
||||
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
req := new(ChangeApiKeyStatusRequest)
|
||||
if err := ctx.Bind(req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"status": req.Status}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// RotateApiKeySecret issues a fresh secret_key. Returned only this time.
|
||||
// @Summary Rotate API key secret
|
||||
// @Description Generate a new secret_key. Returned only once.
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.RotateSecretResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/rotate-secret [post]
|
||||
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
secret := randomHex(32)
|
||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": secret})
|
||||
}
|
||||
|
||||
// DeleteApiKey soft-deletes a row. Orders created with this key will
|
||||
// fail to callback (resolveOrderApiKey returns a clear error) — prefer
|
||||
// disabling via /status over deletion if there are live orders.
|
||||
// @Summary Delete API key
|
||||
// @Description Soft-delete an API key
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id} [delete]
|
||||
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if err := data.DeleteApiKeyByID(id); err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
return c.SucJson(ctx, nil)
|
||||
}
|
||||
|
||||
// GetApiKeyStats returns usage stats for one key. Currently thin — just
|
||||
// the stored call_count and last_used_at — since we don't persist per-
|
||||
// call telemetry. Expanded later if/when we add a request log table.
|
||||
// @Summary Get API key stats
|
||||
// @Description Returns usage stats (call_count, last_used_at) for an API key
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=admin.ApiKeyStatsResponse}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/stats [get]
|
||||
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("api key not found"))
|
||||
}
|
||||
return c.SucJson(ctx, map[string]interface{}{
|
||||
"call_count": row.CallCount,
|
||||
"last_used_at": row.LastUsedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GetApiKeySecret returns the secret_key for one API key. Requires
|
||||
// admin JWT authentication.
|
||||
// @Summary View API key secret
|
||||
// @Description Returns the secret_key for an API key (admin only)
|
||||
// @Tags Admin API Keys
|
||||
// @Security AdminJWT
|
||||
// @Produce json
|
||||
// @Param id path int true "API key ID"
|
||||
// @Success 200 {object} response.ApiResponse{data=map[string]string}
|
||||
// @Failure 400 {object} response.ApiResponse
|
||||
// @Router /admin/api/v1/api-keys/{id}/secret [get]
|
||||
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
row, err := data.GetApiKeyByID(id)
|
||||
if err != nil {
|
||||
return c.FailJson(ctx, err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return c.FailJson(ctx, errors.New("api key not found"))
|
||||
}
|
||||
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user