# 创蓝短信（Chuanglan）TypeScript 全栈接入指南

> 适用场景：**全新独立项目**，TypeScript 全栈（Next.js / Remix / Nuxt 等），直连创蓝 API。  
> 服务商：创蓝 253 云通讯（国际短信网关）  
> API Endpoint：`https://sgap.253.com/send/sms`  
> 签名算法参考：`babylive-backend` 中 `ChuanglanClient.java` + `SignUtil.java`（已验证可用）

---

## 1. 整体架构

创蓝 `account` / `password` 是服务端密钥，**只能在服务端调用**，浏览器/客户端绝不直接接触创蓝。

```
┌─────────────┐     POST /api/sms/send      ┌──────────────────┐     POST + sign     ┌─────────────┐
│  前端页面    │ ──────────────────────────▶│  TS 服务端        │ ──────────────────▶│  创蓝 API    │
│  (React 等)  │◀────────────────────────── │  API Route / tRPC │◀────────────────── │  253.com    │
└─────────────┘     { sessionId }          └──────────────────┘     messageId        └─────────────┘
                                                      │
                                                      ▼
                                               Redis / KV 缓存
                                               （验证码 + 频控）
```

职责划分：

| 层 | 职责 |
|----|------|
| 前端 | 收集手机号、触发发送、倒计时 60s、提交验证码 + `sessionId` |
| 服务端 API | 频控、生成验证码、调创蓝、存/验缓存 |
| `lib/chuanglan` | 签名 + HTTP 请求，不含业务逻辑 |
| Redis | 验证码存储（5 分钟 TTL）、手机号/IP 频控（60 秒 TTL） |

---

## 2. 环境变量

```bash
# .env.local（勿提交 Git）

CHUANGLAN_ACCOUNT=your_account
CHUANGLAN_PASSWORD=your_password
CHUANGLAN_ENDPOINT=https://sgap.253.com/send/sms
CHUANGLAN_CONNECT_TIMEOUT_MS=10000
CHUANGLAN_READ_TIMEOUT_MS=10000

# 验证码业务
SMS_CODE_TTL_SECONDS=300        # 5 分钟
SMS_RATE_LIMIT_SECONDS=60       # 发送冷却

REDIS_URL=redis://127.0.0.1:6379
```

创蓝账号信息可向运维索取（与 babylive-backend `application.yml` 中 `chuanglan.*` 同源）。

---

## 3. 推荐目录结构

以 Next.js App Router 为例，其他 TS 全栈框架可平移 `lib/` 与 `types/`：

```
src/
├── lib/
│   ├── chuanglan/
│   │   ├── client.ts        # 创蓝 HTTP Client
│   │   ├── sign.ts          # MD5 签名
│   │   └── config.ts        # 读取环境变量
│   └── sms/
│       ├── templates.ts     # 多语言短信模板
│       ├── code.ts          # 验证码生成
│       └── service.ts       # 发送 / 校验业务
├── app/
│   └── api/
│       └── sms/
│           ├── send/route.ts
│           └── verify/route.ts
├── types/
│   └── sms.ts
└── hooks/
    └── use-sms-code.ts      # 前端发送 + 倒计时
```

---

## 4. 创蓝 API 协议

### 4.1 请求

**Method：** `POST`  
**URL：** `https://sgap.253.com/send/sms`

**Headers：**

| Header | 说明 |
|--------|------|
| `Content-Type` | `application/json` |
| `nonce` | 毫秒时间戳字符串，如 `1718000000123` |
| `sign` | MD5 签名，见 4.2 |

**Body：**

```json
{
  "account": "your_account",
  "mobile": "8613800138000",
  "msg": "您的验证码是：123456。5分钟内有效。",
  "uid": "optional-session-id"
}
```

| 字段 | 必填 | 说明 |
|------|------|------|
| account | 是 | 创蓝账号 |
| mobile | 是 | 目标手机号，建议带国家码 |
| msg | 是 | 短信正文 |
| uid | 否 | 自定义 ID，建议传本次验证码会话 ID |

