commit
3b333515e5
7 changed files with 2893 additions and 0 deletions
@ -0,0 +1,28 @@ |
|||
# 依赖目录 |
|||
node_modules/ |
|||
|
|||
# 日志文件 |
|||
*.log |
|||
|
|||
# 环境变量文件 |
|||
.env |
|||
.env.local |
|||
.env.*.local |
|||
|
|||
# 临时文件 |
|||
*.tmp |
|||
*.temp |
|||
|
|||
# 编辑器文件 |
|||
.vscode/ |
|||
.idea/ |
|||
*.swp |
|||
*.swo |
|||
|
|||
# 操作系统文件 |
|||
Thumbs.db |
|||
.DS_Store |
|||
|
|||
# 构建输出 |
|||
dist/ |
|||
build/ |
|||
@ -0,0 +1,713 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="zh-CN"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>合格证信息</title> |
|||
<style> |
|||
* { |
|||
margin: 0; |
|||
padding: 0; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
body { |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; |
|||
background-color: #f5f5f5; |
|||
color: #333; |
|||
} |
|||
|
|||
.container { |
|||
max-width: 480px; |
|||
margin: 0 auto; |
|||
background-color: white; |
|||
min-height: 100vh; |
|||
position: relative; |
|||
} |
|||
|
|||
.header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 16px; |
|||
background-color: #28a745; |
|||
color: white; |
|||
position: sticky; |
|||
top: 0; |
|||
z-index: 100; |
|||
} |
|||
|
|||
.back-btn { |
|||
background: none; |
|||
border: none; |
|||
color: white; |
|||
font-size: 24px; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.header h1 { |
|||
font-size: 18px; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.header-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.more-btn, .scan-btn { |
|||
background: none; |
|||
border: none; |
|||
color: white; |
|||
font-size: 18px; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.certificate { |
|||
margin: 16px; |
|||
padding: 16px; |
|||
background-color: white; |
|||
border-radius: 8px; |
|||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.promise-badge { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
background-color: #e6f7ee; |
|||
color: #28a745; |
|||
padding: 8px 12px; |
|||
border-radius: 4px; |
|||
margin-bottom: 16px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.promise-badge span { |
|||
width: 20px; |
|||
height: 20px; |
|||
background-color: #28a745; |
|||
color: white; |
|||
border-radius: 50%; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.promise-text { |
|||
margin-bottom: 20px; |
|||
line-height: 1.6; |
|||
} |
|||
|
|||
.promise-item { |
|||
margin-top: 8px; |
|||
padding-left: 20px; |
|||
} |
|||
|
|||
.info-item { |
|||
padding: 10px 0; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
display: flex; |
|||
align-items: flex-start; |
|||
} |
|||
|
|||
.info-item:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.label { |
|||
font-weight: 500; |
|||
min-width: 80px; |
|||
color: #666; |
|||
} |
|||
|
|||
.value { |
|||
flex: 1; |
|||
color: #333; |
|||
} |
|||
|
|||
.empty-state { |
|||
text-align: center; |
|||
padding: 40px 20px; |
|||
color: #999; |
|||
} |
|||
|
|||
.verify-btn { |
|||
width: 100%; |
|||
padding: 12px; |
|||
background-color: #28a745; |
|||
color: white; |
|||
border: none; |
|||
border-radius: 4px; |
|||
font-size: 16px; |
|||
font-weight: 500; |
|||
cursor: pointer; |
|||
margin-top: 20px; |
|||
} |
|||
|
|||
.verify-btn:hover { |
|||
background-color: #218838; |
|||
} |
|||
|
|||
.hint { |
|||
margin: 16px; |
|||
padding: 12px; |
|||
background-color: #f8f9fa; |
|||
border-radius: 4px; |
|||
font-size: 14px; |
|||
line-height: 1.5; |
|||
color: #6c757d; |
|||
} |
|||
|
|||
.support { |
|||
text-align: center; |
|||
padding: 16px; |
|||
font-size: 14px; |
|||
color: #999; |
|||
border-top: 1px solid #f0f0f0; |
|||
margin-top: 20px; |
|||
} |
|||
|
|||
/* 弹窗样式 */ |
|||
.modal { |
|||
display: none; |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
background-color: rgba(0, 0, 0, 0.5); |
|||
z-index: 1000; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.modal.show { |
|||
display: flex; |
|||
} |
|||
|
|||
.modal-content { |
|||
background-color: white; |
|||
width: 90%; |
|||
max-width: 400px; |
|||
max-height: 80vh; |
|||
border-radius: 8px; |
|||
overflow: hidden; |
|||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.modal-body { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
padding: 0 16px 16px; |
|||
} |
|||
|
|||
.modal-body form { |
|||
padding: 0; |
|||
} |
|||
|
|||
.modal-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 16px; |
|||
background-color: #f8f9fa; |
|||
border-bottom: 1px solid #e9ecef; |
|||
} |
|||
|
|||
.modal-header h2 { |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
color: #333; |
|||
} |
|||
|
|||
.close-btn { |
|||
background: none; |
|||
border: none; |
|||
font-size: 20px; |
|||
cursor: pointer; |
|||
color: #666; |
|||
} |
|||
|
|||
form { |
|||
padding: 16px; |
|||
} |
|||
|
|||
.form-group { |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.form-group label { |
|||
display: block; |
|||
margin-bottom: 6px; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: #333; |
|||
} |
|||
|
|||
.form-group input { |
|||
width: 100%; |
|||
padding: 10px; |
|||
border: 1px solid #ced4da; |
|||
border-radius: 4px; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.form-group input:focus { |
|||
outline: none; |
|||
border-color: #28a745; |
|||
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25); |
|||
} |
|||
|
|||
.form-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
padding: 16px; |
|||
background-color: white; |
|||
border-top: 1px solid #e9ecef; |
|||
position: sticky; |
|||
bottom: 0; |
|||
} |
|||
|
|||
.cancel-btn, .clear-btn, .submit-btn { |
|||
flex: 1; |
|||
padding: 10px; |
|||
border-radius: 4px; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.cancel-btn { |
|||
background-color: #6c757d; |
|||
color: white; |
|||
border: none; |
|||
} |
|||
|
|||
.clear-btn { |
|||
background-color: #ffc107; |
|||
color: #212529; |
|||
border: none; |
|||
} |
|||
|
|||
.submit-btn { |
|||
background-color: #28a745; |
|||
color: white; |
|||
border: none; |
|||
} |
|||
|
|||
.cancel-btn:hover { |
|||
background-color: #5a6268; |
|||
} |
|||
|
|||
.clear-btn:hover { |
|||
background-color: #e0a800; |
|||
} |
|||
|
|||
.submit-btn:hover { |
|||
background-color: #218838; |
|||
} |
|||
|
|||
/* 签名区域样式 */ |
|||
.signature-container { |
|||
position: relative; |
|||
margin: 10px 0; |
|||
} |
|||
|
|||
#signatureCanvas { |
|||
border: 1px solid #ced4da; |
|||
border-radius: 4px; |
|||
background-color: #f8f9fa; |
|||
cursor: crosshair; |
|||
} |
|||
|
|||
.signature-actions { |
|||
margin-top: 8px; |
|||
text-align: right; |
|||
} |
|||
|
|||
.signature-actions button { |
|||
padding: 6px 12px; |
|||
background-color: #6c757d; |
|||
color: white; |
|||
border: none; |
|||
border-radius: 4px; |
|||
font-size: 14px; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.signature-actions button:hover { |
|||
background-color: #5a6268; |
|||
} |
|||
|
|||
/* 签名显示样式 */ |
|||
.signature-display img { |
|||
max-width: 200px; |
|||
max-height: 100px; |
|||
} |
|||
|
|||
/* 响应式设计 */ |
|||
@media (max-width: 480px) { |
|||
.container { |
|||
max-width: 100%; |
|||
} |
|||
|
|||
.certificate { |
|||
margin: 12px; |
|||
padding: 12px; |
|||
} |
|||
|
|||
.header { |
|||
padding: 12px; |
|||
} |
|||
|
|||
.header h1 { |
|||
font-size: 16px; |
|||
} |
|||
} |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<div class="container"> |
|||
<div class="header"> |
|||
<button class="back-btn"><</button> |
|||
<h1>合格证信息</h1> |
|||
<div class="header-actions"> |
|||
<button class="more-btn">•••</button> |
|||
<button class="scan-btn">⭕</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="certificate"> |
|||
<div class="promise-badge"> |
|||
<span>✓</span> 承诺达标合格证 |
|||
</div> |
|||
|
|||
<div class="promise-text"> |
|||
我承诺销售的食用农产品: |
|||
<div class="promise-item">✓ 不使用禁用农药兽药、停用兽药和非法添加物</div> |
|||
<div class="promise-item">✓ 常规农药兽药残留不超标</div> |
|||
<div class="promise-item">✓ 对承诺的真实性负责</div> |
|||
</div> |
|||
|
|||
<div id="certificateInfo"> |
|||
<div class="empty-state"> |
|||
<p>请点击下方按钮填写合格证信息</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<button class="verify-btn" onclick="openModal()">经销商查验</button> |
|||
</div> |
|||
|
|||
<div class="hint"> |
|||
提示:如果您用于经销该产品,请您及时点击查验收货,保存食用农产品合格证电子档案,若您购买用于食用,无需点击查验收货。 |
|||
</div> |
|||
|
|||
<div class="support"> |
|||
技术支持:厦门海荭兴仪器股份有限公司 0592-5768388 |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 填写信息弹窗 --> |
|||
<div id="modal" class="modal"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h2>填写合格证信息</h2> |
|||
<button class="close-btn" onclick="closeModal()">×</button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<form id="certificateForm"> |
|||
<div class="form-group"> |
|||
<label for="subjectName">主体名称:</label> |
|||
<input type="text" id="subjectName" name="subjectName"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="productName">产品名称:</label> |
|||
<input type="text" id="productName" name="productName" required> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="weight">产品重量:</label> |
|||
<input type="text" id="weight" name="weight" required> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="basis">承诺依据:</label> |
|||
<input type="text" id="basis" name="basis"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="origin">产地:</label> |
|||
<input type="text" id="origin" name="origin"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="contact">联系方式:</label> |
|||
<input type="tel" id="contact" name="contact" required pattern="^1[3-9]\d{9}$" title="请输入11位手机号码"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label for="date">开具日期:</label> |
|||
<input type="date" id="date" name="date"> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label>手写签名:</label> |
|||
<div class="signature-container"> |
|||
<canvas id="signatureCanvas" width="300" height="150"></canvas> |
|||
<div class="signature-actions"> |
|||
<button type="button" onclick="clearSignature()">清除</button> |
|||
</div> |
|||
</div> |
|||
<input type="hidden" id="signature" name="signature"> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="form-actions"> |
|||
<button type="button" class="cancel-btn" onclick="closeModal()">取消</button> |
|||
<button type="button" class="clear-btn" onclick="clearForm()">清除</button> |
|||
<button type="submit" form="certificateForm" class="submit-btn">提交</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
// 签名画布相关变量 |
|||
let canvas, ctx, isDrawing = false; |
|||
|
|||
// 从服务器加载最新的合格证信息 |
|||
function loadCertificate() { |
|||
// 这里可以添加从服务器获取最新合格证信息的代码 |
|||
// 暂时保留本地存储的支持,以便在服务器不可用时仍能显示数据 |
|||
const saved = localStorage.getItem('certificate'); |
|||
if (saved) { |
|||
const certificate = JSON.parse(saved); |
|||
displayCertificate(certificate); |
|||
} |
|||
} |
|||
|
|||
// 显示合格证信息 |
|||
function displayCertificate(certificate) { |
|||
const container = document.getElementById('certificateInfo'); |
|||
let html = ''; |
|||
|
|||
html += ` |
|||
<div class="info-item"> |
|||
<span class="label">主体名称:</span> |
|||
<span class="value">${certificate.subjectName || ''}</span> |
|||
</div> |
|||
<div class="info-item"> |
|||
<span class="label">产品名称:</span> |
|||
<span class="value">${certificate.productName || ''}</span> |
|||
</div> |
|||
<div class="info-item"> |
|||
<span class="label">产品重量:</span> |
|||
<span class="value">${certificate.weight || ''}</span> |
|||
</div> |
|||
<div class="info-item"> |
|||
<span class="label">承诺依据:</span> |
|||
<span class="value">${certificate.basis || ''}</span> |
|||
</div> |
|||
<div class="info-item"> |
|||
<span class="label">产地:</span> |
|||
<span class="value">${certificate.origin || ''}</span> |
|||
</div> |
|||
<div class="info-item"> |
|||
<span class="label">联系方式:</span> |
|||
<span class="value">${certificate.contact || ''}</span> |
|||
</div> |
|||
<div class="info-item"> |
|||
<span class="label">开具日期:</span> |
|||
<span class="value">${certificate.date || ''}</span> |
|||
</div> |
|||
`; |
|||
|
|||
if (certificate.signature) { |
|||
html += ` |
|||
<div class="info-item"> |
|||
<span class="label">签名:</span> |
|||
<div class="signature-display"> |
|||
<img src="${certificate.signature}" alt="签名" style="max-width: 200px; max-height: 100px;"> |
|||
</div> |
|||
</div> |
|||
`; |
|||
} |
|||
|
|||
container.innerHTML = html; |
|||
} |
|||
|
|||
function openModal() { |
|||
document.getElementById('modal').classList.add('show'); |
|||
// 初始化签名画布 |
|||
initSignatureCanvas(); |
|||
} |
|||
|
|||
function closeModal() { |
|||
document.getElementById('modal').classList.remove('show'); |
|||
} |
|||
|
|||
// 点击弹窗外部关闭 |
|||
window.onclick = function(event) { |
|||
const modal = document.getElementById('modal'); |
|||
if (event.target == modal) { |
|||
closeModal(); |
|||
} |
|||
}; |
|||
|
|||
// 初始化签名画布 |
|||
function initSignatureCanvas() { |
|||
canvas = document.getElementById('signatureCanvas'); |
|||
if (!canvas) return; |
|||
|
|||
ctx = canvas.getContext('2d'); |
|||
ctx.strokeStyle = '#000'; |
|||
ctx.lineWidth = 2; |
|||
ctx.lineCap = 'round'; |
|||
ctx.lineJoin = 'round'; |
|||
|
|||
// 清除画布 |
|||
clearSignature(); |
|||
|
|||
// 添加鼠标事件 |
|||
canvas.addEventListener('mousedown', startDrawing); |
|||
canvas.addEventListener('mousemove', draw); |
|||
canvas.addEventListener('mouseup', stopDrawing); |
|||
canvas.addEventListener('mouseout', stopDrawing); |
|||
|
|||
// 添加触摸事件(支持移动设备) |
|||
canvas.addEventListener('touchstart', startDrawingTouch); |
|||
canvas.addEventListener('touchmove', drawTouch); |
|||
canvas.addEventListener('touchend', stopDrawing); |
|||
} |
|||
|
|||
function startDrawing(e) { |
|||
isDrawing = true; |
|||
const rect = canvas.getBoundingClientRect(); |
|||
const x = e.clientX - rect.left; |
|||
const y = e.clientY - rect.top; |
|||
ctx.beginPath(); |
|||
ctx.moveTo(x, y); |
|||
} |
|||
|
|||
function draw(e) { |
|||
if (!isDrawing) return; |
|||
const rect = canvas.getBoundingClientRect(); |
|||
const x = e.clientX - rect.left; |
|||
const y = e.clientY - rect.top; |
|||
ctx.lineTo(x, y); |
|||
ctx.stroke(); |
|||
} |
|||
|
|||
function stopDrawing() { |
|||
isDrawing = false; |
|||
} |
|||
|
|||
// 触摸事件处理 |
|||
function startDrawingTouch(e) { |
|||
e.preventDefault(); |
|||
const touch = e.touches[0]; |
|||
const mouseEvent = new MouseEvent('mousedown', { |
|||
clientX: touch.clientX, |
|||
clientY: touch.clientY |
|||
}); |
|||
canvas.dispatchEvent(mouseEvent); |
|||
} |
|||
|
|||
function drawTouch(e) { |
|||
e.preventDefault(); |
|||
const touch = e.touches[0]; |
|||
const mouseEvent = new MouseEvent('mousemove', { |
|||
clientX: touch.clientX, |
|||
clientY: touch.clientY |
|||
}); |
|||
canvas.dispatchEvent(mouseEvent); |
|||
} |
|||
|
|||
// 清除签名 |
|||
function clearSignature() { |
|||
if (!canvas || !ctx) return; |
|||
ctx.clearRect(0, 0, canvas.width, canvas.height); |
|||
// 填充背景色 |
|||
ctx.fillStyle = '#f8f9fa'; |
|||
ctx.fillRect(0, 0, canvas.width, canvas.height); |
|||
// 重置签名隐藏字段 |
|||
document.getElementById('signature').value = ''; |
|||
} |
|||
|
|||
// 保存签名 |
|||
function saveSignature() { |
|||
if (!canvas) return; |
|||
// 将画布转换为base64编码 |
|||
const signatureData = canvas.toDataURL('image/png'); |
|||
// 存储到隐藏输入字段 |
|||
document.getElementById('signature').value = signatureData; |
|||
} |
|||
|
|||
// 清除表单 |
|||
function clearForm() { |
|||
// 清除所有输入字段 |
|||
document.getElementById('subjectName').value = ''; |
|||
document.getElementById('productName').value = ''; |
|||
document.getElementById('weight').value = ''; |
|||
document.getElementById('basis').value = ''; |
|||
document.getElementById('origin').value = ''; |
|||
document.getElementById('contact').value = ''; |
|||
document.getElementById('date').value = ''; |
|||
// 清除签名 |
|||
clearSignature(); |
|||
} |
|||
|
|||
// 表单提交处理 |
|||
document.getElementById('certificateForm').addEventListener('submit', function(e) { |
|||
e.preventDefault(); |
|||
|
|||
// 保存签名 |
|||
saveSignature(); |
|||
|
|||
// 收集表单数据 |
|||
const formData = new FormData(this); |
|||
const certificate = {}; |
|||
|
|||
for (const [key, value] of formData.entries()) { |
|||
certificate[key] = value; |
|||
} |
|||
|
|||
// 构建URLSearchParams对象,确保数据以正确的格式发送 |
|||
const urlEncodedData = new URLSearchParams(); |
|||
for (const [key, value] of formData.entries()) { |
|||
urlEncodedData.append(key, value); |
|||
} |
|||
|
|||
// 发送数据到服务器 |
|||
fetch('/submit', { |
|||
method: 'POST', |
|||
body: urlEncodedData, |
|||
headers: { |
|||
'Content-Type': 'application/x-www-form-urlencoded' |
|||
} |
|||
}) |
|||
.then(response => response.json()) |
|||
.then(data => { |
|||
if (data.success) { |
|||
// 显示结果 |
|||
displayCertificate(data.certificate); |
|||
|
|||
// 关闭弹窗 |
|||
closeModal(); |
|||
|
|||
// 显示成功提示 |
|||
alert('提交成功!合格证信息已保存到数据库。'); |
|||
} else { |
|||
alert('保存失败: ' + data.error); |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
console.error('保存失败:', error); |
|||
alert('保存失败,请重试'); |
|||
}); |
|||
}); |
|||
|
|||
// 页面加载时加载保存的信息 |
|||
window.onload = function() { |
|||
loadCertificate(); |
|||
}; |
|||
</script> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,8 @@ |
|||
// 阿里云OSS配置
|
|||
module.exports = { |
|||
region: 'oss-cn-chengdu', // OSS区域,例如 'oss-cn-hangzhou'
|
|||
accessKeyId: 'LTAI5tRT6ReeHUdmqFpmLZi7', // 访问密钥ID
|
|||
accessKeySecret: 'zTnK27IAphwgCDMmyJzMUsHYxGsDBE', // 访问密钥Secret
|
|||
bucket: 'my-supplier-photos', // OSS存储桶名称
|
|||
endpoint: 'oss-cn-chengdu.aliyuncs.com' // 注意:不要在endpoint中包含bucket名称
|
|||
}; |
|||
@ -0,0 +1,323 @@ |
|||
const fs = require('fs'); |
|||
const path = require('path'); |
|||
const { createHash } = require('crypto'); |
|||
const OSSClient = require('ali-oss'); |
|||
const ossConfig = require('./oss-config'); |
|||
|
|||
// 创建OSS客户端 - ali-oss 6.23.0版本的正确配置
|
|||
let client = null; |
|||
|
|||
// 初始化OSS客户端的函数
|
|||
function initOSSClient() { |
|||
try { |
|||
console.log('初始化OSS客户端配置:', { |
|||
region: ossConfig.region, |
|||
accessKeyId: ossConfig.accessKeyId ? '已配置' : '未配置', |
|||
accessKeySecret: ossConfig.accessKeySecret ? '已配置' : '未配置', |
|||
bucket: ossConfig.bucket, |
|||
endpoint: `https://${ossConfig.endpoint}` |
|||
}); |
|||
|
|||
client = new OSSClient({ |
|||
region: ossConfig.region, |
|||
accessKeyId: ossConfig.accessKeyId, |
|||
accessKeySecret: ossConfig.accessKeySecret, |
|||
bucket: ossConfig.bucket, |
|||
endpoint: ossConfig.endpoint, // 直接使用配置的endpoint,不添加前缀
|
|||
secure: true, // 启用HTTPS
|
|||
cname: false, // 对于标准OSS域名,不需要启用cname模式
|
|||
timeout: 600000, // 设置超时时间为10分钟,适应大文件上传
|
|||
connectTimeout: 60000, // 连接超时时间1分钟
|
|||
socketTimeout: 600000 // socket超时时间10分钟
|
|||
}); |
|||
|
|||
return client; |
|||
} catch (error) { |
|||
console.error('初始化OSS客户端失败:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
// 延迟初始化,避免应用启动时就连接OSS
|
|||
function getOSSClient() { |
|||
if (!client) { |
|||
return initOSSClient(); |
|||
} |
|||
return client; |
|||
} |
|||
|
|||
class OssUploader { |
|||
/** |
|||
* 上传文件到OSS |
|||
* @param {String} filePath - 本地文件路径 |
|||
* @param {String} folder - OSS上的文件夹路径 |
|||
* @param {String} fileType - 文件类型,默认为'image' |
|||
* @returns {Promise<String>} - 上传后的文件URL |
|||
*/ |
|||
/** |
|||
* 计算文件的MD5哈希值 |
|||
* @param {String} filePath - 文件路径 |
|||
* @returns {Promise<String>} - MD5哈希值 |
|||
*/ |
|||
static async getFileHash(filePath) { |
|||
return new Promise((resolve, reject) => { |
|||
const hash = createHash('md5'); |
|||
const stream = fs.createReadStream(filePath); |
|||
|
|||
stream.on('error', reject); |
|||
stream.on('data', chunk => hash.update(chunk)); |
|||
stream.on('end', () => resolve(hash.digest('hex'))); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 计算缓冲区的MD5哈希值 |
|||
* @param {Buffer} buffer - 数据缓冲区 |
|||
* @returns {String} - MD5哈希值 |
|||
*/ |
|||
static getBufferHash(buffer) { |
|||
return createHash('md5').update(buffer).digest('hex'); |
|||
} |
|||
|
|||
static async uploadFile(filePath, folder = 'images', fileType = 'image') { |
|||
try { |
|||
console.log('【OSS上传】开始上传文件:', filePath, '到目录:', folder); |
|||
|
|||
// 确保文件存在
|
|||
const fileExists = await fs.promises.access(filePath).then(() => true).catch(() => false); |
|||
if (!fileExists) { |
|||
throw new Error(`文件不存在: ${filePath}`); |
|||
} |
|||
|
|||
// 获取文件扩展名
|
|||
const extname = path.extname(filePath).toLowerCase(); |
|||
if (!extname) { |
|||
throw new Error(`无法获取文件扩展名: ${filePath}`); |
|||
} |
|||
|
|||
// 基于文件内容计算MD5哈希值,实现文件级去重
|
|||
console.log('【文件去重】开始计算文件哈希值...'); |
|||
const fileHash = await this.getFileHash(filePath); |
|||
console.log(`【文件去重】文件哈希计算完成: ${fileHash}`); |
|||
|
|||
// 使用哈希值作为文件名,确保相同内容的文件生成相同的文件名
|
|||
const uniqueFilename = `${fileHash}${extname}`; |
|||
const ossFilePath = `${folder}/${fileType}/${uniqueFilename}`; |
|||
|
|||
console.log(`【文件去重】使用基于内容的文件名: ${uniqueFilename}`); |
|||
|
|||
// 获取OSS客户端,延迟初始化
|
|||
const ossClient = getOSSClient(); |
|||
|
|||
// 测试OSS连接
|
|||
try { |
|||
await ossClient.list({ max: 1 }); |
|||
console.log('OSS连接测试成功'); |
|||
} catch (connError) { |
|||
console.error('OSS连接测试失败,尝试重新初始化客户端:', connError.message); |
|||
// 尝试重新初始化客户端
|
|||
initOSSClient(); |
|||
} |
|||
|
|||
// 检查OSS客户端配置
|
|||
console.log('【OSS上传】OSS配置检查 - region:', ossClient.options.region, 'bucket:', ossClient.options.bucket); |
|||
|
|||
// 上传文件,明确设置为公共读权限
|
|||
console.log(`开始上传文件到OSS: ${filePath} -> ${ossFilePath}`); |
|||
const result = await ossClient.put(ossFilePath, filePath, { |
|||
headers: { |
|||
'x-oss-object-acl': 'public-read' // 确保文件可以公开访问
|
|||
}, |
|||
acl: 'public-read' // 额外设置ACL参数,确保文件公开可读
|
|||
}); |
|||
console.log(`文件上传成功: ${result.url}`); |
|||
console.log('已设置文件为公共读权限'); |
|||
|
|||
// 返回完整的文件URL
|
|||
return result.url; |
|||
} catch (error) { |
|||
console.error('【OSS上传】上传文件失败:', error); |
|||
console.error('【OSS上传】错误详情:', error.message); |
|||
console.error('【OSS上传】错误栈:', error.stack); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从缓冲区上传文件到OSS |
|||
* @param {Buffer} buffer - 文件数据缓冲区 |
|||
* @param {String} filename - 文件名 |
|||
* @param {String} folder - OSS上的文件夹路径 |
|||
* @param {String} fileType - 文件类型,默认为'image' |
|||
* @returns {Promise<String>} - 上传后的文件URL |
|||
*/ |
|||
static async uploadBuffer(buffer, filename, folder = 'images', fileType = 'image') { |
|||
try { |
|||
// 获取文件扩展名
|
|||
const extname = path.extname(filename).toLowerCase(); |
|||
if (!extname) { |
|||
throw new Error(`无法获取文件扩展名: ${filename}`); |
|||
} |
|||
|
|||
// 基于文件内容计算MD5哈希值,实现文件级去重
|
|||
console.log('【文件去重】开始计算缓冲区哈希值...'); |
|||
const bufferHash = this.getBufferHash(buffer); |
|||
console.log(`【文件去重】缓冲区哈希计算完成: ${bufferHash}`); |
|||
|
|||
// 使用哈希值作为文件名,确保相同内容的文件生成相同的文件名
|
|||
const uniqueFilename = `${bufferHash}${extname}`; |
|||
const ossFilePath = `${folder}/${fileType}/${uniqueFilename}`; |
|||
|
|||
console.log(`【文件去重】使用基于内容的文件名: ${uniqueFilename}`); |
|||
|
|||
// 获取OSS客户端,延迟初始化
|
|||
const ossClient = getOSSClient(); |
|||
|
|||
// 上传缓冲区,明确设置为公共读权限
|
|||
console.log(`开始上传缓冲区到OSS: ${ossFilePath}`); |
|||
const result = await ossClient.put(ossFilePath, buffer, { |
|||
headers: { |
|||
'x-oss-object-acl': 'public-read' // 确保文件可以公开访问
|
|||
}, |
|||
acl: 'public-read' // 额外设置ACL参数,确保文件公开可读
|
|||
}); |
|||
console.log(`缓冲区上传成功: ${result.url}`); |
|||
console.log('已设置文件为公共读权限'); |
|||
|
|||
// 返回完整的文件URL
|
|||
return result.url; |
|||
} catch (error) { |
|||
console.error('OSS缓冲区上传失败:', error); |
|||
console.error('OSS缓冲区上传错误详情:', error.message); |
|||
console.error('OSS缓冲区上传错误栈:', error.stack); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 批量上传文件到OSS |
|||
* @param {Array<String>} filePaths - 本地文件路径数组 |
|||
* @param {String} folder - OSS上的文件夹路径 |
|||
* @param {String} fileType - 文件类型,默认为'image' |
|||
* @returns {Promise<Array<String>>} - 上传后的文件URL数组 |
|||
*/ |
|||
static async uploadFiles(filePaths, folder = 'images', fileType = 'image') { |
|||
try { |
|||
const uploadPromises = filePaths.map(filePath => |
|||
this.uploadFile(filePath, folder, fileType) |
|||
); |
|||
|
|||
const urls = await Promise.all(uploadPromises); |
|||
console.log(`批量上传完成,成功上传${urls.length}个文件`); |
|||
return urls; |
|||
} catch (error) { |
|||
console.error('OSS批量上传失败:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 删除OSS上的文件 |
|||
* @param {String} ossFilePath - OSS上的文件路径 |
|||
* @returns {Promise<Boolean>} - 删除是否成功 |
|||
*/ |
|||
static async deleteFile(ossFilePath) { |
|||
try { |
|||
console.log(`【OSS删除】开始删除OSS文件: ${ossFilePath}`); |
|||
const ossClient = getOSSClient(); |
|||
|
|||
// 【新增】记录OSS客户端配置信息(隐藏敏感信息)
|
|||
console.log(`【OSS删除】OSS客户端配置 - region: ${ossClient.options.region}, bucket: ${ossClient.options.bucket}`); |
|||
|
|||
const result = await ossClient.delete(ossFilePath); |
|||
console.log(`【OSS删除】OSS文件删除成功: ${ossFilePath}`, result); |
|||
return true; |
|||
} catch (error) { |
|||
console.error('【OSS删除】OSS文件删除失败:', ossFilePath, '错误:', error.message); |
|||
console.error('【OSS删除】错误详情:', error); |
|||
|
|||
// 【增强日志】详细分析错误类型
|
|||
console.log('【OSS删除】=== 错误详细分析开始 ==='); |
|||
console.log('【OSS删除】错误名称:', error.name); |
|||
console.log('【OSS删除】错误代码:', error.code); |
|||
console.log('【OSS删除】HTTP状态码:', error.status); |
|||
console.log('【OSS删除】请求ID:', error.requestId); |
|||
console.log('【OSS删除】主机ID:', error.hostId); |
|||
|
|||
// 【关键检查】判断是否为权限不足错误
|
|||
const isPermissionError = |
|||
error.code === 'AccessDenied' || |
|||
error.status === 403 || |
|||
error.message.includes('permission') || |
|||
error.message.includes('AccessDenied') || |
|||
error.message.includes('do not have write permission'); |
|||
|
|||
if (isPermissionError) { |
|||
console.error('【OSS删除】❌ 确认是权限不足错误!'); |
|||
console.error('【OSS删除】❌ 当前AccessKey缺少删除文件的权限'); |
|||
console.error('【OSS删除】❌ 请检查RAM策略是否包含 oss:DeleteObject 权限'); |
|||
console.error('【OSS删除】❌ 建议在RAM中授予 AliyunOSSFullAccess 或自定义删除权限'); |
|||
} |
|||
|
|||
console.log('【OSS删除】=== 错误详细分析结束 ==='); |
|||
|
|||
// 如果文件不存在,也算删除成功
|
|||
if (error.code === 'NoSuchKey' || error.status === 404) { |
|||
console.log(`【OSS删除】文件不存在,视为删除成功: ${ossFilePath}`); |
|||
return true; |
|||
} |
|||
|
|||
// 【新增】对于权限错误,提供更友好的错误信息
|
|||
if (isPermissionError) { |
|||
const permissionError = new Error(`OSS删除权限不足: ${error.message}`); |
|||
permissionError.code = 'OSS_ACCESS_DENIED'; |
|||
permissionError.originalError = error; |
|||
throw permissionError; |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
/** |
|||
* 获取OSS配置信息 |
|||
* @returns {Object} - OSS配置信息 |
|||
*/ |
|||
static getConfig() { |
|||
return { |
|||
region: ossConfig.region, |
|||
bucket: ossConfig.bucket, |
|||
endpoint: ossConfig.endpoint |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* 测试OSS连接 |
|||
* @returns {Promise<Object>} - 连接测试结果 |
|||
*/ |
|||
static async testConnection() { |
|||
try { |
|||
console.log('【OSS连接测试】开始测试OSS连接...'); |
|||
const ossClient = getOSSClient(); |
|||
|
|||
// 执行简单的list操作来验证连接
|
|||
const result = await ossClient.list({ max: 1 }); |
|||
console.log('【OSS连接测试】连接成功,存储桶中有', result.objects.length, '个对象'); |
|||
|
|||
return { |
|||
success: true, |
|||
message: 'OSS连接成功', |
|||
region: ossClient.options.region, |
|||
bucket: ossClient.options.bucket |
|||
}; |
|||
} catch (error) { |
|||
console.error('【OSS连接测试】连接失败:', error.message); |
|||
return { |
|||
success: false, |
|||
message: `OSS连接失败: ${error.message}`, |
|||
error: error.message |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = OssUploader; |
|||
File diff suppressed because it is too large
@ -0,0 +1,18 @@ |
|||
{ |
|||
"name": "hgzxx", |
|||
"version": "1.0.0", |
|||
"description": "", |
|||
"main": "index.js", |
|||
"scripts": { |
|||
"test": "echo \"Error: no test specified\" && exit 1" |
|||
}, |
|||
"keywords": [], |
|||
"author": "", |
|||
"license": "ISC", |
|||
"dependencies": { |
|||
"ali-oss": "^6.23.0", |
|||
"ejs": "^4.0.1", |
|||
"express": "^5.2.1", |
|||
"mysql2": "^3.16.2" |
|||
} |
|||
} |
|||
@ -0,0 +1,176 @@ |
|||
const http = require('http'); |
|||
const fs = require('fs'); |
|||
const path = require('path'); |
|||
const mysql = require('mysql2/promise'); |
|||
const OssUploader = require('./oss-uploader'); |
|||
|
|||
const port = 3008; |
|||
|
|||
// 数据库配置
|
|||
const dbConfig = { |
|||
host: process.env.DB_HOST || '1.95.162.61', |
|||
user: process.env.DB_USER || 'root', |
|||
password: process.env.DB_PASSWORD || 'schl@2025', |
|||
database: process.env.DB_NAME || 'wechat_app', |
|||
waitForConnections: true, |
|||
connectionLimit: 20, |
|||
queueLimit: 0, |
|||
connectTimeout: 10000, |
|||
timezone: '+08:00' |
|||
}; |
|||
|
|||
// 创建数据库连接池
|
|||
const pool = mysql.createPool(dbConfig); |
|||
|
|||
// 测试数据库连接
|
|||
async function testDbConnection() { |
|||
try { |
|||
const connection = await pool.getConnection(); |
|||
console.log('数据库连接成功'); |
|||
connection.release(); |
|||
} catch (error) { |
|||
console.error('数据库连接失败:', error.message); |
|||
} |
|||
} |
|||
|
|||
testDbConnection(); |
|||
|
|||
const server = http.createServer(async (req, res) => { |
|||
// 处理POST请求
|
|||
if (req.method === 'POST' && req.url === '/submit') { |
|||
try { |
|||
let body = ''; |
|||
req.on('data', chunk => { |
|||
body += chunk.toString(); |
|||
}); |
|||
|
|||
req.on('end', async () => { |
|||
// 解析表单数据
|
|||
const formData = new URLSearchParams(body); |
|||
const certificate = { |
|||
subjectName: formData.get('subjectName'), |
|||
contact: formData.get('contact'), |
|||
productName: formData.get('productName'), |
|||
weight: formData.get('weight'), |
|||
basis: formData.get('basis'), |
|||
origin: formData.get('origin'), |
|||
date: formData.get('date'), |
|||
signature: formData.get('signature') |
|||
}; |
|||
|
|||
// 处理手写签名,上传到OSS
|
|||
let signatureUrl = null; |
|||
if (certificate.signature) { |
|||
try { |
|||
// 从base64字符串中提取图片数据
|
|||
const base64Data = certificate.signature.replace(/^data:image\/png;base64,/, ''); |
|||
const buffer = Buffer.from(base64Data, 'base64'); |
|||
|
|||
// 生成唯一的文件名,包含合格证信息的标识
|
|||
const timestamp = Date.now(); |
|||
const filename = `certificate_signature_${timestamp}.png`; |
|||
|
|||
// 上传到OSS,指定文件夹为certificate/signatures
|
|||
signatureUrl = await OssUploader.uploadBuffer(buffer, filename, 'certificate/signatures', 'image'); |
|||
console.log('手写签名上传到OSS成功:', signatureUrl); |
|||
} catch (error) { |
|||
console.error('上传手写签名到OSS失败:', error.message); |
|||
// 即使上传失败,也继续处理,将签名数据存储为base64
|
|||
signatureUrl = certificate.signature; |
|||
} |
|||
} |
|||
|
|||
// 插入数据到数据库
|
|||
await pool.execute( |
|||
'INSERT INTO certificate (company, phoneNumber, productName, grossWeight, commitBasis, origin, issueDate, signature) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', |
|||
[certificate.subjectName, certificate.contact, certificate.productName, certificate.weight, certificate.basis, certificate.origin, certificate.date, signatureUrl] |
|||
); |
|||
|
|||
// 返回成功响应,确保返回的signature是OSS URL
|
|||
const responseCertificate = { |
|||
...certificate, |
|||
signature: signatureUrl |
|||
}; |
|||
|
|||
res.writeHead(200, { |
|||
'Content-Type': 'application/json', |
|||
'Access-Control-Allow-Origin': '*', |
|||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', |
|||
'Access-Control-Allow-Headers': 'Content-Type' |
|||
}); |
|||
res.end(JSON.stringify({ success: true, certificate: responseCertificate })); |
|||
}); |
|||
} catch (error) { |
|||
console.error('保存合格证信息失败:', error.message); |
|||
res.writeHead(500, { |
|||
'Content-Type': 'application/json', |
|||
'Access-Control-Allow-Origin': '*', |
|||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', |
|||
'Access-Control-Allow-Headers': 'Content-Type' |
|||
}); |
|||
res.end(JSON.stringify({ success: false, error: '保存失败,请重试' })); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
// 处理GET请求
|
|||
// 解析请求路径
|
|||
let filePath = '.' + req.url; |
|||
if (filePath === './') { |
|||
filePath = './certificate.html'; |
|||
} |
|||
|
|||
// 获取文件扩展名
|
|||
const extname = String(path.extname(filePath)).toLowerCase(); |
|||
|
|||
// 定义MIME类型
|
|||
const mimeTypes = { |
|||
'.html': 'text/html', |
|||
'.js': 'text/javascript', |
|||
'.css': 'text/css', |
|||
'.json': 'application/json', |
|||
'.png': 'image/png', |
|||
'.jpg': 'image/jpg', |
|||
'.gif': 'image/gif', |
|||
'.svg': 'image/svg+xml', |
|||
'.wav': 'audio/wav', |
|||
'.mp4': 'video/mp4', |
|||
'.woff': 'application/font-woff', |
|||
'.ttf': 'application/font-ttf', |
|||
'.eot': 'application/vnd.ms-fontobject', |
|||
'.otf': 'application/font-otf', |
|||
'.wasm': 'application/wasm' |
|||
}; |
|||
|
|||
// 获取对应的MIME类型
|
|||
const contentType = mimeTypes[extname] || 'application/octet-stream'; |
|||
|
|||
// 读取文件
|
|||
fs.readFile(filePath, (error, content) => { |
|||
if (error) { |
|||
if(error.code == 'ENOENT') { |
|||
// 文件不存在
|
|||
fs.readFile('./404.html', (error, content) => { |
|||
res.writeHead(404, { 'Content-Type': 'text/html' }); |
|||
res.end(content, 'utf-8'); |
|||
}); |
|||
} else { |
|||
// 服务器错误
|
|||
res.writeHead(500); |
|||
res.end('Sorry, check with the site admin for error: ' + error.code + ' ..\n'); |
|||
res.end(); |
|||
} |
|||
} else { |
|||
// 成功读取文件
|
|||
res.writeHead(200, { 'Content-Type': contentType }); |
|||
res.end(content, 'utf-8'); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
// 启动服务器
|
|||
server.listen(port, () => { |
|||
console.log(`Server running at http://localhost:${port}/`); |
|||
}); |
|||
|
|||
console.log(`Server starting on port ${port}...`); |
|||
Loading…
Reference in new issue