diff --git a/.dockerignore b/.dockerignore index 81ece16..138f8bb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,52 +1,10 @@ -node_modules -.git -.gitignore -.env -.env.* -.DS_Store -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Next.js build artifacts (we will copy from builder stage explicitly) -.next/cache - -# Exclude all docs and scripts from production image -docs/** -scripts/** -# 但保留 docker-entrypoint.sh -!scripts/docker-entrypoint.sh - -# Exclude local uploads and temp files -uploads/** -tmp/** -temp/** - -# Exclude miscellaneous local files -*.log -*.md -!README.md -# 允许权威部署规范被查看 -!docs/PORTABLE_DEPLOYMENT_GUIDE.md -*.bat -*.ps1 -*.sh - -# VCS/IDE -.vscode/** -.idea/** -*.swp # ============================================ # Docker 构建排除文件配置 -# 只包含功能性代码和必要文件 +# 优化构建上下文大小,加速构建 # ============================================ # 依赖包(在 Docker 中重新安装) node_modules -npm-debug.log* -yarn-debug.log* -yarn-error.log* .pnp .pnp.js @@ -56,153 +14,88 @@ out build dist -# 测试 +# 测试相关 coverage -*.test.ts -*.test.tsx -*.spec.ts -*.spec.tsx -test -tests +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx __tests__ -# 调试和日志 +# 日志文件 *.log logs -*.log.* npm-debug.log* yarn-debug.log* yarn-error.log* -# 本地环境变量(服务器上已有 .env) -# 注意:.env.production 需要在构建时使用,所以不排除 -.env.local -.env.development.local -.env.test.local -.env.production.local +# 本地环境变量(不要打包到镜像中,运行时通过 Docker 环境变量配置) .env +.env.* +.env.local +.env.*.local .env.example -.env.template -.env*.local -# 但排除其他敏感文件 -server.env -env-backups/ +.env.production +.env.production.local +env-backups +config-bianliang.env # 版本控制 .git .gitignore .gitattributes -.gitmodules -# IDE 和编辑器配置 +# IDE 配置 .vscode .idea .cursor +.trae +.kiro *.swp *.swo -*.sublime-project -*.sublime-workspace -.editorconfig -.eslintcache -# 文档文件(不部署文档) +# 文档(保留 VERSION.txt) docs *.md -README.md -LICENSE -CHANGELOG.md -CONTRIBUTING.md -*.txt !VERSION.txt -# 脚本文件(不部署脚本) +# 脚本(保留 docker-entrypoint.sh) scripts !scripts/docker-entrypoint.sh -*.sh -!scripts/docker-entrypoint.sh -*.ps1 -*.bat -*.py -*.js -!next.config.js -!postcss.config.js -!tailwind.config.ts -# 临时文件和缓存 +# 临时文件 uploads public/uploads backups *.tmp *.temp .cache -.tmp tmp temp -*.cache .DS_Store Thumbs.db -# 压缩包和发布包 +# 压缩包和部署产物 *.tar.gz *.zip -*.rar -*.7z *.tar -*.tgz -release-* -dist-* +*.bak-* +*.ps1.bak-* deploy-artifacts +docker-images +gongxiangp2-source-reference-20260309-202917 +gongxiangp2-source-reference-20260413-172354 +gongxiangp2-source-reference-old-style-20260513-132942 +license-server +flnxi-agent +flnxi-agent-go -# 数据库备份文件 +# 数据库文件 *.sql *.db *.sqlite -*.sqlite3 -# 其他文件 -*.rpm -*.deb +# 其他 *.pem *.key -*.cert -*.crt frp_* -docker-daemon-config.json - -# 部署相关文件 -deploy.sh -auto-deploy.sh -update-deploy.sh -deploy-to-server.sh -deploy-to-server.ps1 -一键部署.bat -部署前检查清单.bat -安全部署.ps1 -安全部署.sh - -# 修复和工具脚本 -修复PowerShell编码.ps1 -快速修复编码.bat -*.修复*.bat -*.修复*.ps1 -*.修复*.sh - -# 配置文件(保留必要的,排除开发用的) -# 注意:以下文件会被包含(功能性代码) -# - package.json -# - package-lock.json -# - Dockerfile -# - docker-compose.yml -# - docker-compose.prod.yml -# - next.config.js -# - tsconfig.json -# - tailwind.config.ts -# - postcss.config.js -# - prisma/schema.prisma -# - prisma/migrations/ (迁移文件是功能性的) -# - app/ -# - components/ -# - lib/ -# - types/ -# - middleware.ts -# - public/ (静态资源) diff --git a/.env.example b/.env.example index e85dcc9..63e864f 100644 --- a/.env.example +++ b/.env.example @@ -2,14 +2,39 @@ NODE_ENV=development # Public URLs +SERVER_IP=192.168.x.x APP_PUBLIC_URL=https://your-domain.example.com NEXTAUTH_URL=https://your-domain.example.com NEXTAUTH_SECRET=replace-with-strong-secret LICENSE_SERVER_URL=https://your-license-server.example.com LICENSE_PROJECT_CODE=flnxi-sucai +# Versioned deployment images +# Customer delivery must pin the full application image tag. Do not rely on latest. +APP_IMAGE=registry.example.com/flnxi/material/flnxi-material:1.0.0 +# Optional: pin runtime images to your private Registry for offline or controlled delivery. +POSTGRES_IMAGE=postgres:15-alpine +MINIO_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z +MINIO_MC_IMAGE=minio/mc:RELEASE.2025-04-16T18-13-26Z + +# Shared-machine isolation (recommended on test servers with multiple projects) +COMPOSE_PROJECT_NAME=flnxi_sucai +APP_CONTAINER_NAME=flnxi-material +POSTGRES_CONTAINER_NAME=flnxi-postgres +MINIO_CONTAINER_NAME=flnxi-minio +MINIO_INIT_CONTAINER_NAME=flnxi-minio-init +POSTGRES_VOLUME_NAME=flnxi-postgres-data +MINIO_VOLUME_NAME=flnxi-minio-data +APP_NETWORK_NAME=flnxi-app-network +APP_PORT=4320 +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + # Database settings DATABASE_URL=postgresql://username:password@host:5432/database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=replace-with-strong-postgres-password +POSTGRES_DB=flnxi # Object storage (S3 compatible) STORAGE_PROVIDER=s3 @@ -19,8 +44,11 @@ S3_ACCESS_KEY_ID=your-access-key S3_SECRET_ACCESS_KEY=your-secret-key S3_REGION=us-east-1 S3_PUBLIC_DOMAIN=https://s3.example.com/your-bucket +S3_SET_ACL=0 S3_FILE_PATH=materials/ S3_ENV_PREFIX= +MINIO_ROOT_USER=replace-with-minio-admin-user +MINIO_ROOT_PASSWORD=replace-with-strong-minio-admin-password # Vector database (optional) VECTOR_DB=pgvector diff --git a/.env.production b/.env.production deleted file mode 100644 index 8316ad2..0000000 --- a/.env.production +++ /dev/null @@ -1,20 +0,0 @@ -DATABASE_URL=postgresql://gongxiangpan:ytPWwGYRptMmKezb@45.249.208.118:5432/gongxiangpan -DATABASE_DRIVER=node - -# NextAuth -NEXTAUTH_SECRET=HzJhmmDRx8OS+ipZE/ersdsod/vk1FyoaLIhQSHygFk= -NEXTAUTH_URL=http://45.249.208.118:4320 -APP_PUBLIC_URL=http://45.249.208.118:4320 - -# MinIO S3 Storage -S3_ENDPOINT=https://s3.1080p.hk -S3_BUCKET=jsxiangche -S3_ACCESS_KEY_ID=AEvT1p0wSNjow3Asu9NV -S3_SECRET_ACCESS_KEY=u8nzFl1S6dNoT5PQaQhkHz8Du6iX0hVnPswtvHhs -S3_REGION=us-east-1 -S3_PUBLIC_DOMAIN=https://s3.1080p.hk/jsxiangche -S3_ENABLE_PATH_STYLE=1 -S3_FILE_PATH=由壹点AI/ - -# Local storage for zip files -UPLOAD_DIR=./uploads \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 273563f..d8aba9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -158,7 +158,7 @@ RUN if [ ! -d "node_modules/.prisma" ]; then \ fi # 构建 Next.js 应用(standalone 模式) - # 允许构建时出现预渲染错误(这些页面会在运行时动态渲染) + # 商业交付镜像必须在构建阶段暴露失败,不能吞掉构建错误继续出包。 RUN set -ex && \ if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \ export PRISMA_QUERY_ENGINE_LIBRARY="${PRISMA_ENGINE_CACHE_PATH}" && \ @@ -172,7 +172,7 @@ RUN if [ ! -d "node_modules/.prisma" ]; then \ else \ unset PRISMA_SCHEMA_ENGINE_BINARY; \ fi && \ - npm run build || (echo "构建完成,但部分页面预渲染失败(这是预期的,这些页面将在运行时动态渲染)" && true) + npm run build # 验证构建产物(允许 ESLint 警告,但不允许致命错误) RUN if [ ! -f ".next/standalone/server.js" ]; then \ diff --git a/__tests__/deployment-compose.test.ts b/__tests__/deployment-compose.test.ts new file mode 100644 index 0000000..8e47680 --- /dev/null +++ b/__tests__/deployment-compose.test.ts @@ -0,0 +1,22 @@ +import fs from 'fs' +import path from 'path' + +describe('docker-compose.prod.yml', () => { + const compose = fs.readFileSync( + path.join(process.cwd(), 'docker-compose.prod.yml'), + 'utf8' + ) + + it('MinIO 初始化默认关闭素材桶匿名访问', () => { + expect(compose).toContain('S3_SET_ACL: ${S3_SET_ACL:-0}') + expect(compose).toContain('S3_PUBLIC_READ: ${S3_PUBLIC_READ:-0}') + expect(compose).toContain('mc anonymous set none "myminio/$${S3_BUCKET}"') + expect(compose).toContain('MinIO bucket anonymous access disabled') + }) + + it('只有显式兼容公开模式时才打开匿名下载', () => { + expect(compose).toContain('WARNING: S3 public read compatibility is enabled') + expect(compose).toContain('mc anonymous set download "myminio/$${S3_BUCKET}"') + expect(compose).toContain('- S3_SET_ACL=${S3_SET_ACL:-0}') + }) +}) diff --git a/config-bianliang.env b/config-bianliang.env index 85798fb..4cba701 100644 --- a/config-bianliang.env +++ b/config-bianliang.env @@ -61,7 +61,7 @@ NEXT_PUBLIC_DINGTALK_CALLBACK_URL= # 图片远程域名白名单(Next/Image) # ----------------------------- # 用于 next.config.js 中的 remotePatterns。逗号分隔主机名; -# 默认已包含 s3.1080p.hk,如使用自有域名请在此追加。 +# 如使用自有对象存储或 CDN 域名,请在此追加。 # 例如:cdn.company.com,images.company.com IMAGE_REMOTE_HOSTS= diff --git a/deploy-new-fixed.ps1 b/deploy-new-fixed.ps1 index 3b8db32..c6bdc74 100644 --- a/deploy-new-fixed.ps1 +++ b/deploy-new-fixed.ps1 @@ -1,23 +1,23 @@ # ================================================================ -# FLNXI素材库 - 部署脚本 v1.2 -# 功能:本地构建 -> 推送到Registry -> 远程部署 -# 特性:自动加载基础镜像、智能重试、镜像清理、趣味提示 +# FLNXI素材库 - 历史内部部署脚本 v1.2 +# 状态:已废弃,不作为客户安装、升级或正式发布入口 +# 当前正式发布入口请使用 deploy-registry.ps1 # ================================================================ param( - [string]$Server = "45.249.208.118", + [string]$Server = "", [string]$Username = "root", [SecureString]$Password = $null, [string]$KeyPath = "", [string]$ImageName = "flnxi-material", - [string]$Tag = "pro-latest", + [string]$Tag = "", [string]$RemotePath = "/root/deploy", [string]$EnvFile = "", [string]$Registry = "harbor", # 默认使用 Harbor - [string]$HarborUrl = "harbor.flnxi.com", + [string]$HarborUrl = "", [string]$HarborProjectName = "flnxi", # Harbor 项目名称,例如: flnxi, sucai, demo, library [string]$HarborUsername = "admin", - [string]$HarborPassword = "Harbor12345", + [string]$HarborPassword = "", [switch]$SkipCheck, [switch]$SkipBuild, [switch]$SkipUpload, @@ -26,7 +26,8 @@ param( [switch]$CleanImages, [int]$KeepImageCount = 5, [switch]$CleanDockerDesktop = $true, # 默认启用清理 Docker Desktop 未使用资源 - [switch]$NoCache # 禁用 Docker 构建缓存(默认使用缓存加速构建) + [switch]$NoCache, # 禁用 Docker 构建缓存(默认使用缓存加速构建) + [switch]$AcknowledgeLegacyInternalDeploy ) # 脚本配置 @@ -41,6 +42,13 @@ try { # 控制台编码设置失败时忽略 } +if (-not $Help -and -not $AcknowledgeLegacyInternalDeploy) { + Write-Host "[ERROR] deploy-new-fixed.ps1 是历史内部部署脚本,默认禁止执行。" -ForegroundColor Red + Write-Host "当前客户/测试发布请使用 deploy-registry.ps1。" -ForegroundColor Yellow + Write-Host "如确需排查历史链路,请补充 -AcknowledgeLegacyInternalDeploy,并显式传入 -Server、-Tag、-HarborUrl 和 Registry 凭据。" -ForegroundColor Yellow + exit 1 +} + $script:StageTracker = @{ Total = 0 Current = 0 @@ -173,21 +181,24 @@ if ($Help) { Write-Host $Message -ForegroundColor $ForegroundColor } - Write-ColorOutput "新建简化部署脚本" "Green" + Write-ColorOutput "历史内部部署脚本(已废弃)" "Yellow" Write-ColorOutput "========================================" "Green" + Write-ColorOutput "此脚本不作为客户安装、升级或正式发布入口;当前请使用 deploy-registry.ps1。" "Yellow" + Write-ColorOutput "默认禁止执行,确需排查历史链路时必须追加 -AcknowledgeLegacyInternalDeploy。" "Yellow" + Write-ColorOutput "" "White" Write-ColorOutput "用法:" "White" - Write-ColorOutput " .\deploy-new-fixed.ps1 [参数]" "Yellow" + Write-ColorOutput " .\deploy-new-fixed.ps1 -AcknowledgeLegacyInternalDeploy [参数]" "Yellow" Write-ColorOutput "" "White" Write-ColorOutput "参数:" "White" - Write-ColorOutput " -Server [地址] 服务器地址 (默认: 45.249.208.118)" "Gray" + Write-ColorOutput " -Server [地址] 服务器地址,必须显式传入" "Gray" Write-ColorOutput " -Username [用户] SSH用户名 (默认: root)" "Gray" Write-ColorOutput " -KeyPath [路径] SSH私钥路径 (推荐)" "Gray" Write-ColorOutput " -EnvFile [路径] 指定构建期使用的环境变量文件" "Gray" Write-ColorOutput " -Registry [类型] Registry类型: default 或 harbor (默认: default)" "Gray" - Write-ColorOutput " -HarborUrl [地址] Harbor地址 (默认: harbor.flnxi.com)" "Gray" + Write-ColorOutput " -HarborUrl [地址] Harbor地址,必须显式传入" "Gray" Write-ColorOutput " -HarborProjectName [名称] Harbor项目名称 (默认: flnxi)" "Gray" Write-ColorOutput " -HarborUsername [用户] Harbor用户名 (默认: admin)" "Gray" - Write-ColorOutput " -HarborPassword [密码] Harbor密码 (默认: Harbor12345)" "Gray" + Write-ColorOutput " -HarborPassword [密码] Harbor密码,必须显式传入" "Gray" Write-ColorOutput " -SkipCheck 跳过代码检查" "Gray" Write-ColorOutput " -SkipBuild 跳过Docker构建" "Gray" Write-ColorOutput " -SkipUpload 跳过镜像推送和远程部署" "Gray" @@ -199,11 +210,7 @@ if ($Help) { Write-ColorOutput " -Help 显示此帮助信息" "Gray" Write-ColorOutput "" "White" Write-ColorOutput "示例:" "White" - Write-ColorOutput " .\deploy-new-fixed.ps1" "Yellow" - Write-ColorOutput " .\deploy-new-fixed.ps1 -KeyPath 'C:\Users\YourUsername\.ssh\youyidian_prod_ed25519'" "Yellow" - Write-ColorOutput " .\deploy-new-fixed.ps1 -Registry harbor" "Yellow" - Write-ColorOutput " .\deploy-new-fixed.ps1 -SkipCheck -SkipUpload" "Yellow" - Write-ColorOutput " .\deploy-new-fixed.ps1 -CleanImages -KeepImageCount 5" "Yellow" + Write-ColorOutput " .\deploy-new-fixed.ps1 -AcknowledgeLegacyInternalDeploy -Server 192.168.1.20 -Tag 1.0.1 -HarborUrl registry.example.com" "Yellow" exit 0 } @@ -322,14 +329,14 @@ APP_PUBLIC_URL=http://$Server:4320 NEXTAUTH_SECRET=$(New-Guid) # 存储配置 -S3_ENDPOINT=https://s3.1080p.hk -S3_BUCKET=jsxiangche -S3_ACCESS_KEY_ID=AEvT1p0wSNjow3Asu9NV -S3_SECRET_ACCESS_KEY=u8nzFl1S6dNoT5PQaQhkHz8Du6iX0hVnPswtvHhs +S3_ENDPOINT=https://s3.example.com +S3_BUCKET=placeholder-bucket +S3_ACCESS_KEY_ID=placeholder-access-key +S3_SECRET_ACCESS_KEY=placeholder-secret-key S3_REGION=us-east-1 -S3_PUBLIC_DOMAIN=https://s3.1080p.hk/jsxiangche +S3_PUBLIC_DOMAIN=https://s3.example.com/placeholder-bucket S3_ENABLE_PATH_STYLE=1 -S3_FILE_PATH=由壹点AI/ +S3_FILE_PATH=materials/ # 本地上传目录 UPLOAD_DIR=./uploads @@ -414,6 +421,91 @@ function Get-TempFileName { return "${Prefix}_${timestamp}_${processId}_${random}.txt" } +function ConvertTo-PlainText { + param([SecureString]$SecureValue) + if (-not $SecureValue) { + return "" + } + + $credential = New-Object System.Management.Automation.PSCredential("placeholder", $SecureValue) + return $credential.GetNetworkCredential().Password +} + +function Get-PlinkPath { + try { + $cmd = Get-Command "plink.exe" -ErrorAction Stop + return $cmd.Source + } catch { + return $null + } +} + +function Start-RemoteProcess { + param( + [string]$Server, + [string]$Username, + [SecureString]$Password = $null, + [string]$KeyPath = "", + [string]$Command, + [string]$StdoutFile = "", + [string]$StderrFile = "", + [int]$ConnectTimeoutSeconds = 30 + ) + + $target = "${Username}@${Server}" + $startParams = @{ + NoNewWindow = $true + PassThru = $true + Wait = $true + } + + if (-not [string]::IsNullOrWhiteSpace($StdoutFile)) { + $startParams.RedirectStandardOutput = $StdoutFile + } + if (-not [string]::IsNullOrWhiteSpace($StderrFile)) { + $startParams.RedirectStandardError = $StderrFile + } + + if ($KeyPath -and (Test-Path $KeyPath)) { + $sshArgs = @( + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=$ConnectTimeoutSeconds", + "-o", "ServerAliveInterval=10", + "-o", "ServerAliveCountMax=3", + "-i", $KeyPath, + $target, + $Command + ) + return Start-Process -FilePath "ssh" -ArgumentList $sshArgs @startParams + } + + if ($Password) { + $plinkPath = Get-PlinkPath + if (-not $plinkPath) { + throw "未找到 plink.exe,无法使用密码执行非交互 SSH。" + } + + $plainPassword = ConvertTo-PlainText -SecureValue $Password + $plinkArgs = @( + "-ssh", + "-batch", + "-pw", $plainPassword, + $target, + $Command + ) + return Start-Process -FilePath $plinkPath -ArgumentList $plinkArgs @startParams + } + + $sshArgs = @( + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=$ConnectTimeoutSeconds", + $target, + $Command + ) + return Start-Process -FilePath "ssh" -ArgumentList $sshArgs @startParams +} + # 测试SSH连接(带重试机制) function Test-SSHConnection { param( @@ -441,28 +533,29 @@ function Test-SSHConnection { $stderrFile = Get-TempFileName -Prefix "ssh_stderr" try { - # 使用 OpenSSH(增加超时时间到20秒) - $sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=20", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3") - if ($KeyPath -and (Test-Path $KeyPath)) { - $sshArgs += @("-i", $KeyPath) if ($attempt -eq 1) { Write-Info "使用SSH密钥: $KeyPath" } } elseif ($Password) { - # 注意:SecureString 密码认证需要额外处理,当前主要使用 SSH 密钥 - $sshArgs += @("-o", "PasswordAuthentication=yes") if ($attempt -eq 1) { - Write-Warning "密码认证支持有限,建议使用 SSH 密钥" + Write-Info "使用 PuTTY plink 进行密码认证" } } else { if ($attempt -eq 1) { Write-Warning "未提供密钥,使用交互式认证" } - $sshArgs += @("-o", "StrictHostKeyChecking=no") } - - $process = Start-Process -FilePath "ssh" -ArgumentList ($sshArgs + @($target, "echo 'SSH连接测试成功'")) -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + + $process = Start-RemoteProcess ` + -Server $Server ` + -Username $Username ` + -Password $Password ` + -KeyPath $KeyPath ` + -Command "echo 'SSH连接测试成功'" ` + -StdoutFile $stdoutFile ` + -StderrFile $stderrFile ` + -ConnectTimeoutSeconds 20 if ($process.ExitCode -eq 0) { Write-Success "SSH连接测试成功" @@ -543,6 +636,7 @@ function Clean-RemoteDockerImages { param( [string]$Server, [string]$Username, + [SecureString]$Password = $null, [string]$KeyPath, [string]$ImageName, [int]$KeepCount = 3 @@ -562,14 +656,14 @@ echo '--- 清理后的镜像列表 ---' docker images | grep '$ImageName' | head -10 "@ - $sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30") - if ($KeyPath -and (Test-Path $KeyPath)) { - $sshArgs += @("-i", $KeyPath) - } - try { - $sshArgs += @("${Username}@${Server}", $cleanCmd) - $process = Start-Process -FilePath "ssh" -ArgumentList $sshArgs -NoNewWindow -PassThru -Wait + $process = Start-RemoteProcess ` + -Server $Server ` + -Username $Username ` + -Password $Password ` + -KeyPath $KeyPath ` + -Command $cleanCmd ` + -ConnectTimeoutSeconds 30 if ($process.ExitCode -eq 0) { Write-Success "服务器镜像清理完成" return $true @@ -1017,27 +1111,31 @@ function Main { # 因为镜像已经在本地构建完成,所有层(包括基础镜像层)都已包含在镜像中 # 在服务器上拉取镜像并部署(带重试机制) Write-Info "在服务器上拉取镜像并部署..." + $composeCmd = "docker compose -f docker-compose.prod.yml" # 如果使用 Harbor,需要在服务器上先登录 if ($Registry -eq "harbor") { $harborLoginCmd = "docker login $HarborUrl -u $HarborUsername -p $HarborPassword" - $deployCmd = "cd $RemotePath && $harborLoginCmd && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app" + $deployCmd = "cd $RemotePath && $harborLoginCmd && $composeCmd stop app && $composeCmd rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && $composeCmd up -d --no-deps --pull never app" } else { # 先停止并删除旧容器,然后拉取新镜像,重新启动(使用 --no-deps --pull never 避免拉取依赖服务和重复拉取) - $deployCmd = "cd $RemotePath && docker-compose stop app && docker-compose rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d --no-deps --pull never app" + $deployCmd = "cd $RemotePath && $composeCmd stop app && $composeCmd rm -f app && docker pull $registryImage && docker tag $registryImage $fullImage && $composeCmd up -d --no-deps --pull never app" } - $sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3") + $remoteCommandAvailable = $false if ($KeyPath -and (Test-Path $KeyPath)) { - $sshArgs += @("-i", $KeyPath) Write-Info "使用 OpenSSH 执行远程部署命令" + $remoteCommandAvailable = $true } elseif ($Password) { - Write-Warning "未提供 SSH 密钥,无法在非交互模式下使用密码。请手动执行部署命令。" - $sshArgs = $null + if (Get-PlinkPath) { + Write-Info "使用 PuTTY plink 执行远程部署命令" + $remoteCommandAvailable = $true + } else { + Write-Warning "本机未找到 plink.exe,无法使用密码自动部署。请手动执行部署命令。" + } } else { Write-Warning "缺少 SSH 凭据,请手动执行部署命令。" - $sshArgs = $null } - if ($sshArgs) { + if ($remoteCommandAvailable) { $deployMaxRetries = 2 $deployRetryDelay = 5 $deployAttempt = 0 @@ -1050,9 +1148,14 @@ function Main { Start-Sleep -Seconds $deployRetryDelay } - $currentSshArgs = $sshArgs + @("${Username}@${Server}", $deployCmd) try { - $sshProcess = Start-Process -FilePath "ssh" -ArgumentList $currentSshArgs -NoNewWindow -PassThru -Wait + $sshProcess = Start-RemoteProcess ` + -Server $Server ` + -Username $Username ` + -Password $Password ` + -KeyPath $KeyPath ` + -Command $deployCmd ` + -ConnectTimeoutSeconds 30 if ($sshProcess.ExitCode -eq 0) { Write-Success "部署成功" $deploySuccess = $true @@ -1076,7 +1179,7 @@ function Main { } } - if (-not $sshTest -or -not $sshArgs) { + if (-not $sshTest -or -not $remoteCommandAvailable) { Write-Warning "远程部署阶段未完成,已提供手动命令指引" } elseif ($sshTest) { Write-Info "开始对线上应用做一次健康检查..." @@ -1095,8 +1198,8 @@ function Main { Clean-LocalDockerImages -ImageName $ImageName -KeepTag $Tag -KeepCount $KeepImageCount # 清理服务器镜像 - if ($sshTest -and $sshArgs) { - Clean-RemoteDockerImages -Server $Server -Username $Username -KeyPath $KeyPath -ImageName $ImageName -KeepCount $KeepImageCount + if ($sshTest -and $remoteCommandAvailable) { + Clean-RemoteDockerImages -Server $Server -Username $Username -Password $Password -KeyPath $KeyPath -ImageName $ImageName -KeepCount $KeepImageCount } # 清理 Registry 镜像(仅适用于旧 Registry) @@ -1136,18 +1239,18 @@ function Main { Write-Info "如果自动部署失败,请在服务器上手动执行:" Write-Info " cd $RemotePath" Write-Info " docker login $HarborUrl -u $HarborUsername -p $HarborPassword" - Write-Info " docker-compose stop app && docker-compose rm -f app" + Write-Info " docker compose -f docker-compose.prod.yml stop app && docker compose -f docker-compose.prod.yml rm -f app" Write-Info " docker pull $registryImage" Write-Info " docker tag $registryImage $fullImage" - Write-Info " docker-compose up -d --no-deps --pull never app" + Write-Info " docker compose -f docker-compose.prod.yml up -d --no-deps --pull never app" } else { Write-Info "镜像已推送到 Registry: $registryImage" Write-Info "如果自动部署失败,请在服务器上手动执行:" Write-Info " cd $RemotePath" - Write-Info " docker-compose stop app && docker-compose rm -f app" + Write-Info " docker compose -f docker-compose.prod.yml stop app && docker compose -f docker-compose.prod.yml rm -f app" Write-Info " docker pull $registryImage" Write-Info " docker tag $registryImage $fullImage" - Write-Info " docker-compose up -d --no-deps --pull never app" + Write-Info " docker compose -f docker-compose.prod.yml up -d --no-deps --pull never app" } Write-Info " # 如需执行数据库迁移,请手动运行: docker exec flnxi-material npx prisma migrate deploy" } @@ -1167,4 +1270,3 @@ function Main { # 执行主函数 Main - diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c68da89..398b018 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,21 +3,22 @@ # 支持内网穿透,资源存储在MinIO存储桶 # 从私有镜像仓库拉取镜像,无需源码 # ============================================ -# Harbor 项目结构: -# - flnxi-public (公开): Agent、runtime 依赖镜像 -# - flnxi (私有): 素材系统镜像,需 token 拉取 +# Registry 项目结构: +# - flnxi/runtime:PostgreSQL、MinIO 等运行依赖镜像 +# - flnxi/material 或 flnxi/material-dev:素材系统正式版 / 测试版镜像 +# 客户交付必须在 .env 中显式填写 APP_IMAGE,避免误拉旧仓库或 latest。 # ============================================ services: # PostgreSQL 数据库 postgres: - # 从 Harbor 的 flnxi-public 项目拉取(公开,无需登录) - image: ${POSTGRES_IMAGE:-harbor.flnxi.com/flnxi-public/runtime/postgres:15-alpine} - container_name: flnxi-postgres + # 可由 .env 覆盖为私有 Registry runtime 镜像;默认走 Docker Hub 官方镜像。 + image: ${POSTGRES_IMAGE:-postgres:15-alpine} + container_name: ${POSTGRES_CONTAINER_NAME:-flnxi-postgres} restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-youyidian123456} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_DB: ${POSTGRES_DB:-flnxi} POSTGRES_INITDB_ARGS: "--encoding=UTF-8" volumes: @@ -33,14 +34,14 @@ services: # MinIO 对象存储 # 使用特定版本号确保稳定性(符合项目宗旨:稳定性优先) minio: - # 从 Harbor 的 flnxi-public 项目拉取(公开,无需登录) - image: ${MINIO_IMAGE:-harbor.flnxi.com/flnxi-public/runtime/minio:RELEASE.2025-04-22T22-12-26Z} - container_name: flnxi-minio + # 可由 .env 覆盖为私有 Registry runtime 镜像;默认走官方镜像。 + image: ${MINIO_IMAGE:-minio/minio:RELEASE.2025-04-22T22-12-26Z} + container_name: ${MINIO_CONTAINER_NAME:-flnxi-minio} restart: unless-stopped command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123456} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} volumes: - minio_data:/data ports: @@ -55,32 +56,47 @@ services: retries: 3 # MinIO 初始化服务(创建bucket) - # 使用 latest 标签确保可用性(mc 客户端版本兼容性好) minio-init: - # 从 Harbor 的 flnxi-public 项目拉取(公开,无需登录) - image: ${MINIO_MC_IMAGE:-harbor.flnxi.com/flnxi-public/runtime/minio-mc:latest} - container_name: flnxi-minio-init + # mc 客户端用于初始化桶;离线或客户现场可在 .env 中覆盖为私有 Registry 镜像。 + image: ${MINIO_MC_IMAGE:-minio/mc:RELEASE.2025-04-16T18-13-26Z} + container_name: ${MINIO_INIT_CONTAINER_NAME:-flnxi-minio-init} + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER is required} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required} + S3_BUCKET: ${S3_BUCKET:-flnxi-materials} + S3_SET_ACL: ${S3_SET_ACL:-0} + S3_PUBLIC_READ: ${S3_PUBLIC_READ:-0} depends_on: minio: condition: service_healthy - entrypoint: > - /bin/sh -c " + entrypoint: | + /bin/sh -c ' + set -e; sleep 5; - mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin123456}; - mc mb myminio/${S3_BUCKET:-flnxi-materials} --ignore-existing; - mc anonymous set download myminio/${S3_BUCKET:-flnxi-materials}; - echo 'MinIO bucket created and configured successfully'; - exit 0; - " + mc alias set myminio http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}"; + mc mb "myminio/$${S3_BUCKET}" --ignore-existing; + PUBLIC_READ="$$(printf "%s" "$${S3_SET_ACL:-0}:$${S3_PUBLIC_READ:-0}" | tr "[:upper:]" "[:lower:]")"; + case "$$PUBLIC_READ" in + 1:*|true:*|yes:*|*:1|*:true|*:yes) + echo "WARNING: S3 public read compatibility is enabled; bucket anonymous download will be allowed"; + mc anonymous set download "myminio/$${S3_BUCKET}"; + ;; + *) + mc anonymous set none "myminio/$${S3_BUCKET}"; + echo "MinIO bucket anonymous access disabled"; + ;; + esac; + echo "MinIO bucket created and configured successfully"; + ' networks: - app-network # 主应用(Pro版 - 从私有镜像仓库拉取,需 token) app: - # 从 Harbor 的 flnxi 项目拉取(私有,需许可证 token 登录) - # Agent 会在验证许可证后自动获取 token 并拉取此镜像 - image: ${APP_IMAGE:-harbor.flnxi.com/flnxi/flnxi-material:pro-${IMAGE_TAG:-latest}} - container_name: flnxi-material + # 客户交付必须显式填写完整 APP_IMAGE,例如: + # APP_IMAGE=registry.example.com/flnxi/material/flnxi-material:1.0.1 + image: ${APP_IMAGE:?APP_IMAGE is required, for example registry.example.com/flnxi/material/flnxi-material:1.0.1} + container_name: ${APP_CONTAINER_NAME:-flnxi-material} restart: unless-stopped ports: - "${APP_PORT:-4320}:3000" @@ -93,33 +109,34 @@ services: # - SERVER_IP 必须在安装脚本或 .env 中配置为「本机在局域网中的访问 IP」,例如:192.168.41.144 # - 对于一键安装用户,install.sh 会自动生成 .env.production 并写入 SERVER_IP,这里直接引用即可 - NODE_ENV=production - # DATABASE_URL 从 .env 文件自动读取(install.sh 会生成正确编码的 URL) - # 如果 .env 文件中没有 DATABASE_URL,则使用默认值(向后兼容) - - DATABASE_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-youyidian123456}@postgres:5432/${POSTGRES_DB:-flnxi}} + # DATABASE_URL 从 .env 文件自动读取;未配置时按同一份 .env 中的数据库变量拼接 + - DATABASE_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-flnxi}} - DATABASE_DRIVER=node - STORAGE_PROVIDER=s3 - S3_ENDPOINT=http://minio:9000 - S3_BUCKET=${S3_BUCKET:-flnxi-materials} - - S3_ACCESS_KEY_ID=${MINIO_ROOT_USER:-minioadmin} - - S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123456} + - S3_ACCESS_KEY_ID=${MINIO_ROOT_USER} + - S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD} - S3_REGION=us-east-1 # 对外访问 MinIO 的地址(供前端和分享链接使用),必须是局域网 / 公网可访问的 IP 或域名 # 这里不再默认 127.0.0.1,避免在 NAS 等环境下写死 localhost - S3_PUBLIC_DOMAIN=http://${SERVER_IP}:${MINIO_API_PORT:-9000}/${S3_BUCKET:-flnxi-materials} - S3_ENABLE_PATH_STYLE=1 - - S3_SET_ACL=1 + - S3_SET_ACL=${S3_SET_ACL:-0} - S3_PREVIEW_URL_EXPIRE_IN=7200 - S3_FILE_PATH=materials/ - NEXT_PUBLIC_S3_FILE_PATH=materials/ # NextAuth 的外部访问地址,从 .env(.production) 中注入(不在这里拼接,以免 SERVER_IP 为空时产生 http://:4320) # install.sh / 部署脚本会写入 NEXTAUTH_URL,例如:http://192.168.41.144:4320 - NEXTAUTH_URL=${NEXTAUTH_URL} - - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-change-this-to-a-random-string-in-production} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?NEXTAUTH_SECRET is required} # 初始管理员账号(供 /api/system/init 使用) - - INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@flnxi.com} - - INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-88888888} + - INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@local.flnxi} + - INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-} + - SYSTEM_INIT_TOKEN=${SYSTEM_INIT_TOKEN:-} volumes: - ./uploads:/app/uploads + - ./data:/app/data depends_on: postgres: condition: service_healthy @@ -130,19 +147,22 @@ services: networks: - app-network healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"] interval: 30s timeout: 10s retries: 3 volumes: postgres_data: + name: ${POSTGRES_VOLUME_NAME:-flnxi-postgres-data} driver: local minio_data: + name: ${MINIO_VOLUME_NAME:-flnxi-minio-data} driver: local networks: app-network: + name: ${APP_NETWORK_NAME:-flnxi-app-network} driver: bridge diff --git a/docker-compose.source-test.yml b/docker-compose.source-test.yml new file mode 100644 index 0000000..de70aed --- /dev/null +++ b/docker-compose.source-test.yml @@ -0,0 +1,24 @@ +services: + postgres: + image: ${POSTGRES_IMAGE:-postgres:16} + pull_policy: never + + minio: + image: ${MINIO_IMAGE:-minio/minio:RELEASE.2025-04-22T22-12-26Z} + pull_policy: never + + minio-init: + image: ${MINIO_MC_IMAGE:-minio/mc:RELEASE.2025-04-16T18-13-26Z} + pull_policy: never + + app: + image: ${APP_IMAGE:-flnxi-sucai-test-local:${IMAGE_TAG:-latest}} + pull_policy: never + build: + context: . + dockerfile: Dockerfile + args: + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-flnxi}} + NEXTAUTH_URL: ${NEXTAUTH_URL} + APP_PUBLIC_URL: ${APP_PUBLIC_URL} + APP_VERSION: ${APP_VERSION:-source-test} diff --git a/docker-compose.yml b/docker-compose.yml index 0fc16f0..61fbf6e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: # DATABASE_DRIVER=${DATABASE_DRIVER} # 站点公共URL(可选,优先用于生成邀请链接等) # APP_PUBLIC_URL=${APP_PUBLIC_URL} - - KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY= + - KEY_VAULTS_SECRET=${KEY_VAULTS_SECRET:-} - VECTOR_DB=pgvector - PGVECTOR_DB_URL=${DATABASE_URL} - STORAGE_PROVIDER=s3 @@ -46,11 +46,11 @@ services: # NextAuth配置从.env文件自动读取 # NEXTAUTH_URL=${NEXTAUTH_URL} # NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - - GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E - - GEMINI_API_BASE_URL=https://api.huandutech.com/v1 - - GEMINI_MODEL=gemini-2.0-flash-exp - - DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f - - DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + - GEMINI_API_BASE_URL=${GEMINI_API_BASE_URL:-} + - GEMINI_MODEL=${GEMINI_MODEL:-} + - DINGTALK_CLIENT_ID=${DINGTALK_CLIENT_ID:-} + - DINGTALK_CLIENT_SECRET=${DINGTALK_CLIENT_SECRET:-} volumes: - ./uploads:/app/uploads healthcheck: diff --git a/scripts/__tests__/deploy-registry-script.test.ts b/scripts/__tests__/deploy-registry-script.test.ts new file mode 100644 index 0000000..5da4861 --- /dev/null +++ b/scripts/__tests__/deploy-registry-script.test.ts @@ -0,0 +1,38 @@ +import fs from 'fs' +import path from 'path' + +describe('deploy-registry.ps1', () => { + const script = fs.readFileSync( + path.join(process.cwd(), 'deploy-registry.ps1'), + 'utf8' + ) + + it('远程部署默认包含升级前数据库备份和 .env 备份', () => { + expect(script).toContain('[switch]$NoRemoteDbBackup') + expect(script).toContain('env_backup="$backup_dir/.env.before-upgrade"') + expect(script).toContain("docker compose -f \"$compose_file\" exec -T postgres sh -c 'pg_dump") + expect(script).toContain('test -s "$db_backup"') + }) + + it('远程部署失败时默认会恢复旧 APP_IMAGE 或旧容器镜像', () => { + expect(script).toContain('[switch]$NoRollback') + expect(script).toContain('rollback()') + expect(script).toContain('trap rollback ERR') + expect(script).toContain('rollback_image="$old_container_image"') + expect(script).toContain('docker compose -f "$compose_file" up -d --no-deps --pull never --force-recreate "$compose_service" || true') + }) + + it('部署后校验运行镜像、版本文件和数据库迁移状态', () => { + expect(script).toContain('verify_running_image') + expect(script).toContain('actual_image="$(docker inspect "$container_id" --format') + expect(script).toContain('运行镜像不匹配') + expect(script).toContain('运行版本不匹配') + expect(script).toContain('npx prisma migrate status --schema=prisma/schema.prisma') + }) + + it('远端 shell 变量应以单引号字符串保留,避免被 PowerShell 提前展开', () => { + expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" up -d --pull never\')') + expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" ps\')') + expect(script).not.toContain('$lines.Add("docker compose -f `"$compose_file`"') + }) +}) diff --git a/scripts/__tests__/docker-entrypoint.test.ts b/scripts/__tests__/docker-entrypoint.test.ts new file mode 100644 index 0000000..3957a17 --- /dev/null +++ b/scripts/__tests__/docker-entrypoint.test.ts @@ -0,0 +1,69 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { spawnSync } from 'child_process' + +describe('scripts/docker-entrypoint.sh', () => { + const entrypoint = fs.readFileSync( + path.join(process.cwd(), 'scripts/docker-entrypoint.sh'), + 'utf8' + ) + + it('数据库迁移失败时必须停止容器启动', () => { + expect(entrypoint).toContain('npx prisma migrate deploy') + expect(entrypoint).toContain('数据库迁移失败') + expect(entrypoint).toMatch(/数据库迁移失败[\s\S]*exit 1/) + expect(entrypoint).not.toContain('数据库迁移失败,尝试继续启动') + }) + + it('迁移命令真实失败时不会继续启动 server.js', () => { + if (process.platform !== 'linux') { + return + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flnxi-entrypoint-')) + const binDir = path.join(tmpDir, 'bin') + fs.mkdirSync(binDir) + + fs.writeFileSync( + path.join(binDir, 'npx'), + [ + '#!/bin/sh', + 'if [ "$1 $2 $3" = "prisma db execute" ]; then', + ' exit 0', + 'fi', + 'if [ "$1 $2 $3" = "prisma migrate deploy" ]; then', + ' exit 42', + 'fi', + 'exit 1', + '', + ].join('\n'), + { mode: 0o755 } + ) + + fs.writeFileSync( + path.join(binDir, 'node'), + [ + '#!/bin/sh', + `echo started > "${path.join(tmpDir, 'server-started').replace(/\\/g, '/')}"`, + 'exit 0', + '', + ].join('\n'), + { mode: 0o755 } + ) + + const scriptPath = path.join(process.cwd(), 'scripts/docker-entrypoint.sh') + const result = spawnSync('sh', [scriptPath], { + cwd: tmpDir, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + }, + encoding: 'utf8', + }) + + expect(result.status).toBe(1) + expect(`${result.stdout}\n${result.stderr}`).toContain('数据库迁移失败') + expect(fs.existsSync(path.join(tmpDir, 'server-started'))).toBe(false) + }) +}) diff --git a/scripts/build-material-image.ps1 b/scripts/build-material-image.ps1 index f2e34c8..8a2e62b 100644 --- a/scripts/build-material-image.ps1 +++ b/scripts/build-material-image.ps1 @@ -1,5 +1,6 @@ -# 素材系统镜像构建脚本 -# 支持测试版和正式版分离 +# 素材系统镜像构建脚本(历史/内部备用) +# 当前客户交付主线请使用根目录 deploy-registry.ps1。 +# 本脚本保留测试/正式分层的旧约定,但默认 Harbor/latest 口径不再作为客户升级依据。 param( [Parameter(Mandatory=$true)] @@ -10,7 +11,8 @@ param( [string]$HarborHost = "harbor.flnxi.com", [string]$HarborProject = "flnxi", [switch]$Push = $false, - [switch]$NoBuild = $false + [switch]$NoBuild = $false, + [switch]$AcknowledgeLegacyHarborPush = $false ) # Harbor 镜像路径规划: @@ -30,6 +32,16 @@ param( $ErrorActionPreference = "Stop" +Write-Host "[WARN] build-material-image.ps1 is a legacy/internal fallback entry." -ForegroundColor Yellow +Write-Host "[WARN] Customer release, upgrade, and rollback should use root deploy-registry.ps1 with a pinned APP_IMAGE tag." -ForegroundColor Yellow +Write-Host "" + +if ($Push -and (-not $AcknowledgeLegacyHarborPush)) { + Write-Host "ERROR: this script still pushes to legacy Harbor/latest targets and must not be used as the customer release entry." -ForegroundColor Red + Write-Host "For internal legacy maintenance only, add -AcknowledgeLegacyHarborPush explicitly." -ForegroundColor Yellow + exit 1 +} + # 根据环境确定镜像路径 $ImageName = "flnxi-material" switch ($Environment) { @@ -78,6 +90,28 @@ try { exit 1 } +# 优先加载本地基础镜像(避免网络问题) +$LocalImagePath = "docker-images/node/node-18-alpine.tar" +if (Test-Path $LocalImagePath) { + Write-Host "检查本地基础镜像..." -ForegroundColor Gray + $existingImage = docker images node:18-alpine --format "{{.ID}}" 2>$null + if (-not $existingImage) { + Write-Host "加载本地基础镜像: $LocalImagePath" -ForegroundColor Cyan + docker load -i $LocalImagePath + if ($LASTEXITCODE -eq 0) { + Write-Host "本地基础镜像加载成功 ✓" -ForegroundColor Green + } else { + Write-Host "警告: 本地基础镜像加载失败,将尝试在线拉取" -ForegroundColor Yellow + } + } else { + Write-Host "本地基础镜像已存在 ✓" -ForegroundColor Green + } +} else { + Write-Host "提示: 未找到本地基础镜像 ($LocalImagePath),将使用在线拉取" -ForegroundColor Yellow + Write-Host " 建议: 运行 'docker save node:18-alpine -o $LocalImagePath' 保存基础镜像" -ForegroundColor Gray +} +Write-Host "" + # 构建镜像 if (-not $NoBuild) { Write-Host "开始构建镜像..." -ForegroundColor Cyan @@ -149,10 +183,11 @@ Write-Host " 最新标签: $LatestImageName" -ForegroundColor Gray Write-Host "" if ($Environment -eq "release") { - Write-Host "提示: 正式版镜像已发布,客户可通过以下地址拉取:" -ForegroundColor Yellow - Write-Host " $HarborHost/$HarborProject/material/flnxi-material:latest" -ForegroundColor White + Write-Host "NOTICE: release image built. Customer delivery must pin the full version image, not latest:" -ForegroundColor Yellow + Write-Host " $FullImageName" -ForegroundColor White + Write-Host "Current release/upgrade/rollback entry is root deploy-registry.ps1." -ForegroundColor Yellow } else { - Write-Host "提示: 测试版镜像已发布到 material-dev 目录" -ForegroundColor Yellow - Write-Host " 测试完成后,使用以下命令发布正式版:" -ForegroundColor Gray - Write-Host " .\scripts\build-material-image.ps1 -Environment release -Version $Tag -Push" -ForegroundColor White + Write-Host "NOTICE: test image built under material-dev." -ForegroundColor Yellow + Write-Host " After testing, publish release with:" -ForegroundColor Gray + Write-Host " .\deploy-registry.ps1 -Action PublishRelease -Version $Tag -RegistryHost -RegistryPassword `$registryPassword" -ForegroundColor White } diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index bb1f0b1..2188a66 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -39,7 +39,9 @@ echo "[2/3] 执行数据库迁移..." if npx prisma migrate deploy; then echo "✓ 数据库迁移完成" else - echo "⚠ 数据库迁移失败,尝试继续启动..." + echo "✗ 数据库迁移失败,已停止启动以保护数据一致性" + echo "请先检查迁移日志、数据库连接和备份状态,修复后再重新启动容器" + exit 1 fi # 3. 启动应用(系统初始化会在首次请求时自动执行) diff --git a/scripts/generate-shared-test-env.ps1 b/scripts/generate-shared-test-env.ps1 new file mode 100644 index 0000000..1ccdcfd --- /dev/null +++ b/scripts/generate-shared-test-env.ps1 @@ -0,0 +1,198 @@ +param( + [string]$OutputPath, + [string]$ServerIp = "192.168.42.134", + [int]$AppPort = 4320, + [int]$MinioApiPort = 39200, + [int]$MinioConsolePort = 39201, + [int]$AgentPort = 3080, + [int]$AgentGoPort = 9091, + [string]$ComposeProjectName = "flnxi_sucai_test", + [string]$ContainerPrefix = "flnxi-sucai-test", + [string]$LicenseServerUrl, + [string]$LicenseProjectCode = "flnxi-sucai", + [string]$InitAdminEmail = "admin@test.flnxi.local" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($LicenseServerUrl)) { + throw "必须显式传入 -LicenseServerUrl,避免把过期或错误的许可证地址写进测试环境。" +} + +function New-RandomString { + param( + [int]$Length = 32, + [string]$Alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + ) + + $bytes = New-Object byte[] ($Length * 2) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + + $builder = New-Object System.Text.StringBuilder + foreach ($byte in $bytes) { + if ($builder.Length -ge $Length) { + break + } + [void]$builder.Append($Alphabet[$byte % $Alphabet.Length]) + } + + return $builder.ToString() +} + +function Get-LicensePublicKey { + param([string]$ProjectRoot) + + $candidateFiles = @( + (Join-Path $ProjectRoot "license-server\.env"), + (Join-Path $ProjectRoot ".env.production"), + (Join-Path $ProjectRoot ".env"), + (Join-Path $ProjectRoot ".env.local") + ) + + foreach ($file in $candidateFiles) { + if (-not (Test-Path $file)) { + continue + } + + $content = Get-Content -Raw $file + + $licenseMatch = [regex]::Match($content, '(?m)^LICENSE_PUBLIC_KEY=(.+)$') + if ($licenseMatch.Success) { + return $licenseMatch.Groups[1].Value.Trim().Trim('"') + } + + $rsaMatch = [regex]::Match($content, '(?m)^RSA_PUBLIC_KEY=(.+)$') + if ($rsaMatch.Success) { + return $rsaMatch.Groups[1].Value.Trim().Trim('"') + } + + $privateMatch = [regex]::Match($content, '(?m)^RSA_PRIVATE_KEY=(.+)$') + if ($privateMatch.Success) { + $privateKey = $privateMatch.Groups[1].Value.Trim().Trim('"') + $pem = $privateKey.Replace('\n', [Environment]::NewLine) + + try { + $rsa = [System.Security.Cryptography.RSA]::Create() + try { + $rsa.ImportFromPem($pem.ToCharArray()) + $publicPem = $rsa.ExportSubjectPublicKeyInfoPem() + return ([regex]::Replace($publicPem, "(\r\n|\n|\r)", '\n')).Trim() + } finally { + $rsa.Dispose() + } + } catch { + throw "检测到 RSA_PRIVATE_KEY,但自动推导公钥失败:$($_.Exception.Message)" + } + } + } + + throw "未找到可用的 LICENSE_PUBLIC_KEY / RSA_PUBLIC_KEY,无法生成测试环境 .env。" +} + +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + throw "必须提供 -OutputPath。" +} + +$projectRoot = Split-Path -Parent $PSScriptRoot +$licensePublicKey = Get-LicensePublicKey -ProjectRoot $projectRoot + +$postgresUser = "flnxi_test" +$postgresDb = "flnxi_sucai_test" +$postgresPassword = New-RandomString -Length 24 +$minioRootUser = "flnxitest" +$minioRootPassword = New-RandomString -Length 24 +$nextAuthSecret = New-RandomString -Length 48 +$localEncryptionKey = New-RandomString -Length 48 +$systemInitToken = New-RandomString -Length 40 +$s3Bucket = "flnxi-sucai-test" +$appUrl = "http://${ServerIp}:${AppPort}" +$s3PublicDomain = "http://${ServerIp}:${MinioApiPort}/${s3Bucket}" + +$envContent = @" +# FLNXI 素材库共享测试机环境变量 +# 用途:192.168.42.134 测试机 +# 生成时间:$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") + +NODE_ENV=production + +# Shared-machine isolation +COMPOSE_PROJECT_NAME=$ComposeProjectName +APP_CONTAINER_NAME=${ContainerPrefix}-app +POSTGRES_CONTAINER_NAME=${ContainerPrefix}-postgres +MINIO_CONTAINER_NAME=${ContainerPrefix}-minio +MINIO_INIT_CONTAINER_NAME=${ContainerPrefix}-minio-init +POSTGRES_VOLUME_NAME=${ContainerPrefix}-postgres-data +MINIO_VOLUME_NAME=${ContainerPrefix}-minio-data +APP_NETWORK_NAME=${ContainerPrefix}-network +APP_PORT=$AppPort +MINIO_API_PORT=$MinioApiPort +MINIO_CONSOLE_PORT=$MinioConsolePort +AGENT_PORT=$AgentPort +AGENT_GO_PORT=$AgentGoPort + +# Public URLs +SERVER_IP=$ServerIp +APP_PUBLIC_URL=$appUrl +NEXTAUTH_URL=$appUrl +NEXTAUTH_SECRET=$nextAuthSecret + +# License +LICENSE_SERVER_URL=$LicenseServerUrl +LICENSE_PROJECT_CODE=$LicenseProjectCode +LICENSE_PUBLIC_KEY="$licensePublicKey" +LOCAL_ENCRYPTION_KEY=$localEncryptionKey + +# Database +POSTGRES_USER=$postgresUser +POSTGRES_PASSWORD=$postgresPassword +POSTGRES_DB=$postgresDb +DATABASE_URL=postgresql://${postgresUser}:${postgresPassword}@postgres:5432/${postgresDb} +DATABASE_DRIVER=node + +# Object storage +STORAGE_PROVIDER=s3 +S3_ENDPOINT=http://minio:9000 +S3_BUCKET=$s3Bucket +S3_ACCESS_KEY_ID=$minioRootUser +S3_SECRET_ACCESS_KEY=$minioRootPassword +S3_REGION=us-east-1 +S3_PUBLIC_DOMAIN=$s3PublicDomain +S3_ENABLE_PATH_STYLE=1 +S3_FILE_PATH=materials/ +S3_ENV_PREFIX=test +MINIO_ROOT_USER=$minioRootUser +MINIO_ROOT_PASSWORD=$minioRootPassword + +# Access control and URL rewriting +PUBLIC_DOMAINS=$ServerIp +NEXT_PUBLIC_PUBLIC_DOMAINS=$ServerIp +ALLOWED_ORIGINS=$appUrl +IMAGE_REMOTE_HOSTS=$ServerIp + +# Initial system bootstrap +SYSTEM_INIT_TOKEN=$systemInitToken +INIT_ADMIN_EMAIL=$InitAdminEmail +INIT_ADMIN_PASSWORD= + +# Optional features disabled by default on shared test server +DINGTALK_CLIENT_ID= +DINGTALK_CLIENT_SECRET= +NEXT_PUBLIC_DINGTALK_CALLBACK_URL= +GEMINI_API_KEY= +GEMINI_API_BASE_URL=https://api.huandutech.com/v1 +GEMINI_MODEL=gemini-2.0-flash-exp +NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL= +NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL= +NEXT_PUBLIC_INTRANET_URL=$appUrl +NEXT_PUBLIC_PORTAL_BASE_URL=$appUrl +UPLOAD_DIR=./uploads +"@ + +$parent = Split-Path -Parent $OutputPath +if ($parent -and -not (Test-Path $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null +} + +[System.IO.File]::WriteAllText($OutputPath, $envContent, [System.Text.UTF8Encoding]::new($false)) +Write-Host "共享测试机环境文件已生成: $OutputPath" diff --git a/发布管理.ps1 b/发布管理.ps1 index 858549f..9ce9df1 100644 --- a/发布管理.ps1 +++ b/发布管理.ps1 @@ -1,28 +1,42 @@ # ================================================================ -# FLNXI 素材系统 - 发布管理工具 v2.0 -# 功能:统一管理测试版部署、正式版发布、Agent 更新等操作 -# 特性:进度条显示、健康检查、智能重试、趣味提示 +# FLNXI 素材系统 - 历史内部发布管理工具 v2.0 +# 状态:已废弃,不作为客户安装、升级或正式发布入口 +# 当前正式发布入口请使用 deploy-registry.ps1 # ================================================================ param( - [switch]$Help + [switch]$Help, + [switch]$AcknowledgeLegacyInternalPublish ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +if (-not $Help -and -not $AcknowledgeLegacyInternalPublish) { + Write-Host "[ERROR] 发布管理.ps1 是历史内部发布控制台,默认禁止执行。" -ForegroundColor Red + Write-Host "当前客户/测试发布请使用 deploy-registry.ps1。" -ForegroundColor Yellow + Write-Host "该脚本包含旧服务器、旧 Harbor、许可证服务和 Docker 清理逻辑,不应作为客户交付入口。" -ForegroundColor Yellow + Write-Host "如确需排查历史链路,请追加 -AcknowledgeLegacyInternalPublish。" -ForegroundColor Yellow + exit 1 +} + # 配置 $Config = @{ - Server = "45.249.208.118" - SshKey = "$env:USERPROFILE\.ssh\youyidian_prod_ed25519" - HarborHost = "harbor.flnxi.com" - HarborProject = "flnxi" # 私有项目(素材系统) + Server = "" + SshKey = "" + HarborHost = "" + HarborProject = "flnxi" # 公开项目(素材系统) HarborPublicProject = "flnxi-public" # 公开项目(Agent、runtime) DockerHubUser = "flnxi" HealthCheckPort = 4320 HealthCheckTimeout = 45 SshRetryCount = 3 SshRetryDelay = 2 + # 新许可证服务器配置 + LicenseServer = "" + LicenseServerUser = "root" + LicenseServerSshKey = "" + LicenseServerPort = 9527 } # 趣味提示 @@ -98,7 +112,7 @@ function Complete-Stage { # 确认操作 function Confirm-Action { param([string]$Message, [string]$Warning = "") - + Write-Host "" if ($Warning) { Write-Color "⚠️ 警告: $Warning" "Yellow" @@ -118,6 +132,11 @@ function Get-CurrentVersion { } function Get-AgentVersion { + # 优先读取 Go 版本 + if (Test-Path "flnxi-agent-go/VERSION.txt") { + return (Get-Content "flnxi-agent-go/VERSION.txt" -Raw).Trim() + } + # 兼容旧版 Node.js 版本 if (Test-Path "flnxi-agent/VERSION.txt") { return (Get-Content "flnxi-agent/VERSION.txt" -Raw).Trim() } @@ -128,21 +147,21 @@ function Get-AgentVersion { function Test-SSHConnection { $target = "root@$($Config.Server)" $attempt = 0 - + while ($attempt -lt $Config.SshRetryCount) { $attempt++ if ($attempt -gt 1) { Write-Info "SSH 连接重试 ($attempt/$($Config.SshRetryCount))..." Start-Sleep -Seconds $Config.SshRetryDelay } - + $result = ssh -i $Config.SshKey -o "BatchMode=yes" -o "ConnectTimeout=10" $target "echo ok" 2>&1 if ($LASTEXITCODE -eq 0) { Write-Success "SSH 连接成功" return $true } } - + Write-Error "SSH 连接失败,已重试 $($Config.SshRetryCount) 次" return $false } @@ -150,13 +169,13 @@ function Test-SSHConnection { # 健康检查 function Test-RemoteHealth { param([int]$Port = 4320, [int]$MaxWait = 45) - + $healthUrl = "http://$($Config.Server):$Port/api/health" Write-Info "开始健康检查: $healthUrl" - + $elapsed = 0 $interval = 3 - + while ($elapsed -lt $MaxWait) { try { $resp = Invoke-WebRequest -Uri $healthUrl -Method GET -TimeoutSec 5 -ErrorAction Stop @@ -172,7 +191,7 @@ function Test-RemoteHealth { Start-Sleep -Seconds $interval $elapsed += $interval } - + Write-Warning "健康检查超时 (${MaxWait}s),请手动检查" return $false } @@ -180,22 +199,22 @@ function Test-RemoteHealth { # 清理本地 Docker 镜像 function Clean-LocalImages { param([string]$ImagePattern = "flnxi", [int]$KeepCount = 5) - + Write-Info "清理本地 Docker 镜像..." - + # 清理悬空镜像 Write-Info " 清理悬空镜像..." docker image prune -f 2>&1 | Out-Null Write-Success " 悬空镜像已清理" - + # 清理旧版本镜像 Write-Info " 清理 $ImagePattern 相关的旧镜像 (保留最新 $KeepCount 个)..." $images = docker images --format "{{.ID}}`t{{.Repository}}:{{.Tag}}`t{{.CreatedAt}}" | Where-Object { $_ -match $ImagePattern } - + if ($images) { $sorted = $images | Sort-Object { ($_ -split "`t")[2] } -Descending $toRemove = $sorted | Select-Object -Skip $KeepCount - + $removedCount = 0 foreach ($img in $toRemove) { $parts = $img -split "`t" @@ -205,7 +224,7 @@ function Clean-LocalImages { docker rmi -f $imgId 2>&1 | Out-Null $removedCount++ } - + if ($removedCount -gt 0) { Write-Success " 已清理 $removedCount 个旧镜像" } else { @@ -217,17 +236,17 @@ function Clean-LocalImages { # 清理远程服务器镜像 function Clean-RemoteImages { param([int]$KeepCount = 5) - + Write-Info "清理服务器上的 Docker 镜像..." - + $cleanCmd = @" docker image prune -f docker images --format '{{.ID}}\t{{.Repository}}:{{.Tag}}' | grep 'flnxi' | tail -n +$($KeepCount + 1) | awk '{print \$1}' | xargs -r docker rmi -f 2>/dev/null || true echo '清理完成' "@ - + ssh -i $Config.SshKey "root@$($Config.Server)" $cleanCmd - + if ($LASTEXITCODE -eq 0) { Write-Success "服务器镜像清理完成" } else { @@ -246,7 +265,7 @@ function Show-Menu { Write-Color "║ [1] 构建并部署测试版 ║" "White" Write-Color "║ ║" "Cyan" Write-Color "║ === 正式发布 (需要确认) === ║" "Yellow" - Write-Color "║ [2] 发布素材系统正式版 (flnxi 私有) ⚠️ ║" "Yellow" + Write-Color "║ [2] 发布素材系统正式版 (flnxi 公开) ⚠️ ║" "Yellow" Write-Color "║ [3] 发布 Agent (flnxi-public 公开) ⚠️ ║" "Yellow" Write-Color "║ ║" "Cyan" Write-Color "║ === 基础设施 === ║" "White" @@ -273,18 +292,39 @@ function Show-Menu { # 1. 构建并部署测试版 function Deploy-TestVersion { Write-Title "构建并部署测试版" - + $tag = "test-$(Get-Date -Format 'yyyyMMdd')" Write-Info "测试版标签: $tag" Write-Info "目标: harbor.flnxi.com/flnxi/material-dev/flnxi-material:$tag" Write-Host "" - + # 启用 BuildKit 以使用缓存加速 $env:DOCKER_BUILDKIT = "1" - + + # 优先加载本地基础镜像(避免网络问题) + $LocalImagePath = "docker-images/node/node-18-alpine.tar" + if (Test-Path $LocalImagePath) { + Write-Info "检查本地基础镜像..." + $existingImage = docker images node:18-alpine --format "{{.ID}}" 2>$null + if (-not $existingImage) { + Write-Info "加载本地基础镜像: $LocalImagePath" + docker load -i $LocalImagePath + if ($LASTEXITCODE -eq 0) { + Write-Success "本地基础镜像加载成功" + } else { + Write-Warning "本地基础镜像加载失败,将尝试在线拉取" + } + } else { + Write-Success "本地基础镜像已存在" + } + } else { + Write-Warning "未找到本地基础镜像 ($LocalImagePath),将使用在线拉取" + Write-Info "建议: 运行 'docker save node:18-alpine -o $LocalImagePath' 保存基础镜像" + } + Write-Info "开始构建 (BuildKit 缓存已启用)..." Write-Info "提示: 首次构建较慢,后续构建会利用缓存加速" - + # 构建镜像 $buildArgs = @( "build", @@ -294,25 +334,25 @@ function Deploy-TestVersion { "-t", "$($Config.HarborHost)/$($Config.HarborProject)/material-dev/flnxi-material:latest", "." ) - + docker @buildArgs if ($LASTEXITCODE -ne 0) { Write-Error "构建失败" return } Write-Success "构建完成" - + # 推送到 Harbor material-dev Write-Info "推送到 Harbor (material-dev)..." docker push "$($Config.HarborHost)/$($Config.HarborProject)/material-dev/flnxi-material:$tag" docker push "$($Config.HarborHost)/$($Config.HarborProject)/material-dev/flnxi-material:latest" - + if ($LASTEXITCODE -ne 0) { Write-Error "推送失败" return } Write-Success "推送完成" - + # 部署到测试服务器 Write-Info "部署到测试服务器..." $deployCmd = @" @@ -321,17 +361,17 @@ docker pull $($Config.HarborHost)/$($Config.HarborProject)/material-dev/flnxi-ma docker compose down && \ docker compose up -d "@ - + ssh -i $Config.SshKey "root@$($Config.Server)" $deployCmd - + if ($LASTEXITCODE -eq 0) { Write-Success "部署命令执行成功" - + # 健康检查 Write-Info "等待服务启动..." Start-Sleep -Seconds 5 $healthy = Test-RemoteHealth -Port $Config.HealthCheckPort -MaxWait $Config.HealthCheckTimeout - + if ($healthy) { Complete-Stage "测试版部署" } @@ -344,16 +384,16 @@ docker compose up -d # 2. 发布素材系统正式版 function Publish-ReleaseVersion { Write-Title "发布素材系统正式版" - + $version = Get-CurrentVersion Write-Info "当前版本: $version" - + $newVersion = Read-Host "请输入新版本号 (当前: $version)" if ([string]::IsNullOrWhiteSpace($newVersion)) { Write-Warning "取消发布" return } - + # 确认 if (-not (Confirm-Action ` "将发布正式版 $newVersion 到 Harbor" ` @@ -361,14 +401,35 @@ function Publish-ReleaseVersion { Write-Warning "取消发布" return } - + # 更新版本文件 $newVersion | Out-File -FilePath "VERSION.txt" -Encoding UTF8 -NoNewline Write-Success "版本文件已更新: $newVersion" - + # 启用 BuildKit $env:DOCKER_BUILDKIT = "1" - + + # 优先加载本地基础镜像(避免网络问题) + $LocalImagePath = "docker-images/node/node-18-alpine.tar" + if (Test-Path $LocalImagePath) { + Write-Info "检查本地基础镜像..." + $existingImage = docker images node:18-alpine --format "{{.ID}}" 2>$null + if (-not $existingImage) { + Write-Info "加载本地基础镜像: $LocalImagePath" + docker load -i $LocalImagePath + if ($LASTEXITCODE -eq 0) { + Write-Success "本地基础镜像加载成功" + } else { + Write-Warning "本地基础镜像加载失败,将尝试在线拉取" + } + } else { + Write-Success "本地基础镜像已存在" + } + } else { + Write-Warning "未找到本地基础镜像 ($LocalImagePath),将使用在线拉取" + Write-Info "建议: 运行 'docker save node:18-alpine -o $LocalImagePath' 保存基础镜像" + } + # 构建正式版镜像 Write-Info "构建正式版镜像 (BuildKit 缓存已启用)..." $buildArgs = @( @@ -380,19 +441,19 @@ function Publish-ReleaseVersion { "--build-arg", "APP_VERSION=$newVersion", "." ) - + docker @buildArgs if ($LASTEXITCODE -ne 0) { Write-Error "构建失败" return } Write-Success "构建完成" - + # 推送到 Harbor material (正式版) Write-Info "推送到 Harbor (material - 正式版)..." docker push "$($Config.HarborHost)/$($Config.HarborProject)/material/flnxi-material:$newVersion" docker push "$($Config.HarborHost)/$($Config.HarborProject)/material/flnxi-material:latest" - + if ($LASTEXITCODE -eq 0) { Write-Success "正式版 $newVersion 发布完成!" Write-Info "客户可通过以下地址拉取:" @@ -402,29 +463,30 @@ function Publish-ReleaseVersion { } } -# 3. 发布 Agent 安装程序 +# 3. 发布 Agent 安装程序 (Go 版本) function Publish-Agent { - Write-Title "发布 Agent 安装程序" - + Write-Title "发布 Agent 安装程序 (Go 版本)" + $currentVersion = Get-AgentVersion Write-Info "当前 Agent 版本: $currentVersion" - + Write-Info "技术栈: Go (镜像约 70MB)" + # Harbor 公开项目镜像配置 # 格式: harbor.flnxi.com/flnxi-public/agent:版本号 $harborRepo = "$($Config.HarborHost)/$($Config.HarborPublicProject)/agent" - + $newVersion = Read-Host "请输入新版本号 (当前: $currentVersion)" if ([string]::IsNullOrWhiteSpace($newVersion)) { Write-Warning "取消发布" return } - + Write-Host "" Write-Info "镜像将推送到 Harbor flnxi-public 项目 (公开,无需登录即可拉取):" Write-Info " ${harborRepo}:${newVersion}" Write-Info " ${harborRepo}:latest" Write-Host "" - + # 确认 if (-not (Confirm-Action ` "将发布 Agent $newVersion 到 Harbor (flnxi-public)" ` @@ -432,7 +494,7 @@ function Publish-Agent { Write-Warning "取消发布" return } - + # 检查 Harbor 登录 $loginCheck = docker login $Config.HarborHost 2>&1 if ($LASTEXITCODE -ne 0) { @@ -440,47 +502,54 @@ function Publish-Agent { return } Write-Success "Harbor 登录状态: OK" - - # 更新版本文件 - $newVersion | Out-File -FilePath "flnxi-agent/VERSION.txt" -Encoding UTF8 -NoNewline + + # 更新版本文件 (Go 版本目录) + $newVersion | Out-File -FilePath "flnxi-agent-go/VERSION.txt" -Encoding UTF8 -NoNewline Write-Success "Agent 版本文件已更新: $newVersion" - + # 启用 BuildKit $env:DOCKER_BUILDKIT = "1" - - # 构建 Agent 镜像 - Write-Info "构建 Agent 镜像 (BuildKit 缓存已启用)..." - Push-Location "flnxi-agent" - + + # 构建 Agent 镜像 (Go 版本) + Write-Info "构建 Agent 镜像 (Go 版本, BuildKit 缓存已启用)..." + Push-Location "flnxi-agent-go" + try { $buildArgs = @( "build", "--progress=plain", "-t", "${harborRepo}:${newVersion}", "-t", "${harborRepo}:latest", - "--build-arg", "AGENT_VERSION=$newVersion", "." ) - + docker @buildArgs if ($LASTEXITCODE -ne 0) { Write-Error "构建失败" return } Write-Success "构建完成" - + + # 显示镜像大小 + $imageSize = docker images "${harborRepo}:${newVersion}" --format "{{.Size}}" + Write-Info "镜像大小: $imageSize" + # 推送到 Harbor flnxi-public 项目 Write-Info "推送到 Harbor (flnxi-public)..." docker push "${harborRepo}:${newVersion}" docker push "${harborRepo}:latest" - + if ($LASTEXITCODE -eq 0) { Write-Success "Agent $newVersion 发布完成!" Write-Host "" Write-Color "═══════════════════════════════════════════════════════════" "Cyan" Write-Info "镜像地址: ${harborRepo}:latest" + Write-Info "镜像大小: $imageSize (Go 版本,比 Node.js 版本减少 ~70%)" Write-Info "客户安装命令:" - Write-Color " docker run -d -p 3080:3080 -v /var/run/docker.sock:/var/run/docker.sock ${harborRepo}:latest" "White" + Write-Color " docker run -d -p 9090:9090 -v /var/run/docker.sock:/var/run/docker.sock ${harborRepo}:latest" "White" + Write-Host "" + Write-Info "一键安装脚本:" + Write-Color " curl -sSL https://install.flnxi.com | bash" "White" Write-Color "═══════════════════════════════════════════════════════════" "Cyan" } else { Write-Error "推送失败" @@ -494,7 +563,7 @@ function Publish-Agent { # 4. 同步 Runtime 镜像 function Sync-RuntimeImages { Write-Title "同步 Runtime 镜像到 Harbor (flnxi-public)" - + # Runtime 镜像列表(含国内镜像源) # 推送到 flnxi-public 公开项目,无需登录即可拉取 $runtimeImages = @( @@ -507,13 +576,13 @@ function Sync-RuntimeImages { @{ Source = "nginx:alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/nginx:alpine"; Target = "runtime/nginx:alpine" }, @{ Source = "node:20-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/node:20-alpine"; Target = "runtime/node:20-alpine" } ) - + Write-Info "将同步以下镜像到 Harbor flnxi-public 项目 (公开,无需登录即可拉取):" foreach ($img in $runtimeImages) { Write-Info " - $($img.Source)" } Write-Host "" - + # 检查 Harbor 登录 $loginCheck = docker login $Config.HarborHost 2>&1 if ($LASTEXITCODE -ne 0) { @@ -522,20 +591,20 @@ function Sync-RuntimeImages { } Write-Success "Harbor 登录状态: OK" Write-Host "" - + $successCount = 0 $failCount = 0 $total = $runtimeImages.Count - + foreach ($img in $runtimeImages) { $index = $runtimeImages.IndexOf($img) + 1 $source = $img.Source $mirror = $img.Mirror # 推送到 flnxi-public 公开项目 $target = "$($Config.HarborHost)/$($Config.HarborPublicProject)/$($img.Target)" - + Write-Color "[$index/$total] $source -> $target" "White" - + # 优先尝试国内镜像源 $pullSuccess = $false if ($mirror) { @@ -549,7 +618,7 @@ function Sync-RuntimeImages { Write-Warning " 国内镜像源失败,尝试官方源..." } } - + # 回退到官方源 if (-not $pullSuccess) { Write-Info " 从官方源拉取: $source" @@ -559,18 +628,18 @@ function Sync-RuntimeImages { $pullSuccess = $true } } - + if (-not $pullSuccess) { Write-Error " 拉取失败" $failCount++ continue } - + # 打标签并推送 Write-Info " 打标签并推送到 Harbor..." docker tag $source $target 2>&1 | Out-Null docker push $target 2>&1 | Out-Null - + if ($LASTEXITCODE -eq 0) { Write-Success " 完成 ✓" $successCount++ @@ -580,7 +649,7 @@ function Sync-RuntimeImages { } Write-Host "" } - + Write-Color "═══════════════════════════════════════════════════════════" "Cyan" Write-Color "同步完成 - 成功: $successCount, 失败: $failCount" $(if ($failCount -eq 0) { "Green" } else { "Yellow" }) } @@ -588,18 +657,18 @@ function Sync-RuntimeImages { # 5. 部署许可证服务器 function Deploy-LicenseServer { Write-Title "部署许可证服务器" - + Write-Info "目标服务器: $($Config.Server)" Write-Info "部署路径: /opt/license-server" Write-Host "" - + # 检查是否已有 .env 文件 $envExists = $false try { $checkEnv = ssh -i $Config.SshKey "root@$($Config.Server)" "test -f /opt/license-server/.env && echo 'exists'" 2>$null $envExists = $checkEnv -eq "exists" } catch {} - + if ($envExists) { Write-Info "检测到已有配置文件 (.env)" Write-Host "" @@ -609,7 +678,7 @@ function Deploy-LicenseServer { Write-Color " [0] 取消" "Gray" Write-Host "" $deployChoice = Read-Host "选择" - + switch ($deployChoice) { "1" { Deploy-LicenseServer-Update } "2" { Deploy-LicenseServer-Fresh } @@ -630,17 +699,17 @@ function Deploy-LicenseServer { # 更新许可证服务器(保留配置) function Deploy-LicenseServer-Update { Write-Info "更新许可证服务器代码..." - + # 测试 SSH 连接 if (-not (Test-SSHConnection)) { return } - + # 上传文件(排除 .env 和 node_modules) Write-Info "上传项目文件..." - + # 创建临时目录 $tempDir = Join-Path $env:TEMP "license-server-deploy-$(Get-Date -Format 'yyyyMMddHHmmss')" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null - + # 复制必要文件 $filesToCopy = @( "docker-compose.yml", @@ -652,14 +721,14 @@ function Deploy-LicenseServer-Update { "tailwind.config.ts", "postcss.config.js" ) - + foreach ($file in $filesToCopy) { $src = "license-server/$file" if (Test-Path $src) { Copy-Item -Path $src -Destination $tempDir -Force } } - + # 复制目录 $dirsToCopy = @("prisma", "app", "lib", "scripts") foreach ($dir in $dirsToCopy) { @@ -668,7 +737,7 @@ function Deploy-LicenseServer-Update { Copy-Item -Path $src -Destination $tempDir -Recurse -Force } } - + # 创建 public 目录(如果不存在) $publicDir = "license-server/public" if (Test-Path $publicDir) { @@ -676,20 +745,20 @@ function Deploy-LicenseServer-Update { } else { New-Item -ItemType Directory -Path "$tempDir/public" -Force | Out-Null } - + # 上传到服务器 scp -i $Config.SshKey -r "$tempDir/*" "root@$($Config.Server):/opt/license-server/" - + if ($LASTEXITCODE -ne 0) { Write-Error "文件上传失败" Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue return } Write-Success "文件上传完成" - + # 清理临时目录 Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue - + # 重新构建和启动 Write-Info "重新构建和启动服务..." $deployCmd = @" @@ -701,9 +770,9 @@ sleep 15 && \ docker compose exec -T license-server npx prisma db push --accept-data-loss 2>/dev/null || \ docker compose exec -T license-server npx prisma db push "@ - + ssh -i $Config.SshKey "root@$($Config.Server)" $deployCmd - + if ($LASTEXITCODE -eq 0) { Write-Success "许可证服务器更新完成!" Write-Info "访问地址: http://$($Config.Server):3001" @@ -716,19 +785,19 @@ docker compose exec -T license-server npx prisma db push # 全新部署许可证服务器 function Deploy-LicenseServer-Fresh { Write-Info "全新部署许可证服务器..." - + # 测试 SSH 连接 if (-not (Test-SSHConnection)) { return } - + # Step 1: 生成 RSA 密钥对 Write-Info "生成 RSA 密钥对..." - + # 使用 OpenSSL 生成密钥(Windows 上需要安装 OpenSSL 或使用 Git Bash) $keysDir = "license-server/keys" if (-not (Test-Path $keysDir)) { New-Item -ItemType Directory -Path $keysDir -Force | Out-Null } - + # 尝试使用 openssl $opensslPath = $null $gitBashOpenssl = "C:\Program Files\Git\usr\bin\openssl.exe" @@ -737,12 +806,12 @@ function Deploy-LicenseServer-Fresh { } elseif (Get-Command openssl -ErrorAction SilentlyContinue) { $opensslPath = "openssl" } - + if ($opensslPath) { Write-Info "使用 OpenSSL 生成密钥..." & $opensslPath genrsa -out "$keysDir/private.key" 2048 2>$null & $opensslPath rsa -in "$keysDir/private.key" -pubout -out "$keysDir/public.key" 2>$null - + if ($LASTEXITCODE -eq 0) { Write-Success "RSA 密钥生成成功" } else { @@ -754,20 +823,20 @@ function Deploy-LicenseServer-Fresh { Write-Info "可以在服务器上运行: cd /opt/license-server && npx ts-node scripts/generate-keys.ts" return } - + # 读取密钥 $privateKey = Get-Content "$keysDir/private.key" -Raw $publicKey = Get-Content "$keysDir/public.key" -Raw - + # 生成其他密钥 $jwtSecret = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object {[char]$_}) $aesKey = -join ((48..57) + (65..70) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_}) - + # Step 2: 创建 .env 文件 Write-Info "创建环境配置文件..." - + $privateKeyEscaped = $privateKey.Replace("`r`n", "\n").Replace("`n", "\n") - + $envContent = @" # 许可证服务器环境变量 # 生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') @@ -787,25 +856,25 @@ AES_SECRET_KEY=$aesKey # 管理员初始密码 ADMIN_INITIAL_PASSWORD=admin123 "@ - + $envContent | Out-File -FilePath "license-server/.env" -Encoding UTF8 -NoNewline Write-Success ".env 文件已创建" - + # Step 3: 上传所有文件 Write-Info "上传项目文件..." - + # 创建远程目录 ssh -i $Config.SshKey "root@$($Config.Server)" "mkdir -p /opt/license-server" - + # 上传文件 scp -i $Config.SshKey -r "license-server/*" "root@$($Config.Server):/opt/license-server/" - + if ($LASTEXITCODE -ne 0) { Write-Error "文件上传失败" return } Write-Success "文件上传完成" - + # Step 4: 构建和启动 Write-Info "构建和启动服务..." $deployCmd = @" @@ -834,9 +903,9 @@ async function init() { init().catch(console.error).finally(() => prisma.\$disconnect()); " "@ - + ssh -i $Config.SshKey "root@$($Config.Server)" $deployCmd - + if ($LASTEXITCODE -eq 0) { Write-Success "许可证服务器部署完成!" Write-Host "" @@ -864,46 +933,46 @@ init().catch(console.error).finally(() => prisma.\$disconnect()); # 6. 查看版本和镜像状态 function Show-Status { Write-Title "版本和镜像状态" - + Write-Color "=== 本地版本 ===" "Yellow" Write-Info "素材系统版本: $(Get-CurrentVersion)" Write-Info "Agent 版本: $(Get-AgentVersion)" Write-Host "" - + Write-Color "=== Harbor 项目结构 ===" "Yellow" Write-Info "flnxi-public (公开): Agent、runtime 依赖" - Write-Info "flnxi (私有): 素材系统镜像" + Write-Info "flnxi (公开): 素材系统镜像" Write-Host "" - + Write-Color "=== Harbor 镜像 ===" "Yellow" Write-Info "检查 Harbor 镜像..." - + # 检查 Harbor 登录状态 $loginCheck = docker login $Config.HarborHost 2>&1 if ($LASTEXITCODE -eq 0) { Write-Success "Harbor 已登录" - + # 列出私有项目镜像 Write-Host "" - Write-Color "flnxi/ (私有 - 素材系统):" "White" + Write-Color "flnxi/ (公开 - 素材系统):" "White" docker images "$($Config.HarborHost)/$($Config.HarborProject)/*" --format " {{.Repository}}:{{.Tag}}\t{{.CreatedSince}}" 2>$null - + # 列出公开项目镜像 Write-Host "" Write-Color "flnxi-public/agent (公开 - Agent):" "White" docker images "$($Config.HarborHost)/$($Config.HarborPublicProject)/agent*" --format " {{.Repository}}:{{.Tag}}\t{{.CreatedSince}}" 2>$null - + Write-Host "" Write-Color "flnxi-public/runtime (公开 - 依赖镜像):" "White" docker images "$($Config.HarborHost)/$($Config.HarborPublicProject)/runtime/*" --format " {{.Repository}}:{{.Tag}}\t{{.CreatedSince}}" 2>$null } else { Write-Warning "未登录 Harbor,请先执行: docker login $($Config.HarborHost)" } - + Write-Host "" Write-Color "=== 服务器状态 ===" "Yellow" Write-Info "检查服务器 $($Config.Server)..." - + $statusCmd = "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'flnxi|license' || echo '无运行中的服务'" ssh -i $Config.SshKey "root@$($Config.Server)" $statusCmd 2>$null } @@ -912,26 +981,19 @@ function Show-Status { if ($Help) { Write-Color "╔═══════════════════════════════════════════════════════════╗" "Cyan" - Write-Color "║ FLNXI 素材系统 - 发布管理工具 v2.0 ║" "Cyan" + Write-Color "║ FLNXI 素材系统 - 历史内部发布工具 v2.0 ║" "Cyan" Write-Color "╠═══════════════════════════════════════════════════════════╣" "Cyan" Write-Color "║ 用法: ║" "White" - Write-Color "║ .\发布管理.ps1 启动交互式菜单 ║" "Gray" + Write-Color "║ .\deploy-registry.ps1 当前版本化发布入口 ║" "Gray" + Write-Color "║ .\发布管理.ps1 -AcknowledgeLegacyInternalPublish ║" "Gray" + Write-Color "║ 仅用于历史链路排查 ║" "Gray" Write-Color "║ .\发布管理.ps1 -Help 显示此帮助 ║" "Gray" Write-Color "╠═══════════════════════════════════════════════════════════╣" "Cyan" - Write-Color "║ 功能: ║" "White" - Write-Color "║ [1] 构建并部署测试版 (含健康检查) ║" "White" - Write-Color "║ [2] 发布素材系统正式版 (需确认) ⚠️ ║" "Yellow" - Write-Color "║ [3] 发布 Agent 安装程序 (需确认) ⚠️ ║" "Yellow" - Write-Color "║ [4] 同步 Runtime 镜像 (国内镜像源加速) ║" "White" - Write-Color "║ [5] 部署许可证服务器 (需确认) ⚠️ ║" "Yellow" - Write-Color "║ [6] 查看版本和镜像状态 ║" "White" - Write-Color "║ [7] 清理本地旧镜像 ║" "White" - Write-Color "║ [8] 清理服务器旧镜像 ║" "White" - Write-Color "║ [9] 深度清理 Docker (释放磁盘空间) ║" "Red" + Write-Color "║ 说明: ║" "White" + Write-Color "║ 该脚本包含旧服务器、旧 Harbor、latest 和内部清理逻辑, ║" "Gray" + Write-Color "║ 不作为客户安装、升级或正式发布入口。 ║" "Gray" Write-Color "╠═══════════════════════════════════════════════════════════╣" "Cyan" - Write-Color "║ 特性: ║" "White" - Write-Color "║ ✓ 进度条显示 ✓ 健康检查 ✓ SSH智能重试 ║" "Green" - Write-Color "║ ✓ 国内镜像源加速 ✓ 趣味提示 ✓ 镜像清理 ║" "Green" + Write-Color "║ 当前入口: deploy-registry.ps1 + docker-compose.prod.yml ║" "Green" Write-Color "╚═══════════════════════════════════════════════════════════╝" "Cyan" exit 0 } @@ -939,10 +1001,10 @@ if ($Help) { # 7. 清理本地旧镜像 function Menu-CleanLocal { Write-Title "清理本地旧镜像" - + $keepCount = Read-Host "保留最新几个镜像? (默认: 5)" if ([string]::IsNullOrWhiteSpace($keepCount)) { $keepCount = 5 } - + Clean-LocalImages -ImagePattern "flnxi" -KeepCount ([int]$keepCount) Complete-Stage "本地镜像清理" } @@ -950,15 +1012,15 @@ function Menu-CleanLocal { # 8. 清理服务器旧镜像 function Menu-CleanRemote { Write-Title "清理服务器旧镜像" - + # 先测试 SSH 连接 if (-not (Test-SSHConnection)) { return } - + $keepCount = Read-Host "保留最新几个镜像? (默认: 5)" if ([string]::IsNullOrWhiteSpace($keepCount)) { $keepCount = 5 } - + Clean-RemoteImages -KeepCount ([int]$keepCount) Complete-Stage "服务器镜像清理" } @@ -966,36 +1028,36 @@ function Menu-CleanRemote { # 9. 深度清理 Docker(释放磁盘空间) function Menu-DeepClean { Write-Title "深度清理 Docker (释放磁盘空间)" - + Write-Warning "此操作将清理:" Write-Info " - 所有未使用的镜像" Write-Info " - 所有停止的容器" Write-Info " - 所有未使用的网络" Write-Info " - 所有构建缓存" Write-Host "" - + # 显示当前磁盘使用 Write-Color "=== 清理前 ===" "Yellow" docker system df Write-Host "" - + if (-not (Confirm-Action "确认执行深度清理?" "这将删除所有未使用的 Docker 资源!")) { Write-Warning "取消清理" return } - + Write-Info "正在清理..." - + # 清理所有未使用的资源 docker system prune -a -f --volumes - + # 清理构建缓存 docker builder prune -a -f - + Write-Host "" Write-Color "=== 清理后 ===" "Yellow" docker system df - + Write-Host "" Write-Success "深度清理完成!" Write-Info "提示: 如果 vhdx 文件仍然很大,可以在 Docker Desktop 设置中" @@ -1007,7 +1069,7 @@ function Menu-DeepClean { while ($true) { Show-Menu $choice = Read-Host "请选择操作" - + switch ($choice) { "1" { Deploy-TestVersion } "2" { Publish-ReleaseVersion } @@ -1018,13 +1080,13 @@ while ($true) { "7" { Menu-CleanLocal } "8" { Menu-CleanRemote } "9" { Menu-DeepClean } - "0" { + "0" { Write-Color "再见!$(Get-FunTip)" "Cyan" - exit 0 + exit 0 } default { Write-Warning "无效选项,请重新选择" } } - + Write-Host "" Read-Host "按 Enter 键继续..." }