> `nonce` 参与签名，放 Header，**不进 Body**。

### 4.2 签名算法

1. 取 Body 全部字段 + `nonce`，组成键值对
2. 按 key **字典序升序**（等价 Java `TreeMap`）
3. 依次拼接 `key + value`，**跳过空值**（`null` / `""` / 纯空白）
4. 末尾追加 `password`
5. 整体做 **MD5**，输出 **32 位小写** hex

```
sign = md5("account" + account + "mobile" + mobile + "msg" + msg + "nonce" + nonce + password)
```

### 4.3 响应

成功（`code === "0"`）：

```json
{
  "code": "0",
  "message": "提交成功",
  "data": { "messageId": "162575412960104448" }
}
```

失败时 `code` 为非 `"0"` 字符串，`message` 为错误描述。

---

## 5. TypeScript 类型

```typescript
// src/types/sms.ts

export type SmsLang = 'zh' | 'en' | 'vi' | 'ms' | 'kh';

export interface ChuanglanSendBody {
  account: string;
  mobile: string;
  msg: string;
  uid?: string;
}

export interface ChuanglanSendResponse {
  code: string;
  message: string;
  data?: { messageId: string };
}

export interface SmsSendResult {
  success: boolean;
  code: string;
  message: string;
  messageId?: string;
}

export interface SendSmsCodeRequest {
  phone: string;
  lang?: SmsLang;
}

export interface SendSmsCodeResponse {
  sessionId: string;
}

export interface VerifySmsCodeRequest {
  phone: string;
  code: string;
  sessionId: string;
}

export interface VerifySmsCodeResponse {
  ok: true;
}
```

---

## 6. 服务端实现

### 6.1 配置

```typescript
// src/lib/chuanglan/config.ts

function required(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing env: ${name}`);
  return v;
}

export const chuanglanConfig = {
  account: required('CHUANGLAN_ACCOUNT'),
  password: required('CHUANGLAN_PASSWORD'),
  endpoint: process.env.CHUANGLAN_ENDPOINT ?? 'https://sgap.253.com/send/sms',
  connectTimeoutMs: Number(process.env.CHUANGLAN_CONNECT_TIMEOUT_MS ?? 10_000),
  readTimeoutMs: Number(process.env.CHUANGLAN_READ_TIMEOUT_MS ?? 10_000),
} as const;

export const smsConfig = {
  codeTtlSeconds: Number(process.env.SMS_CODE_TTL_SECONDS ?? 300),
  rateLimitSeconds: Number(process.env.SMS_RATE_LIMIT_SECONDS ?? 60),
} as const;
```

### 6.2 签名

```typescript
// src/lib/chuanglan/sign.ts
import crypto from 'node:crypto';

export function generateChuanglanSign(
  password: string,
  params: Record<string, string | undefined>,
): string {
  const raw = Object.keys(params)
    .sort()
    .reduce((acc, key) => {
      const value = params[key];
      if (value != null && value.trim() !== '') {
        return acc + key + value;
      }
      return acc;
    }, '');

  return crypto.createHash('md5').update(raw + password, 'utf8').digest('hex').toLowerCase();
}
```

### 6.3 创蓝 Client

```typescript
// src/lib/chuanglan/client.ts
import { chuanglanConfig } from './config';
import { generateChuanglanSign } from './sign';
import type { ChuanglanSendResponse, SmsSendResult } from '@/types/sms';

