video feature

dev_hjm
p31729568 6 years ago
parent fd4e526ca2
commit 498660c015

1
.gitignore vendored

@ -46,6 +46,7 @@
/config/secrets.yml
/config/redis.yml
/config/elasticsearch.yml
/config/aliyun_vod.yml
public/upload.html
/config/configuration.yml

@ -0,0 +1,24 @@
class Callbacks::AliyunVodsController < Callbacks::BaseController
before_action :check_signature_valid!
def create
Videos::DispatchCallbackService.call(params)
render_ok
end
private
def check_signature_valid!
return if AliyunVod::Sign.verify?(header_signature, header_timestamp)
render_not_acceptable
end
def header_timestamp
request.headers['X-VOD-TIMESTAMP']
end
def header_signature
request.headers['X-VOD-SIGNATURE']
end
end

@ -0,0 +1,5 @@
class Callbacks::BaseController < ActionController::Base
include RenderHelper
skip_before_action :verify_authenticity_token
end

@ -7,6 +7,10 @@ module RenderHelper
render json: { status: -1, message: message }
end
def render_not_acceptable(message = '请求已拒绝')
render json: { status: 406, message: message }
end
def render_not_found(message = I18n.t('error.record_not_found'))
render json: { status: 404, message: message }
# render status: 404, json: { errors: errors }

@ -21,7 +21,7 @@ class Users::BaseController < ApplicationController
def private_user_resources!
require_login
return if current_user.admin? || observed_logged_user?
return if current_user.admin_or_business? || observed_logged_user?
render_forbidden
end

@ -0,0 +1,26 @@
class Users::VideoAuthsController < Users::BaseController
before_action :private_user_resources!
def create
result = Videos::CreateAuthService.call(observed_user, create_params)
render_ok(data: result)
rescue Videos::CreateAuthService::Error => ex
render_error(ex.message)
end
def update
video = observed_user.videos.find_by(uuid: params[:video_id])
return render_error('该视频凭证不存在') if video.blank?
result = AliyunVod::Service.refresh_upload_video(video.uuid)
render_ok(data: result)
rescue AliyunVod::Error => _
render_error('刷新上传凭证失败')
end
private
def create_params
params.permit(:title, :file_name, :file_size, :description, :cover_url)
end
end

@ -0,0 +1,58 @@
class Users::VideosController < Users::BaseController
before_action :private_user_resources!
helper_method :current_video
def index
videos = Users::VideoQuery.call(observed_user, search_params)
@count = videos.count
@videos = paginate videos
end
def update
return render_error('该状态下不能编辑视频信息') unless current_video.published?
current_video.update!(title: params[:title])
AliyunVod::Service.update_video_info(current_video.uuid, Title: current_video.title) rescue nil
end
def cancel
video = observed_user.videos.find_by(uuid: params[:video_id])
return render_not_found if video.blank?
return render_error('该状态下不能删除视频') unless video.pending?
video.destroy!
AliyunVod::Service.delete_video([video.uuid]) rescue nil
render_ok
end
def review
params[:status] = 'processing'
videos = Users::VideoQuery.call(observed_user, params)
@count = videos.count
@videos = paginate videos
end
def batch_publish
Videos::BatchPublishService.call(observed_user, batch_publish_params)
render_ok
end
private
def current_video
@_current_video ||= observed_user.videos.find_by(id: params[:id])
end
def search_params
params.permit(:keyword, :sort_by, :sort_direction)
end
def batch_publish_params
params.permit(videos: [])
end
end

@ -372,4 +372,12 @@ module TidingDecorator
I18n.t(locale_format(tiding_type)) % [container.try(:title) || extra]
end
end
def video_content
if tiding_type == 'System'
I18n.t(locale_format(tiding_type, status), reason: extra) % container.try(:title)
else
I18n.t(locale_format(tiding_type)) % [container.try(:title) || extra]
end
end
end

@ -0,0 +1,5 @@
module VideoDecorator
extend ApplicationDecorator
display_time_method :published_at, :created_at, :updated_at
end

@ -0,0 +1,23 @@
# 批量发布视频 消息任务
class BatchPublishVideoNotifyJob < ApplicationJob
queue_as :notify
def perform(user_id, video_ids)
user = User.find_by(id: user_id)
return if user.blank?
attrs = %i[user_id trigger_user_id container_id container_type tiding_type status created_at updated_at]
same_attrs = {
user_id: 1,
trigger_user_id: user.id,
container_type: 'Video',
tiding_type: 'Apply', status: 0
}
Tiding.bulk_insert(*attrs) do |worker|
user.videos.where(id: video_ids).each do |video|
worker.add same_attrs.merge(container_id: video.id)
end
end
end
end

