561 lines
20 KiB
PHP
561 lines
20 KiB
PHP
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Core\Services;
|
||
|
|
|
||
|
|
use PDO;
|
||
|
|
use Throwable;
|
||
|
|
|
||
|
|
class ApiLayer
|
||
|
|
{
|
||
|
|
private static bool $schemaEnsured = false;
|
||
|
|
private static bool $schemaEnsuring = false;
|
||
|
|
|
||
|
|
public static function ensureSchema(?PDO $db = null): void
|
||
|
|
{
|
||
|
|
if (self::$schemaEnsured || self::$schemaEnsuring) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
$db = $db instanceof PDO ? $db : Database::get();
|
||
|
|
if (!($db instanceof PDO)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
self::$schemaEnsuring = true;
|
||
|
|
|
||
|
|
try {
|
||
|
|
try {
|
||
|
|
$db->exec("
|
||
|
|
CREATE TABLE IF NOT EXISTS ac_api_clients (
|
||
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
|
|
name VARCHAR(150) NOT NULL,
|
||
|
|
api_key_hash CHAR(64) NOT NULL UNIQUE,
|
||
|
|
api_key_prefix VARCHAR(24) NOT NULL,
|
||
|
|
webhook_url VARCHAR(1000) NULL,
|
||
|
|
webhook_secret VARCHAR(128) NULL,
|
||
|
|
scopes_json TEXT NULL,
|
||
|
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
|
|
last_used_at DATETIME NULL,
|
||
|
|
last_used_ip VARCHAR(64) NULL,
|
||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
|
|
KEY idx_api_clients_active (is_active)
|
||
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
|
");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
$db->exec("
|
||
|
|
CREATE TABLE IF NOT EXISTS ac_store_order_item_allocations (
|
||
|
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
|
|
order_id INT UNSIGNED NOT NULL,
|
||
|
|
order_item_id INT UNSIGNED NOT NULL,
|
||
|
|
artist_id INT UNSIGNED NULL,
|
||
|
|
release_id INT UNSIGNED NULL,
|
||
|
|
track_id INT UNSIGNED NULL,
|
||
|
|
source_item_type ENUM('track','release','bundle') NOT NULL,
|
||
|
|
source_item_id INT UNSIGNED NOT NULL,
|
||
|
|
title_snapshot VARCHAR(255) NOT NULL,
|
||
|
|
qty INT UNSIGNED NOT NULL DEFAULT 1,
|
||
|
|
gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||
|
|
currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP',
|
||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
KEY idx_store_allocations_order (order_id),
|
||
|
|
KEY idx_store_allocations_item (order_item_id),
|
||
|
|
KEY idx_store_allocations_artist (artist_id),
|
||
|
|
KEY idx_store_allocations_release (release_id),
|
||
|
|
KEY idx_store_allocations_track (track_id),
|
||
|
|
KEY idx_store_allocations_created (created_at)
|
||
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
|
");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
$db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
$db->exec("ALTER TABLE ac_store_order_items ADD COLUMN artist_id INT UNSIGNED NULL AFTER item_id");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
$db->exec("ALTER TABLE ac_releases ADD KEY idx_releases_artist_id (artist_id)");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
$db->exec("ALTER TABLE ac_store_order_items ADD KEY idx_store_order_items_artist_id (artist_id)");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
|
||
|
|
self::backfillReleaseArtists($db);
|
||
|
|
self::backfillOrderItemArtists($db);
|
||
|
|
self::backfillAllocations($db);
|
||
|
|
self::$schemaEnsured = true;
|
||
|
|
} finally {
|
||
|
|
self::$schemaEnsuring = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function issueClient(PDO $db, string $name, string $webhookUrl = '', string $scopesJson = '[]'): string
|
||
|
|
{
|
||
|
|
self::ensureSchema($db);
|
||
|
|
$plainKey = 'ac_' . bin2hex(random_bytes(24));
|
||
|
|
$stmt = $db->prepare("
|
||
|
|
INSERT INTO ac_api_clients (name, api_key_hash, api_key_prefix, webhook_url, scopes_json, is_active)
|
||
|
|
VALUES (:name, :hash, :prefix, :webhook_url, :scopes_json, 1)
|
||
|
|
");
|
||
|
|
$stmt->execute([
|
||
|
|
':name' => $name,
|
||
|
|
':hash' => hash('sha256', $plainKey),
|
||
|
|
':prefix' => substr($plainKey, 0, 16),
|
||
|
|
':webhook_url' => $webhookUrl !== '' ? $webhookUrl : null,
|
||
|
|
':scopes_json' => $scopesJson,
|
||
|
|
]);
|
||
|
|
return $plainKey;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function verifyRequest(?PDO $db = null): ?array
|
||
|
|
{
|
||
|
|
$db = $db instanceof PDO ? $db : Database::get();
|
||
|
|
if (!($db instanceof PDO)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
self::ensureSchema($db);
|
||
|
|
$key = self::extractApiKey();
|
||
|
|
if ($key === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
$stmt = $db->prepare("
|
||
|
|
SELECT id, name, api_key_prefix, webhook_url, webhook_secret, scopes_json, is_active
|
||
|
|
FROM ac_api_clients
|
||
|
|
WHERE api_key_hash = :hash
|
||
|
|
AND is_active = 1
|
||
|
|
LIMIT 1
|
||
|
|
");
|
||
|
|
$stmt->execute([':hash' => hash('sha256', $key)]);
|
||
|
|
$client = $stmt->fetch(PDO::FETCH_ASSOC);
|
||
|
|
if (!$client) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$touch = $db->prepare("
|
||
|
|
UPDATE ac_api_clients
|
||
|
|
SET last_used_at = NOW(), last_used_ip = :ip
|
||
|
|
WHERE id = :id
|
||
|
|
");
|
||
|
|
$touch->execute([
|
||
|
|
':ip' => self::clientIp(),
|
||
|
|
':id' => (int)$client['id'],
|
||
|
|
]);
|
||
|
|
|
||
|
|
return $client;
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function syncOrderItemAllocations(PDO $db, int $orderId, int $orderItemId): void
|
||
|
|
{
|
||
|
|
self::ensureSchema($db);
|
||
|
|
if ($orderId <= 0 || $orderItemId <= 0) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
$stmt = $db->prepare("
|
||
|
|
SELECT id, order_id, item_type, item_id, artist_id, title_snapshot, qty, line_total, currency_snapshot
|
||
|
|
FROM ac_store_order_items
|
||
|
|
WHERE id = :id AND order_id = :order_id
|
||
|
|
LIMIT 1
|
||
|
|
");
|
||
|
|
$stmt->execute([
|
||
|
|
':id' => $orderItemId,
|
||
|
|
':order_id' => $orderId,
|
||
|
|
]);
|
||
|
|
$item = $stmt->fetch(PDO::FETCH_ASSOC);
|
||
|
|
if (!$item) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
$db->prepare("DELETE FROM ac_store_order_item_allocations WHERE order_item_id = :order_item_id")
|
||
|
|
->execute([':order_item_id' => $orderItemId]);
|
||
|
|
|
||
|
|
$type = (string)($item['item_type'] ?? '');
|
||
|
|
$sourceId = (int)($item['item_id'] ?? 0);
|
||
|
|
$qty = max(1, (int)($item['qty'] ?? 1));
|
||
|
|
$lineTotal = (float)($item['line_total'] ?? 0);
|
||
|
|
$currency = strtoupper(trim((string)($item['currency_snapshot'] ?? 'GBP')));
|
||
|
|
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
|
||
|
|
$currency = 'GBP';
|
||
|
|
}
|
||
|
|
|
||
|
|
$rows = [];
|
||
|
|
if ($type === 'track') {
|
||
|
|
$track = self::loadTrackArtistContext($db, $sourceId);
|
||
|
|
if ($track) {
|
||
|
|
$rows[] = [
|
||
|
|
'artist_id' => (int)($track['artist_id'] ?? 0),
|
||
|
|
'release_id' => (int)($track['release_id'] ?? 0),
|
||
|
|
'track_id' => $sourceId,
|
||
|
|
'title_snapshot' => (string)($track['title'] ?? $item['title_snapshot'] ?? 'Track'),
|
||
|
|
'gross_amount' => $lineTotal,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
} elseif ($type === 'release') {
|
||
|
|
$release = self::loadReleaseArtistContext($db, $sourceId);
|
||
|
|
if ($release) {
|
||
|
|
$rows[] = [
|
||
|
|
'artist_id' => (int)($release['artist_id'] ?? 0),
|
||
|
|
'release_id' => $sourceId,
|
||
|
|
'track_id' => null,
|
||
|
|
'title_snapshot' => (string)($release['title'] ?? $item['title_snapshot'] ?? 'Release'),
|
||
|
|
'gross_amount' => $lineTotal,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
} elseif ($type === 'bundle') {
|
||
|
|
$rows = self::buildBundleAllocationRows($db, $sourceId, $lineTotal);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!$rows) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
$insert = $db->prepare("
|
||
|
|
INSERT INTO ac_store_order_item_allocations
|
||
|
|
(order_id, order_item_id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at)
|
||
|
|
VALUES (:order_id, :order_item_id, :artist_id, :release_id, :track_id, :source_item_type, :source_item_id, :title_snapshot, :qty, :gross_amount, :currency_snapshot, NOW())
|
||
|
|
");
|
||
|
|
|
||
|
|
foreach ($rows as $row) {
|
||
|
|
$artistId = (int)($row['artist_id'] ?? 0);
|
||
|
|
$releaseId = (int)($row['release_id'] ?? 0);
|
||
|
|
$trackId = (int)($row['track_id'] ?? 0);
|
||
|
|
$insert->execute([
|
||
|
|
':order_id' => $orderId,
|
||
|
|
':order_item_id' => $orderItemId,
|
||
|
|
':artist_id' => $artistId > 0 ? $artistId : null,
|
||
|
|
':release_id' => $releaseId > 0 ? $releaseId : null,
|
||
|
|
':track_id' => $trackId > 0 ? $trackId : null,
|
||
|
|
':source_item_type' => $type,
|
||
|
|
':source_item_id' => $sourceId,
|
||
|
|
':title_snapshot' => (string)($row['title_snapshot'] ?? $item['title_snapshot'] ?? 'Item'),
|
||
|
|
':qty' => $qty,
|
||
|
|
':gross_amount' => (float)($row['gross_amount'] ?? 0),
|
||
|
|
':currency_snapshot' => $currency,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function dispatchSaleWebhooksForOrder(int $orderId): void
|
||
|
|
{
|
||
|
|
$db = Database::get();
|
||
|
|
if (!($db instanceof PDO) || $orderId <= 0) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
self::ensureSchema($db);
|
||
|
|
|
||
|
|
try {
|
||
|
|
$stmt = $db->query("
|
||
|
|
SELECT id, name, webhook_url, webhook_secret
|
||
|
|
FROM ac_api_clients
|
||
|
|
WHERE is_active = 1
|
||
|
|
AND webhook_url IS NOT NULL
|
||
|
|
AND webhook_url <> ''
|
||
|
|
ORDER BY id ASC
|
||
|
|
");
|
||
|
|
$clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
|
||
|
|
if (!$clients) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
$payload = self::buildOrderWebhookPayload($db, $orderId);
|
||
|
|
if (!$payload) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach ($clients as $client) {
|
||
|
|
$url = trim((string)($client['webhook_url'] ?? ''));
|
||
|
|
if ($url === '') {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||
|
|
if (!is_string($json) || $json === '') {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$headers = [
|
||
|
|
'Content-Type: application/json',
|
||
|
|
'Content-Length: ' . strlen($json),
|
||
|
|
'X-AudioCore-Event: sale.paid',
|
||
|
|
'X-AudioCore-Client: ' . (string)($client['name'] ?? 'AudioCore API'),
|
||
|
|
];
|
||
|
|
$secret = trim((string)($client['webhook_secret'] ?? ''));
|
||
|
|
if ($secret !== '') {
|
||
|
|
$headers[] = 'X-AudioCore-Signature: sha256=' . hash_hmac('sha256', $json, $secret);
|
||
|
|
}
|
||
|
|
self::postJson($url, $json, $headers);
|
||
|
|
}
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function buildOrderWebhookPayload(PDO $db, int $orderId): array
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
$orderStmt = $db->prepare("
|
||
|
|
SELECT id, order_no, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, created_at, updated_at
|
||
|
|
FROM ac_store_orders
|
||
|
|
WHERE id = :id
|
||
|
|
LIMIT 1
|
||
|
|
");
|
||
|
|
$orderStmt->execute([':id' => $orderId]);
|
||
|
|
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
|
||
|
|
if (!$order) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
$allocStmt = $db->prepare("
|
||
|
|
SELECT id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at
|
||
|
|
FROM ac_store_order_item_allocations
|
||
|
|
WHERE order_id = :order_id
|
||
|
|
ORDER BY id ASC
|
||
|
|
");
|
||
|
|
$allocStmt->execute([':order_id' => $orderId]);
|
||
|
|
$allocations = $allocStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||
|
|
|
||
|
|
return [
|
||
|
|
'event' => 'sale.paid',
|
||
|
|
'order' => $order,
|
||
|
|
'allocations' => $allocations,
|
||
|
|
];
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function backfillReleaseArtists(PDO $db): void
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
$db->exec("
|
||
|
|
UPDATE ac_releases r
|
||
|
|
JOIN ac_artists a ON a.name = r.artist_name
|
||
|
|
SET r.artist_id = a.id
|
||
|
|
WHERE r.artist_id IS NULL
|
||
|
|
AND r.artist_name IS NOT NULL
|
||
|
|
AND r.artist_name <> ''
|
||
|
|
");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function backfillOrderItemArtists(PDO $db): void
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
$db->exec("
|
||
|
|
UPDATE ac_store_order_items oi
|
||
|
|
JOIN ac_release_tracks t ON oi.item_type = 'track' AND oi.item_id = t.id
|
||
|
|
JOIN ac_releases r ON r.id = t.release_id
|
||
|
|
SET oi.artist_id = r.artist_id
|
||
|
|
WHERE oi.artist_id IS NULL
|
||
|
|
");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
$db->exec("
|
||
|
|
UPDATE ac_store_order_items oi
|
||
|
|
JOIN ac_releases r ON oi.item_type = 'release' AND oi.item_id = r.id
|
||
|
|
SET oi.artist_id = r.artist_id
|
||
|
|
WHERE oi.artist_id IS NULL
|
||
|
|
");
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function backfillAllocations(PDO $db): void
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
$stmt = $db->query("
|
||
|
|
SELECT oi.id, oi.order_id
|
||
|
|
FROM ac_store_order_items oi
|
||
|
|
LEFT JOIN ac_store_order_item_allocations a ON a.order_item_id = oi.id
|
||
|
|
WHERE a.id IS NULL
|
||
|
|
ORDER BY oi.id ASC
|
||
|
|
LIMIT 5000
|
||
|
|
");
|
||
|
|
$rows = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
|
||
|
|
foreach ($rows as $row) {
|
||
|
|
self::syncOrderItemAllocations($db, (int)($row['order_id'] ?? 0), (int)($row['id'] ?? 0));
|
||
|
|
}
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function loadReleaseArtistContext(PDO $db, int $releaseId): ?array
|
||
|
|
{
|
||
|
|
if ($releaseId <= 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
$stmt = $db->prepare("
|
||
|
|
SELECT r.id, r.title, r.artist_id
|
||
|
|
FROM ac_releases r
|
||
|
|
WHERE r.id = :id
|
||
|
|
LIMIT 1
|
||
|
|
");
|
||
|
|
$stmt->execute([':id' => $releaseId]);
|
||
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||
|
|
return $row ?: null;
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function loadTrackArtistContext(PDO $db, int $trackId): ?array
|
||
|
|
{
|
||
|
|
if ($trackId <= 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
$stmt = $db->prepare("
|
||
|
|
SELECT t.id, t.title, t.release_id, r.artist_id
|
||
|
|
FROM ac_release_tracks t
|
||
|
|
JOIN ac_releases r ON r.id = t.release_id
|
||
|
|
WHERE t.id = :id
|
||
|
|
LIMIT 1
|
||
|
|
");
|
||
|
|
$stmt->execute([':id' => $trackId]);
|
||
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||
|
|
return $row ?: null;
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function buildBundleAllocationRows(PDO $db, int $bundleId, float $lineTotal): array
|
||
|
|
{
|
||
|
|
if ($bundleId <= 0) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
$stmt = $db->prepare("
|
||
|
|
SELECT
|
||
|
|
r.id AS release_id,
|
||
|
|
r.title,
|
||
|
|
r.artist_id,
|
||
|
|
COALESCE(SUM(CASE WHEN sp.is_enabled = 1 THEN sp.track_price ELSE 0 END), 0) AS weight
|
||
|
|
FROM ac_store_bundle_items bi
|
||
|
|
JOIN ac_releases r ON r.id = bi.release_id
|
||
|
|
LEFT JOIN ac_release_tracks t ON t.release_id = r.id
|
||
|
|
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id
|
||
|
|
WHERE bi.bundle_id = :bundle_id
|
||
|
|
GROUP BY r.id, r.title, r.artist_id, bi.sort_order, bi.id
|
||
|
|
ORDER BY bi.sort_order ASC, bi.id ASC
|
||
|
|
");
|
||
|
|
$stmt->execute([':bundle_id' => $bundleId]);
|
||
|
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||
|
|
if (!$rows) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
$weights = [];
|
||
|
|
$totalWeight = 0.0;
|
||
|
|
foreach ($rows as $index => $row) {
|
||
|
|
$weight = max(0.0, (float)($row['weight'] ?? 0));
|
||
|
|
if ($weight <= 0) {
|
||
|
|
$weight = 1.0;
|
||
|
|
}
|
||
|
|
$weights[$index] = $weight;
|
||
|
|
$totalWeight += $weight;
|
||
|
|
}
|
||
|
|
if ($totalWeight <= 0) {
|
||
|
|
$totalWeight = (float)count($rows);
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = [];
|
||
|
|
$allocated = 0.0;
|
||
|
|
$lastIndex = count($rows) - 1;
|
||
|
|
foreach ($rows as $index => $row) {
|
||
|
|
if ($index === $lastIndex) {
|
||
|
|
$amount = round($lineTotal - $allocated, 2);
|
||
|
|
} else {
|
||
|
|
$amount = round($lineTotal * ($weights[$index] / $totalWeight), 2);
|
||
|
|
$allocated += $amount;
|
||
|
|
}
|
||
|
|
$result[] = [
|
||
|
|
'artist_id' => (int)($row['artist_id'] ?? 0),
|
||
|
|
'release_id' => (int)($row['release_id'] ?? 0),
|
||
|
|
'track_id' => null,
|
||
|
|
'title_snapshot' => (string)($row['title'] ?? 'Bundle release'),
|
||
|
|
'gross_amount' => max(0.0, $amount),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
return $result;
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function extractApiKey(): string
|
||
|
|
{
|
||
|
|
$auth = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
|
||
|
|
if (stripos($auth, 'Bearer ') === 0) {
|
||
|
|
return trim(substr($auth, 7));
|
||
|
|
}
|
||
|
|
$header = trim((string)($_SERVER['HTTP_X_API_KEY'] ?? ''));
|
||
|
|
if ($header !== '') {
|
||
|
|
return $header;
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function clientIp(): string
|
||
|
|
{
|
||
|
|
$forwarded = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
|
||
|
|
if ($forwarded !== '') {
|
||
|
|
$parts = explode(',', $forwarded);
|
||
|
|
return trim((string)($parts[0] ?? ''));
|
||
|
|
}
|
||
|
|
return trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function postJson(string $url, string $json, array $headers): void
|
||
|
|
{
|
||
|
|
if (function_exists('curl_init')) {
|
||
|
|
$ch = curl_init($url);
|
||
|
|
if ($ch === false) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
curl_setopt_array($ch, [
|
||
|
|
CURLOPT_POST => true,
|
||
|
|
CURLOPT_RETURNTRANSFER => true,
|
||
|
|
CURLOPT_HTTPHEADER => $headers,
|
||
|
|
CURLOPT_POSTFIELDS => $json,
|
||
|
|
CURLOPT_TIMEOUT => 8,
|
||
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||
|
|
]);
|
||
|
|
curl_exec($ch);
|
||
|
|
curl_close($ch);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
$context = stream_context_create([
|
||
|
|
'http' => [
|
||
|
|
'method' => 'POST',
|
||
|
|
'header' => implode("\r\n", $headers),
|
||
|
|
'content' => $json,
|
||
|
|
'timeout' => 8,
|
||
|
|
'ignore_errors' => true,
|
||
|
|
],
|
||
|
|
]);
|
||
|
|
@file_get_contents($url, false, $context);
|
||
|
|
}
|
||
|
|
}
|