Compare commits
No commits in common. "5b1f0848ac1a922a059a334abe5a9f863d23e9b5" and "9d1f52d95be36f0008ab7a6e7e388a4b014a6184" have entirely different histories.
5b1f0848ac
...
9d1f52d95b
@ -51,7 +51,7 @@ src/
|
||||
├── index.ts # 入口:Fastify 实例 + 插件注册 + 路由挂载
|
||||
├── db/
|
||||
│ ├── client.ts # 数据库连接(mysql2 pool + drizzle)
|
||||
│ └── schema.ts # 全部 15 张表定义(唯一真相源)
|
||||
│ └── schema.ts # 全部 14 张表定义(唯一真相源)
|
||||
├── types/ # TypeScript 类型(auth, quiz, user, api)
|
||||
├── utils/
|
||||
│ ├── config.ts # 环境变量(Zod 校验,启动时 fail-fast)
|
||||
@ -105,12 +105,12 @@ db/seeds/index.ts # 幂等种子导入脚本
|
||||
|
||||
## 数据库
|
||||
|
||||
- **15 张表**,定义在 `src/db/schema.ts`
|
||||
- **14 张表**,定义在 `src/db/schema.ts`
|
||||
- 核心(7):`users`, `categories`, `questions`, `knowledge_cards`, `user_progress`, `skill_tree`, `user_chapter_progress`
|
||||
- 反馈(2):`question_ratings`, `user_feedback`
|
||||
- 游戏化(3):`achievements`, `user_achievements`, `leaderboard_snapshots`
|
||||
- 商业(1):`subscriptions`
|
||||
- 管理员(2):`admin_users`, `admin_audit_log`
|
||||
- 审计(1):`admin_audit_log`
|
||||
- Schema 定义在 `src/db/schema.ts`,迁移由 `drizzle-kit` 从 schema 自动生成
|
||||
- `datetime` 列使用 `default(sql\`CURRENT_TIMESTAMP\`)`(MySQL datetime 无 `defaultNow()`)
|
||||
|
||||
|
||||
@ -361,11 +361,6 @@ runner:
|
||||
container:
|
||||
# 容器使用宿主机网络(解决容器无法访问 127.0.0.1:3200 Gitea 的问题)
|
||||
network: "host"
|
||||
# 挂载宿主机目录到 job 容器(CI deploy 步骤需要读取 docker-compose.yml)
|
||||
options: "-v /opt/duoqi-api:/opt/duoqi-api"
|
||||
# 允许挂载的 volume 白名单
|
||||
valid_volumes:
|
||||
- /opt/duoqi-api
|
||||
# 不强制每次拉取镜像(国内网络下减少失败风险)
|
||||
force_pull: false
|
||||
```
|
||||
@ -384,6 +379,9 @@ ExecStart=/usr/local/bin/act_runner daemon --config /opt/act-runner/config.yaml
|
||||
Restart=always
|
||||
Environment=HOME=/root
|
||||
|
||||
# 关键:挂载 Docker socket,让 job 容器能访问宿主机的 Docker daemon
|
||||
BindPaths=/var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
@ -397,11 +395,6 @@ systemctl start act-runner
|
||||
systemctl status act-runner
|
||||
```
|
||||
|
||||
> **注意**:act_runner 是宿主机原生运行的二进制,不是容器。它本身已有完整的文件系统和网络访问权限。
|
||||
> - **不需要** `BindPaths` 挂载 Docker socket(宿主机进程可直接访问)
|
||||
> - **不需要** `BindPaths` 挂载 `/opt/duoqi-api`(同上)
|
||||
> - job 容器需要访问宿主机目录,通过 `config.yaml` 中的 `container.options` 和 `valid_volumes` 配置
|
||||
|
||||
### 环境隔离策略
|
||||
|
||||
#### RDS 数据库隔离
|
||||
@ -479,22 +472,21 @@ LOG_LEVEL=debug
|
||||
|
||||
**服务器 compose 文件** `/opt/duoqi-api/docker-compose.yml`:
|
||||
|
||||
> 代码库中对应文件为 `docker-compose.prod.yml`,部署时手动重命名为 `docker-compose.yml`。
|
||||
|
||||
```yaml
|
||||
# 使用 host 网络模式,避免 Docker bridge 子网与阿里云 VPC 内网 IP 段冲突
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===== 生产环境 =====
|
||||
api-prod:
|
||||
build:
|
||||
context: .
|
||||
context: /opt/gitea/data/git/repositories/admin/duoqi-api.git
|
||||
dockerfile: Dockerfile
|
||||
image: duoqi-api:prod
|
||||
container_name: duoqi-api-prod
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file: .env.prod
|
||||
ports:
|
||||
- "3000:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
@ -511,16 +503,17 @@ services:
|
||||
limits:
|
||||
memory: 400M
|
||||
|
||||
# ===== 测试环境(Docker profiles 按需启停) =====
|
||||
# ===== 测试环境(按需启停) =====
|
||||
api-test:
|
||||
build:
|
||||
context: .
|
||||
context: /opt/gitea/data/git/repositories/admin/duoqi-api.git
|
||||
dockerfile: Dockerfile
|
||||
image: duoqi-api:test
|
||||
container_name: duoqi-api-test
|
||||
restart: "no"
|
||||
network_mode: host
|
||||
restart: "no" # 不自动重启,手动控制
|
||||
env_file: .env.test
|
||||
ports:
|
||||
- "3001:3001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
interval: 30s
|
||||
@ -537,7 +530,7 @@ services:
|
||||
limits:
|
||||
memory: 300M
|
||||
profiles:
|
||||
- test
|
||||
- test # 使用 profiles 按需启停
|
||||
```
|
||||
|
||||
**启停命令:**
|
||||
@ -628,11 +621,9 @@ git push origin main
|
||||
| 生产部署手动确认 | 防止误操作,确保人工验证后才上线 |
|
||||
| 使用 Gitea Actions | 兼容 GitHub Actions 语法,学习成本低 |
|
||||
| Runner 使用 `network: host` | 容器共享宿主机网络,解决容器无法访问 Gitea 的问题 |
|
||||
| API 容器使用 `network_mode: host` | 避免 Docker bridge 默认子网(172.x.0.0/16)与阿里云 VPC 内网 IP 段冲突 |
|
||||
| Runner 使用 `github_mirror` | 从 gitea.com 镜像拉取 Actions,解决国内无法访问 GitHub 的问题 |
|
||||
| 自定义 Runner 镜像(bun + git + docker CLI) | 避免 checkout REST API 不兼容,支持 CI 中执行 docker 命令 |
|
||||
| Runner config 的 `options` + `valid_volumes` | 让 job 容器能挂载宿主机的 `/opt/duoqi-api` 目录,读取 docker-compose.yml |
|
||||
| CI health check 使用 `bun -e "fetch(...)"` | runner 镜像未安装 curl,用 bun 内置 fetch API 做 HTTP 健康检查 |
|
||||
| Runner 挂载 Docker socket | Job 容器通过 socket 访问宿主机 Docker daemon,执行构建操作 |
|
||||
| 固定 Bun 版本(1.3) | 确保 CI 环境可复现,避免 latest 版本变化导致意外失败 |
|
||||
|
||||
### 部署操作
|
||||
@ -849,10 +840,11 @@ services:
|
||||
# Phase 2: 多实例 docker-compose.yml
|
||||
# API 服务器上的配置
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
api:
|
||||
image: your-registry/duoqi-api:latest
|
||||
network_mode: host
|
||||
deploy:
|
||||
replicas: 2 # 运行 2 个实例
|
||||
resources:
|
||||
@ -920,12 +912,11 @@ docker stats --no-stream
|
||||
# 测试库重置(清空测试数据)
|
||||
mysql -h your-rds-endpoint -u duoqi_test -p -e "DROP DATABASE duoqi_test; CREATE DATABASE duoqi_test CHARACTER SET utf8mb4;"
|
||||
|
||||
# 执行迁移(从本地或 CI 中执行,容器内无 drizzle-kit)
|
||||
# 详见 docs/database-migration-guide.md
|
||||
DATABASE_URL=mysql://duoqi_prod:password@rm-xxxxx:3306/duoqi_prod bun run db:migrate
|
||||
# 执行迁移
|
||||
docker compose exec api-prod npx drizzle-kit migrate
|
||||
|
||||
# 导入种子数据到测试库(首次部署时从本地执行)
|
||||
DATABASE_URL=mysql://duoqi_test:password@rm-xxxxx:3306/duoqi_test bun run db:seed
|
||||
# 导入种子数据到测试库
|
||||
docker compose --profile test exec api-test bun run db:seed
|
||||
|
||||
# 备份生产库
|
||||
mysqldump -h your-rds-endpoint -u duoqi_prod -p duoqi_prod > /opt/backups/duoqi_prod_$(date +%Y%m%d).sql
|
||||
@ -1045,48 +1036,6 @@ mysql -h your-rds-endpoint -u duoqi_prod -p -e "SELECT 1;"
|
||||
# 阿里云 RDS 控制台 → 数据安全性 → 白名单设置
|
||||
```
|
||||
|
||||
#### 7. Docker bridge 网络与 VPC 内网冲突
|
||||
|
||||
> **症状**:服务器无法连接 RDS(或 VPC 内其他服务),ping 显示源 IP 为 `172.x.0.1`(Docker 网桥 IP)而非宿主机内网 IP。
|
||||
|
||||
```bash
|
||||
# 查看路由表,检查是否有 Docker 网桥路由劫持了 VPC 流量
|
||||
route -n
|
||||
# 如果看到类似以下行,说明 Docker bridge 子网与 RDS IP 段冲突:
|
||||
# 172.23.0.0 0.0.0.0 255.255.0.0 U 0 0 0 br-xxxxxx
|
||||
|
||||
# 查看哪个 Docker 网络创建了冲突子网
|
||||
docker network ls
|
||||
docker network inspect <network-name>
|
||||
|
||||
# 解决方案 1:使用 network_mode: host(推荐,已在本方案中采用)
|
||||
# 在 docker-compose.yml 中为服务添加 network_mode: host
|
||||
# 这样不会创建 Docker bridge 网络,从根本上避免 IP 段冲突
|
||||
|
||||
# 解决方案 2:指定不冲突的子网
|
||||
# docker network create --subnet=192.168.200.0/24 my-network
|
||||
```
|
||||
|
||||
#### 8. CI health check 失败
|
||||
|
||||
```bash
|
||||
# 检查 health 路径是否正确(health 路由注册时无 /v1 前缀)
|
||||
# 正确:/health 错误:/v1/health
|
||||
|
||||
# 检查容器是否在运行
|
||||
docker ps -a --filter name=duoqi-api-prod
|
||||
|
||||
# 检查端口是否在监听(host 网络模式下直接看宿主机端口)
|
||||
ss -tlnp | grep 3000
|
||||
|
||||
# 查看容器日志
|
||||
docker logs duoqi-api-prod --tail 50
|
||||
|
||||
# 注意:CI health check 使用 bun -e "fetch(...)" 而非 curl
|
||||
# 如果 bun 的 promise 处理有问题,使用 top-level await:
|
||||
# bun -e "try{const r=await fetch('http://localhost:3000/health');process.exit(r.ok?0:1)}catch{process.exit(1)}"
|
||||
```
|
||||
|
||||
### 回滚操作
|
||||
|
||||
```bash
|
||||
@ -1164,6 +1113,6 @@ docker compose up -d api-prod
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v5.3.0 (host 网络模式 + Act Runner 挂载配置 + 故障排查更新)
|
||||
**最后更新**: 2026-04-18
|
||||
**文档版本**: v5.2.0 (双分支工作流 + 国内网络适配 + Docker 执行器完善)
|
||||
**最后更新**: 2026-04-17
|
||||
**维护者**: Duoqi Team
|
||||
|
||||
@ -1,406 +0,0 @@
|
||||
# 数据库初始化与迁移指南
|
||||
|
||||
> 本地开发机 → 阿里云 RDS MySQL 8.0+ 的数据库操作完整指南
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [前置条件](#前置条件)
|
||||
- [首次初始化](#首次初始化)
|
||||
- [Step 0:创建 RDS 数据库](#step-0创建-rds-数据库)
|
||||
- [Step 1:配置本地连接串](#step-1配置本地连接串)
|
||||
- [Step 2:推送表结构](#step-2推送表结构)
|
||||
- [Step 3:导入种子数据](#step-3导入种子数据)
|
||||
- [Step 4:验证](#step-4验证)
|
||||
- [日常表结构变更](#日常表结构变更)
|
||||
- [开发流程](#开发流程)
|
||||
- [生产部署流程](#生产部署流程)
|
||||
- [常用操作速查](#常用操作速查)
|
||||
- [故障排查](#故障排查)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
### 技术选型
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| ORM | Drizzle ORM — 类型安全,schema 即代码 |
|
||||
| Schema 真相源 | `src/db/schema.ts` — 所有表定义的唯一来源 |
|
||||
| 迁移工具 | `drizzle-kit` — 生成和执行 SQL 迁移文件 |
|
||||
| 迁移文件目录 | `db/migrations/` — 版本化的 SQL 变更记录 |
|
||||
| 种子数据 | `content/*.json` + `db/seeds/index.ts` |
|
||||
|
||||
### 表结构总览(15 张表)
|
||||
|
||||
| 类别 | 表名 | 说明 |
|
||||
|------|------|------|
|
||||
| 核心 | `users`, `categories`, `questions`, `knowledge_cards`, `user_progress`, `skill_tree`, `user_chapter_progress` | 用户、题目、进度 |
|
||||
| 反馈 | `question_ratings`, `user_feedback` | 评价与反馈 |
|
||||
| 游戏化 | `achievements`, `user_achievements`, `leaderboard_snapshots` | 成就与排行 |
|
||||
| 商业 | `subscriptions` | 订阅与 IAP |
|
||||
| 管理员 | `admin_users`, `admin_audit_log` | 后台管理与审计 |
|
||||
|
||||
### 两条核心命令
|
||||
|
||||
| 场景 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 开发/首次部署 | `bun run db:push` | 直接从 `schema.ts` 同步到数据库,适合快速迭代 |
|
||||
| 生产增量变更 | `bun run db:migrate` | 执行 `db/migrations/` 中的 SQL 文件,幂等且可审计 |
|
||||
|
||||
> **选择原则**:首次初始化用 `db:push`(简单直接),后续变更用 `db:migrate`(安全可控)。
|
||||
|
||||
---
|
||||
|
||||
## 前置条件
|
||||
|
||||
### 1. 本地环境
|
||||
|
||||
```bash
|
||||
# 确认 bun 已安装
|
||||
bun --version
|
||||
|
||||
# 安装项目依赖
|
||||
cd duoqi-api
|
||||
bun install
|
||||
```
|
||||
|
||||
### 2. 阿里云 RDS
|
||||
|
||||
- 已创建 MySQL 8.0+ 实例
|
||||
- 已在 RDS 控制台 **白名单设置** 中添加本机公网 IP
|
||||
- 已获取连接信息:地址、端口、用户名、密码
|
||||
|
||||
### 3. 网络连通性验证
|
||||
|
||||
```bash
|
||||
# 替换为实际 RDS 地址
|
||||
mysql -h rm-xxxxx.mysql.rds.aliyuncs.com -u root -p -e "SELECT 1;"
|
||||
```
|
||||
|
||||
如果连接超时,检查:
|
||||
- RDS 白名单是否已添加本机 IP([阿里云 RDS 控制台](https://rdsnext.console.aliyun.com/) → 数据安全性 → 白名单设置)
|
||||
- 本机防火墙/代理是否出站 3306 端口
|
||||
|
||||
---
|
||||
|
||||
## 首次初始化
|
||||
|
||||
> 适用于:全新的 RDS 实例,数据库为空。
|
||||
|
||||
### Step 0:创建 RDS 数据库
|
||||
|
||||
在阿里云 RDS 控制台的 **DMS 数据管理** 或本地 MySQL 客户端中执行:
|
||||
|
||||
```sql
|
||||
-- 生产库
|
||||
CREATE DATABASE duoqi_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 测试库
|
||||
CREATE DATABASE duoqi_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 创建用户并授权(如使用 root 可跳过)
|
||||
CREATE USER 'duoqi_prod'@'%' IDENTIFIED BY 'your-secure-password';
|
||||
GRANT ALL PRIVILEGES ON duoqi_prod.* TO 'duoqi_prod'@'%';
|
||||
|
||||
CREATE USER 'duoqi_test'@'%' IDENTIFIED BY 'your-secure-password';
|
||||
GRANT ALL PRIVILEGES ON duoqi_test.* TO 'duoqi_test'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### Step 1:配置本地连接串
|
||||
|
||||
编辑本地项目 `.env` 文件,将 `DATABASE_URL` 指向目标 RDS 数据库:
|
||||
|
||||
```env
|
||||
# 初始化生产库
|
||||
DATABASE_URL=mysql://duoqi_prod:your-password@rm-xxxxx.mysql.rds.aliyuncs.com:3306/duoqi_prod
|
||||
|
||||
# 或初始化测试库
|
||||
# DATABASE_URL=mysql://duoqi_test:your-password@rm-xxxxx.mysql.rds.aliyuncs.com:3306/duoqi_test
|
||||
```
|
||||
|
||||
> **注意**:操作完成后,记得将 `DATABASE_URL` 改回本地开发值,避免后续开发误操作生产数据库。
|
||||
|
||||
### Step 2:推送表结构
|
||||
|
||||
```bash
|
||||
# 从 schema.ts 直接同步全部 15 张表 + 外键 + 索引
|
||||
bun run db:push
|
||||
```
|
||||
|
||||
`drizzle-kit push` 会:
|
||||
1. 读取 `src/db/schema.ts` 中的表定义
|
||||
2. 连接数据库,对比当前状态
|
||||
3. 创建所有缺失的表、列、索引和外键
|
||||
|
||||
输出示例:
|
||||
```
|
||||
[✓] Changes applied successfully. The following changes were made:
|
||||
└─ Table "users" was created
|
||||
└─ Table "categories" was created
|
||||
└─ ... (共 15 张表)
|
||||
```
|
||||
|
||||
> **替代方案**:如果不想用 `db:push`,也可以用 `db:migrate` 执行已有的迁移文件:
|
||||
> ```bash
|
||||
> bun run db:migrate
|
||||
> ```
|
||||
> 效果相同,区别在于 `db:migrate` 执行的是 `db/migrations/0000_melodic_blacklash.sql` 这个固定文件。
|
||||
|
||||
### Step 3:导入种子数据
|
||||
|
||||
```bash
|
||||
bun run db:seed
|
||||
```
|
||||
|
||||
种子脚本 (`db/seeds/index.ts`) 按依赖拓扑顺序导入,**幂等安全**(可重复执行):
|
||||
|
||||
```
|
||||
Step 0: admin_users → 默认管理员 (admin / admin123)
|
||||
Step 1: categories → 4 个分类(历史、戏曲、相声...)
|
||||
Step 2: skill_tree → 技能树节点
|
||||
Step 3: questions → 题目
|
||||
knowledge_cards → 知识卡片(依赖 questions)
|
||||
Step 4: achievements → 成就定义
|
||||
```
|
||||
|
||||
输出示例:
|
||||
```
|
||||
Admin user seeded: username=admin, password=admin123 (CHANGE IN PRODUCTION!)
|
||||
Categories: 4 inserted, 0 skipped
|
||||
Skill tree: 12 inserted, 0 skipped
|
||||
Questions: 60 inserted, 0 skipped
|
||||
Achievements: 8 inserted, 0 skipped
|
||||
|
||||
Seed data import complete!
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 默认管理员密码 `admin123` 仅用于初始化,**首次登录后必须立即修改**。
|
||||
|
||||
### Step 4:验证
|
||||
|
||||
```bash
|
||||
# 方式一:Drizzle Studio(浏览器可视化)
|
||||
bun run db:studio
|
||||
# 访问 https://local.drizzle.studio → 浏览所有表和数据
|
||||
|
||||
# 方式二:命令行查询
|
||||
mysql -h rm-xxxxx.mysql.rds.aliyuncs.com -u duoqi_prod -p duoqi_prod \
|
||||
-e "SHOW TABLES; SELECT COUNT(*) AS question_count FROM questions;"
|
||||
```
|
||||
|
||||
预期结果:`SHOW TABLES` 输出 15 张表,`questions` 有数据。
|
||||
|
||||
---
|
||||
|
||||
## 日常表结构变更
|
||||
|
||||
> 适用于:schema 已初始化,后续迭代需要新增/修改表结构。
|
||||
|
||||
### 开发流程
|
||||
|
||||
```
|
||||
修改 schema.ts → 生成迁移文件 → 本地验证 → 提交代码
|
||||
```
|
||||
|
||||
#### 1. 修改 Schema
|
||||
|
||||
编辑 `src/db/schema.ts`,例如新增一张表:
|
||||
|
||||
```typescript
|
||||
// src/db/schema.ts — 示例:新增通知表
|
||||
export const notifications = mysqlTable('notifications', {
|
||||
id: char('id', { length: 36 }).primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
content: text('content'),
|
||||
read: tinyint('read').default(0),
|
||||
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 生成迁移文件
|
||||
|
||||
```bash
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
`drizzle-kit generate` 会对比 `schema.ts` 与上一次快照,在 `db/migrations/` 中生成新的 SQL 文件:
|
||||
|
||||
```
|
||||
db/migrations/
|
||||
├── 0000_melodic_blacklash.sql ← 初始化(已存在)
|
||||
├── 0001_add_notifications.sql ← 新增
|
||||
└── meta/
|
||||
├── 0001_snapshot.json
|
||||
└── _journal.json
|
||||
```
|
||||
|
||||
> [!important]
|
||||
> 生成的 SQL 文件**必须提交到 Git**。迁移文件是生产部署的变更记录,不可遗漏。
|
||||
|
||||
#### 3. 本地验证
|
||||
|
||||
```bash
|
||||
# 方式 A:在本地 MySQL 验证
|
||||
bun run db:push
|
||||
|
||||
# 方式 B:模拟生产流程(推荐)
|
||||
bun run db:migrate
|
||||
```
|
||||
|
||||
验证通过后,将迁移文件和 schema 变更一起提交:
|
||||
|
||||
```bash
|
||||
git add src/db/schema.ts db/migrations/
|
||||
git commit -m "feat: 新增通知表"
|
||||
```
|
||||
|
||||
### 生产部署流程
|
||||
|
||||
迁移文件随代码提交后,通过以下两种方式应用到生产数据库:
|
||||
|
||||
#### 方式 A:CI 自动执行(推荐)
|
||||
|
||||
在 `.gitea/workflows/deploy.yml` 的部署 Job 中,**在构建镜像之前**执行迁移:
|
||||
|
||||
```yaml
|
||||
# deploy.yml — 在 build-and-deploy-prod 和 build-and-deploy-test 中添加
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run database migrations
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
run: bun run db:migrate
|
||||
|
||||
# ... 后续 build / deploy 步骤
|
||||
```
|
||||
|
||||
> **为什么迁移必须在部署前执行?**
|
||||
> 新代码可能依赖新表/新列。先部署新代码再迁移,中间会有服务报错窗口。先迁移则旧代码仍可正常运行(向后兼容的 schema 变更)。
|
||||
|
||||
#### 方式 B:手动从本地执行(紧急/首次)
|
||||
|
||||
从本地开发机直接连接 RDS 执行:
|
||||
|
||||
```bash
|
||||
# 1. 临时将 .env 的 DATABASE_URL 改为生产 RDS
|
||||
# 2. 执行迁移
|
||||
bun run db:migrate
|
||||
# 3. 改回本地 DATABASE_URL
|
||||
```
|
||||
|
||||
或通过环境变量覆盖(不修改 .env):
|
||||
|
||||
```bash
|
||||
DATABASE_URL=mysql://duoqi_prod:password@rm-xxxxx:3306/duoqi_prod bun run db:migrate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常用操作速查
|
||||
|
||||
| 操作 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 推送表结构 | `bun run db:push` | 从 schema.ts 同步到数据库(开发用) |
|
||||
| 生成迁移文件 | `bun run db:generate` | 对比 schema 差异,生成 SQL 文件 |
|
||||
| 执行迁移 | `bun run db:migrate` | 执行未应用的迁移 SQL 文件 |
|
||||
| 导入种子数据 | `bun run db:seed` | 幂等导入分类、题目、成就等 |
|
||||
| 可视化浏览 | `bun run db:studio` | 启动 Drizzle Studio Web UI |
|
||||
| 类型检查 | `bun run typecheck` | 确认 schema.ts 无类型错误 |
|
||||
|
||||
### Drizzle Kit 命令对比
|
||||
|
||||
```
|
||||
schema.ts (源码真相)
|
||||
│
|
||||
├── db:push ────────→ 直接同步到数据库(无迁移文件,开发用)
|
||||
│
|
||||
├── db:generate ────→ 生成 SQL 迁移文件到 db/migrations/
|
||||
│ (需要提交到 Git)
|
||||
│
|
||||
└── db:migrate ────→ 执行 db/migrations/ 中的 SQL
|
||||
(生产部署用,幂等)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 1. `db:push` 报连接超时
|
||||
|
||||
```bash
|
||||
# 检查网络连通性
|
||||
mysql -h rm-xxxxx.mysql.rds.aliyuncs.com -u root -p -e "SELECT 1;"
|
||||
|
||||
# 如果超时,检查 RDS 白名单
|
||||
# 阿里云控制台 → RDS → 数据安全性 → 白名单设置
|
||||
# 添加本机公网 IP(可通过 curl ifconfig.me 查看)
|
||||
curl ifconfig.me
|
||||
```
|
||||
|
||||
### 2. `db:push` 报表已存在
|
||||
|
||||
```
|
||||
Error: Table 'users' already exists
|
||||
```
|
||||
|
||||
说明数据库中已有表结构。如果是首次初始化,可能是之前执行过。`db:push` 是幂等的,已有表不会重复创建,可以安全忽略。
|
||||
|
||||
### 3. `db:migrate` 报迁移已应用
|
||||
|
||||
```
|
||||
No pending migrations to execute
|
||||
```
|
||||
|
||||
所有迁移文件已执行过,这是正常输出。
|
||||
|
||||
### 4. `db:seed` 重复执行
|
||||
|
||||
种子脚本是幂等的 — 每条数据插入前都会检查是否已存在,输出 `skipped` 计数。可安全重复运行。
|
||||
|
||||
### 5. `db:generate` 未生成新文件
|
||||
|
||||
```
|
||||
Everything is fine, no changes detected
|
||||
```
|
||||
|
||||
说明 `schema.ts` 没有变更,或变更已生成过迁移文件。检查:
|
||||
- 是否修改了 `src/db/schema.ts` 并保存
|
||||
- `db/migrations/meta/_journal.json` 中是否已记录该变更
|
||||
|
||||
### 6. Schema 变更后类型报错
|
||||
|
||||
```bash
|
||||
# 重新运行类型检查
|
||||
bun run typecheck
|
||||
|
||||
# 常见原因:schema.ts 中修改了字段,但引用该字段的 service/routes 未更新
|
||||
```
|
||||
|
||||
### 7. 本地误操作生产数据库
|
||||
|
||||
如果不小心对生产数据库执行了破坏性操作:
|
||||
|
||||
```bash
|
||||
# 1. 立即停止操作,不要重复执行
|
||||
# 2. 从备份恢复
|
||||
mysql -h rm-xxxxx -u duoqi_prod -p duoqi_prod < /opt/backups/duoqi_prod_YYYYMMDD.sql
|
||||
|
||||
# 3. 如果没有备份,在 RDS 控制台使用「数据恢复」功能(需已开启 binlog)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0.0
|
||||
**最后更新**: 2026-04-22
|
||||
**维护者**: Duoqi Team
|
||||
@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
// Mock bcryptjs
|
||||
vi.mock('bcryptjs', () => ({
|
||||
compare: vi.fn(),
|
||||
hash: vi.fn(),
|
||||
}));
|
||||
|
||||
// Build the mock DB once, then restore individual mocks in beforeEach
|
||||
function buildMockDb() {
|
||||
const db: Record<string, Mock> = {};
|
||||
db.select = vi.fn().mockReturnValue(db);
|
||||
db.from = vi.fn().mockReturnValue(db);
|
||||
db.where = vi.fn().mockReturnValue(db);
|
||||
db.limit = vi.fn().mockResolvedValue([]);
|
||||
db.update = vi.fn().mockReturnValue(db);
|
||||
db.set = vi.fn().mockReturnValue(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
const mockDb = buildMockDb();
|
||||
|
||||
vi.mock('../../../db/client.js', () => ({ db: mockDb }));
|
||||
|
||||
const { changePassword } = await import('../../../services/admin/admin-auth.js');
|
||||
const bcrypt = await import('bcryptjs');
|
||||
|
||||
const mockAdmin = {
|
||||
id: 'admin-123',
|
||||
username: 'testadmin',
|
||||
passwordHash: '$2a$10$hashedoldpassword',
|
||||
role: 'admin',
|
||||
isActive: 1,
|
||||
};
|
||||
|
||||
describe('changePassword', () => {
|
||||
beforeEach(() => {
|
||||
// Reset call counts but keep implementations
|
||||
mockDb.select.mockClear().mockReturnValue(mockDb);
|
||||
mockDb.from.mockClear().mockReturnValue(mockDb);
|
||||
mockDb.where.mockClear().mockReturnValue(mockDb);
|
||||
mockDb.update.mockClear().mockReturnValue(mockDb);
|
||||
mockDb.set.mockClear().mockReturnValue(mockDb);
|
||||
|
||||
// Per-test defaults
|
||||
mockDb.limit.mockResolvedValue([mockAdmin]);
|
||||
(bcrypt.compare as Mock).mockResolvedValue(true);
|
||||
(bcrypt.hash as Mock).mockResolvedValue('$2a$10$hashednewpassword');
|
||||
});
|
||||
|
||||
it('changes password when current password is correct', async () => {
|
||||
await changePassword('admin-123', 'oldPassword1!', 'newPassword1!');
|
||||
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith('oldPassword1!', mockAdmin.passwordHash);
|
||||
expect(bcrypt.hash).toHaveBeenCalledWith('newPassword1!', 10);
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ passwordHash: '$2a$10$hashednewpassword' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedError when admin not found', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
await expect(changePassword('admin-123', 'old', 'new'))
|
||||
.rejects.toThrow('Admin user not found');
|
||||
});
|
||||
|
||||
it('throws ForbiddenError when admin is disabled', async () => {
|
||||
mockDb.limit.mockResolvedValue([{ ...mockAdmin, isActive: 0 }]);
|
||||
|
||||
await expect(changePassword('admin-123', 'old', 'new'))
|
||||
.rejects.toThrow('Admin account is disabled');
|
||||
});
|
||||
|
||||
it('throws UnauthorizedError when current password is wrong', async () => {
|
||||
(bcrypt.compare as Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(changePassword('admin-123', 'wrong', 'new'))
|
||||
.rejects.toThrow('Current password is incorrect');
|
||||
|
||||
expect(mockDb.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ValidationError when new password equals current password', async () => {
|
||||
await expect(changePassword('admin-123', 'samePass1!', 'samePass1!'))
|
||||
.rejects.toThrow('New password must be different from current password');
|
||||
|
||||
expect(mockDb.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -2,7 +2,6 @@ import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import * as adminAuthService from '../../services/admin/admin-auth.js';
|
||||
import { config } from '../../utils/config.js';
|
||||
import type { JwtPayload } from '../../types/auth.js';
|
||||
|
||||
// Zod schema for login request
|
||||
const loginSchema = z.object({
|
||||
@ -10,12 +9,6 @@ const loginSchema = z.object({
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
// Zod schema for change-password request
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(8, 'Current password must be at least 8 characters'),
|
||||
newPassword: z.string().min(8, 'New password must be at least 8 characters').max(128, 'New password must be at most 128 characters'),
|
||||
});
|
||||
|
||||
export async function adminAuthRoutes(app: FastifyInstance): Promise<void> {
|
||||
// New: Username/password login
|
||||
app.post('/login', async (request, reply) => {
|
||||
@ -67,33 +60,4 @@ export async function adminAuthRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return { success: true, data: { authenticated: true }, error: null };
|
||||
});
|
||||
|
||||
// Change own password (any authenticated admin)
|
||||
app.put('/change-password', async (request, reply) => {
|
||||
const parsed = changePasswordSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: parsed.error.issues[0]?.message ?? 'Invalid request body',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = request.user as JwtPayload | undefined;
|
||||
if (!user?.userId) {
|
||||
return reply.status(401).send({
|
||||
success: false,
|
||||
data: null,
|
||||
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
|
||||
});
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
|
||||
await adminAuthService.changePassword(user.userId, currentPassword, newPassword);
|
||||
return { success: true, data: { message: 'Password changed successfully' }, error: null };
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { ForbiddenError, UnauthorizedError, ValidationError } from '../../utils/errors.js';
|
||||
import { ForbiddenError } from '../../utils/errors.js';
|
||||
import type { JwtPayload, AdminLoginResponse } from '../../types/auth.js';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
@ -110,52 +110,6 @@ export async function logFailedLogin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change an admin's own password.
|
||||
* Verifies the current password before updating.
|
||||
*
|
||||
* @param adminId - The authenticated admin's ID (from JWT)
|
||||
* @param currentPassword - The admin's current password for verification
|
||||
* @param newPassword - The new password to set
|
||||
* @throws {NotFoundError} If admin not found
|
||||
* @throws {ForbiddenError} If admin account is disabled
|
||||
* @throws {UnauthorizedError} If current password is incorrect
|
||||
*/
|
||||
export async function changePassword(
|
||||
adminId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const [admin] = await db
|
||||
.select()
|
||||
.from(adminUsers)
|
||||
.where(eq(adminUsers.id, adminId))
|
||||
.limit(1);
|
||||
|
||||
if (!admin) {
|
||||
throw new UnauthorizedError('Admin user not found');
|
||||
}
|
||||
|
||||
if (!admin.isActive) {
|
||||
throw new ForbiddenError('Admin account is disabled');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(currentPassword, admin.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedError('Current password is incorrect');
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
throw new ValidationError('New password must be different from current password');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
await db
|
||||
.update(adminUsers)
|
||||
.set({ passwordHash, updatedAt: new Date() })
|
||||
.where(eq(adminUsers.id, adminId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password for storage
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user