清理: - 删除 34 份过时文档(gap reports/QA临时报告/验收报告/阶段性文档) - 删除 docs/.hermes/skills 第三方 skills 副本(16 文件) - 删除 skills-lock.json 目录归集: - 根目录仅保留 README.md 索引 - product/ — 产品与架构设计(PRD + ARCHITECTURE + P2设计文档 + AI/企业路线图) - tracking/ — Chatwoot parity 开发跟踪 - requirements/ — M01-M12 模块需求 - plans/ — 历史实现计划 - parity/ — 路由 parity 与前端契约 - qa/ — QA 报告与测试计划 - ops/ — 运维部署 命名规范: - 全小写 kebab-case,禁止全大写文件名 - product/tracking/ops 用 NN- 序号前缀 - requirements 用 MNN- 两位零填充模块号 - plans/qa 用 YYYY-MM-DD- 日期前缀 - requirements M1-M9 零填充为 M01-M09(修复字典序) 同步更新: - backend/cmd/route_parity/main.go 路径默认值 - backend/scripts/parity_frontend_smoke.sh 报告路径 - 所有 docs 内部交叉引用 - .gitignore 排除编译产物 (backend/gochat, backend/route_parity) - 新增迁移 000052/000053 - 前端 WS 相关修改
333 lines
10 KiB
Markdown
333 lines
10 KiB
Markdown
## Code Context
|
|
|
|
**Query:** account management, user authentication, profile, roles, permissions, SAML SSO, account_user, custom_role, company
|
|
|
|
### Entry Points
|
|
|
|
- **Saml::UpdateAccountUsersProviderJob** (class) - enterprise/app/jobs/saml/update_account_users_provider_job.rb:1
|
|
- **AddAgentCapacityPolicyToAccountUsers** (class) - db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb:3
|
|
- **Company** (class) - enterprise/app/models/company.rb:23
|
|
|
|
### Related Symbols
|
|
|
|
- app/jobs/application_job.rb: ApplicationJob:1
|
|
- app/models/application_record.rb: ApplicationRecord:1
|
|
- enterprise/app/jobs/saml/update_account_users_provider_job.rb: perform:6, should_update_user_provider?:22, user_has_other_saml_accounts?:30
|
|
- enterprise/app/models/account_saml_settings.rb: update_account_users_provider:64, reset_account_users_provider:68
|
|
- db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb: change:4
|
|
- enterprise/app/models/company.rb: record_activity_at!:63, prepare_jsonb_attributes:71
|
|
|
|
### Code
|
|
|
|
#### Saml::UpdateAccountUsersProviderJob (enterprise/app/jobs/saml/update_account_users_provider_job.rb:1)
|
|
|
|
```ruby
|
|
class Saml::UpdateAccountUsersProviderJob < ApplicationJob
|
|
queue_as :default
|
|
|
|
# Updates the authentication provider for users in an account
|
|
# This job is triggered when SAML settings are created or destroyed
|
|
def perform(account_id, provider)
|
|
account = Account.find(account_id)
|
|
account.users.find_each(batch_size: 1000) do |user|
|
|
next unless should_update_user_provider?(user, provider)
|
|
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
user.update_column(:provider, provider)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
# Determines if a user's provider should be updated based on their multi-account status
|
|
# When resetting to 'email', only update users who don't have SAML enabled on other accounts
|
|
# This prevents breaking SAML authentication for users who belong to multiple accounts
|
|
def should_update_user_provider?(user, provider)
|
|
return !user_has_other_saml_accounts?(user) if provider == 'email'
|
|
|
|
true
|
|
end
|
|
|
|
# Checks if the user belongs to any other accounts that have SAML configured
|
|
# Used to preserve SAML authentication when one account disables SAML but others still use it
|
|
def user_has_other_saml_accounts?(user)
|
|
user.accounts.joins(:saml_settings).exists?
|
|
end
|
|
end
|
|
```
|
|
|
|
#### AddAgentCapacityPolicyToAccountUsers (db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb:3)
|
|
|
|
```ruby
|
|
class AddAgentCapacityPolicyToAccountUsers < ActiveRecord::Migration[7.1]
|
|
def change
|
|
add_reference :account_users, :agent_capacity_policy, null: true, index: true
|
|
end
|
|
end
|
|
```
|
|
|
|
#### Company (enterprise/app/models/company.rb:23)
|
|
|
|
```ruby
|
|
class Company < ApplicationRecord
|
|
include Avatarable
|
|
|
|
ACTIVITY_ROLLUP_INTERVAL = 5.minutes
|
|
|
|
validates :account_id, presence: true
|
|
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
|
|
validates :domain, allow_blank: true, format: {
|
|
with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
|
|
message: I18n.t('errors.companies.domain.invalid')
|
|
}
|
|
validates :domain, uniqueness: { scope: :account_id }, if: -> { domain.present? }
|
|
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
|
|
validates :custom_attributes, jsonb_attributes_length: true
|
|
|
|
belongs_to :account
|
|
has_many :contacts, dependent: :nullify
|
|
before_validation :prepare_jsonb_attributes
|
|
after_create_commit :fetch_favicon, if: -> { domain.present? }
|
|
|
|
scope :ordered_by_name, -> { order(:name) }
|
|
scope :search_by_name_or_domain, lambda { |query|
|
|
where('name ILIKE :search OR domain ILIKE :search', search: "%#{query.strip}%")
|
|
}
|
|
|
|
scope :order_on_contacts_count, lambda { |direction|
|
|
order(
|
|
Arel::Nodes::SqlLiteral.new(
|
|
sanitize_sql_for_order("\"companies\".\"contacts_count\" #{direction} NULLS LAST")
|
|
)
|
|
)
|
|
}
|
|
scope :order_on_last_activity_at, lambda { |direction|
|
|
order(
|
|
Arel::Nodes::SqlLiteral.new(
|
|
sanitize_sql_for_order("\"companies\".\"last_activity_at\" #{direction} NULLS LAST")
|
|
)
|
|
)
|
|
}
|
|
|
|
def record_activity_at!(activity_at)
|
|
|
|
// ... truncated ...
|
|
```
|
|
|
|
#### perform (enterprise/app/jobs/saml/update_account_users_provider_job.rb:6)
|
|
|
|
```ruby
|
|
def perform(account_id, provider)
|
|
account = Account.find(account_id)
|
|
account.users.find_each(batch_size: 1000) do |user|
|
|
next unless should_update_user_provider?(user, provider)
|
|
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
user.update_column(:provider, provider)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
end
|
|
end
|
|
```
|
|
|
|
#### should_update_user_provider? (enterprise/app/jobs/saml/update_account_users_provider_job.rb:22)
|
|
|
|
```ruby
|
|
def should_update_user_provider?(user, provider)
|
|
return !user_has_other_saml_accounts?(user) if provider == 'email'
|
|
|
|
true
|
|
end
|
|
```
|
|
|
|
#### user_has_other_saml_accounts? (enterprise/app/jobs/saml/update_account_users_provider_job.rb:30)
|
|
|
|
```ruby
|
|
def user_has_other_saml_accounts?(user)
|
|
user.accounts.joins(:saml_settings).exists?
|
|
end
|
|
```
|
|
|
|
#### update_account_users_provider (enterprise/app/models/account_saml_settings.rb:64)
|
|
|
|
```ruby
|
|
def update_account_users_provider
|
|
Saml::UpdateAccountUsersProviderJob.perform_later(account_id, 'saml')
|
|
end
|
|
```
|
|
|
|
#### reset_account_users_provider (enterprise/app/models/account_saml_settings.rb:68)
|
|
|
|
```ruby
|
|
def reset_account_users_provider
|
|
Saml::UpdateAccountUsersProviderJob.perform_later(account_id, 'email')
|
|
end
|
|
```
|
|
|
|
#### change (db/migrate/20250806140004_add_agent_capacity_policy_to_account_users.rb:4)
|
|
|
|
```ruby
|
|
def change
|
|
add_reference :account_users, :agent_capacity_policy, null: true, index: true
|
|
end
|
|
```
|
|
|
|
#### record_activity_at! (enterprise/app/models/company.rb:63)
|
|
|
|
```ruby
|
|
def record_activity_at!(activity_at)
|
|
return if last_activity_at.present? && last_activity_at > activity_at - ACTIVITY_ROLLUP_INTERVAL
|
|
|
|
update!(last_activity_at: activity_at)
|
|
end
|
|
```
|
|
|
|
#### prepare_jsonb_attributes (enterprise/app/models/company.rb:71)
|
|
|
|
```ruby
|
|
def prepare_jsonb_attributes
|
|
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
|
|
self.custom_attributes = {} unless custom_attributes.is_a?(Hash)
|
|
end
|
|
```
|
|
|
|
#### fetch_favicon (enterprise/app/models/company.rb:76)
|
|
|
|
```ruby
|
|
def fetch_favicon
|
|
Avatar::AvatarFromFaviconJob.set(wait: 5.seconds).perform_later(self)
|
|
end
|
|
```
|
|
|
|
#### perform (app/jobs/migration/backfill_companies_contacts_count_job.rb:4)
|
|
|
|
```ruby
|
|
def perform
|
|
return unless ChatwootApp.enterprise?
|
|
|
|
Company.find_in_batches(batch_size: 100) do |company_batch|
|
|
company_batch.each do |company|
|
|
Company.reset_counters(company.id, :contacts)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
#### find_or_create_company (enterprise/app/jobs/migration/company_account_batch_job.rb:32)
|
|
|
|
```ruby
|
|
def find_or_create_company(contact, account)
|
|
domain = extract_domain(contact.email)
|
|
company_name = derive_company_name(contact, domain)
|
|
|
|
Company.find_or_create_by!(account: account, domain: domain) do |company|
|
|
company.name = company_name
|
|
end
|
|
rescue ActiveRecord::RecordNotUnique
|
|
# Race condition: Another job created it between our check and create
|
|
# just find the one that was created
|
|
|
|
Company.find_by(account: account, domain: domain)
|
|
end
|
|
```
|
|
|
|
#### associate_company_from_email (enterprise/app/services/contacts/company_association_service.rb:2)
|
|
|
|
```ruby
|
|
def associate_company_from_email(contact)
|
|
return nil if skip_association?(contact)
|
|
|
|
company = find_or_create_company(contact)
|
|
if company
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
# Using update_column and increment_counter to avoid triggering callbacks while maintaining counter cache
|
|
contact.update_column(:company_id, company.id)
|
|
Company.increment_counter(:contacts_count, company.id)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
|
|
end
|
|
company
|
|
end
|
|
```
|
|
|
|
#### find_or_create_company (enterprise/app/services/contacts/company_association_service.rb:29)
|
|
|
|
```ruby
|
|
def find_or_create_company(contact)
|
|
domain = extract_domain(contact.email)
|
|
company_name = derive_company_name(contact, domain)
|
|
|
|
Company.find_or_create_by!(account: contact.account, domain: domain) do |company|
|
|
company.name = company_name
|
|
end
|
|
rescue ActiveRecord::RecordNotUnique
|
|
# If another process created it first, just find that
|
|
Company.find_by(account: contact.account, domain: domain)
|
|
end
|
|
```
|
|
|
|
#### ApplicationJob (app/jobs/application_job.rb:1)
|
|
|
|
```ruby
|
|
class ApplicationJob < ActiveJob::Base
|
|
# https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html
|
|
discard_on ActiveJob::DeserializationError do |job, error|
|
|
Rails.logger.info("Skipping #{job.class} with #{
|
|
job.instance_variable_get(:@serialized_arguments)
|
|
} because of ActiveJob::DeserializationError (#{error.message})")
|
|
end
|
|
end
|
|
```
|
|
|
|
#### ApplicationRecord (app/models/application_record.rb:1)
|
|
|
|
```ruby
|
|
class ApplicationRecord < ActiveRecord::Base
|
|
include Events::Types
|
|
self.abstract_class = true
|
|
|
|
before_validation :validates_column_content_length
|
|
|
|
# the models that exposed in email templates through liquid
|
|
def droppables
|
|
%w[Account Channel Conversation Inbox User Message]
|
|
end
|
|
|
|
# ModelDrop class should exist in app/drops
|
|
def to_drop
|
|
return unless droppables.include?(self.class.name)
|
|
|
|
"#{self.class.name}Drop".constantize.new(self)
|
|
end
|
|
|
|
private
|
|
|
|
# Generic validation for all columns of type string and text
|
|
# Validates the length of the column to prevent DOS via large payloads
|
|
# if a custom length validation is already present, skip the validation
|
|
def validates_column_content_length
|
|
self.class.columns.each do |column|
|
|
check_and_validate_content_length(column) if column_of_type_string_or_text?(column)
|
|
end
|
|
end
|
|
|
|
def column_of_type_string_or_text?(column)
|
|
%i[string text].include?(column.type)
|
|
end
|
|
|
|
def check_and_validate_content_length(column)
|
|
length_validator = self.class.validators_on(column.name).find { |v| v.kind == :length }
|
|
validate_content_length(column) if length_validator.blank?
|
|
end
|
|
|
|
def validate_content_length(column)
|
|
max_length = column.type == :text ? 20_000 : 255
|
|
return if self[column.name].nil? || self[column.name].length <= max_length
|
|
|
|
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
|
|
end
|
|
|
|
def normalize_empty_string_to_nil(attrs = [])
|
|
attrs
|
|
// ... truncated ...
|
|
``` |