12-1. SMS/SMPP 簡訊協定

深入理解簡訊發送的原理與電信網路架構

📲 SMS/SMPP 簡訊協定

🎯 什麼是 SMS?

💡 比喻:行動電話的明信片
不需要網路,只要有訊號就能發送
最多 160 個英文字元或 70 個中文字

SMS(Short Message Service) 是一種文字簡訊服務,透過電信網路(GSM/3G/4G/5G)傳送訊息,不需要網際網路連線。

SMS 特性

優點:

  • ✅ 不需要網路(只需電信訊號)
  • ✅ 幾乎 100% 送達率
  • ✅ 所有手機都支援
  • ✅ 接收者不需安裝 App

缺點:

  • ❌ 長度限制(160 字元/70 中文)
  • ❌ 無法傳送圖片/影片(需用 MMS)
  • ❌ 成本較高
  • ❌ 無已讀回執

使用場景:

  • 🔐 雙因素驗證(2FA)
  • 📢 行銷簡訊
  • 🚨 緊急通知
  • 💳 交易通知
  • ✈️ 機場/航班通知

🏗️ SMS 架構

電信網路組件

手機 A           BSC/RNC         MSC         SMSC         MSC         BSC/RNC       手機 B
(發送者)     (基地台控制器)  (交換中心)  (簡訊中心)  (交換中心)  (基地台控制器)  (接收者)
  │               │              │           │           │              │            │
  ├─ 簡訊 ────────>│──────────────>│───────────>│           │              │            │
  │               │              │           │           │              │            │
  │               │              │           │ 儲存      │              │            │
  │               │              │           │ 轉發      │              │            │
  │               │              │           │           │              │            │
  │               │              │           ├───────────>│──────────────>│────────────>│
  │               │              │           │           │              │            │
  │               │              │<─ 送達報告 ┤           │              │            │
  │<──────────────────────────────┤           │           │              │            │

組件說明:

1. 手機(Mobile Station, MS):

發送和接收簡訊的終端設備

2. 基地台(Base Station, BS):

與手機進行無線通訊

3. BSC/RNC(Base Station Controller / Radio Network Controller):

控制多個基地台

4. MSC(Mobile Switching Center):

行動交換中心
路由通話和簡訊

5. SMSC(Short Message Service Center):

💡 核心:簡訊中心

功能:
- 儲存簡訊
- 轉發簡訊
- 重試機制(對方關機時)
- 送達回報

📨 SMS 傳送流程

詳細步驟

Step 1: 手機 A 發送簡訊
手機 A → BSC → MSC → SMSC

Step 2: SMSC 儲存簡訊
SMSC 查詢手機 B 的位置(HLR/VLR)

Step 3: SMSC 轉發簡訊
SMSC → MSC → BSC → 手機 B

Step 4: 手機 B 確認收到
手機 B → BSC → MSC → SMSC(送達確認)

Step 5: SMSC 回報發送者
SMSC → MSC → BSC → 手機 A(送達報告)

離線處理

如果手機 B 關機或不在服務區:

1. SMSC 儲存簡訊(最多 48-72 小時)
2. 定期重試(每隔幾分鐘)
3. 手機 B 開機後,SMSC 自動發送
4. 如果超過時限,簡訊過期

🔗 SMPP 協定

💡 比喻:應用程式與電信公司的專線
SMPP 讓你的程式直接連到 SMSC 發送簡訊

SMPP(Short Message Peer-to-Peer Protocol) 是一種應用層協定,讓外部應用程式能夠與 SMSC 通訊,發送和接收簡訊。

SMPP 架構

你的應用程式        SMPP 連線        SMSC          電信網路        收件者手機
    │                   │              │               │               │
    ├─ bind_transmitter ─>│              │               │               │
    │<─ bind_transmitter_resp ──┤              │               │               │
    │                   │              │               │               │
    ├─ submit_sm ───────>│              │               │               │
    │  (發送簡訊)         │              │               │               │
    │                   ├──────────────>│               │               │
    │                   │              ├───────────────>│───────────────>│
    │                   │              │               │               │
    │<─ submit_sm_resp ──┤              │               │               │
    │  (message_id)      │              │               │               │
    │                   │              │               │               │
    │<─ deliver_sm ──────┤              │               │               │
    │  (送達報告)         │<──────────────┤               │               │
    │                   │              │               │               │
    ├─ unbind ──────────>│              │               │               │
    │<─ unbind_resp ─────┤              │               │               │

