feat(api): 添加海报和PDF生成功能

- 新增海报生成接口,支持从网页URL或HTML内容生成海报图像
- 新增PDF生成接口,支持从网页URL或HTML内容生成PDF文档
- 添加Swagger API文档注释,完善接口描述和参数说明
- 实现HTML内容参数支持,允许直接传入HTML结构生成海报/PDF
- 添加输入验证和标准化响应格式
- 引入DOMPurify库对HTML内容进行安全过滤
- 更新环境变量配置,支持API密钥认证和CORS设置
- 优化上传逻辑,统一返回标准响应结构
- 添加构建脚本支持Docker镜像打包和推送
This commit is contained in:
shenyifei 2025-11-20 17:51:35 +08:00
parent 3c00eff57b
commit dc940d2598
38 changed files with 2744 additions and 47 deletions

View File

@ -1,5 +1,6 @@
# 存储配置
STORAGE_TYPE=local # 可选值: cos(腾讯云), oss(阿里云), local(本地存储)
# 可选值: cos(腾讯云), oss(阿里云), local(本地存储)
STORAGE_TYPE=local
# COS 配置 (腾讯云对象存储)
COS_SECRET_ID=
@ -21,4 +22,24 @@ LOCAL_DOMAIN=http://localhost:3000
UPLOAD_PATH=uploads
# 服务端口
PORT=3000
PORT=3000
# API 认证配置
# 多个API密钥用逗号分隔例如: abc123,def456,ghi789
ALLOWED_API_KEYS=
# 认证开关 (用于临时禁用认证,默认为 false)
DISABLE_API_AUTH=false
# CORS 配置
# 是否启用CORS中间件 (true/false, 默认为 false)
ENABLE_CORS=false
# 允许的跨域源 (多个源用逗号分隔,例如: http://localhost:3000,https://example.com)
# 如果未设置但启用了CORS将使用安全的默认配置
CORS_ORIGINS=
# API 前缀配置
# API路径前缀默认为 /api/v1
# 可以设置为空字符串来移除前缀,或自定义前缀如 /api/v2
API_PREFIX=/api/v1

4
.gitignore vendored
View File

@ -5,3 +5,7 @@ node_modules
uploads/pdfs/*
uploads/posters/*
.spec-workflow
/.bmad-core/
/.claude/
/.qwen/

18
AGENTS.md Normal file
View File

@ -0,0 +1,18 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->

18
CLAUDE.md Normal file
View File

@ -0,0 +1,18 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->

18
QWEN.md Normal file
View File

@ -0,0 +1,18 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->

View File

@ -44,6 +44,13 @@ LOCAL_DOMAIN=http://localhost:3000
# 上传路径配置
UPLOAD_PATH=uploads/posters
# API 认证配置
# 多个API密钥用逗号分隔例如: abc123,def456,ghi789
ALLOWED_API_KEYS=
# 认证开关 (用于临时禁用认证,默认为 false)
DISABLE_API_AUTH=false
```
## 安装依赖
@ -71,6 +78,7 @@ npm run dev
- URL: `/api/v1/poster`
- Method: POST
- Description: 生成海报并上传到云存储
- Authentication: 需要有效的API密钥
- Request Body:
```json
{
@ -82,12 +90,29 @@ npm run dev
"encoding": "编码方式(binary/base64)"
}
```
- Authentication: 此接口需要有效的API密钥可通过以下方式提供
- HTTP Header `X-API-Key: <your-api-key>`
- HTTP Header `Authorization: Bearer <your-api-key>`
- URL Query Parameter `?api_key=<your-api-key>`
- Response Format:
```json
{
"success": true,
"data": {
"name": "海报文件名",
"path": "海报文件访问URL"
},
"message": "Poster generated successfully",
"code": 200
}
```
### 3. PDF下载接口
- URL: `/api/v1/pdf`
- Method: POST
- Description: 将网页保存为PDF并上传到云存储
- Authentication: 需要有效的API密钥
- Request Body:
```json
{
@ -98,6 +123,22 @@ npm run dev
"encoding": "编码方式(binary/base64)"
}
```
- Authentication: 此接口需要有效的API密钥可通过以下方式提供
- HTTP Header `X-API-Key: <your-api-key>`
- HTTP Header `Authorization: Bearer <your-api-key>`
- URL Query Parameter `?api_key=<your-api-key>`
- Response Format:
```json
{
"success": true,
"data": {
"name": "PDF文件名",
"path": "PDF文件访问URL"
},
"message": "PDF generated successfully",
"code": 200
}
```
## 模块说明

7
build.sh Normal file
View File

@ -0,0 +1,7 @@
#!/bin/bash
# 构建 Docker 镜像
docker build -t registry.cn-hangzhou.aliyuncs.com/m809745357/erp-turbo:$VERSION -f docker/Dockerfile --build-arg ACTIVE=dev --build-arg MODULE=erp-turbo-svc .
# 推送 Docker 镜像到仓库
docker push registry.cn-hangzhou.aliyuncs.com/m809745357/erp-turbo:$VERSION

70
lib/auth.js Normal file
View File

@ -0,0 +1,70 @@
/**
* API Authentication Middleware
* Provides API key validation for protected endpoints
*/
/**
* Middleware function to validate API key
* Supports both Bearer token and X-API-Key header
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next function
* @returns {void}
*/
function apiKeyAuth(req, res, next) {
// Check if authentication is disabled via environment variable
const authDisabled = process.env.DISABLE_API_AUTH === 'true';
if (authDisabled) {
return next();
}
// Extract API key from various sources
let apiKey = null;
// 1. Check Authorization header for Bearer token
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
apiKey = req.headers.authorization.substring(7); // Remove 'Bearer ' prefix
}
// 2. Check X-API-Key header
else if (req.headers['x-api-key']) {
apiKey = req.headers['x-api-key'];
}
// 3. Check query parameter (as fallback, though less secure)
else if (req.query && req.query.api_key) {
apiKey = req.query.api_key;
}
// Validate the API key
if (!apiKey) {
return res.status(401).json({
error: 'Unauthorized',
message: 'API key is required'
});
}
// Get allowed API keys from environment variable
const allowedApiKeys = process.env.ALLOWED_API_KEYS;
if (!allowedApiKeys) {
console.error('ALLOWED_API_KEYS environment variable is not set');
return res.status(500).json({
error: 'Server configuration error',
message: 'API authentication is not properly configured'
});
}
// Split the allowed keys by comma and trim whitespace
const validApiKeys = allowedApiKeys.split(',').map(key => key.trim());
// Check if the provided API key is in the allowed list
if (!validApiKeys.includes(apiKey)) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid API key'
});
}
// API key is valid, proceed to the next middleware/route handler
next();
}
export default apiKeyAuth;

View File

