diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..6f4603d --- /dev/null +++ b/Jenkinsfile @@ -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" + } + } + } +} diff --git a/README.md b/README.md index 3efa0b2..d357aa0 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,18 @@ ├── AGENTS.md # Codex/Agent 入口指令,当前指向 RTK.md ├── RTK.md # 项目协作规则和开发约定 ├── docs/ -│ └── API.md # 前端对接接口文档 +│ ├── API.md # 前端对接接口文档 +│ └── ENVIRONMENT_DEPLOYMENT.md # 测试/生产环境拆分与 Jenkins 规则 ├── deploy/ +│ ├── env/ +│ │ ├── test.env.example # 测试环境变量示例,不包含真实密码 +│ │ └── production.env.example # 生产环境变量示例,不包含真实密码 +│ ├── jenkins/ +│ │ └── deploy-backend.sh # Jenkins 后端部署脚本 │ └── server/ -│ ├── create-env.sh # 在服务器生成真实 .env 和 .env.production -│ └── docker-compose.mysql.yml # 服务器 MySQL Compose 模板 +│ ├── create-env.sh # 在服务器生成测试/生产真实环境变量 +│ ├── docker-compose.mysql.test.yml # 测试 MySQL Compose 模板 +│ └── docker-compose.mysql.production.yml # 生产 MySQL Compose 模板 ├── migrations/ # 数据库迁移 SQL │ ├── 001_initial_schema.sql # 创建基础表结构 │ ├── 002_seed_demo_data.sql # 初始化演示门店和角色 @@ -78,6 +85,7 @@ │ │ └── tasks/ # 任务后台管理和员工端任务模块 │ └── shared/ # 通用响应结构和业务错误 ├── docker-compose.yml # 本地 MySQL +├── Jenkinsfile # Jenkins 测试自动部署、生产手动 Tag 部署规则 ├── package.json ├── pnpm-lock.yaml ├── README.md @@ -95,7 +103,10 @@ | `AGENTS.md` | Agent 工具读取的入口文件,当前通过 `@RTK.md` 引入项目规则。 | | `RTK.md` | 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。 | | `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 都放在这里。 | | `src/app.ts` | 创建 Fastify 应用,注册路由,处理健康检查和全局错误。 | | `src/server.ts` | 真正启动 HTTP 服务,监听端口,并处理优雅停机。 | @@ -113,6 +124,7 @@ | `src/modules/tasks/` | 任务模块,负责后台任务管理、员工端任务处理和任务事件日志。 | | `src/shared/` | 跨模块复用的响应结构和业务错误类型。 | | `docker-compose.yml` | 本地开发用 MySQL 容器配置。 | +| `Jenkinsfile` | 流水线入口。`develop` 合并自动部署测试环境;生产环境只能手动选择 Tag 部署。 | | `package.json` | 项目信息、依赖和常用脚本;脚本会读取现有 `.env.development`。 | | `pnpm-lock.yaml` | pnpm 锁文件,保证依赖版本一致。 | | `tsconfig.json` | TypeScript 编译配置。 | @@ -227,55 +239,77 @@ curl http://localhost:3500/health 服务器部署时不要直接使用本地 `.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. 生成服务器真实环境变量: ```bash -cd /srv/www/access-manage -bash deploy/server/create-env.sh /srv/www/access-manage +cd /srv/www/test/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 -/srv/www/access-manage/.env -/srv/www/access-manage/.env.production +/srv/www/test/access-manage/.env.test.mysql +/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: ```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 -/srv/data/access-manage/mysql +/srv/data/test/access-manage/mysql +/srv/data/production/access-manage/mysql ``` 当前模板默认后端通过服务器本机端口连接 MySQL: ```env DB_HOST=127.0.0.1 -DB_PORT=3307 +DB_PORT=3308 # 测试 +DB_PORT=3307 # 生产 ``` 3. 安装生产依赖、迁移、启动: ```bash pnpm install --prod --frozen-lockfile +pnpm db:migrate:test +pnpm start:test + pnpm db:migrate: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. 健康检查: ```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 都连通。 @@ -288,7 +322,15 @@ curl http://127.0.0.1:3500/health 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 脚本说明 @@ -299,11 +341,14 @@ pnpm build:dev | `pnpm dev` | 使用现有 `.env.development` 启动开发服务,并通过 `tsx watch` 监听代码变化。 | 日常开发接口时使用。 | | `pnpm build` | 使用 `tsc` 编译 TypeScript,输出到 `dist/`。 | 准备运行编译产物或发布前验证时使用。 | | `pnpm build:dev` | 清空旧 `dist/`,再执行 `pnpm build` 生成新的 `dist/`。 | 准备开发/测试服务器部署产物时使用。 | +| `pnpm build:test` | 清空旧 `dist/`,再执行 `pnpm build` 生成新的 `dist/`。 | Jenkins 测试环境构建时使用。 | | `pnpm build:pro` | 清空旧 `dist/`,再执行 `pnpm build` 生成新的 `dist/`。 | 准备正式生产部署产物时使用。 | | `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 typecheck` | 执行 `tsc --noEmit`,只检查类型,不生成文件。 | 改 TypeScript 代码后快速确认类型是否正确。 | | `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:shell` | 进入 Docker 容器里的 MySQL 命令行。 | 需要手动查看表结构或查询数据时使用。 | | `pnpm mysql:up` | 启动本地 MySQL 容器。 | 开发前先启动数据库。 | diff --git a/deploy/env/production.env.example b/deploy/env/production.env.example new file mode 100644 index 0000000..e33ee6d --- /dev/null +++ b/deploy/env/production.env.example @@ -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 diff --git a/deploy/env/test.env.example b/deploy/env/test.env.example new file mode 100644 index 0000000..5c8476e --- /dev/null +++ b/deploy/env/test.env.example @@ -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 diff --git a/deploy/jenkins/deploy-backend.sh b/deploy/jenkins/deploy-backend.sh new file mode 100644 index 0000000..8353c1c --- /dev/null +++ b/deploy/jenkins/deploy-backend.sh @@ -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 diff --git a/deploy/server/create-env.sh b/deploy/server/create-env.sh index 8cceacf..90db25f 100755 --- a/deploy/server/create-env.sh +++ b/deploy/server/create-env.sh @@ -2,11 +2,33 @@ set -euo pipefail target_dir="${1:-$(pwd)}" -mysql_env="${target_dir}/.env" -app_env="${target_dir}/.env.production" +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" + 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 - 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 fi @@ -22,7 +44,7 @@ if [[ -e "${mysql_env}" ]]; then # shellcheck disable=SC1090 source "${mysql_env}" - mysql_database="${MYSQL_DATABASE:-access_manage}" + mysql_database="${MYSQL_DATABASE:-${mysql_database}}" mysql_user="${MYSQL_USER:-access_user}" mysql_password="${MYSQL_PASSWORD:-}" @@ -34,7 +56,6 @@ if [[ -e "${mysql_env}" ]]; then echo "Found existing ${mysql_env}; only creating ${app_env}" else mysql_root_password="root_$(openssl rand -hex 24)" - mysql_database="access_manage" mysql_user="access_user" mysql_password="app_$(openssl rand -hex 24)" @@ -43,6 +64,7 @@ MYSQL_ROOT_PASSWORD=${mysql_root_password} MYSQL_DATABASE=${mysql_database} MYSQL_USER=${mysql_user} MYSQL_PASSWORD=${mysql_password} +MYSQL_HOST_PORT=${mysql_port} EOF echo "Created ${mysql_env}" @@ -51,11 +73,13 @@ fi jwt_secret="$(openssl rand -hex 48)" cat > "${app_env}" <- +``` + +并更新: + +```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` 和独立服务名。 diff --git a/package.json b/package.json index 0592114..e202cc9 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,14 @@ "dev": "DOTENV_CONFIG_PATH=.env.development tsx watch src/server.ts", "build": "tsc", "build:dev": "rm -rf dist && pnpm build", + "build:test": "rm -rf dist && pnpm build", "build:pro": "rm -rf dist && pnpm build", "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", "typecheck": "tsc --noEmit", "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:shell": "docker compose exec mysql mysql -uaccess_user -paccess_pass access_manage", "mysql:up": "docker compose up -d mysql", diff --git a/src/app.ts b/src/app.ts index cb9d109..087a18d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -55,6 +55,8 @@ export function createApp() { return ok({ status: "ok", + environment: env.APP_ENV, + environmentLabel: env.APP_ENV_LABEL, database: "up", now: new Date().toISOString(), }); diff --git a/src/config/env.ts b/src/config/env.ts index b3e844e..b83776f 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -7,6 +7,8 @@ const envSchema = z.object({ NODE_ENV: z .enum(["local", "development", "test", "production"]) .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), DB_HOST: z.string().min(1), @@ -30,4 +32,8 @@ if (!result.success) { } // 其他模块只从 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 +};