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

`Sendgo.SDK` 是不依赖任何框架的**核心客户端**。ASP.NET Core 扩展包构建在它之上。

面向 .NET 8.0。

---

## 安装

```bash
dotnet add package Sendgo.SDK
```

---

## 快速上手

```csharp
using Sendgo;
using Sendgo.Models;

var sendgo = new SendgoClient(new SendgoOptions
{
    AccessKey      = Environment.GetEnvironmentVariable("SENDGO_ACCESS_KEY")!,
    SecretKey      = Environment.GetEnvironmentVariable("SENDGO_SECRET_KEY")!,
    KakaoSenderKey = Environment.GetEnvironmentVariable("SENDGO_KAKAO_SENDER_KEY"),
    SmsSenderKey   = Environment.GetEnvironmentVariable("SENDGO_SMS_SENDER_KEY"),
    ApiVersion     = "v2",
});

await sendgo.SendAlimtalkAsync(new AlimtalkRequest
{
    TemplateCode = "ORDER_CONFIRM_001",
    Contacts = new[] { new Contact { PhoneNumber = "01012345678", Var1 = "ORD-001" } },
});
```

所有发送方法都直接挂在客户端上（而非分渠道的子对象）：

| 方法 | 渠道 |
|------|------|
| `SendAlimtalkAsync` | Kakao 通知消息 |
| `SendFriendtalkAsync` | Kakao 好友消息 |
| `SendBrandMessageAsync` / `BroadcastBrandMessageAsync` | Kakao 品牌消息（仅 v2） |
| `GetBrandMessagesAsync` / `GetBrandMessageAsync` | 品牌消息活动查询 |
| `SendSmsAsync` / `SendLmsAsync` / `SendMmsAsync` | SMS / LMS / MMS |

`Contact.PhoneNumber` 在传输时序列化为 `contact` —— 属性名只是为了 C# 侧的可读性。

`SendgoClient` 是 `sealed` 且实现 `IDisposable`。请以单例形式创建并复用，令牌缓存才能生效；应用退出时释放一次即可，**不要**用 `using` 包在每次调用外面。

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

---

## 通知消息

```csharp
// 批量发送
await sendgo.SendAlimtalkAsync(new AlimtalkRequest
{
    TemplateCode = "ORDER_CONFIRM_001",
    Contacts = new[]
    {
        new Contact { PhoneNumber = "01011111111", Var1 = "ORD-001", Var2 = "29,000元" },
        new Contact { PhoneNumber = "01022222222", Var1 = "ORD-002", Var2 = "15,000元" },
    },
});

// 预约发送
await sendgo.SendAlimtalkAsync(new AlimtalkRequest
{
    TemplateCode = "PROMO_SUMMER_2026",
    ScheduleType = "SCHEDULED",
    At           = "2026-07-28 09:00:00",
    Contacts     = new[] { new Contact { PhoneNumber = "01012345678", Var1 = "夏季限定 5 折" } },
});

// 失败时自动回退为短信
await sendgo.SendAlimtalkAsync(new AlimtalkRequest
{
    TemplateCode = "DELIVERY_START_001",
    ReplaceSms   = "Y",
    SmsSubject   = "[发货通知]",
    SmsContent   = "您购买的商品已出库。",
    Contacts     = new[] { new Contact { PhoneNumber = "01012345678", Var1 = "ORD-001" } },
});
```

`TemplateCode` 与 `Contacts` 标记为 `required`，遗漏时**编译期**即报错。

`Contact` 支持 `Var1` ~ `Var8`。

### 取消令牌

每个方法都接受 `CancellationToken`：

```csharp
await sendgo.SendAlimtalkAsync(request, cancellationToken);
```

在 Web 请求中传入该请求自身的取消令牌，客户端中断时外发调用也会一并取消。

---

## 好友消息

> ⚠️ **已停用 —— 按 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`。
```csharp
await sendgo.SendFriendtalkAsync(new
{
    content  = "7 月限时特惠开始了，欢迎查看。",
    contacts = new[] { new { contact = "01012345678" } },
});
```

> `SendFriendtalkAsync` 接收 `object`（而非强类型 record），因此传入的是**原样的 API 字段名**（小驼峰）。品牌消息则有强类型的 `BrandMessageRequest`，新对接建议使用品牌消息。

---

## 品牌消息

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

> 仅 v2 支持。请设置 `ApiVersion = "v2"`。

```csharp
// 单条发送 —— Targeting 为 M/N/I 时必须提供 Contacts
var result = await sendgo.SendBrandMessageAsync(new BrandMessageRequest
{
    Targeting          = "M",
    MessageType        = "FL",
    FriendTemplateUuid = "9cd5460b-6458-4edc-9b11-c26d3013c340",
    Contacts           = new[] { new Contact { PhoneNumber = "01012345678", Var1 = "29,000元" } },
});