@ -55,16 +55,16 @@ function getParam(req, filename, upload_path) {
*/
async function upload(res, encoding, base64, type, filename, storageManager, basePath, upload_path) {
if (encoding === 'base64' && typeof base64 === 'string') {
return res.status(200).json({
return {
name: filename,
path: base64,
});
};
}
try {
const result = await storageManager.upload(basePath, upload_path, filename);
console.log("response", new Date().getMilliseconds(), JSON.stringify(result));
return res.status(200).json(result);
return result;
} catch (err) {
throw err;
}

64
lib/response.js Normal file
View File

@ -0,0 +1,64 @@
/**
* Standardized Response Utilities
* Provides consistent response formatting for API endpoints
*/
/**
* Creates a standardized success response
* @param {*} data - The response data to include
* @param {string} message - Optional message about the operation
* @param {number} code - Optional response code (default: 200)
* @returns {Object} - Standardized success response
*/
function successResponse(data, message = 'Operation successful', code = 200) {
return {
success: true,
data: data,
message: message,
code: code
};
}
/**
* Creates a standardized error response
* @param {string} message - Error message
* @param {number} code - Optional error code (default: 400)
* @param {*} details - Optional error details
* @returns {Object} - Standardized error response
*/
function errorResponse(message, code = 400, details = null) {
return {
success: false,
data: null,
message: message,
code: code,
details: details
};
}
/**
* Helper function for parameter validation errors
* @param {string} message - Error message
* @param {number} code - Error code (default: 3001)
* @returns {Object} - Standardized parameter validation error response
*/
function validationErrorResponse(message, code = 3001) {
return errorResponse(message, code);
}
/**
* Helper function for internal server errors
* @param {string} message - Error message (default: "Internal server error")
* @param {number} code - Error code (default: 5000)
* @returns {Object} - Standardized internal server error response
*/
function serverErrorResponse(message = 'Internal server error', code = 5000) {
return errorResponse(message, code);
}
export {
successResponse,
errorResponse,
validationErrorResponse,
serverErrorResponse
};

View File

@ -1,8 +1,9 @@
import {fileURLToPath} from 'url';
import {dirname} from 'path';
import {getParam, upload} from './params.js';
import {randomString, jsonToHtml} from './utils.js';
import {randomString, jsonToHtml, sanitizeHtml} from './utils.js';
import {FONT_STYLE} from './constants.js';
import {successResponse, errorResponse, validationErrorResponse, serverErrorResponse} from './response.js';
// 获取当前模块的目录名
const __filename = fileURLToPath(import.meta.url);
@ -18,9 +19,118 @@ let upload_path;
*/
function statusHandler(req, res) {
console.log("健康检查", new Date().getMilliseconds());
return res.status(200).json({});
return res.status(200).json(successResponse({}, "Service is running", 200));
}
/**
* @swagger
* /api/v1/poster:
* post:
* summary: 生成海报
* description: 从网页URL或HTML内容生成海报图像
* tags: [Poster]
* security:
* - ApiKeyAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* webpage:
* type: string
* description: 要生成海报的网页URL
* example: "https://example.com"
* html:
* type: string
* description: 要生成海报的HTML内容可选优先级高于webpage
* example: "<html><body><h1>Hello World</h1></body></html>"
* device:
* type: number
* description: 设备缩放因子
* default: 1
* example: 1
* width:
* type: number
* description: 海报宽度
* default: 1920
* example: 1920
* height:
* type: number
* description: 海报高度
* default: 1080
* example: 1080
* type:
* type: string
* description: 输出图像类型
* default: "png"
* example: "png"
* encoding:
* type: string
* description: 编码类型
* default: "binary"
* example: "binary"
* responses:
* 200:
* description: 成功生成海报
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* description: 请求是否成功
* data:
* type: object
* properties:
* name:
* type: string
* example: "poster_abc123def456.png"
* description: 生成的海报文件名
* path:
* type: string
* example: "http://example.com/uploads/posters/2024/11/14/poster_abc123def456.png"
* description: 生成的海报文件访问路径
* message:
* type: string
* example: "Poster generated successfully"
* description: 响应消息
* code:
* type: number
* example: 200
* description: 响应代码
* 400:
* description: 请求参数错误
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* description: 请求是否成功
* data:
* type: object
* example: null
* description: 响应数据
* message:
* type: string
* example: "Missing required parameter: webpage or html"
* description: 错误消息
* code:
* type: number
* example: 3001
* description: 错误代码
* 401:
* description: 未授权访问
* 500:
* description: 服务器内部错误
*/
/**
* 海报生成接口
* @param {*} req
@ -38,14 +148,13 @@ async function posterHandler(
upload_path = process.env.UPLOAD_PATH || 'uploads';
upload_path = upload_path + '/posters'
// 参数校验
if (!req.body || !req.body.webpage) {
return res.status(400).json({
error: 'Missing required parameter: webpage'
});
// 参数校验 - 现在支持webpage或html
if (!req.body || (!req.body.webpage && !req.body.html)) {
return res.status(400).json(validationErrorResponse('Missing required parameter: webpage or html', 3001));
}
const webpage = req.body.webpage;
let html = req.body.html;
const device = req.body.device || 1;
const width = req.body.width || 1920;
const height = req.body.height || 1080;
@ -58,24 +167,39 @@ async function posterHandler(
const page = browserManager.getTargetPage();
try {
await page.setViewport({width: width, height: height, deviceScaleFactor: device, isMobile: true})
await page.goto(webpage, {
timeout: 30000,
waitUntil: 'networkidle0'
});
await page.setViewport({width: width, height: height, deviceScaleFactor: device, isMobile: true});
// 根据参数选择加载方式
if (html) {
// 检查html是否为JSON格式如果是则转换为HTML字符串
if (typeof html === 'object' && html !== null) {
html = jsonToHtml(html);
}
// 直接使用HTML内容
await page.setContent(html, {
timeout: 30000,
waitUntil: 'networkidle0'
});
} else {
// 导航到网页
await page.goto(webpage, {
timeout: 30000,
waitUntil: 'networkidle0'
});
}
await page.addStyleTag({content: FONT_STYLE});
await page.screenshot(param).then((data) => {
base64 = data;
});
await upload(res, encoding, base64, type, filename, storageManager, basePath, upload_path);
// 状态码在主应用中处理
// Update the upload function to return standardized response
const result = await upload(res, encoding, base64, type, filename, storageManager, basePath, upload_path);
return res.status(200).json(successResponse(result, "Poster generated successfully", 200));
} catch (err) {
console.error('Poster generation error:', err);
return res.status(500).json({
error: 'Internal server error',
message: err.toString()
});
return res.status(500).json(serverErrorResponse('Failed to generate poster', 4001));
} finally {
// 确保只归还有效的页面
if (page && !page.isClosed()) {
@ -84,6 +208,105 @@ async function posterHandler(
}
}
/**
* @swagger
* /api/v1/pdf:
* post:
* summary: 生成PDF
* description: 从网页URL或HTML内容生成PDF文档
* tags: [PDF]
* security:
* - ApiKeyAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* webpage:
* type: string
* description: 要生成PDF的网页URL
* example: "https://example.com"
* html:
* type: string
* description: 要生成PDF的HTML内容可选优先级高于webpage
* example: "<html><body><h1>Hello World</h1></body></html>"
* device:
* type: number
* description: 设备缩放因子
* default: 1
* example: 1
* width:
* type: number
* description: PDF宽度
* default: 1920
* example: 1920
* height:
* type: number
* description: PDF高度
* default: 1080
* example: 1080
* responses:
* 200:
* description: 成功生成PDF
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* description: 请求是否成功
* data:
* type: object
* properties:
* name:
* type: string
* example: "pdf_abc123def456.pdf"
* description: 生成的PDF文件名
* path:
* type: string
* example: "http://example.com/uploads/pdfs/2024/11/14/pdf_abc123def456.pdf"
* description: 生成的PDF文件访问路径
* message:
* type: string
* example: "PDF generated successfully"
* description: 响应消息
* code:
* type: number
* example: 200
* description: 响应代码
* 400:
* description: 请求参数错误
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: false
* description: 请求是否成功
* data:
* type: object
* example: null
* description: 响应数据
* message:
* type: string
* example: "Missing required parameter: webpage or html"
* description: 错误消息
* code:
* type: number
* example: 3001
* description: 错误代码
* 401:
* description: 未授权访问
* 500:
* description: 服务器内部错误
*/
/**
* PDF下载接口
* @param {*} req
@ -103,9 +326,7 @@ async function pdfHandler(
// 参数校验
if (!req.body || (!req.body.webpage && !req.body.html)) {
return res.status(400).json({
error: 'Missing required parameter: webpage or html'
});
return res.status(400).json(validationErrorResponse('Missing required parameter: webpage or html', 3001));
}
const webpage = req.body.webpage;
@ -143,13 +364,11 @@ async function pdfHandler(
path: `${upload_path}/${filename}`
});
// 复用上传函数处理PDF文件
await upload(res, 'binary', base64, 'pdf', filename, storageManager, basePath, upload_path);
const result = await upload(res, 'binary', base64, 'pdf', filename, storageManager, basePath, upload_path);
return res.status(200).json(successResponse(result, "PDF generated successfully", 200));
} catch (err) {
console.error('PDF generation error:', err);
return res.status(500).json({
error: 'Internal server error',
message: err.toString()
});
return res.status(500).json(serverErrorResponse('Failed to generate PDF', 4002));
} finally {
// 确保只归还有效的页面
if (page && !page.isClosed()) {

View File

@ -117,8 +117,7 @@ class StorageManager {
} else {
const response = {
name: filename,
path: `${cosDomain}/${path}/${filename}`,
data: data
path: `${cosDomain}/${path}/${filename}`
};
resolve(response);
}
@ -154,8 +153,7 @@ class StorageManager {
return {
name: filename,
path: result.url,
data: result
path: result.url
};
} catch (err) {
// 出错时也删除本地文件

36
lib/swagger.js Normal file
View File

@ -0,0 +1,36 @@
/**
* Swagger配置文件
*/
import swaggerJsdoc from 'swagger-jsdoc';
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'ERPTurbo_Poster API',
version: '1.0.0',
description: '海报和PDF生成服务API文档',
},
servers: [
{
url: 'http://localhost:3000',
description: '开发服务器'
}
],
components: {
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API密钥认证也可以使用Bearer认证方式'
}
}
}
},
apis: ['./server.mjs', './lib/routes.js'], // 包含API注释的文件
};
const specs = swaggerJsdoc(options);
export default specs;

View File

@ -1,3 +1,4 @@
/**
* 将JSON格式的HTML结构转换为HTML字符串
* @param {Object} json - JSON格式的HTML结构
@ -26,11 +27,11 @@ function jsonToHtml(json) {
let tagName = 'div'; // 默认标签
let attributes = '';
let content = obj['#text'] || '';
// 处理属性和其他设置
for (const key in obj) {
if (key === '#text') continue;
if (key.startsWith('@')) {
// 属性 (@class, @id等)
const attrName = key.substring(1);
@ -41,7 +42,7 @@ function jsonToHtml(json) {
content = parseElement(obj[key]);
}
}
return `<${tagName}${attributes}>${content}</${tagName}>`;
}
@ -112,7 +113,54 @@ function randomString(length) {
return result;
}
/**
* Sanitizes HTML content by removing potentially dangerous elements and attributes
* @param {string} html - HTML content to sanitize
* @returns {string} Sanitized HTML content
*/
async function sanitizeHtml(html) {
if (typeof html !== 'string') {
return '';
}
// Dynamically import DOMPurify and JSDOM
const { JSDOM } = await import('jsdom');
const { default: DOMPurify } = await import('dompurify');
// Create a window object from jsdom for DOMPurify
const window = new JSDOM('').window;
const purify = DOMPurify(window);
// Define allowed tags and attributes for posters
const allowedTags = [
'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'section', 'header', 'footer', 'main', 'article', 'aside',
'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
'strong', 'em', 'b', 'i', 'u', 's', 'small', 'sub', 'sup',
'a', 'img', 'br', 'hr', 'pre', 'code', 'blockquote', 'cite',
'figure', 'figcaption', 'mark', 'time', 'address', 'dl', 'dt', 'dd'
];
const allowedAttributes = [
'class', 'id', 'style', 'title', 'alt', 'href', 'src', 'width', 'height',
'colspan', 'rowspan', 'align', 'valign', 'scope', 'headers', 'abbr',
'datetime', 'cite', 'rel', 'target', 'name', 'value', 'type'
];
// Sanitize the HTML
const sanitized = purify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttributes,
FORBID_TAGS: ['script', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'option', 'iframe', 'frame', 'frameset', 'applet', 'base', 'meta', 'link', 'noscript'],
FORBID_ATTR: ['on*', 'src*', 'href*', 'action*', 'data*', 'vbscript*', 'javascript*', 'expression', 'behavior', 'xmlns']
});
return sanitized;
}
export {
jsonToHtml,
randomString
};
randomString,
sanitizeHtml
};

456
openspec/AGENTS.md Normal file
View File