🔧 SMPP 命令

核心命令

bind_transmitter       - 綁定為發送者
bind_receiver          - 綁定為接收者
bind_transceiver       - 綁定為收發者
submit_sm              - 發送簡訊
deliver_sm             - 接收簡訊或送達報告
unbind                 - 解除綁定
enquire_link           - 保持連線(心跳)

SMPP 會話範例

客戶端                           SMSC
  │                               │
  ├─ bind_transmitter ───────────>│  (綁定為發送者)
  │  system_id: "user123"          │
  │  password: "pass456"            │
  │                               │
  │<─ bind_transmitter_resp ───────┤
  │  status: 0 (OK)                 │
  │                               │
  ├─ submit_sm ───────────────────>│  (發送簡訊)
  │  source_addr: "1234"            │
  │  destination_addr: "0912345678" │
  │  short_message: "Hello!"        │
  │                               │
  │<─ submit_sm_resp ───────────────┤
  │  message_id: "msg_001"          │
  │  status: 0 (OK)                 │
  │                               │
  ├─ enquire_link ────────────────>│  (心跳)
  │                               │
  │<─ enquire_link_resp ────────────┤
  │                               │
  │<─ deliver_sm ───────────────────┤  (送達報告)
  │  receipted_message_id: "msg_001"│
  │  message_state: DELIVERED       │
  │                               │
  ├─ deliver_sm_resp ─────────────>│
  │                               │
  ├─ unbind ──────────────────────>│  (斷開連線)
  │                               │
  │<─ unbind_resp ──────────────────┤

💻 Python 實作 SMPP

安裝 smpplib

pip install smpplib

發送簡訊

import smpplib.gsm
import smpplib.client
import smpplib.consts

# SMSC 連線資訊(需向電信商申請)
client = smpplib.client.Client('smpp.example.com', 2775)

# 綁定為發送者
client.connect()
client.bind_transmitter(system_id='user123', password='pass456')

# 發送簡訊
parts, encoding_flag, msg_type_flag = smpplib.gsm.make_parts('Hello World!')

for part in parts:
    pdu = client.send_message(
        source_addr_ton=smpplib.consts.SMPP_TON_INTL,  # 國際號碼
        source_addr='1234',                             # 發送者號碼
        dest_addr_ton=smpplib.consts.SMPP_TON_INTL,
        destination_addr='886912345678',                # 接收者號碼(台灣)
        short_message=part,
        data_coding=encoding_flag,
        esm_class=msg_type_flag,
        registered_delivery=True,  # 要求送達報告
    )

    print(f"Message ID: {pdu.message_id}")

# 解除綁定
client.unbind()
client.disconnect()

接收簡訊和送達報告

import smpplib.client
import smpplib.consts

def message_received_handler(pdu):
    """處理接收到的簡訊或送達報告"""

    if pdu.command == smpplib.consts.SMPP_CMD_DELIVER_SM:
        # 檢查是否為送達報告
        if pdu.receipted_message_id:
            print(f"送達報告:Message ID {pdu.receipted_message_id}")
            print(f"狀態:{pdu.message_state}")
        else:
            # 普通簡訊
            print(f"收到簡訊:{pdu.short_message.decode()}")
            print(f"發送者:{pdu.source_addr}")

client = smpplib.client.Client('smpp.example.com', 2775)

# 設定回調函數
client.set_message_received_handler(message_received_handler)

# 綁定為接收者
client.connect()
client.bind_receiver(system_id='user123', password='pass456')

# 監聽訊息
client.listen()

雙向通訊(收發)

import smpplib.client
import smpplib.consts
import threading

