diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5d01172 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Build stage for frontend +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/web + +# Copy frontend package files +COPY web/MS_OAuth2API_Next_Web/package*.json ./ + +# Install frontend dependencies +RUN npm install + +# Copy frontend source code +COPY web/MS_OAuth2API_Next_Web/ ./ + +# Build frontend (skip type-check for faster builds) +RUN npm run build-only + +# Production stage for backend +FROM node:20-alpine + +# Install wget for healthcheck +RUN apk add --no-cache wget + +WORKDIR /app + +# Copy backend package files +COPY package*.json ./ + +# Install backend dependencies (production only) +RUN npm install --omit=dev + +# Copy backend source code +COPY config ./config +COPY controllers ./controllers +COPY middlewares ./middlewares +COPY routes ./routes +COPY services ./services +COPY utils ./utils +COPY main.js ./ + +# Copy built frontend assets to public directory +RUN mkdir -p public +COPY --from=frontend-builder /app/web/dist ./public + +# Create logs directory with proper permissions +RUN mkdir -p logs && chmod 755 logs + +# Set environment defaults +ENV NODE_ENV=production +ENV PORT=13000 + +# Expose port +EXPOSE 13000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:13000/ || exit 1 + +# Start command +CMD ["node", "main.js"] diff --git a/README.md b/README.md index 594476c..b78db26 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,58 @@ - 支持邮箱验证 - 支持邮箱清空 - 支持邮件查看 -- 支持Docker部署(TODO 待实现) +- 支持Docker部署 + +### Docker 部署 + +#### 快速开始 + +1. **构建并启动** + ```bash + docker-compose up -d --build + ``` + +2. **查看日志** + ```bash + docker-compose logs -f app + ``` + +3. **停止服务** + ```bash + docker-compose down + ``` + +4. **重启服务** + ```bash + docker-compose restart + ``` + +#### 端口映射 + +| 服务 | 容器内部端口 | 外部映射端口 | +|------|-------------|-------------| +| App | 13000 | 13000 | +| Redis| 6379 | 16379 | +| MySQL| 3306 | 13306 | + +#### 启用 MySQL (可选) + +如需使用 MySQL,编辑 `docker-compose.yml`: +1. 取消 `mysql` 服务的注释 +2. 取消 `app` 服务中 `DB_*` 环境变量的注释 +3. 取消 `depends_on` 中 `mysql` 的注释 +4. 取消 `volumes` 中 `mysql_data` 的注释 + +#### 环境变量配置 + +参考 `.env.example` 文件了解所有可用配置项。在 Docker 环境中,环境变量通过 `docker-compose.yml` 配置。 + +#### 注意事项 +- 应用服务端口: `13000` +- Redis 外部端口: `16379` (容器内 `6379`) +- MySQL 外部端口: `13306` (容器内 `3306`) +- Redis 数据会持久化到 Docker Volume +- 首次构建需编译前端,可能需要几分钟 ### 使用说明 - Redis 配置 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..782900c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +services: + app: + build: . + ports: + - "13000:13000" + environment: + - NODE_ENV=production + - PORT=13000 + - USE_REDIS=1 + - REDIS_HOST=redis + - REDIS_PORT=6379 + ## ===== MySQL Config (uncomment below lines to enable) ===== + #- DB_HOST=mysql + #- DB_PORT=3306 + #- DB_USER=root + #- DB_PASSWORD=ms_oauth2api_root + #- DB_DATABASE=ms_oauth2api + #- DB_WAIT_FOR_CONNECTIONS=true + #- DB_CONNECTION_LIMIT=10 + depends_on: + - redis + #- mysql + restart: unless-stopped + networks: + - app-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:13000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + redis: + image: redis:alpine + ports: + - "16379:6379" + restart: unless-stopped + networks: + - app-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - redis_data:/data + command: redis-server --appendonly yes + + ## ===== MySQL Service (uncomment below to enable) ===== + #mysql: + # image: mysql:8.0 + # ports: + # - "13306:3306" + # environment: + # - MYSQL_ROOT_PASSWORD=ms_oauth2api_root + # - MYSQL_DATABASE=ms_oauth2api + # - MYSQL_CHARACTER_SET_SERVER=utf8mb4 + # - MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci + # restart: unless-stopped + # networks: + # - app-network + # healthcheck: + # test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pms_oauth2api_root"] + # interval: 10s + # timeout: 5s + # retries: 10 + # start_period: 30s + # volumes: + # - mysql_data:/var/lib/mysql + # command: --default-authentication-plugin=mysql_native_password + +networks: + app-network: + driver: bridge + +volumes: + redis_data: + #mysql_data: diff --git a/main.js b/main.js index d517477..93aa15a 100644 --- a/main.js +++ b/main.js @@ -13,12 +13,15 @@ const config = require('./config') const logger = require('./utils/logger') app.use(require('./middlewares/logger')) +// 初始化数据库连接(如果配置了的话) +require('./utils/db') + // 错误处理 const errorHandler = require('./middlewares/error') app.use(errorHandler) // 静态资源 -app.use(static(path.join(__dirname, '../public'))) +app.use(static(path.join(__dirname, './public'))) // 请求体解析 app.use(koaBody({ diff --git a/services/BaseService.js b/services/BaseService.js index 833300f..4c4c972 100644 --- a/services/BaseService.js +++ b/services/BaseService.js @@ -29,6 +29,9 @@ class BaseService { * @returns {Promise} 查询结果 */ async query(sql, params = []) { + if (!pool) { + throw new Error('Database not configured. Please set DB_HOST and DB_DATABASE environment variables.'); + } try { const [rows] = await pool.query(sql, params); return rows; @@ -44,7 +47,7 @@ class BaseService { */ async findAll(fields = ['*']) { if (!validateFieldName(fields) && fields[0] !== '*') { - throw new Error('Invalid field names'); + throw new Error('Invalid field names'); } const fieldList = fields.join(', '); return this.query(`SELECT ${fieldList} FROM ??`, [this.tableName]); @@ -78,15 +81,15 @@ class BaseService { if (!validateFieldName(fields)) { throw new Error('Invalid field names'); } - + const values = fields.map(field => data[field]); const placeholders = fields.map(() => '?').join(', '); - + const [result] = await pool.query( `INSERT INTO ?? (??) VALUES (${placeholders})`, [this.tableName, fields, ...values] ); - + return result.insertId; } @@ -101,15 +104,15 @@ class BaseService { if (!validateFieldName(fields)) { throw new Error('Invalid field names'); } - + const setClause = fields.map(field => `${field} = ?`).join(', '); const values = fields.map(field => data[field]); - + const [result] = await pool.query( `UPDATE ?? SET ${setClause} WHERE id = ?`, [this.tableName, ...values, id] ); - + return result.affectedRows > 0; } @@ -139,40 +142,40 @@ class BaseService { offset = 0, orderBy = 'id ASC' } = options; - + if (!validateFieldName(fields) && fields[0] !== '*') { throw new Error('Invalid field names'); } - + const conditionFields = Object.keys(conditions); if (!validateFieldName(conditionFields)) { throw new Error('Invalid condition field names'); } - + const fieldList = fields.join(', '); let whereClause = '1 = 1'; const values = []; - + if (conditionFields.length > 0) { whereClause = conditionFields.map(field => { values.push(conditions[field]); return `${field} = ?`; }).join(' AND '); } - + // 验证 orderBy const orderByParts = orderBy.split(' '); if (orderByParts.length > 2 || (orderByParts[1] && !['ASC', 'DESC'].includes(orderByParts[1].toUpperCase()))) { throw new Error('Invalid orderBy parameter'); } - + const sql = ` SELECT ${fieldList} FROM ?? WHERE ${whereClause} ORDER BY ${orderBy} LIMIT ? OFFSET ? `; - + return this.query(sql, [this.tableName, ...values, limit, offset]); } @@ -186,22 +189,22 @@ class BaseService { if (conditionFields.length > 0 && !validateFieldName(conditionFields)) { throw new Error('Invalid condition field names'); } - + let whereClause = '1 = 1'; const values = []; - + if (conditionFields.length > 0) { whereClause = conditionFields.map(field => { values.push(conditions[field]); return `${field} = ?`; }).join(' AND '); } - + const [result] = await pool.query( `SELECT COUNT(*) AS count FROM ?? WHERE ${whereClause}`, [this.tableName, ...values] ); - + return result[0].count; } diff --git a/utils/db.js b/utils/db.js index 9c7232b..c2a4c84 100644 --- a/utils/db.js +++ b/utils/db.js @@ -1,14 +1,22 @@ const mysql = require('mysql2/promise'); -const pool = mysql.createPool({ - host: process.env.DB_HOST, - port: process.env.DB_PORT, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE, - waitForConnections: process.env.DB_WAIT_FOR_CONNECTIONS, - connectionLimit: process.env.DB_CONNECTION_LIMIT, - queueLimit: process.env.DB_QUEUE_LIMIT, -}); +let pool = null; + +// Only create pool if database configuration is provided +if (process.env.DB_HOST && process.env.DB_DATABASE) { + pool = mysql.createPool({ + host: process.env.DB_HOST, + port: process.env.DB_PORT || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_DATABASE, + waitForConnections: process.env.DB_WAIT_FOR_CONNECTIONS !== 'false', + connectionLimit: parseInt(process.env.DB_CONNECTION_LIMIT) || 10, + queueLimit: parseInt(process.env.DB_QUEUE_LIMIT) || 0, + }); + console.log('✅ MySQL pool created'); +} else { + console.log('ℹ️ MySQL not configured, database features disabled'); +} module.exports = pool; diff --git a/web/MS_OAuth2API_Next_Web/src/views/email/index.vue b/web/MS_OAuth2API_Next_Web/src/views/email/index.vue index 15257a8..4c5cfc9 100644 --- a/web/MS_OAuth2API_Next_Web/src/views/email/index.vue +++ b/web/MS_OAuth2API_Next_Web/src/views/email/index.vue @@ -236,9 +236,9 @@ const parseFileContent = (file: UploadRawFile) => { return } - const matches = content.split('\n'); + const matches = content.split('\n').map(item => item.trim()).filter(item => item !== ''); - if (matches) { + if (matches.length > 0) { emailList.value = matches } else { ElMessage.warning('未在文件中找到邮箱地址')