diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..68813b0 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,6 @@ +ACCESS_MANAGE_API_BASE_URL=http://127.0.0.1:3500/api +ROLE_USER_SESSION_COOKIE=role_user_session +APP_ENV=production +APP_ENV_LABEL=生产环境 +PORT=3210 +HOSTNAME=0.0.0.0 diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..7de2e6f --- /dev/null +++ b/.env.test.example @@ -0,0 +1,6 @@ +ACCESS_MANAGE_API_BASE_URL=http://127.0.0.1:3501/api +ROLE_USER_SESSION_COOKIE=role_user_session_test +APP_ENV=test +APP_ENV_LABEL=测试环境 +PORT=3211 +HOSTNAME=0.0.0.0 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..931f46b --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,138 @@ +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 = "role-user" + 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 + pnpm lint + ''' + } + } + + stage("Build") { + steps { + script { + sh isProductionDeploy() ? "pnpm build:prod" : "pnpm build:test" + } + } + } + + stage("Package standalone") { + steps { + sh ''' + set -euo pipefail + rm -rf .deploy/role-user + mkdir -p .deploy/role-user/.next + cp -R .next/standalone/. .deploy/role-user/ + cp -R .next/static .deploy/role-user/.next/static + cp -R public .deploy/role-user/public + ''' + } + } + + stage("Deploy test") { + when { + expression { isTestDeploy() } + } + steps { + sh "bash deploy/jenkins/deploy-standalone.sh test .deploy/role-user" + } + } + + stage("Deploy production") { + when { + expression { isProductionDeploy() && !params.SKIP_DEPLOY } + } + steps { + sh "bash deploy/jenkins/deploy-standalone.sh production .deploy/role-user" + } + } + } +} diff --git a/deploy/jenkins/deploy-standalone.sh b/deploy/jenkins/deploy-standalone.sh new file mode 100644 index 0000000..0a58654 --- /dev/null +++ b/deploy/jenkins/deploy-standalone.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +deploy_env="${1:?Usage: deploy-standalone.sh test|production [artifact_dir]}" +artifact_dir="${2:-.deploy/role-user}" + +case "${deploy_env}" in + test) + env_file=".env.test" + ;; + production) + env_file=".env.production" + ;; + *) + echo "Unknown deploy environment: ${deploy_env}" >&2 + exit 1 + ;; +esac + +if [[ ! -f "${artifact_dir}/server.js" ]]; then + echo "Standalone server.js not found in ${artifact_dir}" >&2 + exit 1 +fi + +project_name="${PROJECT_NAME:-role-user}" +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)" + +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 "${artifact_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 "Start with: set -a; source ${target_dir}/shared/${env_file}; set +a; node ${target_dir}/current/server.js" +fi diff --git a/docs/ENVIRONMENT_DEPLOYMENT.md b/docs/ENVIRONMENT_DEPLOYMENT.md new file mode 100644 index 0000000..5a3ce84 --- /dev/null +++ b/docs/ENVIRONMENT_DEPLOYMENT.md @@ -0,0 +1,91 @@ +# 环境拆分与流水线规则 + +`role-user` 是 Next.js standalone 服务。测试环境和生产环境通过独立部署目录、独立运行时环境变量和独立后端 API 地址拆分。 + +## 环境约定 + +| 环境 | 触发方式 | 代码依据 | 构建命令 | 默认部署目录 | 默认端口 | 后端 API | +| --- | --- | --- | --- | --- | --- | --- | +| 测试环境 | `develop` 合并后自动触发 Jenkins | `develop` 最新提交 | `pnpm build:test` | `/srv/www/test/role-user/current` | `3211` | `http://127.0.0.1:3501/api` | +| 生产环境 | Jenkins 手动触发 | Gitea 项目 Tag | `pnpm build:prod` | `/srv/www/production/role-user/current` | `3210` | `http://127.0.0.1:3500/api` | + +生产环境禁止因代码合并自动部署。生产部署时必须在 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 对应的提交,再部署。 + +## 运行时环境变量 + +真实环境变量必须留在服务器,不提交到仓库。 + +部署脚本默认读取: + +```text +/srv/www/test/role-user/shared/.env.test +/srv/www/production/role-user/shared/.env.production +``` + +示例文件: + +```text +.env.test.example +.env.production.example +``` + +`ACCESS_MANAGE_API_BASE_URL` 是服务端 BFF 使用的运行时变量,不加 `NEXT_PUBLIC_`,不会暴露到浏览器 bundle。Cookie 名测试和生产应分开,避免同域下会话互相覆盖。 + +## standalone 产物 + +`next.config.ts` 已启用 `output: "standalone"`。Jenkins 构建后会打包: + +```text +.next/standalone +.next/static +public +``` + +部署脚本会发布到: + +```text +${DEPLOY_BASE_DIR}/${DEPLOY_ENV}/role-user/releases/- +``` + +并更新: + +```text +${DEPLOY_BASE_DIR}/${DEPLOY_ENV}/role-user/current +``` + +如果 Jenkins 不在目标服务器上运行,可以配置: + +```text +DEPLOY_REMOTE=user@server +``` + +## 生产发布流程 + +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 "role-user production release 2026-06-05" +git push origin v2026.06.05-1 +``` diff --git a/package.json b/package.json index f5e6e53..157b107 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "scripts": { "dev": "next dev -p 3210", "build": "next build", + "build:test": "next build", + "build:prod": "next build", "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit --incremental false" diff --git a/src/app/globals.css b/src/app/globals.css index 03439ee..3e855e7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1029,6 +1029,22 @@ textarea:focus-visible { color: var(--accent-ink); } +.environment-badge { + background: rgba(20, 20, 24, 0.86); + border-radius: 999px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16); + color: #ffffff; + font-size: 12px; + font-weight: 750; + letter-spacing: 0; + line-height: 1; + padding: 8px 10px; + position: fixed; + right: 12px; + top: 12px; + z-index: 60; +} + .skeleton { animation: pulse 1.2s ease-in-out infinite; background: linear-gradient(90deg, #ececef, #fafafa, #ececef); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f0f59b1..6b65cfc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import { connection } from "next/server"; import "./globals.css"; @@ -24,10 +25,19 @@ export const viewport: Viewport = { themeColor: "#ffffff" }; -export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { +export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + await connection(); + + const environmentLabel = process.env.APP_ENV_LABEL || "生产环境"; + return ( - {children} + +
+ {environmentLabel} +
+ {children} + ); }