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

`sendgo_flutter` 是用于调用 Sendgo API 的 Dart 客户端。

> **⚠️ 仅限服务端使用。** 访问密钥与私密密钥**绝不可**打包进 Flutter 应用 —— 已发布的应用包可被反编译并提取出密钥，从而被他人用你的账户任意发送消息。请只在 Dart 后端（Shelf、Serverpod、Dart Frog、Cloud Functions 等）中使用本 SDK，让 Flutter 应用调用你自己的服务端接口。

---

## 安装

```bash
dart pub add sendgo_flutter
```

---

## 快速上手

```dart
import 'dart:io';
import 'package:sendgo_flutter/sendgo_flutter.dart';

final sendgo = SendgoClient(
  accessKey: Platform.environment['SENDGO_ACCESS_KEY']!,
  secretKey: Platform.environment['SENDGO_SECRET_KEY']!,
  kakaoSenderKey: Platform.environment['SENDGO_KAKAO_SENDER_KEY'],
  smsSenderKey: Platform.environment['SENDGO_SMS_SENDER_KEY'],
  apiVersion: 'v2',
);

await sendgo.alimtalk.send(AlimtalkRequest(
  templateCode: 'ORDER_CONFIRM_001',
  contacts: [Contact(contact: '01012345678', name: '洪吉童', var1: 'ORD-001')],
));
```

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

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

`apiVersion` 的默认值为 `'v1'`，品牌消息需要显式设为 `'v2'`。

请创建一次客户端并复用（例如顶层 `final`），令牌缓存才能生效。

---

## 通知消息

```dart
// 批量发送
await sendgo.alimtalk.send(AlimtalkRequest(
  templateCode: 'ORDER_CONFIRM_001',
  contacts: [
    Contact(contact: '01011111111', var1: 'ORD-001', var2: '29,000元'),
    Contact(contact: '01022222222', var1: 'ORD-002', var2: '15,000元'),
  ],
));

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

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

`templateCode` 与 `contacts` 是 `required` 命名参数，遗漏时编译期即报错。
`Contact` 支持 `var1` ~ `var8`，以及任意命名变量的 `variables`。

---

## 好友消息

> ⚠️ **已停用 —— 按 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`。
```dart
// 文本型
await sendgo.friendtalk.send(FriendtalkRequest(
  content: '7 月限时特惠开始了，欢迎查看。',
  contacts: [Contact(contact: '01012345678')],
));

// 图片型
await sendgo.friendtalk.send(FriendtalkRequest(
  messageType: 'FI',
  content: '本周特价商品',
  imageUrl: 'https://cdn.example.com/banner.jpg',
  imageLink: 'https://example.com/event',
  contacts: [Contact(contact: '01012345678')],
));
```

---

## 品牌消息

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

> 仅 v2 支持。请设置 `apiVersion: 'v2'`。

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

// 群发 —— 已同意接收的全部频道好友（不传 contacts）
final accepted = await sendgo.brandMessage.broadcast(BrandMessageRequest(
  messageType: 'FW',
  friendTemplateUuid: '9cd5460b-6458-4edc-9b11-c26d3013c340',
));

// 群发在上游为异步处理，需轮询查看进度
await sendgo.brandMessage.campaign(accepted['data']['campaignId'] as String);
await sendgo.brandMessage.campaigns(from: '2026-08-01', count: 10);
```

`friendTemplateUuid` 是 `required`；`targeting` 默认为 `'M'`，`messageType` 默认为 `'FT'`。
`broadcast` 内部调用 `request.asBroadcast()`，即把 `targeting` 换成 `'F'` 后再发送，因此两者接受相同的请求对象。

`targeting` 的取值含义：

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

---

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

```dart
// SMS（90 字节以内）
await sendgo.sms.sendSms(SmsRequest(
  content: '[Sendgo] 验证码：123456（请在 5 分钟内输入）',
  contacts: [Contact(contact: '01012345678')],
));

// LMS（长文，2,000 字节以内）
await sendgo.sms.sendLms(SmsRequest(
  subject: '[重要] 服务维护通知',
  content: '服务将于 2026-07-25 02:00 ~ 06:00 进行维护。',
  contacts: [Contact(contact: '01012345678')],
));

