feat: 初始化随机图片服务项目
添加核心功能模块包括: - 服务入口文件 app.js - 图片服务、缓存管理、页数检测等核心服务 - 日志工具和定时任务调度器 - 项目配置文件和文档
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 服务端口
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# 缓存配置
|
||||||
|
CACHE_DIR=./cache
|
||||||
|
MAX_CACHE_SIZE_MB=1024
|
||||||
|
|
||||||
|
# API 配置
|
||||||
|
SOMEACG_API_BASE=https://someacg.vercel.app/api
|
||||||
|
SOMEACG_CDN_BASE=https://cdn.someacg.top/graph/origin
|
||||||
|
|
||||||
|
# 页数检测配置
|
||||||
|
MAX_PAGE_DETECTION_TIMEOUT=30000
|
||||||
|
MAX_RETRY_ATTEMPTS=3
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FILE=./logs/app.log
|
||||||
144
.gitignore
vendored
Normal file
144
.gitignore
vendored
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Cache directories
|
||||||
|
cache/
|
||||||
|
*.cache
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
176
README.md
Normal file
176
README.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# SomeACG 随机图片服务
|
||||||
|
|
||||||
|
基于 someacg 提供的图片列表 API,开发的高性能随机图片服务。支持自动页数检测、智能缓存管理和定时更新功能。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 🚀 **高性能缓存**: 预加载全量图片数据,100% 缓存命中率
|
||||||
|
- 🔄 **自动更新**: 每日凌晨自动检测最大页数并更新缓存
|
||||||
|
- 📱 **多端支持**: 支持桌面端和移动端不同尺寸的图片
|
||||||
|
- 🛡️ **错误处理**: 完善的重试机制和降级策略
|
||||||
|
- 📊 **状态监控**: 提供详细的服务状态和缓存统计信息
|
||||||
|
- 🗂️ **智能管理**: 自动清理过期缓存,支持配置缓存大小限制
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置环境变量
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env` 并根据需要修改配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
主要配置项:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 服务端口
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# 缓存配置
|
||||||
|
CACHE_DIR=./cache
|
||||||
|
MAX_CACHE_SIZE_MB=1024
|
||||||
|
|
||||||
|
# API 配置
|
||||||
|
SOMEACG_API_BASE=https://someacg.vercel.app/api
|
||||||
|
SOMEACG_CDN_BASE=https://cdn.someacg.top/graph/origin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生产环境
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 开发环境
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 获取随机图片
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/random
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 | 可选值 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| size | string/number | 图片尺寸类型 | `1`/`mobile`(移动端), `2`/`desktop`(桌面端),不传则随机 |
|
||||||
|
| type | string | 返回类型 | `url`(默认,返回图片信息), `image`(直接返回图片数据) |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_id": "68889a7645c10a92ad0e8e94",
|
||||||
|
"quality": true,
|
||||||
|
"title": "「綺麗。」",
|
||||||
|
"desc": "",
|
||||||
|
"file_name": "132641568_p0.jpg",
|
||||||
|
"size": {
|
||||||
|
"width": 3024,
|
||||||
|
"height": 4536,
|
||||||
|
"_id": "6894f7e091dd1a5cfedcd845"
|
||||||
|
},
|
||||||
|
"url": "https://cdn.someacg.top/graph/origin/132641568_p0.jpg",
|
||||||
|
"cached": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务状态
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/status
|
||||||
|
```
|
||||||
|
|
||||||
|
返回服务运行状态、内存使用情况、缓存统计等信息。
|
||||||
|
|
||||||
|
### 首页
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /
|
||||||
|
```
|
||||||
|
|
||||||
|
返回服务基本信息。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 页数检测
|
||||||
|
|
||||||
|
- 启动时自动检测 someacg API 的最大页数
|
||||||
|
- 使用二分法高效探测页数范围
|
||||||
|
- 每日凌晨 2:00 自动更新页数范围
|
||||||
|
- 支持桌面端和移动端分别检测
|
||||||
|
|
||||||
|
### 缓存策略
|
||||||
|
|
||||||
|
1. **预加载**: 启动时和每日更新后,全量拉取所有页面的图片列表
|
||||||
|
2. **随机排序**: 将获取的图片列表随机打乱,确保随机性
|
||||||
|
3. **本地缓存**: 按顺序下载并缓存图片到本地存储
|
||||||
|
4. **智能清理**: 达到缓存大小限制时,自动清理最早访问的图片
|
||||||
|
5. **100% 命中**: 所有用户请求都从本地缓存获取,无需访问外部 CDN
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
- **网络重试**: API 请求失败时自动重试 3 次
|
||||||
|
- **降级策略**: 页数检测失败时使用前一天的缓存数据
|
||||||
|
- **跳过机制**: 缓存中图片丢失时自动跳到下一张
|
||||||
|
- **限流处理**: 遇到 429 错误时自动延长等待时间
|
||||||
|
|
||||||
|
### 定时任务
|
||||||
|
|
||||||
|
- **每日更新**: 凌晨 2:00 自动检测页数并重新预加载缓存
|
||||||
|
- **健康检查**: 每小时检查服务状态和内存使用情况
|
||||||
|
- **日志清理**: 每 6 小时执行一次日志清理和垃圾回收
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app.js # 主应用入口
|
||||||
|
├── services/
|
||||||
|
│ ├── CacheManager.js # 缓存管理服务
|
||||||
|
│ ├── PageDetector.js # 页数检测服务
|
||||||
|
│ ├── ImageService.js # 图片服务(核心业务逻辑)
|
||||||
|
│ └── scheduler.js # 定时任务调度器
|
||||||
|
└── utils/
|
||||||
|
└── logger.js # 日志工具
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
| 配置项 | 默认值 | 说明 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| PORT | 3000 | 服务端口 |
|
||||||
|
| CACHE_DIR | ./cache | 缓存目录 |
|
||||||
|
| MAX_CACHE_SIZE_MB | 1024 | 最大缓存大小(MB) |
|
||||||
|
| MAX_PAGE_DETECTION_TIMEOUT | 30000 | 页数检测超时时间(毫秒) |
|
||||||
|
| MAX_RETRY_ATTEMPTS | 3 | 最大重试次数 |
|
||||||
|
| LOG_LEVEL | info | 日志级别 |
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
- 使用本地缓存避免频繁访问外部 CDN
|
||||||
|
- 批量并发下载图片,提高预加载效率
|
||||||
|
- 智能缓存清理策略,避免磁盘空间不足
|
||||||
|
- 内存使用监控和垃圾回收机制
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 首次启动需要较长时间进行预加载,请耐心等待
|
||||||
|
2. 确保有足够的磁盘空间用于图片缓存
|
||||||
|
3. someacg CDN 对单 IP 有并发限制,预加载过程中可能遇到 429 错误
|
||||||
|
4. 建议在服务器环境中运行,以获得更好的网络稳定性
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
5155
package-lock.json
generated
Normal file
5155
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "someacg-randompic",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "基于 someacg 提供的图片列表 API 的高性能随机图片服务",
|
||||||
|
"main": "src/app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/app.js",
|
||||||
|
"dev": "nodemon src/app.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"keywords": ["someacg", "random", "image", "api"],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"fs-extra": "^11.1.1",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1",
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/app.js
Normal file
119
src/app.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const CacheManager = require('./services/CacheManager');
|
||||||
|
const PageDetector = require('./services/PageDetector');
|
||||||
|
const ImageService = require('./services/ImageService');
|
||||||
|
const scheduler = require('./services/scheduler');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
// 全局服务实例
|
||||||
|
let cacheManager;
|
||||||
|
let pageDetector;
|
||||||
|
let imageService;
|
||||||
|
|
||||||
|
// 初始化服务
|
||||||
|
async function initializeServices() {
|
||||||
|
try {
|
||||||
|
logger.info('正在初始化服务...');
|
||||||
|
|
||||||
|
cacheManager = new CacheManager();
|
||||||
|
pageDetector = new PageDetector();
|
||||||
|
imageService = new ImageService(cacheManager, pageDetector);
|
||||||
|
|
||||||
|
// 初始化缓存目录
|
||||||
|
await cacheManager.init();
|
||||||
|
|
||||||
|
// 检测页数范围
|
||||||
|
await pageDetector.detectMaxPages();
|
||||||
|
|
||||||
|
// 预加载缓存
|
||||||
|
await imageService.preloadCache();
|
||||||
|
|
||||||
|
// 启动定时任务
|
||||||
|
scheduler.start(pageDetector, imageService);
|
||||||
|
|
||||||
|
logger.info('服务初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('服务初始化失败:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
message: 'SomeACG 随机图片服务',
|
||||||
|
version: '1.0.0',
|
||||||
|
status: 'running'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 随机图片接口
|
||||||
|
app.get('/api/random', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { size, type = 'url' } = req.query;
|
||||||
|
const result = await imageService.getRandomImage(size, type);
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
// 直接返回图片
|
||||||
|
res.set('Content-Type', 'image/jpeg');
|
||||||
|
res.send(result.buffer);
|
||||||
|
} else {
|
||||||
|
// 返回图片信息
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取随机图片失败:', error);
|
||||||
|
res.status(500).json({ error: '服务暂时不可用' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 状态接口
|
||||||
|
app.get('/api/status', (req, res) => {
|
||||||
|
const status = {
|
||||||
|
uptime: process.uptime(),
|
||||||
|
memory: process.memoryUsage(),
|
||||||
|
maxPages: pageDetector ? pageDetector.getMaxPages() : null,
|
||||||
|
cacheStats: cacheManager ? cacheManager.getStats() : null
|
||||||
|
};
|
||||||
|
res.json(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 错误处理中间件
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger.error('未处理的错误:', err);
|
||||||
|
res.status(500).json({ error: '内部服务器错误' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 处理
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: '接口不存在' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
logger.info(`服务器启动在端口 ${PORT}`);
|
||||||
|
await initializeServices();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 优雅关闭
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logger.info('收到 SIGTERM 信号,正在关闭服务器...');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
logger.info('收到 SIGINT 信号,正在关闭服务器...');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
261
src/services/CacheManager.js
Normal file
261
src/services/CacheManager.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
const fs = require('fs-extra');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class CacheManager {
|
||||||
|
constructor() {
|
||||||
|
this.cacheDir = process.env.CACHE_DIR || './cache';
|
||||||
|
this.maxCacheSizeMB = parseInt(process.env.MAX_CACHE_SIZE_MB) || 1024;
|
||||||
|
this.maxCacheSize = this.maxCacheSizeMB * 1024 * 1024; // 转换为字节
|
||||||
|
|
||||||
|
// 缓存统计
|
||||||
|
this.stats = {
|
||||||
|
totalFiles: 0,
|
||||||
|
totalSize: 0,
|
||||||
|
hitCount: 0,
|
||||||
|
missCount: 0,
|
||||||
|
lastCleanup: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件访问时间记录
|
||||||
|
this.accessTimes = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化缓存目录
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
// 确保缓存目录存在
|
||||||
|
await fs.ensureDir(this.cacheDir);
|
||||||
|
|
||||||
|
// 扫描现有缓存文件
|
||||||
|
await this.scanCacheDir();
|
||||||
|
|
||||||
|
logger.info(`缓存管理器初始化完成,缓存目录: ${this.cacheDir}`);
|
||||||
|
logger.info(`当前缓存统计:`, this.stats);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('缓存管理器初始化失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描缓存目录,统计现有文件
|
||||||
|
*/
|
||||||
|
async scanCacheDir() {
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(this.cacheDir);
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(this.cacheDir, file);
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
|
||||||
|
if (stat.isFile()) {
|
||||||
|
totalSize += stat.size;
|
||||||
|
this.accessTimes.set(file, stat.mtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stats.totalFiles = files.length;
|
||||||
|
this.stats.totalSize = totalSize;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('扫描缓存目录失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成缓存文件名
|
||||||
|
* @param {string} fileName - 原始文件名
|
||||||
|
* @returns {string} 缓存文件名
|
||||||
|
*/
|
||||||
|
generateCacheFileName(fileName) {
|
||||||
|
const hash = crypto.createHash('md5').update(fileName).digest('hex');
|
||||||
|
const ext = path.extname(fileName);
|
||||||
|
return `${hash}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存文件路径
|
||||||
|
* @param {string} fileName - 原始文件名
|
||||||
|
* @returns {string} 缓存文件完整路径
|
||||||
|
*/
|
||||||
|
getCacheFilePath(fileName) {
|
||||||
|
const cacheFileName = this.generateCacheFileName(fileName);
|
||||||
|
return path.join(this.cacheDir, cacheFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否已缓存
|
||||||
|
* @param {string} fileName - 原始文件名
|
||||||
|
* @returns {Promise<boolean>} 是否已缓存
|
||||||
|
*/
|
||||||
|
async isCached(fileName) {
|
||||||
|
const cacheFilePath = this.getCacheFilePath(fileName);
|
||||||
|
return await fs.pathExists(cacheFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的图片
|
||||||
|
* @param {string} fileName - 原始文件名
|
||||||
|
* @returns {Promise<Buffer|null>} 图片数据或 null
|
||||||
|
*/
|
||||||
|
async getCachedImage(fileName) {
|
||||||
|
try {
|
||||||
|
const cacheFilePath = this.getCacheFilePath(fileName);
|
||||||
|
|
||||||
|
if (await fs.pathExists(cacheFilePath)) {
|
||||||
|
// 更新访问时间
|
||||||
|
const cacheFileName = this.generateCacheFileName(fileName);
|
||||||
|
this.accessTimes.set(cacheFileName, new Date());
|
||||||
|
|
||||||
|
this.stats.hitCount++;
|
||||||
|
return await fs.readFile(cacheFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stats.missCount++;
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`获取缓存图片失败 ${fileName}:`, error);
|
||||||
|
this.stats.missCount++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存图片
|
||||||
|
* @param {string} fileName - 原始文件名
|
||||||
|
* @param {Buffer} imageBuffer - 图片数据
|
||||||
|
* @returns {Promise<boolean>} 是否缓存成功
|
||||||
|
*/
|
||||||
|
async cacheImage(fileName, imageBuffer) {
|
||||||
|
try {
|
||||||
|
const cacheFilePath = this.getCacheFilePath(fileName);
|
||||||
|
const cacheFileName = this.generateCacheFileName(fileName);
|
||||||
|
|
||||||
|
// 检查缓存空间
|
||||||
|
await this.ensureCacheSpace(imageBuffer.length);
|
||||||
|
|
||||||
|
// 写入缓存文件
|
||||||
|
await fs.writeFile(cacheFilePath, imageBuffer);
|
||||||
|
|
||||||
|
// 更新统计信息
|
||||||
|
this.stats.totalFiles++;
|
||||||
|
this.stats.totalSize += imageBuffer.length;
|
||||||
|
this.accessTimes.set(cacheFileName, new Date());
|
||||||
|
|
||||||
|
logger.debug(`图片已缓存: ${fileName} (${imageBuffer.length} bytes)`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`缓存图片失败 ${fileName}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保有足够的缓存空间
|
||||||
|
* @param {number} requiredSize - 需要的空间大小(字节)
|
||||||
|
*/
|
||||||
|
async ensureCacheSpace(requiredSize) {
|
||||||
|
if (this.stats.totalSize + requiredSize <= this.maxCacheSize) {
|
||||||
|
return; // 空间足够
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('缓存空间不足,开始清理...');
|
||||||
|
|
||||||
|
// 获取所有缓存文件,按访问时间排序
|
||||||
|
const files = Array.from(this.accessTimes.entries())
|
||||||
|
.sort((a, b) => a[1] - b[1]); // 按访问时间升序排序
|
||||||
|
|
||||||
|
let freedSpace = 0;
|
||||||
|
const targetFreeSpace = requiredSize + (this.maxCacheSize * 0.1); // 额外释放10%空间
|
||||||
|
|
||||||
|
for (const [fileName, accessTime] of files) {
|
||||||
|
if (freedSpace >= targetFreeSpace) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(this.cacheDir, fileName);
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
|
||||||
|
await fs.remove(filePath);
|
||||||
|
|
||||||
|
freedSpace += stat.size;
|
||||||
|
this.stats.totalSize -= stat.size;
|
||||||
|
this.stats.totalFiles--;
|
||||||
|
this.accessTimes.delete(fileName);
|
||||||
|
|
||||||
|
logger.debug(`清理缓存文件: ${fileName} (${stat.size} bytes)`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`清理缓存文件失败 ${fileName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stats.lastCleanup = new Date();
|
||||||
|
logger.info(`缓存清理完成,释放空间: ${freedSpace} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有缓存
|
||||||
|
*/
|
||||||
|
async clearCache() {
|
||||||
|
try {
|
||||||
|
await fs.emptyDir(this.cacheDir);
|
||||||
|
|
||||||
|
this.stats.totalFiles = 0;
|
||||||
|
this.stats.totalSize = 0;
|
||||||
|
this.accessTimes.clear();
|
||||||
|
|
||||||
|
logger.info('缓存已清空');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('清空缓存失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存统计信息
|
||||||
|
* @returns {Object} 统计信息
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
...this.stats,
|
||||||
|
maxCacheSizeMB: this.maxCacheSizeMB,
|
||||||
|
currentSizeMB: Math.round(this.stats.totalSize / 1024 / 1024 * 100) / 100,
|
||||||
|
hitRate: this.stats.hitCount + this.stats.missCount > 0
|
||||||
|
? Math.round(this.stats.hitCount / (this.stats.hitCount + this.stats.missCount) * 100) / 100
|
||||||
|
: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定的缓存文件
|
||||||
|
* @param {string} fileName - 原始文件名
|
||||||
|
* @returns {Promise<boolean>} 是否删除成功
|
||||||
|
*/
|
||||||
|
async removeCachedImage(fileName) {
|
||||||
|
try {
|
||||||
|
const cacheFilePath = this.getCacheFilePath(fileName);
|
||||||
|
const cacheFileName = this.generateCacheFileName(fileName);
|
||||||
|
|
||||||
|
if (await fs.pathExists(cacheFilePath)) {
|
||||||
|
const stat = await fs.stat(cacheFilePath);
|
||||||
|
await fs.remove(cacheFilePath);
|
||||||
|
|
||||||
|
this.stats.totalFiles--;
|
||||||
|
this.stats.totalSize -= stat.size;
|
||||||
|
this.accessTimes.delete(cacheFileName);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`删除缓存文件失败 ${fileName}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CacheManager;
|
||||||
406
src/services/ImageService.js
Normal file
406
src/services/ImageService.js
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class ImageService {
|
||||||
|
constructor(cacheManager, pageDetector) {
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
|
this.pageDetector = pageDetector;
|
||||||
|
this.apiBase = process.env.SOMEACG_API_BASE || 'https://someacg.vercel.app/api';
|
||||||
|
this.cdnBase = process.env.SOMEACG_CDN_BASE || 'https://cdn.someacg.top/graph/origin';
|
||||||
|
this.maxRetries = parseInt(process.env.MAX_RETRY_ATTEMPTS) || 3;
|
||||||
|
|
||||||
|
// 预加载的图片队列
|
||||||
|
this.imageQueue = {
|
||||||
|
desktop: [], // size=2
|
||||||
|
mobile: [] // size=1
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当前队列索引
|
||||||
|
this.queueIndex = {
|
||||||
|
desktop: 0,
|
||||||
|
mobile: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预加载状态
|
||||||
|
this.preloadStatus = {
|
||||||
|
desktop: { total: 0, loaded: 0, inProgress: false },
|
||||||
|
mobile: { total: 0, loaded: 0, inProgress: false }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取随机图片
|
||||||
|
* @param {string|number} size - 尺寸类型,可以是 '1', '2', 'mobile', 'desktop' 或 undefined
|
||||||
|
* @param {string} type - 返回类型,'url' 或 'image'
|
||||||
|
* @returns {Promise<Object>} 图片信息或图片数据
|
||||||
|
*/
|
||||||
|
async getRandomImage(size, type = 'url') {
|
||||||
|
try {
|
||||||
|
// 标准化 size 参数
|
||||||
|
const normalizedSize = this.normalizeSize(size);
|
||||||
|
const sizeType = normalizedSize === 1 ? 'mobile' : 'desktop';
|
||||||
|
|
||||||
|
// 从预加载队列中获取图片
|
||||||
|
const imageInfo = this.getNextImageFromQueue(sizeType);
|
||||||
|
|
||||||
|
if (!imageInfo) {
|
||||||
|
// 队列为空,尝试实时获取
|
||||||
|
logger.warn(`${sizeType} 队列为空,尝试实时获取图片`);
|
||||||
|
return await this.getRealTimeRandomImage(normalizedSize, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
// 返回图片数据
|
||||||
|
const imageBuffer = await this.getImageBuffer(imageInfo.file_name);
|
||||||
|
return {
|
||||||
|
...imageInfo,
|
||||||
|
buffer: imageBuffer
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 返回图片URL和信息
|
||||||
|
return {
|
||||||
|
...imageInfo,
|
||||||
|
url: `${this.cdnBase}/${imageInfo.file_name}`,
|
||||||
|
cached: await this.cacheManager.isCached(imageInfo.file_name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取随机图片失败:', error);
|
||||||
|
throw new Error('获取图片失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化尺寸参数
|
||||||
|
* @param {string|number} size - 输入的尺寸参数
|
||||||
|
* @returns {number} 标准化后的尺寸(1 或 2)
|
||||||
|
*/
|
||||||
|
normalizeSize(size) {
|
||||||
|
if (size === 'mobile' || size === '1' || size === 1) {
|
||||||
|
return 1;
|
||||||
|
} else if (size === 'desktop' || size === '2' || size === 2) {
|
||||||
|
return 2;
|
||||||
|
} else {
|
||||||
|
// 随机选择
|
||||||
|
return Math.random() < 0.5 ? 1 : 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从队列中获取下一张图片
|
||||||
|
* @param {string} sizeType - 'mobile' 或 'desktop'
|
||||||
|
* @returns {Object|null} 图片信息或 null
|
||||||
|
*/
|
||||||
|
getNextImageFromQueue(sizeType) {
|
||||||
|
const queue = this.imageQueue[sizeType];
|
||||||
|
const currentIndex = this.queueIndex[sizeType];
|
||||||
|
|
||||||
|
if (queue.length === 0 || currentIndex >= queue.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageInfo = queue[currentIndex];
|
||||||
|
this.queueIndex[sizeType] = (currentIndex + 1) % queue.length;
|
||||||
|
|
||||||
|
return imageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实时获取随机图片(当队列为空时使用)
|
||||||
|
* @param {number} size - 尺寸类型
|
||||||
|
* @param {string} type - 返回类型
|
||||||
|
* @returns {Promise<Object>} 图片信息
|
||||||
|
*/
|
||||||
|
async getRealTimeRandomImage(size, type) {
|
||||||
|
const randomPage = this.pageDetector.getRandomPage(size);
|
||||||
|
const imageList = await this.fetchImageList(randomPage, size);
|
||||||
|
|
||||||
|
if (!imageList || imageList.length === 0) {
|
||||||
|
throw new Error('未找到可用图片');
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomIndex = Math.floor(Math.random() * imageList.length);
|
||||||
|
const imageInfo = imageList[randomIndex];
|
||||||
|
|
||||||
|
if (type === 'image') {
|
||||||
|
const imageBuffer = await this.downloadImage(imageInfo.file_name);
|
||||||
|
return {
|
||||||
|
...imageInfo,
|
||||||
|
buffer: imageBuffer
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...imageInfo,
|
||||||
|
url: `${this.cdnBase}/${imageInfo.file_name}`,
|
||||||
|
cached: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预加载缓存
|
||||||
|
*/
|
||||||
|
async preloadCache() {
|
||||||
|
logger.info('开始预加载图片缓存...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 并行预加载两种尺寸的图片
|
||||||
|
await Promise.all([
|
||||||
|
this.preloadImagesForSize(2, 'desktop'),
|
||||||
|
this.preloadImagesForSize(1, 'mobile')
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info('图片缓存预加载完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('预加载缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为指定尺寸预加载图片
|
||||||
|
* @param {number} size - 尺寸类型
|
||||||
|
* @param {string} sizeType - 'mobile' 或 'desktop'
|
||||||
|
*/
|
||||||
|
async preloadImagesForSize(size, sizeType) {
|
||||||
|
if (this.preloadStatus[sizeType].inProgress) {
|
||||||
|
logger.warn(`${sizeType} 预加载已在进行中`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.preloadStatus[sizeType].inProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`开始预加载 ${sizeType} 图片 (size=${size})`);
|
||||||
|
|
||||||
|
// 获取所有页面的图片列表
|
||||||
|
const allImages = await this.fetchAllImages(size);
|
||||||
|
|
||||||
|
// 随机打乱顺序
|
||||||
|
this.shuffleArray(allImages);
|
||||||
|
|
||||||
|
// 更新队列
|
||||||
|
this.imageQueue[sizeType] = allImages;
|
||||||
|
this.queueIndex[sizeType] = 0;
|
||||||
|
|
||||||
|
// 更新预加载状态
|
||||||
|
this.preloadStatus[sizeType].total = allImages.length;
|
||||||
|
this.preloadStatus[sizeType].loaded = 0;
|
||||||
|
|
||||||
|
logger.info(`${sizeType} 图片列表获取完成,共 ${allImages.length} 张图片`);
|
||||||
|
|
||||||
|
// 开始下载和缓存图片
|
||||||
|
await this.downloadAndCacheImages(allImages, sizeType);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`预加载 ${sizeType} 图片失败:`, error);
|
||||||
|
} finally {
|
||||||
|
this.preloadStatus[sizeType].inProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有页面的图片
|
||||||
|
* @param {number} size - 尺寸类型
|
||||||
|
* @returns {Promise<Array>} 所有图片信息
|
||||||
|
*/
|
||||||
|
async fetchAllImages(size) {
|
||||||
|
const maxPage = this.pageDetector.getMaxPageForSize(size);
|
||||||
|
const allImages = [];
|
||||||
|
|
||||||
|
logger.info(`开始获取 size=${size} 的所有图片,共 ${maxPage} 页`);
|
||||||
|
|
||||||
|
// 分批获取,避免过多并发请求
|
||||||
|
const batchSize = 10;
|
||||||
|
for (let i = 1; i <= maxPage; i += batchSize) {
|
||||||
|
const batch = [];
|
||||||
|
|
||||||
|
for (let page = i; page < i + batchSize && page <= maxPage; page++) {
|
||||||
|
batch.push(this.fetchImageList(page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(batch);
|
||||||
|
|
||||||
|
for (const imageList of results) {
|
||||||
|
if (imageList && imageList.length > 0) {
|
||||||
|
allImages.push(...imageList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`已获取第 ${i}-${Math.min(i + batchSize - 1, maxPage)} 页,当前总数: ${allImages.length}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`获取第 ${i}-${Math.min(i + batchSize - 1, maxPage)} 页时出错:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定页面的图片列表
|
||||||
|
* @param {number} page - 页码
|
||||||
|
* @param {number} size - 尺寸类型
|
||||||
|
* @returns {Promise<Array>} 图片列表
|
||||||
|
*/
|
||||||
|
async fetchImageList(page, size) {
|
||||||
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.apiBase}/list`, {
|
||||||
|
params: { page, size },
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200 && Array.isArray(response.data)) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === this.maxRetries) {
|
||||||
|
logger.warn(`获取图片列表失败 page=${page}, size=${size}:`, error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sleep(1000 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载并缓存图片
|
||||||
|
* @param {Array} images - 图片信息数组
|
||||||
|
* @param {string} sizeType - 尺寸类型
|
||||||
|
*/
|
||||||
|
async downloadAndCacheImages(images, sizeType) {
|
||||||
|
logger.info(`开始下载和缓存 ${sizeType} 图片,共 ${images.length} 张`);
|
||||||
|
|
||||||
|
const concurrency = 5; // 并发下载数量
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < images.length; i += concurrency) {
|
||||||
|
const batch = images.slice(i, i + concurrency);
|
||||||
|
|
||||||
|
await Promise.all(batch.map(async (image) => {
|
||||||
|
try {
|
||||||
|
// 检查是否已缓存
|
||||||
|
if (await this.cacheManager.isCached(image.file_name)) {
|
||||||
|
completed++;
|
||||||
|
this.preloadStatus[sizeType].loaded = completed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载图片
|
||||||
|
const imageBuffer = await this.downloadImage(image.file_name);
|
||||||
|
|
||||||
|
// 缓存图片
|
||||||
|
await this.cacheManager.cacheImage(image.file_name, imageBuffer);
|
||||||
|
|
||||||
|
completed++;
|
||||||
|
this.preloadStatus[sizeType].loaded = completed;
|
||||||
|
|
||||||
|
if (completed % 100 === 0) {
|
||||||
|
logger.info(`${sizeType} 已缓存 ${completed}/${images.length} 张图片`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`下载图片失败 ${image.file_name}:`, error.message);
|
||||||
|
completed++;
|
||||||
|
this.preloadStatus[sizeType].loaded = completed;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`${sizeType} 图片缓存完成,共处理 ${completed} 张图片`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载图片
|
||||||
|
* @param {string} fileName - 文件名
|
||||||
|
* @returns {Promise<Buffer>} 图片数据
|
||||||
|
*/
|
||||||
|
async downloadImage(fileName) {
|
||||||
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.cdnBase}/${fileName}`, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.from(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
// 遇到限流,等待更长时间
|
||||||
|
await this.sleep(5000 * attempt);
|
||||||
|
} else if (attempt === this.maxRetries) {
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
await this.sleep(1000 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图片数据(优先从缓存)
|
||||||
|
* @param {string} fileName - 文件名
|
||||||
|
* @returns {Promise<Buffer>} 图片数据
|
||||||
|
*/
|
||||||
|
async getImageBuffer(fileName) {
|
||||||
|
// 先尝试从缓存获取
|
||||||
|
let imageBuffer = await this.cacheManager.getCachedImage(fileName);
|
||||||
|
|
||||||
|
if (!imageBuffer) {
|
||||||
|
// 缓存未命中,下载图片
|
||||||
|
imageBuffer = await this.downloadImage(fileName);
|
||||||
|
|
||||||
|
// 异步缓存图片
|
||||||
|
this.cacheManager.cacheImage(fileName, imageBuffer).catch(error => {
|
||||||
|
logger.warn(`异步缓存图片失败 ${fileName}:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预加载状态
|
||||||
|
* @returns {Object} 预加载状态
|
||||||
|
*/
|
||||||
|
getPreloadStatus() {
|
||||||
|
return {
|
||||||
|
...this.preloadStatus,
|
||||||
|
queueStatus: {
|
||||||
|
desktop: {
|
||||||
|
total: this.imageQueue.desktop.length,
|
||||||
|
current: this.queueIndex.desktop
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
total: this.imageQueue.mobile.length,
|
||||||
|
current: this.queueIndex.mobile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随机打乱数组
|
||||||
|
* @param {Array} array - 要打乱的数组
|
||||||
|
*/
|
||||||
|
shuffleArray(array) {
|
||||||
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠函数
|
||||||
|
* @param {number} ms - 毫秒数
|
||||||
|
*/
|
||||||
|
sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ImageService;
|
||||||
172
src/services/PageDetector.js
Normal file
172
src/services/PageDetector.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class PageDetector {
|
||||||
|
constructor() {
|
||||||
|
this.apiBase = process.env.SOMEACG_API_BASE || 'https://someacg.vercel.app/api';
|
||||||
|
this.maxRetries = parseInt(process.env.MAX_RETRY_ATTEMPTS) || 3;
|
||||||
|
this.timeout = parseInt(process.env.MAX_PAGE_DETECTION_TIMEOUT) || 30000;
|
||||||
|
|
||||||
|
// 缓存的最大页数
|
||||||
|
this.maxPages = {
|
||||||
|
desktop: 0, // size=2
|
||||||
|
mobile: 0 // size=1
|
||||||
|
};
|
||||||
|
|
||||||
|
// 上次检测时间
|
||||||
|
this.lastDetection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测指定 size 的最大页数
|
||||||
|
* @param {number} size - 1 为移动端,2 为桌面端
|
||||||
|
* @returns {Promise<number>} 最大页数
|
||||||
|
*/
|
||||||
|
async detectMaxPageForSize(size) {
|
||||||
|
logger.info(`开始检测 size=${size} 的最大页数`);
|
||||||
|
|
||||||
|
let left = 1;
|
||||||
|
let right = 10000; // 初始上限
|
||||||
|
let maxPage = 1;
|
||||||
|
|
||||||
|
// 先找到一个有效的上限
|
||||||
|
while (await this.isPageValid(right, size)) {
|
||||||
|
left = right;
|
||||||
|
right *= 2;
|
||||||
|
if (right > 100000) break; // 防止无限循环
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二分查找最大页数
|
||||||
|
while (left <= right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await this.isPageValid(mid, size)) {
|
||||||
|
maxPage = mid;
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
right = mid - 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`检测页数 ${mid} 时出错:`, error.message);
|
||||||
|
right = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`size=${size} 的最大页数为: ${maxPage}`);
|
||||||
|
return maxPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查指定页数是否有效
|
||||||
|
* @param {number} page - 页码
|
||||||
|
* @param {number} size - 尺寸类型
|
||||||
|
* @returns {Promise<boolean>} 是否有效
|
||||||
|
*/
|
||||||
|
async isPageValid(page, size) {
|
||||||
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.apiBase}/list`, {
|
||||||
|
params: { page, size },
|
||||||
|
timeout: this.timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查响应是否有效
|
||||||
|
if (response.status === 200 && Array.isArray(response.data) && response.data.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 404 || error.response?.status === 400) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === this.maxRetries) {
|
||||||
|
logger.warn(`检测页数 ${page} (size=${size}) 失败,已重试 ${this.maxRetries} 次:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待后重试
|
||||||
|
await this.sleep(1000 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测所有类型的最大页数
|
||||||
|
*/
|
||||||
|
async detectMaxPages() {
|
||||||
|
try {
|
||||||
|
logger.info('开始检测最大页数...');
|
||||||
|
|
||||||
|
// 并行检测两种尺寸的最大页数
|
||||||
|
const [desktopMax, mobileMax] = await Promise.all([
|
||||||
|
this.detectMaxPageForSize(2), // 桌面端
|
||||||
|
this.detectMaxPageForSize(1) // 移动端
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.maxPages.desktop = desktopMax;
|
||||||
|
this.maxPages.mobile = mobileMax;
|
||||||
|
this.lastDetection = new Date();
|
||||||
|
|
||||||
|
logger.info('页数检测完成:', this.maxPages);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('页数检测失败:', error);
|
||||||
|
|
||||||
|
// 如果检测失败且没有缓存的页数,使用默认值
|
||||||
|
if (this.maxPages.desktop === 0) {
|
||||||
|
this.maxPages.desktop = 100;
|
||||||
|
this.maxPages.mobile = 100;
|
||||||
|
logger.warn('使用默认页数范围:', this.maxPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最大页数
|
||||||
|
* @returns {Object} 包含桌面端和移动端最大页数的对象
|
||||||
|
*/
|
||||||
|
getMaxPages() {
|
||||||
|
return { ...this.maxPages };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定尺寸的最大页数
|
||||||
|
* @param {number} size - 1 为移动端,2 为桌面端
|
||||||
|
* @returns {number} 最大页数
|
||||||
|
*/
|
||||||
|
getMaxPageForSize(size) {
|
||||||
|
return size === 1 ? this.maxPages.mobile : this.maxPages.desktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取随机页码
|
||||||
|
* @param {number} size - 1 为移动端,2 为桌面端
|
||||||
|
* @returns {number} 随机页码
|
||||||
|
*/
|
||||||
|
getRandomPage(size) {
|
||||||
|
const maxPage = this.getMaxPageForSize(size);
|
||||||
|
return Math.floor(Math.random() * maxPage) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上次检测时间
|
||||||
|
* @returns {Date|null} 上次检测时间
|
||||||
|
*/
|
||||||
|
getLastDetectionTime() {
|
||||||
|
return this.lastDetection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠函数
|
||||||
|
* @param {number} ms - 毫秒数
|
||||||
|
*/
|
||||||
|
sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PageDetector;
|
||||||
243
src/services/scheduler.js
Normal file
243
src/services/scheduler.js
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
const cron = require('node-cron');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class Scheduler {
|
||||||
|
constructor() {
|
||||||
|
this.tasks = new Map();
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动定时任务
|
||||||
|
* @param {PageDetector} pageDetector - 页数检测器实例
|
||||||
|
* @param {ImageService} imageService - 图片服务实例
|
||||||
|
*/
|
||||||
|
start(pageDetector, imageService) {
|
||||||
|
if (this.isRunning) {
|
||||||
|
logger.warn('定时任务已在运行中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('启动定时任务调度器...');
|
||||||
|
|
||||||
|
// 每日凌晨 2:00 执行页数检测和缓存更新
|
||||||
|
const dailyTask = cron.schedule('0 2 * * *', async () => {
|
||||||
|
logger.info('开始执行每日定时任务...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 检测最大页数
|
||||||
|
logger.info('执行页数检测...');
|
||||||
|
await pageDetector.detectMaxPages();
|
||||||
|
|
||||||
|
// 2. 重新预加载缓存
|
||||||
|
logger.info('重新预加载图片缓存...');
|
||||||
|
await imageService.preloadCache();
|
||||||
|
|
||||||
|
logger.info('每日定时任务执行完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('每日定时任务执行失败:', error);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
scheduled: false,
|
||||||
|
timezone: 'Asia/Shanghai'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每小时检查一次服务状态
|
||||||
|
const healthCheckTask = cron.schedule('0 * * * *', () => {
|
||||||
|
this.performHealthCheck(pageDetector, imageService);
|
||||||
|
}, {
|
||||||
|
scheduled: false,
|
||||||
|
timezone: 'Asia/Shanghai'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每6小时清理一次日志(可选)
|
||||||
|
const logCleanupTask = cron.schedule('0 */6 * * *', () => {
|
||||||
|
this.performLogCleanup();
|
||||||
|
}, {
|
||||||
|
scheduled: false,
|
||||||
|
timezone: 'Asia/Shanghai'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动所有任务
|
||||||
|
dailyTask.start();
|
||||||
|
healthCheckTask.start();
|
||||||
|
logCleanupTask.start();
|
||||||
|
|
||||||
|
// 保存任务引用
|
||||||
|
this.tasks.set('daily', dailyTask);
|
||||||
|
this.tasks.set('healthCheck', healthCheckTask);
|
||||||
|
this.tasks.set('logCleanup', logCleanupTask);
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
logger.info('定时任务调度器启动完成');
|
||||||
|
|
||||||
|
// 记录下次执行时间
|
||||||
|
this.logNextExecutionTimes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止所有定时任务
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
logger.warn('定时任务未在运行');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('停止定时任务调度器...');
|
||||||
|
|
||||||
|
// 停止所有任务
|
||||||
|
for (const [name, task] of this.tasks) {
|
||||||
|
try {
|
||||||
|
task.stop();
|
||||||
|
logger.info(`定时任务 ${name} 已停止`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`停止定时任务 ${name} 失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tasks.clear();
|
||||||
|
this.isRunning = false;
|
||||||
|
logger.info('定时任务调度器已停止');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行健康检查
|
||||||
|
* @param {PageDetector} pageDetector - 页数检测器实例
|
||||||
|
* @param {ImageService} imageService - 图片服务实例
|
||||||
|
*/
|
||||||
|
performHealthCheck(pageDetector, imageService) {
|
||||||
|
try {
|
||||||
|
logger.info('执行服务健康检查...');
|
||||||
|
|
||||||
|
// 检查页数检测器状态
|
||||||
|
const maxPages = pageDetector.getMaxPages();
|
||||||
|
const lastDetection = pageDetector.getLastDetectionTime();
|
||||||
|
|
||||||
|
if (!lastDetection || Date.now() - lastDetection.getTime() > 25 * 60 * 60 * 1000) {
|
||||||
|
logger.warn('页数检测时间过久,可能需要手动触发检测');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxPages.desktop === 0 || maxPages.mobile === 0) {
|
||||||
|
logger.error('页数检测结果异常:', maxPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查图片服务状态
|
||||||
|
const preloadStatus = imageService.getPreloadStatus();
|
||||||
|
|
||||||
|
for (const [sizeType, status] of Object.entries(preloadStatus)) {
|
||||||
|
if (sizeType === 'queueStatus') continue;
|
||||||
|
|
||||||
|
if (status.inProgress) {
|
||||||
|
logger.info(`${sizeType} 预加载正在进行中: ${status.loaded}/${status.total}`);
|
||||||
|
} else if (status.total === 0) {
|
||||||
|
logger.warn(`${sizeType} 预加载队列为空`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录内存使用情况
|
||||||
|
const memUsage = process.memoryUsage();
|
||||||
|
const memUsageMB = {
|
||||||
|
rss: Math.round(memUsage.rss / 1024 / 1024),
|
||||||
|
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||||
|
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||||
|
external: Math.round(memUsage.external / 1024 / 1024)
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('内存使用情况 (MB):', memUsageMB);
|
||||||
|
|
||||||
|
// 如果内存使用过高,记录警告
|
||||||
|
if (memUsageMB.heapUsed > 512) {
|
||||||
|
logger.warn('内存使用量较高,当前堆内存使用:', memUsageMB.heapUsed, 'MB');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('服务健康检查完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('健康检查执行失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行日志清理
|
||||||
|
*/
|
||||||
|
performLogCleanup() {
|
||||||
|
try {
|
||||||
|
logger.info('执行日志清理...');
|
||||||
|
|
||||||
|
// 这里可以添加日志文件清理逻辑
|
||||||
|
// 例如删除超过一定天数的日志文件
|
||||||
|
|
||||||
|
// 强制垃圾回收(如果可用)
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc();
|
||||||
|
logger.info('执行了垃圾回收');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('日志清理完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('日志清理失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发每日任务
|
||||||
|
* @param {PageDetector} pageDetector - 页数检测器实例
|
||||||
|
* @param {ImageService} imageService - 图片服务实例
|
||||||
|
*/
|
||||||
|
async triggerDailyTask(pageDetector, imageService) {
|
||||||
|
logger.info('手动触发每日任务...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pageDetector.detectMaxPages();
|
||||||
|
await imageService.preloadCache();
|
||||||
|
logger.info('手动每日任务执行完成');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('手动每日任务执行失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务状态
|
||||||
|
* @returns {Object} 任务状态信息
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
const status = {
|
||||||
|
isRunning: this.isRunning,
|
||||||
|
tasks: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, task] of this.tasks) {
|
||||||
|
status.tasks[name] = {
|
||||||
|
running: task.running || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录下次执行时间
|
||||||
|
*/
|
||||||
|
logNextExecutionTimes() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 计算下次每日任务执行时间
|
||||||
|
const nextDaily = new Date(now);
|
||||||
|
nextDaily.setHours(2, 0, 0, 0);
|
||||||
|
if (nextDaily <= now) {
|
||||||
|
nextDaily.setDate(nextDaily.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算下次健康检查执行时间
|
||||||
|
const nextHealthCheck = new Date(now);
|
||||||
|
nextHealthCheck.setMinutes(0, 0, 0);
|
||||||
|
nextHealthCheck.setHours(nextHealthCheck.getHours() + 1);
|
||||||
|
|
||||||
|
logger.info(`下次每日任务执行时间: ${nextDaily.toLocaleString('zh-CN')}`);
|
||||||
|
logger.info(`下次健康检查执行时间: ${nextHealthCheck.toLocaleString('zh-CN')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
module.exports = new Scheduler();
|
||||||
50
src/utils/logger.js
Normal file
50
src/utils/logger.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
|
||||||
|
// 确保日志目录存在
|
||||||
|
const logDir = path.join(process.cwd(), 'logs');
|
||||||
|
fs.ensureDirSync(logDir);
|
||||||
|
|
||||||
|
// 创建 winston logger
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss'
|
||||||
|
}),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.json()
|
||||||
|
),
|
||||||
|
defaultMeta: { service: 'someacg-randompic' },
|
||||||
|
transports: [
|
||||||
|
// 错误日志文件
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'error.log'),
|
||||||
|
level: 'error',
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5
|
||||||
|
}),
|
||||||
|
// 所有日志文件
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'combined.log'),
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开发环境下同时输出到控制台
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.simple(),
|
||||||
|
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||||
|
return `${timestamp} [${level}]: ${message} ${Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''}`;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
Reference in New Issue
Block a user