3 Commits

Author SHA1 Message Date
湛兮 f1e3976c95 ci: add production tag pipeline 2026-06-05 14:38:23 +08:00
湛兮 998a44431d chore: approve backend pnpm build scripts 2026-06-05 12:40:59 +08:00
湛兮 488df0b1ee chore: split dev and production deployment env 2026-06-05 12:32:10 +08:00
14 changed files with 574 additions and 30 deletions
Vendored
+122
View File
@@ -0,0 +1,122 @@
def isProductionDeploy() {
return params.DEPLOY_ENV == "production"
}
def isTestDeploy() {
def branch = env.BRANCH_NAME ?: ""
return params.DEPLOY_ENV == "test" && !params.SKIP_DEPLOY && branch == env.TEST_BRANCH
}
@NonCPS
boolean isUserTriggeredBuild() {
return currentBuild.rawBuild.getCauses().any { cause ->
cause.class.name == "hudson.model.Cause\$UserIdCause"
}
}
pipeline {
agent any
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: "30", artifactNumToKeepStr: "10"))
}
parameters {
choice(
name: "DEPLOY_ENV",
choices: ["test", "production"],
description: "test: develop 合并后自动部署测试环境;production: 仅允许手动输入 Tag 部署"
)
string(name: "RELEASE_TAG", defaultValue: "", description: "生产部署必填,必须是 Gitea 中已存在的 Tag")
booleanParam(name: "SKIP_DEPLOY", defaultValue: false, description: "只构建检查,不执行部署")
}
environment {
PROJECT_NAME = "access-manage"
TEST_BRANCH = "develop"
DEPLOY_BASE_DIR = "/srv/www"
}
stages {
stage("Validate deploy policy") {
steps {
script {
if (isProductionDeploy()) {
if (!isUserTriggeredBuild()) {
error("生产环境禁止自动触发,只能在 Jenkins 手动 Build With Parameters。")
}
if (!params.RELEASE_TAG?.trim()) {
error("生产环境部署必须填写 RELEASE_TAG,且只能从项目 Tag 部署。")
}
}
if (params.DEPLOY_ENV == "test" && env.BRANCH_NAME && env.BRANCH_NAME != env.TEST_BRANCH) {
echo "当前分支 ${env.BRANCH_NAME} 不是 ${env.TEST_BRANCH},本次只构建检查,不自动部署测试环境。"
}
}
}
}
stage("Checkout production tag") {
when {
expression { isProductionDeploy() }
}
steps {
sh '''
set -euo pipefail
git fetch --tags --force origin '+refs/tags/*:refs/tags/*'
tag_commit="$(git rev-parse -q --verify "refs/tags/${RELEASE_TAG}^{commit}")"
if [ -z "${tag_commit}" ]; then
echo "Tag not found: ${RELEASE_TAG}" >&2
exit 1
fi
git checkout -f "${tag_commit}"
git log -1 --oneline
'''
}
}
stage("Install") {
steps {
sh '''
corepack enable || true
pnpm install --frozen-lockfile
'''
}
}
stage("Verify") {
steps {
sh "pnpm typecheck"
}
}
stage("Build") {
steps {
script {
sh isProductionDeploy() ? "pnpm build:pro" : "pnpm build:test"
}
}
}
stage("Deploy test") {
when {
expression { isTestDeploy() }
}
steps {
sh "bash deploy/jenkins/deploy-backend.sh test"
}
}
stage("Deploy production") {
when {
expression { isProductionDeploy() && !params.SKIP_DEPLOY }
}
steps {
sh "bash deploy/jenkins/deploy-backend.sh production"
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
pipeline {
agent any
parameters {
gitParameter(
name: 'RELEASE_TAG',
type: 'PT_TAG',
tagFilter: 'v*',
branchFilter: 'origin/(.*)',
sortMode: 'DESCENDING_SMART',
selectedValue: 'TOP',
useRepository: 'http://127.0.0.1:3001/my-project/access-manage.git',
quickFilterEnabled: true,
listSize: '10',
requiredParameter: true,
description: '请选择要部署到生产环境的 Git Tag。列表自动来自当前项目仓库,生产只能从 Tag 发布。'
)
}
environment {
CI = 'true'
}
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '10'))
}
stages {
stage('Checkout Tag') {
steps {
checkout([
$class: 'GitSCM',
branches: [[name: '*/master']],
userRemoteConfigs: [[
url: 'http://127.0.0.1:3001/my-project/access-manage.git'
]]
])
sh '''
set -eu
test -n "$RELEASE_TAG"
NORMALIZED_RELEASE_TAG="$(printf '%s' "$RELEASE_TAG" | sed 's/\\^{}$//')"
echo "Deploying production tag: $NORMALIZED_RELEASE_TAG"
git fetch --tags --force
git checkout -f "refs/tags/$NORMALIZED_RELEASE_TAG"
git describe --tags --exact-match HEAD
'''
}
}
stage('Env') {
steps {
sh '''
git --version
node -v
corepack --version
corepack enable
corepack prepare pnpm@11.5.0 --activate
pnpm -v
'''
}
}
stage('Install') {
steps {
sh '''
pnpm install --frozen-lockfile
'''
}
}
stage('Check') {
steps {
sh '''
pnpm typecheck
'''
}
}
stage('Build') {
steps {
sh '''
pnpm build:pro
test -f dist/server.js
test -f dist/db/migrate.js
'''
}
}
stage('Deploy Production') {
steps {
sh 'sudo /usr/local/bin/deploy-access-manage-from-jenkins "$WORKSPACE"'
}
}
}
}
+62 -17
View File
@@ -44,11 +44,18 @@
├── AGENTS.md # Codex/Agent 入口指令,当前指向 RTK.md ├── AGENTS.md # Codex/Agent 入口指令,当前指向 RTK.md
├── RTK.md # 项目协作规则和开发约定 ├── RTK.md # 项目协作规则和开发约定
├── docs/ ├── docs/
── API.md # 前端对接接口文档 ── API.md # 前端对接接口文档
│ └── ENVIRONMENT_DEPLOYMENT.md # 测试/生产环境拆分与 Jenkins 规则
├── deploy/ ├── deploy/
│ ├── env/
│ │ ├── test.env.example # 测试环境变量示例,不包含真实密码
│ │ └── production.env.example # 生产环境变量示例,不包含真实密码
│ ├── jenkins/
│ │ └── deploy-backend.sh # Jenkins 后端部署脚本
│ └── server/ │ └── server/
│ ├── create-env.sh # 在服务器生成真实 .env 和 .env.production │ ├── create-env.sh # 在服务器生成测试/生产真实环境变量
── docker-compose.mysql.yml # 服务器 MySQL Compose 模板 ── docker-compose.mysql.test.yml # 测试 MySQL Compose 模板
│ └── docker-compose.mysql.production.yml # 生产 MySQL Compose 模板
├── migrations/ # 数据库迁移 SQL ├── migrations/ # 数据库迁移 SQL
│ ├── 001_initial_schema.sql # 创建基础表结构 │ ├── 001_initial_schema.sql # 创建基础表结构
│ ├── 002_seed_demo_data.sql # 初始化演示门店和角色 │ ├── 002_seed_demo_data.sql # 初始化演示门店和角色
@@ -78,6 +85,7 @@
│ │ └── tasks/ # 任务后台管理和员工端任务模块 │ │ └── tasks/ # 任务后台管理和员工端任务模块
│ └── shared/ # 通用响应结构和业务错误 │ └── shared/ # 通用响应结构和业务错误
├── docker-compose.yml # 本地 MySQL ├── docker-compose.yml # 本地 MySQL
├── Jenkinsfile # Jenkins 测试自动部署、生产手动 Tag 部署规则
├── package.json ├── package.json
├── pnpm-lock.yaml ├── pnpm-lock.yaml
├── README.md ├── README.md
@@ -95,7 +103,10 @@
| `AGENTS.md` | Agent 工具读取的入口文件,当前通过 `@RTK.md` 引入项目规则。 | | `AGENTS.md` | Agent 工具读取的入口文件,当前通过 `@RTK.md` 引入项目规则。 |
| `RTK.md` | 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。 | | `RTK.md` | 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。 |
| `docs/API.md` | 面向前端对接的完整接口文档,包含认证、权限、字段约束、示例请求响应和错误码。 | | `docs/API.md` | 面向前端对接的完整接口文档,包含认证、权限、字段约束、示例请求响应和错误码。 |
| `deploy/server/` | 服务器部署辅助文件。`create-env.sh` 在服务器本地生成真实环境变量,`docker-compose.mysql.yml` 用于启动服务器 MySQL。 | | `docs/ENVIRONMENT_DEPLOYMENT.md` | 测试/生产环境拆分、Jenkins 参数、Tag 发布和服务器路径约定。 |
| `deploy/env/` | 测试/生产环境变量示例,只给字段结构,不提交真实密码。 |
| `deploy/jenkins/` | Jenkins 部署脚本。当前 `deploy-backend.sh` 会按环境发布到独立目录。 |
| `deploy/server/` | 服务器部署辅助文件。`create-env.sh` 在服务器本地生成测试/生产真实环境变量,Compose 模板分别启动测试和生产 MySQL。 |
| `migrations/` | 数据库迁移目录。所有建表、改表、初始化基础数据的 SQL 都放在这里。 | | `migrations/` | 数据库迁移目录。所有建表、改表、初始化基础数据的 SQL 都放在这里。 |
| `src/app.ts` | 创建 Fastify 应用,注册路由,处理健康检查和全局错误。 | | `src/app.ts` | 创建 Fastify 应用,注册路由,处理健康检查和全局错误。 |
| `src/server.ts` | 真正启动 HTTP 服务,监听端口,并处理优雅停机。 | | `src/server.ts` | 真正启动 HTTP 服务,监听端口,并处理优雅停机。 |
@@ -113,6 +124,7 @@
| `src/modules/tasks/` | 任务模块,负责后台任务管理、员工端任务处理和任务事件日志。 | | `src/modules/tasks/` | 任务模块,负责后台任务管理、员工端任务处理和任务事件日志。 |
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 | | `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
| `docker-compose.yml` | 本地开发用 MySQL 容器配置。 | | `docker-compose.yml` | 本地开发用 MySQL 容器配置。 |
| `Jenkinsfile` | 流水线入口。`develop` 合并自动部署测试环境;生产环境只能手动选择 Tag 部署。 |
| `package.json` | 项目信息、依赖和常用脚本;脚本会读取现有 `.env.development`。 | | `package.json` | 项目信息、依赖和常用脚本;脚本会读取现有 `.env.development`。 |
| `pnpm-lock.yaml` | pnpm 锁文件,保证依赖版本一致。 | | `pnpm-lock.yaml` | pnpm 锁文件,保证依赖版本一致。 |
| `tsconfig.json` | TypeScript 编译配置。 | | `tsconfig.json` | TypeScript 编译配置。 |
@@ -227,55 +239,77 @@ curl http://localhost:3500/health
服务器部署时不要直接使用本地 `.env.development`,也不要把本机 `.env.*` 打进部署包。真实密码只应该在服务器本地生成和保存。 服务器部署时不要直接使用本地 `.env.development`,也不要把本机 `.env.*` 打进部署包。真实密码只应该在服务器本地生成和保存。
当前服务器按测试环境和生产环境拆分,默认路径如下:
| 环境 | 应用目录 | 默认端口 | 数据目录 |
| --- | --- | --- | --- |
| 测试环境 | `/srv/www/test/access-manage` | `3501` | `/srv/data/test/access-manage/mysql` |
| 生产环境 | `/srv/www/production/access-manage` | `3500` | `/srv/data/production/access-manage/mysql` |
1. 生成服务器真实环境变量: 1. 生成服务器真实环境变量:
```bash ```bash
cd /srv/www/access-manage cd /srv/www/test/access-manage
bash deploy/server/create-env.sh /srv/www/access-manage bash deploy/server/create-env.sh /srv/www/test/access-manage test
cd /srv/www/production/access-manage
bash deploy/server/create-env.sh /srv/www/production/access-manage production
``` ```
这个脚本会生成两个不提交到仓库的文件: 这个脚本会生成不提交到仓库的文件:
```text ```text
/srv/www/access-manage/.env /srv/www/test/access-manage/.env.test.mysql
/srv/www/access-manage/.env.production /srv/www/test/access-manage/.env.test
/srv/www/production/access-manage/.env.production.mysql
/srv/www/production/access-manage/.env.production
``` ```
其中 `.env` 给 MySQL 容器使用,`.env.production` 给 Node 后端使用。如果 `.env` 已经存在,脚本会读取现有 `MYSQL_PASSWORD`,只补 `.env.production`;如果 `.env.production`存在,脚本会拒绝覆盖。 其中 `.env.*.mysql` 给 MySQL 容器使用,`.env.test` / `.env.production` 给 Node 后端使用。如果应用 env 已存在,脚本会拒绝覆盖。
2. 启动服务器 MySQL 2. 启动服务器 MySQL
```bash ```bash
docker-compose -f deploy/server/docker-compose.mysql.yml up -d mysql cd /srv/www/test/access-manage
docker-compose --env-file .env.test.mysql -f deploy/server/docker-compose.mysql.test.yml up -d mysql
cd /srv/www/production/access-manage
docker-compose --env-file .env.production.mysql -f deploy/server/docker-compose.mysql.production.yml up -d mysql
``` ```
服务器模板会把 MySQL 数据持久化到: 服务器模板会把 MySQL 数据分别持久化到:
```text ```text
/srv/data/access-manage/mysql /srv/data/test/access-manage/mysql
/srv/data/production/access-manage/mysql
``` ```
当前模板默认后端通过服务器本机端口连接 MySQL: 当前模板默认后端通过服务器本机端口连接 MySQL:
```env ```env
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3307 DB_PORT=3308 # 测试
DB_PORT=3307 # 生产
``` ```
3. 安装生产依赖、迁移、启动: 3. 安装生产依赖、迁移、启动:
```bash ```bash
pnpm install --prod --frozen-lockfile pnpm install --prod --frozen-lockfile
pnpm db:migrate:test
pnpm start:test
pnpm db:migrate:prod pnpm db:migrate:prod
pnpm start:prod pnpm start:prod
``` ```
`pnpm build:dev``pnpm build:pro` 只生成编译后的 `dist/` 目录。服务器上不需要再执行 `pnpm build` `pnpm build:dev``pnpm build:test``pnpm build:pro` 只生成编译后的 `dist/` 目录。服务器上不需要再执行 `pnpm build`
4. 健康检查: 4. 健康检查:
```bash ```bash
curl http://127.0.0.1:3500/health curl http://127.0.0.1:3501/health # 测试
curl http://127.0.0.1:3500/health # 生产
``` ```
只有返回里出现 `database: "up"`,才代表后端服务和 MySQL 都连通。 只有返回里出现 `database: "up"`,才代表后端服务和 MySQL 都连通。
@@ -288,7 +322,15 @@ curl http://127.0.0.1:3500/health
pnpm build:dev pnpm build:dev
``` ```
命令执行完成后会得到最新的 `dist/`。如果需要压缩上传,由部署者手动选择要打包的文件;正式生产构建时可以执行 `pnpm build:pro` 命令执行完成后会得到最新的 `dist/`。如果需要压缩上传,由部署者手动选择要打包的文件;测试构建可执行 `pnpm build:test`正式生产构建时执行 `pnpm build:pro`
## Jenkins 环境规则
流水线规则见 [docs/ENVIRONMENT_DEPLOYMENT.md](./docs/ENVIRONMENT_DEPLOYMENT.md)。
- 测试环境:`develop` 合并后自动触发,部署到 `/srv/www/test/access-manage/current`
- 生产环境:禁止代码合并自动触发,只能在 Jenkins 手动选择 `DEPLOY_ENV=production` 并填写 Gitea 项目 Tag。
- 生产部署会先 checkout 到 `RELEASE_TAG` 对应的提交,再构建和部署。
## package.json 脚本说明 ## package.json 脚本说明
@@ -299,11 +341,14 @@ pnpm build:dev
| `pnpm dev` | 使用现有 `.env.development` 启动开发服务,并通过 `tsx watch` 监听代码变化。 | 日常开发接口时使用。 | | `pnpm dev` | 使用现有 `.env.development` 启动开发服务,并通过 `tsx watch` 监听代码变化。 | 日常开发接口时使用。 |
| `pnpm build` | 使用 `tsc` 编译 TypeScript,输出到 `dist/`。 | 准备运行编译产物或发布前验证时使用。 | | `pnpm build` | 使用 `tsc` 编译 TypeScript,输出到 `dist/`。 | 准备运行编译产物或发布前验证时使用。 |
| `pnpm build:dev` | 清空旧 `dist/`,再执行 `pnpm build` 生成新的 `dist/`。 | 准备开发/测试服务器部署产物时使用。 | | `pnpm build:dev` | 清空旧 `dist/`,再执行 `pnpm build` 生成新的 `dist/`。 | 准备开发/测试服务器部署产物时使用。 |
| `pnpm build:test` | 清空旧 `dist/`,再执行 `pnpm build` 生成新的 `dist/`。 | Jenkins 测试环境构建时使用。 |
| `pnpm build:pro` | 清空旧 `dist/`,再执行 `pnpm build` 生成新的 `dist/`。 | 准备正式生产部署产物时使用。 | | `pnpm build:pro` | 清空旧 `dist/`,再执行 `pnpm build` 生成新的 `dist/`。 | 准备正式生产部署产物时使用。 |
| `pnpm start` | 使用现有 `.env.development` 运行 `dist/server.js`。 | 已经执行过 `pnpm build` 后,用编译产物启动服务。 | | `pnpm start` | 使用现有 `.env.development` 运行 `dist/server.js`。 | 已经执行过 `pnpm build` 后,用编译产物启动服务。 |
| `pnpm start:test` | 使用 `.env.test` 运行 `dist/server.js`。 | 服务器测试环境启动编译产物时使用。 |
| `pnpm start:prod` | 使用 `.env.production` 运行 `dist/server.js`。 | 服务器生产环境启动编译产物时使用。 | | `pnpm start:prod` | 使用 `.env.production` 运行 `dist/server.js`。 | 服务器生产环境启动编译产物时使用。 |
| `pnpm typecheck` | 执行 `tsc --noEmit`,只检查类型,不生成文件。 | 改 TypeScript 代码后快速确认类型是否正确。 | | `pnpm typecheck` | 执行 `tsc --noEmit`,只检查类型,不生成文件。 | 改 TypeScript 代码后快速确认类型是否正确。 |
| `pnpm db:migrate` | 使用现有 `.env.development` 运行 `src/db/migrate.ts`,按顺序执行 `migrations/*.sql`。 | 第一次启动项目、拉到新迁移、改数据库结构后使用。 | | `pnpm db:migrate` | 使用现有 `.env.development` 运行 `src/db/migrate.ts`,按顺序执行 `migrations/*.sql`。 | 第一次启动项目、拉到新迁移、改数据库结构后使用。 |
| `pnpm db:migrate:test` | 使用 `.env.test` 运行 `dist/db/migrate.js`,按顺序执行 `migrations/*.sql`。 | 服务器测试环境建表、升级表结构或初始化基础数据时使用。 |
| `pnpm db:migrate:prod` | 使用 `.env.production` 运行 `dist/db/migrate.js`,按顺序执行 `migrations/*.sql`。 | 服务器生产环境建表、升级表结构或初始化基础数据时使用。 | | `pnpm db:migrate:prod` | 使用 `.env.production` 运行 `dist/db/migrate.js`,按顺序执行 `migrations/*.sql`。 | 服务器生产环境建表、升级表结构或初始化基础数据时使用。 |
| `pnpm db:shell` | 进入 Docker 容器里的 MySQL 命令行。 | 需要手动查看表结构或查询数据时使用。 | | `pnpm db:shell` | 进入 Docker 容器里的 MySQL 命令行。 | 需要手动查看表结构或查询数据时使用。 |
| `pnpm mysql:up` | 启动本地 MySQL 容器。 | 开发前先启动数据库。 | | `pnpm mysql:up` | 启动本地 MySQL 容器。 | 开发前先启动数据库。 |
+14
View File
@@ -0,0 +1,14 @@
NODE_ENV=production
APP_ENV=production
APP_ENV_LABEL=生产环境
PORT=3500
DB_HOST=127.0.0.1
DB_PORT=3307
DB_USER=access_user
DB_PASSWORD=replace-with-production-password
DB_NAME=access_manage
DB_CONNECTION_LIMIT=10
JWT_SECRET=replace-with-at-least-32-characters-production-secret
JWT_EXPIRES_IN=2h
+14
View File
@@ -0,0 +1,14 @@
NODE_ENV=test
APP_ENV=test
APP_ENV_LABEL=测试环境
PORT=3501
DB_HOST=127.0.0.1
DB_PORT=3308
DB_USER=access_user
DB_PASSWORD=replace-with-test-password
DB_NAME=access_manage_test
DB_CONNECTION_LIMIT=10
JWT_SECRET=replace-with-at-least-32-characters-test-secret
JWT_EXPIRES_IN=2h
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
deploy_env="${1:?Usage: deploy-backend.sh test|production}"
case "${deploy_env}" in
test)
env_file=".env.test"
;;
production)
env_file=".env.production"
;;
*)
echo "Unknown deploy environment: ${deploy_env}" >&2
exit 1
;;
esac
project_name="${PROJECT_NAME:-access-manage}"
base_dir="${DEPLOY_BASE_DIR:-/srv/www}"
target_dir="${DEPLOY_TARGET_DIR:-${base_dir}/${deploy_env}/${project_name}}"
deploy_remote="${DEPLOY_REMOTE:-}"
release_id="${BUILD_NUMBER:-manual}-$(git rev-parse --short=12 HEAD 2>/dev/null || date +%Y%m%d%H%M%S)"
package_dir=".deploy/${project_name}"
rm -rf "${package_dir}"
mkdir -p "${package_dir}"
cp -R dist "${package_dir}/dist"
cp -R migrations "${package_dir}/migrations"
cp -R deploy "${package_dir}/deploy"
cp package.json pnpm-lock.yaml "${package_dir}/"
remote_shell() {
if [[ -n "${deploy_remote}" ]]; then
ssh "${deploy_remote}" "$@"
else
bash -lc "$*"
fi
}
remote_copy() {
local source_dir="$1"
local dest_dir="$2"
if [[ -n "${deploy_remote}" ]]; then
rsync -az --delete "${source_dir}/" "${deploy_remote}:${dest_dir}/"
else
mkdir -p "${dest_dir}"
rsync -az --delete "${source_dir}/" "${dest_dir}/"
fi
}
release_dir="${target_dir}/releases/${release_id}"
shared_dir="${target_dir}/shared"
remote_shell "mkdir -p '${release_dir}' '${shared_dir}' '${target_dir}/logs'"
remote_copy "${package_dir}" "${release_dir}"
remote_shell "
set -euo pipefail
if [ -f '${shared_dir}/${env_file}' ]; then
ln -sfn '../../shared/${env_file}' '${release_dir}/${env_file}'
else
echo 'Missing ${shared_dir}/${env_file}; create it before starting the service.' >&2
fi
ln -sfn '${release_dir}' '${target_dir}/current'
"
if [[ -n "${DEPLOY_POST_DEPLOY_CMD:-}" ]]; then
remote_shell "cd '${target_dir}/current' && ${DEPLOY_POST_DEPLOY_CMD}"
else
echo "Deployed ${project_name} ${deploy_env} to ${target_dir}/current"
echo "Set DEPLOY_POST_DEPLOY_CMD in Jenkins to install production dependencies, run migrations, and restart the service."
fi
+31 -7
View File
@@ -2,11 +2,33 @@
set -euo pipefail set -euo pipefail
target_dir="${1:-$(pwd)}" target_dir="${1:-$(pwd)}"
mysql_env="${target_dir}/.env" deploy_env="${2:-production}"
case "${deploy_env}" in
test)
mysql_env="${target_dir}/.env.test.mysql"
app_env="${target_dir}/.env.test"
node_env="test"
app_port="${ACCESS_MANAGE_TEST_PORT:-3501}"
mysql_port="${ACCESS_MANAGE_TEST_DB_PORT:-3308}"
mysql_database="${ACCESS_MANAGE_TEST_DB_NAME:-access_manage_test}"
;;
production)
mysql_env="${target_dir}/.env.production.mysql"
app_env="${target_dir}/.env.production" app_env="${target_dir}/.env.production"
node_env="production"
app_port="${ACCESS_MANAGE_PRODUCTION_PORT:-3500}"
mysql_port="${ACCESS_MANAGE_PRODUCTION_DB_PORT:-3307}"
mysql_database="${ACCESS_MANAGE_PRODUCTION_DB_NAME:-access_manage}"
;;
*)
echo "Usage: $0 [target_dir] [test|production]" >&2
exit 1
;;
esac
if [[ -e "${app_env}" ]]; then if [[ -e "${app_env}" ]]; then
echo "Refuse to overwrite existing .env.production in ${target_dir}" >&2 echo "Refuse to overwrite existing $(basename "${app_env}") in ${target_dir}" >&2
exit 1 exit 1
fi fi
@@ -22,7 +44,7 @@ if [[ -e "${mysql_env}" ]]; then
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "${mysql_env}" source "${mysql_env}"
mysql_database="${MYSQL_DATABASE:-access_manage}" mysql_database="${MYSQL_DATABASE:-${mysql_database}}"
mysql_user="${MYSQL_USER:-access_user}" mysql_user="${MYSQL_USER:-access_user}"
mysql_password="${MYSQL_PASSWORD:-}" mysql_password="${MYSQL_PASSWORD:-}"
@@ -34,7 +56,6 @@ if [[ -e "${mysql_env}" ]]; then
echo "Found existing ${mysql_env}; only creating ${app_env}" echo "Found existing ${mysql_env}; only creating ${app_env}"
else else
mysql_root_password="root_$(openssl rand -hex 24)" mysql_root_password="root_$(openssl rand -hex 24)"
mysql_database="access_manage"
mysql_user="access_user" mysql_user="access_user"
mysql_password="app_$(openssl rand -hex 24)" mysql_password="app_$(openssl rand -hex 24)"
@@ -43,6 +64,7 @@ MYSQL_ROOT_PASSWORD=${mysql_root_password}
MYSQL_DATABASE=${mysql_database} MYSQL_DATABASE=${mysql_database}
MYSQL_USER=${mysql_user} MYSQL_USER=${mysql_user}
MYSQL_PASSWORD=${mysql_password} MYSQL_PASSWORD=${mysql_password}
MYSQL_HOST_PORT=${mysql_port}
EOF EOF
echo "Created ${mysql_env}" echo "Created ${mysql_env}"
@@ -51,11 +73,13 @@ fi
jwt_secret="$(openssl rand -hex 48)" jwt_secret="$(openssl rand -hex 48)"
cat > "${app_env}" <<EOF cat > "${app_env}" <<EOF
NODE_ENV=production NODE_ENV=${node_env}
PORT=3500 APP_ENV=${node_env}
APP_ENV_LABEL=$([[ "${deploy_env}" == "test" ]] && echo "测试环境" || echo "生产环境")
PORT=${app_port}
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3307 DB_PORT=${mysql_port}
DB_USER=${mysql_user} DB_USER=${mysql_user}
DB_PASSWORD=${mysql_password} DB_PASSWORD=${mysql_password}
DB_NAME=${mysql_database} DB_NAME=${mysql_database}
@@ -0,0 +1,22 @@
version: "3.8"
services:
mysql:
image: mysql:8.4
container_name: access-manage-mysql-production
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
TZ: Asia/Shanghai
ports:
- "${MYSQL_HOST_PORT:-3307}:3306"
volumes:
- /srv/data/production/access-manage/mysql:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
interval: 5s
timeout: 3s
retries: 20
@@ -0,0 +1,22 @@
version: "3.8"
services:
mysql:
image: mysql:8.4
container_name: access-manage-mysql-test
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
TZ: Asia/Shanghai
ports:
- "${MYSQL_HOST_PORT:-3308}:3306"
volumes:
- /srv/data/test/access-manage/mysql:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
interval: 5s
timeout: 3s
retries: 20
+100
View File
@@ -0,0 +1,100 @@
# 环境拆分与流水线规则
本项目拆分为测试环境和生产环境两套独立环境。两套环境使用不同部署目录、环境变量文件、数据库端口和数据目录,避免测试迭代影响生产。
## 环境约定
| 环境 | 触发方式 | 代码依据 | 默认部署目录 | 默认应用端口 | 默认数据库端口 | 数据目录 |
| --- | --- | --- | --- | --- | --- | --- |
| 测试环境 | `develop` 合并后自动触发 Jenkins | `develop` 最新提交 | `/srv/www/test/access-manage/current` | `3501` | `3308` | `/srv/data/test/access-manage/mysql` |
| 生产环境 | Jenkins 手动触发 | Gitea 项目 Tag | `/srv/www/production/access-manage/current` | `3500` | `3307` | `/srv/data/production/access-manage/mysql` |
生产环境禁止因代码合并自动部署。生产部署时必须在 Jenkins 参数中选择 `DEPLOY_ENV=production` 并填写已存在的 `RELEASE_TAG`
## Jenkins 参数
| 参数 | 说明 |
| --- | --- |
| `DEPLOY_ENV` | `test``production`。默认 `test`。 |
| `RELEASE_TAG` | 生产环境必填,必须是 Gitea 仓库中已存在的 Tag。 |
| `SKIP_DEPLOY` | 为 `true` 时只执行安装、检查、构建,不部署。 |
`Jenkinsfile` 会强制校验:
- 测试环境只在 `develop` 分支自动部署。
- 生产环境必须手动触发。
- 生产环境必须填写 `RELEASE_TAG`
- 生产环境构建会先 checkout 到该 Tag 对应的提交,再部署。
## 服务器环境变量
测试和生产的真实环境变量必须留在服务器,不提交到仓库。
生成测试环境:
```bash
cd /srv/www/test/access-manage
bash deploy/server/create-env.sh /srv/www/test/access-manage test
docker-compose --env-file .env.test.mysql -f deploy/server/docker-compose.mysql.test.yml up -d mysql
```
生成生产环境:
```bash
cd /srv/www/production/access-manage
bash deploy/server/create-env.sh /srv/www/production/access-manage production
docker-compose --env-file .env.production.mysql -f deploy/server/docker-compose.mysql.production.yml up -d mysql
```
部署脚本默认会读取:
```text
/srv/www/test/access-manage/shared/.env.test
/srv/www/production/access-manage/shared/.env.production
```
如果使用 `create-env.sh``current` 外生成了 env 文件,请把对应文件放到 `shared/` 下,或者在服务器上建立等价软链。
## 生产发布流程
1. 在需要发布的提交上创建 Tag。
2. 推送 Tag 到 Gitea。
3. Jenkins 手动 Build With Parameters。
4. 选择 `DEPLOY_ENV=production`
5. 填写 `RELEASE_TAG`
6. 执行构建部署。
示例:
```bash
git tag -a v2026.06.05-1 -m "access-manage production release 2026-06-05"
git push origin v2026.06.05-1
```
## 部署脚本
`deploy/jenkins/deploy-backend.sh` 会把 `dist/``migrations/``deploy/``package.json``pnpm-lock.yaml` 发布到:
```text
${DEPLOY_BASE_DIR}/${DEPLOY_ENV}/access-manage/releases/<build>-<commit>
```
并更新:
```text
${DEPLOY_BASE_DIR}/${DEPLOY_ENV}/access-manage/current
```
如果 Jenkins 不在目标服务器上运行,可以配置:
```text
DEPLOY_REMOTE=user@server
```
如果需要部署后自动安装依赖、执行迁移和重启服务,在 Jenkins 中配置:
```text
DEPLOY_POST_DEPLOY_CMD=pnpm install --prod --frozen-lockfile && pnpm db:migrate:prod && systemctl restart access-manage-production
```
测试环境应配置独立命令,例如使用 `.env.test` 和独立服务名。
+4 -2
View File
@@ -7,11 +7,14 @@
"dev": "DOTENV_CONFIG_PATH=.env.development tsx watch src/server.ts", "dev": "DOTENV_CONFIG_PATH=.env.development tsx watch src/server.ts",
"build": "tsc", "build": "tsc",
"build:dev": "rm -rf dist && pnpm build", "build:dev": "rm -rf dist && pnpm build",
"build:test": "rm -rf dist && pnpm build",
"build:pro": "rm -rf dist && pnpm build", "build:pro": "rm -rf dist && pnpm build",
"start": "DOTENV_CONFIG_PATH=.env.development node dist/server.js", "start": "DOTENV_CONFIG_PATH=.env.development node dist/server.js",
"start:test": "DOTENV_CONFIG_PATH=.env.test node dist/server.js",
"start:prod": "DOTENV_CONFIG_PATH=.env.production node dist/server.js", "start:prod": "DOTENV_CONFIG_PATH=.env.production node dist/server.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"db:migrate": "DOTENV_CONFIG_PATH=.env.development tsx src/db/migrate.ts", "db:migrate": "DOTENV_CONFIG_PATH=.env.development tsx src/db/migrate.ts",
"db:migrate:test": "DOTENV_CONFIG_PATH=.env.test node dist/db/migrate.js",
"db:migrate:prod": "DOTENV_CONFIG_PATH=.env.production node dist/db/migrate.js", "db:migrate:prod": "DOTENV_CONFIG_PATH=.env.production node dist/db/migrate.js",
"db:shell": "docker compose exec mysql mysql -uaccess_user -paccess_pass access_manage", "db:shell": "docker compose exec mysql mysql -uaccess_user -paccess_pass access_manage",
"mysql:up": "docker compose up -d mysql", "mysql:up": "docker compose up -d mysql",
@@ -36,6 +39,5 @@
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"tsx": "^4.22.3", "tsx": "^4.22.3",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, }
"packageManager": "pnpm@11.5.0"
} }
+2 -2
View File
@@ -1,2 +1,2 @@
allowBuilds: onlyBuiltDependencies:
esbuild: true - esbuild
+2
View File
@@ -55,6 +55,8 @@ export function createApp() {
return ok({ return ok({
status: "ok", status: "ok",
environment: env.APP_ENV,
environmentLabel: env.APP_ENV_LABEL,
database: "up", database: "up",
now: new Date().toISOString(), now: new Date().toISOString(),
}); });
+7 -1
View File
@@ -7,6 +7,8 @@ const envSchema = z.object({
NODE_ENV: z NODE_ENV: z
.enum(["local", "development", "test", "production"]) .enum(["local", "development", "test", "production"])
.default("development"), .default("development"),
APP_ENV: z.enum(["local", "development", "test", "production"]).optional(),
APP_ENV_LABEL: z.string().min(1).optional(),
PORT: z.coerce.number().int().positive().default(3000), PORT: z.coerce.number().int().positive().default(3000),
DB_HOST: z.string().min(1), DB_HOST: z.string().min(1),
@@ -30,4 +32,8 @@ if (!result.success) {
} }
// 其他模块只从 env 读取已校验过的值,不再直接访问 process.env。 // 其他模块只从 env 读取已校验过的值,不再直接访问 process.env。
export const env = result.data; export const env = {
...result.data,
APP_ENV: result.data.APP_ENV ?? result.data.NODE_ENV,
APP_ENV_LABEL: result.data.APP_ENV_LABEL ?? result.data.NODE_ENV
};