export async function sendChuanglanSms(
  mobile: string,
  msg: string,
  uid?: string,
): Promise<SmsSendResult> {
  const nonce = String(Date.now());

  const body: Record<string, string> = {
    account: chuanglanConfig.account,
    mobile,
    msg,
  };
  if (uid) body.uid = uid;

  const sign = generateChuanglanSign(chuanglanConfig.password, { ...body, nonce });

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), chuanglanConfig.readTimeoutMs);

  try {
    const res = await fetch(chuanglanConfig.endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        nonce,
        sign,
      },
      body: JSON.stringify(body),
      signal: controller.signal,
    });

    const data = (await res.json()) as ChuanglanSendResponse;

    if (data.code === '0') {
      return {
        success: true,
        code: data.code,
        message: 'OK',
        messageId: data.data?.messageId,
      };
    }

    return { success: false, code: data.code, message: data.message };
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error';
    return { success: false, code: 'HTTP_ERROR', message };
  } finally {
    clearTimeout(timer);
  }
}
```

### 6.4 短信模板

与 babylive-backend `sms.verify` 配置一致：

```typescript
// src/lib/sms/templates.ts
import type { SmsLang } from '@/types/sms';

const TEMPLATES: Record<string, string> = {
  default: '您的验证码是：{code}。5分钟内有效。',
  zh: '您的验证码是：{code}。5分钟内有效。',
  en: 'Your verification code is {code}. Valid for 5 minutes.',
  vi: 'Mã xác minh của bạn là {code}. Có hiệu lực trong 5 phút.',
  ms: 'Kod pengesahan anda ialah {code}. Sah selama 5 minit.',
  kh: 'កូដផ្ទៀងផ្ទាត់របស់អ្នកគឺ {code} ។ មានសុពលភាពរយៈពេល ៥ នាទី។',
};

export function renderVerifySms(lang: SmsLang | undefined, code: string): string {
  const key = lang?.trim() || 'zh';
  const tpl = TEMPLATES[key] ?? TEMPLATES.default ?? TEMPLATES.zh;
  return tpl.replace('{code}', code);
}
```

```typescript
// src/lib/sms/code.ts

export function generateSixDigitCode(): string {
  return String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
}
```

### 6.5 业务 Service（Redis）

```typescript
// src/lib/sms/service.ts
import { randomUUID } from 'node:crypto';
import { sendChuanglanSms } from '@/lib/chuanglan/client';
import { smsConfig } from '@/lib/chuanglan/config';
import { generateSixDigitCode } from './code';
import { renderVerifySms } from './templates';
import type { SmsLang } from '@/types/sms';

// 按项目替换为 ioredis / @upstash/redis 等
import { redis } from '@/lib/redis';

const codeKey = (sessionId: string) => `sms:code:${sessionId}`;
const phoneRateKey = (phone: string) => `sms:rate:phone:${phone}`;
const ipRateKey = (ip: string) => `sms:rate:ip:${ip}`;

export class SmsRateLimitError extends Error {
  constructor() {
    super('发送太频繁，请60秒后再试');
    this.name = 'SmsRateLimitError';
  }
}

export class SmsSendError extends Error {
  code: string;
  constructor(code: string, message: string) {
    super(message);
    this.name = 'SmsSendError';
    this.code = code;
  }
}

export async function sendVerifyCode(params: {
  phone: string;
  lang?: SmsLang;
  clientIp: string;
}): Promise<{ sessionId: string }> {
  const { phone, lang, clientIp } = params;

  const [phoneLimited, ipLimited] = await Promise.all([
    redis.exists(phoneRateKey(phone)),
    redis.exists(ipRateKey(clientIp)),
  ]);
  if (phoneLimited || ipLimited) throw new SmsRateLimitError();

  const code = generateSixDigitCode();
  const sessionId = randomUUID();
  const msg = renderVerifySms(lang, code);

  const result = await sendChuanglanSms(phone, msg, sessionId);
  if (!result.success) {
    throw new SmsSendError(result.code, result.message);
  }

  await Promise.all([
    redis.set(codeKey(sessionId), JSON.stringify({ phone, code }), 'EX', smsConfig.codeTtlSeconds),
    redis.set(phoneRateKey(phone), '1', 'EX', smsConfig.rateLimitSeconds),
    redis.set(ipRateKey(clientIp), '1', 'EX', smsConfig.rateLimitSeconds),
  ]);

  return { sessionId };
}

