ERP生产单附件管理:从数据库设计到Spring Boot实战

发布时间:2026/7/31 3:44:17
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-OSS3-其他 ); -- 生产单附件访问日志表可选用于审计 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 ResponseEntityUploadResult 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 ListProdOrderAttachment 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 classattachment-manager div classupload-section input typefile reffileInput changehandleFileSelect :acceptallowedFileTypes multiple styledisplay: none button clicktriggerFileSelect classbtn btn-primary i classel-icon-upload/i 添加附件 /button span classfile-size-hint支持图片、PDF、Word、Excel单个文件不超过50MB/span /div !-- 上传进度显示 -- div v-ifuploadingFiles.length 0 classupload-progress div v-forfile in uploadingFiles :keyfile.id classprogress-item div classfile-name{{ file.name }}/div el-progress :percentagefile.percentage :statusfile.status :stroke-width6 /el-progress span v-iffile.status success classsuccess-text上传成功/span span v-iffile.status exception classerror-text{{ file.error }}/span /div /div !-- 附件列表 -- div v-ifattachments.length 0 classattachment-list h4已上传附件{{ attachments.length }}个/h4 div v-forattachment in attachments :keyattachment.id classattachment-item div classfile-icon i :classgetFileIcon(attachment.fileType)/i /div div classfile-info div classfile-name{{ attachment.fileName }}/div div classfile-meta {{ formatFileSize(attachment.fileSize) }} • {{ formatDate(attachment.uploadTime) }} • {{ attachment.uploadUser }} /div div v-ifattachment.description classfile-desc {{ attachment.description }} /div /div div classfile-actions button clickpreviewFile(attachment) classbtn-link i classel-icon-view/i 预览 /button button clickdownloadFile(attachment) classbtn-link i classel-icon-download/i 下载 /button button v-ifcanDelete(attachment) clickdeleteFile(attachment) classbtn-link danger i classel-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]; } } } /script4.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; } // 允许上传附件的状态已发布、生产中、暂停 ListString allowedStatus Arrays.asList(RELEASED, IN_PROGRESS, PAUSED); if (!allowedStatus.contains(order.getStatus())) { return false; } // 2. 检查用户角色权限 UserRole userRole getUserRole(user); ListString 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) { ListProdOrderAttachment attachments attachmentService.getAttachmentsByOrderNo(orderNo); // 获取生产单的附件要求配置 ProdOrder order prodOrderMapper.selectByNo(orderNo); ListAttachmentRequirement 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. 清理数据库中存在但文件不存在的记录 ListProdOrderAttachment 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年前的数据 ListProdOrderAttachment 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是否包含charsetUTF-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生产单附件功能时建议采用渐进式策略先实现核心的文件上传、存储和查看功能确保稳定运行后再逐步添加权限控制、版本管理、移动端支持等高级特性。每次功能扩展前都要充分评估对现有业务流程的影响并制定相应的回滚方案。