From c36b2c8a1b3762ff76e1b71d984893c0dc53f2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sat, 11 Apr 2026 17:05:57 +0900 Subject: [PATCH] =?UTF-8?q?=E6=8A=95=E7=A8=BF=E3=81=AB=E5=AF=BE=E3=81=99?= =?UTF-8?q?=E3=82=8B=E5=B1=A5=E6=AD=B4=EF=BC=88#264=EF=BC=89=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge branch 'main' into feature/264 #264 #264 #264 #264 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/307 --- backend/app/controllers/posts_controller.rb | 36 ++-- backend/app/models/post.rb | 3 + backend/app/models/post_version.rb | 38 ++++ backend/app/models/tag.rb | 10 + backend/app/services/post_version_recorder.rb | 57 +++++ .../20260409123700_create_post_versions.rb | 203 ++++++++++++++++++ backend/db/schema.rb | 26 ++- backend/lib/tasks/sync_nico.rake | 13 +- backend/spec/models/post_version_spec.rb | 41 ++++ backend/spec/requests/posts_spec.rb | 123 +++++++++++ 10 files changed, 533 insertions(+), 17 deletions(-) create mode 100644 backend/app/models/post_version.rb create mode 100644 backend/app/services/post_version_recorder.rb create mode 100644 backend/db/migrate/20260409123700_create_post_versions.rb create mode 100644 backend/spec/models/post_version_spec.rb 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..c933813 --- /dev/null +++ b/backend/app/models/post_version.rb @@ -0,0 +1,38 @@ +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' }, prefix: true, validate: true + + validates :version_no, presence: true, numericality: { 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..052bacd --- /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_by_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[:tags], + 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..a2c6da7 --- /dev/null +++ b/backend/db/migrate/20260409123700_create_post_versions.rb @@ -0,0 +1,203 @@ +require 'set' + + +class CreatePostVersions < ActiveRecord::Migration[8.0] + class Post < ApplicationRecord + self.table_name = 'posts' + end + + class PostTag < ApplicationRecord + self.table_name = 'post_tags' + end + + class PostVersion < ApplicationRecord + self.table_name = 'post_versions' + end + + def up + 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', + name: 'post_versions_version_no_positive' + t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')", + name: 'post_versions_event_type_valid' + end + + PostVersion.reset_column_information + + say_with_time 'Backfilling post_versions' do + Post.find_in_batches(batch_size: 500) do |posts| + post_ids = posts.map(&:id) + + post_tag_rows_by_post_id = + PostTag + .joins('INNER JOIN tags ON tags.id = post_tags.tag_id') + .joins('INNER JOIN tag_names ON tag_names.id = tags.tag_name_id') + .where(post_id: post_ids) + .pluck('post_tags.post_id', + 'post_tags.created_at', + 'post_tags.discarded_at', + 'post_tags.created_user_id', + 'post_tags.deleted_user_id', + 'tag_names.name') + .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| + post_id, created_at, discarded_at, created_user_id, deleted_user_id, tag_name = row + h[post_id] << { created_at:, + discarded_at:, + created_user_id:, + deleted_user_id:, + tag_name: } + end + + rows = [] + + posts.each do |post| + post_tag_rows = post_tag_rows_by_post_id[post.id] + + events = post_tag_rows.flat_map do |post_tag_row| + ary = [[post_tag_row[:created_at], + post_tag_row[:created_user_id], + :add, + post_tag_row[:tag_name]]] + + if post_tag_row[:discarded_at] + ary << [post_tag_row[:discarded_at], + post_tag_row[:deleted_user_id], + :remove, + post_tag_row[:tag_name]] + end + + ary + end + + kind_order = { add: 0, remove: 1 } + + events.sort_by! do |event_at, user_id, kind, tag_name| + [event_at, user_id || 0, kind_order.fetch(kind), tag_name] + end + + event_buckets = bucket_events(events) + + active_tags = Set.new + version_no = 0 + + if event_buckets.empty? + version_no += 1 + rows << build_row(post:, + version_no:, + event_type: 'create', + created_at: post.created_at, + created_by_user_id: post.uploaded_user_id, + tags: []) + next + end + + first_bucket = event_buckets.first + merge_first_bucket_into_create = first_bucket[:first_at] <= post.created_at + 1.second + + if merge_first_bucket_into_create + event_buckets.shift + apply_bucket!(active_tags, first_bucket) + + version_no += 1 + rows << build_row( + post:, + version_no:, + event_type: 'create', + created_at: post.created_at, + created_by_user_id: post.uploaded_user_id || first_bucket[:user_ids].compact.first, + tags: active_tags.to_a.sort) + else + version_no += 1 + rows << build_row( + post:, + version_no:, + event_type: 'create', + created_at: post.created_at, + created_by_user_id: post.uploaded_user_id, + tags: []) + end + + event_buckets.each do |bucket| + apply_bucket!(active_tags, bucket) + + version_no += 1 + rows << build_row( + post:, + version_no:, + event_type: 'update', + created_at: bucket[:first_at], + created_by_user_id: bucket[:user_ids].compact.first, + tags: active_tags.to_a.sort) + end + end + + PostVersion.insert_all!(rows) if rows.any? + end + end + end + + def down + drop_table :post_versions + end + + private + + def bucket_events events + buckets = [] + + events.each do |event_at, user_id, kind, tag_name| + if buckets.empty? || event_at - buckets.last[:last_at] > 1.second + buckets << { first_at: event_at, + last_at: event_at, + user_ids: [user_id], + events: [[kind, tag_name]] } + else + bucket = buckets.last + bucket[:last_at] = event_at + bucket[:user_ids] << user_id + bucket[:events] << [kind, tag_name] + end + end + + buckets + end + + def apply_bucket! active_tags, bucket + bucket[:events].each do |kind, tag_name| + if kind == :add + active_tags.add(tag_name) + else + active_tags.delete(tag_name) + end + end + end + + def build_row post:, version_no:, event_type:, created_at:, created_by_user_id:, tags: + { post_id: post.id, + version_no:, + event_type:, + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: tags.join(' '), + parent_id: post.parent_id, + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at:, + created_by_user_id: } + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 22541e2..42c7cd4 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do +ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -132,6 +132,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do t.index ["tag_id"], name: "index_post_tags_on_tag_id" end + create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "post_id", null: false + 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.bigint "parent_id" + t.datetime "original_created_from" + t.datetime "original_created_before" + t.datetime "created_at", null: false + t.bigint "created_by_user_id" + t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" + t.index ["parent_id"], name: "index_post_versions_on_parent_id" + t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true + t.index ["post_id"], name: "index_post_versions_on_post_id" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" + end + create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "title" t.string "url", limit: 768, null: false @@ -362,6 +383,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "deleted_user_id" + add_foreign_key "post_versions", "posts" + add_foreign_key "post_versions", "posts", column: "parent_id" + add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "posts", "posts", column: "parent_id" add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "settings", "users" 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 diff --git a/backend/spec/models/post_version_spec.rb b/backend/spec/models/post_version_spec.rb new file mode 100644 index 0000000..d35ab4c --- /dev/null +++ b/backend/spec/models/post_version_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe PostVersion, type: :model do + let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') } + let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } + + let!(:post_record) do + Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post| + PostTag.create!(post: post, tag: tag) + end + end + + let!(:post_version) do + PostVersion.create!( + post: post_record, + version_no: 1, + event_type: 'create', + title: post_record.title, + url: post_record.url, + thumbnail_base: post_record.thumbnail_base, + tags: post_record.snapshot_tag_names.join(' '), + parent: post_record.parent, + original_created_from: post_record.original_created_from, + original_created_before: post_record.original_created_before, + created_at: Time.current, + created_by_user: nil + ) + end + + it 'is read only after create' do + expect do + post_version.update!(title: 'changed') + end.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'cannot be destroyed' do + expect do + post_version.destroy! + end.to raise_error(ActiveRecord::ReadOnlyRecord) + end +end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 120a221..1295165 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -795,4 +795,127 @@ RSpec.describe 'Posts API', type: :request do expect(user.reload.viewed?(post_record)).to be(false) end end + + describe 'post versioning' do + let(:member) { create(:user, :member) } + + def snapshot_tags(post) + post.snapshot_tag_names.join(' ') + end + + def create_post_version_for!(post) + PostVersion.create!( + post: post, + version_no: 1, + event_type: 'create', + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: snapshot_tags(post), + parent: post.parent, + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: post.created_at, + created_by_user: post.uploaded_user + ) + end + + it 'creates version 1 on POST /posts' do + sign_in_as(member) + + expect do + post '/posts', params: { + title: 'versioned post', + url: 'https://example.com/versioned-post', + tags: 'spec_tag', + thumbnail: dummy_upload + } + end.to change(PostVersion, :count).by(1) + + expect(response).to have_http_status(:created) + + created_post = Post.find(json.fetch('id')) + version = PostVersion.find_by!(post: created_post, version_no: 1) + + expect(version.event_type).to eq('create') + expect(version.title).to eq('versioned post') + expect(version.url).to eq('https://example.com/versioned-post') + expect(version.created_by_user_id).to eq(member.id) + expect(version.tags).to eq(snapshot_tags(created_post)) + end + + it 'creates next version on PUT /posts/:id when snapshot changes' do + sign_in_as(member) + create_post_version_for!(post_record) + + tag_name2 = TagName.create!(name: 'spec_tag_2') + Tag.create!(tag_name: tag_name2, category: :general) + + expect do + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag_2' + } + end.to change(PostVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + version = post_record.reload.post_versions.order(:version_no).last + expect(version.version_no).to eq(2) + expect(version.event_type).to eq('update') + expect(version.title).to eq('updated title') + expect(version.created_by_user_id).to eq(member.id) + expect(version.tags).to eq(snapshot_tags(post_record.reload)) + end + + it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do + sign_in_as(member) + create_post_version_for!(post_record) + + expect do + put "/posts/#{post_record.id}", params: { + title: post_record.title, + tags: 'spec_tag' + } + end.not_to change(PostVersion, :count) + + expect(response).to have_http_status(:ok) + + version = post_record.reload.post_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.tags).to eq(snapshot_tags(post_record)) + end + + it 'does not create a version when POST /posts is invalid' do + sign_in_as(member) + + expect do + post '/posts', params: { + title: 'invalid post', + url: 'ぼざクリタグ広場', + tags: 'spec_tag', + thumbnail: dummy_upload + } + end.not_to change(PostVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'does not create a version when PUT /posts/:id is invalid' do + sign_in_as(member) + create_post_version_for!(post_record) + + expect do + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag', + original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, + original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601 + } + end.not_to change(PostVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + end end