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

`@sendgo/nestjs` 把 [`@sendgo/node`](https://github.com/send-go/node) 核心封装为 NestJS 模块：注册 `SendgoService`，使其可被注入到任意 provider 或 controller。

---

## 安装

```bash
npm install @sendgo/nestjs
```

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

---

## 注册模块

### 同步注册

```typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { SendgoModule } from '@sendgo/nestjs';

@Module({
  imports: [
    SendgoModule.forRoot({
      accessKey: process.env.SENDGO_ACCESS_KEY!,
      secretKey: process.env.SENDGO_SECRET_KEY!,
      kakaoSenderKey: process.env.SENDGO_KAKAO_SENDER_KEY,
      smsSenderKey: process.env.SENDGO_SMS_SENDER_KEY,
      apiVersion: 'v2',
    }),
  ],
})
export class AppModule {}
```

### 异步注册（配合 ConfigModule）

若配置来自 `ConfigService` 或其他异步来源，请使用 `forRootAsync`：

```typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SendgoModule } from '@sendgo/nestjs';

@Module({
  imports: [
    ConfigModule.forRoot(),
    SendgoModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        accessKey: config.getOrThrow('SENDGO_ACCESS_KEY'),
        secretKey: config.getOrThrow('SENDGO_SECRET_KEY'),
        kakaoSenderKey: config.get('SENDGO_KAKAO_SENDER_KEY'),
        smsSenderKey: config.get('SENDGO_SMS_SENDER_KEY'),
        apiVersion: 'v2',
      }),
    }),
  ],
})
export class AppModule {}
```

用 `getOrThrow` 而不是 `get`：这样密钥缺失时应用**启动就会失败**，而不是等到第一次发送才报错。

---

## 注入服务

```typescript
// notification.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { SendgoService } from '@sendgo/nestjs';

@Injectable()
export class NotificationService {
  private readonly logger = new Logger(NotificationService.name);

  constructor(private readonly sendgo: SendgoService) {}

  async orderConfirmed(phone: string, orderNo: string) {
    return this.sendgo.alimtalk.send({
      templateCode: 'ORDER_CONFIRM_001',
      contacts: [{ contact: phone, var1: orderNo }],
    });
  }
}
```

各发送渠道都是服务上的 getter：

| 属性 | 渠道 |
|------|------|
| `sendgo.alimtalk` | Kakao 通知消息 |
| `sendgo.friendtalk` | Kakao 好友消息 |
| `sendgo.brandMessage` | Kakao 品牌消息（仅 v2） |
| `sendgo.sms` | SMS / LMS / MMS |
| `sendgo.client` | 底层的 `@sendgo/node` 客户端 |

需要用到扩展包尚未转发的能力时，可通过 `sendgo.client` 直接访问核心客户端。

---

## 品牌消息

> **好友消息已于 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'`。

```typescript
// campaign.service.ts
import { Injectable } from '@nestjs/common';
import { SendgoService } from '@sendgo/nestjs';

@Injectable()
export class CampaignService {
  constructor(private readonly sendgo: SendgoService) {}

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

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

  // 群发在上游为异步处理，需轮询查看进度
  status(campaignId: string) {
    return this.sendgo.brandMessage.campaign(campaignId);
  }

  recent() {
    return this.sendgo.brandMessage.campaigns({ count: 10 });
  }
}
```

群发可能触达整个好友列表，请把它放在受守卫（guard）保护的管理端点，或由定时任务触发，不要暴露为公开路由。

`targeting` 的取值含义：

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

---

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

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

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

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

---

## 异常映射

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

```typescript
// sendgo-exception.filter.ts
import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { SendgoError } from '@sendgo/nestjs';
import type { Response } from 'express';

@Catch(SendgoError)
export class SendgoExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(SendgoExceptionFilter.name);

  catch(error: SendgoError, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse<Response>();

    this.logger.error(
      `Sendgo ${error.statusCode} [${error.errorCode}] ${error.endpoint}: ${error.message}`,
    );

    const status = (() => {
      switch (error.errorCode) {
        // 是我们的配置有误，而非调用方的请求有误。
        case 'INVALID_ACCESS_KEY':
        case 'INVALID_SECRET_KEY':
        case 'IP_NOT_ALLOWED':
          return HttpStatus.INTERNAL_SERVER_ERROR;
        case 'PAYMENT_REQUIRED':
          return HttpStatus.PAYMENT_REQUIRED;
        default:
          return error.statusCode >= 500
            ? HttpStatus.BAD_GATEWAY
            : HttpStatus.BAD_REQUEST;
      }
    })();

    response.status(status).json({ error: error.errorCode });
  }
}
```

```typescript
// main.ts
app.useGlobalFilters(new SendgoExceptionFilter());
```

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

---

## 后台发送

发送是一次外发 HTTP 调用。对调用方不必等待的场景，请交给队列（`@nestjs/bullmq`）：

```typescript
// notification.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger, UnrecoverableError } from '@nestjs/common';
import { SendgoError } from '@sendgo/nestjs';
import { SendgoService } from '@sendgo/nestjs';
import type { Job } from 'bullmq';

@Processor('notifications')
export class NotificationProcessor extends WorkerHost {
  private readonly logger = new Logger(NotificationProcessor.name);

  constructor(private readonly sendgo: SendgoService) {
    super();
  }

  async process(job: Job<{ phone: string; orderNo: string }>) {
    try {
      await this.sendgo.alimtalk.send({
        templateCode: 'ORDER_CONFIRM_001',
        contacts: [{ contact: job.data.phone, var1: job.data.orderNo }],
      });
    } catch (error) {
      if (error instanceof SendgoError && error.statusCode < 500) {
        // 4xx 重试也不会成功 —— 别耗尽重试次数。
        this.logger.error(`Sendgo ${error.errorCode}: ${error.message}`);

        throw new UnrecoverableError(error.message);
      }

      throw error;   // 5xx 为临时故障，交给 BullMQ 重试
    }
  }
}
```

---

## 测试

在测试模块中替换 `SendgoService`，这样测试就不会真的发出请求：

```typescript
import { Test } from '@nestjs/testing';
import { SendgoService } from '@sendgo/nestjs';

const sendgo = {
  alimtalk: { send: jest.fn() },
  brandMessage: { send: jest.fn(), broadcast: jest.fn() },
};

const moduleRef = await Test.createTestingModule({
  providers: [NotificationService],
})
  .useMocker((token) => (token === SendgoService ? sendgo : undefined))
  .compile();

await moduleRef.get(NotificationService).orderConfirmed('01012345678', 'ORD-001');

expect(sendgo.alimtalk.send).toHaveBeenCalledWith(
  expect.objectContaining({ templateCode: 'ORDER_CONFIRM_001' }),
);
```

---

## 配置项

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

---

## 短链接

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

> 仅 v2 支持。

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

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

```typescript
@Injectable()
export class LinkService {
  constructor(private readonly sendgo: SendgoService) {}

  async shorten(targetUrl: string) {
    const created = await this.sendgo.shortUrl.create({ targetUrl });

    return created.data.shortUrl;
  }

  stats(code: string) {
    return this.sendgo.shortUrl.stats(code);
  }
}
```

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

---

## 包信息

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

### 如何获取 API 密钥

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