@ -0,0 +1,17 @@
# 获取阿里云视频信息
class GetAliyunVideoInfoJob < ApplicationJob
queue_as :default
def perform(vod_video_id)
video = Video.find_by(uuid: vod_video_id)
return if video.blank? || video.vod_uploading?
result = AliyunVod::Service.get_play_info(video.uuid)
cover_url = result.dig('VideoBase', 'CoverURL')
file_url = (result.dig('PlayInfoList', 'PlayInfo') || []).first&.[]('PlayURL')
video.cover_url = cover_url if cover_url.present? && video.cover_url.blank?
video.file_url = file_url if file_url.present?
video.save!
end
end

@ -0,0 +1,5 @@
module AliyunVod
class << self
attr_accessor :access_key_id, :access_key_secret, :base_url, :callback_url, :signature_key
end
end

@ -0,0 +1,2 @@
class AliyunVod::Error < StandardError
end

@ -0,0 +1,8 @@
module AliyunVod::Service
extend AliyunVod::Service::Base
extend AliyunVod::Service::VideoUpload
extend AliyunVod::Service::VideoProcess
extend AliyunVod::Service::VideoManage
extend AliyunVod::Service::VideoPlay
end

@ -0,0 +1,46 @@
module AliyunVod::Service::Base
def request(method, params)
params = AliyunVod::Sign.format_params(params.compact) # 多层hash需要预先处理保证值为string
params[:Signature] = AliyunVod::Sign.generate(params, method: method.to_s.upcase)
Rails.logger.info("[AliyunVod] request => method: #{method}, params: #{params}")
response = Faraday.public_send(method, AliyunVod.base_url, params)
result = JSON.parse(response.body)
Rails.logger.info("[AliyunVod] response => status: #{response.status}, result: #{result}")
raise AliyunVod::Error, result['Code'] if response.status != 200
result
rescue => ex
::Util.logger_error(ex)
raise AliyunVod::Error, ex.message
end
def base_params
{
AccessKeyId: AliyunVod.access_key_id,
Format: 'JSON',
Version: '2017-03-21',
SignatureMethod: 'HMAC-SHA1',
SignatureVersion: '1.0',
SignatureNonce: signature_nonce,
Timestamp: timestamp,
UserData: user_data
}
end
def user_data
{ MessageCallback: { CallbackURL: AliyunVod.callback_url } }
end
def timestamp
Time.now.utc.iso8601
end
def signature_nonce
chars = ('a'..'z').to_a + ('A'..'Z').to_a + ('0'..'9').to_a
chars.sample(16).join('')
end
end

@ -0,0 +1,40 @@
# 视频管理
module AliyunVod::Service::VideoManage
# 修改视频信息
def update_video_info(video_id, **opts)
params = {
Action: 'UpdateVideoInfo',
VideoId: video_id
}.merge(base_params)
params = opts.merge(params)
result = request(:post, params)
result
end
# 获取视频信息
def get_video_info(video_id)
params = {
Action: 'GetVideoInfo',
VideoId: video_id
}.merge(base_params)
result = request(:post, params)
result
end
# 删除视频信息
def delete_video(video_ids)
params = {
Action: 'DeleteVideo',
VideoIds: video_ids.join(',')
}.merge(base_params)
result = request(:post, params)
result
end
end

@ -0,0 +1,17 @@
# 视频播放
module AliyunVod::Service::VideoPlay
# 获取视频播放地址
# https://help.aliyun.com/document_detail/56124.html?spm=a2c4g.11186623.6.715.4d7e2d52dU1CTK
def get_play_info(video_id, **opts)
params = {
Action: 'GetPlayInfo',
VideoId: video_id
}.merge(base_params)
params = opts.merge(params)
result = request(:post, params)
result
end
end

@ -0,0 +1,17 @@
# 视频处理
module AliyunVod::Service::VideoProcess
# 提交媒体截图作业
def submit_snapshot_job(video_id, **opts)
params = {
Action: 'SubmitSnapshotJob',
VideoId: video_id
}.merge(base_params)
params = opts.merge(params)
result = request(:post, params)
raise AliyunVod::Error, '提交媒体截图作业失败' if result['SnapshotJob'].blank?
result
end
end

