> **用于从 Next.js 服务端发送 Kakao 通知消息、品牌消息与短信的官方 React SDK**

`@sendgo/react` 把 [`@sendgo/node`](https://github.com/send-go/node) 核心包装为**服务端函数**（可用作 Server Action 或在 Route Handler 中调用），并附带一个用于调用你自己接口的客户端 Hook。

> **⚠️ 仅限服务端使用。** 访问密钥与私密密钥**绝不可**进入浏览器。请只在带 `'use server'` 的文件、Route Handler 或其他 Node 环境中调用发送函数。若把它们导入客户端组件，密钥就会被打进客户端 bundle。

---

## 安装

```bash
npm install @sendgo/react
```

核心 `@sendgo/node` 会作为依赖一并安装。

---

## 配置

发送函数直接读取环境变量，因此无需在代码里初始化：

```env
# .env.local
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
SENDGO_API_VERSION=v2
```

变量名**不要**加 `NEXT_PUBLIC_` 前缀 —— 那会把密钥暴露给浏览器。

`SENDGO_API_VERSION` 未设置时默认为 `v1`；品牌消息需要 `v2`。

---

## Server Action

```typescript
// app/actions/notify.ts
'use server';

import { sendAlimtalk, SendgoError } from '@sendgo/react';

export async function notifyOrderConfirmed(phone: string, orderNo: string) {
  try {
    await sendAlimtalk({
      templateCode: 'ORDER_CONFIRM_001',
      contacts: [{ contact: phone, var1: orderNo }],
    });

    return { ok: true as const };
  } catch (error) {
    if (error instanceof SendgoError) {
      // 上游细节记录到日志，返回给客户端的信息保持通用。
      console.error(`Sendgo ${error.statusCode} [${error.errorCode}]: ${error.message}`);

      return { ok: false as const, error: '消息发送失败' };
    }

    throw error;
  }
}
```

返回结构化结果而不是直接抛出：Server Action 抛出的异常在生产构建下会变成不带信息的通用错误。

可用的服务端函数：

| 函数 | 渠道 |
|------|------|
| `sendAlimtalk` | Kakao 通知消息 |
| `sendFriendtalk` | Kakao 好友消息 |
| `sendBrandMessage` / `broadcastBrandMessage` | Kakao 品牌消息（仅 v2） |
| `listBrandMessages` / `getBrandMessage` | 品牌消息活动查询 |
| `sendSms` / `sendLms` / `sendMms` | SMS / LMS / MMS |
| `createSendgoClient` | 需要完整核心客户端时使用 |

---

## Route Handler

```typescript
// app/api/notify/route.ts
import { NextResponse } from 'next/server';
import { sendAlimtalk, SendgoError } from '@sendgo/react';

export async function POST(request: Request) {
  const { phone, orderNo } = await request.json();

  try {
    await sendAlimtalk({
      templateCode: 'ORDER_CONFIRM_001',
      contacts: [{ contact: phone, var1: orderNo }],
    });

    return NextResponse.json({ ok: true }, { status: 202 });
  } catch (error) {
    if (error instanceof SendgoError) {
      console.error(`Sendgo ${error.statusCode} [${error.errorCode}]: ${error.message}`);

      // 5xx 是上游问题，4xx 是请求问题。
      return NextResponse.json(
        { error: error.errorCode },
        { status: error.statusCode >= 500 ? 502 : 400 },
      );
    }

    throw error;
  }
}
```

---

## 品牌消息

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

```typescript
// app/actions/campaign.ts
'use server';

import {
  sendBrandMessage,
  broadcastBrandMessage,
  listBrandMessages,
  getBrandMessage,
} from '@sendgo/react';

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

// 群发 —— 已同意接收的全部频道好友（不传 contacts）
export async function announce() {
  return broadcastBrandMessage({
    messageType: 'FW',
    friendTemplateUuid: '9cd5460b-6458-4edc-9b11-c26d3013c340',
  });
}

// 群发在上游为异步处理，需轮询查看进度
export async function status(campaignId: string) {
  return getBrandMessage(campaignId);
}

export async function recent() {
  return listBrandMessages({ count: 10 });
}
```

群发可能触达你的整个好友列表。请在 Server Action 内先做权限校验 —— Server Action 本质上是一个可被任意调用方 POST 的端点，**不会**因为 UI 里没有按钮就受到保护。

`targeting` 的取值含义：

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

---

## 客户端 Hook

`useAlimtalk` 用于调用**你自己的**接口，并附带 pending / error 状态。它返回 `{ send, loading, error, data, reset }`：

```tsx
'use client';

import { useAlimtalk } from '@sendgo/react';

export function NotifyButton({ phone, orderNo }: { phone: string; orderNo: string }) {
  const { send, loading, error, data, reset } = useAlimtalk();

  return (
    <>
      <button
        disabled={loading}
        onClick={() =>
          send({
            templateCode: 'ORDER_CONFIRM_001',
            contacts: [{ contact: phone, var1: orderNo }],
          })
        }
      >
        {loading ? '发送中…' : '发送'}
      </button>

      {error && (
        <p role="alert">
          {error.message}
          <button onClick={reset}>关闭</button>
        </p>
      )}
      {data && <p>已发送。</p>}
    </>
  );
}
```

这个 Hook 只负责请求你的服务端端点 —— 它不会持有任何密钥。

---

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

```typescript
'use server';

import { sendSms, sendLms, sendMms } from '@sendgo/react';

await sendSms({
  content: '[Sendgo] 验证码：123456（请在 5 分钟内输入）',
  contacts: [{ contact: '01012345678' }],
});

await sendLms({
  subject: '[重要] 服务维护通知',
  content: '服务将于 2026-07-25 02:00 ~ 06:00 进行维护。',
  contacts: [{ contact: '01012345678' }],
});

await sendMms({
  subject: '[活动] 7 月特惠',
  content: '欢迎查看本月特价商品。',
  contacts: [{ contact: '01012345678' }],
});
```

三个函数的参数类型都是 `Omit<SmsParams, 'messageType'>` —— 消息类型由函数本身决定，因此不会发错类型。

---

## 需要完整客户端时

```typescript
'use server';

import { createSendgoClient } from '@sendgo/react';

// 不传参数时从环境变量读取配置。
const sendgo = createSendgoClient();

// 也可以显式覆盖（例如多租户）
const tenantClient = createSendgoClient({
  accessKey: tenant.accessKey,
  secretKey: tenant.secretKey,
  apiVersion: 'v2',
});

await sendgo.alimtalk.send({ /* ... */ });
```

---

## 类型

```typescript
import type {
  AlimtalkParams,
  FriendtalkParams,
  BrandMessageParams,
  BrandMessageListParams,
  BrandMessageTargeting,
  SmsParams,
  Contact,
  SendgoConfig,
  SendgoResponse,
} from '@sendgo/react';
```

`SendgoError` 以值的形式导出，可用于 `instanceof` 判断。

---

## 环境变量

| 变量 | 是否必填 | 默认值 | 说明 |
|------|----------|--------|------|
| `SENDGO_ACCESS_KEY` | **必填** | — | Sendgo 访问密钥 |
| `SENDGO_SECRET_KEY` | **必填** | — | Sendgo 私密密钥 |
| `SENDGO_KAKAO_SENDER_KEY` | 选填 | `undefined` | Kakao 发送者资料密钥 |
| `SENDGO_SMS_SENDER_KEY` | 选填 | `undefined` | 短信主叫号码密钥 |
| `SENDGO_API_VERSION` | 选填 | `v1` | API 版本（`v1` \| `v2`） |

---

## 短链接

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

> 仅 v2 支持。

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

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

```typescript
'use server';

import { createShortUrl, shortUrlStats } from '@sendgo/react';

export async function shorten(targetUrl: string) {
  const created = await createShortUrl({ targetUrl });

  return created.data.shortUrl;
}

export async function reactions(code: string) {
  return shortUrlStats(code, { from: '2026-08-01' });
}
```

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

---

## 包信息

- **包名**：`@sendgo/react`（npm）
- **仓库**：[send-go/react](https://github.com/send-go/react)
- **注册表**：https://www.npmjs.com/package/@sendgo/react
- **许可**：MIT

### 如何获取 API 密钥

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