> **用于发送 Kakao 通知消息、品牌消息与短信的官方 Spring Boot Starter**

`io.sendgo:sendgo-spring` 把 [`io.sendgo:sendgo-java`](https://github.com/send-go/java) 核心封装为 Spring Boot Starter：设置 `sendgo.access-key` 即可自动装配出一个 `SendgoClient` Bean。

要求 Spring Boot 3.x 与 Java 17+。

---

## 安装

### Gradle

```groovy
implementation "io.sendgo:sendgo-spring:1.0.1"
```

### Maven

```xml
<dependency>
    <groupId>io.sendgo</groupId>
    <artifactId>sendgo-spring</artifactId>
    <version>1.0.1</version>
</dependency>
```

核心 `sendgo-java` 会作为传递依赖一并引入。

---

## 配置

```yaml
# application.yml
sendgo:
  access-key: ${SENDGO_ACCESS_KEY}
  secret-key: ${SENDGO_SECRET_KEY}
  kakao-sender-key: ${SENDGO_KAKAO_SENDER_KEY}
  sms-sender-key: ${SENDGO_SMS_SENDER_KEY}
  api-version: v2
```

或使用 properties 形式：

```properties
sendgo.access-key=${SENDGO_ACCESS_KEY}
sendgo.secret-key=${SENDGO_SECRET_KEY}
sendgo.kakao-sender-key=${SENDGO_KAKAO_SENDER_KEY}
sendgo.api-version=v2
```

Starter 附带配置元数据，因此 IDE 能对这些键做自动补全。
自动装配以 `sendgo.access-key` 存在为条件 —— 未设置时应用仍能正常启动，这让不发送消息的本地 profile 不受影响。

Starter 与纯 `sendgo-java` 核心的 `api-version` 默认值**都是 `v2`**，因此只有需要固定使用 v1 的旧对接才要显式指定。

---

## 注入客户端

```java
import io.sendgo.SendgoClient;
import io.sendgo.model.AlimtalkRequest;
import io.sendgo.model.Contact;
import org.springframework.stereotype.Service;

@Service
public class OrderNotifier {

    private final SendgoClient sendgo;

    public OrderNotifier(SendgoClient sendgo) {
        this.sendgo = sendgo;
    }

    public void confirmed(String phone, String orderNo) {
        sendgo.alimtalk().send(AlimtalkRequest.builder()
                .templateCode("ORDER_CONFIRM_001")
                .contact(Contact.builder().contact(phone).var1(orderNo).build())
                .build());
    }
}
```

各发送渠道都是该 Bean 上的方法：`alimtalk()`、`friendtalk()`、`brandMessage()`、`sms()`。

`contact(...)` 每次调用都会追加一位接收者；也可用 `contacts(List.of(...))` 一次性指定全部。

### 覆盖该 Bean

自动装配的 Bean 带 `@ConditionalOnMissingBean`，因此自行声明的 Bean 会优先生效 —— 适用于多租户或按环境使用不同基础地址：

```java
@Configuration
public class SendgoConfiguration {

    @Bean
    public SendgoClient sendgoClient(TenantContext tenants) {
        return new SendgoClient(io.sendgo.SendgoConfig.builder()
                .accessKey(tenants.current().sendgoAccessKey())
                .secretKey(tenants.current().sendgoSecretKey())
                .apiVersion("v2")
                .build());
    }
}
```

---

## 品牌消息

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

```java
import io.sendgo.model.BrandMessageRequest;
import java.util.Map;

@Service
public class CampaignService {

    private final SendgoClient sendgo;

    public CampaignService(SendgoClient sendgo) {
        this.sendgo = sendgo;
    }

    // 单条发送 —— targeting 为 M/N/I 时必须提供接收者
    public void promote(String phone) {
        sendgo.brandMessage().send(BrandMessageRequest.builder()
                .targeting("M")
                .messageType("FL")
                .friendTemplateUuid("9cd5460b-6458-4edc-9b11-c26d3013c340")
                .contact(Contact.builder().contact(phone).var1("29,000元").build())
                .build());
    }

    // 群发 —— 已同意接收的全部频道好友（不传接收者）
    public Map<String, Object> announce() {
        return sendgo.brandMessage().broadcast(BrandMessageRequest.builder()
                .messageType("FW")
                .friendTemplateUuid("9cd5460b-6458-4edc-9b11-c26d3013c340")
                .build());
    }

    // 群发在上游为异步处理，需轮询查看进度
    public Map<String, Object> status(String campaignId) {
        return sendgo.brandMessage().campaign(campaignId);
    }
}
```

群发可能触达整个好友列表，请把它放在带 `@PreAuthorize` 的管理端点或定时任务里，不要暴露为公开接口。

`targeting` 的取值含义：

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

---

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

```java
import io.sendgo.model.SmsRequest;

sendgo.sms().sendSms(SmsRequest.sms()
        .content("[Sendgo] 验证码：123456（请在 5 分钟内输入）")
        .contact(Contact.builder().contact("01012345678").build()));

sendgo.sms().sendLms(SmsRequest.lms()
        .subject("[重要] 服务维护通知")
        .content("服务将于 2026-07-25 02:00 ~ 06:00 进行维护。")
        .contact(Contact.builder().contact("01012345678").build()));

sendgo.sms().sendMms(SmsRequest.mms()
        .subject("[活动] 7 月特惠")
        .content("欢迎查看本月特价商品。")
        .contact(Contact.builder().contact("01012345678").build()));
```

`SmsRequest` **不使用** `builder()`/`build()`：它通过 `sms()` / `lms()` / `mms()` 静态工厂创建，再用返回自身的链式 setter 配置。

---

## 在事务提交后发送

在事务内发送意味着之后的回滚仍然留下了已投递的消息。请使用由事务提交触发的应用事件：

```java
public record OrderCreated(String phone, String orderNo) {}

@Service
public class OrderService {

    private final ApplicationEventPublisher events;
    private final OrderRepository orders;

    @Transactional
    public void create(OrderForm form) {
        Order order = orders.save(Order.from(form));
        events.publishEvent(new OrderCreated(order.getPhone(), order.getNumber()));
    }
}

@Component
public class OrderCreatedListener {

    private final OrderNotifier notifier;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderCreated event) {
        notifier.confirmed(event.phone(), event.orderNo());
    }
}
```

`AFTER_COMMIT` 保证记录已持久化后才发出消息，`@Async` 则把外发 HTTP 调用移出请求线程。

---

## 重试临时故障

```java
@Service
public class ResilientNotifier {

    private final SendgoClient sendgo;

    @Retryable(
            retryFor = SendgoException.class,
            maxAttempts = 3,
            backoff = @Backoff(delay = 1000, multiplier = 2))
    public void send(AlimtalkRequest request) {
        sendgo.alimtalk().send(request);
    }

    @Recover
    public void recover(SendgoException e, AlimtalkRequest request) {
        // 4xx 重试也不会成功 —— 记录后停止。
        log.error("Sendgo {} [{}]: {}", e.getStatusCode(), e.getErrorCode(), e.getMessage());
    }
}
```

若只想重试 5xx，请在 `send` 开头检查 `getStatusCode()`，对 4xx 抛出一个不可重试的异常。

---

## 测试

用 mock 替换该 Bean，这样测试就不会真的发出请求：

```java
@SpringBootTest
class OrderNotifierTest {

    @MockBean
    SendgoClient sendgo;

    @Autowired
    OrderNotifier notifier;

    @Test
    void sendsAlimtalk() {
        var alimtalk = mock(AlimtalkService.class);
        given(sendgo.alimtalk()).willReturn(alimtalk);

        notifier.confirmed("01012345678", "ORD-001");

        then(alimtalk).should().send(any(AlimtalkRequest.class));
    }
}
```

---

## 异常处理

```java
import io.sendgo.exception.SendgoException;

try {
    sendgo.alimtalk().send(request);
} catch (SendgoException e) {
    switch (e.getErrorCode()) {
        case "INVALID_ACCESS_KEY", "INVALID_SECRET_KEY" ->
                log.error("请检查 Sendgo 密钥");
        case "PAYMENT_REQUIRED" ->
                log.error("Sendgo 余额不足");
        case "IP_NOT_ALLOWED" ->
                log.error("该 IP 未在白名单中");
        default -> {
            if (e.getStatusCode() >= 500) {
                throw e;   // 交给 @Retryable 处理
            }
            log.error("Sendgo {}: {}", e.getStatusCode(), e.getMessage());
        }
    }
}
```

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

---

## 配置属性

| 属性 | 是否必填 | 默认值 | 说明 |
|------|----------|--------|------|
| `sendgo.access-key` | **必填** | — | Sendgo 访问密钥；同时用于启用自动装配 |
| `sendgo.secret-key` | **必填** | — | Sendgo 私密密钥 |
| `sendgo.kakao-sender-key` | 选填 | `null` | Kakao 发送者资料密钥 |
| `sendgo.sms-sender-key` | 选填 | `null` | 短信主叫号码密钥 |
| `sendgo.api-version` | 选填 | `v2` | API 版本（`v1` \| `v2`） |
| `sendgo.url` | 选填 | `https://sendgo.io` | API 基础地址 |

---

## 短链接

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

> 仅 v2 支持。

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

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

```java
@Service
public class LinkService {

    private final SendgoClient sendgo;

    public LinkService(SendgoClient sendgo) {
        this.sendgo = sendgo;
    }

    public String shorten(String targetUrl) {
        Map<String, Object> created = sendgo.shortUrl().create(ShortUrlRequest.builder()
                .targetUrl(targetUrl)
                .build());

        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) created.get("data");

        return (String) data.get("shortUrl");
    }

    public Map<String, Object> stats(String code) {
        return sendgo.shortUrl().stats(code);
    }
}
```

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

---

## 包信息

- **包名**：`io.sendgo:sendgo-spring`（Maven Central）
- **仓库**：[send-go/spring](https://github.com/send-go/spring)
- **注册表**：https://central.sonatype.com/artifact/io.sendgo/sendgo-spring
- **许可**：MIT

### 如何获取 API 密钥

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