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:
line-6000
2026-04-22 02:28:31 +08:00
parent 097c716714
commit 6bb47d4b00
247 changed files with 9933 additions and 1279 deletions
+62 -29
View File
@@ -3,9 +3,8 @@ package mq
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -19,6 +18,29 @@ import (
"github.com/assimon/luuu/util/sign"
)
// resolveOrderApiKey returns the api_keys row that signed the order.
// Every order must carry an ApiKeyID (the default key is always seeded
// at bootstrap, and the middleware/EPAY inline flow always stamps it).
// If the row is missing or disabled, we return an error — using any
// other secret would produce a signature the merchant can't verify.
// The admin can resend the callback after fixing the key.
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
if order.ApiKeyID == 0 {
return nil, fmt.Errorf("order trade_id=%s has no api_key_id", order.TradeId)
}
row, err := data.GetApiKeyByID(order.ApiKeyID)
if err != nil {
return nil, fmt.Errorf("lookup api_key_id=%d: %w", order.ApiKeyID, err)
}
if row.ID == 0 {
return nil, fmt.Errorf("api_key_id=%d not found (deleted?)", order.ApiKeyID)
}
if row.Status != mdb.ApiKeyStatusEnable {
return nil, fmt.Errorf("api_key_id=%d is disabled", order.ApiKeyID)
}
return row, nil
}
const batchSize = 100
const sqliteBusyRetryAttempts = 3
@@ -165,56 +187,62 @@ func processCallback(tradeID string) {
}
func sendOrderCallback(order *mdb.Orders) error {
apiKeyRow, err := resolveOrderApiKey(order)
if err != nil || apiKeyRow == nil || apiKeyRow.ID == 0 {
return errors.New("no api key row available for callback")
}
switch order.PaymentType {
case mdb.PaymentTypeEpay:
// 构造 EPay 标准回调参数
// EPAY uses pid (integer) and secret_key as "key".
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
if convErr != nil {
return fmt.Errorf("epay pid not numeric: %s", apiKeyRow.Pid)
}
notifyData := response.OrderNotifyResponseEpay{
PID: config.GetEpayPid(),
TradeNo: order.TradeId, // epusdt 订单号作为 EPay 平台订单号
OutTradeNo: order.OrderId, // 注意:EPay 回调要求商户订单号使用 out_trade_no 参数
PID: pidInt,
TradeNo: order.TradeId,
OutTradeNo: order.OrderId,
Type: "alipay",
Name: order.Name,
Money: fmt.Sprintf("%.4f", order.Amount),
TradeStatus: "TRADE_SUCCESS",
}
signstr2, err := sign.Get(notifyData, config.GetEpayKey())
signstr2, err := sign.Get(notifyData, apiKeyRow.SecretKey)
if err != nil {
return err
}
// 使用 form-encoded POSTEPay 标准协议格式)
formData := url.Values{
"pid": {fmt.Sprintf("%d", notifyData.PID)},
"trade_no": {notifyData.TradeNo},
"out_trade_no": {notifyData.OutTradeNo},
"type": {notifyData.Type},
"name": {notifyData.Name},
"money": {notifyData.Money},
"trade_status": {notifyData.TradeStatus},
"sign": {signstr2},
"sign_type": {"MD5"},
formData := map[string]string{
"pid": fmt.Sprintf("%d", notifyData.PID),
"trade_no": notifyData.TradeNo,
"out_trade_no": notifyData.OutTradeNo,
"type": notifyData.Type,
"name": notifyData.Name,
"money": notifyData.Money,
"trade_status": notifyData.TradeStatus,
"sign": signstr2,
"sign_type": "MD5",
}
resp, err := http.PostForm(order.NotifyUrl, formData)
epayResp, err := http_client.GetHttpClient().R().SetQueryParams(formData).Get(order.NotifyUrl)
if err != nil {
return err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
log.Sugar.Infof("[mq] epay notify_url response status: %d, body: %s", epayResp.StatusCode(), string(epayResp.Body()))
if epayResp.StatusCode() != http.StatusOK {
return errors.New(epayResp.Status())
}
if !isCallbackAck(epayResp.Body()) {
return errors.New("not ok")
}
fmt.Printf("notify_url response status: %d, body: %s\n", resp.StatusCode, string(responseBody))
default:
client := http_client.GetHttpClient()
orderResp := response.OrderNotifyResponse{
Pid: apiKeyRow.Pid,
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
@@ -224,7 +252,7 @@ func sendOrderCallback(order *mdb.Orders) error {
BlockTransactionId: order.BlockTransactionId,
Status: mdb.StatusPaySuccess,
}
signature, err := sign.Get(orderResp, config.GetApiAuthToken())
signature, err := sign.Get(orderResp, apiKeyRow.SecretKey)
if err != nil {
return err
}
@@ -240,7 +268,7 @@ func sendOrderCallback(order *mdb.Orders) error {
if resp.StatusCode() != http.StatusOK {
return errors.New(resp.Status())
}
if string(resp.Body()) != "ok" {
if !isCallbackAck(resp.Body()) {
return errors.New("not ok")
}
}
@@ -248,6 +276,11 @@ func sendOrderCallback(order *mdb.Orders) error {
return nil
}
func isCallbackAck(body []byte) bool {
ack := strings.ToLower(strings.TrimSpace(string(body)))
return ack == "ok" || ack == "success"
}
func cleanupExpiredTransactionLocks() {
if err := data.CleanupExpiredTransactionLocks(); err != nil {
log.Sugar.Errorf("[mq] cleanup expired transaction locks failed: %v", err)