// 群发 —— 已同意接收的全部频道好友（不传 Contacts）
var accepted = await sendgo.BroadcastBrandMessageAsync(new BrandMessageRequest
{
    MessageType        = "FW",
    FriendTemplateUuid = "9cd5460b-6458-4edc-9b11-c26d3013c340",
});

// 群发在上游为异步处理，需轮询查看进度
var list   = await sendgo.GetBrandMessagesAsync(count: 10);
var detail = await sendgo.GetBrandMessageAsync(campaignId);
```

`FriendTemplateUuid` 是 `required`；`Targeting` 默认为 `"M"`，`MessageType` 默认为 `"FT"`。

`targeting` 的取值含义：

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

---

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

```csharp
// SMS（90 字节以内）
await sendgo.SendSmsAsync(new SmsRequest
{
    Content  = "[Sendgo] 验证码：123456（请在 5 分钟内输入）",
    Contacts = new[] { new Contact { PhoneNumber = "01012345678" } },
});

// LMS（长文，2,000 字节以内）
await sendgo.SendLmsAsync(new SmsRequest
{
    Subject  = "[重要] 服务维护通知",
    Content  = "服务将于 2026-07-25 02:00 ~ 06:00 进行维护。",
    Contacts = new[] { new Contact { PhoneNumber = "01012345678" } },
});

// MMS（含图片）
await sendgo.SendMmsAsync(new SmsRequest
{
    Subject  = "[活动] 7 月特惠",
    Content  = "欢迎查看本月特价商品。",
    Contacts = new[] { new Contact { PhoneNumber = "01012345678" } },
});
```

---

## 错误处理

```csharp
using Sendgo.Exceptions;

try
{
    await sendgo.SendAlimtalkAsync(request, ct);
}
catch (SendgoException e)
{
    logger.LogError("Sendgo {Status} [{Code}] {Endpoint}: {Message}",
        e.StatusCode, e.ErrorCode, e.Endpoint, e.Message);

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

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

---

## 测试

`SendgoClient` 是 `sealed` 且内部自行创建 `HttpClient`，因此**无法**继承或注入替身消息处理器。请在其前面放一层自己的接口来做替换：

```csharp
public interface IOrderNotifier
{
    Task ConfirmedAsync(string phone, string orderNo, CancellationToken ct = default);
}

public class OrderNotifier(SendgoClient sendgo) : IOrderNotifier
{
    public Task ConfirmedAsync(string phone, string orderNo, CancellationToken ct = default) =>
        sendgo.SendAlimtalkAsync(new AlimtalkRequest
        {
            TemplateCode = "ORDER_CONFIRM_001",
            Contacts = new[] { new Contact { PhoneNumber = phone, Var1 = orderNo } },
        }, ct);
}
```

需要端到端验证 SDK 本身时，把 `BaseUrl` 指向本地桩服务（WireMock.Net 等），而不是替换客户端。

---

## 配置项

| 属性 | 是否必填 | 默认值 | 说明 |
|------|----------|--------|------|
| `AccessKey` | **必填** | — | Sendgo 访问密钥 |
| `SecretKey` | **必填** | — | Sendgo 私密密钥 |
| `KakaoSenderKey` | 选填 | `null` | Kakao 发送者资料密钥 |
| `SmsSenderKey` | 选填 | `null` | 短信主叫号码密钥 |
| `ApiVersion` | 选填 | `"v1"` | API 版本（`v1` \| `v2`） |
| `BaseUrl` | 选填 | `"https://sendgo.io"` | API 基础地址 |

`AccessKey` 与 `SecretKey` 标记为 `required`，遗漏时编译期即报错。

---

## 短链接

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

> 仅 v2 支持。

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

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

```csharp
var created = await sendgo.CreateShortUrlAsync(new ShortUrlRequest
{
    TargetUrl = "https://example.com/promotions/summer-sale",
    Title = "夏季促销落地页",
}, ct);

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

await sendgo.GetShortUrlsAsync(count: 10, ct: ct);
await sendgo.GetShortUrlAsync(code, ct);
await sendgo.DeactivateShortUrlAsync(code, ct);   // 仅停止重定向，统计保留
```

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

---

## 包信息

- **包名**：`Sendgo.SDK`（NuGet）
- **仓库**：[send-go/dotnet](https://github.com/send-go/dotnet)
- **注册表**：https://www.nuget.org/packages/Sendgo.SDK
- **许可**：MIT

### 如何获取 API 密钥

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