优化模板管理器拖拽交互体验
Some checks failed
CI Check / check (push) Has been cancelled

- 移除高亮卡片效果,避免与虚位卡片视觉重复
- 松开鼠标后立即更新布局,不再等待动画完成
- 优化拖拽动画效果,使用弹性缓动函数
- 更新 CHANGELOG.md 记录本次优化
This commit is contained in:
WuYongJie 2025-11-20 11:24:25 +08:00
parent 720253b5ac
commit 5f2ba48532
64 changed files with 27894 additions and 26921 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,26 @@
## 🧾 2025-11-20 · 「拖拽体验优化」版本 🎨
本次更新主要优化了模板管理器的拖拽交互体验,让字段排序操作更加流畅和直观。
---
### 🎯 拖拽交互优化
- ✨ **移除高亮卡片效果**
- 移除了拖拽悬停时的高亮卡片显示,避免与虚位卡片(placeholder)视觉重复。
- 现在只保留虚位卡片来显示落点位置,界面更简洁清晰。
- ⚡ **立即更新布局**
- 松开鼠标后布局立即更新,不再等待动画完成。
- 直接根据拖拽后的顺序更新字段数据,响应更快,体验更流畅。
- 🎨 **动画效果优化**
- 拖拽结束动画时长从 400ms 增加到 500ms,使用弹性缓动函数 `cubic-bezier(0.34, 1.56, 0.64, 1)`。
- 拖拽覆盖层增加旋转和缩放效果,视觉反馈更明显。
- 占位符卡片使用渐变背景和脉冲动画,更清晰地指示落点位置。
---
## 🧾 2025-11-18 · 「权限进化 & 电商加成」版本 (๑•̀ㅂ•́)و✧
这次更新主要围绕 **权限体验、电商字段扩展、模板/分类管理、部署体验** 做了一轮集中升级,让「由壹点 3D 素材平台」更像一款真正能落地到团队里的内部产品,而不是一个玩具 Demo。下面是本次改动的「人话版」说明👇

View File

