From f3d9e88ea221e5999beaa9ed0045f388e48259b2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 19 Apr 2026 05:29:59 +0900 Subject: [PATCH 1/6] #309 --- .../20260409123700_create_post_versions.rb | 6 +- .../20260419035400_create_tag_versions.rb | 89 +++++++++++++++++++ backend/db/schema.rb | 21 ++++- 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 backend/db/migrate/20260419035400_create_tag_versions.rb diff --git a/backend/db/migrate/20260409123700_create_post_versions.rb b/backend/db/migrate/20260409123700_create_post_versions.rb index a2c6da7..58df885 100644 --- a/backend/db/migrate/20260409123700_create_post_versions.rb +++ b/backend/db/migrate/20260409123700_create_post_versions.rb @@ -2,15 +2,15 @@ require 'set' class CreatePostVersions < ActiveRecord::Migration[8.0] - class Post < ApplicationRecord + class Post < ActiveRecord::Base self.table_name = 'posts' end - class PostTag < ApplicationRecord + class PostTag < ActiveRecord::Base self.table_name = 'post_tags' end - class PostVersion < ApplicationRecord + class PostVersion < ActiveRecord::Base self.table_name = 'post_versions' end diff --git a/backend/db/migrate/20260419035400_create_tag_versions.rb b/backend/db/migrate/20260419035400_create_tag_versions.rb new file mode 100644 index 0000000..a195841 --- /dev/null +++ b/backend/db/migrate/20260419035400_create_tag_versions.rb @@ -0,0 +1,89 @@ +class CreateTagVersions < ActiveRecord::Migration[8.0] + class Tag < ActiveRecord::Base + self.table_name = 'tags' + end + + class TagName < ActiveRecord::Base + self.table_name = 'tag_names' + end + + class TagImplication < ActiveRecord::Base + self.table_name = 'tag_implications' + end + + class TagVersion < ActiveRecord::Base + self.table_name = 'tag_versions' + end + + def up + create_table :tag_versions do |t| + t.references :tag, null: false, foreign_key: true, index: false + t.integer :version_no, null: false + t.string :event_type, null: false + t.string :name, null: false + t.string :category, null: false + t.text :aliases, null: false + t.text :parent_tag_ids, null: false + t.datetime :created_at, null: false + t.references :created_by_user, foreign_key: { to_table: :users }, index: false + + t.index [:tag_id, :version_no], unique: true + t.index :created_at + t.index [:tag_id, :created_at], order: { created_at: :desc } + t.index [:created_by_user_id, :created_at], order: { created_at: :desc } + t.check_constraint 'version_no > 0', + name: 'tag_versions_version_no_positive' + end + + TagVersion.reset_column_information + + say_with_time 'Backfilling tag_versions' do + Tag.where(discarded_at: nil).find_in_batches(batch_size: 500) do |tags| + tag_ids = tags.map(&:id) + + tag_implication_rows_by_tag_id = + TagImplication + .where(tag_id: tag_ids) + .pluck(:tag_id, :parent_tag_id) + .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| + h[row[0]] << row[1] + end + + tag_name_rows_by_tag_id = + TagName + .joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id') + .where(tags: { id: tag_ids }) + .pluck('tags.id', 'tag_names.name') + .each_with_object({ }) do |row, h| + h[row[0]] = row[1] + end + + tag_alias_rows_by_tag_id = + TagName + .joins('INNER JOIN tags ON tags.tag_name_id = tag_names.canonical_id') + .where(tags: { id: tag_ids }) + .where(tag_names: { discarded_at: nil }) + .pluck('tags.id', 'tag_names.name') + .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| + h[row[0]] << row[1] + end + + TagVersion.insert_all(tags.map { |tag| + { tag_id: tag.id, + version_no: 1, + event_type: 'create', + name: tag_name_rows_by_tag_id[tag.id], + category: tag.category, + aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '), + parent_tag_ids: tag_implication_rows_by_tag_id[tag.id].sort.join(' '), + created_at: tag.created_at, + created_by_user_id: nil } + }) + end + end + end + + def down + drop_table :tag_versions + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 42c7cd4..8ebf572 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_04_09_123700) do +ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) 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 @@ -216,6 +216,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end + create_table "tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.string "name", null: false + t.string "category", null: false + t.text "aliases", null: false + t.text "parent_tag_ids", null: false + t.datetime "created_at", null: false + t.bigint "created_by_user_id" + t.index ["created_at"], name: "index_tag_versions_on_created_at" + t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true + t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive" + end + create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_name_id", null: false t.string "category", default: "general", null: false @@ -394,6 +411,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do add_foreign_key "tag_names", "tag_names", column: "canonical_id" add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags", column: "target_tag_id" + add_foreign_key "tag_versions", "tags" + add_foreign_key "tag_versions", "users", column: "created_by_user_id" add_foreign_key "tags", "tag_names" add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "users" -- 2.34.1 From f3a2b0835902a05818f5bc7308f06d8cfaa81557 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 19 Apr 2026 17:14:47 +0900 Subject: [PATCH 2/6] #309 --- .../app/controllers/nico_tags_controller.rb | 25 +++++-- backend/app/controllers/posts_controller.rb | 13 +++- .../controllers/tag_children_controller.rb | 18 ++++- backend/app/controllers/tags_controller.rb | 30 ++++++-- backend/app/models/my_discard.rb | 6 +- backend/app/models/post_version.rb | 18 +---- backend/app/models/tag.rb | 23 ++++-- backend/app/models/tag_version.rb | 16 ++++ backend/app/models/version_record.rb | 19 +++++ .../app/services/nico_tag_version_recorder.rb | 16 ++++ backend/app/services/post_version_recorder.rb | 42 ++--------- backend/app/services/tag_version_recorder.rb | 22 ++++++ backend/app/services/version_recorder.rb | 57 +++++++++++++++ .../20260419035400_create_tag_versions.rb | 73 ++++++++++++++++++- backend/db/schema.rb | 17 +++++ backend/lib/tasks/sync_nico.rake | 4 + 16 files changed, 319 insertions(+), 80 deletions(-) create mode 100644 backend/app/models/tag_version.rb create mode 100644 backend/app/models/version_record.rb create mode 100644 backend/app/services/nico_tag_version_recorder.rb create mode 100644 backend/app/services/tag_version_recorder.rb create mode 100644 backend/app/services/version_recorder.rb diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 8bb582c..973d70f 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -30,16 +30,31 @@ class NicoTagsController < ApplicationController id = params[:id].to_i tag = Tag.find(id) - return head :bad_request if tag.category != 'nico' + return head :bad_request unless tag.nico? - linked_tag_names = params[:tags].to_s.split(' ') + linked_tag_names = params[:tags].to_s.split linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false, with_no_deerjikist: false) - return head :bad_request if linked_tags.any? { |t| t.category == 'nico' } + return head :bad_request if linked_tags.any? { |t| t.nico? } - tag.linked_tags = linked_tags - tag.save! + ApplicationRecord.transaction do + record_tag_snapshots!(linked_tags, created_by_user: current_user) + + tag.linked_tags = linked_tags + tag.save! + + NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) + end render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok end + + private + + def record_tag_snapshots! tags, created_by_user: + tags.each do |tag| + event_type = tag.tag_versions.exists? ? :update : :create + TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + end + end end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 26ca581..03e2d8a 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -128,9 +128,11 @@ class PostsController < ApplicationController original_created_from:, original_created_before:) post.thumbnail.attach(thumbnail) - ActiveRecord::Base.transaction do + ApplicationRecord.transaction do post.save! tags = Tag.normalise_tags(tag_names) + record_tag_snapshots!(tags, created_by_user: current_user) + tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) post.resized_thumbnail! @@ -170,10 +172,13 @@ class PostsController < ApplicationController post = Post.find(params[:id].to_i) - ActiveRecord::Base.transaction do + ApplicationRecord.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) + + normalised_tag = Tag.normalise_tags(tag_names, with_tagme: false) + record_tag_snapshots(normalised_tags, create_by_user: current_user) + + tags = post.tags.where(category: 'nico').to_a + normalised_tags tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) diff --git a/backend/app/controllers/tag_children_controller.rb b/backend/app/controllers/tag_children_controller.rb index 4b352b4..8a82126 100644 --- a/backend/app/controllers/tag_children_controller.rb +++ b/backend/app/controllers/tag_children_controller.rb @@ -7,7 +7,14 @@ class TagChildrenController < ApplicationController child_id = params[:child_id] return head :bad_request if parent_id.blank? || child_id.blank? - Tag.find(parent_id).children << Tag.find(child_id) rescue nil + parent = Tag.find(parent_id) + child = Tag.find(child_id) + return head :bad_request if parent.nico? || child.nico? + + ApplicationRecord.transaction do + TagImplication.find_or_create_by!(parent_tag: parent, tag: child) + TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user) + end head :no_content end @@ -20,7 +27,14 @@ class TagChildrenController < ApplicationController child_id = params[:child_id] return head :bad_request if parent_id.blank? || child_id.blank? - Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil + parent = Tag.find(parent_id) + child = Tag.find(child_id) + return head :bad_request if parent.nico? || child.nico? + + ApplicationRecord.transaction do + TagImplication.find_by(parent_tag: parent, tag: child)&.destroy! + TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user) + end head :no_content end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 3a9e8a6..71b7309 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -218,15 +218,25 @@ class TagsController < ApplicationController tag = Tag.find(params[:id]) - if name.present? - tag.tag_name.update!(name:) - end + ApplicationRecord.transaction do + old_nico = tag.nico? + + if category.present? + new_nico = category == 'nico' + + if old_nico != new_nico + return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, + status: :unprocessable_entity + end + end + + tag.tag_name.update!(name:) if name.present? + tag.update!(category:) if category.present? - if category.present? - tag.update!(category:) + record_tag_version!(tag, event_type: :update, created_by_user: current_user) end - render json: TagRepr.base(tag) + render json: TagRepr.base(tag.reload) end private @@ -244,4 +254,12 @@ class TagsController < ApplicationController children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, material: material.as_json&.merge(file:, content_type:)) end + + def record_tag_version!(tag, event_type:, created_by_user:) + if tag.nico? + NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) + else + TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + end + end end diff --git a/backend/app/models/my_discard.rb b/backend/app/models/my_discard.rb index dc4a98d..f51984e 100644 --- a/backend/app/models/my_discard.rb +++ b/backend/app/models/my_discard.rb @@ -1,7 +1,11 @@ module MyDiscard extend ActiveSupport::Concern - included { include Discard::Model } + included do + include Discard::Model + + default_scope -> { kept } + end class_methods do def find_undiscard_or_create_by! attrs, &block diff --git a/backend/app/models/post_version.rb b/backend/app/models/post_version.rb index c933813..523d1a0 100644 --- a/backend/app/models/post_version.rb +++ b/backend/app/models/post_version.rb @@ -1,29 +1,13 @@ class PostVersion < ApplicationRecord - before_update do - raise ActiveRecord::ReadOnlyRecord, '版は更新できません.' - end - - before_destroy do - raise ActiveRecord::ReadOnlyRecord, '版は削除できません.' - end + include VersionRecord 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 diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index dfa83b0..8ac0f33 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -8,8 +8,6 @@ class Tag < ApplicationRecord ; end - default_scope -> { kept } - has_many :post_tags, inverse_of: :tag has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' @@ -36,6 +34,9 @@ class Tag < ApplicationRecord has_many :deerjikists, dependent: :delete_all has_many :materials + has_many :tag_versions + has_many :nico_tag_versions + belongs_to :tag_name delegate :wiki_page, to: :tag_name @@ -152,10 +153,11 @@ class Tag < ApplicationRecord retry end - def self.merge_tags! target_tag, source_tags + def self.merge_tags! target_tag, source_tags, created_by_user: nil target_tag => Tag affected_post_ids = Set.new + affected_tag_ids = Set.new Tag.transaction do Array(source_tags).compact.uniq.each do |source_tag| @@ -166,7 +168,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) + source_pt.discard_by!(created_by_user) unless PostTag.kept.exists?(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag) end @@ -179,6 +181,7 @@ class Tag < ApplicationRecord end source_tag.discard! + record_tag_discard!(source_tag, current_by_user: nil) if source_tag.nico? source_tag_name.discard! @@ -186,10 +189,12 @@ class Tag < ApplicationRecord source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, updated_at: Time.current) end + + record_tag_version!(target_tag, event_type: :update, created_by_user: nil) end Post.where(id: affected_post_ids.to_a).find_each do |post| - PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) + PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) end # 投稿件数を再集計 @@ -199,6 +204,14 @@ class Tag < ApplicationRecord target_tag.reload end + def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name) + + def snapshot_parent_tag_ids = parents.order('id').pluck('id') + + def snapshot_linked_tags + linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + end + private def nico_tag_name_must_start_with_nico diff --git a/backend/app/models/tag_version.rb b/backend/app/models/tag_version.rb new file mode 100644 index 0000000..c15933e --- /dev/null +++ b/backend/app/models/tag_version.rb @@ -0,0 +1,16 @@ +class TagVersion < ApplicationRecord + include VersionRecord + + belongs_to :tag + + enum :category, { deerjikist: 'deerjikist', + meme: 'meme', + character: 'character', + general: 'general', + material: 'material', + meta: 'meta', + nico: 'nico' }, validate: true + + validates :name, presence: true + validates :category, presence: true +end diff --git a/backend/app/models/version_record.rb b/backend/app/models/version_record.rb new file mode 100644 index 0000000..7224639 --- /dev/null +++ b/backend/app/models/version_record.rb @@ -0,0 +1,19 @@ +module VersionRecord + extend ActiveSupport::Concern + + def readonly? = persisted? + + included do + 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 + + scope :chronological, -> { order(:version_no, :id) } + end +end diff --git a/backend/app/services/nico_tag_version_recorder.rb b/backend/app/services/nico_tag_version_recorder.rb new file mode 100644 index 0000000..07a5c63 --- /dev/null +++ b/backend/app/services/nico_tag_version_recorder.rb @@ -0,0 +1,16 @@ +class NicoTagVersionRecorder < VersionRecorder + def self.record! tag:, event_type:, created_by_user: + new(tag:, event_type:, created_by_user:).record! + end + + def initialize tag:, event_type:, created_by_user: + super(record: tag, event_type:, created_by_user:) + end + + private + + def version_class = NicoTagVersion + def version_association = :nico_tag_versions + def record_key = :tag + def snapshot_attributes = { name: @tag.name, linked_tags: @tag.snapshot_linked_tags.join(' ') } +end diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb index 052bacd..87e267e 100644 --- a/backend/app/services/post_version_recorder.rb +++ b/backend/app/services/post_version_recorder.rb @@ -4,36 +4,15 @@ class PostVersionRecorder 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 + super(record: post, event_type:, created_by_user:) end private + def version_class = PostVersion + def version_association = :post_versions + def record_key = :post + def snapshot_attributes { title: @post.title, url: @post.url, @@ -43,15 +22,4 @@ class PostVersionRecorder 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/app/services/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb new file mode 100644 index 0000000..b979462 --- /dev/null +++ b/backend/app/services/tag_version_recorder.rb @@ -0,0 +1,22 @@ +class TagVersionRecorder < VersionRecorder + def self.record! tag:, event_type:, created_by_user: + new(tag:, event_type:, created_by_user:).record! + end + + def initialize tag:, event_type:, created_by_user: + super(record: tag, event_type:, created_by_user:) + end + + private + + def version_class = TagVersion + def version_association = :tag_versions + def record_key = :tag + + def snapshot_attributes + { name: @tag.name, + category: @tag.category, + aliases: @tag.snapshot_aliases.join(' '), + parent_tag_ids: @tag.snapshot_parent_tag_ids.join(' ') } + end +end diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb new file mode 100644 index 0000000..5368313 --- /dev/null +++ b/backend/app/services/version_recorder.rb @@ -0,0 +1,57 @@ +class VersionRecorder + EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze + + def initialize record:, event_type:, created_by_user: + @record = record + @event_type = event_type.to_s + @created_by_user = created_by_user + + validate_event_type! + end + + def record! record, event_type:, created_by_user: + @record.with_lock do + latest = latest_version + + if !(latest) && @event_type != 'create' + raise "#{ version_class.name } first event must be create" + end + + if @event_type == 'create' && latest + raise "#{ version_class.name } create event already exists" + end + + attrs = snapshot_attributes + + return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs) + + version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs)) + end + end + + private + + def latest_version = versions.order(version_no: :desc).first + + def versions = @record.public_send(version_association) + + def base_attributes latest + { version_no: (latest&.version_no || 0) + 1, + event_type: @event_type, + created_at: Time.current, + created_by_user: @created_by_user } + end + + def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v } + + def validate_event_type! + return if EVENT_TYPES.include?(@event_type) + + raise ArgumentError, "Invalid event_type: #{ @event_type }" + end + + def version_class = raise NotImplementedError + def version_association = raise NotImplementedError + def record_key = raise NotImplementedError + def snapshot_attributes = raise NotImplementedError +end diff --git a/backend/db/migrate/20260419035400_create_tag_versions.rb b/backend/db/migrate/20260419035400_create_tag_versions.rb index a195841..d1f54a6 100644 --- a/backend/db/migrate/20260419035400_create_tag_versions.rb +++ b/backend/db/migrate/20260419035400_create_tag_versions.rb @@ -15,6 +15,14 @@ class CreateTagVersions < ActiveRecord::Migration[8.0] self.table_name = 'tag_versions' end + class NicoTagVersion < ActiveRecord::Base + self.table_name = 'nico_tag_versions' + end + + class NicoTagRelation < ActiveRecord::Base + self.table_name = 'nico_tag_relations' + end + def up create_table :tag_versions do |t| t.references :tag, null: false, foreign_key: true, index: false @@ -35,10 +43,28 @@ class CreateTagVersions < ActiveRecord::Migration[8.0] name: 'tag_versions_version_no_positive' end - TagVersion.reset_column_information + create_table :nico_tag_versions do |t| + t.references :tag, null: false, foreign_key: true, index: false + t.integer :version_no, null: false + t.string :event_type, null: false + t.string :name, null: false + t.text :linked_tags, null: false + t.datetime :created_at, null: false + t.references :created_by_user, foreign_key: { to_table: :users }, index: false + t.index [:tag_id, :version_no], unique: true + t.index :created_at + t.index [:tag_id, :created_at], order: { created_at: :desc } + t.index [:created_by_user_id, :created_at], order: { created_at: :desc } + t.check_constraint 'version_no > 0', + name: 'nico_tag_versions_version_no_positive' + end + + TagVersion.reset_column_information say_with_time 'Backfilling tag_versions' do - Tag.where(discarded_at: nil).find_in_batches(batch_size: 500) do |tags| + Tag.where(discarded_at: nil) + .where.not(category: 'nico') + .find_in_batches(batch_size: 500) do |tags| tag_ids = tags.map(&:id) tag_implication_rows_by_tag_id = @@ -74,16 +100,57 @@ class CreateTagVersions < ActiveRecord::Migration[8.0] event_type: 'create', name: tag_name_rows_by_tag_id[tag.id], category: tag.category, - aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '), + aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '), parent_tag_ids: tag_implication_rows_by_tag_id[tag.id].sort.join(' '), created_at: tag.created_at, created_by_user_id: nil } }) end end + + NicoTagVersion.reset_column_information + say_with_time 'Backfilling nico_tag_versions' do + Tag.where(discarded_at: nil, category: 'nico') + .find_in_batches(batch_size: 500) do |tags| + tag_ids = tags.map(&:id) + + tag_name_rows_by_tag_id = + TagName + .joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id') + .where(tags: { id: tag_ids }) + .pluck('tags.id', 'tag_names.name') + .each_with_object({ }) do |row, h| + h[row[0]] = row[1] + end + + nico_tag_relation_rows_by_tag_id = + NicoTagRelation + .joins('INNER JOIN tags nico_tags ON nico_tags.id = nico_tag_relations.nico_tag_id') + .joins('INNER JOIN tags linked_tags ON linked_tags.id = nico_tag_relations.tag_id') + .joins('INNER JOIN tag_names ON tag_names.id = linked_tags.tag_name_id') + .where(nico_tags: { id: tag_ids }) + .where(linked_tags: { discarded_at: nil }) + .where(tag_names: { discarded_at: nil }) + .pluck('nico_tags.id', 'tag_names.name') + .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| + h[row[0]] << row[1] + end + + NicoTagVersion.insert_all(tags.map { |tag| + { tag_id: tag.id, + version_no: 1, + event_type: 'create', + name: tag_name_rows_by_tag_id[tag.id], + linked_tags: nico_tag_relation_rows_by_tag_id[tag.id].sort.join(' '), + created_at: tag.created_at, + created_by_user_id: nil } + }) + end + end end def down + drop_table :nico_tag_versions drop_table :tag_versions end end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 8ebf572..ede919d 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -104,6 +104,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id" end + create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.string "name", null: false + t.text "linked_tags", null: false + t.datetime "created_at", null: false + t.bigint "created_by_user_id" + t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" + t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true + t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" + end + create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false @@ -394,6 +409,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do add_foreign_key "materials", "users", column: "updated_by_user_id" add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" + add_foreign_key "nico_tag_versions", "tags" + add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_tags", "posts" diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index da396a0..fc09b72 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -115,6 +115,10 @@ namespace :nico do datum['tags'].each do |raw| name = TagNameSanitisationRule.sanitise("nico:#{ raw }") tag = Tag.find_or_create_by_tag_name!(name, category: :nico) + + event_type = tag.nico_tag_versions.exists? ? :update : :create + NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil) + desired_nico_tag_based_ids << tag.id # 新たに記載される外部タグと連携される内部タグを記載 -- 2.34.1 From 96307af509561fd20c0261a874f47e4fd045e503 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 19 Apr 2026 17:47:22 +0900 Subject: [PATCH 3/6] #309 --- .../app/controllers/nico_tags_controller.rb | 11 +------- backend/app/controllers/posts_controller.rb | 8 +++--- backend/app/controllers/tags_controller.rb | 17 ++++-------- backend/app/models/nico_tag_version.rb | 7 +++++ backend/app/models/tag.rb | 5 ++-- backend/app/models/tag_name.rb | 2 -- backend/app/models/tag_version.rb | 3 +-- .../app/services/nico_tag_version_recorder.rb | 5 +++- backend/app/services/post_version_recorder.rb | 16 ++++++------ backend/app/services/tag_version_recorder.rb | 8 +++--- backend/app/services/tag_versioning.rb | 26 +++++++++++++++++++ backend/app/services/version_recorder.rb | 2 +- 12 files changed, 63 insertions(+), 47 deletions(-) create mode 100644 backend/app/models/nico_tag_version.rb create mode 100644 backend/app/services/tag_versioning.rb diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 973d70f..f0e33a4 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -38,7 +38,7 @@ class NicoTagsController < ApplicationController return head :bad_request if linked_tags.any? { |t| t.nico? } ApplicationRecord.transaction do - record_tag_snapshots!(linked_tags, created_by_user: current_user) + TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) tag.linked_tags = linked_tags tag.save! @@ -48,13 +48,4 @@ class NicoTagsController < ApplicationController render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok end - - private - - def record_tag_snapshots! tags, created_by_user: - tags.each do |tag| - event_type = tag.tag_versions.exists? ? :update : :create - TagVersionRecorder.record!(tag:, event_type:, created_by_user:) - end - end end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 03e2d8a..300392a 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -131,7 +131,7 @@ class PostsController < ApplicationController ApplicationRecord.transaction do post.save! tags = Tag.normalise_tags(tag_names) - record_tag_snapshots!(tags, created_by_user: current_user) + TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) @@ -175,10 +175,10 @@ class PostsController < ApplicationController ApplicationRecord.transaction do post.update!(title:, original_created_from:, original_created_before:) - normalised_tag = Tag.normalise_tags(tag_names, with_tagme: false) - record_tag_snapshots(normalised_tags, create_by_user: current_user) + normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false) + TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) - tags = post.tags.where(category: 'nico').to_a + normalised_tags + tags = post.tags.nico.to_a + normalised_tags tags = Tag.expand_parent_tags(tags) sync_post_tags!(post, tags) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 71b7309..9b23fd1 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -218,21 +218,14 @@ class TagsController < ApplicationController tag = Tag.find(params[:id]) - ApplicationRecord.transaction do - old_nico = tag.nico? - - if category.present? - new_nico = category == 'nico' - - if old_nico != new_nico - return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, - status: :unprocessable_entity - end - end + if category.present? && tag.nico? != (category == 'nico') + return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, + status: :unprocessable_entity + end + ApplicationRecord.transaction do tag.tag_name.update!(name:) if name.present? tag.update!(category:) if category.present? - record_tag_version!(tag, event_type: :update, created_by_user: current_user) end diff --git a/backend/app/models/nico_tag_version.rb b/backend/app/models/nico_tag_version.rb new file mode 100644 index 0000000..b57252a --- /dev/null +++ b/backend/app/models/nico_tag_version.rb @@ -0,0 +1,7 @@ +class NicoTagVersion < ApplicationRecord + include VersionRecord + + belongs_to :tag + + validates :name, presence: true +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 8ac0f33..3730b1e 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -157,7 +157,6 @@ class Tag < ApplicationRecord target_tag => Tag affected_post_ids = Set.new - affected_tag_ids = Set.new Tag.transaction do Array(source_tags).compact.uniq.each do |source_tag| @@ -181,7 +180,7 @@ class Tag < ApplicationRecord end source_tag.discard! - record_tag_discard!(source_tag, current_by_user: nil) + TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) if source_tag.nico? source_tag_name.discard! @@ -190,7 +189,7 @@ class Tag < ApplicationRecord updated_at: Time.current) end - record_tag_version!(target_tag, event_type: :update, created_by_user: nil) + TagVersioning.record!(target_tag, event_type: :update, created_by_user:) end Post.where(id: affected_post_ids.to_a).find_each do |post| diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index b118300..de79d10 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -1,8 +1,6 @@ class TagName < ApplicationRecord include MyDiscard - default_scope -> { kept } - has_one :tag has_one :wiki_page diff --git a/backend/app/models/tag_version.rb b/backend/app/models/tag_version.rb index c15933e..6ca5d0a 100644 --- a/backend/app/models/tag_version.rb +++ b/backend/app/models/tag_version.rb @@ -8,8 +8,7 @@ class TagVersion < ApplicationRecord character: 'character', general: 'general', material: 'material', - meta: 'meta', - nico: 'nico' }, validate: true + meta: 'meta' }, validate: true validates :name, presence: true validates :category, presence: true diff --git a/backend/app/services/nico_tag_version_recorder.rb b/backend/app/services/nico_tag_version_recorder.rb index 07a5c63..09f953e 100644 --- a/backend/app/services/nico_tag_version_recorder.rb +++ b/backend/app/services/nico_tag_version_recorder.rb @@ -12,5 +12,8 @@ class NicoTagVersionRecorder < VersionRecorder def version_class = NicoTagVersion def version_association = :nico_tag_versions def record_key = :tag - def snapshot_attributes = { name: @tag.name, linked_tags: @tag.snapshot_linked_tags.join(' ') } + + def snapshot_attributes + { name: @record.name, linked_tags: @record.snapshot_linked_tags.join(' ') } + end end diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb index 87e267e..589e6d8 100644 --- a/backend/app/services/post_version_recorder.rb +++ b/backend/app/services/post_version_recorder.rb @@ -1,4 +1,4 @@ -class PostVersionRecorder +class PostVersionRecorder < VersionRecorder def self.record! post:, event_type:, created_by_user: new(post:, event_type:, created_by_user:).record! end @@ -14,12 +14,12 @@ class PostVersionRecorder def record_key = :post 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 } + { title: @record.title, + url: @record.url, + thumbnail_base: @record.thumbnail_base, + tags: @record.snapshot_tag_names.join(' '), + parent: @record.parent, + original_created_from: @record.original_created_from, + original_created_before: @record.original_created_before } end end diff --git a/backend/app/services/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb index b979462..fe2b0c1 100644 --- a/backend/app/services/tag_version_recorder.rb +++ b/backend/app/services/tag_version_recorder.rb @@ -14,9 +14,9 @@ class TagVersionRecorder < VersionRecorder def record_key = :tag def snapshot_attributes - { name: @tag.name, - category: @tag.category, - aliases: @tag.snapshot_aliases.join(' '), - parent_tag_ids: @tag.snapshot_parent_tag_ids.join(' ') } + { name: @record.name, + category: @record.category, + aliases: @record.snapshot_aliases.join(' '), + parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } end end diff --git a/backend/app/services/tag_versioning.rb b/backend/app/services/tag_versioning.rb new file mode 100644 index 0000000..713cfeb --- /dev/null +++ b/backend/app/services/tag_versioning.rb @@ -0,0 +1,26 @@ +class TagVersioning + def self.record! tag, event_type:, created_by_user: + if tag.nico? + NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) + else + TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + end + end + + def self.record_tag_snapshot! tag, created_by_user: + event_type = + if tag.nico? + tag.nico_tag_versions.exists? ? :update : :create + else + tag.tag_versions.exists? ? :update : :create + end + + record!(tag, event_type:, created_by_user:) + end + + def self.record_tag_snapshots! tags, created_by_user: + tags.each do |tag| + record_tag_snapshot!(tag, created_by_user:) + end + end +end diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb index 5368313..3e1dc5c 100644 --- a/backend/app/services/version_recorder.rb +++ b/backend/app/services/version_recorder.rb @@ -9,7 +9,7 @@ class VersionRecorder validate_event_type! end - def record! record, event_type:, created_by_user: + def record! @record.with_lock do latest = latest_version -- 2.34.1 From 58429c5e8b412a347e61a60e4568114ff1585c78 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 19 Apr 2026 17:58:06 +0900 Subject: [PATCH 4/6] #309 --- backend/app/models/tag.rb | 6 +++--- backend/app/models/wiki_page.rb | 2 -- backend/app/services/nico_tag_version_recorder.rb | 2 +- backend/app/services/post_version_recorder.rb | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 3730b1e..157b083 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -180,7 +180,6 @@ class Tag < ApplicationRecord end source_tag.discard! - TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) if source_tag.nico? source_tag_name.discard! @@ -189,6 +188,7 @@ class Tag < ApplicationRecord updated_at: Time.current) end + TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) TagVersioning.record!(target_tag, event_type: :update, created_by_user:) end @@ -205,9 +205,9 @@ class Tag < ApplicationRecord def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name) - def snapshot_parent_tag_ids = parents.order('id').pluck('id') + def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) - def snapshot_linked_tags + def snapshot_linked_tag_names linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 1573127..8d3feec 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -4,8 +4,6 @@ require 'set' class WikiPage < ApplicationRecord include MyDiscard - default_scope -> { kept } - has_many :wiki_revisions, dependent: :destroy belongs_to :created_user, class_name: 'User' belongs_to :updated_user, class_name: 'User' diff --git a/backend/app/services/nico_tag_version_recorder.rb b/backend/app/services/nico_tag_version_recorder.rb index 09f953e..8f1be7f 100644 --- a/backend/app/services/nico_tag_version_recorder.rb +++ b/backend/app/services/nico_tag_version_recorder.rb @@ -14,6 +14,6 @@ class NicoTagVersionRecorder < VersionRecorder def record_key = :tag def snapshot_attributes - { name: @record.name, linked_tags: @record.snapshot_linked_tags.join(' ') } + { name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') } end end diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb index 589e6d8..0e56fc7 100644 --- a/backend/app/services/post_version_recorder.rb +++ b/backend/app/services/post_version_recorder.rb @@ -18,7 +18,7 @@ class PostVersionRecorder < VersionRecorder url: @record.url, thumbnail_base: @record.thumbnail_base, tags: @record.snapshot_tag_names.join(' '), - parent: @record.parent, + parent_id: @record.parent_id, original_created_from: @record.original_created_from, original_created_before: @record.original_created_before } end -- 2.34.1 From 2ea08ef4dd81fcf1bdbd8a55b7ebda09f5e5e39b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 19 Apr 2026 19:49:56 +0900 Subject: [PATCH 5/6] #309 --- backend/app/controllers/posts_controller.rb | 2 ++ .../controllers/tag_children_controller.rb | 4 +++ backend/app/controllers/tags_controller.rb | 3 ++ backend/app/models/tag.rb | 31 +++++++------------ backend/app/services/post_version_recorder.rb | 6 ++++ backend/app/services/tag_versioning.rb | 12 +++++++ backend/app/services/version_recorder.rb | 7 ++++- backend/lib/tasks/sync_nico.rake | 1 + backend/spec/requests/posts_spec.rb | 13 ++++---- backend/spec/requests/tag_children_spec.rb | 4 +-- 10 files changed, 54 insertions(+), 29 deletions(-) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 300392a..111052b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -173,6 +173,8 @@ class PostsController < ApplicationController post = Post.find(params[:id].to_i) ApplicationRecord.transaction do + PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) + post.update!(title:, original_created_from:, original_created_before:) normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false) diff --git a/backend/app/controllers/tag_children_controller.rb b/backend/app/controllers/tag_children_controller.rb index 8a82126..8eb972e 100644 --- a/backend/app/controllers/tag_children_controller.rb +++ b/backend/app/controllers/tag_children_controller.rb @@ -12,6 +12,8 @@ class TagChildrenController < ApplicationController return head :bad_request if parent.nico? || child.nico? ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(child, created_by_user: current_user) + TagImplication.find_or_create_by!(parent_tag: parent, tag: child) TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user) end @@ -32,6 +34,8 @@ class TagChildrenController < ApplicationController return head :bad_request if parent.nico? || child.nico? ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(child, created_by_user: current_user) + TagImplication.find_by(parent_tag: parent, tag: child)&.destroy! TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user) end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9b23fd1..94a7041 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -224,8 +224,11 @@ class TagsController < ApplicationController end ApplicationRecord.transaction do + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + tag.tag_name.update!(name:) if name.present? tag.update!(category:) if category.present? + record_tag_version!(tag, event_type: :update, created_by_user: current_user) end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 157b083..54c3d68 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -79,25 +79,11 @@ class Tag < ApplicationRecord def material_id = materials.first&.id - def self.tagme - @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) - end - - def self.bot - @bot ||= find_or_create_by_tag_name!('bot操作', category: :meta) - end - - def self.no_deerjikist - @no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) - end - - def self.video - @video ||= find_or_create_by_tag_name!('動画', category: :meta) - end - - def self.niconico - @niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta) - end + def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) + def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) + def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) + def self.video = find_or_create_by_tag_name!('動画', category: :meta) + def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) def self.normalise_tags tag_names, with_tagme: true, with_no_deerjikist: true, @@ -159,11 +145,15 @@ class Tag < ApplicationRecord affected_post_ids = Set.new Tag.transaction do + TagVersioning.ensure_snapshot!(target_tag, created_by_user:) + Array(source_tags).compact.uniq.each do |source_tag| source_tag => Tag next if source_tag == target_tag + TagVersioning.ensure_snapshot!(source_tag, created_by_user:) + source_tag.post_tags.kept.find_each do |source_pt| post_id = source_pt.post_id affected_post_ids << post_id @@ -179,6 +169,7 @@ class Tag < ApplicationRecord raise ActiveRecord::RecordInvalid.new(source_tag_name) end + TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) source_tag.discard! if source_tag.nico? @@ -188,11 +179,11 @@ class Tag < ApplicationRecord updated_at: Time.current) end - TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) TagVersioning.record!(target_tag, event_type: :update, created_by_user:) end Post.where(id: affected_post_ids.to_a).find_each do |post| + PostVersionRecorder.ensure_snapshot!(post, created_by_user:) PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) end diff --git a/backend/app/services/post_version_recorder.rb b/backend/app/services/post_version_recorder.rb index 0e56fc7..515d1d8 100644 --- a/backend/app/services/post_version_recorder.rb +++ b/backend/app/services/post_version_recorder.rb @@ -7,6 +7,12 @@ class PostVersionRecorder < VersionRecorder super(record: post, event_type:, created_by_user:) end + def self.ensure_snapshot! post, created_by_user: + return if post.post_versions.exists? + + record!(post:, event_type: :create, created_by_user:) + end + private def version_class = PostVersion diff --git a/backend/app/services/tag_versioning.rb b/backend/app/services/tag_versioning.rb index 713cfeb..ae5b3dd 100644 --- a/backend/app/services/tag_versioning.rb +++ b/backend/app/services/tag_versioning.rb @@ -7,6 +7,18 @@ class TagVersioning end end + def self.ensure_snapshot! tag, created_by_user: + if tag.nico? + return if tag.nico_tag_versions.exists? + + NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:) + else + return if tag.tag_versions.exists? + + TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:) + end + end + def self.record_tag_snapshot! tag, created_by_user: event_type = if tag.nico? diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb index 3e1dc5c..e705ec3 100644 --- a/backend/app/services/version_recorder.rb +++ b/backend/app/services/version_recorder.rb @@ -10,7 +10,10 @@ class VersionRecorder end def record! - @record.with_lock do + raise "#{ record_class.name } must be persisted" unless @record.persisted? + + ApplicationRecord.transaction do + @record = record_class.unscoped.lock.find(@record.id) latest = latest_version if !(latest) && @event_type != 'create' @@ -54,4 +57,6 @@ class VersionRecorder def version_association = raise NotImplementedError def record_key = raise NotImplementedError def snapshot_attributes = raise NotImplementedError + + def record_class = @record.class end diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index fc09b72..f7c760c 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -153,6 +153,7 @@ namespace :nico do 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.ensure_snapshot!(post, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) end end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 605fd8d..a804bc8 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -1,8 +1,8 @@ -include ActiveSupport::Testing::TimeHelpers - require 'rails_helper' require 'set' +include ActiveSupport::Testing::TimeHelpers + RSpec.describe 'Posts API', type: :request do # create / update で thumbnail.attach は走るが、 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 @@ -1082,15 +1082,16 @@ RSpec.describe 'Posts API', type: :request do 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 + PostTag.create!(post: post_record, tag: Tag.no_deerjikist) + create_post_version_for!(post_record.reload) + + expect { put "/posts/#{post_record.id}", params: { title: post_record.title, tags: 'spec_tag' } - end.not_to change(PostVersion, :count) - + }.not_to change(PostVersion, :count) expect(response).to have_http_status(:ok) version = post_record.reload.post_versions.order(:version_no).last diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb index a5e4f83..6a93334 100644 --- a/backend/spec/requests/tag_children_spec.rb +++ b/backend/spec/requests/tag_children_spec.rb @@ -66,7 +66,7 @@ RSpec.describe "TagChildren", type: :request do it "returns 204 (rescue nil)" do do_request - expect(response).to have_http_status(:no_content) + expect(response).to have_http_status(:not_found) end end end @@ -126,7 +126,7 @@ RSpec.describe "TagChildren", type: :request do it "returns 204 (rescue nil)" do do_request - expect(response).to have_http_status(:no_content) + expect(response).to have_http_status(:not_found) end end end -- 2.34.1 From 351b8348c9b76f3598af7bf3c3641000feb6728d Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 19 Apr 2026 20:14:01 +0900 Subject: [PATCH 6/6] #309 --- backend/spec/models/version_record_spec.rb | 74 +++++++++++++++ backend/spec/requests/nico_tags_spec.rb | 55 +++++++++++ backend/spec/requests/posts_spec.rb | 48 ++++++++++ backend/spec/requests/tag_children_spec.rb | 80 +++++++++++++++- backend/spec/requests/tags_spec.rb | 67 ++++++++++++- backend/spec/tasks/nico_sync_spec.rb | 104 +++++++++++++++++++++ 6 files changed, 421 insertions(+), 7 deletions(-) create mode 100644 backend/spec/models/version_record_spec.rb diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb new file mode 100644 index 0000000..d3acb34 --- /dev/null +++ b/backend/spec/models/version_record_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +RSpec.describe VersionRecord, type: :model do + let!(:tag) { create(:tag, name: 'version_record_tag') } + let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') } + + it 'makes TagVersion read only after create' do + version = TagVersion.create!( + tag: tag, + version_no: 1, + event_type: 'create', + name: tag.name, + category: tag.category, + aliases: '', + parent_tag_ids: '', + created_at: Time.current, + created_by_user: nil + ) + + expect { + version.update!(name: 'changed') + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'prevents TagVersion destroy' do + version = TagVersion.create!( + tag: tag, + version_no: 1, + event_type: 'create', + name: tag.name, + category: tag.category, + aliases: '', + parent_tag_ids: '', + created_at: Time.current, + created_by_user: nil + ) + + expect { + version.destroy! + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'makes NicoTagVersion read only after create' do + version = NicoTagVersion.create!( + tag: nico_tag, + version_no: 1, + event_type: 'create', + name: nico_tag.name, + linked_tags: '', + created_at: Time.current, + created_by_user: nil + ) + + expect { + version.update!(name: 'nico:changed') + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'prevents NicoTagVersion destroy' do + version = NicoTagVersion.create!( + tag: nico_tag, + version_no: 1, + event_type: 'create', + name: nico_tag.name, + linked_tags: '', + created_at: Time.current, + created_by_user: nil + ) + + expect { + version.destroy! + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end +end diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index 6ee9479..26d5de0 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -14,6 +14,7 @@ RSpec.describe 'NicoTags', type: :request do describe 'PATCH /tags/nico/:id' do let(:member) { create(:user, :member) } + let(:admin) { create(:user, :admin) } let(:nico_tag) { create(:tag, :nico) } it '401 when not logged in' do @@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } expect(response).to have_http_status(:bad_request) end + + it '200 and updates linked tags while recording tag versions' do + sign_in_as(admin) + + nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source') + nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) + + linked_a_name = TagName.create!(name: 'nico_linked_a') + linked_a = Tag.create!(tag_name: linked_a_name, category: :general) + + linked_b_name = TagName.create!(name: 'nico_linked_b') + linked_b = Tag.create!(tag_name: linked_b_name, category: :general) + + TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin) + + expect { + patch "/tags/nico/#{nico_tag.id}", params: { + tags: " #{linked_a.name}\n#{linked_b.name} " + } + }.to change(TagVersion, :count).by(2) + .and change(NicoTagVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + + names = json.map { |t| t['name'] } + expect(names).to match_array(['nico_linked_a', 'nico_linked_b']) + + linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id) + expect(linked_versions.map(&:event_type)).to eq(['create', 'create']) + expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id)) + + versions = nico_tag.reload.nico_tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.last.linked_tags.split).to match_array([ + 'nico_linked_a', + 'nico_linked_b' + ]) + expect(versions.last.created_by_user_id).to eq(admin.id) + end + + it '400 when linked tag normalises to nico tag' do + sign_in_as(member) + + other_nico = create(:tag, :nico, name: 'nico:linked_ng') + TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name) + + TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member) + + expect { + patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:bad_request) + end end end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index a804bc8..3c59c9c 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -1131,4 +1131,52 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end end + + describe 'tag versioning from post write actions' do + let(:member) { create(:user, :member) } + + it 'creates tag snapshot for normalised tags on POST /posts' do + sign_in_as(member) + + expect { + post '/posts', params: { + title: 'tag versioned post', + url: 'https://example.com/tag-versioned-post', + tags: 'spec_tag', + thumbnail: dummy_upload + } + }.to change { tag.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:created) + + version = tag.reload.tag_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('spec_tag') + expect(version.category).to eq('general') + expect(version.created_by_user_id).to eq(member.id) + end + + it 'creates tag snapshot for normalised tags on PUT /posts/:id' do + sign_in_as(member) + + tag_name2 = TagName.create!(name: 'spec_tag_2') + tag2 = Tag.create!(tag_name: tag_name2, category: :general) + + expect { + put "/posts/#{post_record.id}", params: { + title: 'updated title', + tags: 'spec_tag_2' + } + }.to change { tag2.reload.tag_versions.count }.by(1) + + expect(response).to have_http_status(:ok) + + version = tag2.reload.tag_versions.order(:version_no).last + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('spec_tag_2') + expect(version.created_by_user_id).to eq(member.id) + end + end end diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb index 6a93334..8e1b91b 100644 --- a/backend/spec/requests/tag_children_spec.rb +++ b/backend/spec/requests/tag_children_spec.rb @@ -58,17 +58,49 @@ RSpec.describe "TagChildren", type: :request do end end - context "when Tag.find raises (invalid ids) it still returns 204" do + context "when Tag.find raises (invalid ids)" do before { stub_current_user(admin) } let(:parent_id) { -1 } let(:child_id) { -1 } - it "returns 204 (rescue nil)" do + it "returns 404" do do_request expect(response).to have_http_status(:not_found) end end + + context 'when parent is nico' do + before { stub_current_user(admin) } + + let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it 'returns 400 and does not create relation' do + expect { + do_request + }.not_to change(TagImplication, :count) + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when child is nico' do + before { stub_current_user(admin) } + + let!(:child) { create(:tag, :nico, name: 'nico:child_ng') } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it 'returns 400 and does not create relation' do + expect { + do_request + }.not_to change(TagImplication, :count) + + expect(response).to have_http_status(:bad_request) + end + end end describe "DELETE /tag_children" do @@ -116,18 +148,58 @@ RSpec.describe "TagChildren", type: :request do expect(response).to have_http_status(:no_content) end + + it 'records create and update versions for child tag' do + expect { + do_request + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:no_content) + + versions = child.reload.tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s) + expect(versions.second.parent_tag_ids).to eq('') + expect(versions.second.created_by_user_id).to eq(admin.id) + end end - context "when Tag.find raises (invalid ids) it still returns 204" do + context "when Tag.find raises (invalid ids)" do before { stub_current_user(admin) } let(:parent_id) { -1 } let(:child_id) { -1 } - it "returns 204 (rescue nil)" do + it "returns 404" do do_request expect(response).to have_http_status(:not_found) end end + + context 'when parent is nico' do + before { stub_current_user(admin) } + + let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it 'returns 400' do + do_request + expect(response).to have_http_status(:bad_request) + end + end + + context 'when child is nico' do + before { stub_current_user(admin) } + + let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it 'returns 400' do + do_request + expect(response).to have_http_status(:bad_request) + end + end end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 9a140b1..42d3728 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -364,9 +364,70 @@ RSpec.describe 'Tags API', type: :request do expect(response.status).to be_in([404, 500]) end - it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do - patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' } - expect(response.status).to be_in([422, 500]) + it 'nico category への変更は 422 を返す' do + patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.name).to eq('spec_tag') + expect(tag.category).to eq('general') + end + + it 'creates initial and update tag versions when name and category change' do + expect { + patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' } + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + versions = tag.reload.tag_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create', 'update']) + + expect(versions.first.name).to eq('spec_tag') + expect(versions.first.category).to eq('general') + expect(versions.first.aliases.split).to include('unko') + + expect(versions.second.name).to eq('new_tag_name') + expect(versions.second.category).to eq('meme') + expect(versions.second.created_by_user_id).to eq(member_user.id) + end + + it 'returns 422 when changing normal tag category to nico' do + expect { + patch "/tags/#{tag.id}", params: { category: 'nico' } + }.not_to change(TagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(tag.reload.category).to eq('general') + end + + it 'creates nico tag versions when updating nico tag name' do + nico_tag_name = TagName.create!(name: 'nico:tags_spec_source') + nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) + + expect { + patch "/tags/#{nico_tag.id}", params: { name: 'nico:tags_spec_renamed' } + }.to change(NicoTagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + + versions = nico_tag.reload.nico_tag_versions.order(:version_no) + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.first.name).to eq('nico:tags_spec_source') + expect(versions.second.name).to eq('nico:tags_spec_renamed') + expect(versions.second.created_by_user_id).to eq(member_user.id) + end + + it 'returns 422 when changing nico tag category to normal category' do + nico_tag_name = TagName.create!(name: 'nico:category_change_ng') + nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) + + expect { + patch "/tags/#{nico_tag.id}", params: { category: 'general' } + }.not_to change(NicoTagVersion, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(nico_tag.reload.category).to eq('nico') end end end diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index ff64490..27e63e5 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -214,4 +214,108 @@ RSpec.describe "nico:sync" do expect(version.event_type).to eq('create') expect(version.tags).to eq(snapshot_tags(post.reload)) end + + it '新規 nico tag に nico tag version を作る' do + Tag.bot + Tag.tagme + Tag.niconico + Tag.video + Tag.no_deerjikist + + stub_python([{ + 'code' => 'sm9', + 'title' => 't', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('')) + + expect { + run_rake_task('nico:sync') + }.to change(NicoTagVersion, :count).by(1) + + nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' }) + version = nico_tag.nico_tag_versions.order(:version_no).last + + expect(version.version_no).to eq(1) + expect(version.event_type).to eq('create') + expect(version.name).to eq('nico:AAA') + expect(version.created_by_user).to be_nil + end + + it '既存 post に version が無い場合は create snapshot を補う' do + post = Post.create!( + title: 'old', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil + ) + + kept_general = create_tag!('spec_kept_without_version', category: 'general') + PostTag.create!(post: post, tag: kept_general) + + Tag.bot + Tag.tagme + Tag.no_deerjikist + + stub_python([{ + 'code' => 'sm9', + 'title' => 'changed title', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('')) + + expect { + run_rake_task('nico:sync') + }.to change { post.reload.post_versions.count }.by(1) + + versions = post.reload.post_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create']) + expect(versions.first.title).to eq('changed title') + expect(versions.first.tags).to eq(snapshot_tags(post.reload)) + end + + it '既存 version がある post には update version を作る' do + post = Post.create!( + title: 'old', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil + ) + + kept_general = create_tag!('spec_kept_with_version', category: 'general') + PostTag.create!(post: post, tag: kept_general) + + PostVersionRecorder.record!( + post: post, + event_type: :create, + created_by_user: nil + ) + + Tag.bot + Tag.tagme + Tag.no_deerjikist + + stub_python([{ + 'code' => 'sm9', + 'title' => 'changed title', + 'tags' => ['AAA'], + 'uploaded_at' => '2026-01-01 12:34:56' + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('')) + + expect { + run_rake_task('nico:sync') + }.to change { post.reload.post_versions.count }.by(1) + + versions = post.reload.post_versions.order(:version_no) + + expect(versions.map(&:event_type)).to eq(['create', 'update']) + expect(versions.first.title).to eq('old') + expect(versions.second.title).to eq('changed title') + expect(versions.second.tags).to eq(snapshot_tags(post.reload)) + end end -- 2.34.1