class SMSGateway:
    def __init__(self, host, port, system_id, password):
        self.client = smpplib.client.Client(host, port)
        self.system_id = system_id
        self.password = password

    def connect(self):
        """連線並綁定為收發者"""
        self.client.connect()
        self.client.bind_transceiver(
            system_id=self.system_id,
            password=self.password
        )
        print("已連線到 SMSC")

    def send_sms(self, to, message):
        """發送簡訊"""
        parts, encoding, msg_type = smpplib.gsm.make_parts(message)

        for part in parts:
            pdu = self.client.send_message(
                source_addr_ton=smpplib.consts.SMPP_TON_INTL,
                source_addr='1234',
                dest_addr_ton=smpplib.consts.SMPP_TON_INTL,
                destination_addr=to,
                short_message=part,
                data_coding=encoding,
                esm_class=msg_type,
                registered_delivery=True
            )

            print(f"簡訊已發送:{pdu.message_id}")

    def on_receive(self, pdu):
        """接收簡訊回調"""
        if pdu.receipted_message_id:
            # 送達報告
            print(f"✅ 送達確認:{pdu.receipted_message_id}")
        else:
            # 接收到的簡訊
            sender = pdu.source_addr.decode()
            message = pdu.short_message.decode()
            print(f"📩 收到簡訊來自 {sender}: {message}")

            # 自動回覆
            self.send_sms(sender, f"已收到您的訊息:{message}")

    def start_listening(self):
        """開始監聽"""
        self.client.set_message_received_handler(self.on_receive)

        # 在背景執行緒中監聽
        listen_thread = threading.Thread(target=self.client.listen)
        listen_thread.daemon = True
        listen_thread.start()

    def disconnect(self):
        """斷開連線"""
        self.client.unbind()
        self.client.disconnect()

# 使用
gateway = SMSGateway('smpp.example.com', 2775, 'user123', 'pass456')
gateway.connect()
gateway.start_listening()

# 發送簡訊
gateway.send_sms('886912345678', 'Hello from SMPP!')

# 保持運行
import time
try:
    while True:
        time.sleep(1)
except KeyboardInterrupt:
    gateway.disconnect()

📱 實際應用

1. 雙因素驗證(2FA)

import random
import string

class SMS2FA:
    def __init__(self, sms_gateway):
        self.gateway = sms_gateway
        self.codes = {}  # 儲存驗證碼(實務應用應使用 Redis)

    def generate_code(self, length=6):
        """產生隨機驗證碼"""
        return ''.join(random.choices(string.digits, k=length))

    def send_verification_code(self, phone_number):
        """發送驗證碼"""
        code = self.generate_code()

        # 儲存驗證碼(5 分鐘有效)
        self.codes[phone_number] = {
            'code': code,
            'expires': time.time() + 300  # 5 分鐘
        }

        # 發送簡訊
        message = f"您的驗證碼是:{code},5分鐘內有效。"
        self.gateway.send_sms(phone_number, message)

        print(f"驗證碼已發送給 {phone_number}")

    def verify_code(self, phone_number, code):
        """驗證驗證碼"""
        if phone_number not in self.codes:
            return False

        stored = self.codes[phone_number]

        # 檢查是否過期
        if time.time() > stored['expires']:
            del self.codes[phone_number]
            return False

        # 驗證碼是否正確
        if stored['code'] == code:
            del self.codes[phone_number]  # 使用後刪除
            return True

        return False

# 使用
twofa = SMS2FA(gateway)

# 發送驗證碼
twofa.send_verification_code('886912345678')

# 驗證
is_valid = twofa.verify_code('886912345678', '123456')
if is_valid:
    print("驗證成功")
else:
    print("驗證失敗")

2. 批次行銷簡訊

import time
from concurrent.futures import ThreadPoolExecutor

class BulkSMSService:
    def __init__(self, gateway):
        self.gateway = gateway

    def send_bulk(self, recipients, message, delay=1):
        """批次發送簡訊"""
        total = len(recipients)
        success = 0
        failed = 0

        for i, recipient in enumerate(recipients):
            try:
                # 發送簡訊
                self.gateway.send_sms(recipient['phone'], message)

                success += 1
                print(f"[{i+1}/{total}] 已發送給 {recipient['name']}")

                # 避免發送太快被限制
                time.sleep(delay)

            except Exception as e:
                failed += 1
                print(f"[{i+1}/{total}] 發送失敗:{recipient['name']} - {e}")

        print(f"\n統計:成功 {success},失敗 {failed}")

    def send_personalized_bulk(self, recipients, template):
        """發送個人化簡訊"""
        for recipient in recipients:
            # 替換變數
            message = template.format(**recipient)
            self.gateway.send_sms(recipient['phone'], message)
            time.sleep(1)