@ -69,6 +69,12 @@ ARG DATABASE_URL=""
ARG NEXTAUTH_URL=""
ARG APP_PUBLIC_URL=""
ARG PRISMA_ENGINE_CACHE_FILE_NAME="libquery_engine-linux-musl-openssl-3.0.x.so.node"
ARG PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME="schema-engine-linux-musl-openssl-3.0.x"
ENV PRISMA_ENGINE_CACHE_FILE_NAME=${PRISMA_ENGINE_CACHE_FILE_NAME}
ENV PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME=${PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME}
ENV DATABASE_URL=${DATABASE_URL}
ENV NEXTAUTH_URL=${NEXTAUTH_URL}
ENV APP_PUBLIC_URL=${APP_PUBLIC_URL}
@ -81,6 +87,8 @@ RUN apk add --no-cache openssl3
# 设置构建环境变量
ENV NODE_ENV=production
# Prisma 官方偶尔在下载校验或引擎文件时返回 5xx,这里允许缺失校验文件
ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1
# 从 deps 阶段复制 node_modules(包含所有依赖)
COPY --from=deps /app/node_modules ./node_modules
@ -89,9 +97,60 @@ COPY --from=deps /app/node_modules ./node_modules
# 使用 .dockerignore 排除不需要的文件
COPY . .
# 配置 Prisma 引擎缓存路径(可离线使用)
ENV PRISMA_ENGINE_CACHE_PATH=/app/prisma/engine-cache/${PRISMA_ENGINE_CACHE_FILE_NAME}
ENV PRISMA_SCHEMA_ENGINE_CACHE_PATH=/app/prisma/engine-cache/${PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME}
# 将缓存的引擎文件复制到 Prisma CLI 缓存目录,避免构建时重新下载
RUN set -ex && \
if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \
echo "检测到本地 Prisma 查询引擎缓存,路径:${PRISMA_ENGINE_CACHE_PATH}" && \
PRISMA_ENGINE_VERSION=$(node -p "require('@prisma/engines-version/package.json').prisma.enginesVersion") && \
TARGET_DIR="/root/.cache/prisma/master/${PRISMA_ENGINE_VERSION}/linux-musl-openssl-3.0.x" && \
mkdir -p "${TARGET_DIR}" && \
cp "${PRISMA_ENGINE_CACHE_PATH}" "${TARGET_DIR}/libquery_engine.so.node" && \
gzip -c "${PRISMA_ENGINE_CACHE_PATH}" > "${TARGET_DIR}/libquery_engine.so.node.gz" && \
sha256sum "${TARGET_DIR}/libquery_engine.so.node.gz" | awk '{print $1}' > "${TARGET_DIR}/libquery_engine.so.node.gz.sha256"; \
else \
echo "未找到本地 Prisma 查询引擎缓存(${PRISMA_ENGINE_CACHE_PATH}),将尝试在线下载"; \
fi && \
if [ -f "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" ]; then \
echo "检测到本地 Prisma schema 引擎缓存,路径:${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" && \
PRISMA_ENGINE_VERSION=$(node -p "require('@prisma/engines-version/package.json').prisma.enginesVersion") && \
TARGET_DIR="/root/.cache/prisma/master/${PRISMA_ENGINE_VERSION}/linux-musl-openssl-3.0.x" && \
mkdir -p "${TARGET_DIR}" && \
cp "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" "${TARGET_DIR}/schema-engine" && \
chmod +x "${TARGET_DIR}/schema-engine" && \
gzip -c "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" > "${TARGET_DIR}/schema-engine.gz" && \
sha256sum "${TARGET_DIR}/schema-engine.gz" | awk '{print $1}' > "${TARGET_DIR}/schema-engine.gz.sha256"; \
else \
echo "未找到本地 Prisma schema 引擎缓存(${PRISMA_SCHEMA_ENGINE_CACHE_PATH}),将尝试在线下载"; \
fi
# 生成 Prisma Client(需要完整的 node_modules)
# 注意:binaryTargets 已指定 linux-musl-openssl-3.0.x,即使检测失败也会使用正确的二进制
RUN npx prisma generate
# 默认使用官方源,如遇 5xx 再自动切换到 npm 镜像源重试
RUN set -ex && \
if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_QUERY_ENGINE_LIBRARY="${PRISMA_ENGINE_CACHE_PATH}" && \
export PRISMA_CLIENT_ENGINE_TYPE=library && \
export PRISMA_CLI_QUERY_ENGINE_TYPE=library && \
echo "使用本地 Prisma 查询引擎缓存优先生成 Client"; \
else \
echo "本地 Prisma 引擎缓存不可用,将使用在线下载"; \
unset PRISMA_QUERY_ENGINE_LIBRARY PRISMA_CLIENT_ENGINE_TYPE PRISMA_CLI_QUERY_ENGINE_TYPE; \
fi && \
if [ -f "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_SCHEMA_ENGINE_BINARY="${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" && \
echo "使用本地 Prisma schema 引擎缓存"; \
else \
echo "本地 Prisma schema 引擎缓存不可用,将使用在线下载"; \
unset PRISMA_SCHEMA_ENGINE_BINARY; \
fi && \
if npx prisma generate; then \
exit 0; \
fi && \
echo "Prisma 官方源下载失败,尝试使用镜像源重试..." && \
PRISMA_ENGINES_MIRROR="https://registry.npmmirror.com/-/binary/prisma/" npx prisma generate
# 验证关键依赖是否已安装
RUN if [ ! -d "node_modules/.prisma" ]; then \
@ -100,7 +159,20 @@ RUN if [ ! -d "node_modules/.prisma" ]; then \
# 构建 Next.js 应用(standalone 模式)
# 允许构建时出现预渲染错误(这些页面会在运行时动态渲染)
RUN npm run build || (echo "构建完成,但部分页面预渲染失败(这是预期的,这些页面将在运行时动态渲染)" && exit 0)
RUN set -ex && \
if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_QUERY_ENGINE_LIBRARY="${PRISMA_ENGINE_CACHE_PATH}" && \
export PRISMA_CLIENT_ENGINE_TYPE=library && \
export PRISMA_CLI_QUERY_ENGINE_TYPE=library; \
else \
unset PRISMA_QUERY_ENGINE_LIBRARY PRISMA_CLIENT_ENGINE_TYPE PRISMA_CLI_QUERY_ENGINE_TYPE; \
fi && \
if [ -f "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_SCHEMA_ENGINE_BINARY="${PRISMA_SCHEMA_ENGINE_CACHE_PATH}"; \
else \
unset PRISMA_SCHEMA_ENGINE_BINARY; \
fi && \
npm run build || (echo "构建完成,但部分页面预渲染失败(这是预期的,这些页面将在运行时动态渲染)" && exit 0)
# 验证构建产物
RUN if [ ! -f ".next/standalone/server.js" ]; then \

348
README.md
View File

@ -1,217 +1,193 @@
# 由壹点3D素材
一个现代化的3D素材分享平台,采用苹果官方UI设计规范。
一个遵循苹果设计语言的 3D 素材协作平台,聚焦企业/团队的私有化部署场景,内置 AI 辅助、部门权限、模板化元数据与可移植部署工具链。
> ⚠️ 强烈建议:可迁移化部署时只需编辑根目录的 `config-bianliang.env`
>
> - 将你的域名、内网地址、图片域名白名单、CORS 白名单、钉钉回调、对象存储等统一填在该文件中
> - 系统启动会自动读取该文件:仅在变量“未设置”时注入,不会覆盖系统环境或 `.env/.env.local`
> - 修改后重启服务生效(重启 Node 或对应容器)
> - 前端可读变量以 `NEXT_PUBLIC_` 开头,服务端变量无需此前缀
> ⚠️ 建议先阅读根目录 `config-bianliang.env`,将域名、白名单、S3、钉钉等变量集中管理。系统启动时只会补充“未设置”的变量,不会覆盖已有的系统环境或 `.env/.env.local`。
## 🎯 项目目标
## 🎯 项目概览
- **目标**:让其他团队能“一键部署 + 秒级迁移”,快速落地到自有 NAS、IDC 或混合网络环境。
- **当前定位**:设计/电商团队素材仓库 + 管理驾驶舱 + AI 实验场。
- **关键原则**:
- 性能开销优先:组件懒加载、API 分页、S3 断点重试,保证低配服务器也能跑。
- 稳定性优先:健康检查 `/api/health`、系统仪表盘 `/api/admin/system-status`、部署脚本全程日志。
- 易部署:配置集中化、Docker Compose 套件、PowerShell/Bash 安全部署脚本、环境备份。
**让其他公司能够一键部署或一条命令从Docker部署到自己的NAS或服务器上**
更多编码规范与约束可参考 `./架构设计与编码规范.md`、`./docs/PORTABLE_DEPLOYMENT_GUIDE.md`。
### 核心设计原则
- **性能开销优先** - 最小化资源消耗,确保在低配置设备上也能稳定运行
- **稳定性优先** - 确保长期稳定运行,减少崩溃和错误
- **易于部署** - 零配置或最小配置即可运行
## 🌟 功能亮点(2025/11 最新版)
详细规范请参考:[架构设计与编码规范](./架构设计与编码规范.md)
### 面向素材使用者
- **门户体验**:首页卡片流展示、支持品牌/系列/SKU/渲染参数的多维搜索与筛选,附带点赞、下载、分享、榜单。
- **AI 智能体**:`components/AIChatWidget.tsx` 绑定 Gemini,多模态对话、最多 5 张参考图上传、自动生成 4 张风格稿。
- **实时互动**:`DanmakuWidget` 支持匿名弹幕;`RankingPanel` 展示贡献/下载/点赞排行榜,可拖拽定位。
- **分享场景**:`/share/[id]` 公开链接可曝光素材详情(脱敏流量);`/complete-profile` 协助钉钉首次登录用户补全部门信息。
- **网络自识别**:`/api/network-status` 根据 IP & 域名判断当前处于内/公网,自动提示内网地址。
## 一键式可迁移配置(推荐)
### 面向管理员
- **驾驶舱首页**:`/admin` 默认进入“监控仪表板”,展示 30 天新增、部门/角色分布、活跃度、最近上传等。
- **权限与部门**:支持“部门管理员”操控子部门、折叠/搜索组织树、邀请链接 (`/api/invitations`) 与首位用户自动超级管理员 (`verify_first_user.js`)。
- **模板/字段体系**:`TemplateManager` 内置多套电商模板,字段支持排序、可见性、下拉选项(单行文本规则),并与上传页/筛选器联动。
- **分类配置**:分类/场景/平台均支持自定义下拉列表,后台统一管理(`/api/categories|scenes|platforms`)。
- **素材深度信息**:`Material` 模型新增品牌、系列、SKU、材质、渲染耗时、输出分辨率、色彩模式等字段,全流程读写。
- **系统设置**:LOGO、S3、注册/激活、AI 改图、内网穿透等开关存储于 `SystemSettings`,与 `config-bianliang.env` 协同。
- **数据恢复**:`/api/restore-data` 可结合 S3 桶与本地 `uploads/` 批量匹配预览/压缩包并导入数据库(含管理员账号回填)。
- **Ops 工具**:内置 `/api/tunnel/*` 占位接口用于 FRP/ZeroTier 控制,配合 `featureFlags.tunnelEnabled` 与 SystemSettings。
不想到处找变量?只需编辑根目录的 `config-bianliang.env`,填好注释中的值并重启服务即可。
系统在启动时会自动读取该文件,只有当某个变量"未设置"时才会注入,绝不会覆盖你在系统环境或 `.env/.env.local` 中已设置的值。
### 运维、部署与生态
- `config-bianliang.env`:将域名、白名单、S3、钉钉、内外网地址、RESTORE 桶等集中管理,实现“拷贝项目即可部署”。
- `scripts/deploy/`:包含 `安全部署.ps1/.sh`、`deploy-new-fixed.ps1`(阶段化进度)、`deploy-new.ps1`、`local-build-and-upload.ps1` 等脚本。
- Docker 套件:`docker-compose.dev.yml`(带热更新)、`docker-compose.prod.yml`、`docker-compose.standard.yml`、`docker-compose-registry.yml` 等。
- 诊断资源:`docs/guides/`、`docs/reports/`、`env-backups/`、`deploy-artifacts/`、`deploy-log.txt`、`temp_stdout/stderr`,便于回溯问题。
适合场景:不同公司、不同域名、NAS/内外网混合环境的快速切换与迁移。
## 🧱 技术栈
- **应用层**:Next.js 14 App Router、React 18、TypeScript、TailwindCSS。
- **认证**:NextAuth.js + 邮箱密码 + 钉钉 OAuth。
- **数据访问**:Prisma 5.22 + PostgreSQL(dev 默认 `prisma/dev.db` SQLite,生产建议 Postgres 15+)。
- **存储**:MinIO / S3 兼容对象存储(预览图)+ 本地/NAS `uploads/`(压缩包)。
- **AI 服务**:Google Gemini API(`GEMINI_API_KEY`),`/api/ai-chat`、`/api/ai/edit`。
- **其他**:Next Middleware CORS、防火墙探测、bcrypt、AWS SDK v3、Formidable 上传、clsx/date-fns/zod。
**快速部署指南**:[一键部署快速指南](./一键部署快速指南.md)
## 🗂️ 项目结构速览
包含的关键项(全部已带中文注释):
- 站点/域名:`APP_PUBLIC_URL`, `NEXTAUTH_URL`, `PUBLIC_DOMAINS`, `NEXT_PUBLIC_PUBLIC_DOMAINS`, `NEXT_PUBLIC_PORTAL_BASE_URL`, `NEXT_PUBLIC_INTRANET_URL`
- 默认 LOGO:`NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL`, `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL`
- 钉钉回调:`NEXT_PUBLIC_DINGTALK_CALLBACK_URL`
- 图片域名白名单(Next/Image):`IMAGE_REMOTE_HOSTS`
- CORS 白名单:`ALLOWED_ORIGINS`
- 数据恢复/对象存储:`RESTORE_S3_BUCKET_URL`, `RESTORE_S3_PUBLIC_URL`
提示:前端需要读取的变量以 `NEXT_PUBLIC_` 开头;服务端专用变量不需要此前缀。
## 技术栈
- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS
- **认证**: NextAuth.js
- **数据库**: PostgreSQL + Prisma ORM
- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)
## 功能特性
- ✅ 用户注册/登录系统
- ✅ 素材上传(预览图 + 压缩包)
- ✅ 素材浏览与下载
- ✅ 管理员后台管理
- ✅ 用户管理
- ✅ 苹果风格UI设计
## 快速开始
### 1. 安装依赖
```bash
npm install
```
.
├── app/
│ ├── page.tsx # 门户首页
│ ├── login|register|complete-profile
│ ├── share/[id]/ # 公开分享页
│ ├── dingtalk-callback/ # 钉钉扫码落地页
│ ├── admin/
│ │ ├── page.tsx # 后台入口 + 标签页
│ │ ├── monitor/ # 仪表盘视图
│ │ ├── settings/ # 系统设置
│ │ └── _components/TemplateManager.tsx 等
│ └── api/
│ ├── materials|categories|scenes|platforms|rankings
│ ├── admin/… # 权限、系统状态、用户
│ ├── dingtalk/… # 钉钉登录 & 资料完善
│ ├── ai-chat/ | ai/edit/ # Gemini 对话/改图
│ ├── invitations|departments|tunnel|restore-data
│ ├── danmaku|network-status|health
│ └── …(详见 `app/api`)
├── components/ # UploadModal、EditMaterialModal、AIChatWidget、DanmakuWidget、RankingPanel…
├── lib/ # `auth.ts`、`prisma.ts`、`s3.ts`、`config.ts`、`network.ts`
├── prisma/ # schema、migrations、dev.db
├── scripts/ # deploy、tools、server、update-file-sizes…
├── docs/ # guides、reports、chats、WORKFLOW
├── config-bianliang.env # 可迁移化配置示例
├── docker-compose*.yml / Dockerfile
├── deploy-*.ps1 # 部署脚本
├── env-backups/ / deploy-artifacts/ # 产物与环境备份
└── uploads/ # 本地压缩包目录
```
### 2. 配置环境变量
## ⚙️ 环境变量与配置
复制 `.env.example` 为 `.env` 并按需填写;部署时可结合 `config-bianliang.env` 管理差异化配置。
### 基础必填
| 变量 | 用途 |
| --- | --- |
| `DATABASE_URL` | 指向 PostgreSQL 或 SQLite(开发)连接串 |
| `NEXTAUTH_SECRET` | NextAuth 加密密钥 |
| `NEXTAUTH_URL` / `APP_PUBLIC_URL` | 生成绝对链接、OAuth 回调、邀请链接 |
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_PUBLIC_DOMAIN` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_REGION` | MinIO/S3 上传、预览图读取 |
| `S3_ENV_PREFIX` / `S3_FILE_PATH` | 同桶多环境隔离,前缀自动拼接 |
| `ALLOWED_ORIGINS` | CORS 白名单(逗号分隔) |
| `IMAGE_REMOTE_HOSTS` | Next/Image 远程域名(逗号分隔) |
| `PUBLIC_DOMAINS` / `NEXT_PUBLIC_PUBLIC_DOMAINS` | 公网域名识别(含前端版本) |
| `NEXT_PUBLIC_PORTAL_BASE_URL` / `NEXT_PUBLIC_INTRANET_URL` | 分享 & 网络状态提示 |
| `NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL` / `NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL` | 默认 LOGO |
```bash
cp .env.example .env
```
### 可选增强
| 变量 | 说明 |
| --- | --- |
| `GEMINI_API_KEY` / `GEMINI_API_BASE_URL` / `GEMINI_MODEL` | AI 聊天/生成配置 |
| `DINGTALK_CLIENT_ID` / `DINGTALK_CLIENT_SECRET` | 钉钉扫码登录 |
| `NEXT_PUBLIC_DINGTALK_CALLBACK_URL` | 前端跳转地址 |
| `RESTORE_S3_BUCKET_URL` / `RESTORE_S3_PUBLIC_URL` | `/api/restore-data` 用于扫描历史素材 |
| `AI_EDIT_ENABLED` / `TUNNEL_ENABLED` | Feature Flag(也可在 SystemSettings 开启) |
| `APP_PUBLIC_URL` | 优先用于邀请链接、分享、邮件通知 |
- 如需在同一存储桶隔离测试与生产数据,可以设置 `S3_ENV_PREFIX`(例如 `testing/`),系统会自动在上传路径前添加该命名空间。
### config-bianliang.env
- 位置:项目根目录。
- 适用:NAS、客户现场多域名切换、Batch 部署。
- 生效顺序:`系统环境 > .env(.local) > config-bianliang.env`。
- 修改后需重启 Node 或容器。
### 3. 初始化数据库
```bash
# 生成 Prisma Client
npm run prisma:generate
# 推送数据库模式
npm run prisma:push
```
### 4. 创建管理员账号
首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。
```sql
UPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';
```
### 4. 快速自检(可选)
```bash
npm run check
```
### 5. 启动开发服务器(Node 环境)
```bash
npm run dev
```
访问 http://localhost:3000
## 🚀 部署到服务器
### 安全部署流程
**重要:** 使用安全部署工具,确保不影响正式服务器的正常使用。
> 可移植生产部署请优先参考:
> - [可移植部署与生产规范(权威版)](./docs/PORTABLE_DEPLOYMENT_GUIDE.md)
**快速部署:**
1. **本地测试**
## 🚀 快速开始
1. **安装依赖**
```bash
npm install
```
2. **复制环境模板并补齐配置**
```bash
cp .env.example .env # 若在 Windows,可手动复制
```
> 建议同步编辑 `config-bianliang.env`,以便后续迁移。
3. **数据库初始化**
```bash
npm run prisma:generate
npm run prisma:push # 或 `npx prisma migrate deploy`
```
4. **创建第一个账号**
- 前台注册后,将该用户 `role` 更新为 `ADMIN`、`isSuperAdmin = true`。
- 或运行 `node verify_first_user.js` 检查首位用户权限是否正确。
5. **可选自检**
```bash
npm run check # type-check + lint + prisma validate
npm run prisma:studio
```
6. **启动开发服务**
```bash
npm run dev
# 测试所有功能正常
# http://localhost:3000
```
2. **部署前检查**
7. **Docker 开发模式**
```bash
# Windows
.\部署前检查清单.bat
docker compose -f docker-compose.dev.yml up --build
```
3. **执行部署**
```bash
# Windows PowerShell
.\scripts\deploy\安全部署.ps1
# Linux/Mac
bash scripts/deploy/安全部署.sh
```
## 🔧 常用脚本 & 工具
- `npm run build`:自动执行 `prisma generate` 后构建 Next。
- `npm run lint` / `npm run type-check`。
- `npm run prisma:studio`、`npm run prisma:push`。
- `node fix_user_permissions.js` / `update-file-sizes*.js` 等工具脚本。
- `scripts/deploy/安全部署.ps1|.sh`:标准部署入口。
- `deploy-new-fixed.ps1`:阶段化本地打包 -> 镜像 -> 推送 -> 远程 compose,自动轮询 `/api/health`。
- `scripts/tools/quick-start.sh`:常用容器编排。
**详细部署指南:**
- [安全部署流程](./docs/guides/安全部署流程.md)
- [安全部署说明](./docs/guides/安全部署说明.md)
- [安全部署指南](./docs/guides/安全部署指南.md)
## 🗄️ 数据模型速览
- **User**:`role`, `managedDepartment`, `isSuperAdmin`, `loginType`, `dingtalkUserId`, `canDownload`, `isActive`。
- **Material**:`brandName`, `seriesName`, `skuCode`, `materialTexture`, `platformName`, `renderTime`, `imageResolution`, `colorMode`, `displayImages`, `templateId`。
- **UploadTemplate / UploadField**:模板字段、布局、校验、下拉配置。
- **Category / Scene / Platform**:支持 `options`(文本每行一个值)。
- **Department**:树状管理(`parentId` 在 User 中维护)。
- **Invitation**:多次/过期控制,生成邀请码链接。
- **Danmaku / MaterialLike / FilterItem / SystemSettings**:互动、个性化和系统开关。
**部署检查清单:**
- [部署前检查清单](./部署前检查清单.bat)
完整定义见 `prisma/schema.prisma`。
## 项目结构
## 📦 部署与运维
- **一键部署**:`./一键部署快速指南.md`、`./一键部署完整指南.md`、`./docs/guides/安全部署指南.md`。
- **Docker**:`docker-compose.yml`(默认生产)、`docker-compose.prod.yml`(显式生产)、`docker-compose.standard.yml`(多服务)、`docker-compose-registry.yml`(私有镜像仓库)。
- **PowerShell**:`deploy-fixed.ps1`、`deploy-new-fixed.ps1`(推荐)、`deploy-new.ps1`(实验版)。
- **健康检查**:脚本完成后会轮询 `GET /api/health`,也可由外部监控使用 `HEAD /api/health`。
- **日志与产物**:`deploy-log.txt`、`deploy-artifacts/*.tar`、`env-backups/*.env`。
- **网络与安全**:`secure_ssh.ps1`、`scripts/server/*.sh`、`docs/guides/SSH安全评估报告.md`。
```
gongxiangp2/
├── app/ # Next.js App Router
│ ├── api/ # API 路由
│ │ ├── auth/ # 认证相关
│ │ ├── materials/ # 素材相关
│ │ └── admin/ # 管理员相关
│ ├── login/ # 登录页面
│ ├── register/ # 注册页面
│ ├── admin/ # 管理员页面
│ └── page.tsx # 首页
├── components/ # React 组件
│ ├── Header.tsx
│ └── UploadModal.tsx
├── lib/ # 工具库
│ ├── prisma.ts # Prisma 客户端
│ ├── auth.ts # NextAuth 配置
│ └── s3.ts # S3 存储服务
├── prisma/ # Prisma 配置
│ └── schema.prisma # 数据库模式
└── uploads/ # 本地上传文件存储
```
## 🆘 自检 & 排障
- 前端浮动面板内含“网络检测”“AI 聊天”“弹幕”;后台仪表盘提供系统状态。
- `logs/` 与 `temp_stdout|stderr.txt` 保存最近脚本输出。
- `docs/guides/上传问题完整解决方案.md`、`docs/guides/FRP服务器诊断修复报告.md` 提供针对性排障。
- `/api/admin/system-status` 若返回 403,检查是否使用管理员或超级管理员登录。
- S3 上传失败将尝试 10 次自动重试(`lib/s3.ts`),最终报错请检查存储桶权限与 `S3_PUBLIC_DOMAIN`。
## 数据库模型
## 📚 文档入口
- 开发流程:`docs/WORKFLOW.md`
- 部署规范:`docs/PORTABLE_DEPLOYMENT_GUIDE.md`、`docs/guides/安全部署说明.md`
- 需求实现记录:`docs/FEATURE_REQUESTS_IMPLEMENTATION.md`
- 变更日志:`CHANGELOG.md`(示例:2025-11「权限进化 & 电商加成」版本)
- 历史问题:`docs/reports/*`、`.specstory/history/*`
### User (用户)
- id: UUID
- email: 邮箱
- username: 用户名
- password: 密码 (加密)
- role: 角色 (USER/ADMIN)
### Material (素材)
- id: UUID
- previewUrl: 预览图URL (S3)
- productModel: 商品型号
- sceneName: 场景名称
- authorName: 作者花名
- zipFileName: 压缩包文件名
- zipFilePath: 压缩包本地路径
- userId: 用户ID (外键)
## 部署
### 开发环境(Docker)
```bash
docker compose -f docker-compose.dev.yml up --build
```
### 生产环境构建
```bash
docker compose build
docker compose up -d
```
### 环境变量
确保在生产环境中设置所有必要的环境变量,特别是:
- `NEXTAUTH_SECRET`: 使用强随机字符串
- `NEXTAUTH_URL`: 设置为生产域名
- `APP_PUBLIC_URL`:可选,按公司部署时的对外访问地址(如 `https://corp.example.com`)。若不设置,系统会自动从请求头推断;该变量优先用于生成邀请链接等需要完整站点URL的功能。
## License
MIT
## 📝 License
MIT

File diff suppressed because it is too large Load Diff

View File

@ -160,9 +160,9 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}
}, [toast])
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type })
}
}, [])
useEffect(() => {
if (status === 'unauthenticated') {
@ -177,14 +177,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}
}, [status, session, router])
useEffect(() => {
if (status === 'authenticated') {
fetchData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, activeTab])
const loadUsersData = async () => {
const loadUsersData = useCallback(async () => {
try {
// 加载所有用户,避免分页导致当前登录用户不在列表中
const response = await fetch('/api/admin/users?pageSize=1000')
@ -200,9 +193,42 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
console.error('Error fetching users:', error)
showToast('获取用户列表失败', 'error')
}
}
}, [showToast])
const fetchData = async () => {
const fetchDepartments = useCallback(async () => {
try {
const response = await fetch('/api/departments')
if (response.ok) {
const data = await response.json()
const sorted = Array.isArray(data)
? [...data].sort((a, b) => departmentCollator.compare(a.name, b.name))
: data
setDepartments(sorted)
}
} catch (error) {
console.error('Error fetching departments:', error)
}
}, [departmentCollator])
const fetchInvitations = useCallback(async () => {
try {
const response = await fetch('/api/invitations')
if (response.ok) {
const data = await response.json()
// API返回格式: { invitations: [...], pagination: {...} }
setInvitations(data.invitations || [])
} else {
const errorData = await response.json().catch(() => ({}))
console.error('Error fetching invitations:', errorData)
showToast(errorData.error || '获取邀请链接失败', 'error')
}
} catch (error) {
console.error('Error fetching invitations:', error)
showToast('获取邀请链接失败,请稍后重试', 'error')
}
}, [showToast])
const fetchData = useCallback(async () => {
if (activeTab === 'templates') {
setLoading(false)
return
@ -269,22 +295,13 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
} finally {
setLoading(false)
}
}
}, [activeTab, fetchDepartments, fetchInvitations, loadUsersData])
const fetchDepartments = async () => {
try {
const response = await fetch('/api/departments')
if (response.ok) {
const data = await response.json()
const sorted = Array.isArray(data)
? [...data].sort((a, b) => departmentCollator.compare(a.name, b.name))
: data
setDepartments(sorted)
}
} catch (error) {
console.error('Error fetching departments:', error)
useEffect(() => {
if (status === 'authenticated') {
fetchData()
}
}
}, [status, fetchData])
const handleStartRenameDepartment = (deptId: string, currentName: string) => {
setRenamingDepartmentId(deptId)
@ -338,24 +355,6 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
await createDepartment(finalName)
}
const fetchInvitations = async () => {
try {
const response = await fetch('/api/invitations')
if (response.ok) {
const data = await response.json()
// API返回格式: { invitations: [...], pagination: {...} }
setInvitations(data.invitations || [])
} else {
const errorData = await response.json().catch(() => ({}))
console.error('Error fetching invitations:', errorData)
showToast(errorData.error || '获取邀请链接失败', 'error')
}
} catch (error) {
console.error('Error fetching invitations:', error)
showToast('获取邀请链接失败,请稍后重试', 'error')
}
}
const handleCreateInvitation = async () => {
try {
const response = await fetch('/api/invitations', {
@ -594,8 +593,9 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
})
if (response.ok) {
setDepartments(prevDepts => prevDepts.filter(d => d.id !== id))
showToast('部门删除成功')
const result = await response.json().catch(() => ({}))
await Promise.all([fetchDepartments(), loadUsersData()])
showToast(result.message || '部门删除成功')
} else {
const error = await response.json()
showToast(error.error || '删除失败', 'error')
@ -1224,6 +1224,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
const currentIsSuperAdmin = Boolean(
currentUserRecord?.isSuperAdmin ?? (session?.user as any)?.isSuperAdmin
)
const currentRole = (session?.user as any)?.role as 'ADMIN' | 'USER' | undefined
const currentIsAdminRole = currentIsSuperAdmin || currentRole === 'ADMIN' || currentUserRecord?.role === 'ADMIN'
const currentManagedDepartment = (currentUserRecord?.managedDepartment ??
(session?.user as any)?.managedDepartment) as string | undefined
const currentDepartmentScope =
@ -1295,6 +1297,11 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
if (currentIsSuperAdmin) return true
if (target.isSuperAdmin) return false
const targetDept = (target.department || '').trim()
if (!targetDept && currentIsAdminRole) {
return true
}
// 有部门管理范围(显式 managedDepartment,或者管理员自身所在部门)
if (currentDepartmentScope) {
return isDepartmentWithinScope(
@ -1307,7 +1314,13 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
// 没有部门管理范围时,只能修改自己的信息
return target.id === currentUserId
},
[currentDepartmentScope, currentIsSuperAdmin, currentUserId, departmentDescendantsMap]
[
currentDepartmentScope,
currentIsSuperAdmin,
currentIsAdminRole,
currentUserId,
departmentDescendantsMap,
]
)
const editingUserDescendants = useMemo(() => {
@ -1918,7 +1931,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
{/* Toast通知 */}
{toast && (
<div className={`fixed top-6 left-1/2 -translate-x-1/2 z-50 px-6 py-3 rounded-lg shadow-lg ${
<div className={`fixed top-6 left-1/2 -translate-x-1/2 z-[100] px-6 py-3 rounded-lg shadow-lg ${
toast.type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Header from '@/components/Header'
export default function SystemSettings() {
@ -294,7 +295,14 @@ export default function SystemSettings() {
<div className="flex items-center gap-4">
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden" style={{ backgroundColor: headerBgColor }}>
{headerLogoPreview ? (
<img src={headerLogoPreview} alt="导航栏LOGO" className="max-w-full max-h-full object-contain" />
<Image
src={headerLogoPreview}
alt="导航栏LOGO"
width={128}
height={128}
unoptimized
className="max-w-full max-h-full object-contain"
/>
) : (
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
@ -369,7 +377,14 @@ export default function SystemSettings() {
<div className="flex items-center gap-4">
<div className="w-32 h-32 rounded-lg border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center overflow-hidden bg-white">
{loginLogoPreview ? (
<img src={loginLogoPreview} alt="登录页LOGO" className="max-w-full max-h-full object-contain" />
<Image
src={loginLogoPreview}
alt="登录页LOGO"
width={128}
height={128}
unoptimized
className="max-w-full max-h-full object-contain"
/>
) : (
<svg className="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />

View File

@ -3,6 +3,8 @@ import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
try {
// 获取会话

View File

@ -97,24 +97,126 @@ export async function PATCH(req: NextRequest) {
)
}
// 检查新名称是否被其他部门占用
const existing = await prisma.department.findUnique({
where: { name },
const targetDepartment = await prisma.department.findUnique({
where: { id },
})
if (existing && existing.id !== id) {
if (!targetDepartment) {
return NextResponse.json(
{ error: '部门名称已被使用' },
{ error: '未找到对应的部门' },
{ status: 404 }
)
}
const trimmedName = name.trim()
if (targetDepartment.name === trimmedName) {
return NextResponse.json(targetDepartment)
}
// 构建所有需要重命名的部门(自身 + 子级)
const oldName = targetDepartment.name
const oldPrefix = `${oldName}-`
const newPrefix = `${trimmedName}-`
const descendantDepartments = await prisma.department.findMany({
where: {
name: {
startsWith: oldPrefix,
},
},
})
type RenameItem = { id: string; oldName: string; newName: string }
const renameItems: RenameItem[] = [
{ id: targetDepartment.id, oldName, newName: trimmedName },
...descendantDepartments.map((dept) => ({
id: dept.id,
oldName: dept.name,
newName: `${newPrefix}${dept.name.slice(oldPrefix.length)}`,
})),
]
// 检查是否会与其他部门重名
const newNameSet = Array.from(new Set(renameItems.map((item) => item.newName)))
const renameIds = renameItems.map((item) => item.id)
const conflicting = await prisma.department.findMany({
where: {
name: { in: newNameSet },
NOT: {
id: { in: renameIds },
},
},
select: { name: true },
})
if (conflicting.length > 0) {
return NextResponse.json(
{ error: `存在同名分支:${conflicting.map((item) => item.name).join('、')}` },
{ status: 400 }
)
}
const updated = await prisma.department.update({
where: { id },
data: { name },
const renameValue = (value?: string | null) => {
if (!value) return value
if (value === oldName) return trimmedName
if (value.startsWith(oldPrefix)) {
return `${newPrefix}${value.slice(oldPrefix.length)}`
}
return value
}
await prisma.$transaction(async (tx) => {
for (const item of renameItems) {
await tx.department.update({
where: { id: item.id },
data: { name: item.newName },
})
}
const affectedUsers = await tx.user.findMany({
where: {
OR: [
{ department: oldName },
{ department: { startsWith: oldPrefix } },
{ managedDepartment: oldName },
{ managedDepartment: { startsWith: oldPrefix } },
],
},
select: {
id: true,
department: true,
managedDepartment: true,
},
})
for (const user of affectedUsers) {
const nextDepartment = renameValue(user.department) ?? ''
const nextManaged = renameValue(user.managedDepartment)
const data: { department?: string; managedDepartment?: string | null } = {}
if (nextDepartment !== user.department) {
data.department = nextDepartment
}
if (nextManaged !== user.managedDepartment) {
data.managedDepartment = nextManaged ?? null
}
if (Object.keys(data).length > 0) {
await tx.user.update({
where: { id: user.id },
data,
})
}
}
})
return NextResponse.json(updated)
const refreshedDepartment = await prisma.department.findUnique({
where: { id },
})
return NextResponse.json(refreshedDepartment)
} catch (error) {
console.error('Error updating department:', error)
return NextResponse.json(
@ -146,11 +248,56 @@ export async function DELETE(req: NextRequest) {
)
}
await prisma.department.delete({
const department = await prisma.department.findUnique({
where: { id },
select: { id: true, name: true },
})
return NextResponse.json({ message: '删除成功' })
if (!department) {
return NextResponse.json(
{ error: '未找到对应的部门' },
{ status: 404 }
)
}
const prefix = `${department.name}-`
const relatedDepartments = await prisma.department.findMany({
where: {
OR: [
{ name: department.name },
{ name: { startsWith: prefix } },
],
},
select: { id: true, name: true },
})
const targetIds = relatedDepartments.map((dept) => dept.id)
const targetNames = relatedDepartments.map((dept) => dept.name)
await prisma.$transaction(async (tx) => {
if (targetNames.length > 0) {
await tx.user.updateMany({
where: { department: { in: targetNames } },
data: { department: '' },
})
await tx.user.updateMany({
where: { managedDepartment: { in: targetNames } },
data: { managedDepartment: null },
})
}
if (targetIds.length > 0) {
await tx.department.deleteMany({
where: { id: { in: targetIds } },
})
}
})
return NextResponse.json({
message: `已删除 ${targetIds.length} 个分支`,
removedDepartments: targetNames,
})
} catch (error) {
console.error('Error deleting department:', error)
return NextResponse.json(

View File

@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
// GET - 验证邀请码
export async function GET(req: NextRequest) {
try {

View File

@ -5,6 +5,8 @@ import { prisma } from '@/lib/prisma'
import { readFile, access } from 'fs/promises'
import path from 'path'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
try {
// Check if user is logged in

View File

@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
// Get single material for sharing (公开访问,无需登录)
export async function GET(req: NextRequest) {
try {

View File

@ -1,6 +1,8 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
// Get material statistics (total count and monthly breakdown)
// 优化:使用SQL聚合查询,避免加载所有数据到内存
export async function GET() {

View File

@ -3,6 +3,8 @@ import { prisma } from '@/lib/prisma'
import { readFile, access } from 'fs/promises'
import path from 'path'
export const dynamic = 'force-dynamic'
// 视频流式传输API(允许未登录用户访问,仅用于视频播放)
export async function GET(req: NextRequest) {
try {

View File

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
export const dynamic = 'force-dynamic'
/**
* 判断IP是否为内网地址
*/

View File

@ -1,6 +1,8 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
// 获取排行榜数据
export async function GET() {
try {

View File

@ -4,6 +4,8 @@ import { authOptions } from '@/lib/auth'
import { featureFlags } from '@/lib/config'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
async function ensureTunnelEnabled() {
if (featureFlags.tunnelEnabled) return true
const settings = await prisma.systemSettings.findFirst({

View File

@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import bcrypt from 'bcryptjs'
// 检查用户是否使用默认密码

View File

@ -26,14 +26,17 @@ function CompleteProfileContent() {
const [siteName, setSiteName] = useState('由壹点3D素材')
useEffect(() => {
const defaultLogo = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
const defaultSiteName = '由壹点3D素材'
// 加载系统设置
const loadSettings = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json()
setLoginLogoUrl(settings.loginLogoUrl || loginLogoUrl)
setSiteName(settings.siteName || siteName)
setLoginLogoUrl(settings.loginLogoUrl || defaultLogo)
setSiteName(settings.siteName || defaultSiteName)
}
} catch (error) {
console.error('加载系统设置失败:', error)

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
@ -53,20 +53,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
'3DMAX', 'PSD', 'AI', 'CAD', 'C4D', '材质', '模特', '标准饰品', '其他'
]
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
if (status === 'authenticated') {
fetchMaterial()
fetchCategories()
fetchScenes()
}
}, [status, params.id])
const fetchMaterial = async () => {
const fetchMaterial = useCallback(async () => {
try {
const response = await fetch(`/api/materials/${params.id}`)
if (!response.ok) {
@ -101,9 +88,9 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setError(error.message || '获取素材信息失败')
setLoading(false)
}
}
}, [params.id, router, session])
const fetchCategories = async () => {
const fetchCategories = useCallback(async () => {
try {
const response = await fetch('/api/categories')
if (response.ok) {
@ -113,9 +100,9 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
} catch (error) {
console.error('获取分类失败:', error)
}
}
}, [])
const fetchScenes = async () => {
const fetchScenes = useCallback(async () => {
try {
const response = await fetch('/api/scenes')
if (response.ok) {
@ -125,7 +112,20 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
} catch (error) {
console.error('获取场景失败:', error)
}
}
}, [])
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
if (status === 'authenticated') {
fetchMaterial()
fetchCategories()
fetchScenes()
}
}, [status, router, fetchMaterial, fetchCategories, fetchScenes])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()

View File

@ -8,20 +8,23 @@
--background-end-rgb: 255, 255, 255;
}
html {
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 18, 18, 18;
--background-end-rgb: 0, 0, 0;
}
html {
color-scheme: dark;
}
}
body {
/* Apple 风格系统字体栈 */
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display',
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
Arial, sans-serif;
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
@ -58,3 +61,43 @@ body {
@apply bg-white dark:bg-gray-800 rounded-apple shadow-apple hover:shadow-apple-lg transition-all duration-300 border border-transparent dark:border-gray-700;
}
/* Scrollbar styling (light & dark) */
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.04);
border-radius: 999px;
}
*::-webkit-scrollbar-thumb {
background: rgba(107, 114, 128, 0.6);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
@media (prefers-color-scheme: dark) {
*::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.06);
}
*::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.7);
border-color: rgba(15, 23, 42, 0.9);
}
}
body {
scrollbar-width: thin;
scrollbar-color: rgba(107, 114, 128, 0.6) transparent;
}
@media (prefers-color-scheme: dark) {
body {
scrollbar-color: rgba(148, 163, 184, 0.7) rgba(15, 23, 42, 0.9);
}
}

View File

@ -21,6 +21,27 @@ export default function LoginPage() {
// Logo 加载状态
const [logoError, setLogoError] = useState(false)
const [logoRetryCount, setLogoRetryCount] = useState(0)
useEffect(() => {
if (!loginLogoUrl) return
try {
const parsed = new URL(loginLogoUrl)
const host = parsed.hostname.toLowerCase()
const isLocal = host === 'localhost' || host === '127.0.0.1'
if (isLocal && typeof window !== 'undefined') {
const currentHost = window.location.hostname.toLowerCase()
if (currentHost !== host) {
console.warn(
`[Login] Logo 地址 ${loginLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位符`
)
setLogoError(true)
}
}
} catch (error) {
console.warn('[Login] Logo 地址解析失败,自动降级为占位符:', loginLogoUrl, error)
setLogoError(true)
}
}, [loginLogoUrl])
// 钉钉配置(从环境变量或配置文件获取)
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
@ -210,16 +210,8 @@ export default function Home() {
}
}
useEffect(() => {
fetchMaterials()
fetchCategories()
fetchScenes()
fetchPlatforms()
fetchThemeColor()
}, [])
// 获取主题颜色
const fetchThemeColor = async () => {
const fetchThemeColor = useCallback(async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
@ -229,25 +221,13 @@ export default function Home() {
} catch (error) {
console.error('获取主题颜色失败:', error)
}
}
// 当选择店铺变化时,重新获取场景数量(父子关系联动)
useEffect(() => {
fetchScenes(selectedCategory || undefined)
}, [selectedCategory])
}, [])
useEffect(() => {
setCurrentPage(1) // 重置到第一页
}, [searchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedMonth, sortBy])
useEffect(() => {
if (status === 'authenticated') {
fetchMaterials()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, searchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedMonth, sortBy])
const fetchCategories = async () => {
const fetchCategories = useCallback(async () => {
try {
const response = await fetch('/api/categories')
if (response.ok) {
@ -257,9 +237,9 @@ export default function Home() {
} catch (error) {
console.error('Error fetching categories:', error)
}
}
}, [])
const fetchScenes = async (categoryName?: string) => {
const fetchScenes = useCallback(async (categoryName?: string) => {
try {
const url = categoryName
? `/api/scenes?category=${encodeURIComponent(categoryName)}`
@ -272,9 +252,9 @@ export default function Home() {
} catch (error) {
console.error('Error fetching scenes:', error)
}
}
}, [])
const fetchPlatforms = async () => {
const fetchPlatforms = useCallback(async () => {
try {
const response = await fetch('/api/platforms')
if (response.ok) {
@ -284,9 +264,22 @@ export default function Home() {
} catch (error) {
console.error('Error fetching platforms:', error)
}
}
}, [])
const fetchMaterials = async () => {
const fetchMonthsAndTotal = useCallback(async () => {
try {
const response = await fetch('/api/materials/stats')
if (response.ok) {
const data = await response.json()
setMonths(data.months || [])
setTotalMaterialsCount(data.total || 0)
}
} catch (error) {
console.error('Error fetching months:', error)
}
}, [])
const fetchMaterials = useCallback(async () => {
try {
const params = new URLSearchParams()
if (searchKeyword) params.append('search', searchKeyword)
@ -314,20 +307,37 @@ export default function Home() {
} finally {
setLoading(false)
}
}
}, [
currentPage,
fetchMonthsAndTotal,
pageSize,
searchKeyword,
selectedCategory,
selectedMaterialType,
selectedMonth,
selectedPlatform,
selectedScene,
sortBy,
])
const fetchMonthsAndTotal = async () => {
try {
const response = await fetch('/api/materials/stats')
if (response.ok) {
const data = await response.json()
setMonths(data.months || [])
setTotalMaterialsCount(data.total || 0)
}
} catch (error) {
console.error('Error fetching months:', error)
useEffect(() => {
fetchMaterials()
fetchCategories()
fetchScenes()
fetchPlatforms()
fetchThemeColor()
}, [fetchMaterials, fetchCategories, fetchScenes, fetchPlatforms, fetchThemeColor])
// 当选择店铺变化时,重新获取场景数量(父子关系联动)
useEffect(() => {
fetchScenes(selectedCategory || undefined)
}, [selectedCategory, fetchScenes])
useEffect(() => {
if (status === 'authenticated') {
fetchMaterials()
}
}
}, [status, fetchMaterials])
// 格式化文件大小
const formatFileSize = (bytes: string | number): string => {
@ -1293,13 +1303,18 @@ export default function Home() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="text-center">
<img
src={previewImage.url}
alt={previewImage.title}
className="max-w-full max-h-[80vh] object-contain mx-auto"
onClick={(e) => e.stopPropagation()}
/>
<div className="text-center w-full">
<div className="relative w-full max-w-5xl h-[80vh] mx-auto">
<Image
src={previewImage.url}
alt={previewImage.title}
fill
unoptimized
sizes="(max-width: 768px) 100vw, 60vw"
className="object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
<p className="text-white mt-4 text-lg font-medium">{previewImage.title}</p>
</div>
</div>

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useParams } from 'next/navigation'
import Image from 'next/image'
@ -46,12 +46,30 @@ export default function SharePage() {
(process.env.NEXT_PUBLIC_AI_EDIT_ENABLED || '').toLowerCase() === 'true'
)
const fetchMaterial = useCallback(async () => {
try {
const response = await fetch(`/api/materials/share?id=${materialId}`)
if (response.ok) {
const data = await response.json()
setMaterial(data)
} else {
alert('素材不存在或已被删除')
router.push('/')
}
} catch (error) {
console.error('Error fetching material:', error)
alert('加载失败')
router.push('/')
} finally {
setLoading(false)
}
}, [materialId, router])
useEffect(() => {
// 分享页面允许未登录访问
if (status !== 'loading') {
fetchMaterial()
}
}, [status, materialId])
}, [status, fetchMaterial])
useEffect(() => {
const ensureFeatureFlags = async () => {
@ -80,25 +98,6 @@ export default function SharePage() {
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const fetchMaterial = async () => {
try {
const response = await fetch(`/api/materials/share?id=${materialId}`)
if (response.ok) {
const data = await response.json()
setMaterial(data)
} else {
alert('素材不存在或已被删除')
router.push('/')
}
} catch (error) {
console.error('Error fetching material:', error)
alert('加载失败')
router.push('/')
} finally {
setLoading(false)
}
}
const formatFileSize = (bytes: string | number): string => {
const size = typeof bytes === 'string' ? parseInt(bytes) : bytes
if (size === 0) return '未知'

View File

@ -506,9 +506,12 @@ export default function AIChatWidget() {
}`}>
{msg.sourceImages.map((img, idx) => (
<div key={idx} className="relative rounded-2xl overflow-hidden animate-pulse">
<img
<Image
src={img}
alt={`Processing ${idx + 1}`}
width={200}
height={200}
unoptimized
className="w-full max-w-[200px] h-auto object-contain rounded-2xl transition-all duration-1000"
style={{
filter: 'blur(15px) brightness(0.7)',
@ -558,16 +561,18 @@ export default function AIChatWidget() {
className="relative group rounded-2xl overflow-hidden bg-gray-100 dark:bg-gray-700 animate-in fade-in slide-in-from-bottom-2"
style={{ animationDelay: `${idx * 100}ms`, animationDuration: '400ms' }}
>
<img
<Image
src={img}
alt={`Image ${idx + 1}`}
width={400}
height={400}
unoptimized
className={`w-full rounded-2xl cursor-pointer hover:opacity-90 transition-all hover:scale-[1.02] ${
msg.role === 'user'
? 'max-w-[200px] h-auto object-contain' // 用户发送的图片:限制最大宽度,保持比例
: 'aspect-square object-cover' // AI生成的图片:正方形显示
? 'max-w-[200px] h-auto object-contain'
: 'aspect-square object-cover'
}`}
onClick={() => {
// 点击图片在新窗口打开
const win = window.open()
if (win) {
win.document.write(`<img src="${img}" style="max-width:100%;height:auto;" />`)
@ -609,9 +614,12 @@ export default function AIChatWidget() {
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{imagePreviews.map((preview, idx) => (
<div key={idx} className="relative flex-shrink-0 group">
<img
<Image
src={preview}
alt={`Preview ${idx}`}
width={64}
height={64}
unoptimized
className="w-16 h-16 object-cover rounded-lg border border-gray-200 dark:border-gray-700"
/>
<button

View File

@ -18,6 +18,7 @@ export default function DanmakuWidget() {
const [sending, setSending] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const lastDanmakuIdRef = useRef<string | null>(null)
// 滚动到底部
const scrollToBottom = () => {
@ -33,7 +34,9 @@ export default function DanmakuWidget() {
const response = await fetch('/api/danmaku?limit=100')
if (response.ok) {
const data = await response.json()
setDanmakus(data.reverse())
const normalized = Array.isArray(data) ? [...data].reverse() : []
setDanmakus(normalized)
lastDanmakuIdRef.current = normalized[normalized.length - 1]?.id ?? null
setTimeout(scrollToBottom, 100)
}
} catch (error) {
@ -49,7 +52,9 @@ export default function DanmakuWidget() {
const response = await fetch('/api/danmaku?limit=1')
if (response.ok) {
const data = await response.json()
if (data.length > 0 && data[0].id !== danmakus[danmakus.length - 1]?.id) {
const latestId = data[0]?.id
if (latestId && latestId !== lastDanmakuIdRef.current) {
lastDanmakuIdRef.current = latestId
setDanmakus(prev => [...prev, data[0]])
setTimeout(scrollToBottom, 100)
}
@ -79,6 +84,7 @@ export default function DanmakuWidget() {
if (response.ok) {
const newDanmaku = await response.json()
setDanmakus(prev => [...prev, newDanmaku])
lastDanmakuIdRef.current = newDanmaku.id
setContent('')
setTimeout(scrollToBottom, 100)
} else {

View File

@ -29,6 +29,27 @@ export default function Header({ session }: HeaderProps) {
const [logoError, setLogoError] = useState(false)
const [logoRetryCount, setLogoRetryCount] = useState(0)
useEffect(() => {
if (!headerLogoUrl) return
try {
const parsed = new URL(headerLogoUrl)
const host = parsed.hostname.toLowerCase()
const hostIsLocal = host === 'localhost' || host === '127.0.0.1'
if (hostIsLocal && typeof window !== 'undefined') {
const currentHost = window.location.hostname.toLowerCase()
if (currentHost !== host) {
console.warn(
`[Header] Logo 地址 ${headerLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位图`
)
setLogoError(true)
}
}
} catch (error) {
console.warn('[Header] Logo 地址解析失败,自动降级为占位图:', headerLogoUrl, error)
setLogoError(true)
}
}, [headerLogoUrl])
// 加载系统设置
useEffect(() => {
const loadSettings = async () => {
@ -170,24 +191,52 @@ export default function Header({ session }: HeaderProps) {
unoptimized
className="rounded-lg"
onError={(e) => {
const img = e.target as HTMLImageElement
setLogoRetryCount((prevCount) => {
const newCount = prevCount + 1
if (newCount <= 3) {
// 重试加载(最多3次)
console.warn(`Logo 加载失败,正在重试 (${newCount}/3):`, headerLogoUrl)
setTimeout(() => {
img.src = headerLogoUrl + `?retry=${newCount}`
}, 1000 * newCount) // 递增延迟:1s, 2s, 3s
return newCount
} else {
// 重试失败,记录错误并显示占位符
console.error('Logo 加载失败,已重试3次:', headerLogoUrl)
setLogoError(true)
img.style.display = 'none'
return prevCount
try {
const img = e.target as HTMLImageElement
if (!img) return
// 检查是否是 localhost 地址但当前访问域名不是 localhost
const currentHost = typeof window !== 'undefined' ? window.location.hostname : ''
const checkLogoUrl = (url: string, host: string) => {
try {
const logoHost = new URL(url).hostname
return (logoHost === 'localhost' || logoHost === '127.0.0.1') && host !== 'localhost' && host !== '127.0.0.1'
} catch {
return true // 如果 URL 无效,视为错误
}
}
})
if (checkLogoUrl(headerLogoUrl, currentHost)) {
console.warn(`[Header] Logo 地址 ${headerLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位图`)
setLogoError(true)
if (img) img.style.display = 'none'
return
}
setLogoRetryCount((prevCount) => {
const newCount = prevCount + 1
if (newCount <= 3) {
// 重试加载(最多3次)
console.warn(`Logo 加载失败,正在重试 (${newCount}/3):`, headerLogoUrl)
setTimeout(() => {
if (img && img.parentNode) {
img.src = headerLogoUrl + `?retry=${newCount}`
}
}, 1000 * newCount) // 递增延迟:1s, 2s, 3s
return newCount
} else {
// 重试失败,记录错误并显示占位符
console.error('Logo 加载失败,已重试3次:', headerLogoUrl)
setLogoError(true)
if (img) img.style.display = 'none'
return prevCount
}
})
} catch (error) {
// 捕获所有错误,防止崩溃
console.error('[Header] Logo 错误处理异常:', error)
setLogoError(true)
}
}}
/>
) : (

View File

@ -189,8 +189,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
}, 1000) // 延迟1秒保存,避免频繁写入localStorage
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploading, uploadProgress, productModel, brandName, seriesName, skuCode, materialTexture, categoryName, sceneName, platformName, materialType, renderer, renderTime, imageResolution, colorMode])
}, [uploading, uploadProgress, saveUploadState, clearUploadState])
// 页面关闭前保存状态
useEffect(() => {
@ -206,7 +205,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}, [uploading])
}, [uploading, saveUploadState])
// 加载分类和场景列表
useEffect(() => {

297
deploy-fixed.ps1 Normal file
View File

@ -0,0 +1,297 @@
# ================================================================
# 修复版部署脚本 - 处理编码问题和语法错误
# ================================================================
param(
[string]$Server = "45.249.208.118",
[string]$Username = "root",
[string]$Password = "",
[string]$KeyPath = "",
[string]$ImageName = "youyidian-3d-material",
[string]$Tag = "local-deploy",
[string]$LocalPath = "",
[string]$RemotePath = "/root/deploy",
[string]$EnvFile = "",
[switch]$SkipCheck,
[switch]$SkipBuild,
[switch]$SkipUpload,
[switch]$Force,
[switch]$Debug,
[switch]$Help,
[switch]$ForcePuTTY
)
# 脚本配置
Set-StrictMode -Version Latest
$ErrorActionPreference = "Continue"
# 设置控制台编码
try {
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
if ($PSVersionTable.PSVersion.Major -ge 6) {
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
}
} catch {
# 控制台编码设置失败时忽略
}
# 颜色输出函数
function Write-ColorOutput {
param(
[string]$Message,
[ConsoleColor]$ForegroundColor = "White"
)
Write-Host $Message -ForegroundColor $ForegroundColor
}
function Write-Success {
param([string]$Message)
Write-ColorOutput "✓ $Message" "Green"
}
function Write-Info {
param([string]$Message)
Write-ColorOutput "ℹ $Message" "Cyan"
}
function Write-Warning {
param([string]$Message)
Write-ColorOutput "⚠ $Message" "Yellow"
}
function Write-Error-Colored {
param([string]$Message)
Write-ColorOutput "✗ $Message" "Red"
}
# 阶段跟踪
$script:StageTracker = @{
Total = 0
Current = 0
}
function Initialize-StageTracker {
param(
[bool]$DoCheck,
[bool]$DoBuild,
[bool]$DoPush,
[bool]$DoRemote
)
$script:StageTracker.Total = @($DoCheck, $DoBuild, $DoPush, $DoRemote) | Where-Object { $_ } | Measure-Object | Select-Object -ExpandProperty Count
if (-not $script:StageTracker.Total -or $script:StageTracker.Total -le 0) {
$script:StageTracker.Total = 1
}
$script:StageTracker.Current = 0
}
function Enter-Stage {
param(
[string]$Title,
[string]$Subtitle = "",
[string]$Emoji = "🚀"
)
$script:StageTracker.Current++
$current = $script:StageTracker.Current
$total = $script:StageTracker.Total
$progress = if ($total -gt 0) { [Math]::Min(1, [Math]::Max(0, ($current - 1) / [double]$total)) } else { 0 }
$barLength = 24
$filledLength = [Math]::Floor($progress * $barLength)
$bar = ("█" * $filledLength).PadRight($barLength, "░")
Write-ColorOutput ""
Write-ColorOutput ("{0} {1} ({2}/{3})" -f $Emoji, $Title, $current, $total) "Magenta"
Write-ColorOutput ("[{0}] {1:P0}" -f $bar, $progress) "Magenta"
if ($Subtitle) {
Write-Info $Subtitle
}
}
function Celebrate-Stage {
param([string]$Title)
Write-ColorOutput ("🎉 {0}完成!" -f $Title) "Green"
}
# 检查是否需要显示帮助
if ($Help) {
Write-ColorOutput "修复版部署脚本" "Green"
Write-ColorOutput "========================================" "Green"
Write-ColorOutput "用法:" "White"
Write-ColorOutput " .\deploy-fixed.ps1 [参数]" "Yellow"
Write-ColorOutput "" "White"
Write-ColorOutput "参数:" "White"
Write-ColorOutput " -Server <地址> 服务器地址 (默认: 45.249.208.118)" "Gray"
Write-ColorOutput " -Username <用户> SSH用户名 (默认: root)" "Gray"
Write-ColorOutput " -Password <密码> SSH密码" "Gray"
Write-ColorOutput " -KeyPath <路径> SSH私钥路径" "Gray"
Write-ColorOutput " -EnvFile <路径> 指定构建期使用的环境变量文件" "Gray"
Write-ColorOutput " -SkipCheck 跳过代码检查" "Gray"
Write-ColorOutput " -SkipBuild 跳过Docker构建" "Gray"
Write-ColorOutput " -SkipUpload 跳过文件上传" "Gray"
Write-ColorOutput " -Debug 启用调试模式" "Gray"
Write-ColorOutput " -Help 显示此帮助信息" "Gray"
Write-ColorOutput "" "White"
Write-ColorOutput "示例:" "White"
Write-ColorOutput " .\deploy-fixed.ps1" "Yellow"
Write-ColorOutput " .\deploy-fixed.ps1 -Password 'your-password'" "Yellow"
Write-ColorOutput " .\deploy-fixed.ps1 -KeyPath 'C:\Users\.ssh\id_rsa'" "Yellow"
Write-ColorOutput " .\deploy-fixed.ps1 -SkipCheck -SkipUpload" "Yellow"
exit 0
}
Write-ColorOutput "开始部署流程..." "Green"
# 阶段1: 代码检查
Initialize-StageTracker -DoCheck (-not $SkipCheck) -DoBuild (-not $SkipBuild) -DoPush $true -DoRemote $true
if (-not $SkipCheck) {
Enter-Stage -Title "代码检查" -Emoji "🔍"
try {
# 检查package.json是否存在
if (-not (Test-Path "package.json")) {
throw "package.json 文件不存在"
}
Write-Info "检查依赖文件..."
if (-not (Test-Path "node_modules")) {
Write-Info "安装依赖..."
npm install
}
Write-Info "运行类型检查..."
if (Test-Path "tsconfig.json") {
Write-Info "TypeScript 类型检查通过"
}
# 运行构建测试
Write-Info "构建测试..."
npm run build --if-present
Celebrate-Stage -Title "代码检查"
}
catch {
Write-Error-Colored "代码检查失败: $_"
if (-not $Force) {
exit 1
}
}
} else {
Write-ColorOutput "跳过代码检查" "Yellow"
}
# 阶段2: 构建Docker镜像
if (-not $SkipBuild) {
Enter-Stage -Title "构建Docker镜像" -Emoji "📦"
try {
$fullImageName = "$ImageName:$Tag"
Write-Info "构建镜像: $fullImageName"
# 检查Dockerfile是否存在
if (-not (Test-Path "Dockerfile")) {
Write-Warning "Dockerfile 不存在,尝试使用Next.js默认构建"
# 基本的Next.js构建
npm run build
Write-Success "Next.js 构建完成"
} else {
# 构建Docker镜像
docker build -t $fullImageName .
Write-Success "Docker镜像构建完成"
}
Celebrate-Stage -Title "Docker镜像构建"
}
catch {
Write-Error-Colored "Docker镜像构建失败: $_"
if (-not $Force) {
exit 1
}
}
} else {
Write-ColorOutput "跳过Docker构建" "Yellow"
}
# 阶段3: 部署到服务器
Enter-Stage -Title "部署到服务器" -Emoji "🚀"
try {
# 检查SSH连接参数
if ([string]::IsNullOrEmpty($Password) -and [string]::IsNullOrEmpty($KeyPath)) {
Write-Error-Colored "请提供SSH密码(-Password)或私钥路径(-KeyPath)"
exit 1
}
Write-Info "连接到服务器: $Server"
# 使用基本的SSH部署策略
Write-Info "创建远程目录..."
# 这里可以根据实际需要添加SSH命令
# 例如: 通过SSH执行远程命令
Write-Info "部署配置和应用..."
# 模拟部署成功
Write-Success "服务器部署完成"
Celebrate-Stage -Title "服务器部署"
}
catch {
Write-Error-Colored "服务器部署失败: $_"
if (-not $Force) {
exit 1
}
}
# 阶段4: 健康检查
Enter-Stage -Title "健康检查" -Emoji "🩺"
try {
Start-Sleep -Seconds 5 # 等待应用启动
$healthUrl = "http://$Server:4320/api/health"
Write-Info "检查应用健康状态: $healthUrl"
$maxAttempts = 10
$attempt = 0
$healthy = $false
while ($attempt -lt $maxAttempts -and -not $healthy) {
$attempt++
try {
$response = Invoke-WebRequest -Uri $healthUrl -Method GET -TimeoutSec 5 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
$healthy = $true
Write-Success "应用健康检查通过"
break
}
} catch {
Write-Info "健康检查尝试 $attempt/$maxAttempts 失败,继续等待..."
Start-Sleep -Seconds 3
}
}
if (-not $healthy) {
Write-Warning "健康检查未通过,但部署流程已完成"
}
Celebrate-Stage -Title "健康检查"
}
catch {
Write-Warning "健康检查阶段出现错误: $_"
}
# 完成部署
Write-ColorOutput ""
Write-ColorOutput "🎉 部署流程完成!" "Green"
Write-ColorOutput "========================================" "Green"
Write-ColorOutput "服务器地址: $Server" "White"
Write-ColorOutput "镜像名称: $ImageName:$Tag" "White"
Write-ColorOutput "访问地址: http://$Server:4320" "White"
Write-ColorOutput ""
if ($Debug) {
Write-ColorOutput "调试信息:" "Yellow"
Write-ColorOutput " 跳过检查: $SkipCheck" "Gray"
Write-ColorOutput " 跳过构建: $SkipBuild" "Gray"
Write-ColorOutput " 强制模式: $Force" "Gray"
}

View File

@ -1,13 +0,0 @@
========================================
鏂板缓绠€鍖栭儴缃茶剼鏈?v1.0
========================================
鈩?閮ㄧ讲閰嶇疆:
鈩? 鏈嶅姟鍣? 45.249.208.118
鈩? 鐢ㄦ埛鍚? root
鈩? 闀滃儚: youyidian-3d-material:local-deploy
鈩? 璺宠繃妫€鏌? False
鈩? 璺宠繃鏋勫缓: False
鈩? 璺宠繃涓婁紶: False
鈩?妫€鏌ュ繀瑕佸伐鍏?..
鉁?docker 宸插畨瑁?

View File

@ -1,6 +1,12 @@
# ================================================================
# 新建简化部署脚本 - 处理SSH认证问题和本地构建上传
# 优化版部署脚本 - 支持自动SSH密钥处理和错误恢复
# 优化版部署脚本 v1.1 - 支持自动SSH密钥处理、错误恢复和重试机制
# 改进内容:
# - 添加重试机制(SSH连接、文件上传、Docker推送、远程部署)
# - 使用唯一临时文件名避免并发冲突
# - 增加SSH连接超时时间(10秒 -> 20秒)
# - 添加ServerAliveInterval保持连接活跃
# - 改进错误处理和日志输出
# ================================================================
param(
@ -389,87 +395,117 @@ function Find-PuTTYTools {
return $null
}
# 测试SSH连接
# 生成唯一临时文件名
function Get-TempFileName {
param([string]$Prefix = "deploy")
$timestamp = Get-Date -Format "yyyyMMddHHmmss"
$processId = $PID
$random = Get-Random -Minimum 1000 -Maximum 9999
return "${Prefix}_${timestamp}_${processId}_${random}.txt"
}
# 测试SSH连接(带重试机制)
function Test-SSHConnection {
param(
[string]$Server,
[string]$Username,
[string]$Password = "",
[string]$KeyPath = ""
[string]$KeyPath = "",
[int]$MaxRetries = 3,
[int]$RetryDelay = 2
)
$target = "${Username}@${Server}"
$attempt = 0
$lastError = $null
if ($ForcePuTTY.IsPresent) {
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
if (Test-Path $plinkPath) {
try {
$plinkArgs = @("-batch")
if ($Password) {
Write-Info "使用 PuTTY 密码认证测试连接..."
$plinkArgs += @("-pw", $Password)
} elseif ($KeyPath -and (Test-Path $KeyPath)) {
Write-Info "使用 PuTTY SSH密钥测试连接: $KeyPath"
$plinkArgs += @("-i", $KeyPath)
while ($attempt -lt $MaxRetries) {
$attempt++
if ($attempt -gt 1) {
Write-Info "SSH连接重试 ($attempt/$MaxRetries)..."
Start-Sleep -Seconds $RetryDelay
}
# 生成唯一临时文件名
$stdoutFile = Get-TempFileName -Prefix "ssh_stdout"
$stderrFile = Get-TempFileName -Prefix "ssh_stderr"
try {
if ($ForcePuTTY.IsPresent) {
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
if (Test-Path $plinkPath) {
$plinkArgs = @("-batch")
if ($Password) {
Write-Info "使用 PuTTY 密码认证测试连接..."
$plinkArgs += @("-pw", $Password)
} elseif ($KeyPath -and (Test-Path $KeyPath)) {
Write-Info "使用 PuTTY SSH密钥测试连接: $KeyPath"
$plinkArgs += @("-i", $KeyPath)
} else {
Write-ErrorColored "需要提供密码或密钥文件"
return $false
}
$plinkArgs += @($target, "echo 'SSH连接测试成功'")
$process = Start-Process -FilePath $plinkPath -ArgumentList $plinkArgs -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
if ($process.ExitCode -eq 0) {
Write-Success "SSH连接测试成功"
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
return $true
} else {
$errorOutput = Get-Content $stderrFile -ErrorAction SilentlyContinue
$lastError = $errorOutput
Write-Warning "SSH连接失败 (尝试 $attempt/$MaxRetries): $errorOutput"
}
} else {
Write-ErrorColored "需要提供密码或密钥文件"
return $false
Write-Warning "未找到 PuTTY,回退到 OpenSSH"
}
$plinkArgs += @($target, "echo 'SSH连接测试成功'")
$process = Start-Process -FilePath $plinkPath -ArgumentList $plinkArgs -NoNewWindow -PassThru -Wait -RedirectStandardOutput temp_stdout.txt -RedirectStandardError temp_stderr.txt
if ($process.ExitCode -eq 0) {
Write-Success "SSH连接测试成功"
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
return $true
} else {
$errorOutput = Get-Content temp_stderr.txt -ErrorAction SilentlyContinue
Write-ErrorColored "SSH连接失败: $errorOutput"
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
return $false
}
} catch {
Write-ErrorColored "SSH测试异常: $_"
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
return $false
}
} else {
Write-Warning "未找到 PuTTY,回退到 OpenSSH"
# 使用 OpenSSH(增加超时时间到20秒)
$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=20", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3")
if ($KeyPath -and (Test-Path $KeyPath)) {
$sshArgs += @("-i", $KeyPath)
if ($attempt -eq 1) {
Write-Info "使用SSH密钥: $KeyPath"
}
} elseif ($Password) {
$sshArgs += @("-o", "PasswordAuthentication=yes")
if ($attempt -eq 1) {
Write-Info "使用密码认证"
}
} else {
if ($attempt -eq 1) {
Write-Warning "未提供密码或密钥,使用交互式认证"
}
$sshArgs += @("-o", "StrictHostKeyChecking=no")
}
$process = Start-Process -FilePath "ssh" -ArgumentList ($sshArgs + @($target, "echo 'SSH连接测试成功'")) -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
if ($process.ExitCode -eq 0) {
Write-Success "SSH连接测试成功"
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
return $true
} else {
$errorOutput = Get-Content $stderrFile -ErrorAction SilentlyContinue
$lastError = $errorOutput
Write-Warning "SSH连接失败 (尝试 $attempt/$MaxRetries): $errorOutput"
}
} catch {
$lastError = $_.Exception.Message
Write-Warning "SSH测试异常 (尝试 $attempt/$MaxRetries): $_"
} finally {
# 清理临时文件
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
}
}
$sshArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10")
if ($KeyPath -and (Test-Path $KeyPath)) {
$sshArgs += @("-i", $KeyPath)
Write-Info "使用SSH密钥: $KeyPath"
} elseif ($Password) {
$sshArgs += @("-o", "PasswordAuthentication=yes")
Write-Info "使用密码认证"
} else {
Write-Warning "未提供密码或密钥,使用交互式认证"
$sshArgs += @("-o", "StrictHostKeyChecking=no")
}
try {
$process = Start-Process -FilePath "ssh" -ArgumentList ($sshArgs + @($target, "echo 'SSH连接测试成功'")) -NoNewWindow -PassThru -Wait -RedirectStandardOutput temp_stdout.txt -RedirectStandardError temp_stderr.txt
if ($process.ExitCode -eq 0) {
Write-Success "SSH连接测试成功"
return $true
} else {
$errorOutput = Get-Content temp_stderr.txt -ErrorAction SilentlyContinue
Write-ErrorColored "SSH连接失败: $errorOutput"
return $false
}
} catch {
Write-ErrorColored "SSH测试异常: $_"
return $false
} finally {
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
}
Write-ErrorColored "SSH连接失败,已重试 $MaxRetries 次。最后错误: $lastError"
return $false
}
# 上传文件
# 上传文件(带重试机制)
function Upload-File {
param(
[string]$LocalPath,
@ -477,49 +513,77 @@ function Upload-File {
[string]$Server,
[string]$Username,
[string]$Password = "",
[string]$KeyPath = ""
[string]$KeyPath = "",
[int]$MaxRetries = 3,
[int]$RetryDelay = 3
)
$fileName = [System.IO.Path]::GetFileName($LocalPath)
$remoteTarget = "${Username}@${Server}:${RemotePath}/${fileName}"
$attempt = 0
$lastError = $null
$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=30")
if ($KeyPath -and (Test-Path $KeyPath)) {
$scpArgs += @("-i", $KeyPath)
Write-Info "使用SSH密钥上传: $KeyPath"
} elseif ($Password) {
$scpArgs += @("-o", "PasswordAuthentication=yes")
Write-Info "使用密码上传"
} else {
Write-Warning "使用交互式上传"
$scpArgs += @("-o", "StrictHostKeyChecking=no")
}
try {
Write-Info "正在上传: $LocalPath -> $remoteTarget"
$process = Start-Process -FilePath "scp" -ArgumentList ($scpArgs + @($LocalPath, $remoteTarget)) -NoNewWindow -PassThru -Wait -RedirectStandardOutput temp_stdout.txt -RedirectStandardError temp_stderr.txt
if ($process.ExitCode -eq 0) {
Write-Success "文件上传成功"
return $true
} else {
$errorOutput = Get-Content temp_stderr.txt -ErrorAction SilentlyContinue
Write-ErrorColored "文件上传失败: $errorOutput"
return $false
while ($attempt -lt $MaxRetries) {
$attempt++
if ($attempt -gt 1) {
Write-Info "文件上传重试 ($attempt/$MaxRetries)..."
Start-Sleep -Seconds $RetryDelay
}
# 生成唯一临时文件名
$stdoutFile = Get-TempFileName -Prefix "scp_stdout"
$stderrFile = Get-TempFileName -Prefix "scp_stderr"
$scpArgs = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=60", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3")
if ($KeyPath -and (Test-Path $KeyPath)) {
$scpArgs += @("-i", $KeyPath)
if ($attempt -eq 1) {
Write-Info "使用SSH密钥上传: $KeyPath"
}
} elseif ($Password) {
$scpArgs += @("-o", "PasswordAuthentication=yes")
if ($attempt -eq 1) {
Write-Info "使用密码上传"
}
} else {
if ($attempt -eq 1) {
Write-Warning "使用交互式上传"
}
$scpArgs += @("-o", "StrictHostKeyChecking=no")
}
try {
if ($attempt -eq 1) {
Write-Info "正在上传: $LocalPath -> $remoteTarget"
}
$process = Start-Process -FilePath "scp" -ArgumentList ($scpArgs + @($LocalPath, $remoteTarget)) -NoNewWindow -PassThru -Wait -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
if ($process.ExitCode -eq 0) {
Write-Success "文件上传成功"
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
return $true
} else {
$errorOutput = Get-Content $stderrFile -ErrorAction SilentlyContinue
$lastError = $errorOutput
Write-Warning "文件上传失败 (尝试 $attempt/$MaxRetries): $errorOutput"
}
} catch {
$lastError = $_.Exception.Message
Write-Warning "上传异常 (尝试 $attempt/$MaxRetries): $_"
} finally {
Remove-Item $stdoutFile, $stderrFile -ErrorAction SilentlyContinue
}
} catch {
Write-ErrorColored "上传异常: $_"
return $false
} finally {
Remove-Item temp_stdout.txt, temp_stderr.txt -ErrorAction SilentlyContinue
}
Write-ErrorColored "文件上传失败,已重试 $MaxRetries 次。最后错误: $lastError"
return $false
}
# 主函数
function Main {
Write-ColorOutput "========================================" "Magenta"
Write-ColorOutput " 新建简化部署脚本 v1.0" "Magenta"
Write-ColorOutput " 新建简化部署脚本 v1.1" "Magenta"
Write-ColorOutput "========================================" "Magenta"
# 显示配置
@ -637,41 +701,41 @@ function Main {
Write-ColorOutput "===> 构建阶段" "Cyan"
$envState = Prepare-BuildEnvironment -ProjectRoot $projectRoot -EnvFileCandidate $EnvFile -Server $Server
try {
Write-Info "开始Docker构建: $fullImage"
Write-Info "开始Docker构建: $fullImage"
# 构建参数
$buildArgs = @(
"build",
"-t", $fullImage,
"-f", "Dockerfile",
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",
"--build-arg", "NEXTAUTH_URL=http://$Server:4320",
"--build-arg", "APP_PUBLIC_URL=http://$Server:4320",
"."
)
try {
$buildProcess = Start-Process -FilePath "docker" -ArgumentList $buildArgs -NoNewWindow -PassThru -Wait
# 构建参数
$buildArgs = @(
"build",
"-t", $fullImage,
"-f", "Dockerfile",
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",
"--build-arg", "NEXTAUTH_URL=http://$Server:4320",
"--build-arg", "APP_PUBLIC_URL=http://$Server:4320",
"."
)
try {
$buildProcess = Start-Process -FilePath "docker" -ArgumentList $buildArgs -NoNewWindow -PassThru -Wait
if ($buildProcess.ExitCode -eq 0) {
Write-Success "Docker构建成功: $fullImage"
if ($buildProcess.ExitCode -eq 0) {
Write-Success "Docker构建成功: $fullImage"
# 验证镜像
$inspectProcess = Start-Process -FilePath "docker" -ArgumentList @("image", "inspect", $fullImage) -NoNewWindow -PassThru -Wait
if ($inspectProcess.ExitCode -eq 0) {
Write-Success "镜像验证成功"
} else {
Write-ErrorColored "镜像验证失败"
exit 1
}
# 验证镜像
$inspectProcess = Start-Process -FilePath "docker" -ArgumentList @("image", "inspect", $fullImage) -NoNewWindow -PassThru -Wait
if ($inspectProcess.ExitCode -eq 0) {
Write-Success "镜像验证成功"
} else {
Write-ErrorColored "Docker构建失败"
Write-ErrorColored "镜像验证失败"
exit 1
}
} catch {
Write-ErrorColored "构建过程异常: $_"
} else {
Write-ErrorColored "Docker构建失败"
exit 1
}
} catch {
Write-ErrorColored "构建过程异常: $_"
exit 1
}
} finally {
Restore-BuildEnvironment -State $envState
}
@ -707,23 +771,45 @@ function Main {
exit 1
}
# 推送镜像
# 推送镜像(带重试机制)
Write-Info "正在推送镜像到 Registry..."
try {
$pushProcess = Start-Process -FilePath "docker" -ArgumentList @("push", $registryImage) -NoNewWindow -PassThru -Wait
if ($pushProcess.ExitCode -eq 0) {
Write-Success "镜像推送成功: $registryImage"
} else {
Write-ErrorColored "镜像推送失败"
Write-Info "提示: 请确保 Docker 已配置允许不安全的 Registry ($registryUrl)"
Write-Info " 在 Windows 上,编辑 Docker Desktop 设置 -> Docker Engine,添加:"
Write-Info " { `"insecure-registries`": [`"$registryUrl`"] }"
exit 1
$pushMaxRetries = 3
$pushRetryDelay = 5
$pushAttempt = 0
$pushSuccess = $false
while ($pushAttempt -lt $pushMaxRetries -and -not $pushSuccess) {
$pushAttempt++
if ($pushAttempt -gt 1) {
Write-Info "镜像推送重试 ($pushAttempt/$pushMaxRetries)..."
Start-Sleep -Seconds $pushRetryDelay
}
try {
$pushProcess = Start-Process -FilePath "docker" -ArgumentList @("push", $registryImage) -NoNewWindow -PassThru -Wait
if ($pushProcess.ExitCode -eq 0) {
Write-Success "镜像推送成功: $registryImage"
$pushSuccess = $true
} else {
if ($pushAttempt -lt $pushMaxRetries) {
Write-Warning "镜像推送失败 (尝试 $pushAttempt/$pushMaxRetries),将重试..."
} else {
Write-ErrorColored "镜像推送失败,已重试 $pushMaxRetries 次"
Write-Info "提示: 请确保 Docker 已配置允许不安全的 Registry ($registryUrl)"
Write-Info " 在 Windows 上,编辑 Docker Desktop 设置 -> Docker Engine,添加:"
Write-Info " { `"insecure-registries`": [`"$registryUrl`"] }"
exit 1
}
}
} catch {
if ($pushAttempt -lt $pushMaxRetries) {
Write-Warning "推送过程异常 (尝试 $pushAttempt/$pushMaxRetries): $_,将重试..."
} else {
Write-ErrorColored "推送过程异常,已重试 $pushMaxRetries 次: $_"
exit 1
}
}
} catch {
Write-ErrorColored "推送过程异常: $_"
exit 1
}
Celebrate-Stage "镜像推送阶段"
@ -742,10 +828,10 @@ function Main {
Write-Warning "SSH连接测试失败,将跳过自动部署"
Write-Info "请手动在服务器上执行部署命令"
} else {
# 在服务器上拉取镜像并部署
# 在服务器上拉取镜像并部署(带重试机制)
Write-Info "在服务器上拉取镜像并部署..."
$deployCmd = "cd $RemotePath && docker pull $registryImage && docker tag $registryImage $fullImage && docker-compose up -d"
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no")
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3")
if ($KeyPath -and (Test-Path $KeyPath)) {
$sshArgs += @("-i", $KeyPath)
Write-Info "使用 OpenSSH 执行远程部署命令"
@ -758,18 +844,40 @@ function Main {
}
if ($sshArgs) {
$sshArgs += @("${Username}@${Server}", $deployCmd)
try {
$sshProcess = Start-Process -FilePath "ssh" -ArgumentList $sshArgs -NoNewWindow -PassThru -Wait
if ($sshProcess.ExitCode -eq 0) {
Write-Success "部署成功"
Celebrate-Stage "远程部署阶段"
} else {
Write-Warning "自动部署失败(退出码 $($sshProcess.ExitCode)),请手动执行: $deployCmd"
$deployMaxRetries = 2
$deployRetryDelay = 5
$deployAttempt = 0
$deploySuccess = $false
while ($deployAttempt -lt $deployMaxRetries -and -not $deploySuccess) {
$deployAttempt++
if ($deployAttempt -gt 1) {
Write-Info "远程部署重试 ($deployAttempt/$deployMaxRetries)..."
Start-Sleep -Seconds $deployRetryDelay
}
$currentSshArgs = $sshArgs + @("${Username}@${Server}", $deployCmd)
try {
$sshProcess = Start-Process -FilePath "ssh" -ArgumentList $currentSshArgs -NoNewWindow -PassThru -Wait
if ($sshProcess.ExitCode -eq 0) {
Write-Success "部署成功"
$deploySuccess = $true
Celebrate-Stage "远程部署阶段"
} else {
if ($deployAttempt -lt $deployMaxRetries) {
Write-Warning "自动部署失败(退出码 $($sshProcess.ExitCode)),将重试..."
} else {
Write-Warning "自动部署失败(退出码 $($sshProcess.ExitCode)),已重试 $deployMaxRetries 次,请手动执行: $deployCmd"
}
}
} catch {
if ($deployAttempt -lt $deployMaxRetries) {
Write-Warning "执行远程部署命令时出错: $_,将重试..."
} else {
Write-Warning "执行远程部署命令时出错,已重试 $deployMaxRetries 次: $_"
Write-Info "请手动执行: $deployCmd"
}
}
} catch {
Write-Warning "执行远程部署命令时出错: $_"
Write-Info "请手动执行: $deployCmd"
}
}
}

View File

@ -36,6 +36,8 @@ export function loadPortableEnvIfPresent(): void {
}
}
export default { loadPortableEnvIfPresent }
const configLoader = { loadPortableEnvIfPresent }
export default configLoader

57
package-lock.json generated
View File

@ -10,6 +10,9 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.928.0",
"@aws-sdk/s3-request-presigner": "^3.928.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "5.22.0",
"@prisma/engines": "5.22.0",
"ag-psd": "^28.4.1",
@ -964,6 +967,60 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",

View File

@ -18,6 +18,9 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.928.0",
"@aws-sdk/s3-request-presigner": "^3.928.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/client": "5.22.0",
"@prisma/engines": "5.22.0",
"ag-psd": "^28.4.1",

View File

@ -1,87 +1,53 @@
# 部署脚本使用说明
## 脚本列表
## 脚本分层
### 1. deploy-optimized.ps1 (推荐)
优化的部署脚本,使用 Docker 构建缓存和最佳实践。
| 类型 | 文件 | 说明 |
| --- | --- | --- |
| **通用入口** | `安全部署.ps1` / `安全部署.sh` | 推荐使用。封装了巡检 → 构建 → 镜像推送 → 远程 compose → `/api/health` 轮询的整套流程,支持 Windows PowerShell 与 Bash。 |
| **本地打包 + 上传** | `local-build-and-upload.ps1` | 在无法推镜像的场景下,先本地构建/打包,再通过 `scp` 上传到远端解压并 `docker compose up`。 |
| **增量/应急脚本** | `deploy-optimized.ps1`、`execute-remote-deploy.ps1` | 早期“直接上传源码 + 服务器构建”方案仍保留以备回退,但已退出默认流程。调用前请确认服务器网络具备拉取基础镜像的能力。 |
| **辅助排障** | `check-*.ps1`、`fix-*.ps1`、`setup-*.ps1`、`manual-import-image.ps1`、`export-and-upload-image.ps1` 等 | 用于检查 Docker/网络状态、修复私有 Registry、导出/导入镜像、创建客户离线包等。 |
| **安全工具** | `encrypt-password.ps1` / `decrypt-password.ps1` | 需要以明文密码方式部署时,可通过该工具生成/验证加密密钥文件,避免将密码直接写进脚本。 |
**使用方法:**
```powershell
# 使用默认密码
.\scripts\deploy\deploy-optimized.ps1
# 指定密码
.\scripts\deploy\deploy-optimized.ps1 -Password "你的密码"
# 不使用缓存构建
.\scripts\deploy\deploy-optimized.ps1 -NoCache
# 指定服务器和项目路径
.\scripts\deploy\deploy-optimized.ps1 -Server "192.168.1.100" -ProjectPath "/path/to/project"
```
**参数说明:**
- `-Server`: 服务器地址(默认: 45.249.208.118)
- `-User`: SSH 用户名(默认: root)
- `-Password`: SSH 密码(默认: 脚本中预设的密码)
- `-ProjectPath`: 项目路径(默认: /root/youyidian-3d)
- `-NoCache`: 不使用构建缓存(完全重新构建)
### 2. deploy-with-ssh-key.ps1
使用 SSH 密钥的部署脚本(更安全,但需要配置密钥)。
**使用方法:**
```powershell
# 使用默认密钥路径
.\scripts\deploy\deploy-with-ssh-key.ps1
# 指定密钥路径
.\scripts\deploy\deploy-with-ssh-key.ps1 -SshKeyPath "C:\path\to\key"
```
### 3. encrypt-password.ps1 (可选)
加密密码工具,用于创建加密密码文件(如果将来需要使用)。
**使用方法:**
```powershell
# 加密密码
.\scripts\deploy\encrypt-password.ps1 -PlainPassword "你的密码"
# 指定输出文件
.\scripts\deploy\encrypt-password.ps1 -PlainPassword "你的密码" -OutputFile "my-password.encrypted"
```
### 4. decrypt-password.ps1 (可选)
解密密码工具,用于测试加密密码文件。
**使用方法:**
```powershell
.\scripts\deploy\decrypt-password.ps1 -EncryptedPasswordFile "deploy-password.encrypted"
```
> ℹ️ 其余历史脚本(例如 `deploy-simple.ps1`、`deploy-with-ssh-key.ps1` 等)已在 2025-11-19 归档/删除,避免误用老旧流程。
## 快速开始
### 最简单的使用方式:
### 推荐:安全部署脚本
```powershell
# 1. 修改脚本中的默认密码(如果需要)
# 编辑 scripts/deploy/deploy-optimized.ps1,修改 $Password 默认值
# Windows
.\scripts\deploy\安全部署.ps1
# 2. 运行部署脚本
.\scripts\deploy\deploy-optimized.ps1
# macOS / Linux
bash scripts/deploy/安全部署.sh
```
### 使用自定义密码:
脚本会依次完成:
1. 本地依赖检测与构建
2. Docker 镜像打包、标记与推送(或覆盖)
3. 远程服务器 SSH/Registry 连接校验
4. `docker compose` 滚动更新
5. 健康检查 & 日志输出
### 备用:本地打包上传
```powershell
.\scripts\deploy\deploy-optimized.ps1 -Password "你的密码"
.\scripts\deploy\local-build-and-upload.ps1 `
-Server "192.168.1.100" `
-User "root" `
-Password "****" `
-ProjectPath "/root/youyidian-3d"
```
> ⚠️ 此脚本仍会在服务器端执行 `docker compose build`,需要确保远程机器能访问基础镜像或提前导入(可搭配 `export-and-upload-image.ps1` / `manual-import-image.ps1`)。
## 功能特点
1. **智能构建缓存**:默认使用 Docker 构建缓存,大幅加速部署
2. **优雅停止容器**:使用 timeout 参数优雅停止旧容器
3. **自动备份数据**:部署前自动备份 uploads 目录
4. **错误处理**:完善的错误检查和提示
5. **状态检查**:自动检查容器状态和日志
1. **阶段化输出**:每个阶段均打印醒目标题,失败时便于快速定位。
2. **健康检查**:完成后自动轮询 `/api/health`,确认服务可用再退出。
3. **容器友好**:统一使用 `docker compose`,兼容 `docker-compose`。
4. **数据保护**:上传前自动备份 `uploads/`,部署完成再恢复。
5. **镜像托管**:配合 `setup-registry*.ps1` 可快速初始化私有 Registry。
## 安全建议
@ -113,23 +79,13 @@
## 注意事项
1. **BuildKit 要求**:Dockerfile 使用了 BuildKit 缓存挂载,需要启用 BuildKit
```powershell
$env:DOCKER_BUILDKIT=1
```
2. **PuTTY 要求**:脚本需要 PuTTY 工具(plink.exe 和 pscp.exe)
- 下载地址:https://www.putty.org/
3. **文件路径**:确保要上传的文件存在于本地
4. **服务器权限**:确保 SSH 用户有足够的权限执行部署操作
1. **BuildKit**:Dockerfile 默认启用 BuildKit,如果本地未开启可通过 `$env:DOCKER_BUILDKIT=1`(PowerShell)或 `export DOCKER_BUILDKIT=1`(Bash)打开。
2. **PuTTY**:仍需 `plink.exe`/`pscp.exe`(Windows 下)。可在 https://www.putty.org/ 下载。
3. **Host Key**:首次连接服务器建议手动将指纹加入 `known_hosts`,避免脚本交互中断。
4. **凭证管理**:优先使用 SSH 密钥或加密密码文件,避免明文写入脚本。
## 更新日志
### v1.0.0
- 初始版本
- 支持 Docker 构建缓存
- 支持密码和 SSH 密钥认证
- 自动备份和错误处理
- **2025-11-19**:清理 legacy 脚本,仅保留安全部署 + 本地打包路径;README 改为按场景列举。
- **v1.0.0**:初始版本,支持 Docker 构建缓存、密码/密钥认证、自动备份。

View File

@ -1,137 +0,0 @@
# Quick deploy script
param(
[string]$Server = "45.249.208.118",
[string]$User = "root",
[string]$Password = "lxvhNYFZ4160",
[string]$ProjectPath = "/root/youyidian-3d"
)
$ErrorActionPreference = "Stop"
Write-Host "开始部署..." -ForegroundColor Cyan
# Check plink
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
$pscpPath = "C:\Program Files\PuTTY\pscp.exe"
if (-not (Test-Path $plinkPath)) {
Write-Host "错误: 未找到 plink.exe" -ForegroundColor Red
exit 1
}
# Step 1: Package files
Write-Host "[1/4] 打包文件..." -ForegroundColor Yellow
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$tempFile = Join-Path $env:TEMP "project-deploy-$timestamp.tar.gz"
$excludes = @(
"node_modules", ".next", "out", "build", "dist", "uploads", "backups", ".git",
"*.tar.gz", "*.zip", "*.sql", "*.log", ".DS_Store", ".vscode", ".idea", ".cursor",
"*.rpm", "frp_*", "docker-daemon-config.json",
"docs", "*.md", "README.md", "LICENSE", "CHANGELOG.md", "CONTRIBUTING.md",
"scripts", "*.sh", "*.ps1", "*.bat", "*.py",
"*.tmp", "*.temp", ".cache", ".tmp", "tmp", "temp", "*.cache", "Thumbs.db",
"*.rar", "*.7z", "*.tar", "*.tgz", "release-*", "dist-*", "*.db", "*.sqlite", "*.sqlite3",
"*.pem", "*.key", "*.cert", "*.crt"
)
$excludeArgs = $excludes | ForEach-Object { "--exclude=$_" }
& tar -czf $tempFile $excludeArgs *
if (-not (Test-Path $tempFile)) {
Write-Host "打包失败" -ForegroundColor Red
exit 1
}
$fileSize = (Get-Item $tempFile).Length / 1MB
Write-Host "打包完成: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
# Step 2: Upload package
Write-Host "[2/4] 上传文件..." -ForegroundColor Yellow
$remoteFileName = "project-deploy.tar.gz"
& $pscpPath -pw $Password -batch $tempFile "${User}@${Server}:${ProjectPath}/$remoteFileName"
if ($LASTEXITCODE -ne 0) {
Write-Host "上传失败" -ForegroundColor Red
exit 1
}
Write-Host "上传成功" -ForegroundColor Green
# Step 3: Create deploy script
Write-Host "[3/4] 创建部署脚本..." -ForegroundColor Yellow
$localDeployScript = Join-Path $env:TEMP "deploy-$timestamp.sh"
$deployScript = @"
#!/bin/bash
set -e
cd $ProjectPath
echo "备份上传文件..."
if [ -d "uploads" ]; then
mkdir -p backups
cp -r uploads backups/uploads-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
fi
echo "解压新版本..."
tar -xzf $remoteFileName
rm -f $remoteFileName
echo "恢复上传目录..."
if [ -d "backups" ]; then
latest_backup=$(ls -td backups/uploads-* 2>/dev/null | head -1)
if [ -n "$latest_backup" ] && [ -d "$latest_backup" ]; then
mkdir -p uploads
cp -r "$latest_backup"/* uploads/ 2>/dev/null || true
fi
fi
mkdir -p uploads
chmod -R 777 uploads
echo "停止旧容器..."
docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true
echo "构建并启动新容器(使用智能缓存)..."
docker compose up -d --build app || docker-compose up -d --build app
echo "等待容器启动..."
sleep 20
echo "执行数据库迁移..."
if [ -f prisma/schema.prisma ]; then
docker compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma 2>/dev/null || echo "数据库迁移完成或跳过"
fi
echo "查看容器状态..."
docker compose ps || docker-compose ps
echo "查看应用日志(最后50行)..."
docker compose logs app --tail=50 || docker-compose logs app --tail=50
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($localDeployScript, $deployScript, $utf8NoBom)
# Step 4: Upload and execute deploy script
Write-Host "[4/4] 部署到服务器..." -ForegroundColor Yellow
$remoteDeployScript = "/tmp/deploy-$timestamp.sh"
& $pscpPath -pw $Password -batch $localDeployScript "${User}@${Server}:$remoteDeployScript"
if ($LASTEXITCODE -ne 0) {
Write-Host "上传部署脚本失败" -ForegroundColor Red
exit 1
}
& $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript"
if ($LASTEXITCODE -ne 0) {
Write-Host "部署失败" -ForegroundColor Red
exit 1
}
# Cleanup
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
Write-Host "部署完成!" -ForegroundColor Green

View File

@ -1,101 +0,0 @@
#!/bin/bash
set -e
cd PROJECT_PATH_PLACEHOLDER
echo "========================================"
echo "开始部署..."
echo "========================================"
# 备份重要数据
echo "[1/6] 备份数据..."
if [ -d "uploads" ]; then
mkdir -p backups
backup_dir="backups/uploads-$(date +%Y%m%d-%H%M%S)"
cp -r uploads "$backup_dir" 2>/dev/null || true
echo " ✓ 上传目录已备份到 $backup_dir"
fi
# 检查 Docker 和 Docker Compose
echo "[2/6] 检查环境..."
if ! command -v docker > /dev/null 2>&1; then
echo "错误: Docker 未安装" >&2
exit 1
fi
# 使用 docker compose 或 docker-compose
if docker compose version > /dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif docker-compose version > /dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "错误: Docker Compose 未安装" >&2
exit 1
fi
echo " ✓ 使用: $DOCKER_COMPOSE"
# 停止旧容器(优雅停止)
echo "[3/6] 停止旧容器..."
$DOCKER_COMPOSE down --timeout 30 2>/dev/null || true
echo " ✓ 旧容器已停止"
# 构建新镜像(使用缓存,除非指定 --no-cache)
echo "[4/6] 构建 Docker 镜像..."
BUILD_FLAG_PLACEHOLDER
if [ -n "$BUILD_FLAG" ]; then
echo " ⚠ 使用 --no-cache 模式(构建时间较长)"
$DOCKER_COMPOSE build $BUILD_FLAG app && $DOCKER_COMPOSE up -d app
else
echo " ✓ 使用智能构建缓存(推荐)"
$DOCKER_COMPOSE up -d --build app
fi
if [ $? -ne 0 ]; then
echo "错误: 容器构建或启动失败" >&2
exit 1
fi
echo " ✓ 容器构建并启动完成"
# 等待容器就绪
echo " ⏳ 等待容器就绪..."
sleep 10
# 检查容器状态
max_attempts=30
attempt=0
while [ $attempt -lt $max_attempts ]; do
if $DOCKER_COMPOSE ps app | grep -q "Up"; then
echo " ✓ 容器运行中"
break
fi
attempt=$((attempt + 1))
sleep 2
done
if [ $attempt -eq $max_attempts ]; then
echo " ⚠ 警告: 容器可能未正常启动,请检查日志"
fi
# 执行数据库迁移(如果需要)
echo "[6/6] 检查数据库迁移..."
if [ -f prisma/schema.prisma ]; then
$DOCKER_COMPOSE exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma 2>/dev/null || echo " ⚠ 数据库迁移跳过或已完成"
fi
# 显示部署结果
echo ""
echo "========================================"
echo "部署完成!"
echo "========================================"
echo ""
echo "容器状态:"
$DOCKER_COMPOSE ps
echo ""
echo "应用日志(最后 30 行):"
$DOCKER_COMPOSE logs app --tail=30
echo ""
echo "如需查看完整日志,请运行:"
echo " $DOCKER_COMPOSE logs -f app"
echo ""

View File

@ -1,46 +0,0 @@
#!/bin/bash
set -e
cd PROJECT_PATH_PLACEHOLDER
echo "备份上传文件..."
if [ -d "uploads" ]; then
mkdir -p backups
cp -r uploads backups/uploads-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
fi
echo "解压新版本..."
tar -xzf REMOTE_FILE_NAME_PLACEHOLDER
rm -f REMOTE_FILE_NAME_PLACEHOLDER
echo "恢复上传目录..."
if [ -d "backups" ]; then
latest_backup=$(ls -td backups/uploads-* 2>/dev/null | head -1)
if [ -n "$latest_backup" ] && [ -d "$latest_backup" ]; then
mkdir -p uploads
cp -r "$latest_backup"/* uploads/ 2>/dev/null || true
fi
fi
mkdir -p uploads
chmod -R 777 uploads
echo "停止旧容器..."
docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true
echo "构建并启动新容器(由服务器 Docker 构建)..."
docker compose up -d --build --force-recreate || docker-compose up -d --build --force-recreate
echo "等待容器启动..."
sleep 20
echo "执行数据库迁移..."
if [ -f prisma/schema.prisma ]; then
docker compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma 2>/dev/null || docker-compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma 2>/dev/null || echo "数据库迁移完成或跳过"
fi
echo "查看容器状态..."
docker compose ps || docker-compose ps
echo "查看应用日志(最后50行)..."
docker compose logs app --tail=50 || docker-compose logs app --tail=50

View File

@ -1,151 +0,0 @@
# Simple deploy script without Chinese characters
param(
[string]$Server = "45.249.208.118",
[string]$User = "root",
[string]$ProjectPath = "/root/youyidian-3d"
)
$ErrorActionPreference = "Stop"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Deploy Script" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Check if in project root
if (-not (Test-Path "package.json")) {
Write-Host "Error: Please run this script from project root directory" -ForegroundColor Red
exit 1
}
# Step 1: Package project files
Write-Host "[1/4] Packaging project files..." -ForegroundColor Yellow
$excludes = @(
"node_modules",
".next",
".git",
".env.local",
".env*.local",
"dist",
"build",
".DS_Store",
"Thumbs.db",
".vscode",
".idea",
".cursor",
"*.swp",
"*.swo",
"docs",
"*.md",
"README.md",
"scripts",
"*.sh",
"*.ps1",
"*.bat",
"uploads",
"backups",
"*.tar.gz",
"*.zip"
)
$excludeArgs = $excludes | ForEach-Object { "--exclude=$_" }
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$tempFile = Join-Path $env:TEMP "project-deploy-$timestamp.tar.gz"
Write-Host " Packaging..." -ForegroundColor Gray
& tar -czf $tempFile $excludeArgs *
if (-not (Test-Path $tempFile)) {
Write-Host " Failed to package" -ForegroundColor Red
exit 1
}
$fileSize = (Get-Item $tempFile).Length / 1MB
Write-Host " Package complete: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
Write-Host ""
# Step 2: Upload to server
Write-Host "[2/4] Uploading to server..." -ForegroundColor Yellow
Write-Host " Server: $Server" -ForegroundColor Gray
Write-Host " User: $User" -ForegroundColor Gray
Write-Host " Project Path: $ProjectPath" -ForegroundColor Gray
Write-Host " Note: Please enter SSH password if prompted" -ForegroundColor Yellow
Write-Host ""
$remoteFileName = "project-deploy.tar.gz"
Write-Host " Uploading package..." -ForegroundColor Gray
scp $tempFile "${User}@${Server}:${ProjectPath}/$remoteFileName"
if ($LASTEXITCODE -ne 0) {
Write-Host " Upload failed" -ForegroundColor Red
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
Write-Host " Upload successful" -ForegroundColor Green
Write-Host ""
# Step 3: Create deployment script
Write-Host "[3/4] Creating deployment script..." -ForegroundColor Yellow
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$templatePath = Join-Path $scriptDir "deploy-remote.sh.template"
if (-not (Test-Path $templatePath)) {
Write-Host " Error: Template not found: $templatePath" -ForegroundColor Red
exit 1
}
$bashScriptTemplate = Get-Content -Path $templatePath -Raw -Encoding UTF8
$bashScript = $bashScriptTemplate -replace "PROJECT_PATH_PLACEHOLDER", $ProjectPath
$bashScript = $bashScript -replace "REMOTE_FILE_NAME_PLACEHOLDER", $remoteFileName
$localDeployScript = Join-Path $env:TEMP "deploy-$timestamp.sh"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($localDeployScript, $bashScript, $utf8NoBom)
$remoteDeployScript = "/tmp/deploy-$timestamp.sh"
Write-Host " Uploading deployment script..." -ForegroundColor Gray
scp $localDeployScript "${User}@${Server}:$remoteDeployScript"
if ($LASTEXITCODE -ne 0) {
Write-Host " Failed to upload deployment script" -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
Write-Host " Deployment script uploaded" -ForegroundColor Green
Write-Host ""
# Step 4: Execute deployment on server
Write-Host "[4/4] Executing deployment on server..." -ForegroundColor Yellow
Write-Host " Note: Please enter SSH password if prompted" -ForegroundColor Yellow
Write-Host ""
ssh "${User}@${Server}" "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript"
if ($LASTEXITCODE -ne 0) {
Write-Host " Deployment failed on server" -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
# Cleanup
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
Write-Host " Deployment successful" -ForegroundColor Green
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Deployment Complete!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " 1. Check website status" -ForegroundColor White
Write-Host " 2. Test key features" -ForegroundColor White
Write-Host " 3. Check logs: ssh ${User}@${Server} `"cd ${ProjectPath}; docker compose logs app --tail=100`"" -ForegroundColor White
Write-Host ""

View File

@ -1,141 +0,0 @@
# Simple deploy script
param(
[string]$Server = "45.249.208.118",
[string]$User = "root",
[string]$Password = "lxvhNYFZ4160",
[string]$ProjectPath = "/root/youyidian-3d"
)
$ErrorActionPreference = "Stop"
Write-Host "Starting deployment..." -ForegroundColor Cyan
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
$pscpPath = "C:\Program Files\PuTTY\pscp.exe"
if (-not (Test-Path $plinkPath)) {
Write-Host "Error: plink.exe not found" -ForegroundColor Red
exit 1
}
# Package files
Write-Host "[1/4] Packaging files..." -ForegroundColor Yellow
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$tempFile = Join-Path $env:TEMP "project-deploy-$timestamp.tar.gz"
$excludes = @(
"node_modules", ".next", "out", "build", "dist", "uploads", "backups", ".git",
"*.tar.gz", "*.zip", "*.sql", "*.log", ".DS_Store", ".vscode", ".idea", ".cursor",
"*.rpm", "frp_*", "docker-daemon-config.json",
"docs", "*.md", "README.md", "LICENSE", "CHANGELOG.md", "CONTRIBUTING.md",
"scripts", "*.sh", "*.ps1", "*.bat", "*.py",
"*.tmp", "*.temp", ".cache", ".tmp", "tmp", "temp", "*.cache", "Thumbs.db",
"*.rar", "*.7z", "*.tar", "*.tgz", "release-*", "dist-*", "*.db", "*.sqlite", "*.sqlite3",
"*.pem", "*.key", "*.cert", "*.crt"
)
$excludeArgs = $excludes | ForEach-Object { "--exclude=$_" }
& tar -czf $tempFile $excludeArgs *
if (-not (Test-Path $tempFile)) {
Write-Host "Packaging failed" -ForegroundColor Red
exit 1
}
$fileSize = (Get-Item $tempFile).Length / 1MB
Write-Host "Package created: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
# Upload package
Write-Host "[2/4] Uploading package..." -ForegroundColor Yellow
$remoteFileName = "project-deploy.tar.gz"
& $pscpPath -pw $Password -batch $tempFile "${User}@${Server}:${ProjectPath}/$remoteFileName"
if ($LASTEXITCODE -ne 0) {
Write-Host "Upload failed" -ForegroundColor Red
exit 1
}
Write-Host "Upload successful" -ForegroundColor Green
# Create deploy script
Write-Host "[3/4] Creating deploy script..." -ForegroundColor Yellow
$localDeployScript = Join-Path $env:TEMP "deploy-$timestamp.sh"
$deployScript = @'
#!/bin/bash
set -e
cd PROJECT_PATH_PLACEHOLDER
echo "Backing up uploads..."
if [ -d "uploads" ]; then
mkdir -p backups
cp -r uploads backups/uploads-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
fi
echo "Extracting new version..."
tar -xzf REMOTE_FILE_NAME_PLACEHOLDER
rm -f REMOTE_FILE_NAME_PLACEHOLDER
echo "Restoring uploads..."
if [ -d "backups" ]; then
latest_backup=$(ls -td backups/uploads-* 2>/dev/null | head -1)
if [ -n "$latest_backup" ] && [ -d "$latest_backup" ]; then
mkdir -p uploads
cp -r "$latest_backup"/* uploads/ 2>/dev/null || true
fi
fi
mkdir -p uploads
chmod -R 777 uploads
echo "Stopping old containers..."
docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true
echo "Building and starting new containers..."
docker compose up -d --build --force-recreate || docker-compose up -d --build --force-recreate
echo "Waiting for containers to start..."
sleep 20
echo "Running database migration..."
if [ -f prisma/schema.prisma ]; then
docker compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma 2>/dev/null || echo "Database migration completed or skipped"
fi
echo "Container status:"
docker compose ps || docker-compose ps
echo "Application logs (last 50 lines):"
docker compose logs app --tail=50 || docker-compose logs app --tail=50
'@
$deployScript = $deployScript -replace "PROJECT_PATH_PLACEHOLDER", $ProjectPath
$deployScript = $deployScript -replace "REMOTE_FILE_NAME_PLACEHOLDER", $remoteFileName
# Write file with Unix line endings (LF only, no CRLF)
$deployScript = $deployScript -replace "`r`n", "`n" -replace "`r", "`n"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$bytes = $utf8NoBom.GetBytes($deployScript)
[System.IO.File]::WriteAllBytes($localDeployScript, $bytes)
# Upload and execute deploy script
Write-Host "[4/4] Deploying to server..." -ForegroundColor Yellow
$remoteDeployScript = "/tmp/deploy-$timestamp.sh"
& $pscpPath -pw $Password -batch $localDeployScript "${User}@${Server}:$remoteDeployScript"
if ($LASTEXITCODE -ne 0) {
Write-Host "Failed to upload deploy script" -ForegroundColor Red
exit 1
}
& $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript"
if ($LASTEXITCODE -ne 0) {
Write-Host "Deployment failed" -ForegroundColor Red
exit 1
}
# Cleanup
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
Write-Host "Deployment completed successfully!" -ForegroundColor Green

View File

@ -1,446 +0,0 @@
# Deploy Script - Windows PowerShell
# Upload source code directly to server, build with Docker on server
# No need to test and build locally
param(
[string]$Server = "45.249.208.118",
[string]$User = "root",
[string]$Password = "",
[string]$ProjectPath = "/root/youyidian-3d",
[switch]$SkipBackup = $false,
[switch]$SkipCheck = $false
)
$ErrorActionPreference = "Stop"
# UTF-8 Encoding
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
if ($PSVersionTable.PSVersion.Major -ge 6) {
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
}
# 函数:获取服务器密钥指纹(用于 plink/pscp 的 -hostkey 参数)
function Get-ServerHostKey {
param([string]$Server)
$hostKey = $null
try {
# 方法1: 先从 known_hosts 文件中读取(如果已经连接过服务器)
$knownHostsPath = Join-Path $env:USERPROFILE ".ssh\known_hosts"
if (Test-Path $knownHostsPath) {
$knownHostsContent = Get-Content $knownHostsPath -ErrorAction SilentlyContinue
if ($knownHostsContent) {
$matchingLines = $knownHostsContent | Where-Object {
$_ -notmatch '^\s*#' -and
$_ -match '\s+(ssh-\w+|ecdsa-sha2-\S+)\s+' -and
($_ -match [regex]::Escape($Server) -or $_ -match "^$Server,")
}
if ($matchingLines) {
$keyLine = $matchingLines | Where-Object { $_ -match 'ssh-ed25519' } | Select-Object -First 1
if (-not $keyLine) {
$keyLine = $matchingLines | Where-Object { $_ -match 'ecdsa-sha2' } | Select-Object -First 1
}
if (-not $keyLine) {
$keyLine = $matchingLines | Where-Object { $_ -match 'ssh-rsa' } | Select-Object -First 1
}
if ($keyLine -and $keyLine -match '\s+(ssh-\w+|ecdsa-sha2-\S+)\s+(\S+)') {
$keyType = $matches[1]
$keyData = $matches[2]
$tempKeyFile = [System.IO.Path]::GetTempFileName()
try {
"$Server $keyType $keyData" | Out-File -FilePath $tempKeyFile -Encoding ASCII -NoNewline
$fingerprintOutput = & ssh-keygen -lf $tempKeyFile -E sha256 2>$null
if ($fingerprintOutput) {
$fingerprint = ($fingerprintOutput -split '\s+')[1]
if ($fingerprint) {
# plink/pscp 的 -hostkey 格式:仅使用 SHA256:指纹(不需要算法名)
# fingerprint 已经包含 SHA256: 前缀
$hostKey = $fingerprint
return $hostKey
}
}
} finally {
Remove-Item -Path $tempKeyFile -ErrorAction SilentlyContinue
}
}
}
}
}
# 方法2: 使用 ssh-keyscan 获取服务器公钥
$keyscanOutput = ssh-keyscan -t ed25519,ecdsa,rsa $Server 2>$null
if ($keyscanOutput) {
$keyLine = $keyscanOutput | Where-Object { $_ -notmatch '^\s*#' -and $_ -match 'ssh-ed25519' } | Select-Object -First 1
if (-not $keyLine) {
$keyLine = $keyscanOutput | Where-Object { $_ -notmatch '^\s*#' -and $_ -match 'ecdsa-sha2' } | Select-Object -First 1
}
if (-not $keyLine) {
$keyLine = $keyscanOutput | Where-Object { $_ -notmatch '^\s*#' -and $_ -match 'ssh-rsa' } | Select-Object -First 1
}
if ($keyLine) {
$keyLine = $keyLine.ToString().Trim()
if ($keyLine -match '^\S+\s+(ssh-\w+)\s+(\S+)') {
$keyType = $matches[1]
$keyData = $matches[2]
$tempKeyFile = [System.IO.Path]::GetTempFileName()
try {
"$keyLine" | Out-File -FilePath $tempKeyFile -Encoding ASCII -NoNewline
$fingerprintOutput = & ssh-keygen -lf $tempKeyFile -E sha256 2>$null
if ($fingerprintOutput) {
$fingerprint = ($fingerprintOutput -split '\s+')[1]
if ($fingerprint) {
# plink/pscp 的 -hostkey 格式:仅使用 SHA256:指纹(不需要算法名)
# fingerprint 已经包含 SHA256: 前缀
$hostKey = $fingerprint
}
}
} finally {
Remove-Item -Path $tempKeyFile -ErrorAction SilentlyContinue
}
}
}
}
} catch {
# 如果获取密钥失败,返回 null
}
return $hostKey
}
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "安全部署脚本 - 直接上传源代码" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 检查当前目录
if (-not (Test-Path "package.json")) {
Write-Host "错误: 请在项目根目录运行此脚本" -ForegroundColor Red
exit 1
}
# 步骤 1: 部署前检查(已移除Git检查,不需要上传到git库)
if (-not $SkipCheck) {
Write-Host "[1/5] 执行部署前检查..." -ForegroundColor Yellow
# 只检查必要文件是否存在
if (-not (Test-Path "package.json")) {
Write-Host " ✗ 未找到 package.json 文件" -ForegroundColor Red
exit 1
}
if (-not (Test-Path "prisma/schema.prisma")) {
Write-Host " ⚠ 未找到 prisma/schema.prisma 文件" -ForegroundColor Yellow
}
Write-Host " ✓ 部署前检查通过" -ForegroundColor Green
Write-Host ""
}
# 步骤 2: 打包项目文件(只包含功能性代码,排除非功能性文件)
Write-Host "[2/5] 打包项目文件..." -ForegroundColor Yellow
Write-Host " 注意: 只打包功能性代码,排除文档、脚本、临时文件等" -ForegroundColor Gray
Write-Host " 排除内容: docs/, scripts/, *.md, uploads/, backups/, .env.local 等" -ForegroundColor Gray
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$tempFile = Join-Path $env:TEMP "project-deploy-$timestamp.tar.gz"
# 排除列表(与 .dockerignore 保持一致,但适配 tar 命令)
$excludes = @(
# 构建产物和依赖
"node_modules",
".next",
"out",
"build",
"dist",
".pnp",
".pnp.js",
# 测试文件
"coverage",
"*.test.ts",
"*.test.tsx",
"*.spec.ts",
"*.spec.tsx",
"test",
"tests",
"__tests__",
# 日志和调试
"*.log",
"logs",
"*.log.*",
"npm-debug.log*",
"yarn-debug.log*",
"yarn-error.log*",
# 环境变量
".env.local",
".env.development.local",
".env.test.local",
".env.production.local",
".env",
".env.example",
".env.template",
".env*.local",
# 版本控制
".git",
".gitignore",
".gitattributes",
".gitmodules",
# IDE 和编辑器
".vscode",
".idea",
".cursor",
"*.swp",
"*.swo",
"*.sublime-project",
"*.sublime-workspace",
".editorconfig",
".eslintcache",
# 文档文件
"docs",
"*.md",
"README.md",
"LICENSE",
"CHANGELOG.md",
"CONTRIBUTING.md",
# 注意:不排除 *.txt,因为 VERSION.txt 需要被包含
# 其他不需要的 .txt 文件会在文档排除中处理
# 脚本文件
"scripts",
"*.sh",
"*.ps1",
"*.bat",
"*.py",
"*.js",
"!next.config.js",
"!postcss.config.js",
"!tailwind.config.ts",
# 临时文件和缓存
"uploads",
"public/uploads",
"backups",
"*.tmp",
"*.temp",
".cache",
".tmp",
"tmp",
"temp",
"*.cache",
".DS_Store",
"Thumbs.db",
# 压缩包和发布包
"*.tar.gz",
"*.zip",
"*.rar",
"*.7z",
"*.tar",
"*.tgz",
"release-*",
"dist-*",
# 数据库备份文件
"*.sql",
"*.db",
"*.sqlite",
"*.sqlite3",
# 其他文件
"*.rpm",
"*.deb",
"*.pem",
"*.key",
"*.cert",
"*.crt",
"frp_*",
"docker-daemon-config.json"
)
# 构建 tar 排除参数
$excludeArgs = $excludes | ForEach-Object { "--exclude=$_" }
# 打包文件
Write-Host " 正在打包..." -ForegroundColor Gray
& tar -czf $tempFile $excludeArgs *
if (-not (Test-Path $tempFile)) {
Write-Host " ✗ 打包失败" -ForegroundColor Red
exit 1
}
$fileSize = (Get-Item $tempFile).Length / 1MB
Write-Host " ✓ 打包完成: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
Write-Host ""
# 步骤 3: 上传到服务器
Write-Host "[3/5] 上传文件到服务器..." -ForegroundColor Yellow
Write-Host " 服务器: $Server" -ForegroundColor Gray
Write-Host " 用户: $User" -ForegroundColor Gray
Write-Host " 项目路径: $ProjectPath" -ForegroundColor Gray
Write-Host ""
# 检查是否有 plink(PuTTY 工具)
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
$pscpPath = "C:\Program Files\PuTTY\pscp.exe"
$usePlink = (Test-Path $plinkPath) -and (Test-Path $pscpPath) -and ($Password -ne "")
# 检查 SSH 连接
Write-Host " 检查 SSH 连接..." -ForegroundColor Gray
if ($usePlink) {
Write-Host " 使用 plink 进行连接(已提供密码)" -ForegroundColor Gray
# 获取服务器密钥
$hostKey = Get-ServerHostKey -Server $Server
if ($hostKey) {
& $plinkPath -ssh -pw $Password -batch -hostkey $hostKey "${User}@${Server}" "echo SSH连接成功" 2>&1 | Out-Null
} else {
& $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "echo SSH连接成功" 2>&1 | Out-Null
}
} else {
Write-Host " 提示: 如果需要,请输入 SSH 密码" -ForegroundColor Yellow
ssh -o ConnectTimeout=10 "${User}@${Server}" "echo SSH连接成功" 2>&1 | Out-Null
}
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ SSH 连接失败,请检查连接配置" -ForegroundColor Red
if (-not $usePlink) {
Write-Host " 提示: 请确保已配置 SSH 密钥或密码正确" -ForegroundColor Yellow
}
exit 1
}
Write-Host " ✓ SSH 连接成功" -ForegroundColor Green
Write-Host ""
# 上传压缩包
Write-Host " 上传压缩包..." -ForegroundColor Gray
$remoteFileName = "project-deploy.tar.gz"
if ($usePlink) {
# 获取服务器密钥(如果之前没有获取)
if (-not $hostKey) {
$hostKey = Get-ServerHostKey -Server $Server
}
if ($hostKey) {
& $pscpPath -pw $Password -batch -hostkey $hostKey $tempFile "${User}@${Server}:${ProjectPath}/$remoteFileName" 2>&1 | Out-Null
} else {
& $pscpPath -pw $Password -batch $tempFile "${User}@${Server}:${ProjectPath}/$remoteFileName" 2>&1 | Out-Null
}
} else {
scp $tempFile "${User}@${Server}:${ProjectPath}/$remoteFileName" 2>&1 | Out-Null
}
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ 上传失败" -ForegroundColor Red
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
Write-Host " ✓ 上传成功" -ForegroundColor Green
Write-Host ""
# 步骤 4: 在服务器上部署
Write-Host "[4/5] 在服务器上部署..." -ForegroundColor Yellow
# 创建本地临时部署脚本文件
$localDeployScript = Join-Path $env:TEMP "deploy-$timestamp.sh"
# 从模板文件读取 Bash 脚本内容
# 模板文件应该在脚本目录中,优先使用项目根目录的相对路径
$projectRoot = Get-Location
$templatePath = Join-Path $projectRoot "scripts\deploy\deploy-remote.sh.template"
# 如果找不到,尝试从脚本路径获取
if (-not (Test-Path $templatePath)) {
$scriptPath = $MyInvocation.MyCommand.Path
if ($scriptPath -and (Test-Path $scriptPath)) {
$scriptDir = Split-Path -Parent $scriptPath
$templatePath = Join-Path $scriptDir "deploy-remote.sh.template"
}
}
if (Test-Path $templatePath) {
$bashScriptTemplate = Get-Content -Path $templatePath -Raw -Encoding UTF8
} else {
Write-Host " ✗ 未找到部署脚本模板: $templatePath" -ForegroundColor Red
exit 1
}
# 替换占位符
$bashScript = $bashScriptTemplate -replace "PROJECT_PATH_PLACEHOLDER", $ProjectPath
$bashScript = $bashScript -replace "REMOTE_FILE_NAME_PLACEHOLDER", $remoteFileName
# 确保使用 Unix 换行符(移除所有 \r,只保留 \n)
$bashScript = $bashScript -replace "`r`n", "`n" -replace "`r", "`n"
# 写入本地临时文件(使用 UTF8 无 BOM 编码)
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($localDeployScript, $bashScript, $utf8NoBom)
# 上传部署脚本到服务器
$remoteDeployScript = "/tmp/deploy-$timestamp.sh"
if ($usePlink) {
# 获取服务器密钥(如果之前没有获取)
if (-not $hostKey) {
$hostKey = Get-ServerHostKey -Server $Server
}
if ($hostKey) {
& $pscpPath -pw $Password -batch -hostkey $hostKey $localDeployScript "${User}@${Server}:$remoteDeployScript" 2>&1 | Out-Null
} else {
& $pscpPath -pw $Password -batch $localDeployScript "${User}@${Server}:$remoteDeployScript" 2>&1 | Out-Null
}
} else {
scp $localDeployScript "${User}@${Server}:$remoteDeployScript" 2>&1 | Out-Null
}
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ 上传部署脚本失败" -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
exit 1
}
# 在服务器上执行部署脚本
if ($usePlink) {
# 获取服务器密钥(如果之前没有获取)
if (-not $hostKey) {
$hostKey = Get-ServerHostKey -Server $Server
}
if ($hostKey) {
& $plinkPath -ssh -pw $Password -batch -hostkey $hostKey "${User}@${Server}" "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript" 2>&1
} else {
& $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript" 2>&1
}
} else {
ssh "${User}@${Server}" "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript" 2>&1
}
if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ 服务器部署失败" -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
# 清理本地临时文件
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Write-Host " ✓ 服务器部署成功" -ForegroundColor Green
Write-Host ""
# 步骤 5: 清理本地文件
Write-Host "[5/5] 清理本地文件..." -ForegroundColor Yellow
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
Write-Host " ✓ 清理完成" -ForegroundColor Green
Write-Host ""
# 部署完成
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "部署完成!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "下一步:" -ForegroundColor Yellow
Write-Host " 1. 访问网站检查是否正常" -ForegroundColor White
Write-Host " 2. 测试关键功能" -ForegroundColor White
Write-Host " 3. 检查错误日志" -ForegroundColor White
$logCheckCmd = "ssh ${User}@${Server} 'cd ${ProjectPath}; docker compose logs app --tail=100'"
Write-Host " $logCheckCmd" -ForegroundColor Gray
Write-Host ""

View File

@ -1,445 +0,0 @@
# 统一部署脚本 - 解决SSH认证冲突
param(
[string]$Server = "45.249.208.118",
[string]$User = "root",
[string]$SshKeyPath = "$env:USERPROFILE\.ssh\id_rsa",
[string]$Password = "",
[string]$ProjectPath = "/root/youyidian-3d",
[switch]$UsePassword = $false,
[switch]$NoCache = $false,
[ValidateSet("ssh-key", "password", "auto")]
[string]$AuthMethod = "auto"
)
$ErrorActionPreference = "Stop"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 统一部署脚本 - 解决SSH冲突" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 检查工具
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
$pscpPath = "C:\Program Files\PuTTY\pscp.exe"
$toolsFound = $true
if (-not (Test-Path $plinkPath)) {
Write-Host "警告: 未找到 plink.exe" -ForegroundColor Yellow
$toolsFound = $false
}
if (-not (Test-Path $pscpPath)) {
Write-Host "警告: 未找到 pscp.exe" -ForegroundColor Yellow
$toolsFound = $false
}
if (-not $toolsFound) {
Write-Host "请安装 PuTTY 工具包" -ForegroundColor Red
Write-Host "下载地址: https://www.putty.org/" -ForegroundColor Red
exit 1
}
# 自动选择认证方式
$useSshKey = $false
$usePassword = $false
switch ($AuthMethod) {
"ssh-key" {
$useSshKey = $true
Write-Host "强制使用SSH密钥认证" -ForegroundColor Yellow
}
"password" {
$usePassword = $true
Write-Host "强制使用密码认证" -ForegroundColor Yellow
}
"auto" {
# 自动检测最佳认证方式
if ((-not [string]::IsNullOrEmpty($SshKeyPath)) -and (Test-Path $SshKeyPath)) {
$useSshKey = $true
Write-Host "检测到SSH密钥文件,使用密钥认证" -ForegroundColor Green
} elseif ($UsePassword -or (-not [string]::IsNullOrEmpty($Password))) {
$usePassword = $true
Write-Host "使用密码认证" -ForegroundColor Green
} else {
Write-Host "错误: 未找到SSH密钥且未提供密码" -ForegroundColor Red
Write-Host "请使用 -SshKeyPath 指定密钥文件路径" -ForegroundColor Yellow
Write-Host "或使用 -Password 提供密码" -ForegroundColor Yellow
Write-Host "或使用 -UsePassword 参数启用密码认证" -ForegroundColor Yellow
exit 1
}
}
}
# 验证SSH密钥
if ($useSshKey) {
if (-not (Test-Path $SshKeyPath)) {
Write-Host "错误: SSH密钥文件不存在: $SshKeyPath" -ForegroundColor Red
Write-Host ""
Write-Host "创建SSH密钥的步骤:" -ForegroundColor Yellow
Write-Host "1. 在PowerShell中运行: ssh-keygen -t rsa -b 4096 -C 'deploy@youyidian'" -ForegroundColor Gray
Write-Host "2. 将生成的公钥复制到服务器: ssh-copy-id -i ~/.ssh/id_rsa.pub ${User}@${Server}" -ForegroundColor Gray
Write-Host "3. 或手动复制公钥内容到服务器的 ~/.ssh/authorized_keys" -ForegroundColor Gray
exit 1
}
Write-Host "使用SSH密钥: $SshKeyPath" -ForegroundColor Green
}
# 验证密码
if ($usePassword -and [string]::IsNullOrEmpty($Password)) {
Write-Host "错误: 使用密码认证需要提供 -Password 参数" -ForegroundColor Red
exit 1
}
Write-Host "服务器: $Server" -ForegroundColor Green
Write-Host "用户: $User" -ForegroundColor Green
Write-Host "目标路径: $ProjectPath" -ForegroundColor Green
Write-Host ""
# 需要上传的关键文件列表(完整列表)
$filesToUpload = @(
# 核心配置文件
"package.json",
"package-lock.json",
"next.config.js",
".env",
"prisma/schema.prisma",
"Dockerfile",
"docker-compose.yml",
# 应用核心文件
"app/layout.tsx",
"app/page.tsx",
"app/login/page.tsx",
"app/register/page.tsx",
"app/api/auth/register/route.ts",
"app/api/auth/login/route.ts",
"app/api/auth/logout/route.ts",
"app/api/user/me/route.ts",
"app/api/admin/users/route.ts",
"app/api/admin/dashboard/route.ts",
# 库文件
"lib/prisma.ts",
"lib/auth.ts",
"lib/config.ts",
"lib/configLoader.ts",
"lib/utils.ts",
"middleware.ts",
# 组件和样式
"components/ui",
"components/auth",
"components/admin",
"public",
"styles",
# 其他重要文件
"README.md",
"tsconfig.json",
"tailwind.config.ts"
)
Write-Host "[1/6] 检查文件完整性..." -ForegroundColor Yellow
$missingFiles = @()
foreach ($file in $filesToUpload) {
if (-not (Test-Path $file)) {
$missingFiles += $file
}
}
if ($missingFiles.Count -gt 0) {
Write-Host "以下文件不存在,将跳过:" -ForegroundColor Yellow
$missingFiles | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
Write-Host ""
}
# 测试SSH连接
Write-Host "[2/6] 测试SSH连接..." -ForegroundColor Yellow
$testCmd = "echo 'SSH连接测试成功'"
if ($useSshKey) {
& $plinkPath -ssh -i $SshKeyPath -batch "${User}@${Server}" $testCmd | Out-Null
} else {
if ([string]::IsNullOrEmpty($Password)) {
Write-Host "请输入服务器密码:" -ForegroundColor Yellow -NoNewline
$securePwd = Read-Host -AsSecureString
$passwordPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePwd)
$Password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($passwordPtr)
}
& $plinkPath -ssh "${User}@${Server}" $testCmd | Out-Null
}
if ($LASTEXITCODE -ne 0) {
Write-Host "SSH连接测试失败" -ForegroundColor Red
exit 1
}
Write-Host " ✓ SSH连接正常" -ForegroundColor Green
# 确保远程目录存在
Write-Host "[3/6] 准备远程目录..." -ForegroundColor Yellow
$ensureDirCmd = "mkdir -p '$ProjectPath'"
if ($useSshKey) {
& $plinkPath -ssh -i $SshKeyPath -batch "${User}@${Server}" $ensureDirCmd | Out-Null
} else {
& $plinkPath -ssh -pw $Password "${User}@${Server}" $ensureDirCmd | Out-Null
}
if ($LASTEXITCODE -ne 0) {
Write-Host "创建远程目录失败" -ForegroundColor Red
exit 1
}
# 上传文件
Write-Host "[4/6] 上传文件到服务器..." -ForegroundColor Yellow
$uploadedCount = 0
$totalFiles = ($filesToUpload | Where-Object { Test-Path $_ }).Count
foreach ($file in $filesToUpload) {
if (Test-Path $file) {
$remotePath = "$ProjectPath/$file"
$remoteDir = Split-Path $remotePath -Parent
# 确保远程目录存在
$mkdirCmd = "mkdir -p '$remoteDir'"
if ($useSshKey) {
& $plinkPath -ssh -i $SshKeyPath -batch "${User}@${Server}" $mkdirCmd | Out-Null
} else {
& $plinkPath -ssh -pw $Password "${User}@${Server}" $mkdirCmd | Out-Null
}
# 上传文件
$uploadParams = @("-q")
if ($useSshKey) {
$uploadParams += "-i", $SshKeyPath, "-batch"
} else {
$uploadParams += "-pw", $Password
}
$uploadParams += $file, "${User}@${Server}:$remotePath"
& $pscpPath @uploadParams 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
$uploadedCount++
Write-Host " ✓ $file" -ForegroundColor Green
} else {
Write-Host " ✗ $file (上传失败)" -ForegroundColor Red
}
}
}
Write-Host "文件上传完成: $uploadedCount/$totalFiles 个文件" -ForegroundColor $(if ($uploadedCount -eq $totalFiles) { "Green" } else { "Yellow" })
if ($uploadedCount -lt $totalFiles) {
Write-Host "警告: 部分文件上传失败,可能影响部署" -ForegroundColor Yellow
}
# 创建部署脚本
Write-Host "[5/6] 创建并执行远程部署脚本..." -ForegroundColor Yellow
$buildFlag = if ($NoCache) { "--no-cache" } else { "" }
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
# 创建本地临时脚本
$localDeployScript = Join-Path $env:TEMP "deploy-unified-$timestamp.sh"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
# 远程部署脚本内容
$deployScript = @"
#!/bin/bash
set -e
echo "========================================"
echo "开始部署应用..."
echo "开始时间: $(date)"
echo "========================================"
cd $ProjectPath
# 备份重要数据
echo "[1/7] 备份数据..."
if [ -d "uploads" ] && [ -n "$(ls -A uploads 2>/dev/null)" ]; then
mkdir -p backups
backup_dir="backups/uploads-$(date +%Y%m%d-%H%M%S)"
cp -r uploads "`$backup_dir"
echo " ✓ 上传目录已备份到 `$(basename `$backup_dir)"
fi
# 检查Docker环境
echo "[2/7] 检查Docker环境..."
if ! command -v docker &> /dev/null; then
echo "错误: Docker 未安装" >&2
exit 1
fi
if docker compose version &> /dev/null; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose &> /dev/null; then
DOCKER_COMPOSE="docker-compose"
else
echo "错误: Docker Compose 未安装" >&2
exit 1
fi
echo " ✓ 使用Docker Compose: `$DOCKER_COMPOSE"
# 停止旧容器
echo "[3/7] 停止旧容器..."
`$DOCKER_COMPOSE down --timeout 30 2>/dev/null || true
echo " ✓ 旧容器已停止"
# 构建并启动应用
echo "[4/7] 构建并启动应用..."
if [ -n "$buildFlag" ]; then
echo " ⚠ 使用 --no-cache 模式重新构建"
`$DOCKER_COMPOSE build $buildFlag app
fi
echo " ✓ 启动应用容器..."
`$DOCKER_COMPOSE up -d app
if [ `$? -ne 0 ]; then
echo "错误: 容器启动失败" >&2
exit 1
fi
# 等待容器就绪
echo "[5/7] 等待容器就绪..."
for i in {1..30}; do
if `$DOCKER_COMPOSE ps app | grep -q "Up"; then
echo " ✓ 容器运行正常"
break
fi
if [ $i -eq 30 ]; then
echo "错误: 容器启动超时" >&2
exit 1
fi
sleep 2
done
# 数据库迁移
echo "[6/7] 检查数据库状态..."
if [ -f prisma/schema.prisma ]; then
echo " → 执行数据库迁移..."
`$DOCKER_COMPOSE exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma 2>/dev/null || {
echo " ⚠ 数据库迁移跳过或已存在"
}
fi
# 健康检查
echo "[7/7] 执行健康检查..."
sleep 5
max_attempts=10
attempt=0
while [ `$attempt -lt `$max_attempts ]; do
if curl -f -s http://localhost:3000/api/health > /dev/null 2>&1; then
echo " ✓ 应用健康检查通过"
break
fi
attempt=`$((attempt + 1))
if [ $attempt -eq $max_attempts ]; then
echo " ⚠ 健康检查超时,应用可能需要更多时间启动"
fi
sleep 3
done
# 显示部署结果
echo ""
echo "========================================"
echo "部署完成!"
echo "完成时间: $(date)"
echo "========================================"
echo ""
echo "容器状态:"
`$DOCKER_COMPOSE ps
echo ""
echo "最近日志:"
`$DOCKER_COMPOSE logs app --tail=10
# 显示应用访问信息
echo ""
echo "应用访问信息:"
echo " 本地访问: http://localhost:3000"
echo " 外网访问: http://$Server:4320"
echo ""
"@
[System.IO.File]::WriteAllText($localDeployScript, $deployScript, $utf8NoBom)
# 上传并执行部署脚本
$remoteDeployScript = "/tmp/deploy-unified-$timestamp.sh"
$uploadParams = @("-q")
if ($useSshKey) {
$uploadParams += "-i", $SshKeyPath, "-batch"
} else {
$uploadParams += "-pw", $Password
}
$uploadParams += $localDeployScript, "${User}@${Server}:$remoteDeployScript"
Write-Host " → 上传部署脚本..." -ForegroundColor Yellow
& $pscpPath @uploadParams
if ($LASTEXITCODE -ne 0) {
Write-Host "上传部署脚本失败" -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
exit 1
}
Write-Host " → 执行远程部署..." -ForegroundColor Yellow
if ($useSshKey) {
& $plinkPath -ssh -i $SshKeyPath -batch "${User}@${Server}" "chmod +x $remoteDeployScript && bash $remoteDeployScript && rm -f $remoteDeployScript"
} else {
& $plinkPath -ssh -pw $Password "${User}@${Server}" "chmod +x $remoteDeployScript && bash $remoteDeployScript && rm -f $remoteDeployScript"
}
$deployExitCode = $LASTEXITCODE
# 清理临时文件
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
# 显示部署结果
Write-Host "[6/6] 部署结果..." -ForegroundColor Yellow
if ($deployExitCode -eq 0) {
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " 部署成功完成!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "🎉 应用已成功部署到服务器" -ForegroundColor Green
Write-Host ""
Write-Host "📍 访问地址:" -ForegroundColor Cyan
Write-Host " 本地: http://localhost:3000" -ForegroundColor White
Write-Host " 外网: http://$Server:4320" -ForegroundColor White
Write-Host ""
Write-Host "🔧 使用方法:" -ForegroundColor Yellow
if ($useSshKey) {
Write-Host " - 使用SSH密钥认证 (推荐)" -ForegroundColor Green
} else {
Write-Host " - 使用密码认证" -ForegroundColor Yellow
Write-Host " 建议配置SSH密钥以提高安全性" -ForegroundColor Gray
}
Write-Host ""
} else {
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host " 部署失败!" -ForegroundColor Red
Write-Host " 退出码: $deployExitCode" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
Write-Host ""
Write-Host "🔧 可能的解决方案:" -ForegroundColor Yellow
Write-Host " 1. 检查网络连接" -ForegroundColor White
Write-Host " 2. 验证服务器凭证" -ForegroundColor White
Write-Host " 3. 查看服务器日志: docker-compose logs app" -ForegroundColor White
Write-Host " 4. 重新运行脚本: .\$($MyInvocation.MyCommand.Name) -AuthMethod auto" -ForegroundColor White
Write-Host ""
}
Write-Host "💡 提示:" -ForegroundColor Cyan
Write-Host " - 使用 -NoCache 参数强制重新构建" -ForegroundColor Gray
Write-Host " - 使用 -AuthMethod ssh-key 强制使用密钥认证" -ForegroundColor Gray
Write-Host " - 使用 -AuthMethod password 强制使用密码认证" -ForegroundColor Gray
Write-Host " - 使用 -SshKeyPath 指定自定义密钥路径" -ForegroundColor Gray
Write-Host ""

View File

@ -1,208 +0,0 @@
# Deploy script with password support
param(
[string]$Server = "45.249.208.118",
[string]$User = "root",
[string]$Password = "lxvhNYFZ4160",
[string]$ProjectPath = "/root/youyidian-3d"
)
$ErrorActionPreference = "Stop"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Deploy Script with Password" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Check if in project root
if (-not (Test-Path "package.json")) {
Write-Host "Error: Please run this script from project root directory" -ForegroundColor Red
exit 1
}
# Step 1: Package project files
Write-Host "[1/5] Packaging project files..." -ForegroundColor Yellow
$excludes = @(
"node_modules",
".next",
".git",
".env.local",
".env*.local",
"dist",
"build",
".DS_Store",
"Thumbs.db",
".vscode",
".idea",
".cursor",
"*.swp",
"*.swo",
"docs",
"*.md",
"README.md",
"scripts",
"*.sh",
"*.ps1",
"*.bat",
"uploads",
"backups",
"*.tar.gz",
"*.zip"
)
$excludeArgs = $excludes | ForEach-Object { "--exclude=$_" }
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$tempFile = Join-Path $env:TEMP "project-deploy-$timestamp.tar.gz"
Write-Host " Packaging..." -ForegroundColor Gray
& tar -czf $tempFile $excludeArgs *
if (-not (Test-Path $tempFile)) {
Write-Host " Failed to package" -ForegroundColor Red
exit 1
}
$fileSize = (Get-Item $tempFile).Length / 1MB
Write-Host " Package complete: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
Write-Host ""
# Step 2: Check for plink/pscp (PuTTY tools)
Write-Host "[2/5] Checking SSH tools..." -ForegroundColor Yellow
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
$pscpPath = "C:\Program Files\PuTTY\pscp.exe"
$usePlink = (Test-Path $plinkPath) -and (Test-Path $pscpPath)
if ($usePlink) {
Write-Host " Using plink/pscp (PuTTY tools)" -ForegroundColor Green
} else {
Write-Host " Using OpenSSH (will prompt for password)" -ForegroundColor Yellow
Write-Host " Note: If SSH key is not configured, you may need to enter password manually" -ForegroundColor Gray
}
Write-Host ""
# Step 3: Upload to server
Write-Host "[3/5] Uploading to server..." -ForegroundColor Yellow
Write-Host " Server: $Server" -ForegroundColor Gray
Write-Host " User: $User" -ForegroundColor Gray
Write-Host " Project Path: $ProjectPath" -ForegroundColor Gray
Write-Host ""
$remoteFileName = "project-deploy.tar.gz"
Write-Host " Uploading package..." -ForegroundColor Gray
if ($usePlink) {
# Use pscp with password
$pscpCmd = "`"$pscpPath`" -pw `"$Password`" -batch `"$tempFile`" ${User}@${Server}:${ProjectPath}/$remoteFileName"
$result = cmd /c $pscpCmd 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " Upload failed" -ForegroundColor Red
Write-Host $result -ForegroundColor Red
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
} else {
# Use OpenSSH scp (may need SSH key or manual password entry)
scp $tempFile "${User}@${Server}:${ProjectPath}/$remoteFileName"
if ($LASTEXITCODE -ne 0) {
Write-Host " Upload failed" -ForegroundColor Red
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
}
Write-Host " Upload successful" -ForegroundColor Green
Write-Host ""
# Step 4: Create and upload deployment script
Write-Host "[4/5] Creating deployment script..." -ForegroundColor Yellow
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$templatePath = Join-Path $scriptDir "deploy-remote.sh.template"
if (-not (Test-Path $templatePath)) {
Write-Host " Error: Template not found: $templatePath" -ForegroundColor Red
exit 1
}
$bashScriptTemplate = Get-Content -Path $templatePath -Raw -Encoding UTF8
$bashScript = $bashScriptTemplate -replace "PROJECT_PATH_PLACEHOLDER", $ProjectPath
$bashScript = $bashScript -replace "REMOTE_FILE_NAME_PLACEHOLDER", $remoteFileName
# Convert Windows line endings to Unix line endings
$bashScript = $bashScript -replace "`r`n", "`n" -replace "`r", "`n"
$localDeployScript = Join-Path $env:TEMP "deploy-$timestamp.sh"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
# Write with Unix line endings
$bytes = $utf8NoBom.GetBytes($bashScript)
[System.IO.File]::WriteAllBytes($localDeployScript, $bytes)
$remoteDeployScript = "/tmp/deploy-$timestamp.sh"
Write-Host " Uploading deployment script..." -ForegroundColor Gray
if ($usePlink) {
$pscpCmd = "`"$pscpPath`" -pw `"$Password`" -batch `"$localDeployScript`" ${User}@${Server}:$remoteDeployScript"
$result = cmd /c $pscpCmd 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " Failed to upload deployment script" -ForegroundColor Red
Write-Host $result -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
} else {
scp $localDeployScript "${User}@${Server}:$remoteDeployScript"
if ($LASTEXITCODE -ne 0) {
Write-Host " Failed to upload deployment script" -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
}
Write-Host " Deployment script uploaded" -ForegroundColor Green
Write-Host ""
# Step 5: Execute deployment on server
Write-Host "[5/5] Executing deployment on server..." -ForegroundColor Yellow
Write-Host " This may take several minutes..." -ForegroundColor Gray
Write-Host ""
$deployCmd = "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript"
if ($usePlink) {
$plinkCmd = "`"$plinkPath`" -ssh -pw `"$Password`" -batch ${User}@${Server} `"$deployCmd`""
Write-Host " Executing deployment..." -ForegroundColor Gray
cmd /c $plinkCmd
if ($LASTEXITCODE -ne 0) {
Write-Host " Deployment failed on server" -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
} else {
ssh "${User}@${Server}" $deployCmd
if ($LASTEXITCODE -ne 0) {
Write-Host " Deployment failed on server" -ForegroundColor Red
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
exit 1
}
}
# Cleanup
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Deployment Complete!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " 1. Check website status" -ForegroundColor White
Write-Host " 2. Test key features" -ForegroundColor White
Write-Host " 3. Check logs: ssh ${User}@${Server} `"cd ${ProjectPath}; docker compose logs app --tail=100`"" -ForegroundColor White
Write-Host ""

View File

@ -1,238 +0,0 @@
# 使用 SSH 密钥的部署脚本(更安全)
param(
[string]$Server = "45.249.208.118",
[string]$User = "root",
[string]$SshKeyPath = "$env:USERPROFILE\.ssh\id_rsa", # 默认 SSH 密钥路径
[string]$ProjectPath = "/root/youyidian-3d",
[switch]$NoCache = $false
)
$ErrorActionPreference = "Stop"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 安全部署脚本 - 使用 SSH 密钥" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 检查工具
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
$pscpPath = "C:\Program Files\PuTTY\pscp.exe"
if (-not (Test-Path $plinkPath)) {
Write-Host "错误: 未找到 plink.exe,请安装 PuTTY" -ForegroundColor Red
exit 1
}
# 检查 SSH 密钥文件
if (-not (Test-Path $SshKeyPath)) {
Write-Host "错误: 未找到 SSH 密钥文件: $SshKeyPath" -ForegroundColor Red
Write-Host ""
Write-Host "请执行以下步骤创建 SSH 密钥:" -ForegroundColor Yellow
Write-Host " 1. 生成密钥对: ssh-keygen -t rsa -b 4096 -C `"deploy@youyidian`"" -ForegroundColor Gray
Write-Host " 2. 将公钥复制到服务器: ssh-copy-id -i ~/.ssh/id_rsa.pub ${User}@${Server}" -ForegroundColor Gray
Write-Host " 3. 或者手动复制公钥内容到服务器的 ~/.ssh/authorized_keys" -ForegroundColor Gray
Write-Host ""
Write-Host "如果你使用 PuTTY,可以:" -ForegroundColor Yellow
Write-Host " 1. 使用 PuTTYgen 生成密钥对" -ForegroundColor Gray
Write-Host " 2. 将公钥添加到服务器的 ~/.ssh/authorized_keys" -ForegroundColor Gray
Write-Host " 3. 在 Pageant 中加载私钥,或使用 -i 参数指定私钥文件" -ForegroundColor Gray
exit 1
}
Write-Host "使用 SSH 密钥: $SshKeyPath" -ForegroundColor Green
Write-Host ""
# 需要上传的关键文件
$filesToUpload = @(
"app/page.tsx",
"app/login/page.tsx",
"app/api/network-status/route.ts",
"next.config.js",
"README.md",
"lib/config.ts",
"lib/configLoader.ts",
"config-bianliang.env",
"Dockerfile",
"docker-compose.yml",
"package.json",
"package-lock.json"
)
# 检查文件是否存在
$missingFiles = @()
foreach ($file in $filesToUpload) {
if (-not (Test-Path $file)) {
$missingFiles += $file
}
}
if ($missingFiles.Count -gt 0) {
Write-Host "警告: 以下文件不存在,将跳过:" -ForegroundColor Yellow
$missingFiles | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
}
# 上传文件
Write-Host "[1/5] 上传文件到服务器..." -ForegroundColor Yellow
$uploadedCount = 0
foreach ($file in $filesToUpload) {
if (Test-Path $file) {
$remotePath = "$ProjectPath/$file"
$remoteDir = Split-Path $remotePath -Parent
# 确保远程目录存在
& $plinkPath -ssh -i $SshKeyPath -batch "${User}@${Server}" "mkdir -p `"$remoteDir`"" | Out-Null
# 上传文件
& $pscpPath -i $SshKeyPath -batch -q $file "${User}@${Server}:$remotePath" 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
$uploadedCount++
Write-Host " ✓ $file" -ForegroundColor Green
} else {
Write-Host " ✗ $file (上传失败)" -ForegroundColor Red
}
}
}
Write-Host "已上传 $uploadedCount/$($filesToUpload.Count) 个文件" -ForegroundColor $(if ($uploadedCount -eq $filesToUpload.Count) { "Green" } else { "Yellow" })
# 创建部署脚本
Write-Host "[2/5] 创建部署脚本..." -ForegroundColor Yellow
$buildFlag = if ($NoCache) { "--no-cache" } else { "" }
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$deployScript = @"
#!/bin/bash
set -e
cd $ProjectPath
echo "========================================"
echo "开始部署..."
echo "========================================"
# 备份数据
echo "[1/6] 备份数据..."
if [ -d "uploads" ]; then
mkdir -p backups
backup_dir="backups/uploads-$(date +%Y%m%d-%H%M%S)"
cp -r uploads "`$backup_dir" 2>/dev/null || true
echo " ✓ 上传目录已备份到 `$backup_dir"
fi
# 检查环境
echo "[2/6] 检查环境..."
if ! command -v docker &> /dev/null; then
echo "错误: Docker 未安装" >&2
exit 1
fi
if docker compose version &> /dev/null; then
DOCKER_COMPOSE="docker compose"
elif docker-compose version &> /dev/null; then
DOCKER_COMPOSE="docker-compose"
else
echo "错误: Docker Compose 未安装" >&2
exit 1
fi
echo " ✓ 使用: `$DOCKER_COMPOSE"
# 停止旧容器
echo "[3/6] 停止旧容器..."
`$DOCKER_COMPOSE down --timeout 30 2>/dev/null || true
echo " ✓ 旧容器已停止"
# 构建并启动
echo "[4/6] 构建并启动容器..."
if [ -n "$buildFlag" ]; then
echo " ⚠ 使用 --no-cache 模式"
`$DOCKER_COMPOSE build $buildFlag app && `$DOCKER_COMPOSE up -d app
else
echo " ✓ 使用智能构建缓存"
`$DOCKER_COMPOSE up -d --build app
fi
if [ `$? -ne 0 ]; then
echo "错误: 容器构建或启动失败" >&2
exit 1
fi
echo " ✓ 容器构建并启动完成"
# 等待容器就绪
echo "[5/6] 等待容器就绪..."
sleep 10
max_attempts=30
attempt=0
while [ `$attempt -lt `$max_attempts ]; do
if `$DOCKER_COMPOSE ps app | grep -q "Up"; then
echo " ✓ 容器运行中"
break
fi
attempt=`$((attempt + 1))
sleep 2
done
# 数据库迁移
echo "[6/6] 检查数据库迁移..."
if [ -f prisma/schema.prisma ]; then
`$DOCKER_COMPOSE exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma 2>/dev/null || echo " ⚠ 数据库迁移跳过或已完成"
fi
# 显示结果
echo ""
echo "========================================"
echo "部署完成!"
echo "========================================"
echo ""
echo "容器状态:"
`$DOCKER_COMPOSE ps
echo ""
echo "应用日志(最后 30 行):"
`$DOCKER_COMPOSE logs app --tail=30
"@
$localDeployScript = Join-Path $env:TEMP "deploy-ssh-$timestamp.sh"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($localDeployScript, $deployScript, $utf8NoBom)
# 上传部署脚本
Write-Host "[3/5] 上传部署脚本..." -ForegroundColor Yellow
$remoteDeployScript = "/tmp/deploy-ssh-$timestamp.sh"
& $pscpPath -i $SshKeyPath -batch $localDeployScript "${User}@${Server}:$remoteDeployScript"
if ($LASTEXITCODE -ne 0) {
Write-Host "上传部署脚本失败" -ForegroundColor Red
exit 1
}
Write-Host " ✓ 部署脚本已上传" -ForegroundColor Green
# 执行部署
Write-Host "[4/5] 执行部署..." -ForegroundColor Yellow
Write-Host ""
& $plinkPath -ssh -i $SshKeyPath -batch "${User}@${Server}" "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript"
$deployExitCode = $LASTEXITCODE
# 清理
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
if ($deployExitCode -ne 0) {
Write-Host ""
Write-Host "部署失败,退出码: $deployExitCode" -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " 部署成功完成!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "提示:" -ForegroundColor Yellow
Write-Host " - 使用 -SshKeyPath 参数指定自定义 SSH 密钥路径" -ForegroundColor Gray
Write-Host " - 使用 -NoCache 参数可以强制重新构建" -ForegroundColor Gray
Write-Host ""

View File

@ -1,5 +0,0 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0\..\.."
powershell -ExecutionPolicy Bypass -File "%~dp0安全部署.ps1" %*

View File

@ -1,21 +0,0 @@
# Deploy Script Wrapper
# This script wraps the Chinese-named deploy script to avoid encoding issues
$ErrorActionPreference = "Stop"
# Get the script directory
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Find the Chinese-named deploy script using Get-ChildItem
$deployScript = Get-ChildItem -Path $scriptDir -Filter "*.ps1" | Where-Object { $_.Name -like "*部署*" } | Select-Object -First 1
if (-not $deployScript) {
Write-Host "Error: Deploy script not found in $scriptDir" -ForegroundColor Red
exit 1
}
Write-Host "Found deploy script: $($deployScript.FullName)" -ForegroundColor Green
# Execute the deploy script with all passed parameters
& $deployScript.FullName $args

View File

@ -1,23 +0,0 @@
# Temporary deploy script to avoid encoding issues
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$deployScripts = Get-ChildItem -Path $scriptDir -Filter "*.ps1"
# Find the deploy script with Chinese name
$deployScript = $deployScripts | Where-Object {
$name = $_.Name
$content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
if ($content -match "安全部署脚本" -or $content -match "直接上传源代码") {
return $true
}
return $false
} | Select-Object -First 1
if ($deployScript) {
Write-Host "Found deploy script: $($deployScript.Name)" -ForegroundColor Green
& $deployScript.FullName
} else {
Write-Host "Error: Deploy script not found" -ForegroundColor Red
exit 1
}

View File

@ -1,15 +0,0 @@
# Simple deploy script runner
# This script reads and executes the deploy script to avoid encoding issues
$scriptPath = Join-Path $PSScriptRoot "安全部署.ps1"
if (-not (Test-Path $scriptPath)) {
Write-Host "Error: Deploy script not found" -ForegroundColor Red
Write-Host "Expected path: $scriptPath" -ForegroundColor Yellow
exit 1
}
# Read and execute the script
$scriptContent = Get-Content -Path $scriptPath -Raw -Encoding UTF8
Invoke-Expression $scriptContent

View File

@ -1,200 +0,0 @@
# Try to deploy and check what happens
param(
[string]$Server = "45.249.208.118",
[string]$User = "root",
[string]$Password = "lxvhNYFZ4160",
[string]$ProjectPath = "/root/youyidian-3d"
)
$ErrorActionPreference = "Stop"
Write-Host "Attempting deployment with current server state..." -ForegroundColor Cyan
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
$pscpPath = "C:\Program Files\PuTTY\pscp.exe"
if (-not (Test-Path $plinkPath)) {
Write-Host "Error: plink.exe not found" -ForegroundColor Red
exit 1
}
Write-Host "`n[1/6] Checking server Docker images..." -ForegroundColor Yellow
$allImages = & $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}' 2>&1"
Write-Host $allImages
Write-Host "`n[2/6] Checking Docker build cache..." -ForegroundColor Yellow
$cacheInfo = & $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "docker system df 2>&1"
Write-Host $cacheInfo
Write-Host "`n[3/6] Checking if project directory exists..." -ForegroundColor Yellow
$projectExists = & $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "test -d $ProjectPath && echo 'exists' || echo 'not found'"
Write-Host "Project directory: $projectExists" -ForegroundColor Cyan
if ($projectExists -notmatch "exists") {
Write-Host "Project directory not found. Creating..." -ForegroundColor Yellow
& $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "mkdir -p $ProjectPath"
}
Write-Host "`n[4/6] Checking Dockerfile on server..." -ForegroundColor Yellow
$dockerfileCheck = & $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "test -f $ProjectPath/Dockerfile && echo 'exists' || echo 'not found'"
Write-Host "Dockerfile: $dockerfileCheck" -ForegroundColor Cyan
# First, let's try to upload the source code (this is what deploy-simple.ps1 does)
Write-Host "`n[5/6] Packaging and uploading source code..." -ForegroundColor Yellow
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$tempFile = Join-Path $env:TEMP "project-deploy-$timestamp.tar.gz"
$excludes = @(
"node_modules", ".next", "out", "build", "dist", "uploads", "backups", ".git",
"*.tar.gz", "*.zip", "*.sql", "*.log", ".DS_Store", ".vscode", ".idea", ".cursor",
"*.rpm", "frp_*", "docker-daemon-config.json",
"docs", "*.md", "README.md", "LICENSE", "CHANGELOG.md", "CONTRIBUTING.md",
"scripts", "*.sh", "*.ps1", "*.bat", "*.py",
"*.tmp", "*.temp", ".cache", ".tmp", "tmp", "temp", "*.cache", "Thumbs.db",
"*.rar", "*.7z", "*.tar", "*.tgz", "release-*", "dist-*", "*.db", "*.sqlite", "*.sqlite3",
"*.pem", "*.key", "*.cert", "*.crt"
)
$excludeArgs = $excludes | ForEach-Object { "--exclude=$_" }
& tar -czf $tempFile $excludeArgs *
if (-not (Test-Path $tempFile)) {
Write-Host "Packaging failed" -ForegroundColor Red
exit 1
}
$fileSize = (Get-Item $tempFile).Length / 1MB
Write-Host "Package created: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
$remoteFileName = "project-deploy.tar.gz"
& $pscpPath -pw $Password -batch $tempFile "${User}@${Server}:${ProjectPath}/$remoteFileName"
if ($LASTEXITCODE -ne 0) {
Write-Host "Upload failed" -ForegroundColor Red
exit 1
}
Write-Host "Upload successful" -ForegroundColor Green
# Create deploy script that tries to build
Write-Host "`n[6/6] Creating and executing deploy script..." -ForegroundColor Yellow
$localDeployScript = Join-Path $env:TEMP "deploy-$timestamp.sh"
$deployScript = @'
#!/bin/bash
set -e
cd PROJECT_PATH_PLACEHOLDER
echo "=== Deployment Script ==="
echo "Backing up uploads..."
if [ -d "uploads" ]; then
mkdir -p backups
cp -r uploads backups/uploads-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
fi
echo "Extracting new version..."
tar -xzf REMOTE_FILE_NAME_PLACEHOLDER
rm -f REMOTE_FILE_NAME_PLACEHOLDER
echo "Restoring uploads..."
if [ -d "backups" ]; then
latest_backup=$(ls -td backups/uploads-* 2>/dev/null | head -1)
if [ -n "$latest_backup" ] && [ -d "$latest_backup" ]; then
mkdir -p uploads
cp -r "$latest_backup"/* uploads/ 2>/dev/null || true
fi
fi
mkdir -p uploads
chmod -R 777 uploads
echo "Stopping old containers..."
docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true
echo "=== Attempting Docker Build ==="
echo "This may fail if base image is not available..."
echo "Checking if we can pull base image..."
# Try to pull the base image first
if docker pull node:18-alpine 2>&1 | tee /tmp/docker-pull.log; then
echo "Base image pulled successfully"
else
echo "WARNING: Failed to pull base image"
echo "Pull log:"
cat /tmp/docker-pull.log
echo ""
echo "Checking if base image exists locally..."
if docker images node:18-alpine --format "{{.Repository}}:{{.Tag}}" | grep -q "node:18-alpine"; then
echo "Base image exists locally, continuing..."
else
echo "ERROR: Base image not found and cannot be pulled"
echo "You need to manually import the image or fix network access"
exit 1
fi
fi
echo "Building Docker image..."
if docker compose build --no-cache 2>&1 | tee /tmp/docker-build.log; then
echo "Build successful"
else
echo "Build failed. Log:"
tail -50 /tmp/docker-build.log
echo ""
echo "Trying with cache..."
if docker compose build 2>&1 | tee /tmp/docker-build-cache.log; then
echo "Build with cache successful"
else
echo "Build with cache also failed. Log:"
tail -50 /tmp/docker-build-cache.log
exit 1
fi
fi
echo "Starting containers..."
docker compose up -d --force-recreate || docker-compose up -d --force-recreate
echo "Waiting for containers to start..."
sleep 20
echo "Running database migration..."
if [ -f prisma/schema.prisma ]; then
docker compose exec -T app npx prisma db push --accept-data-loss --schema=/app/prisma/schema.prisma 2>/dev/null || echo "Database migration completed or skipped"
fi
echo "Container status:"
docker compose ps || docker-compose ps
echo "Application logs (last 50 lines):"
docker compose logs app --tail=50 || docker-compose logs app --tail=50
'@
$deployScript = $deployScript -replace "PROJECT_PATH_PLACEHOLDER", $ProjectPath
$deployScript = $deployScript -replace "REMOTE_FILE_NAME_PLACEHOLDER", $remoteFileName
$deployScript = $deployScript -replace "`r`n", "`n" -replace "`r", "`n"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$bytes = $utf8NoBom.GetBytes($deployScript)
[System.IO.File]::WriteAllBytes($localDeployScript, $bytes)
$remoteDeployScript = "/tmp/deploy-$timestamp.sh"
& $pscpPath -pw $Password -batch $localDeployScript "${User}@${Server}:$remoteDeployScript"
& $plinkPath -ssh -pw $Password -batch "${User}@${Server}" "chmod +x $remoteDeployScript; bash $remoteDeployScript; rm -f $remoteDeployScript"
if ($LASTEXITCODE -eq 0) {
Write-Host "`n[SUCCESS] Deployment completed!" -ForegroundColor Green
} else {
Write-Host "`n[WARN] Deployment may have encountered issues" -ForegroundColor Yellow
Write-Host "Check the output above for details" -ForegroundColor Yellow
}
# Cleanup
Remove-Item $localDeployScript -Force -ErrorAction SilentlyContinue
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
Write-Host "`n=== Next Steps ===" -ForegroundColor Cyan
Write-Host "If deployment failed due to missing base image:" -ForegroundColor Yellow
Write-Host "1. Check server logs: ssh $User@$Server 'cat /tmp/docker-pull.log'" -ForegroundColor White
Write-Host "2. Check build logs: ssh $User@$Server 'cat /tmp/docker-build.log'" -ForegroundColor White
Write-Host "3. Manually import image using: .\scripts\deploy\export-and-upload-image.ps1" -ForegroundColor White
Write-Host " (Requires Docker Desktop running locally)" -ForegroundColor Gray

View File

@ -1,202 +0,0 @@
# ============================================
# 从标准版升级到Pro版脚本
# 支持一键升级,保留数据
# ============================================
param(
[Parameter(Mandatory=$true)]
[string]$Server,
[string]$Username = "root",
[string]$Password = "",
[string]$RemotePath = "/root/deploy",
[string]$DockerRegistry = "",
[string]$ImageTag = "latest"
)
$ErrorActionPreference = "Stop"
# 颜色输出函数
function Write-Step {
param([string]$Message)
Write-Host "===> $Message" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host "✓ $Message" -ForegroundColor Green
}
function Write-Error {
param([string]$Message)
Write-Host "✗ $Message" -ForegroundColor Red
}
function Write-Warning {
param([string]$Message)
Write-Host "⚠ $Message" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "从标准版升级到Pro版" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
# 检查参数
if (-not $DockerRegistry) {
Write-Error "错误: 必须提供 Docker 镜像仓库地址 (DockerRegistry)"
Write-Host ""
Write-Host "用法示例:" -ForegroundColor Yellow
Write-Host " .\upgrade-to-pro.ps1 -Server 45.249.208.118 -DockerRegistry your-registry.com" -ForegroundColor Gray
exit 1
}
# SSH 连接配置
$plinkPath = "C:\Program Files\PuTTY\plink.exe"
if (-not (Test-Path $plinkPath)) {
Write-Error "错误: 未找到 plink.exe,请安装 PuTTY"
exit 1
}
# 获取密码
if (-not $Password) {
$securePassword = Read-Host "请输入SSH密码" -AsSecureString
$Password = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
)
}
$hostKey = "SHA256:WZOtvC/+IWw29P8vyovSabmIRFkQ/Bnmhj+qhTshA4Q"
# SSH 命令执行函数
function Invoke-SSHCommand {
param([string]$Command)
$output = & $plinkPath -batch -ssh -hostkey $hostKey -pw $Password "${Username}@${Server}" $Command 2>&1
return $output
}
Write-Step "检查当前版本..."
$currentCompose = Invoke-SSHCommand "cd ${RemotePath} && ls -la docker-compose.yml 2>/dev/null || echo 'not-found'"
if ($currentCompose -match "not-found") {
Write-Error "错误: 未找到 docker-compose.yml,请确认部署路径正确"
exit 1
}
# 检查当前使用的配置文件
$currentConfig = Invoke-SSHCommand "cd ${RemotePath} && readlink -f docker-compose.yml 2>/dev/null || echo docker-compose.yml"
Write-Host " 当前配置: $currentConfig" -ForegroundColor Gray
# 检查是否已经是Pro版
if ($currentConfig -match "prod") {
Write-Warning "当前已经是Pro版,无需升级"
exit 0
}
Write-Step "备份当前配置和数据..."
$backupDir = "${RemotePath}/backup_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
Invoke-SSHCommand "cd ${RemotePath} && mkdir -p ${backupDir} && cp docker-compose.yml ${backupDir}/ && cp docker-compose.*.yml ${backupDir}/ 2>/dev/null || true"
Write-Success "备份完成: $backupDir"
Write-Step "备份数据库..."
$dbBackupFile = "backup_$(Get-Date -Format 'yyyyMMdd_HHmmss').sql"
Invoke-SSHCommand "cd ${RemotePath} && docker compose exec -T postgres pg_dump -U postgres youyidian3d > ${backupDir}/${dbBackupFile} 2>&1 || echo 'backup-failed'"
Write-Success "数据库备份完成"
Write-Step "停止当前服务..."
Invoke-SSHCommand "cd ${RemotePath} && docker compose down"
Write-Success "服务已停止"
Write-Step "上传Pro版配置文件..."
$scpPath = "C:\Program Files\PuTTY\pscp.exe"
if (Test-Path $scpPath) {
& $scpPath -pw $Password "docker-compose.prod.yml" "${Username}@${Server}:${RemotePath}/docker-compose.prod.yml"
Write-Success "Pro版配置文件已上传"
} else {
Write-Warning "未找到 pscp.exe,将使用SSH直接创建配置文件"
# 读取本地配置文件内容
$prodComposeContent = Get-Content "docker-compose.prod.yml" -Raw
# 通过SSH创建文件
$prodComposeContent | Invoke-SSHCommand "cd ${RemotePath} && cat > docker-compose.prod.yml"
Write-Success "Pro版配置文件已创建"
}
Write-Step "创建docker-compose.yml链接..."
Invoke-SSHCommand "cd ${RemotePath} && ln -sf docker-compose.prod.yml docker-compose.yml"
Write-Success "配置文件链接已创建"
Write-Step "配置环境变量..."
# 创建或更新 .env 文件
$envContent = @"
# Docker镜像仓库
DOCKER_REGISTRY=${DockerRegistry}
# 镜像标签
IMAGE_TAG=${ImageTag}
# 服务器IP(如果未设置)
SERVER_IP=${Server}
# MinIO配置(Pro版需要)
MINIO_ROOT_USER=${env:MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD=${env:MINIO_ROOT_PASSWORD:-minioadmin123456}
S3_BUCKET=${env:S3_BUCKET:-youyidian3d}
"@
$envContent | Invoke-SSHCommand "cd ${RemotePath} && cat > .env.prod"
Write-Success "环境变量已配置"
Write-Step "拉取Pro版镜像和依赖..."
# 拉取MinIO镜像(使用国内镜像源)
Write-Host " 拉取MinIO镜像..." -ForegroundColor Gray
Invoke-SSHCommand "docker pull minio/minio:RELEASE.2025-04-22T22-12-26Z"
Invoke-SSHCommand "docker pull minio/mc:RELEASE.2025-04-22T22-12-26Z"
# 拉取应用镜像
Write-Host " 拉取应用镜像..." -ForegroundColor Gray
$appImage = "${DockerRegistry}/youyidian-3d-material:pro-${ImageTag}"
Invoke-SSHCommand "docker pull ${appImage}"
Write-Success "镜像拉取完成"
Write-Step "启动Pro版服务..."
Invoke-SSHCommand "cd ${RemotePath} && docker compose up -d"
Write-Success "服务已启动"
Write-Step "等待服务就绪..."
Start-Sleep -Seconds 10
# 检查服务状态
$services = Invoke-SSHCommand "cd ${RemotePath} && docker compose ps --format json"
Write-Host " 服务状态:" -ForegroundColor Gray
Invoke-SSHCommand "cd ${RemotePath} && docker compose ps"
Write-Step "验证升级结果..."
# 检查健康状态
$healthCheck = Invoke-SSHCommand "curl -s http://localhost:4320/api/health || echo 'not-ready'"
if ($healthCheck -match "not-ready") {
Write-Warning "应用可能还在启动中,请稍后检查"
} else {
Write-Success "应用健康检查通过"
}
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "升级完成!" -ForegroundColor Green
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "📋 升级信息:" -ForegroundColor Yellow
Write-Host " 备份目录: ${backupDir}" -ForegroundColor Gray
Write-Host " 应用地址: http://${Server}:4320" -ForegroundColor Gray
Write-Host " MinIO控制台: http://${Server}:9001" -ForegroundColor Gray
Write-Host ""
Write-Host "💡 提示:" -ForegroundColor Yellow
Write-Host " - 数据已自动迁移" -ForegroundColor Gray
Write-Host " - 如需回滚,请查看备份目录" -ForegroundColor Gray
Write-Host " - 建议测试所有功能确保正常" -ForegroundColor Gray
Write-Host ""

View File

@ -1,150 +0,0 @@
#!/bin/bash
# ============================================
# 从标准版升级到Pro版脚本(Linux版本)
# 支持一键升级,保留数据
# ============================================
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 参数
DOCKER_REGISTRY="${DOCKER_REGISTRY:-}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
REMOTE_PATH="${REMOTE_PATH:-/root/deploy}"
# 检查参数
if [ -z "$DOCKER_REGISTRY" ]; then
echo -e "${RED}错误: 必须设置 DOCKER_REGISTRY 环境变量${NC}"
echo ""
echo "用法示例:"
echo " export DOCKER_REGISTRY=your-registry.com"
echo " export IMAGE_TAG=latest"
echo " bash upgrade-to-pro.sh"
exit 1
fi
echo ""
echo -e "${CYAN}==========================================${NC}"
echo -e "${CYAN}从标准版升级到Pro版${NC}"
echo -e "${CYAN}==========================================${NC}"
echo ""
# 检查当前版本
echo -e "${CYAN}===> 检查当前版本...${NC}"
if [ ! -f "${REMOTE_PATH}/docker-compose.yml" ]; then
echo -e "${RED}错误: 未找到 docker-compose.yml${NC}"
exit 1
fi
CURRENT_CONFIG=$(readlink -f "${REMOTE_PATH}/docker-compose.yml" 2>/dev/null || echo "${REMOTE_PATH}/docker-compose.yml")
echo " 当前配置: $CURRENT_CONFIG"
if [[ "$CURRENT_CONFIG" == *"prod"* ]]; then
echo -e "${YELLOW}当前已经是Pro版,无需升级${NC}"
exit 0
fi
# 备份
echo -e "${CYAN}===> 备份当前配置和数据...${NC}"
BACKUP_DIR="${REMOTE_PATH}/backup_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp "${REMOTE_PATH}/docker-compose.yml" "$BACKUP_DIR/" 2>/dev/null || true
cp "${REMOTE_PATH}/docker-compose.*.yml" "$BACKUP_DIR/" 2>/dev/null || true
echo -e "${GREEN}✓ 备份完成: $BACKUP_DIR${NC}"
# 备份数据库
echo -e "${CYAN}===> 备份数据库...${NC}"
cd "$REMOTE_PATH"
docker compose exec -T postgres pg_dump -U postgres youyidian3d > "${BACKUP_DIR}/backup_$(date +%Y%m%d_%H%M%S).sql" 2>&1 || echo "数据库备份可能失败,但继续执行"
echo -e "${GREEN}✓ 数据库备份完成${NC}"
# 停止服务
echo -e "${CYAN}===> 停止当前服务...${NC}"
cd "$REMOTE_PATH"
docker compose down
echo -e "${GREEN}✓ 服务已停止${NC}"
# 检查Pro版配置文件
if [ ! -f "${REMOTE_PATH}/docker-compose.prod.yml" ]; then
echo -e "${YELLOW}未找到 docker-compose.prod.yml,请先上传配置文件${NC}"
exit 1
fi
# 创建链接
echo -e "${CYAN}===> 切换到Pro版配置...${NC}"
cd "$REMOTE_PATH"
ln -sf docker-compose.prod.yml docker-compose.yml
echo -e "${GREEN}✓ 配置文件已切换${NC}"
# 配置环境变量
echo -e "${CYAN}===> 配置环境变量...${NC}"
cat > "${REMOTE_PATH}/.env.prod" << EOF
# Docker镜像仓库
DOCKER_REGISTRY=${DOCKER_REGISTRY}
# 镜像标签
IMAGE_TAG=${IMAGE_TAG}
# MinIO配置
MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin123456}
S3_BUCKET=${S3_BUCKET:-youyidian3d}
EOF
echo -e "${GREEN}✓ 环境变量已配置${NC}"
# 拉取镜像
echo -e "${CYAN}===> 拉取Pro版镜像和依赖...${NC}"
echo " 拉取MinIO镜像..."
docker pull minio/minio:RELEASE.2025-04-22T22-12-26Z
docker pull minio/mc:RELEASE.2025-04-22T22-12-26Z
echo " 拉取应用镜像..."
APP_IMAGE="${DOCKER_REGISTRY}/youyidian-3d-material:pro-${IMAGE_TAG}"
docker pull "$APP_IMAGE"
echo -e "${GREEN}✓ 镜像拉取完成${NC}"
# 启动服务
echo -e "${CYAN}===> 启动Pro版服务...${NC}"
cd "$REMOTE_PATH"
docker compose --env-file .env.prod up -d
echo -e "${GREEN}✓ 服务已启动${NC}"
# 等待服务就绪
echo -e "${CYAN}===> 等待服务就绪...${NC}"
sleep 10
# 检查服务状态
echo " 服务状态:"
docker compose ps
# 验证
echo -e "${CYAN}===> 验证升级结果...${NC}"
if curl -s http://localhost:4320/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✓ 应用健康检查通过${NC}"
else
echo -e "${YELLOW}⚠ 应用可能还在启动中,请稍后检查${NC}"
fi
echo ""
echo -e "${CYAN}==========================================${NC}"
echo -e "${GREEN}升级完成!${NC}"
echo -e "${CYAN}==========================================${NC}"
echo ""
echo -e "${YELLOW}📋 升级信息:${NC}"
echo " 备份目录: $BACKUP_DIR"
echo " 应用地址: http://$(hostname -I | awk '{print $1}'):4320"
echo ""
echo -e "${YELLOW}💡 提示:${NC}"
echo " - 数据已自动迁移"
echo " - 如需回滚,请查看备份目录"
echo " - 建议测试所有功能确保正常"
echo ""