From 510cbb0d784213665cf4fff7e869dd3777fb9638 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 24 Jun 2026 00:38:29 +0900 Subject: [PATCH] #306 --- .../app/controllers/materials_controller.rb | 20 ++++++++++- backend/app/services/material_file_sha256.rb | 21 ++++++++---- .../app/services/material_version_recorder.rb | 3 -- backend/app/services/material_zip_exporter.rb | 33 ++++++++++++++++++- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 751b874..023a557 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -1,6 +1,7 @@ class MaterialsController < ApplicationController rescue_from MaterialZipExporter::EmptyExportError, with: :render_zip_empty rescue_from MaterialZipExporter::DuplicatePathError, with: :render_zip_duplicate_path + rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file def index page = (params[:page].presence || 1).to_i @@ -203,6 +204,22 @@ class MaterialsController < ApplicationController render_unprocessable_entity("ZIP export path が重複してゐます: #{ error.message }") end + def render_zip_missing_file error + missing_files = error.missing_files.map do |missing_file| + { material_id: missing_file.material_id, + export_path: missing_file.export_path, + blob_id: missing_file.blob_id, + filename: missing_file.filename } + end + + render json: { type: 'validation_error', + message: 'ZIP export に必要な素材ファイルが欠損しています.', + errors: { }, + base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'], + missing_files: }, + status: :unprocessable_entity + end + def apply_file_sha256! material, file_sha256 return if file_sha256.blank? return unless material.file.attached? @@ -222,7 +239,8 @@ class MaterialsController < ApplicationController file_content_type: blob.content_type, file_byte_size: blob.byte_size, file_checksum: blob.checksum, - file_sha256: blob.metadata['sha256'] || MaterialFileSha256.from_blob(blob) } + file_sha256: blob.metadata['sha256'] || + MaterialFileSha256.from_blob(blob, allow_download: true) } end def render_material_import_block block diff --git a/backend/app/services/material_file_sha256.rb b/backend/app/services/material_file_sha256.rb index b4243df..f9a19b0 100644 --- a/backend/app/services/material_file_sha256.rb +++ b/backend/app/services/material_file_sha256.rb @@ -1,15 +1,24 @@ require 'digest' class MaterialFileSha256 - def self.from_blob blob + def self.from_blob blob, allow_download: false sha256 = blob.metadata['sha256'] return sha256 if sha256.present? + return nil unless allow_download - blob.open do |file| - sha256 = Digest::SHA256.file(file.path).hexdigest - blob.metadata['sha256'] = sha256 - blob.save! if blob.changed? - sha256 + begin + blob.open do |file| + sha256 = Digest::SHA256.file(file.path).hexdigest + blob.metadata['sha256'] = sha256 + blob.save! if blob.changed? + sha256 + end + rescue ActiveStorage::FileNotFoundError, ArgumentError => error + Rails.logger.warn( + "MaterialFileSha256.from_blob failed for blob_id=#{blob.id}: " \ + "#{error.class}: #{error.message}", + ) + nil end end diff --git a/backend/app/services/material_version_recorder.rb b/backend/app/services/material_version_recorder.rb index d5d997f..cf58198 100644 --- a/backend/app/services/material_version_recorder.rb +++ b/backend/app/services/material_version_recorder.rb @@ -49,9 +49,6 @@ class MaterialVersionRecorder < VersionRecorder return @file_snapshot if @file_snapshot return empty_file_snapshot unless blob - blob.metadata['sha256'] ||= MaterialFileSha256.from_blob(blob) - blob.save! if blob.changed? - { file_blob_id: blob.id, file_filename: blob.filename.to_s, file_content_type: blob.content_type, diff --git a/backend/app/services/material_zip_exporter.rb b/backend/app/services/material_zip_exporter.rb index 4976e54..b4a02e9 100644 --- a/backend/app/services/material_zip_exporter.rb +++ b/backend/app/services/material_zip_exporter.rb @@ -5,9 +5,19 @@ require 'zlib' # Keep this service boundary stable so job/cached export paths can replace it later. class MaterialZipExporter Entry = Struct.new(:path, :data, :mtime, keyword_init: true) + MissingFile = Struct.new(:material_id, :export_path, :blob_id, :filename, keyword_init: true) class EmptyExportError < StandardError; end class DuplicatePathError < StandardError; end + class MissingFileError < StandardError + attr_reader :missing_files + + def initialize missing_files + @missing_files = missing_files + + super("Missing files: #{missing_files.map(&:export_path).join(', ')}") + end + end def initialize profile: 'legacy_drive', tag_id: nil @profile = profile.presence || 'legacy_drive' @@ -35,15 +45,22 @@ class MaterialZipExporter rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id + missing_files = [] + entries = rows.filter_map do |item| material = item.material next unless material.file.attached? + data = download_blob(item, missing_files) + next unless data + Entry.new(path: item.export_path, - data: material.file.blob.download, + data:, mtime: material.updated_at || Time.current) end + raise MissingFileError.new(missing_files) if missing_files.any? + paths = entries.map(&:path) duplicated = paths.find { |path| paths.count(path) > 1 } raise DuplicatePathError, duplicated if duplicated @@ -51,6 +68,20 @@ class MaterialZipExporter entries end + def download_blob item, missing_files + blob = item.material.file.blob + + blob.download + rescue ActiveStorage::FileNotFoundError + missing_files << MissingFile.new( + material_id: item.material_id, + export_path: item.export_path, + blob_id: blob.id, + filename: blob.filename.to_s, + ) + nil + end + class ZipWriter VERSION_NEEDED = 20 GP_FLAG = 0x0800