> **用于发送 Kakao 通知消息、品牌消息与短信的官方 Laravel 扩展包**

`sendgo/laravel` 在 [`sendgo/php`](https://github.com/send-go/php) 核心之上提供 Laravel 集成：服务提供者自动发现、可发布的配置文件，以及 `Sendgo` 门面。

要求 PHP 8.2+ 与 Laravel 10/11。

---

## 安装

```bash
composer require sendgo/laravel
```

服务提供者会被自动发现，无需手动注册。

### 发布配置文件

```bash
php artisan vendor:publish --tag=sendgo-config
```

---

## 配置

```env
# .env
SENDGO_ACCESS_KEY=your_access_key
SENDGO_SECRET_KEY=your_secret_key
SENDGO_KAKAO_SENDER_KEY=your_kakao_sender_key
SENDGO_SMS_SENDER_KEY=your_sms_sender_key
SENDGO_API_VERSION=v2
```

```php
<?php
// config/sendgo.php
return [
    'access_key'       => env('SENDGO_ACCESS_KEY'),
    'secret_key'       => env('SENDGO_SECRET_KEY'),
    'kakao_sender_key' => env('SENDGO_KAKAO_SENDER_KEY'),
    'sms_sender_key'   => env('SENDGO_SMS_SENDER_KEY'),
    'api_version'      => env('SENDGO_API_VERSION', 'v2'),
    'url'              => env('SENDGO_URL', 'https://sendgo.io'),
];
```

配置缓存过（`php artisan config:cache`）之后修改 `.env` 不会生效 —— 部署时请重新执行一次。

---

## 快速上手

```php
<?php

use Sendgo\Laravel\Facades\Sendgo;

Sendgo::alimtalk()->send([
    'templateCode' => 'ORDER_CONFIRM_001',
    'contacts'     => [
        ['contact' => '01012345678', 'name' => '洪吉童', 'var1' => 'ORD-001'],
    ],
]);
```

各发送渠道都是门面上的方法：

| 方法 | 渠道 |
|------|------|
| `Sendgo::alimtalk()` | Kakao 通知消息 |
| `Sendgo::friendtalk()` | Kakao 好友消息 |
| `Sendgo::brandMessage()` | Kakao 品牌消息（仅 v2） |
| `Sendgo::sms()` | SMS / LMS / MMS |

也可以直接注入核心客户端，这在依赖关系需要显式声明时更清晰：

```php
<?php

use Sendgo\Php\Sendgo;

class OrderNotifier
{
    public function __construct(private Sendgo $sendgo) {}

    public function confirmed(string $phone, string $orderNo): void
    {
        // 注入客户端时，各渠道是属性而不是方法。
        $this->sendgo->alimtalk->send([
            'templateCode' => 'ORDER_CONFIRM_001',
            'contacts'     => [['contact' => $phone, 'var1' => $orderNo]],
        ]);
    }
}
```

> 门面写作 `Sendgo::alimtalk()`（方法），注入的客户端写作 `$sendgo->alimtalk`（属性）。核心类同时支持两种写法，因此两边都可用。

---

## 通知消息

```php
<?php

// 批量发送
Sendgo::alimtalk()->send([
    'templateCode' => 'ORDER_CONFIRM_001',
    'contacts'     => [
        ['contact' => '01011111111', 'var1' => 'ORD-001', 'var2' => '29,000元'],
        ['contact' => '01022222222', 'var1' => 'ORD-002', 'var2' => '15,000元'],
    ],
]);

// 预约发送
Sendgo::alimtalk()->send([
    'templateCode' => 'PROMO_SUMMER_2026',
    'scheduleType' => 'SCHEDULED',
    'at'           => '2026-07-28 09:00:00',
    'contacts'     => [['contact' => '01012345678', 'var1' => '夏季限定 5 折']],
]);

// 失败时自动回退为短信
Sendgo::alimtalk()->send([
    'templateCode' => 'DELIVERY_START_001',
    'replaceSms'   => 'Y',
    'smsSubject'   => '[发货通知]',
    'smsContent'   => '您购买的商品已出库。',
    'contacts'     => [['contact' => '01012345678', 'var1' => 'ORD-001']],
]);
```

---

## 好友消息

> ⚠️ **已停用 —— 按 Kakao 政策，好友消息已于 2025-12-31 停止服务。**
> 自 2026-01-01 起，好友消息的发送请求由 Kakao 侧自动改以**品牌消息（自由形式）**发出。
> 调用仍会成功；而且自由文本类型（`FT`/`FI`/`FW`）发往单个接收者的路径目前仍只有这一条，
> 因此现有代码无需立即改动。
>
> 以下情形请改用**品牌消息**：
> - 基于模板的富文本类型（`FL`/`FC`/`FM`/`FP`/`FA`）
> - **非**频道好友的接收者（`targeting` = `N` / `I`）
> - 向已同意接收的全部频道好友群发（`targeting` = `F`）
>
> 消息类型一一对应，转换由服务端处理 —— `FT`→`BT`、`FI`→`BI`、`FW`→`BW`、`FL`→`BL`、
> `FC`→`BC`、`FM`→`BM`、`FP`→`BP`、`FA`→`BA`。
```php
<?php

Sendgo::friendtalk()->send([
    'content'  => '7 月限时特惠开始了，欢迎查看。',
    'contacts' => [['contact' => '01012345678']],
]);
```

---

## 品牌消息

品牌消息是好友消息的后继渠道：可触达**非频道好友**的接收者（`'targeting' => 'N'`），也可向**已同意接收的全部频道好友群发**（`'targeting' => 'F'`）。消息类型与好友消息一一对应（`FT`→`BT`、`FI`→`BI`、`FW`→`BW`、`FL`→`BL`、`FC`→`BC`、`FM`→`BM`、`FP`→`BP`、`FA`→`BA`）—— 传入好友消息的代码，服务端会自动转换。

> 仅 v2 支持。请设置 `SENDGO_API_VERSION=v2`。

```php
<?php

// 单条发送 —— targeting 为 M/N/I 时必须提供 contacts
Sendgo::brandMessage()->send([
    'targeting'          => 'M',
    'messageType'        => 'FL',
    'friendTemplateUuid' => '9cd5460b-6458-4edc-9b11-c26d3013c340',
    'contacts'           => [['contact' => '01012345678', 'var1' => '29,000元']],
]);

// 群发 —— 已同意接收的全部频道好友（不传 contacts）
$result = Sendgo::brandMessage()->broadcast([
    'messageType'        => 'FW',
    'friendTemplateUuid' => '9cd5460b-6458-4edc-9b11-c26d3013c340',
]);

// 群发在上游为异步处理，需轮询查看进度
Sendgo::brandMessage()->campaign($result['data']['campaignId']);
Sendgo::brandMessage()->campaigns(['from' => '2026-08-01', 'count' => 10]);
```

群发可能触达整个好友列表，请务必从明确的管理操作触发（Artisan 命令或受权限保护的路由），不要放在公开路由上。

---

## 短信 / 长短信 / 多媒体短信

```php
<?php

Sendgo::sms()->sendSms([
    'content'  => '[Sendgo] 验证码：123456（请在 5 分钟内输入）',
    'contacts' => [['contact' => '01012345678']],
]);

Sendgo::sms()->sendLms([
    'subject'  => '[重要] 服务维护通知',
    'content'  => '服务将于 2026-07-25 02:00 ~ 06:00 进行维护。',
    'contacts' => [['contact' => '01012345678']],
]);

Sendgo::sms()->sendMms([
    'subject'  => '[活动] 7 月特惠',
    'content'  => '欢迎查看本月特价商品。',
    'contacts' => [['contact' => '01012345678']],
]);
```

---

## 队列发送

发送是一次外发 HTTP 调用，请不要放在请求生命周期里：

```php
<?php
// app/Jobs/SendOrderConfirm.php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Sendgo\Php\Exception\SendgoException;
use Sendgo\Php\Sendgo;

class SendOrderConfirm implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue;

    public int $tries = 3;

    public function __construct(
        private string $phone,
        private string $orderNo,
    ) {}

    public function handle(Sendgo $sendgo): void
    {
        try {
            $sendgo->alimtalk->send([
                'templateCode' => 'ORDER_CONFIRM_001',
                'contacts'     => [['contact' => $this->phone, 'var1' => $this->orderNo]],
            ]);
        } catch (SendgoException $e) {
            // 4xx 重试也不会成功，直接标记失败并记录原因。
            if ($e->getStatusCode() < 500) {
                $this->fail($e);

                return;
            }

            throw $e;   // 5xx 为临时故障，交给队列重试
        }
    }

    /** 5xx 重试的退避间隔（秒） */
    public function backoff(): array
    {
        return [10, 60, 300];
    }
}
```

对 4xx 调用 `fail()` 是关键：否则一个错误的模板代码会耗尽全部重试次数，看起来像临时故障。

### 事务提交后再发送

在事务内派发任务，可能出现事务回滚了但消息已经发出去的情况：

```php
<?php

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($order) {
    $order->save();

    // 事务提交后才入队
    DB::afterCommit(fn () => SendOrderConfirm::dispatch($order->phone, $order->number));
});
```

如果队列连接配置了 `after_commit => true`，则所有任务默认就是提交后派发。

---

## 异常处理

```php
<?php

use Sendgo\Php\Exception\SendgoException;

try {
    Sendgo::alimtalk()->send([...]);
} catch (SendgoException $e) {
    Log::error('Sendgo 发送失败', [
        'status'     => $e->getStatusCode(),
        'error_code' => $e->getErrorCode(),
        'endpoint'   => $e->getEndpoint(),
    ]);

    match ($e->getErrorCode()) {
        'INVALID_ACCESS_KEY',
        'INVALID_SECRET_KEY'    => report(new \RuntimeException('请检查 Sendgo 密钥')),
        'IP_NOT_ALLOWED'        => report(new \RuntimeException('该 IP 未在白名单中')),
        'PAYMENT_REQUIRED'      => report(new \RuntimeException('Sendgo 余额不足')),
        'INVALID_TEMPLATE_CODE' => Log::warning('模板不存在'),
        default                 => null,
    };
}
```

请根据 `getErrorCode()` 分支，而不要匹配错误消息文本 —— 消息文案可能变更，错误代码才是契约。
`TOKEN_EXPIRED` 与 `TOKEN_MISMATCH` 已在核心 SDK 内部处理：会重新签发令牌并自动重试该请求一次。

---

## 测试

门面自带替身支持：

```php
<?php

use Sendgo\Laravel\Facades\Sendgo;

public function test_order_creation_sends_alimtalk(): void
{
    // 也可以直接把核心客户端换成 mock 绑定到容器：
    $this->instance(\Sendgo\Php\Sendgo::class, $fake = \Mockery::mock(\Sendgo\Php\Sendgo::class));

    $fake->shouldReceive('alimtalk->send')->once();

    $this->post('/orders', ['phone' => '01012345678'])->assertCreated();
}
```

使用队列时，`Queue::fake()` 之后断言 `SendOrderConfirm::class` 已被派发会更简单，也不必碰 SDK。

---

## 配置项

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

---

## 短链接

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

> 仅 v2 支持。

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

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

```php
use Sendgo\Laravel\Facades\Sendgo;

$short = Sendgo::shortUrl()->create([
    'targetUrl' => 'https://example.com/promotions/summer-sale',
    'title'     => '夏季促销落地页',
]);

$link = $short['data']['shortUrl'];
$code = $short['data']['code'];

$stats = Sendgo::shortUrl()->stats($code, ['from' => '2026-08-01']);
Sendgo::shortUrl()->deactivate($code);
```

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

---

## 包信息

- **包名**：`sendgo/laravel`（Packagist）
- **仓库**：[send-go/laravel](https://github.com/send-go/laravel)
- **注册表**：https://packagist.org/packages/sendgo/laravel
- **许可**：MIT

### 如何获取 API 密钥

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