@ -0,0 +1,33 @@
# 视频上传
module AliyunVod::Service::VideoUpload
# 获取视频上传地址和凭证
def create_upload_video(title, filename, **opts)
params = {
Action: 'CreateUploadVideo',
Title: title,
FileName: filename
}.merge(base_params)
params = opts.merge(params)
result = request(:post, params)
raise AliyunVod::Error, '获取上传凭证失败' if result['UploadAddress'].blank?
result
end
# 刷新视频上传凭证
def refresh_upload_video(video_id)
params = {
Action: 'RefreshUploadVideo',
VideoId: video_id
}.merge(base_params)
result = request(:post, params)
raise AliyunVod::Error, '刷新上传凭证失败' if result['UploadAddress'].blank?
result
end
end

@ -0,0 +1,41 @@
module AliyunVod::Sign
# https://help.aliyun.com/document_detail/44434.html?spm=a2c4g.11186623.2.16.354c7853oqlhMb&/#SignatureNonce
def self.generate(params, **opts)
method = opts[:method] || 'POST'
key = opts[:key] || AliyunVod.access_key_secret + '&'
digest = OpenSSL::Digest.new('sha1')
str = params_to_string(params)
str = percent_encode(str)
str = "#{method}&%2F&#{str}"
Base64.encode64(OpenSSL::HMAC.digest(digest, key, str)).gsub(/\n/, '')
end
def self.verify?(signature, timestamp)
content = "#{AliyunVod.callback_url}|#{timestamp}|#{AliyunVod.signature_key}"
our_signature = Digest::MD5.hexdigest(content)
ActiveSupport::SecurityUtils.secure_compare(signature, our_signature)
end
def self.params_to_string(params)
params.sort.map { |k, v| "#{percent_encode(k)}=#{percent_encode(v)}" }.join('&')
end
def self.percent_encode(str)
return '' if str.blank?
CGI::escape(str.to_s).gsub('/\+/','%20').gsub('/\*/','%2A').gsub('/%7E/','~')
end
def self.format_params(params)
params.each_with_object({}) do |arr, obj|
obj[arr[0]] = arr[1].is_a?(Hash) ? parse_hash_to_str(arr[1]) : arr[1]
end
end
def self.parse_hash_to_str(hash)
hash.each_with_object({}) do |h, obj|
obj[h[0]] = h[1].is_a?(Hash) ? parse_hash_to_str(h[1].clone) : h[1].to_s
end.to_json
end
end

@ -5,7 +5,7 @@ class BiddingUser < ApplicationRecord
belongs_to :project_package, counter_cache: true
aasm(:status) do
state :pending, initiali: true
state :pending, initial: true
state :bidding_won
state :bidding_lost

@ -16,7 +16,7 @@ class Library < ApplicationRecord
validates :uuid, presence: true, uniqueness: true
aasm(:status) do
state :pending, initiali: true
state :pending, initial: true
state :processing
state :refused
state :published

@ -4,7 +4,7 @@ class LibraryApply < ApplicationRecord
belongs_to :library
aasm(:status) do
state :pending, initiali: true
state :pending, initial: true
state :refused
state :agreed

@ -17,7 +17,7 @@ class ProjectPackage < ApplicationRecord
scope :invisible, -> { where(status: %i[pending applying refused]) }
aasm(:status) do
state :pending, initiali: true
state :pending, initial: true
state :applying
state :refused
state :published

@ -4,7 +4,7 @@ class ProjectPackageApply < ApplicationRecord
belongs_to :project_package
aasm(:status) do
state :pending, initiali: true
state :pending, initial: true
state :refused
state :agreed

@ -135,6 +135,8 @@ class User < ApplicationRecord
# 教学案例
has_many :libraries, dependent: :destroy
# 视频
has_many :videos, dependent: :destroy
# Groups and active users
scope :active, lambda { where(status: STATUS_ACTIVE) }

@ -0,0 +1,36 @@
class Video < ApplicationRecord
include AASM
belongs_to :user
has_many :video_applies, dependent: :destroy
has_one :processing_video_apply, -> { where(status: :pending) }, class_name: 'VideoApply'
aasm(:status) do
state :pending, initial: true
state :processing
state :refused
state :published
event :apply_publish do
transitions from: :pending, to: :processing
end
event :refuse do
transitions from: :processing, to: :refused
end
event :publish do
transitions from: :processing, to: :published, guard: :vod_uploaded?
end
end
aasm(:vod_status, namespace: :vod) do
state :uploading, initial: true
state :uploaded
event :upload_success do
transitions from: :uploading, to: :uploaded
end
end
end

