---
# System prepended metadata

title: '[08/10_b] 假刪除/會員系統/Gemfile/session(含面試題)'
tags: [面試題, Rails]

---

# [08/10_b] 假刪除/會員系統/Gemfile/session(含面試題)
手刻會員系統是面試題。

## 解說: Gemfile
https://railsbook.tw/chapters/09-using-gems.html


## 解說: session
HTTP是一種無狀態的通訊協定，在這邊做任何事情後，跳到其他頁面再回來並不會保留資訊。
可以透過Params 或 Cookies 讓瀏覽器能夠在切換頁面時記住資訊，
Rails 提供了所謂的 Session 可以更方便的操作，**用來記住登入的狀態、記住使用者購物車的內容等等**。

發 session(cookie) = 一種認證機制
拿到 cookie 不等於登入
登入 = 拿到 session 而且可以做裡面的事
登入成功會發令牌(cookie)給你，瀏覽器會留一個 session 做比對，看是否正確
會有時間性，如果過期或是對方 server 重新啟動都要再重新登入

Session 是一個實體方法，用法跟 Hash 很像。
可以把 Session 當作是 Hash，把 user.id 這個值存到屬性 thankyou9527，取名隨意。
```rb
session[:thankyou9527] = user.id
```


## 更改預設首頁
在Route第一行加入
```ruby=
get "/", to: "notes#index"
```

## 製作假刪除
在note增加「假刪除」欄位 deleted_at
1. 用rails g 增加一個migration（名 add_deleted_at）
2. 在db/migrate加欄位（column）與索引（index 增加速度）
    3. 欄位三格代表: 增加欄位的表格, 新欄位名稱, 型態＝時間戳記
4. 然後記得 $ rails db:migrate
```ruby=
  def change
    add_column :notes, :deleted_at, :datetime
    add_index :notes, :deleted_at
  end
```
### 方法一
**在Controller**（notes_controller.rb）
在def destroy方法
* 將原本的 @note.destroy改成刪除時間
* 讓他從原本的刪除變成更新資料 destroy 改 update
* 更新刪除的時間，預設都是 null 點了之後會變成 true
  
```ruby=
@note.update(deleted_at: Time.now)
```
在def index，
* 將原本的 @notes = Note.order(id: :desc)先關掉。
* 用where篩選顯示條件：設deleted_at沒資料（沒被按刪除 沒印時間）才印出來。
```ruby=
@notes = Note.where(deleted_at: nil).order(id: :desc)
```
### 方法二
定義類別方法。用available方法簡化那段where。
```ruby=
@notes = Note.available.order(id: :desc) #反向排序
```

被簡化的available，移到Model（note.rb）加為**類別方法（要加self）**
```ruby=
def self.available
  where(deleted_at: nil)
end
```
### 方法三 scope
或寫為 用scope指向lambda
* 商業邏輯，以後如果要改需求改這邊就好
* 定義類別方法作「假刪除」，加入刪除時間，讓他看起來被刪掉
* 用 where 過濾，deleted_at 預設 nil，沒點刪除的都是 nil（有點，會加入假時間）
```ruby=
def self.available
    where(deleted_at: nil)
end
    scope :available
``` 
或寫為**預設值default_scope**
* default_scope 所有的搜尋都會加上後面的條件
* 一般的 scope 還是要在類別後面加這個方法名稱，default不用 => 不用寫 scope :available
```ruby=
default_scope { where(deleted_at: nil) }
```
取消scope的寫法
```ruby=
Note.unscope(:where)
```
#### 套件 pranoia

https://github.com/rubysherpas/paranoia
* 直接用 acts_as_paranoid 取代 default_scope { where(deleted_at: nil) }
* 使用前先在 Gemfile 寫入 gem "paranoia", "~> 2.2"
* 接著輸入 bundle install，如果沒反應就重開 server （acts_as_paranoid）
    
    
## 註冊會員
**在Route**
增加users路徑，不需要show與index。
* 關掉 get "/users", to: "users#profile" ，對user只要開建user的路徑就好
* 不需要使用者列表（後臺看到就好）
* 用collection 追加路徑
    * 方法: [:方法名稱] 取代GET路徑：
        * GET /users/sign_up 註冊表單
        * GET /users/sign_in 登入表單
