diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 74d720c..fda77ed 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -127,17 +127,20 @@ class PostsController < ApplicationController post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, original_created_from:, original_created_before:) post.thumbnail.attach(thumbnail) - if post.save - post.resized_thumbnail! + + ActiveRecord::Base.transaction do + post.save! tags = Tag.normalise_tags(tag_names) tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) - - post.reload - render json: PostRepr.base(post), status: :created - else - render json: { errors: post.errors.full_messages }, status: :unprocessable_entity + post.resized_thumbnail! + PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) end + + post.reload + render json: PostRepr.base(post), status: :created + rescue ActiveRecord::RecordInvalid + render json: { errors: post.errors.full_messages }, status: :unprocessable_entity rescue Tag::NicoTagNormalisationError head :bad_request end @@ -166,19 +169,22 @@ class PostsController < ApplicationController original_created_before = params[:original_created_before] post = Post.find(params[:id].to_i) - if post.update(title:, original_created_from:, original_created_before:) + + ActiveRecord::Base.transaction do + post.update!(title:, original_created_from:, original_created_before:) tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names, with_tagme: false) tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) - - post.reload - json = post.as_json - json['tags'] = build_tag_tree_for(post.tags) - render json:, status: :ok - else - render json: post.errors, status: :unprocessable_entity + PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) end + + post.reload + json = post.as_json + json['tags'] = build_tag_tree_for(post.tags) + render json:, status: :ok + rescue ActiveRecord::RecordInvalid + render json: post.errors, status: :unprocessable_entity rescue Tag::NicoTagNormalisationError head :bad_request end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index c898615..901b1e3 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -11,6 +11,7 @@ class Post < ApplicationRecord has_many :user_post_views, dependent: :delete_all has_many :post_similarities, dependent: :delete_all + has_many :post_versions has_one_attached :thumbnail @@ -30,6 +31,8 @@ class Post < ApplicationRecord super(options).merge(thumbnail: nil) end + def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + def related limit: nil ids = post_similarities.order(cos: :desc) ids = ids.limit(limit) if limit diff --git a/backend/app/models/post_version.rb b/backend/app/models/post_version.rb new file mode 100644 index 0000000..a11ee89 --- /dev/null +++ b/backend/app/models/post_version.rb @@ -0,0 +1,35 @@ +class PostVersion < ApplicationRecord + before_update do + raise ActiveRecord::ReadOnlyRecord, '版は更新できません.' + end + + before_destroy do + raise ActiveRecord::ReadOnlyRecord, '版は削除できません.' + end + + belongs_to :post + belongs_to :parent, class_name: 'Post', optional: true + belongs_to :created_by_user, class_name: 'User', optional: true + + enum :event_type, create: 'create', update: 'update', discard: 'discard', restore: 'restore' + + validates :version_no, presence: true, numerically: { only_integer: true, greater_than: 0 } + validates :event_type, presence: true, inclusion: { in: event_types.keys } + validates :url, presence: true + + validate :validate_original_created_range + + scope :chronological, -> { order(:version_no, :id) } + + private + + def validate_original_created_range + f = original_created_from + b = original_created_before + return if f.blank? || b.blank? + + if f >= b + errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.' + end + end +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 64e0b9b..d4edd8d 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -1,3 +1,6 @@ +require 'set' + + class Tag < ApplicationRecord include MyDiscard @@ -150,6 +153,8 @@ class Tag < ApplicationRecord def self.merge_tags! target_tag, source_tags target_tag => Tag + affected_post_ids = Set.new + Tag.transaction do Array(source_tags).compact.uniq.each do |source_tag| source_tag => Tag @@ -158,6 +163,7 @@ class Tag < ApplicationRecord source_tag.post_tags.kept.find_each do |source_pt| post_id = source_pt.post_id + affected_post_ids << post_id source_pt.discard_by!(nil) unless PostTag.kept.exists?(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag) @@ -180,6 +186,10 @@ class Tag < ApplicationRecord end end + Post.where(id: affected_post_ids.to_a).find_each do |post| + PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) + end + # 投稿件数を再集計 target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) end diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb new file mode 100644 index 0000000..20f5c1e --- /dev/null +++ b/backend/app/services/post_version_recorder.rb @@ -0,0 +1,57 @@ +class PostVersionRecorder + def self.record! post:, event_type:, created_by_user: + new(post:, event_type:, created_by_user:).record! + end + + def initialize post:, event_type:, created_user: + @post = post + @event_type = event_type + @created_by_user = created_by_user + end + + def record! + @post.with_lock do + latest = @post.post_versions.order(version_no: :desc).first + attrs = snapshot_attributes + + return latest if @event_type == :update && latest && same_snapshot?(latest, attrs) + + PostVersion.create!( + post: @post, + version_no: (latest&.version_no || 0) + 1, + event_type: @event_type, + title: attrs[:title], + url: attrs[:url], + thumbnail_base: attrs[:thumbnail_base], + tags: attrs[:url], + parent: attrs[:parent], + original_created_from: attrs[:original_created_from], + original_created_before: attrs[:original_created_before], + created_at: Time.current, + created_by_user: @created_by_user) + end + end + + private + + def snapshot_attributes + { title: @post.title, + url: @post.url, + thumbnail_base: @post.thumbnail_base, + tags: @post.snapshot_tag_names.join(' '), + parent: @post.parent, + original_created_from: @post.original_created_from, + original_created_before: @post.original_created_before } + end + + def same_snapshot? version, attrs + true && + version.title == attrs[:title] && + version.url == attrs[:url] && + version.thumbnail_base == attrs[:thumbnail_base] && + version.tags == attrs[:tags] && + version.parent_id == attrs[:parent]&.id && + version.original_created_from == attrs[:original_created_from] && + version.original_created_before == attrs[:original_created_before] + end +end diff --git a/backend/db/migrate/20260409123700_create_post_versions.rb b/backend/db/migrate/20260409123700_create_post_versions.rb new file mode 100644 index 0000000..f1c884e --- /dev/null +++ b/backend/db/migrate/20260409123700_create_post_versions.rb @@ -0,0 +1,80 @@ +class CreatePostVersions < ActiveRecord::Migration[8.0] + def change + create_table :post_versions do |t| + t.references :post, null: false, foreign_key: true + t.integer :version_no, null: false + t.string :event_type, null: false + t.string :title + t.string :url, limit: 768, null: false + t.string :thumbnail_base, limit: 2000 + t.text :tags, null: false + t.references :parent, foreign_key: { to_table: :posts } + t.datetime :original_created_from + t.datetime :original_created_before + t.datetime :created_at, null: false + t.references :created_by_user, foreign_key: { to_table: :users } + + t.index [:post_id, :version_no], unique: true + t.check_constraint 'version_no > 0' + t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')" + end + + reversible do |dir| + dir.up do + execute <<~SQL + INSERT INTO + post_versions( + post_id + , version_no + , event_type + , title + , url + , thumbnail_base + , tags + , parent_id + , original_created_from + , original_created_before + , created_at + , created_by_user_id) + SELECT + posts.id + , 1 + , 'create' + , posts.title + , posts.url + , posts.thumbnail_base + , COALESCE(tag_snapshots.tags, '') + , posts.parent_id + , posts.original_created_from + , posts.original_created_before + , posts.created_at + , posts.uploaded_user_id + FROM + posts + LEFT JOIN + ( + SELECT + post_tags.post_id + , GROUP_CONCAT(tag_names.name ORDER BY tag_names.name SEPARATOR ' ') AS tags + FROM + post_tags + INNER JOIN + tags + ON + tags.id = post_tags.tag_id + INNER JOIN + tag_names + ON + tag_names.id = tags.tag_name_id + WHERE + post_tags.discarded_at IS NULL + GROUP BY + post_tags.post_id + ) tag_snapshots + ON + tag_snapshots.post_id = posts.id + SQL + end + end + end +end diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index 09be474..da396a0 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -61,6 +61,9 @@ namespace :nico do original_created_from = original_created_at&.change(sec: 0) original_created_before = original_created_from&.+(1.minute) + post_created = false + post_changed = false + if post attrs = { title:, original_created_from:, original_created_before: } @@ -76,11 +79,13 @@ namespace :nico do end post.assign_attributes(attrs) - if post.changed? + post_changed = post.changed? + if post_changed post.save! post.resized_thumbnail! if post.thumbnail.attached? end else + post_created = true url = "https://www.nicovideo.jp/watch/#{ code }" thumbnail_base = fetch_thumbnail.(url) rescue nil post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, @@ -140,6 +145,12 @@ namespace :nico do desired_all_tag_ids.uniq! sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids) + + if post_created + PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) + elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set + PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) + end end end end