From deb5cfddb2a1ebfffd0276901209bb2a59ef4fdb Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 1 Dec 2025 00:48:40 -0800 Subject: [PATCH] add character creation + image download API - validate granblue_id via HEAD request to GBF CDN - create characters with editor role check (role >= 7) - async image download job with Redis status polling - download_status endpoint for progress tracking --- .../api/v1/characters_controller.rb | 100 +++++++++++++++++- app/jobs/download_character_images_job.rb | 87 +++++++++++++++ .../character_image_download_service.rb | 76 +++++++++++++ app/services/character_image_validator.rb | 93 ++++++++++++++++ config/routes.rb | 10 +- 5 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 app/jobs/download_character_images_job.rb create mode 100644 app/services/character_image_download_service.rb create mode 100644 app/services/character_image_validator.rb diff --git a/app/controllers/api/v1/characters_controller.rb b/app/controllers/api/v1/characters_controller.rb index d46de15..c18fd44 100644 --- a/app/controllers/api/v1/characters_controller.rb +++ b/app/controllers/api/v1/characters_controller.rb @@ -5,12 +5,15 @@ module Api class CharactersController < Api::V1::ApiController include IdResolvable - before_action :set + before_action :set, only: %i[show related download_images download_status] + before_action :ensure_editor_role, only: %i[create validate download_images] + # GET /characters/:id def show render json: CharacterBlueprint.render(@character, view: :full) end + # GET /characters/:id/related def related return render json: [] unless @character.character_id @@ -19,12 +22,107 @@ module Api render json: CharacterBlueprint.render(related) end + # POST /characters + # Creates a new character record + def create + character = Character.new(character_params) + + if character.save + render json: CharacterBlueprint.render(character, view: :full), status: :created + else + render_validation_error_response(character) + end + end + + # GET /characters/validate/:granblue_id + # Validates that a granblue_id has accessible images on Granblue servers + def validate + granblue_id = params[:granblue_id] + validator = CharacterImageValidator.new(granblue_id) + + response_data = { + granblue_id: granblue_id, + exists_in_db: validator.exists_in_db? + } + + if validator.valid? + render json: response_data.merge( + valid: true, + image_urls: validator.image_urls + ) + else + render json: response_data.merge( + valid: false, + error: validator.error_message + ) + end + end + + # POST /characters/:id/download_images + # Triggers async image download for a character + def download_images + # Queue the download job + DownloadCharacterImagesJob.perform_later( + @character.id, + force: params.dig(:options, :force) == true, + size: params.dig(:options, :size) || 'all' + ) + + # Set initial status + DownloadCharacterImagesJob.update_status( + @character.id, + 'queued', + progress: 0, + images_downloaded: 0 + ) + + render json: { + status: 'queued', + character_id: @character.id, + granblue_id: @character.granblue_id, + message: 'Image download job has been queued' + }, status: :accepted + end + + # GET /characters/:id/download_status + # Returns the status of an image download job + def download_status + status = DownloadCharacterImagesJob.status(@character.id) + + render json: status.merge( + character_id: @character.id, + granblue_id: @character.granblue_id + ) + end + private def set @character = find_by_any_id(Character, params[:id]) render_not_found_response('character') unless @character end + + # Ensures the current user has editor role (role >= 7) + def ensure_editor_role + return if current_user&.role && current_user.role >= 7 + + Rails.logger.warn "[CHARACTERS] Unauthorized access attempt by user #{current_user&.id}" + render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized + end + + def character_params + params.require(:character).permit( + :granblue_id, :name_en, :name_jp, :rarity, :element, + :proficiency1, :proficiency2, :gender, :race1, :race2, + :flb, :ulb, :special, + :min_hp, :max_hp, :max_hp_flb, :max_hp_ulb, + :min_atk, :max_atk, :max_atk_flb, :max_atk_ulb, + :base_da, :base_ta, :ougi_ratio, :ougi_ratio_flb, + :release_date, :flb_date, :ulb_date, + :wiki_en, :wiki_ja, :gamewith, :kamigame, + nicknames_en: [], nicknames_jp: [], character_id: [] + ) + end end end end diff --git a/app/jobs/download_character_images_job.rb b/app/jobs/download_character_images_job.rb new file mode 100644 index 0000000..282aeb0 --- /dev/null +++ b/app/jobs/download_character_images_job.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Background job for downloading character images from Granblue servers to S3. +# Stores progress in Redis for status polling. +# +# @example Enqueue a download job +# job = DownloadCharacterImagesJob.perform_later(character.id) +# # Poll status with: DownloadCharacterImagesJob.status(character.id) +class DownloadCharacterImagesJob < ApplicationJob + queue_as :downloads + + retry_on StandardError, wait: :exponentially_longer, attempts: 3 + + discard_on ActiveRecord::RecordNotFound do |job, _error| + character_id = job.arguments.first + Rails.logger.error "[DownloadCharacterImages] Character #{character_id} not found" + update_status(character_id, 'failed', error: 'Character not found') + end + + # Status keys for Redis storage + REDIS_KEY_PREFIX = 'character_image_download' + STATUS_TTL = 1.hour.to_i + + class << self + # Get the current status of a download job for a character + # + # @param character_id [String] UUID of the character + # @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error + def status(character_id) + data = redis.get(redis_key(character_id)) + return { status: 'not_found' } unless data + + JSON.parse(data, symbolize_names: true) + end + + def redis_key(character_id) + "#{REDIS_KEY_PREFIX}:#{character_id}" + end + + def redis + @redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')) + end + + def update_status(character_id, status, **attrs) + data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs) + redis.setex(redis_key(character_id), STATUS_TTL, data.to_json) + end + end + + def perform(character_id, force: false, size: 'all') + Rails.logger.info "[DownloadCharacterImages] Starting download for character #{character_id}" + + character = Character.find(character_id) + update_status(character_id, 'processing', progress: 0, images_downloaded: 0) + + service = CharacterImageDownloadService.new( + character, + force: force, + size: size, + storage: :s3 + ) + + result = service.download + + if result.success? + Rails.logger.info "[DownloadCharacterImages] Completed for character #{character_id}" + update_status( + character_id, + 'completed', + progress: 100, + images_downloaded: result.total, + images_total: result.total, + images: result.images + ) + else + Rails.logger.error "[DownloadCharacterImages] Failed for character #{character_id}: #{result.error}" + update_status(character_id, 'failed', error: result.error) + raise StandardError, result.error # Trigger retry + end + end + + private + + def update_status(character_id, status, **attrs) + self.class.update_status(character_id, status, **attrs) + end +end diff --git a/app/services/character_image_download_service.rb b/app/services/character_image_download_service.rb new file mode 100644 index 0000000..7d99fb7 --- /dev/null +++ b/app/services/character_image_download_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Service wrapper for downloading character images from Granblue servers to S3. +# Uses the existing CharacterDownloader but provides a cleaner interface for controllers. +# +# @example Download images for a character +# service = CharacterImageDownloadService.new(character) +# result = service.download +# if result.success? +# puts result.images +# else +# puts result.error +# end +class CharacterImageDownloadService + Result = Struct.new(:success?, :images, :error, :total, keyword_init: true) + + def initialize(character, options = {}) + @character = character + @force = options[:force] || false + @size = options[:size] || 'all' + @storage = options[:storage] || :s3 + end + + # Downloads images for the character + # + # @return [Result] Struct with success status, images manifest, and any errors + def download + downloader = Granblue::Downloaders::CharacterDownloader.new( + @character.granblue_id, + storage: @storage, + verbose: Rails.env.development? + ) + + selected_size = @size == 'all' ? nil : @size + downloader.download(selected_size) + + manifest = build_image_manifest + + Result.new( + success?: true, + images: manifest, + total: count_total_images(manifest) + ) + rescue StandardError => e + Rails.logger.error "[CharacterImageDownload] Failed for #{@character.granblue_id}: #{e.message}" + Result.new( + success?: false, + error: e.message + ) + end + + private + + def build_image_manifest + sizes = Granblue::Downloaders::CharacterDownloader::SIZES + variants = build_variants + + sizes.each_with_object({}) do |size, manifest| + manifest[size] = variants.map do |variant| + extension = size == 'detail' ? 'png' : 'jpg' + "#{variant}.#{extension}" + end + end + end + + def build_variants + variants = ["#{@character.granblue_id}_01", "#{@character.granblue_id}_02"] + variants << "#{@character.granblue_id}_03" if @character.flb + variants << "#{@character.granblue_id}_04" if @character.ulb + variants + end + + def count_total_images(manifest) + manifest.values.sum(&:size) + end +end diff --git a/app/services/character_image_validator.rb b/app/services/character_image_validator.rb new file mode 100644 index 0000000..62b0a5a --- /dev/null +++ b/app/services/character_image_validator.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# Validates that a granblue_id has accessible images on the Granblue Fantasy game server. +# Used to verify that a character ID is valid before creating a database record. +# +# @example Validate a character ID +# validator = CharacterImageValidator.new("3040001000") +# if validator.valid? +# puts validator.image_urls +# else +# puts validator.error_message +# end +class CharacterImageValidator + BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/npc' + + SIZES = { + main: 'f', + grid: 'm', + square: 's' + }.freeze + + attr_reader :granblue_id, :error_message, :image_urls + + def initialize(granblue_id) + @granblue_id = granblue_id.to_s + @error_message = nil + @image_urls = {} + end + + # Validates the granblue_id by checking if the main image is accessible. + # + # @return [Boolean] true if valid, false otherwise + def valid? + return invalid_format unless valid_format? + + check_image_accessibility + end + + # Checks if a character with this granblue_id already exists in the database. + # + # @return [Boolean] true if exists, false otherwise + def exists_in_db? + Character.exists?(granblue_id: @granblue_id) + end + + private + + def valid_format? + @granblue_id.present? && @granblue_id.match?(/^\d{10}$/) + end + + def invalid_format + @error_message = 'Invalid granblue_id format. Must be a 10-digit number.' + false + end + + def check_image_accessibility + variant_id = "#{@granblue_id}_01" + + # Build image URLs for all sizes + SIZES.each do |size_name, directory| + url = "#{BASE_URL}/#{directory}/#{variant_id}.jpg" + @image_urls[size_name] = url + end + + # Check if the main image is accessible via HEAD request + main_url = @image_urls[:main] + + begin + uri = URI.parse(main_url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = 5 + http.read_timeout = 5 + + request = Net::HTTP::Head.new(uri.request_uri) + response = http.request(request) + + if response.code == '200' + true + else + @error_message = "No images found for this granblue_id (HTTP #{response.code})" + false + end + rescue Net::OpenTimeout, Net::ReadTimeout => e + @error_message = "Request timed out while validating image URL: #{e.message}" + false + rescue StandardError => e + @error_message = "Failed to validate image URL: #{e.message}" + false + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 132df7f..4a18324 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,7 +13,15 @@ Rails.application.routes.draw do resources :grid_characters, only: %i[create update destroy] resources :grid_summons, only: %i[create update destroy] resources :weapons, only: :show - resources :characters, only: :show + resources :characters, only: %i[show create] do + collection do + get 'validate/:granblue_id', action: :validate, as: :validate + end + member do + post 'download_images' + get 'download_status' + end + end resources :summons, only: :show resources :favorites, only: [:create]