> **用于发送 Kakao 通知消息、品牌消息与短信的官方 Rails 集成包**

`sendgo-rails` 通过 Railtie 封装 [`sendgo`](https://github.com/send-go/ruby) 核心 gem：新增 `config.sendgo` 命名空间、支持环境变量回退，并以 `Sendgo::Rails.client` 暴露一个记忆化的客户端。

---

## 安装

```ruby
# Gemfile
gem "sendgo-rails"
```

```bash
bundle install
bin/rails g sendgo:install
```

生成器会写出 `config/initializers/sendgo.rb`。核心 `sendgo` gem 会作为依赖一并安装。

要求 Ruby 3.1+ 与 Rails 6.1 或更高版本（`railties >= 6.1`）。

---

## 配置

```ruby
# config/initializers/sendgo.rb
Rails.application.config.sendgo.tap do |config|
  config.access_key       = ENV["SENDGO_ACCESS_KEY"]
  config.secret_key       = ENV["SENDGO_SECRET_KEY"]
  config.kakao_sender_key = ENV["SENDGO_KAKAO_SENDER_KEY"]
  config.sms_sender_key   = ENV["SENDGO_SMS_SENDER_KEY"]
  config.api_version      = ENV.fetch("SENDGO_API_VERSION", "v2")
  config.url              = ENV.fetch("SENDGO_URL", "https://sendgo.io")
end
```

每一项配置都按两步解析：先看 `config.sendgo.<key>`，再回退到同名环境变量。因此在 initializer 里把某个值留为 `nil` 与完全不设置是等价的 —— 环境变量会生效。这也意味着你可以完全不要 initializer，只用环境变量来配置本 gem。

如果更倾向使用 Rails credentials：

```ruby
config.access_key = Rails.application.credentials.dig(:sendgo, :access_key)
```

`api_version` 在 Rails 集成中默认为 **`v2`**（纯 Ruby 核心默认为 `v1`）。

---

## 快速上手

```ruby
Sendgo::Rails.client.alimtalk.send(
  template_code: "ORDER_CONFIRM_001",
  contacts: [{ contact: "01012345678", var1: "ORD-001" }]
)
```

`Sendgo::Rails.client` 是**记忆化且惰性**的 —— 直到首次调用才会构建，因此仅仅 require 本 gem 不会在启动期读取配置。核心 gem 的各渠道都可用：

| 方法 | 渠道 |
|------|------|
| `client.alimtalk` | Kakao 通知消息 |
| `client.friendtalk` | Kakao 好友消息 |
| `client.brand_message` | Kakao 品牌消息（仅 v2） |
| `client.sms` | SMS / LMS / MMS |

由于客户端被记忆化，运行期修改 `config.sendgo` 在调用 `Sendgo::Rails.reset!` 之前不会生效。

---

## 封装成自己的类

在控制器里到处调用 `Sendgo::Rails.client` 会把模板代码散落各处。一个薄薄的对象能把它们集中起来，也给测试提供了可替换的接缝：

```ruby
# app/services/order_notifier.rb
class OrderNotifier
  def initialize(client: Sendgo::Rails.client)
    @client = client
  end

  def confirmed(order)
    @client.alimtalk.send(
      template_code: "ORDER_CONFIRM_001",
      contacts: [{ contact: order.phone, var1: order.number }]
    )
  end
end
```

---

## 品牌消息

> **好友消息已于 2025-12-31 停止服务。** 自 2026-01-01 起，Kakao 会将好友消息请求
> 自动改以品牌消息（自由形式）发出。`friendtalk` 仍可调用，且自由文本类型
> `FT`/`FI`/`FW` 发往单个接收者的路径目前仍只有这一条 —— 基于模板的富文本类型
> （`FL`/`FC`/`FM`/`FP`/`FA`）、非好友定向（`N`/`I`）与群发（`F`）请使用品牌消息。
品牌消息是好友消息的后继渠道：可触达**非频道好友**的接收者（`targeting: "N"`），也可向**已同意接收的全部频道好友群发**（`targeting: "F"`）。消息类型与好友消息一一对应（`FT`→`BT`、`FI`→`BI`、`FW`→`BW`、`FL`→`BL`、`FC`→`BC`、`FM`→`BM`、`FP`→`BP`、`FA`→`BA`）—— 传入好友消息的代码，服务端会自动转换。

> 仅 v2 支持，也是 Rails 集成的默认版本。

```ruby
client = Sendgo::Rails.client

# 单条发送 —— targeting 为 M/N/I 时必须提供 contacts
client.brand_message.send(
  targeting: "M",
  message_type: "FL",
  friend_template_uuid: "9cd5460b-6458-4edc-9b11-c26d3013c340",
  contacts: [{ contact: "01012345678", var1: "29,000元" }]
)

# 群发 —— 已同意接收的全部频道好友（不传接收者列表）
result = client.brand_message.broadcast(
  message_type: "FW",
  friend_template_uuid: "9cd5460b-6458-4edc-9b11-c26d3013c340"
)

# 群发在上游为异步处理，需轮询查看进度
client.brand_message.campaign(result.dig("data", "campaignId"))
client.brand_message.campaigns(from: "2026-08-01", count: 10)
```

`friend_template_uuid` 是必填的关键字参数；`message_type` 默认为 `"FT"`，`targeting` 默认为 `"M"`。`broadcast` 等同于把 `targeting` 固定为 `"F"` 的 `send`，两者接受相同的参数。

群发可能触达整个好友列表，请放在受权限保护的管理操作或 rake 任务里，不要暴露为公开路由。

`targeting` 的取值含义：

| 值 | 含义 |
|----|------|
| `M` | 频道好友中的指定接收者 |
| `N` | 非频道好友的接收者 |
| `I` | 按标识符指定的接收者 |
| `F` | 已同意接收的全部频道好友（群发） |

---

## 从模型回调中发送

请在事务**提交之后**发送 —— 在事务内触发的回调，即使记录随后因校验失败被回滚，消息也已经发出去了：

```ruby
class Order < ApplicationRecord
  after_commit :notify_customer, on: :create

  private

  def notify_customer
    OrderConfirmJob.perform_later(id)
  end
end
```

---

## ActiveJob

发送是一次网络调用，请不要放在请求周期里：

```ruby
# app/jobs/order_confirm_job.rb
class OrderConfirmJob < ApplicationJob
  queue_as :notifications

  # 5xx 为临时故障；4xx 重试也不会成功。
  # `:polynomially_longer` 需要 Rails 7.1+；6.1–7.0 请用 `:exponentially_longer`。
  retry_on Sendgo::SendgoError, wait: :polynomially_longer, attempts: 3 do |job, error|
    raise error if error.status_code >= 500

    Rails.logger.error("Sendgo #{error.status_code} [#{error.error_code}]: #{error.message}")
  end

  def perform(order_id)
    order = Order.find(order_id)

    Sendgo::Rails.client.alimtalk.send(
      template_code: "ORDER_CONFIRM_001",
      contacts: [{ contact: order.phone, var1: order.number }]
    )
  end
end
```

带块的 `retry_on` 会在**重试次数耗尽之后**执行该块。若想遇到 4xx 立即停止，请在 `perform` 内 rescue 并改用 `discard_on`：

```ruby
discard_on Sendgo::SendgoError do |_job, error|
  Rails.logger.error("已丢弃：Sendgo #{error.error_code}")
end
```

---

## Rake 任务

```ruby
# lib/tasks/promo.rake
namespace :promo do
  desc "向已同意接收的全部频道好友群发当前促销"
  task broadcast: :environment do
    result = Sendgo::Rails.client.brand_message.broadcast(
      message_type: "FW",
      friend_template_uuid: "9cd5460b-6458-4edc-9b11-c26d3013c340"
    )

    puts "已接受：#{result.dig('data', 'campaignId')}"
  end
end
```

---

## 测试

`reset!` 会丢弃已记忆化的客户端，这正是让打桩生效的关键：

```ruby
# spec/support/sendgo.rb
RSpec.configure do |config|
  config.before do
    Sendgo::Rails.reset!
    allow(Sendgo::Rails).to receive(:client).and_return(fake_sendgo)
  end
end

def fake_sendgo
  @fake_sendgo ||= instance_double(
    Sendgo::Client,
    alimtalk: instance_double(Sendgo::AlimtalkService, send: { "message" => "Success" }),
    brand_message: instance_double(Sendgo::BrandMessageService, send: { "message" => "Success" })
  )
end
```

```ruby
it "创建订单时发送通知消息" do
  expect(fake_sendgo.alimtalk).to receive(:send).with(
    hash_including(template_code: "ORDER_CONFIRM_001")
  )

  Order.create!(phone: "01012345678", number: "ORD-001")
end
```

不调用 `reset!` 的话，前一个用例记忆化的客户端会泄漏到下一个用例。

---

## 错误处理

```ruby
begin
  Sendgo::Rails.client.alimtalk.send(
    template_code: "ORDER_CONFIRM_001",
    contacts: [{ contact: "01012345678" }]
  )
rescue Sendgo::SendgoError => e
  case e.error_code
  when "INVALID_ACCESS_KEY", "INVALID_SECRET_KEY"
    Rails.logger.error("请检查 Sendgo 密钥")
  when "PAYMENT_REQUIRED"
    Rails.logger.error("Sendgo 余额不足")
  when "IP_NOT_ALLOWED"
    Rails.logger.error("该 IP 未在白名单中")
  else
    raise if e.status_code >= 500   # 临时故障，交给任务重试

    Rails.logger.error("Sendgo #{e.status_code}: #{e.message}")
  end
end
```

`Sendgo::SendgoError` 提供 `status_code`、`error_code`、`endpoint`、`api_version`、`response_body`。
请根据 `error_code` 分支，而不要匹配错误消息文本 —— 消息文案可能变更，错误代码才是契约。
`TOKEN_EXPIRED` 与 `TOKEN_MISMATCH` 已在核心 gem 内部处理：会重新签发令牌并自动重试该请求一次。

---

## 配置项

| `config.sendgo` 键 | 环境变量 | 是否必填 | 默认值 | 说明 |
|--------------------|----------|----------|--------|------|
| `access_key` | `SENDGO_ACCESS_KEY` | **必填** | — | Sendgo 访问密钥 |
| `secret_key` | `SENDGO_SECRET_KEY` | **必填** | — | Sendgo 私密密钥 |
| `kakao_sender_key` | `SENDGO_KAKAO_SENDER_KEY` | 选填 | `nil` | Kakao 发送者资料密钥 |
| `sms_sender_key` | `SENDGO_SMS_SENDER_KEY` | 选填 | `nil` | 短信主叫号码密钥 |
| `api_version` | `SENDGO_API_VERSION` | 选填 | `"v2"` | API 版本（`v1` \| `v2`） |
| `url` | `SENDGO_URL` | 选填 | `"https://sendgo.io"` | API 基础地址 |

---

## 短链接

短链接会缩短消息正文中的链接，并统计该链接是否被真正点击。
短信按字节计费，因此链接更短就意味着正文可以写更多内容。

> 仅 v2 支持。

再次缩短同一个原始 URL 会**直接返回已有的链接**。若希望按活动分别统计反应，
请使用 `forceNew` 生成新的短码。

`deactivate` 不会删除链接，只停止重定向。当已发送消息中的链接必须失效时使用它；
累计统计会保留，访问已停止的链接会返回 `410 Gone`。

```ruby
created = Sendgo::Rails.client.short_url.create(
  target_url: promotion_url(@promotion),
  title: @promotion.name
)

code = created.dig("data", "code")
stats = Sendgo::Rails.client.short_url.stats(code)
```

`stats` 返回按日趋势（`daily`）以及按设备（`byDevice`）、来源（`byReferer`）、国家（`byCountry`）的分解。按日趋势读取的是预聚合表，因此点击量再大响应时间也保持稳定。

---

## 包信息

- **包名**：`sendgo-rails`（RubyGems）
- **仓库**：[send-go/rails](https://github.com/send-go/rails)
- **注册表**：https://rubygems.org/gems/sendgo-rails
- **许可**：MIT

### 如何获取 API 密钥

登录 Sendgo 后，在 **API/SDK → API 对接** 菜单中签发访问密钥与私密密钥。
使用通知消息／好友消息需在 **Kakao 渠道** 登记发送者资料以获取 `kakao_sender_key`；使用短信需在 **发送号码** 菜单登记主叫号码。