```ruby=
resources :users, only: [:create] do
collection do
	get :sign_up   # GET /users/sign_up 註冊表單
    end
end
```
**在Controller**
users_controller.rb 要定義新方法 @user = User.new（不讓網頁中form_for引用時是空的），在View要新增網頁。
```ruby=
def sign_in
    @user = User.new
end
```
**新Model**
註冊也需要新欄位，都是string。
1. 用rails g 增加一個migration： rails g model User email password nickname
2. 然後記得 $ rails db:migrate

**在View**
在註冊會員網頁（sign_up.html.erb）增加form_for表格，依次放：
* Email
* 密碼
* 密碼驗證
* 帳號

指定@users的路徑，以免系統猜錯。
* email_field也是防呆機制（瀏覽器自動檢查）。
* 直接給@user明確路徑，以免它猜錯（可看路徑表）。* 

```htmlmixed=
<%= form_for(@user, url: "/users") do |f| %>

<div class="field">
  <%= f.label :email %>
  <%= f.email_field :email %>
</div>

<div class="field">
  <%= f.label :password %>
  <%= f.password_field :password %>
</div>

<div class="field">
  <%= f.label :password_confirmation %>
  <%= f.password_field :password_confirmation %>
</div>

<div class="field">
  <%= f.label :account %>
  <%= f.text_field :account %>
</div>

<%= f.submit %>

<% end %>
```
**回到Controller**
由於在Route已經設定sign_up送出時，only送到create。所以要在Controller裡設定後續（新增會員的動作）：
```ruby=
def create
    @user = User.new(user_params)

    if @user.save
    	redirect_to "/"
    else
    	render :sign_up
    end
end
```

**在Model**
user.rb 加上驗證，要求若干表格「必填」
https://guides.rubyonrails.org/active_record_validations.html
```ruby=
  validates :email, presence: true
  validates :account, presence: true
  validates :password, presence: true
```

**在Controller**
清洗資料，只允許這些表格（user_params 在 create）送資料
```ruby=
private
def user_params
    params.require(:user).permit(:email,
                                :password,
                                :password_confirmation,
                                :account)
end    
```
### 沒有password_confirmation
* 可先在Model利用Rails的一個Active Model，做出暫時可讀寫效果
* confirmation = 確認兩個欄位值是一樣的, uniqueness = 唯一值
> attr_accessor :password_confirmation

### 密碼加密
不要在db存進明碼，必須加密。查官方手冊Active Record Callback，可看到Model建立一筆資料的各個階段（before_save之類）。

在user.rb ，加入一個Model前的驗證器。
* 加密（digest）有MD5與SHA1兩種方法，在開頭引用require進來。
* 密碼加鹽巴之後，帶給類別方法self.password。
* 由於before_save在新增、更新時都生效，會重複加密，因此改掛before_create。

```ruby=
require 'digest' #也能放在外面這裡

before_create :encrypt_password

private
    def encrypt_password
        require 'digest'
        salted_pw = "xyz#{self.password}827128#{self.password}82-12j23h"
        self.password = Digest::SHA1.hexdigest(salted_pw)
    end
```

### 提示訊息 password_confirmation
若兩次密碼不一樣，應該跳出提示訊息。

**在Model**（user.rb）利用confirmation驗證器的password驗證補寫。兒原本 attr_accessor :password_confirmation 已不需要，移除。
```ruby=
  validates :password, presence: true, confirmation: true
```

帳號與郵件必須是唯一的（不重複），加上uniqueness，最後寫為：
```ruby=
  validates :email, presence: true, uniqueness: true
  validates :account, presence: true, uniqueness: true
  validates :password, presence: true, confirmation: true
```
**在View**
在sign_up網頁開頭，也加入if any error，標出異常時上色。
```ruby=
<% if @user.errors.any? %>
  <ul>
    <% @user.errors.full_messages.each do |message| %>
    <li><%= message %></li>
    <% end %>
  </ul>
<% end %>
```

## 會員登入
**在Route**
新增 get :sign_in 
若用 post :sign_in 是拉出去做，會在網址列公開帳密

