65 Commits
Author SHA1 Message Date
wangbo 385c509086 fix(deploy): 限制初始管理员密码长度 2026-07-25 20:49:09 +08:00
wangbo f31e349409 feat(config): 添加 Node.js 堆内存配置和代理路径规范化
- 在 .env.sample 中新增 NODE_MAX_OLD_SPACE_SIZE 配置项,默认设置为 8192 MiB
- 在 docker-compose.yml 中添加 NODE_OPTIONS 环境变量配置
- 在 easyai-proxy.conf.sample 中添加 /api 路径规范化重定向规则
- 修改 https.sh 脚本优先使用完整代理模板生成配置文件
- 更新 README.md 添加新版本发布说明
- 扩展测试脚本验证代理配置和路径重定向功能
2026-07-24 22:10:45 +08:00
wangbo 6ee9666b0c fix(deploy): 初始化并校验公开 API 地址 2026-07-16 01:00:32 +08:00
wangbo 64bbf5c109 feat(deploy): initialize authentication security secrets 2026-07-15 11:29:41 +08:00
wangbo 2a40ad91c8 优化部署脚本非交互变量支持 2026-06-30 22:12:26 +08:00
wangbo d54cf2f6ce chore: enable gzip for nginx api responses 2026-06-30 22:00:27 +08:00
wangbo efd843b1b2 chore: tune easyai-server native memory settings 2026-06-29 20:54:18 +08:00
wangbo 2a7f5444bf chore: remove easyai-server memory limits 2026-06-29 20:50:24 +08:00
wangbo 5518039792 chore(deploy): add server native memory guards 2026-06-24 16:38:41 +08:00
wangbo cabee634d4 chore(deploy): cap server node heap 2026-06-23 16:59:11 +08:00
wangbo 0aebe62f70 perf(redis): 优化 Redis 配置并添加资源限制
- 为 Redis 添加 ./data/redis:/data 持久化挂载
- 在 redis.conf 中设置 maxmemory 2gb 限制内存使用
- 禁用 RDB 快照,启用 appendfsync everysec 避免 OOM
- 在 docker-compose.yml 中为 Redis 添加 mem_limit: 2g 和 memswap_limit: 2g
- 为 MongoDB 添加 --wiredTigerCacheSizeGB 1.5 和 mem_limit: 2500m
- 为 easyai-server 添加 mem_limit: 2200m 内存限制
- 更新 README.md 文档说明 Redis 资源护栏和迁移步骤
2026-06-22 23:20:40 +08:00
wangbo 42ef68857d fix(nginx): 更新代理配置以支持微前端框架
- 注释掉默认的 X-Frame-Options 头部设置
- 添加 proxy_hide_header 指令隐藏上游返回的 X-Frame-Options
- 增加 CORS 相关头部配置支持跨源资源访问
- 配置 Access-Control-Allow-Origin 允许所有来源
- 添加跨域请求方法和头部字段白名单
- 设置 Cross-Origin-Resource-Policy 为跨源策略
2026-06-15 23:11:01 +08:00
wangbo f7cb72342a fix(docker): configure wsgateway inbound routing 2026-05-16 10:50:50 +08:00
wangbo f44a9a39e5 chore(docker): 为后端服务添加健康检查 2026-05-11 19:18:42 +08:00
wangbo f04bb5e4fc feat: 基础服务镜像支持环境变量与多架构迁移脚本
- docker-compose:REDIS_IMAGE、MONGO_IMAGE、RABBITMQ_IMAGE 可配置,默认保持原阿里云地址
- .env.sample:补充三镜像变量、Mongo 4.4 备选地址及用途说明、RabbitMQ 备选示例
- 新增 mirror-image-to-registry.ps1:从 Hub/镜像站同步指定架构到私有仓库,含凭据与并行复制优化

Made-with: Cursor
2026-04-10 17:35:18 +08:00
wangbo 5c856749c1 fix(deploy): 浅克隆说明、README 精简与 Docker 自动安装可观测性
- README: Windows 一条 PowerShell 命令,保留脚本权限说明;合并重复段落
- start.sh/ps1: git clone --depth 1 示例
- start.ps1: 选 [2] 时输出 winget/choco 日志与退出码,失败原因可辨;Read-Host Trim;winget 后若 docker 可用则视为成功

Made-with: Cursor
2026-04-10 17:09:18 +08:00
wangbo b63318709e chore(start.ps1): 移除 Git 自动安装逻辑
部署脚本不依赖本机 git,winget/choco 安装与 PATH 刷新已无必要。

Made-with: Cursor
2026-04-10 16:44:58 +08:00
wangbo 3b7b06e06f merge origin/main: sync upstream; docker-compose.yml use remote version
Made-with: Cursor
2026-04-10 16:39:00 +08:00
wangbo ce042333c4 docs(win): 分行展示一键部署命令,避免 git 误解析参数
- README: Windows 克隆与启动改为分行,补充单行安全写法与说明
- start.ps1: 无 Git 时 winget/choco 自动安装、PATH 刷新、UTF-8 BOM 与文件头使用说明

Made-with: Cursor
2026-04-10 16:31:44 +08:00
wangbo 988fceff00 chore(docker): 更新 Docker Compose 配置 2026-04-10 16:06:17 +08:00
wangbo e5f412f2cd chore(docker): 移除不必要的数据卷挂载配置 2026-04-10 16:00:26 +08:00
wangbo 1aab453374 fix(redis): 修复 Redis 容器只读模式配置问题 2026-04-10 15:28:25 +08:00
wangbo 2f0ae76273 feat(install): 支持 macOS 系统的一键部署
- 更新 README.md 标题为 Linux/Mac 兼容
- 添加 macOS 系统检测逻辑,使用 uname 命令识别 Darwin 内核
- 实现 macOS Docker Desktop 的自动安装功能,通过 Homebrew 安装
- 添加 Docker Desktop 启动等待机制,最多等待 120 秒
- 提供 macOS 用户安装选项,支持自动或手动安装模式
- 跳过 macOS 上的 systemd 服务管理命令执行
2026-04-09 21:26:56 +08:00
wangbo 8365433014 docs(README): 添加部署咨询二维码 2026-04-09 18:48:46 +08:00
wangbo 8ea92ca7a8 docs(README): 添加部署咨询二维码 2026-04-09 18:48:04 +08:00
wangbo c60bc54fdc Merge remote-tracking branch 'origin/main' 2026-04-09 13:43:42 +08:00
wangbo 3a14c722e0 fix(deploy): 优化 HTTPS 配置和 Nginx 模板检查 2026-04-09 13:43:27 +08:00
wangbo a59b3930bb fix(deploy): 修复 start.ps1 闪退并增强稳定性
重建部署脚本并修复解析阶段报错,避免因脚本损坏导致运行后直接退出;同时保留 dry-run 与 Docker 检查流程,提升可诊断性与非交互执行稳定性。

Made-with: Cursor
2026-04-09 10:31:03 +08:00
wangbo 7fe9762dc9 fix(installer): 修复 Docker Compose 安装逻辑
- 使用 curl 下载指定 URL 的 docker-compose 二进制文件
- 通过 install 命令替代 mv 和 chmod 设置权限
- 使用临时文件存储下载的二进制文件并及时清理
- 修复了旧版 docker-compose 检测后的安装流程
2026-04-08 16:45:52 +08:00
wangbo 6bb62904e0 fix(config): 更新服务名称和配置检测逻辑
- 将 comfy-server 替换为 easyai-server 在环境配置文件中
- 修改 README.md 中的服务引用名称
- 添加 WS_GATEWAY_LOG_LEVEL 日志级别配置
- 实现 nginx 配置文件自动检测和选择功能
- 更新 https.sh 中的配置文件查找逻辑
2026-04-04 23:01:43 +08:00
wangbo f33b0850fe feat(config): 添加每日记忆整理配置和域名配置交互功能
- 在 .env.AMS.sample 和 .env.sample 中新增记忆整理相关配置项
- 实现了 https.sh 脚本中的域名配置交互与文件生成功能
- 支持配置文件中域名的替换和新增操作
- 在 README.md 中补充了一键启动脚本使用说明和 HTTPS 配置指南
- 添加了 docker-compose.yml 单独更新命令说明
2026-04-02 17:45:29 +08:00
wangbo c0ee3bee52 docs(README): 移除单独更新docker-compose.yml的相关说明
- 删除了关于单独更新docker-compose.yml文件的步骤说明
- 移除了相应的bash命令示例代码块
- 清理了相关的备份和下载操作文档
2026-04-01 09:50:12 +08:00
wangbo 2d924f5631 docs(readme): 更新文档移除一键启动脚本说明
- 移除了 Linux/Ubuntu 和 Windows 一键启动脚本的相关说明
- 添加了其他功能配置的概述
- 保留了 PDF 图文解析 Markdown 功能的配置说明
2026-04-01 09:49:52 +08:00
wangbo 0496fe7288 docs(readme): 更新升级指南文档
- 添加老客户旧版本部署包升级
2026-04-01 09:39:26 +08:00
wangbo 3bab0ecbce fix(server): 移除HTTP端口映射配置 2026-03-31 17:52:04 +08:00
wangbo 341408d23c feat(config): 添加 WebSocket 端口配置支持 2026-03-31 17:49:57 +08:00
wangbo b7dddfb750 fix(https): 修复域名提取携带分号导致证书申请失败
修正 https.sh 中 server_name 提取与 certbot 参数构建逻辑,避免将 `credit99.cn;` 等非法域名传给 certbot;同时在 start.sh 增加域名规范化与格式校验,提前拦截协议前缀、路径和分号等脏输入。

Made-with: Cursor
2026-03-31 15:04:56 +08:00
wangbo e66b3c71af fix(gateway): 修复ws-gateway端口配置默认值问题 2026-03-30 16:03:50 +08:00
wangbo a6239f0057 fix(comfyui): 修正服务依赖配置
- 将comfy-server依赖替换为easyai-server
- 确保服务启动顺序正确
2026-03-30 01:26:37 +08:00
wangbo d8540d1172 rename(comfyAI): 将项目名称从comfyAI统一更改为easyai 2026-03-30 01:10:22 +08:00
wangbo 7ebc7d5be4 feat(config): default server-to-gateway connection to internal network
Wire WS gateway TCP host/port into the server service environment with ws-gateway:4002 defaults so production deployments in easyai connect over internal container networking out of the box.

Made-with: Cursor
2026-03-30 01:07:33 +08:00
wangbo f4bc04acde feat(config): 统一 ws-gateway 端口变量命名
统一部署侧 WebSocket 端口配置为 CONFIG_WS_PORT,移除 SERVER_WS_PORT 的重复语义,并同步更新映射与注释说明以降低运维歧义。

Made-with: Cursor
2026-03-29 10:05:57 +08:00
wangbo 80c9b5576a feat(config): 添加 Dozzle 端口配置支持
- 在 .env.sample 中新增 DOZZLE_PORT 配置项,默认值为 8080
- 修改 docker-compose.yml 中 Dozzle 服务端口映射使用环境变量
- 实现端口配置的可自定义化,提升部署灵活性
2026-03-22 22:07:28 +08:00
wangbo d5fa673b4e 修复不签HTTPS会退出脚本的问题 2026-03-22 21:55:28 +08:00
wangbo e2326e50c9 修复不签HTTPS会退出脚本的问题 2026-03-22 21:52:21 +08:00
wangbo 0bd8c4f359 feat(config): 增加记忆去重阈值示例配置
在 AMS 示例环境变量中新增向量与文本去重阈值,便于正式部署时控制缺少 embedding 模型场景下的重复写入策略。

Made-with: Cursor
2026-03-21 14:40:47 +08:00
wangbo d6d9705566 feat(config): 更新环境配置中的嵌入模型设置
- 将 MEMORY_EMBEDDING_MODEL 从 text-embedding-v4 更改为
2026-03-20 22:52:13 +08:00
wangbo 41549e8a72 feat(comfyui):
- 在docker-compose.yml中为ComfyUI容器添加MEMORY_DATABASE_URL环境变量
- 设置默认值指向easyai-pgvector数据库服务
2026-03-20 22:45:56 +08:00
wangbo ac795ad38d config(env): 设置对话压缩默认模型并移除内存服务配置文件
- 为 MEMORY_CHAT_MODEL 环境变量设置默认值 qwen-plus
- 从 docker-compose.yml 中移除 agent-memory 服务的 profiles 配置
- 调整内存服务容器配置以优化部署流程
2026-03-20 22:04:13 +08:00
wangbo dd1c905cd8 fix: update.ps1 保存为 UTF-8 BOM 避免 PowerShell 5.1 解析闪退
Made-with: Cursor
2026-03-20 18:43:21 +08:00
wangbo 512dd66762 feat: 优化 update 脚本,新增 Windows 版 update.ps1
- update.sh: 改为 git pull 拉取整个仓库,命令行内选择更新方式(默认拉取+更新)
- update.ps1: 新增 Windows 更新脚本,功能与 Linux 版一致
- README: 更新 update 脚本说明及 Windows 使用方式

Made-with: Cursor
2026-03-20 18:35:33 +08:00
wangbo f2f10d543e ```
chore(config): 移除 MEMORY_DATABASE_URL 配置并更新 docker-compose 环境文件

- 移除了 .env.AMS.sample 中的 MEMORY_DATABASE_URL 配置项
- 在 docker-compose.yml 中为服务添加了 .env.ASG 环境文件引用
- 更新了环境配置文件的加载方式
```
2026-03-20 18:24:30 +08:00
wangbo a70545b702 feat: 部署脚本支持 .env.AMS,与 ASG 一致从 .sample 生成无后缀文件
Made-with: Cursor
2026-03-20 18:23:18 +08:00
wangbo ded45063d9 feat: 新增 Agent 记忆服务部署、重命名容器、整理 env 配置
- 新增 agent-memory 服务与 .env.AMS.sample
- easyai-pgvector 共用 PostgreSQL,独立库 easyai_memory
- 新增 docker/postgres/init-pgvector.sql 初始化
- 容器名: comfyAI-web→easyai-web, comfy-server→easyai-server
- easyai-asg-pg→easyai-pgvector
- 记忆服务端口配置移至 .env(comfy-server 调用用)
- 整理 .env.sample 结构,新增 /ams-api/ Nginx 代理

Made-with: Cursor
2026-03-20 17:06:39 +08:00
wangbo e64a90332c feat: 优化 Windows 部署与文档
- start.ps1: 局域网 IP 自动检测, 部署完成提示默认账户 admin/123456
- start.ps1: 修复 UTF-8 BOM 避免 PowerShell 5.1 解析闪退
- docker-compose: dozzle 容器增加 restart 与 logging 配置
- 新增 reset-docker-network.ps1 网络重置脚本
- README: 顶部增加 Linux/Windows 一键部署命令, Windows 权限说明

Made-with: Cursor
2026-03-11 14:41:53 +08:00
wangbo e9c594c3e6 feat: RabbitMQ 改用 DaoCloud 国内镜像,保留官方和阿里云地址,添加 watchtower 自动更新
Made-with: Cursor
2026-03-11 10:31:54 +08:00
wangbo f37fd958e1 fix: 将退出原因写入 start.ps1.log 便于闪退排查
- 新增 Write-Log 与日志文件 start.ps1.log
- 脚本加载/正常结束/错误退出均写入日志
- trap 捕获异常时写入错误信息与堆栈
- README 补充日志位置说明

Made-with: Cursor
2026-03-10 22:15:54 +08:00
wangbo fe370bd5bc fix: 修复 Windows start.ps1 闪退,保持终端不关闭便于 Debug
- 增加 trap 错误捕获与堆栈输出
- 增加 Wait-ForExit 结束时暂停,避免窗口闪退
- 使用 $PSScriptRoot 获取脚本目录
- README 补充 Windows 权限配置与启动说明

Made-with: Cursor
2026-03-10 22:13:12 +08:00
wangbo b7a11abe9f feat: 新增 Windows 一键部署脚本 start.ps1
- start.ps1: PowerShell 部署脚本,支持本地/局域网 IP 访问
- docs/Windows一键部署方案.md: 实现方案与测试说明
- scripts/test-start-ps1-env.py: .env 替换逻辑验证
- .github/workflows/test-start-ps1.yml: Windows CI 测试

Made-with: Cursor
2026-03-10 21:59:12 +08:00
wangbo ea70cecef1 docs: HTTPS 启用时增加 80、443 端口放行说明
Made-with: Cursor
2026-03-09 16:56:15 +08:00
wangbo bb2373d4f6 feat(deploy): 优化部署脚本,支持问答式配置
- start.sh: 交互式选择 IP/域名访问,自动生成 .env、.env.tools、.env.ASG
- start.sh: 移除内置克隆逻辑,需先 git clone 再执行
- 新增 docker/verify: Docker 验证环境与快速验证脚本