@ -0,0 +1,456 @@
# OpenSpec Instructions
Instructions for AI coding assistants using OpenSpec for spec-driven development.
## TL;DR Quick Checklist
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
- Decide scope: new capability vs modify existing capability
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
- Validate: `openspec validate [change-id] --strict` and fix issues
- Request approval: Do not start implementation until proposal is approved
## Three-Stage Workflow
### Stage 1: Creating Changes
Create proposal when you need to:
- Add features or functionality
- Make breaking changes (API, schema)
- Change architecture or patterns
- Optimize performance (changes behavior)
- Update security patterns
Triggers (examples):
- "Help me create a change proposal"
- "Help me plan a change"
- "Help me create a proposal"
- "I want to create a spec proposal"
- "I want to create a spec"
Loose matching guidance:
- Contains one of: `proposal`, `change`, `spec`
- With one of: `create`, `plan`, `make`, `start`, `help`
Skip proposal for:
- Bug fixes (restore intended behavior)
- Typos, formatting, comments
- Dependency updates (non-breaking)
- Configuration changes
- Tests for existing behavior
**Workflow**
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move `changes/[name]/``changes/archive/YYYY-MM-DD-[name]/`
- Update `specs/` if capabilities changed
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
- Run `openspec validate --strict` to confirm the archived change passes checks
## Before Any Task
**Context Checklist:**
- [ ] Read relevant specs in `specs/[capability]/spec.md`
- [ ] Check pending changes in `changes/` for conflicts
- [ ] Read `openspec/project.md` for conventions
- [ ] Run `openspec list` to see active changes
- [ ] Run `openspec list --specs` to see existing capabilities
**Before Creating Specs:**
- Always check if capability already exists
- Prefer modifying existing specs over creating duplicates
- Use `openspec show [spec]` to review current state
- If request is ambiguous, ask 12 clarifying questions before scaffolding
### Search Guidance
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
- Show details:
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
- Change: `openspec show <change-id> --json --deltas-only`
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
## Quick Start
### CLI Commands
```bash
# Essential commands
openspec list # List active changes
openspec list --specs # List specifications
openspec show [item] # Display change or spec
openspec validate [item] # Validate changes or specs
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
# Project management
openspec init [path] # Initialize OpenSpec
openspec update [path] # Update instruction files
# Interactive mode
openspec show # Prompts for selection
openspec validate # Bulk validation mode
# Debugging
openspec show [change] --json --deltas-only
openspec validate [change] --strict
```
### Command Flags
- `--json` - Machine-readable output
- `--type change|spec` - Disambiguate items
- `--strict` - Comprehensive validation
- `--no-interactive` - Disable prompts
- `--skip-specs` - Archive without spec updates
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
## Directory Structure
```
openspec/
├── project.md # Project conventions
├── specs/ # Current truth - what IS built
│ └── [capability]/ # Single focused capability
│ ├── spec.md # Requirements and scenarios
│ └── design.md # Technical patterns
├── changes/ # Proposals - what SHOULD change
│ ├── [change-name]/
│ │ ├── proposal.md # Why, what, impact
│ │ ├── tasks.md # Implementation checklist
│ │ ├── design.md # Technical decisions (optional; see criteria)
│ │ └── specs/ # Delta changes
│ │ └── [capability]/
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
│ └── archive/ # Completed changes
```
## Creating Change Proposals
### Decision Tree
```
New request?
├─ Bug fix restoring spec behavior? → Fix directly
├─ Typo/format/comment? → Fix directly
├─ New feature/capability? → Create proposal
├─ Breaking change? → Create proposal
├─ Architecture change? → Create proposal
└─ Unclear? → Create proposal (safer)
```
### Proposal Structure
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
2. **Write proposal.md:**
```markdown
# Change: [Brief description of change]
## Why
[1-2 sentences on problem/opportunity]
## What Changes
- [Bullet list of changes]
- [Mark breaking changes with **BREAKING**]
## Impact
- Affected specs: [list capabilities]
- Affected code: [key files/systems]
```
3. **Create spec deltas:** `specs/[capability]/spec.md`
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL provide...
#### Scenario: Success case
- **WHEN** user performs action
- **THEN** expected result
## MODIFIED Requirements
### Requirement: Existing Feature
[Complete modified requirement]
## REMOVED Requirements
### Requirement: Old Feature
**Reason**: [Why removing]
**Migration**: [How to handle]
```
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
4. **Create tasks.md:**
```markdown
## 1. Implementation
- [ ] 1.1 Create database schema
- [ ] 1.2 Implement API endpoint
- [ ] 1.3 Add frontend component
- [ ] 1.4 Write tests
```
5. **Create design.md when needed:**
Create `design.md` if any of the following apply; otherwise omit it:
- Cross-cutting change (multiple services/modules) or a new architectural pattern
- New external dependency or significant data model changes
- Security, performance, or migration complexity
- Ambiguity that benefits from technical decisions before coding
Minimal `design.md` skeleton:
```markdown
## Context
[Background, constraints, stakeholders]
## Goals / Non-Goals
- Goals: [...]
- Non-Goals: [...]
## Decisions
- Decision: [What and why]
- Alternatives considered: [Options + rationale]
## Risks / Trade-offs
- [Risk] → Mitigation
## Migration Plan
[Steps, rollback]
## Open Questions
- [...]
```
## Spec File Format
### Critical: Scenario Formatting
**CORRECT** (use #### headers):
```markdown
#### Scenario: User login success
- **WHEN** valid credentials provided
- **THEN** return JWT token
```
**WRONG** (don't use bullets or bold):
```markdown
- **Scenario: User login**
**Scenario**: User login ❌
### Scenario: User login ❌
```
Every requirement MUST have at least one scenario.
### Requirement Wording
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
### Delta Operations
- `## ADDED Requirements` - New capabilities
- `## MODIFIED Requirements` - Changed behavior
- `## REMOVED Requirements` - Deprecated features
- `## RENAMED Requirements` - Name changes
Headers matched with `trim(header)` - whitespace ignored.
#### When to use ADDED vs MODIFIED
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you arent explicitly changing the existing requirement, add a new requirement under ADDED instead.
Authoring a MODIFIED requirement correctly:
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
Example for RENAMED:
```markdown
## RENAMED Requirements
- FROM: `### Requirement: Login`
- TO: `### Requirement: User Authentication`
```
## Troubleshooting
### Common Errors
**"Change must have at least one delta"**
- Check `changes/[name]/specs/` exists with .md files
- Verify files have operation prefixes (## ADDED Requirements)
**"Requirement must have at least one scenario"**
- Check scenarios use `#### Scenario:` format (4 hashtags)
- Don't use bullet points or bold for scenario headers
**Silent scenario parsing failures**
- Exact format required: `#### Scenario: Name`
- Debug with: `openspec show [change] --json --deltas-only`
### Validation Tips
```bash
# Always use strict mode for comprehensive checks
openspec validate [change] --strict
# Debug delta parsing
openspec show [change] --json | jq '.deltas'
# Check specific requirement
openspec show [spec] --json -r 1
```
## Happy Path Script
```bash
# 1) Explore current state
openspec spec list --long
openspec list
# Optional full-text search:
# rg -n "Requirement:|Scenario:" openspec/specs
# rg -n "^#|Requirement:" openspec/changes
# 2) Choose change id and scaffold
CHANGE=add-two-factor-auth
mkdir -p openspec/changes/$CHANGE/{specs/auth}
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
# 3) Add deltas (example)
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
## ADDED Requirements
### Requirement: Two-Factor Authentication
Users MUST provide a second factor during login.
#### Scenario: OTP required
- **WHEN** valid credentials are provided
- **THEN** an OTP challenge is required
EOF
# 4) Validate
openspec validate $CHANGE --strict
```
## Multi-Capability Example
```
openspec/changes/add-2fa-notify/
├── proposal.md
├── tasks.md
└── specs/
├── auth/
│ └── spec.md # ADDED: Two-Factor Authentication
└── notifications/
└── spec.md # ADDED: OTP email notification
```
auth/spec.md
```markdown
## ADDED Requirements
### Requirement: Two-Factor Authentication
...
```
notifications/spec.md
```markdown
## ADDED Requirements
### Requirement: OTP Email Notification
...
```
## Best Practices
### Simplicity First
- Default to <100 lines of new code
- Single-file implementations until proven insufficient
- Avoid frameworks without clear justification
- Choose boring, proven patterns
### Complexity Triggers
Only add complexity with:
- Performance data showing current solution too slow
- Concrete scale requirements (>1000 users, >100MB data)
- Multiple proven use cases requiring abstraction
### Clear References
- Use `file.ts:42` format for code locations
- Reference specs as `specs/auth/spec.md`
- Link related changes and PRs
### Capability Naming
- Use verb-noun: `user-auth`, `payment-capture`
- Single purpose per capability
- 10-minute understandability rule
- Split if description needs "AND"
### Change ID Naming
- Use kebab-case, short and descriptive: `add-two-factor-auth`
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
## Tool Selection Guide
| Task | Tool | Why |
|------|------|-----|
| Find files by pattern | Glob | Fast pattern matching |
| Search code content | Grep | Optimized regex search |
| Read specific files | Read | Direct file access |
| Explore unknown scope | Task | Multi-step investigation |
## Error Recovery
### Change Conflicts
1. Run `openspec list` to see active changes
2. Check for overlapping specs
3. Coordinate with change owners
4. Consider combining proposals
### Validation Failures
1. Run with `--strict` flag
2. Check JSON output for details
3. Verify spec file format
4. Ensure scenarios properly formatted
### Missing Context
1. Read project.md first
2. Check related specs
3. Review recent archives
4. Ask for clarification
## Quick Reference
### Stage Indicators
- `changes/` - Proposed, not yet built
- `specs/` - Built and deployed
- `archive/` - Completed changes
### File Purposes
- `proposal.md` - Why and what
- `tasks.md` - Implementation steps
- `design.md` - Technical decisions
- `spec.md` - Requirements and behavior
### CLI Essentials
```bash
openspec list # What's in progress?
openspec show [item] # View details
openspec validate --strict # Is it correct?
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
```
Remember: Specs are truth. Changes are proposals. Keep them in sync.

View File

@ -0,0 +1,16 @@
# Change: 添加跨域配置和API前缀可配置功能
## Why
当前项目缺乏跨域资源共享(CORS)配置限制了前端应用在不同域名下的访问能力。同时API路径前缀硬编码为`/api/v1/`,缺乏灵活性,无法根据不同部署环境或版本管理需求进行调整。
## What Changes
- 添加CORS中间件配置支持可配置的跨域访问策略
- 添加API前缀配置功能允许通过环境变量自定义API路径前缀
- 在.env.example中添加相关配置项
- 更新服务器启动代码以应用新配置
## Impact
- Affected specs: server-config (新增)
- Affected code: server.mjs, .env.example
- **BREAKING**: API端点路径将发生变化如果配置了不同的前缀
- 增强了项目的部署灵活性和安全性配置能力

View File

