docs: 从 main 同步 AGENTS、文档与部署脚本

This commit is contained in:
2026-06-18 14:58:25 +08:00
parent 3212cd6040
commit ea695012cc
7 changed files with 649 additions and 763 deletions

View File

@@ -226,55 +226,226 @@ pnpm docker:ps
---
## 八、后续更新部署
## 八、推荐发版流程:本地打包 → 上传 → 线上更新
**推荐:先删旧代码再解压新 zip**(避免 `packages/shared/public/球员` 等中文目录残留导致 Vite 构建失败)
**日常更新推荐走这条链路**,不在服务器上编译,省 CPU/内存,发版包可重复部署
推荐主流程是:本地或构建机生成版本化镜像包 → 上传 tar 与 manifest → 服务器执行 `deploy-update.sh --images ... --tag ...`。详细步骤见:[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)(脚本位于 `docs/docker/build-and-export-images.ps1` / `build-and-export-images.sh`)。
```text
本地 Windows/Mac 宝塔/SCP 上传 服务器终端
───────────────── ─────────────── ─────────────────
checkout 目标分支 → 只传 tar + manifest → deploy-update.sh
build 三端镜像 到 /www/wwwroot/thebet365 自动备份+迁移+重启
导出 .tar + .manifest 不要覆盖 .env.docker
```
### 方式 A服务器直接拉代码并构建
镜像构建细节见:[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)。
---
### 步骤 1本地打包镜像
#### 1.1 前置条件
- 已安装 **Docker Desktop**Windows/Mac或 Linux Docker
- 在项目根目录,且已 `git checkout` 到要发布的分支(如 `main``theme-4`
#### 1.2 确认环境文件
本地需有 `.env.docker`(可从 `.env.docker.example` 复制)。构建 **admin** 镜像时会读取其中的 `VITE_PLAYER_URL`(玩家站公网地址,用于邀请链接)。若玩家域名有变,**改 `.env.docker` 后需重新打包 admin**。
#### 1.3 执行构建脚本
**Windows推荐 CMD**
```bat
cd C:\path\to\thebet365
docs\docker\build-and-export-images.bat --tag latest
```
带版本号(便于回滚追溯):
```bat
docs\docker\build-and-export-images.bat --tag v20260618
```
**Linux / macOS / Git Bash**
```bash
cd /path/to/thebet365
chmod +x docs/docker/build-and-export-images.sh
./docs/docker/build-and-export-images.sh --tag latest
```
首次或发版建议不加 `--use-cache`(默认全量构建)。仅重新导出已有镜像时:
```bat
docs\docker\build-and-export-images.bat --export-only --tag latest
```
#### 1.4 构建产物(在项目根目录)
| 文件 | 说明 |
|------|------|
| `thebet365-images-<tag>.tar` | 含 `api` / `player` / `admin` 三个镜像,约 200300 MB |
| `thebet365-images-<tag>.manifest.txt` | 记录 tag、构建时间、`git_commit`、镜像 ID、tar SHA-256 |
示例:`thebet365-images-latest.tar``thebet365-images-latest.manifest.txt`
> 这两个文件已在 `.gitignore` 中,**不要提交到 Git**。
---
### 步骤 2上传到服务器
#### 2.1 上传目标目录
服务器项目目录,例如:
```text
/www/wwwroot/thebet365
```
#### 2.2 本次更新需要上传的文件
| 文件 | 是否必传 |
|------|----------|
| `thebet365-images-<tag>.tar` | **必传** |
| `thebet365-images-<tag>.manifest.txt` | 建议传(便于核对版本) |
可用 **宝塔 → 文件 → 上传**,或 SCP
```bash
scp thebet365-images-latest.tar thebet365-images-latest.manifest.txt \
root@你的服务器IP:/www/wwwroot/thebet365/
```
#### 2.3 不要覆盖的文件
| 文件/目录 | 说明 |
|-----------|------|
| **`.env.docker`** | 生产密钥、端口、域名配置;**保留服务器上原有文件** |
| `postgres_data` 等 Docker 卷 | 数据库与上传文件,不在文件管理里替换 |
服务器目录里应**早已存在**(首次部署时上传过):`docker-compose.prod.yml``scripts/``docker/` 等。仅换镜像时**不必**重传整份代码。
若部署脚本有更新(如 `scripts/deploy-lib.sh`),可单独上传覆盖 `scripts/` 目录。
---
### 步骤 3服务器执行更新脚本
SSH 或 **宝塔 → 终端**,进入项目目录:
```bash
cd /www/wwwroot/thebet365
./scripts/deploy-update.sh --pull
chmod +x scripts/*.sh
./scripts/deploy-update.sh --images thebet365-images-latest.tar --tag latest
```
### 方式 B上传 zip 后在服务器构建
`--tag` 必须与打包时一致(上例为 `latest`;若本地用了 `--tag v20260618`,这里也要写 `v20260618`)。
保留原来的 `.env.docker`,替换代码后执行:
#### 脚本会自动完成(按顺序)
1. 检查 `.env.docker`(缺少密钥会报错;示例密码仅**警告**,不阻断)
2. 启动并等待 PostgreSQL / Redis 就绪
3. **更新前备份**`./backups/thebet365-db-pre-update-<时间>.sql.gz``thebet365-uploads-pre-update-<时间>.tar.gz`
4. `docker load -i` 导入镜像包
5. 用新 API 镜像执行 `prisma migrate deploy`(数据库结构变更)
6. 重启/替换 `api``player``admin` 容器
7. 等待健康检查通过,执行 `prisma migrate status`
8. 将本次发布写入 `.deploy/current-release.env`(含备份路径)
#### `.env.docker` 会被改什么?
- **不会**整文件覆盖,**不会**改 `JWT_SECRET``POSTGRES_PASSWORD`
- 部署结束时可能只更新一行:`IMAGE_TAG=<本次 tag>`
#### 首次用镜像包部署(新机器)
若服务器从未部署过,用首次脚本:
```bash
cd /www/wwwroot/thebet365
./scripts/deploy-update.sh
./scripts/deploy-first.sh --images thebet365-images-latest.tar --tag latest
```
### 方式 C上传已构建镜像包
---
### 步骤 4验证是否成功
```bash
cd /www/wwwroot/thebet365
./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
# 容器状态api / player / admin 应为 healthy
docker compose -f docker-compose.prod.yml --env-file .env.docker ps
# 迁移是否全部应用
docker compose -f docker-compose.prod.yml --env-file .env.docker exec api \
sh -c 'cd /app/apps/api && npx prisma migrate status'
# 本次发布记录与备份路径
cat .deploy/current-release.env
# 今天是否生成了新备份
ls -lh backups/ | tail -5
# API 日志(无报错即可)
docker compose -f docker-compose.prod.yml --env-file .env.docker logs --tail=50 api
```
更新脚本默认会:
浏览器:玩家站、管理站各访问一次;必要时强刷或清 CDN 缓存。
- 先备份 PostgreSQL 与 uploads 到 `./backups/`,并生成 `.sha256`
- 构建或加载指定 tag 的新镜像
- 使用新 API 镜像执行 `prisma migrate deploy`
- 启动/替换 API、玩家端、管理端容器
- 等待 API、玩家端、管理端健康检查通过
- 执行 `prisma migrate status` 检查数据库迁移状态
- 将当前发布写入 `.deploy/current-release.env`,并保留上一次发布到 `.deploy/previous-release.env`
---
除非已经手工确认有其他备份,否则不要使用 `--no-backup`
### 步骤 5部署后管理端配置镜像不会自动开启
若本次更新含站内邮箱、员工菜单等新功能,需在**管理后台**手动配置:
1. **员工管理** → 给员工勾选可见菜单(`visibleMenus`
2. **内容管理** → 开启 Inbox站内邮箱及充值/Banner/公告通知开关
3. 验证:充值审核后玩家收到站内信、侧栏充值待审角标、在线人数等
---
### 备份说明
| 项目 | 说明 |
|------|------|
| **何时备份** | 执行 `deploy-update.sh` 时,在**加载新镜像、跑迁移之前** |
| **备份内容** | 更新前一刻的 PostgreSQL 全库 + `uploads` 用户上传文件 |
| **存放位置** | `/www/wwwroot/thebet365/backups/` |
| **文件命名** | `thebet365-db-pre-update-YYYYMMDD-HHMMSS.sql.gz``thebet365-uploads-pre-update-....tar.gz` |
| **记录位置** | `.deploy/current-release.env` 中的 `db_backup=``uploads_backup=` |
手动备份(不更新时也可执行):
```bash
./scripts/backup-db.sh
./scripts/backup-prod.sh --prefix manual
```
**恢复数据库**(仅出问题时):先 `stop api`,再将 `.sql.gz` 导入 postgres最后 `start api`。项目无一键恢复脚本,需手工操作;详见下方回滚说明。
---
### 其他更新方式(备选)
| 方式 | 适用场景 | 命令 |
|------|----------|------|
| **A服务器拉代码构建** | 服务器性能足够、不用传 tar | `./scripts/deploy-update.sh --pull` |
| **B上传 zip 后服务器构建** | 无 Git、在服务器编译 | 替换代码后 `./scripts/deploy-update.sh` |
方式 B 上传代码时**保留** `.env.docker`;若用 zip 覆盖,注意清理旧的中文目录 `packages/shared/public/球员`(见第九节故障排查)。
---
### 回滚应用镜像
```bash
cd /www/wwwroot/thebet365
./scripts/rollback.sh --to v1.2.2
./scripts/rollback.sh --to <旧tag>
```
回滚脚本只切换 `api` / `player` / `admin` 镜像 tag不自动恢复数据库。若新版本包含不可逆迁移或已写入不兼容数据,需要先按 `backups/` 中的 `.sql.gz` 备份手工恢复 PostgreSQL执行镜像回滚。
`rollback.sh` **只切换** `api` / `player` / `admin` 镜像 tag**不自动恢复数据库**。若新版本已执行不可逆迁移,需先从 `backups/`选取 `pre-update` `.sql.gz` 手工恢复 PostgreSQL再回滚镜像
除非已确认另有备份,否则不要使用 `--no-backup` 跳过自动备份。
---
@@ -329,10 +500,10 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker build --no-cach
## 十、与本地开发的区别
| 场景 | 命令 |
| 场景 | 命令 / 文档 |
|------|------|
| 本地开发(仅 DB 用 Docker | `docker compose up -d` + `pnpm dev` |
| 生产首次部署 | `./scripts/deploy-first.sh` |
| 生产后续更新 | `./scripts/deploy-update.sh` |
| 生产后续更新(推荐) | 本地打包 → 上传 tar → `./scripts/deploy-update.sh --images ... --tag ...`(见第八节) |
相关文档:[项目启动指南.md](./项目启动指南.md)

View File

@@ -0,0 +1,247 @@
# 管理端页面切换性能分析与优化任务
> 分析日期2026-06-18
> 范围:`apps/admin` 侧边栏切换路由时的响应速度(非玩家端)。
---
## 任务清单
- [ ] **measure-baseline**:用 DevTools Network/Performance 记录慢路径基线(`/users``/bets`、充值 tab 切换)
- [ ] **keepalive-layout**`ManageLayout``RouterView` 增加 `KeepAlive` + 列表页 `defineOptions({ name })`
- [ ] **list-stale-cache**:高频列表页改 `onActivated` + 模块级/短 TTL 缓存,避免 remount 全量 refetch
- [ ] **fix-deposit-tabs**`DepositManage``v-if``v-show` 或 keep-alive 子 tab
- [ ] **lighten-agent-manager**`AgentManager` 挂载 API 合并或按 tab 延迟加载
- [ ] **guard-session**`beforeEach` 去阻塞式 `ensureStaffSession``api` 拦截器减少 per-request reconcile
- [ ] **bundle-i18n-ep**Element Plus 按需引入 + 落地 `split-i18n` + `App.vue` CSS 瘦身
---
## 现象定义
用户感知的「切换慢」通常包含三段延迟叠加:
```mermaid
sequenceDiagram
participant User
participant RouterGuard
participant ChunkLoader
participant PageView
participant API
User->>RouterGuard: 点击侧边栏
RouterGuard->>RouterGuard: ensureStaffSession (可能 HTTP)
RouterGuard->>ChunkLoader: 动态 import 页面 chunk
ChunkLoader->>PageView: mount 组件
PageView->>API: onMounted 并发拉列表
API-->>PageView: 渲染表格
PageView-->>User: 页面可交互
```
---
## 一、架构结论
| 层级 | 现状 | 对切页的影响 |
|------|------|-------------|
| 路由 | 全部 lazy load`apps/admin/src/router/index.ts` | 首次进入某页需下载 chunk |
| 布局 | `ManageLayout.vue` 常驻 | 壳层不重载,合理 |
| **页面缓存** | **全项目无 `<KeepAlive>`** | **切走即销毁,回来必 remount + 重拉数据** |
| 数据层 | 无 Pinia仅少数 composable 有模块级缓存 | 绝大多数列表页无跨访问缓存 |
| 首屏 bundle | Element Plus 全量 + 三语 i18n 打进主包 | 影响首访/冷启动,对已登录切页影响次之 |
**最大根因:没有 KeepAlive + 页面 `onMounted` 全量 refetch。**
---
## 二、切页时实际发生什么
### 2.1 路由守卫(每条鉴权路由)
`apps/admin/src/router/index.ts``beforeEach`
- 有 token 时 **`await ensureStaffSession()`**
- `apps/admin/src/utils/session-hydrate.ts` 有 60s TTL过期后会 **`GET /manage/auth/me`****阻塞导航完成**
- 访问 smoke-tests 路由时额外 `await ensureLoaded()`
### 2.2 页面生命周期(无缓存)
`ManageLayout.vue` 内为裸 `<RouterView />`
- 旧页面 **unmount**
- 新页面 chunk **动态 import****mount**
- 典型模式:`onMounted(load)` / 顶层 `void load()`
**Dashboard 子页** 体验较好:`useAdminDashboard.ts` 模块级 `stats` 缓存,同 session 内切 `/``/dashboard/players` 可跳过 API`HomeEntry` 仍可能闪 boot 屏)。
### 2.3 布局层常驻副作用
`ManageLayout.vue` `onMounted`
- `useDepositPendingCount`:立即请求 + **每 30s 轮询** `pending-count`
- `useSmokeTestsAllowed`:一次性权限探测
不直接阻塞切页,但增加后台并发请求。
### 2.4 每个 API 请求的同步开销
`apps/admin/src/api.ts` 请求拦截器对每个请求调用 `reconcileStaffSessionFromToken()`JWT decode + localStorage。列表页 mount 时常 **并发 310 个请求**,同步开销被放大。
---
## 三、按影响排序的瓶颈清单
### P0 — 切换体验(每次切页都痛)
**1. 无 KeepAlive列表页反复 remount + refetch**
受影响页面(模式相同):
- `Bets.vue``Cashback.vue`
- `Matches.vue``MatchesOutrights.vue`
- `DepositOrders.vue``StaffManage.vue`
- `Contents.vue``FinanceLogs.vue`
**2. 重型页面挂载 API burst**
`AgentManager.vue``/users`)— 约 2900 行 SFC`onMounted` 并行:
- `GET /admin/users/page-init`
- `GET /admin/users`(全量玩家)
- `GET /admin/agents?level=1`
- page-init 后再按层级 **N+1** 拉子代理
每次从其他页回到 `/users` 都会重复上述 burst。
**3. Tab 用 `v-if` 导致子页销毁**
`DepositManage.vue`
```vue
<DepositOrders v-if="activeTab === 'orders'" />
<PaymentMethods v-if="activeTab === 'methods'" />
```
同页内切换 tab 也会 destroy + `onMounted(fetchList)`
**4. Matches 展开面板扇出请求**
`LeagueMatchesPanel.vue` `watch(..., { immediate: true })`:恢复 session 展开最多 3 个联赛时,**最多 3 路** `GET /admin/matches`;折叠再展开会 remount 重拉。
### P1 — 间歇性卡顿(特定路径 / 时间)
**5. Session hydrate 阻塞导航**
60s TTL 过期后,**每次切页**先等 `/manage/auth/me`。弱网或后端慢时,侧边栏点击后「卡住」数秒。
**6. HomeEntry 回 Dashboard 闪屏**
`HomeEntry.vue``onBeforeMount` 再次 `ensureStaffSession` + `booting` 全屏 loadingrouter 已做过 hydrate
**7. 首次进入大 chunk 的 JS 解析**
| 页面 | 风险 |
|------|------|
| `AgentManager.vue` | 巨型 SFC + 多子组件 |
| `Settlement.vue` | ~1500 行 + async echarts |
| `Contents.vue` | `ContentRichEditor.vue` |
### P2 — 首屏 / 冷启动
**8. Element Plus 全量注册**
`main.ts` 全量 `app.use(ElementPlus)` + `element-plus/dist/index.css`,无按需引入。
**9. i18n 三语未真正拆包**
`admin-messages.ts` 仍静态 import zh/en/ms 全文;`split-i18n.mjs` 未落地,首屏携带全部语言文案。
**10. App.vue 全局 CSS ~1900 行**
无 scoped 的暗色/浅色双套 Element 覆盖,与全量 EP CSS 叠加。
---
## 四、问题分层矩阵
| 症状 | 最可能原因 | 验证方式 |
|------|-----------|----------|
| 任意页切回上一页都慢 | 无 KeepAlive + onMounted refetch | Network切回同页重复相同 API |
| 仅 `/users` 特别慢 | AgentManager 多路并行 API + 大 chunk | mount 时 3+ 请求Performance 看 JS |
| 偶尔点菜单无反应数秒 | `ensureStaffSession` 阻塞 | 切页瞬间是否有 `/manage/auth/me` |
| 充值页 tab 切换慢 | DepositManage `v-if` | 切 tab 是否重复 `deposit-orders` 请求 |
| 首次进某页慢、之后再进仍慢 | 大 chunk + 仍无缓存 | 对比首次/二次 Network |
| 整体首次打开就慢 | EP 全量 + i18n 三语 + 全局 CSS | `pnpm --filter @thebet365/admin build:analyze` |
---
## 五、优化路径(分阶段)
### 阶段 A — 切页体验12 天,收益最大)
1. `ManageLayout.vue``<RouterView>` 外包 `<KeepAlive :max="8">`,列表页 `defineOptions({ name })`
2. 列表页改 `onActivated` + stale-while-revalidate有缓存先展示后台静默刷新
3. `DepositManage.vue``v-if``v-show` 或 keep-alive 两个 tab
4. `HomeEntry.vue`:去掉重复 hydrate / 仅首次 boot
### 阶段 B — 重型页与 API23 天)
1. 拆分 `AgentManager.vue`:按 tab lazy或合并 bootstrap 为单一 `page-init` 接口
2. 提取通用 `useListCache(key, fetcher, ttl)` 给 Bets/Users/Deposit 等
3. `LeagueMatchesPanel`:对已加载 `leagueId` 短 TTL 缓存
4. `api.ts``reconcileStaffSessionFromToken` 移到 token 变更时,而非每请求
### 阶段 C — 守卫与 bundle35 天)
1. `beforeEach` 改为同步 JWT/localStorage 校验;`/me` 仅登录/刷新时调用
2. Element Plus 改按需 + 去全量 CSS
3. 执行/合入 i18n `split-i18n.mjs`,首屏只加载当前语言
4. 瘦身 `App.vue` 全局样式
### 阶段 D — 度量与验收
```bash
pnpm --filter @thebet365/admin build:analyze
```
验收指标Chrome DevToolsFast 3G
- 切回已访问列表页:无重复全量列表 API或仅 background refresh
- `/users` 二次进入API 数从 3+ 降到 01
- 侧边栏切换guard 阶段无阻塞性 `/me`60s 内)
---
## 六、当前不必优先动的部分
- **ECharts**:已 async chunk仅 dashboard/settlement 加载
- **ContentRichEditor**:未全局引入,仅 contents 路由
- **deposit 30s 轮询**:后台流量,通常不是切页主因
- **ManageLayout computed 菜单**:开销相对小
---
## 七、推荐落地顺序
1. **只做一处**KeepAlive + 列表页 activated 缓存(阶段 A
2. **`/users` 最慢**:在 A 之后做 AgentManager API 合并/延迟加载(阶段 B
3. **首屏整体慢**:再动 Element Plus / i18n 拆分(阶段 C
---
## 八、关键文件索引
| 职责 | 路径 |
|------|------|
| 路由 + beforeEach | `apps/admin/src/router/index.ts` |
| 布局壳 | `apps/admin/src/layouts/ManageLayout.vue` |
| Session 水合 / TTL | `apps/admin/src/utils/session-hydrate.ts` |
| Auth store | `apps/admin/src/stores/auth.ts` |
| Axios 拦截器 | `apps/admin/src/api.ts` |
| Dashboard 入口 | `apps/admin/src/views/HomeEntry.vue` |
| Dashboard 数据缓存 | `apps/admin/src/composables/useAdminDashboard.ts` |
| Deposit 轮询 | `apps/admin/src/composables/useDepositPendingCount.ts` |
| Bootstrap | `apps/admin/src/main.ts` |
| Vite 分包 | `apps/admin/vite.config.ts` |
| 重型用户页 | `apps/admin/src/views/AgentManager.vue` |
| 充值 tab | `apps/admin/src/views/DepositManage.vue` |

View File

@@ -1,695 +0,0 @@
# 创蓝短信ChuanglanTypeScript 全栈接入指南
> 适用场景:**全新独立项目**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 业务 ServiceRedis
```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 RouteNext.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 时不可用内存 MapServerless 多实例会失效)
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` 为准。

View File

@@ -139,6 +139,8 @@ docs\docker\build-and-export-images.bat --export-only
## 五、上传到服务器并部署
完整分步说明(本地打包 → 上传 → 终端执行 → 验证)见上级文档 **[Docker部署指南.md 第八节](../Docker部署指南.md#八推荐发版流程本地打包--上传--线上更新)**。
### 1. 上传
将以下内容传到服务器同一目录(如 `/www/wwwroot/thebet365`

View File

@@ -2,8 +2,7 @@
本文档说明 thebet365 **玩家端短信验证码**(注册 / 找回密码)在后端的日志行为,以及如何与创蓝控制台对账、排查「收不到码」问题。
相关代码:`apps/api/src/domains/identity/sms/`
创蓝接入总览见 [chuanglan-sms-js-guide.md](./chuanglan-sms-js-guide.md)。
相关代码:`apps/api/src/domains/identity/sms/``SmsService``ChuanglanClient`
---