Sách Giáo Khoa Việt Nam - sachgiaokhoa.org
CodeIgniter 4 website:
- Catalog 999 SGK (lớp 1-12) từ API, lọc theo lớp/môn/NXB, tìm kiếm
- Trang chủ grouped browse (bậc học -> lớp -> môn, ưu tiên môn quan trọng)
- Chi tiết sách: metadata đầy đủ, cover nghệ thuật /thumb/{W}x{H}/
- Đọc online qua gate captcha + countdown, PDF viewer pdf.js
- Download: captcha + token một lần + auto cache-bust version
- Tự đồng bộ catalog/cover khi API thay đổi (refresh:catalog)
- Legal: DMCA, miễn trừ trách nhiệm, điều khoản, bảo mật, liên hệ
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Client for the upstream SGK books API, with MongoDB catalog cache.
|
||||
*/
|
||||
class BookApi
|
||||
{
|
||||
/** Recursively remove a directory tree (contents + dir). */
|
||||
public static function rrmdir(string $dir): void
|
||||
{
|
||||
$items = glob($dir . '/*') ?: [];
|
||||
foreach ($items as $item) {
|
||||
is_dir($item) ? self::rrmdir($item) : @unlink($item);
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
private string $baseUrl;
|
||||
private string $token;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = rtrim((string) env('api.baseUrl', ''), '/');
|
||||
$this->token = (string) env('api.token', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch catalog, refresh MongoDB cache when stale. Returns array of books.
|
||||
*/
|
||||
public function catalog(bool $forceRefresh = false): array
|
||||
{
|
||||
$col = Mongo::col('catalog');
|
||||
$cacheTs = $col->findOne(['_id' => 'meta']);
|
||||
|
||||
$ttl = (int) env('api.cacheTtl', 21600);
|
||||
$fresh = $cacheTs !== null
|
||||
&& isset($cacheTs['updatedAt'])
|
||||
&& ((time() - (int) ((string) $cacheTs['updatedAt'] / 1000)) < $ttl)
|
||||
&& ($cacheTs['count'] ?? 0) > 0;
|
||||
|
||||
if (! $forceRefresh && $fresh) {
|
||||
return $this->sorted($this->queryAll($col));
|
||||
}
|
||||
|
||||
$data = $this->request('/api/books');
|
||||
$books = $data['books'] ?? [];
|
||||
if (empty($books)) {
|
||||
// fall back to stale cache rather than nothing
|
||||
$stale = $this->queryAll($col);
|
||||
if (! empty($stale)) {
|
||||
return $this->sorted($stale);
|
||||
}
|
||||
throw new Exception('Không tải được danh mục sách từ API nguồn.');
|
||||
}
|
||||
|
||||
$newSourceUpdatedAt = (string) ($data['updatedAt'] ?? '');
|
||||
$oldSourceUpdatedAt = (string) ($cacheTs['sourceUpdatedAt'] ?? '');
|
||||
$mediaVersion = (string) ($cacheTs['mediaVersion'] ?? '');
|
||||
|
||||
// upstream data changed → purge local cover cache + thumbs, bump version
|
||||
// so Cloudflare/browser see covers as new resources (fetched lazily again).
|
||||
if ($forceRefresh || $newSourceUpdatedAt !== $oldSourceUpdatedAt) {
|
||||
foreach (['covers', 'covers/thumbs'] as $rel) {
|
||||
$dir = WRITEPATH . 'cache/' . $rel;
|
||||
if (! is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
if ($rel === 'covers/thumbs') {
|
||||
// recursive delete (thumbnails live in {W}x{H} subdirs)
|
||||
self::rrmdir($dir);
|
||||
} else {
|
||||
foreach (glob($dir . '/*') ?: [] as $f) {
|
||||
if (is_file($f)) {
|
||||
@unlink($f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$mediaVersion = (string) time();
|
||||
}
|
||||
|
||||
$col->deleteMany(['bookId' => ['$exists' => true]]);
|
||||
$docs = [];
|
||||
foreach ($books as $b) {
|
||||
$docs[] = $this->toDoc($b);
|
||||
}
|
||||
$col->insertMany($docs);
|
||||
$col->updateOne(
|
||||
['_id' => 'meta'],
|
||||
['$set' => [
|
||||
'updatedAt' => new \MongoDB\BSON\UTCDateTime(),
|
||||
'count' => count($docs),
|
||||
'sourceUpdatedAt' => $newSourceUpdatedAt,
|
||||
'mediaVersion' => $mediaVersion,
|
||||
]],
|
||||
['upsert' => true]
|
||||
);
|
||||
|
||||
return $this->sorted($this->queryAll($col));
|
||||
}
|
||||
|
||||
private function queryAll(\MongoDB\Collection $col): array
|
||||
{
|
||||
return $col->find(['bookId' => ['$exists' => true]], ['projection' => ['_id' => 0]])->toArray();
|
||||
}
|
||||
|
||||
private function sorted(array $books): array
|
||||
{
|
||||
usort($books, function ($a, $b) {
|
||||
$ga = (int) ($a['grade'] ?? 0);
|
||||
$gb = (int) ($b['grade'] ?? 0);
|
||||
if ($ga !== $gb) {
|
||||
return $ga <=> $gb;
|
||||
}
|
||||
return strcmp((string) ($a['subject'] ?? ''), (string) ($b['subject'] ?? ''))
|
||||
?: strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? ''));
|
||||
});
|
||||
return $books;
|
||||
}
|
||||
|
||||
public function book(string $id): ?array
|
||||
{
|
||||
foreach ($this->catalog() as $b) {
|
||||
if (($b['id'] ?? null) === $id) {
|
||||
return $this->docToArray($b);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a resource (pdf/cover) from upstream to the client with auth injected.
|
||||
*/
|
||||
public function stream(string $endpoint, string $downloadName = null): void
|
||||
{
|
||||
$url = $this->baseUrl . $endpoint;
|
||||
$ch = curl_init($url);
|
||||
|
||||
// forward range headers for pdf.js partial requests
|
||||
$range = $_SERVER['HTTP_RANGE'] ?? null;
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => array_filter([
|
||||
'Authorization: Bearer ' . $this->token,
|
||||
$range ? 'Range: ' . $range : null,
|
||||
]),
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 0,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
|
||||
$trim = trim($line);
|
||||
foreach (['content-type', 'content-length', 'accept-ranges', 'content-range', 'content-disposition', 'cache-control'] as $h) {
|
||||
if (stripos($trim, $h) === 0) {
|
||||
if ($h === 'content-disposition') {
|
||||
return strlen($line); // we set our own
|
||||
}
|
||||
header($trim);
|
||||
}
|
||||
}
|
||||
return strlen($line);
|
||||
},
|
||||
CURLOPT_WRITEFUNCTION => function ($ch, $data) {
|
||||
echo $data;
|
||||
if (ob_get_level()) {
|
||||
ob_flush();
|
||||
}
|
||||
flush();
|
||||
return strlen($data);
|
||||
},
|
||||
]);
|
||||
|
||||
if ($downloadName) {
|
||||
header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"');
|
||||
}
|
||||
|
||||
$ok = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (! $ok || $code >= 400) {
|
||||
http_response_code($ok ? $code : 502);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a resource from upstream to a local file. Returns bytes written or false.
|
||||
*/
|
||||
public function fetchToFile(string $endpoint, string $dest): int|false
|
||||
{
|
||||
$ch = curl_init($this->baseUrl . $endpoint);
|
||||
$fp = fopen($dest, 'wb');
|
||||
if (! $fp) {
|
||||
return false;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token],
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_FILE => $fp,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$size = (int) curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD);
|
||||
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
curl_close($ch);
|
||||
fclose($fp);
|
||||
|
||||
if ($code !== 200 || $size === 0 || ! str_starts_with($type, 'image/')) {
|
||||
return false;
|
||||
}
|
||||
return $size;
|
||||
}
|
||||
|
||||
private function request(string $path): array
|
||||
{
|
||||
$ch = curl_init($this->baseUrl . $path);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token],
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code !== 200 || ! is_string($body)) {
|
||||
throw new Exception("API lỗi HTTP {$code}");
|
||||
}
|
||||
|
||||
return json_decode($body, true) ?? throw new Exception('API trả về JSON không hợp lệ.');
|
||||
}
|
||||
|
||||
private function toDoc(array $b): array
|
||||
{
|
||||
$meta = $b['meta'] ?? [];
|
||||
return [
|
||||
'bookId' => $b['id'],
|
||||
'id' => $b['id'],
|
||||
'title' => $b['title'] ?? '',
|
||||
'grade' => (string) ($b['grade'] ?? ''),
|
||||
'publisher' => $b['publisher'] ?? '',
|
||||
'subject' => $b['subject'] ?? '',
|
||||
'localFile' => $b['localFile'] ?? null,
|
||||
'pdfEndpoint' => $b['pdfEndpoint'] ?? null,
|
||||
'coverEndpoint' => $b['coverEndpoint'] ?? null,
|
||||
'meta' => [
|
||||
'authors' => $meta['authors'] ?? null,
|
||||
'publisherFull' => $meta['publisherFull'] ?? null,
|
||||
'pages' => $meta['pages'] ?? null,
|
||||
'sizeMB' => $meta['sizeMB'] ?? null,
|
||||
'isbn' => $meta['isbn'] ?? null,
|
||||
'price' => $meta['price'] ?? null,
|
||||
'pdfVersion' => $meta['pdfVersion'] ?? null,
|
||||
'creationDate' => $meta['creationDate'] ?? null,
|
||||
'cover' => $meta['cover'] ?? null,
|
||||
'coverSource' => $meta['coverSource'] ?? null,
|
||||
],
|
||||
'search' => mb_strtolower(($b['title'] ?? '') . ' ' . ($b['subject'] ?? '') . ' ' . ($b['publisher'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function docToArray(array|object $doc): array
|
||||
{
|
||||
$doc = (array) $doc;
|
||||
if (isset($doc['meta']) && is_object($doc['meta'])) {
|
||||
$doc['meta'] = (array) $doc['meta'];
|
||||
}
|
||||
unset($doc['_id']);
|
||||
return $doc;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user