智付通金流整合

今年初用 rails 做了個系統與智付通的金流整合。整合過程其實蠻單純的,稍微將實做整合的過程記錄在這邊。

當初整合智付通的流程是,使用者在我站挑選要購買的商品後,到結帳頁面輸入送貨地址,是否要開索取發票,是否使用折價卷或點數扣抵等等資訊。後。將使用者與確認完整的購買資訊 POST 到智付通的付款頁面。待使用者付款完成後,智付通會將使用者再導回我站,並且傳回付款結果等資訊告知我站是否已成功付款;同時背景也會呼叫我站的 Notify URL。

spgateway-integration

大概整合內容如上,很簡單。

幾個整合的重點:

  • 跳轉到智付通的頁面要用 Form POST 把玩家導過去,要整頁導過去。智付通文件有說明放 iframe 裡導頁會有問題。
  • 智付通回饋付款結果有兩個
    • ReturnURL (前景) 讓使用者在付款完後(不管成功失敗) 導回 ReturnURL
    • NotifyURL (背景) 會在背景把支付完後的資訊打給 NotifyURL
  • 加解密的方式

這邊遇到一點問題的就是加解密,所以說明一下。其他頁面跳轉大部分的金流好像都差不多。

加解密的方式

整個加密重點在這段,智付通的 API 文件上提供 mcrypt_encrypt(CRYPT_RIJNDAEL_128, key, CRYPT_MODE_CBC, iv) (PHP) 與 RijndaelManaged() (.NET) 的 AES 加密範例可參考。

用 ruby 有遇到一點麻煩,因為先參考了mcrypt 的範例,一開始就直接用 OpenSSL 來加密,但是 encrypt 出的字串與智富通的不合。參考這篇 Stack Overflow 的答案  可知,在 ruby 裡用 OpenSSL::Cipher 與 mcrypt 執行起來的行為是不一樣的。將 256 bit 的 key 傳入 mcrypt( ) 他會自己改為 rijndael-256 的編碼,而 OpenSSL::Cipher 只會拿前 128 bits 。

而智付通給的key 是 256 bits 的

權衡之下,還是用了 php-mcrypt ….讓 mcrypt 自己去調整編碼方式

# 這個 hash 轉成 QUERY STRING 涵式是這邊抄來的: 
# https://justanothercoder.wordpress.com/2009/04/24/converting-a-hash-to-a-query-string-in-ruby/
payload_str = Common::hash_to_querystring(@payload)

# 加密 payload
crypto = Mcrypt.new(:rijndael_128, :cbc, key, iv, :pkcs)
ciphertext = crypto.encrypt(payload_str)

ciphertext.unpack("H*").join()

最後,組出單向的核對字串:

def trade_sha(tradeInfo)
    key = Settings.payment.spgateway.key
    iv = Settings.payment.spgateway.iv

    Digest::SHA256.hexdigest("HashKey=#{key}&#{tradeInfo}&HashIV=#{iv}").upcase
end

完整的加密 Gist

在智付通把使用者導回 ReturnURL 的時後同時也會送來一串交易結果的 payload,這個 payload 的解密方式如下

def decrypt
    key = Settings.payment.spgateway.key
    iv = Settings.payment.spgateway.iv

    crypto = Mcrypt.new(:rijndael_128, :cbc, key, iv)
    plaintext = crypto.decrypt([@payload].pack('H*'))

    # 移除解密後多出來的 padding
    # 因為知道加密的內容是 JSON 所以可以用這樣的懶惰方式移除 padding...
    plaintext[0, plaintext.rindex('}') + 1]
  end

記得好像就醬,目前好像開立電子發票,信用卡請款等等都可用上述的方式加解密。整合來說並不算太複雜。

複雜的反而是開電子發票後如果客人要退貨,依照相關法規要退發票重新申報這些。如果可以直接進智付通後台操作那就還好…不然靠 API 要弄電子發票真的很繁雜….

佈署 Rails App 的一些想法

基礎的架構想法是這樣的:

SSL 憑證掛在處理流量的 Nginx 上順便讓他做基本的 LoadBalancer。後面就是N個 Rails App 的 VM 來處理LB後的流量。Rails 的 Application 還是用習慣的 unicorn 來執行成 unix socket 讓同台機器上的自己的 Nginx 來把非 static 的 Request 導向 Rails App。

rails-topology.png

Nginx 上的設定