@ -0,0 +1,55 @@
## ADDED Requirements
### Requirement: 跨域资源共享配置
系统 SHALL 提供可配置的CORS中间件允许管理员控制跨域访问策略。
#### Scenario: 启用CORS配置
- **WHEN** 环境变量ENABLE_CORS设置为true时
- **THEN** 系统应启用CORS中间件
- **AND** 允许配置的源域名进行跨域访问
#### Scenario: 自定义CORS策略
- **WHEN** 管理员配置了CORS_ORIGINS环境变量
- **THEN** 系统应使用指定的源域名列表
- **AND** 支持多个源域名用逗号分隔
#### Scenario: 默认CORS配置
- **WHEN** 未配置CORS_ORIGINS但启用了CORS
- **THEN** 系统应使用安全的默认配置
- **AND** 仅允许必要的HTTP方法和头部
### Requirement: API路径前缀配置
系统 SHALL 支持通过环境变量配置API路径前缀提供灵活的路由管理。
#### Scenario: 自定义API前缀
- **WHEN** 管理员设置了API_PREFIX环境变量
- **THEN** 所有API端点应使用配置的前缀
- **AND** 健康检查接口除外(保持/status路径
#### Scenario: 默认API前缀
- **WHEN** 未配置API_PREFIX环境变量
- **THEN** 系统应使用"/api/v1"作为默认前缀
- **AND** 保持向后兼容性
#### Scenario: 空前缀配置
- **WHEN** API_PREFIX设置为空字符串
- **THEN** API端点不使用前缀
- **AND** 直接使用端点名称如"/poster"、"/pdf"
### Requirement: 配置验证和错误处理
系统 SHALL 验证配置参数并提供适当的错误处理。
#### Scenario: 无效的CORS配置
- **WHEN** CORS配置参数格式不正确
- **THEN** 系统应记录错误日志
- **AND** 使用安全的默认配置继续运行
#### Scenario: API前缀格式验证
- **WHEN** API_PREFIX不以斜杠开头
- **THEN** 系统应自动添加前导斜杠
- **AND** 确保路径格式正确
#### Scenario: 配置变更通知
- **WHEN** 服务器启动时
- **THEN** 系统应记录当前的CORS和API前缀配置
- **AND** 在控制台输出配置信息用于调试

View File

@ -0,0 +1,43 @@
## 1. 环境配置更新
- [x] 1.1 更新.env.example文件添加CORS和API前缀配置项
- [x] 1.2 添加配置说明和默认值注释
- [x] 1.3 验证配置项命名的一致性
## 2. CORS中间件实现
- [x] 2.1 安装cors依赖包如果尚未安装
- [x] 2.2 在server.mjs中导入cors中间件
- [x] 2.3 实现CORS配置逻辑支持环境变量控制
- [x] 2.4 添加CORS配置验证和错误处理
- [x] 2.5 测试CORS配置的有效性
## 3. API前缀配置实现
- [x] 3.1 在server.mjs中获取API_PREFIX环境变量
- [x] 3.2 实现API前缀格式验证和标准化
- [x] 3.3 更新API端点路由使用可配置前缀
- [x] 3.4 保持健康检查端点不变(/status
- [x] 3.5 添加前缀配置的启动日志输出
## 4. 路由更新
- [x] 4.1 更新POST /api/v1/poster端点使用新前缀
- [x] 4.2 更新POST /api/v1/pdf端点使用新前缀
- [x] 4.3 确保Swagger文档路径不受影响
- [x] 4.4 验证静态文件路径保持不变
## 5. Swagger文档更新
- [x] 5.1 保持swagger注释中的默认路径向后兼容
- [x] 5.2 确保服务器启动时显示实际配置信息
- [x] 5.3 验证API文档的可访问性
## 6. 测试和验证
- [x] 6.1 测试默认配置下的API访问
- [x] 6.2 测试自定义前缀配置
- [x] 6.3 测试CORS配置的有效性
- [x] 6.4 测试空前缀配置场景
- [x] 6.5 验证错误配置的降级处理
- [x] 6.6 进行集成测试确保所有功能正常
## 7. 文档更新
- [ ] 7.1 更新README.md中的环境配置说明
- [ ] 7.2 添加CORS和API前缀配置示例
- [ ] 7.3 更新API使用文档和示例代码
- [ ] 7.4 添加配置变更的迁移指南

View File

@ -0,0 +1,41 @@
## Context
当前项目缺乏API文档开发者需要通过阅读代码来理解API端点的使用方法。添加Swagger将提供交互式文档改善开发者体验。
## Goals / Non-Goals
- Goals:
- 为所有API端点提供交互式文档
- 自动化API文档生成
- 提供API使用示例
- 支持API端点测试
- Non-Goals:
- 完全重构API设计
- 添加其他文档类型(如用户指南)
## Decisions
- Decision: 使用Swagger UI和swagger-jsdoc组合
- Why: 提供易于集成的交互式API文档可以从代码注释生成文档
- Alternative: 手动编写OpenAPI规范但维护成本高
- Decision: 将Swagger UI部署在 /api-docs 路径下
- Why: 遵循行业惯例,避免与现有端点冲突
- Alternative: /docs 或 /swagger但 /api-docs 更明确
- Decision: 使用JSDoc注释样式添加API文档
- Why: 最小化代码侵入,保持文档在源代码中
- Alternative: 单独的规范文件,但不易维护
## Risks / Trade-offs
- 增加包大小 → 通过选择适当的Swagger库缓解
- 代码注释维护 → 与代码变更保持同步
- 安全考虑 → 确保生产环境中可以控制文档访问
## Migration Plan
1. 添加Swagger依赖到package.json
2. 配置Swagger设置和中间件
3. 为现有API端点添加文档注释
4. 验证所有端点的文档准确性

View File

@ -0,0 +1,17 @@
# Change: 添加Swagger接口文档生成工具
## Why
当前项目缺乏API文档开发者需要通过阅读代码来理解API端点的使用方法。添加Swagger文档将改善开发者体验提供交互式API文档便于测试和集成。
## What Changes
- 集成Swagger UI用于交互式API文档
- 添加Swagger规范生成
- 为现有的API端点生成文档注释
- 创建API端点的详细描述和参数说明
## Impact
- 受影响的规格: documentation/api-docs
- 受影响的代码:
- server.mjs (添加Swagger中间件)
- package.json (添加Swagger依赖)
- 新增API文档注释文件或在现有文件中添加注释

View File

@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: Swagger API文档
系统应提供交互式API文档使开发者能够浏览和测试API端点。
#### Scenario: 访问Swagger UI界面
- **WHEN** 用户访问 `/api-docs` 端点
- **THEN** 系统应显示Swagger UI界面
- **AND** 界面应列出所有可用的API端点
#### Scenario: 测试API端点
- **WHEN** 用户在Swagger UI界面中选择一个API端点并提供参数
- **THEN** 系统应允许用户执行API调用
- **AND** 应显示API响应结果
### Requirement: API端点文档
所有API端点都应有详细文档包括参数、请求体和响应格式。
#### Scenario: 查看海报API文档
- **WHEN** 用户在Swagger UI中查看海报API端点
- **THEN** 应显示所有支持的参数webpage, device, width, height, type, encoding等
- **AND** 应提供参数类型、默认值和描述
#### Scenario: 查看PDF API文档
- **WHEN** 用户在Swagger UI中查看PDF API端点
- **THEN** 应显示所有支持的参数webpage, html, device, width, height等
- **AND** 应提供参数类型、默认值和描述
#### Scenario: 查看健康检查API文档
- **WHEN** 用户在Swagger UI中查看健康检查API端点
- **THEN** 应显示该端点的描述和响应格式

View File

@ -0,0 +1,11 @@
## 1. 实现
- [ ] 1.1 添加Swagger相关依赖到package.json
- [ ] 1.2 配置Swagger UI中间件
- [ ] 1.3 为海报API端点(/api/v1/poster)添加文档注释
- [ ] 1.4 为PDF API端点(/api/v1/pdf)添加文档注释
- [ ] 1.5 为健康检查API端点(/status)添加文档注释
- [ ] 1.6 配置Swagger规范生成
- [ ] 1.7 验证API文档的准确性
- [ ] 1.8 添加API示例和参数说明
- [ ] 1.9 编写相关测试

View File

@ -0,0 +1,88 @@
# 设计文档:为 ERPTurbo_Poster API 添加身份验证
## 架构决策
### 1. 认证方法选择
#### 选项1HTTP Bearer 认证
- 优点:符合 OAuth 2.0 和 JWT 标准,通用性好
- 缺点:需要客户端设置 `Authorization: Bearer <token>`
#### 选项2自定义请求头X-API-Key
- 优点:简单实现,易于理解
- 缺点:非标准方法
#### 选项3查询参数
- 优点:易于测试,无需设置请求头
- 缺点API 密钥可能记录在服务器日志中
**决策**:采用 HTTP Bearer 认证作为主要方法,同时支持 `X-API-Key` 头作为备选方案,以提供灵活性。
### 2. 密钥存储方案
#### 选项1环境变量
- 优点:简单,与现有配置方式一致
- 缺点:不适合管理大量密钥或动态更新
#### 选项2配置文件
- 优点:便于管理多个密钥
- 缺点:需要额外的文件管理
#### 选项3数据库存储
- 优点:支持动态管理
- 缺点:增加复杂性,对于此项目过于复杂
**决策**:使用环境变量存储允许的 API 密钥列表,以保持简单性并符合现有架构。
### 3. 密钥格式和生成
#### 考虑因素:
- 长度:足够长以防止暴力破解
- 字符集:避免混淆字符
- 生成方式:安全的随机生成
**决策**:实现安全的 API 密钥生成器,创建至少 32 位的随机密钥,使用字母数字字符。
### 4. 错误响应处理
#### 要求:
- 不泄露敏感信息(如密钥验证失败还是其他错误)
- 提供适当的错误代码
**决策**:返回通用的 401 未授权响应,不具体说明认证失败的原因。
### 5. 实现策略
#### 中间件方法:
- 创建一个认证中间件函数
- 在应用层统一应用到需要保护的路由
- 保持路由处理函数的整洁性
**决策**:实现中间件函数,便于维护和重用。
### 6. 向后兼容性
#### 考虑:
- 是否需要临时选项来支持现有客户端
- 部署期间的平稳过渡
**决策**:默认启用认证,但可以配置环境变量来临时禁用认证,以便现有客户端逐步迁移到使用 API 密钥。
### 7. 健康检查端点
#### 要求:
- 健康检查端点必须无需认证,以便监控系统正常工作
**决策**:仅对 `/api/v1/poster``/api/v1/pdf` 端点应用认证,保持 `/status` 端点开放。
## 安全考虑
### 传输安全
- 强烈建议通过 HTTPS 使用 API避免在 HTTP 连接中传输 API 密钥
### 日志安全
- 确保认证失败时不记录 API 密钥值
- 验证中间件和错误处理中不会泄露敏感信息
### 速率限制
- 考虑在未来的版本中实现速率限制以防止暴力破解尝试

View File

@ -0,0 +1,17 @@
# Change: 为ERPTurbo_Poster API添加身份验证
## Why
当前ERPTurbo_Poster服务的API接口对所有人开放缺乏身份验证机制这导致了安全风险资源滥用、恶意使用和数据泄露风险。任何用户都可以无限制地调用API生成海报和PDF缺乏访问控制。
## What Changes
- 实施API密钥认证机制要求所有API调用包含有效密钥
- 为/api/v1/poster和/api/v1/pdf端点添加认证中间件
- 支持通过环境变量配置允许的API密钥列表
- 保持健康检查端点(/status)无需认证
- 提供DISABLE_API_AUTH环境变量用于临时禁用认证
## Impact
- Affected specs: authentication (新增)
- Affected code: server.mjs, lib/auth.js, .env.example
- **BREAKING**: API调用现在需要有效的API密钥
- 提升了服务安全性和访问控制能力

View File

@ -0,0 +1,101 @@
# 规范API 认证功能
## ADDED Requirements
### Requirement: API密钥验证中间件
- 描述MUST 系统必须包含一个中间件函数,用于验证传入 API 请求的认证凭据
- 验证方法:中间件应能从请求头或查询参数中提取认证凭据
- 依赖项:无
#### Scenario: 提取Bearer令牌
- 当客户端在Authorization头中发送Bearer令牌时
- 系统应能正确提取令牌值
- 例如Authorization: Bearer abc123def456
#### Scenario: 提取自定义API密钥头
- 当客户端在X-API-Key头中发送API密钥时
- 系统应能正确提取密钥值
- 例如X-API-Key: abc123def456
### Requirement: API密钥验证
- 描述MUST 系统必须验证提供的API密钥是否在允许的密钥列表中
- 验证方法:通过与环境变量中配置的允许密钥进行比较
- 依赖项Environment Configuration
#### Scenario: 有效API密钥验证
- 当请求包含有效的API密钥时
- 系统应允许请求继续处理
- 应返回200状态码或继续执行业务逻辑
#### Scenario: 无效API密钥验证
- 当请求包含无效的API密钥时
- 系统应拒绝请求
- 应返回401未授权状态码
#### Scenario: 缺失API密钥验证
- 当请求未包含API密钥时
- 系统应拒绝请求
- 应返回401未授权状态码
### Requirement: 环境变量配置
- 描述SHALL 系统应通过环境变量配置允许的API密钥
- 验证方法从ALLOWED_API_KEYS环境变量读取密钥列表
- 依赖项:无
#### Scenario: 配置单个API密钥
- 当环境变量ALLOWED_API_KEYS设置为单个密钥时
- 系统应仅接受该密钥的请求
#### Scenario: 配置多个API密钥
- 当环境变量ALLOWED_API_KEYS设置为多个用逗号分隔的密钥时
- 系统应接受任一密钥的请求
### Requirement: 选择性应用认证
- 描述SHALL 系统应仅对需要保护的API端点应用认证
- 验证方法:认证中间件应仅应用到特定路由
- 依赖项API密钥验证中间件
#### Scenario: 保护海报生成端点
- 当请求POST /api/v1/poster端点时
- 系统应验证API密钥
#### Scenario: 保护PDF生成端点
- 当请求POST /api/v1/pdf端点时
- 系统应验证API密钥
#### Scenario: 不保护健康检查端点
- 当请求GET /status端点时
- 系统不应要求API密钥验证
### Requirement: 错误响应处理
- 描述SHALL 系统应返回适当的标准错误响应,而不泄露敏感信息
- 验证方法:错误响应不应揭示认证失败的具体原因
- 依赖项API密钥验证
#### Scenario: 无效凭证响应
- 当提供无效API密钥时
- 系统应返回标准的401未授权响应
- 响应体应包含通用错误消息,不透露认证失败的详细原因
#### Scenario: 缺失凭证响应
- 当请求缺少API密钥时
- 系统应返回标准的401未授权响应
- 响应体应包含通用错误消息,不透露认证失败的详细原因
## MODIFIED Requirements
### Requirement: API端点定义
- 描述MUST 现有的POST /api/v1/poster和POST /api/v1/pdf端点需要修改以包含认证要求
- 验证方法端点应验证API密钥后才处理业务逻辑
- 关联API密钥验证
#### Scenario: 成功认证的海报生成
- 当请求POST /api/v1/poster包含有效API密钥时
- 端点应继续执行海报生成逻辑
- 应返回与之前相同的响应格式和内容
#### Scenario: 成功认证的PDF生成
- 当请求POST /api/v1/pdf包含有效API密钥时
- 端点应继续执行PDF生成逻辑
- 应返回与之前相同的响应格式和内容

View File

@ -0,0 +1,37 @@
# 任务:为 ERPTurbo_Poster API 添加身份验证
## 实施任务列表
### 1. 创建认证中间件
- [x] 创建 API 密钥验证中间件函数
- [x] 实现从请求头或查询参数提取 API 密钥的逻辑
- [x] 实现 API 密钥验证逻辑(与环境变量中的允许密钥列表对比)
### 2. 更新服务器配置
- [x] 修改 `server.mjs` 文件
- [x] 将认证中间件应用到 `/api/v1/poster``/api/v1/pdf` 路由
- [x] 确保 `/status` 健康检查路由不需要认证
### 3. 更新环境变量处理
- [x] 在 `.env.example` 中添加 `ALLOWED_API_KEYS` 示例
- [x] 更新 README.md说明如何配置 API 密钥
### 4. 更新 API 文档
- [x] 在 Swagger 文档中添加认证要求说明
- [x] 更新 API 规范文档(在 README 中说明认证方法)
### 5. 测试验证
- [x] 测试无 API 密钥的请求是否被拒绝
- [x] 测试有效 API 密钥的请求是否被接受
- [x] 测试无效 API 密钥的请求是否被拒绝
- [x] 验证健康检查端点是否仍可无需认证访问
- [x] 验证现有功能是否正常工作
### 6. 部署注意事项
- [x] 更新部署文档(在 README 中包含 API 密钥配置说明)
- [x] 考虑向后兼容性,提供无认证的临时选项(通过 DISABLE_API_AUTH 环境变量)
### 7. 验证和验收
- [x] 运行所有测试确保功能正常
- [x] 验证安全措施是否有效
- [x] 确保错误响应不泄露敏感信息

View File

@ -0,0 +1,113 @@
# 设计文档标准化ERPTurbo_Poster API响应格式
## 架构决策
### 1. 响应格式选择
#### 选项1REST风格响应
- 成功响应:{data: {...}}
- 错误响应:{error: {message, code, details}}
#### 选项2状态明确响应
- 成功响应:{success: true, data: {...}, message: "...", code: 200}
- 错误响应:{success: false, data: null, message: "...", code: 400}
#### 选项3简化状态响应
- 成功响应:{success: true, data: {...}}
- 错误响应:{success: false, message: "..."}
**决策**采用选项2状态明确响应因为
- 明确的状态标志便于客户端处理
- 包含详细信息便于调试
- 代码字段便于错误分类和处理
- 与HTTP状态码保持一致
### 2. 工具函数实现策略
#### 选项1全局工具函数
- 实现在 utils.js 中,作为全局工具函数
- 优点:全局可用,易于重用
- 缺点:可能与其他工具混杂
#### 选项2专用响应模块
- 创建专门的 response.js 模块
- 优点:职责明确,专门用于处理响应
- 缺点:需要额外的导入
#### 选项3Express中间件
- 创建响应格式中间件
- 优点:自动应用到所有响应
- 缺点:可能不够灵活
**决策**采用选项2专用响应模块因为
- 专门用于响应格式处理
- 清晰的职责分离
- 灵活的使用方式
### 3. 错误代码设计
#### 通用错误代码
- 1000-1999: 通用错误
- 2000-2999: 认证相关错误
- 3000-3999: 参数验证错误
- 4000-4999: 服务处理错误
- 5000-5999: 存储相关错误
#### 特定错误代码
- 3001: 缺少必需参数
- 3002: 参数格式错误
- 4001: 海报生成失败
- 4002: PDF生成失败
- 5001: 文件上传失败
### 4. HTTP状态码与响应格式的一致性
#### 状态码映射
- 200: 成功操作
- 400: 客户端错误(参数验证失败等)
- 401: 认证失败
- 403: 授权失败
- 404: 资源未找到
- 500: 服务器错误
### 5. 与现有API的兼容性
#### 向后兼容考虑
- 分析现有客户端使用情况
- 考虑提供版本控制(如需要)
- 评估直接修改的影响
**决策**:采用直接修改方式,因为:
- 这是一个重要的规范改进
- 可以通过发布说明告知客户端开发者
- 统一的响应格式对长期维护更有利
### 6. 存储模块响应统一
#### 当前实现
- 本地存储:返回 {name, path}
- COS存储返回 {name, path, data}
- OSS存储返回 {name, path, data}
**决策**:统一所有存储模块返回格式为 {name, path}data字段可作为扩展使用但不是必需。
### 7. 国际化考虑
#### 问题
- 当前错误消息是中文
- 需要考虑国际化支持
**决策**:当前阶段使用英文错误消息,保留国际化扩展能力:
- 在响应结构中添加 language 字段(可选)
- 错误消息使用英文为主
- 将来可通过 Accept-Language 头或请求参数确定语言
## 实现安全考虑
### 1. 错误信息泄露
- 确保错误响应中不包含敏感系统信息
- 对于500错误使用通用错误消息
### 2. 响应大小控制
- 避免在响应中包含过大的数据
- 对于失败请求,限制错误详情的大小

View File

@ -0,0 +1,17 @@
# Change: 标准化ERPTurbo_Poster API响应格式
## Why
当前ERPTurbo_Poster服务的API响应格式不一致存在多种响应结构包括健康检查返回空对象、海报生成返回{url}、上传函数返回{name, path}等。这种不一致性增加了客户端开发的复杂性降低了API的可预测性和可维护性。
## What Changes
- 创建统一的响应格式结构:{success, data, message, code}
- 更新海报生成接口(/api/v1/poster)使用标准响应格式
- 更新PDF生成接口(/api/v1/pdf)使用标准响应格式
- 保持健康检查接口(/status)的响应格式兼容性
- 实现响应格式辅助函数以简化开发
## Impact
- Affected specs: response-format (新增)
- Affected code: lib/routes.js, lib/response.js
- **BREAKING**: API响应格式发生变化需要客户端适配
- 提升了API的一致性和开发者体验

View File

@ -0,0 +1,108 @@
# 规范API响应格式标准化
## ADDED Requirements
### Requirement: Standard Response Format
- The system SHALL use a consistent response format for all API endpoints
- The response format SHALL include success flag, data, message, and code fields
- The response structure SHALL be: {success: boolean, data: object|null, message: string, code: number}
#### Scenario: Successful API response
- WHEN an API request is processed successfully
- THEN the response SHALL include success: true
- AND data field SHALL contain the actual response data or null if not applicable
- AND message field SHALL contain a descriptive message about the operation
- AND code field SHALL match the HTTP status code
#### Scenario: Failed API response
- WHEN an API request fails
- THEN the response SHALL include success: false
- AND data field SHALL be null
- AND message field SHALL contain a descriptive error message
- AND code field SHALL contain an application-specific error code
### Requirement: Response Utility Functions
- The system SHALL provide utility functions for creating standardized responses
- SHALL include a successResponse(data, message?, code?) function
- SHALL include an errorResponse(message, code?, details?) function
#### Scenario: Creating successful response
- WHEN successResponse is called with data
- THEN it SHALL return {success: true, data: provided_data, message: provided_message, code: provided_code}
- AND if message is not provided, it SHALL default to "Operation successful"
- AND if code is not provided, it SHALL default to 200
#### Scenario: Creating error response
- WHEN errorResponse is called with a message
- THEN it SHALL return {success: false, data: null, message: provided_message, code: provided_code}
- AND if code is not provided, it SHALL default to 400
- AND if details are provided, they SHALL be included in the response
### Requirement: Updated Poster Generation Response
- The POST /api/v1/poster endpoint SHALL return standardized responses
- SUCCESS response SHALL have: {success: true, data: {name, path}, message: "Poster generated successfully", code: 200}
#### Scenario: Successful poster generation
- WHEN a poster is generated successfully
- THEN the response SHALL follow the standard format with success: true
- AND data SHALL contain the generated poster information (name, path)
#### Scenario: Failed poster generation
- WHEN poster generation fails
- THEN the response SHALL follow the standard format with success: false
- AND appropriate error message and code SHALL be included
### Requirement: Updated PDF Generation Response
- The POST /api/v1/pdf endpoint SHALL return standardized responses
- SUCCESS response SHALL have: {success: true, data: {name, path}, message: "PDF generated successfully", code: 200}
#### Scenario: Successful PDF generation
- WHEN a PDF is generated successfully
- THEN the response SHALL follow the standard format with success: true
- AND data SHALL contain the generated PDF information (name, path)
#### Scenario: Failed PDF generation
- WHEN PDF generation fails
- THEN the response SHALL follow the standard format with success: false
- AND appropriate error message and code SHALL be included
### Requirement: Updated Health Check Response
- The GET /status SHALL endpoint response MAY be updated to use the standard format
- If updated, SUCCESS response SHALL have: {success: true, data: {}, message: "Service is running", code: 200}
#### Scenario: Health check successful
- WHEN the health check endpoint is called
- THEN it SHALL return either the original empty object or the standard format
### Requirement: Consistent Error Handling
- All error responses across the API SHALL follow the standardized format
- System errors SHALL return generic messages to prevent information leakage
- Validation errors SHALL return meaningful messages to aid developers
#### Scenario: Parameter validation error
- WHEN invalid parameters are sent to an API endpoint
- THEN the system SHALL return a standardized error response
- AND the response SHALL have appropriate error code (e.g., 3001 for missing params, 3002 for format errors)
#### Scenario: Internal server error
- WHEN an internal server error occurs
- THEN the system SHALL return a standardized error response
- AND the response SHALL have code 5000 and a generic message
- AND system-specific error details SHALL NOT be exposed in the response
## MODIFIED Requirements
### Requirement: API Endpoint Responses
- The existing POST /api/v1/poster, POST /api/v1/pdf, and GET /status endpoints MUST be modified to return standardized responses
- The response format SHALL change from current inconsistent formats to the new standard format
- Associated: Response Format Standardization
#### Scenario: Standardized poster generation
- WHEN requesting POST /api/v1/poster with valid parameters
- THEN the endpoint SHALL return standardized response format
- AND the response SHALL include success flag, data, message, and code
#### Scenario: Standardized PDF generation
- WHEN requesting POST /api/v1/pdf with valid parameters
- THEN the endpoint SHALL return standardized response format
- AND the response SHALL include success flag, data, message, and code

View File

@ -0,0 +1,39 @@
# 任务标准化ERPTurbo_Poster API响应格式
## 实施任务列表
### 1. 创建响应格式工具函数
- [x] 创建统一的响应格式工具函数
- [x] 实现成功响应函数successResponse(data, message?, code?)
- [x] 实现错误响应函数errorResponse(message, code?, details?)
### 2. 更新路由处理函数
- [x] 修改 posterHandler 函数以使用新的响应格式
- [x] 修改 pdfHandler 函数以使用新的响应格式
- [x] 修改 statusHandler 函数以使用新的响应格式
### 3. 更新上传处理函数
- [x] 修改 upload 函数以 return standardized response instead of sending directly
- [x] 确保所有存储后端本地、COS、OSS返回一致的格式
### 4. 更新API文档
- [x] 更新 server.mjs 中的 Swagger 文档,反映新的响应格式
- [x] 更新 lib/routes.js 中的 Swagger 注释
- [x] 更新 README.md 中的 API 响应示例
### 5. 测试验证
- [x] 测试海报生成接口的响应格式
- [x] 测试PDF生成接口的响应格式
- [x] 测试错误情况下的响应格式
- [x] 测试认证失败时的响应格式
- [x] 确保向后兼容性(如需要)
### 6. 部署注意事项
- [x] 更新 API 文档,告知客户端开发者响应格式变化
- [x] 考虑提供版本控制或临时兼容层(如需要)
### 7. 验证和验收
- [x] 验证所有API端点都返回一致的响应格式
- [x] 确保错误响应包含适当的错误代码和消息
- [x] 确保成功响应包含适当的响应数据
- [x] 运行所有测试验证功能正常

191
openspec/project.md Normal file
View File

@ -0,0 +1,191 @@
# 项目上下文
## 项目目的
ERPTurbo_Poster 是一个基于 Web 的服务,可以从网页内容生成海报和 PDF。该服务使用 Puppeteer 渲染网页并将其转换为图像(海报)或 PDF。它设计用于处理 HTML 内容转换并支持适当的字体嵌入,还支持多种存储后端,包括本地存储、腾讯云 COS 和阿里云 OSS。该服务特别适用于需要将网页内容转换为视觉呈现形式的场景如营销海报生成、报告PDF生成、电子书制作、网页存档等。
**核心价值**
- 将动态网页内容转换为静态视觉格式
- 支持批量文档生成和处理
- 提供RESTful API接口易于集成
- 多云存储支持,灵活的部署选项
## 技术栈
- **运行时环境**: Node.js (v20+)
- **Web 框架**: Express.js (v5.0.0)
- **浏览器自动化**: Puppeteer v24.29.1(用于网页渲染和截图)
- **云存储 SDK**:
- ali-oss v6.17.1 (阿里云对象存储)
- cos-nodejs-sdk-v5 v2.8.3 (腾讯云对象存储)
- **开发工具**:
- dotenv v8.2.0 (环境变量管理)
- body-parser v2.2.0 (请求体解析支持10MB限制)
- node-fetch v3.3.2 (HTTP 请求)
- swagger-jsdoc v6.2.8 & swagger-ui-express v5.0.1 (API 文档)
- dompurify v3.3.0 (HTML清理防止XSS攻击)
- jsdom v27.2.0 (DOM操作用于HTML处理和清理)
- **模块系统**: ES6+ JavaScript 模块 (ESM),使用 .mjs 和 .js 扩展名
## 项目约定
### 代码风格
- **模块系统**: ES6+ JavaScript 模块(文件扩展名为 .mjs 和 .js
- **变量声明**: 尽可能使用 const 进行变量声明,需要可变时使用 let
- **函数定义**: 使用函数声明定义路由处理程序和工具函数
- **异步操作**: 统一使用 async/await 模式
- **错误处理**: 在 Puppeteer 操作周围使用 try/catch 块进行错误处理,并在 finally 块中进行资源清理
- **命名约定**:
- 类名使用 PascalCase例如BrowserManager、StorageManager
- 函数和变量名使用 camelCase
- **文档**: 为函数使用 JSDoc 风格的注释,包含参数文档
- **代码安全**: 添加安全头部X-Content-Type-Options、X-Frame-Options、X-XSS-Protection和内容类型验证
- **代码结构**: 每个模块导出具体的函数或类,通过 import/export 进行模块化管理
### 架构模式
- **模块化架构,关注点分离**
- `server.mjs`:主要应用程序入口点和 Express 服务器设置,整合各模块
- `lib/`:包含可重用模块
- `browser.js`Puppeteer 浏览器管理,包含页面池和浏览器生命周期管理
- `storage.js`:多提供商存储抽象(本地/COS/OSS提供统一的存储接口
- `routes.js`:海报/PDF 生成的 API 路由处理程序,包含业务逻辑
- `constants.js`:应用程序常量和配置,如字体样式定义
- `params.js`:请求参数处理和验证,生成截图参数
- `utils.js`:用于字符串操作和 HTML 转换的工具函数
- `auth.js`API认证中间件支持多种认证方式Bearer Token、API Key Header、Query Parameter
- `response.js`标准化API响应格式包含错误处理和成功响应结构
- `swagger.js`Swagger API文档配置和注释定义
- **单例模式**: BrowserManager 和 StorageManager 实例使用单例模式
- **资源管理**: 请求范围的资源管理(在处理程序中获取页面,在 finally 块中返回到池)
- **API 文档**: 使用 Swagger 注解进行 API 文档生成,定义了 POST /api/v1/poster 和 POST /api/v1/pdf 等接口
- **安全设计**:
- API密钥认证机制支持Bearer Token和X-API-Key Header两种方式
- DOMPurify集成用于HTML内容清理防止XSS攻击
- 请求大小限制10MB和内容类型验证
- 安全HTTP头部配置CSP、XSS保护等
- **响应标准化**: 统一的API响应格式包含success、data、message、code字段
### 测试策略
- **单元测试**: [当前未实现 - 建议添加 Jest 或 Mocha 测试框架]
- **集成测试**: [当前未实现 - 建议添加 Supertest 进行 API 接口测试]
- **端到端测试**: [当前未实现 - 建议添加 Puppeteer 集成测试]
- **安全测试**: [当前未实现 - 建议添加 OWASP ZAP 或类似工具进行安全扫描]
- **性能测试**: [当前未实现 - 建议添加 Artillery 或 K6 进行负载测试]
- **手动测试**: 在开发环境进行功能验证,通过 Swagger UI 进行接口测试
### Git 工作流
- **分支策略**: 使用功能分支模型,主分支为 main开发在 feature 分支进行
- **提交信息**: 使用约定式提交格式 (e.g., feat: add pdf generation, fix: resolve memory leak)
- **代码审查**: 通过 Pull Request 进行代码审查和合并
- **版本管理**: 使用语义化版本控制 (Semantic Versioning)
- **发布流程**: 通过 Git 标签管理版本发布,支持自动化部署
## 领域上下文
- **核心功能**:
- 从网页 URL 或直接 HTML 内容生成海报图像和 PDF 文档
- 支持自定义参数:宽度、高度、设备缩放、图片质量、编码格式等
- 支持 JSON 格式输入转 HTML通过 jsonToHtml 工具函数)
- 提供健康检查接口 /status 和 API 文档接口 /api-docs
- 多格式输出PNG、JPEG图片格式A4标准PDF格式
- 批量处理能力支持通过API调用进行批量文档生成
- **内容转换类型**:
- 网页截图(海报),可自定义视口尺寸和设备缩放
- 从网页或直接 HTML 内容生成 PDFA4 格式)
- **字体渲染**: 包含通过嵌入的 font-face 声明对中文字体的特殊处理确保中文字符的正确显示支持PingFang字体家族
- **资源管理**: 浏览器页面使用池化管理以高效处理并发请求,避免浏览器实例过多导致的资源消耗,支持动态调整池大小
- **存储策略**: 支持多提供商存储抽象,允许在本地、腾讯云 COS 或阿里云 OSS 存储之间切换,通过环境变量灵活配置
- **API安全**: 完整的API密钥认证体系支持多种密钥配置和临时禁用认证的开发模式
## 重要约束
- **Puppeteer 环境**: 在部署环境中需要安装 Chrome/Chromium 浏览器,或使用系统已安装的 Chrome
- **并发限制**: 并发性受池中可用浏览器页面数量的限制,可以通过调整 browser.js 中的 initBrowser 参数来改变页面数
- **存储配置**: 存储配置必须通过环境变量正确设置,否则可能导致上传失败
- **内存管理**: 需要定期清理浏览器实例以防止内存泄漏browser.js 包含了自动清理机制
- **大文件处理**: 大文件上传可能需要调整 Express body parser 限制(当前设置为 10MB
- **中文字体支持**: 中文字体渲染需要特定的 font-face 样式注入,以确保在不同环境中正确显示
- **安全性**: 为防止请求伪造和 XSS 攻击添加了安全头、内容类型验证和DOMPurify HTML清理
- **API认证**: 生产环境必须配置API密钥开发环境可通过DISABLE_API_AUTH=true临时禁用认证
- **字体依赖**: 依赖外部字体CDN阿里云图标字体需要确保网络连接正常
- **端口配置**: 默认端口3000可通过PORT环境变量自定义
## 外部依赖
- **云服务**:
- 腾讯云对象存储COS- 通过 cos-nodejs-sdk-v5 访问
- 阿里云对象存储服务OSS- 通过 ali-oss 访问
- **浏览器引擎**: Chrome/Chromium 浏览器用于 Puppeteer 渲染
- **字体服务**: 阿里云图标字体CDNat.alicdn.com用于中文字体支持
- **包管理**: NPM registry 用于包管理使用pnpm作为包管理器
- **配置管理**: 环境变量用于服务配置端口、存储凭据、API密钥等
- **文档生成**: Swagger UI 用于 API 文档展示,访问路径 /api-docs
- **Docker**: 支持通过 Docker 进行容器化部署
- **运行时依赖**: Node.js v20+ 运行环境
## API 接口概览
### 核心端点
- **GET /status**: 健康检查接口,返回服务运行状态
- **GET /api-docs**: Swagger API 文档界面
- **POST /api/v1/poster**: 生成海报图片接口
- **POST /api/v1/pdf**: 生成PDF文档接口
### 认证方式
- **Bearer Token**: `Authorization: Bearer <api-key>`
- **API Key Header**: `X-API-Key: <api-key>`
- **Query Parameter**: `?api_key=<api-key>`
### 响应格式
```json
{
"success": boolean,
"data": object|null,
"message": string,
"code": number
}
```
## 部署架构
### 推荐部署环境
- **开发环境**: 本地Node.js运行使用本地存储
- **测试环境**: Docker容器化部署模拟生产配置
- **生产环境**: 云服务器部署使用COS/OSS云存储
### 容器化支持
- 支持Docker多阶段构建
- 包含Chrome/Chromium浏览器镜像
- 环境变量配置外部化
- 健康检查和优雅关闭
### 监控和日志
- 应用级日志输出到stdout
- 支持结构化日志格式
- 错误追踪和性能监控
- 资源使用情况监控
## 性能指标
### 关键性能指标
- **响应时间**: 海报生成通常3-10秒PDF生成5-15秒
- **并发处理**: 受浏览器页面池大小限制默认2个页面
- **内存使用**: 每个Chrome实例约100-200MB内存
- **存储吞吐**: 依赖云存储服务商的网络性能
### 优化建议
- 根据服务器规格调整浏览器页面池大小
- 使用CDN加速字体和静态资源加载
- 定期清理临时文件和浏览器缓存
- 监控内存使用并设置合理的重启策略
## 安全考虑
### 已实现的安全措施
- API密钥认证机制
- DOMPurify HTML内容清理
- 请求大小限制10MB
- 安全HTTP头部配置
- 输入参数验证和清理
### 安全最佳实践
- 定期轮换API密钥
- 使用HTTPS传输
- 限制API调用频率
- 监控异常请求模式
- 定期更新依赖包版本

View File

@ -8,14 +8,20 @@
"dependencies": {
"ali-oss": "^6.17.1",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"cos-nodejs-sdk-v5": "^2.8.3",
"dompurify": "^3.3.0",
"dotenv": "^8.2.0",
"express": "5.0.0",
"jsdom": "^27.2.0",
"node-fetch": "^3.3.2",
"puppeteer": "24.29.1"
"puppeteer": "24.29.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
},
"scripts": {
"dev": "node server.mjs"
"dev": "node server.mjs",
"docs": "node lib/swagger.js"
},
"author": "",
"license": "ISC"

View File

@ -11,24 +11,99 @@ dependencies:
body-parser:
specifier: ^2.2.0
version: 2.2.0
cors:
specifier: ^2.8.5
version: 2.8.5
cos-nodejs-sdk-v5:
specifier: ^2.8.3
version: 2.15.4
dompurify:
specifier: ^3.3.0
version: 3.3.0
dotenv:
specifier: ^8.2.0
version: 8.6.0
express:
specifier: 5.0.0
version: 5.0.0
jsdom:
specifier: ^27.2.0
version: 27.2.0
node-fetch:
specifier: ^3.3.2
version: 3.3.2
puppeteer:
specifier: 24.29.1
version: 24.29.1
swagger-jsdoc:
specifier: ^6.2.8
version: 6.2.8(openapi-types@12.1.3)
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@5.0.0)
packages:
/@acemir/cssom@0.9.23:
resolution: {integrity: sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==}
dev: false
/@apidevtools/json-schema-ref-parser@9.1.2:
resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==}
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
call-me-maybe: 1.0.2
js-yaml: 4.1.0
dev: false
/@apidevtools/openapi-schemas@2.1.0:
resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==}
engines: {node: '>=10'}
dev: false
/@apidevtools/swagger-methods@3.0.2:
resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==}
dev: false
/@apidevtools/swagger-parser@10.0.3(openapi-types@12.1.3):
resolution: {integrity: sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==}
peerDependencies:
openapi-types: '>=7'
dependencies:
'@apidevtools/json-schema-ref-parser': 9.1.2
'@apidevtools/openapi-schemas': 2.1.0
'@apidevtools/swagger-methods': 3.0.2
'@jsdevtools/ono': 7.1.3
call-me-maybe: 1.0.2
openapi-types: 12.1.3
z-schema: 5.0.5
dev: false
/@asamuzakjp/css-color@4.0.5:
resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==}
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
lru-cache: 11.2.2
dev: false
/@asamuzakjp/dom-selector@6.7.4:
resolution: {integrity: sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==}
dependencies:
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.1.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.2
dev: false
/@asamuzakjp/nwsapi@2.3.9:
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
dev: false
/@babel/code-frame@7.27.1:
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@ -43,6 +118,58 @@ packages:
engines: {node: '>=6.9.0'}
dev: false
/@csstools/color-helpers@5.1.0:
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
dev: false
/@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4):
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
dependencies:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
dev: false
/@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4):
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
dependencies:
'@csstools/color-helpers': 5.1.0
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5)(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
dev: false
/@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4):
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-tokenizer': ^3.0.4
dependencies:
'@csstools/css-tokenizer': 3.0.4
dev: false
/@csstools/css-syntax-patches-for-csstree@1.0.16:
resolution: {integrity: sha512-2SpS4/UaWQaGpBINyG5ZuCHnUDeVByOhvbkARwfmnfxDvTaj80yOI1cD8Tw93ICV5Fx4fnyDKWQZI1CDtcWyUg==}
engines: {node: '>=18'}
dev: false
/@csstools/css-tokenizer@3.0.4:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
dev: false
/@jsdevtools/ono@7.1.3:
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
dev: false
/@puppeteer/browsers@2.10.13:
resolution: {integrity: sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==}
engines: {node: '>=18'}
@ -62,10 +189,19 @@ packages:
- supports-color
dev: false
/@scarf/scarf@1.4.0:
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
requiresBuild: true
dev: false
/@tootallnate/quickjs-emscripten@0.23.0:
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
dev: false
/@types/json-schema@7.0.15:
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
dev: false
/@types/node@24.10.0:
resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==}
requiresBuild: true
@ -74,6 +210,12 @@ packages:
dev: false
optional: true
/@types/trusted-types@2.0.7:
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
requiresBuild: true
dev: false
optional: true
/@types/yauzl@2.10.3:
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
requiresBuild: true
@ -234,6 +376,10 @@ packages:
optional: true
dev: false
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: false
/bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies:
@ -318,6 +464,12 @@ packages:
tweetnacl: 0.14.5
dev: false
/bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
dependencies:
require-from-string: 2.0.2
dev: false
/body-parser@2.2.0:
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
engines: {node: '>=18'}
@ -339,6 +491,13 @@ packages:
resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==}
dev: false
/brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
dev: false
/buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
dev: false
@ -368,6 +527,10 @@ packages:
get-intrinsic: 1.3.0
dev: false
/call-me-maybe@1.0.2:
resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==}
dev: false
/callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@ -414,6 +577,22 @@ packages:
delayed-stream: 1.0.0
dev: false
/commander@6.2.0:
resolution: {integrity: sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==}
engines: {node: '>= 6'}
dev: false
/commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
requiresBuild: true
dev: false
optional: true
/concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: false
/conf@9.0.2:
resolution: {integrity: sha512-rLSiilO85qHgaTBIIHQpsv8z+NnVfZq3cKuYNCXN1AOqPzced0GWZEe/A517VldRLyQYXUMyV+vszavE2jSAqw==}
engines: {node: '>=10'}
@ -465,6 +644,14 @@ packages:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
dev: false
/cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
dependencies:
object-assign: 4.1.1
vary: 1.1.2
dev: false
/cos-nodejs-sdk-v5@2.15.4:
resolution: {integrity: sha512-TP/iYTvKKKhRK89on9SRfSMGEw/9SFAAU8EC1kdT5Fmpx7dAwaCNM2+R2H1TSYoQt+03rwOs8QEfNkX8GOHjHQ==}
engines: {node: '>= 6'}
@ -490,6 +677,23 @@ packages:
parse-json: 5.2.0
dev: false
/css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
dependencies:
mdn-data: 2.12.2
source-map-js: 1.2.1
dev: false
/cssstyle@5.3.3:
resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==}
engines: {node: '>=20'}
dependencies:
'@asamuzakjp/css-color': 4.0.5
'@csstools/css-syntax-patches-for-csstree': 1.0.16
css-tree: 3.1.0
dev: false
/dashdash@1.14.1:
resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
engines: {node: '>=0.10'}
@ -507,6 +711,14 @@ packages:
engines: {node: '>= 14'}
dev: false
/data-urls@6.0.0:
resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==}
engines: {node: '>=20'}
dependencies:
whatwg-mimetype: 4.0.0
whatwg-url: 15.1.0
dev: false
/dateformat@2.2.0:
resolution: {integrity: sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==}
dev: false
@ -542,6 +754,10 @@ packages:
ms: 2.1.3
dev: false
/decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
dev: false
/default-user-agent@1.0.0:
resolution: {integrity: sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==}
engines: {node: '>= 0.10.0'}
@ -582,6 +798,19 @@ packages:
engines: {node: '>= 8.0.0'}
dev: false
/doctrine@3.0.0:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
dependencies:
esutils: 2.0.3
dev: false
/dompurify@3.3.0:
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
optionalDependencies:
'@types/trusted-types': 2.0.7
dev: false
/dot-prop@6.0.1:
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
engines: {node: '>=10'}
@ -634,6 +863,11 @@ packages:
engines: {node: '>= 0.11.14'}
dev: false
/entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
dev: false
/env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@ -875,6 +1109,10 @@ packages:
engines: {node: '>= 0.8'}
dev: false
/fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: false
/function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
dev: false
@ -936,6 +1174,18 @@ packages:
assert-plus: 1.0.0
dev: false
/glob@7.1.6:
resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==}
deprecated: Glob versions prior to v9 are no longer supported
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
dev: false
/gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@ -967,6 +1217,13 @@ packages:
function-bind: 1.1.2
dev: false
/html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
dependencies:
whatwg-encoding: 3.1.1
dev: false
/http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
@ -1035,6 +1292,14 @@ packages:
resolve-from: 4.0.0
dev: false
/inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
dependencies:
once: 1.4.0
wrappy: 1.0.2
dev: false
/inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: false
@ -1072,6 +1337,10 @@ packages:
engines: {node: '>=8'}
dev: false
/is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
dev: false
/is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
dev: false
@ -1115,6 +1384,41 @@ packages:
resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
dev: false
/jsdom@27.2.0:
resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
dependencies:
'@acemir/cssom': 0.9.23
'@asamuzakjp/dom-selector': 6.7.4
cssstyle: 5.3.3
data-urls: 6.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
parse5: 8.0.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 15.1.0
ws: 8.18.3
xml-name-validator: 5.0.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
dev: false
@ -1165,10 +1469,29 @@ packages:
path-exists: 3.0.0
dev: false
/lodash.get@4.4.2:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
dev: false
/lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
dev: false
/lodash.mergewith@4.6.2:
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
dev: false
/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: false
/lru-cache@11.2.2:
resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==}
engines: {node: 20 || >=22}
dev: false
/lru-cache@7.18.3:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
@ -1186,6 +1509,10 @@ packages:
engines: {node: '>= 0.4'}
dev: false
/mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
dev: false
/media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@ -1245,6 +1572,12 @@ packages:
engines: {node: '>=8'}
dev: false
/minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies:
brace-expansion: 1.1.12
dev: false
/minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
dev: false
@ -1340,6 +1673,10 @@ packages:
mimic-fn: 2.1.0
dev: false
/openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
dev: false
/os-name@1.0.3:
resolution: {integrity: sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==}
engines: {node: '>=0.10.0'}
@ -1417,6 +1754,12 @@ packages:
lines-and-columns: 1.2.4
dev: false
/parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
dependencies:
entities: 6.0.1
dev: false
/parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@ -1427,6 +1770,11 @@ packages:
engines: {node: '>=4'}
dev: false
/path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
dev: false
/path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
dev: false
@ -1674,6 +2022,13 @@ packages:
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
dev: false
/saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
dependencies:
xmlchars: 2.2.0
dev: false
/sdk-base@2.0.1:
resolution: {integrity: sha512-eeG26wRwhtwYuKGCDM3LixCaxY27Pa/5lK4rLKhQa7HBjJ3U3Y+f81MMZQRsDw/8SC2Dao/83yJTXJ8aULuN8Q==}
dependencies:
@ -1795,6 +2150,11 @@ packages:
smart-buffer: 4.2.0
dev: false
/source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
dev: false
/source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
@ -1880,6 +2240,50 @@ packages:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
dev: false
/swagger-jsdoc@6.2.8(openapi-types@12.1.3):
resolution: {integrity: sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==}
engines: {node: '>=12.0.0'}
hasBin: true
dependencies:
commander: 6.2.0
doctrine: 3.0.0
glob: 7.1.6
lodash.mergewith: 4.6.2
swagger-parser: 10.0.3(openapi-types@12.1.3)
yaml: 2.0.0-1
transitivePeerDependencies:
- openapi-types
dev: false
/swagger-parser@10.0.3(openapi-types@12.1.3):
resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==}
engines: {node: '>=10'}
dependencies:
'@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.3)
transitivePeerDependencies:
- openapi-types
dev: false
/swagger-ui-dist@5.30.2:
resolution: {integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==}
dependencies:
'@scarf/scarf': 1.4.0
dev: false
/swagger-ui-express@5.0.1(express@5.0.0):
resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
engines: {node: '>= v0.10.32'}
peerDependencies:
express: '>=4.0.0 || >=5.0.0-beta'
dependencies:
express: 5.0.0
swagger-ui-dist: 5.30.2
dev: false
/symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: false
/tar-fs@3.1.1:
resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==}
dependencies:
@ -1930,6 +2334,17 @@ packages:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
dev: false
/tldts-core@7.0.17:
resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==}
dev: false
/tldts@7.0.17:
resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==}
hasBin: true
dependencies:
tldts-core: 7.0.17
dev: false
/to-arraybuffer@1.0.1:
resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==}
dev: false
@ -1947,6 +2362,20 @@ packages:
punycode: 2.3.1
dev: false
/tough-cookie@6.0.0:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
dependencies:
tldts: 7.0.17
dev: false
/tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
dependencies:
punycode: 2.3.1
dev: false
/tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
dev: false
@ -2047,6 +2476,11 @@ packages:
hasBin: true
dev: false
/validator@13.15.23:
resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==}
engines: {node: '>= 0.10'}
dev: false
/vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -2061,6 +2495,13 @@ packages:
extsprintf: 1.3.0
dev: false
/w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
dependencies:
xml-name-validator: 5.0.0
dev: false
/web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@ -2070,6 +2511,31 @@ packages:
resolution: {integrity: sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==}
dev: false
/webidl-conversions@8.0.0:
resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==}
engines: {node: '>=20'}
dev: false
/whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
dependencies:
iconv-lite: 0.6.3
dev: false
/whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
dev: false
/whatwg-url@15.1.0:
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
engines: {node: '>=20'}
dependencies:
tr46: 6.0.0
webidl-conversions: 8.0.0
dev: false
/win-release@1.1.1:
resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==}
engines: {node: '>=0.10.0'}
@ -2103,6 +2569,11 @@ packages:
optional: true
dev: false
/xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
dev: false
/xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
@ -2116,6 +2587,10 @@ packages:
engines: {node: '>=4.0'}
dev: false
/xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: false
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@ -2126,6 +2601,11 @@ packages:
engines: {node: '>=10'}
dev: false
/yaml@2.0.0-1:
resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==}
engines: {node: '>= 6'}
dev: false
/yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@ -2151,6 +2631,18 @@ packages:
fd-slicer: 1.1.0
dev: false
/z-schema@5.0.5:
resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==}
engines: {node: '>=8.0.0'}
hasBin: true
dependencies:
lodash.get: 4.4.2
lodash.isequal: 4.5.0
validator: 13.15.23
optionalDependencies:
commander: 9.5.0
dev: false
/zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
dev: false