Made-with: Cursor
2026-03-09 16:40:38 +08:00
wangbo 59a1a88e29 优化脚本,支持一键部署 2026-03-09 16:22:33 +08:00
wangbo 5bca174da8 fix tools容器中环境变量配置时效的问题 2026-03-07 13:29:12 +08:00
wangbo b3f969b3a3 fix: PG 18 数据卷挂载路径修正为 /var/lib/postgresql
Made-with: Cursor
2026-03-05 23:07:13 +08:00
wangbo 1b85369903 feat(proxy): 添加沙箱 API 转发配置 /sandbox/
Made-with: Cursor
2026-03-04 16:38:27 +08:00
32 changed files with 4295 additions and 392 deletions
+52
View File
@@ -0,0 +1,52 @@
# ============================================
# Agent 记忆服务(agent-memory)环境变量
# 使用前请复制为 .env.AMS 并根据实际情况修改
# ============================================
# ---------- PostgreSQL(复用 easyai-pgvector----------
# ---------- 主服务连接(用于 Embedding 与对话压缩)----------
# 主服务 API 地址(容器内网地址)
MEMORY_AI_BASE_URL=http://easyai-server:3001
MEMORY_AI_API_KEY=
# 默认模型配置接口路径已内置为 /content/llm/default-models,无需额外环境变量
# ---------- Embeddings ----------
MEMORY_EMBEDDINGS_PATH=/v1/embeddings
MEMORY_EMBEDDING_MODELS_PATH=/v1/embeddings/models
MEMORY_EMBEDDING_DIMENSION=1024
MEMORY_EMBEDDING_MODEL=Qwen3-Embedding-v4
MEMORY_SCORE_CONFIDENCE_BASE=0.6
MEMORY_SCORE_CONFIDENCE_GAIN=0.4
MEMORY_SCORE_IMPORTANCE_BASE=0.7
MEMORY_SCORE_IMPORTANCE_GAIN=0.3
MEMORY_DEDUP_MIN_SCORE=0.92
MEMORY_TEXT_DEDUP_MIN_SCORE=0.9
# ---------- 对话压缩 ----------
MEMORY_CHAT_COMPLETIONS_PATH=/v1/chat/completions
MEMORY_CHAT_MODELS_PATH=/v1/models
MEMORY_CHAT_MODEL=qwen-plus
MEMORY_COMPRESSION_ENABLED=true
MEMORY_COMPRESSION_MIN_LENGTH=80
# ---------- 每日记忆整理 ----------
# 是否启用每天凌晨 2 点记忆整理(去重、冲突处理)
MEMORY_MAINTENANCE_ENABLED=true
# dry-run: true=仅分析不落库;建议升级首日先开 true 观察
MEMORY_MAINTENANCE_DRY_RUN=false
# 每个租户单轮扫描上限
MEMORY_MAINTENANCE_BATCH_SIZE=200
# 是否启用 LLM 语义冲突裁决(复用会话总结模型,无需单独模型配置)
MEMORY_MAINTENANCE_LLM_ENABLED=true
# 单轮最多调用 LLM 裁决的冲突对数量(用于控成本)
MEMORY_MAINTENANCE_LLM_MAX_PAIRS=20
# ---------- 其他 ----------
# 日志级别
LOG_LEVEL=log,error,warn,debug
# 是否禁用 Swagger 文档(true 禁用)
MEMORY_DOCS_DISABLE=false
# 记忆服务镜像版本
AMS_VERSION=latest
+4 -5
View File
@@ -5,9 +5,8 @@
# ---------- PostgreSQL ----------
# Prisma 数据库连接字符串(容器内网地址)
ASG_DATABASE_URL=postgresql://easyai:easyai2025@easyai-asg-pg:5432/agent_governance?schema=public
ASG_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/agent_governance?schema=public
# PostgreSQL 容器初始化配置(与 docker-compose 中 sg-postgres 保持一致)
ASG_POSTGRES_USER=easyai
ASG_POSTGRES_PASSWORD=easyai2025
ASG_POSTGRES_DB=agent_governance
@@ -22,13 +21,13 @@ ASG_REDIS_DB=8
# ---------- 服务端口 ----------
# HTTP API 端口
ASG_PORT=3003
# TCP 微服务端口(供 comfy-server 内部调用)
# TCP 微服务端口(供 easyai-server 内部调用)
ASG_TCP_PORT=4003
ASG_TCP_HOST=0.0.0.0
# ---------- 主服务连接 ----------
# Agent 调用时需要访问的 comfy-server 地址(容器内网地址)
ASG_MAIN_BACKEND_URL=http://comfy-server:3001
# Agent 调用时需要访问的 easyai-server 地址(容器内网地址)
ASG_MAIN_BACKEND_URL=http://easyai-server:3001
# 管理员账号(用于 Agent 登录获取 token
ASG_ADMIN_USERNAME=admin
ASG_ADMIN_PASSWORD=123456
+188 -51
View File
@@ -1,70 +1,111 @@
#############################################
#1、如下配置需要根据实际情况进行配置
##############################################
# 默认服务器地址,本地不需要更改,云服务需要修改为云端IP,并放行对应端口 TODO
# EasyAI 环境变量配置
# 复制为 .env 并根据实际情况修改
#############################################
# ========== 1. 访问地址(部署时必改) ==========
# 默认服务器 API 地址,云服务需修改为云端 IP 并放行对应端口
NUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001
#域名访问使用如下配置,使用/api进行转发,并配置nginx代理/api至3001端口
#NUXT_PUBLIC_BASE_APIURL=/api
# 默认的服务器websocket地址,本地不需要更改,云服务需要修改为云端IP,并放行对应端口 TODO
# 域名访问NUXT_PUBLIC_BASE_APIURL=/api
# 后端生成文件、预签名地址和异步任务查询地址时使用的公开 API 根地址。
# 留空时启动/升级脚本会根据本节已有配置初始化;手工维护时必须填写完整 http(s) URL。
CONFIG_PUBLIC_API_BASE_URL=
# 启动/升级脚本的高级覆盖项,适用于已有外部 TLS、特殊端口或手工反向代理。
# 示例:https://api.example.com:8443/api
DEPLOY_PUBLIC_API_BASE_URL=
# 默认 WebSocket 地址
NUXT_PUBLIC_BASE_SOCKETURL=ws://127.0.0.1:3002
#域名访问使用如下配置,配置为/,并代理/socket.io请求到3002端口
#NUXT_PUBLIC_BASE_SOCKETURL=wss://yourwebsite.com/socket.io
# Agent 服务治理 API 地址,前端管理页面需要。IP访问填http://<IP>:3003,域名访问填/asg-api
# 域名访问NUXT_PUBLIC_BASE_SOCKETURL=wss://yourwebsite.com/socket.io
# Agent 服务治理 API 地址,前端管理页面需要
NUXT_PUBLIC_SG_APIURL=http://127.0.0.1:3003
#域名访问使用如下配置,通过nginx代理转发
#NUXT_PUBLIC_SG_APIURL=/asg-api
# 域名访问NUXT_PUBLIC_SG_APIURL=/asg-api
#############################################
#2、以下部分可保持默认,如果没有端口冲突,生产环境可修改密码
##############################################
#comfyAI-web 前端应用暴露端口,访问地址ip:3010访问,初始化管理员账号admin,密码123456
# ========== 2. 服务端口 ==========
WEB_PORT=3010
#支持静态资源CDN,将静态资源从服务器分离
NUXT_APP_CDN_URL=
# easyai-web 前端端口,访问地址 ip:3010,初始化管理员 admin/123456
SERVER_HTTP_PORT=3001
# easyai-server 后端 HTTP 端口
NODE_MAX_OLD_SPACE_SIZE=8192
# easyai-server 的 Node.js V8 old-space 堆上限,单位 MiB
MALLOC_ARENA_MAX=2
# 限制 glibc native heap arena,降低 sharp/libvips 场景下 RSS 膨胀
MALLOC_TRIM_THRESHOLD_=131072
# glibc 更积极 trim 空闲 top chunk,降低 native RSS 高水位
MALLOC_MMAP_THRESHOLD_=131072
# 较大的 native 分配更倾向 mmap,释放后更容易归还 OS
MALLOC_TOP_PAD_=131072
# 降低 trim 后额外保留的 heap padding
SHARP_CACHE_ENABLED=false
# 默认关闭 sharp/libvips 全局 cache;需要开启时再配置 SHARP_CACHE_* 参数
SHARP_CONCURRENCY=1
# sharp/libvips 并发,8G 单机建议保持 1
# ws-gateway WebSocket 端口(统一作为对外访问映射端口使用)
CONFIG_WS_PORT=3002
# ws-gateway 容器内部 TCP 微服务监听地址(供 easyai-server 推送事件)
CONFIG_TCP_HOST=0.0.0.0
# ws-gateway 容器内部 TCP 微服务监听端口(默认 4002)
CONFIG_TCP_PORT=4002
#视频编辑对外暴露端口
VIDEO_EDIT_PORT=8000
# 视频编辑服务对外端口
#沙箱环境对外端口,不建议暴露,如果需要暴露,取消docker-compose.yml中的对应注释
AMS_PORT=3004
# Agent 记忆服务 HTTP 端口(启用 memory profile 时)
# ========== 3. Agent 记忆服务(启用 memory profile 时) ==========
MEMORY_TCP_HOST=agent-memory
MEMORY_TCP_PORT=4004
# easyai-server 调用 agent-memory 的 TCP 连接
# 记忆整理相关参数在 .env.AMS 中配置(MEMORY_MAINTENANCE_*
# ========== 4. 沙箱环境 ==========
SANDBOX_PORT=8081
#SANDBOX jupyterlab 端口
# 不建议对外暴露
SANDBOX_JUPYTERLAB_PORT=8888
# 配置Jupter的token,安全考虑,建议设置
SANDBOX_JUPYTER_TOKEN=easyaiisbest
# 建议设置 token
SANDBOX_SERVICE_BASE_URL=
#REDIS暴露端口,默认不暴露
# ========== 5. Redis ==========
REDIS_PORT=
# 默认不对外暴露
# Redis 容器镜像(可改为国内镜像站或自建仓库)
REDIS_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest
CONFIG_COMFYUI_QUENE_REDIS_USERNAME=
CONFIG_COMFYUI_QUENE_REDIS_PASSWORD=
#队列使用的DB
CONFIG_COMFYUI_QUENE_REDIS_DB=6
#普通缓存使用的DB
CONFIG_COMFYUI_CACHE_REDIS_DB=11
#MONGO 暴露端口,默认不暴露。用户名密码初次部署可以修改,更新请勿修改
# ========== 6. MongoDB ==========
MONGO_PORT=27017
# MongoDB 容器镜像(可改为国内镜像站或自建仓库)
MONGO_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest
# 固定 MongoDB 4.4 系列(与旧数据/旧客户端兼容、或需锁定 4.x 行为时使用;升级大版本前请备份并查阅迁移说明):
# MONGO_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:4.4
MONGO_INITDB_ROOT_USERNAME=username
MONGO_INITDB_ROOT_PASSWORD=password
#comfy-server后端web服务暴露端口。一般情况下无需修改
SERVER_HTTP_PORT=3001
SERVER_WS_PORT=3002
#watchtower 监听端口,自动更新容器和通过浏览器查看容器日志
WATCHTOWER_PORT=8089
PORTAINER_PORT=8090
PORTAINER_HTTPS_PORT=8091
# 初次部署可修改,更新请勿修改
#网络代理,链接gpt等需要设置
CONFIG_PROXY_URL=
# ========== 7. 消息队列 RabbitMQ ==========
# RabbitMQ 容器镜像(可改为国内镜像站或自建仓库),备选示例:
# RABBITMQ_IMAGE=docker.m.daocloud.io/library/rabbitmq:4-management
# RABBITMQ_IMAGE=docker.dockerproxy.com/library/rabbitmq:4-management
# RABBITMQ_IMAGE=rabbitmq:4-management
RABBITMQ_IMAGE=registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest
#实例ID、集群情况下区分不同客户端
CONFIG_INSTANCE_ID=2025
#消息队列
CONFIG_MQ_PROTOCOL=amqp
CONFIG_MQ_USER=admin
CONFIG_MQ_PASSWORD=easyai2025
@@ -73,25 +114,121 @@ CONFIG_MQ_PORT=5672
CONFIG_MQ_ADMIN_PORT=15672
CONFIG_MQ_VHOST=/
#版本
VERSION=latest
#日志与调试
LOG_LEVEL=log,error,warn,debug
#Token过期时间,单位秒
# ========== 8. 鉴权与安全 ==========
CONFIG_TOKEN_EXPIRE=1800
# token加密密钥,可以修改为任意字符串
CONFIG_JWT_SECRET='this is a very secret secret'
# JWT 签名密钥。启动/升级脚本发现空值、历史默认值或不足 32 字节时会生成并持久化到 .env。
# 也可手动生成:openssl rand -base64 48
CONFIG_JWT_SECRET=
# 后台 JWT 动态配置的 AES-256-GCM 独立主密钥,建议与 JWT 签名密钥分别生成。
# 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。
CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=
# 验证码 HMAC、设备/指纹哈希和审计完整性密钥,由启动/升级脚本独立生成并持久化。
CONFIG_OTP_HASH_SECRET=
CONFIG_AUDIT_HASH_PEPPER=
CONFIG_AUDIT_INTEGRITY_KEY=
# 允许访问后端的浏览器 Origin,多个值用英文逗号分隔,生产环境禁止使用 *。
# 域名部署示例:https://yourwebsite.com,https://www.yourwebsite.com
CONFIG_SECURITY_ORIGIN=http://127.0.0.1:3010,http://localhost:3010
# 可信反向代理 IP/CIDR,多个值用英文逗号分隔;未使用反向代理时保持为空。
# 仅填写实际 Nginx/负载均衡地址,不要使用 0.0.0.0/0。
CONFIG_TRUSTED_PROXY=
# 完成身份冲突扫描和人工处置后才能启用手机号、邮箱和微信身份唯一索引。
CONFIG_AUTH_UNIQUE_INDEXES_ENABLED=false
# 仅在首次创建 admin 账号时使用;启动脚本会生成 20 位字母数字随机值并持久化到 .env。
# 已存在 admin 的升级环境不会使用或修改现有管理员密码。
CONFIG_INITIAL_ADMIN_PASSWORD=
CONFIG_TOKEN_SIGN_SK=easyai2025easyai
# ========== 9. 运维与调试 ==========
CONFIG_INSTANCE_ID=2025
# 集群情况下区分不同客户端
#minio
#MINIO_ROOT_USER=minioadmin
#MINIO_ROOT_PASSWORD=minioadmin
CONFIG_PROXY_URL=
# 连接 GPT 等外部服务时设置
LOG_LEVEL=log,error,warn,debug
# WS 网关专用日志级别(默认生产不输出 debug)
WS_GATEWAY_LOG_LEVEL=log,error,warn
DOZZLE_PORT=8080
WATCHTOWER_PORT=8089
PORTAINER_PORT=8090
PORTAINER_HTTPS_PORT=8091
# ========== 10. WS Gateway 集群背板(Redis ==========
# 是否启用 ws-gateway Redis 集群背板:true=支持多节点路由,false=仅单机本地投递
GATEWAY_CLUSTER_REDIS_ENABLED=true
# 当前 ws-gateway 节点 ID;单节点可保持默认,集群部署时每个实例必须唯一
GATEWAY_CLUSTER_NODE_ID=easyai-wsgateway-node-1
# 背板 Pub/Sub 频道名;用于节点间转发消息
GATEWAY_CLUSTER_REDIS_CHANNEL=easyai:wsgateway:cluster
# 路由表 key 前缀(记录 channel+clientId 对应的 nodeId
GATEWAY_CLUSTER_REDIS_ROUTE_PREFIX=easyai:wsgateway:route
# 路由 TTL(秒);连接存活期间会续租,断开后自然过期
GATEWAY_CLUSTER_REDIS_ROUTE_TTL_SEC=120
# Redis 失联后自动重连间隔(毫秒)
GATEWAY_CLUSTER_REDIS_RECONNECT_INTERVAL_MS=5000
# 可选:Redis URL(配置后优先于 host/port/user/password/db
GATEWAY_CLUSTER_REDIS_URL=
# 以下为 URL 未配置时使用的拆分配置
GATEWAY_CLUSTER_REDIS_HOST=redis
GATEWAY_CLUSTER_REDIS_PORT=6379
GATEWAY_CLUSTER_REDIS_USERNAME=
GATEWAY_CLUSTER_REDIS_PASSWORD=
GATEWAY_CLUSTER_REDIS_DB=0
# ========== 11. easyai-server 发布事件到 ws-gatewayTCP ==========
# easyai-server 访问 ws-gateway 的 TCP 地址(容器网络内建议写服务名 ws-gateway)
WS_GATEWAY_TCP_HOST=ws-gateway
# easyai-server 访问 ws-gateway 的 TCP 端口(需与 CONFIG_TCP_PORT 一致)
WS_GATEWAY_TCP_PORT=4002
# 事件发布 Pattern,通常保持默认
WS_GATEWAY_TCP_EVENT_PATTERN=gateway.event.publish
# 发布超时时间(毫秒);超时只记录 ERROR,不中断主进程
WS_GATEWAY_TCP_TIMEOUT_MS=1500
# ========== 12. ws-gateway 转发客户端命令到 easyai-serverTCP ==========
# 拖动画布、协作 presence 等客户端命令由 ws-gateway 转发到 easyai-server 处理
GATEWAY_INBOUND_ENABLED=true
# easyai-server 监听地址;容器内应监听 0.0.0.0,供 ws-gateway 访问
GATEWAY_INBOUND_TCP_LISTEN_HOST=0.0.0.0
# ws-gateway 访问 easyai-server 的 TCP 地址;Docker 部署必须使用服务名,不能用 127.0.0.1
GATEWAY_INBOUND_TCP_HOST=easyai-server
# ws-gateway 到 easyai-server 的命令入口端口,对应主服务 HTTP 端口 3001
GATEWAY_INBOUND_TCP_PORT=4001
GATEWAY_INBOUND_TCP_PATTERN=gateway.inbound.command
GATEWAY_INBOUND_TIMEOUT_MS=5000
# ========== 13. WS 会话鉴权(MCP 风格,可选) ==========
# true=客户端必须鉴权后才能建立可用会话;false=允许匿名会话
WS_AUTH_REQUIRED=false
# 鉴权阶段超时时间(毫秒)
WS_AUTH_TIMEOUT_MS=6000
# 可用鉴权方法(逗号分隔):none/bearer/ws_ticket
WS_AUTH_METHODS=none,bearer
# bearer 令牌列表(逗号分隔,生产环境请使用安全配置中心)
WS_AUTH_BEARER_TOKENS=
# ws_ticket 票据列表(逗号分隔,适合短时授权)
WS_AUTH_WS_TICKETS=
# ========== 14. 静态资源 CDN(可选) ==========
NUXT_APP_CDN_URL=
# ========== 15. 版本 ==========
VERSION=latest
# ========== 16. OSS 配置(可选,PDF 解析图片上传) ==========
# 可填写 .env.tools 或在此覆盖
# OSS_ENDPOINT=
# OSS_ACCESS_KEY_ID=
# OSS_ACCESS_KEY_SECRET=
# OSS_BUCKET=
# OSS_REGION=us-east-1
# OSS_DOMAIN=
@@ -0,0 +1,42 @@
name: Test deployment public URL (Linux)
on:
push:
paths:
- 'start.sh'
- 'update.sh'
- 'docker-compose.yml'
- 'easyai-proxy.conf.sample'
- '.env.sample'
- 'scripts/init-public-api-base-url.sh'
- 'scripts/test-public-api-base-url.sh'
- 'scripts/test-deployment-public-url.sh'
- '.github/workflows/test-deployment-public-url.yml'
pull_request:
paths:
- 'start.sh'
- 'update.sh'
- 'docker-compose.yml'
- 'easyai-proxy.conf.sample'
- '.env.sample'
- 'scripts/init-public-api-base-url.sh'
- 'scripts/test-public-api-base-url.sh'
- 'scripts/test-deployment-public-url.sh'
- '.github/workflows/test-deployment-public-url.yml'
workflow_dispatch:
jobs:
test-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check shell syntax
run: |
bash -n start.sh update.sh scripts/init-public-api-base-url.sh scripts/test-public-api-base-url.sh scripts/test-deployment-public-url.sh
- name: Test migration helper
run: bash scripts/test-public-api-base-url.sh
- name: Test deployment dry-run matrix
run: bash scripts/test-deployment-public-url.sh
+51
View File
@@ -0,0 +1,51 @@
# 在 Windows 上测试 start.ps1DRY_RUN 模式,不启动 Docker
name: Test start.ps1 (Windows)
on:
push:
paths:
- 'start.ps1'
- 'update.ps1'
- 'scripts/Initialize-PublicApiBaseUrl.ps1'
- 'scripts/Test-PublicApiBaseUrl.ps1'
- '.env.sample'
- '.github/workflows/test-start-ps1.yml'
pull_request:
paths:
- 'start.ps1'
- 'update.ps1'
- 'scripts/Initialize-PublicApiBaseUrl.ps1'
- 'scripts/Test-PublicApiBaseUrl.ps1'
- '.env.sample'
- '.github/workflows/test-start-ps1.yml'
workflow_dispatch:
jobs:
test-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Run start.ps1 (DRY_RUN)
env:
DEPLOY_DRY_RUN: "1"
DEPLOY_IP: "192.168.1.100"
run: |
(Get-Content .env.sample) -replace '^SERVER_HTTP_PORT=.*$', 'SERVER_HTTP_PORT=4100' | Set-Content .env.sample -Encoding UTF8
powershell -ExecutionPolicy Bypass -File .\start.ps1
- name: Verify .env
run: |
$api = Select-String -Path .env -Pattern '^NUXT_PUBLIC_BASE_APIURL=' | ForEach-Object { $_.Line }
$socket = Select-String -Path .env -Pattern '^NUXT_PUBLIC_BASE_SOCKETURL=' | ForEach-Object { $_.Line }
$sg = Select-String -Path .env -Pattern '^NUXT_PUBLIC_SG_APIURL=' | ForEach-Object { $_.Line }
$publicApi = Select-String -Path .env -Pattern '^CONFIG_PUBLIC_API_BASE_URL=' | ForEach-Object { $_.Line }
if ($api -ne 'NUXT_PUBLIC_BASE_APIURL=http://192.168.1.100:4100') { exit 1 }
if ($socket -ne 'NUXT_PUBLIC_BASE_SOCKETURL=ws://192.168.1.100:3002') { exit 1 }
if ($sg -ne 'NUXT_PUBLIC_SG_APIURL=http://192.168.1.100:3003') { exit 1 }
if ($publicApi -ne 'CONFIG_PUBLIC_API_BASE_URL=http://192.168.1.100:4100') { exit 1 }
Write-Host "OK: .env 配置正确"
- name: Test public API URL migration helper
run: |
powershell -ExecutionPolicy Bypass -File .\scripts\Test-PublicApiBaseUrl.ps1
+365 -50
View File
@@ -1,13 +1,352 @@
## 一键部署
### Linux/Mac
```bash
git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git && cd easyai && chmod +x start.sh && ./start.sh
```
也可以通过环境变量直接传入部署方式,脚本会跳过对应问答:
```bash
# IP 访问
DEPLOY_ACCESS=ip DEPLOY_IP=服务器IP ./start.sh
# 域名访问,不申请 HTTPS 证书
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=false ./start.sh
# 域名访问,并自动申请 HTTPS 证书
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=true CERTBOT_EMAIL=ops@example.com ./start.sh
```
常用变量:
| 变量 | 说明 |
|------|------|
| `DEPLOY_ACCESS` | `ip``domain`;未传时进入问答选择 |
| `DEPLOY_IP` | IP 模式访问地址,如 `10.0.0.8` |
| `DEPLOY_DOMAIN` | 域名模式访问地址,可带 `http://``https://`,脚本会自动清理 |
| `DEPLOY_HTTPS` | `true/false`,域名模式下是否申请 HTTPS 证书 |
| `DEPLOY_PUBLIC_API_BASE_URL` | 高级覆盖后端公开 API 地址,例如 `https://api.example.com:8443/api` |
| `CERTBOT_EMAIL` | 申请 HTTPS 证书使用的邮箱,默认 `wangbo@51easyai.com` |
| `DEPLOY_FORCE_RECONFIG` | 非空时即使已有 `.env` 也重新生成部署配置 |
| `DEPLOY_DRY_RUN` | `1` 时只生成配置文件,不安装 Docker、不启动服务 |
| `DEPLOY_NON_INTERACTIVE` | `1` 时缺少必要变量会直接报错,不再进入问答 |
| `DEPLOY_MAC_DOCKER_INSTALL` | macOS 未安装 Docker 时使用,`auto` 自动安装或 `manual` 提示手动安装 |
#### 后端公开 API 地址
后端返回文件、预签名上传和异步任务查询地址时,按以下优先级确定公开入口:
1. `.env` 中的 `CONFIG_PUBLIC_API_BASE_URL`
2. 来自可信代理的当前请求地址(仅当请求来源命中 `CONFIG_TRUSTED_PROXY` 时读取转发 Header)。
3. 当前请求的直接 `Host`
启动和升级脚本会在拉取镜像、重建服务之前校验并持久化 `CONFIG_PUBLIC_API_BASE_URL`:新 IP 部署使用 `http://IP:${SERVER_HTTP_PORT}`;域名部署中 `DEPLOY_HTTPS=false` 使用 `http/ws``DEPLOY_HTTPS=true` 使用 `https/wss`API 路径均为 `/api`。旧环境缺少该配置时,会依次从绝对 `NUXT_PUBLIC_BASE_APIURL`、WebSocket origin、首个 `CONFIG_SECURITY_ORIGIN` 推导。已有合法值保持不变,非法或无法推导时脚本会终止并提示修复。
已有外部 TLS、手工代理、自定义公开端口或特殊路径时,可在执行脚本前设置高级覆盖:
```bash
DEPLOY_PUBLIC_API_BASE_URL=https://api.example.com:8443/custom-api ./update.sh
```
也可以直接在 `.env` 中维护:
```dotenv
CONFIG_PUBLIC_API_BASE_URL=https://api.example.com:8443/api
CONFIG_TRUSTED_PROXY=127.0.0.1,10.0.0.0/8
```
新的 `easyai-proxy.conf.sample` 会为 `/api/``/mcp` 转发保留客户端 authority 中的显式端口,并补齐 `X-Forwarded-Host``X-Forwarded-Port``/api/` 继续发送 `X-Original-Prefix: /api`。脚本不会改写已经生成的本地 Nginx 配置,旧部署可先通过显式 `CONFIG_PUBLIC_API_BASE_URL` 生效,再按模板手工同步代理 Header。
`https.sh` 单独执行时也可以跳过域名选择:
```bash
DEPLOY_DOMAIN=demo.example.com EASYAI_PROXY_CONF=demo.example.com.conf CERTBOT_EMAIL=ops@example.com ./https.sh
```
如需对已有 Nginx 配置追加或替换域名,可设置 `EASYAI_PROXY_DOMAIN_ACTION=add|replace`;未设置时默认追加。
### Windows
**PowerShell** 中执行:
```powershell
git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"; Set-Location "easyai"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```
Windows 也支持变量方式跳过选择:
```powershell
# 本机访问
$env:DEPLOY_ACCESS = "local"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
# 局域网访问
$env:DEPLOY_IP = "192.168.1.10"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
# 仅生成配置,不启动 Docker
$env:DEPLOY_IP = "127.0.0.1"
$env:DEPLOY_DRY_RUN = "1"
$env:DEPLOY_NO_WAIT = "1"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```
> **Windows 脚本权限说明**PowerShell 默认禁止运行脚本,直接双击 `start.ps1` 会闪退。
>
> - **推荐**:使用 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"` 执行,无需修改系统策略
> - **或**:以管理员身份打开 PowerShell,执行 `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`,之后可直接运行 `.\start.ps1`
---
🚀部署问题扫码咨询
![img.png](img.png)
## 更新升级
### 老用户(旧版本部署包)升级教程
如果你当前使用的是较早版本的部署包,**无法在旧包目录里直接执行 `git pull`** 来获取配置文件,建议按下面流程升级:
1. 先停止并下线旧项目容器:
```bash
cd ~/easyai
docker compose down
```
如果你的环境使用的是旧命令,请改用:
```bash
docker-compose down
```
2. 将旧目录重命名备份(示例改名为 `easyai2`):
```bash
cd ~
mv easyai easyai2
```
3. 重新执行一键部署命令,按脚本提示完成初始化输入。
如果希望跳过问答,可直接传入变量:
```bash
DEPLOY_ACCESS=ip DEPLOY_IP=服务器IP ./start.sh
# 或
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=false ./start.sh
```
4. 当脚本询问是否启用 HTTPS 时:
- 如果你之前已经有可用证书,填写 `N`(不生成新证书)。
- 只有在需要新申请证书时,才选择启用并生成证书。
5. 完成上述迁移后,后续若有新的配置文件或版本更新,可直接在新的 `easyai` 目录中执行 `./update.sh` 进行更新。
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
- 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL`
- 兼容 `docker compose` 和 `docker-compose` 两种命令格式
- 自动拉取最新镜像并重启服务
### 使用步骤
1. [首次执行,后续无需重复执行]添加执行权限,命令:
```bash
chmod +x update.sh
```
2. 执行更新(默认会 `git pull` 拉取整个仓库)
```bash
./update.sh
```
> **注意**update.sh 需要在 Git 克隆的目录下运行。若通过 zip 下载而非 git clone,请先使用 `git clone` 获取项目。
### 使用方式
- 执行 `./update.sh` 后会**命令行内选择**更新方式:
- `[1]` 更新并拉取仓库(git pull)+ 更新镜像并重启(**默认**,回车即选)
- `[2]` 仅更新镜像并重启(跳过 git pull,适用于有本地修改不想被覆盖的场景)
- 如果本次**不涉及配置文件更新**(如 `.env*`、`docker-compose.yml`、`easyai-proxy.conf*` 无变更),可直接选择 `[2]`,仅更新后台服务镜像并重启即可。
- 自动化更新可通过变量跳过选择:
```bash
# 更新仓库 + 拉取镜像 + 重启(等同默认选 1)
UPDATE_MODE=full ./update.sh
# 仅拉取镜像 + 重启(等同选 2)
UPDATE_MODE=image ./update.sh
# 或使用布尔变量
UPDATE_SKIP_REPO_UPDATE=true ./update.sh
```
- **查看帮助**`./update.sh -h` 或 `./update.sh --help`
### 更新说明
- 脚本会执行 `git pull` 拉取整个仓库最新代码
- 拉取后会检查并补齐缺失的 .env、.env.tools、.env.ASG、.env.AMS(不会覆盖已有文件)
- 最后执行 `docker compose pull` 和 `docker compose up -d` 拉取镜像并重启服务
### Windows 用户(update.ps1
Windows 下使用 `update.ps1`,功能与 Linux 版一致:
```powershell
.\update.ps1
```
- 执行后会**命令行内选择**`[1]` 更新并拉取仓库 + 更新镜像(默认);`[2]` 仅更新镜像
- 需在 Git 克隆的 easyai 目录下运行
- 也可通过变量跳过选择:
```powershell
$env:UPDATE_MODE = "image"
$env:UPDATE_NO_WAIT = "1"
.\update.ps1
```
---
## 重要更新记录:
### 2026.07.16
1. **公开 URL 稳定化**:新增 `CONFIG_PUBLIC_API_BASE_URL`,后端文件、预签名和任务查询地址不再依赖 Redis 中缓存的首次请求 Host。
2. **新装与升级迁移**Linux/macOS 和 Windows 启动、升级脚本会在重建服务前初始化并校验公开 API 地址,支持 IP 自定义 `SERVER_HTTP_PORT`、HTTP/HTTPS 域名和 `DEPLOY_PUBLIC_API_BASE_URL` 高级覆盖。
3. **代理端口透传**:新 Nginx 模板为 `/api/`、`/mcp` 补齐公开 Host/Port Header;已有生成配置不会自动改写。
### 2026.07.15
1. **主服务 Node.js 堆内存上限**`easyai-server` 默认通过 `NODE_OPTIONS` 将 V8 old-space 堆上限设置为 8192 MiB,可在 `.env` 中使用 `NODE_MAX_OLD_SPACE_SIZE` 覆盖。该值只限制 V8 堆,不包含 Buffer、sharp/libvips 等 native 内存。
2. **生效要求**:该参数仅在创建 Node.js 进程时读取,更新部署文件后需要在合适的维护窗口重建 `easyai-server` 容器。
### 2026.06.24
1. **主服务 sharp/native 内存护栏**`easyai-server` 默认关闭 sharp/libvips cache,并将 sharp 并发限制为 1;同时设置 `MALLOC_ARENA_MAX=2`、`MALLOC_TRIM_THRESHOLD_=131072`、`MALLOC_MMAP_THRESHOLD_=131072`、`MALLOC_TOP_PAD_=131072`,降低图片处理和大 Buffer 场景下 native RSS 持续膨胀风险。
2. **重 native 操作隔离**:遮罩合并、alpha 反转等 sharp/raw 操作改为一次性子进程执行,处理完成后由 OS 回收子进程 native heap,避免主服务 `[heap]` 被撑大。
3. **生图 base64 输出收敛**:模型返回的 base64 图片会先上传到 OSS,返回对象和 raw/debug 响应中不再长期保留大 base64 字符串。
4. **重启要求**:该内存护栏必须在新进程启动时生效,更新后需执行 `docker compose up -d easyai-server` 或重建并重启主服务容器。
### 2026.06.22
1. **Redis 资源护栏**Redis 增加 `./data/redis:/data` 持久化挂载、`maxmemory 2gb`、`appendfsync everysec`,并禁用 RDB 快照,避免队列历史膨胀后占满内存和容器写层。
2. **Mongo/主服务内存限制**Mongo 增加 `--wiredTigerCacheSizeGB 1.5` 和容器内存限制,`easyai-server` 增加内存限制,避免 8G 机器上 Redis/Mongo/Node 相互挤压触发 OOM。
3. **升级前置要求**:如果线上 Redis 之前没有 `/data` volume,先完成 Redis request-limit 队列历史清理,再迁移 Redis 数据目录,最后重建 Redis 容器。
#### Redis 数据迁移与重启步骤
执行前建议先做云盘快照或外部备份。磁盘空间紧张时,不要直接在当前机器上 `BGSAVE` 或复制大体积 Redis 文件。
```bash
cd ~/easyai
# 1. 先通过后台 ModelRuntime 运维接口或后端启动清理逻辑清理 request-limit 历史:
# - trim bull:request_limit_*:events 到小长度
# - 只清 completed/failed,不删除 waiting/active/delayed
# 2. 确认 Redis 内存已经下降后,再准备新的持久化目录
mkdir -p ./data/redis
# 3. 如果旧 Redis 数据在容器写层,先按实际容器内 /data 内容迁移到宿主机目录
docker cp redis:/data/. ./data/redis/
# 4. 更新配置后重建关键容器
docker compose up -d redis mongo easyai-server
```
迁移后验收建议:Redis `used_memory_human < 2G``bull:request_limit_*:events` 长度维持在小窗口,24 小时内无 OOM。
### 2026.3.20
1. **新增 Agent 记忆服务(AMS)模块**:新增 `agent-memory`(Agent 长期记忆)容器,复用 `easyai-pgvector` 数据库(需 pgvector 扩展),用于支持 Agent 对话记忆、向量召回与反馈能力。
2. **新增环境变量文件**:新增 `.env.AMS.sample` 文件,包含记忆服务所需的全部环境变量。
3. 主服务 `easyai-server` 新增 `MEMORY_TCP_HOST` 和 `MEMORY_TCP_PORT` 环境变量,用于内部 TCP 微服务通信。
4. **Nginx 代理**`easyai-proxy.conf.sample` 中新增 `/ams-api/` 路径代理(可选)。
#### 升级步骤
**步骤一:更新文件**
将以下文件更新到最新版本:
- `docker-compose.yml`
- `easyai-proxy.conf.sample`
- `docker/postgres/init-pgvector.sql`PostgreSQL 首次启动时创建 vector 扩展)
新增文件复制到部署目录:
- `.env.AMS.sample` → 复制为 `.env.AMS` 并根据实际环境修改
**步骤二:配置 `.env.AMS`**
```bash
cp .env.AMS.sample .env.AMS
```
根据实际环境修改 `.env.AMS` 中的关键配置:
```dotenv
# PostgreSQL 连接(独立库 easyai_memory,与 .env.ASG 中账号密码一致)
MEMORY_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/easyai_memory?schema=public
# 主服务 API 地址(用于 Embedding 与对话压缩)
MEMORY_AI_BASE_URL=http://easyai-server:3001
# Embedding 模型与维度(需与主服务一致)
MEMORY_EMBEDDING_DIMENSION=1024
MEMORY_EMBEDDING_MODEL=text-embedding-v4
# 每日记忆整理(默认每天凌晨 2 点执行)
MEMORY_MAINTENANCE_ENABLED=true
# 升级首日建议 true,确认无误后再改为 false
MEMORY_MAINTENANCE_DRY_RUN=false
# 单轮每租户处理上限
MEMORY_MAINTENANCE_BATCH_SIZE=200
# 启用语义冲突裁决(复用会话总结模型,无需单独配置模型)
MEMORY_MAINTENANCE_LLM_ENABLED=true
MEMORY_MAINTENANCE_LLM_MAX_PAIRS=20
```
说明:
- `MEMORY_MAINTENANCE_ENABLED`:总开关,`false` 时不执行每日整理。
- `MEMORY_MAINTENANCE_DRY_RUN``true` 仅记录分析和审计,不执行软删除。
- `MEMORY_MAINTENANCE_LLM_ENABLED`:开启后冲突处理优先使用会话总结模型做语义裁决,失败会回退规则处理。
- `MEMORY_MAINTENANCE_LLM_MAX_PAIRS`:单轮最多调用模型裁决的冲突对数,用于控制成本。
**步骤三:启动服务**
记忆服务使用 Docker Compose Profile,需显式启用:
```bash
cd ~/easyai
# 启动全部服务(含记忆服务)
docker compose --profile memory up -d
# 或仅启动基础服务(不含记忆)
docker compose up -d
```
新增容器:`agent-memory`Agent 记忆服务),复用 `easyai-pgvector`,使用独立库 `easyai_memory`。初始化流程与 ASG 一致:
- **PostgreSQL 首次启动**`docker/postgres/init-pgvector.sql` 创建 `vector`、`pgcrypto` 扩展
- **easyai-asg 启动**entrypoint 执行 `prisma migrate deploy`,创建治理相关表
- **agent-memory 启动**entrypoint 执行 `prisma migrate deploy`,创建 `memory_records` 等表
**注意**`easyai-pgvector` 使用 `registry.cn-shanghai.aliyuncs.com/easyaigc/pgvector:0.8.2-pg18-trixie`(含 pgvector 扩展)。若此前使用其他镜像且已有数据,升级前请备份 `asg_postgres_data` 卷。
**步骤四:验证**
```bash
# 检查容器状态
docker compose ps agent-memory
# 检查记忆服务健康状态
curl http://127.0.0.1:3004/health
# 通过 Nginx 代理访问(配置 Nginx 后)
curl https://<你的域名>/ams-api/health
```
> **注意**:记忆服务默认不启动,使用 `docker compose --profile memory up -d` 显式启用。若不需要 Agent 记忆功能,保持 `docker compose up -d` 即可,主服务会正常运行(记忆相关能力不可用)。
---
### 2026.3.2
1. **新增 Agent 服务治理(ASG)模块**:新增 `easyai-asg`Agent 服务治理)容器和独立的 `easyai-asg-pg`PostgreSQL 18)数据库容器,用于支持 Agent 自动化治理能力。
1. **新增 Agent 服务治理(ASG)模块**:新增 `easyai-asg`(Agent 服务治理)容器和独立的 `easyai-pgvector`PostgreSQL 18)数据库容器,用于支持 Agent 自动化治理能力。
2. **新增 Nginx 反向代理**:在 `easyai-proxy.conf.sample` 中新增 `/asg-api/` 路径代理,用于暴露 ASG 服务的 REST API。
3. **新增环境变量文件**:新增 `.env.ASG.sample` 文件,包含 ASG 服务所需的全部环境变量。
4. 主服务 `comfy-server` 新增 `ASG_TCP_HOST``ASG_TCP_PORT` 环境变量,用于内部 TCP 微服务通信。
4. 主服务 `easyai-server` 新增 `ASG_TCP_HOST` 和 `ASG_TCP_PORT` 环境变量,用于内部 TCP 微服务通信。
5. **前端新增环境变量**`.env` 中新增 `NUXT_PUBLIC_SG_APIURL`,用于前端治理管理页面调用 ASG API。
#### 升级步骤
@@ -41,7 +380,7 @@ cp .env.ASG.sample .env.ASG
根据实际环境修改 `.env.ASG` 中的关键配置:
```dotenv
# PostgreSQL 连接(默认使用容器内网地址,一般无需修改)
ASG_DATABASE_URL=postgresql://easyai:easyai2025@easyai-asg-pg:5432/agent_governance?schema=public
ASG_DATABASE_URL=postgresql://easyai:easyai2025@easyai-pgvector:5432/agent_governance?schema=public
ASG_POSTGRES_USER=easyai
ASG_POSTGRES_PASSWORD=easyai2025
@@ -50,7 +389,7 @@ ASG_REDIS_HOST=redis
ASG_REDIS_PASSWORD= # 与主服务 Redis 密码保持一致
# 主服务连接地址(容器内网地址)
ASG_MAIN_BACKEND_URL=http://comfy-server:3001
ASG_MAIN_BACKEND_URL=http://easyai-server:3001
# 管理员账号(用于 Agent 调用时登录获取 token
ASG_ADMIN_USERNAME=admin
ASG_ADMIN_PASSWORD=123456
@@ -85,13 +424,13 @@ cd ~/easyai
docker compose up -d
```
新增容器:`easyai-asg-pg`PostgreSQL)和 `easyai-asg`(Agent 服务治理),数据库会在首次启动时自动完成初始化和迁移。
新增容器:`easyai-pgvector`PostgreSQL)和 `easyai-asg`(Agent 服务治理),数据库会在首次启动时自动完成初始化和迁移。
**步骤五:验证**
```bash
# 检查容器状态
docker compose ps easyai-asg-pg easyai-asg
docker compose ps easyai-pgvector easyai-asg
# 检查 ASG 服务健康状态
curl http://127.0.0.1:3003/health
@@ -226,7 +565,7 @@ start.sh 脚本用于服务器一键安装启动EasyAI应用
### 首次安装部署步骤,并使用服务器公网IP进行访问
1. 克隆脚本和相关文件到服务器
```bash
git clone https://git.51easyai.com/wangbo/easyai.git
git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git
# 进入easyai目录
cd easyai
```
@@ -247,6 +586,25 @@ chmod +x start.sh
```
6. 脚本运行完成无错误,并且提示`EasyAI应用启动成功`表示应用启动成功,打开浏览器输入服务器的公网ip:3010或者局域网IP:3010,即可访问EasyAI应用
## Windows 一键启动
**不要双击** `start.ps1`(易闪退)。在 **PowerShell** 中执行:
```powershell
git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"; Set-Location "easyai"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```
已在 `easyai` 目录内时:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```
> **Windows 脚本权限说明**PowerShell 默认禁止运行脚本,直接双击 `start.ps1` 会闪退。
>
> - **推荐**:使用 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"` 执行,无需修改系统策略
> - **或**:以管理员身份打开 PowerShell,执行 `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`,之后可直接运行 `.\start.ps1`
### 启用HTTPS
1. [更改为你的域名]修改`easyai-proxy.conf`中域名`51easyai.com`为你的域名[可以使用Ctrl+F批量替换51easyai.com为你的域名]
2. [修改.env文件]修改如下两个环境变量为如下的对应的值
@@ -267,49 +625,6 @@ chmod +x https.sh
如果需要启用PDF图文解析Markdown功能,需要`env.tools`中的OSS环境变量,具体参考文件中的配置说明:自动解析PDF图文,将图片文件上传到OSS,并解析成Markdown格式,并返回
### 更新升级
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- 自动检查和更新 `docker-compose.yml` 文件(从远程仓库获取最新版本)
- 兼容 `docker compose` 和 `docker-compose` 两种命令格式
- 自动拉取最新镜像并重启服务
#### 使用步骤
1. [首次执行,后续无需重复执行]添加执行权限,命令:
```bash
chmod +x update.sh
```
2. 执行更新(默认会检查并更新 docker-compose.yml
```bash
./update.sh
```
#### 参数选项
- **跳过 docker-compose.yml 更新**:如果你已经手动修改了 `docker-compose.yml` 文件,可以使用 `-s` 或 `--skip-compose-update` 参数跳过更新
```bash
# 跳过 docker-compose.yml 更新,仅更新容器镜像
./update.sh -s
# 或
./update.sh --skip-compose-update
```
- **查看帮助信息**
```bash
./update.sh -h
# 或
./update.sh --help
```
#### 更新说明
- 脚本会自动从远程仓库下载最新的 `docker-compose.yml` 文件
- 如果本地文件与远程文件不同,会将原文件备份为 `docker-compose.yml.bak`
- 如果本地文件已是最新版本,则跳过更新
- 更新完成后会自动执行 `docker-compose pull` 和 `docker-compose up -d` 来重启服务
## 常见问题
1. 某个服务无法运行
+43
View File
@@ -0,0 +1,43 @@
## 部署配置,通过问答让用户选择
1. 通过IP地址还是通过域名访问?
2. (如果通过IP地址访问),输入服务器IP,并保证3001,3002,3003三个端口已经开放
3. 如果通过域名访问输入域名,不含https://的前缀,例如:51easyai.com
3.1 是否启用https访问
4. 对于IP地址访问的情形,复制`.env.sample``.env`,并将NUXT_PUBLIC_BASE_APIURL、NUXT_PUBLIC_BASE_SOCKETURL、NUXT_PUBLIC_SG_APIURL三个分别进行如下设置
```bash
NUXT_PUBLIC_BASE_APIURL=http://<用户输入的IP地址>:3001
NUXT_PUBLIC_BASE_SOCKETURL=ws://<用户输入的IP地址>:3002
NUXT_PUBLIC_SG_APIURL=http://<用户输入的IP地址>:3003
```
5. 对于使用域名的情况下情况,复制`.env.sampla``.env`,将上述3个变量设置为:
```bash
NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=wss://<用户输入的域名>/socket.io
NUXT_PUBLIC_SG_APIURL=/asg-api
```
6. 复制 `.env.tools.sample``.env.tools`,复制`.env.ASG.sample``.env.ASG.sample`
7. 对于使用域名的情况下,将`easyai-proxy.conf.sample`复制为`easyai-proxy.conf`,并将文件名修改为`用户输入的域名.conf`,并将`51easyai.com`替换为用户的域名
8. 执行原来的start脚本内容,包括安装docker安装和部署
9. 如果启用https访问,还要同步执行原来的https脚本
## 平台要求,需要兼容主流的linux云平台
## 部署要求
直接通过一个命令访问,包含自动从https://git.51easyai.com/wangbo/easyai克隆项目
```
bash -c https://git.51easyai.com/wangbo/easyai/src/branch/main/start.sh
```
Binary file not shown.
+130 -45
View File
@@ -2,8 +2,8 @@
# (各项参数均有详细的说明,理论情况下保持默认即可运行)
#version: '3'
services:
comfyAI-web:
container_name: comfyAI-web
easyai-web:
container_name: easyai-web
#更新修改冒号后面的版本号
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${VERSION}
#端口冲突时,只需要修改前面的端口比如修改为,3011:3010
@@ -11,15 +11,12 @@ services:
- "com.centurylinklabs.watchtower.enable=true"
ports:
- "${WEB_PORT}:3010"
read_only: true
volumes:
# 这一行是关键。它将一个名为 './data/forend/.pm2' 的持久化卷挂载到容器内的 /app/.pm2 目录
- ./data/forend/.pm2:/app/.pm2
# read_only: true
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.8
depends_on:
- comfy-server
- easyai-server
restart: unless-stopped
environment:
# 默认服务器地址,本地不需要更改,云服务需要修改为云端IP,并放行对应端口
@@ -46,17 +43,17 @@ services:
memory: 1500MB
reservations:
memory: 600MB
comfy-server:
container_name: comfy-server
easyai-server:
container_name: easyai-server
# 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${VERSION} # 阿里云
labels:
- "com.centurylinklabs.watchtower.enable=true"
ports:
- "${SERVER_HTTP_PORT}:3001" #http端口
read_only: true
# read_only: true
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.6
depends_on:
- mongo
@@ -68,14 +65,21 @@ services:
- ./data/restores:/app/restores
- ./data/backend/app/tmp:/app/tmp
- ./data/tmp:/tmp
- ./data/backend/.pm2:/app/.pm2
# - ./data/backend/pm2.config.js:/app/pm2.config.js
restart: unless-stopped
environment:
# Node.js V8 old-space 堆上限(MiB),可在 .env 中覆盖
- NODE_OPTIONS=--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-8192}
# MongoDB配置,用户管理,不会配置的话保持默认即可
- CONFIG_DB_MONGO_URI=mongodb://172.21.0.3:27017
- CONFIG_DB_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- CONFIG_DB_MONGO_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
- MALLOC_ARENA_MAX=${MALLOC_ARENA_MAX:-2}
- MALLOC_TRIM_THRESHOLD_=${MALLOC_TRIM_THRESHOLD_:-131072}
- MALLOC_MMAP_THRESHOLD_=${MALLOC_MMAP_THRESHOLD_:-131072}
- MALLOC_TOP_PAD_=${MALLOC_TOP_PAD_:-131072}
- SHARP_CACHE_ENABLED=${SHARP_CACHE_ENABLED:-false}
- SHARP_CONCURRENCY=${SHARP_CONCURRENCY:-1}
# Redis配置,队列缓存,不会配置的话保持默认即可
- CONFIG_COMFYUI_QUENE_REDIS_HOST=172.21.0.4
- CONFIG_COMFYUI_QUENE_REDIS_PORT=6379
@@ -102,15 +106,31 @@ services:
- CONFIG_MQ_PORT=${CONFIG_MQ_PORT}
- CONFIG_MQ_VHOST=${CONFIG_MQ_VHOST}
# 视频编辑接口
- CONFIG_VIDEO_EDIT_API_URL=http://video-edit:8000
- CONFIG_VIDEO_EDIT_API_URL=${CONFIG_VIDEO_EDIT_API_URL:-http://video-edit:8000}
# 沙箱环境baseURL
- SANDBOX_SERVICE_BASE_URL=${SANDBOX_SERVICE_BASE_URL:-http://sandbox:8000}
# 服务治理 TCP 连接
- ASG_TCP_HOST=easyai-asg
- ASG_TCP_PORT=4003
# ws-gateway 转发客户端命令到 easyai-server(画布协作等),对应主服务 3001
- GATEWAY_INBOUND_ENABLED=${GATEWAY_INBOUND_ENABLED:-true}
- GATEWAY_INBOUND_TCP_LISTEN_HOST=${GATEWAY_INBOUND_TCP_LISTEN_HOST:-0.0.0.0}
- GATEWAY_INBOUND_TCP_PORT=${GATEWAY_INBOUND_TCP_PORT:-4001}
# Agent 记忆服务 TCP 连接(来自 .env)
- MEMORY_TCP_HOST=${MEMORY_TCP_HOST:-agent-memory}
- MEMORY_TCP_PORT=${MEMORY_TCP_PORT:-4004}
# easyai-server 发布事件到 ws-gateway(默认走容器内网服务名)
- WS_GATEWAY_TCP_HOST=${WS_GATEWAY_TCP_HOST:-ws-gateway}
- WS_GATEWAY_TCP_PORT=${WS_GATEWAY_TCP_PORT:-4002}
# 日志大小设置,避免日志文件过大
env_file:
- .env
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3001/health', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
@@ -123,11 +143,12 @@ services:
labels:
- "com.centurylinklabs.watchtower.enable=true"
ports:
- "${SERVER_WS_PORT}:3002" #http端口
- "${CONFIG_WS_PORT:-3002}:3002" # ws-gateway WebSocket 对外端口映射
read_only: true
networks:
- comfyai
- easyai
depends_on:
- easyai-server
- redis
- rabbitmq
volumes:
@@ -140,11 +161,18 @@ services:
- CONFIG_COMFYUI_QUENE_REDIS_PASSWORD=
#日志与调试
- LOG_LEVEL=${LOG_LEVEL}
- WS_GATEWAY_LOG_LEVEL=${WS_GATEWAY_LOG_LEVEL}
#MQ
- CONFIG_MQ_USER=${CONFIG_MQ_USER}
- CONFIG_MQ_PASSWORD=${CONFIG_MQ_PASSWORD}
- CONFIG_MQ_HOST=rabbitmq
- CONFIG_MQ_PORT=${CONFIG_MQ_PORT}
# 客户端命令转发到 easyai-server 入站 TCP;容器内不能使用 127.0.0.1
- GATEWAY_INBOUND_ENABLED=${GATEWAY_INBOUND_ENABLED:-true}
- GATEWAY_INBOUND_TCP_HOST=${GATEWAY_INBOUND_TCP_HOST:-easyai-server}
- GATEWAY_INBOUND_TCP_PORT=${GATEWAY_INBOUND_TCP_PORT:-4001}
- GATEWAY_INBOUND_TCP_PATTERN=${GATEWAY_INBOUND_TCP_PATTERN:-gateway.inbound.command}
- GATEWAY_INBOUND_TIMEOUT_MS=${GATEWAY_INBOUND_TIMEOUT_MS:-5000}
# 日志大小设置,避免日志文件过大
env_file:
- .env
@@ -154,8 +182,8 @@ services:
max-size: "100m"
max-file: "10"
mongo:
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest
# image: registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:4.4
# 镜像见 .env 中 MONGO_IMAGE4.4 备选地址与用途见 .env.sample 注释
image: ${MONGO_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest}
container_name: mongo
restart: unless-stopped
privileged: true
@@ -163,12 +191,14 @@ services:
ports:
- ${MONGO_PORT+${MONGO_PORT}:27017}
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.3
environment:
# 这里的配置只有首次运行生效。修改后,重启镜像是不会生效的。需要把持久化数据删除再重启,才有效果
- MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
command: [ "mongod", "--wiredTigerCacheSizeGB", "1.5" ]
mem_limit: 2500m
volumes:
# - /usr/local/mongo/data:/data/db
- mongo_data:/data/db # 使用docker volume存储数据,提高性能,避免兼容性问题
@@ -185,17 +215,20 @@ services:
max-size: "100m"
max-file: "10"
redis:
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest
image: ${REDIS_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/redis-aliyun:latest}
container_name: redis
restart: always
volumes:
- ./redis.conf:/etc/redis/redis.conf # 挂载本地 redis.conf
- ./data/redis:/data # 持久化 Redis AOF/RDB,避免落到容器写层
command: [ "redis-server", "/etc/redis/redis.conf" ] # 让 Redis 读取配置文件
mem_limit: 2g
memswap_limit: 2g
# ports:
# - ${REDIS_PORT+${REDIS_PORT}:6379}
read_only: true
# read_only: true
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.4
# 日志大小设置,避免日志文件过大
logging:
@@ -204,8 +237,10 @@ services:
max-size: "100m"
max-file: "10"
rabbitmq:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest #阿里云镜像加速
# image: rabbitmq:4-management #官方原版镜像
# 镜像地址见 .env 中 RABBITMQ_IMAGE(备选示例见 .env.sample
image: ${RABBITMQ_IMAGE:-registry.cn-shanghai.aliyuncs.com/easyaigc/mq:latest}
labels:
- "com.centurylinklabs.watchtower.enable=true"
container_name: rabbitmq
restart: unless-stopped
environment:
@@ -217,7 +252,7 @@ services:
volumes:
- rabbitmq_data:/var/lib/rabbitmq # 持久化数据
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.12
watchtower:
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest
@@ -233,7 +268,7 @@ services:
# - "${WATCHTOWER_PORT}:8080"
read_only: true
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.9
# 日志大小设置,避免日志文件过大
logging:
@@ -244,25 +279,36 @@ services:
video-edit:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/videoedit:latest
container_name: video-edit
platform: linux/amd64
labels:
- "com.centurylinklabs.watchtower.enable=true"
- 'com.centurylinklabs.watchtower.enable=true'
volumes:
- ./data/videoedit/temp:/app/temp
ports:
- "${VIDEO_EDIT_PORT}:8000"
- '${VIDEO_EDIT_PORT}:8000'
env_file:
- .env.tools
environment:
- PYTHONUNBUFFERED=1
- OMP_NUM_THREADS=4
- OPENBLAS_NUM_THREADS=4
env_file:
- .env.tools
shm_size: 2g
ipc: host
shm_size: 3g
cap_add:
- SYS_ADMIN
init: true
restart: unless-stopped
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.10
healthcheck:
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" ]
test:
[
'CMD',
'python',
'-c',
"import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')",
]
interval: 30s
timeout: 10s
retries: 3
@@ -278,12 +324,18 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
ports:
- 8080:8080
- "${DOZZLE_PORT:-8080}:8080"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
sandbox:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/sandbox:latest
container_name: sandbox
networks:
- comfyai
- easyai
#沙箱环境默认不对外暴露
ports:
# - "${SANDBOX_PORT}:8000"
@@ -330,18 +382,21 @@ services:
timeout: 10s
retries: 3
start_period: 10s
easyai-asg-pg:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/postgres:18-alpine
container_name: easyai-asg-pg
easyai-pgvector:
# 使用带 pgvector 的镜像,供 ASG 与 Agent 记忆服务共用
image: registry.cn-shanghai.aliyuncs.com/easyaigc/pgvector:0.8.2-pg18-trixie
container_name: easyai-pgvector
restart: unless-stopped
environment:
POSTGRES_USER: ${ASG_POSTGRES_USER:-easyai}
POSTGRES_PASSWORD: ${ASG_POSTGRES_PASSWORD:-easyai2025}
POSTGRES_DB: ${ASG_POSTGRES_DB:-agent_governance}
volumes:
- asg_postgres_data:/var/lib/postgresql/data
- asg_postgres_data:/var/lib/postgresql
# 首次启动时执行,创建 pgvector 扩展供记忆服务使用
- ./docker/postgres/init-pgvector.sql:/docker-entrypoint-initdb.d/02-init-pgvector.sql
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.13
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${ASG_POSTGRES_USER:-easyai} -d ${ASG_POSTGRES_DB:-agent_governance}"]
@@ -362,10 +417,10 @@ services:
ports:
- "${ASG_PORT:-3003}:3003"
networks:
comfyai:
easyai:
ipv4_address: 172.21.0.14
depends_on:
easyai-asg-pg:
easyai-pgvector:
condition: service_healthy
redis:
condition: service_started
@@ -385,6 +440,38 @@ services:
memory: 512MB
reservations:
memory: 128MB
agent-memory:
container_name: agent-memory
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:${AMS_VERSION:-latest}
labels:
- "com.centurylinklabs.watchtower.enable=true"
ports:
- "${AMS_PORT:-3004}:3004"
environment:
- MEMORY_DATABASE_URL=${MEMORY_DATABASE_URL:-postgresql://easyai:easyai2025@easyai-pgvector:5432/easyai_memory}
networks:
easyai:
ipv4_address: 172.21.0.16
depends_on:
easyai-pgvector:
condition: service_healthy
easyai-server:
condition: service_started
restart: unless-stopped
env_file:
- .env.AMS
- .env.ASG
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "10"
deploy:
resources:
limits:
memory: 512MB
reservations:
memory: 128MB
# portainer:
# image: registry.cn-shanghai.aliyuncs.com/comfy-ai/portainer-ce:2.21.5
# container_name: portainer
@@ -430,11 +517,9 @@ volumes:
rabbitmq_data:
asg_postgres_data:
networks:
comfyai:
easyai:
driver: bridge
ipam:
config:
- subnet: 172.21.0.0/16
gateway: 172.21.0.1
+14
View File
@@ -0,0 +1,14 @@
-- 首次启动时执行,与 ASG 共用同一 PostgreSQL 实例
-- 1. 为 agent_governance 启用 pgvector(兼容共库场景)
\c agent_governance
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 2. 创建记忆服务独立数据库 easyai_memory
CREATE DATABASE easyai_memory;
GRANT ALL PRIVILEGES ON DATABASE easyai_memory TO easyai;
-- 3. 为 easyai_memory 启用 pgvector
\c easyai_memory
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
+33
View File
@@ -0,0 +1,33 @@
# EasyAI 部署脚本验证环境
# 在 Docker 容器内运行 start.sh,通过挂载 Docker Socket 使用宿主机 Docker 启动服务
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
gnupg \
lsb-release \
git \
sudo \
&& rm -rf /var/lib/apt/lists/*
# 安装 Docker CLI(使用宿主机 Docker 守护进程)
RUN install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
# 创建工作目录
WORKDIR /workspace/easyai
# 复制项目文件(运行时通过 volume 挂载覆盖)
COPY . /workspace/easyai/
# 允许以 root 运行(容器内通常为 root)
ENV DEPLOY_ACCESS=ip
ENV DEPLOY_IP=127.0.0.1
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# 快速验证:仅测试配置生成,不拉取镜像、不启动服务
# 适合 CI 或快速检查脚本逻辑
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_ROOT"
echo "================================"
echo " EasyAI 部署脚本快速验证"
echo " (仅配置,不启动 Docker)"
echo "================================"
# 使用管道模拟用户输入:1=IP模式, 127.0.0.1=IP地址
printf '1\n127.0.0.1\n' | DEPLOY_DRY_RUN=1 DEPLOY_FORCE_RECONFIG=1 ./start.sh
echo ""
echo "✅ 配置验证通过,检查生成的文件:"
ls -la .env .env.tools .env.ASG .env.AMS 2>/dev/null || true
echo ""
grep -E "NUXT_PUBLIC_(BASE_APIURL|BASE_SOCKETURL|SG_APIURL)" .env 2>/dev/null | head -3
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# 在 Docker 容器中验证 EasyAI 部署脚本
# 用法: 在 easyai 项目根目录执行 ./docker/verify/run-verify.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_ROOT"
echo "================================"
echo " EasyAI 部署脚本 Docker 验证"
echo "================================"
echo "项目目录: $PROJECT_ROOT"
echo ""
# 构建验证镜像
echo "📦 构建验证镜像..."
docker build -f docker/verify/Dockerfile -t easyai-deploy-verify:latest .
echo ""
echo "🚀 运行部署脚本(非交互模式,挂载 Docker Socket..."
echo " 使用 DEPLOY_ACCESS=ip DEPLOY_IP=127.0.0.1"
echo ""
# 挂载 Docker Socket,使容器内 docker 命令使用宿主机 Docker
# 挂载项目目录,使用本地文件(避免容器内复制过时)
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PROJECT_ROOT:/workspace/easyai" \
-w /workspace/easyai \
-e DEPLOY_ACCESS=ip \
-e DEPLOY_IP=127.0.0.1 \
-e DEPLOY_FORCE_RECONFIG=1 \
easyai-deploy-verify:latest \
bash -c './start.sh'
EXIT_CODE=$?
echo ""
if [ $EXIT_CODE -eq 0 ]; then
echo "✅ 部署脚本验证通过"
else
echo "❌ 部署脚本验证失败 (exit code: $EXIT_CODE)"
fi
exit $EXIT_CODE
+212
View File
@@ -0,0 +1,212 @@
# EasyAI Windows 一键部署方案
## 1. 背景与目标
`start.sh` 是 Linux 下的一键部署脚本,本方案为 Windows 平台提供等效的 `start.ps1` PowerShell 脚本。针对 Windows 典型场景做如下**精简**
- **仅 IP 访问**:不含域名模式与 HTTPS
- **本地访问无需放行端口**:选择本地 (127.0.0.1) 时,不涉及防火墙配置
- **Docker 未安装时**:用户可选择手动或自动安装 **Docker Desktop for Windows**winget/Chocolatey
- **支持变量非交互部署**:通过 `DEPLOY_IP``DEPLOY_ACCESS=local` 可跳过访问方式选择
## 2. Linux start.sh 流程梳理
### 2.1 整体流程
| 步骤 | 功能 | 说明 |
|------|------|------|
| 1 | 项目初始化 | 校验当前目录下存在 `docker-compose.yml` |
| 2 | 部署配置问答 | IP 或域名二选一,并采集对应参数 |
| 3 | 配置文件生成 | 生成/更新 `.env``.env.tools``.env.ASG` |
| 4 | Docker 安装与检查 | 检测并安装 DockerUbuntu/CentOS |
| 5 | 启动服务 | `docker compose pull && docker compose up -d` |
| 6 | HTTPS 配置(可选) | 域名模式下执行 `https.sh` |
### 2.2 配置问答逻辑
- **IP 模式**:输入服务器 IP 地址,需要放行 3001、3002、3003 端口
- **域名模式**:输入域名,可选是否启用 HTTPS,需放行 80、443 端口
### 2.3 环境变量写入 .env
- `NUXT_PUBLIC_BASE_APIURL`API 地址
- `NUXT_PUBLIC_BASE_SOCKETURL`WebSocket 地址
- `NUXT_PUBLIC_SG_APIURL`Agent 服务治理 API 地址
**IP 模式**
```
NUXT_PUBLIC_BASE_APIURL=http://<IP>:3001
NUXT_PUBLIC_BASE_SOCKETURL=ws://<IP>:3002
NUXT_PUBLIC_SG_APIURL=http://<IP>:3003
```
**域名模式**
```
NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=wss://<domain>/socket.io
NUXT_PUBLIC_SG_APIURL=/asg-api
```
---
## 3. Windows 与 Linux 差异
| 项目 | Linux | Windows |
|------|-------|---------|
| 脚本语言 | Bash | PowerShell |
| 文本替换 | sed | `(Get-Content) -replace``Set-Content` |
| 用户输入 | `read -p` | `Read-Host` |
| 访问方式 | IP + 域名 + HTTPS | **仅 IP**(本地 / 局域网) |
| 端口放行 | 本地无特殊说明 | **本地访问无需放行**,局域网需放行 3001/3002/3003 |
| Docker | apt/yum 安装 Linux Docker | **Docker Desktop for Windows**,未安装时可选择手动或自动安装 |
---
## 4. Windows IP 访问方式设计(核心差异)
Windows 版**仅支持 IP 访问**,不包含域名模式与 HTTPS 配置。用户访问方式分为两类:
| 选项 | 访问方式 | IP 值 | 端口说明 |
|------|----------|-------|----------|
| 1 | 本地访问 | `127.0.0.1` | 本机访问,**无需放行端口** |
| 2 | 局域网访问 | 用户输入本机局域网 IP | 同网段设备访问,需放行 3001、3002、3003 |
**交互流程:**
1. `[1] 本地访问` → 自动使用 `127.0.0.1`,无需配置防火墙
2. `[2] 局域网访问` → 提示用户输入局域网 IP(可提示 `ipconfig` 查看),并提醒放行上述端口
---
## 5. 实现方案
### 5.1 脚本文件
- 文件路径:`easyai/start.ps1`
- 一行命令示例:
```powershell
git clone https://git.51easyai.com/wangbo/easyai; cd easyai; .\start.ps1
```
### 5.2 模块划分
| 模块 | 函数/区块 | 功能 |
|------|-----------|------|
| 项目初始化 | `Init-ProjectDir` | 检查 `docker-compose.yml`,切换到项目根目录 |
| 配置问答 | `Run-DeployQuestions` | 本地访问 / 局域网访问选择(含 IP 输入) |
| 环境变量 | `PromptOrEnv` | 支持环境变量覆盖,用于 CI/自动化 |
| 配置文件 | `Setup-EnvFiles` | 复制 sample 并修改 `.env`、`.env.tools`、`.env.ASG` |
| Docker 检查 | `Test-Docker` | 检测 `docker`,未安装则让用户选择**手动安装**或**自动安装** |
| 启动服务 | `Start-Services` | `docker compose pull` 与 `docker compose up -d` |
| 主流程 | `Main` | 串联上述步骤 |
### 5.3 环境变量支持(非交互模式)
| 变量 | 说明 |
|------|------|
| `DEPLOY_ACCESS` | `local` 时直接使用 `127.0.0.1``lan`/`ip` 需要同时传 `DEPLOY_IP` |
| `DEPLOY_IP` | 访问 IP(本地填 `127.0.0.1`,局域网填实际 IP |
| `DEPLOY_DRY_RUN` | 1 时只生成配置,不安装/启动 Docker |
| `DEPLOY_FORCE_RECONFIG` | 非空时强制重新配置 |
| `DEPLOY_NON_INTERACTIVE` | 1 时缺少必要变量会直接报错,不进入问答 |
| `DEPLOY_DOCKER_INSTALL` | Docker Desktop 未安装时使用,`manual` 打开下载地址,`auto` 使用 winget/choco 自动安装 |
| `DEPLOY_NO_WAIT` | 1 时脚本结束或失败不等待按 Enter |
### 5.4 配置文件修改实现(仅 IP 模式)
使用 PowerShell 替换 `.env` 中相关行:
```powershell
$content = Get-Content .env -Raw -Encoding UTF8
$content = $content -replace 'NUXT_PUBLIC_BASE_APIURL=.*', "NUXT_PUBLIC_BASE_APIURL=http://${DEPLOY_IP}:3001"
$content = $content -replace 'NUXT_PUBLIC_BASE_SOCKETURL=.*', "NUXT_PUBLIC_BASE_SOCKETURL=ws://${DEPLOY_IP}:3002"
$content = $content -replace 'NUXT_PUBLIC_SG_APIURL=.*', "NUXT_PUBLIC_SG_APIURL=http://${DEPLOY_IP}:3003"
Set-Content .env -Value $content -Encoding UTF8 -NoNewline
```
### 5.5 Docker 处理策略
- **目标**:检测并安装 **Docker Desktop for Windows**Windows 版 Docker
- **检测**:执行 `docker --version` 或 `docker compose version`
- **未安装时** prompt 让用户选择
- `[1] 手动安装`:输出安装说明及 Docker Desktop for Windows 下载链接,退出脚本
- `[2] 自动安装`:通过 winget 或 Chocolatey 安装 Docker Desktop for Windows,安装后需用户重启终端/机器再继续
- 若设置 `DEPLOY_DOCKER_INSTALL=manual|auto`,脚本会直接执行对应分支,不再提示选择
### 5.6 执行策略
PowerShell 默认可能禁止执行脚本,建议在文档中说明:
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
或在脚本开头提示用户使用:
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
---
## 6. 部署完成输出
成功部署后输出访问地址,例如:
- 本地访问:`http://127.0.0.1:3010`
- 局域网访问:`http://<LAN_IP>:3010`
---
## 7. 实现清单
- [x] 创建 `start.ps1` 主脚本
- [x] 实现 `Init-ProjectDir`:项目目录校验
- [x] 实现 `Run-DeployQuestions`:本地 / 局域网 IP 选择(无域名/HTTPS)
- [x] 实现 `Setup-EnvFiles`:配置文件生成与替换
- [x] 实现 `Test-Docker`Docker 检测,未安装时 prompt「手动安装」或「自动安装」
- [x] 实现 `Install-Docker`:自动安装 **Docker Desktop for Windows**winget / Chocolatey
- [x] 实现 `Start-Services``docker compose` 启动
- [x] 实现 `Main`:主流程串联
- [x] 支持 `DEPLOY_DRY_RUN`、`DEPLOY_IP` 环境变量非交互模式
- [x] 支持 `DEPLOY_ACCESS=local`、`DEPLOY_NON_INTERACTIVE`、`DEPLOY_DOCKER_INSTALL` 非交互控制
- [x] 在 README 或文档中补充 Windows 部署说明与执行策略
- [x] 创建 `scripts/test-start-ps1-env.py` 验证 .env 替换逻辑
---
## 8. Windows 测试说明
### 本地测试(需 Windows 或 WSL + PowerShell
```powershell
# 非交互 + 仅生成配置(不启动 Docker)
$env:DEPLOY_DRY_RUN = "1"
$env:DEPLOY_IP = "127.0.0.1"
.\start.ps1
# 或使用本机访问模式变量
$env:DEPLOY_DRY_RUN = "1"
$env:DEPLOY_ACCESS = "local"
$env:DEPLOY_NO_WAIT = "1"
.\start.ps1
# 检查 .env 是否已正确写入
Select-String -Path .env -Pattern "NUXT_PUBLIC_BASE"
```
### 完整部署测试(需 Windows + Docker Desktop
```powershell
.\start.ps1
# 按提示选择 [1] 本地访问 或 [2] 局域网访问
# 若未安装 Docker,选择 [1] 手动 或 [2] 自动安装
```
### 执行策略(若提示无法运行脚本)
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
# 或
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
+68 -3
View File
@@ -3,6 +3,12 @@ map $http_upgrade $connection_upgrade {
'' close;
}
# $host 会丢失客户端 authority 中的显式端口;后端公开 URL 推断需保留 $http_host。
map $http_host $easyai_forwarded_host {
default $http_host;
'' $host;
}
# www 域名重定向,使用二级域名注释掉此部分的域名重定向
server {
listen 80;
@@ -32,11 +38,26 @@ server {
listen [::]:80;
server_name 51easyai.com;
# EasyAI response gzip: compress JSON/text API responses; SSE/WebSocket stay uncompressed by content type.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 4;
gzip_proxied any;
gzip_types
application/json
application/javascript
application/xml
text/css
text/plain
text/xml
image/svg+xml;
# ssl 版本控制
ssl_protocols TLSv1.2 TLSv1.3; # 只启用 TLS 1.2 和 TLS 1.3
# 通用安全头部
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options "SAMEORIGIN";
# add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
# 用于 certbot 验证的配置
location /.well-known/acme-challenge/ {
@@ -57,6 +78,20 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# 如果 X-Frame-Options 是上游返回的,隐藏它
proxy_hide_header X-Frame-Options;
# Wujie 会 fetch HTML,必须允许跨源读取
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Accept, Authorization" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
}
# 规范化 API 根路径,确保 https://example.com/api 不会落到前端 location /。
location = /api {
return 308 /api/;
}
location /api/ {
@@ -76,7 +111,9 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Host $easyai_forwarded_host;
proxy_set_header X-Forwarded-Host $easyai_forwarded_host;
proxy_set_header X-Forwarded-Port $server_port;
}
location /asg-api/ {
@@ -91,6 +128,19 @@ server {
proxy_set_header Host $host;
}
# Agent 记忆服务 API(可选,用于健康检查或外部调用)
location /ams-api/ {
proxy_pass http://127.0.0.1:3004/;
proxy_read_timeout 300s;
client_max_body_size 20M;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/ams-api';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location /plugins/ {
proxy_pass http://127.0.0.1:3020/plugins/;
proxy_redirect off;
@@ -134,7 +184,9 @@ server {
location /mcp {
proxy_pass http://127.0.0.1:3001/mcp;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header Host $easyai_forwarded_host;
proxy_set_header X-Forwarded-Host $easyai_forwarded_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
@@ -144,4 +196,17 @@ server {
proxy_buffering off; # 对于 WebSocket 连接禁用缓冲
}
# 沙箱环境 API(脚本执行、下载、安装依赖等),需在 docker-compose 中取消 SANDBOX_PORT 映射
location /sandbox/ {
proxy_pass http://127.0.0.1:8081/;
proxy_read_timeout 300s;
client_max_body_size 50M;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/sandbox';
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
}
+346 -6
View File
@@ -1,3 +1,5 @@
#!/bin/bash
if command -v nginx &> /dev/null; then
echo "✅ Nginx 已安装,跳过安装步骤"
else
@@ -103,8 +105,335 @@ else
exit 1
fi
# ===== 域名配置交互与文件生成 =====
sanitize_domain() {
local domain=$1
domain="${domain#http://}"
domain="${domain#https://}"
domain="${domain%%/*}"
domain="${domain%;}"
echo "$domain" | xargs
}
validate_domain_value() {
local domain=$1
[[ "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]]
}
get_env_domain() {
local domain="${HTTPS_DOMAIN:-${EASYAI_PROXY_DOMAIN:-${DEPLOY_DOMAIN:-}}}"
if [ -z "$domain" ]; then
return 0
fi
domain="$(sanitize_domain "$domain")"
if ! validate_domain_value "$domain"; then
echo "❌ 域名变量格式不正确: $domain" >&2
exit 1
fi
echo "$domain"
}
is_non_interactive() {
[ "${HTTPS_NON_INTERACTIVE:-${DEPLOY_NON_INTERACTIVE:-0}}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ]
}
prompt_domain() {
local domain
domain="$(get_env_domain)"
if [ -n "$domain" ]; then
echo "$domain"
return 0
fi
if is_non_interactive; then
echo "❌ 当前为非交互环境,HTTPS 配置需要传入域名变量" >&2
echo " 请设置 DEPLOY_DOMAIN=<域名> 或 EASYAI_PROXY_DOMAIN=<域名>" >&2
exit 1
fi
while true; do
read -r -p "🌐 请输入域名(例如 demo.example.com: " domain
domain="$(sanitize_domain "$domain")"
if [ -n "$domain" ] && validate_domain_value "$domain"; then
echo "$domain"
return 0
fi
echo "❌ 域名格式不正确,请重新输入。"
done
}
escape_sed() {
echo "$1" | sed 's/[.[\*^$()+?{}|/]/\\&/g'
}
get_primary_domain_from_conf() {
local conf_file=$1
awk '
/server_name/ {
for (i = 2; i <= NF; i++) {
gsub(";", "", $i)
if ($i !~ /^www\./ && $i !~ /^\*/ && $i != "localhost") {
print $i
exit
}
}
}
' "$conf_file"
}
create_conf_by_template() {
local domain=$1
local target_file=$2
# 始终优先使用仓库维护的完整代理模板,避免仅生成前端 location /。
if [ -f "./easyai-proxy.conf.sample" ]; then
cp "./easyai-proxy.conf.sample" "./$target_file"
sed -i.bak "s/51easyai\.com/$domain/g" "./$target_file"
rm -f "./$target_file.bak"
return 0
fi
if [ -f "./demo.51easyai.com.conf" ]; then
cp "./demo.51easyai.com.conf" "./$target_file"
sed -i.bak "s/www\.demo\.51easyai\.com/www.$domain/g; s/demo\.51easyai\.com/$domain/g" "./$target_file"
rm -f "./$target_file.bak"
return 0
fi
if [ -f "./2.conf" ]; then
cp "./2.conf" "./$target_file"
sed -i.bak "s/www\.2/www.$domain/g; s/\b2\b/$domain/g" "./$target_file"
rm -f "./$target_file.bak"
return 0
fi
cat > "./$target_file" <<EOF
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name www.$domain;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files \$uri =404;
}
location / {
return 301 https://$domain\$request_uri;
}
}
server {
listen 80;
listen [::]:80;
server_name $domain;
# EasyAI response gzip: compress JSON/text API responses; SSE/WebSocket stay uncompressed by content type.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 4;
gzip_proxied any;
gzip_types
application/json
application/javascript
application/xml
text/css
text/plain
text/xml
image/svg+xml;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files \$uri =404;
}
location / {
proxy_pass http://127.0.0.1:3010/;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Host \$host;
}
}
EOF
}
conf_contains_domain() {
local conf_file=$1
local domain=$2
grep -Eq "server_name[^;]*(^|[[:space:]])(www\\.)?${domain//./\\.}([[:space:];]|$)" "./$conf_file"
}
normalize_domain_action() {
local action=$1
action="$(printf '%s' "$action" | tr '[:upper:]' '[:lower:]')"
case "$action" in
1|replace) echo "1" ;;
2|add|"") echo "2" ;;
*)
echo "❌ EASYAI_PROXY_DOMAIN_ACTION 只能是 replace 或 add,当前为: $action" >&2
exit 1
;;
esac
}
upsert_domain_for_conf() {
local conf_file=$1
local env_domain="${2:-}"
local env_action="${3:-}"
local mode
local new_domain
echo "️ 检测到当前目录已有配置文件: $conf_file"
if [ -n "$env_domain" ]; then
new_domain="$(sanitize_domain "$env_domain")"
if ! validate_domain_value "$new_domain"; then
echo "❌ 域名变量格式不正确: $new_domain"
exit 1
fi
if conf_contains_domain "$conf_file" "$new_domain"; then
echo "️ 配置文件已包含域名 $new_domain,无需修改。"
return 0
fi
mode="$(normalize_domain_action "$env_action")"
echo "使用变量配置域名: $new_domain, action=$([ "$mode" = "1" ] && echo replace || echo add)"
else
while true; do
echo "请选择域名处理方式:"
echo " 1) 替换当前域名"
echo " 2) 新增一个域名"
read -r -p "请输入选项 (1/2): " mode
case "$mode" in
1|2) break ;;
*) echo "❌ 无效选项,请输入 1 或 2" ;;
esac
done
new_domain=$(prompt_domain)
fi
if [ "$mode" = "1" ]; then
local old_domain
old_domain=$(get_primary_domain_from_conf "./$conf_file")
if [ -z "$old_domain" ]; then
echo "⚠️ 未能自动识别旧域名,将直接尝试新增域名。"
mode="2"
else
local old_domain_escaped
local new_domain_escaped
old_domain_escaped=$(escape_sed "$old_domain")
new_domain_escaped=$(escape_sed "$new_domain")
sed -i.bak "s/www\\.$old_domain_escaped/www.$new_domain_escaped/g; s/$old_domain_escaped/$new_domain_escaped/g" "./$conf_file"
rm -f "./$conf_file.bak"
echo "✅ 已将域名从 $old_domain 替换为 $new_domain"
fi
fi
if [ "$mode" = "2" ]; then
local changed=0
if ! grep -Eq "server_name[^;]*[[:space:]]$new_domain([[:space:];]|$)" "./$conf_file"; then
sed -i.bak "/server_name/s/;/ $new_domain;/g" "./$conf_file"
changed=1
fi
if ! grep -Eq "server_name[^;]*[[:space:]]www\\.$new_domain([[:space:];]|$)" "./$conf_file"; then
sed -i.bak "/server_name/s/;/ www.$new_domain;/g" "./$conf_file"
changed=1
fi
rm -f "./$conf_file.bak"
if [ "$changed" -eq 1 ]; then
echo "✅ 已新增域名:$new_domain(含 www.$new_domain"
else
echo "ℹ️ 配置文件已包含该域名,无需新增。"
fi
fi
}
detect_existing_proxy_conf() {
local preferred_file=$1
local domain_filter="${2:-}"
local -a candidates=()
local -a matched_candidates=()
local conf
if [ -n "$preferred_file" ] && [ -f "./$preferred_file" ]; then
echo "$preferred_file"
return 0
fi
while IFS= read -r conf; do
if grep -Eq "^[[:space:]]*server_name[[:space:]]+" "./$conf"; then
candidates+=("$conf")
fi
done < <(ls -1 *.conf 2>/dev/null || true)
if [ "${#candidates[@]}" -eq 0 ]; then
return 1
fi
if [ -n "$domain_filter" ]; then
for conf in "${candidates[@]}"; do
if conf_contains_domain "$conf" "$domain_filter"; then
matched_candidates+=("$conf")
fi
done
if [ "${#matched_candidates[@]}" -eq 1 ]; then
echo "${matched_candidates[0]}"
return 0
fi
if [ "${#matched_candidates[@]}" -gt 1 ] && is_non_interactive; then
echo "❌ 多个配置文件都包含 $domain_filter,请通过 EASYAI_PROXY_CONF 指定文件" >&2
exit 1
fi
fi
if [ "${#candidates[@]}" -eq 1 ]; then
echo "${candidates[0]}"
return 0
fi
if is_non_interactive; then
echo "❌ 检测到多个可用配置文件,请通过 EASYAI_PROXY_CONF 指定文件" >&2
exit 1
fi
echo "⚠️ 检测到多个可用配置文件,请选择一个:" >&2
local i
for i in "${!candidates[@]}"; do
printf " %s) %s\n" "$((i + 1))" "${candidates[$i]}" >&2
done
while true; do
read -r -p "请输入序号: " i
if [[ "$i" =~ ^[0-9]+$ ]] && [ "$i" -ge 1 ] && [ "$i" -le "${#candidates[@]}" ]; then
echo "${candidates[$((i - 1))]}"
return 0
fi
echo "❌ 无效序号,请重新输入。"
done
}
echo "🚀 复制当前目录的配置文件到nginx配置文件目录"
cp -r ./easyai-proxy.conf /etc/nginx/conf.d/
# 支持 EASYAI_PROXY_CONF 指定配置文件(如 51easyai.com.conf
PREFERRED_CONF_FILE="${EASYAI_PROXY_CONF:-}"
DEFAULT_NEW_CONF_FILE="${EASYAI_PROXY_CONF:-easyai-proxy.conf}"
HTTPS_DOMAIN_INPUT="$(get_env_domain)"
CONF_FILE=$(detect_existing_proxy_conf "$PREFERRED_CONF_FILE" "$HTTPS_DOMAIN_INPUT")
if [ -n "$CONF_FILE" ]; then
upsert_domain_for_conf "$CONF_FILE" "$HTTPS_DOMAIN_INPUT" "${EASYAI_PROXY_DOMAIN_ACTION:-}"
else
CONF_FILE="$DEFAULT_NEW_CONF_FILE"
echo "ℹ️ 当前目录未找到可用 nginx 配置文件(*.conf 且包含 server_name"
input_domain=$(prompt_domain)
create_conf_by_template "$input_domain" "$CONF_FILE"
echo "✅ 已根据域名 $input_domain 创建配置文件: ./$CONF_FILE"
fi
cp "./$CONF_FILE" "/etc/nginx/conf.d/$CONF_FILE"
echo "🚀 重载nginx"
sudo nginx -s reload
@@ -113,10 +442,21 @@ sudo nginx -s stop
echo "🚀 使用certbot 自动配置证书"
# 从 Nginx 配置文件中提取所有域名
DOMAINS=$(find /etc/nginx/conf.d/ -name "easyai-proxy.conf" -type f -exec grep "server_name" {} \; | \
SERVER_NAME_LINES=$(
if [ -f "/etc/nginx/conf.d/$CONF_FILE" ]; then
grep "server_name" "/etc/nginx/conf.d/$CONF_FILE" 2>/dev/null || true
else
find /etc/nginx/conf.d/ -name "*.conf" -exec grep "server_name" {} \; 2>/dev/null || true
fi
)
DOMAINS=$(echo "$SERVER_NAME_LINES" | \
grep -v "#" | \
awk '{for(i=2;i<=NF;i++) if($i!=";") print $i}' | \
sed 's/;//g' | \
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | \
grep -E '^[A-Za-z0-9.-]+$' | \
grep -vE '^(\*|localhost)$' | \
sort -u | \
tr '\n' ' ')
@@ -126,20 +466,20 @@ if [ -n "$DOMAINS" ]; then
sudo nginx -s stop
# 构建域名参数字符串
DOMAIN_ARGS=""
DOMAIN_ARGS=()
for domain in $DOMAINS; do
DOMAIN_ARGS="$DOMAIN_ARGS -d $domain"
DOMAIN_ARGS+=("-d" "$domain")
done
# 使用 certbot --nginx 插件安装证书
sudo certbot --nginx \
--non-interactive \
--agree-tos \
--email wangbo@51easyai.com \
--email "${CERTBOT_EMAIL:-wangbo@51easyai.com}" \
--rsa-key-size 2048 \
--preferred-challenges http \
--force-renewal \
$DOMAIN_ARGS
"${DOMAIN_ARGS[@]}"
# 启动 Nginx 服务
echo "启动 Nginx 服务..."
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+480
View File
@@ -0,0 +1,480 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Mirror a public image (default: Docker Hub via optional China mirror) to your registry,
keeping only selected platforms (default linux/amd64 + linux/arm64), then push one multi-arch tag.
.DESCRIPTION
Uses skopeo copy per digest + docker buildx imagetools create.
Docker Desktop users: credentials are resolved from credential helper into a UTF-8 no-BOM auth file.
.PARAMETER Source
Docker Hub style: redis:latest, nginx:alpine, bitnami/redis:7, library/redis:7, docker.io/prom/prometheus:latest
Or any registry: ghcr.io/org/image:tag (no Docker-Hub mirror rewrite)
.PARAMETER DestRepo
Target repository without tag, e.g. registry.cn-shanghai.aliyuncs.com/myns/redis
.PARAMETER DestTag
Tag on destination (default: tag parsed from -Source)
.PARAMETER MirrorRegistry
Pull-through mirror host for Docker Hub only, e.g. docker.m.daocloud.io. Empty = use docker.io directly.
.PARAMETER Platforms
OS/arch list to keep, default linux/amd64 and linux/arm64.
.PARAMETER SkopeoParallelCopies
skopeo --image-parallel-copies (layers in flight). 0 = auto (higher default for better upload utilization).
If upload still looks underused, try 32 or 40; if CPU/memory spikes, lower to 12.
Other tuning (outside this script): use ACR region closest to you; Docker Desktop -> Resources give more CPUs/RAM;
avoid VPN throttling; wired Ethernet; temporarily pause heavy uploads on same link.
.EXAMPLE
.\mirror-image-to-registry.ps1 -Source nginx:alpine -DestRepo registry.cn-shanghai.aliyuncs.com/myns/nginx -DestTag alpine
.EXAMPLE
.\mirror-image-to-registry.ps1 -Source docker.io/library/redis:latest -DestRepo registry.example.com/prod/redis -MirrorRegistry ""
#>
param(
[Parameter(Mandatory = $true)][string] $Source,
[Parameter(Mandatory = $true)][string] $DestRepo,
[string] $DestTag = "",
[string] $MirrorRegistry = "docker.m.daocloud.io",
[string[]] $Platforms = @("linux/amd64", "linux/arm64"),
[string] $SkopeoImage = "quay.io/skopeo/stable:latest",
[string] $DestUsername = "",
[string] $DestPassword = "",
[int] $SkopeoParallelCopies = 0
)
$ErrorActionPreference = "Stop"
# Parallel layer copies: 0 = auto (scale with CPU cores, capped)
$nCpu = [Environment]::ProcessorCount
if ($SkopeoParallelCopies -le 0) {
$script:EffectiveParallelCopies = [Math]::Min(40, [Math]::Max(16, $nCpu * 4))
Write-Host "SkopeoParallelCopies=auto -> $script:EffectiveParallelCopies (logical CPUs: $nCpu)"
} else {
$script:EffectiveParallelCopies = $SkopeoParallelCopies
Write-Host "SkopeoParallelCopies=$script:EffectiveParallelCopies"
}
function Test-CmdInPath {
param([string]$Name)
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
function Get-RegistryHostFromRepo {
param([string]$Repo)
return ($Repo -split '/')[0]
}
function Get-DockerConfigPath {
return (Join-Path $env:USERPROFILE ".docker\config.json")
}
function Write-JsonFileUtf8NoBom {
param([string]$Path, [string]$JsonText)
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($Path, $JsonText, $utf8NoBom)
}
function New-SkopeoAuthJsonFile {
param(
[Parameter(Mandatory = $true)][string]$RegistryHost,
[Parameter(Mandatory = $true)][string]$Username,
[Parameter(Mandatory = $true)][string]$Password
)
$pair = "${Username}:${Password}"
$authB64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pair))
$payload = @{
auths = @{
$RegistryHost = @{
auth = $authB64
}
}
}
$path = [System.IO.Path]::GetTempFileName() + ".json"
$jsonText = $payload | ConvertTo-Json -Compress -Depth 6
Write-JsonFileUtf8NoBom -Path $path -JsonText $jsonText
return $path
}
function Get-InlineAuthFromDockerConfig {
param([string]$DockerConfigPath, [string]$RegistryHost)
if (-not (Test-Path -LiteralPath $DockerConfigPath)) { return $null }
$txt = Get-Content -LiteralPath $DockerConfigPath -Raw -ErrorAction Stop
$cfg = $txt | ConvertFrom-Json
if (-not $cfg.auths) { return $null }
foreach ($p in $cfg.auths.PSObject.Properties) {
if ($p.Name -eq $RegistryHost -and $p.Value.auth) {
return [string]$p.Value.auth
}
}
return $null
}
function Get-CredentialFromDockerHelper {
param([string]$RegistryHost, [string]$HelperName)
if (-not $HelperName) { return $null }
$exeName = "docker-credential-$HelperName"
$exe = Get-Command $exeName -ErrorAction SilentlyContinue
if (-not $exe) {
$candidates = @(
(Join-Path ${env:ProgramFiles} "Docker\Docker\resources\bin\$exeName.exe"),
(Join-Path ${env:ProgramFiles} "Docker\Docker\resources\bin\$HelperName.exe")
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) { $exe = Get-Command $c -ErrorAction SilentlyContinue; break }
}
}
if (-not $exe) {
Write-Warning "Credential helper '$exeName' not found on PATH or Docker resources."
return $null
}
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $exe.Source
$psi.Arguments = "get"
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $psi
[void]$p.Start()
$p.StandardInput.WriteLine($RegistryHost)
$p.StandardInput.Close()
$out = $p.StandardOutput.ReadToEnd()
$err = $p.StandardError.ReadToEnd()
$p.WaitForExit()
if ($p.ExitCode -ne 0) {
Write-Warning "docker-credential-$HelperName get failed (exit $($p.ExitCode)): $err"
return $null
}
try {
$j = $out | ConvertFrom-Json
return @{ Username = $j.Username; Secret = $j.Secret }
} catch {
return $null
}
}
function Resolve-DestinationAuthFile {
param([string]$RegistryHost)
if ($DestUsername -and $DestPassword) {
Write-Host "Using -DestUsername / -DestPassword for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $DestUsername -Password $DestPassword)
}
if ($env:ALIYUN_CR_USER -and $env:ALIYUN_CR_PASS) {
Write-Host "Using ALIYUN_CR_USER / ALIYUN_CR_PASS for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $env:ALIYUN_CR_USER -Password $env:ALIYUN_CR_PASS)
}
if ($env:DEST_REGISTRY_USER -and $env:DEST_REGISTRY_PASS) {
Write-Host "Using DEST_REGISTRY_USER / DEST_REGISTRY_PASS for registry push auth."
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $env:DEST_REGISTRY_USER -Password $env:DEST_REGISTRY_PASS)
}
$dockerCfg = Get-DockerConfigPath
$inline = Get-InlineAuthFromDockerConfig -DockerConfigPath $dockerCfg -RegistryHost $RegistryHost
if ($inline) {
Write-Host "Using inline auth from Docker config.json for $RegistryHost"
$path = [System.IO.Path]::GetTempFileName() + ".json"
$payload = @{ auths = @{ $RegistryHost = @{ auth = $inline } } }
$jsonText = $payload | ConvertTo-Json -Compress -Depth 6
Write-JsonFileUtf8NoBom -Path $path -JsonText $jsonText
return $path
}
if (Test-Path -LiteralPath $dockerCfg) {
$cfg = (Get-Content -LiteralPath $dockerCfg -Raw) | ConvertFrom-Json
$helperName = $null
if ($cfg.credHelpers) {
foreach ($hp in $cfg.credHelpers.PSObject.Properties) {
if ($hp.Name -eq $RegistryHost) {
$helperName = [string]$hp.Value
break
}
}
}
if (-not $helperName -and $cfg.credsStore) {
$helperName = $cfg.credsStore
}
if ($helperName) {
Write-Host "Resolving credentials via docker-credential-$helperName (Docker Desktop)..."
$c = Get-CredentialFromDockerHelper -RegistryHost $RegistryHost -HelperName $helperName
if ($c -and $c.Username -and $c.Secret) {
return (New-SkopeoAuthJsonFile -RegistryHost $RegistryHost -Username $c.Username -Password $c.Secret)
}
}
}
return $null
}
function Split-ImageRefPathAndTag {
param([string]$Ref)
if ($Ref -match '@sha256:') {
throw "Digest-pinned sources (@sha256:...) are not supported. Use a tag."
}
$tag = "latest"
$path = $Ref
if ($Ref -match '^(.+):([^:/]+)$') {
$path = $matches[1]
$tag = $matches[2]
}
return @{ Path = $path; Tag = $tag }
}
function Test-IsDockerHubOnlyPath {
param([string]$PathPart)
if ($PathPart -match '^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]+/') { return $false }
return $true
}
function Resolve-DockerHubRepoPath {
param([string]$PathPart)
if ($PathPart -notmatch '/') {
return "library/$PathPart"
}
return $PathPart
}
# Returns: IsDockerHub, InspectRef (docker://...), SourceRepoPath for copy by digest (host/repo without tag), Tag
function Resolve-SourceInspectReference {
param(
[string]$Source,
[string]$MirrorRegistry
)
$s = $Source.Trim()
if ($s.StartsWith("docker://")) {
$s = $s.Substring(9)
}
$st = Split-ImageRefPathAndTag $s
$path = $st.Path
$tag = $st.Tag
if (Test-IsDockerHubOnlyPath $path) {
$repo = Resolve-DockerHubRepoPath $path
$hubRef = "docker.io/${repo}:${tag}"
if ($MirrorRegistry) {
$inspect = "docker://${MirrorRegistry}/${repo}:${tag}"
} else {
$inspect = "docker://${hubRef}"
}
return @{
IsDockerHub = $true
InspectRef = $inspect
SourceRepoPath = if ($MirrorRegistry) { "${MirrorRegistry}/${repo}" } else { "docker.io/${repo}" }
Tag = $tag
}
}
# Full registry reference e.g. ghcr.io/org/img:tag
$firstSlash = $path.IndexOf('/')
if ($firstSlash -lt 1) { throw "Invalid source: $Source" }
$hostPart = $path.Substring(0, $firstSlash)
$repoPath = $path.Substring($firstSlash + 1)
if (-not $repoPath) { throw "Invalid source: $Source" }
$inspect = "docker://${hostPart}/${repoPath}:${tag}"
return @{
IsDockerHub = $false
InspectRef = $inspect
SourceRepoPath = "${hostPart}/${repoPath}"
Tag = $tag
}
}
function Get-DockerAuthVolumeArgs {
param([string]$AuthFilePath)
if ($AuthFilePath -and (Test-Path -LiteralPath $AuthFilePath)) {
return @("-v", "${AuthFilePath}:/auth.json:ro")
}
return @()
}
function Invoke-SkopeoCopyWithAuth {
param(
[string]$SkopeoImage,
[string]$SourceRef,
[string]$DestRef,
[string]$AuthFilePath,
[int]$ParallelCopies = 16,
[int]$GoMaxProcs = 0
)
$parallelArgs = @()
if ($ParallelCopies -gt 0) {
$parallelArgs = @("--image-parallel-copies", "$ParallelCopies")
}
if ($GoMaxProcs -le 0) { $GoMaxProcs = [Environment]::ProcessorCount }
$vol = Get-DockerAuthVolumeArgs -AuthFilePath $AuthFilePath
if (-not (Test-CmdInPath "skopeo")) {
if (-not $AuthFilePath) {
throw "No auth file for skopeo copy. Use -DestUsername/-DestPassword, DEST_REGISTRY_*, ALIYUN_CR_*, or docker login with resolvable credentials."
}
# GOMAXPROCS lets the Go runtime use more threads for TLS/registry I/O alongside parallel blob copies
$dockerLine = @("run", "--rm", "-e", "GOMAXPROCS=$GoMaxProcs") + $vol + @($SkopeoImage) + @("copy", "--authfile", "/auth.json") + $parallelArgs + @($SourceRef, $DestRef)
& docker @dockerLine
$script:SkopeoLastExitCode = $LASTEXITCODE
return
}
if ($AuthFilePath) {
$env:GOMAXPROCS = "$GoMaxProcs"
try {
& skopeo copy --authfile $AuthFilePath @parallelArgs $SourceRef $DestRef
} finally {
Remove-Item Env:GOMAXPROCS -ErrorAction SilentlyContinue
}
} else {
$env:GOMAXPROCS = "$GoMaxProcs"
try {
& skopeo copy @parallelArgs $SourceRef $DestRef
} finally {
Remove-Item Env:GOMAXPROCS -ErrorAction SilentlyContinue
}
}
$script:SkopeoLastExitCode = $LASTEXITCODE
}
function Invoke-SkopeoInspectRaw {
param([string]$SkopeoImage, [string]$InspectRef)
if (Test-CmdInPath "skopeo") {
$rawLines = & skopeo inspect --raw $InspectRef 2>&1
$code = $LASTEXITCODE
} else {
$rawLines = & docker run --rm $SkopeoImage inspect --raw $InspectRef 2>&1
$code = $LASTEXITCODE
}
return [PSCustomObject]@{ RawLines = $rawLines; ExitCode = $code }
}
function Test-PlatformMatch {
param($ManifestEntry, [string]$OsWant, [string]$ArchWant)
if (-not $ManifestEntry.platform) { return $false }
$os = $ManifestEntry.platform.os
$arch = $ManifestEntry.platform.architecture
if ($os -ne $OsWant) { return $false }
if ($arch -eq $ArchWant) { return $true }
return $false
}
if (-not (Test-CmdInPath "docker")) {
Write-Error "docker not found."
}
$resolved = Resolve-SourceInspectReference -Source $Source -MirrorRegistry $MirrorRegistry
if (-not $DestTag) {
$DestTag = $resolved.Tag
}
$destHost = Get-RegistryHostFromRepo -Repo $DestRepo
$destImage = "${DestRepo}:${DestTag}"
if (-not (Test-CmdInPath "skopeo")) {
Write-Host "Using skopeo from Docker image: $SkopeoImage"
}
Write-Host "Inspect: $($resolved.InspectRef)"
Write-Host "Destination: $destImage"
Write-Host "Platforms: $($Platforms -join ', ')"
$inspectResult = Invoke-SkopeoInspectRaw -SkopeoImage $SkopeoImage -InspectRef $resolved.InspectRef
if ($inspectResult.ExitCode -ne 0) {
throw "skopeo inspect failed (exit $($inspectResult.ExitCode)): $($inspectResult.RawLines)"
}
$rawLines = $inspectResult.RawLines
$rawJson = if ($rawLines -is [array]) { $rawLines -join "`n" } else { [string]$rawLines }
$rawJson = $rawJson.Trim()
if ($rawJson -notmatch '^\s*\{') {
$idx = $rawJson.LastIndexOf('{')
if ($idx -ge 0) { $rawJson = $rawJson.Substring($idx) }
}
$index = $rawJson | ConvertFrom-Json
# Single manifest (not a list): copy tag-to-tag
if (-not $index.manifests) {
Write-Host "Source is a single manifest (not a multi-arch index). Copying tag as-is..."
$srcTagRef = $resolved.InspectRef
$authFileForPush = Resolve-DestinationAuthFile -RegistryHost $destHost
if (-not $authFileForPush) { throw "Could not resolve push credentials for $destHost." }
try {
Invoke-SkopeoCopyWithAuth -SkopeoImage $SkopeoImage -SourceRef $srcTagRef -DestRef "docker://${destImage}" -AuthFilePath $authFileForPush -ParallelCopies $script:EffectiveParallelCopies
if ($script:SkopeoLastExitCode -ne 0) { throw "skopeo copy failed (exit $($script:SkopeoLastExitCode))" }
Write-Host "Done: $destImage"
} finally {
if ($authFileForPush -and (Test-Path -LiteralPath $authFileForPush)) {
Remove-Item -LiteralPath $authFileForPush -Force -ErrorAction SilentlyContinue
}
}
exit 0
}
$digestByPlatform = @{}
foreach ($plat in $Platforms) {
$parts = $plat -split '/', 2
if ($parts.Length -ne 2) { throw "Invalid platform (use os/arch): $plat" }
$osW = $parts[0]
$archW = $parts[1]
$found = $null
foreach ($m in $index.manifests) {
if (Test-PlatformMatch -ManifestEntry $m -OsWant $osW -ArchWant $archW) {
$found = $m.digest
break
}
}
if (-not $found) {
throw "Platform $plat not found in source manifest list. Inspect with: docker buildx imagetools inspect $($resolved.InspectRef -replace '^docker://','')"
}
$digestByPlatform[$plat] = $found
}
foreach ($p in $Platforms) {
Write-Host "$p : $($digestByPlatform[$p])"
}
$srcBase = $resolved.SourceRepoPath
$dstBase = $DestRepo
$authFileForPush = Resolve-DestinationAuthFile -RegistryHost $destHost
if (-not $authFileForPush) {
throw @"
Could not resolve push credentials for $destHost.
Use: -DestUsername / -DestPassword, or env DEST_REGISTRY_USER + DEST_REGISTRY_PASS, or ALIYUN_CR_USER + ALIYUN_CR_PASS, or docker login.
"@
}
try {
Write-Host ""
Write-Host "Tip: After the 'Copying blob ...' lines, the console may stay quiet for many minutes while large layers upload to the registry (not frozen). Watch Task Manager -> Network (send) if unsure."
$step = 1
$total = $Platforms.Count
foreach ($plat in $Platforms) {
$d = $digestByPlatform[$plat]
Write-Host ""
Write-Host "[$step/$total] skopeo copy $plat ..."
$step++
Invoke-SkopeoCopyWithAuth -SkopeoImage $SkopeoImage -SourceRef "docker://${srcBase}@${d}" -DestRef "docker://${dstBase}@${d}" -AuthFilePath $authFileForPush -ParallelCopies $script:EffectiveParallelCopies
if ($script:SkopeoLastExitCode -ne 0) { throw "skopeo copy failed for $plat (exit $($script:SkopeoLastExitCode))" }
}
$refsForBuildx = @()
foreach ($plat in $Platforms) {
$refsForBuildx += "${dstBase}@$($digestByPlatform[$plat])"
}
Write-Host ""
Write-Host "[$($total + 1)/$($total + 1)] docker buildx imagetools create..."
& docker buildx imagetools create -t $destImage @refsForBuildx
if ($LASTEXITCODE -ne 0) { throw "docker buildx imagetools create failed" }
Write-Host ""
Write-Host "Done: $destImage"
Write-Host "Verify: docker buildx imagetools inspect $destImage"
} finally {
if ($authFileForPush -and (Test-Path -LiteralPath $authFileForPush)) {
Remove-Item -LiteralPath $authFileForPush -Force -ErrorAction SilentlyContinue
}
}
+7 -10
View File
@@ -15,16 +15,15 @@ daemonize no
#pidfile /data/dockerData/redis/run/redis6379.pid
#默认为no,redis持久化,可以改为yes
appendonly yes
dir /data
#当客户端闲置多长时间后关闭连接,如果指定为0,表示关闭该功能 # 增加到300秒,或设为0禁用
timeout 300
# 服务器系统默认配置参数影响 Redis 的应用
maxclients 10000
# 减少到60秒,更快检测连接问题
tcp-keepalive 60
#指定在多长时间内,有多少次更新操作,就将数据同步到数据文件,可以多个条件配合(分别表示900秒(15分钟)内有1个更改,300秒(5分钟)内有10个更改以及60秒内有10000个更改)
save 900 1
save 300 10
save 60 10000
# 禁用 RDB 快照,避免小内存机器上 fork bgsave 触发 OOM;生产保留 AOF。
save ""
# 按需求调整 Redis 线程数
tcp-backlog 511
# 设置Redis副本实例是否为只读模式
@@ -34,14 +33,13 @@ replica-read-only no
# 设置Redis最大内存达到时的数据淘汰策略
# noeviction:当内存达到最大限制时,Redis不会自动删除任何键,这可能导致进一步的操作失败,直到手动干预 # 当内存不足时,删除最近最少使用的键
# maxmemory-policy allkeys-lru
maxmemory 2gb
maxmemory-policy noeviction
# 设置数据库数量,这里设置为16个数据库
databases 16
# 启用 AOF, AOF常规配置
appendonly yes
# 改为由操作系统决定何时同步,减少I/O压力
appendfsync no
no-appendfsync-on-rewrite no
# AOF 常规配置
appendfsync everysec
no-appendfsync-on-rewrite yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# 慢查询阈值
@@ -71,4 +69,3 @@ zset-max-ziplist-entries 128
zset-max-ziplist-value 64
+67
View File
@@ -0,0 +1,67 @@
#Requires -RunAsAdministrator
# WSL 与 Docker 网络配置重置脚本
# 需以管理员身份运行
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " WSL & Docker 网络配置重置" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 1. 关闭 WSL
Write-Host "[1/5] 关闭 WSL..." -ForegroundColor Yellow
wsl --shutdown
Start-Sleep -Seconds 3
Write-Host " 完成" -ForegroundColor Green
# 2. 重置 Windows 网络栈(可选,可能需重启)
Write-Host "[2/5] 重置 Windows 网络栈..." -ForegroundColor Yellow
try {
netsh winsock reset 2>$null
netsh int ip reset 2>$null
Write-Host " 完成 (如有提示重启,建议执行)" -ForegroundColor Green
} catch {
Write-Host " 跳过: $($_.Exception.Message)" -ForegroundColor Gray
}
# 3. 清理 Docker 网络
Write-Host "[3/5] 清理 Docker 未使用网络..." -ForegroundColor Yellow
$dockerOk = $false
try {
$null = docker info 2>&1
$dockerOk = $true
} catch { }
if ($dockerOk) {
docker network prune -f 2>$null
Write-Host " 完成" -ForegroundColor Green
} else {
Write-Host " 跳过 (Docker 未运行,请先启动 Docker Desktop)" -ForegroundColor Gray
}
# 4. 清除 Docker 端口代理残留(若有)
Write-Host "[4/5] 检查端口代理..." -ForegroundColor Yellow
$proxies = netsh interface portproxy show all 2>$null
if ($proxies -and $proxies -match "3010|3001|3002|3003") {
Write-Host " 发现相关端口代理,请手动在管理员 CMD 执行:" -ForegroundColor Yellow
Write-Host " netsh interface portproxy reset" -ForegroundColor White
} else {
Write-Host " 无异常端口代理" -ForegroundColor Green
}
# 5. 提示
Write-Host "[5/5] 下一步操作:" -ForegroundColor Yellow
Write-Host ""
Write-Host " 1. 启动 Docker Desktop(若已关闭)" -ForegroundColor White
Write-Host " 2. 等待 Docker 完全就绪后,执行:" -ForegroundColor White
Write-Host " cd D:\01一键部署包\easyai" -ForegroundColor Cyan
Write-Host " docker compose up -d" -ForegroundColor Cyan
Write-Host ""
Write-Host " 3. 若端口仍不可访问,在 Docker Desktop 中:" -ForegroundColor White
Write-Host " [设置] -> [Troubleshoot] -> [Reset to factory defaults]" -ForegroundColor Cyan
Write-Host " (会清除所有容器/镜像,谨慎使用)" -ForegroundColor Gray
Write-Host ""
Write-Host " 4. 若执行了 netsh winsock/int ip reset,建议重启电脑" -ForegroundColor White
Write-Host ""
Read-Host "按 Enter 键退出"
+102
View File
@@ -0,0 +1,102 @@
function Get-PublicEnvValue {
param([string]$Content, [string]$Key)
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$")
if (-not $match.Success) { return "" }
$value = $match.Groups[1].Value.Trim()
if ($value.Length -ge 2) {
$first = $value.Substring(0, 1)
$last = $value.Substring($value.Length - 1, 1)
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
return $value.Substring(1, $value.Length - 2)
}
}
return $value
}
function Set-PublicEnvValue {
param([string]$Content, [string]$Key, [string]$Value)
$pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?"
$replacement = "$Key=$Value`r`n"
if ([regex]::IsMatch($Content, $pattern)) {
return [regex]::Replace($Content, $pattern, $replacement, 1)
}
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" }
return $Content + $replacement
}
function ConvertTo-PublicApiBaseUrl {
param([string]$Value)
$normalized = if ($null -eq $Value) { "" } else { $Value.Trim().TrimEnd('/') }
if (-not $normalized) { return $null }
$uri = $null
if (-not [Uri]::TryCreate($normalized, [UriKind]::Absolute, [ref]$uri)) { return $null }
if ($uri.Scheme -notin @("http", "https")) { return $null }
if ($uri.UserInfo -or $uri.Query -or $uri.Fragment) { return $null }
if (-not $uri.Host -or $uri.Port -lt 1 -or $uri.Port -gt 65535) { return $null }
return $normalized
}
function Get-DerivedPublicApiBaseUrl {
param([string]$Content)
$apiUrl = Get-PublicEnvValue $Content "NUXT_PUBLIC_BASE_APIURL"
$absoluteApiUrl = ConvertTo-PublicApiBaseUrl $apiUrl
if ($absoluteApiUrl) { return $absoluteApiUrl }
if (-not $apiUrl.StartsWith('/')) { return $null }
$origin = $null
$socketUrl = Get-PublicEnvValue $Content "NUXT_PUBLIC_BASE_SOCKETURL"
$socketUri = $null
if ([Uri]::TryCreate($socketUrl, [UriKind]::Absolute, [ref]$socketUri) -and
$socketUri.Scheme -in @("ws", "wss")) {
$scheme = if ($socketUri.Scheme -eq "wss") { "https" } else { "http" }
$origin = "${scheme}://$($socketUri.Authority)"
}
if (-not $origin) {
$securityOrigin = (Get-PublicEnvValue $Content "CONFIG_SECURITY_ORIGIN").Split(',')[0].Trim()
$securityUri = $null
if ([Uri]::TryCreate($securityOrigin, [UriKind]::Absolute, [ref]$securityUri) -and
$securityUri.Scheme -in @("http", "https")) {
$origin = "$($securityUri.Scheme)://$($securityUri.Authority)"
}
}
if (-not $origin) { return $null }
return ConvertTo-PublicApiBaseUrl "$($origin.TrimEnd('/'))$apiUrl"
}
function Initialize-PublicApiBaseUrl {
param(
[Parameter(Mandatory = $true)][string]$Path,
[ValidateSet("configure", "upgrade")][string]$Mode = "upgrade",
[string]$Override = $env:DEPLOY_PUBLIC_API_BASE_URL
)
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
$content = Get-Content $Path -Raw -Encoding UTF8
if ($null -eq $content) { $content = "" }
if (-not $Override) { $Override = Get-PublicEnvValue $content "DEPLOY_PUBLIC_API_BASE_URL" }
$current = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL"
if ($Override) {
$selected = ConvertTo-PublicApiBaseUrl $Override
if (-not $selected) { throw "Invalid public API base URL: $Override" }
} elseif ($current) {
if (-not (ConvertTo-PublicApiBaseUrl $current)) {
throw "Invalid CONFIG_PUBLIC_API_BASE_URL: $current"
}
return
} else {
$selected = Get-DerivedPublicApiBaseUrl $content
if (-not $selected) {
throw "Cannot derive CONFIG_PUBLIC_API_BASE_URL. Set a full URL such as https://example.com/api"
}
}
$content = Set-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL" $selected
[System.IO.File]::WriteAllText(
(Resolve-Path $Path),
$content,
[System.Text.UTF8Encoding]::new($false)
)
Write-Host " [OK] Public API base URL: $selected" -ForegroundColor Green
}
+92
View File
@@ -0,0 +1,92 @@
function New-SecuritySecret {
$bytes = New-Object byte[] 48
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return [Convert]::ToBase64String($bytes)
}
function New-InitialAdminPassword {
$bytes = New-Object byte[] 10
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return ([BitConverter]::ToString($bytes) -replace "-", "").ToLowerInvariant()
}
function Get-EnvValue {
param([string]$Content, [string]$Key)
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$")
if (-not $match.Success) { return "" }
$value = $match.Groups[1].Value.Trim()
if ($value.Length -ge 2) {
$first = $value.Substring(0, 1)
$last = $value.Substring($value.Length - 1, 1)
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
return $value.Substring(1, $value.Length - 2)
}
}
return $value
}
function Set-EnvValue {
param([string]$Content, [string]$Key, [string]$Value)
$pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?"
$replacement = "$Key=$Value`r`n"
if ([regex]::IsMatch($Content, $pattern)) {
return [regex]::Replace($Content, $pattern, $replacement, 1)
}
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" }
return $Content + $replacement
}
function Initialize-SecurityEnv {
param(
[Parameter(Mandatory = $true)][string]$Path,
[ValidateSet("new", "upgrade")][string]$Mode = "upgrade"
)
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
$content = Get-Content $Path -Raw -Encoding UTF8
if ($null -eq $content) { $content = "" }
$definitions = @(
@{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 }
)
if ($Mode -eq "new") {
$definitions += @(
@{ Key = "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_OTP_HASH_SECRET"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_AUDIT_HASH_PEPPER"; Legacy = ""; MinLength = 32 },
@{ Key = "CONFIG_AUDIT_INTEGRITY_KEY"; Legacy = ""; MinLength = 32 }
)
}
foreach ($definition in $definitions) {
$current = Get-EnvValue $content $definition.Key
$needsInitialization = $current.Length -lt $definition.MinLength -or
($definition.Legacy -and $current -eq $definition.Legacy)
if (-not $needsInitialization) { continue }
$content = Set-EnvValue $content $definition.Key (New-SecuritySecret)
Write-Host " [OK] Initialized security secret: $($definition.Key)" -ForegroundColor Green
}
if ($Mode -eq "new") {
$adminPassword = Get-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD"
if ($adminPassword.Length -lt 12) {
$content = Set-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD" (New-InitialAdminPassword)
Write-Host " [OK] Initialized admin password: CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor Green
}
}
[System.IO.File]::WriteAllText(
(Resolve-Path $Path),
$content,
[System.Text.UTF8Encoding]::new($false)
)
}
+93
View File
@@ -0,0 +1,93 @@
#Requires -Version 5.1
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $scriptDir "Initialize-PublicApiBaseUrl.ps1")
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-public-url-" + [Guid]::NewGuid())
[System.IO.Directory]::CreateDirectory($tempDir) | Out-Null
function Write-TestEnv {
param([string]$Name, [string[]]$Lines)
$path = Join-Path $tempDir $Name
[System.IO.File]::WriteAllText($path, ($Lines -join "`r`n") + "`r`n", [System.Text.UTF8Encoding]::new($false))
return $path
}
function Assert-PublicUrl {
param([string]$Path, [string]$Expected)
$content = Get-Content $Path -Raw -Encoding UTF8
$actual = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL"
if ($actual -ne $Expected) { throw "Expected '$Expected', got '$actual'" }
}
try {
$existing = Write-TestEnv "existing.env" @(
"CONFIG_PUBLIC_API_BASE_URL=https://existing.example.com:8443/api",
"NUXT_PUBLIC_BASE_APIURL=/api"
)
Initialize-PublicApiBaseUrl -Path $existing -Mode upgrade
Assert-PublicUrl $existing "https://existing.example.com:8443/api"
$absolute = Write-TestEnv "absolute.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100"
)
Initialize-PublicApiBaseUrl -Path $absolute -Mode upgrade
Assert-PublicUrl $absolute "http://10.0.0.8:4100"
$websocket = Write-TestEnv "websocket.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=/api",
"NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io"
)
Initialize-PublicApiBaseUrl -Path $websocket -Mode upgrade
Assert-PublicUrl $websocket "https://demo.example.com/api"
$securityOrigin = Write-TestEnv "security-origin.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=/api",
"NUXT_PUBLIC_BASE_SOCKETURL=",
"CONFIG_SECURITY_ORIGIN=http://demo.example.com,https://demo.example.com"
)
Initialize-PublicApiBaseUrl -Path $securityOrigin -Mode upgrade
Assert-PublicUrl $securityOrigin "http://demo.example.com/api"
$fileOverride = Write-TestEnv "file-override.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"DEPLOY_PUBLIC_API_BASE_URL=https://proxy.example.com:9443/custom-api",
"NUXT_PUBLIC_BASE_APIURL=/api"
)
Initialize-PublicApiBaseUrl -Path $fileOverride -Mode upgrade
Assert-PublicUrl $fileOverride "https://proxy.example.com:9443/custom-api"
$explicitOverride = Write-TestEnv "explicit-override.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=/api"
)
Initialize-PublicApiBaseUrl `
-Path $explicitOverride `
-Mode configure `
-Override "http://192.168.1.20:4567"
Assert-PublicUrl $explicitOverride "http://192.168.1.20:4567"
$invalid = Write-TestEnv "invalid.env" @(
"CONFIG_PUBLIC_API_BASE_URL=ftp://bad.example.com/api",
"NUXT_PUBLIC_BASE_APIURL=https://good.example.com/api"
)
$blocked = $false
try { Initialize-PublicApiBaseUrl -Path $invalid -Mode upgrade } catch { $blocked = $true }
if (-not $blocked) { throw "Invalid existing CONFIG_PUBLIC_API_BASE_URL must block startup" }
$unresolved = Write-TestEnv "unresolved.env" @(
"CONFIG_PUBLIC_API_BASE_URL=",
"NUXT_PUBLIC_BASE_APIURL=/api"
)
$blocked = $false
try { Initialize-PublicApiBaseUrl -Path $unresolved -Mode upgrade } catch { $blocked = $true }
if (-not $blocked) { throw "Unresolved public API URL must block startup" }
Write-Host "Public API base URL PowerShell tests passed" -ForegroundColor Green
} finally {
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
# 初始化并持久化后端对外公开 API 地址。不会 source .env,避免执行其中内容。
public_url_read_env_value() {
local file="$1"
local key="$2"
local value
value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")"
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s' "$value"
}
public_url_write_env_value() {
local file="$1"
local key="$2"
local value="$3"
local tmp
tmp="$(mktemp "${file}.tmp.XXXXXX")"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
index($0, key "=") == 1 {
if (!replaced) {
print key "=" value
replaced = 1
}
next
}
{ print }
END {
if (!replaced) print key "=" value
}
' "$file" > "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$file"
}
public_url_normalize() {
local value
value="$(printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
while [ "${value%/}" != "$value" ]; do value="${value%/}"; done
printf '%s' "$value"
}
public_url_validate() {
local value rest authority host port
value="$(public_url_normalize "$1")"
case "$value" in
http://*|https://*) ;;
*) return 1 ;;
esac
case "$value" in
*[[:space:]@?#]*) return 1 ;;
esac
rest="${value#*://}"
authority="${rest%%/*}"
[ -n "$authority" ] || return 1
if [[ "$authority" == \[* ]]; then
[[ "$authority" =~ ^\[[0-9A-Fa-f:.]+\](:[0-9]+)?$ ]] || return 1
if [[ "$authority" == *]:* ]]; then port="${authority##*:}"; fi
else
[[ "$authority" =~ ^[A-Za-z0-9._-]+(:[0-9]+)?$ ]] || return 1
if [[ "$authority" == *:* ]]; then
host="${authority%:*}"
port="${authority##*:}"
[ -n "$host" ] || return 1
fi
fi
if [ -n "${port:-}" ]; then
[[ "$port" =~ ^[0-9]+$ ]] || return 1
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1
fi
}
public_url_derive_from_env() {
local file="$1"
local api_url socket_url security_origin origin scheme combined
api_url="$(public_url_read_env_value "$file" "NUXT_PUBLIC_BASE_APIURL")"
if public_url_validate "$api_url"; then
public_url_normalize "$api_url"
return 0
fi
[[ "$api_url" == /* ]] || return 1
socket_url="$(public_url_read_env_value "$file" "NUXT_PUBLIC_BASE_SOCKETURL")"
if [[ "$socket_url" =~ ^(ws|wss)://([^/?#[:space:]]+) ]]; then
scheme="http"
[ "${BASH_REMATCH[1]}" = "wss" ] && scheme="https"
origin="${scheme}://${BASH_REMATCH[2]}"
fi
if [ -z "${origin:-}" ]; then
security_origin="$(public_url_read_env_value "$file" "CONFIG_SECURITY_ORIGIN")"
security_origin="${security_origin%%,*}"
security_origin="$(public_url_normalize "$security_origin")"
if [[ "$security_origin" =~ ^(https?://[^/]+) ]]; then
origin="${BASH_REMATCH[1]}"
fi
fi
[ -n "${origin:-}" ] || return 1
combined="$(public_url_normalize "${origin}${api_url}")"
public_url_validate "$combined" || return 1
printf '%s' "$combined"
}
init_public_api_base_url() {
local file="${1:-.env}"
local mode="${2:-upgrade}"
local override="${3:-${DEPLOY_PUBLIC_API_BASE_URL:-}}"
local current selected
if [ ! -f "$file" ]; then
echo "❌ 环境配置文件不存在: $file" >&2
return 1
fi
if [ "$mode" != "configure" ] && [ "$mode" != "upgrade" ]; then
echo "❌ 公开 API 地址初始化模式无效: $mode(仅支持 configure/upgrade" >&2
return 1
fi
if [ -z "$override" ]; then
override="$(public_url_read_env_value "$file" "DEPLOY_PUBLIC_API_BASE_URL")"
fi
current="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")"
if [ -n "$override" ]; then
selected="$(public_url_normalize "$override")"
elif [ -n "$current" ]; then
if ! public_url_validate "$current"; then
echo "❌ CONFIG_PUBLIC_API_BASE_URL 配置无效: $current" >&2
return 1
fi
return 0
else
selected="$(public_url_derive_from_env "$file")" || {
echo "❌ 无法从现有前端配置推导 CONFIG_PUBLIC_API_BASE_URL" >&2
echo " 请设置完整地址,例如 https://example.com/api" >&2
return 1
}
fi
if ! public_url_validate "$selected"; then
echo "❌ 公开 API 地址配置无效: $selected" >&2
return 1
fi
selected="$(public_url_normalize "$selected")"
public_url_write_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL" "$selected"
echo " ✓ 已配置公开 API 地址: $selected"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
init_public_api_base_url "${1:-.env}" "${2:-upgrade}" "${3:-}"
fi
+120
View File
@@ -0,0 +1,120 @@
#!/bin/bash
# 为本地 .env 初始化持久化安全密钥。只处理空值和已知历史默认值,
# 已存在的自定义值保持不变;数据库中的 JWT 轮转配置仍由后端优先使用。
generate_security_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 48 | tr -d '\r\n'
return
fi
if [ -r /dev/urandom ]; then
od -An -N48 -tx1 /dev/urandom | tr -d ' \r\n'
return
fi
echo "❌ 无法生成安全随机值:需要 openssl 或 /dev/urandom" >&2
return 1
}
generate_initial_admin_password() {
if command -v openssl >/dev/null 2>&1; then
# 10 random bytes encoded as hexadecimal: exactly 20 alphanumeric characters.
openssl rand -hex 10 | tr -d '\r\n'
return
fi
if [ -r /dev/urandom ]; then
od -An -N10 -tx1 /dev/urandom | tr -d ' \r\n'
return
fi
echo "❌ 无法生成初始管理员密码:需要 openssl 或 /dev/urandom" >&2
return 1
}
read_env_value() {
local file="$1"
local key="$2"
local value
value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")"
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s' "$value"
}
write_env_value() {
local file="$1"
local key="$2"
local value="$3"
local tmp
tmp="$(mktemp "${file}.tmp.XXXXXX")"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
index($0, key "=") == 1 {
if (!replaced) {
print key "=" value
replaced = 1
}
next
}
{ print }
END {
if (!replaced) print key "=" value
}
' "$file" > "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$file"
}
ensure_security_secret() {
local file="$1"
local key="$2"
local legacy_value="${3:-}"
local minimum_length="${4:-32}"
local current
current="$(read_env_value "$file" "$key")"
if [ "${#current}" -ge "$minimum_length" ] && { [ -z "$legacy_value" ] || [ "$current" != "$legacy_value" ]; }; then
return 0
fi
write_env_value "$file" "$key" "$(generate_security_secret)"
echo " ✓ 已初始化安全密钥: ${key}"
}
ensure_initial_admin_password() {
local file="$1"
local current
current="$(read_env_value "$file" "CONFIG_INITIAL_ADMIN_PASSWORD")"
if [ "${#current}" -ge 12 ]; then
return 0
fi
write_env_value "$file" "CONFIG_INITIAL_ADMIN_PASSWORD" "$(generate_initial_admin_password)"
echo " ✓ 已初始化管理员密码: CONFIG_INITIAL_ADMIN_PASSWORD"
}
init_security_env() {
local file="${1:-.env}"
local mode="${2:-upgrade}"
if [ ! -f "$file" ]; then
echo "❌ 环境配置文件不存在: $file" >&2
return 1
fi
if [ "$mode" != "new" ] && [ "$mode" != "upgrade" ]; then
echo "❌ 安全环境初始化模式无效: $mode(仅支持 new/upgrade" >&2
return 1
fi
ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret"
if [ "$mode" = "new" ]; then
ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"
ensure_security_secret "$file" "CONFIG_OTP_HASH_SECRET"
ensure_security_secret "$file" "CONFIG_AUDIT_HASH_PEPPER"
ensure_security_secret "$file" "CONFIG_AUDIT_INTEGRITY_KEY"
ensure_initial_admin_password "$file"
fi
chmod 600 "$file"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
init_security_env "${1:-.env}" "${2:-upgrade}"
fi
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
cp \
"$REPO_ROOT/start.sh" \
"$REPO_ROOT/https.sh" \
"$REPO_ROOT/docker-compose.yml" \
"$REPO_ROOT/.env.sample" \
"$REPO_ROOT/.env.tools.sample" \
"$REPO_ROOT/.env.ASG.sample" \
"$REPO_ROOT/.env.AMS.sample" \
"$REPO_ROOT/easyai-proxy.conf.sample" \
"$TMP_DIR/"
mkdir -p "$TMP_DIR/scripts"
cp \
"$REPO_ROOT/scripts/init-security-env.sh" \
"$REPO_ROOT/scripts/init-public-api-base-url.sh" \
"$TMP_DIR/scripts/"
cd "$TMP_DIR"
reset_case() {
rm -f \
.env \
.env.tools \
.env.ASG \
.env.AMS \
easyai-proxy.conf \
demo.example.com.conf
}
sed -i.bak 's/^SERVER_HTTP_PORT=.*/SERVER_HTTP_PORT=4100/' .env.sample
rm -f .env.sample.bak
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=ip \
DEPLOY_IP=10.0.0.8 \
bash start.sh >/dev/null
grep -qx 'NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100' .env
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=http://10.0.0.8:4100' .env
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
docker compose config --quiet
fi
reset_case
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=domain \
DEPLOY_DOMAIN=demo.example.com \
DEPLOY_HTTPS=false \
bash start.sh >/dev/null
grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=ws://demo.example.com/socket.io' .env
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=http://demo.example.com/api' .env
grep -q 'proxy_set_header X-Forwarded-Host $easyai_forwarded_host;' demo.example.com.conf
grep -q 'proxy_set_header X-Forwarded-Port $server_port;' demo.example.com.conf
grep -q "proxy_set_header X-Original-Prefix '/api';" demo.example.com.conf
grep -q 'location = /api {' demo.example.com.conf
# 直接验证 https.sh 的缺省配置生成函数会采用完整模板,而不是只代理 3010。
eval "$(awk '
/^create_conf_by_template\(\)/ { capture = 1 }
/^conf_contains_domain\(\)/ { capture = 0 }
capture { print }
' https.sh)"
create_conf_by_template demo.example.com easyai-proxy.conf
grep -q 'server_name demo.example.com;' easyai-proxy.conf
grep -q 'location = /api {' easyai-proxy.conf
grep -q 'location /api/ {' easyai-proxy.conf
grep -q 'proxy_pass http://127.0.0.1:3001/;' easyai-proxy.conf
grep -q 'location /socket.io {' easyai-proxy.conf
grep -q 'proxy_set_header X-Forwarded-Host $easyai_forwarded_host;' easyai-proxy.conf
reset_case
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=domain \
DEPLOY_DOMAIN=demo.example.com \
DEPLOY_HTTPS=true \
bash start.sh >/dev/null
grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io' .env
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=https://demo.example.com/api' .env
reset_case
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=ip \
DEPLOY_IP=10.0.0.8 \
DEPLOY_PUBLIC_API_BASE_URL=https://edge.example.com:8443/custom-api \
bash start.sh >/dev/null
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=https://edge.example.com:8443/custom-api' .env
echo "Deployment public URL dry-run tests passed"
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-public-api-base-url.sh
. "${SCRIPT_DIR}/init-public-api-base-url.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_env_value() {
local file="$1"
local expected="$2"
local actual
actual="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")"
[ "$actual" = "$expected" ] || fail "expected $expected, got $actual"
}
write_env() {
local file="$1"
shift
printf '%s\n' "$@" > "$file"
}
existing="$TMP_DIR/existing.env"
write_env "$existing" \
"CONFIG_PUBLIC_API_BASE_URL=https://existing.example.com:8443/api" \
"NUXT_PUBLIC_BASE_APIURL=/api"
init_public_api_base_url "$existing" upgrade >/dev/null
assert_env_value "$existing" "https://existing.example.com:8443/api"
absolute="$TMP_DIR/absolute.env"
write_env "$absolute" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100"
init_public_api_base_url "$absolute" upgrade >/dev/null
assert_env_value "$absolute" "http://10.0.0.8:4100"
websocket="$TMP_DIR/websocket.env"
write_env "$websocket" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=/api" \
"NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io"
init_public_api_base_url "$websocket" upgrade >/dev/null
assert_env_value "$websocket" "https://demo.example.com/api"
security_origin="$TMP_DIR/security-origin.env"
write_env "$security_origin" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=/api" \
"NUXT_PUBLIC_BASE_SOCKETURL=" \
"CONFIG_SECURITY_ORIGIN=http://demo.example.com,https://demo.example.com"
init_public_api_base_url "$security_origin" upgrade >/dev/null
assert_env_value "$security_origin" "http://demo.example.com/api"
file_override="$TMP_DIR/file-override.env"
write_env "$file_override" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"DEPLOY_PUBLIC_API_BASE_URL=https://proxy.example.com:9443/custom-api" \
"NUXT_PUBLIC_BASE_APIURL=/api"
init_public_api_base_url "$file_override" upgrade >/dev/null
assert_env_value "$file_override" "https://proxy.example.com:9443/custom-api"
explicit_override="$TMP_DIR/explicit-override.env"
write_env "$explicit_override" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=/api"
init_public_api_base_url "$explicit_override" configure "http://192.168.1.20:4567" >/dev/null
assert_env_value "$explicit_override" "http://192.168.1.20:4567"
invalid="$TMP_DIR/invalid.env"
write_env "$invalid" \
"CONFIG_PUBLIC_API_BASE_URL=ftp://bad.example.com/api" \
"NUXT_PUBLIC_BASE_APIURL=https://good.example.com/api"
if init_public_api_base_url "$invalid" upgrade >/dev/null 2>&1; then
fail "invalid existing CONFIG_PUBLIC_API_BASE_URL must block startup"
fi
unresolved="$TMP_DIR/unresolved.env"
write_env "$unresolved" \
"CONFIG_PUBLIC_API_BASE_URL=" \
"NUXT_PUBLIC_BASE_APIURL=/api"
if init_public_api_base_url "$unresolved" upgrade >/dev/null 2>&1; then
fail "unresolved public API URL must block startup"
fi
echo "Public API base URL shell tests passed"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# 验证 start.ps1 的 .env 替换逻辑(与 PowerShell 等效)
import re
DEPLOY_IP = "192.168.1.100"
content = """NUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001
#NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=ws://127.0.0.1:3002
NUXT_PUBLIC_SG_APIURL=http://127.0.0.1:3003
"""
content = re.sub(r'^NUXT_PUBLIC_BASE_APIURL=.*', f'NUXT_PUBLIC_BASE_APIURL=http://{DEPLOY_IP}:3001', content, flags=re.MULTILINE)
content = re.sub(r'^NUXT_PUBLIC_BASE_SOCKETURL=.*', f'NUXT_PUBLIC_BASE_SOCKETURL=ws://{DEPLOY_IP}:3002', content, flags=re.MULTILINE)
content = re.sub(r'^NUXT_PUBLIC_SG_APIURL=.*', f'NUXT_PUBLIC_SG_APIURL=http://{DEPLOY_IP}:3003', content, flags=re.MULTILINE)
expected = f"""NUXT_PUBLIC_BASE_APIURL=http://{DEPLOY_IP}:3001
#NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=ws://{DEPLOY_IP}:3002
NUXT_PUBLIC_SG_APIURL=http://{DEPLOY_IP}:3003
"""
# 注释行不应被替换,检查第一行和第三行
lines = content.split('\n')
assert lines[0] == f'NUXT_PUBLIC_BASE_APIURL=http://{DEPLOY_IP}:3001', f"Got {lines[0]}"
assert lines[2] == f'NUXT_PUBLIC_BASE_SOCKETURL=ws://{DEPLOY_IP}:3002', f"Got {lines[2]}"
assert lines[3] == f'NUXT_PUBLIC_SG_APIURL=http://{DEPLOY_IP}:3003', f"Got {lines[3]}"
assert lines[1] == '#NUXT_PUBLIC_BASE_APIURL=/api', f"Comment should not change: {lines[1]}"
print("OK: .env replacement logic validated")
+436
View File
@@ -0,0 +1,436 @@
#Requires -Version 5.1
#
# 从 Git 克隆后首次部署(推荐分行复制执行,避免参数被误传给 git;浅克隆加 --depth 1):
# git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"
# Set-Location ".\easyai"
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
#
# 若必须一行,请用分号分隔整条链,且 -File 后必须是带引号的脚本路径(不要用 Istart.ps1,应为 .\start.ps1):
# git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"; Set-Location "easyai"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
#
# 若出现 error: unknown switch 'E':说明 -ExecutionPolicy 被交给了 git(可复现:git clone URL -ExecutionPolicy)。
# 常见原因:整段命令未用分号分隔、或把 powershell 与 git 写在同一行且参数被 git 解析。
# "Istart.ps1" 多为把 ".\start.ps1" 复制错(点反斜杠被看成字母 I)。
#
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Stop"
$script:Root = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
$script:LogFile = Join-Path $script:Root "start.ps1.log"
$script:DeployDryRun = ($env:DEPLOY_DRY_RUN -eq "1")
$script:DeployIP = ""
$script:SkipDeployQuestions = $false
$script:SecurityEnvMode = "upgrade"
$script:PublicApiBaseUrlCandidate = ""
$script:DockerDesktopUrl = "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe"
function Write-Log {
param([string]$Message)
try {
Add-Content -Path $script:LogFile -Encoding UTF8 -Value ("[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message)
} catch { }
}
function Wait-ForExit {
if ($env:CI -eq "true") { return }
if ($env:DEPLOY_NO_WAIT -eq "1") { return }
Write-Host ""
Read-Host "Press Enter to exit"
}
trap {
$msg = $_.Exception.Message
Write-Log "FATAL: $msg"
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host "Script failed. Log file: $script:LogFile" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
Write-Host $msg -ForegroundColor Red
Wait-ForExit
exit 1
}
function Step { param([string]$Message) Write-Host $Message }
function Ok { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green }
function Warn { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow }
function Fail { param([string]$Message) Write-Host "[ERR] $Message" -ForegroundColor Red; throw $Message }
function Init-ProjectDir {
$composePath = Join-Path $script:Root "docker-compose.yml"
if (-not (Test-Path $composePath)) {
Fail "docker-compose.yml not found. Please run this script in easyai directory."
}
Set-Location $script:Root
Step "Project directory: $script:Root"
}
function Ensure-FileFromSample {
param([string]$Target,[string]$Sample)
if (Test-Path $Target) { return }
if (-not (Test-Path $Sample)) { Fail "Missing sample file: $Sample" }
Copy-Item $Sample $Target
Ok "$Target created"
}
function Get-DetectedLanIp {
try {
$ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object {
$_.IPAddress -notmatch "^127\." -and
$_.IPAddress -notmatch "^169\.254\." -and
$_.InterfaceAlias -notmatch "Loopback"
} | Sort-Object InterfaceIndex | Select-Object -First 1 -ExpandProperty IPAddress
return $ip
} catch {
return $null
}
}
function Run-DeployQuestions {
Write-Host ""
Write-Host "================================"
Write-Host " EasyAI Deployment Config"
Write-Host "================================"
Write-Host ""
if ($env:DEPLOY_IP) {
$script:DeployIP = $env:DEPLOY_IP.Trim()
Step "Using DEPLOY_IP=$($script:DeployIP)"
return
}
if ($env:DEPLOY_ACCESS) {
$mode = $env:DEPLOY_ACCESS.Trim().ToLowerInvariant()
switch ($mode) {
{ $_ -in @("local", "localhost", "127.0.0.1") } {
$script:DeployIP = "127.0.0.1"
Step "Using DEPLOY_ACCESS=$($env:DEPLOY_ACCESS), IP=$($script:DeployIP)"
return
}
{ $_ -in @("lan", "ip") } {
Fail "DEPLOY_ACCESS=$($env:DEPLOY_ACCESS) requires DEPLOY_IP, for example: `$env:DEPLOY_IP='192.168.1.10'"
}
default {
Fail "DEPLOY_ACCESS only supports local or lan on Windows, current value: $($env:DEPLOY_ACCESS)"
}
}
}
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
Fail "Non-interactive deployment requires DEPLOY_IP or DEPLOY_ACCESS=local."
}
Write-Host "Choose mode:"
Write-Host " [1] Local only (127.0.0.1)"
Write-Host " [2] LAN access"
$choice = Read-Host "Select [1/2]"
switch ($choice) {
"1" { $script:DeployIP = "127.0.0.1"; Step "Selected local mode" }
"2" {
$detected = Get-DetectedLanIp
if ($detected) {
Write-Host "Detected LAN IP: $detected"
$inputIp = (Read-Host "Input LAN IP (Enter to use detected)").Trim()
$script:DeployIP = if ([string]::IsNullOrWhiteSpace($inputIp)) { $detected } else { $inputIp }
} else {
$inputIp = (Read-Host "Input LAN IP").Trim()
if ([string]::IsNullOrWhiteSpace($inputIp)) { Fail "IP cannot be empty." }
$script:DeployIP = $inputIp
}
Warn "Allow firewall ports: 3001, 3002, 3003"
}
default { Fail "Invalid selection." }
}
}
function Upsert-Env {
param([string]$Content,[string]$Key,[string]$Value)
$line = "$Key=$Value"
$pattern = "(?m)^$Key=.*$"
if ($Content -match $pattern) { return ($Content -replace $pattern, $line) }
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`n" }
return ($Content + $line + "`n")
}
function Setup-EnvFiles {
Write-Host ""
Step "Configuring env files..."
Ensure-FileFromSample ".env.tools" ".env.tools.sample"
Ensure-FileFromSample ".env.ASG" ".env.ASG.sample"
Ensure-FileFromSample ".env.AMS" ".env.AMS.sample"
if (-not (Test-Path ".env")) { $script:SecurityEnvMode = "new" }
Ensure-FileFromSample ".env" ".env.sample"
$content = Get-Content ".env" -Raw -Encoding UTF8
if (-not $content) { $content = "" }
$serverPortMatch = [regex]::Match($content, '(?m)^SERVER_HTTP_PORT=(\d+)$')
$serverPort = if ($serverPortMatch.Success) { $serverPortMatch.Groups[1].Value } else { "3001" }
$publicApiBaseUrl = "http://$($script:DeployIP):$serverPort"
$content = Upsert-Env $content "NUXT_PUBLIC_BASE_APIURL" $publicApiBaseUrl
$content = Upsert-Env $content "NUXT_PUBLIC_BASE_SOCKETURL" "ws://$($script:DeployIP):3002"
$content = Upsert-Env $content "NUXT_PUBLIC_SG_APIURL" "http://$($script:DeployIP):3003"
$webPortMatch = [regex]::Match($content, '(?m)^WEB_PORT=(\d+)$')
$webPort = if ($webPortMatch.Success) { $webPortMatch.Groups[1].Value } else { "3010" }
$content = Upsert-Env $content "CONFIG_SECURITY_ORIGIN" "http://$($script:DeployIP):$webPort"
$script:PublicApiBaseUrlCandidate = if ($env:DEPLOY_PUBLIC_API_BASE_URL) {
$env:DEPLOY_PUBLIC_API_BASE_URL
} else {
$publicApiBaseUrl
}
[System.IO.File]::WriteAllText((Join-Path $script:Root ".env"), $content, [System.Text.UTF8Encoding]::new($false))
Ok ".env configured for IP=$($script:DeployIP)"
}
function Test-DockerInstalled {
$docker = Get-Command docker -ErrorAction SilentlyContinue
if (-not $docker) { return $false }
try { & docker --version *> $null; return $true } catch { return $false }
}
function Test-DockerRunning {
param([int]$TimeoutSeconds = 10)
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "docker"
$psi.Arguments = "info"
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$p = [System.Diagnostics.Process]::Start($psi)
$ok = $p.WaitForExit($TimeoutSeconds * 1000)
if (-not $ok) { try { $p.Kill() } catch { }; return $false }
return ($p.ExitCode -eq 0)
} catch {
return $false
}
}
function Start-DockerDesktopAndWait {
param([int]$MaxWaitSeconds = 120, [int]$CheckIntervalSeconds = 5)
Step "Docker not running, trying to start Docker Desktop..."
$path = Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe"
if (-not (Test-Path $path)) { $path = Join-Path ${env:ProgramFiles(x86)} "Docker\Docker\Docker Desktop.exe" }
if (-not (Test-Path $path)) { Warn "Docker Desktop executable not found."; return $false }
$startedByCli = $false
try {
& docker desktop start *> $null
if ($LASTEXITCODE -eq 0) { $startedByCli = $true; Step "Sent docker desktop start command" }
} catch { }
if (-not $startedByCli) { Start-Process -FilePath $path -WindowStyle Hidden }
$elapsed = 0
while ($elapsed -lt $MaxWaitSeconds) {
Start-Sleep -Seconds $CheckIntervalSeconds
$elapsed += $CheckIntervalSeconds
if (Test-DockerRunning) { Ok "Docker engine ready"; return $true }
}
Warn "Timeout waiting for Docker."
return $false
}
function Ensure-DockerRunning {
if (-not (Test-DockerInstalled)) { return $false }
if (Test-DockerRunning) { Ok "Docker engine running"; return $true }
return (Start-DockerDesktopAndWait)
}
function Install-DockerDesktop {
function Test-Winget {
return [bool](Get-Command winget -ErrorAction SilentlyContinue)
}
function Test-Choco {
return [bool](Get-Command choco -ErrorAction SilentlyContinue)
}
if (Test-Winget) {
Step "Installing Docker Desktop via winget..."
Write-Log "winget install Docker.DockerDesktop"
$wingetOut = & winget install --id Docker.DockerDesktop -e --accept-source-agreements --accept-package-agreements 2>&1
$wingetOut | ForEach-Object { Write-Host $_ }
$wingetCode = $LASTEXITCODE
Write-Log "winget exit code: $wingetCode"
if ($wingetCode -eq 0) {
Ok "Install started. Reopen terminal and rerun script."
Write-Log "winget reported success"
exit 0
}
if (Test-DockerInstalled) {
Ok "Docker CLI detected after winget. Reopen terminal if docker commands fail, then rerun script."
Write-Log "winget exit $wingetCode but docker is available"
exit 0
}
Warn "winget did not report success (exit code: $wingetCode). Trying Chocolatey if available..."
} else {
Warn "winget not found (install 'App Installer' from Microsoft Store or use Windows 11 / recent Windows 10). Trying Chocolatey if available..."
}
if (Test-Choco) {
Step "Installing Docker Desktop via choco..."
Write-Log "choco install docker-desktop"
$chocoOut = & choco install docker-desktop -y 2>&1
$chocoOut | ForEach-Object { Write-Host $_ }
$chocoCode = $LASTEXITCODE
Write-Log "choco exit code: $chocoCode"
if ($chocoCode -eq 0) {
Ok "Install started. Reopen terminal and rerun script."
exit 0
}
if (Test-DockerInstalled) {
Ok "Docker CLI detected after choco. Reopen terminal if docker commands fail, then rerun script."
exit 0
}
Warn "choco did not report success (exit code: $chocoCode)."
} else {
Warn "Chocolatey (choco) not found."
}
Warn "Automatic install was not completed. Please install Docker Desktop manually:"
Write-Host " $script:DockerDesktopUrl"
Write-Log "Docker auto-install failed (winget/choco unavailable or non-zero exit)"
Wait-ForExit
exit 1
}
function Test-Docker {
Write-Host ""
Write-Host "================================"
Write-Host " Docker Check"
Write-Host "================================"
Write-Host ""
if (Test-DockerInstalled) {
Ok "Docker installed"
if (-not (Ensure-DockerRunning)) { Warn "Docker failed to start automatically."; Wait-ForExit; exit 1 }
return
}
Warn "Docker Desktop not detected."
$installMode = if ($env:DEPLOY_DOCKER_INSTALL) { $env:DEPLOY_DOCKER_INSTALL.Trim().ToLowerInvariant() } else { "" }
if (-not [string]::IsNullOrWhiteSpace($installMode)) {
switch ($installMode) {
{ $_ -in @("manual", "1") } { Start-Process $script:DockerDesktopUrl; Wait-ForExit; exit 1 }
{ $_ -in @("auto", "2") } { Install-DockerDesktop }
default { Fail "DEPLOY_DOCKER_INSTALL only supports manual or auto, current value: $($env:DEPLOY_DOCKER_INSTALL)" }
}
}
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
Fail "Docker Desktop not detected. Set DEPLOY_DOCKER_INSTALL=manual or DEPLOY_DOCKER_INSTALL=auto, or install Docker Desktop first."
}
Write-Host "Choose:"
Write-Host " [1] Manual install (open URL and exit)"
Write-Host " [2] Auto install (winget/choco)"
$choice = (Read-Host "Select [1/2]").Trim()
switch ($choice) {
"1" { Start-Process $script:DockerDesktopUrl; Wait-ForExit; exit 1 }
"2" { Install-DockerDesktop }
default { Fail "Invalid selection." }
}
}
function Start-Services {
Write-Host ""
Step "Starting EasyAI services..."
$useComposeV2 = $false
try { & docker compose version *> $null; if ($LASTEXITCODE -eq 0) { $useComposeV2 = $true } } catch { }
if ($useComposeV2) {
docker compose pull
if ($LASTEXITCODE -ne 0) { Fail "docker compose pull failed." }
docker compose up -d
if ($LASTEXITCODE -ne 0) { Fail "docker compose up failed." }
} else {
docker-compose pull
if ($LASTEXITCODE -ne 0) { Fail "docker-compose pull failed." }
docker-compose up -d
if ($LASTEXITCODE -ne 0) { Fail "docker-compose up failed." }
}
Ok "EasyAI started"
}
function Main {
Init-ProjectDir
if ((Test-Path ".env") -and -not $env:DEPLOY_FORCE_RECONFIG -and -not $env:DEPLOY_IP -and -not $env:DEPLOY_ACCESS) {
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
$line = Get-Content ".env" -Encoding UTF8 | Where-Object { $_ -like "NUXT_PUBLIC_BASE_APIURL=*" } | Select-Object -First 1
if ($line -match '^NUXT_PUBLIC_BASE_APIURL=http://([^/:]+)(?::\d+)?(?:/.*)?$') { $script:DeployIP = $Matches[1] }
$script:SkipDeployQuestions = $true
Step "Using existing env config"
} else {
$answer = (Read-Host "Existing .env found. Reconfigure? [y/N]").Trim().ToLower()
$yes = ($answer.Length -gt 0) -and ($answer[0] -eq [char]121)
if (-not $yes) {
$line = Get-Content ".env" -Encoding UTF8 | Where-Object { $_ -like "NUXT_PUBLIC_BASE_APIURL=*" } | Select-Object -First 1
if ($line -match '^NUXT_PUBLIC_BASE_APIURL=http://([^/:]+)(?::\d+)?(?:/.*)?$') { $script:DeployIP = $Matches[1] }
$script:SkipDeployQuestions = $true
Step "Using existing env config"
}
}
}
if (-not $script:SkipDeployQuestions) {
Run-DeployQuestions
Setup-EnvFiles
} else {
Ensure-FileFromSample ".env.tools" ".env.tools.sample"
Ensure-FileFromSample ".env.ASG" ".env.ASG.sample"
Ensure-FileFromSample ".env.AMS" ".env.AMS.sample"
}
. (Join-Path $script:Root "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $script:Root ".env") -Mode $script:SecurityEnvMode
. (Join-Path $script:Root "scripts\Initialize-PublicApiBaseUrl.ps1")
$publicUrlMode = if ($script:SkipDeployQuestions) { "upgrade" } else { "configure" }
Initialize-PublicApiBaseUrl `
-Path (Join-Path $script:Root ".env") `
-Mode $publicUrlMode `
-Override $script:PublicApiBaseUrlCandidate
if ($script:DeployDryRun) {
Warn "dry-run mode: skip docker and services"
} else {
Test-Docker
Start-Services
}
$ip = if ($script:DeployIP) { $script:DeployIP } else { "127.0.0.1" }
Write-Host ""
if ($script:DeployDryRun) {
Write-Host "================================" -ForegroundColor Yellow
Write-Host " 配置已生成(未启动 Docker 服务)" -ForegroundColor Yellow
Write-Host "================================" -ForegroundColor Yellow
Write-Host ""
Write-Host " 预期访问: " -NoNewline; Write-Host "http://${ip}:3010" -ForegroundColor Cyan
Write-Host ""
Write-Host " 初始管理员: admin;随机密码保存在 .env 的 CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor DarkGray
} else {
Write-Host "================================" -ForegroundColor Green
Write-Host " 部署成功" -ForegroundColor Green
Write-Host "================================" -ForegroundColor Green
Write-Host ""
Write-Host " 访问地址: " -NoNewline; Write-Host "http://${ip}:3010" -ForegroundColor Cyan
Write-Host ""
Write-Host " -------- 初始管理员(首次登录后请修改密码)--------" -ForegroundColor Yellow
Write-Host " 账号: " -NoNewline; Write-Host "admin" -ForegroundColor White
Write-Host " 密码位置: " -NoNewline; Write-Host ".env -> CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor White
}
Write-Host ""
Wait-ForExit
}
Write-Log "=== script start ==="
Write-Host "EasyAI deploy script starting..." -ForegroundColor Cyan
Write-Host "Log file: $script:LogFile"
Write-Host ""
Main
Write-Log "=== script end ==="
+504 -147
View File
@@ -1,186 +1,543 @@
#!/bin/bash
# EasyAI 一键部署脚本
# 支持交互式问答配置,兼容 IP 与域名两种访问方式
# 一行命令: git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git && cd easyai && chmod +x ./start.sh && ./start.sh
set -e # 发生错误时终止脚本执行
set -e
echo "==========================="
echo "🚀 开始自动安装 Docker 和 Docker Compose"
echo "==========================="
# 仅配置模式(验证用):DEPLOY_DRY_RUN=1 只生成配置文件,不执行 Docker 安装和启动
DEPLOY_DRY_RUN="${DEPLOY_DRY_RUN:-0}"
# 获取操作系统类型和版本
OS_FAMILY=$(hostnamectl | grep "Operating System" | awk '{print $3}')
OS_VERSION_ID=$(grep -oP '(?<=^VERSION_ID=")[0-9.]+' /etc/os-release | cut -d'.' -f1)
# ==================== 项目初始化 ====================
init_project_dir() {
local script_source
script_source="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
if [ -f "${script_source}/docker-compose.yml" ]; then
echo "📁 项目目录: ${script_source}"
cd "$script_source"
return 0
fi
echo "❌ 未找到 docker-compose.yml,请在 easyai 项目目录下运行 start.sh"
echo " 启动命令: git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git && cd easyai && chmod +x ./start.sh && ./start.sh"
exit 1
}
# ==================== 配置变量(支持环境变量非交互模式) ====================
DEPLOY_ACCESS="${DEPLOY_ACCESS:-}" # ip | domain
DEPLOY_MODE="" # ip | domain | skip
DEPLOY_IP="${DEPLOY_IP:-}"
DEPLOY_DOMAIN="${DEPLOY_DOMAIN:-}"
DEPLOY_HTTPS_ENV="${DEPLOY_HTTPS_INPUT:-${DEPLOY_HTTPS:-}}"
DEPLOY_HTTPS=false
SECURITY_ENV_MODE="upgrade"
PUBLIC_API_BASE_URL_CANDIDATE=""
is_non_interactive() {
[ "${DEPLOY_NON_INTERACTIVE:-0}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ]
}
require_interactive() {
local missing_hint=$1
if is_non_interactive; then
echo "❌ 当前为非交互环境,无法继续问答配置"
echo " 请通过环境变量传入: ${missing_hint}"
exit 1
fi
}
normalize_bool() {
local value
value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "$value" in
1|true|yes|y|on) echo "true" ;;
0|false|no|n|off|"") echo "false" ;;
*)
echo "❌ 布尔变量值不合法: $1(支持 true/false/1/0/yes/no/y/n/on/off" >&2
return 1
;;
esac
}
set_deploy_https_from_env_or_default() {
local normalized
if ! normalized="$(normalize_bool "${DEPLOY_HTTPS_ENV:-false}")"; then
exit 1
fi
DEPLOY_HTTPS="$normalized"
}
infer_deploy_access_from_env() {
if [ -z "$DEPLOY_ACCESS" ]; then
if [ -n "$DEPLOY_DOMAIN" ]; then
DEPLOY_ACCESS="domain"
elif [ -n "$DEPLOY_IP" ]; then
DEPLOY_ACCESS="ip"
fi
fi
}
sanitize_domain() {
local domain="$1"
domain="${domain#http://}"
domain="${domain#https://}"
domain="${domain%%/*}"
domain="${domain%;}"
echo "$domain"
}
validate_domain() {
local domain="$1"
# 基础域名校验:仅允许字母、数字、连字符和点;必须包含至少一个点;不能以点或连字符开头结尾
if [[ ! "$domain" =~ ^[A-Za-z0-9.-]+$ ]]; then
return 1
fi
if [[ "$domain" != *.* ]]; then
return 1
fi
if [[ "$domain" =~ ^[.-] || "$domain" =~ [.-]$ ]]; then
return 1
fi
if [[ "$domain" == *".."* ]]; then
return 1
fi
return 0
}
prompt_or_env() {
local var_name=$1
local prompt_text=$2
local env_name=$3
local default=$4
if [ -n "${!env_name}" ]; then
eval "$var_name=\"${!env_name}\""
return
fi
require_interactive "$env_name"
if [ -n "$default" ]; then
read -r -p "${prompt_text} [$default]: " input
eval "$var_name=\"${input:-$default}\""
else
read -r -p "${prompt_text}: " input
eval "$var_name=\"$input\""
fi
}
run_deploy_questions() {
echo ""
echo "================================"
echo " EasyAI 部署配置(问答模式)"
echo "================================"
echo ""
infer_deploy_access_from_env
# 非交互模式:环境变量已完整设置则直接使用(CI/自动化部署)
if [ -n "$DEPLOY_ACCESS" ]; then
if [ "$DEPLOY_ACCESS" = "ip" ] && [ -n "$DEPLOY_IP" ]; then
DEPLOY_MODE="ip"
echo "使用环境变量: IP 模式, IP=$DEPLOY_IP"
return
fi
if [ "$DEPLOY_ACCESS" = "domain" ] && [ -n "$DEPLOY_DOMAIN" ]; then
DEPLOY_DOMAIN="$(sanitize_domain "$DEPLOY_DOMAIN")"
if ! validate_domain "$DEPLOY_DOMAIN"; then
echo "❌ 环境变量 DEPLOY_DOMAIN 格式不合法: ${DEPLOY_DOMAIN}"
exit 1
fi
DEPLOY_MODE="domain"
set_deploy_https_from_env_or_default
echo "使用环境变量: 域名模式, 域名=$DEPLOY_DOMAIN"
return
fi
if [ "$DEPLOY_ACCESS" != "ip" ] && [ "$DEPLOY_ACCESS" != "domain" ]; then
echo "❌ DEPLOY_ACCESS 只能是 ip 或 domain,当前为: $DEPLOY_ACCESS"
exit 1
fi
fi
# 1. IP 或域名访问
if [ -z "$DEPLOY_ACCESS" ]; then
require_interactive "DEPLOY_ACCESS=ip DEPLOY_IP=<服务器IP> 或 DEPLOY_ACCESS=domain DEPLOY_DOMAIN=<域名>"
echo "1. 通过 IP 地址还是域名访问?"
echo " [1] IP 地址(需开放 3001、3002、3003 端口)"
echo " [2] 域名"
read -r -p "请选择 [1/2]: " choice
case "$choice" in
1) DEPLOY_MODE="ip" ;;
2) DEPLOY_MODE="domain" ;;
*) echo "❌ 无效选择"; exit 1 ;;
esac
else
DEPLOY_MODE="$DEPLOY_ACCESS"
fi
if [ "$DEPLOY_MODE" = "ip" ]; then
# 2. 输入服务器 IP
prompt_or_env DEPLOY_IP "2. 请输入服务器 IP 地址" "DEPLOY_IP" ""
if [ -z "$DEPLOY_IP" ]; then
echo "❌ IP 不能为空"
exit 1
fi
echo " 请确保防火墙已放行 3001、3002、3003 端口"
else
# 3. 输入域名
prompt_or_env DEPLOY_DOMAIN "3. 请输入域名(不含 https:// 前缀,如 51easyai.com" "DEPLOY_DOMAIN" ""
DEPLOY_DOMAIN="$(sanitize_domain "$DEPLOY_DOMAIN")"
if [ -z "$DEPLOY_DOMAIN" ]; then
echo "❌ 域名不能为空"
exit 1
fi
if ! validate_domain "$DEPLOY_DOMAIN"; then
echo "❌ 域名格式不合法: ${DEPLOY_DOMAIN}"
echo " 请输入类似 51easyai.com 的域名,不要包含协议、路径或分号"
exit 1
fi
# 3.1 是否启用 HTTPS
if [ -n "$DEPLOY_HTTPS_ENV" ]; then
set_deploy_https_from_env_or_default
else
require_interactive "DEPLOY_HTTPS=true 或 DEPLOY_HTTPS=false"
read -r -p "3.1 是否签名 HTTPS 证书?原来已经有 HTTPS 证书这里选择 N,第一次部署或者原来没有证书选 Y [y/N]: " https_choice
DEPLOY_HTTPS=false
if [[ "$https_choice" =~ ^[yY] ]]; then
DEPLOY_HTTPS=true
fi
fi
if [ "$DEPLOY_HTTPS" = true ]; then
echo " 启用 HTTPS 需确保防火墙已放行 80、443 端口"
fi
fi
}
# ==================== 生成配置文件 ====================
setup_env_files() {
echo ""
echo "📝 配置环境文件..."
# 6. 复制 .env.tools、.env.ASG、.env.AMS(无 example 后缀的从 .sample 生成)
if [ ! -f .env.tools ]; then
cp .env.tools.sample .env.tools
echo " ✓ .env.tools"
fi
if [ ! -f .env.ASG ]; then
cp .env.ASG.sample .env.ASG
echo " ✓ .env.ASG"
fi
if [ ! -f .env.AMS ]; then
cp .env.AMS.sample .env.AMS
echo " ✓ .env.AMS"
fi
# 4/5. 配置 .env
if [ ! -f .env ]; then
cp .env.sample .env
SECURITY_ENV_MODE="new"
fi
local web_port server_port public_scheme socket_scheme
web_port="$(awk -F= '$1 == "WEB_PORT" { print $2; exit }' .env | tr -d '[:space:]')"
web_port="${web_port:-3010}"
server_port="$(awk -F= '$1 == "SERVER_HTTP_PORT" { print $2; exit }' .env | tr -d '[:space:]')"
server_port="${server_port:-3001}"
if [ "$DEPLOY_MODE" = "ip" ]; then
# IP 模式
sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=http://${DEPLOY_IP}:${server_port}|" .env
sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=ws://${DEPLOY_IP}:3002|" .env
sed -i.bak "s|^NUXT_PUBLIC_SG_APIURL=.*|NUXT_PUBLIC_SG_APIURL=http://${DEPLOY_IP}:3003|" .env
sed -i.bak "s|^CONFIG_SECURITY_ORIGIN=.*|CONFIG_SECURITY_ORIGIN=http://${DEPLOY_IP}:${web_port}|" .env
PUBLIC_API_BASE_URL_CANDIDATE="${DEPLOY_PUBLIC_API_BASE_URL:-http://${DEPLOY_IP}:${server_port}}"
echo " ✓ .env 已配置为 IP 模式 (${DEPLOY_IP})"
else
# 域名模式
public_scheme="http"
socket_scheme="ws"
if [ "$DEPLOY_HTTPS" = true ]; then
public_scheme="https"
socket_scheme="wss"
fi
sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=/api|" .env
sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=${socket_scheme}://${DEPLOY_DOMAIN}/socket.io|" .env
sed -i.bak "s|^NUXT_PUBLIC_SG_APIURL=.*|NUXT_PUBLIC_SG_APIURL=/asg-api|" .env
# 域名模式同时允许同域 HTTP/HTTPS,兼容首次签证书和复用既有证书两种流程。
sed -i.bak "s|^CONFIG_SECURITY_ORIGIN=.*|CONFIG_SECURITY_ORIGIN=https://${DEPLOY_DOMAIN},http://${DEPLOY_DOMAIN}|" .env
PUBLIC_API_BASE_URL_CANDIDATE="${DEPLOY_PUBLIC_API_BASE_URL:-${public_scheme}://${DEPLOY_DOMAIN}/api}"
echo " ✓ .env 已配置为域名模式 (${DEPLOY_DOMAIN})"
# 7. Nginx 配置(域名模式)
PROXY_CONF="${DEPLOY_DOMAIN}.conf"
if [ ! -f "$PROXY_CONF" ]; then
local proxy_template="easyai-proxy.conf.sample"
if [ ! -f "$proxy_template" ]; then
echo "❌ 未找到 Nginx 配置模板: easyai-proxy.conf.sample"
echo " 请确认仓库文件完整后重试"
exit 1
fi
sed "s/51easyai.com/${DEPLOY_DOMAIN}/g" "$proxy_template" > "$PROXY_CONF"
echo " ✓ Nginx 配置已生成: $PROXY_CONF"
fi
fi
rm -f .env.bak
}
# ==================== Docker 安装(复用原 start.sh 逻辑) ====================
install_docker() {
echo ""
echo "================================"
echo " Docker 安装与检查"
echo "================================"
local host_os
host_os="$(uname -s 2>/dev/null || echo Unknown)"
if [[ "$host_os" == "Darwin" ]]; then
OS_FAMILY="macOS"
OS_VERSION_ID="0"
OS_CODENAME=""
elif [ -f /etc/os-release ]; then
. /etc/os-release
case "${ID:-}" in
ubuntu|debian) OS_FAMILY="Ubuntu" ;;
centos|rhel|fedora) OS_FAMILY="CentOS" ;;
*) OS_FAMILY="${ID:-Unknown}" ;;
esac
OS_VERSION_ID=$(grep -oP '(?<=^VERSION_ID=")[0-9.]+' /etc/os-release 2>/dev/null | cut -d'.' -f1 || echo "0")
OS_CODENAME=""
if [[ "$OS_FAMILY" == "Ubuntu" ]]; then
OS_CODENAME=$(lsb_release -cs)
OS_CODENAME=$(lsb_release -cs 2>/dev/null || (grep VERSION_CODENAME /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"'))
fi
# 定义国内镜像源
# Ubuntu 镜像源
UBUNTU_DOCKER_MIRROR_URL="https://mirrors.ustc.edu.cn/docker-ce" # 中科大
UBUNTU_DOCKER_GPG_URL="https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu/gpg"
# CentOS 镜像源 (选择一个即可,这里提供清华和阿里云)
CENTOS_DOCKER_MIRROR_URL="https://mirrors.tuna.tsinghua.edu.cn/docker-ce" # 清华大学
# CENTOS_DOCKER_MIRROR_URL="https://mirrors.aliyun.com/docker-ce" # 阿里云 - 如果清华源不稳定可以尝试这个
# 函数:检查并等待网络连接
check_network() {
echo "🌐 检查网络连接到 Docker 官方仓库 (备用)..."
if curl -sSf https://download.docker.com/ &> /dev/null; then
echo "✅ 网络连接到 Docker 官方仓库正常。"
return 0
else
echo "⚠️ 无法连接到 Docker 官方仓库。将尝试使用国内镜像源。"
# 即使无法连接官方源,也继续尝试国内源
OS_FAMILY=$(hostnamectl 2>/dev/null | grep "Operating System" | awk '{print $3}' || echo "Unknown")
OS_VERSION_ID="0"
OS_CODENAME=""
fi
UBUNTU_DOCKER_MIRROR_URL="https://mirrors.ustc.edu.cn/docker-ce"
UBUNTU_DOCKER_GPG_URL="https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu/gpg"
CENTOS_DOCKER_MIRROR_URL="https://mirrors.tuna.tsinghua.edu.cn/docker-ce"
check_network() {
if curl -sSf --connect-timeout 5 https://download.docker.com/ &>/dev/null; then
echo "✅ 网络连接正常"
return 0
fi
echo "⚠️ 将使用国内镜像源"
return 0
}
# 预检网络连接(非阻塞,仅作为提示)
check_network
# Docker 安装
install_docker_macos() {
if ! command -v brew &>/dev/null; then
echo "❌ 未检测到 Homebrew,无法自动安装 Docker Desktop。"
echo " 请先安装 Homebrew: https://brew.sh/"
return 1
fi
if brew list --cask docker &>/dev/null; then
echo "✅ Docker Desktop 已安装(Homebrew"
else
echo "📦 正在通过 Homebrew 安装 Docker Desktop..."
brew install --cask docker
fi
echo "🚀 正在尝试启动 Docker Desktop..."
open -a Docker || true
echo "⏳ 等待 Docker 引擎就绪(最多 120 秒)..."
local i
for i in $(seq 1 60); do
if docker info &>/dev/null; then
echo "✅ Docker Desktop 已启动并可用"
return 0
fi
sleep 2
done
echo "⚠️ Docker Desktop 已安装,但尚未就绪。"
echo " 请手动打开 Docker Desktop,待状态正常后重新运行 ./start.sh"
return 1
}
if command -v docker &>/dev/null; then
echo "✅ Docker 已安装,跳过安装步骤"
echo "✅ Docker 已安装"
else
if [[ "$OS_FAMILY" == "Ubuntu" ]]; then
echo "📦 安装依赖 (Ubuntu)..."
sudo apt update -y
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common gnupg lsb-release
echo "🔑 添加 Docker GPG 密钥 (Ubuntu) - 优先使用国内镜像源..."
# 现代 Ubuntu 推荐使用 gpg --dearmor
sudo apt install -y apt-transport-https ca-certificates curl gnupg lsb-release
sudo install -m 0755 -d /etc/apt/keyrings
if ! curl -fsSL "$UBUNTU_DOCKER_GPG_URL" | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg; then
echo "❌ 无法从国内镜像源获取 GPG 密钥,尝试从官方源获取..."
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg || {
echo "❌ 无法获取 Docker GPG 密钥,请检查网络或 GPG 密钥 URL。"
exit 1
}
fi
sudo chmod a+r /etc/apt/keyrings/docker.gpg # 确保可读权限
echo "🌍 添加 Docker 源 (Ubuntu) - 使用国内镜像源..."
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $UBUNTU_DOCKER_MIRROR_URL/linux/ubuntu \
$OS_CODENAME stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
curl -fsSL "$UBUNTU_DOCKER_GPG_URL" | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null || \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $UBUNTU_DOCKER_MIRROR_URL/linux/ubuntu $OS_CODENAME stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update -y
echo "🐳 安装 Docker (Ubuntu)..."
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || {
echo "❌ Docker 或其组件安装失败。请检查错误日志或尝试手动安装。"
exit 1
}
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
elif [[ "$OS_FAMILY" == "CentOS" ]]; then
echo "📦 安装依赖 (CentOS)..."
sudo yum install -y yum-utils device-mapper-persistent-data lvm2
echo "🌍 添加 Docker 源 (CentOS) - 使用国内镜像源..."
# 添加 Docker CE 官方 repo
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
# 替换为国内镜像源
echo "🔄 替换 Docker 源为国内镜像 ($CENTOS_DOCKER_MIRROR_URL)..."
sudo sed -i "s+https://download.docker.com+$CENTOS_DOCKER_MIRROR_URL+" /etc/yum.repos.d/docker-ce.repo || {
echo "❌ 替换 Docker 源失败,可能 repo 文件路径不正确或无权限。"
exit 1
}
# CentOS 8+ 可能会遇到 module 冲突,禁用默认的 container-tools 模块
if [[ "$OS_VERSION_ID" -ge "8" ]]; then
echo "⚙️ 禁用 CentOS 8+ 默认的 container-tools 模块以避免冲突..."
sudo yum module disable -y container-tools
sudo yum module enable -y container-tools:docker
sudo sed -i "s+https://download.docker.com+$CENTOS_DOCKER_MIRROR_URL+g" /etc/yum.repos.d/docker-ce.repo
[[ "$OS_VERSION_ID" -ge "8" ]] && sudo yum module disable -y container-tools 2>/dev/null || true
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
elif [[ "$OS_FAMILY" == "macOS" ]]; then
echo "⚠️ 检测到 macOS,未找到 Docker。"
mac_install_choice="${DEPLOY_MAC_DOCKER_INSTALL:-${DEPLOY_DOCKER_INSTALL:-}}"
if [ -z "$mac_install_choice" ]; then
require_interactive "DEPLOY_MAC_DOCKER_INSTALL=auto 或 DEPLOY_MAC_DOCKER_INSTALL=manual"
echo "请选择安装方式:"
echo " [1] 自动安装 Docker Desktop(需要 Homebrew"
echo " [2] 手动安装(默认)"
read -r -p "请选择 [1/2]: " mac_install_choice
fi
sudo yum makecache
echo "🐳 安装 Docker (CentOS)..."
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || {
echo "❌ Docker 或其组件安装失败。请检查错误日志或尝试手动安装。"
echo "提示:如果仍遇到下载问题,请检查网络、防火墙或尝试切换另一个国内镜像源。"
mac_install_choice="$(printf '%s' "$mac_install_choice" | tr '[:upper:]' '[:lower:]')"
case "${mac_install_choice:-2}" in
1|auto)
if ! install_docker_macos; then
exit 1
}
fi
;;
2|manual)
echo " 请手动安装 Docker Desktop: https://www.docker.com/products/docker-desktop/"
echo " 安装并启动 Docker Desktop 后,重新运行 ./start.sh"
exit 1
;;
*)
echo "❌ 无效选择"
exit 1
;;
esac
else
echo "未知操作系统 ($OS_FAMILY),无法安装 Docker。"
echo "不支持的操作系统: $OS_FAMILY"
exit 1
fi
echo "✅ 启动并设置 Docker 开机自启..."
if [[ "$OS_FAMILY" != "macOS" ]]; then
sudo systemctl enable docker
sudo systemctl start docker
# 将当前用户添加到 docker 组,以便无需 sudo 运行 Docker 命令
if ! getent group docker | grep -qw "$USER"; then
echo "👥 将当前用户 ($USER) 添加到 docker 组..."
sudo usermod -aG docker "$USER"
echo "🔔 请注销并重新登录,以便更改生效。"
getent group docker | grep -qw "$USER" || sudo usermod -aG docker "$USER"
fi
fi
# Docker Compose 检查和安装 (优先使用 docker-compose-plugin)
if command -v docker &> /dev/null && docker compose version &> /dev/null; then
echo "✅ Docker Compose (插件版) 已安装,跳过安装步骤"
elif command -v docker-compose &> /dev/null && ! command -v docker &> /dev/null; then
echo "⚠️ 检测到旧版 Docker Compose 独立安装,但未检测到 Docker 插件版。"
echo "建议在安装 Docker CE 时一起安装 docker-compose-plugin。"
if docker compose version &>/dev/null; then
echo "✅ Docker Compose 已就绪"
elif command -v docker-compose &>/dev/null; then
echo "✅ Docker Compose (兼容模式) 已就绪"
else
echo "⚙️ 安装 Docker Compose (插件版)..."
PLUGIN_INSTALL_SUCCESS=1
# 如果 Docker CE 安装成功,docker-compose-plugin 应该已经安装了。
# 这里是额外的检查,以防万一。
if [[ "$OS_FAMILY" == "Ubuntu" ]]; then
sudo apt-get install -y docker-compose-plugin || PLUGIN_INSTALL_SUCCESS=0
elif [[ "$OS_FAMILY" == "CentOS" ]]; then
sudo yum install -y docker-compose-plugin || PLUGIN_INSTALL_SUCCESS=0
else
echo "❌ 未知操作系统,无法安装 Docker Compose 插件版。"
PLUGIN_INSTALL_SUCCESS=0
fi
# 检查插件版安装是否成功
if [[ $PLUGIN_INSTALL_SUCCESS -eq 1 ]]; then
echo "✅ Docker Compose 插件版安装成功"
else
echo "⚠️ Docker Compose 插件版安装失败,尝试使用本地二进制文件安装..."
#将文件移动至/usr/bin目录下,并重命名
sudo cp ./docker-compose-linux-x86_64 /usr/bin/docker-compose
# 添加执行权限
sudo chmod +x /usr/bin/docker-compose
# 验证安装
if command -v docker-compose &> /dev/null; then
echo "✅ Docker Compose 二进制文件安装成功"
else
echo "❌ Docker Compose 二进制文件安装失败,请手动检查。"
echo "❌ 请安装 docker-compose-plugin"
exit 1
fi
fi
fi
}
echo "📌 Docker 运行状态:"
sudo systemctl status docker --no-pager || true
echo "📌 Docker Compose 版本:"
# 优先使用 docker compose 命令(新版),如果不行再尝试 docker-compose(旧版或别名)
if command -v docker &> /dev/null && docker compose version &> /dev/null; then
docker compose version
elif command -v docker-compose &> /dev/null; then
docker-compose -v
else
echo "❌ 无法检测 Docker Compose 版本。"
fi
echo "🎉 Docker 和 Docker Compose 已就绪!"
echo "🚀 启动EasyAI"
# 对于新版 docker-compose-plugin,命令是 'docker compose'
if command -v docker &> /dev/null && docker compose version &> /dev/null; then
# ==================== 启动服务 ====================
start_services() {
echo ""
echo "🚀 启动 EasyAI 服务..."
if docker compose version &>/dev/null; then
sudo docker compose pull && sudo docker compose up -d
else # 兼容旧版独立安装的 docker-compose
else
sudo docker-compose pull && sudo docker-compose up -d
fi
echo "🎉 EasyAI 应用启动成功"
}
# ==================== 执行 HTTPS 配置 ====================
run_https_setup() {
if [ "$DEPLOY_HTTPS" = true ] && [ -n "$DEPLOY_DOMAIN" ]; then
echo ""
echo "🔒 执行 HTTPS 配置(请确保服务器已放行 80、443 端口)..."
if [ -f "./https.sh" ]; then
# https.sh 依赖 easyai-proxy.conf,需使用生成的域名配置文件
export EASYAI_PROXY_CONF="${DEPLOY_DOMAIN}.conf"
bash ./https.sh
else
echo "⚠️ 未找到 https.sh,请手动配置 HTTPS"
fi
fi
}
# ==================== 主流程 ====================
main() {
init_project_dir
infer_deploy_access_from_env
# 检查是否已有 .env 且非强制重新配置
if [ -f .env ] && [ -z "$DEPLOY_FORCE_RECONFIG" ] && [ -z "$DEPLOY_ACCESS" ]; then
echo "📁 检测到已有 .env 配置"
if is_non_interactive; then
echo "⏭️ 使用现有配置继续..."
DEPLOY_MODE="skip"
else
read -r -p "是否重新配置部署方式?[y/N]: " reconfigure
if [[ ! "$reconfigure" =~ ^[yY] ]]; then
echo "⏭️ 使用现有配置继续..."
DEPLOY_MODE="skip"
fi
fi
fi
if [ "$DEPLOY_MODE" != "skip" ]; then
run_deploy_questions
fi
if [ "$DEPLOY_MODE" != "skip" ]; then
setup_env_files
else
if [ ! -f .env.tools ]; then
cp .env.tools.sample .env.tools
fi
if [ ! -f .env.ASG ]; then
cp .env.ASG.sample .env.ASG
fi
if [ ! -f .env.AMS ]; then
cp .env.AMS.sample .env.AMS
fi
fi
# 空值或历史默认值只在首次发现时生成并写回 .env,后续启动保持稳定。
# 数据库中的后台 JWT 轮转配置由后端优先使用,不受此处影响。
# shellcheck source=scripts/init-security-env.sh
. ./scripts/init-security-env.sh
init_security_env .env "$SECURITY_ENV_MODE"
# 在拉取镜像或重建服务前完成公开地址迁移;非法或无法推导时立即终止。
# shellcheck source=scripts/init-public-api-base-url.sh
. ./scripts/init-public-api-base-url.sh
if [ "$DEPLOY_MODE" = "skip" ]; then
init_public_api_base_url .env upgrade "${DEPLOY_PUBLIC_API_BASE_URL:-}"
else
init_public_api_base_url .env configure "$PUBLIC_API_BASE_URL_CANDIDATE"
fi
if [ "$DEPLOY_DRY_RUN" = "1" ]; then
echo ""
echo "⚠️ dry-run 模式:跳过 Docker 安装和服务启动"
echo " 配置文件已生成,可直接运行 ./start.sh 完成部署"
else
install_docker
start_services
export DEPLOY_DOMAIN
run_https_setup
fi
echo ""
echo "================================"
echo " 部署完成"
echo "================================"
if [ "$DEPLOY_MODE" = "ip" ] && [ -n "$DEPLOY_IP" ]; then
echo "访问地址: http://${DEPLOY_IP}:3010"
elif [ "$DEPLOY_MODE" = "domain" ] && [ -n "$DEPLOY_DOMAIN" ]; then
if [ "$DEPLOY_HTTPS" = true ]; then
echo "访问地址: https://${DEPLOY_DOMAIN}"
else
echo "访问地址: http://${DEPLOY_DOMAIN} (配置 Nginx 后)"
fi
if [ "$DEPLOY_HTTPS" = true ]; then
echo "HTTPS 已启用"
fi
fi
echo ""
}
main "$@"
+262
View File
@@ -0,0 +1,262 @@
#Requires -Version 5.1
# EasyAI Windows 更新脚本
# 拉取仓库最新代码,更新镜像并重启服务
# 用法: .\update.ps1
# 设置控制台编码为 UTF-8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Stop"
$script:LogDir = $PSScriptRoot
if (-not $script:LogDir) { $script:LogDir = $env:TEMP }
$script:LogFile = Join-Path $script:LogDir "update.ps1.log"
function Write-Log {
param([string]$Msg)
try {
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Msg"
Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
} catch { }
}
trap {
$errMsg = $_.Exception.Message
try {
Add-Content -Path $script:LogFile -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] 错误: $errMsg" -Encoding UTF8 -ErrorAction SilentlyContinue
} catch { }
Write-Host ""
Write-Host "发生错误: $errMsg" -ForegroundColor Red
if ($env:CI -ne "true") { Read-Host "按 Enter 键退出" }
exit 1
}
function Wait-ForExit {
if ($env:CI -eq "true") { return }
if ($env:UPDATE_NO_WAIT -eq "1") { return }
Write-Host ""
Read-Host "按 Enter 键退出"
}
function Write-Step { param($Msg) Write-Host $Msg }
function Write-Ok { param($Msg) Write-Host "$Msg" -ForegroundColor Green }
function Write-Err { param($Msg) Write-Host "$Msg" -ForegroundColor Red; throw $Msg }
function Write-Warn { param($Msg) Write-Host "⚠️ $Msg" -ForegroundColor Yellow }
# -h/--help
if ($args -contains "-h" -or $args -contains "--help") {
Write-Host "用法: .\update.ps1"
Write-Host ""
Write-Host "默认拉取仓库并更新镜像;也可通过环境变量跳过交互选择。"
Write-Host ""
Write-Host "环境变量:"
Write-Host " UPDATE_MODE=full|image"
Write-Host " full 更新并拉取仓库(git pull)+ 更新镜像并重启"
Write-Host " image 仅更新镜像并重启(跳过 git pull)"
Write-Host " UPDATE_SKIP_REPO_UPDATE=true|false"
Write-Host " true 等同 UPDATE_MODE=image"
Write-Host " false 等同 UPDATE_MODE=full"
Write-Host " UPDATE_NO_WAIT=1"
Write-Host " 结束或失败时不等待按 Enter"
exit 0
}
# 进入项目目录
$scriptDir = $PSScriptRoot
if (-not $scriptDir) { Write-Err "无法获取脚本所在目录" }
$composePath = Join-Path $scriptDir "docker-compose.yml"
if (-not (Test-Path $composePath)) { Write-Err "未找到 docker-compose.yml,请在 easyai 目录下运行 update.ps1" }
Set-Location $scriptDir
Write-Step "📁 项目目录: $scriptDir"
function ConvertTo-BoolEnv {
param([string]$Value)
$v = if ($null -eq $Value) { "" } else { $Value.Trim().ToLowerInvariant() }
switch ($v) {
{ $_ -in @("1", "true", "yes", "y", "on") } { return $true }
{ $_ -in @("0", "false", "no", "n", "off", "") } { return $false }
default { Write-Err "布尔变量值不合法: $Value(支持 true/false/1/0/yes/no/y/n/on/off" }
}
}
function Resolve-UpdateMode {
if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_SKIP_REPO_UPDATE)) {
$skip = ConvertTo-BoolEnv $env:UPDATE_SKIP_REPO_UPDATE
Write-Step "使用环境变量: UPDATE_SKIP_REPO_UPDATE=$skip"
return $skip
}
if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_MODE)) {
$mode = $env:UPDATE_MODE.Trim().ToLowerInvariant()
switch ($mode) {
{ $_ -in @("full", "repo", "pull", "git", "1") } {
Write-Step "使用环境变量: UPDATE_MODE=$($env:UPDATE_MODE)"
return $false
}
{ $_ -in @("image", "images", "docker", "skip-git", "skip_repo", "skip-repo", "2") } {
Write-Step "使用环境变量: UPDATE_MODE=$($env:UPDATE_MODE)"
return $true
}
default { Write-Err "UPDATE_MODE 只能是 full 或 image,当前为: $($env:UPDATE_MODE)" }
}
}
if ($env:UPDATE_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
Write-Step "使用默认更新方式: 更新并拉取仓库(git pull)+ 更新镜像并重启"
return $false
}
Write-Host ""
Write-Host "请选择更新方式:"
Write-Host " [1] 更新并拉取仓库(git pull)+ 更新镜像并重启(默认)"
Write-Host " [2] 仅更新镜像并重启(跳过 git pull)"
$choice = Read-Host "请选择 [1/2,回车默认 1]"
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
switch ($choice) {
"2" { return $true }
"1" { return $false }
default {
Write-Warn "无效选择,将使用默认:更新并拉取仓库"
return $false
}
}
}
$skipRepoUpdate = Resolve-UpdateMode
# ==================== 拉取仓库 ====================
if (-not $skipRepoUpdate) {
Write-Host ""
Write-Host "==========================="
Write-Host "📥 拉取仓库最新代码"
Write-Host "==========================="
if (-not (Test-Path ".git")) {
Write-Err "当前目录不是 Git 仓库,请使用 git clone 克隆项目后使用 update.ps1"
}
Write-Step "📥 正在执行 git pull..."
try {
$output = git pull 2>&1
if ($LASTEXITCODE -ne 0) { throw "git pull 返回 $LASTEXITCODE" }
Write-Ok "仓库已更新到最新版本"
} catch {
Write-Err "git pull 失败,请检查网络或远程仓库配置"
}
# 确保环境配置文件存在
Write-Host ""
Write-Step "📝 检查环境配置文件..."
if (-not (Test-Path ".env") -and (Test-Path ".env.sample")) {
Copy-Item ".env.sample" ".env"
Write-Ok ".env"
}
if (-not (Test-Path ".env.tools") -and (Test-Path ".env.tools.sample")) {
Copy-Item ".env.tools.sample" ".env.tools"
Write-Ok ".env.tools"
}
if (-not (Test-Path ".env.ASG") -and (Test-Path ".env.ASG.sample")) {
Copy-Item ".env.ASG.sample" ".env.ASG"
Write-Ok ".env.ASG"
}
if (-not (Test-Path ".env.AMS") -and (Test-Path ".env.AMS.sample")) {
Copy-Item ".env.AMS.sample" ".env.AMS"
Write-Ok ".env.AMS"
}
Write-Host ""
} else {
Write-Host ""
Write-Step "⏭️ 跳过仓库更新,仅更新镜像并重启"
Write-Host ""
}
# 空值或历史默认值只初始化一次并写回 .env;已有自定义值及数据库轮转配置不受影响。
if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps1 完成初始化" }
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade"
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
. (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1")
Initialize-PublicApiBaseUrl `
-Path (Join-Path $scriptDir ".env") `
-Mode "upgrade" `
-Override $env:DEPLOY_PUBLIC_API_BASE_URL
# ==================== Docker 检查 ====================
function Test-DockerInstalled {
$docker = Get-Command docker -ErrorAction SilentlyContinue
if (-not $docker) { return $false }
try { $null = & docker --version 2>&1; return $true } catch { return $false }
}
function Test-DockerRunning {
param([int]$TimeoutSeconds = 10)
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "docker"; $psi.Arguments = "info"
$psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true
$psi.UseShellExecute = $false; $psi.CreateNoWindow = $true
$p = [System.Diagnostics.Process]::Start($psi)
$exited = $p.WaitForExit($TimeoutSeconds * 1000)
if (-not $exited) { try { $p.Kill() } catch { }; return $false }
return ($p.ExitCode -eq 0)
} catch { return $false }
}
function Ensure-DockerRunning {
if (-not (Test-DockerInstalled)) { return $false }
if (Test-DockerRunning) {
Write-Ok "Docker 引擎已运行"
$v = docker --version 2>$null; if ($v) { Write-Host " $v" }
return $true
}
Write-Warn "Docker 未运行,请手动启动 Docker Desktop 后重新执行"
return $false
}
Write-Host "==========================="
Write-Host "🚀 检查 Docker"
Write-Host "==========================="
Write-Host ""
if (-not (Test-DockerInstalled)) {
Write-Err "未检测到 Docker,请先运行 start.ps1 完成首次部署"
}
if (-not (Ensure-DockerRunning)) {
Write-Host ""
Write-Host "请启动 Docker Desktop,确认其完全就绪后再重新运行 update.ps1"
Wait-ForExit
exit 1
}
# 检测 docker compose
$hasComposeV2 = $false
try { $null = docker compose version 2>&1; $hasComposeV2 = $true } catch { }
# ==================== 更新并启动 ====================
Write-Host ""
Write-Step "🚀 重新启动 EasyAI..."
if ($hasComposeV2) {
docker compose pull
if ($LASTEXITCODE -ne 0) { Write-Err "docker compose pull 失败" }
docker compose up -d
if ($LASTEXITCODE -ne 0) { Write-Err "docker compose up 失败" }
} else {
docker-compose pull
if ($LASTEXITCODE -ne 0) { Write-Err "docker-compose pull 失败" }
docker-compose up -d
if ($LASTEXITCODE -ne 0) { Write-Err "docker-compose up 失败" }
}
Write-Host ""
Write-Host "================================"
Write-Host " 更新完成"
Write-Host "================================"
Write-Host "🎉 EasyAI 应用更新成功"
Write-Host "访问地址: http://127.0.0.1:3010"
Write-Host ""
Write-Log "=== 更新完成 ==="
Wait-ForExit
+118 -53
View File
@@ -2,78 +2,141 @@
set -e # 发生错误时终止脚本执行
# 参数解析
SKIP_COMPOSE_UPDATE=false
# 解析命令行参数
while [[ $# -gt 0 ]]; do
case $1 in
--skip-compose-update|-s)
SKIP_COMPOSE_UPDATE=true
shift
;;
--help|-h)
echo "用法: $0 [选项]"
# 仅支持 -h/--help 快速查看
if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then
echo "用法: $0"
echo ""
echo "选项:"
echo " -s, --skip-compose-update 跳过 docker-compose.yml 的更新"
echo " -h, --help 显示此帮助信息"
echo "默认拉取仓库并更新镜像;也可通过环境变量跳过交互选择。"
echo ""
echo "环境变量:"
echo " UPDATE_MODE=full|image"
echo " full 更新并拉取仓库(git pull)+ 更新镜像并重启"
echo " image 仅更新镜像并重启(跳过 git pull)"
echo " UPDATE_SKIP_REPO_UPDATE=true|false"
echo " true 等同 UPDATE_MODE=image"
echo " false 等同 UPDATE_MODE=full"
exit 0
fi
# 进入脚本所在目录(项目根目录)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
cd "$SCRIPT_DIR"
# 命令行内选择更新方式(默认:更新并拉取仓库)
SKIP_REPO_UPDATE=false
normalize_bool() {
local value
value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
case "$value" in
1|true|yes|y|on) echo "true" ;;
0|false|no|n|off|"") echo "false" ;;
*)
echo "❌ 布尔变量值不合法: $1(支持 true/false/1/0/yes/no/y/n/on/off" >&2
return 1
;;
esac
}
resolve_update_mode() {
local mode
if [ -n "${UPDATE_SKIP_REPO_UPDATE:-}" ]; then
mode="$(normalize_bool "$UPDATE_SKIP_REPO_UPDATE")" || exit 1
SKIP_REPO_UPDATE="$mode"
echo "使用环境变量: UPDATE_SKIP_REPO_UPDATE=$SKIP_REPO_UPDATE"
return
fi
if [ -n "${UPDATE_MODE:-}" ]; then
mode="$(printf '%s' "$UPDATE_MODE" | tr '[:upper:]' '[:lower:]')"
case "$mode" in
full|repo|pull|git|1)
SKIP_REPO_UPDATE=false
;;
image|images|docker|skip-git|skip_repo|skip-repo|2)
SKIP_REPO_UPDATE=true
;;
*)
echo "未知参数: $1"
echo "使用 --help 或 -h 查看帮助信息"
echo "UPDATE_MODE 只能是 full 或 image,当前为: $UPDATE_MODE"
exit 1
;;
esac
done
echo "使用环境变量: UPDATE_MODE=$UPDATE_MODE"
return
fi
# 更新 docker-compose.yml 文件
if [ "$SKIP_COMPOSE_UPDATE" = false ]; then
if [ "${UPDATE_NON_INTERACTIVE:-0}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ]; then
echo "使用默认更新方式: 更新并拉取仓库(git pull)+ 更新镜像并重启"
SKIP_REPO_UPDATE=false
return
fi
echo ""
echo "请选择更新方式:"
echo " [1] 更新并拉取仓库(git pull)+ 更新镜像并重启(默认)"
echo " [2] 仅更新镜像并重启(跳过 git pull)"
read -r -p "请选择 [1/2,回车默认 1]: " choice
choice="${choice:-1}"
case "$choice" in
2) SKIP_REPO_UPDATE=true ;;
1) ;;
*) echo "❌ 无效选择,将使用默认:更新并拉取仓库"; SKIP_REPO_UPDATE=false ;;
esac
}
resolve_update_mode
# 拉取整个仓库更新
if [ "$SKIP_REPO_UPDATE" = false ]; then
echo ""
echo "==========================="
echo "📄 检查并更新 docker-compose.yml"
echo "📥 拉取仓库最新代码"
echo "==========================="
COMPOSE_FILE="docker-compose.yml"
COMPOSE_URL="https://git.51easyai.com/wangbo/easyai/raw/main/docker-compose.yml"
TEMP_FILE=$(mktemp)
if [ ! -d .git ]; then
echo "❌ 当前目录不是 Git 仓库,无法执行 git pull"
echo " 请使用 git clone 克隆项目后即可使用 update.sh 更新"
echo " 克隆命令: git clone https://git.51easyai.com/wangbo/easyai && cd easyai"
exit 1
fi
# 检查本地文件是否存在
if [ ! -f "$COMPOSE_FILE" ]; then
echo "⚠️ 本地 $COMPOSE_FILE 不存在,直接下载最新版本..."
curl -fsSL "$COMPOSE_URL" -o "$COMPOSE_FILE"
echo "$COMPOSE_FILE 已下载"
echo "📥 正在执行 git pull..."
if git pull; then
echo "✅ 仓库已更新到最新版本"
else
echo "📥 正在下载远程 $COMPOSE_FILE..."
if curl -fsSL "$COMPOSE_URL" -o "$TEMP_FILE"; then
# 比较本地文件和远程文件的内容
if cmp -s "$COMPOSE_FILE" "$TEMP_FILE"; then
echo "✅ 本地 $COMPOSE_FILE 已是最新版本,无需更新"
rm -f "$TEMP_FILE"
else
echo "🔄 检测到新版本,正在更新..."
# 备份原文件
BACKUP_FILE="${COMPOSE_FILE}.bak"
cp "$COMPOSE_FILE" "$BACKUP_FILE"
echo "💾 原文件已备份为 $BACKUP_FILE"
# 替换为新文件
mv "$TEMP_FILE" "$COMPOSE_FILE"
echo "$COMPOSE_FILE 已更新到最新版本"
fi
else
echo "❌ 无法下载远程文件,跳过更新步骤"
rm -f "$TEMP_FILE"
fi
echo "❌ git pull 失败,请检查网络或远程仓库配置"
exit 1
fi
# 确保环境配置文件存在(从 .sample 生成,不覆盖已有文件)
echo ""
echo "📝 检查环境配置文件..."
[ ! -f .env ] && [ -f .env.sample ] && cp .env.sample .env && echo " ✓ .env"
[ ! -f .env.tools ] && [ -f .env.tools.sample ] && cp .env.tools.sample .env.tools && echo " ✓ .env.tools"
[ ! -f .env.ASG ] && [ -f .env.ASG.sample ] && cp .env.ASG.sample .env.ASG && echo " ✓ .env.ASG"
[ ! -f .env.AMS ] && [ -f .env.AMS.sample ] && cp .env.AMS.sample .env.AMS && echo " ✓ .env.AMS"
echo ""
else
echo "⏭️ 跳过 docker-compose.yml 更新(已使用 --skip-compose-update 参数)"
echo "⏭️ 跳过仓库更新,仅更新镜像并重启"
echo ""
fi
# 升级已有部署时,将空值或历史默认 JWT 密钥一次性替换为持久化随机值。
# 已存在的自定义值保持不变,数据库 JWT 轮转配置仍由后端优先使用。
if [ ! -f .env ]; then
echo "❌ 未找到 .env,请先执行 start.sh 完成初始化"
exit 1
fi
# shellcheck source=scripts/init-security-env.sh
. ./scripts/init-security-env.sh
init_security_env .env upgrade
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
# shellcheck source=scripts/init-public-api-base-url.sh
. ./scripts/init-public-api-base-url.sh
init_public_api_base_url .env upgrade "${DEPLOY_PUBLIC_API_BASE_URL:-}"
echo "==========================="
echo "🚀 开始自动安装 Docker 和 Docker Compose"
echo "==========================="
@@ -112,9 +175,11 @@ elif command -v docker-compose &> /dev/null; then
echo "✅ 检测到 Docker Compose (旧版本: docker-compose)"
else
echo "⚙️ 安装 Docker Compose..."
sudo mv ./docker-compose-linux-x86_64 /usr/bin/docker-compose
#设置权限
chmod +x /usr/bin/docker-compose
COMPOSE_BIN_URL="https://static.51easyai.com/docker-compose-linux-x86_64"
TMP_COMPOSE_BIN="/tmp/docker-compose-linux-x86_64"
curl -fL "$COMPOSE_BIN_URL" -o "$TMP_COMPOSE_BIN"
sudo install -m 0755 "$TMP_COMPOSE_BIN" /usr/bin/docker-compose
rm -f "$TMP_COMPOSE_BIN"
DOCKER_COMPOSE_CMD="docker-compose"
echo "✅ Docker Compose 安装完成"
fi