export async function verifyCode(params: {
  phone: string;
  code: string;
  sessionId: string;
}): Promise<void> {
  const raw = await redis.get(codeKey(params.sessionId));
  if (!raw) throw new Error('验证码已过期');

  const cached = JSON.parse(raw) as { phone: string; code: string };
  if (cached.phone !== params.phone || cached.code !== params.code) {
    throw new Error('验证码错误');
  }

  await redis.del(codeKey(params.sessionId));
}
```

---

## 7. API Route（Next.js 示例）

### 7.1 发送验证码

```typescript
// src/app/api/sms/send/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendVerifyCode, SmsRateLimitError, SmsSendError } from '@/lib/sms/service';
import type { SendSmsCodeRequest } from '@/types/sms';

function getClientIp(req: NextRequest): string {
  return (
    req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
    || req.headers.get('x-real-ip')
    || '0.0.0.0'
  );
}

export async function POST(req: NextRequest) {
  const body = (await req.json()) as SendSmsCodeRequest;

  if (!body.phone?.trim()) {
    return NextResponse.json({ message: 'phone 必填' }, { status: 400 });
  }

  try {
    const { sessionId } = await sendVerifyCode({
      phone: body.phone.trim(),
      lang: body.lang,
      clientIp: getClientIp(req),
    });
    return NextResponse.json({ sessionId });
  } catch (err) {
    if (err instanceof SmsRateLimitError) {
      return NextResponse.json({ message: err.message }, { status: 429 });
    }
    if (err instanceof SmsSendError) {
      return NextResponse.json({ message: err.message, code: err.code }, { status: 502 });
    }
    return NextResponse.json({ message: '服务器错误' }, { status: 500 });
  }
}
```

### 7.2 校验验证码

```typescript
// src/app/api/sms/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyCode } from '@/lib/sms/service';
import type { VerifySmsCodeRequest } from '@/types/sms';

export async function POST(req: NextRequest) {
  const body = (await req.json()) as VerifySmsCodeRequest;

  if (!body.phone || !body.code || !body.sessionId) {
    return NextResponse.json({ message: '参数不完整' }, { status: 400 });
  }

  try {
    await verifyCode(body);
    return NextResponse.json({ ok: true });
  } catch (err) {
    const message = err instanceof Error ? err.message : '校验失败';
    return NextResponse.json({ message }, { status: 400 });
  }
}
```

### 7.3 对外 API 契约

**发送**

```
POST /api/sms/send
Content-Type: application/json

{ "phone": "8613800138000", "lang": "zh" }

→ 200 { "sessionId": "uuid" }
→ 429 { "message": "发送太频繁，请60秒后再试" }
→ 502 { "message": "...", "code": "创蓝错误码" }
```

**校验**

```
POST /api/sms/verify
Content-Type: application/json

{ "phone": "8613800138000", "code": "123456", "sessionId": "uuid" }

→ 200 { "ok": true }
→ 400 { "message": "验证码错误或已过期" }
```

---

## 8. 前端调用

### 8.1 API Client

```typescript
// src/lib/api/sms.ts
import type { SendSmsCodeResponse, SmsLang, VerifySmsCodeResponse } from '@/types/sms';

export async function sendSmsCode(phone: string, lang: SmsLang = 'zh'): Promise<string> {
  const res = await fetch('/api/sms/send', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ phone, lang }),
  });

  const json = await res.json();
  if (!res.ok) throw new Error(json.message ?? '发送失败');
  return (json as SendSmsCodeResponse).sessionId;
}

export async function verifySmsCode(
  phone: string,
  code: string,
  sessionId: string,
): Promise<void> {
  const res = await fetch('/api/sms/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ phone, code, sessionId }),
  });

  const json = await res.json();
  if (!res.ok) throw new Error(json.message ?? '校验失败');
  void json as VerifySmsCodeResponse;
}
```

### 8.2 React Hook 示例

```typescript
// src/hooks/use-sms-code.ts
'use client';

