Переглянути джерело

feat(contact): 添加联系表单功能,包括模型、路由和邮件服务

- 创建Contact模型和数据库迁移
- 实现联系表单提交API和后台管理API
- 添加邮件通知服务,包括管理员通知和客户自动回复
- 更新数据库配置支持SSL连接
- 添加nodemailer依赖
你的用户名 8 годин тому
батько
коміт
2a7f7c1b3b
8 змінених файлів з 533 додано та 3 видалено
  1. 2 0
      app.js
  2. 21 3
      config/config.json
  3. 71 0
      migrations/20250101000000-create-contact.js
  4. 69 0
      models/contact.js
  5. 10 0
      package-lock.json
  6. 1 0
      package.json
  7. 234 0
      routes/contact.js
  8. 125 0
      utils/emailService.js

+ 2 - 0
app.js

@@ -8,6 +8,7 @@ const cors = require('cors')
 const indexRouter = require('./routes/index');
 const categoryRouter = require('./routes/category');
 const newsRouter = require('./routes/news');
+const contactRouter = require('./routes/contact');
 
 //文章路由文件
 const adminArticlesRouter = require('./routes/admin/articles');
@@ -37,5 +38,6 @@ app.use('/', indexRouter);
 app.use('/admin/articles', adminArticlesRouter);
 app.use('/category', categoryRouter);
 app.use('/news', newsRouter);
+app.use('/contact', contactRouter);
 
 module.exports = app;

+ 21 - 3
config/config.json

@@ -6,7 +6,13 @@
     "host": "8.210.65.64",
     "dialect": "mysql",
     "timezone":"+08:00",
-    "logQueryParameters":true
+    "logQueryParameters":true,
+    "dialectOptions": {
+      "ssl": {
+        "require": false,
+        "rejectUnauthorized": false
+      }
+    }
   },
   "test": {
     "username": "feiniao_site_node_development",
@@ -14,7 +20,13 @@
     "database": "feiniao_site_node_development",
     "host": "8.210.65.64",
     "dialect": "mysql",
-    "timezone":"+08:00"
+    "timezone":"+08:00",
+    "dialectOptions": {
+      "ssl": {
+        "require": false,
+        "rejectUnauthorized": false
+      }
+    }
   },
   "production": { 
     "username": "feiniao_site_node_development",
@@ -22,6 +34,12 @@
     "database": "feiniao_site_node_development",
     "host": "8.210.65.64",
     "dialect": "mysql",
-    "timezone":"+08:00"
+    "timezone":"+08:00",
+    "dialectOptions": {
+      "ssl": {
+        "require": false,
+        "rejectUnauthorized": false
+      }
+    }
   }
 }

+ 71 - 0
migrations/20250101000000-create-contact.js

@@ -0,0 +1,71 @@
+'use strict';
+
+module.exports = {
+  up: async (queryInterface, Sequelize) => {
+    await queryInterface.createTable('contacts', {
+      id: {
+        allowNull: false,
+        autoIncrement: true,
+        primaryKey: true,
+        type: Sequelize.INTEGER
+      },
+      name: {
+        type: Sequelize.STRING(100),
+        allowNull: false,
+        comment: '联系人姓名'
+      },
+      email: {
+        type: Sequelize.STRING(255),
+        allowNull: false,
+        comment: '联系人邮箱'
+      },
+      phone: {
+        type: Sequelize.STRING(20),
+        allowNull: true,
+        comment: '联系电话'
+      },
+      company: {
+        type: Sequelize.STRING(200),
+        allowNull: true,
+        comment: '公司名称'
+      },
+      subject: {
+        type: Sequelize.STRING(200),
+        allowNull: false,
+        comment: '留言主题'
+      },
+      message: {
+        type: Sequelize.TEXT,
+        allowNull: false,
+        comment: '留言内容'
+      },
+      status: {
+        type: Sequelize.INTEGER,
+        defaultValue: 0,
+        comment: '处理状态:0-未处理,1-已处理'
+      },
+      isEmailSent: {
+        type: Sequelize.BOOLEAN,
+        defaultValue: false,
+        comment: '是否已发送邮件通知'
+      },
+      createdAt: {
+        allowNull: false,
+        type: Sequelize.DATE
+      },
+      updatedAt: {
+        allowNull: false,
+        type: Sequelize.DATE
+      }
+    });
+
+    // 添加索引
+    await queryInterface.addIndex('contacts', ['email']);
+    await queryInterface.addIndex('contacts', ['status']);
+    await queryInterface.addIndex('contacts', ['createdAt']);
+  },
+
+  down: async (queryInterface, Sequelize) => {
+    await queryInterface.dropTable('contacts');
+  }
+};