# 使用
bulk_service = BulkSMSService(gateway)

# 收件人列表
recipients = [
    {'name': 'Alice', 'phone': '886912345678'},
    {'name': 'Bob', 'phone': '886987654321'},
    # ...
]

# 發送相同訊息
bulk_service.send_bulk(recipients, '新產品上市!限時優惠中!')

# 發送個人化訊息
recipients_with_data = [
    {'name': 'Alice', 'phone': '886912345678', 'points': 1000},
    {'name': 'Bob', 'phone': '886987654321', 'points': 500},
]

template = "親愛的 {name},您目前有 {points} 點數可使用!"
bulk_service.send_personalized_bulk(recipients_with_data, template)

3. 交易通知

class TransactionNotifier:
    def __init__(self, gateway):
        self.gateway = gateway

    def notify_payment(self, phone, amount, merchant):
        """付款通知"""
        message = f"""
您已完成付款
金額:NT$ {amount:,}
商家:{merchant}
時間:{datetime.now().strftime('%Y-%m-%d %H:%M')}
        """.strip()

        self.gateway.send_sms(phone, message)

    def notify_withdrawal(self, phone, amount, atm_location):
        """提款通知"""
        message = f"""
提款交易成功
金額:NT$ {amount:,}
地點:{atm_location}
時間:{datetime.now().strftime('%Y-%m-%d %H:%M')}
        """.strip()

        self.gateway.send_sms(phone, message)

    def notify_suspicious_activity(self, phone, activity):
        """可疑活動警告"""
        message = f"""
【安全警示】
偵測到可疑活動:{activity}
如非本人操作,請立即聯絡客服。
        """.strip()

        self.gateway.send_sms(phone, message)

# 使用
notifier = TransactionNotifier(gateway)

# 付款通知
notifier.notify_payment('886912345678', 1500, '7-11 中正店')

# 提款通知
notifier.notify_withdrawal('886912345678', 5000, '台北火車站 ATM')

# 可疑活動警告
notifier.notify_suspicious_activity('886912345678', '境外登入')

🎓 常見面試題

Q1:SMS 和即時通訊 App 有什麼不同?

答案:

特性SMS即時通訊 App (LINE/WhatsApp)
網路電信網路(不需網路)網際網路(需 WiFi/4G)
裝置所有手機需安裝 App
長度160 字元/70 中文幾乎無限制
多媒體❌ 無(需用 MMS)✅ 圖片、影片、檔案
送達率接近 100%需對方在線
成本每則收費免費(使用網路流量)
已讀回執❌ 無✅ 有
群組❌ 無(需分別發送)✅ 支援

何時使用 SMS?

✅ 雙因素驗證(2FA)- 不依賴網路,安全性高
✅ 緊急通知(地震、颱風)- 送達率高
✅ 銀行交易通知 - 可信度高
✅ 老人機、功能型手機 - 無需 App
✅ 沒有網路的地方 - 只要有訊號即可

何時使用即時通訊 App?

✅ 日常聊天 - 免費、多媒體
✅ 群組討論 - 方便管理
✅ 商業客服 - 互動性強
✅ 行銷推廣 - 成本低

記憶技巧:

  • SMSSimple Message Service(簡單訊息服務)- 簡單、可靠
  • AppAlways Powerful Platform(功能強大平台)- 功能多、便宜

Q2:SMPP 和 HTTP SMS API 有什麼不同?

答案:

比喻:
SMPP = 專線(直接連到電信機房)
HTTP API = 網頁介面(透過服務商)

SMPP(專線連接):

# 建立長連線
client.connect()
client.bind_transmitter()

# 發送(即時)
client.send_message()

# 保持連線(心跳)
client.enquire_link()

優點
 即時毫秒級
 高吞吐量每秒數百則
 雙向通訊收發簡訊
 送達報告即時

缺點
 複雜需維護長連線
 成本高需專線費用
 需電信商合作

HTTP SMS API(網頁介面):

# 每次發送都是新請求
import requests

response = requests.post('https://sms-api.com/send', data={
    'api_key': 'xxx',
    'to': '886912345678',
    'message': 'Hello'
})

優點
 簡單HTTP 請求
 成本低按量計費
 容易整合

