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

`Sendgo.AspNetCore` 在 [`Sendgo.SDK`](https://www.nuget.org/packages/Sendgo.SDK) 核心之上提供 `AddSendgo` 扩展方法，把 `SendgoClient` 注册为单例。

面向 .NET 8.0。

---

## 安装

```bash
dotnet add package Sendgo.AspNetCore
```

核心 `Sendgo.SDK` 会作为依赖一并安装。

---

## 注册客户端

### 从配置读取

```csharp
// Program.cs
builder.Services.AddSendgo(builder.Configuration.GetSection("Sendgo"));
```

```json
// appsettings.json
{
  "Sendgo": {
    "AccessKey": "your_access_key",
    "SecretKey": "your_secret_key",
    "KakaoSenderKey": "your_kakao_sender_key",
    "SmsSenderKey": "your_sms_sender_key",
    "ApiVersion": "v2"
  }
}
```

**不要**把密钥提交到源码里的 `appsettings.json`：开发期用 user secrets，生产环境用环境变量或密钥保管服务：

```bash
dotnet user-secrets set "Sendgo:AccessKey" "your_access_key"
dotnet user-secrets set "Sendgo:SecretKey" "your_secret_key"
```

环境变量使用双下划线分隔：`Sendgo__AccessKey`。

### 使用 lambda

```csharp
builder.Services.AddSendgo(options =>
{
    options.AccessKey = builder.Configuration["Sendgo:AccessKey"]!;
    options.SecretKey = builder.Configuration["Sendgo:SecretKey"]!;
    options.KakaoSenderKey = builder.Configuration["Sendgo:KakaoSenderKey"];
    options.ApiVersion = "v2";
});
```

两个重载注册的是同一个单例，因此令牌缓存在整个应用内共享。`SendgoClient` 实现了 `IDisposable`，DI 容器会在应用关闭时释放该单例 —— **不要**用 `using` 包住它。

---

## 注入客户端

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

public class OrderNotifier(SendgoClient sendgo, ILogger<OrderNotifier> logger)
{
    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);
}
```

```csharp
builder.Services.AddScoped<OrderNotifier>();
```

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

### Minimal API 端点

```csharp
app.MapPost("/notify", async (
    NotifyRequest body,
    SendgoClient sendgo,
    CancellationToken ct) =>
{
    await sendgo.SendAlimtalkAsync(new AlimtalkRequest
    {
        TemplateCode = "ORDER_CONFIRM_001",
        Contacts = new[] { new Contact { PhoneNumber = body.Phone, Var1 = body.OrderNo } },
    }, ct);

    return Results.Accepted();
});

record NotifyRequest(string Phone, string OrderNo);
```

把端点自身的 `CancellationToken` 传下去，客户端中断请求时外发调用也会一并取消。

---

## 品牌消息

> **好友消息已于 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 支持。请设置 `ApiVersion = "v2"`。

```csharp
app.MapPost("/campaigns/targeted", async (
    string phone, SendgoClient sendgo, CancellationToken ct) =>
{
    var result = await sendgo.SendBrandMessageAsync(new BrandMessageRequest
    {
        Targeting = "M",
        MessageType = "FL",
        FriendTemplateUuid = "9cd5460b-6458-4edc-9b11-c26d3013c340",
        Contacts = new[] { new Contact { PhoneNumber = phone, Var1 = "29,000元" } },
    }, ct);

    return Results.Ok(result);
});

app.MapPost("/campaigns/broadcast", async (SendgoClient sendgo, CancellationToken ct) =>
{
    // 不传接收者列表 —— 由 Kakao 展开受众
    var result = await sendgo.BroadcastBrandMessageAsync(new BrandMessageRequest
    {
        MessageType = "FW",
        FriendTemplateUuid = "9cd5460b-6458-4edc-9b11-c26d3013c340",
    }, ct);

    return Results.Accepted(value: result);
}).RequireAuthorization("Admin");

app.MapGet("/campaigns", (SendgoClient sendgo, CancellationToken ct) =>
    sendgo.GetBrandMessagesAsync(count: 10, ct: ct));

app.MapGet("/campaigns/{campaignId}", (string campaignId, SendgoClient sendgo, CancellationToken ct) =>
    sendgo.GetBrandMessageAsync(campaignId, ct));
