From 16e9b8ca490dc34a233f6b7434885b779dfe7a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Sun, 8 Mar 2026 15:46:05 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=BF=E3=82=B0=E3=81=AE=E5=90=88=E4=BD=B5?= =?UTF-8?q?=E5=87=A6=E7=90=86=E8=BF=BD=E5=8A=A0=EF=BC=88#282=EF=BC=89=20(#?= =?UTF-8?q?284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #282 #282 #282 #282 #282 #282 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/284 --- .../app/controllers/deerjikists_controller.rb | 4 +- .../app/controllers/nico_tags_controller.rb | 2 +- backend/app/controllers/posts_controller.rb | 4 +- backend/app/controllers/tags_controller.rb | 2 +- .../app/controllers/wiki_pages_controller.rb | 4 +- backend/app/models/tag.rb | 50 ++++++++++--- backend/app/models/user.rb | 24 +++---- backend/app/models/wiki_line.rb | 2 +- backend/app/models/wiki_page.rb | 19 ++--- backend/app/models/wiki_revision.rb | 2 +- backend/spec/models/tag_spec.rb | 71 +++++++++++++++++++ backend/spec/requests/tag_children_spec.rb | 1 - backend/spec/requests/tags_spec.rb | 4 +- 13 files changed, 138 insertions(+), 51 deletions(-) create mode 100644 backend/spec/models/tag_spec.rb diff --git a/backend/app/controllers/deerjikists_controller.rb b/backend/app/controllers/deerjikists_controller.rb index b04eaf6..dff9d82 100644 --- a/backend/app/controllers/deerjikists_controller.rb +++ b/backend/app/controllers/deerjikists_controller.rb @@ -17,7 +17,7 @@ class DeerjikistsController < ApplicationController def update return head :unauthorized unless current_user - return head :forbidden unless current_user.member? + return head :forbidden unless current_user.gte_member? platform = params[:platform].to_s.strip code = params[:code].to_s.strip @@ -34,7 +34,7 @@ class DeerjikistsController < ApplicationController def destroy return head :unauthorized unless current_user - return head :forbidden unless current_user.member? + return head :forbidden unless current_user.gte_member? platform = params[:platform].to_s.strip code = params[:code].to_s.strip diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 9058fa7..2349deb 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -25,7 +25,7 @@ class NicoTagsController < ApplicationController def update return head :unauthorized unless current_user - return head :forbidden unless current_user.member? + return head :forbidden unless current_user.gte_member? id = params[:id].to_i diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 33fef9a..f9f68f5 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -78,7 +78,7 @@ class PostsController < ApplicationController def create return head :unauthorized unless current_user - return head :forbidden unless current_user.member? + return head :forbidden unless current_user.gte_member? # TODO: サイトに応じて thumbnail_base 設定 title = params[:title].presence @@ -122,7 +122,7 @@ class PostsController < ApplicationController def update return head :unauthorized unless current_user - return head :forbidden unless current_user.member? + return head :forbidden unless current_user.gte_member? title = params[:title].presence tag_names = params[:tags].to_s.split(' ') diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 186b6b9..bf4c821 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -109,7 +109,7 @@ class TagsController < ApplicationController def update return head :unauthorized unless current_user - return head :forbidden unless current_user.member? + return head :forbidden unless current_user.gte_member? name = params[:name].presence category = params[:category].presence diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 53c1b05..749cf19 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -83,7 +83,7 @@ class WikiPagesController < ApplicationController def create return head :unauthorized unless current_user - return head :forbidden unless current_user.member? + return head :forbidden unless current_user.gte_member? name = params[:title]&.strip body = params[:body].to_s @@ -105,7 +105,7 @@ class WikiPagesController < ApplicationController def update return head :unauthorized unless current_user - return head :forbidden unless current_user.member? + return head :forbidden unless current_user.gte_member? title = params[:title]&.strip body = params[:body].to_s diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 83afbd3..eaf51da 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -23,6 +23,8 @@ class Tag < ApplicationRecord has_many :parents, through: :reversed_tag_implications, source: :parent_tag has_many :tag_similarities, dependent: :delete_all + has_many :tag_similarities_as_target, + class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all has_many :deerjikists, dependent: :delete_all @@ -46,7 +48,7 @@ class Tag < ApplicationRecord validate :tag_name_must_be_canonical validate :category_must_be_deerjikist_with_deerjikists - scope :nico_tags, -> { where(category: :nico) } + scope :nico_tags, -> { nico } CATEGORY_PREFIXES = { 'general:' => :general, @@ -64,9 +66,7 @@ class Tag < ApplicationRecord (self.tag_name ||= build_tag_name).name = val end - def has_wiki - wiki_page.present? - end + def has_wiki = wiki_page.present? def self.tagme @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) @@ -97,14 +97,12 @@ class Tag < ApplicationRecord pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| - if cat && tag.category != cat - tag.update!(category: cat) - end + tag.update!(category: cat) if cat && tag.category != cat end end tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) - tags << Tag.no_deerjikist if tags.all? { |t| t.category != 'deerjikist' } + tags << Tag.no_deerjikist if tags.all? { |t| !(t.deerjikist?) } tags.uniq(&:id) end @@ -142,12 +140,44 @@ class Tag < ApplicationRecord retry end + def self.merge_tags! target_tag, source_tags + target_tag => Tag + + Tag.transaction do + Array(source_tags).compact.uniq.each do |st| + st => Tag + + next if st == target_tag + + st.post_tags.find_each do |pt| + if PostTag.kept.exists?(post_id: pt.post_id, tag_id: target_tag.id) + pt.discard_by!(nil) + # discard 後の update! は禁止なので DB を直に更新 + pt.update_columns(tag_id: target_tag.id, updated_at: Time.current) + else + pt.update!(tag: target_tag) + end + end + + tag_name = st.tag_name + st.destroy! + tag_name.reload + tag_name.update!(canonical: target_tag.tag_name) + end + + # 投稿件数を再集計 + target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) + end + + target_tag.reload + end + private def nico_tag_name_must_start_with_nico n = name.to_s - if ((category == 'nico' && !(n.downcase.start_with?('nico:'))) || - (category != 'nico' && n.downcase.start_with?('nico:'))) + if ((nico? && !(n.downcase.start_with?('nico:'))) || + (!(nico?) && n.downcase.start_with?('nico:'))) errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' end end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index ede464a..7e07642 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -1,29 +1,23 @@ class User < ApplicationRecord - enum :role, { guest: 'guest', member: 'member', admin: 'admin' } + enum :role, guest: 'guest', member: 'member', admin: 'admin' validates :name, length: { maximum: 255 } validates :inheritance_code, presence: true, length: { maximum: 64 } validates :role, presence: true, inclusion: { in: roles.keys } validates :banned, inclusion: { in: [true, false] } - has_many :posts + has_many :created_posts, + class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify has_many :settings has_many :user_ips, dependent: :destroy has_many :ip_addresses, through: :user_ips has_many :user_post_views, dependent: :destroy has_many :viewed_posts, through: :user_post_views, source: :post - has_many :created_wiki_pages, class_name: 'WikiPage', foreign_key: 'created_user_id', dependent: :nullify - has_many :updated_wiki_pages, class_name: 'WikiPage', foreign_key: 'updated_user_id', dependent: :nullify + has_many :created_wiki_pages, + class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify + has_many :updated_wiki_pages, + class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify - def viewed? post - user_post_views.exists? post_id: post.id - end - - def member? - ['member', 'admin'].include?(role) - end - - def admin? - role == 'admin' - end + def viewed?(post) = user_post_views.exists?(post_id: post.id) + def gte_member? = member? || admin? end diff --git a/backend/app/models/wiki_line.rb b/backend/app/models/wiki_line.rb index c169917..b9c3465 100644 --- a/backend/app/models/wiki_line.rb +++ b/backend/app/models/wiki_line.rb @@ -8,7 +8,7 @@ class WikiLine < ApplicationRecord sha = Digest::SHA256.hexdigest(body) now = Time.current - upsert({ sha256: sha, body:, created_at: now, updated_at: now }) + upsert(sha256: sha, body:, created_at: now, updated_at: now) find_by!(sha256: sha) end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 5434be3..d795a8e 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -14,17 +14,13 @@ class WikiPage < ApplicationRecord belongs_to :tag_name validates :tag_name, presence: true - def title - tag_name.name - end + def title = tag_name.name def title= val (self.tag_name ||= build_tag_name).name = val end - def current_revision - wiki_revisions.order(id: :desc).first - end + def current_revision = wiki_revisions.order(id: :desc).first def body rev = current_revision @@ -49,11 +45,8 @@ class WikiPage < ApplicationRecord page end - def pred_revision_id revision_id - wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) - end - - def succ_revision_id revision_id - wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) - end + def pred_revision_id(revision_id) = + wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) + def succ_revision_id(revision_id) = + wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) end diff --git a/backend/app/models/wiki_revision.rb b/backend/app/models/wiki_revision.rb index da6ca7d..3e0781a 100644 --- a/backend/app/models/wiki_revision.rb +++ b/backend/app/models/wiki_revision.rb @@ -7,7 +7,7 @@ class WikiRevision < ApplicationRecord has_many :wiki_revision_lines, dependent: :delete_all has_many :wiki_lines, through: :wiki_revision_lines - enum :kind, { content: 0, redirect: 1 } + enum :kind, content: 0, redirect: 1 validates :kind, presence: true validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb new file mode 100644 index 0000000..389f1a1 --- /dev/null +++ b/backend/spec/models/tag_spec.rb @@ -0,0 +1,71 @@ +require 'rails_helper' + +RSpec.describe Tag, type: :model do + describe '.merge_tags!' do + let!(:target_tag) { create(:tag) } + let!(:source_tag) { create(:tag) } + + let!(:post_record) { Post.create!(url: 'https://example.com/posts/1', title: 'test post') } + + context 'when merging a simple source tag' do + let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } + + it 'moves the post_tag, deletes the source tag, and aliases the source tag_name' do + described_class.merge_tags!(target_tag, [source_tag]) + + expect(source_post_tag.reload.tag_id).to eq(target_tag.id) + expect(Tag.exists?(source_tag.id)).to be(false) + expect(source_tag.tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + end + end + + context 'when the target already has the same post_tag' do + let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) } + let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } + + it 'discards the duplicate source post_tag and keeps one active target post_tag' do + described_class.merge_tags!(target_tag, [source_tag]) + + active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id) + discarded_source = PostTag.with_discarded.find(source_post_tag.id) + + expect(active.count).to eq(1) + expect(discarded_source.discarded_at).to be_present + expect(Tag.exists?(source_tag.id)).to be(false) + expect(source_tag.tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + end + end + + context 'when source_tags includes the target itself' do + let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } + + it 'ignores the target tag in source_tags' do + described_class.merge_tags!(target_tag, [source_tag, target_tag]) + + expect(Tag.exists?(target_tag.id)).to be(true) + expect(Tag.exists?(source_tag.id)).to be(false) + expect(source_post_tag.reload.tag_id).to eq(target_tag.id) + end + end + + context 'when aliasing the source tag_name is invalid' do + let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } + let!(:wiki_page) do + WikiPage.create!( + tag_name: source_tag.tag_name, + created_user: create_admin_user!, + updated_user: create_admin_user!) + end + + it 'rolls back the transaction' do + expect { + described_class.merge_tags!(target_tag, [source_tag]) + }.to raise_error(ActiveRecord::RecordInvalid) + + expect(Tag.exists?(source_tag.id)).to be(true) + expect(source_post_tag.reload.tag_id).to eq(source_tag.id) + expect(source_tag.tag_name.reload.canonical_id).to be_nil + end + end + end +end diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb index 9db9beb..a5e4f83 100644 --- a/backend/spec/requests/tag_children_spec.rb +++ b/backend/spec/requests/tag_children_spec.rb @@ -1,4 +1,3 @@ -# spec/requests/tag_children_spec.rb require "rails_helper" RSpec.describe "TagChildren", type: :request do diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index ca09879..70309b7 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -106,8 +106,8 @@ RSpec.describe 'Tags API', type: :request do end before do - allow(member_user).to receive(:member?).and_return(true) - allow(non_member_user).to receive(:member?).and_return(false) + allow(member_user).to receive(:gte_member?).and_return(true) + allow(non_member_user).to receive(:gte_member?).and_return(false) end describe "PATCH /tags/:id" do