```ruby=
resources :users, only: [:create] do
collection do
	get :sign_up   # GET /users/sign_up 註冊表單
	get :sign_in   # GET /users/sign_in 登入表單
    end
end
```

**在Controller**
直接給要定義新方法（不讓網頁中form_for引用時是空的），在View要新增網頁
```ruby=
def sign_up
    @user = User.new
end
```

**在View**
在註冊會員網頁（sign_up）增加form_for表格，依次放：
* Email
* 密碼

方法只能是POST，因此依路徑表，只有 login_path
若放在Route的resources下，路徑會是 #POST /users/sign_in 登入
> post: sign_in 

但建議分給不同的Controller。
* 登入跟註冊是兩件事情，註冊是一般的 CRUD，但登入會**用到 sessions**，所以把他獨立拉出來寫成SessionController
    * sessions 是某個 controller 的名字
* **在Route**規定凡遇到登入，就連到session controller下的create。
    * 寫入 sessions = 登入，用 as 換 path 名字，看起來比較乾淨
```ruby=
post "/users/sign_in", to: "sessions#create", as: "login"
```
* 也因此，目前網頁form_for的路徑會是'/users/sign_in'

再建立這個**新Controller**「sessions_controller」，並增加其create。
> rails g controller sessions

得到 sessions_controller.rb

* 一樣先在private下引入清洗資料，這次（登入）只需要2欄。
* 一樣驗證user_params
* 因為密碼已經加密過，如同user.rb引入一樣的撒鹽密碼（hashed_password），才能用find_by無誤驗證。
* if else 判斷
    * 成功的話發session 且轉回首頁（session就類似cookie）
    * 失敗的話回登入頁
* 登出 = 把sessions刪掉，之後在下方加 def destory

```ruby=
def create
    # 驗證 email & password（如同user.rb）
    pw = user_params[:password]
    salted_pw = "xyz#{pw}827128#{pw}82-12j23h"
    hashed_password = Digest::SHA1.hexdigest(salted_pw)
    
    user = User.find_by(email: user_params[:email],
                        password: hashed_password)

    if user
      session[:thankyou9527] = user.id # session自由取名 # 用 session 方法把 user.id 送進來
      redirect_to "/"
    else
      redirect_to "/users/sign_in"
    end
    
private
    def user_params
        params.require(:user).permit(:email, :password)
    end

end

```

* 到def new增加檢查。若session是空的，就回到登入頁。
* 假設而已，可撤除。
```ruby=
def new
    redirect_to "/user/sign_in" if session[:thankyou9527].nil?        
    @note = Note.new
    end
end
```
確認路徑可寫為url: login_path，完善View的網頁表單。
```htmlmixed=
<%= form_for(@user, url: login_path) do |f| %>
  <div class="field">
    <%= f.label :email %>
    <%= f.email_field :email %>
  </div>

  <div class="field">
    <%= f.label :password %>
    <%= f.password_field :password %>
  </div>

  <%= f.submit "Login" %>
<% end %>
```

## 顯示登入登出狀態（layout）
**在View**
在公版（layout）每一頁左上角放登入/登出狀態
* 在/view/layout/application.html.erb 的body內加nav
* 可再加一行把session印出來，原本是使用者數字，必須改印出email或名稱 =>再丟入helper裡面（print_out_session）。
    > <%= User.find(session[:thankyou9527]).email %>

```htmlmixed=
<nav>
    <%= link_to "登入", sign_in_users_path%>
    <%= link_to "註冊", sign_up_users_path%>
    <%= link_tp "登出", ""%>
    <%= print_out_session %>
</nav>
```
* 建議nav與之後的footer獨立出去，在views下建目錄/shared/_新網頁.html.erb。
    * application.html.erb 再渲染render回來。

```htmlmixed=
<body>
  <%= render "shared/navbar" %>
  <%= yield %>
  <%= render "shared/footer" %>
</body>
```


承上，在獨立的 _navbar.html.erb 裡，區分登出/登入看到的不同狀態。
* 如果登入的話（新方法 user_signed_in? 後面在Helper建起來），會看到登出與Email狀態。
* 否則就需要登入或註冊。