+ 69 - 0
models/contact.js

@@ -0,0 +1,69 @@
+const { DataTypes } = require('sequelize');
+const sequelize = require('./index').sequelize;
+
+const Contact = sequelize.define('Contact', {
+  id: {
+    type: DataTypes.INTEGER,
+    primaryKey: true,
+    autoIncrement: true
+  },
+  name: {
+    type: DataTypes.STRING(100),
+    allowNull: false,
+    comment: '联系人姓名'
+  },
+  email: {
+    type: DataTypes.STRING(255),
+    allowNull: false,
+    validate: {
+      isEmail: true
+    },
+    comment: '联系人邮箱'
+  },
+  phone: {
+    type: DataTypes.STRING(20),
+    allowNull: true,
+    comment: '联系电话'
+  },
+  company: {
+    type: DataTypes.STRING(200),
+    allowNull: true,
+    comment: '公司名称'
+  },
+  subject: {
+    type: DataTypes.STRING(200),
+    allowNull: false,
+    comment: '留言主题'
+  },
+  message: {
+    type: DataTypes.TEXT,
+    allowNull: false,
+    comment: '留言内容'
+  },
+  status: {
+    type: DataTypes.INTEGER,
+    defaultValue: 0,
+    comment: '处理状态:0-未处理,1-已处理'
+  },
+  isEmailSent: {
+    type: DataTypes.BOOLEAN,
+    defaultValue: false,
+    comment: '是否已发送邮件通知'
+  }
+}, {
+  tableName: 'contacts',
+  timestamps: true,
+  indexes: [
+    {
+      fields: ['email']
+    },
+    {
+      fields: ['status']
+    },
+    {
+      fields: ['createdAt']
+    }
+  ]
+});
+
+module.exports = Contact;

+ 10 - 0
package-lock.json

@@ -14,6 +14,7 @@
         "express": "~4.16.1",
         "morgan": "~1.9.1",
         "mysql2": "^3.12.0",
+        "nodemailer": "^7.0.6",
         "nodemon": "^3.1.9",
         "sequelize": "^6.37.5"
       },
@@ -723,6 +724,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/nodemailer": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-7.0.6.tgz",
+      "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/nodemon": {
       "version": "3.1.9",
       "resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.9.tgz",

+ 1 - 0
package.json

@@ -13,6 +13,7 @@
     "express": "~4.16.1",
     "morgan": "~1.9.1",
     "mysql2": "^3.12.0",
+    "nodemailer": "^7.0.6",
     "nodemon": "^3.1.9",
     "sequelize": "^6.37.5"
   },

+ 234 - 0
routes/contact.js

