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

`sendgo-django` 封装 [`sendgo-python`](https://github.com/send-go/python) 核心，使客户端从 `settings.SENDGO` 读取配置并在首次使用时才构建。

要求 Django 4.2+ 与 Python 3.10+。

---

## 安装

```bash
pip install sendgo-django
```

核心 `sendgo-python` 会作为依赖一并安装。

---

## 配置

### 1. 注册应用

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "sendgo_django",
]
```

### 2. 添加配置字典

```python
# settings.py
import os

SENDGO = {
    "ACCESS_KEY": os.environ["SENDGO_ACCESS_KEY"],
    "SECRET_KEY": os.environ["SENDGO_SECRET_KEY"],
    "KAKAO_SENDER_KEY": os.environ.get("SENDGO_KAKAO_SENDER_KEY"),
    "SMS_SENDER_KEY": os.environ.get("SENDGO_SMS_SENDER_KEY"),
    "API_VERSION": "v2",
}
```

`ACCESS_KEY` 与 `SECRET_KEY` 是必填的 —— 缺少任意一个会在首次使用时抛出 `ImproperlyConfigured`，而不是在发送时静默失败。

---

## 快速上手

```python
from sendgo_django import client

client.alimtalk.send(
    template_code="ORDER_CONFIRM_001",
    contacts=[{"contact": "01012345678", "name": "洪吉童", "var1": "ORD-001"}],
)
```

`client` 是 `SimpleLazyObject`，因此在模块顶层导入它**不会**读取 settings 也不会构建任何东西。核心客户端在首次属性访问时创建并记忆化 —— 可以安全地在 `models.py`、信号处理器或任何启动期加载的位置导入。

核心的各发送渠道都可用：

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

---

## 品牌消息

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

```python
from sendgo_django import client

# 单条发送 —— targeting 为 M/N/I 时必须提供 contacts
client.brand_message.send(
    targeting="M",
    message_type="FL",
    friend_template_uuid="9cd5460b-6458-4edc-9b11-c26d3013c340",
    contacts=[{"contact": "01012345678", "var1": "29,000元"}],
)

# 群发 —— 已同意接收的全部频道好友（不传 contacts）
client.brand_message.broadcast(
    message_type="FW",
    friend_template_uuid="9cd5460b-6458-4edc-9b11-c26d3013c340",
)

# 活动查询。`from` 是 Python 关键字，因此参数名为 `from_`。
campaigns = client.brand_message.campaigns(from_="2026-08-01", count=10)
one = client.brand_message.campaign("1f0a6d0e-6b3b-4f0f-9b2f-2f6f6a1b7c11")
```

群发可能触达整个好友列表，请从管理命令或受权限保护的管理后台操作触发，不要放在公开视图里。

`targeting` 的取值含义：

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

---

## 从信号中发送

请在事务**提交之后**发送，而不是在事务内部 —— 否则回滚了消息也已经发出去了：

```python
# orders/signals.py
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Order
from .tasks import send_order_confirm

@receiver(post_save, sender=Order)
def notify_order_created(sender, instance, created, **kwargs):
    if not created:
        return

    transaction.on_commit(
        lambda: send_order_confirm.delay(instance.phone, instance.number)
    )
```

---

## Celery 任务

发送是一次网络调用，请不要放在请求周期里：

```python
# orders/tasks.py
from celery import shared_task
from sendgo import SendgoError
from sendgo_django import client

@shared_task(bind=True, max_retries=3)
def send_order_confirm(self, phone: str, order_no: str):
    try:
        client.alimtalk.send(
            template_code="ORDER_CONFIRM_001",
            contacts=[{"contact": phone, "var1": order_no}],
        )
    except SendgoError as exc:
        # 5xx 为临时故障；4xx 重试也不会成功。
        if exc.status_code >= 500:
            raise self.retry(exc=exc, countdown=2 ** self.request.retries)
        raise
```

对 4xx 直接 `raise` 是关键：否则一个错误的模板代码会耗尽全部重试次数，看起来像临时故障。

---

## 管理命令

```python
# orders/management/commands/send_promo.py
from django.core.management.base import BaseCommand
from sendgo_django import client

class Command(BaseCommand):
    help = "向已同意接收的全部频道好友群发当前促销"

    def handle(self, *args, **options):
        result = client.brand_message.broadcast(
            message_type="FW",
            friend_template_uuid="9cd5460b-6458-4edc-9b11-c26d3013c340",
        )

        self.stdout.write(self.style.SUCCESS(f"已接受：{result['data']['campaignId']}"))
```

---

## 测试

请 patch 惰性对象背后的构建函数，这样测试就不会真的发出请求：

```python
from unittest.mock import MagicMock, patch

@patch("sendgo_django.conf.get_client")
def test_order_creation_sends_alimtalk(get_client, db):
    fake = MagicMock()
    get_client.return_value = fake

    Order.objects.create(phone="01012345678", number="ORD-001")

    fake.alimtalk.send.assert_called_once()
```

patch `get_client` 而不是惰性对象本身，能保证各测试之间互不影响。若需要在测试中重建实例，可调用 `sendgo_django.conf.reset()` 丢弃已记忆化的客户端。

---

## 错误处理

```python
from sendgo import SendgoError
from sendgo_django import client

try:
    client.alimtalk.send(
        template_code="ORDER_CONFIRM_001",
        contacts=[{"contact": "01012345678"}],
    )
except SendgoError as e:
    if e.error_code in ("INVALID_ACCESS_KEY", "INVALID_SECRET_KEY"):
        logger.critical("请检查 Sendgo 密钥")
    elif e.error_code == "PAYMENT_REQUIRED":
        logger.critical("Sendgo 余额不足")
    elif e.error_code == "IP_NOT_ALLOWED":
        logger.critical("该 IP 未在白名单中")
    elif e.status_code >= 500:
        retry_later()
    else:
        logger.error("Sendgo %s: %s", e.status_code, e)
```

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

---

## 配置项

| 键 | 是否必填 | 默认值 | 说明 |
|----|----------|--------|------|
| `ACCESS_KEY` | **必填** | — | Sendgo 访问密钥 |
| `SECRET_KEY` | **必填** | — | Sendgo 私密密钥 |
| `KAKAO_SENDER_KEY` | 选填 | `None` | Kakao 发送者资料密钥 |
| `SMS_SENDER_KEY` | 选填 | `None` | 短信主叫号码密钥 |
| `API_VERSION` | 选填 | `"v1"` | API 版本（`v1` \| `v2`） |
| `BASE_URL` | 选填 | `"https://sendgo.io"` | API 基础地址 |

---

## 短链接

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

> 仅 v2 支持。

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

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

```python
from sendgo_django import client

created = client.short_url.create(
    target_url="https://example.com/promotions/summer-sale",
    title="夏季促销落地页",
)

code = created["data"]["code"]
stats = client.short_url.stats(code, from_="2026-08-01")
```

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

---

## 包信息

- **包名**：`sendgo-django`（PyPI）
- **仓库**：[send-go/django](https://github.com/send-go/django)
- **注册表**：https://pypi.org/project/sendgo-django/
- **许可**：MIT

### 如何获取 API 密钥

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