缺點
 較慢HTTP 開銷
 吞吐量低
 送達報告需 Webhook

選擇建議:

場景推薦
大量發送(> 10000/天)SMPP
雙因素驗證(需即時)SMPP
少量通知(< 1000/天)HTTP API
快速整合(不想維護連線)HTTP API

Q3:如何提升 SMS 送達率?

答案:

1. 選擇可靠的 SMSC:

與多家電信商合作
使用有 Tier 1 Carrier 合作的服務商

2. 設定正確的 TON/NPI:

# TON (Type of Number)
smpplib.consts.SMPP_TON_INTL      # 國際號碼(+886)
smpplib.consts.SMPP_TON_NATIONAL  # 國內號碼(0912)

# NPI (Numbering Plan Indicator)
smpplib.consts.SMPP_NPI_ISDN      # 標準電話號碼

# 錯誤設定會導致發送失敗

3. 處理字元編碼:

import smpplib.gsm

# 自動處理編碼
parts, encoding, msg_type = smpplib.gsm.make_parts('中文測試')

# GSM 7-bit:160 字元
# UCS-2(中文):70 字元

4. 實作重試機制:

def send_with_retry(client, phone, message, max_retries=3):
    for attempt in range(max_retries):
        try:
            pdu = client.send_message(
                source_addr='1234',
                destination_addr=phone,
                short_message=message
            )
            print(f"發送成功:{pdu.message_id}")
            return True

        except Exception as e:
            print(f"嘗試 {attempt + 1} 失敗:{e}")
            time.sleep(5)  # 等待 5 秒後重試

    print("發送失敗")
    return False

5. 監控送達報告:

def on_delivery_report(pdu):
    message_id = pdu.receipted_message_id
    state = pdu.message_state

    if state == smpplib.consts.SMPP_MSGSTATE_DELIVERED:
        print(f"✅ {message_id} 已送達")
    elif state == smpplib.consts.SMPP_MSGSTATE_UNDELIVERABLE:
        print(f"❌ {message_id} 無法送達")
        # 記錄失敗,稍後重試

client.set_message_received_handler(on_delivery_report)

6. 驗證號碼格式:

import re

def validate_phone(phone):
    """驗證台灣手機號碼"""
    # 09XXXXXXXX 或 886 9XXXXXXXX
    pattern = r'^(09\d{8}|8869\d{8})$'

    if re.match(pattern, phone):
        # 統一格式為國際號碼
        if phone.startswith('09'):
            phone = '886' + phone[1:]
        return phone
    else:
        raise ValueError(f"無效號碼:{phone}")

# 使用
try:
    validated = validate_phone('0912345678')
    send_sms(validated, 'Hello')
except ValueError as e:
    print(e)

Q4:如何計算簡訊長度與費用?

答案:

簡訊長度計算:

GSM 7-bit(英文、數字、基本符號):
- 單則:160 字元
- 長簡訊(Concatenated):153 字元/則

UCS-2(中文、Emoji):
- 單則:70 字元
- 長簡訊:67 字元/則

Python 計算:

import smpplib.gsm

def calculate_sms_parts(message):
    """計算簡訊則數"""
    parts, encoding, msg_type = smpplib.gsm.make_parts(message)

    num_parts = len(parts)

    if encoding == smpplib.consts.SMPP_ENCODING_DEFAULT:
        # GSM 7-bit
        max_length = 160 if num_parts == 1 else 153
        encoding_name = 'GSM 7-bit'
    else:
        # UCS-2(中文)
        max_length = 70 if num_parts == 1 else 67
        encoding_name = 'UCS-2'

    return {
        'parts': num_parts,
        'encoding': encoding_name,
        'max_length': max_length,
        'total_chars': len(message)
    }

# 測試
messages = [
    "Hello World",                    # 11 字元 → 1 則
    "This is a very long message " * 10,  # 290 字元 → 2 則
    "中文測試",                       # 4 字元 → 1 則
    "這是一則很長的中文簡訊" * 10,    # 110 字元 → 2 則
]

for msg in messages:
    result = calculate_sms_parts(msg)
    print(f"訊息:{msg[:50]}...")
    print(f"則數:{result['parts']} 則")
    print(f"編碼:{result['encoding']}")
    print(f"字數:{result['total_chars']}/{result['max_length']}")
    print('-' * 50)

