Browse Source

#309

feature/309
みてるぞ 2 weeks ago
parent
commit
f3a2b08359
16 changed files with 319 additions and 80 deletions
  1. +20
    -5
      backend/app/controllers/nico_tags_controller.rb
  2. +9
    -4
      backend/app/controllers/posts_controller.rb
  3. +16
    -2
      backend/app/controllers/tag_children_controller.rb
  4. +24
    -6
      backend/app/controllers/tags_controller.rb
  5. +5
    -1
      backend/app/models/my_discard.rb
  6. +1
    -17
      backend/app/models/post_version.rb
  7. +18
    -5
      backend/app/models/tag.rb
  8. +16
    -0
      backend/app/models/tag_version.rb
  9. +19
    -0
      backend/app/models/version_record.rb
  10. +16
    -0
      backend/app/services/nico_tag_version_recorder.rb
  11. +5
    -37
      backend/app/services/post_version_recorder.rb
  12. +22
    -0
      backend/app/services/tag_version_recorder.rb
  13. +57
    -0
      backend/app/services/version_recorder.rb
  14. +70
    -3
      backend/db/migrate/20260419035400_create_tag_versions.rb
  15. +17
    -0
      backend/db/schema.rb
  16. +4
    -0
      backend/lib/tasks/sync_nico.rake

+ 20
- 5
backend/app/controllers/nico_tags_controller.rb View File

@@ -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

+ 9
- 4
backend/app/controllers/posts_controller.rb View File

@@ -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)


+ 16
- 2
backend/app/controllers/tag_children_controller.rb View File

@@ -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


+ 24
- 6
backend/app/controllers/tags_controller.rb View File

@@ -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

+ 5
- 1
backend/app/models/my_discard.rb View File

@@ -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


+ 1
- 17
backend/app/models/post_version.rb View File

@@ -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


+ 18
- 5
backend/app/models/tag.rb View File

@@ -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


+ 16
- 0
backend/app/models/tag_version.rb View File

@@ -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

+ 19
- 0
backend/app/models/version_record.rb View File

@@ -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

+ 16
- 0
backend/app/services/nico_tag_version_recorder.rb View File

@@ -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

+ 5
- 37
backend/app/services/post_version_recorder.rb View File

@@ -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

+ 22
- 0
backend/app/services/tag_version_recorder.rb View File

@@ -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

+ 57
- 0
backend/app/services/version_recorder.rb View File

@@ -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

+ 70
- 3
backend/db/migrate/20260419035400_create_tag_versions.rb View File

@@ -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

+ 17
- 0
backend/db/schema.rb View File

@@ -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"


+ 4
- 0
backend/lib/tasks/sync_nico.rake View File

@@ -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

# 新たに記載される外部タグと連携される内部タグを記載


Loading…
Cancel
Save