feat: new chain
This commit is contained in:
@@ -3,6 +3,7 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://127.0.0.1:8000
|
||||
APP_TIMEZONE=Asia/Singapore
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TransferRecord;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TransferRecordController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('admin.transfers.index', [
|
||||
'portal' => $this->portal(),
|
||||
// value => label (DB stores lowercase chain / uppercase asset)
|
||||
'chains' => [
|
||||
'tron' => 'TRON',
|
||||
'eth' => 'ETH',
|
||||
'btc' => 'BTC',
|
||||
],
|
||||
'assets' => ['USDT', 'TRX', 'ETH', 'BTC'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function data(Request $request)
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'chain', 'asset', 'amount', 'status', 'operator', 'created_at', 'updated_at'];
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy('transfer_records.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['transfer_records.*'], 'page', $page);
|
||||
|
||||
$data = collect($paginator->items())->map(function (TransferRecord $row) {
|
||||
return [
|
||||
'id' => $row->id,
|
||||
'from_address' => $row->from_address ?: '',
|
||||
'to_address' => $row->to_address ?: '',
|
||||
'chain' => $row->chain ?: '',
|
||||
'tx_hash' => $row->tx_hash ?: '',
|
||||
'amount' => $row->amount ?: '',
|
||||
'type' => $row->type ?: '',
|
||||
'asset' => $row->asset ?: '',
|
||||
'operator' => $row->operator ?: '',
|
||||
'status' => $row->status ?: '',
|
||||
'error' => $row->error ?: '',
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = TransferRecord::query();
|
||||
$agent = $this->agent();
|
||||
|
||||
if ($agent !== null) {
|
||||
$channelIds = AgentScope::channelIdsFor($agent);
|
||||
if ($channelIds === []) {
|
||||
return $q->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$q->whereExists(function ($sub) use ($channelIds) {
|
||||
$sub->selectRaw('1')
|
||||
->from('wallet_addresses')
|
||||
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||
->whereColumn('wallet_addresses.address', 'transfer_records.from_address')
|
||||
->whereIn('devices.channel_id', $channelIds);
|
||||
});
|
||||
}
|
||||
|
||||
$from = trim((string) $request->query('from_address', ''));
|
||||
$to = trim((string) $request->query('to_address', ''));
|
||||
$txHash = trim((string) $request->query('tx_hash', ''));
|
||||
$chain = trim((string) $request->query('chain', ''));
|
||||
$asset = trim((string) $request->query('asset', ''));
|
||||
$status = trim((string) $request->query('status', ''));
|
||||
$operator = trim((string) $request->query('operator', ''));
|
||||
|
||||
if ($from !== '') {
|
||||
$q->where('transfer_records.from_address', 'like', '%'.$from.'%');
|
||||
}
|
||||
if ($to !== '') {
|
||||
$q->where('transfer_records.to_address', 'like', '%'.$to.'%');
|
||||
}
|
||||
if ($txHash !== '') {
|
||||
$q->where('transfer_records.tx_hash', 'like', '%'.$txHash.'%');
|
||||
}
|
||||
if ($chain !== '') {
|
||||
$q->where('transfer_records.chain', $chain);
|
||||
}
|
||||
if ($asset !== '') {
|
||||
$q->where('transfer_records.asset', $asset);
|
||||
}
|
||||
if ($status !== '') {
|
||||
$q->where('transfer_records.status', $status);
|
||||
}
|
||||
if ($operator !== '') {
|
||||
$q->where('transfer_records.operator', 'like', '%'.$operator.'%');
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -65,7 +65,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => env('APP_TIMEZONE', 'Asia/Singapore'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
<dd data-name="mnemonics">
|
||||
<a lay-href="{{ route('admin.mnemonics.index') }}">助记词</a>
|
||||
</dd>
|
||||
<dd data-name="transfers">
|
||||
<a lay-href="{{ route('admin.transfers.index') }}">交易记录</a>
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
<li data-name="collect" class="layui-nav-item">
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
@extends('admin.content')
|
||||
|
||||
@section('title', '交易记录')
|
||||
|
||||
@section('content')
|
||||
@php $portal = $portal ?? 'admin'; @endphp
|
||||
<style>
|
||||
.tx-cell { word-break: break-all; font-family: Menlo, Monaco, Consolas, monospace; font-size: 12px; }
|
||||
.tx-status-success { color: #16b777; font-weight: 600; }
|
||||
.tx-status-failed { color: #ff5722; font-weight: 600; }
|
||||
.tx-chain-tag {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
line-height: 20px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
background: #909399;
|
||||
}
|
||||
.tx-chain-eth, .tx-chain-ethereum { background: #627eea; }
|
||||
.tx-chain-btc, .tx-chain-bitcoin { background: #f7931a; }
|
||||
.tx-chain-trx, .tx-chain-tron { background: #ff0013; }
|
||||
</style>
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-tx-search">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">From</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="from_address" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">To</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="to_address" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">Tx Hash</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="tx_hash" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">链</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="chain">
|
||||
<option value="">全部</option>
|
||||
@foreach ($chains as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">资产</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="asset">
|
||||
<option value="">全部</option>
|
||||
@foreach ($assets as $a)
|
||||
<option value="{{ $a }}">{{ $a }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="status">
|
||||
<option value="">全部</option>
|
||||
<option value="success">success</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">操作者</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="operator" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" lay-submit lay-filter="LAY-tx-search">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="layui-card-body">
|
||||
<table id="LAY-tx-list" lay-filter="LAY-tx-list"></table>
|
||||
<script type="text/html" id="LAY-tx-ops">
|
||||
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="detail">详情</a>
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer;
|
||||
|
||||
function chainTag(chain) {
|
||||
var raw = String(chain || '—');
|
||||
var key = raw.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
return '<span class="tx-chain-tag tx-chain-' + (key || 'unknown') + '">' + raw + '</span>';
|
||||
}
|
||||
|
||||
function statusHtml(status) {
|
||||
var s = String(status || '');
|
||||
if (s === 'success') return '<span class="tx-status-success">success</span>';
|
||||
if (s === 'failed') return '<span class="tx-status-failed">failed</span>';
|
||||
return s || '—';
|
||||
}
|
||||
|
||||
table.render({
|
||||
elem: '#LAY-tx-list',
|
||||
id: 'LAY-tx-list',
|
||||
url: @json(route($portal.'.transfers.data')),
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||
{ field: 'chain', title: '链', width: 90, templet: function (d) { return chainTag(d.chain); } },
|
||||
{ field: 'asset', title: '资产', width: 80, sort: true },
|
||||
{ field: 'amount', title: '金额', width: 120, sort: true },
|
||||
{ field: 'from_address', title: 'From', minWidth: 220, templet: function (d) {
|
||||
return '<span class="tx-cell">' + (d.from_address || '') + '</span>';
|
||||
}},
|
||||
{ field: 'to_address', title: 'To', minWidth: 220, templet: function (d) {
|
||||
return '<span class="tx-cell">' + (d.to_address || '') + '</span>';
|
||||
}},
|
||||
{ field: 'tx_hash', title: 'Tx Hash', minWidth: 220, templet: function (d) {
|
||||
return '<span class="tx-cell">' + (d.tx_hash || '—') + '</span>';
|
||||
}},
|
||||
{ field: 'status', title: '状态', width: 90, sort: true, templet: function (d) {
|
||||
return statusHtml(d.status);
|
||||
}},
|
||||
{ field: 'operator', title: '操作者', width: 120 },
|
||||
{ field: 'type', title: '类型', width: 70 },
|
||||
{ field: 'created_at', title: '时间', width: 170, sort: true },
|
||||
{ title: '操作', width: 90, align: 'center', fixed: 'right', toolbar: '#LAY-tx-ops' }
|
||||
]],
|
||||
page: true, limit: 15, height: 'full-220',
|
||||
request: { pageName: 'page', limitName: 'limit' },
|
||||
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
||||
});
|
||||
|
||||
form.on('submit(LAY-tx-search)', function (data) {
|
||||
table.reload('LAY-tx-list', { where: data.field, page: { curr: 1 } });
|
||||
return false;
|
||||
});
|
||||
|
||||
table.on('tool(LAY-tx-list)', function (obj) {
|
||||
if (obj.event !== 'detail') return;
|
||||
var d = obj.data;
|
||||
var esc = function (v) {
|
||||
return String(v == null || v === '' ? '—' : v)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
};
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: '交易详情 — #' + d.id,
|
||||
area: ['640px', '480px'],
|
||||
content:
|
||||
'<div style="padding:16px;line-height:1.8;font-size:13px;">' +
|
||||
'<div><b>状态</b>:' + statusHtml(d.status) + '</div>' +
|
||||
'<div><b>链 / 资产</b>:' + esc(d.chain) + ' / ' + esc(d.asset) + '</div>' +
|
||||
'<div><b>金额</b>:' + esc(d.amount) + '</div>' +
|
||||
'<div><b>类型</b>:' + esc(d.type) + '</div>' +
|
||||
'<div><b>From</b>:<span class="tx-cell">' + esc(d.from_address) + '</span></div>' +
|
||||
'<div><b>To</b>:<span class="tx-cell">' + esc(d.to_address) + '</span></div>' +
|
||||
'<div><b>Tx Hash</b>:<span class="tx-cell">' + esc(d.tx_hash) + '</span></div>' +
|
||||
'<div><b>操作者</b>:' + esc(d.operator) + '</div>' +
|
||||
'<div><b>时间</b>:' + esc(d.created_at) + '</div>' +
|
||||
'<div style="margin-top:8px;"><b>错误</b>:<pre style="white-space:pre-wrap;word-break:break-all;margin:4px 0 0;">' + esc(d.error) + '</pre></div>' +
|
||||
'</div>'
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -74,6 +74,9 @@
|
||||
<dd data-name="mnemonics">
|
||||
<a lay-href="{{ route('user.mnemonics.index') }}">助记词</a>
|
||||
</dd>
|
||||
<dd data-name="transfers">
|
||||
<a lay-href="{{ route('user.transfers.index') }}">交易记录</a>
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
<li data-name="collect" class="layui-nav-item">
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Http\Controllers\Admin\PageVisitController;
|
||||
use App\Http\Controllers\Admin\PhotoController;
|
||||
use App\Http\Controllers\Admin\RawLogController;
|
||||
use App\Http\Controllers\Admin\SystemSettingsController;
|
||||
use App\Http\Controllers\Admin\TransferRecordController;
|
||||
use App\Http\Controllers\Admin\WalletAddressController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -52,6 +53,9 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
||||
Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index');
|
||||
Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data');
|
||||
|
||||
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
||||
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
||||
|
||||
Route::get('photos', [PhotoController::class, 'index'])->name('photos.index');
|
||||
Route::get('photos/data', [PhotoController::class, 'data'])->name('photos.data');
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Admin\MnemonicController;
|
||||
use App\Http\Controllers\Admin\NoteController;
|
||||
use App\Http\Controllers\Admin\PageVisitController;
|
||||
use App\Http\Controllers\Admin\PhotoController;
|
||||
use App\Http\Controllers\Admin\TransferRecordController;
|
||||
use App\Http\Controllers\Admin\WalletAddressController;
|
||||
use App\Http\Controllers\Agent\AuthController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -41,6 +42,9 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
||||
Route::get('mnemonics', [MnemonicController::class, 'index'])->name('mnemonics.index');
|
||||
Route::get('mnemonics/data', [MnemonicController::class, 'data'])->name('mnemonics.data');
|
||||
|
||||
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
||||
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
||||
|
||||
Route::get('photos', [PhotoController::class, 'index'])->name('photos.index');
|
||||
Route::get('photos/data', [PhotoController::class, 'data'])->name('photos.data');
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\TransferRecord;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TransferRecordAdminTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
#[Test]
|
||||
public function admin_can_list_transfer_records(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => 'TFrom1111111111111111111111111111',
|
||||
'to_address' => 'TTo222222222222222222222222222222',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'abc123',
|
||||
'amount' => '10',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'tg:1',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->get(route('admin.transfers.index'))
|
||||
->assertOk()
|
||||
->assertSee('交易记录');
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.transfers.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.asset', 'USDT')
|
||||
->assertJsonPath('data.0.status', 'success');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_only_sees_transfers_for_own_channel_addresses(): void
|
||||
{
|
||||
$agent = User::query()->create(['username' => 'agent-tx', 'password' => 'secret12', 'status' => 1]);
|
||||
$ch = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
Channel::query()->create(['channel_id' => $ch, 'user_id' => $agent->id, 'status' => 1]);
|
||||
$device = Device::query()->create(['device_id' => 'dev-tx-1', 'channel_id' => $ch]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'TMine3333333333333333333333333333',
|
||||
'chain_type' => 'TRON',
|
||||
'source' => 'trust',
|
||||
]);
|
||||
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => 'TMine3333333333333333333333333333',
|
||||
'to_address' => 'TTo',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'mine',
|
||||
'amount' => '1',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'tg:agent',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => 'TOther444444444444444444444444444',
|
||||
'to_address' => 'TTo',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'other',
|
||||
'amount' => '2',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'tg:other',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
|
||||
$this->actingAs($agent, 'agent')
|
||||
->getJson(route('user.transfers.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.tx_hash', 'mine');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user