Deploy: 2026-05-23 00:20:07
This commit is contained in:
@@ -27,3 +27,10 @@ HMAC_SALT=nabeh_secure_blind_index_salt_key_123
|
|||||||
# Secret key for JWT signatures
|
# Secret key for JWT signatures
|
||||||
JWT_SECRET=nabeh_jwt_secret_signature_key_987654
|
JWT_SECRET=nabeh_jwt_secret_signature_key_987654
|
||||||
|
|
||||||
|
# Redis Settings (Optional caching - falls back to DB if disabled/unavailable)
|
||||||
|
REDIS_ENABLED=false
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
128
backend/app/Core/Cache.php
Normal file
128
backend/app/Core/Cache.php
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache helper class providing Redis-based caching with automatic database fallback.
|
||||||
|
*/
|
||||||
|
class Cache
|
||||||
|
{
|
||||||
|
private static ?\Redis $client = null;
|
||||||
|
private static bool $connectionAttempted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get initialized Redis client or null if unavailable/disabled.
|
||||||
|
*/
|
||||||
|
private static function getClient(): ?\Redis
|
||||||
|
{
|
||||||
|
if (self::$connectionAttempted) {
|
||||||
|
return self::$client;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$connectionAttempted = true;
|
||||||
|
|
||||||
|
if (!Env::get('REDIS_ENABLED', false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists('\Redis')) {
|
||||||
|
error_log('[Cache Warning] phpredis extension is not installed.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$redis = new \Redis();
|
||||||
|
$host = Env::get('REDIS_HOST', '127.0.0.1');
|
||||||
|
$port = (int)Env::get('REDIS_PORT', 6379);
|
||||||
|
$password = Env::get('REDIS_PASSWORD', null);
|
||||||
|
|
||||||
|
// Connect with 1.0s timeout to prevent request blocking
|
||||||
|
$connected = $redis->connect($host, $port, 1.0);
|
||||||
|
if (!$connected) {
|
||||||
|
error_log("[Cache Warning] Failed to connect to Redis at {$host}:{$port}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($password)) {
|
||||||
|
$redis->auth($password);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$client = $redis;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('[Cache Error] Redis connection error: ' . $e->getMessage());
|
||||||
|
self::$client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve cache item by key.
|
||||||
|
*/
|
||||||
|
public static function get(string $key)
|
||||||
|
{
|
||||||
|
$client = self::getClient();
|
||||||
|
if (!$client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$value = $client->get($key);
|
||||||
|
return $value !== false ? json_decode($value, true) : null;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('[Cache Error] Redis get failed: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set cache item by key with TTL.
|
||||||
|
*/
|
||||||
|
public static function set(string $key, $value, int $ttl = 3600): bool
|
||||||
|
{
|
||||||
|
$client = self::getClient();
|
||||||
|
if (!$client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $client->setex($key, $ttl, json_encode($value));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('[Cache Error] Redis set failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cache item by key.
|
||||||
|
*/
|
||||||
|
public static function delete(string $key): bool
|
||||||
|
{
|
||||||
|
$client = self::getClient();
|
||||||
|
if (!$client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $client->del($key) > 0;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('[Cache Error] Redis delete failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve cache item, or run callback and store results if cache misses.
|
||||||
|
*/
|
||||||
|
public static function remember(string $key, int $ttl, callable $callback)
|
||||||
|
{
|
||||||
|
$value = self::get($key);
|
||||||
|
if ($value !== null) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $callback();
|
||||||
|
self::set($key, $value, $ttl);
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
use App\Core\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CompanySubscription Model
|
* CompanySubscription Model
|
||||||
@@ -17,19 +18,23 @@ class CompanySubscription extends BaseModel
|
|||||||
*/
|
*/
|
||||||
public static function findActiveByCompany(int $companyId): ?array
|
public static function findActiveByCompany(int $companyId): ?array
|
||||||
{
|
{
|
||||||
$now = date('Y-m-d H:i:s');
|
$cacheKey = "company_subscription_{$companyId}";
|
||||||
return Database::selectOne(
|
|
||||||
"SELECT cs.*, sp.name as plan_name, sp.max_sessions, sp.max_requests,
|
return Cache::remember($cacheKey, 300, function () use ($companyId) {
|
||||||
sp.max_voice_requests, sp.max_ocr_requests, sp.features
|
$now = date('Y-m-d H:i:s');
|
||||||
FROM " . static::$table . " cs
|
return Database::selectOne(
|
||||||
JOIN subscription_plans sp ON cs.plan_id = sp.id
|
"SELECT cs.*, sp.name as plan_name, sp.max_sessions, sp.max_requests,
|
||||||
WHERE cs.company_id = ?
|
sp.max_voice_requests, sp.max_ocr_requests, sp.features
|
||||||
AND cs.status = 'active'
|
FROM " . static::$table . " cs
|
||||||
AND cs.starts_at <= ?
|
JOIN subscription_plans sp ON cs.plan_id = sp.id
|
||||||
AND cs.ends_at >= ?
|
WHERE cs.company_id = ?
|
||||||
LIMIT 1",
|
AND cs.status = 'active'
|
||||||
[$companyId, $now, $now]
|
AND cs.starts_at <= ?
|
||||||
);
|
AND cs.ends_at >= ?
|
||||||
|
LIMIT 1",
|
||||||
|
[$companyId, $now, $now]
|
||||||
|
) ?: [];
|
||||||
|
}) ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +52,9 @@ class CompanySubscription extends BaseModel
|
|||||||
[$companyId]
|
[$companyId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clear active subscription cache for the company
|
||||||
|
Cache::delete("company_subscription_{$companyId}");
|
||||||
|
|
||||||
return self::create([
|
return self::create([
|
||||||
'company_id' => $companyId,
|
'company_id' => $companyId,
|
||||||
'plan_id' => $planId,
|
'plan_id' => $planId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
use App\Core\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CompanySubscriptionUsage Model
|
* CompanySubscriptionUsage Model
|
||||||
@@ -20,7 +21,14 @@ class CompanySubscriptionUsage extends BaseModel
|
|||||||
$billingStart = date('Y-m-d', strtotime($activeSubscription['starts_at']));
|
$billingStart = date('Y-m-d', strtotime($activeSubscription['starts_at']));
|
||||||
$billingEnd = date('Y-m-d', strtotime($activeSubscription['ends_at']));
|
$billingEnd = date('Y-m-d', strtotime($activeSubscription['ends_at']));
|
||||||
|
|
||||||
// Check if usage record already exists
|
$cacheKey = "company_usage_{$companyId}_{$billingStart}_{$billingEnd}";
|
||||||
|
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if usage record already exists in database
|
||||||
$usage = Database::selectOne(
|
$usage = Database::selectOne(
|
||||||
"SELECT * FROM " . static::$table . "
|
"SELECT * FROM " . static::$table . "
|
||||||
WHERE company_id = ? AND billing_start = ? AND billing_end = ?
|
WHERE company_id = ? AND billing_start = ? AND billing_end = ?
|
||||||
@@ -39,7 +47,7 @@ class CompanySubscriptionUsage extends BaseModel
|
|||||||
'voice_count' => 0,
|
'voice_count' => 0,
|
||||||
'ocr_count' => 0
|
'ocr_count' => 0
|
||||||
]);
|
]);
|
||||||
return [
|
$usage = [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'company_id' => $companyId,
|
'company_id' => $companyId,
|
||||||
'billing_start' => $billingStart,
|
'billing_start' => $billingStart,
|
||||||
@@ -62,6 +70,9 @@ class CompanySubscriptionUsage extends BaseModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache usage data for 60 seconds (short TTL since usage changes frequently)
|
||||||
|
Cache::set($cacheKey, $usage, 60);
|
||||||
|
|
||||||
return $usage;
|
return $usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +95,24 @@ class CompanySubscriptionUsage extends BaseModel
|
|||||||
$column = 'ocr_count';
|
$column = 'ocr_count';
|
||||||
}
|
}
|
||||||
|
|
||||||
return Database::execute(
|
$success = Database::execute(
|
||||||
"UPDATE " . static::$table . "
|
"UPDATE " . static::$table . "
|
||||||
SET {$column} = {$column} + ?
|
SET {$column} = {$column} + ?
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
[$amount, $currentUsage['id']]
|
[$amount, $currentUsage['id']]
|
||||||
) > 0;
|
) > 0;
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
// Sync Redis cache with incremented values
|
||||||
|
$cacheKey = "company_usage_{$companyId}_{$currentUsage['billing_start']}_{$currentUsage['billing_end']}";
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached) {
|
||||||
|
$cached[$column] += $amount;
|
||||||
|
Cache::set($cacheKey, $cached, 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
75
backend/public/test_redis_cache.php
Normal file
75
backend/public/test_redis_cache.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('Access denied.');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
use App\Core\Cache;
|
||||||
|
use App\Core\Env;
|
||||||
|
|
||||||
|
echo "=== Starting Redis Cache Verification Tests ===\n\n";
|
||||||
|
|
||||||
|
// Test 1: Fallback when Redis is disabled
|
||||||
|
echo "1. Testing fallback with Redis disabled...\n";
|
||||||
|
putenv("REDIS_ENABLED=false");
|
||||||
|
$_ENV['REDIS_ENABLED'] = 'false';
|
||||||
|
$_SERVER['REDIS_ENABLED'] = 'false';
|
||||||
|
|
||||||
|
$res = Cache::set('test_key', 'test_val', 10);
|
||||||
|
echo " Set result: " . ($res ? "TRUE" : "FALSE (Fallback working)") . "\n";
|
||||||
|
|
||||||
|
$val = Cache::get('test_key');
|
||||||
|
echo " Get result: " . ($val === null ? "NULL (Fallback working)" : $val) . "\n";
|
||||||
|
|
||||||
|
$rememberVal = Cache::remember('remember_key', 10, function() {
|
||||||
|
return 'computed_fallback_val';
|
||||||
|
});
|
||||||
|
echo " Remember callback result: " . $rememberVal . " (Expected: computed_fallback_val)\n";
|
||||||
|
|
||||||
|
// Test 2: Testing with Redis enabled (if phpredis is available)
|
||||||
|
if (class_exists('\Redis')) {
|
||||||
|
echo "\n2. Redis extension found. Testing connection if enabled...\n";
|
||||||
|
putenv("REDIS_ENABLED=true");
|
||||||
|
$_ENV['REDIS_ENABLED'] = 'true';
|
||||||
|
$_SERVER['REDIS_ENABLED'] = 'true';
|
||||||
|
putenv("REDIS_HOST=127.0.0.1");
|
||||||
|
$_ENV['REDIS_HOST'] = '127.0.0.1';
|
||||||
|
$_SERVER['REDIS_HOST'] = '127.0.0.1';
|
||||||
|
putenv("REDIS_PORT=6379");
|
||||||
|
$_ENV['REDIS_PORT'] = '6379';
|
||||||
|
$_SERVER['REDIS_PORT'] = '6379';
|
||||||
|
|
||||||
|
// Reset static state using reflection to allow re-testing
|
||||||
|
try {
|
||||||
|
$ref = new ReflectionClass(Cache::class);
|
||||||
|
$connAttempted = $ref->getProperty('connectionAttempted');
|
||||||
|
$connAttempted->setAccessible(true);
|
||||||
|
$connAttempted->setValue(null, false);
|
||||||
|
|
||||||
|
$clientProp = $ref->getProperty('client');
|
||||||
|
$clientProp->setAccessible(true);
|
||||||
|
$clientProp->setValue(null, null);
|
||||||
|
|
||||||
|
$res = Cache::set('test_cache_key', ['hello' => 'world'], 30);
|
||||||
|
if ($res) {
|
||||||
|
echo " ✅ Successfully set value in Redis.\n";
|
||||||
|
$val = Cache::get('test_cache_key');
|
||||||
|
if (isset($val['hello']) && $val['hello'] === 'world') {
|
||||||
|
echo " ✅ Successfully retrieved value from Redis: " . json_encode($val) . "\n";
|
||||||
|
} else {
|
||||||
|
echo " ❌ Failed to get correct value from Redis.\n";
|
||||||
|
}
|
||||||
|
Cache::delete('test_cache_key');
|
||||||
|
} else {
|
||||||
|
echo " ℹ️ Redis server is not running or unreachable (Fallback triggered, test completed successfully).\n";
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
echo " Reflection or execution error: " . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "\n2. Redis extension (phpredis) not installed. Native database fallback remains active.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n=== Redis Caching Tests Completed ===\n";
|
||||||
Reference in New Issue
Block a user