// MMS（含图片）
await sendgo.sms.sendMms(SmsRequest(
  subject: '[活动] 7 月特惠',
  content: '欢迎查看本月特价商品。',
  contacts: [Contact(contact: '01012345678')],
));
```

`sendSms` / `sendLms` / `sendMms` 会分别把 `messageType` 固定为 `SMS` / `LMS` / `MMS`，因此即使请求对象里写了别的类型也不会发错。

---

## 在 Dart 后端中使用

Flutter 应用调用你自己的接口，密钥留在服务端 —— 以 [Dart Frog](https://dartfrog.vgv.dev) 为例：

```dart
// routes/notify.dart
import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'package:sendgo_flutter/sendgo_flutter.dart';

// 顶层创建一次，令牌缓存跨请求复用。
final _sendgo = SendgoClient(
  accessKey: Platform.environment['SENDGO_ACCESS_KEY']!,
  secretKey: Platform.environment['SENDGO_SECRET_KEY']!,
  kakaoSenderKey: Platform.environment['SENDGO_KAKAO_SENDER_KEY'],
  apiVersion: 'v2',
);

Future<Response> onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response(statusCode: HttpStatus.methodNotAllowed);
  }

  final body = await context.request.json() as Map<String, dynamic>;

  try {
    await _sendgo.alimtalk.send(AlimtalkRequest(
      templateCode: 'ORDER_CONFIRM_001',
      contacts: [Contact(contact: body['phone'] as String, var1: body['orderNo'] as String)],
    ));

    return Response(statusCode: HttpStatus.accepted);
  } on SendgoException catch (e) {
    // 上游细节记录到日志，对外只返回通用状态。
    print('Sendgo ${e.statusCode} [${e.errorCode}]: ${e.message}');

    return Response(
      statusCode: e.statusCode >= 500 ? HttpStatus.badGateway : HttpStatus.badRequest,
    );
  }
}
```

---

## 错误处理

```dart
try {
  await sendgo.alimtalk.send(request);
} on SendgoException catch (e) {
  print('Sendgo ${e.statusCode} [${e.errorCode}] ${e.endpoint}: ${e.message}');

  switch (e.errorCode) {
    case 'INVALID_ACCESS_KEY':
    case 'INVALID_SECRET_KEY':
      // 是我们的配置有误，而非调用方的请求有误。
      alertOps('请检查 Sendgo 密钥');
    case 'IP_NOT_ALLOWED':
      alertOps('该 IP 未在白名单中');
    case 'PAYMENT_REQUIRED':
      alertOps('Sendgo 余额不足');
    default:
      // 5xx 为临时故障，可重试；4xx 重试不会成功。
      if (e.statusCode >= 500) rethrow;
  }
}
```

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

---

## 配置项

| 命名参数 | 是否必填 | 默认值 | 说明 |
|----------|----------|--------|------|
| `accessKey` | **必填** | — | Sendgo 访问密钥 |
| `secretKey` | **必填** | — | Sendgo 私密密钥 |
| `kakaoSenderKey` | 选填 | `null` | Kakao 发送者资料密钥 |
| `smsSenderKey` | 选填 | `null` | 短信主叫号码密钥 |
| `apiVersion` | 选填 | `'v1'` | API 版本（`v1` \| `v2`） |
| `baseUrl` | 选填 | `'https://sendgo.io'` | API 基础地址 |

---

## 短链接

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

> 仅 v2 支持。

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

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

```dart
final created = await sendgo.shortUrl.create(const ShortUrlRequest(
  targetUrl: 'https://example.com/promotions/summer-sale',
  title: '夏季促销落地页',
));

final code = created['data']['code'] as String;
final link = created['data']['shortUrl'] as String;

// 反应统计 —— 按日趋势 + 设备/来源/国家分解
final stats = await sendgo.shortUrl.stats(code, from: '2026-08-01');

await sendgo.shortUrl.list(count: 10);
await sendgo.shortUrl.show(code);
await sendgo.shortUrl.deactivate(code);   // 仅停止重定向，统计保留
```

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

---

## 包信息

- **包名**：`sendgo_flutter`（pub.dev）
- **仓库**：[send-go/flutter](https://github.com/send-go/flutter)
- **注册表**：https://pub.dev/packages/sendgo_flutter
- **许可**：MIT

### 如何获取 API 密钥

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