```

群发在上游为异步处理，因此发送响应只表示已被接受 —— 请轮询详情端点查看进度。群发可能触达整个好友列表，所以上面的示例给它加了 `RequireAuthorization`。

`targeting` 的取值含义：

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

---

## 后台发送

发送是一次外发 HTTP 调用，对调用方不必等待的场景请把它移出请求路径：

```csharp
public class NotificationQueue
{
    private readonly Channel<AlimtalkRequest> _channel =
        Channel.CreateBounded<AlimtalkRequest>(1000);

    public ValueTask EnqueueAsync(AlimtalkRequest request, CancellationToken ct) =>
        _channel.Writer.WriteAsync(request, ct);

    public IAsyncEnumerable<AlimtalkRequest> ReadAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

public class NotificationWorker(
    NotificationQueue queue,
    SendgoClient sendgo,
    ILogger<NotificationWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var request in queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                await sendgo.SendAlimtalkAsync(request, stoppingToken);
            }
            catch (SendgoException e) when (e.StatusCode < 500)
            {
                // 4xx 重试也不会成功 —— 丢弃并记录原因。
                logger.LogError("Sendgo {Code}: {Message}", e.ErrorCode, e.Message);
            }
            catch (SendgoException e)
            {
                logger.LogWarning("Sendgo 临时故障 {Status}，重新入队", e.StatusCode);
                await queue.EnqueueAsync(request, stoppingToken);
            }
        }
    }
}
```

```csharp
builder.Services.AddSingleton<NotificationQueue>();
builder.Services.AddHostedService<NotificationWorker>();
```

`Channel` 只存在于进程内存中，因此进程重启会丢失未发送的消息。需要持久化时请改用真正的队列（Azure Service Bus、RabbitMQ 等）。

---

## 测试

`SendgoClient` 是 `sealed` 且内部自行创建 `HttpClient`，因此**无法**继承，也无法注入替身消息处理器。请在其前面放一层自己的接口来做替换 —— 这同时也让调用点不再依赖 SDK：

```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);
}
```

```csharp
builder.Services.AddScoped<IOrderNotifier, OrderNotifier>();
```

```csharp
public class TestFactory : WebApplicationFactory<Program>
{
    public Mock<IOrderNotifier> Notifier { get; } = new();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<IOrderNotifier>();
            services.AddScoped(_ => Notifier.Object);
        });
    }
}
```

若某个端到端测试必须真正走一遍 SDK，请把 `Sendgo:BaseUrl` 指向本地桩服务（WireMock.Net，或另一个 `WebApplication`），而不是替换客户端。

---

## 异常处理

把 Sendgo 的错误映射为 HTTP 状态码，而不是让它变成 500：

```csharp
using Sendgo.Exceptions;

app.UseExceptionHandler(handler => handler.Run(async context =>
{
    var error = context.Features.Get<IExceptionHandlerFeature>()?.Error;

    if (error is not SendgoException e)
    {
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        return;
    }

    context.Response.StatusCode = e.ErrorCode switch
    {
        // 是我们的配置有误，而非调用方的请求有误。
        "INVALID_ACCESS_KEY" or "INVALID_SECRET_KEY" or "IP_NOT_ALLOWED"
            => StatusCodes.Status500InternalServerError,
        "PAYMENT_REQUIRED" => StatusCodes.Status402PaymentRequired,
        _ => e.StatusCode >= 500 ? StatusCodes.Status502BadGateway : StatusCodes.Status400BadRequest,
    };

    await context.Response.WriteAsJsonAsync(new { error = e.ErrorCode });
}));
```

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

---

## 配置项

配置节直接绑定到 `SendgoOptions`。

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

---

## 短链接

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

> 仅 v2 支持。

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

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

```csharp
app.MapPost("/shorten", async (
    string targetUrl, SendgoClient sendgo, CancellationToken ct) =>
{
    var created = await sendgo.CreateShortUrlAsync(new ShortUrlRequest
    {
        TargetUrl = targetUrl,
    }, ct);

    return Results.Ok(created);
});

app.MapGet("/shorten/{code}/stats", (string code, SendgoClient sendgo, CancellationToken ct) =>
    sendgo.GetShortUrlStatsAsync(code, ct: ct));
```

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

---

## 包信息

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

### 如何获取 API 密钥

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