@@ -0,0 +1,234 @@
+const express = require('express');
+const router = express.Router();
+const Contact = require('../models/contact');
+const { sendContactNotification, sendAutoReply } = require('../utils/emailService');
+const { successResponse, errorResponse } = require('../utils/responese');
+
+/**
+ * @api {post} /contact 提交联系我们表单
+ * @apiName CreateContact
+ * @apiGroup Contact
+ * 
+ * @apiParam {String} name 联系人姓名(必填)
+ * @apiParam {String} email 联系人邮箱(必填)
+ * @apiParam {String} [phone] 联系电话
+ * @apiParam {String} [company] 公司名称
+ * @apiParam {String} subject 留言主题(必填)
+ * @apiParam {String} message 留言内容(必填)
+ * 
+ * @apiSuccess {Boolean} status 请求状态
+ * @apiSuccess {String} message 响应消息
+ * @apiSuccess {Object} data 创建的联系信息
+ * 
+ * @apiSuccessExample {json} Success-Response:
+ * HTTP/1.1 201 Created
+ * {
+ *   "status": true,
+ *   "message": "留言提交成功,我们会尽快回复您",
+ *   "data": {
+ *     "id": 1,
+ *     "message": "感谢您的留言,我们已收到并会在24小时内回复"
+ *   }
+ * }
+ */
+router.post('/', async (req, res) => {
+  try {
+    const { name, email, phone, company, subject, message } = req.body;
+    
+    // 参数验证
+    const errors = [];
+    if (!name || name.trim().length === 0) {
+      errors.push('联系人姓名不能为空');
+    }
+    if (!email || email.trim().length === 0) {
+      errors.push('联系人邮箱不能为空');
+    }
+    if (!subject || subject.trim().length === 0) {
+      errors.push('留言主题不能为空');
+    }
+    if (!message || message.trim().length === 0) {
+      errors.push('留言内容不能为空');
+    }
+    
+    // 邮箱格式验证
+    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+    if (email && !emailRegex.test(email)) {
+      errors.push('邮箱格式不正确');
+    }
+    
+    // 字符长度验证
+    if (name && name.trim().length > 100) {
+      errors.push('联系人姓名不能超过100个字符');
+    }
+    if (subject && subject.trim().length > 200) {
+      errors.push('留言主题不能超过200个字符');
+    }
+    if (company && company.trim().length > 200) {
+      errors.push('公司名称不能超过200个字符');
+    }
+    if (phone && phone.trim().length > 20) {
+      errors.push('联系电话不能超过20个字符');
+    }
+    
+    if (errors.length > 0) {
+      return res.status(400).json(errorResponse('参数验证失败', errors));
+    }
+    
+    // 保存到数据库
+    const contactData = await Contact.create({
+      name: name.trim(),
+      email: email.trim().toLowerCase(),
+      phone: phone ? phone.trim() : null,
+      company: company ? company.trim() : null,
+      subject: subject.trim(),
+      message: message.trim()
+    });
+    
+    // 异步发送邮件(不阻塞响应)
+    setImmediate(async () => {
+      try {
+        // 发送通知邮件给管理员
+        const notificationSent = await sendContactNotification(contactData.toJSON());
+        
+        // 发送自动回复给客户
+        const autoReplySent = await sendAutoReply(contactData.toJSON());
+        
+        // 更新邮件发送状态
+        if (notificationSent || autoReplySent) {
+          await contactData.update({ isEmailSent: true });
+        }
+        
+        console.log(`联系表单处理完成 - ID: ${contactData.id}, 通知邮件: ${notificationSent ? '成功' : '失败'}, 自动回复: ${autoReplySent ? '成功' : '失败'}`);
+      } catch (emailError) {
+        console.error('邮件发送过程出错:', emailError);
+      }
+    });
+    
+    // 立即返回成功响应
+    res.status(201).json(successResponse('留言提交成功,我们会尽快回复您', {
+      id: contactData.id,
+      message: '感谢您的留言,我们已收到并会在24小时内回复'
+    }));
+    
+  } catch (error) {
+    console.error('提交联系表单失败:', error);
+    res.status(500).json(errorResponse('服务器内部错误'));
+  }
+});
+
+/**
+ * @api {get} /contact 获取联系我们列表(管理员用)
+ * @apiName GetContacts
+ * @apiGroup Contact
+ * 
+ * @apiParam {Number} [page=1] 页码
+ * @apiParam {Number} [limit=10] 每页数量
+ * @apiParam {Number} [status] 处理状态筛选(0-未处理,1-已处理)
+ */
+router.get('/', async (req, res) => {
+  try {
+    const { page = 1, limit = 10, status } = req.query;
+    const offset = (page - 1) * limit;
+    
+    const whereConditions = {};
+    if (status !== undefined) {
+      whereConditions.status = parseInt(status);
+    }
+    
+    const { count, rows } = await Contact.findAndCountAll({
+      where: whereConditions,
+      order: [['createdAt', 'DESC']],
+      limit: parseInt(limit),
+      offset: parseInt(offset)
+    });
+    
+    res.json(successResponse('获取成功', {
+      contacts: rows,
+      pagination: {
+        total: count,
+        page: parseInt(page),
+        limit: parseInt(limit),
+        totalPages: Math.ceil(count / limit)
+      }
+    }));
+  } catch (error) {
+    console.error('获取联系列表失败:', error);
+    res.status(500).json(errorResponse('服务器内部错误'));
+  }
+});
+
+/**
+ * @api {get} /contact/:id 获取联系详情
+ * @apiName GetContactDetail
+ * @apiGroup Contact
+ */
+router.get('/:id', async (req, res) => {
+  try {
+    const { id } = req.params;
+    
+    const contact = await Contact.findByPk(id);
+    if (!contact) {
+      return res.status(404).json(errorResponse('联系记录不存在'));
+    }
+    
+    res.json(successResponse('获取成功', contact));
+  } catch (error) {
+    console.error('获取联系详情失败:', error);
+    res.status(500).json(errorResponse('服务器内部错误'));
+  }
+});
+
+/**
+ * @api {put} /contact/:id/status 更新处理状态
+ * @apiName UpdateContactStatus
+ * @apiGroup Contact
+ * 
+ * @apiParam {Number} status 处理状态(0-未处理,1-已处理)
+ */
+router.put('/:id/status', async (req, res) => {
+  try {
+    const { id } = req.params;
+    const { status } = req.body;
+    
+    if (![0, 1].includes(parseInt(status))) {
+      return res.status(400).json(errorResponse('状态值只能是0或1'));
+    }
+    
+    const contact = await Contact.findByPk(id);
+    if (!contact) {
+      return res.status(404).json(errorResponse('联系记录不存在'));
+    }
+    
+    await contact.update({ status: parseInt(status) });
+    
+    res.json(successResponse('状态更新成功', contact));
+  } catch (error) {
+    console.error('更新联系状态失败:', error);
+    res.status(500).json(errorResponse('服务器内部错误'));
+  }
+});
+
+/**
+ * @api {delete} /contact/:id 删除联系记录
+ * @apiName DeleteContact
+ * @apiGroup Contact
+ */
+router.delete('/:id', async (req, res) => {
+  try {
+    const { id } = req.params;
+    
+    const contact = await Contact.findByPk(id);
+    if (!contact) {
+      return res.status(404).json(errorResponse('联系记录不存在'));
+    }
+    
+    await contact.destroy();
+    
+    res.json(successResponse('删除成功'));
+  } catch (error) {
+    console.error('删除联系记录失败:', error);
+    res.status(500).json(errorResponse('服务器内部错误'));
+  }
+});
+
+module.exports = router;