經過後面Route的as寫法後，可以得到 logout_path 的路徑與方法，給登出用。
```htmlmixed=
<nav>
  <% if user_signed_in? %>
    <%= link_to "登出", logout_path, method: "delete" %> |
    <%= current_user.email %>
  <% else %>
    <%= link_to "登入", sign_in_users_path %> |
    <%= link_to "註冊", sign_up_users_path %>
  <% end %>
</nav>
```
在Helper定義方法
在app/helpers/ 下的sessions_helper 移到 users_helper
* 放在helper，所有的View都能用
* 方法名**current_user代表登入中的user**
```ruby=
def current_user
    if session[:thankyou9527]
        User.find(session[:thankyou9527])
    else
        nil
    end
```
拉出新方法user_sign_in?方便分類。換句話說，登入時session不為空。
```ruby=
def user_sign_in?
    session[:thankyou9527] != nil #
end

def current_user
    if user_sign_in?
        User.find(session[:thankyou9527])
    else
        nil
    end
end
```
有需要的話，可以在stylesheets下建立任意scss檔案
>    .{ mainfooter
>    樣式
>    }

網頁_footer中可以寫
```htmlmixed=
<footer class="mainfooter">&copy;版權所有</footer>
```
## 會員登出
Route的登入路徑下加一行 delete（如同post 來自路徑表）
```ruby=
post "/users/sign_in", to: "sessions#create", as: "login"
delete "/users", to: "sessions#destroy", as: "logout"
```
Route的**as:"login" 寫法**後，可以得到 login_path與 logout_path的路徑與方法。
而sing_in網頁中form_for的url連結，也能改為 logout_path
> <%= form_for(@user, url: login_path) do |f| %>

在sessions controller將剛剛新寫的destroy加進去。
把上面的if user複製改一下：如果沒有票（nil），就回首頁（"/"）。
```ruby=
def destroy
    session[:thankyou9527] = nil
    redirect_to "/"
end
```

為了讓View都能使用user_sign_in?，將它移到**最高層Controller（application_controller）**。
* 若不移，那controller與view helper，就要include helper（include UserHelper）。
    * 但是，若向上面一樣user_helper移走之後，nav的 if user_sign_in 就壞掉了。
* 所以，在controller加一行 helper_method，讓helper繼續能用得到。

```ruby=
helper_method :user_sign_in?, :current_user #讓helper繼續用得到

private
def user_sign_in?
    session[:thankyou9527] != nil #登入代表session不為nil
end

def current_user
    if user_sign_in?
        User.find(session[:thankyou9527]) #若找到user的session就能登入
    else
        nil
    end
end
```

也將note_controller中def new開頭「檢查未登入（check_login!）if..end」移到**最高層controller**，仍保留 @note = Note.new 就好。
* 確認裡面要有這些方法
    * user_signed_in?
    * current_user
    * check_login!
    * record_not_found

```ruby=
def check_login!
    if not user_signed_in?
        redirect_to "/users/sign_in" #若未登入 回登入頁
    end
end
```
而原本note_controller再掛回來，除了show與index，都要登入才能做（新增、修改、刪除）。
```ruby=
before_action :check_login!, except: [:index, :show]
```

#### Device套件
在model，可以掛 before_action :authenticate_user! 功能，直接檢查有無登入。

> rails g devise MODEL

MODEL 要換字！ 看你要取什麼名字

套件裡面的 Wiki 有各式套件，照抄就好，抄官網最好，別亂抄來路不明的文章

#### Paranoia套件
套件安裝（按官方文件）
[paranoia](https://github.com/rubysherpas/paranoia)
放在  Model 裡面
直接用 acts_as_paranoid 內建套件取代 default_scope { where(deleted_at: nil) }
1. 到Gemfile貼上 "paranoia", "~> 2.2"
2. 終端機跑 bundle install
3. 到Model的rb貼上 acts_as_paranoid

## 補充：增加migration欄位
終端機 rails g migration 名字-通常會寫add作什麼

在Model目錄中的新rb檔內的change內，設定要作什麼。

## 補充：Lighthouse 燈塔
可以從開發者工具看，分析評分。
黃色以上較佳。


###### tags: `Rails`
###### tags: `面試題`