ERP生产单附件管理:从数据库设计到Spring Boot实战
在实际生产制造企业的 ERP 系统中,生产单(或称工单)是驱动车间作业的核心单据。传统 ERP 往往只记录文本信息,如物料编码、数量、工序说明等。但在真实生产场景中,仅靠文字描述远远不够——操作员需要看到产品图片来确认外观标准,质检员需要对照图纸或规格书,维修记录可能需要附带现场照片作为证据。因此,为生产单添加图片和文档附件功能,是提升 ERP 系统实用性和现场作业效率的关键改造。
本文将基于常见的 ERP 二次开发经验,详细介绍如何在生产单模块中实现图片和文档的上传、存储、关联与查看功能。内容涵盖数据库设计、服务端接口、前端交互、权限控制以及生产环境下的存储方案选型。无论您使用的是自研 ERP 还是基于易飞、金蝶、用友等平台进行定制,本文的设计思路和代码示例都能提供直接参考。
1. 理解 ERP 生产单附件管理的核心需求
在为生产单添加附件功能前,必须先厘清业务场景和技术约束,避免设计出无法落地或难以维护的方案。
1.1 生产单附件的典型使用场景
- 图纸与技术文档:生产开始前,操作员需要查看产品图纸、工艺卡片、作业指导书等文档。
- 样品图片:对于外观有特殊要求的产品,需附上标准样品图片,供生产过程中比对。
- 质量记录:质检环节发现异常时,需要拍摄照片并关联到生产单,作为质量追溯的依据。
- 设备参数截图:特定工序需要记录设备运行参数,截图保存便于后续分析。
- 客户提供的参考文件:客户可能提供特殊包装要求、标签模板等文件,需要随单流转。
1.2 技术层面的关键考量
- 文件大小与类型限制:生产文档可能从几 KB 的 PDF 到几百 MB 的 CAD 图纸,系统需要合理限制。
- 版本管理:同一生产单的图纸可能更新,系统需要保留历史版本还是覆盖更新?
- 权限控制:不同角色(如操作员、质检员、计划员)对附件的操作权限应不同。
- 存储策略:文件存储在数据库还是文件系统?需要考虑备份、迁移和访问性能。
- 并发处理:多个用户同时上传附件到同一生产单时,系统需要保持数据一致性。
2. 数据库设计与存储方案选型
附件功能的核心是解决文件如何存储、如何与生产单关联这两个问题。下面分别介绍两种主流方案。
2.1 方案一:文件存储到数据库(BLOB 字段)
将文件内容直接存入数据库的 BLOB(二进制大对象)字段。这种方案管理简单,备份和恢复时文件与业务数据保持一致。
-- 生产单附件表结构示例 CREATE TABLE prod_order_attachment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50) NOT NULL COMMENT '生产单号', file_name VARCHAR(255) NOT NULL COMMENT '原始文件名', file_size BIGINT NOT NULL COMMENT '文件大小(字节)', file_type VARCHAR(100) COMMENT '文件MIME类型', file_content LONGBLOB COMMENT '文件内容', upload_user VARCHAR(50) COMMENT '上传用户', upload_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', description VARCHAR(500) COMMENT '文件描述', version INT DEFAULT 1 COMMENT '版本号,用于同一文件多次上传' ); -- 为生产单号建立索引,提高查询效率 CREATE INDEX idx_order_no ON prod_order_attachment(order_no);适用场景:
- 文件平均大小小于 1MB
- 附件数量不多(单生产单小于 10 个)
- 系统有严格的数据库备份机制
- 开发团队希望简化部署和迁移流程
优点:
- 数据一致性高,附件随生产单一起备份/恢复
- 不需要单独管理文件存储路径
- 权限控制可以统一到数据库层面
缺点:
- 数据库体积增长快,影响备份和查询性能
- 大文件(如超过 10MB)会显著降低数据库性能
- 文件访问需要经过应用层,无法直接通过 CDN 或静态服务器加速
2.2 方案二:文件存储到文件系统或对象存储
只将文件的元信息(名称、路径、大小等)存到数据库,实际文件内容存储到服务器的文件系统或云对象存储(如阿里云 OSS、腾讯云 COS)。
-- 生产单附件表结构(文件系统存储方案) CREATE TABLE prod_order_attachment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50) NOT NULL COMMENT '生产单号', file_name VARCHAR(255) NOT NULL COMMENT '原始文件名', file_size BIGINT NOT NULL COMMENT '文件大小(字节)', file_type VARCHAR(100) COMMENT '文件MIME类型', file_path VARCHAR(500) NOT NULL COMMENT '服务器存储路径', upload_user VARCHAR(50) COMMENT '上传用户', upload_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', description VARCHAR(500) COMMENT '文件描述', storage_type TINYINT DEFAULT 1 COMMENT '存储类型:1-本地文件,2-OSS,3-其他' ); -- 生产单附件访问日志表(可选,用于审计) CREATE TABLE attachment_access_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, attachment_id BIGINT NOT NULL COMMENT '附件ID', access_user VARCHAR(50) COMMENT '访问用户', access_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '访问时间', operation_type VARCHAR(20) COMMENT '操作类型:VIEW, DOWNLOAD, DELETE' );文件存储路径设计示例:
/erp-attachments/ ├── prod-order/ │ ├── 2024/ │ │ ├── 01/(按月份分目录) │ │ │ ├── PO20240115001/(按生产单号分目录) │ │ │ │ ├── drawing_001.jpg │ │ │ │ ├── spec.pdf │ │ │ │ └── thumbnail/(缩略图目录) │ │ │ │ └── drawing_001_thumb.jpg适用场景:
- 文件大小不一,可能存在大文件(超过 10MB)
- 附件数量多,需要频繁访问
- 系统需要支持文件预览、缩略图生成等高级功能
- 有独立文件备份和CDN加速需求
2.3 生产环境存储方案决策建议
对于大多数制造企业 ERP 系统,推荐采用方案二(文件系统存储),并参考以下决策矩阵:
| 考量因素 | 文件系统存储 | 数据库存储 | 云对象存储 |
|---|---|---|---|
| 文件大小支持 | 支持大文件,受磁盘限制 | 建议小于10MB | 支持超大文件 |
| 访问性能 | 中等,受服务器IO影响 | 较低,需要数据库查询 | 高,支持CDN加速 |
| 备份复杂度 | 需要单独备份文件目录 | 统一备份,简单 | 自动多副本,简单 |
| 成本 | 中等(服务器磁盘) | 高(数据库存储成本) | 按使用量付费,灵活 |
| 扩展性 | 需要手动扩容磁盘 | 数据库扩容复杂 | 自动扩展,无需干预 |
| 适用场景 | 中小型企业,预算有限 | 文件量小,管理要求简单 | 中大型企业,需要高可用 |
注意:如果选择文件系统存储,务必制定定期备份策略,并将备份文件存储在与生产服务器物理隔离的位置。
3. 服务端接口设计与实现
以 Spring Boot 为例,展示生产单附件上传、下载、查询等核心接口的实现。
3.1 附件上传接口
@RestController @RequestMapping("/api/prod-order/attachment") public class ProdOrderAttachmentController { @Autowired private ProdOrderAttachmentService attachmentService; /** * 上传生产单附件 */ @PostMapping("/upload") public ResponseEntity<UploadResult> uploadAttachment( @RequestParam("orderNo") String orderNo, @RequestParam("file") MultipartFile file, @RequestParam(value = "description", required = false) String description, HttpServletRequest request) { // 校验生产单是否存在且处于可编辑状态 if (!attachmentService.validateOrderStatus(orderNo)) { return ResponseEntity.badRequest() .body(UploadResult.error("生产单当前状态不允许添加附件")); } // 校验文件大小和类型 if (file.isEmpty()) { return ResponseEntity.badRequest() .body(UploadResult.error("文件不能为空")); } if (file.getSize() > 50 * 1024 * 1024) { // 限制50MB return ResponseEntity.badRequest() .body(UploadResult.error("文件大小不能超过50MB")); } // 获取当前登录用户(需要集成权限框架) String uploadUser = getCurrentUser(request); try { ProdOrderAttachment attachment = attachmentService.saveAttachment( orderNo, file, description, uploadUser); return ResponseEntity.ok(UploadResult.success(attachment)); } catch (IOException e) { log.error("文件上传失败", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(UploadResult.error("文件上传失败:" + e.getMessage())); } } // 获取当前用户(示例方法,需根据实际权限框架调整) private String getCurrentUser(HttpServletRequest request) { // 可从Session、JWT Token或SSO中获取 return "system_user"; // 示例返回值 } } // 上传结果封装类 @Data class UploadResult { private boolean success; private String message; private ProdOrderAttachment data; public static UploadResult success(ProdOrderAttachment attachment) { UploadResult result = new UploadResult(); result.setSuccess(true); result.setMessage("上传成功"); result.setData(attachment); return result; } public static UploadResult error(String message) { UploadResult result = new UploadResult(); result.setSuccess(false); result.setMessage(message); return result; } }3.2 服务层核心实现
@Service @Slf4j public class ProdOrderAttachmentService { @Value("${file.upload-dir:/erp-attachments/prod-order}") private String uploadDir; @Autowired private ProdOrderAttachmentMapper attachmentMapper; /** * 保存附件到文件系统并记录元数据到数据库 */ public ProdOrderAttachment saveAttachment(String orderNo, MultipartFile file, String description, String uploadUser) throws IOException { // 生成唯一文件名,避免重名覆盖 String originalFileName = file.getOriginalFilename(); String fileExtension = getFileExtension(originalFileName); String storageFileName = generateStorageFileName(originalFileName, fileExtension); // 构建存储路径:按年/月/生产单号组织 String relativePath = buildFilePath(orderNo, storageFileName); File destFile = new File(uploadDir + File.separator + relativePath); // 确保目录存在 destFile.getParentFile().mkdirs(); // 保存文件 file.transferTo(destFile); // 如果是图片,生成缩略图(可选) if (isImageFile(file.getContentType())) { generateThumbnail(destFile, relativePath); } // 保存附件记录到数据库 ProdOrderAttachment attachment = new ProdOrderAttachment(); attachment.setOrderNo(orderNo); attachment.setFileName(originalFileName); attachment.setFileSize(file.getSize()); attachment.setFileType(file.getContentType()); attachment.setFilePath(relativePath); attachment.setUploadUser(uploadUser); attachment.setDescription(description); attachment.setUploadTime(new Date()); attachmentMapper.insert(attachment); log.info("生产单{}附件上传成功:{}", orderNo, originalFileName); return attachment; } /** * 根据生产单号查询附件列表 */ public List<ProdOrderAttachment> getAttachmentsByOrderNo(String orderNo) { return attachmentMapper.selectByOrderNo(orderNo); } /** * 下载附件 */ public Resource downloadAttachment(Long attachmentId, HttpServletResponse response) throws IOException { ProdOrderAttachment attachment = attachmentMapper.selectById(attachmentId); if (attachment == null) { throw new FileNotFoundException("附件不存在"); } File file = new File(uploadDir + File.separator + attachment.getFilePath()); if (!file.exists()) { throw new FileNotFoundException("文件不存在:" + attachment.getFilePath()); } // 设置下载响应头 String encodedFileName = URLEncoder.encode(attachment.getFileName(), "UTF-8") .replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFileName); response.setContentType(attachment.getFileType()); response.setContentLengthLong(attachment.getFileSize()); return new FileSystemResource(file); } // 辅助方法:生成存储文件名 private String generateStorageFileName(String originalName, String extension) { String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); String random = UUID.randomUUID().toString().substring(0, 8); return timestamp + "_" + random + "." + extension; } // 辅助方法:构建文件存储路径 private String buildFilePath(String orderNo, String fileName) { LocalDate now = LocalDate.now(); return now.getYear() + File.separator + String.format("%02d", now.getMonthValue()) + File.separator + orderNo + File.separator + fileName; } // 辅助方法:判断是否为图片文件 private boolean isImageFile(String contentType) { return contentType != null && contentType.startsWith("image/"); } // 辅助方法:获取文件扩展名 private String getFileExtension(String fileName) { return fileName.substring(fileName.lastIndexOf(".") + 1); } }3.3 配置文件示例
# application.yml spring: servlet: multipart: max-file-size: 50MB max-request-size: 50MB file: upload-dir: /data/erp-attachments/prod-order allowed-types: | image/jpeg,image/png,image/gif, application/pdf,application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet4. 前端实现与用户体验优化
前端需要实现文件选择、上传进度显示、附件列表展示、预览和下载等功能。
4.1 Vue.js 组件示例
<template> <div class="attachment-manager"> <div class="upload-section"> <input type="file" ref="fileInput" @change="handleFileSelect" :accept="allowedFileTypes" multiple style="display: none" > <button @click="triggerFileSelect" class="btn btn-primary"> <i class="el-icon-upload"></i> 添加附件 </button> <span class="file-size-hint">支持图片、PDF、Word、Excel,单个文件不超过50MB</span> </div> <!-- 上传进度显示 --> <div v-if="uploadingFiles.length > 0" class="upload-progress"> <div v-for="file in uploadingFiles" :key="file.id" class="progress-item"> <div class="file-name">{{ file.name }}</div> <el-progress :percentage="file.percentage" :status="file.status" :stroke-width="6" ></el-progress> <span v-if="file.status === 'success'" class="success-text">上传成功</span> <span v-if="file.status === 'exception'" class="error-text">{{ file.error }}</span> </div> </div> <!-- 附件列表 --> <div v-if="attachments.length > 0" class="attachment-list"> <h4>已上传附件({{ attachments.length }}个)</h4> <div v-for="attachment in attachments" :key="attachment.id" class="attachment-item"> <div class="file-icon"> <i :class="getFileIcon(attachment.fileType)"></i> </div> <div class="file-info"> <div class="file-name">{{ attachment.fileName }}</div> <div class="file-meta"> {{ formatFileSize(attachment.fileSize) }} • {{ formatDate(attachment.uploadTime) }} • {{ attachment.uploadUser }} </div> <div v-if="attachment.description" class="file-desc"> {{ attachment.description }} </div> </div> <div class="file-actions"> <button @click="previewFile(attachment)" class="btn-link"> <i class="el-icon-view"></i> 预览 </button> <button @click="downloadFile(attachment)" class="btn-link"> <i class="el-icon-download"></i> 下载 </button> <button v-if="canDelete(attachment)" @click="deleteFile(attachment)" class="btn-link danger" > <i class="el-icon-delete"></i> 删除 </button> </div> </div> </div> </div> </template> <script> export default { name: 'AttachmentManager', props: { orderNo: { type: String, required: true }, existingAttachments: { type: Array, default: () => [] } }, data() { return { attachments: [...this.existingAttachments], uploadingFiles: [], allowedFileTypes: '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx' } }, methods: { triggerFileSelect() { this.$refs.fileInput.click(); }, async handleFileSelect(event) { const files = Array.from(event.target.files); for (const file of files) { await this.uploadFile(file); } // 清空input,允许重复选择相同文件 event.target.value = ''; }, async uploadFile(file) { const uploadFile = { id: Date.now() + Math.random(), name: file.name, percentage: 0, status: 'uploading' }; this.uploadingFiles.push(uploadFile); const formData = new FormData(); formData.append('file', file); formData.append('orderNo', this.orderNo); try { const response = await this.$http.post('/api/prod-order/attachment/upload', formData, { onUploadProgress: (progressEvent) => { const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); uploadFile.percentage = percentCompleted; } }); if (response.data.success) { uploadFile.status = 'success'; this.attachments.push(response.data.data); this.$emit('attachment-added', response.data.data); } else { uploadFile.status = 'exception'; uploadFile.error = response.data.message; } } catch (error) { uploadFile.status = 'exception'; uploadFile.error = error.response?.data?.message || '上传失败'; } // 3秒后移除上传进度显示 setTimeout(() => { const index = this.uploadingFiles.indexOf(uploadFile); if (index > -1) { this.uploadingFiles.splice(index, 1); } }, 3000); }, previewFile(attachment) { // 根据文件类型采用不同预览方式 if (this.isImageFile(attachment.fileType)) { this.previewImage(attachment); } else if (this.isPdfFile(attachment.fileType)) { this.previewPdf(attachment); } else { this.$message.info('该文件类型不支持预览,请下载后查看'); } }, previewImage(attachment) { // 使用图片预览组件 this.$imagePreview({ images: [this.getFileUrl(attachment)], index: 0 }); }, async downloadFile(attachment) { try { const response = await this.$http.get( `/api/prod-order/attachment/download/${attachment.id}`, { responseType: 'blob' } ); const blob = new Blob([response.data]); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = attachment.fileName; link.click(); window.URL.revokeObjectURL(downloadUrl); } catch (error) { this.$message.error('下载失败:' + error.message); } }, getFileIcon(fileType) { if (this.isImageFile(fileType)) return 'el-icon-picture'; if (this.isPdfFile(fileType)) return 'el-icon-document'; if (this.isWordFile(fileType)) return 'el-icon-document'; if (this.isExcelFile(fileType)) return 'el-icon-document'; return 'el-icon-document'; }, isImageFile(fileType) { return fileType && fileType.startsWith('image/'); }, getFileUrl(attachment) { return `/api/prod-order/attachment/preview/${attachment.id}`; }, formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } } } </script>4.2 文件预览服务端支持
对于图片和 PDF 文件,可以提供在线预览功能:
/** * 文件预览接口 */ @GetMapping("/preview/{attachmentId}") public void previewAttachment(@PathVariable Long attachmentId, HttpServletResponse response) throws IOException { ProdOrderAttachment attachment = attachmentService.getAttachmentById(attachmentId); if (attachment == null) { response.sendError(404, "附件不存在"); return; } // 检查文件类型是否支持预览 if (!isPreviewSupported(attachment.getFileType())) { response.sendError(415, "该文件类型不支持预览"); return; } File file = new File(uploadDir + File.separator + attachment.getFilePath()); if (!file.exists()) { response.sendError(404, "文件不存在"); return; } // 设置预览响应头(非下载) response.setContentType(attachment.getFileType()); response.setHeader("Content-Disposition", "inline; filename=\"" + URLEncoder.encode(attachment.getFileName(), "UTF-8") + "\""); // 输出文件内容 try (InputStream in = new FileInputStream(file); OutputStream out = response.getOutputStream()) { IOUtils.copy(in, out); } } private boolean isPreviewSupported(String fileType) { return fileType != null && ( fileType.startsWith("image/") || fileType.equals("application/pdf") ); }5. 权限控制与业务规则
在生产环境中,附件管理需要严格的权限控制和业务规则校验。
5.1 基于角色的权限控制
@Service public class AttachmentPermissionService { /** * 检查用户是否有权限上传附件到指定生产单 */ public boolean canUploadAttachment(String orderNo, String user) { // 1. 检查生产单状态:只有特定状态允许上传附件 ProdOrder order = prodOrderService.getOrderByNo(orderNo); if (order == null) { return false; } // 允许上传附件的状态:已发布、生产中、暂停 List<String> allowedStatus = Arrays.asList("RELEASED", "IN_PROGRESS", "PAUSED"); if (!allowedStatus.contains(order.getStatus())) { return false; } // 2. 检查用户角色权限 UserRole userRole = getUserRole(user); List<String> allowedRoles = Arrays.asList("PROD_MANAGER", "QUALITY_INSPECTOR", "OPERATOR"); return allowedRoles.contains(userRole.getRoleCode()); } /** * 检查用户是否有权限删除附件 */ public boolean canDeleteAttachment(Long attachmentId, String user) { ProdOrderAttachment attachment = attachmentMapper.selectById(attachmentId); if (attachment == null) { return false; } // 只有上传者本人或管理员可以删除 if (attachment.getUploadUser().equals(user)) { return true; } // 管理员权限检查 return hasAdminPermission(user); } }5.2 生产单状态校验
在生产单的关键状态变更时,需要校验附件完整性:
@Service public class ProdOrderStatusService { @Autowired private ProdOrderAttachmentService attachmentService; /** * 生产单完成前的附件完整性检查 */ public ValidationResult validateAttachmentCompleteness(String orderNo) { List<ProdOrderAttachment> attachments = attachmentService.getAttachmentsByOrderNo(orderNo); // 获取生产单的附件要求配置 ProdOrder order = prodOrderMapper.selectByNo(orderNo); List<AttachmentRequirement> requirements = getAttachmentRequirements(order.getProductCode()); ValidationResult result = new ValidationResult(); result.setValid(true); for (AttachmentRequirement req : requirements) { boolean requirementMet = attachments.stream() .anyMatch(att -> req.getAttachmentType().equals(att.getDescription())); if (!requirementMet && req.isMandatory()) { result.setValid(false); result.getMessages().add("缺少必需附件:" + req.getAttachmentType()); } } return result; } }6. 生产环境部署与运维考量
将附件功能部署到生产环境时,需要考虑性能、安全性和可维护性。
6.1 Nginx 静态文件服务配置
对于文件系统存储方案,可以使用 Nginx 直接服务静态文件,减轻应用服务器压力:
# nginx.conf server { listen 80; server_name erp.example.com; # 应用接口 location /api/ { proxy_pass http://erp-app-server:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 静态附件服务 location /attachments/ { alias /data/erp-attachments/; # 安全设置:禁止直接访问附件目录 location ~ /attachments/.*/\. { deny all; } # 图片文件缓存设置 location ~* \.(jpg|jpeg|png|gif)$ { expires 7d; add_header Cache-Control "public, immutable"; } # 文档文件缓存设置 location ~* \.(pdf|doc|docx|xls|xlsx)$ { expires 1d; add_header Cache-Control "public"; } # 权限验证:通过内部请求到应用服务器验证权限 auth_request /api/auth/validate-attachment; auth_request_set $auth_status $upstream_status; # 权限验证失败处理 error_page 401 = @attachment_auth_error; } location @attachment_auth_error { return 302 /login?redirect=$request_uri; } # 内部权限验证接口 location = /api/auth/validate-attachment { internal; proxy_pass http://erp-app-server:8080/api/auth/validate-attachment; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-URI $request_uri; } }6.2 定期清理与归档策略
制定附件清理策略,避免存储空间无限增长:
@Component @Slf4j public class AttachmentCleanupTask { @Autowired private ProdOrderAttachmentMapper attachmentMapper; @Value("${file.upload-dir:/erp-attachments/prod-order}") private String uploadDir; /** * 每月清理临时文件和过期附件 */ @Scheduled(cron = "0 0 2 1 * ?") // 每月1号凌晨2点执行 public void cleanupExpiredAttachments() { log.info("开始执行附件清理任务"); // 1. 清理数据库中存在但文件不存在的记录 List<ProdOrderAttachment> allAttachments = attachmentMapper.selectAll(); int orphanRecordCount = 0; for (ProdOrderAttachment attachment : allAttachments) { File file = new File(uploadDir + File.separator + attachment.getFilePath()); if (!file.exists()) { attachmentMapper.deleteById(attachment.getId()); orphanRecordCount++; } } // 2. 清理文件系统中存在但数据库不存在的文件 int orphanFileCount = cleanupOrphanFiles(); log.info("附件清理完成:删除{}个孤儿记录,{}个孤儿文件", orphanRecordCount, orphanFileCount); } /** * 根据业务规则归档历史附件 */ @Scheduled(cron = "0 0 3 1 1 ?") // 每年1月1号凌晨3点执行 public void archiveHistoricalAttachments() { LocalDate archiveDate = LocalDate.now().minusYears(2); // 归档2年前的数据 List<ProdOrderAttachment> oldAttachments = attachmentMapper .selectByUploadTimeBefore(Date.from(archiveDate.atStartOfDay() .atZone(ZoneId.systemDefault()).toInstant())); // 将附件移动到归档目录,并更新数据库记录 for (ProdOrderAttachment attachment : oldAttachments) { archiveSingleAttachment(attachment); } } }6.3 监控与告警配置
设置附件服务的监控指标:
# Prometheus 监控配置 metrics: attachment: upload_count: type: counter help: "附件上传总数" upload_size_bytes: type: histogram help: "附件上传大小分布" upload_duration_seconds: type: histogram help: "附件上传耗时分布" error_count: type: counter help: "附件操作错误数" storage_usage_bytes: type: gauge help: "附件存储空间使用量"7. 常见问题排查与解决方案
在实际使用中,可能会遇到以下典型问题:
7.1 文件上传失败问题排查
| 问题现象 | 可能原因 | 检查方式 | 解决方案 |
|---|---|---|---|
| 上传大文件时超时 | Nginx或Tomcat超时设置过小 | 检查Nginx的client_max_body_size和Tomcat的connectionUploadTimeout | 调整超时时间,大文件建议采用分片上传 |
| 上传后文件损坏 | 网络传输中断或存储空间不足 | 检查文件MD5值,检查磁盘空间 | 实现MD5校验,监控磁盘使用率 |
| 文件名中文乱码 | 字符编码不一致 | 检查请求头Content-Type是否包含charset=UTF-8 | 统一使用UTF-8编码,对文件名进行URL编码 |
7.2 文件访问权限问题
// 详细的权限验证逻辑 @Service public class AttachmentAccessService { public boolean canAccessAttachment(Long attachmentId, String user) { ProdOrderAttachment attachment = attachmentMapper.selectById(attachmentId); if (attachment == null) { return false; } // 1. 检查用户是否有该生产单的访问权限 ProdOrder order = prodOrderMapper.selectByNo(attachment.getOrderNo()); if (!prodOrderService.canAccessOrder(order.getId(), user)) { return false; } // 2. 检查附件是否在有效期内(如有设置) if (attachment.getExpireTime() != null && attachment.getExpireTime().before(new Date())) { return false; } // 3. 记录访问日志(用于审计) logAccess(attachmentId, user, "VIEW"); return true; } }7.3 性能优化建议
大文件上传优化:
- 实现分片上传,支持断点续传
- 前端计算文件MD5,服务端校验避免重复上传
- 使用异步处理生成缩略图等衍生文件
图片处理优化:
- 根据客户端需求动态生成不同尺寸的图片
- 使用WebP格式替代JPEG/PNG以减小文件体积
- 实现懒加载,避免一次性加载大量图片
数据库优化:
- 为常用查询字段(如order_no、upload_time)建立索引
- 定期归档历史数据,保持主表体积合理
- 使用读写分离,将查询操作路由到从库
8. 扩展功能与进阶实践
在基础功能稳定后,可以考虑以下扩展方向:
8.1 版本管理功能
对于技术图纸等需要版本控制的文档,可以实现版本管理:
-- 版本化管理附件表结构 CREATE TABLE prod_order_attachment_version ( id BIGINT PRIMARY KEY AUTO_INCREMENT, attachment_id BIGINT NOT NULL COMMENT '主附件ID', version INT NOT NULL COMMENT '版本号', file_name VARCHAR(255) NOT NULL, file_path VARCHAR(500) NOT NULL, upload_user VARCHAR(50), upload_time DATETIME, description VARCHAR(500), is_current TINYINT DEFAULT 0 COMMENT '是否当前版本' );8.2 与现有ERP流程集成
将附件功能深度集成到ERP业务流程中:
- 审批流程:重要附件需要经过审批才能生效
- 消息通知:新附件上传时通知相关责任人
- 移动端支持:支持手机拍照直接上传到生产单
- 批量操作:支持附件的批量上传、下载和删除
8.3 安全加固措施
- 病毒扫描:集成病毒扫描服务,对上传文件进行安全检测
- 水印功能:敏感文档预览时自动添加用户水印
- 访问日志:详细记录附件的上传、下载、预览操作
- 加密存储:对敏感文件进行加密存储
实施ERP生产单附件功能时,建议采用渐进式策略:先实现核心的文件上传、存储和查看功能,确保稳定运行后再逐步添加权限控制、版本管理、移动端支持等高级特性。每次功能扩展前都要充分评估对现有业务流程的影响,并制定相应的回滚方案。