import { useCallback, useRef, useState } from 'react';
import { sendSmsCode } from '@/lib/api/sms';
import type { SmsLang } from '@/types/sms';

const COOLDOWN_SECONDS = 60;

export function useSmsCode(lang: SmsLang = 'zh') {
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [countdown, setCountdown] = useState(0);
  const [sending, setSending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);

  const startCountdown = useCallback(() => {
    setCountdown(COOLDOWN_SECONDS);
    timerRef.current = setInterval(() => {
      setCountdown((prev) => {
        if (prev <= 1) {
          if (timerRef.current) clearInterval(timerRef.current);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);
  }, []);

  const send = useCallback(async (phone: string) => {
    if (countdown > 0 || sending) return;
    setSending(true);
    setError(null);
    try {
      const id = await sendSmsCode(phone, lang);
      setSessionId(id);
      startCountdown();
    } catch (err) {
      setError(err instanceof Error ? err.message : '发送失败');
    } finally {
      setSending(false);
    }
  }, [countdown, sending, lang, startCountdown]);

  return { sessionId, countdown, sending, error, send };
}
```

页面中使用：

```tsx
const { sessionId, countdown, sending, error, send } = useSmsCode('zh');

<button disabled={sending || countdown > 0} onClick={() => send(phone)}>
  {countdown > 0 ? `${countdown}s 后重试` : '获取验证码'}
</button>

// 提交表单时带上 sessionId + code 调 /api/sms/verify 或合并进登录/注册接口
```

---

## 9. 手机号格式

- 国际短信建议带国家码：`8613800138000`（`86` + 11 位）
- 前端可在提交前统一格式化，或在 `service.ts` 中做 normalize
- 创蓝账号为国际网关（`sgap.253.com`），非中国大陆号段需确认创蓝侧已开通对应路由

---

## 10. 业务规则（与 babylive 对齐）

| 规则 | 值 |
|------|-----|
| 验证码位数 | 6 位数字 |
| 验证码有效期 | 5 分钟 |
| 同手机号冷却 | 60 秒 |
| 同 IP 冷却 | 60 秒 |
| 校验成功后 | 立即删除缓存（一次性） |

---

## 11. 签名自测

接入后用固定参数验证签名是否与 Java 端一致：

```typescript
import { generateChuanglanSign } from '@/lib/chuanglan/sign';

const sign = generateChuanglanSign('your_password', {
  account: 'your_account',
  mobile: '8613800138000',
  msg: '您的验证码是：123456。5分钟内有效。',
  uid: 'test-session-001',
  nonce: '1718000000123',
});

console.log(sign);
// 应与 Java SignUtil.generateSign 输出完全相同
```

检查清单：

- [ ] key 字典序排序
- [ ] 空 `uid` 不参与签名
- [ ] `nonce` 在 Header + 签名参数，不在 Body
- [ ] MD5 32 位小写
- [ ] UTF-8 编码

---

## 12. 安全与运维

1. `CHUANGLAN_*` 仅服务端环境变量，不进 `NEXT_PUBLIC_*`
2. 日志中手机号脱敏、禁止打印验证码明文
3. 生产环境 Redis 必开；无 Redis 时不可用内存 Map（Serverless 多实例会失效）
4. `uid` / `sessionId` 建议用 UUID，便于与创蓝 `messageId` 对账
5. 监控创蓝 `code` 分布与 `HTTP_ERROR` 比例

---

## 13. 接入步骤速查

```
1. 配置 .env.local（创蓝账号 + Redis）
2. 复制 lib/chuanglan/*（sign + client）
3. 复制 lib/sms/*（templates + service）
4. 添加 /api/sms/send 与 /api/sms/verify
5. 前端 useSmsCode + 表单提交携带 sessionId
6. 跑签名自测，发一条真实短信验证
```

新项目按此文档从零接入即可，**无需依赖 babylive-backend 运行时**；签名算法以该仓库 `ChuanglanClient.java` 为准。
