diff --git a/AGENTS.md b/AGENTS.md index 8903716..b069515 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,46 +1,206 @@ # AGENTS.md -## Runtime And Workspace -- Use Node 22+ and pnpm 11; `packageManager` pins `pnpm@11.5.2`. -- This is a pnpm workspace: `apps/api` is NestJS/Prisma, `apps/player` is Vue H5 on `:5173`, `apps/admin` is the unified platform-admin + agent Vue app on `:5174`, and `packages/shared` holds shared types/constants plus `public` assets. -- The frontends alias `@thebet365/shared` to `packages/shared/src/index.ts` because the built shared package is CommonJS; update shared source exports, not only `dist`. -- `packages/shared` build runs `scripts/generate-phone-countries.mjs` before `tsc`. +> 给 AI 编程助手(Cursor 等)看的项目速查手册,浓缩开发约定与易错点。 +> 人类日常开发请优先看 `README.md` 与 `docs/`。 -## Local Setup -- Start local infrastructure with `docker compose up -d`; this exposes PostgreSQL on `5432` and Redis on `6379`. -- Copy env for the API as `cp .env.example apps/api/.env`; Nest reads `.env` from the API working directory when run via the workspace filter. -- First database setup from repo root: `pnpm db:generate`, then `pnpm db:migrate`, then `pnpm db:seed`. -- `pnpm dev:api` runs `scripts/ensure-port-free.mjs 3000` and will kill any listener on port `3000` before starting Nest watch. -- If starting apps separately, start API before `pnpm dev:player` or `pnpm dev:admin`; both Vite servers proxy `/api` to `http://localhost:3000`. +## 目录 -## Commands -- `pnpm dev` builds shared once, then runs all workspace `dev` scripts in parallel. -- `pnpm dev:admin` and `pnpm dev:manage` are the same admin app. -- Full verification is `pnpm build` and `pnpm test`; there is no root lint or formatter script in current manifests. -- API tests: `pnpm --filter @thebet365/api test`. -- Focused API Jest test: `pnpm --filter @thebet365/api exec jest settlement-calculator.spec.ts --runInBand`; avoid `pnpm ... test -- ...` for focused args because pnpm passes the literal `--` through to Jest here. -- Frontend typecheck/build: `pnpm --filter @thebet365/player build` and `pnpm --filter @thebet365/admin build`. -- Admin bundle report: `pnpm --filter @thebet365/admin build:analyze`. +| 章节 | 何时查 | +|------|--------| +| [运行环境与仓库结构](#运行环境与仓库结构) | 不确定 monorepo 各 app 分工 | +| [本地启动](#本地启动) | 起 dev、数据库、端口 | +| [常用命令](#常用命令) | build / test / 打镜像 | +| [API 领域结构](#api-领域结构) | 业务代码该放哪个 domain | +| [API 约定](#api-约定) | 鉴权、钱包、迁移、赔率快照 | +| [前端约定](#前端约定) | 改 .vue/.ts、权限、dist | +| [i18n 改文案流程](#i18n-改文案流程) | 三语文案改哪几个文件 | +| [主题分支](#主题分支player-多皮肤) | theme-2/3/4 与 main 同步禁忌 | +| [演示账号](#演示账号开发-seed) | 本地登录测试号 | +| [文档索引](#文档索引) | 详细说明在 `docs/` 哪篇 | +| [测试与冒烟](#测试与冒烟) | Jest、UAT、smoke-tests | +| [部署注意](#部署注意) | 生产 compose、seed、打包 | +| [AI 改代码时请避免](#ai-改代码时请避免) | 常见踩坑清单 | -## API Notes -- API URLs use the global `/api` prefix; Swagger is at `/api/docs` in non-production or when `ENABLE_SWAGGER` is truthy. -- `AppModule` installs a global `JwtAuthGuard`; public endpoints must use `@Public()` from `apps/api/src/shared/common/decorators.ts`. -- Keep business rules in `apps/api/src/domains/*`; `apps/api/src/applications/{player,admin,agent}` should stay as portal/controller orchestration. -- Wallet changes should go through ledger/wallet services; UAT docs explicitly forbid direct balance edits for settlement fixes. -- After editing `apps/api/prisma/schema.prisma`, run `pnpm db:generate`; use `pnpm db:migrate` for dev migrations and `pnpm db:migrate:deploy` for deployment. +> **AI 会读吗?** Cursor 等工具会在对话开始时把 `AGENTS.md` 注入上下文;当前约 200 行,一般能整篇读入。文件继续变长时,索引有助于人和 AI 快速定位章节;细节仍以 `docs/` 为准,此处只写约定与指针。 -## Frontend Notes -- Edit `.ts` and `.vue` sources in `apps/player/src` and `apps/admin/src`; many `.js` siblings exist under `src`, but both `index.html` files load `/src/main.ts` and Vite resolution prefers TS before JS. -- Admin is one app for both `ADMIN` and `AGENT` accounts; route/menu gating is in `apps/admin/src/router/index.ts` and `apps/admin/src/stores/auth.ts`. -- Player is mobile-first; performance expectations and required mobile paths are in `docs/player-mobile-performance.md`. +## 运行环境与仓库结构 -## Tests And Smoke Checks -- Jest specs live under `apps/api/src/**/*.spec.ts` with `rootDir: src`; the documented unit/regression suite is rule-oriented and does not require a live DB. -- DB-backed smoke tests are exposed in the admin UI under `smoke-tests`; `SmokeTestService` allows them outside production, or in production only with `ALLOW_SMOKE_TESTS=true`. -- UAT regression flow is documented in `docs/UAT_CHECKLIST.md`; it includes admin UI smoke tests plus manual wallet/agent-credit checks. +- 使用 Node 22+、pnpm 11;`packageManager` 固定为 `pnpm@11.5.2`。 +- pnpm workspace:`apps/api`(NestJS/Prisma)、`apps/player`(Vue H5,`:5173`)、`apps/admin`(平台管理 + 代理合一,`:5174`)、`packages/shared`(共享类型/常量与 `public` 静态资源)。 +- 前端将 `@thebet365/shared` 别名到 `packages/shared/src/index.ts`(构建产物为 CommonJS);改 shared 时须更新源码导出,不要只改 `dist`。 +- `packages/shared` 构建前会执行 `scripts/generate-phone-countries.mjs`,再跑 `tsc`。 +- 本地开发 compose:`docker-compose.yml`(仅 Postgres + Redis);生产 compose:`docker-compose.prod.yml` + `.env.docker`。 -## Deployment Gotchas -- Production compose uses `.env.docker`: `docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build` or `pnpm docker:up` after creating `.env.docker`. -- In production, keep `SEED_DATABASE=false` after first seed; production seed creates only `admin` plus WC2026 sample data, while dev seed includes agent/player demo accounts. -- `scripts/prod-init-db.sh` is destructive by design: it requires `CONFIRM=YES`, backs up unless `--skip-backup`, truncates business data, then seeds production data. -- Deployment packaging is `pnpm pack:deploy`; `pack.mjs` removes legacy shared public directories except `flags` and `players` before creating `release/thebet365-deploy-*.zip`. +## 本地启动 + +- 基础设施:`docker compose up -d`(PostgreSQL `5432`、Redis `6379`)。 +- API 环境变量:`cp .env.example apps/api/.env`;通过 workspace filter 启动时,Nest 从 API 工作目录读 `.env`。 +- 首次数据库(仓库根目录):`pnpm db:generate` → `pnpm db:migrate` → `pnpm db:seed`。 +- `pnpm dev:api` 会先执行 `scripts/ensure-port-free.mjs 3000`,释放 3000 端口再启动 watch。 +- 分应用启动时,**先 API**,再 `pnpm dev:player` 或 `pnpm dev:admin`;两个 Vite 都把 `/api` 代理到 `http://localhost:3000`。 +- Windows PowerShell 不支持 `&&` 链式命令,需分步执行或用 `;`。 + +## 常用命令 + +- `pnpm dev`:先构建 shared,再并行跑各 workspace 的 `dev`。 +- `pnpm dev:admin` 与 `pnpm dev:manage` 是同一个管理端应用。 +- 全量校验:`pnpm build`、`pnpm test`;根目录**没有**统一的 lint/format 脚本。 +- API 测试:`pnpm --filter @thebet365/api test`。 +- 单测文件:`pnpm --filter @thebet365/api exec jest settlement-calculator.spec.ts --runInBand`;不要用 `pnpm ... test -- ...` 传参,pnpm 会把字面量 `--` 传给 Jest。 +- 前端构建:`pnpm --filter @thebet365/player build`、`pnpm --filter @thebet365/admin build`。 +- 管理端体积分析:`pnpm --filter @thebet365/admin build:analyze`。 +- 本地镜像打包(Windows):`docs\docker\build-and-export-images.bat --tag latest`。 +- 生产更新(服务器):`./scripts/deploy-update.sh --images thebet365-images-.tar --tag `(详见 `docs/Docker部署指南.md` 第八节)。 + +## API 领域结构 + +业务规则放在 `apps/api/src/domains/*`,应用层只做编排: + +| 领域 | 路径 | 职责 | +|------|------|------| +| identity | `domains/identity/` | 登录、用户、员工、RBAC | +| agent | `domains/agent/` | 代理网络、授信 | +| ledger | `domains/ledger/` | 钱包、账变 | +| catalog | `domains/catalog/` | 赛事 | +| odds | `domains/odds/` | 盘口 | +| betting | `domains/betting/` | 注单 | +| settlement | `domains/settlement/` | 结算 | +| operations | `domains/operations/` | 返水、内容、审计等 | +| player-messages / presence | 各子模块 | 站内信、在线状态 | + +门户控制器:`applications/{player,admin,agent}/`。 + +## API 约定 + +- 全局前缀 `/api`;非生产或 `ENABLE_SWAGGER=true` 时 Swagger 在 `/api/docs`。 +- `AppModule` 注册了全局 `JwtAuthGuard`;公开接口须加 `@Public()`(`apps/api/src/shared/common/decorators.ts`)。 +- 业务规则放在 `domains/*`;`applications/*` 只做门户/控制器编排,不要写领域规则。 +- 钱包变动走 ledger/wallet 服务;UAT 明确禁止为修结算直接改余额。 +- 下注赔率以提交时快照为准(`BetSelection.odds` + `oddsVersion`),结算用快照,不受后续盘口变动影响。 +- 改 `apps/api/prisma/schema.prisma` 后执行 `pnpm db:generate`;开发迁移 `pnpm db:migrate`,部署由 `deploy-update.sh` 执行 `prisma migrate deploy`。 +- 新增迁移后,theme 分支若只改 player 样式,也须同步 API/Admin 与迁移文件。 + +## 前端约定 + +- 改 `apps/player/src`、`apps/admin/src` 下的 **`.ts` / `.vue`**;`src` 下虽有 `.js` 旁文件,但 `index.html` 入口是 `/src/main.ts`,Vite 优先解析 TS。 +- 管理端同一应用服务 `ADMIN` 与 `AGENT` 账号;路由/菜单权限在 `router/index.ts`、`stores/auth.ts`;员工可见菜单字段 `visibleMenus`。 +- 管理端权限常量:`apps/admin/src/constants/permissions.ts`(`AdminPerm`)。 +- 玩家端移动优先;性能验收见 `docs/player-mobile-performance.md`。 +- 管理端切页慢的分析与优化任务见 `docs/admin-page-switch-performance.md`。 +- **不要**把 `apps/player/dist/` 提交进 Git;生产由 Docker 构建生成静态资源。 + +## i18n 改文案流程 + +支持语言均为 **zh-CN / en-US / ms-MY**(马来语)。改文案须**三语同步**,不要只改中文。 + +### 玩家端(`apps/player`) + +| 项 | 说明 | +|----|------| +| 文案文件 | `apps/player/src/i18n/zh-CN.ts`、`en-US.ts`、`ms-MY.ts` | +| 结构 | **嵌套对象**(如 `bet.place_bet`、`nav.home`) | +| 组件用法 | `useI18n()` → `t('bet.odds_changed')` | +| 语言切换 | `useAppLocale().setLocale()`;按需 `ensurePlayerLocale` 懒加载对应 ts 文件 | +| 存储 key | `localStorage.locale`;登录用户会 `POST /player/language` 同步后端 | + +**改文案步骤:** + +1. 在 Vue 里用 `t('模块.key')` 引用,**不要**在模板写死中文。 +2. 在 **三个** locale 文件的**相同路径**下各加一条(保持 key 一致)。 +3. 带占位符用 vue-i18n 约定,如 `'共 {n} 场': '共 {n} 场'` → `t('key', { n: 3 })`。 +4. 本地 `pnpm dev:player` 切换语言各看一遍;热更新一般即时生效。 +5. theme 分支合并 main 功能时:**只合并新增 key**,勿用 main 文件整文件覆盖(theme 可能有不同措辞)。 + +### 管理端(`apps/admin`) + +| 项 | 说明 | +|----|------| +| 核心入口 | `apps/admin/src/i18n/admin-messages.ts`(三语扁平 `Record`) | +| 列表/弹窗大段文案 | `admin-pages.ts`(中/英)、`admin-pages-ms.ts`(马来)→ 通过 spread 并入 `admin-messages` | +| 表单校验 key | `form-validation.ts`(`err.*` 等,用 `resolveFormError` 展示) | +| 组件用法 | `useAdminLocale().t('nav.bets')` 或 `t('key', { n: 1 })` 占位符 `{n}` | +| 语言切换 | `useAdminLocale().setLocale()` → `preloadAdminLocale` + `localStorage.admin_locale` | +| 动态拆包 | `bundles/{zh-CN,en-US,ms-MY}.ts` + `locale-loader.ts`(当前主包仍含三语全文,拆包未完全落地) | + +**改文案步骤:** + +1. 确定 key 命名:导航 `nav.*`、通用 `common.*`、页面 `user.*` / `match.*` / `deposit.*` 等,与现有前缀保持一致。 +2. 在 `admin-messages.ts` 的 **zh / en / ms 三个对象**里各加同名 key(核心短文案)。 +3. 若属于某列表页/弹窗长文案,优先加到 `admin-pages.ts`(中/英)与 `admin-pages-ms.ts`(马来)的对应 export,它们会 spread 进 `admin-messages`。 +4. 表单校验错误:throw `FormValidationError('err.xxx')` 并在三语里定义 `err.xxx`。 +5. 本地 `pnpm dev:admin`,右上角切换语言验证;改 `admin-pages*` 后若未刷新,重启 dev 一次。 +6. Admin 与 Player **文案独立**,改玩家端不要只改 admin 文件(反之亦然)。 + +### 常见错误 + +- 只改 `zh-CN` 未改 `en-US`、`ms-MY` → 切换语言显示 key 或英文 fallback。 +- 玩家端 key 层级不一致(如中文有 `bet.foo` 英文漏了 `bet` 层)→ `t()` 返回 key 字符串。 +- 管理端在 `admin-pages.ts` 只加了 `adminPagesZh` 未加 `adminPagesEn` / `adminPagesMs`。 +- theme 分支用 `git checkout main -- apps/player/src/i18n/` 覆盖 → 冲掉主题分支已有翻译。 + +### 与业务/后台内容的关系 + +- **i18n 文件**:前端固定 UI 标签、按钮、提示。 +- **管理端「公共管理」/ 公告 / Banner**:运营可配正文,走 API 与数据库,**不要**硬编码进 i18n(玩家端公告详情等展示 API 返回内容)。 + +## 主题分支(player 多皮肤) + +| 分支 | 风格 | 注意 | +|------|------|------| +| `main` | 暗金主题 | 生产默认发版分支 | +| `theme-2` | Pinnacle 蓝白 | 玩家 UI 与 main 分叉,**禁止** `git checkout main -- apps/player/...` 整文件覆盖 | +| `theme-3` | 统一移动端视觉 | 同 theme-2 | +| `theme-4` | 海军蓝暗色极简 | 含悬浮客服等独有组件 | + +同步功能到 theme 分支:API/Admin/迁移可整目录 checkout;Player 只做逻辑合并 + 保留各分支 `styles.css` 与主题资源。发版前在目标分支打包镜像。 + +## 演示账号(开发 seed) + +| 角色 | 用户名 | 密码 | +|------|--------|------| +| 超级管理员 | admin | Admin@123 | +| 一级代理 | agent1 | Agent@123 | +| 二级代理 | agent2 | Agent@123 | +| 玩家 | player1 | Player@123 | + +生产 seed 仅创建 admin + WC2026 样例数据,不含代理/玩家演示号。详见 `docs/默认数据说明.md`。 + +## 文档索引 + +| 文档 | 用途 | +|------|------| +| `docs/项目启动指南.md` | 本地开发、排错、端口 | +| `docs/Docker部署指南.md` | 生产部署、备份、回滚、发版流程 | +| `docs/docker/镜像构建与导出.md` | 本地打镜像 tar | +| `docs/默认数据说明.md` | seed 数据、WC2026、48 强 | +| `docs/投注玩法说明.md` | 玩法与判赢规则 | +| `docs/结算与返水金额规则.md` | 派彩/返水金额公式 | +| `docs/settlement-and-fund-flow-analysis.md` | 结算操作流程 | +| `docs/手动充值功能说明.md` | 充值审核流程 | +| `docs/短信调试与日志说明.md` | 创蓝短信排错 | +| `docs/UAT_CHECKLIST.md` | 上线前回归清单 | +| `docs/player-mobile-performance.md` | 玩家端性能验收 | +| `docs/admin-page-switch-performance.md` | 管理端切页优化任务 | + +## 测试与冒烟 + +- Jest 用例在 `apps/api/src/**/*.spec.ts`,`rootDir: src`;文档中的单元/规则测试一般不依赖真实数据库。 +- 管理端「冒烟测试」页调用 DB 相关检查;`SmokeTestService` 非生产默认可用,生产需 `ALLOW_SMOKE_TESTS=true`。 +- UAT 回归流程见 `docs/UAT_CHECKLIST.md`(含管理端 UI 冒烟与钱包/代理额度手工检查)。 + +## 部署注意 + +- 生产 compose 使用 `.env.docker`:`docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build`,或配置好后 `pnpm docker:up`。 +- **不要**用 example 覆盖线上 `.env.docker`;部署脚本通常只回写 `IMAGE_TAG`。 +- 生产首次 seed 后保持 `SEED_DATABASE=false`;迁移由 `deploy-update.sh` 执行,勿在 API 容器设 `RUN_MIGRATIONS_ON_START=true`(除非应急)。 +- `scripts/prod-init-db.sh` **会清空业务数据**:须 `CONFIRM=YES`,默认先备份,再 truncate 并灌生产数据。 +- 打包部署 zip:`pnpm pack:deploy`;`pack.mjs` 会删除 `packages/shared/public` 下除 `flags`、`players` 外的遗留中文目录。 +- 构建 player/admin 若报 `ENOENT ... public/球员`:清理 `packages/shared/public` 下错误中文目录后重试。 + +## AI 改代码时请避免 + +- 不要 `git merge main` 进 theme 分支做 player 同步(会把 main 暗金样式大量带入)。 +- 不要直接改数据库余额修结算;走结算/钱包服务。 +- 不要提交 `.env`、`.env.docker`、镜像 tar、`apps/*/dist/`。 +- 改 player 主题相关文件时,先确认当前分支是 `main` 还是 `theme-*`,避免用错 CSS 变量(如 theme-2 无 `--gold`)。 +- 管理端改列表页性能时,参考 `docs/admin-page-switch-performance.md` 任务清单,优先 KeepAlive + 缓存而非盲目减 API 字段。 diff --git a/docs/Docker部署指南.md b/docs/Docker部署指南.md index 0d7414d..dd6a166 100644 --- a/docs/Docker部署指南.md +++ b/docs/Docker部署指南.md @@ -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-.tar` | 含 `api` / `player` / `admin` 三个镜像,约 200–300 MB | +| `thebet365-images-.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-.tar` | **必传** | +| `thebet365-images-.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) diff --git a/docs/admin-page-switch-performance.md b/docs/admin-page-switch-performance.md new file mode 100644 index 0000000..07fc664 --- /dev/null +++ b/docs/admin-page-switch-performance.md @@ -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` 常驻 | 壳层不重载,合理 | +| **页面缓存** | **全项目无 ``** | **切走即销毁,回来必 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` 内为裸 ``: + +- 旧页面 **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 时常 **并发 3–10 个请求**,同步开销被放大。 + +--- + +## 三、按影响排序的瓶颈清单 + +### 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 + + +``` + +同页内切换 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` 全屏 loading(router 已做过 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 — 切页体验(1–2 天,收益最大) + +1. `ManageLayout.vue` 的 `` 外包 ``,列表页 `defineOptions({ name })` +2. 列表页改 `onActivated` + stale-while-revalidate(有缓存先展示,后台静默刷新) +3. `DepositManage.vue`:`v-if` → `v-show` 或 keep-alive 两个 tab +4. `HomeEntry.vue`:去掉重复 hydrate / 仅首次 boot + +### 阶段 B — 重型页与 API(2–3 天) + +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 — 守卫与 bundle(3–5 天) + +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 DevTools,Fast 3G): + +- 切回已访问列表页:无重复全量列表 API(或仅 background refresh) +- `/users` 二次进入:API 数从 3+ 降到 0–1 +- 侧边栏切换: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` | diff --git a/docs/chuanglan-sms-js-guide.md b/docs/chuanglan-sms-js-guide.md deleted file mode 100644 index 9812d72..0000000 --- a/docs/chuanglan-sms-js-guide.md +++ /dev/null @@ -1,695 +0,0 @@ -# 创蓝短信(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` 为准。 diff --git a/docs/docker/镜像构建与导出.md b/docs/docker/镜像构建与导出.md index 1ae5aa1..3717c7b 100644 --- a/docs/docker/镜像构建与导出.md +++ b/docs/docker/镜像构建与导出.md @@ -139,6 +139,8 @@ docs\docker\build-and-export-images.bat --export-only ## 五、上传到服务器并部署 +完整分步说明(本地打包 → 上传 → 终端执行 → 验证)见上级文档 **[Docker部署指南.md 第八节](../Docker部署指南.md#八推荐发版流程本地打包--上传--线上更新)**。 + ### 1. 上传 将以下内容传到服务器同一目录(如 `/www/wwwroot/thebet365`): diff --git a/docs/短信调试与日志说明.md b/docs/短信调试与日志说明.md index 9218164..3e5327c 100644 --- a/docs/短信调试与日志说明.md +++ b/docs/短信调试与日志说明.md @@ -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`) --- diff --git a/scripts/deploy-lib.sh b/scripts/deploy-lib.sh index 44d238a..6bcb41e 100755 --- a/scripts/deploy-lib.sh +++ b/scripts/deploy-lib.sh @@ -156,9 +156,11 @@ validate_prod_env() { [ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD" [ -n "$jwt_secret" ] || die ".env.docker 缺少 JWT_SECRET" - if [ "$allow_defaults" != "true" ]; then - [ "$postgres_password" != "thebet365" ] || die "POSTGRES_PASSWORD 仍是示例值;如确为测试环境,请加 --allow-default-secrets" - [ "$jwt_secret" != "change-me-in-production-use-long-random-string" ] || die "JWT_SECRET 仍是示例值;如确为测试环境,请加 --allow-default-secrets" + if [ "$postgres_password" = "thebet365" ]; then + warn "POSTGRES_PASSWORD 仍是示例值 thebet365,生产环境建议尽快修改" + fi + if [ "$jwt_secret" = "change-me-in-production-use-long-random-string" ]; then + warn "JWT_SECRET 仍是示例值,生产环境建议尽快修改" fi if [ "$seed_database" = "true" ]; then