費用計算:

def calculate_cost(message, price_per_sms=1.5):
    """計算簡訊費用(台灣約 NT$1.5/則)"""
    result = calculate_sms_parts(message)
    total_cost = result['parts'] * price_per_sms

    return {
        'parts': result['parts'],
        'price_per_sms': price_per_sms,
        'total_cost': total_cost
    }

# 批次計算
recipients = 1000
message = "親愛的客戶,您的訂單已出貨!"

cost = calculate_cost(message)
total = cost['total_cost'] * recipients

print(f"單則簡訊:{cost['parts']} 則 × NT${cost['price_per_sms']} = NT${cost['total_cost']}")
print(f"發送 {recipients} 人:NT${total}")

Q5:如何避免簡訊被當作垃圾訊息?

答案:

1. 使用 Opt-in(明確同意):

def subscribe_sms(user):
    """使用者訂閱簡訊"""
    # 發送確認簡訊
    send_sms(user.phone, """
歡迎訂閱!
您將收到我們的最新優惠資訊。
如需取消,請回覆 STOP
    """)

    # 記錄同意
    user.sms_opt_in = True
    user.sms_opt_in_date = datetime.now()
    user.save()

2. 提供取消訂閱機制:

def on_receive_sms(pdu):
    """處理接收到的簡訊"""
    sender = pdu.source_addr.decode()
    message = pdu.short_message.decode().upper()

    if message in ['STOP', 'UNSUBSCRIBE', '取消']:
        # 取消訂閱
        user = User.objects.get(phone=sender)
        user.sms_opt_in = False
        user.save()

        # 發送確認
        send_sms(sender, "您已成功取消訂閱。")

3. 避免垃圾訊息特徵:

# ❌ 不好的做法
message = "!!!免費贈送!!! 立即點擊 http://bit.ly/xxx"

# ✅ 好的做法
message = """
親愛的會員,
您有一張 100 元折價券即將到期。
有效期限:2025/01/31
詳情:https://example.com/coupon/abc123
"""

4. 限制發送頻率:

from datetime import datetime, timedelta

def can_send_sms(user):
    """檢查是否可以發送(避免騷擾)"""
    # 最多每天 3 則
    today_count = SMSLog.objects.filter(
        phone=user.phone,
        sent_at__gte=datetime.now().date()
    ).count()

    if today_count >= 3:
        return False

    # 最少間隔 1 小時
    last_sms = SMSLog.objects.filter(
        phone=user.phone
    ).order_by('-sent_at').first()

    if last_sms and (datetime.now() - last_sms.sent_at) < timedelta(hours=1):
        return False

    return True

5. 使用 Sender ID:

設定可識別的發送者 ID(需電信商核准)

發送者:「MyCompany」或「客服中心」
而不是:「+8869XXXXXXXX」

增加可信度,降低被誤認為垃圾訊息的機率

📝 總結

SMS/SMPP 協定核心要點:

  • SMS:電信網路的文字簡訊服務 📲

    • 不需網路,只需訊號
    • 送達率高(接近 100%)
    • 長度限制(160 字元/70 中文)
  • SMPP:應用程式與 SMSC 的通訊協定 🔗

    • 長連線(保持連線)
    • 雙向通訊(收發簡訊)
    • 即時送達報告

使用場景:

雙因素驗證(2FA)🔐
交易通知 💳
緊急警報 🚨
行銷簡訊 📢

記憶口訣:「簡可靠短,雙即收報」

  • SMS:簡(簡單)、可(可靠)、短(長度限制)
  • SMPP:雙(雙向)、即(即時)、收(收發)、報(送達報告)

🔗 延伸閱讀


🎉 教學系列完成!

恭喜你完成了「網路協定完整指南」的所有 27 篇文章!

你已經學會了:

  • ✅ 網路基礎(OSI、TCP/IP)
  • ✅ HTTP/HTTPS
  • ✅ WebSocket
  • ✅ 即時通訊(MQTT、XMPP、WebRTC)
  • ✅ SIP 視訊通話
  • ✅ DNS、Email、FTP、SMS

現在你已經具備扎實的網路協定知識,足以應對技術面試和實際開發!💪

0%