新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
19 KiB
19 KiB
创蓝短信(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. 环境变量
# .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:
{
"account": "your_account",
"mobile": "8613800138000",
"msg": "您的验证码是:123456。5分钟内有效。",
"uid": "optional-session-id"
}
| 字段 | 必填 | 说明 |
|---|---|---|
| account | 是 | 创蓝账号 |
| mobile | 是 | 目标手机号,建议带国家码 |
| msg | 是 | 短信正文 |
| uid | 否 | 自定义 ID,建议传本次验证码会话 ID |
nonce参与签名,放 Header,不进 Body。
4.2 签名算法
- 取 Body 全部字段 +
nonce,组成键值对 - 按 key 字典序升序(等价 Java
TreeMap) - 依次拼接
key + value,跳过空值(null/""/ 纯空白) - 末尾追加
password - 整体做 MD5,输出 32 位小写 hex
sign = md5("account" + account + "mobile" + mobile + "msg" + msg + "nonce" + nonce + password)
4.3 响应
成功(code === "0"):
{
"code": "0",
"message": "提交成功",
"data": { "messageId": "162575412960104448" }
}
失败时 code 为非 "0" 字符串,message 为错误描述。
5. 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 配置
// 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 签名
// 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
// 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 配置一致:
// 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);
}
// 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)
// 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 发送验证码
// 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 校验验证码
// 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
// 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 示例
// 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 };
}
页面中使用:
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 端一致:
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. 安全与运维
CHUANGLAN_*仅服务端环境变量,不进NEXT_PUBLIC_*- 日志中手机号脱敏、禁止打印验证码明文
- 生产环境 Redis 必开;无 Redis 时不可用内存 Map(Serverless 多实例会失效)
uid/sessionId建议用 UUID,便于与创蓝messageId对账- 监控创蓝
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 为准。