@ -0,0 +1,19 @@
class VideoApply < ApplicationRecord
include AASM
belongs_to :video
aasm(:status) do
state :pending, initial: true
state :refused
state :agreed
event :refuse do
transitions from: :pending, to: :refused
end
event :agree do
transitions from: :pending, to: :agreed
end
end
end

@ -0,0 +1,3 @@
class ApplicationQuery
include Callable
end

@ -0,0 +1,28 @@
class Users::VideoQuery < ApplicationQuery
include CustomSortable
sort_columns :published_at, :title, default_by: :published_at, default_direction: :desc
attr_reader :user, :params
def initialize(user, params)
@user = user
@params = params
end
def call
videos = user.videos.published
videos =
case params[:status]
when 'published' then videos.published
when 'processing' then videos.processing
else videos.published
end
keyword = params[:keyword].to_s.strip
videos = videos.where('title LIKE ?', "%#{keyword}%") if keyword.present?
custom_sort(videos, params[:sort_by], params[:sort_direction])
end
end

@ -22,14 +22,7 @@ module ElasticsearchAble
fragment_size: EduSetting.get('es_highlight_fragment_size') || 30,
tag: '<span class="highlight">',
fields: {
name: { type: 'plain' },
challenge_names: { type: 'plain' },
challenge_tag_names: { type: 'plain' },
description: { type: 'plain' },
subject_stages: { type: 'plain' },
content: { type: 'plain' },
descendants_contents: { type: 'plain' },
member_user_names: { type: 'plain' }
'*' => { type: 'plain', number_of_fragments: 3 }
}
}
end

@ -0,0 +1,35 @@
class Videos::AgreeApplyService < ApplicationService
Error = Class.new(StandardError)
attr_reader :video_apply, :video, :user
def initialize(video_apply, user)
@video_apply = video_apply
@video = video_apply.video
@user = user
end
def call
raise Error, '该状态下不能进行此操作' unless video_apply.may_agree? && video.may_publish?
ActiveRecord::Base.transaction do
video_apply.agree!
video.published_at = Time.now
video.publish
video.save!
# 将消息改为已处理
Tiding.where(container_id: video.id, container_type: 'Video', tiding_type: 'Apply', status: 0).update_all(status: 1)
notify_video_author!
end
end
private
def notify_video_author!
Tiding.create!(user_id: video.user_id, trigger_user_id: 1,
container_id: video.id, container_type: 'Video',
tiding_type: 'System', status: 1)
end
end

@ -0,0 +1,31 @@
class Videos::BatchPublishService < ApplicationService
attr_reader :user, :params
def initialize(user, params)
@user = user
@params = params
end
def call
video_params = Array.wrap(params[:videos]).compact
return if video_params.blank?
video_ids = []
ActiveRecord::Base.transaction do
video_params.each do |param|
video = user.videos.find_by(uuid: param[:video_id])
next if video.blank? || video.processing_video_apply.present?
video.title = param[:title].to_s.strip.presence || video.title
video.apply_publish
video.save!
video.video_applies.create!
video_ids << video.id
end
end
BatchPublishVideoNotifyJob.perform_later(user.id, video_ids) if video_ids.present?
end
end

@ -0,0 +1,41 @@
class Videos::CreateAuthService < ApplicationService
Error = Class.new(StandardError)
attr_reader :user, :params
def initialize(user, params)
@user = user
@params = params.clone
end
def call
validate!
result = upload_video_result
Video.create!(user: user, uuid: result['VideoId'], title: title, cover_url: params[:cover_url])
result
end
private
def title
@_title ||= params.delete(:title).to_s.strip
end
def filename
@_filename ||= params.delete(:file_name).to_s.strip
end
def validate!
raise Error, '视频标题不能为空' if title.blank?
raise Error, '源文件名不能为空' if filename.blank?
end
def upload_video_result
AliyunVod::Service.create_upload_video(title, filename, params)
rescue AliyunVod::Error => _
raise Error, '获取视频上传凭证失败'
end
end

@ -0,0 +1,25 @@
class Videos::DispatchCallbackService < ApplicationService
attr_reader :video, :params
def initialize(params)
@video = Video.find_by(uuid: params[:VideoId])
@params = params
end
def call
return if video.blank?
# TODO:: 拆分事件分发
case params['EventType']
when 'FileUploadComplete' then
video.file_url = params['FileUrl']
video.upload_success
video.save!
GetAliyunVideoInfoJob.perform_later(video.uuid)
end
rescue => ex
Util.logger_error(ex)
end
end

