# 创蓝短信(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 { 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 { const nonce = String(Date.now()); const body: Record = { 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 = { 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 { 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 { 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 { 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(null); const [countdown, setCountdown] = useState(0); const [sending, setSending] = useState(false); const [error, setError] = useState(null); const timerRef = useRef | 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'); // 提交表单时带上 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` 为准。