From c9504266b3bb2b26c6799250b59de1a68852820e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 13 Jan 2022 22:06:40 +0800 Subject: [PATCH 1/5] Add Countable concerns for storage counters by use ActiveRecord --- app/models/application_record.rb | 3 +- app/models/concerns/countable.rb | 25 +++++++++++ .../{counterable.rb => redis_countable.rb} | 2 +- app/models/counter.rb | 26 ++++++++++++ app/models/user.rb | 6 ++- db/migrate/20220113130722_create_counters.rb | 12 ++++++ db/schema.rb | 13 +++++- test/factories/counters.rb | 9 ++++ test/models/counter_test.rb | 42 +++++++++++++++++++ 9 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 app/models/concerns/countable.rb rename app/models/concerns/{counterable.rb => redis_countable.rb} (97%) create mode 100644 app/models/counter.rb create mode 100644 db/migrate/20220113130722_create_counters.rb create mode 100644 test/factories/counters.rb create mode 100644 test/models/counter_test.rb diff --git a/app/models/application_record.rb b/app/models/application_record.rb index f1b8f84881..95feacf56e 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -3,7 +3,8 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true - include Counterable + include RedisCountable + include Countable scope :recent, -> { order(id: :desc) } scope :exclude_ids, ->(ids) { where.not(id: ids.map(&:to_i)) } diff --git a/app/models/concerns/countable.rb b/app/models/concerns/countable.rb new file mode 100644 index 0000000000..f947971149 --- /dev/null +++ b/app/models/concerns/countable.rb @@ -0,0 +1,25 @@ +# Countable for storage by use ActiveRecord +module Countable + extend ActiveSupport::Concern + + included do + scope :with_counters, -> { includes(@@countable_names) } + end + + class_methods do + def countable(*names) + class_eval do + @@countable_names ||= [] + + names.each do |name| + @@countable_names << "#{name}_counter".to_sym + has_one :"#{name}_counter", -> { where(key: name) }, as: :countable, class_name: "Counter" + + define_method :"#{name}" do + send(:"#{name}_counter") || send(:"create_#{name}_counter") + end + end + end + end + end +end diff --git a/app/models/concerns/counterable.rb b/app/models/concerns/redis_countable.rb similarity index 97% rename from app/models/concerns/counterable.rb rename to app/models/concerns/redis_countable.rb index e94b7f3764..bb3879b1b7 100644 --- a/app/models/concerns/counterable.rb +++ b/app/models/concerns/redis_countable.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # 增加访问量的功能 -module Counterable +module RedisCountable extend ActiveSupport::Concern class Counter diff --git a/app/models/counter.rb b/app/models/counter.rb new file mode 100644 index 0000000000..81bef84cf2 --- /dev/null +++ b/app/models/counter.rb @@ -0,0 +1,26 @@ +class Counter < ApplicationRecord + belongs_to :countable, polymorphic: true + validates :countable, presence: true + + delegate :to_i, :to_s, :inspect, to: :value + + def incr(by = 1) + increment!(:value, by).value + end + + def decr(by = 1) + decrement!(:value, by).value + end + + def respond_to_missing? + true + end + + def method_missing(method, *args, &block) + if value.respond_to?(method) + value.send(method, *args, &block) + else + super + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index e9eb80e9bc..9eeb5c39df 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -36,6 +36,8 @@ class User < ApplicationRecord has_many :teams, through: :team_users has_one :sso, class_name: "UserSSO", dependent: :destroy + countable :monthly_replies_count, :yearly_replies_count + attr_accessor :password_confirmation validates :login, format: {with: ALLOW_LOGIN_FORMAT_REGEXP, message: I18n.t("users.username_allows_format")}, @@ -62,11 +64,11 @@ def self.find_for_database_authentication(warden_conditions) end def self.find_by_email(email) - fetch_by_uniq_keys(email: email) + fetch_by_uniq_keys(email:) end def self.find_by_login!(slug) - find_by_login(slug) || raise(ActiveRecord::RecordNotFound.new(slug: slug)) + find_by_login(slug) || raise(ActiveRecord::RecordNotFound.new(slug:)) end def self.find_by_login(slug) diff --git a/db/migrate/20220113130722_create_counters.rb b/db/migrate/20220113130722_create_counters.rb new file mode 100644 index 0000000000..31b7bae837 --- /dev/null +++ b/db/migrate/20220113130722_create_counters.rb @@ -0,0 +1,12 @@ +class CreateCounters < ActiveRecord::Migration[7.0] + def change + create_table :counters do |t| + t.references :countable, polymorphic: true + t.string :key, null: false + t.integer :value, null: false, default: 0 + t.timestamps + end + + add_index :counters, [:countable_type, :countable_id, :key], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index a7cf0e0a24..7f383a8684 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_04_09_032709) do +ActiveRecord::Schema.define(version: 2022_01_13_130722) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -50,6 +50,17 @@ t.index ["user_id"], name: "index_comments_on_user_id" end + create_table "counters", force: :cascade do |t| + t.string "countable_type" + t.bigint "countable_id" + t.string "key", null: false + t.integer "value", default: 0, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["countable_type", "countable_id", "key"], name: "index_counters_on_countable_type_and_countable_id_and_key", unique: true + t.index ["countable_type", "countable_id"], name: "index_counters_on_countable" + end + create_table "devices", id: :serial, force: :cascade do |t| t.integer "platform", null: false t.integer "user_id", null: false diff --git a/test/factories/counters.rb b/test/factories/counters.rb new file mode 100644 index 0000000000..c580479c6a --- /dev/null +++ b/test/factories/counters.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :counter do + association :countable, factory: :user + key { "foo_count" } + value { 0 } + end +end diff --git a/test/models/counter_test.rb b/test/models/counter_test.rb new file mode 100644 index 0000000000..0622ba1259 --- /dev/null +++ b/test/models/counter_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "test_helper" + +class CounterTest < ActiveSupport::TestCase + test "base" do + counter = create :counter + assert_equal 0, counter.value + assert_equal 0, counter.to_i + + counter.incr + assert_equal 1, counter.value + + counter.incr + assert_equal 2, counter.value + + counter.incr(3) + assert_equal 5, counter.value + + counter.decr + assert_equal 4, counter.value + + counter.decr(2) + assert_equal 2, counter.value + end + + test "User counter" do + user = create(:user) + + assert_equal 0, user.yearly_replies_count.to_i + user.yearly_replies_count.incr + assert_equal 1, user.yearly_replies_count.to_i + user.yearly_replies_count.decr + assert_equal 0, user.yearly_replies_count.to_i + + assert_equal 0, user.monthly_replies_count.to_i + user.monthly_replies_count.incr + assert_equal 1, user.monthly_replies_count.to_i + user.monthly_replies_count.decr + assert_equal 0, user.monthly_replies_count.to_i + end +end From 41855d9892a8bfa6dd870fc3e3aef19113f357a1 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 13 Jan 2022 22:22:31 +0800 Subject: [PATCH 2/5] Add TotalUserStatsJob for update user yearly, monthly replies_count --- app/controllers/users_controller.rb | 2 +- app/jobs/scheduler/total_user_stats_job.rb | 13 +++++++++++++ app/models/counter.rb | 4 ---- db/migrate/20220113130722_create_counters.rb | 1 + db/schema.rb | 1 + 5 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 app/jobs/scheduler/total_user_stats_job.rb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 90f4a3d6be..59c6f2f85e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -13,7 +13,7 @@ class UsersController < ApplicationController def index @total_user_count = User.count - @active_users = User.without_team.fields_for_list.hot.limit(100) + @active_users = Counter.where(countable_type: "User", key: :yearly_replies_count).includes(:countable).order("value desc").limit(100).map(&:countable) end def feed diff --git a/app/jobs/scheduler/total_user_stats_job.rb b/app/jobs/scheduler/total_user_stats_job.rb new file mode 100644 index 0000000000..534883006e --- /dev/null +++ b/app/jobs/scheduler/total_user_stats_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Scheduler + # cleanup spam topic at 1 month ago + class TotalUserStatsJob < ApplicationJob + def perform + User.find_each.each do |user| + user.monthly_replies_count.update(value: user.replies.where("created_at > ?", 1.month.ago).count) + user.yearly_replies_count.update(value: user.replies.where("created_at > ?", 1.year.ago).count) + end + end + end +end diff --git a/app/models/counter.rb b/app/models/counter.rb index 81bef84cf2..7fc632a58a 100644 --- a/app/models/counter.rb +++ b/app/models/counter.rb @@ -12,10 +12,6 @@ def decr(by = 1) decrement!(:value, by).value end - def respond_to_missing? - true - end - def method_missing(method, *args, &block) if value.respond_to?(method) value.send(method, *args, &block) diff --git a/db/migrate/20220113130722_create_counters.rb b/db/migrate/20220113130722_create_counters.rb index 31b7bae837..7a56046fa6 100644 --- a/db/migrate/20220113130722_create_counters.rb +++ b/db/migrate/20220113130722_create_counters.rb @@ -8,5 +8,6 @@ def change end add_index :counters, [:countable_type, :countable_id, :key], unique: true + add_index :counters, [:countable_type, :key, :value] end end diff --git a/db/schema.rb b/db/schema.rb index 7f383a8684..ae3141874c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -59,6 +59,7 @@ t.datetime "updated_at", precision: 6, null: false t.index ["countable_type", "countable_id", "key"], name: "index_counters_on_countable_type_and_countable_id_and_key", unique: true t.index ["countable_type", "countable_id"], name: "index_counters_on_countable" + t.index ["countable_type", "key", "value"], name: "index_counters_on_countable_type_and_key_and_value" end create_table "devices", id: :serial, force: :cascade do |t| From c835b42cfe837551c2971e1c54b8fc4a6d8f21a2 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 13 Jan 2022 22:37:41 +0800 Subject: [PATCH 3/5] TotalUserStatsJob by use schedule exec on every week Saturday --- app/jobs/scheduler/total_user_stats_job.rb | 11 +++++++++-- config/schedule.yml | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/jobs/scheduler/total_user_stats_job.rb b/app/jobs/scheduler/total_user_stats_job.rb index 534883006e..c322f64f3d 100644 --- a/app/jobs/scheduler/total_user_stats_job.rb +++ b/app/jobs/scheduler/total_user_stats_job.rb @@ -3,8 +3,15 @@ module Scheduler # cleanup spam topic at 1 month ago class TotalUserStatsJob < ApplicationJob - def perform - User.find_each.each do |user| + def perform(limit: 2000) + users = User.where("replies_count > 0") + users = if limit.to_i > 0 + users.order("updated_at desc").limit(limit) + else + users.where("replies_count > 0").order("updated_at desc").find_each + end + + users.each do |user| user.monthly_replies_count.update(value: user.replies.where("created_at > ?", 1.month.ago).count) user.yearly_replies_count.update(value: user.replies.where("created_at > ?", 1.year.ago).count) end diff --git a/config/schedule.yml b/config/schedule.yml index a478d37efd..f88ea3b9a8 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -15,3 +15,9 @@ spam_cleanup: class: "Scheduler::SpamCleanupJob" queue: cleanup description: "Cleanup spam contents before 1 month ago" + +update_user_stats: + cron: "0 0 * * 6" + class: "Scheduler::TotalUserStatsJob" + queue: default + description: "Update user stats" From c9fc19f6d4bbaae667b351b876ac20fa8e82f59e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 13 Jan 2022 22:54:32 +0800 Subject: [PATCH 4/5] Use counter for output active users for API --- app/controllers/api/v3/users_controller.rb | 2 +- app/controllers/users_controller.rb | 3 ++- app/views/users/index.html.erb | 12 ++++++++--- test/controllers/api/users_test.rb | 23 +++++++++++++--------- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/app/controllers/api/v3/users_controller.rb b/app/controllers/api/v3/users_controller.rb index 69648600c3..d26b8c6dd1 100644 --- a/app/controllers/api/v3/users_controller.rb +++ b/app/controllers/api/v3/users_controller.rb @@ -17,7 +17,7 @@ def index limit = params[:limit].to_i limit = 100 if limit > 100 - @users = User.fields_for_list.hot.limit(limit) + @users = @active_users = Counter.where(countable_type: "User", key: "yearly_replies_count").includes(:countable).order("value desc").limit(limit).map(&:countable) end # Get full detail of current user, for account setting. diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 59c6f2f85e..9255117b7a 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -13,7 +13,8 @@ class UsersController < ApplicationController def index @total_user_count = User.count - @active_users = Counter.where(countable_type: "User", key: :yearly_replies_count).includes(:countable).order("value desc").limit(100).map(&:countable) + key = params[:type] = "monthly" ? :monthly_replies_count : :yearly_replies_count + @active_users = Counter.where(countable_type: "User", key: key).includes(:countable).order("value desc").limit(100).map(&:countable) end def feed diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 0981202bc3..dca01afc49 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -3,9 +3,15 @@
-
-
<%= t("users.hot_users") %>
-
Total <%= @total_user_count %> users
+
+
+
<%= t("users.hot_users") %>
+
Total <%= @total_user_count %> users
+
+
<%= render "user_list", users: @active_users, columns: 4 %> diff --git a/test/controllers/api/users_test.rb b/test/controllers/api/users_test.rb index 4412efa28d..4e0362f68b 100644 --- a/test/controllers/api/users_test.rb +++ b/test/controllers/api/users_test.rb @@ -5,13 +5,18 @@ describe Api::V3::UsersController do describe "GET /api/v3/users.json" do before do - create_list(:user, 10) + @users = create_list(:user, 10) + + @users.each do |user| + user.yearly_replies_count.to_i + user.monthly_replies_count.to_i + end end it "should work" do get "/api/v3/users.json" assert_equal 200, response.status - assert_equal User.count, json["users"].size + assert_equal @users.count, json["users"].size assert_has_keys json["users"][0], "id", "name", "login", "avatar_url" end @@ -90,7 +95,7 @@ describe "recent order" do it "should work" do - @topics = create_list(:topic, 3, user: user) + @topics = create_list(:topic, 3, user:) get "/api/v3/users/#{user.login}/topics.json", offset: 0, limit: 2 assert_equal 200, response.status assert_equal 2, json["topics"].size @@ -103,8 +108,8 @@ describe "hot order" do it "should work" do - @hot_topic = create(:topic, user: user, likes_count: 4) - @topics = create_list(:topic, 3, user: user) + @hot_topic = create(:topic, user:, likes_count: 4) + @topics = create_list(:topic, 3, user:) get "/api/v3/users/#{user.login}/topics.json", order: "likes", offset: 0, limit: 3 assert_equal 200, response.status @@ -115,8 +120,8 @@ describe "hot order" do it "should work" do - @hot_topic = create(:topic, user: user, replies_count: 4) - @topics = create_list(:topic, 3, user: user) + @hot_topic = create(:topic, user:, replies_count: 4) + @topics = create_list(:topic, 3, user:) get "/api/v3/users/#{user.login}/topics.json", order: "replies", offset: 0, limit: 3 assert_equal 200, response.status @@ -132,7 +137,7 @@ describe "recent order" do it "should work" do - @replies = create_list(:reply, 3, user: user, topic: topic) + @replies = create_list(:reply, 3, user:, topic:) get "/api/v3/users/#{user.login}/replies.json", offset: 0, limit: 2 assert_equal 2, json["replies"].size fields = %w[id user body_html topic_id topic_title] @@ -148,7 +153,7 @@ let(:user) { create(:user) } it "should work" do - @topics = create_list(:topic, 4, user: user) + @topics = create_list(:topic, 4, user:) user.favorite_topic(@topics[0].id) user.favorite_topic(@topics[1].id) user.favorite_topic(@topics[3].id) From adc7a633f2043e0edcc6ba14bcc66aa7ead4f07f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 13 Jan 2022 23:06:47 +0800 Subject: [PATCH 5/5] Show yearly replies_count on user profile page. --- app/views/users/_sidebar.html.erb | 10 +++++----- config/locales/views.en.yml | 2 +- config/locales/views.zh-CN.yml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/views/users/_sidebar.html.erb b/app/views/users/_sidebar.html.erb index 85e03519ed..c8143354ca 100644 --- a/app/views/users/_sidebar.html.erb +++ b/app/views/users/_sidebar.html.erb @@ -25,6 +25,7 @@
<%= t("users.number_of_user", no: @user.id)%> / "><%= @user.created_at.to_date %>
+

@@ -42,11 +43,10 @@ <%= icon_tag("location") %> <%= location_name_tag(@user.location) %>
<% end %> -
- <%= t("users.topics_count_html", count: @user.topics_count) %> - / - <%= t("users.replies_count_html", count: @user.replies_count) %> -
+
+
<%= t("users.topics_count_html", count: @user.topics_count) %>
+
<%= t("users.replies_count_html", count: @user.replies_count, yearly_count: @user.yearly_replies_count) %>
+