@ -0,0 +1,38 @@
class Videos::RefuseApplyService < ApplicationService
Error = Class.new(StandardError)
attr_reader :video_apply, :video, :user, :params
def initialize(video_apply, user, params)
@video_apply = video_apply
@video = video_apply.video
@user = user
@params = params
end
def call
reason = params[:reason].to_s.strip
raise Error, '原因不能为空' if reason.blank?
raise Error, '该状态下不能进行此操作' unless video_apply.may_refuse?
ActiveRecord::Base.transaction do
video_apply.reason = reason
video_apply.refuse
video_apply.save!
video.refuse!
# 将消息改为已处理
Tiding.where(container_id: video.id, container_type: 'Video', tiding_type: 'Apply', status: 0).update_all(status: 1)
notify_video_author!
end
end
private
def notify_video_author!
Tiding.create!(user_id: video.user_id, trigger_user_id: 1,
container_id: video.id, container_type: 'Video',
tiding_type: 'System', status: 2, extra: video_apply.reason)
end
end

@ -0,0 +1,5 @@
json.extract! video, :id, :title, :cover_url, :file_url
json.published_at video.display_published_at
json.created_at video.display_created_at
json.updated_at video.display_updated_at

@ -0,0 +1,2 @@
json.count @count
json.videos @videos, partial: 'video', as: :video

@ -0,0 +1,7 @@
json.count @count
json.videos do
json.array! @video.each do |video|
json.partial! 'video', video: video
json.file_url nil
end
end

@ -0,0 +1 @@
json.partial! 'video', video: current_video

@ -0,0 +1,15 @@
defaults: &defaults
access_key_id: 'test'
access_key_secret: 'test'
base_url: 'http://vod.cn-shanghai.aliyuncs.com'
callback_url: 'http://47.96.87.25:48080/api/callbacks/aliyun_vod.json'
signature_key: 'test12345678'
development:
<<: *defaults
test:
<<: *defaults
production:
<<: *defaults

@ -0,0 +1,6 @@
config = Rails.application.config_for(:aliyun_vod)
AliyunVod.access_key_id = config['access_key_id']
AliyunVod.access_key_secret = config['access_key_secret']
AliyunVod.base_url = config['base_url'] || 'http://vod.cn-shanghai.aliyuncs.com'.freeze
AliyunVod.callback_url = config['callback_url']
AliyunVod.signature_key = config['signature_key']

@ -220,3 +220,8 @@
BiddingEnd_end: "你发布的众包任务:%s已进入选标阶段请尽快进行选择确认"
BiddingWon_end: "恭喜,你应征的众包任务:%s在评选环节中标了"
BiddingLost_end: "很遗憾,你应征投稿的众包任务:%s未中标"
Video:
Apply_end: "申请发布视频:%s"
System:
1_end: "你提交的发布视频申请:%s审核已通过"
2_end: "你提交的发布视频申请:%s审核未通过<br/><span>原因:%{reason}</span>"

@ -70,6 +70,16 @@ Rails.application.routes.draw do
resources :recent_contacts, only: [:index]
resource :private_message_details, only: [:show]
resource :unread_message_info, only: [:show]
# 视频
resources :videos, only: [:index, :update] do
collection do
get :review
post :batch_publish
post :cancel
end
end
resource :video_auths, only: [:create, :update]
end
@ -718,6 +728,8 @@ Rails.application.routes.draw do
scope module: :projects do
resources :project_applies, only: [:create]
end
post 'callbacks/aliyun_vod', to: 'callbacks/aliyun_vods#create'
end
#git 认证回调

@ -0,0 +1,20 @@
class CreateVideos < ActiveRecord::Migration[5.2]
def change
create_table :videos do |t|
t.references :user, index: true, type: :integer
t.string :title
t.string :uuid, comment: 'Aliyun媒体ID'
t.string :cover_url, comment: '视频封面'
t.string :file_url, comment: '视频地址'
t.string :status
t.string :vod_status, comment: 'Aliyun媒体状态'
t.datetime :published_at, index: true
t.timestamps
t.index :uuid, unique: true
end
end
end

@ -0,0 +1,10 @@
class CreateVideoApplies < ActiveRecord::Migration[5.2]
def change
create_table :video_applies do |t|
t.references :video, index: true, type: :integer
t.string :status
t.string :reason
t.timestamps
end
end
end
Loading…
Cancel
Save