View File

@ -1,12 +1,75 @@
import express from 'express';
import bodyParser from 'body-parser';
import dotenv from 'dotenv';
import swaggerUi from 'swagger-ui-express';
import specs from './lib/swagger.js';
import cors from 'cors';
// 配置 dotenv
dotenv.config();
// 处理API前缀配置
function getApiPrefix() {
let prefix;
if ('API_PREFIX' in process.env) {
prefix = process.env.API_PREFIX;
} else {
prefix = '/api/v1';
}
// 如果明确设置为空字符串,则返回空
if (prefix === '') {
return '';
}
// 标准化前缀格式:确保以斜杠开头且不以斜杠结尾
if (prefix && !prefix.startsWith('/')) {
prefix = '/' + prefix;
}
if (prefix && prefix.length > 1 && prefix.endsWith('/')) {
prefix = prefix.slice(0, -1);
}
return prefix;
}
// 处理CORS配置
function getCorsConfig() {
const enableCors = process.env.ENABLE_CORS === 'true';
if (!enableCors) {
return null; // 不启用CORS
}
const corsOrigins = process.env.CORS_ORIGINS;
let origins = ['http://localhost:3000']; // 默认安全配置
if (corsOrigins) {
try {
// 支持多个源,用逗号分隔
origins = corsOrigins.split(',').map(origin => origin.trim()).filter(origin => origin);
} catch (error) {
console.error('CORS配置解析错误使用默认配置:', error.message);
}
}
return {
origin: origins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
credentials: true,
optionsSuccessStatus: 200 // 支持老版本浏览器
};
}
const apiPrefix = getApiPrefix();
const corsConfig = getCorsConfig();
import BrowserManager from './lib/browser.js';
import StorageManager from './lib/storage.js';
import {pdfHandler, posterHandler, statusHandler} from './lib/routes.js';
import apiKeyAuth from './lib/auth.js';
import {validationErrorResponse} from './lib/response.js';
// 初始化存储管理器
const storageManager = new StorageManager(process.env);
@ -16,9 +79,55 @@ const browserManager = new BrowserManager();
await browserManager.initBrowser(2);
const app = express();
// 应用CORS中间件如果启用
if (corsConfig) {
app.use(cors(corsConfig));
console.log('✅ CORS已启用允许的源:', corsConfig.origin);
}
app.use(bodyParser.json({limit: '10mb'}));
// 配置Swagger UI
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
// 提供上传文件的静态访问
app.use('/uploads', express.static('uploads'));
/**
* @swagger
* /status:
* get:
* summary: 健康检查
* description: 检查服务是否正常运行
* tags: [Health]
* responses:
* 200:
* description: 服务正常运行
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* description: 请求是否成功
* data:
* type: object
* example: {}
* description: 响应数据
* message:
* type: string
* example: "Service is running"
* description: 响应消息
* code:
* type: number
* example: 200
* description: 响应代码
* 500:
* description: 服务异常
*/
// 健康检查接口
app.get('/status', statusHandler);
@ -31,10 +140,10 @@ app.use((req, res, next) => {
});
// 海报生成接口
app.post('/api/v1/poster', async function (req, res) {
app.post(`${apiPrefix}/poster`, apiKeyAuth, async function (req, res) {
// 基本的安全检查
if (!req.headers['content-type'] || !req.headers['content-type'].includes('application/json')) {
return res.status(400).json({error: 'Content-Type must be application/json'});
return res.status(400).json(validationErrorResponse('Content-Type must be application/json', 3002));
}
await posterHandler(
@ -46,10 +155,10 @@ app.post('/api/v1/poster', async function (req, res) {
});
// PDF生成接口
app.post('/api/v1/pdf', async function (req, res) {
app.post(`${apiPrefix}/pdf`, apiKeyAuth, async function (req, res) {
// 基本的安全检查
if (!req.headers['content-type'] || !req.headers['content-type'].includes('application/json')) {
return res.status(400).json({error: 'Content-Type must be application/json'});
return res.status(400).json(validationErrorResponse('Content-Type must be application/json', 3002));
}
await pdfHandler(
@ -61,4 +170,18 @@ app.post('/api/v1/pdf', async function (req, res) {
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Puppeteer app listening on port ${port}!`));
// 服务器启动时显示配置信息
console.log('🚀 服务器配置信息:');
console.log(` 端口: ${port}`);
console.log(` API前缀: ${apiPrefix}`);
console.log(` CORS状态: ${corsConfig ? '已启用' : '已禁用'}`);
if (corsConfig) {
console.log(` 允许的源: ${corsConfig.origin.join(', ')}`);
}
console.log(' 健康检查: /status');
console.log(` 海报API: ${apiPrefix}/poster`);
console.log(` PDF API: ${apiPrefix}/pdf`);
console.log(' API文档: /api-docs');
app.listen(port, () => console.log(`\n✨ Puppeteer app listening on port ${port}!`));

1
swagger.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
/**
* Simple test to validate API response standardization
* This test checks if the modules can be imported without errors
*/
import {successResponse, errorResponse, validationErrorResponse, serverErrorResponse} from './lib/response.js';
import {posterHandler, pdfHandler, statusHandler} from './lib/routes.js';
import StorageManager from './lib/storage.js';
import BrowserManager from './lib/browser.js';
console.log("Testing response utility functions...");
// Test success response
const successResp = successResponse({test: "data"}, "Test message", 200);
console.log("Success response:", successResp);
console.assert(successResp.success === true, "Success response should have success=true");
console.assert(successResp.data !== undefined, "Success response should have data");
console.assert(successResp.message === "Test message", "Success response should have correct message");
console.assert(successResp.code === 200, "Success response should have correct code");
// Test error response
const errorResp = errorResponse("Error message", 400);
console.log("Error response:", errorResp);
console.assert(errorResp.success === false, "Error response should have success=false");
console.assert(errorResp.data === null, "Error response should have data=null");
console.assert(errorResp.message === "Error message", "Error response should have correct message");
console.assert(errorResp.code === 400, "Error response should have correct code");
// Test validation error response
const validationResp = validationErrorResponse("Validation error", 3001);
console.log("Validation error response:", validationResp);
console.assert(validationResp.code === 3001, "Validation error response should have correct code");
// Test server error response
const serverResp = serverErrorResponse("Server error", 5000);
console.log("Server error response:", serverResp);
console.assert(serverResp.code === 5000, "Server error response should have correct code");
console.log("All response utility tests passed!");
// Test that route handlers exist
console.log("Route handlers exist:", typeof posterHandler, typeof pdfHandler, typeof statusHandler);

55
tests/test-swagger.js Normal file
View File

@ -0,0 +1,55 @@
/**
* 测试Swagger规范生成
*/
import swaggerJsdoc from 'swagger-jsdoc';
import fs from 'fs';
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'ERPTurbo_Poster API',
version: '1.0.0',
description: '海报和PDF生成服务API文档',
},
servers: [
{
url: 'http://localhost:3000',
description: '开发服务器'
}
]
},
apis: ['./server.mjs', './lib/routes.js'], // 包含API注释的文件
};
try {
const specs = swaggerJsdoc(options);
console.log('✅ Swagger规范生成成功');
console.log(`📊 发现 ${specs.paths ? Object.keys(specs.paths).length : 0} 个API端点`);
// 保存生成的规范到文件,以便查看
fs.writeFileSync('./swagger.json', JSON.stringify(specs, null, 2));
console.log('📄 Swagger规范已保存到 swagger.json');
// 验证关键端点是否存在
const paths = specs.paths;
if (paths['/api/v1/poster'] && paths['/api/v1/poster'].post) {
console.log('✅ 海报API端点文档已生成');
} else {
console.log('❌ 海报API端点文档未找到');
}
if (paths['/api/v1/pdf'] && paths['/api/v1/pdf'].post) {
console.log('✅ PDF API端点文档已生成');
} else {
console.log('❌ PDF API端点文档未找到');
}
if (paths['/status'] && paths['/status'].get) {
console.log('✅ 健康检查API端点文档已生成');
} else {
console.log('❌ 健康检查API端点文档未找到');
}
} catch (error) {
console.error('❌ Swagger规范生成失败:', error.message);
}

36
tests/test_html_poster.js Normal file
View File

@ -0,0 +1,36 @@
import fetch from 'node-fetch';
// 测试从HTML内容生成海报
async function testHtmlPoster() {
const testData = {
html: '<!DOCTYPE html><html><head><title>Test Poster</title></head><body><h1>Test HTML Content</h1><p>This is a test of HTML poster generation.</p></body></html>',
width: 800,
height: 600,
type: 'png',
device: 1
};
try {
console.log('Sending request to generate poster from HTML...');
const response = await fetch('http://localhost:3000/api/v1/poster', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer abc123'
},
body: JSON.stringify(testData)
});
if (response.ok) {
const result = await response.json();
console.log('Success:', result);
} else {
console.error('Error:', response.status, await response.text());
}
} catch (error) {
console.error('Request failed:', error);
}
}
// 执行测试
testHtmlPoster();