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)
+401
View File
@@ -1,6 +1,7 @@
package mq
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -13,6 +14,7 @@ import (
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/sign"
)
func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) {
@@ -158,6 +160,7 @@ func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) {
BlockTransactionId: "block_callback",
CallbackNum: 1,
CallBackConfirm: mdb.CallBackConfirmNo,
ApiKeyID: 1, // seeded by testutil.SetupTestDatabases
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
@@ -218,6 +221,7 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) {
BlockTransactionId: "block_callback_restart",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
ApiKeyID: 1, // seeded by testutil.SetupTestDatabases
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
@@ -259,6 +263,403 @@ func TestDispatchPendingCallbacksResumesRetryAfterRestart(t *testing.T) {
}
}
func TestDispatchPendingCallbacksEpayRequiresAck(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
epayKey, err := data.GetEnabledApiKey("1001")
if err != nil || epayKey == nil || epayKey.ID == 0 {
t.Fatalf("load epay key: %v", err)
}
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
http.Error(w, "fail", http.StatusInternalServerError)
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_callback_epay_fail",
OrderId: "order_callback_epay_fail",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_fail",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_callback_epay_fail",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
PaymentType: mdb.PaymentTypeEpay,
ApiKeyID: epayKey.ID,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
}
dispatchPendingCallbacks()
waitFor(t, 3*time.Second, func() bool {
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
if innerErr != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmNo && current.CallbackNum == 1
})
if got := atomic.LoadInt32(&requestCount); got != 1 {
t.Fatalf("callback request count = %d, want 1", got)
}
}
func TestDispatchPendingCallbacksEpayAcceptsTrimmedOk(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
epayKey, err := data.GetEnabledApiKey("1001")
if err != nil || epayKey == nil || epayKey.ID == 0 {
t.Fatalf("load epay key: %v", err)
}
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
_, _ = io.WriteString(w, " ok\n")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_callback_epay_ok",
OrderId: "order_callback_epay_ok",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_ok",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_callback_epay_ok",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
PaymentType: mdb.PaymentTypeEpay,
ApiKeyID: epayKey.ID,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
}
dispatchPendingCallbacks()
waitFor(t, 3*time.Second, func() bool {
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
if innerErr != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 1
})
if got := atomic.LoadInt32(&requestCount); got != 1 {
t.Fatalf("callback request count = %d, want 1", got)
}
}
// Orders must always carry an ApiKeyID (the default key is seeded at
// bootstrap and the signing middleware / EPAY inline flow always
// stamps it). If ApiKeyID is 0, resolveOrderApiKey returns an error —
// the callback won't sign with a guess.
func TestResolveOrderApiKeyRejectsZeroApiKeyID(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
order := &mdb.Orders{
TradeId: "no-api-key-id",
ApiKeyID: 0,
}
if _, err := resolveOrderApiKey(order); err == nil {
t.Fatal("expected error for ApiKeyID=0")
}
}
// Happy path: when ApiKeyID points at an enabled row, resolveOrderApiKey
// returns it.
func TestResolveOrderApiKeyReturnsEnabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
row := &mdb.ApiKey{
Name: "target",
Pid: "target-key",
SecretKey: "target-secret",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(row).Error; err != nil {
t.Fatalf("create api key: %v", err)
}
order := &mdb.Orders{
TradeId: "happy-path",
ApiKeyID: row.ID,
}
got, err := resolveOrderApiKey(order)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if got == nil || got.ID != row.ID {
t.Fatalf("resolved row = %+v, want id=%d", got, row.ID)
}
}
func TestSendOrderCallbackGmpayUsesApiKeySecretByPid(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
key := &mdb.ApiKey{
Name: "gmpay-key",
Pid: "9001",
SecretKey: "gmpay-secret-9001",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(key).Error; err != nil {
t.Fatalf("create gmpay api key: %v", err)
}
warningKey := &mdb.ApiKey{
Name: "wrong-key",
Pid: "9002",
SecretKey: "wrong-secret-9002",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(warningKey).Error; err != nil {
t.Fatalf("create wrong api key: %v", err)
}
var received map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &received)
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_gmpay_sign",
OrderId: "order_gmpay_sign",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_gmpay",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_gmpay_sign",
ApiKeyID: key.ID,
}
if err := sendOrderCallback(order); err != nil {
t.Fatalf("send callback: %v", err)
}
if received == nil {
t.Fatal("expected callback payload")
}
if gotPid, _ := received["pid"].(string); gotPid != key.Pid {
t.Fatalf("payload pid = %q, want %q", gotPid, key.Pid)
}
recvSig, _ := received["signature"].(string)
if recvSig == "" {
t.Fatal("payload signature is empty")
}
delete(received, "signature")
calcSig, err := sign.Get(received, key.SecretKey)
if err != nil {
t.Fatalf("calc signature with target secret: %v", err)
}
if recvSig != calcSig {
t.Fatalf("signature mismatch: got %q want %q", recvSig, calcSig)
}
wrongSig, err := sign.Get(received, warningKey.SecretKey)
if err != nil {
t.Fatalf("calc signature with wrong secret: %v", err)
}
if recvSig == wrongSig {
t.Fatal("signature should not match wrong api key secret")
}
}
func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
key := &mdb.ApiKey{
Name: "epay-key",
Pid: "9101",
SecretKey: "epay-secret-9101",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(key).Error; err != nil {
t.Fatalf("create epay api key: %v", err)
}
warningKey := &mdb.ApiKey{
Name: "wrong-epay-key",
Pid: "9102",
SecretKey: "wrong-epay-secret-9102",
Status: mdb.ApiKeyStatusEnable,
}
if err := dao.Mdb.Create(warningKey).Error; err != nil {
t.Fatalf("create wrong epay api key: %v", err)
}
formPayload := map[string]string{}
callbackMethod := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callbackMethod = r.Method
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for k, v := range r.Form {
if len(v) > 0 {
formPayload[k] = v[0]
}
}
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_epay_sign",
OrderId: "order_epay_sign",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay",
Token: "USDT",
Name: "VIP",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_epay_sign",
PaymentType: mdb.PaymentTypeEpay,
ApiKeyID: key.ID,
}
if err := sendOrderCallback(order); err != nil {
t.Fatalf("send epay callback: %v", err)
}
if callbackMethod != http.MethodGet {
t.Fatalf("callback method = %s, want %s", callbackMethod, http.MethodGet)
}
if formPayload["sign_type"] != "MD5" {
t.Fatalf("sign_type = %q, want MD5", formPayload["sign_type"])
}
if formPayload["type"] != "alipay" {
t.Fatalf("type = %q, want alipay", formPayload["type"])
}
if formPayload["trade_status"] != "TRADE_SUCCESS" {
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", formPayload["trade_status"])
}
if formPayload["pid"] != key.Pid {
t.Fatalf("form pid = %q, want %q", formPayload["pid"], key.Pid)
}
recvSig := formPayload["sign"]
if recvSig == "" {
t.Fatal("epay sign is empty")
}
signParams := map[string]interface{}{
"pid": formPayload["pid"],
"trade_no": formPayload["trade_no"],
"out_trade_no": formPayload["out_trade_no"],
"type": formPayload["type"],
"name": formPayload["name"],
"money": formPayload["money"],
"trade_status": formPayload["trade_status"],
}
calcSig, err := sign.Get(signParams, key.SecretKey)
if err != nil {
t.Fatalf("calc epay signature with target secret: %v", err)
}
if recvSig != calcSig {
t.Fatalf("epay signature mismatch: got %q want %q", recvSig, calcSig)
}
wrongSig, err := sign.Get(signParams, warningKey.SecretKey)
if err != nil {
t.Fatalf("calc epay signature with wrong secret: %v", err)
}
if recvSig == wrongSig {
t.Fatal("epay signature should not match wrong api key secret")
}
}
func TestDispatchPendingCallbacksEpayAcceptsSuccessAck(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
callbackLimiter = make(chan struct{}, 1)
callbackInflight = sync.Map{}
epayKey, err := data.GetEnabledApiKey("1001")
if err != nil || epayKey == nil || epayKey.ID == 0 {
t.Fatalf("load epay key: %v", err)
}
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
_, _ = io.WriteString(w, "success")
}))
defer server.Close()
order := &mdb.Orders{
TradeId: "trade_callback_epay_success",
OrderId: "order_callback_epay_success",
Amount: 1,
Currency: "CNY",
ActualAmount: 1,
ReceiveAddress: "wallet_epay_success",
Token: "USDT",
Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL,
BlockTransactionId: "block_callback_epay_success",
CallbackNum: 0,
CallBackConfirm: mdb.CallBackConfirmNo,
PaymentType: mdb.PaymentTypeEpay,
ApiKeyID: epayKey.ID,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create callback order: %v", err)
}
dispatchPendingCallbacks()
waitFor(t, 3*time.Second, func() bool {
current, innerErr := data.GetOrderInfoByTradeId(order.TradeId)
if innerErr != nil || current.ID <= 0 {
return false
}
return current.CallBackConfirm == mdb.CallBackConfirmOk && current.CallbackNum == 1
})
if got := atomic.LoadInt32(&requestCount); got != 1 {
t.Fatalf("callback request count = %d, want 1", got)
}
}
func waitFor(t *testing.T, timeout time.Duration, fn func() bool) {
t.Helper()