diff --git a/.env.example b/.env.example
index 1c4b1d5..b9b75b2 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/app/Http/Controllers/Admin/TransferRecordController.php b/app/Http/Controllers/Admin/TransferRecordController.php
new file mode 100644
index 0000000..4b31d71
--- /dev/null
+++ b/app/Http/Controllers/Admin/TransferRecordController.php
@@ -0,0 +1,124 @@
+ $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;
+ }
+}
diff --git a/config/app.php b/config/app.php
index 423eed5..8de48d8 100644
--- a/config/app.php
+++ b/config/app.php
@@ -65,7 +65,7 @@ return [
|
*/
- 'timezone' => 'UTC',
+ 'timezone' => env('APP_TIMEZONE', 'Asia/Singapore'),
/*
|--------------------------------------------------------------------------
diff --git a/resources/views/admin/shell.blade.php b/resources/views/admin/shell.blade.php
index 17bc34a..7e8dd49 100644
--- a/resources/views/admin/shell.blade.php
+++ b/resources/views/admin/shell.blade.php
@@ -84,6 +84,9 @@
助记词
+
+ 交易记录
+
diff --git a/resources/views/admin/transfers/index.blade.php b/resources/views/admin/transfers/index.blade.php
new file mode 100644
index 0000000..9f166fb
--- /dev/null
+++ b/resources/views/admin/transfers/index.blade.php
@@ -0,0 +1,179 @@
+@extends('admin.content')
+
+@section('title', '交易记录')
+
+@section('content')
+@php $portal = $portal ?? 'admin'; @endphp
+
+
+@endsection
+
+@push('scripts')
+
+@endpush
diff --git a/resources/views/user/shell.blade.php b/resources/views/user/shell.blade.php
index 061021b..18b86ac 100644
--- a/resources/views/user/shell.blade.php
+++ b/resources/views/user/shell.blade.php
@@ -74,6 +74,9 @@
助记词
+
+ 交易记录
+
diff --git a/routes/admin.php b/routes/admin.php
index 9b48ba6..53cc48e 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -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');
diff --git a/routes/user.php b/routes/user.php
index aff1295..c3a2a35 100644
--- a/routes/user.php
+++ b/routes/user.php
@@ -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');
diff --git a/tests/Feature/TransferRecordAdminTest.php b/tests/Feature/TransferRecordAdminTest.php
new file mode 100644
index 0000000..cb49fc4
--- /dev/null
+++ b/tests/Feature/TransferRecordAdminTest.php
@@ -0,0 +1,93 @@
+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');
+ }
+}