+ 125 - 0
utils/emailService.js

@@ -0,0 +1,125 @@
+const nodemailer = require('nodemailer');
+
+// 邮件配置 - 请根据实际情况修改
+const emailConfig = {
+  // 使用QQ邮箱示例,您可以根据需要修改为其他邮箱服务
+  service: 'qq', // 或者使用 'gmail', '163', 'outlook' 等
+  auth: {
+    user: 'your-email@qq.com', // 发送邮箱 - 请修改为实际邮箱
+    pass: 'your-app-password'   // 邮箱授权码(不是登录密码)- 请修改为实际授权码
+  }
+};
+
+// 接收邮箱配置 - 请修改为实际接收邮箱
+const receiverEmail = 'admin@yourcompany.com'; // 接收留言的邮箱
+
+// 创建邮件传输器
+const transporter = nodemailer.createTransporter(emailConfig);
+
+/**
+ * 发送联系我们留言通知邮件
+ * @param {Object} contactData 联系信息
+ * @returns {Promise<boolean>} 发送结果
+ */
+async function sendContactNotification(contactData) {
+  try {
+    const { name, email, phone, company, subject, message, createdAt } = contactData;
+    
+    const mailOptions = {
+      from: emailConfig.auth.user,
+      to: receiverEmail,
+      subject: `【网站留言】${subject}`,
+      html: `
+        <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+          <h2 style="color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px;">
+            新的联系我们留言
+          </h2>
+          
+          <div style="background-color: #f9f9f9; padding: 20px; border-radius: 5px; margin: 20px 0;">
+            <h3 style="color: #555; margin-top: 0;">联系人信息</h3>
+            <p><strong>姓名:</strong> ${name}</p>
+            <p><strong>邮箱:</strong> <a href="mailto:${email}">${email}</a></p>
+            ${phone ? `<p><strong>电话:</strong> ${phone}</p>` : ''}
+            ${company ? `<p><strong>公司:</strong> ${company}</p>` : ''}
+            <p><strong>留言时间:</strong> ${new Date(createdAt).toLocaleString('zh-CN')}</p>
+          </div>
+          
+          <div style="background-color: #fff; padding: 20px; border: 1px solid #ddd; border-radius: 5px;">
+            <h3 style="color: #555; margin-top: 0;">留言主题</h3>
+            <p style="font-size: 16px; font-weight: bold; color: #333;">${subject}</p>
+            
+            <h3 style="color: #555;">留言内容</h3>
+            <div style="background-color: #f5f5f5; padding: 15px; border-radius: 3px; line-height: 1.6;">
+              ${message.replace(/\n/g, '<br>')}
+            </div>
+          </div>
+          
+          <div style="margin-top: 20px; padding: 15px; background-color: #e8f5e8; border-radius: 5px;">
+            <p style="margin: 0; color: #666; font-size: 14px;">
+              <strong>提示:</strong>请及时回复客户留言,可直接回复到客户邮箱:<a href="mailto:${email}">${email}</a>
+            </p>
+          </div>
+        </div>
+      `
+    };
+
+    const result = await transporter.sendMail(mailOptions);
+    console.log('邮件发送成功:', result.messageId);
+    return true;
+  } catch (error) {
+    console.error('邮件发送失败:', error);
+    return false;
+  }
+}
+
+/**
+ * 发送自动回复邮件给客户
+ * @param {Object} contactData 联系信息
+ * @returns {Promise<boolean>} 发送结果
+ */
+async function sendAutoReply(contactData) {
+  try {
+    const { name, email, subject } = contactData;
+    
+    const mailOptions = {
+      from: emailConfig.auth.user,
+      to: email,
+      subject: `感谢您的留言 - ${subject}`,
+      html: `
+        <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+          <h2 style="color: #4CAF50;">感谢您的留言!</h2>
+          
+          <p>尊敬的 ${name},</p>
+          
+          <p>感谢您通过我们的网站联系我们。我们已经收到您的留言,我们的工作人员会在24小时内回复您。</p>
+          
+          <div style="background-color: #f9f9f9; padding: 15px; border-radius: 5px; margin: 20px 0;">
+            <p><strong>您的留言主题:</strong>${subject}</p>
+            <p><strong>提交时间:</strong>${new Date().toLocaleString('zh-CN')}</p>
+          </div>
+          
+          <p>如有紧急事务,请直接拨打我们的客服电话:<strong>400-xxx-xxxx</strong></p>
+          
+          <hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
+          
+          <p style="color: #666; font-size: 14px;">
+            此邮件为系统自动发送,请勿直接回复。<br>
+            如需联系我们,请访问:<a href="https://yourwebsite.com">https://yourwebsite.com</a>
+          </p>
+        </div>
+      `
+    };
+
+    const result = await transporter.sendMail(mailOptions);
+    console.log('自动回复邮件发送成功:', result.messageId);
+    return true;
+  } catch (error) {
+    console.error('自动回复邮件发送失败:', error);
+    return false;
+  }
+}
+
+module.exports = {
+  sendContactNotification,
+  sendAutoReply
+};