summons: add validate, create, download endpoints
This commit is contained in:
parent
deb5cfddb2
commit
a27bdecde0
5 changed files with 369 additions and 2 deletions
|
|
@ -5,18 +5,114 @@ module Api
|
||||||
class SummonsController < Api::V1::ApiController
|
class SummonsController < Api::V1::ApiController
|
||||||
include IdResolvable
|
include IdResolvable
|
||||||
|
|
||||||
before_action :set
|
before_action :set, only: %i[show download_images download_status]
|
||||||
|
before_action :ensure_editor_role, only: %i[create validate download_images]
|
||||||
|
|
||||||
|
# GET /summons/:id
|
||||||
def show
|
def show
|
||||||
render json: SummonBlueprint.render(@summon, view: :full)
|
render json: SummonBlueprint.render(@summon, view: :full)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /summons
|
||||||
|
# Creates a new summon record
|
||||||
|
def create
|
||||||
|
summon = Summon.new(summon_params)
|
||||||
|
|
||||||
|
if summon.save
|
||||||
|
render json: SummonBlueprint.render(summon, view: :full), status: :created
|
||||||
|
else
|
||||||
|
render_validation_error_response(summon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /summons/validate/:granblue_id
|
||||||
|
# Validates that a granblue_id has accessible images on Granblue servers
|
||||||
|
def validate
|
||||||
|
granblue_id = params[:granblue_id]
|
||||||
|
validator = SummonImageValidator.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 /summons/:id/download_images
|
||||||
|
# Triggers async image download for a summon
|
||||||
|
def download_images
|
||||||
|
# Queue the download job
|
||||||
|
DownloadSummonImagesJob.perform_later(
|
||||||
|
@summon.id,
|
||||||
|
force: params.dig(:options, :force) == true,
|
||||||
|
size: params.dig(:options, :size) || 'all'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set initial status
|
||||||
|
DownloadSummonImagesJob.update_status(
|
||||||
|
@summon.id,
|
||||||
|
'queued',
|
||||||
|
progress: 0,
|
||||||
|
images_downloaded: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
status: 'queued',
|
||||||
|
summon_id: @summon.id,
|
||||||
|
granblue_id: @summon.granblue_id,
|
||||||
|
message: 'Image download job has been queued'
|
||||||
|
}, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /summons/:id/download_status
|
||||||
|
# Returns the status of an image download job
|
||||||
|
def download_status
|
||||||
|
status = DownloadSummonImagesJob.status(@summon.id)
|
||||||
|
|
||||||
|
render json: status.merge(
|
||||||
|
summon_id: @summon.id,
|
||||||
|
granblue_id: @summon.granblue_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set
|
def set
|
||||||
@summon = find_by_any_id(Summon, params[:id])
|
@summon = find_by_any_id(Summon, params[:id])
|
||||||
render_not_found_response('summon') unless @summon
|
render_not_found_response('summon') unless @summon
|
||||||
end
|
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 "[SUMMONS] Unauthorized access attempt by user #{current_user&.id}"
|
||||||
|
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
def summon_params
|
||||||
|
params.require(:summon).permit(
|
||||||
|
:granblue_id, :name_en, :name_jp, :summon_id, :rarity, :element, :series,
|
||||||
|
:flb, :ulb, :transcendence, :subaura, :limit,
|
||||||
|
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb, :max_hp_xlb,
|
||||||
|
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb, :max_atk_xlb,
|
||||||
|
:max_level,
|
||||||
|
:release_date, :flb_date, :ulb_date, :transcendence_date,
|
||||||
|
:wiki_en, :wiki_ja, :gamewith, :kamigame,
|
||||||
|
nicknames_en: [], nicknames_jp: []
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
87
app/jobs/download_summon_images_job.rb
Normal file
87
app/jobs/download_summon_images_job.rb
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Background job for downloading summon images from Granblue servers to S3.
|
||||||
|
# Stores progress in Redis for status polling.
|
||||||
|
#
|
||||||
|
# @example Enqueue a download job
|
||||||
|
# job = DownloadSummonImagesJob.perform_later(summon.id)
|
||||||
|
# # Poll status with: DownloadSummonImagesJob.status(summon.id)
|
||||||
|
class DownloadSummonImagesJob < ApplicationJob
|
||||||
|
queue_as :downloads
|
||||||
|
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
discard_on ActiveRecord::RecordNotFound do |job, _error|
|
||||||
|
summon_id = job.arguments.first
|
||||||
|
Rails.logger.error "[DownloadSummonImages] Summon #{summon_id} not found"
|
||||||
|
update_status(summon_id, 'failed', error: 'Summon not found')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Status keys for Redis storage
|
||||||
|
REDIS_KEY_PREFIX = 'summon_image_download'
|
||||||
|
STATUS_TTL = 1.hour.to_i
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Get the current status of a download job for a summon
|
||||||
|
#
|
||||||
|
# @param summon_id [String] UUID of the summon
|
||||||
|
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
|
||||||
|
def status(summon_id)
|
||||||
|
data = redis.get(redis_key(summon_id))
|
||||||
|
return { status: 'not_found' } unless data
|
||||||
|
|
||||||
|
JSON.parse(data, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis_key(summon_id)
|
||||||
|
"#{REDIS_KEY_PREFIX}:#{summon_id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def redis
|
||||||
|
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_status(summon_id, status, **attrs)
|
||||||
|
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
|
||||||
|
redis.setex(redis_key(summon_id), STATUS_TTL, data.to_json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(summon_id, force: false, size: 'all')
|
||||||
|
Rails.logger.info "[DownloadSummonImages] Starting download for summon #{summon_id}"
|
||||||
|
|
||||||
|
summon = Summon.find(summon_id)
|
||||||
|
update_status(summon_id, 'processing', progress: 0, images_downloaded: 0)
|
||||||
|
|
||||||
|
service = SummonImageDownloadService.new(
|
||||||
|
summon,
|
||||||
|
force: force,
|
||||||
|
size: size,
|
||||||
|
storage: :s3
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.download
|
||||||
|
|
||||||
|
if result.success?
|
||||||
|
Rails.logger.info "[DownloadSummonImages] Completed for summon #{summon_id}"
|
||||||
|
update_status(
|
||||||
|
summon_id,
|
||||||
|
'completed',
|
||||||
|
progress: 100,
|
||||||
|
images_downloaded: result.total,
|
||||||
|
images_total: result.total,
|
||||||
|
images: result.images
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Rails.logger.error "[DownloadSummonImages] Failed for summon #{summon_id}: #{result.error}"
|
||||||
|
update_status(summon_id, 'failed', error: result.error)
|
||||||
|
raise StandardError, result.error # Trigger retry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_status(summon_id, status, **attrs)
|
||||||
|
self.class.update_status(summon_id, status, **attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
80
app/services/summon_image_download_service.rb
Normal file
80
app/services/summon_image_download_service.rb
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Service wrapper for downloading summon images from Granblue servers to S3.
|
||||||
|
# Uses the existing SummonDownloader but provides a cleaner interface for controllers.
|
||||||
|
#
|
||||||
|
# @example Download images for a summon
|
||||||
|
# service = SummonImageDownloadService.new(summon)
|
||||||
|
# result = service.download
|
||||||
|
# if result.success?
|
||||||
|
# puts result.images
|
||||||
|
# else
|
||||||
|
# puts result.error
|
||||||
|
# end
|
||||||
|
class SummonImageDownloadService
|
||||||
|
Result = Struct.new(:success?, :images, :error, :total, keyword_init: true)
|
||||||
|
|
||||||
|
def initialize(summon, options = {})
|
||||||
|
@summon = summon
|
||||||
|
@force = options[:force] || false
|
||||||
|
@size = options[:size] || 'all'
|
||||||
|
@storage = options[:storage] || :s3
|
||||||
|
end
|
||||||
|
|
||||||
|
# Downloads images for the summon
|
||||||
|
#
|
||||||
|
# @return [Result] Struct with success status, images manifest, and any errors
|
||||||
|
def download
|
||||||
|
downloader = Granblue::Downloaders::SummonDownloader.new(
|
||||||
|
@summon.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 "[SummonImageDownload] Failed for #{@summon.granblue_id}: #{e.message}"
|
||||||
|
Result.new(
|
||||||
|
success?: false,
|
||||||
|
error: e.message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_image_manifest
|
||||||
|
sizes = Granblue::Downloaders::SummonDownloader::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
|
||||||
|
# Summons use the raw granblue_id for base variant (no _01 suffix)
|
||||||
|
variants = [@summon.granblue_id]
|
||||||
|
variants << "#{@summon.granblue_id}_02" if @summon.ulb
|
||||||
|
if @summon.transcendence
|
||||||
|
variants << "#{@summon.granblue_id}_03"
|
||||||
|
variants << "#{@summon.granblue_id}_04"
|
||||||
|
end
|
||||||
|
variants
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_total_images(manifest)
|
||||||
|
manifest.values.sum(&:size)
|
||||||
|
end
|
||||||
|
end
|
||||||
96
app/services/summon_image_validator.rb
Normal file
96
app/services/summon_image_validator.rb
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Validates that a granblue_id has accessible images on the Granblue Fantasy game server.
|
||||||
|
# Used to verify that a summon ID is valid before creating a database record.
|
||||||
|
#
|
||||||
|
# @example Validate a summon ID
|
||||||
|
# validator = SummonImageValidator.new("2040001000")
|
||||||
|
# if validator.valid?
|
||||||
|
# puts validator.image_urls
|
||||||
|
# else
|
||||||
|
# puts validator.error_message
|
||||||
|
# end
|
||||||
|
class SummonImageValidator
|
||||||
|
BASE_URL = 'https://prd-game-a-granbluefantasy.akamaized.net/assets_en/img/sp/assets/summon'
|
||||||
|
|
||||||
|
SIZES = {
|
||||||
|
main: 'ls',
|
||||||
|
grid: 'party_sub',
|
||||||
|
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 summon with this granblue_id already exists in the database.
|
||||||
|
#
|
||||||
|
# @return [Boolean] true if exists, false otherwise
|
||||||
|
def exists_in_db?
|
||||||
|
Summon.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
|
||||||
|
# Summons use the raw granblue_id without variant suffix for base image
|
||||||
|
variant_id = @granblue_id
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# Skip CRL verification in development (Akamai CDN can have CRL issues locally)
|
||||||
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -22,7 +22,15 @@ Rails.application.routes.draw do
|
||||||
get 'download_status'
|
get 'download_status'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :summons, only: :show
|
resources :summons, 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 :favorites, only: [:create]
|
resources :favorites, only: [:create]
|
||||||
|
|
||||||
get 'version', to: 'api#version'
|
get 'version', to: 'api#version'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue