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

`sendgo/symfony` 把 [`sendgo/php`](https://github.com/send-go/php) 核心封装为 Symfony bundle：将 `Sendgo\Php\Sendgo` 注册进容器、在编译期校验配置，并支持自动装配。

要求 PHP 8.2+ 与 Symfony 6.4 或 7.x。

---

## 安装

```bash
composer require sendgo/symfony
```

### 注册 bundle

使用 [Symfony Flex](https://symfony.com/doc/current/setup/flex.html) 时会自动注册。未使用 Flex 则手动添加：

```php
<?php
// config/bundles.php

return [
    // ...
    Sendgo\Symfony\SendgoBundle::class => ['all' => true],
];
```

---

## 配置

```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
```

```yaml
# config/packages/sendgo.yaml
sendgo:
    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:      'v2'
```

`access_key` 与 `secret_key` 声明为 `isRequired()->cannotBeEmpty()`，缺失时**容器编译**就会失败 —— 问题在缓存预热阶段暴露，而不是等到第一次发送。

`api_version` 在本 bundle 中默认为 **`v2`**（纯 PHP 核心默认为 `v1`）。

---

## 注入客户端

bundle 以类全名注册服务，因此构造函数自动装配即可，无需写 `services.yaml`：

```php
<?php
// src/Controller/OrderController.php

namespace App\Controller;

use App\Entity\Order;
use Sendgo\Php\Sendgo;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

class OrderController extends AbstractController
{
    public function __construct(private Sendgo $sendgo) {}

    #[Route('/orders/{id}/confirm', methods: ['POST'])]
    public function confirm(Order $order): JsonResponse
    {
        $this->sendgo->alimtalk->send([
            'templateCode' => 'ORDER_CONFIRM_001',
            'contacts'     => [[
                'contact' => $order->getUser()->getPhone(),
                'var1'    => $order->getNumber(),
            ]],
        ]);

        return $this->json(['success' => true]);
    }
}
```

各发送渠道都是客户端上的**属性**：

| 属性 | 渠道 |
|------|------|
| `$sendgo->alimtalk` | Kakao 通知消息 |
| `$sendgo->friendtalk` | Kakao 好友消息 |
| `$sendgo->brandMessage` | Kakao 品牌消息（仅 v2） |
| `$sendgo->sms` | SMS / LMS / MMS |

同时注册了公开别名 `sendgo`，因此在无法自动装配的位置（遗留服务、手工装配的命令脚本）可以用 `$container->get('sendgo')`。

---

## 品牌消息

> **好友消息已于 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 支持，也是本 bundle 的默认版本。

```php
<?php
// src/Service/CampaignService.php

namespace App\Service;

use Sendgo\Php\Sendgo;

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

    // 单条发送 —— targeting 为 M/N/I 时必须提供 contacts
    public function promote(string $phone): array
    {
        return $this->sendgo->brandMessage->send([
            'targeting'          => 'M',
            'messageType'        => 'FL',
            'friendTemplateUuid' => '9cd5460b-6458-4edc-9b11-c26d3013c340',
            'contacts'           => [['contact' => $phone, 'var1' => '29,000元']],
        ]);
    }

    // 群发 —— 已同意接收的全部频道好友（不传接收者列表）
    public function announce(): array
    {
        return $this->sendgo->brandMessage->broadcast([
            'messageType'        => 'FW',
            'friendTemplateUuid' => '9cd5460b-6458-4edc-9b11-c26d3013c340',
        ]);
    }

    // 群发在上游为异步处理，需轮询查看进度
    public function status(string $campaignId): array
    {
        return $this->sendgo->brandMessage->campaign($campaignId);
    }

    public function recent(): array
    {
        return $this->sendgo->brandMessage->campaigns(['count' => 10]);
    }
}
```

`broadcast()` 等同于把 `targeting` 固定为 `F` 的 `send()`，两者接受相同的参数。

`targeting` 的取值含义：

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

---

## 用 Messenger 异步发送

发送是一次外发 HTTP 调用。交给 [Messenger](https://symfony.com/doc/current/messenger.html) 处理，上游变慢时就不会一直占着 Web 请求：

```php
<?php
// src/Message/SendAlimtalk.php

namespace App\Message;

class SendAlimtalk
{
    /** @param array<int, array<string, string>> $contacts */
    public function __construct(
        public readonly string $templateCode,
        public readonly array $contacts,
    ) {}
}
```

```php
<?php
// src/MessageHandler/SendAlimtalkHandler.php

namespace App\MessageHandler;

use App\Message\SendAlimtalk;
use Psr\Log\LoggerInterface;
use Sendgo\Php\Exception\SendgoException;
use Sendgo\Php\Sendgo;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;

#[AsMessageHandler]
class SendAlimtalkHandler
{
    public function __construct(
        private Sendgo $sendgo,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(SendAlimtalk $message): void
    {
        try {
            $this->sendgo->alimtalk->send([
                'templateCode' => $message->templateCode,
                'contacts'     => $message->contacts,
            ]);
        } catch (SendgoException $e) {
            if ($e->getStatusCode() < 500) {
                // 4xx 重试也不会成功 —— 直接停止，别耗尽重试次数。
                $this->logger->error('Sendgo {code}: {message}', [
                    'code'    => $e->getErrorCode(),
                    'message' => $e->getMessage(),
                ]);

                throw new UnrecoverableMessageHandlingException($e->getMessage(), previous: $e);
            }

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

对 4xx 抛出 `UnrecoverableMessageHandlingException` 是关键：否则一个错误的模板代码会被反复重试直到传输层放弃，看起来像临时故障。

```php
// 派发示例
$bus->dispatch(new SendAlimtalk('ORDER_CONFIRM_001', [
    ['contact' => '01012345678', 'var1' => 'ORD-001'],
]));
```

### 在 Doctrine flush 之后发送

在事务内派发，可能出现事务回滚了但消息已经发出去的情况。请配置 [Doctrine 事务中间件](https://symfony.com/doc/current/messenger.html#middleware)，或从 `postFlush` 监听器派发，而不要写在实体的生命周期回调里。

---

## 异常处理

```php
<?php

use Sendgo\Php\Exception\SendgoException;

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

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

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

---

## 测试

服务是 public 的，因此可以在测试容器中替换：

```php
<?php
// tests/Controller/OrderControllerTest.php

namespace App\Tests\Controller;

use Sendgo\Php\AlimtalkService;
use Sendgo\Php\Sendgo;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class OrderControllerTest extends WebTestCase
{
    public function testConfirmSendsAlimtalk(): void
    {
        $client = static::createClient();

        $alimtalk = $this->createMock(AlimtalkService::class);
        $alimtalk->expects($this->once())->method('send');

        // 各渠道是 `readonly` 属性。反射只能在其仍未初始化时赋值 ——
        // 而 PHPUnit 的替身正处于这种状态，因为它从不执行真正的构造函数。
        // 对已构造完成的真实客户端调用 setValue() 会抛出
        // "Cannot modify readonly property"。
        $sendgo = $this->createMock(Sendgo::class);
        (new \ReflectionProperty(Sendgo::class, 'alimtalk'))->setValue($sendgo, $alimtalk);

        static::getContainer()->set(Sendgo::class, $sendgo);

        $client->request('POST', '/orders/1/confirm');

        $this->assertResponseIsSuccessful();
    }
}
```

大多数测试套件更简单的做法是在客户端前面放一层自己的接口 —— mock `NotificationSenderInterface`，让 `Sendgo` 完全不出现在测试里。只有当你确实要断言 SDK 调用本身时才需要反射。

---

## 配置项

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

---

## 常见问题

**与 `sendgo/php` 有什么区别？**
`sendgo/php` 是不依赖框架的核心包。`sendgo/symfony` 在其之上增加 bundle 注册、配置校验树、容器注册与自动装配。两者发出的请求完全相同。

**同时支持 Symfony 6.4 和 7 吗？**
是。`symfony/config`、`symfony/dependency-injection`、`symfony/http-kernel` 的约束都是 `^6.4|^7.0`。

**可以有多个客户端（多租户）吗？**
bundle 只注册一个服务。若需按租户使用不同密钥，请自行注册一个工厂服务来构造 `Sendgo\Php\Sendgo`，并注入该工厂。

---

## 短链接

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

> 仅 v2 支持。

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

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

```php
// 通过自动装配的 Sendgo\Php\Sendgo 以属性方式访问。
$short = $this->sendgo->shortUrl->create([
    'targetUrl' => 'https://example.com/promotions/summer-sale',
    'title'     => '夏季促销落地页',
]);

$code = $short['data']['code'];
$stats = $this->sendgo->shortUrl->stats($code);
```

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

---

## 包信息

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

### 如何获取 API 密钥

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