upstream store {
    server 10.x.x.1;
    server 10.x.x.2;
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;

    .....
    location / {
        proxy_pass http://store_milife;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

想得到要小心的是

  • 如果要用 SSL 架站的話,Nginx 上要把 HTTP 的 Request 轉向 HTTPS 再導到 內網的 Rails App,類似這樣的方式:
server {
    ...
    if ($scheme = http) {
        return 301 https://$server_name$request_uri;
    }
}
  • Rails erb 裡的 link_to 會因為在內網只用 http 架站 enforce_ssl 沒有打開,所以會導向 http://。如果 Web Server 有處理掉這段的話就可以不用在 Rails 裡面處理
  • enforce_ssl 不要打開,不然會發生無限導向的狀況

 

佈署 Rails App 到 Ubuntu 16

專案

現在到了一個沒有 .NET 的工作環境,臨時趕一專案需要趕快做一個 EC 平台串金流。

基於對 Rails 的愛,選了 Rails 來開發這個專案。時間雖然很趕,但 Rails 的框架很多東西都做好了,開發起來就很輕鬆。

設施&佈局

佈署目標是 Google Computing Cloud, 本來打算用 Container Engine 但因為 SSL 已經設定好在一個 Computing Cloud VM 上。找了一下暫時找不到怎麼把 Computing Enging 的 vm 跟 Container engine 內網串起來。所以還是建了 Ubuntu VM 來佈署。

>> 這次佈署的架構在這篇

在已經掛了 SSL 的 Nginx 上做簡單的 Reverse Proxy:

upstream store_milife {
    server 10.x.x.x;
    server 10.x.x.x;
    server 10.x.x.x;
}

server {
    location / {
        proxy_pass http://store-sites;
    }
}

透過內網把 Request 轉到我們部署了 Rails 的VM。這裡我們裝了另一個 Nginx 讓它先處理 Static asserts 再處理其他的 Request,我們把這些其他的 Request 轉到 unicorn 建立的 unix socket

upstream app {
    server unix:/opt/store/shared/sockets/unicorn.sock fail_timeout=0;
}

server {

    root /opt/store/public;

    try_files $uri/index.html $uri @app;

    location @app {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_pass http://app;
    }
    ...
}</pre>
<h2>Session</h2>
這麼一來因為我們有了 Load Balance 的機制,Rails App 就得把 Session 往共用的儲存空間放,不能再放本機記憶體,<a href="https://github.com/rails/activerecord-session_store" target="_blank" rel="noopener">這個 GEM</a> 可讓你很輕鬆的設定資料庫為 Session store,在原本的資料庫多一個表格存放 Session
gem 'activerecord-session_store'

送信功能

結帳後送信在 Rails 也是很簡單的,Rails 把送信的機制做在 Active Job 裏。 Active Job 讓 Rails App 在需要執行一些背景工作時可以透過一致的介面溝通。你可以選用你喜歡的 Queue 來處理在 Application 裡送出的 Job 。

Rails 預設的 Async Queue 是跟著 application 跑在同一個執行緒的。在開發這個專案的時候我選用了 Resque 。Resque 是把 Queue 存在 Redis 裡。

Email 的設定:

config.active_job.queue_adapter = :resque
# 根據不同的環境來設定 Queue 可避免類似 QA 環境的 Job 被 Production 吃到的問題
config.active_job.queue_name_prefix = "store_#{Rails.env}"
# 把要寄出的信件開在開發機上的瀏覽器預覽方便開發調整
config.action_mailer.delivery_method = :smtp
# 正式環境下用 :smtp 沒問題,但是測試環境下我用了 `letter_opener`
# 測試環境:
config.action_mailer.delivery_method = :letter_opener

接下來我們可用

$ rails g mailer StoreMailer

來建立幫我們寄件的郵差,在 app/mailers/store_mailer.rb 裏我們可以設定要寄出 Email 的內容。

寄信對 Rails 來說,就像在 Render HTML 頁面一樣,在 app/views/store_mailer 裡我們將設定對應的 view 作為 Email 的內容。比較不同的是,Mailer 要使用 heleper 的話需要自己指定 helper 不會自已載入。

class StoreMailer < ApplicationMailer     helper :application # Load app/helpers/application_helper.rb          ... end 

另外在 mailer class 裡我們需要設定內容,def order_notification 會對應到 app/views/store_mailer/order_notification.erb

 
def order_notification(user_session, order)     
    @header = {         
        :title => '訂購完成通知',
        :disclaimer => true,
    }

    @user = user_session
    @order = order
    mail(to: @user['email'], subject: @header[:title])
end

View 的寫法就與 HTML 頁面的 ERB 一樣

執行 Mailer Job

Resque config/initializers/resque.rb 可以載入 config/resque.yml 並藉此設定與 Redis 的連線

config/resque.yml

development: localhost:6379
test: localhost:6379
production: 10.x.x.x:6379

config/initializers/resque.rb

rails_root = ENV['RAILS_ROOT'] || File.dirname(__FILE__) + '/../..'
rails_env = ENV['RAILS_ENV'] || 'development'

resque_config = YAML.load_file(rails_root + '/config/resque.yml')
Resque.redis = resque_config[rails_env]

執行 Resque 的工作步驟很簡單,因為在此專案裡我有使用 foreman 管理不同的 process, 所以在 Procfile 裏我多加了下列工作:

mailer: rake environment resque:work QUEUE=store_${RAILS_ENV}_mailers

執行時,只需要如下指令即可啟動郵差的執行緒

foreman start mailer

Active Job 背景執行的工作

系統長大的時候常常會遇到一些效能的瓶頸,比如說,我們要把資料庫裡的資料匯出成 Excel 讓人下載,資料庫那邊的效能瓶頸以外,就是處理效能的瓶頸了。Active Job 讓我可以把要做的事情丟到 Queue 裡讓另一個 process 來處理。 Rails Application 就不需要在 Request Timeout 前短短回應時間內把它做完。

這邊有佈署 Active Job 的方式

Active Job 的佈署實例

當我們在 Web 處理 Request 的執行緒做一些消耗性能但是又不緊急的工作,流量大時不免會變成系統效能的瓶頸。

Async => 非同步的設計在這時候可以幫我們降低同一個執行緒要處理的工作消耗,可以用來處理類似發信,寫檔案,緩慢寫入讀取資料的事情。

在 Rails 要做到很簡單,Rails 有 Active Job 可以搭配許多 Queue framework,這邊我用的 Resque 就是其中之一。設定並不困難,安裝了 gem 然後在 #{env}.rb 設定

config.active_job.queue_adapter = :resque
config.active_job.queue_name_prefix  = "app_#{Rails.env}"

這邊要說的是…部署這些 Job 的方式。

之前有提到的專案應用上,我使用的系統是 Ubuntu 16。Rails 的 Application 裡用的是 foreman 來管理啟動 unicorn server。

web: bundle exec unicorn -c ./config/unicorn.rb

Worker 也可以這樣使用。在我們佈署到上線的環境時,我們會需要把這些 Worker 的執行程式包裝成服務來啟動。醬可以藉由設定讓他在 Server 重啟之後自己繼續執行。

Woker 的 Procfile 會像這樣:

mailer: rake environment resque:work QUEUE=store_${RAILS_ENV}_mailers
logger: rake environment resque:work QUEUE=store_${RAILS_ENV}_logger
filegen: rake environment resque:work QUEUE=store_${RAILS_ENV}_file_generation

Foreman 可以幫我們做到這點。它提供了許多匯出成系統服務的指令。我使用的指令如下:

$ sudo foreman export systemd --env .production-env --app storeworkers --user store /etc/systemd/system

這個指令會依照當下執行目錄的 Procfile 加上 --env 指定的環境變數來產生服務設定檔,放在指定的 /etc/systemd/system 裡。

產生的 /etc/systemd/system/storeworkers.target 會包含 Procfile 指定的所有工作,而每一個 Procfile 指定的工作內容將會被定義匯出在 /etc/systemd/system 裡。

匯出後我們可以透過 systemctl 來設定。

$ sudo systemctl [start|restart|enable] storeworkers.target

設定為 enable 後將可以在系統啟動後一併執行服務。

status 則可以查看該 worker 的最近幾筆紀錄

sudo systemctl status storeworkers.target

● storeworker.target
   Loaded: loaded (/etc/systemd/system/storeworkers.target; enabled; vendor preset: enabled)
   Active: active since Thu 2017-06-29 02:59:04 UTC; 20min ago

Jun 29 02:59:04 storeworkers systemd[1]: Stopped target storeworkers.target.
Jun 29 02:59:04 storeworkers systemd[1]: Stopping storeworkers.target.
Jun 29 02:59:04 storeworkers systemd[1]: Reached target storeworkers.target.