feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
695
docs/chuanglan-sms-js-guide.md
Normal file
695
docs/chuanglan-sms-js-guide.md
Normal file
@@ -0,0 +1,695 @@
|
||||
# 创蓝短信(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` 为准。
|
||||
269
docs/手动充值功能说明.md
Normal file
269
docs/手动充值功能说明.md
Normal file
@@ -0,0 +1,269 @@
|
||||
## 手动充值功能说明文档
|
||||
|
||||
---
|
||||
|
||||
### 一、功能概述
|
||||
|
||||
手动充值功能允许玩家通过银行转账或USDT方式进行充值,管理员在后台审核并确认充值后,系统自动为玩家上分。
|
||||
|
||||
**核心流程**:
|
||||
|
||||
```
|
||||
管理员配置收款方式(银行/USDT)
|
||||
↓
|
||||
玩家选择充值方式,输入金额,上传转账截图
|
||||
↓
|
||||
生成充值订单(状态:PENDING 审核中)
|
||||
↓
|
||||
管理员审核充值记录
|
||||
├─ 批准 → 自动给玩家钱包上分(可调整金额)
|
||||
└─ 拒绝 → 记录拒绝原因
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 二、数据库表结构
|
||||
|
||||
#### `payment_methods` — 收款方式配置表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BigInt | 主键 |
|
||||
| `method_type` | VARCHAR(20) | 类型:`BANK`(银行)/ `USDT` |
|
||||
| `bank_name` | VARCHAR(128) | 银行名称(BANK 类型使用) |
|
||||
| `account_holder` | VARCHAR(128) | 银行账户名(BANK 类型使用) |
|
||||
| `account_number` | VARCHAR(128) | 银行账号(BANK 类型使用) |
|
||||
| `usdt_address` | VARCHAR(256) | USDT 地址(USDT 类型使用) |
|
||||
| `qr_code_url` | VARCHAR(500) | USDT 二维码图片 URL(USDT 类型使用) |
|
||||
| `display_name` | VARCHAR(128) | 展示名称 |
|
||||
| `sort_order` | INT | 排序序号,越小越靠前 |
|
||||
| `is_active` | BOOLEAN | 是否启用(管理员可见性) |
|
||||
| `show_on_player` | BOOLEAN | 是否对玩家展示 |
|
||||
| `created_by` | BigInt | 创建者 ID |
|
||||
| `created_at` / `updated_at` | DateTime | 创建/更新时间 |
|
||||
|
||||
#### `deposit_orders` — 充值订单表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | BigInt | 主键 |
|
||||
| `order_no` | VARCHAR(64) | 订单号(唯一),格式:`DEP{timestamp}{random}` |
|
||||
| `player_id` | BigInt | 玩家 ID |
|
||||
| `payment_method_id` | BigInt | 关联的收款方式 ID |
|
||||
| `method_type` | VARCHAR(20) | 冗余的类型(BANK/USDT),便于筛选 |
|
||||
| `amount` | DECIMAL(18,4) | 玩家申报的充值金额 |
|
||||
| `screenshot_url` | VARCHAR(500) | 转账截图 URL |
|
||||
| `status` | VARCHAR(20) | 状态:`PENDING` / `APPROVED` / `REJECTED` |
|
||||
| `approved_amount` | DECIMAL(18,4) | 实际批准的金额(批准时写入,可能与申报金额不同) |
|
||||
| `reviewer_id` | BigInt | 审核人 ID |
|
||||
| `reviewed_at` | DateTime | 审核时间 |
|
||||
| `reject_reason` | VARCHAR(500) | 拒绝原因 |
|
||||
| `remark` | VARCHAR(500) | 备注 |
|
||||
| `created_at` / `updated_at` | DateTime | 创建/更新时间 |
|
||||
|
||||
---
|
||||
|
||||
### 三、管理后台操作
|
||||
|
||||
#### 3.1 配置收款方式
|
||||
|
||||
**入口**:管理后台左侧菜单 → 「收款方式」
|
||||
|
||||
支持两种类型:
|
||||
|
||||
**银行转账**:
|
||||
- 填写银行名称(如:工商银行)
|
||||
- 填写账户持有人姓名
|
||||
- 填写银行账号
|
||||
- 设置展示名称(可选)
|
||||
- 设置排序、是否启用、是否展示给玩家
|
||||
|
||||
**USDT 充值**:
|
||||
- 填写 USDT 收款地址
|
||||
- 上传 USDT 二维码图片(通过后台媒体库上传,类别为 `payments`)
|
||||
- 设置展示名称(可选)
|
||||
- 设置排序、是否启用、是否展示给玩家
|
||||
|
||||
**操作说明**:
|
||||
- `Active` 开关:控制该收款方式是否启用(管理员可见)
|
||||
- `Show Player` 开关:控制是否在前台展示给玩家
|
||||
- 一个收款方式可以同时启用但不展示给玩家(用于暂停充值)
|
||||
- 支持创建多个同类型的收款方式
|
||||
|
||||
#### 3.2 审核充值订单
|
||||
|
||||
**入口**:管理后台左侧菜单 → 「充值审核」
|
||||
|
||||
**列表功能**:
|
||||
- 显示所有充值订单,包含:订单号、玩家、收款方式类型、金额、截图、状态、批准金额、审核人、时间
|
||||
- 支持按状态筛选(全部/待审核/已批准/已拒绝)
|
||||
- 支持按类型筛选(全部/银行/USDT)
|
||||
- 支持按玩家用户名搜索
|
||||
- 点击截图缩略图可放大查看
|
||||
|
||||
**批准操作**:
|
||||
1. 点击订单行的「Approve」按钮
|
||||
2. 在弹窗中查看截图
|
||||
3. 系统默认填入玩家申报的金额,管理员可根据截图实际情况调整金额
|
||||
4. 可选填写备注
|
||||
5. 点击「Confirm Approve」确认
|
||||
|
||||
**批准后自动执行**:
|
||||
- 订单状态变为 `APPROVED`
|
||||
- 系统自动调用钱包充值服务,将批准金额添加到玩家可用余额
|
||||
- 生成一条钱包交易记录,类型为 `PLAYER_DEPOSIT`
|
||||
- 记录审核人和审核时间
|
||||
|
||||
**拒绝操作**:
|
||||
1. 点击订单行的「Reject」按钮
|
||||
2. 在弹窗中必须填写拒绝原因
|
||||
3. 点击「Confirm Reject」确认
|
||||
|
||||
**拒绝后**:
|
||||
- 订单状态变为 `REJECTED`
|
||||
- 记录拒绝原因和审核人
|
||||
- 玩家不会收到任何资金
|
||||
|
||||
---
|
||||
|
||||
### 四、玩家端操作
|
||||
|
||||
#### 4.1 充值入口
|
||||
|
||||
- 玩家登录后,进入「账单」页面
|
||||
- 顶部有一个「+ 充值」按钮,点击进入充值页面
|
||||
- 或直接访问 `/wallet/recharge`
|
||||
|
||||
#### 4.2 充值流程
|
||||
|
||||
1. **选择充值方式**:顶部有「银行转账」和「USDT」两个标签切换
|
||||
2. **选择收款账户**:系统展示管理员配置的可用收款方式列表,点击选中
|
||||
3. **查看收款信息**:
|
||||
- 银行转账:显示银行名称、账户持有人、银行账号(支持点击复制)
|
||||
- USDT:显示 USDT 地址(支持点击复制)和二维码图片
|
||||
4. **输入充值金额**:输入实际转账的金额
|
||||
5. **上传转账截图**:
|
||||
- 点击上传区域选择图片
|
||||
- 前端自动压缩图片(目标大小 ≤1MB,最大分辨率 1920px)
|
||||
- 原文件限制 10MB,压缩后限制 5MB
|
||||
- 支持预览和删除已选截图
|
||||
6. **提交充值**:点击提交按钮,系统创建充值订单
|
||||
7. **成功提示**:显示订单号,提示等待管理员审核
|
||||
|
||||
#### 4.3 查看充值记录
|
||||
|
||||
- 充值页面右上角有「记录」链接,或访问 `/wallet/recharge/history`
|
||||
- 显示所有充值订单列表,每条订单显示:
|
||||
- 收款方式类型标签
|
||||
- 充值金额
|
||||
- 状态(审核中/已通过/已拒绝)
|
||||
- 收款方式名称、提交时间
|
||||
- 如果批准金额与申报金额不同,显示「实际到账」金额
|
||||
- 如果被拒绝,显示拒绝原因
|
||||
- 支持下拉刷新
|
||||
|
||||
---
|
||||
|
||||
### 五、API 接口列表
|
||||
|
||||
#### 管理后台接口
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `POST` | `/admin/payment-methods` | `deposit.manage` | 创建收款方式 |
|
||||
| `GET` | `/admin/payment-methods` | `deposit.manage` | 查看所有收款方式 |
|
||||
| `PUT` | `/admin/payment-methods/:id` | `deposit.manage` | 更新收款方式 |
|
||||
| `DELETE` | `/admin/payment-methods/:id` | `deposit.manage` | 停用收款方式(软删除) |
|
||||
| `GET` | `/admin/deposit-orders` | `deposit.review` | 分页查看充值订单 |
|
||||
| `POST` | `/admin/deposit-orders/:id/approve` | `deposit.review` | 批准充值订单 |
|
||||
| `POST` | `/admin/deposit-orders/:id/reject` | `deposit.review` | 拒绝充值订单 |
|
||||
|
||||
**充值订单列表查询参数**:
|
||||
- `page` — 页码(默认 1)
|
||||
- `pageSize` — 每页数量(默认 20,最大 100)
|
||||
- `status` — 状态筛选:`PENDING` / `APPROVED` / `REJECTED`
|
||||
- `keyword` — 玩家用户名关键词
|
||||
- `methodType` — 类型筛选:`BANK` / `USDT`
|
||||
- `dateFrom` / `dateTo` — 日期范围(ISO 格式)
|
||||
|
||||
**批准充值订单请求体**:
|
||||
```json
|
||||
{
|
||||
"approvedAmount": 100.00, // 可选,不传则使用玩家申报金额
|
||||
"remark": "备注信息" // 可选
|
||||
}
|
||||
```
|
||||
|
||||
**拒绝充值订单请求体**:
|
||||
```json
|
||||
{
|
||||
"reason": "截图金额与申报金额不符" // 必填
|
||||
}
|
||||
```
|
||||
|
||||
#### 玩家端接口
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `GET` | `/player/payment-methods` | 需要 | 查看可用的收款方式列表 |
|
||||
| `POST` | `/player/deposit-orders` | 需要 | 提交充值订单(multipart/form-data) |
|
||||
| `GET` | `/player/deposit-orders` | 需要 | 查看自己的充值记录 |
|
||||
|
||||
**提交充值订单**(multipart/form-data):
|
||||
- `paymentMethodId` — 收款方式 ID
|
||||
- `amount` — 充值金额
|
||||
- `screenshot` — 转账截图图片文件(最大 5MB,仅接受图片类型)
|
||||
|
||||
**查看收款方式请求参数**:
|
||||
- `methodType` — 可选,筛选类型:`BANK` / `USDT`
|
||||
|
||||
---
|
||||
|
||||
### 六、文件存储
|
||||
|
||||
充值相关截图存储在 `uploads/deposits/` 目录下,文件命名格式:`{timestamp}-{random8}.{ext}`
|
||||
|
||||
USDT 二维码图片通过管理员上传接口上传,存储在 `uploads/payments/` 目录下。
|
||||
|
||||
**上传大小限制**:
|
||||
- 管理员上传(USDT 二维码):5MB
|
||||
- 玩家上传(充值截图):5MB(前端压缩后),原文件 10MB
|
||||
|
||||
---
|
||||
|
||||
### 七、错误码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| `INVALID_METHOD_TYPE` | 无效的收款方式类型 |
|
||||
| `PAYMENT_METHOD_NOT_FOUND` | 收款方式不存在或已停用 |
|
||||
| `ORDER_NOT_FOUND` | 充值订单不存在 |
|
||||
| `ORDER_NOT_PENDING` | 订单不是待审核状态 |
|
||||
| `REASON_REQUIRED` | 拒绝原因为必填项 |
|
||||
| `SCREENSHOT_REQUIRED` | 转账截图为必填项 |
|
||||
| `FILE_MUST_BE_IMAGE` | 必须上传图片文件 |
|
||||
| `INVALID_AMOUNT` | 金额无效 |
|
||||
| `PAYMENT_METHOD_REQUIRED` | 未选择收款方式 |
|
||||
|
||||
---
|
||||
|
||||
### 八、权限配置
|
||||
|
||||
新增两个后台权限码,需要在数据库中通过种子数据或手动添加到 `permissions` 表:
|
||||
|
||||
| 权限码 | 说明 |
|
||||
|--------|------|
|
||||
| `deposit.manage` | 管理收款方式 |
|
||||
| `deposit.review` | 审核充值订单 |
|
||||
|
||||
并将这些权限分配给需要操作充值功能的管理员角色(通过 `role_permissions` 表关联)。
|
||||
|
||||
---
|
||||
|
||||
### 九、注意事项
|
||||
|
||||
1. **金额调整**:批准充值时,管理员可以调整实际充值金额。当玩家输入的金额与截图显示的不一致时,以截图为准进行调整。
|
||||
2. **并发审核**:批准操作在数据库事务中执行,会检查订单状态是否为 `PENDING`,防止多人同时审核同一笔订单。
|
||||
3. **停用收款方式**:删除操作为软删除(设置 `isActive=false` 和 `showOnPlayer=false`),不会删除历史记录。
|
||||
4. **截图审核**:管理员应仔细核对截图中的转账金额、时间、收款账户是否与配置的收款方式一致。
|
||||
5. **前端图片压缩**:使用 `browser-image-compression` 库在浏览器端压缩图片,减少上传时间和服务器存储压力。
|
||||
Reference in New Issue
Block a user