chore: initialize qiming workspace repository
This commit is contained in:
23
qiming-file-server/.gitignore
vendored
Normal file
23
qiming-file-server/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
nohup.out
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
160
qiming-file-server/AGENTS.md
Normal file
160
qiming-file-server/AGENTS.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# AI Assistant Instructions for qiming-file-server Project
|
||||
|
||||
## Project Overview
|
||||
|
||||
qiming-file-server 是一个跨平台的文件服务部署工具,支持 Windows、Linux、macOS 操作系统,提供 start/stop/restart 命令行操作。
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
qiming-file-server start # 启动服务 (默认 env.production)
|
||||
qiming-file-server start --env dev # 启动服务 (开发环境)
|
||||
qiming-file-server stop # 停止服务
|
||||
qiming-file-server restart # 重启服务
|
||||
qiming-file-server status # 查看服务状态
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
qiming-file-server/
|
||||
├── src/
|
||||
│ ├── cli.js # CLI 入口 (跨平台兼容)
|
||||
│ ├── server.js # 主服务器
|
||||
│ ├── routes/
|
||||
│ │ └── router.js # 路由定义 (含 /health 端点)
|
||||
│ ├── utils/
|
||||
│ │ ├── serviceManager.js # 服务管理 (跨平台)
|
||||
│ │ ├── envUtils.js # 环境变量工具
|
||||
│ │ ├── build/ # 构建相关工具
|
||||
│ │ ├── buildArg/ # 构建参数工具
|
||||
│ │ ├── buildDependency/ # 依赖管理
|
||||
│ │ ├── buildJudge/ # 构建判断工具
|
||||
│ │ ├── buildPermission/ # 权限管理
|
||||
│ │ ├── common/ # 公共工具
|
||||
│ │ ├── computer/ # 计算机工具
|
||||
│ │ ├── error/ # 错误处理
|
||||
│ │ └── log/ # 日志工具
|
||||
│ ├── appConfig/ # 应用配置 (环境变量等)
|
||||
│ ├── config/
|
||||
│ │ └── swagger/ # Swagger 文档配置
|
||||
│ ├── env.development # 开发环境配置
|
||||
│ ├── env.production # 生产环境配置
|
||||
│ └── env.test # 测试环境配置
|
||||
├── scripts/
|
||||
│ ├── start-prod.js # 生产环境启动脚本
|
||||
│ ├── start-dev.js # 开发环境启动脚本
|
||||
│ └── pnpm-check.sh # pnpm 检查脚本
|
||||
├── tests/ # 测试文件
|
||||
└── package.json # 项目配置
|
||||
```
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### 1. Cross-Platform Compatibility
|
||||
|
||||
所有代码必须支持 Windows、Linux、macOS:
|
||||
|
||||
- **路径处理**: 使用 `path.join()`, `path.resolve()`, `os.tmpdir()`, `os.homedir()`
|
||||
- **进程管理**: 使用 `tree-kill` 杀进程组,Windows 使用 `taskkill`,Linux/macOS 使用 `kill` 信号
|
||||
- **命令执行**: 使用 `cross-spawn` 执行 shell 命令
|
||||
- **文件操作**: 使用 `fs-extra` 增强文件系统操作
|
||||
|
||||
### 2. Environment Configuration
|
||||
|
||||
- 环境变量优先级: 命令行参数 > 环境变量文件 > 默认值
|
||||
- 支持通过 `--env` 参数指定环境 (development/production/test)
|
||||
- 支持通过 `--env-file` 指定自定义配置文件
|
||||
- 配置通过 `src/appConfig/index.js` 统一管理,环境文件位于 `src/env.*`
|
||||
|
||||
### 3. CLI Design
|
||||
|
||||
- 使用 `commander.js` 解析命令行参数
|
||||
- 命令结构: `qiming-file-server <command> [options]`
|
||||
- 支持的命令: start, stop, restart, status
|
||||
- 全局选项: `--env`, `--port`, `--config`, `--help`
|
||||
|
||||
### 4. Health Check Endpoint
|
||||
|
||||
健康检查端点位于 `/health`,返回格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": 1738600000000,
|
||||
"uptime": 3600,
|
||||
"version": "1.0.0",
|
||||
"platform": "darwin"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Service Management
|
||||
|
||||
- PID 文件存储在临时目录 (`os.tmpdir()`)
|
||||
- 启动时检查服务是否已运行
|
||||
- 停止时支持优雅退出 (30秒超时)
|
||||
- 重启组合 stop + start 操作
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style
|
||||
|
||||
- 使用中文注释(根据项目规则)
|
||||
- 函数必须添加 JSDoc 注释
|
||||
- 跨平台代码必须包含平台检测逻辑
|
||||
- 错误处理必须包含详细日志
|
||||
|
||||
### Testing
|
||||
|
||||
- 单元测试放置在 `tests/unit/` 目录
|
||||
- 测试框架使用 Jest
|
||||
- 关键函数必须编写单元测试
|
||||
|
||||
### Logging
|
||||
|
||||
- 使用 `src/utils/log/logUtils.js` 中的 `log()` 函数
|
||||
- 日志级别: error, warn, info, debug
|
||||
- 日志文件按日期分割,存储在 `LOG_BASE_DIR`
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Route
|
||||
|
||||
1. 在 `src/routes/` 目录下创建新的路由文件
|
||||
2. 在 `router.js` 中挂载路由
|
||||
3. 添加相应的服务逻辑到 `src/service/` 目录
|
||||
|
||||
### Adding a New CLI Command
|
||||
|
||||
1. 在 `src/cli.js` 中使用 `commander` 定义命令
|
||||
2. 在 `src/utils/serviceManager.js` 中实现服务逻辑
|
||||
3. 更新 `README.md` 文档
|
||||
|
||||
### Modifying Configuration
|
||||
|
||||
1. 在 `src/env.production` / `src/env.development` / `src/env.test` 中添加环境变量
|
||||
2. 在 `config/index.js` 中读取配置
|
||||
3. 更新 `README.md` 文档
|
||||
|
||||
## Build and Deploy
|
||||
|
||||
```bash
|
||||
# 开发环境运行
|
||||
npm run dev
|
||||
|
||||
# 生产环境运行
|
||||
npm run prod
|
||||
|
||||
# 运行测试
|
||||
npm run test
|
||||
|
||||
# 发布到 npm
|
||||
npm publish
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- 所有平台相关代码必须使用条件判断 (`process.platform`)
|
||||
- 避免使用 Unix 特定命令(grep, sed, awk 等)
|
||||
- Windows 环境使用 `cross-spawn` 执行命令
|
||||
- 确保日志目录有写入权限
|
||||
147
qiming-file-server/CHANGELOG.md
Normal file
147
qiming-file-server/CHANGELOG.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.3] - 2026-03-06
|
||||
|
||||
### Changed
|
||||
|
||||
- **发布与包管理**
|
||||
- 移除 `packageManager` 与 `preinstall` 的 pnpm 强约束,支持使用 npm 安装与发布
|
||||
- `prepublishOnly` 改为 `npm run build`,便于 npm 发布流程
|
||||
|
||||
## [1.2.2] - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
- **性能耗时日志**
|
||||
- 所有关键 API 接口(项目初始化、更新、目录/文件列表)添加 INFO 级别耗时日志,包含 `elapsedMs` 数值字段
|
||||
- 中间步骤(备份、解压、文件写入、清理等)添加 DEBUG 级别耗时日志
|
||||
- 错误路径统一添加 `elapsedMs` 字段,便于排查超时和异常耗时问题
|
||||
|
||||
### Changed
|
||||
|
||||
- **日志级别优化**
|
||||
- 中间步骤日志(备份、解压、文件写入等"开始"类日志)从 INFO 降级为 DEBUG,减少生产环境日志噪音
|
||||
- 通过 `LOG_LEVEL` 环境变量控制日志输出级别(error > warn > info > debug)
|
||||
|
||||
- **日志格式统一**
|
||||
- 所有耗时字段统一使用 `elapsedMs`(数值类型,单位毫秒),便于日志聚合和监控系统解析
|
||||
- 移除冗余的重复日志输出
|
||||
|
||||
### Fixed
|
||||
|
||||
- **downloadAllFiles 耗时统计修正**
|
||||
- 将耗时记录从压缩准备阶段移至 `archive.on("end")` 事件,准确反映实际压缩完成时间
|
||||
|
||||
## [1.2.0] - 2026-02-06
|
||||
|
||||
### Added
|
||||
|
||||
- **CLI 参数解析增强**
|
||||
- 支持在命令行中传递 `--KEY=VALUE` 格式的参数
|
||||
- 自动将这些参数写入 `process.env` 环境变量
|
||||
- 可以在启动服务时通过命令行覆盖环境变量配置
|
||||
|
||||
### Changed
|
||||
|
||||
- **命令灵活性提升**
|
||||
- `start` 和 `restart` 命令现在允许接受未知选项
|
||||
- 允许传递额外的自定义参数给服务
|
||||
|
||||
## [1.1.1] - 2026-02-04
|
||||
|
||||
### Added
|
||||
|
||||
- **CLI 版本查询增强**
|
||||
- 新增 `-v` 参数作为查看版本的快捷方式(支持 `-v, --version`)
|
||||
|
||||
## [1.1.0] - 2026-02-03
|
||||
|
||||
### Changed
|
||||
|
||||
- **ES Module 迁移**
|
||||
- 全面迁移到 ES Module (ESM) 语法
|
||||
- 所有 `require` 替换为 `import`
|
||||
- 所有 `module.exports` 替换为 `export`
|
||||
- 更新 Node.js 版本要求至 >= 22.0.0
|
||||
- `package.json` 添加 `"type": "module"`
|
||||
|
||||
- **构建流程更新**
|
||||
- 新增 `scripts/build.js` 使用 esbuild 打包
|
||||
- 输出格式设置为 ESM
|
||||
- 支持代码压缩和发布优化
|
||||
|
||||
### Added
|
||||
|
||||
- **LOG_BASE_DIR 容错增强**
|
||||
- 当配置路径无法创建时自动回退到系统临时目录
|
||||
- 避免因路径问题导致服务启动失败
|
||||
|
||||
## [1.0.0] - 2025-02-03
|
||||
|
||||
### Added
|
||||
|
||||
- **CLI 工具支持**
|
||||
- 新增 `qiming-file-server` CLI 命令行工具
|
||||
- 支持 `start`、`stop`、`restart`、`status` 命令
|
||||
- 支持 `--port`、`--env`、`--config` 等命令行参数
|
||||
- 支持通过命令行参数覆盖环境变量配置
|
||||
|
||||
- **跨平台支持**
|
||||
- 支持 Windows、Linux、macOS 三大平台
|
||||
- 使用 `cross-spawn` 确保跨平台命令执行
|
||||
- 使用 `tree-kill` 实现跨平台进程终止
|
||||
- PID 文件管理支持多平台临时目录
|
||||
|
||||
- **健康检查端点**
|
||||
- 新增 `/health` HTTP 端点
|
||||
- 返回服务状态、版本、运行时间、内存使用等信息
|
||||
|
||||
- **环境变量配置**
|
||||
- 完整的配置文件加载机制
|
||||
- 支持 `env.development`、`env.production`、`env.test`
|
||||
- 详细的路径配置项说明
|
||||
- CLI 专用配置项
|
||||
|
||||
- **项目文档**
|
||||
- 新增 `docs/ENV.md` 完整的环境变量配置指南
|
||||
- 更新 `README.md` 包含 CLI 使用说明
|
||||
- 新增 `AGENTS.md` 和 `CLAUDE.md` 软链接
|
||||
|
||||
- **测试**
|
||||
- 新增 `tests/unit/cli.test.js` 单元测试
|
||||
- 覆盖服务管理器、环境变量工具、跨平台兼容性等
|
||||
|
||||
### Changed
|
||||
|
||||
- **包配置**
|
||||
- `package.json` 新增 `bin` 字段支持 CLI 全局安装
|
||||
- 新增 `commander`、`cross-spawn`、`fs-extra`、`tree-kill` 依赖
|
||||
|
||||
### Fixed
|
||||
|
||||
- **启动脚本**
|
||||
- 修复 `scripts/start-cli.js` 实际加载 server.js 的问题
|
||||
- 修复 `router.js` 中 package.json 路径解析问题
|
||||
|
||||
### Security
|
||||
|
||||
- 使用 `path.resolve` 和 `fs.readFileSync` 替代 `require` 确保安全性
|
||||
|
||||
## [0.1.0] - 2024-XX-XX
|
||||
|
||||
Initial release of qiming-file-server project.
|
||||
|
||||
### Added
|
||||
|
||||
- 项目基础结构
|
||||
- Express 服务器配置
|
||||
- 构建路由、项目路由、代码路由
|
||||
- pnpm 磁盘空间优化功能
|
||||
- 日志缓存管理
|
||||
1
qiming-file-server/CLAUDE.md
Normal file
1
qiming-file-server/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
201
qiming-file-server/LICENSE
Normal file
201
qiming-file-server/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
320
qiming-file-server/README.md
Normal file
320
qiming-file-server/README.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# qiming-file-server
|
||||
|
||||
Cross-platform file service deployment tooling for Windows, Linux, and macOS.
|
||||
|
||||
## Features
|
||||
|
||||
- **CLI**: `start`, `stop`, `restart`, `status`
|
||||
- **Cross-platform**: Windows, Linux, and macOS
|
||||
- **Configuration**: Environment variables and CLI flags
|
||||
- **Health endpoint**: `GET /health` for liveness checks
|
||||
- **PID file**: Tracks and manages the server process
|
||||
|
||||
## Installation
|
||||
|
||||
### Local development
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd qiming-file-server
|
||||
|
||||
npm install
|
||||
|
||||
# Development mode
|
||||
npm run dev
|
||||
|
||||
# Production mode (local)
|
||||
npm run prod
|
||||
```
|
||||
|
||||
### Global CLI install
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
npm install -g .
|
||||
|
||||
# Then run from anywhere
|
||||
qiming-file-server --help
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js >= 22.0.0 (native ES modules)
|
||||
- zip/unzip (for project archives)
|
||||
- pnpm (recommended) or npm/yarn
|
||||
|
||||
## CLI
|
||||
|
||||
Available commands:
|
||||
|
||||
### Basics
|
||||
|
||||
```bash
|
||||
# Start (defaults to env.production)
|
||||
qiming-file-server start
|
||||
|
||||
# Start with a specific env
|
||||
qiming-file-server start --env development
|
||||
qiming-file-server start --env production
|
||||
qiming-file-server start --env test
|
||||
|
||||
# Stop
|
||||
qiming-file-server stop
|
||||
|
||||
# Force stop
|
||||
qiming-file-server stop --force
|
||||
|
||||
# Restart
|
||||
qiming-file-server restart
|
||||
|
||||
# Status
|
||||
qiming-file-server status
|
||||
```
|
||||
|
||||
### Advanced
|
||||
|
||||
```bash
|
||||
# Custom port
|
||||
qiming-file-server start --port 8080
|
||||
|
||||
# Custom config file
|
||||
qiming-file-server start --config /path/to/config.json
|
||||
|
||||
# Combined
|
||||
qiming-file-server start --env development --port 3000
|
||||
```
|
||||
|
||||
### npm scripts
|
||||
|
||||
```bash
|
||||
npm run cli:start
|
||||
npm run cli:start:dev # development
|
||||
npm run cli:start:prod # production
|
||||
npm run cli:start:test # test
|
||||
|
||||
npm run cli:stop
|
||||
npm run cli:restart
|
||||
npm run cli:status
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Full reference: [Environment variables](./docs/ENV.md)
|
||||
|
||||
### Quick example
|
||||
|
||||
```bash
|
||||
# Defaults from env.production (typical)
|
||||
qiming-file-server start --env production --port 60000
|
||||
|
||||
# Override paths (trim to what you need)
|
||||
qiming-file-server start --env production --port 60000 \
|
||||
PROJECT_SOURCE_DIR=/data/projects \
|
||||
DIST_TARGET_DIR=/var/www/html \
|
||||
UPLOAD_PROJECT_DIR=/data/uploads
|
||||
```
|
||||
|
||||
### Core path variables
|
||||
|
||||
| Variable | Purpose |
|
||||
| -------- | ------- |
|
||||
| `INIT_PROJECT_DIR` | Scaffold / init project directory |
|
||||
| `UPLOAD_PROJECT_DIR` | Uploaded project archives |
|
||||
| `PROJECT_SOURCE_DIR` | Project source tree |
|
||||
| `DIST_TARGET_DIR` | Build output (e.g. nginx root) |
|
||||
| `LOG_BASE_DIR` | Log directory root |
|
||||
| `COMPUTER_WORKSPACE_DIR` | “Computer” workspace |
|
||||
| `COMPUTER_LOG_DIR` | “Computer” logs |
|
||||
|
||||
More options and scenarios: [Environment variables](./docs/ENV.md)
|
||||
|
||||
### CLI precedence
|
||||
|
||||
```bash
|
||||
# Port: CLI > env > default
|
||||
qiming-file-server start --env production --port 8080
|
||||
```
|
||||
|
||||
## Health check
|
||||
|
||||
The server exposes `GET /health` for monitoring and probes.
|
||||
|
||||
### Request
|
||||
|
||||
```bash
|
||||
curl http://localhost:60000/health
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": 1738600000000,
|
||||
"uptime": 3600,
|
||||
"version": "1.0.0",
|
||||
"platform": "darwin",
|
||||
"nodeVersion": "v22.0.0",
|
||||
"pid": 12345,
|
||||
"memory": {
|
||||
"heapUsed": 25.5,
|
||||
"heapTotal": 50.0,
|
||||
"rss": 100.0,
|
||||
"external": 5.0
|
||||
},
|
||||
"env": "production"
|
||||
}
|
||||
```
|
||||
|
||||
### Response fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| status | string | `"ok"` when healthy |
|
||||
| timestamp | number | Unix time (ms) |
|
||||
| uptime | number | Uptime in seconds |
|
||||
| version | string | Server version |
|
||||
| platform | string | `darwin` / `linux` / `win32` |
|
||||
| nodeVersion | string | Node.js version |
|
||||
| pid | number | Process ID |
|
||||
| memory | object | Memory usage (MB) |
|
||||
| env | string | Active environment name |
|
||||
|
||||
## Cross-platform notes
|
||||
|
||||
### Windows
|
||||
|
||||
- PID file under `%TEMP%\qiming-file-server\`
|
||||
- Stop uses `taskkill /F /PID`
|
||||
- Paths use backslashes `\`
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
- PID file under `/tmp/qiming-file-server/`
|
||||
- Stop uses signals (SIGTERM / SIGKILL)
|
||||
- Paths use `/`
|
||||
|
||||
### General
|
||||
|
||||
- Paths built with `path.join()`
|
||||
- Temp dir from `os.tmpdir()`
|
||||
- Shell commands via `cross-spawn`
|
||||
- Process trees via `tree-kill`
|
||||
|
||||
## pnpm disk usage
|
||||
|
||||
Created, uploaded, or copied projects get an optimized `.npmrc` to reduce pnpm disk footprint.
|
||||
|
||||
### Automatic injection
|
||||
|
||||
`.npmrc` is applied when:
|
||||
|
||||
- **Create project** (`/create-project`)
|
||||
- **Upload project** (`/upload-project`)
|
||||
- **Copy project** (`/copy-project`)
|
||||
|
||||
### Inspect disk usage
|
||||
|
||||
```bash
|
||||
npm run pnpm:check
|
||||
|
||||
npm run pnpm:check:dev # development
|
||||
npm run pnpm:check:prod # production
|
||||
npm run pnpm:check:test # test
|
||||
|
||||
bash scripts/pnpm-check.sh /path/to/projects
|
||||
```
|
||||
|
||||
### Prune unused packages
|
||||
|
||||
```bash
|
||||
npm run pnpm:prune
|
||||
|
||||
npm run pnpm:prune:log
|
||||
```
|
||||
|
||||
### Scheduled prune (built-in)
|
||||
|
||||
The app can run a cron-style prune job. Configure with env vars, for example:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml or .env
|
||||
environment:
|
||||
PNPM_PRUNE_ENABLED: "true"
|
||||
PNPM_PRUNE_SCHEDULE: "0 2 * * 0"
|
||||
PNPM_PRUNE_TIMEZONE: "Asia/Shanghai"
|
||||
PNPM_PRUNE_RUN_ON_START: "false"
|
||||
```
|
||||
|
||||
**Example schedules:**
|
||||
|
||||
```bash
|
||||
"0 2 * * 0" # Sunday 02:00
|
||||
"0 3 * * *" # Daily 03:00
|
||||
"0 2 1 * *" # 1st of month 02:00
|
||||
"0 */6 * * *" # Every 6 hours
|
||||
```
|
||||
|
||||
### Expected benefits
|
||||
|
||||
- Lower disk use when many projects share dependencies (often a large share of savings vs naive installs)
|
||||
- Faster installs when using a nearby registry mirror
|
||||
- Automated `.npmrc`; periodic store maintenance when enabled
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server will not start
|
||||
|
||||
1. Check the port is free
|
||||
2. Check log directory permissions
|
||||
3. Confirm env files exist
|
||||
|
||||
```bash
|
||||
qiming-file-server start --env development
|
||||
```
|
||||
|
||||
### Server will not stop
|
||||
|
||||
```bash
|
||||
qiming-file-server stop --force
|
||||
|
||||
ps aux | grep qiming-file-server
|
||||
kill -9 <pid>
|
||||
```
|
||||
|
||||
### Health check fails
|
||||
|
||||
1. Confirm the process is running
|
||||
2. Confirm host/port
|
||||
3. Check firewall rules
|
||||
|
||||
```bash
|
||||
qiming-file-server status
|
||||
curl http://localhost:60000/health
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Adding a command
|
||||
|
||||
In `src/cli.js`, use Commander:
|
||||
|
||||
```javascript
|
||||
program
|
||||
.command("newcommand")
|
||||
.description("Description of the new command")
|
||||
.option("--option", "Option description")
|
||||
.action((options) => {
|
||||
// handler
|
||||
});
|
||||
```
|
||||
|
||||
### Adding a setting
|
||||
|
||||
1. Add variables to `src/env.development` / `src/env.production` / `src/env.test`
|
||||
2. Wire them through `src/appConfig/index.js` as needed
|
||||
3. Document the change
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
675
qiming-file-server/docs/ENV.md
Normal file
675
qiming-file-server/docs/ENV.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# Environment variables — full reference
|
||||
|
||||
qiming-file-server can be configured entirely through environment variables. This document lists every supported variable.
|
||||
|
||||
## Precedence
|
||||
|
||||
From highest to lowest:
|
||||
|
||||
1. **CLI arguments**
|
||||
2. **Process environment variables**
|
||||
3. **Env files** (`.env.production`, `.env.development`, `.env.test` — loaded according to project conventions; see `src/env.*`)
|
||||
4. **Built-in defaults**
|
||||
|
||||
## Env files
|
||||
|
||||
Files under `src/` are selected based on `NODE_ENV`:
|
||||
|
||||
| Mode | File | Purpose |
|
||||
| ---- | ---- | ------- |
|
||||
| development | `env.development` | Local development |
|
||||
| production | `env.production` | Production |
|
||||
| test | `env.test` | Tests |
|
||||
|
||||
## Core settings
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `NODE_ENV` | `development` | `development` / `production` / `test` |
|
||||
| `PORT` | `60000` | HTTP listen port |
|
||||
| `REQUEST_BODY_LIMIT` | `2000mb` | Express body size limit |
|
||||
|
||||
### Logging
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `LOG_BASE_DIR` | — | Base directory for log files |
|
||||
| `LOG_LEVEL` | `debug` | `error` / `warn` / `info` / `debug` |
|
||||
| `LOG_CONSOLE_ENABLED` | `true` | Mirror logs to console |
|
||||
| `LOG_PREFIX_API` | `api` | Prefix for API logs |
|
||||
| `LOG_PREFIX_BUILD` | `build` | Prefix for build logs |
|
||||
|
||||
### Project paths
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `INIT_PROJECT_NAME_REACT` | `react-vite-template` | Built-in scaffold name for React projects |
|
||||
| `INIT_PROJECT_NAME_VUE3` | `vue3-vite-template` | Built-in scaffold name for Vue3 projects |
|
||||
| `INIT_PROJECT_DIR` | — | Scaffold / init project directory |
|
||||
| `UPLOAD_PROJECT_DIR` | — | Directory for uploaded project archives |
|
||||
| `PROJECT_SOURCE_DIR` | — | Unpacked / working project sources |
|
||||
| `DIST_TARGET_DIR` | — | Build output directory (e.g. served by nginx) |
|
||||
| `COMPUTER_WORKSPACE_DIR` | — | “Computer” feature workspace |
|
||||
| `COMPUTER_LOG_DIR` | — | “Computer” feature logs |
|
||||
|
||||
### Build
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `MAX_BUILD_CONCURRENCY` | `20` | Max concurrent builds |
|
||||
| `DEV_SERVER_PORT_TIMEOUT` | `5000` | Dev-server port probe timeout (ms) |
|
||||
| `DEV_SERVER_STOP_TIMEOUT` | `5000` | Dev-server stop timeout (ms) |
|
||||
| `DEV_SERVER_STOP_CHECK_INTERVAL` | `100` | Poll interval while stopping dev server (ms) |
|
||||
| `DEV_SERVER_STOP_MAX_ATTEMPTS` | `50` | Max stop retries |
|
||||
|
||||
### Uploads
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `MAX_INLINE_FILE_SIZE_BYTES` | `1048576` | Max size (bytes) to return inline; larger files are not inlined |
|
||||
| `UPLOAD_MAX_FILE_SIZE_BYTES` | `1048576000` | Max archive upload size (bytes) |
|
||||
| `UPLOAD_ALLOWED_EXTENSIONS` | `.zip` | Allowed extensions (comma-separated) |
|
||||
| `UPLOAD_SINGLE_FILE_SIZE_BYTES` | `1048576000` | Max single upload size (bytes) |
|
||||
|
||||
### Traversal / filters
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `TRAVERSE_EXCLUDE_DIRS` | `dist,node_modules,.pnpm-store,__MACOSX,.attachments` | Comma-separated dirs to skip when traversing |
|
||||
| `BACKUP_TRAVERSE_EXCLUDE_FILES` | `pnpm-lock.yaml,yarn.lock,package-lock.json` | Comma-separated files to skip in backup traversal |
|
||||
| `CONTENT_TRAVERSE_EXCLUDE_FILES` | `AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json` | Comma-separated files to skip for content API |
|
||||
| `INLINE_IMAGE_EXTENSIONS` | `.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif` | Image extensions eligible for inline serving |
|
||||
| `TOP_LEVEL_NOISE_PATTERNS` | `__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments` | Comma-separated noise patterns for top-level flattening after unzip |
|
||||
|
||||
### pnpm prune scheduler
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `PNPM_PRUNE_ENABLED` | `true` | Enable scheduled `pnpm store` maintenance |
|
||||
| `PNPM_PRUNE_SCHEDULE` | `0 2 * * *` | Cron expression |
|
||||
| `PNPM_PRUNE_TIMEZONE` | `Asia/Shanghai` | Time zone for the schedule |
|
||||
| `PNPM_PRUNE_RUN_ON_START` | `false` | Run once immediately on startup |
|
||||
|
||||
**Example cron expressions:**
|
||||
|
||||
| Expression | Meaning |
|
||||
| ---------- | ------- |
|
||||
| `0 2 * * 0` | Every Sunday 02:00 |
|
||||
| `0 3 * * *` | Every day 03:00 |
|
||||
| `0 2 1 * *` | 1st of month 02:00 |
|
||||
| `0 */6 * * *` | Every 6 hours |
|
||||
|
||||
### Log cache
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `LOG_CACHE_ENABLED` | `true` | Enable in-memory log cache |
|
||||
| `LOG_CACHE_DURATION` | `180000` | TTL (ms); default 3 minutes |
|
||||
| `LOG_CACHE_MAX_ENTRIES` | `100` | Max cached entries |
|
||||
| `LOG_CACHE_MAX_FILE_SIZE` | `2097152` | Max cached file size (bytes); default 2 MB |
|
||||
|
||||
### CLI-only
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| `CLI_PID_DIR` | system temp | Directory for PID file |
|
||||
| `CLI_PID_FILE` | `server.pid` | PID filename |
|
||||
| `CLI_STOP_TIMEOUT` | `30000` | Graceful stop timeout (ms) |
|
||||
| `CLI_CHECK_INTERVAL` | `500` | Poll interval when stopping (ms) |
|
||||
| `CLI_LOG_DIR` | system temp | CLI log directory |
|
||||
|
||||
## Extra CLI environment
|
||||
|
||||
| Variable | Description |
|
||||
| -------- | ----------- |
|
||||
| `CONFIG_FILE` | Optional path to an extra env file (CLI) |
|
||||
|
||||
## Examples
|
||||
|
||||
### Use production defaults but override paths
|
||||
|
||||
In real deployments you often keep `env.production` as the base and only override paths:
|
||||
|
||||
#### Example A: production + custom paths
|
||||
|
||||
```bash
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60000 \
|
||||
PROJECT_SOURCE_DIR=/data/my-projects \
|
||||
DIST_TARGET_DIR=/var/www/my-app \
|
||||
UPLOAD_PROJECT_DIR=/data/uploads \
|
||||
LOG_BASE_DIR=/var/log/qiming
|
||||
```
|
||||
|
||||
#### Example B: multiple instances on one host
|
||||
|
||||
```bash
|
||||
# Instance 1 — main
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60001 \
|
||||
PROJECT_SOURCE_DIR=/data/main-site \
|
||||
DIST_TARGET_DIR=/var/www/main-site
|
||||
|
||||
# Instance 2 — staging
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60002 \
|
||||
PROJECT_SOURCE_DIR=/data/test-site \
|
||||
DIST_TARGET_DIR=/var/www/test-site
|
||||
|
||||
# Instance 3 — internal
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60003 \
|
||||
PROJECT_SOURCE_DIR=/data/internal \
|
||||
DIST_TARGET_DIR=/var/www/internal
|
||||
```
|
||||
|
||||
> Only pass `--port` and the paths you need; everything else comes from `env.production`.
|
||||
|
||||
#### Example C: override file
|
||||
|
||||
```bash
|
||||
cat > env.override << 'EOF'
|
||||
# Path overrides for production
|
||||
PROJECT_SOURCE_DIR=/data/override-projects
|
||||
DIST_TARGET_DIR=/var/www/override-nginx
|
||||
UPLOAD_PROJECT_DIR=/data/override-uploads
|
||||
LOG_BASE_DIR=/var/log/override
|
||||
EOF
|
||||
|
||||
qiming-file-server start --env production --env-file ./env.override
|
||||
```
|
||||
|
||||
#### Example D: Kubernetes ConfigMap
|
||||
|
||||
```bash
|
||||
kubectl create configmap qiming-overrides \
|
||||
--from-literal=PROJECT_SOURCE_DIR=/data/k8s-projects \
|
||||
--from-literal=DIST_TARGET_DIR=/usr/share/nginx/apps \
|
||||
--from-literal=UPLOAD_PROJECT_DIR=/data/k8s-uploads \
|
||||
--from-literal=LOG_BASE_DIR=/var/log/qiming
|
||||
```
|
||||
|
||||
Reference it in the Deployment:
|
||||
|
||||
```yaml
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: qiming-overrides
|
||||
```
|
||||
|
||||
#### Example E: full path set (illustrative)
|
||||
|
||||
Below lists path-related variables you might set. Trim to what you actually need:
|
||||
|
||||
```bash
|
||||
# Production with defaults from env.production, paths overridden
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60000
|
||||
|
||||
# Or set paths explicitly
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60000 \
|
||||
INIT_PROJECT_NAME_REACT=react-vite-template \
|
||||
INIT_PROJECT_NAME_VUE3=vue3-vite-template \
|
||||
INIT_PROJECT_DIR=/app/project_init \
|
||||
UPLOAD_PROJECT_DIR=/app/project_zips \
|
||||
PROJECT_SOURCE_DIR=/app/project_workspace \
|
||||
DIST_TARGET_DIR=/app/project_nginx \
|
||||
LOG_BASE_DIR=/app/logs/project_logs \
|
||||
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace \
|
||||
COMPUTER_LOG_DIR=/app/logs/computer_logs
|
||||
```
|
||||
|
||||
#### Path variables as documented for `env.production`
|
||||
|
||||
| Variable | Example default | Description |
|
||||
| -------- | --------------- | ----------- |
|
||||
| `INIT_PROJECT_NAME_REACT` | `react-vite-template` | Built-in scaffold name for React projects |
|
||||
| `INIT_PROJECT_NAME_VUE3` | `vue3-vite-template` | Built-in scaffold name for Vue3 projects |
|
||||
| `INIT_PROJECT_DIR` | `/app/project_init` | Init project directory |
|
||||
| `UPLOAD_PROJECT_DIR` | `/app/project_zips` | Uploaded zips |
|
||||
| `PROJECT_SOURCE_DIR` | `/app/project_workspace` | Project workspace |
|
||||
| `DIST_TARGET_DIR` | `/app/project_nginx` | Build output (nginx root) |
|
||||
| `LOG_BASE_DIR` | `/app/logs/project_logs` | Log root |
|
||||
| `COMPUTER_WORKSPACE_DIR` | `/app/computer-project-workspace` | Computer workspace |
|
||||
| `COMPUTER_LOG_DIR` | `/app/logs/computer_logs` | Computer logs |
|
||||
|
||||
#### Path-only snippets
|
||||
|
||||
```bash
|
||||
# 1) Only change project sources
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60000 \
|
||||
PROJECT_SOURCE_DIR=/data/my-projects
|
||||
|
||||
# 2) Several paths
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60000 \
|
||||
PROJECT_SOURCE_DIR=/data/projects \
|
||||
DIST_TARGET_DIR=/var/www/html \
|
||||
UPLOAD_PROJECT_DIR=/data/uploads
|
||||
|
||||
# 3) Full path set
|
||||
qiming-file-server start \
|
||||
--env production \
|
||||
--port 60000 \
|
||||
INIT_PROJECT_NAME_REACT=my-react-template \
|
||||
INIT_PROJECT_NAME_VUE3=my-vue3-template \
|
||||
INIT_PROJECT_DIR=/data/init \
|
||||
UPLOAD_PROJECT_DIR=/data/zips \
|
||||
PROJECT_SOURCE_DIR=/data/workspace \
|
||||
DIST_TARGET_DIR=/var/www/nginx \
|
||||
LOG_BASE_DIR=/var/logs/project_logs \
|
||||
COMPUTER_WORKSPACE_DIR=/data/computer \
|
||||
COMPUTER_LOG_DIR=/var/logs/computer
|
||||
```
|
||||
|
||||
> `--port` selects the HTTP port; unset path keys keep their values from `env.production`.
|
||||
|
||||
#### Verify overrides
|
||||
|
||||
```bash
|
||||
qiming-file-server status
|
||||
curl http://localhost:60000/health | jq
|
||||
|
||||
# Check startup logs for loaded env (messages are in English, e.g. "Loaded environment files: ...")
|
||||
grep -E "Loaded environment|Environment configuration file" /var/log/qiming/server.log
|
||||
```
|
||||
|
||||
### CLI overrides precedence
|
||||
|
||||
CLI wins over env files and plain env:
|
||||
|
||||
```bash
|
||||
qiming-file-server start --port 8080
|
||||
|
||||
qiming-file-server start --env production
|
||||
|
||||
qiming-file-server start --env production --port 8080
|
||||
```
|
||||
|
||||
### Shell environment
|
||||
|
||||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
export PORT=8080
|
||||
export NODE_ENV=production
|
||||
export LOG_LEVEL=info
|
||||
|
||||
# Persist in ~/.bashrc or ~/.zshrc
|
||||
echo 'export PORT=8080' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```cmd
|
||||
set PORT=8080
|
||||
set NODE_ENV=production
|
||||
|
||||
setx PORT 8080
|
||||
setx NODE_ENV production
|
||||
```
|
||||
|
||||
### Docker / Compose
|
||||
|
||||
#### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
qiming-file-server:
|
||||
image: qiming-file-server:latest
|
||||
container_name: qiming-file-server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=60000
|
||||
|
||||
- LOG_BASE_DIR=/app/logs
|
||||
- LOG_LEVEL=info
|
||||
- LOG_CONSOLE_ENABLED=false
|
||||
|
||||
- PROJECT_SOURCE_DIR=/app/projects
|
||||
- DIST_TARGET_DIR=/app/nginx
|
||||
- UPLOAD_PROJECT_DIR=/app/uploads
|
||||
- INIT_PROJECT_DIR=/app/init
|
||||
- COMPUTER_WORKSPACE_DIR=/app/computer
|
||||
- COMPUTER_LOG_DIR=/app/computer-logs
|
||||
|
||||
- MAX_BUILD_CONCURRENCY=20
|
||||
|
||||
- PNPM_PRUNE_ENABLED=true
|
||||
- PNPM_PRUNE_SCHEDULE=0 3 * * *
|
||||
- PNPM_PRUNE_TIMEZONE=Asia/Shanghai
|
||||
|
||||
- LOG_CACHE_ENABLED=true
|
||||
- LOG_CACHE_DURATION=180000
|
||||
- LOG_CACHE_MAX_ENTRIES=100
|
||||
- LOG_CACHE_MAX_FILE_SIZE=2097152
|
||||
|
||||
volumes:
|
||||
- ./projects:/app/projects
|
||||
- ./logs:/app/logs
|
||||
- ./nginx:/app/nginx
|
||||
- ./uploads:/app/uploads
|
||||
- ./init:/app/init
|
||||
- ./computer:/app/computer
|
||||
|
||||
ports:
|
||||
- "60000:60000"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:60000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
```
|
||||
|
||||
#### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install -g pnpm && pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/logs /app/projects /app/nginx /app/uploads /app/init /app/computer
|
||||
|
||||
EXPOSE 60000
|
||||
|
||||
CMD ["node", "scripts/start-cli.js"]
|
||||
```
|
||||
|
||||
### Sample env files
|
||||
|
||||
#### .env.production
|
||||
|
||||
```bash
|
||||
# ==================== Server ====================
|
||||
NODE_ENV=production
|
||||
PORT=60000
|
||||
REQUEST_BODY_LIMIT=2000mb
|
||||
|
||||
# ==================== Logging ====================
|
||||
LOG_BASE_DIR=/app/logs
|
||||
LOG_LEVEL=info
|
||||
LOG_CONSOLE_ENABLED=false
|
||||
LOG_PREFIX_API=api
|
||||
LOG_PREFIX_BUILD=build
|
||||
|
||||
# ==================== Paths ====================
|
||||
INIT_PROJECT_NAME_REACT=react-vite-template
|
||||
INIT_PROJECT_NAME_VUE3=vue3-vite-template
|
||||
INIT_PROJECT_DIR=/app/project_init
|
||||
UPLOAD_PROJECT_DIR=/app/project_zips
|
||||
PROJECT_SOURCE_DIR=/app/project_workspace
|
||||
DIST_TARGET_DIR=/app/project_nginx
|
||||
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace
|
||||
COMPUTER_LOG_DIR=/app/logs/computer_logs
|
||||
|
||||
# ==================== Build ====================
|
||||
MAX_BUILD_CONCURRENCY=20
|
||||
DEV_SERVER_PORT_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_CHECK_INTERVAL=100
|
||||
DEV_SERVER_STOP_MAX_ATTEMPTS=50
|
||||
|
||||
# ==================== Uploads ====================
|
||||
MAX_INLINE_FILE_SIZE_BYTES=1048576
|
||||
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
|
||||
UPLOAD_ALLOWED_EXTENSIONS=.zip
|
||||
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
|
||||
|
||||
# ==================== Traversal / filters ====================
|
||||
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
|
||||
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
CONTENT_TRAVERSE_EXCLUDE_FILES=AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
|
||||
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
|
||||
|
||||
# ==================== pnpm prune ====================
|
||||
PNPM_PRUNE_ENABLED=true
|
||||
PNPM_PRUNE_SCHEDULE=0 2 * * *
|
||||
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
|
||||
PNPM_PRUNE_RUN_ON_START=false
|
||||
|
||||
# ==================== Log cache ====================
|
||||
LOG_CACHE_ENABLED=true
|
||||
LOG_CACHE_DURATION=180000
|
||||
LOG_CACHE_MAX_ENTRIES=100
|
||||
LOG_CACHE_MAX_FILE_SIZE=2097152
|
||||
|
||||
# ==================== CLI ====================
|
||||
CLI_PID_DIR=/tmp/qiming-file-server
|
||||
CLI_STOP_TIMEOUT=30000
|
||||
CLI_CHECK_INTERVAL=500
|
||||
CLI_LOG_DIR=/tmp/qiming-file-server/logs
|
||||
```
|
||||
|
||||
#### .env.development
|
||||
|
||||
```bash
|
||||
# ==================== Server ====================
|
||||
NODE_ENV=development
|
||||
PORT=60000
|
||||
REQUEST_BODY_LIMIT=2000mb
|
||||
|
||||
# ==================== Logging ====================
|
||||
LOG_BASE_DIR=./logs
|
||||
LOG_LEVEL=debug
|
||||
LOG_CONSOLE_ENABLED=true
|
||||
LOG_PREFIX_API=api
|
||||
LOG_PREFIX_BUILD=build
|
||||
|
||||
# ==================== Paths ====================
|
||||
INIT_PROJECT_NAME_REACT=react-vite-template
|
||||
INIT_PROJECT_NAME_VUE3=vue3-vite-template
|
||||
INIT_PROJECT_DIR=./project_init
|
||||
UPLOAD_PROJECT_DIR=./project_zips
|
||||
PROJECT_SOURCE_DIR=./project_workspace
|
||||
DIST_TARGET_DIR=./project_nginx
|
||||
COMPUTER_WORKSPACE_DIR=./computer-project-workspace
|
||||
COMPUTER_LOG_DIR=./computer_logs
|
||||
|
||||
# ==================== Build ====================
|
||||
MAX_BUILD_CONCURRENCY=20
|
||||
DEV_SERVER_PORT_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_CHECK_INTERVAL=100
|
||||
DEV_SERVER_STOP_MAX_ATTEMPTS=50
|
||||
|
||||
# ==================== Uploads ====================
|
||||
MAX_INLINE_FILE_SIZE_BYTES=1048576
|
||||
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
|
||||
UPLOAD_ALLOWED_EXTENSIONS=.zip
|
||||
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
|
||||
|
||||
# ==================== Traversal / filters ====================
|
||||
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
|
||||
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
CONTENT_TRAVERSE_EXCLUDE_FILES=AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
|
||||
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
|
||||
|
||||
# ==================== pnpm prune ====================
|
||||
PNPM_PRUNE_ENABLED=false
|
||||
PNPM_PRUNE_SCHEDULE=0 2 * * *
|
||||
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
|
||||
PNPM_PRUNE_RUN_ON_START=false
|
||||
|
||||
# ==================== Log cache ====================
|
||||
LOG_CACHE_ENABLED=true
|
||||
LOG_CACHE_DURATION=180000
|
||||
LOG_CACHE_MAX_ENTRIES=100
|
||||
LOG_CACHE_MAX_FILE_SIZE=2097152
|
||||
|
||||
# ==================== CLI ====================
|
||||
CLI_PID_DIR=./tmp
|
||||
CLI_STOP_TIMEOUT=30000
|
||||
CLI_CHECK_INTERVAL=500
|
||||
CLI_LOG_DIR=./tmp/logs
|
||||
```
|
||||
|
||||
### Kubernetes example
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: qiming-file-server-config
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
PORT: "60000"
|
||||
LOG_BASE_DIR: "/app/logs"
|
||||
LOG_LEVEL: "info"
|
||||
LOG_CONSOLE_ENABLED: "false"
|
||||
PROJECT_SOURCE_DIR: "/app/projects"
|
||||
DIST_TARGET_DIR: "/app/nginx"
|
||||
UPLOAD_PROJECT_DIR: "/app/uploads"
|
||||
INIT_PROJECT_DIR: "/app/init"
|
||||
COMPUTER_WORKSPACE_DIR: "/app/computer"
|
||||
COMPUTER_LOG_DIR: "/app/computer-logs"
|
||||
MAX_BUILD_CONCURRENCY: "20"
|
||||
PNPM_PRUNE_ENABLED: "true"
|
||||
PNPM_PRUNE_SCHEDULE: "0 3 * * *"
|
||||
PNPM_PRUNE_TIMEZONE: "Asia/Shanghai"
|
||||
LOG_CACHE_ENABLED: "true"
|
||||
LOG_CACHE_DURATION: "180000"
|
||||
LOG_CACHE_MAX_ENTRIES: "100"
|
||||
LOG_CACHE_MAX_FILE_SIZE: "2097152"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: qiming-file-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: qiming-file-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: qiming-file-server
|
||||
spec:
|
||||
containers:
|
||||
- name: qiming-file-server
|
||||
image: qiming-file-server:latest
|
||||
ports:
|
||||
- containerPort: 60000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: qiming-file-server-config
|
||||
volumeMounts:
|
||||
- name: projects
|
||||
mountPath: /app/projects
|
||||
- name: logs
|
||||
mountPath: /app/logs
|
||||
- name: nginx
|
||||
mountPath: /app/nginx
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
- name: init
|
||||
mountPath: /app/init
|
||||
- name: computer
|
||||
mountPath: /app/computer
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 60000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 60000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: projects
|
||||
hostPath:
|
||||
path: /data/projects
|
||||
- name: logs
|
||||
hostPath:
|
||||
path: /data/logs
|
||||
- name: nginx
|
||||
hostPath:
|
||||
path: /data/nginx
|
||||
- name: uploads
|
||||
hostPath:
|
||||
path: /data/uploads
|
||||
- name: init
|
||||
hostPath:
|
||||
path: /data/init
|
||||
- name: computer
|
||||
hostPath:
|
||||
path: /data/computer
|
||||
```
|
||||
|
||||
## Verifying configuration
|
||||
|
||||
```bash
|
||||
curl http://localhost:60000/health
|
||||
```
|
||||
|
||||
The JSON body includes the active environment name (e.g. `"env": "production"`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Changes not applied
|
||||
|
||||
1. Confirm the correct env file path and `NODE_ENV`.
|
||||
2. Variable names are case-sensitive.
|
||||
3. Validate YAML/JSON if you embed config in manifests.
|
||||
4. Read server logs for load errors.
|
||||
|
||||
### Port already in use
|
||||
|
||||
If `PORT` is taken, startup fails. Pick another port or free the listener.
|
||||
|
||||
### Permissions
|
||||
|
||||
Ensure log and data directories are writable:
|
||||
|
||||
```bash
|
||||
chmod -R 755 /path/to/logs
|
||||
mkdir -p /path/to/logs /path/to/projects
|
||||
```
|
||||
|
||||
### Docker env not visible
|
||||
|
||||
Pass variables with `environment:` or `envFrom:`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=60000
|
||||
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: my-config
|
||||
```
|
||||
|
||||
## Related docs
|
||||
|
||||
- [README.md](../README.md) — Overview
|
||||
- [PNPM_CHECK.md](./PNPM_CHECK.md) — pnpm disk usage helper
|
||||
106
qiming-file-server/docs/PNPM_CHECK.md
Normal file
106
qiming-file-server/docs/PNPM_CHECK.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# pnpm-check usage
|
||||
|
||||
## Overview
|
||||
|
||||
The `pnpm-check` script inspects and analyzes pnpm disk usage. It supports multiple environment configurations.
|
||||
|
||||
## How to run
|
||||
|
||||
### Option 1: npm scripts (recommended)
|
||||
|
||||
The script reads the project directory from the current `NODE_ENV` configuration.
|
||||
|
||||
```bash
|
||||
# Auto-detect current environment (defaults to development)
|
||||
npm run pnpm:check
|
||||
|
||||
# Development
|
||||
npm run pnpm:check:dev
|
||||
|
||||
# Production
|
||||
npm run pnpm:check:prod
|
||||
|
||||
# Test
|
||||
npm run pnpm:check:test
|
||||
```
|
||||
|
||||
### Option 2: Wrapper script
|
||||
|
||||
```bash
|
||||
# Use current environment variables
|
||||
node scripts/pnpm-check-wrapper.js
|
||||
|
||||
# Pin environment
|
||||
NODE_ENV=production node scripts/pnpm-check-wrapper.js
|
||||
```
|
||||
|
||||
### Option 3: Bash script directly
|
||||
|
||||
```bash
|
||||
# Pass project directory explicitly
|
||||
bash scripts/pnpm-check.sh /path/to/projects
|
||||
|
||||
# Via environment variable
|
||||
PROJECT_SOURCE_DIR=/path/to/projects bash scripts/pnpm-check.sh
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
### 1. pnpm version
|
||||
|
||||
```
|
||||
✅ pnpm version: 10.18.2
|
||||
```
|
||||
|
||||
Shows the installed pnpm version.
|
||||
|
||||
### 2. pnpm store
|
||||
|
||||
```
|
||||
📁 pnpm Store path:
|
||||
/Users/xxx/.pnpm-store
|
||||
Store size: 2.5G
|
||||
```
|
||||
|
||||
- **Store path**: Global content-addressable store location
|
||||
- **Store size**: **Actual disk usage**; all projects share this store
|
||||
|
||||
### 3. Store status
|
||||
|
||||
```
|
||||
📊 pnpm Store status:
|
||||
Packages in the store are untouched
|
||||
```
|
||||
|
||||
Shows how packages in the store are used (`pnpm store status`).
|
||||
|
||||
### 4. Per-project `node_modules` size
|
||||
|
||||
```
|
||||
📦 Per-project node_modules (apparent size):
|
||||
⚠️ Note: du double-counts hard links; real usage is much lower
|
||||
[project-a] 500MB (includes hard-link double counting)
|
||||
[project-b] 500MB (includes hard-link double counting)
|
||||
```
|
||||
|
||||
⚠️ **Important**: Figures come from `du` and **double-count hard links**; real usage is usually much lower.
|
||||
|
||||
### 5. `.pnpm` folder size
|
||||
|
||||
```
|
||||
🗂️ Per-project .pnpm folders (apparent size):
|
||||
⚠️ Note: entries in .pnpm are hard links; little extra space per project
|
||||
[project-a] 400MB (hard links, shared in store)
|
||||
[project-b] 400MB (hard links, shared in store)
|
||||
```
|
||||
|
||||
⚠️ **Important**: Files under `.pnpm` are hard links; they do not each consume separate space on disk.
|
||||
|
||||
### 6. Real disk usage
|
||||
|
||||
```
|
||||
💾 Filesystem usage (df):
|
||||
Filesystem: /dev/disk1 | Used: 150GB | Avail: 200GB | Use%: 43%
|
||||
```
|
||||
|
||||
Using `df` on the filesystem is the most reliable way to see actual free space.
|
||||
318
qiming-file-server/docs/QUICK_START.md
Normal file
318
qiming-file-server/docs/QUICK_START.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# Quick start
|
||||
|
||||
This guide helps you get started with the qiming-file-server CLI.
|
||||
|
||||
## 1. Local testing
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start the server (development)
|
||||
pnpm run cli:start:dev
|
||||
|
||||
# Check status
|
||||
pnpm run cli:status
|
||||
|
||||
# Health check
|
||||
curl http://localhost:60000/health
|
||||
|
||||
# Stop the server
|
||||
pnpm run cli:stop
|
||||
```
|
||||
|
||||
## 2. Global install
|
||||
|
||||
```bash
|
||||
# Global install
|
||||
npm install -g .
|
||||
|
||||
# or
|
||||
pnpm add -g .
|
||||
|
||||
# Then run from any directory
|
||||
qiming-file-server start --env development
|
||||
qiming-file-server status
|
||||
qiming-file-server stop
|
||||
```
|
||||
|
||||
## 3. Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `qiming-file-server start` | Start (default: production) |
|
||||
| `qiming-file-server start --env dev` | Start (development) |
|
||||
| `qiming-file-server stop` | Stop |
|
||||
| `qiming-file-server restart` | Restart |
|
||||
| `qiming-file-server status` | Status |
|
||||
| `qiming-file-server --help` | Help |
|
||||
|
||||
## 4. Health check
|
||||
|
||||
```bash
|
||||
curl http://localhost:60000/health
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": 1738600000000,
|
||||
"uptime": 3600,
|
||||
"version": "1.0.0",
|
||||
"platform": "darwin",
|
||||
"nodeVersion": "v22.0.0",
|
||||
"pid": 12345,
|
||||
"memory": {
|
||||
"heapUsed": 25.5,
|
||||
"heapTotal": 50.0,
|
||||
"rss": 100.0,
|
||||
"external": 5.0
|
||||
},
|
||||
"env": "production"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Environment variables
|
||||
|
||||
### Defaults
|
||||
|
||||
```bash
|
||||
# Use defaults from env.production
|
||||
qiming-file-server start --env production --port 60000
|
||||
```
|
||||
|
||||
### Override paths
|
||||
|
||||
```bash
|
||||
# Override path-related variables as needed
|
||||
qiming-file-server start --env production --port 60000 \
|
||||
PROJECT_SOURCE_DIR=/data/projects \
|
||||
DIST_TARGET_DIR=/var/www/html \
|
||||
UPLOAD_PROJECT_DIR=/data/uploads
|
||||
```
|
||||
|
||||
See the full reference: [Environment variables](./ENV.md)
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### Server will not start
|
||||
|
||||
Check if the port is in use:
|
||||
|
||||
```bash
|
||||
lsof -i :60000
|
||||
|
||||
# or
|
||||
netstat -tlnp | grep 60000
|
||||
```
|
||||
|
||||
### Stop fails
|
||||
|
||||
Force stop:
|
||||
|
||||
```bash
|
||||
qiming-file-server stop --force
|
||||
```
|
||||
|
||||
### Logs / sanity check
|
||||
|
||||
```bash
|
||||
qiming-file-server status
|
||||
curl http://localhost:60000/health
|
||||
```
|
||||
|
||||
## 7. Docker
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
qiming-file-server:
|
||||
image: qiming-file-server:latest
|
||||
container_name: qiming-file-server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=60000
|
||||
- PROJECT_SOURCE_DIR=/app/projects
|
||||
- DIST_TARGET_DIR=/var/www/html
|
||||
- UPLOAD_PROJECT_DIR=/app/uploads
|
||||
- LOG_BASE_DIR=/app/logs
|
||||
volumes:
|
||||
- ./projects:/app/projects
|
||||
- ./logs:/app/logs
|
||||
ports:
|
||||
- "60000:60000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:60000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
## 8. Tests
|
||||
|
||||
The repo includes several ways to exercise the CLI.
|
||||
|
||||
### 8.1 Run all tests
|
||||
|
||||
```bash
|
||||
pnpm run test:run
|
||||
|
||||
pnpm run test:unit
|
||||
|
||||
pnpm run test:integration
|
||||
```
|
||||
|
||||
### 8.2 CLI unit tests
|
||||
|
||||
```bash
|
||||
npx jest tests/unit/cli.test.js
|
||||
|
||||
pnpm run test:unit -- --testPathPattern=cli.test.js
|
||||
```
|
||||
|
||||
**Coverage areas:**
|
||||
|
||||
| Group | Topics |
|
||||
| ----- | ------ |
|
||||
| Service Manager | PID file, process state, service status |
|
||||
| Environment Utils | Env resolution, typing, CLI parsing |
|
||||
| Cross-Platform | Platform detection, paths |
|
||||
| Config | CLI-specific config validation |
|
||||
|
||||
### 8.3 Manual checks
|
||||
|
||||
#### Development
|
||||
|
||||
```bash
|
||||
pnpm run cli:start:dev
|
||||
pnpm run cli:status
|
||||
curl http://localhost:60000/health
|
||||
pnpm run cli:stop
|
||||
```
|
||||
|
||||
#### Production
|
||||
|
||||
```bash
|
||||
pnpm run cli:start:prod
|
||||
pnpm run cli:status
|
||||
curl http://localhost:60000/health
|
||||
pnpm run cli:restart
|
||||
pnpm run cli:stop
|
||||
```
|
||||
|
||||
#### Custom config
|
||||
|
||||
```bash
|
||||
qiming-file-server start --env development --port 60001
|
||||
|
||||
qiming-file-server start --env development --port 60002 \
|
||||
PROJECT_SOURCE_DIR=/data/test-projects \
|
||||
DIST_TARGET_DIR=/var/www/test-nginx
|
||||
|
||||
qiming-file-server status
|
||||
qiming-file-server stop
|
||||
```
|
||||
|
||||
### 8.4 Health endpoint
|
||||
|
||||
```bash
|
||||
curl http://localhost:60000/health
|
||||
|
||||
curl http://localhost:60000/health | jq
|
||||
|
||||
curl http://localhost:60000/health | jq '.status'
|
||||
curl http://localhost:60000/health | jq '.uptime'
|
||||
curl http://localhost:60000/health | jq '.memory'
|
||||
```
|
||||
|
||||
### 8.5 Cross-platform
|
||||
|
||||
#### macOS / Linux
|
||||
|
||||
```bash
|
||||
qiming-file-server start --env production
|
||||
ps aux | grep qiming-file-server
|
||||
qiming-file-server stop
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```cmd
|
||||
qiming-file-server start --env production
|
||||
tasklist | findstr qiming-file-server
|
||||
qiming-file-server stop --force
|
||||
```
|
||||
|
||||
### 8.6 Automation script (`test-cli.sh`)
|
||||
|
||||
Create `test-cli.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# qiming-file-server CLI smoke test
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== qiming-file-server CLI test ==="
|
||||
|
||||
PORT=60000
|
||||
HEALTH_URL="http://localhost:${PORT}/health"
|
||||
|
||||
test_command() {
|
||||
local cmd=$1
|
||||
local description=$2
|
||||
echo "Test: ${description}"
|
||||
echo "Command: ${cmd}"
|
||||
eval "${cmd}"
|
||||
echo "✓ done"
|
||||
echo ""
|
||||
}
|
||||
|
||||
echo "Cleaning up..."
|
||||
qiming-file-server stop 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
test_command "qiming-file-server start --env production --port ${PORT}" "start server"
|
||||
|
||||
sleep 3
|
||||
|
||||
echo "Test: health check"
|
||||
curl -s "${HEALTH_URL}" | jq '.status'
|
||||
echo "✓ health OK"
|
||||
echo ""
|
||||
|
||||
test_command "qiming-file-server status" "status"
|
||||
|
||||
test_command "qiming-file-server restart" "restart"
|
||||
sleep 3
|
||||
|
||||
echo "Test: health after restart"
|
||||
curl -s "${HEALTH_URL}" | jq '.status'
|
||||
echo "✓ health OK after restart"
|
||||
echo ""
|
||||
|
||||
test_command "qiming-file-server stop" "stop"
|
||||
|
||||
echo "=== All tests passed ==="
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
chmod +x test-cli.sh
|
||||
./test-cli.sh
|
||||
```
|
||||
|
||||
## 9. Related docs
|
||||
|
||||
- [README.md](../README.md) — Main readme
|
||||
- [ENV.md](./ENV.md) — Environment variables
|
||||
- [CHANGELOG.md](../CHANGELOG.md) — Changelog
|
||||
165
qiming-file-server/docs/TEST_CLI_USAGE.md
Normal file
165
qiming-file-server/docs/TEST_CLI_USAGE.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# test-cli.sh usage
|
||||
|
||||
`test-cli.sh` is the CLI automation test script for qiming-file-server. It verifies locally that `qiming-file-server` can start, report status, answer health checks, restart, and stop.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Grant execute permission (first time only)
|
||||
chmod +x scripts/test-cli.sh
|
||||
|
||||
# Default: pnpm install + pnpm link, then test
|
||||
./scripts/test-cli.sh
|
||||
|
||||
# Run dist directly (no global link)
|
||||
./scripts/test-cli.sh --direct
|
||||
|
||||
# Already installed globally; skip install steps
|
||||
./scripts/test-cli.sh --installed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Notes |
|
||||
|------------|--------|
|
||||
| bash | Script runtime |
|
||||
| curl | Health endpoint requests |
|
||||
| jq | JSON parsing |
|
||||
| pnpm | Required for modes 1 and 2 (optional for mode 3) |
|
||||
| node | v22+ recommended |
|
||||
|
||||
Install example (macOS):
|
||||
|
||||
```bash
|
||||
brew install curl jq
|
||||
# Install pnpm / node per project requirements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Three test modes
|
||||
|
||||
### Mode 1: Default (pnpm link)
|
||||
|
||||
- **Command**: `./scripts/test-cli.sh` (no extra flags)
|
||||
- **Behavior**:
|
||||
- Runs `pnpm install` in the project root
|
||||
- Runs `pnpm run build` to compile into `dist/`
|
||||
- Runs `pnpm link --global` so `qiming-file-server` is available globally
|
||||
- Tests using the global `qiming-file-server` command
|
||||
- **Use when**: Local development, first-time verification, or when not installed globally
|
||||
|
||||
### Mode 2: Direct run (`--direct`)
|
||||
|
||||
- **Command**: `./scripts/test-cli.sh --direct`
|
||||
- **Behavior**:
|
||||
- Does not run `pnpm link`; runs `node dist/cli.js` directly
|
||||
- Requires a built project: `dist/` exists and `dist/cli.js` exists
|
||||
- **Prerequisite**: Run `pnpm run build` first
|
||||
- **Use when**: You do not want to change global commands and only want to test the current build
|
||||
|
||||
### Mode 3: Installed (`--installed`)
|
||||
|
||||
- **Command**: `./scripts/test-cli.sh --installed`
|
||||
- **Behavior**:
|
||||
- No install, no build, no link; calls the installed `qiming-file-server` directly
|
||||
- Fails with a hint if `qiming-file-server` is not found
|
||||
- **Prerequisite**: Installed via `npm install -g qiming-file-server` or `pnpm link --global`
|
||||
- **Use when**: Verifying a global install, CI, or an environment where the CLI is already installed
|
||||
|
||||
---
|
||||
|
||||
## Command-line options
|
||||
|
||||
| Option | Short | Description | Default |
|
||||
|--------|-------|-------------|---------|
|
||||
| `--port <port>` | `-p` | Port for the test server | `60000` |
|
||||
| `--direct` | — | Mode 2: run dist with Node directly | — |
|
||||
| `--installed` | — | Mode 3: use globally installed CLI | — |
|
||||
| `--project-dir <dir>` | — | Project directory | `./test-projects` |
|
||||
| `--nginx-dir <dir>` | — | Nginx directory | `./test-nginx` |
|
||||
| `--upload-dir <dir>` | — | Upload directory | `./test-uploads` |
|
||||
| `--help` | `-h` | Show help | — |
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
These override defaults (equivalent to CLI options where applicable):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | `60000` |
|
||||
| `PROJECT_DIR` | Project directory | `./test-projects` |
|
||||
| `NGINX_DIR` | Nginx directory | `./test-nginx` |
|
||||
| `UPLOAD_DIR` | Upload directory | `./test-uploads` |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
PORT=60001 ./scripts/test-cli.sh --installed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test flow
|
||||
|
||||
The script runs in order:
|
||||
|
||||
1. **Test 1: Start** — `start --env production --port <PORT>`, wait ~3 seconds.
|
||||
2. **Test 2: Health** — `GET http://localhost:<PORT>/health`, assert `status === "ok"` in JSON.
|
||||
3. **Test 3: Status** — `status`, check output looks normal.
|
||||
4. **Test 4: Restart** — `restart`, wait ~3 seconds.
|
||||
5. **Test 5: Health after restart** — `GET /health` again.
|
||||
6. **Test 6: Stop** — `stop`.
|
||||
|
||||
On exit or error, cleanup runs (stop service; mode 1 also runs `pnpm unlink --global`).
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Default mode, default port 60000
|
||||
./scripts/test-cli.sh
|
||||
|
||||
# Mode 2, custom port 60001
|
||||
./scripts/test-cli.sh --direct --port 60001
|
||||
|
||||
# Mode 3, custom port
|
||||
./scripts/test-cli.sh --installed -p 60001
|
||||
|
||||
# Custom project/nginx/upload dirs
|
||||
./scripts/test-cli.sh --project-dir ./my-projects --nginx-dir ./my-nginx --upload-dir ./my-uploads
|
||||
|
||||
# Help
|
||||
./scripts/test-cli.sh --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: `dist` missing or `dist/cli.js` missing?**
|
||||
A: Run `pnpm run build` before `--direct`, or use the default mode (builds automatically).
|
||||
|
||||
**Q: `qiming-file-server` command not found?**
|
||||
A: For `--installed`, install globally first, e.g. `npm install -g qiming-file-server` or `pnpm link --global` in this repo. Or use default mode or `--direct`.
|
||||
|
||||
**Q: Port in use?**
|
||||
A: Use `-p`/`--port` or `PORT`, e.g. `./scripts/test-cli.sh -p 60001`.
|
||||
|
||||
**Q: Missing curl / jq?**
|
||||
A: The script checks dependencies and reports. On macOS: `brew install curl jq`.
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [QUICK_START.md](./QUICK_START.md) — Quick start
|
||||
- [ENV.md](./ENV.md) — Environment and configuration
|
||||
- [CLAUDE.md](../CLAUDE.md) / [AGENTS.md](../AGENTS.md) — Project layout and conventions
|
||||
41
qiming-file-server/jest.config.js
Normal file
41
qiming-file-server/jest.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
export default {
|
||||
testEnvironment: "node",
|
||||
|
||||
// 测试文件匹配模式
|
||||
testMatch: ["**/tests/**/*.test.js", "**/__tests__/**/*.test.js"],
|
||||
|
||||
// 覆盖率收集
|
||||
collectCoverageFrom: [
|
||||
"src/**/*.js",
|
||||
"!src/server.js",
|
||||
"!src/config/**",
|
||||
"!**/node_modules/**",
|
||||
],
|
||||
|
||||
// 覆盖率阈值
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 50,
|
||||
functions: 50,
|
||||
lines: 50,
|
||||
statements: 50,
|
||||
},
|
||||
},
|
||||
|
||||
// 覆盖率报告格式
|
||||
coverageReporters: ["text", "lcov", "html"],
|
||||
|
||||
// 测试超时时间(毫秒)
|
||||
testTimeout: 10000,
|
||||
|
||||
// 显示详细信息
|
||||
verbose: true,
|
||||
|
||||
// 清除模拟
|
||||
clearMocks: true,
|
||||
resetMocks: true,
|
||||
restoreMocks: true,
|
||||
|
||||
// 设置测试环境变量
|
||||
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"],
|
||||
};
|
||||
7695
qiming-file-server/package-lock.json
generated
Normal file
7695
qiming-file-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
93
qiming-file-server/package.json
Normal file
93
qiming-file-server/package.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "qiming-file-server",
|
||||
"displayName": "qiming-file-server",
|
||||
"version": "1.2.9",
|
||||
"description": "Cross-platform file service deployment tool with start/stop/restart CLI commands",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"bin": {
|
||||
"qiming-file-server": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "nodemon src/server.js",
|
||||
"dev": "nodemon scripts/start-dev.js",
|
||||
"test": "nodemon scripts/start-test.js",
|
||||
"prod": "node scripts/start-prod.js",
|
||||
"test:run": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
|
||||
"test:watch": "jest --watch",
|
||||
"test:unit": "jest --testPathPattern=tests/unit",
|
||||
"test:integration": "jest --testPathPattern=tests/integration",
|
||||
"start:test": "nodemon scripts/start-test.js",
|
||||
"pnpm:check:dev": "NODE_ENV=development node scripts/pnpm-check-wrapper.js",
|
||||
"pnpm:check:prod": "NODE_ENV=production node scripts/pnpm-check-wrapper.js",
|
||||
"pnpm:check:test": "NODE_ENV=test node scripts/pnpm-check-wrapper.js",
|
||||
"pnpm:prune": "pnpm store prune",
|
||||
"pnpm:prune:manual": "node scripts/pnpm-prune-manual.js",
|
||||
"pnpm:status": "pnpm store status",
|
||||
"cli:start": "node src/cli.js start",
|
||||
"cli:stop": "node src/cli.js stop",
|
||||
"cli:restart": "node src/cli.js restart",
|
||||
"cli:status": "node src/cli.js status",
|
||||
"cli:start:dev": "node src/cli.js start --env development",
|
||||
"cli:start:prod": "node src/cli.js start --env production",
|
||||
"cli:start:test": "node src/cli.js start --env test",
|
||||
"build": "node scripts/build.js",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"keywords": [
|
||||
"file-server",
|
||||
"deployer",
|
||||
"cli",
|
||||
"cross-platform",
|
||||
"qiming"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": ""
|
||||
},
|
||||
"bugs": {
|
||||
"url": ""
|
||||
},
|
||||
"homepage": "",
|
||||
"dependencies": {
|
||||
"archiver": "^5.3.2",
|
||||
"commander": "^12.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"dotenv": "^17.2.2",
|
||||
"express": "^5.1.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"isbinaryfile": "^5.0.6",
|
||||
"json-bigint": "^1.0.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"pm2": "^6.0.11",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
"uuid": "^13.0.0",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"esbuild": "^0.24.2",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.4"
|
||||
}
|
||||
}
|
||||
5025
qiming-file-server/pnpm-lock.yaml
generated
Normal file
5025
qiming-file-server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
217
qiming-file-server/scripts/build.js
Normal file
217
qiming-file-server/scripts/build.js
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* qiming-file-server 发 npm 前编译脚本(ESM)
|
||||
*
|
||||
* 使用方法:
|
||||
* - 发布(默认): node scripts/build.js
|
||||
* - 本地调试: node scripts/build.js --all
|
||||
*
|
||||
* env 文件位于 src/,构建时复制 env.development、env.production 到 dist/
|
||||
* 使用 --all 参数会额外复制其余 src/env.*(如 env.test)
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const distRoot = path.join(projectRoot, "dist");
|
||||
|
||||
const pkgPath = path.join(projectRoot, "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
const version = pkg.version;
|
||||
|
||||
// 获取命令行参数
|
||||
const args = process.argv.slice(2);
|
||||
const debugMode = args.includes("--all");
|
||||
|
||||
/**
|
||||
* 递归查找目录下所有 .js 文件
|
||||
*/
|
||||
function findJsFiles(dir, files = []) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const fullPath = path.join(dir, e.name);
|
||||
if (e.isDirectory()) findJsFiles(fullPath, files);
|
||||
else if (e.name.endsWith(".js")) files.push(fullPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 esbuild 压缩 JS 文件
|
||||
* @param {string} dir 目标目录
|
||||
* @param {object} esbuild esbuild 实例
|
||||
*/
|
||||
async function compressJsFiles(esbuild, dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
|
||||
const jsFiles = findJsFiles(dir);
|
||||
let totalSaved = 0;
|
||||
|
||||
for (const file of jsFiles) {
|
||||
const code = fs.readFileSync(file, "utf8");
|
||||
const result = await esbuild.transform(code, {
|
||||
minify: true,
|
||||
target: "node22",
|
||||
});
|
||||
fs.writeFileSync(file, result.code, "utf8");
|
||||
const originalSize = Buffer.byteLength(code, "utf8");
|
||||
const compressedSize = Buffer.byteLength(result.code, "utf8");
|
||||
const saved = originalSize - compressedSize;
|
||||
totalSaved += saved;
|
||||
console.log(`[build] 压缩 ${path.relative(distRoot, file)}: ${(originalSize / 1024).toFixed(1)}KB -> ${(compressedSize / 1024).toFixed(1)}KB`);
|
||||
}
|
||||
console.log(`[build] 压缩 ${path.relative(distRoot, dir)} 完成,节省 ${(totalSaved / 1024).toFixed(1)}KB`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制单个文件
|
||||
*/
|
||||
function copyFileSync(src, dest) {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制目录内容(不包含顶层目录)
|
||||
* srcDir/abc/xyz.js -> destDir/abc/xyz.js
|
||||
*/
|
||||
function copyDirContentsSync(srcDir, destDir) {
|
||||
if (!fs.existsSync(srcDir)) return;
|
||||
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
||||
|
||||
for (const e of entries) {
|
||||
const srcPath = path.join(srcDir, e.name);
|
||||
const destPath = path.join(destDir, e.name);
|
||||
|
||||
if (e.isDirectory()) {
|
||||
copyDirContentsSync(srcPath, destPath);
|
||||
} else if (e.isFile() && e.name.endsWith(".js")) {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function build() {
|
||||
const esbuild = (await import("esbuild")).default;
|
||||
|
||||
// ---------- 0. 清理旧目录 ----------
|
||||
const oldSrcDir = path.join(distRoot, "src");
|
||||
if (fs.existsSync(oldSrcDir)) {
|
||||
fs.rmSync(oldSrcDir, { recursive: true, force: true });
|
||||
console.log("[build] Old src/ directory deleted");
|
||||
}
|
||||
|
||||
// ---------- 1. 打包 CLI ----------
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(projectRoot, "src", "cli.js")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
outfile: path.join(distRoot, "cli.js"),
|
||||
format: "esm",
|
||||
external: ["commander", "cross-spawn", "fs-extra", "tree-kill"],
|
||||
define: { __BUILD_VERSION__: JSON.stringify(version) },
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
});
|
||||
console.log("[build] dist/cli.js output");
|
||||
|
||||
// ---------- 2. 复制 src 下的子目录到 dist 根目录 ----------
|
||||
// appConfig/* -> dist/appConfig/* 应用配置(环境变量等)
|
||||
// config/* -> dist/config/* Swagger/API 文档
|
||||
// routes/* -> dist/routes/*
|
||||
// scheduler/* -> dist/scheduler/*
|
||||
// service/* -> dist/service/*
|
||||
// utils/* -> dist/utils/*
|
||||
// server.js -> dist/server.js(单独复制)
|
||||
|
||||
const srcRoot = path.join(projectRoot, "src");
|
||||
|
||||
// 复制子目录(与 src 结构同步:appConfig、routes、scheduler、service、utils)
|
||||
const subdirs = ["appConfig", "routes", "scheduler", "service", "utils"];
|
||||
for (const subdir of subdirs) {
|
||||
const srcPath = path.join(srcRoot, subdir);
|
||||
const destPath = path.join(distRoot, subdir);
|
||||
copyDirContentsSync(srcPath, destPath);
|
||||
console.log(`[build] ${subdir}/ -> dist/${subdir}/ copied`);
|
||||
}
|
||||
|
||||
// 单独复制 src/config/ 下的 swagger 相关文件(移除遗留的 dist/config/index.js,应用配置已迁至 appConfig)
|
||||
const srcConfigPath = path.join(srcRoot, "config");
|
||||
const destConfigPath = path.join(distRoot, "config");
|
||||
const legacyConfigIndex = path.join(destConfigPath, "index.js");
|
||||
if (fs.existsSync(legacyConfigIndex)) fs.rmSync(legacyConfigIndex);
|
||||
copyDirContentsSync(srcConfigPath, destConfigPath);
|
||||
console.log(`[build] config/ -> dist/config/ copied`);
|
||||
|
||||
// 单独复制 server.js 到 dist 根目录(无需修改导入路径,源码已用 ./appConfig)
|
||||
const serverJsSrc = path.join(srcRoot, "server.js");
|
||||
const serverJsDest = path.join(distRoot, "server.js");
|
||||
copyFileSync(serverJsSrc, serverJsDest);
|
||||
console.log("[build] server.js -> dist/ copied");
|
||||
|
||||
// ---------- 3. 压缩 dist 下所有 JS 文件(CLI 除外) ----------
|
||||
const distJsFiles = findJsFiles(distRoot);
|
||||
let totalSaved = 0;
|
||||
|
||||
for (const file of distJsFiles) {
|
||||
// 跳过已压缩的 cli.js
|
||||
if (path.basename(file) === "cli.js") continue;
|
||||
|
||||
const code = fs.readFileSync(file, "utf8");
|
||||
const result = await esbuild.transform(code, {
|
||||
minify: true,
|
||||
target: "node22",
|
||||
});
|
||||
fs.writeFileSync(file, result.code, "utf8");
|
||||
const originalSize = Buffer.byteLength(code, "utf8");
|
||||
const compressedSize = Buffer.byteLength(result.code, "utf8");
|
||||
const saved = originalSize - compressedSize;
|
||||
totalSaved += saved;
|
||||
console.log(`[build] 压缩 ${path.relative(distRoot, file)}: ${(originalSize / 1024).toFixed(1)}KB -> ${(compressedSize / 1024).toFixed(1)}KB`);
|
||||
}
|
||||
console.log(`[build] Compression completed, saving ${(totalSaved / 1024).toFixed(1)}KB`);
|
||||
|
||||
// ---------- 4. 复制 env 文件(从 src/ 到 dist/) ----------
|
||||
// 发布包需包含 development + production,以便 CLI --env production 能正常启动
|
||||
const envToShip = ["env.development", "env.production"];
|
||||
for (const envFile of envToShip) {
|
||||
const src = path.join(srcRoot, envFile);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, path.join(distRoot, envFile));
|
||||
console.log(`[build] ${envFile} -> dist/ copied`);
|
||||
}
|
||||
}
|
||||
|
||||
// 调试模式下额外复制其余 env.*(如 env.test)
|
||||
if (debugMode) {
|
||||
const envFiles = fs.readdirSync(srcRoot).filter((f) => f.startsWith("env."));
|
||||
for (const envFile of envFiles) {
|
||||
if (!envToShip.includes(envFile)) {
|
||||
fs.copyFileSync(path.join(srcRoot, envFile), path.join(distRoot, envFile));
|
||||
console.log(`[build] ${envFile} -> dist/ copied`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 5. 输出启动说明 ----------
|
||||
console.log("");
|
||||
console.log("[build] =========================================");
|
||||
console.log("[build] Build completed!");
|
||||
console.log("[build] =========================================");
|
||||
console.log("[build] Start service: cd dist && node server.js");
|
||||
console.log("[build] Or use CLI: node cli.js start");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
build().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
287
qiming-file-server/scripts/check-package-in-store.js
Normal file
287
qiming-file-server/scripts/check-package-in-store.js
Normal file
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 检查指定包是否在 pnpm store 中,以及下载原因诊断工具
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/check-package-in-store.js @radix-ui/react-toggle
|
||||
* node scripts/check-package-in-store.js @radix-ui/react-toggle 1.0.3
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from "child_process";
|
||||
import path from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const packageName = process.argv[2];
|
||||
const packageVersion = process.argv[3];
|
||||
|
||||
if (!packageName) {
|
||||
console.error("❌ Please provide package name");
|
||||
console.log("\nUsage:");
|
||||
console.log(" node scripts/check-package-in-store.js <package-name> [version]");
|
||||
console.log("\nExamples:");
|
||||
console.log(" node scripts/check-package-in-store.js @radix-ui/react-toggle");
|
||||
console.log(" node scripts/check-package-in-store.js @radix-ui/react-toggle 1.0.3");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("======================================");
|
||||
console.log("pnpm Store package diagnosis tool");
|
||||
console.log("======================================");
|
||||
console.log("");
|
||||
|
||||
// 1. 获取 store 路径
|
||||
console.log("1️⃣ Check pnpm Store path:");
|
||||
console.log("----------------------------------------");
|
||||
let storePath;
|
||||
try {
|
||||
storePath = execSync("pnpm store path", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
console.log(`✅ Store path: ${storePath}`);
|
||||
|
||||
// 检查 store 结构
|
||||
const filesDir = path.join(storePath, "files");
|
||||
const indexDir = path.join(storePath, "index");
|
||||
if (filesDir && indexDir) {
|
||||
console.log(` Store structure: pnpm v10 (hash sharding storage)`);
|
||||
console.log(` - files/ directory: store hash files`);
|
||||
console.log(` - index/ directory: store index information`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to get store path: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// 2. 检查 store 中是否有该包
|
||||
console.log("2️⃣ Check if the package exists in the Store:");
|
||||
console.log("----------------------------------------");
|
||||
let foundDirs = "";
|
||||
let packageFound = false;
|
||||
|
||||
try {
|
||||
// pnpm v10 store structure: store/v10/files/ and store/v10/index/
|
||||
// files/ directory is the hash sharding directory (00-ff), each sharding directory is the hash named file
|
||||
// index/ directory stores index information
|
||||
const encodedPackageName = packageName
|
||||
.replace(/@/g, "%40")
|
||||
.replace(/\//g, "%2f");
|
||||
|
||||
const filesDir = path.join(storePath, "files");
|
||||
const indexDir = path.join(storePath, "index");
|
||||
|
||||
console.log(` Search package: ${packageName}`);
|
||||
console.log(` Encoded package name: ${encodedPackageName}`);
|
||||
console.log(` Store files directory: ${filesDir}`);
|
||||
console.log(` Store index directory: ${indexDir}`);
|
||||
console.log(" (Note: pnpm v10 uses hash file storage, packages are named by hash values)");
|
||||
console.log("");
|
||||
|
||||
// 方法1: 使用 pnpm store status 检查(最可靠的方法)
|
||||
console.log(" Method 1: Use pnpm store status check...");
|
||||
try {
|
||||
const statusOutput = execSync("pnpm store status", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 检查输出中是否包含该包的信息
|
||||
if (statusOutput.includes(packageName) || statusOutput.includes(encodedPackageName)) {
|
||||
console.log(" ✅ pnpm store status shows the package related information:");
|
||||
const relevantLines = statusOutput
|
||||
.split("\n")
|
||||
.filter(line => line.includes(packageName) || line.includes(encodedPackageName))
|
||||
.slice(0, 5);
|
||||
relevantLines.forEach(line => console.log(` ${line}`));
|
||||
packageFound = true;
|
||||
} else {
|
||||
console.log(" ⚠️ pnpm store status did not find the direct information of the package");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ⚠️ Failed to execute pnpm store status: ${e.message}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// 方法2: 在 index 目录中查找(索引可能包含包名信息)
|
||||
console.log(" Method 2: Find in the index directory...");
|
||||
try {
|
||||
const findCommand = `find "${indexDir}" -type f -exec grep -l "${encodedPackageName}" {} \\; 2>/dev/null | head -10`;
|
||||
const indexFiles = execSync(findCommand, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
|
||||
if (indexFiles) {
|
||||
console.log(" ✅ Found related files in the index:");
|
||||
indexFiles.split("\n").slice(0, 5).forEach(file => {
|
||||
console.log(` ${file}`);
|
||||
});
|
||||
packageFound = true;
|
||||
} else {
|
||||
console.log(" ⚠️ index directory did not find related files");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ⚠️ Failed to search index directory: ${e.message}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// 方法3: 尝试在整个 store 中查找包含包名的任何内容
|
||||
console.log(" Method 3: Search for any content containing the package name in the store...");
|
||||
try {
|
||||
const findCommand = `grep -r "${encodedPackageName}" "${indexDir}" 2>/dev/null | head -5`;
|
||||
const grepResults = execSync(findCommand, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
|
||||
if (grepResults) {
|
||||
console.log(" ✅ Found content containing the package name:");
|
||||
grepResults.split("\n").forEach(line => {
|
||||
const parts = line.split(":");
|
||||
if (parts.length > 1) {
|
||||
console.log(` File: ${parts[0]}`);
|
||||
console.log(` Content: ${parts.slice(1).join(":").substring(0, 100)}...`);
|
||||
}
|
||||
});
|
||||
packageFound = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// grep 没找到结果时会返回非零退出码,这是正常的
|
||||
if (e.status !== 1) {
|
||||
console.log(` ⚠️ Failed to search: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 总结查找结果
|
||||
console.log(" 📊 Summary of search results:");
|
||||
if (packageFound) {
|
||||
console.log(" ✅ Found the related information of the package in the store");
|
||||
} else {
|
||||
console.log(` ❌ Did not find the package in the store: ${packageName}`);
|
||||
console.log(" 💡 This may be the reason for the download: the package is not in the store");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`⚠️ Failed during check: ${error.message}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// 3. 检查 store 状态
|
||||
console.log("3️⃣ Check Store status:");
|
||||
console.log("----------------------------------------");
|
||||
try {
|
||||
const statusOutput = execSync("pnpm store status", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
console.log(statusOutput);
|
||||
} catch (error) {
|
||||
console.log(`⚠️ Failed to get store status: ${error.message}`);
|
||||
if (error.message.includes("ENOENT")) {
|
||||
console.log(" 💡 Store index may be damaged,建议运行: pnpm store prune");
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// 4. 检查包的依赖信息(如果提供了项目路径)
|
||||
console.log("4️⃣ Diagnose possible reasons:");
|
||||
console.log("----------------------------------------");
|
||||
const reasons = [];
|
||||
|
||||
if (!packageFound) {
|
||||
reasons.push({
|
||||
reason: "Package not in Store",
|
||||
description: "This is the first time to install the package, or the package has never been used by other projects",
|
||||
solution: "This is a normal behavior, after installation, the package will be added to the store, and subsequent projects can reuse it",
|
||||
});
|
||||
} else if (packageVersion) {
|
||||
const dirs = foundDirs.split("\n").filter(Boolean);
|
||||
const hasExactVersion = dirs.some((dir) => {
|
||||
try {
|
||||
const pkgJsonPath = path.join(dir, "package.json");
|
||||
const pkgJsonContent = readFileSync(pkgJsonPath, "utf8");
|
||||
const pkgJson = JSON.parse(pkgJsonContent);
|
||||
return pkgJson.version === packageVersion;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasExactVersion) {
|
||||
reasons.push({
|
||||
reason: "Version mismatch",
|
||||
description: `Store has the package, but the version is not ${packageVersion}`,
|
||||
solution: "Different projects use different versions of the package, this is normal",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 检查镜像源配置
|
||||
try {
|
||||
const registry = execSync("pnpm config get registry", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
console.log(`📦 Current mirror source: ${registry}`);
|
||||
|
||||
if (registry.includes("npmmirror.com")) {
|
||||
reasons.push({
|
||||
reason: "Mirror source problem",
|
||||
description: "When using the domestic mirror source, some packages may need to be re-downloaded",
|
||||
solution: "This is normal, the mirror source synchronization may have a delay",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
if (reasons.length === 0) {
|
||||
console.log("✅ No obvious problems found");
|
||||
console.log(" 💡 If still downloading, it may be:");
|
||||
console.log(" - Package integrity verification failed");
|
||||
console.log(" - Dependency resolution requires a specific version");
|
||||
console.log(" - Hard link failed, fallback to download");
|
||||
} else {
|
||||
console.log("Possible reasons:");
|
||||
reasons.forEach((item, index) => {
|
||||
console.log(`\n${index + 1}. ${item.reason}`);
|
||||
console.log(` Description: ${item.description}`);
|
||||
console.log(` Solution: ${item.solution}`);
|
||||
});
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// 5. 建议的进一步检查
|
||||
console.log("5️⃣ Further checks recommended:");
|
||||
console.log("----------------------------------------");
|
||||
const encodedPackageNameForHelp = packageName
|
||||
.replace(/@/g, "%40")
|
||||
.replace(/\//g, "%2f");
|
||||
console.log("1. View detailed installation logs (add --loglevel=debug):");
|
||||
console.log(` pnpm install --loglevel=debug 2>&1 | grep -i "${packageName}"`);
|
||||
console.log("");
|
||||
console.log("2. Check the lock file of the project:");
|
||||
console.log(` cat pnpm-lock.yaml | grep -A 5 "${packageName}"`);
|
||||
console.log("");
|
||||
console.log("3. Manually check the store content (traverse all hash shards):");
|
||||
console.log(` # 方法1: 使用 find 递归查找`);
|
||||
console.log(` find "${storePath}/files" -type d -name "*${encodedPackageNameForHelp}*" 2>/dev/null`);
|
||||
console.log(` find "${storePath}/index" -type f -name "*${encodedPackageNameForHelp}*" 2>/dev/null`);
|
||||
console.log("");
|
||||
console.log(` # Method 2: Traverse the hash sharding directory (if you know the approximate hash value):`);
|
||||
console.log(` for dir in "${storePath}/files"/{00..ff}; do`);
|
||||
console.log(` [ -d "$dir" ] && ls -la "$dir" 2>/dev/null | grep -q "${encodedPackageNameForHelp}" && echo "找到在: $dir";`);
|
||||
console.log(` done`);
|
||||
console.log("");
|
||||
console.log("4. Check the dependency of the package:");
|
||||
console.log(` pnpm why ${packageName}`);
|
||||
console.log("");
|
||||
console.log("5. Use pnpm command to check (if the package is installed):");
|
||||
console.log(` pnpm list ${packageName}`);
|
||||
console.log(` pnpm store status | grep -i "${packageName}"`);
|
||||
console.log("");
|
||||
199
qiming-file-server/scripts/pnpm-check-store-usage.js
Normal file
199
qiming-file-server/scripts/pnpm-check-store-usage.js
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 检查 pnpm store 使用情况
|
||||
* 验证环境变量是否正确传递给子进程
|
||||
*/
|
||||
|
||||
import { spawn, exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
console.log("========================================");
|
||||
console.log("pnpm Store usage check");
|
||||
console.log("========================================\n");
|
||||
|
||||
async function checkPnpmConfig() {
|
||||
console.log("1. Check pnpm configuration:");
|
||||
console.log("----------------------------------------");
|
||||
|
||||
try {
|
||||
console.log("📦 Current Node.js process pnpm related environment variables:");
|
||||
const pnpmEnvVars = Object.keys(process.env)
|
||||
.filter(
|
||||
(key) =>
|
||||
key.toLowerCase().includes("pnpm") ||
|
||||
key === "PATH" ||
|
||||
key === "HOME"
|
||||
)
|
||||
.sort();
|
||||
|
||||
if (pnpmEnvVars.length === 0) {
|
||||
console.log(" (No pnpm related environment variables found)");
|
||||
} else {
|
||||
pnpmEnvVars.forEach((key) => {
|
||||
const value = process.env[key];
|
||||
const displayValue =
|
||||
value.length > 100 ? value.substring(0, 100) + "..." : value;
|
||||
console.log(` ${key} = ${displayValue}`);
|
||||
});
|
||||
}
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
"📦 Get pnpm configuration through spawn (inherit process.env):"
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn("sh", ["-c", "pnpm config list"], {
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let output = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
console.log(output);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Exit code: ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log("📦 Get pnpm configuration through spawn (no env):");
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn("sh", ["-c", "pnpm config list"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let output = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
console.log(output);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Exit code: ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(` ❌ 错误: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStoreLocation() {
|
||||
console.log("\n2. 检查 Store 位置:");
|
||||
console.log("----------------------------------------");
|
||||
|
||||
try {
|
||||
const { stdout } = await execPromise("pnpm store path", {
|
||||
env: process.env,
|
||||
});
|
||||
const storePath = stdout.trim();
|
||||
console.log(`📁 Store 路径: ${storePath}`);
|
||||
|
||||
try {
|
||||
const { stdout: sizeOutput } = await execPromise(
|
||||
`du -sh "${storePath}" 2>/dev/null || echo "无法计算"`
|
||||
);
|
||||
const size = sizeOutput.split("\t")[0];
|
||||
console.log(`💾 Store 大小: ${size}`);
|
||||
} catch (e) {
|
||||
console.log("💾 Store 大小: 无法计算");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSpawnWithEnv() {
|
||||
console.log("\n3. Test spawn environment variable passing:");
|
||||
console.log("----------------------------------------");
|
||||
|
||||
console.log("✅ Test 1: spawn pass env: process.env");
|
||||
await new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
"sh",
|
||||
["-c", 'echo "Registry: $(pnpm config get registry)"'],
|
||||
{
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
console.log(` ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
child.on("exit", () => resolve());
|
||||
});
|
||||
|
||||
console.log("\n❌ Test 2: spawn no env");
|
||||
await new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
"sh",
|
||||
["-c", 'echo "Registry: $(pnpm config get registry)"'],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
console.log(` ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
child.on("exit", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function analyzeInstallProgress() {
|
||||
console.log("\n4. pnpm install progress indicator description:");
|
||||
console.log("----------------------------------------");
|
||||
|
||||
console.log(`
|
||||
📊 Progress indicator meaning:
|
||||
- resolved: Total number of resolved dependencies (determined version number and dependency relationship)
|
||||
- reused: Number of packages reused through hard links from the store
|
||||
- downloaded: Number of packages downloaded from the central repository
|
||||
- added: Number of packages added to node_modules
|
||||
|
||||
✅ Ideal state: downloaded = 0, means completely using local store
|
||||
|
||||
❓ Why resolved != reused?
|
||||
Different packages may be:
|
||||
1. Virtual packages (virtual references of peer dependencies)
|
||||
2. Symbolic links (links to other packages)
|
||||
3. Local packages (packages in workspace)
|
||||
4. Optional dependencies (packages skipped based on platform conditions)
|
||||
|
||||
💡 Key indicators:
|
||||
- downloaded = 0 → ✅ Environment variables take effect, using store
|
||||
- downloaded > 0 → ❌ Some packages downloaded from the network
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await checkPnpmConfig();
|
||||
await checkStoreLocation();
|
||||
await testSpawnWithEnv();
|
||||
await analyzeInstallProgress();
|
||||
|
||||
console.log("\n========================================");
|
||||
console.log("Check completed!");
|
||||
console.log("========================================\n");
|
||||
} catch (error) {
|
||||
console.error("Execution error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
37
qiming-file-server/scripts/pnpm-check-wrapper.js
Normal file
37
qiming-file-server/scripts/pnpm-check-wrapper.js
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* pnpm-check 脚本的 Node.js 包装器
|
||||
* 自动加载项目配置并传递给 bash 脚本
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const config = (await import("../src/appConfig/index.js")).default;
|
||||
|
||||
const projectSourceDir = config.PROJECT_SOURCE_DIR;
|
||||
const scriptPath = path.join(__dirname, "pnpm-check.sh");
|
||||
|
||||
console.log(`🔧 Current environment: ${config.NODE_ENV}`);
|
||||
console.log(`📂 Project directory: ${projectSourceDir}`);
|
||||
console.log("");
|
||||
|
||||
const child = spawn("bash", [scriptPath, projectSourceDir], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PROJECT_SOURCE_DIR: projectSourceDir,
|
||||
NODE_ENV: config.NODE_ENV,
|
||||
},
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("❌ Execution failed:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
423
qiming-file-server/scripts/pnpm-check.sh
Normal file
423
qiming-file-server/scripts/pnpm-check.sh
Normal file
@@ -0,0 +1,423 @@
|
||||
#!/bin/bash
|
||||
# pnpm disk usage inspection script
|
||||
|
||||
echo "======================================"
|
||||
echo "pnpm disk usage analyzer"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# Ensure pnpm is installed
|
||||
if ! command -v pnpm &> /dev/null; then
|
||||
echo "❌ pnpm not found; install it first"
|
||||
echo " npm install -g pnpm"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ pnpm version: $(pnpm --version)"
|
||||
echo ""
|
||||
|
||||
# Read PROJECT_SOURCE_DIR from env file
|
||||
get_project_dir_from_env() {
|
||||
local env_name=${1:-"development"}
|
||||
local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
local project_root="$(dirname "$script_dir")"
|
||||
local env_file="$project_root/env.$env_name"
|
||||
|
||||
if [ -f "$env_file" ]; then
|
||||
local project_dir=$(grep "^PROJECT_SOURCE_DIR=" "$env_file" | cut -d'=' -f2)
|
||||
if [ -n "$project_dir" ]; then
|
||||
echo "$project_dir"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Store path
|
||||
echo "📁 pnpm store path:"
|
||||
STORE_PATH=$(pnpm store path 2>/dev/null)
|
||||
STORE_FILESYSTEM=""
|
||||
if [ -n "$STORE_PATH" ]; then
|
||||
echo " $STORE_PATH"
|
||||
|
||||
if [ -d "$STORE_PATH" ]; then
|
||||
STORE_SIZE=$(du -sh "$STORE_PATH" 2>/dev/null | awk '{print $1}')
|
||||
echo " Store size: $STORE_SIZE"
|
||||
|
||||
STORE_FILESYSTEM=$(df "$STORE_PATH" 2>/dev/null | tail -n 1 | awk '{print $1}')
|
||||
if [ -n "$STORE_FILESYSTEM" ]; then
|
||||
echo " Filesystem: $STORE_FILESYSTEM"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ Could not resolve store path"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Store status
|
||||
echo "📊 pnpm store status:"
|
||||
STORE_STATUS_OUTPUT=$(pnpm store status 2>&1)
|
||||
STORE_STATUS_EXIT_CODE=$?
|
||||
|
||||
if [ $STORE_STATUS_EXIT_CODE -eq 0 ]; then
|
||||
echo "$STORE_STATUS_OUTPUT"
|
||||
else
|
||||
if echo "$STORE_STATUS_OUTPUT" | grep -q "ENOENT"; then
|
||||
echo " ⚠️ Store index missing or corrupted"
|
||||
echo " 💡 Try:"
|
||||
echo " pnpm store prune"
|
||||
else
|
||||
echo " ⚠️ Could not read store status"
|
||||
echo " Error: $(echo "$STORE_STATUS_OUTPUT" | head -n 1)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Project directory resolution
|
||||
# Priority: 1) CLI arg 2) PROJECT_SOURCE_DIR 3) env file 4) prompt
|
||||
PROJECT_DIR=""
|
||||
AUTO_DETECTED_DIR=""
|
||||
SOURCE_INFO=""
|
||||
|
||||
if [ -n "$1" ]; then
|
||||
AUTO_DETECTED_DIR="$1"
|
||||
SOURCE_INFO="command-line argument"
|
||||
elif [ -n "$PROJECT_SOURCE_DIR" ]; then
|
||||
AUTO_DETECTED_DIR="$PROJECT_SOURCE_DIR"
|
||||
SOURCE_INFO="env PROJECT_SOURCE_DIR"
|
||||
else
|
||||
ENV_NAME="${NODE_ENV:-development}"
|
||||
AUTO_DETECTED_DIR=$(get_project_dir_from_env "$ENV_NAME")
|
||||
|
||||
if [ -z "$AUTO_DETECTED_DIR" ]; then
|
||||
for env in development production test; do
|
||||
AUTO_DETECTED_DIR=$(get_project_dir_from_env "$env")
|
||||
if [ -n "$AUTO_DETECTED_DIR" ]; then
|
||||
ENV_NAME="$env"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$AUTO_DETECTED_DIR" ]; then
|
||||
SOURCE_INFO="config file env.$ENV_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$AUTO_DETECTED_DIR" ]; then
|
||||
echo ""
|
||||
echo "📂 Detected project directory (source: $SOURCE_INFO)"
|
||||
echo " Path: $AUTO_DETECTED_DIR"
|
||||
echo ""
|
||||
read -p "👉 Press Enter to use this path, or type a different path: " USER_INPUT
|
||||
|
||||
if [ -n "$USER_INPUT" ]; then
|
||||
PROJECT_DIR="$USER_INPUT"
|
||||
echo "📝 Using entered project directory: $PROJECT_DIR"
|
||||
else
|
||||
PROJECT_DIR="$AUTO_DETECTED_DIR"
|
||||
echo "✅ Using detected project directory"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Could not auto-detect project directory"
|
||||
echo ""
|
||||
echo "💡 You can:"
|
||||
echo " - Pass a path: $0 /path/to/projects"
|
||||
echo " - Set env: PROJECT_SOURCE_DIR=/path/to/projects $0"
|
||||
echo " - Set NODE_ENV: NODE_ENV=development $0"
|
||||
echo ""
|
||||
|
||||
read -p "📝 Project directory path (Enter to skip project scan): " USER_INPUT
|
||||
|
||||
if [ -n "$USER_INPUT" ]; then
|
||||
PROJECT_DIR="$USER_INPUT"
|
||||
echo "📂 Using entered project directory"
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping project scan..."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$PROJECT_DIR" ] && [ ! -d "$PROJECT_DIR" ]; then
|
||||
echo ""
|
||||
echo "⚠️ Project directory does not exist: $PROJECT_DIR"
|
||||
echo ""
|
||||
echo "Skipping project scan..."
|
||||
PROJECT_DIR=""
|
||||
fi
|
||||
|
||||
if [ -n "$PROJECT_DIR" ]; then
|
||||
echo ""
|
||||
echo "🔍 Scanning project directory: $PROJECT_DIR"
|
||||
echo ""
|
||||
|
||||
if [ -d "$PROJECT_DIR" ]; then
|
||||
PROJECT_FILESYSTEM=$(df "$PROJECT_DIR" 2>/dev/null | tail -n 1 | awk '{print $1}')
|
||||
if [ -n "$PROJECT_FILESYSTEM" ]; then
|
||||
echo "💾 Filesystem check:"
|
||||
echo " Project filesystem: $PROJECT_FILESYSTEM"
|
||||
if [ -n "$STORE_FILESYSTEM" ]; then
|
||||
echo " Store filesystem: $STORE_FILESYSTEM"
|
||||
echo ""
|
||||
if [ "$PROJECT_FILESYSTEM" = "$STORE_FILESYSTEM" ]; then
|
||||
echo " ✅ Project and store are on the same filesystem"
|
||||
echo " 💡 Hard links work; disk savings apply"
|
||||
else
|
||||
echo " ⚠️ Project and store are on different filesystems"
|
||||
echo " ❌ Hard links cannot cross filesystems; pnpm will copy files"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ Could not determine store filesystem"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "📦 Per-project node_modules (apparent size):"
|
||||
echo " ⚠️ Note: du double-counts hard links; real usage is lower"
|
||||
TOTAL_SIZE_KB=0
|
||||
COUNT=0
|
||||
MAX_DISPLAY=5
|
||||
|
||||
while IFS= read -r dir; do
|
||||
SIZE_HUMAN=$(du -sh "$dir" 2>/dev/null | awk '{print $1}')
|
||||
SIZE_KB=$(du -sk "$dir" 2>/dev/null | awk '{print $1}')
|
||||
PROJECT_NAME=$(echo "$dir" | sed "s|$PROJECT_DIR/||" | sed 's|/node_modules||')
|
||||
|
||||
if [ $COUNT -lt $MAX_DISPLAY ]; then
|
||||
echo " [$PROJECT_NAME] $SIZE_HUMAN (includes hard-link double counting)"
|
||||
fi
|
||||
|
||||
TOTAL_SIZE_KB=$((TOTAL_SIZE_KB + SIZE_KB))
|
||||
((COUNT++))
|
||||
done < <(find "$PROJECT_DIR" -name "node_modules" -type d -maxdepth 3 2>/dev/null)
|
||||
|
||||
if [ $COUNT -gt $MAX_DISPLAY ]; then
|
||||
echo " ... $((COUNT - MAX_DISPLAY)) more not shown"
|
||||
fi
|
||||
|
||||
if [ $TOTAL_SIZE_KB -gt 0 ]; then
|
||||
if [ $TOTAL_SIZE_KB -gt 1048576 ]; then
|
||||
TOTAL_SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fG\", $TOTAL_SIZE_KB/1048576}")
|
||||
elif [ $TOTAL_SIZE_KB -gt 1024 ]; then
|
||||
TOTAL_SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fM\", $TOTAL_SIZE_KB/1024}")
|
||||
else
|
||||
TOTAL_SIZE_HUMAN="${TOTAL_SIZE_KB}K"
|
||||
fi
|
||||
echo " Total apparent size: $TOTAL_SIZE_HUMAN (real usage is lower)"
|
||||
fi
|
||||
echo " Found $COUNT node_modules directories"
|
||||
echo ""
|
||||
|
||||
echo "🗂️ Per-project .pnpm folders (apparent size):"
|
||||
echo " ⚠️ Note: entries under .pnpm are hard links; little extra space per copy"
|
||||
PNPM_COUNT=0
|
||||
PNPM_TOTAL_SIZE_KB=0
|
||||
|
||||
while IFS= read -r dir; do
|
||||
SIZE_HUMAN=$(du -sh "$dir" 2>/dev/null | awk '{print $1}')
|
||||
SIZE_KB=$(du -sk "$dir" 2>/dev/null | awk '{print $1}')
|
||||
PROJECT_NAME=$(echo "$dir" | sed "s|$PROJECT_DIR/||" | sed 's|/node_modules/.pnpm||')
|
||||
|
||||
if [ $PNPM_COUNT -lt $MAX_DISPLAY ]; then
|
||||
echo " [$PROJECT_NAME] $SIZE_HUMAN (hard links, shared in store)"
|
||||
fi
|
||||
|
||||
PNPM_TOTAL_SIZE_KB=$((PNPM_TOTAL_SIZE_KB + SIZE_KB))
|
||||
((PNPM_COUNT++))
|
||||
done < <(find "$PROJECT_DIR" -type d -path "*/node_modules/.pnpm" -maxdepth 4 2>/dev/null)
|
||||
|
||||
if [ $PNPM_COUNT -gt $MAX_DISPLAY ]; then
|
||||
echo " ... $((PNPM_COUNT - MAX_DISPLAY)) more not shown"
|
||||
fi
|
||||
|
||||
if [ $PNPM_TOTAL_SIZE_KB -gt 0 ]; then
|
||||
if [ $PNPM_TOTAL_SIZE_KB -gt 1048576 ]; then
|
||||
PNPM_TOTAL_SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fG\", $PNPM_TOTAL_SIZE_KB/1048576}")
|
||||
elif [ $PNPM_TOTAL_SIZE_KB -gt 1024 ]; then
|
||||
PNPM_TOTAL_SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fM\", $PNPM_TOTAL_SIZE_KB/1024}")
|
||||
else
|
||||
PNPM_TOTAL_SIZE_HUMAN="${PNPM_TOTAL_SIZE_KB}K"
|
||||
fi
|
||||
echo " Total apparent size: $PNPM_TOTAL_SIZE_HUMAN (all hard-linked to store)"
|
||||
fi
|
||||
echo " Found $PNPM_COUNT .pnpm directories"
|
||||
echo ""
|
||||
|
||||
echo "💾 Filesystem usage (df):"
|
||||
echo " Whole filesystem is more accurate than summing du:"
|
||||
df -h "$PROJECT_DIR" | tail -n 1 | awk '{print " Filesystem: "$1, "| Used: "$3, "| Avail: "$4, "| Use: "$5}'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "======================================"
|
||||
echo "💡 Important:"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "⚠️ du counts hard-linked files multiple times!"
|
||||
echo " Files under each project’s .pnpm are hard links into the store."
|
||||
echo " Real disk use ≈ store size + small symlink/metadata overhead."
|
||||
echo " Often ~30–50% of what naive du sums suggest."
|
||||
echo ""
|
||||
echo "✅ Ways to see real usage:"
|
||||
echo " 1. pnpm store size (shown above)"
|
||||
echo " 2. df -h for the whole filesystem"
|
||||
echo " 3. Compare df before and after installs"
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "💡 Recommendations:"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "1. Prune unused packages from the store:"
|
||||
echo " pnpm store prune"
|
||||
echo ""
|
||||
echo "2. Prefer one store and projects on the same filesystem:"
|
||||
echo " Keep projects and the store on the same volume when possible"
|
||||
echo ""
|
||||
echo "3. Tune per-project .npmrc for your registry and link strategy"
|
||||
echo ""
|
||||
|
||||
if [ -n "$PROJECT_DIR" ] && [ -d "$PROJECT_DIR" ]; then
|
||||
echo ""
|
||||
read -p "🔍 Verify hard links between two projects? (y to start, Enter to skip): " VERIFY_HARDLINK
|
||||
|
||||
if [[ "$VERIFY_HARDLINK" =~ ^[Yy]$ ]]; then
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "🔗 Hard link verification"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "💡 Project layout: $PROJECT_DIR/{projectId}"
|
||||
echo ""
|
||||
|
||||
echo "📋 Project IDs under this directory (up to 5):"
|
||||
PROJECT_IDS=$(find "$PROJECT_DIR" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2>/dev/null | head -5)
|
||||
if [ -n "$PROJECT_IDS" ]; then
|
||||
echo "$PROJECT_IDS" | while read -r pid; do
|
||||
echo " - $pid"
|
||||
done
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
read -p "👉 First project ID: " PROJECT_1_ID
|
||||
|
||||
if [ -z "$PROJECT_1_ID" ]; then
|
||||
echo "❌ Project ID cannot be empty"
|
||||
else
|
||||
read -p "👉 Second project ID: " PROJECT_2_ID
|
||||
|
||||
if [ -z "$PROJECT_2_ID" ]; then
|
||||
echo "❌ Project ID cannot be empty"
|
||||
elif [ "$PROJECT_1_ID" = "$PROJECT_2_ID" ]; then
|
||||
echo "❌ Enter two different project IDs"
|
||||
else
|
||||
echo ""
|
||||
|
||||
PROJECT_1="$PROJECT_DIR/$PROJECT_1_ID"
|
||||
PROJECT_2="$PROJECT_DIR/$PROJECT_2_ID"
|
||||
PROJECT_1_NAME="$PROJECT_1_ID"
|
||||
PROJECT_2_NAME="$PROJECT_2_ID"
|
||||
|
||||
if [ ! -d "$PROJECT_1" ]; then
|
||||
echo "❌ Project directory not found: $PROJECT_1"
|
||||
elif [ ! -d "$PROJECT_2" ]; then
|
||||
echo "❌ Project directory not found: $PROJECT_2"
|
||||
else
|
||||
|
||||
echo "🔍 Comparing projects:"
|
||||
echo " • $PROJECT_1_NAME"
|
||||
echo " • $PROJECT_2_NAME"
|
||||
echo ""
|
||||
|
||||
PNPM_DIR_1="$PROJECT_1/node_modules/.pnpm"
|
||||
PNPM_DIR_2="$PROJECT_2/node_modules/.pnpm"
|
||||
|
||||
if [ ! -d "$PNPM_DIR_1" ]; then
|
||||
echo "⚠️ $PROJECT_1_NAME: no node_modules/.pnpm"
|
||||
echo " Path: $PNPM_DIR_1"
|
||||
elif [ ! -d "$PNPM_DIR_2" ]; then
|
||||
echo "⚠️ $PROJECT_2_NAME: no node_modules/.pnpm"
|
||||
echo " Path: $PNPM_DIR_2"
|
||||
else
|
||||
echo "🔎 Looking for overlapping dependencies..."
|
||||
|
||||
PACKAGES_1=($(find "$PNPM_DIR_1" -maxdepth 1 -type d -name "*@*" 2>/dev/null | xargs -I {} basename {}))
|
||||
PACKAGES_2=($(find "$PNPM_DIR_2" -maxdepth 1 -type d -name "*@*" 2>/dev/null | xargs -I {} basename {}))
|
||||
|
||||
COMMON_PACKAGES=()
|
||||
for pkg1 in "${PACKAGES_1[@]}"; do
|
||||
for pkg2 in "${PACKAGES_2[@]}"; do
|
||||
if [ "$pkg1" = "$pkg2" ]; then
|
||||
COMMON_PACKAGES+=("$pkg1")
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ ${#COMMON_PACKAGES[@]} -eq 0 ]; then
|
||||
echo "⚠️ No shared packages between the two projects"
|
||||
else
|
||||
echo "📦 Found ${#COMMON_PACKAGES[@]} shared package(s); verifying..."
|
||||
echo ""
|
||||
|
||||
VERIFIED_COUNT=0
|
||||
SAME_INODE_COUNT=0
|
||||
|
||||
for pkg in "${COMMON_PACKAGES[@]:0:5}"; do
|
||||
PKG_FILE_1=$(find "$PNPM_DIR_1/$pkg" -name "package.json" -path "*/node_modules/*/package.json" 2>/dev/null | head -n 1)
|
||||
PKG_FILE_2=$(find "$PNPM_DIR_2/$pkg" -name "package.json" -path "*/node_modules/*/package.json" 2>/dev/null | head -n 1)
|
||||
|
||||
if [ -n "$PKG_FILE_1" ] && [ -f "$PKG_FILE_1" ] && [ -n "$PKG_FILE_2" ] && [ -f "$PKG_FILE_2" ]; then
|
||||
INODE_1=$(ls -i "$PKG_FILE_1" | awk '{print $1}')
|
||||
INODE_2=$(ls -i "$PKG_FILE_2" | awk '{print $1}')
|
||||
|
||||
PKG_DISPLAY=$(echo "$pkg" | sed 's/@[^@]*$//')
|
||||
|
||||
if [ "$INODE_1" = "$INODE_2" ]; then
|
||||
echo " ✅ $PKG_DISPLAY: inode=$INODE_1 (same)"
|
||||
((SAME_INODE_COUNT++))
|
||||
else
|
||||
echo " ❌ $PKG_DISPLAY: $INODE_1 vs $INODE_2 (different)"
|
||||
fi
|
||||
|
||||
((VERIFIED_COUNT++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
|
||||
if [ $VERIFIED_COUNT -eq 0 ]; then
|
||||
echo "⚠️ Could not verify package files"
|
||||
elif [ $SAME_INODE_COUNT -eq $VERIFIED_COUNT ]; then
|
||||
echo "✅ Hard links look good"
|
||||
echo " Checked $VERIFIED_COUNT package(s); all inodes match"
|
||||
echo " Same physical files → disk savings from deduplication"
|
||||
elif [ $SAME_INODE_COUNT -eq 0 ]; then
|
||||
echo "❌ Hard links not shared"
|
||||
echo " Checked $VERIFIED_COUNT package(s); inodes all differ"
|
||||
echo ""
|
||||
echo " Possible causes:"
|
||||
echo " - Projects on different filesystems"
|
||||
echo " - Filesystem without working hard links (e.g. some network mounts)"
|
||||
echo " - pnpm / store configuration"
|
||||
else
|
||||
echo "⚠️ Mixed results"
|
||||
echo " Checked $VERIFIED_COUNT package(s); $SAME_INODE_COUNT inode match(es)"
|
||||
echo " May indicate inconsistent config or partial reinstalls"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Done"
|
||||
|
||||
34
qiming-file-server/scripts/pnpm-prune-manual.js
Normal file
34
qiming-file-server/scripts/pnpm-prune-manual.js
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 手动执行 pnpm store prune 的脚本
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/pnpm-prune-manual.js
|
||||
*/
|
||||
|
||||
import { executePruneManually } from "../src/scheduler/pnpmPruneScheduler.js";
|
||||
|
||||
async function main() {
|
||||
console.log("====================================");
|
||||
console.log("Manually execute pnpm store prune");
|
||||
console.log("====================================");
|
||||
console.log("");
|
||||
|
||||
try {
|
||||
await executePruneManually();
|
||||
console.log("");
|
||||
console.log("✅ Execution completed");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("");
|
||||
console.error("❌ Execution failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// If running this script directly
|
||||
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").replace(/^.*[\/\\]/, ""))) {
|
||||
main();
|
||||
}
|
||||
|
||||
export { main };
|
||||
10
qiming-file-server/scripts/start-cli.js
Normal file
10
qiming-file-server/scripts/start-cli.js
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* CLI 环境启动脚本(ESM)
|
||||
* 由 qiming-file-server CLI 命令调用
|
||||
*/
|
||||
|
||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
||||
console.log("[CLI] 启动服务 - 环境: " + process.env.NODE_ENV);
|
||||
await import("../src/server.js");
|
||||
3
qiming-file-server/scripts/start-dev.js
Normal file
3
qiming-file-server/scripts/start-dev.js
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.NODE_ENV = "development";
|
||||
await import("../src/server.js");
|
||||
3
qiming-file-server/scripts/start-prod.js
Normal file
3
qiming-file-server/scripts/start-prod.js
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.NODE_ENV = "production";
|
||||
await import("../src/server.js");
|
||||
3
qiming-file-server/scripts/start-test.js
Normal file
3
qiming-file-server/scripts/start-test.js
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.NODE_ENV = "test";
|
||||
await import("../src/server.js");
|
||||
436
qiming-file-server/scripts/test-cli.sh
Normal file
436
qiming-file-server/scripts/test-cli.sh
Normal file
@@ -0,0 +1,436 @@
|
||||
#!/bin/bash
|
||||
|
||||
#===============================================================================
|
||||
# qiming-file-server CLI 自动化测试脚本
|
||||
#
|
||||
# 功能: 自动化测试 qiming-file-server CLI 的启动、状态查询、健康检查、重启、停止等功能
|
||||
#
|
||||
# 使用方法:
|
||||
# chmod +x test-cli.sh
|
||||
# ./test-cli.sh # 模式一: pnpm link 全局命令
|
||||
# ./test-cli.sh --direct # 模式二: 直接用 node 运行 dist 目录
|
||||
# ./test-cli.sh --installed # 模式三: 已全局安装,跳过所有安装步骤
|
||||
#
|
||||
# 环境要求:
|
||||
# - bash shell
|
||||
# - curl
|
||||
# - jq (用于格式化 JSON 输出)
|
||||
# - pnpm
|
||||
# - node (v22+)
|
||||
#
|
||||
# 脚本行为:
|
||||
# 模式一 (默认): pnpm install + pnpm link 后测试
|
||||
# 模式二 (--direct): 直接用 node 运行 dist/,无需全局链接
|
||||
# 模式三 (--installed): 已全局安装,直接测试,跳过所有安装步骤
|
||||
#===============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 项目根目录(脚本位于 scripts/ 下,根目录为上级目录)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
DIST_DIR="${PROJECT_ROOT}/dist"
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 测试模式: "link", "direct" 或 "installed"
|
||||
TEST_MODE="link"
|
||||
|
||||
# 日志函数
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
log_section() {
|
||||
echo ""
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo -e "${CYAN} $1${NC}"
|
||||
echo -e "${CYAN}========================================${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# 模式一: 本地安装 - 安装依赖并全局链接,使 qiming-file-server 命令可用
|
||||
#-------------------------------------------------------------------------------
|
||||
do_local_install() {
|
||||
log_info "模式一: 本地安装模式"
|
||||
log_info "进入项目根目录 ${PROJECT_ROOT}"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
log_info "执行 pnpm install..."
|
||||
pnpm install
|
||||
|
||||
log_info "执行 pnpm run build(编译 CLI 到 dist/)..."
|
||||
pnpm run build
|
||||
|
||||
log_info "执行 pnpm link --global(全局链接)..."
|
||||
pnpm link --global
|
||||
|
||||
log_success "本地安装完成,qiming-file-server 命令已可用"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# 模式二: 直接运行 - 检查 dist 目录是否存在,直接用 node 运行
|
||||
#-------------------------------------------------------------------------------
|
||||
check_direct_mode() {
|
||||
if [ ! -d "${DIST_DIR}" ]; then
|
||||
log_error "dist 目录不存在,请先运行: npm run build"
|
||||
log_info "或者使用默认模式: ./test-cli.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${DIST_DIR}/cli.js" ]; then
|
||||
log_error "dist/cli.js 不存在,请先运行: npm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "模式二: 直接运行模式"
|
||||
log_info "将使用 'node ${DIST_DIR}/cli.js' 执行测试"
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# 模式三: 已安装 - 跳过所有安装步骤,直接使用全局命令测试
|
||||
#-------------------------------------------------------------------------------
|
||||
check_installed_mode() {
|
||||
if ! command -v qiming-file-server &> /dev/null; then
|
||||
log_error "未找到 qiming-file-server 命令,请先全局安装:"
|
||||
log_info " npm install -g qiming-file-server"
|
||||
log_info "或者使用其他模式:"
|
||||
log_info " ./test-cli.sh # 模式一: pnpm link"
|
||||
log_info " ./test-cli.sh --direct # 模式二: node 直接运行"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "模式三: 已安装模式"
|
||||
log_info "检测到 qiming-file-server 全局命令"
|
||||
log_info "将直接使用 'qiming-file-server' 执行测试"
|
||||
}
|
||||
|
||||
# 测试函数
|
||||
test_command() {
|
||||
local cmd=$1
|
||||
local description=$2
|
||||
log_info "测试: ${description}"
|
||||
log_info "命令: ${cmd}"
|
||||
eval "${cmd}"
|
||||
local exit_code=$?
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
log_success "✓ 完成: ${description}"
|
||||
return 0
|
||||
else
|
||||
log_error "✗ 失败: ${description} (退出码: ${exit_code})"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 默认配置
|
||||
PORT=${PORT:-60000}
|
||||
HEALTH_URL="http://localhost:${PORT}/health"
|
||||
PROJECT_DIR=${PROJECT_DIR:-./test-projects}
|
||||
NGINX_DIR=${NGINX_DIR:-./test-nginx}
|
||||
UPLOAD_DIR=${UPLOAD_DIR:-./test-uploads}
|
||||
|
||||
# 显示配置
|
||||
show_config() {
|
||||
log_section "测试配置"
|
||||
echo " 测试模式: ${TEST_MODE_LABEL}"
|
||||
echo " 端口: ${PORT}"
|
||||
echo " 健康检查: ${HEALTH_URL}"
|
||||
echo " 项目目录: ${PROJECT_DIR}"
|
||||
echo " Nginx目录: ${NGINX_DIR}"
|
||||
echo " 上传目录: ${UPLOAD_DIR}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 获取模式标签
|
||||
get_mode_label() {
|
||||
case "${TEST_MODE}" in
|
||||
link)
|
||||
TEST_MODE_LABEL="pnpm link 全局命令"
|
||||
;;
|
||||
direct)
|
||||
TEST_MODE_LABEL="node 直接运行"
|
||||
;;
|
||||
installed)
|
||||
TEST_MODE_LABEL="已全局安装"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 检查依赖
|
||||
check_dependencies() {
|
||||
log_info "检查依赖..."
|
||||
|
||||
local missing_deps=()
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
missing_deps+=("curl")
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
missing_deps+=("jq")
|
||||
fi
|
||||
|
||||
# 模式一和模式二需要 pnpm
|
||||
if [ "${TEST_MODE}" != "installed" ]; then
|
||||
if ! command -v pnpm &> /dev/null; then
|
||||
missing_deps+=("pnpm")
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v node &> /dev/null; then
|
||||
missing_deps+=("node")
|
||||
fi
|
||||
|
||||
if [ ${#missing_deps[@]} -gt 0 ]; then
|
||||
log_error "缺少依赖: ${missing_deps[*]}"
|
||||
log_info "请安装后再运行,例如: brew install ${missing_deps[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "依赖已满足"
|
||||
}
|
||||
|
||||
# 获取当前模式下的命令前缀
|
||||
get_cmd_prefix() {
|
||||
case "${TEST_MODE}" in
|
||||
link)
|
||||
echo "qiming-file-server"
|
||||
;;
|
||||
direct)
|
||||
echo "node ${DIST_DIR}/cli.js"
|
||||
;;
|
||||
installed)
|
||||
echo "qiming-file-server"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 清理函数:停止服务
|
||||
cleanup() {
|
||||
log_info "清理环境(停止服务)..."
|
||||
local cmd_prefix=$(get_cmd_prefix)
|
||||
${cmd_prefix} stop 2>/dev/null || true
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# 退出时清理
|
||||
cleanup_exit() {
|
||||
local exit_code=$?
|
||||
|
||||
# 只在模式一下取消全局链接
|
||||
if [ "${TEST_MODE}" = "link" ]; then
|
||||
log_info "取消全局链接: pnpm unlink --global"
|
||||
(cd "${PROJECT_ROOT}" && pnpm unlink --global) 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
exit $exit_code
|
||||
fi
|
||||
}
|
||||
|
||||
# 测试健康检查端点
|
||||
test_health_endpoint() {
|
||||
log_info "测试健康检查端点..."
|
||||
|
||||
local response=$(curl -s "${HEALTH_URL}")
|
||||
local status=$(echo "${response}" | jq -r '.status' 2>/dev/null || echo "error")
|
||||
|
||||
if [ "${status}" = "ok" ]; then
|
||||
log_success "健康检查通过"
|
||||
log_info "响应: ${response}"
|
||||
return 0
|
||||
else
|
||||
log_error "健康检查失败,状态: ${status}"
|
||||
log_info "响应: ${response}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 测试服务状态查询
|
||||
test_status_command() {
|
||||
log_info "测试服务状态查询..."
|
||||
local cmd_prefix=$(get_cmd_prefix)
|
||||
${cmd_prefix} status
|
||||
log_success "状态查询完成"
|
||||
}
|
||||
|
||||
# 主测试流程
|
||||
run_tests() {
|
||||
get_mode_label
|
||||
show_config
|
||||
check_dependencies
|
||||
|
||||
log_section "开始测试"
|
||||
|
||||
# 根据模式执行不同的初始化
|
||||
case "${TEST_MODE}" in
|
||||
link)
|
||||
do_local_install
|
||||
;;
|
||||
direct)
|
||||
check_direct_mode
|
||||
;;
|
||||
installed)
|
||||
check_installed_mode
|
||||
;;
|
||||
esac
|
||||
|
||||
# 清理环境(停止可能残留的服务)
|
||||
cleanup
|
||||
|
||||
# ========== 测试 1: 启动服务 ==========
|
||||
log_section "测试 1: 启动服务"
|
||||
local cmd_prefix=$(get_cmd_prefix)
|
||||
test_command "${cmd_prefix} start --env production --port ${PORT}" "启动服务"
|
||||
sleep 3
|
||||
|
||||
# ========== 测试 2: 健康检查 ==========
|
||||
log_section "测试 2: 健康检查"
|
||||
test_health_endpoint
|
||||
|
||||
# ========== 测试 3: 状态查询 ==========
|
||||
log_section "测试 3: 状态查询"
|
||||
test_status_command
|
||||
|
||||
# ========== 测试 4: 重启服务 ==========
|
||||
log_section "测试 4: 重启服务"
|
||||
test_command "${cmd_prefix} restart" "重启服务"
|
||||
sleep 3
|
||||
|
||||
# ========== 测试 5: 重启后健康检查 ==========
|
||||
log_section "测试 5: 重启后健康检查"
|
||||
test_health_endpoint
|
||||
|
||||
# ========== 测试 6: 停止服务 ==========
|
||||
log_section "测试 6: 停止服务"
|
||||
test_command "${cmd_prefix} stop" "停止服务"
|
||||
|
||||
# ========== 完成 ==========
|
||||
log_section "测试完成"
|
||||
log_success "所有 CLI 测试通过!"
|
||||
|
||||
# 根据模式显示不同的后续提示
|
||||
echo ""
|
||||
case "${TEST_MODE}" in
|
||||
link)
|
||||
echo "已测试全局命令 'qiming-file-server'"
|
||||
;;
|
||||
direct)
|
||||
echo "已测试 'node ${DIST_DIR}/cli.js' 直接运行"
|
||||
;;
|
||||
installed)
|
||||
echo "已测试全局安装版本"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 显示帮助信息
|
||||
show_help() {
|
||||
echo "qiming-file-server CLI 测试脚本"
|
||||
echo ""
|
||||
echo "用法: $0 [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " -p, --port <端口> 指定测试端口 (默认: 60000)"
|
||||
echo " --direct 模式二: 直接用 node 运行 dist/"
|
||||
echo " --installed 模式三: 已全局安装,跳过所有安装步骤"
|
||||
echo " --project-dir <目录> 指定项目目录"
|
||||
echo " --nginx-dir <目录> 指定 Nginx 目录"
|
||||
echo " --upload-dir <目录> 指定上传目录"
|
||||
echo " -h, --help 显示帮助信息"
|
||||
echo ""
|
||||
echo "三种测试模式:"
|
||||
echo " 1. 默认模式 (pnpm link): ./test-cli.sh"
|
||||
echo " - 执行 pnpm install + pnpm run build + pnpm link"
|
||||
echo " - 使用全局命令 'qiming-file-server'"
|
||||
echo ""
|
||||
echo " 2. 直接运行模式: ./test-cli.sh --direct"
|
||||
echo " - 无需 pnpm link"
|
||||
echo " - 使用 'node dist/cli.js' 运行"
|
||||
echo ""
|
||||
echo " 3. 已安装模式: ./test-cli.sh --installed"
|
||||
echo " - 跳过所有安装步骤"
|
||||
echo " - 直接使用已全局安装的命令"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 # 模式一: pnpm link 全局命令"
|
||||
echo " $0 --direct # 模式二: node 直接运行"
|
||||
echo " $0 --installed # 模式三: 已全局安装"
|
||||
echo " $0 --port 60001 # 指定端口"
|
||||
echo " $0 --installed -p 60001 # 组合使用"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 解析命令行参数
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-p|--port)
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--direct)
|
||||
TEST_MODE="direct"
|
||||
shift
|
||||
;;
|
||||
--installed)
|
||||
TEST_MODE="installed"
|
||||
shift
|
||||
;;
|
||||
--project-dir)
|
||||
PROJECT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--nginx-dir)
|
||||
NGINX_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--upload-dir)
|
||||
UPLOAD_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "未知参数: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# 脚本入口
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
# 无论正常结束、失败或 Ctrl+C,退出时都执行清理
|
||||
trap 'cleanup_exit' EXIT
|
||||
|
||||
run_tests
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
162
qiming-file-server/src/appConfig/index.js
Normal file
162
qiming-file-server/src/appConfig/index.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// 应用配置模块(ESM):环境变量与运行时配置,与 src/config 的 Swagger 等区分
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config();
|
||||
|
||||
/** 解析日志根目录:若配置的路径无法创建(如本机无 /app),则回退到系统临时目录 */
|
||||
function resolveLogBaseDir() {
|
||||
const raw = process.env.LOG_BASE_DIR;
|
||||
const fallback = path.join(os.tmpdir(), "qiming-file-server", "project_logs");
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
fs.mkdirSync(raw, { recursive: true });
|
||||
return raw;
|
||||
} catch (_) {
|
||||
try {
|
||||
fs.mkdirSync(fallback, { recursive: true });
|
||||
} catch (__) {}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取环境变量,默认为 development
|
||||
const env = process.env.NODE_ENV || "development";
|
||||
|
||||
// 加载环境变量文件(env 位于 appConfig 同级目录:src/env.* 或 dist/env.*)
|
||||
function loadEnvFile(envName) {
|
||||
const envFile = path.join(__dirname, "..", `env.${envName}`);
|
||||
if (fs.existsSync(envFile)) {
|
||||
dotenv.config({ path: envFile });
|
||||
console.log(`Environment configuration file env.${envName} loaded`);
|
||||
} else {
|
||||
const message = `Environment configuration file env.${envName} does not exist, please create the corresponding environment configuration file and try again`;
|
||||
console.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载对应环境的配置文件
|
||||
loadEnvFile(env);
|
||||
|
||||
// 从环境变量构建配置对象
|
||||
const config = {
|
||||
NODE_ENV: env,
|
||||
PORT: parseInt(process.env.PORT),
|
||||
INIT_PROJECT_NAME_REACT:
|
||||
process.env.INIT_PROJECT_NAME_REACT || "react-vite-template",
|
||||
INIT_PROJECT_NAME_VUE3:
|
||||
process.env.INIT_PROJECT_NAME_VUE3 || "vue3-vite-template",
|
||||
INIT_PROJECT_DIR: process.env.INIT_PROJECT_DIR,
|
||||
PROJECT_SOURCE_DIR: process.env.PROJECT_SOURCE_DIR,
|
||||
DIST_TARGET_DIR: process.env.DIST_TARGET_DIR,
|
||||
UPLOAD_PROJECT_DIR: process.env.UPLOAD_PROJECT_DIR,
|
||||
MAX_BUILD_CONCURRENCY: process.env.MAX_BUILD_CONCURRENCY
|
||||
? parseInt(process.env.MAX_BUILD_CONCURRENCY, 10)
|
||||
: undefined,
|
||||
MAX_INLINE_FILE_SIZE_BYTES: process.env.MAX_INLINE_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.MAX_INLINE_FILE_SIZE_BYTES, 10)
|
||||
: undefined,
|
||||
UPLOAD_MAX_FILE_SIZE_BYTES: process.env.UPLOAD_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.UPLOAD_MAX_FILE_SIZE_BYTES, 10)
|
||||
: undefined,
|
||||
UPLOAD_ALLOWED_EXTENSIONS: process.env.UPLOAD_ALLOWED_EXTENSIONS
|
||||
? process.env.UPLOAD_ALLOWED_EXTENSIONS.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
UPLOAD_SINGLE_FILE_SIZE_BYTES: process.env.UPLOAD_SINGLE_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.UPLOAD_SINGLE_FILE_SIZE_BYTES, 10)
|
||||
: undefined,
|
||||
DOWNLOAD_MAX_FILE_SIZE_BYTES: process.env.DOWNLOAD_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.DOWNLOAD_MAX_FILE_SIZE_BYTES, 10)
|
||||
: undefined,
|
||||
REQUEST_BODY_LIMIT: process.env.REQUEST_BODY_LIMIT,
|
||||
TRAVERSE_EXCLUDE_DIRS: process.env.TRAVERSE_EXCLUDE_DIRS
|
||||
? process.env.TRAVERSE_EXCLUDE_DIRS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
BACKUP_TRAVERSE_EXCLUDE_FILES: process.env.BACKUP_TRAVERSE_EXCLUDE_FILES
|
||||
? process.env.BACKUP_TRAVERSE_EXCLUDE_FILES.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
CONTENT_TRAVERSE_EXCLUDE_FILES: process.env.CONTENT_TRAVERSE_EXCLUDE_FILES
|
||||
? process.env.CONTENT_TRAVERSE_EXCLUDE_FILES.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
INLINE_IMAGE_EXTENSIONS: process.env.INLINE_IMAGE_EXTENSIONS
|
||||
? process.env.INLINE_IMAGE_EXTENSIONS.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
TOP_LEVEL_NOISE_PATTERNS: process.env.TOP_LEVEL_NOISE_PATTERNS
|
||||
? process.env.TOP_LEVEL_NOISE_PATTERNS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
LOG_BASE_DIR: resolveLogBaseDir(),
|
||||
LOG_LEVEL: process.env.LOG_LEVEL
|
||||
? process.env.LOG_LEVEL.toLowerCase()
|
||||
: undefined,
|
||||
LOG_PREFIX_API: process.env.LOG_PREFIX_API,
|
||||
LOG_PREFIX_BUILD: process.env.LOG_PREFIX_BUILD,
|
||||
LOG_CONSOLE_ENABLED:
|
||||
typeof process.env.LOG_CONSOLE_ENABLED === "string"
|
||||
? process.env.LOG_CONSOLE_ENABLED.toLowerCase() === "true"
|
||||
: undefined,
|
||||
LOG_CACHE_ENABLED:
|
||||
typeof process.env.LOG_CACHE_ENABLED === "string"
|
||||
? process.env.LOG_CACHE_ENABLED.toLowerCase() === "true"
|
||||
: undefined,
|
||||
LOG_CACHE_DURATION: process.env.LOG_CACHE_DURATION
|
||||
? parseInt(process.env.LOG_CACHE_DURATION, 10)
|
||||
: undefined,
|
||||
LOG_CACHE_MAX_ENTRIES: process.env.LOG_CACHE_MAX_ENTRIES
|
||||
? parseInt(process.env.LOG_CACHE_MAX_ENTRIES, 10)
|
||||
: undefined,
|
||||
LOG_CACHE_MAX_FILE_SIZE: process.env.LOG_CACHE_MAX_FILE_SIZE
|
||||
? parseInt(process.env.LOG_CACHE_MAX_FILE_SIZE, 10)
|
||||
: undefined,
|
||||
DEV_SERVER_PORT_TIMEOUT: process.env.DEV_SERVER_PORT_TIMEOUT
|
||||
? parseInt(process.env.DEV_SERVER_PORT_TIMEOUT, 10)
|
||||
: undefined,
|
||||
DEV_SERVER_STOP_TIMEOUT: process.env.DEV_SERVER_STOP_TIMEOUT
|
||||
? parseInt(process.env.DEV_SERVER_STOP_TIMEOUT, 10)
|
||||
: undefined,
|
||||
DEV_SERVER_STOP_CHECK_INTERVAL: process.env.DEV_SERVER_STOP_CHECK_INTERVAL
|
||||
? parseInt(process.env.DEV_SERVER_STOP_CHECK_INTERVAL, 10)
|
||||
: undefined,
|
||||
DEV_SERVER_STOP_MAX_ATTEMPTS: process.env.DEV_SERVER_STOP_MAX_ATTEMPTS
|
||||
? parseInt(process.env.DEV_SERVER_STOP_MAX_ATTEMPTS, 10)
|
||||
: undefined,
|
||||
COMPUTER_WORKSPACE_DIR: process.env.COMPUTER_WORKSPACE_DIR,
|
||||
COMPUTER_LOG_DIR: process.env.COMPUTER_LOG_DIR,
|
||||
CLI_SERVICE_NAME: "qiming-file-server",
|
||||
CLI_PID_DIR: process.env.CLI_PID_DIR || (
|
||||
process.platform === "win32"
|
||||
? path.join(process.env.TEMP || "", "qiming-file-server")
|
||||
: path.join("/tmp", "qiming-file-server")
|
||||
),
|
||||
CLI_PID_FILE: "server.pid",
|
||||
CLI_STOP_TIMEOUT: process.env.CLI_STOP_TIMEOUT
|
||||
? parseInt(process.env.CLI_STOP_TIMEOUT, 10)
|
||||
: 30000,
|
||||
CLI_CHECK_INTERVAL: process.env.CLI_CHECK_INTERVAL
|
||||
? parseInt(process.env.CLI_CHECK_INTERVAL, 10)
|
||||
: 500,
|
||||
CLI_LOG_DIR: process.env.CLI_LOG_DIR || (
|
||||
process.platform === "win32"
|
||||
? path.join(process.env.TEMP || "", "qiming-file-server", "logs")
|
||||
: path.join("/tmp", "qiming-file-server", "logs")
|
||||
),
|
||||
CLI_IS_WINDOWS: process.platform === "win32",
|
||||
};
|
||||
|
||||
export default config;
|
||||
220
qiming-file-server/src/cli.js
Normal file
220
qiming-file-server/src/cli.js
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* qiming-file-server CLI 入口
|
||||
*
|
||||
* 设计原则:
|
||||
* - CLI 只负责参数解析与输出
|
||||
* - 服务生命周期逻辑统一收敛到 serviceManager
|
||||
*/
|
||||
|
||||
import { Command } from "commander";
|
||||
import { createRequire } from "module";
|
||||
import {
|
||||
SERVICE_CONFIG,
|
||||
startService,
|
||||
stopService,
|
||||
restartService,
|
||||
getServiceStatus,
|
||||
getPidFilePath,
|
||||
formatUptime,
|
||||
} from "./utils/serviceManager.js";
|
||||
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
const version = typeof __BUILD_VERSION__ !== "undefined" ? __BUILD_VERSION__ : esmRequire("../package.json").version;
|
||||
|
||||
const program = new Command();
|
||||
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function error(message) {
|
||||
console.error(`${colors.red}ERROR: ${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function success(message) {
|
||||
console.log(`${colors.green}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function info(message) {
|
||||
console.log(`${colors.blue}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一执行命令动作并设置退出码
|
||||
* @param {Function} action 命令动作
|
||||
* @returns {Function} commander action
|
||||
*/
|
||||
function runCommandAction(action) {
|
||||
return async (...args) => {
|
||||
try {
|
||||
const result = await action(...args);
|
||||
if (result && result.success === false) {
|
||||
error(result.message || 'Command failed');
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.exitCode = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
error(err?.message || 'Command failed with unexpected error');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* start 命令
|
||||
*
|
||||
* @param {Object} options - commander 参数
|
||||
* @returns {Promise<Object>} 执行结果
|
||||
*/
|
||||
async function runStartCommand(options) {
|
||||
const result = await startService({
|
||||
env: options.env,
|
||||
port: options.port,
|
||||
config: options.config,
|
||||
timeout: Number(options.timeout),
|
||||
startTimeout: Number(options.startTimeout),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
success(`Service started (PID: ${result.pid})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* stop 命令
|
||||
*
|
||||
* @param {Object} options - commander 参数
|
||||
* @returns {Promise<Object>} 执行结果
|
||||
*/
|
||||
async function runStopCommand(options) {
|
||||
const result = await stopService({
|
||||
force: options.force,
|
||||
timeout: Number(options.timeout),
|
||||
});
|
||||
if (result.success) {
|
||||
success(result.message || 'Service stopped');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* restart 命令
|
||||
*
|
||||
* @param {Object} options - commander 参数
|
||||
* @returns {Promise<Object>} 执行结果
|
||||
*/
|
||||
async function runRestartCommand(options) {
|
||||
const result = await restartService({
|
||||
env: options.env,
|
||||
port: options.port,
|
||||
config: options.config,
|
||||
timeout: Number(options.timeout),
|
||||
startTimeout: Number(options.startTimeout),
|
||||
});
|
||||
if (result.success) {
|
||||
success(result.message || 'Service restarted');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* status 命令
|
||||
*
|
||||
* @returns {Object} 执行结果
|
||||
*/
|
||||
function runStatusCommand() {
|
||||
const status = getServiceStatus();
|
||||
|
||||
info(`${SERVICE_CONFIG.name} service status:`);
|
||||
console.log('');
|
||||
console.log(` Service name: ${SERVICE_CONFIG.name}`);
|
||||
console.log(` Running status: ${status.running ? 'Running' : 'Stopped'}`);
|
||||
console.log(` Message: ${status.message}`);
|
||||
console.log(` PID file: ${getPidFilePath()}`);
|
||||
|
||||
if (status.pidInfo) {
|
||||
console.log(` Process ID: ${status.pidInfo.pid}`);
|
||||
console.log(` Environment: ${status.pidInfo.env || 'Unknown'}`);
|
||||
console.log(` Port: ${status.pidInfo.port || 'Unknown'}`);
|
||||
console.log(` Version: ${status.pidInfo.version || version}`);
|
||||
console.log(` Platform: ${status.pidInfo.platform || process.platform}`);
|
||||
console.log(` Started at: ${status.pidInfo.startedAt || 'Unknown'}`);
|
||||
console.log(` Uptime: ${formatUptime(status.pidInfo.startedAt)}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
return {
|
||||
success: status.running,
|
||||
message: status.message,
|
||||
};
|
||||
}
|
||||
|
||||
// 主程序
|
||||
function main() {
|
||||
program
|
||||
.name('qiming-file-server')
|
||||
.description('Cross-platform file service deployment tool, supporting start/stop/restart/status')
|
||||
.version(version, '-v, --version', 'Display version number')
|
||||
.helpOption('-h, --help', 'Display help information');
|
||||
|
||||
program
|
||||
.command('start').allowUnknownOption()
|
||||
.description('Start service')
|
||||
.option('--env <environment>', '环境: development|production|test', 'production')
|
||||
.option('--port <port>', 'Service port')
|
||||
.option('--config <path>', 'Custom configuration file path')
|
||||
.option('--timeout <ms>', '停止旧进程等待超时(毫秒)', `${SERVICE_CONFIG.defaultStopTimeout}`)
|
||||
.option('--start-timeout <ms>', '启动健康检查超时(毫秒)', `${SERVICE_CONFIG.defaultStartTimeout}`)
|
||||
.action(runCommandAction(runStartCommand));
|
||||
|
||||
program
|
||||
.command('stop')
|
||||
.description('Stop service')
|
||||
.option('--force', 'Force stop')
|
||||
.option('--timeout <ms>', '停止服务等待超时(毫秒)', `${SERVICE_CONFIG.defaultStopTimeout}`)
|
||||
.action(runCommandAction(runStopCommand));
|
||||
|
||||
program
|
||||
.command('restart').allowUnknownOption()
|
||||
.description('Restart service')
|
||||
.option('--env <environment>', '环境: development|production|test', 'production')
|
||||
.option('--port <port>', 'Service port')
|
||||
.option('--config <path>', 'Custom configuration file path')
|
||||
.option('--timeout <ms>', '停止旧进程等待超时(毫秒)', `${SERVICE_CONFIG.defaultStopTimeout}`)
|
||||
.option('--start-timeout <ms>', '启动健康检查超时(毫秒)', `${SERVICE_CONFIG.defaultStartTimeout}`)
|
||||
.action(runCommandAction(runRestartCommand));
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('View service status')
|
||||
.action(runCommandAction(runStatusCommand));
|
||||
|
||||
program
|
||||
.command('help')
|
||||
.description('Display help information')
|
||||
.action(() => {
|
||||
program.outputHelp();
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp();
|
||||
process.exitCode = 0;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
58
qiming-file-server/src/config/swagger.js
Normal file
58
qiming-file-server/src/config/swagger.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import swaggerJsdoc from "swagger-jsdoc";
|
||||
import config from "../appConfig/index.js";
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Application Builder API",
|
||||
version: "1.0.0",
|
||||
description: "Application builder API documentation",
|
||||
contact: {
|
||||
name: "API Support",
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: `http://localhost:${config.PORT}`,
|
||||
description: `${config.NODE_ENV} environment`,
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{ name: "Build", description: "Build related interfaces" },
|
||||
{ name: "Project", description: "Project management interfaces" },
|
||||
{ name: "Code", description: "Code submission interfaces" },
|
||||
],
|
||||
components: {
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean", example: false },
|
||||
error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", example: "VALIDATION_ERROR" },
|
||||
message: { type: "string", example: "Project ID cannot be empty" },
|
||||
timestamp: { type: "string", format: "date-time" },
|
||||
requestId: { type: "string" },
|
||||
details: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Success: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean", example: true },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
apis: ["./src/config/swagger/*.js"],
|
||||
};
|
||||
|
||||
const specs = swaggerJsdoc(options);
|
||||
export default specs;
|
||||
222
qiming-file-server/src/config/swagger/buildDocs.js
Normal file
222
qiming-file-server/src/config/swagger/buildDocs.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Build
|
||||
* description: Build and dev server management APIs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/build/start-dev:
|
||||
* get:
|
||||
* summary: Start the dev server
|
||||
* tags: [Build]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: projectId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* - in: query
|
||||
* name: basePath
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Base path (Vite projects only)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Dev server started
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* projectId:
|
||||
* type: string
|
||||
* pid:
|
||||
* type: number
|
||||
* port:
|
||||
* type: number
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/build/build:
|
||||
* get:
|
||||
* summary: Build the project
|
||||
* tags: [Build]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: projectId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* - in: query
|
||||
* name: basePath
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Base path (Vite projects only)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Build successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* projectId:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Invalid parameters or max concurrency reached
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/build/stop-dev:
|
||||
* get:
|
||||
* summary: Stop the dev server
|
||||
* tags: [Build]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: projectId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* - in: query
|
||||
* name: pid
|
||||
* required: true
|
||||
* schema:
|
||||
* type: number
|
||||
* description: Process ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Dev server stopped
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* projectId:
|
||||
* type: string
|
||||
* pid:
|
||||
* type: number
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/build/restart-dev:
|
||||
* get:
|
||||
* summary: Restart the dev server
|
||||
* tags: [Build]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: projectId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* - in: query
|
||||
* name: pid
|
||||
* schema:
|
||||
* type: number
|
||||
* description: Process ID (optional)
|
||||
* - in: query
|
||||
* name: basePath
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Base path (Vite projects only)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Dev server restarted
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/build/list-dev:
|
||||
* get:
|
||||
* summary: List running dev servers
|
||||
* tags: [Build]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* list:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* projectId:
|
||||
* type: string
|
||||
* pid:
|
||||
* type: number
|
||||
* port:
|
||||
* type: number
|
||||
* startedAt:
|
||||
* type: number
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/build/parse-build-error:
|
||||
* post:
|
||||
* summary: Parse build error output
|
||||
* tags: [Build]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - projectId
|
||||
* - errorMessage
|
||||
* properties:
|
||||
* projectId:
|
||||
* type: string
|
||||
* errorMessage:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Parsed successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
*/
|
||||
|
||||
export default {};
|
||||
134
qiming-file-server/src/config/swagger/codeDocs.js
Normal file
134
qiming-file-server/src/config/swagger/codeDocs.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Code
|
||||
* description: Code file management APIs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/project/submit-files-update:
|
||||
* post:
|
||||
* summary: Submit file updates and restart the dev server
|
||||
* tags: [Code]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - projectId
|
||||
* - codeVersion
|
||||
* - files
|
||||
* properties:
|
||||
* projectId:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* codeVersion:
|
||||
* type: string
|
||||
* description: Code version
|
||||
* files:
|
||||
* type: array
|
||||
* description: Files to update
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* contents:
|
||||
* type: string
|
||||
* binary:
|
||||
* type: boolean
|
||||
* sizeExceeded:
|
||||
* type: boolean
|
||||
* basePath:
|
||||
* type: string
|
||||
* description: Base path (optional)
|
||||
* pid:
|
||||
* type: number
|
||||
* description: Process ID (optional)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Submitted successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* projectId:
|
||||
* type: string
|
||||
* pid:
|
||||
* type: number
|
||||
* port:
|
||||
* type: number
|
||||
* restarted:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/project/upload-single-file:
|
||||
* post:
|
||||
* summary: Upload a single file
|
||||
* tags: [Code]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - projectId
|
||||
* - codeVersion
|
||||
* - file
|
||||
* - filePath
|
||||
* properties:
|
||||
* projectId:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* codeVersion:
|
||||
* type: string
|
||||
* description: Code version
|
||||
* filePath:
|
||||
* type: string
|
||||
* description: Relative path of the file inside the project
|
||||
* file:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: File contents
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Upload successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* projectId:
|
||||
* type: string
|
||||
* restarted:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
export default {};
|
||||
267
qiming-file-server/src/config/swagger/projectDocs.js
Normal file
267
qiming-file-server/src/config/swagger/projectDocs.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: Project
|
||||
* description: Project management APIs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/project/create-project:
|
||||
* post:
|
||||
* summary: Create a new project
|
||||
* tags: [Project]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - projectId
|
||||
* properties:
|
||||
* projectId:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Project created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* projectPath:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/project/upload-start-dev:
|
||||
* post:
|
||||
* summary: Upload project archive and start the dev server
|
||||
* tags: [Project]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - projectId
|
||||
* - file
|
||||
* properties:
|
||||
* projectId:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* file:
|
||||
* type: string
|
||||
* format: binary
|
||||
* description: Project archive (.zip)
|
||||
* basePath:
|
||||
* type: string
|
||||
* description: Base path (Vite projects only)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Uploaded and dev server started
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* projectId:
|
||||
* type: string
|
||||
* pid:
|
||||
* type: number
|
||||
* port:
|
||||
* type: number
|
||||
* 400:
|
||||
* description: Invalid parameters or file format
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/project/get-project-content:
|
||||
* get:
|
||||
* summary: Get project content
|
||||
* tags: [Project]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: projectId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Project content retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* contents:
|
||||
* type: string
|
||||
* binary:
|
||||
* type: boolean
|
||||
* sizeExceeded:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/project/backup-current-version:
|
||||
* post:
|
||||
* summary: Backup the current version
|
||||
* tags: [Project]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - projectId
|
||||
* - codeVersion
|
||||
* properties:
|
||||
* projectId:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* codeVersion:
|
||||
* type: string
|
||||
* description: Code version
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Backup successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* message:
|
||||
* type: string
|
||||
* zipPath:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/project/export-project:
|
||||
* get:
|
||||
* summary: Export project as a ZIP archive
|
||||
* tags: [Project]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: projectId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Project ZIP file download
|
||||
* content:
|
||||
* application/zip:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/project/get-project-content-by-version:
|
||||
* get:
|
||||
* summary: Get project content for a specific version
|
||||
* tags: [Project]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: projectId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Project ID
|
||||
* - in: query
|
||||
* name: codeVersion
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Code version
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Project content retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* files:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* contents:
|
||||
* type: string
|
||||
* binary:
|
||||
* type: boolean
|
||||
* sizeExceeded:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Invalid parameters
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
|
||||
export default {};
|
||||
85
qiming-file-server/src/env.development
Normal file
85
qiming-file-server/src/env.development
Normal file
@@ -0,0 +1,85 @@
|
||||
# 开发环境配置
|
||||
NODE_ENV=development
|
||||
PORT=16000
|
||||
|
||||
# 日志配置
|
||||
LOG_BASE_DIR=/app/logs/project_logs
|
||||
LOG_LEVEL=debug
|
||||
LOG_CONSOLE_ENABLED=true
|
||||
LOG_PREFIX_API=api
|
||||
LOG_PREFIX_BUILD=build
|
||||
|
||||
# 系统内置初始化工程
|
||||
INIT_PROJECT_NAME_REACT=react-vite-template
|
||||
INIT_PROJECT_NAME_VUE3=vue3-vite-template
|
||||
INIT_PROJECT_DIR=/app/project_init
|
||||
|
||||
#上传的项目压缩包所在路径
|
||||
UPLOAD_PROJECT_DIR=/app/project_zips
|
||||
|
||||
#项目源文件所在路径
|
||||
PROJECT_SOURCE_DIR=/app/project_workspace
|
||||
|
||||
#dist拷贝到的目标目录,nginx加载
|
||||
DIST_TARGET_DIR=/app/project_nginx
|
||||
|
||||
#build最大并发数
|
||||
MAX_BUILD_CONCURRENCY=20
|
||||
|
||||
#单文件大小限制,如果超大就不返回内容(用于判断查询文件内容时是否超大;1M)
|
||||
MAX_INLINE_FILE_SIZE_BYTES=1048576
|
||||
|
||||
# 上传限制(用户上传压缩包;1000M)
|
||||
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
|
||||
UPLOAD_ALLOWED_EXTENSIONS=.zip
|
||||
|
||||
# 单文件上传大小限制(用于上传工程单文件;1000M)
|
||||
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
|
||||
|
||||
# 打包下载文件大小限制(字节;100M)
|
||||
DOWNLOAD_MAX_FILE_SIZE_BYTES=104857600
|
||||
|
||||
# 请求体大小限制(Express 格式)
|
||||
REQUEST_BODY_LIMIT=2000mb
|
||||
|
||||
# 目录遍历排除(返回内容、归档等)
|
||||
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
|
||||
|
||||
# 归档遍历排除文件
|
||||
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
|
||||
# 返回项目内容遍历排除文件
|
||||
CONTENT_TRAVERSE_EXCLUDE_FILES=AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
|
||||
# 允许内联返回内容的图片扩展名
|
||||
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
|
||||
|
||||
# 顶层噪声条目(用于解压后扁平化判断)
|
||||
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
|
||||
|
||||
# 开发服务器配置
|
||||
DEV_SERVER_PORT_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_CHECK_INTERVAL=100
|
||||
DEV_SERVER_STOP_MAX_ATTEMPTS=50
|
||||
|
||||
# pnpm清理配置
|
||||
PNPM_PRUNE_ENABLED=true
|
||||
PNPM_PRUNE_SCHEDULE=0 2 * * *
|
||||
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
|
||||
PNPM_PRUNE_RUN_ON_START=false
|
||||
|
||||
# 日志缓存配置
|
||||
# 是否启用日志缓存
|
||||
LOG_CACHE_ENABLED=true
|
||||
# 缓存过期时间(毫秒)默认3分钟
|
||||
LOG_CACHE_DURATION=180000
|
||||
# 最大缓存项目数量
|
||||
LOG_CACHE_MAX_ENTRIES=100
|
||||
# 最大缓存文件大小(字节)默认2MB,超过此大小的文件不缓存
|
||||
LOG_CACHE_MAX_FILE_SIZE=2097152
|
||||
|
||||
# computer 工作目录
|
||||
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace
|
||||
# computer 日志目录
|
||||
COMPUTER_LOG_DIR=/app/logs/computer_logs
|
||||
85
qiming-file-server/src/env.production
Normal file
85
qiming-file-server/src/env.production
Normal file
@@ -0,0 +1,85 @@
|
||||
# 生产环境配置
|
||||
NODE_ENV=production
|
||||
PORT=60000
|
||||
|
||||
# 日志配置
|
||||
LOG_BASE_DIR=/app/logs/project_logs
|
||||
LOG_LEVEL=debug
|
||||
LOG_CONSOLE_ENABLED=true
|
||||
LOG_PREFIX_API=api
|
||||
LOG_PREFIX_BUILD=build
|
||||
|
||||
# 系统内置初始化工程
|
||||
INIT_PROJECT_NAME_REACT=react-vite-template
|
||||
INIT_PROJECT_NAME_VUE3=vue3-vite-template
|
||||
INIT_PROJECT_DIR=/app/project_init
|
||||
|
||||
#上传的项目压缩包所在路径
|
||||
UPLOAD_PROJECT_DIR=/app/project_zips
|
||||
|
||||
#项目源文件所在路径
|
||||
PROJECT_SOURCE_DIR=/app/project_workspace
|
||||
|
||||
#dist拷贝到的目标目录,nginx加载
|
||||
DIST_TARGET_DIR=/app/project_nginx
|
||||
|
||||
#build最大并发数
|
||||
MAX_BUILD_CONCURRENCY=20
|
||||
|
||||
#单文件大小限制,如果超大就不返回内容(用于判断查询文件内容时是否超大;1M)
|
||||
MAX_INLINE_FILE_SIZE_BYTES=1048576
|
||||
|
||||
# 上传限制(用户上传压缩包;1000M)
|
||||
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
|
||||
UPLOAD_ALLOWED_EXTENSIONS=.zip
|
||||
|
||||
# 单文件上传大小限制(用于上传工程单文件;1000M)
|
||||
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
|
||||
|
||||
# 打包下载文件大小限制(字节;100M)
|
||||
DOWNLOAD_MAX_FILE_SIZE_BYTES=104857600
|
||||
|
||||
# 请求体大小限制(Express 格式)
|
||||
REQUEST_BODY_LIMIT=2000mb
|
||||
|
||||
# 目录遍历排除(返回内容、归档等)
|
||||
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
|
||||
|
||||
# 归档遍历排除文件
|
||||
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
|
||||
# 返回项目内容遍历排除文件
|
||||
CONTENT_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
|
||||
# 允许内联返回内容的图片扩展名
|
||||
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
|
||||
|
||||
# 顶层噪声条目(用于解压后扁平化判断)
|
||||
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
|
||||
|
||||
# 开发服务器配置
|
||||
DEV_SERVER_PORT_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_CHECK_INTERVAL=100
|
||||
DEV_SERVER_STOP_MAX_ATTEMPTS=50
|
||||
|
||||
# pnpm清理配置
|
||||
PNPM_PRUNE_ENABLED=true
|
||||
PNPM_PRUNE_SCHEDULE=0 2 * * *
|
||||
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
|
||||
PNPM_PRUNE_RUN_ON_START=false
|
||||
|
||||
# 日志缓存配置
|
||||
# 是否启用日志缓存
|
||||
LOG_CACHE_ENABLED=true
|
||||
# 缓存过期时间(毫秒)默认3分钟
|
||||
LOG_CACHE_DURATION=180000
|
||||
# 最大缓存项目数量
|
||||
LOG_CACHE_MAX_ENTRIES=100
|
||||
# 最大缓存文件大小(字节)默认2MB,超过此大小的文件不缓存
|
||||
LOG_CACHE_MAX_FILE_SIZE=2097152
|
||||
|
||||
# computer 工作目录
|
||||
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace
|
||||
# computer 日志目录
|
||||
COMPUTER_LOG_DIR=/app/logs/computer_logs
|
||||
85
qiming-file-server/src/env.test
Normal file
85
qiming-file-server/src/env.test
Normal file
@@ -0,0 +1,85 @@
|
||||
# 生产环境配置
|
||||
NODE_ENV=test
|
||||
PORT=60000
|
||||
|
||||
# 日志配置
|
||||
LOG_BASE_DIR=/app/logs/project_logs
|
||||
LOG_LEVEL=debug
|
||||
LOG_CONSOLE_ENABLED=true
|
||||
LOG_PREFIX_API=api
|
||||
LOG_PREFIX_BUILD=build
|
||||
|
||||
# 系统内置初始化工程
|
||||
INIT_PROJECT_NAME_REACT=react-vite-template
|
||||
INIT_PROJECT_NAME_VUE3=vue3-vite-template
|
||||
INIT_PROJECT_DIR=/app/project_init
|
||||
|
||||
#上传的项目压缩包所在路径
|
||||
UPLOAD_PROJECT_DIR=/app/project_zips
|
||||
|
||||
#项目源文件所在路径
|
||||
PROJECT_SOURCE_DIR=/app/project_workspace
|
||||
|
||||
#dist拷贝到的目标目录,nginx加载
|
||||
DIST_TARGET_DIR=/app/project_nginx
|
||||
|
||||
#build最大并发数
|
||||
MAX_BUILD_CONCURRENCY=20
|
||||
|
||||
#单文件大小限制,如果超大就不返回内容(用于判断查询文件内容时是否超大;1M)
|
||||
MAX_INLINE_FILE_SIZE_BYTES=1048576
|
||||
|
||||
# 上传限制(用户上传压缩包;1000M)
|
||||
UPLOAD_MAX_FILE_SIZE_BYTES=1048576000
|
||||
UPLOAD_ALLOWED_EXTENSIONS=.zip
|
||||
|
||||
# 单文件上传大小限制(用于上传工程单文件;1000M)
|
||||
UPLOAD_SINGLE_FILE_SIZE_BYTES=1048576000
|
||||
|
||||
# 打包下载文件大小限制(字节;100M)
|
||||
DOWNLOAD_MAX_FILE_SIZE_BYTES=104857600
|
||||
|
||||
# 请求体大小限制(Express 格式)
|
||||
REQUEST_BODY_LIMIT=2000mb
|
||||
|
||||
# 目录遍历排除(返回内容、归档等)
|
||||
TRAVERSE_EXCLUDE_DIRS=dist,node_modules,.pnpm-store,__MACOSX,.attachments
|
||||
|
||||
# 归档遍历排除文件
|
||||
BACKUP_TRAVERSE_EXCLUDE_FILES=pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
|
||||
# 返回项目内容遍历排除文件
|
||||
CONTENT_TRAVERSE_EXCLUDE_FILES=AGENT.md,AGENTS.md,CLAUDE.md,pnpm-lock.yaml,yarn.lock,package-lock.json
|
||||
|
||||
# 允许内联返回内容的图片扩展名
|
||||
INLINE_IMAGE_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.svg,.ico,.webp,.avif
|
||||
|
||||
# 顶层噪声条目(用于解压后扁平化判断)
|
||||
TOP_LEVEL_NOISE_PATTERNS=__MACOSX,Thumbs.db,node_modules,.pnpm-store,.attachments
|
||||
|
||||
# 开发服务器配置
|
||||
DEV_SERVER_PORT_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_TIMEOUT=5000
|
||||
DEV_SERVER_STOP_CHECK_INTERVAL=100
|
||||
DEV_SERVER_STOP_MAX_ATTEMPTS=50
|
||||
|
||||
# pnpm清理配置
|
||||
PNPM_PRUNE_ENABLED=true
|
||||
PNPM_PRUNE_SCHEDULE=0 2 * * *
|
||||
PNPM_PRUNE_TIMEZONE=Asia/Shanghai
|
||||
PNPM_PRUNE_RUN_ON_START=false
|
||||
|
||||
# 日志缓存配置
|
||||
# 是否启用日志缓存
|
||||
LOG_CACHE_ENABLED=true
|
||||
# 缓存过期时间(毫秒)默认3分钟
|
||||
LOG_CACHE_DURATION=180000
|
||||
# 最大缓存项目数量
|
||||
LOG_CACHE_MAX_ENTRIES=100
|
||||
# 最大缓存文件大小(字节)默认2MB,超过此大小的文件不缓存
|
||||
LOG_CACHE_MAX_FILE_SIZE=2097152
|
||||
|
||||
# computer 工作目录
|
||||
COMPUTER_WORKSPACE_DIR=/app/computer-project-workspace
|
||||
# computer 日志目录
|
||||
COMPUTER_LOG_DIR=/app/logs/computer_logs
|
||||
231
qiming-file-server/src/routes/buildRoutes.js
Normal file
231
qiming-file-server/src/routes/buildRoutes.js
Normal file
@@ -0,0 +1,231 @@
|
||||
import express from "express";
|
||||
import { startDevServer } from "../utils/build/startDevUtils.js";
|
||||
import { restartDevServer } from "../utils/build/restartDevUtils.js";
|
||||
import { keepAliveDevServer } from "../utils/build/keepAliveDevUtils.js";
|
||||
import { stopDevServer } from "../utils/build/stopDevUtils.js";
|
||||
import { buildProject } from "../utils/build/buildProjectUtils.js";
|
||||
import { listRunningProcesses } from "../utils/build/processManager.js";
|
||||
import BuildErrorParser from "../utils/error/buildErrorParser.js";
|
||||
import { getDevLog } from "../utils/log/getDevLogUtils.js";
|
||||
import logCacheManager from "../utils/log/logCacheManager.js";
|
||||
import portPool from "../utils/buildArg/portPool.js";
|
||||
import {
|
||||
ValidationError,
|
||||
BusinessError,
|
||||
SystemError,
|
||||
ProcessError,
|
||||
asyncHandler,
|
||||
} from "../utils/error/errorHandler.js";
|
||||
|
||||
const buildRouter = express.Router();
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: "/start-dev",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const projectId = req.query.projectId;
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
|
||||
// 设置长超时时间:依赖安装可能需要较长时间
|
||||
req.setTimeout(600000); // 10分钟
|
||||
res.setTimeout(600000); // 10分钟
|
||||
|
||||
const result = await startDevServer(req, String(projectId));
|
||||
res.json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/keep-alive",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const projectId = req.query.projectId;
|
||||
const pid = req.query.pid;
|
||||
const port = req.query.port;
|
||||
const basePath = req.query.basePath;
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (!pid) {
|
||||
throw new ValidationError("Process ID cannot be empty", { field: "pid" });
|
||||
}
|
||||
if (!port) {
|
||||
throw new ValidationError("Port cannot be empty", { field: "port" });
|
||||
}
|
||||
if (!basePath) {
|
||||
throw new ValidationError("basePath cannot be empty", { field: "basePath" });
|
||||
}
|
||||
|
||||
const result = await keepAliveDevServer(
|
||||
req,
|
||||
String(projectId),
|
||||
pid,
|
||||
port,
|
||||
basePath,
|
||||
);
|
||||
res.json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/build",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const projectId = req.query.projectId;
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
|
||||
// 设置长超时时间:构建可能需要较长时间
|
||||
req.setTimeout(600000); // 10分钟
|
||||
res.setTimeout(600000); // 10分钟
|
||||
|
||||
const result = await buildProject(req, String(projectId));
|
||||
res.json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/stop-dev",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const projectId = req.query.projectId;
|
||||
const pid = req.query.pid;
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (!pid) {
|
||||
throw new ValidationError("Process ID cannot be empty", { field: "pid" });
|
||||
}
|
||||
|
||||
const result = await stopDevServer(req, String(projectId), pid, {
|
||||
strict: true,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/restart-dev",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const projectId = req.query.projectId;
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
|
||||
// 设置长超时时间:重启包含依赖安装,可能需要较长时间
|
||||
req.setTimeout(600000); // 10分钟
|
||||
res.setTimeout(600000); // 10分钟
|
||||
|
||||
const result = await restartDevServer(req, String(projectId));
|
||||
res.json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/list-dev",
|
||||
method: "get",
|
||||
handler: (req, res) => {
|
||||
const list = listRunningProcesses();
|
||||
const result = { success: true, list };
|
||||
res.json(result);
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/parse-build-error",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, errorMessage } = req.body;
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (!errorMessage) {
|
||||
throw new ValidationError("Error message cannot be empty", {
|
||||
field: "errorMessage",
|
||||
});
|
||||
}
|
||||
|
||||
const errorParser = new BuildErrorParser();
|
||||
const userFriendlyMessage = errorParser.parseBuildError(
|
||||
errorMessage,
|
||||
String(projectId)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: userFriendlyMessage,
|
||||
});
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/get-dev-log",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const projectId = req.query.projectId;
|
||||
const startIndex = req.query.startIndex;
|
||||
const logType = req.query.logType || "temp";
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
|
||||
// 解析起始行号,默认为1
|
||||
const startLine = startIndex ? parseInt(startIndex, 10) : 1;
|
||||
if (isNaN(startLine) || startLine < 1) {
|
||||
throw new ValidationError("Start index must be a positive integer (starting from 1)", {
|
||||
field: "startIndex",
|
||||
value: startIndex,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await getDevLog(String(projectId), startLine, logType);
|
||||
res.json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/get-log-cache-stats",
|
||||
method: "get",
|
||||
handler: (req, res) => {
|
||||
const stats = logCacheManager.getStats();
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Get log cache statistics successfully",
|
||||
stats,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/clear-all-log-cache",
|
||||
method: "get",
|
||||
handler: (req, res) => {
|
||||
logCacheManager.clear();
|
||||
res.json({
|
||||
success: true,
|
||||
message: "All log caches have been cleared",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/port-pool-status",
|
||||
method: "get",
|
||||
handler: (req, res) => {
|
||||
const status = portPool.getStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Get port pool status successfully",
|
||||
...status,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 注册路由
|
||||
routes.forEach((route) => {
|
||||
buildRouter[route.method](route.path, route.handler);
|
||||
});
|
||||
|
||||
export default buildRouter;
|
||||
222
qiming-file-server/src/routes/codeRoutes.js
Normal file
222
qiming-file-server/src/routes/codeRoutes.js
Normal file
@@ -0,0 +1,222 @@
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import { asyncHandler, ValidationError } from "../utils/error/errorHandler.js";
|
||||
import codeService from "../service/codeService.js";
|
||||
import { log } from "../utils/log/logUtils.js";
|
||||
import config from "../appConfig/index.js";
|
||||
import { extractIsolationContext } from "../utils/common/projectPathUtils.js";
|
||||
|
||||
const codeRouter = express.Router();
|
||||
|
||||
// 配置multer用于内存存储(适合小文件)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: config.UPLOAD_SINGLE_FILE_SIZE_BYTES, // 从环境变量读取文件大小限制
|
||||
},
|
||||
});
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: "/specified-files-update",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, codeVersion, files } = req.body || {};
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
log(projectId, "INFO", "Partial files update", {
|
||||
projectId,
|
||||
codeVersion,
|
||||
filesCount: files ? files.length : 0,
|
||||
});
|
||||
|
||||
// 解码文件内容(前端使用 encodeURIComponent 编码)
|
||||
if (files && Array.isArray(files)) {
|
||||
files.forEach((fileOp) => {
|
||||
if (!fileOp) return;
|
||||
|
||||
// 只要有 contents 就解码
|
||||
if (typeof fileOp.contents === "string" && fileOp.contents) {
|
||||
try {
|
||||
fileOp.contents = decodeURIComponent(fileOp.contents);
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Decode file content failed", {
|
||||
fileName: fileOp.path,
|
||||
error: err.message,
|
||||
});
|
||||
// 如果解码失败,保持原样(可能前端没有编码)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await codeService.specifiedFilesUpdate(
|
||||
String(projectId),
|
||||
String(codeVersion),
|
||||
files,
|
||||
req,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/all-files-update",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, codeVersion, files, basePath, pid } = req.body || {};
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
log(projectId, "INFO", "Submit files", {
|
||||
projectId,
|
||||
codeVersion,
|
||||
basePath,
|
||||
pid,
|
||||
});
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (codeVersion === undefined || codeVersion === null) {
|
||||
throw new ValidationError("codeVersion cannot be empty", {
|
||||
field: "codeVersion",
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(files)) {
|
||||
throw new ValidationError("files must be an array", { field: "files" });
|
||||
}
|
||||
|
||||
// 解码文件内容(前端使用 encodeURIComponent 编码)
|
||||
if (files && Array.isArray(files)) {
|
||||
files.forEach((file) => {
|
||||
if (file && typeof file.contents === "string" && file.contents) {
|
||||
try {
|
||||
file.contents = decodeURIComponent(file.contents);
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Decode file content failed", {
|
||||
fileName: file.name,
|
||||
error: err.message,
|
||||
});
|
||||
// 如果解码失败,保持原样(可能前端没有编码)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await codeService.allFilesUpdate(
|
||||
String(projectId),
|
||||
String(codeVersion),
|
||||
files,
|
||||
req,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/upload-single-file",
|
||||
method: "post",
|
||||
middleware: upload.single("file"),
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, codeVersion, filePath } = req.body || {};
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
const file = req.file; // 从 multer 中间件获取上传的文件
|
||||
|
||||
log(projectId, "INFO", "上传单个文件", {
|
||||
projectId,
|
||||
codeVersion,
|
||||
filePath,
|
||||
});
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (codeVersion === undefined || codeVersion === null) {
|
||||
throw new ValidationError("codeVersion cannot be empty", {
|
||||
field: "codeVersion",
|
||||
});
|
||||
}
|
||||
if (!file) {
|
||||
throw new ValidationError("File cannot be empty", { field: "file" });
|
||||
}
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
throw new ValidationError("File path cannot be empty", { field: "filePath" });
|
||||
}
|
||||
|
||||
// 记录接收到的文件信息,用于调试
|
||||
log(projectId, "INFO", "Received file information", {
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
bufferLength: file.buffer ? file.buffer.length : 0,
|
||||
bufferIsBuffer: Buffer.isBuffer(file.buffer),
|
||||
});
|
||||
|
||||
// 构建文件对象,统一使用buffer(multer memoryStorage对所有文件类型都提供buffer)
|
||||
const fileObj = {
|
||||
buffer: file.buffer,
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
};
|
||||
|
||||
const result = await codeService.uploadSingleFile(
|
||||
String(projectId),
|
||||
String(codeVersion),
|
||||
fileObj,
|
||||
filePath,
|
||||
req,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/rollback-version",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, codeVersion, rollbackTo } = req.body || {};
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
log(projectId, "INFO", "Rollback version", {
|
||||
projectId,
|
||||
codeVersion,
|
||||
rollbackTo,
|
||||
});
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (codeVersion === undefined || codeVersion === null) {
|
||||
throw new ValidationError("codeVersion cannot be empty", {
|
||||
field: "codeVersion",
|
||||
});
|
||||
}
|
||||
if (rollbackTo === undefined || rollbackTo === null) {
|
||||
throw new ValidationError("rollbackTo cannot be empty", {
|
||||
field: "rollbackTo",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await codeService.rollbackVersion(
|
||||
String(projectId),
|
||||
String(codeVersion),
|
||||
String(rollbackTo),
|
||||
req,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// 注册路由
|
||||
routes.forEach((route) => {
|
||||
if (route.middleware) {
|
||||
// 如果有中间件,先注册中间件再注册处理器
|
||||
codeRouter[route.method](route.path, route.middleware, route.handler);
|
||||
} else {
|
||||
// 没有中间件,直接注册处理器
|
||||
codeRouter[route.method](route.path, route.handler);
|
||||
}
|
||||
});
|
||||
|
||||
export default codeRouter;
|
||||
414
qiming-file-server/src/routes/computerRoutes.js
Normal file
414
qiming-file-server/src/routes/computerRoutes.js
Normal file
@@ -0,0 +1,414 @@
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ValidationError, asyncHandler } from "../utils/error/errorHandler.js";
|
||||
import { log } from "../utils/log/logUtils.js";
|
||||
import config from "../appConfig/index.js";
|
||||
import { createWorkspace, pushSkillsToWorkspace, } from "../utils/computer/computerUtils.js";
|
||||
import {
|
||||
getFileList,
|
||||
updateFiles,
|
||||
uploadFile,
|
||||
uploadFiles,
|
||||
downloadAllFiles,
|
||||
} from "../utils/computer/computerFileUtils.js";
|
||||
|
||||
const computerRouter = express.Router();
|
||||
|
||||
// 使用磁盘存储,便于后续解压 zip
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
try {
|
||||
// 将临时上传目录放到 COMPUTER_WORKSPACE_DIR/<userId>/<cId>/.tmp 下
|
||||
const baseDir = config.COMPUTER_WORKSPACE_DIR;
|
||||
if (!baseDir) {
|
||||
return cb(
|
||||
new Error("COMPUTER_WORKSPACE_DIR is not configured, cannot determine upload temporary directory")
|
||||
);
|
||||
}
|
||||
|
||||
const userId = req.body?.userId || "unknown";
|
||||
const cId = req.body?.cId || "unknown";
|
||||
const tmpUploadDir = path.join(
|
||||
baseDir,
|
||||
String(userId),
|
||||
String(cId),
|
||||
".tmp"
|
||||
);
|
||||
if (!fs.existsSync(tmpUploadDir)) {
|
||||
fs.mkdirSync(tmpUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
cb(null, tmpUploadDir);
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || ".zip";
|
||||
const baseName = path.basename(file.originalname, ext);
|
||||
const uniqueSuffix = `${Date.now()}_${Math.round(Math.random() * 1e6)}`;
|
||||
cb(null, `${baseName}_${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: {
|
||||
fileSize: config.UPLOAD_MAX_FILE_SIZE_BYTES,
|
||||
},
|
||||
});
|
||||
|
||||
// Multer 错误处理中间件(复用通用模式)
|
||||
function multerErrorHandler(err, req, res, next) {
|
||||
if (err && err.name === "MulterError") {
|
||||
return next(err);
|
||||
}
|
||||
if (err && (err.name === "ValidationError" || err instanceof ValidationError)) {
|
||||
return next(err);
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/create-workspace",
|
||||
method: "post",
|
||||
middleware: upload.single("file"),
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId } = req.body || {};
|
||||
const file = req.file || null;
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
|
||||
log(logId, "INFO", "Create workspace request", {
|
||||
userId,
|
||||
cId,
|
||||
hasFile: !!file,
|
||||
fileName: file?.originalname,
|
||||
fileSize: file?.size,
|
||||
});
|
||||
|
||||
// 兼容老逻辑:仅处理上传 file,不处理 skillUrls
|
||||
const result = await createWorkspace(userId, cId, file);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: "/create-workspace-v2",
|
||||
method: "post",
|
||||
middleware: upload.single("file"),
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId, skillUrls } = req.body || {};
|
||||
const file = req.file || null;
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
|
||||
// multipart/form-data 中数组可能被序列化成 JSON 字符串
|
||||
let normalizedSkillUrls = skillUrls;
|
||||
if (typeof skillUrls === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(skillUrls);
|
||||
normalizedSkillUrls = Array.isArray(parsed) ? parsed : [skillUrls];
|
||||
} catch {
|
||||
normalizedSkillUrls = [skillUrls];
|
||||
}
|
||||
}
|
||||
|
||||
log(logId, "INFO", "Create workspace v2 request", {
|
||||
userId,
|
||||
cId,
|
||||
hasFile: !!file,
|
||||
fileName: file?.originalname,
|
||||
fileSize: file?.size,
|
||||
skillUrlsCount: Array.isArray(normalizedSkillUrls) ? normalizedSkillUrls.length : 0,
|
||||
});
|
||||
|
||||
const result = await createWorkspace(userId, cId, file, normalizedSkillUrls);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: "/push-skills-to-workspace",
|
||||
method: "post",
|
||||
middleware: upload.single("file"),
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId } = req.body || {};
|
||||
const file = req.file || null;
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
|
||||
log(logId, "INFO", "推送技能到工作空间请求", {
|
||||
userId,
|
||||
cId,
|
||||
hasFile: !!file,
|
||||
fileName: file?.originalname,
|
||||
fileSize: file?.size,
|
||||
});
|
||||
|
||||
// 兼容老逻辑:仅处理上传 file,不处理 skillUrls
|
||||
const result = await pushSkillsToWorkspace(userId, cId, file);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: "/push-skills-to-workspace-v2",
|
||||
method: "post",
|
||||
middleware: upload.single("file"),
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId, skillUrls } = req.body || {};
|
||||
const file = req.file || null;
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
|
||||
// multipart/form-data 中数组可能被序列化成 JSON 字符串
|
||||
let normalizedSkillUrls = skillUrls;
|
||||
if (typeof skillUrls === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(skillUrls);
|
||||
normalizedSkillUrls = Array.isArray(parsed) ? parsed : [skillUrls];
|
||||
} catch {
|
||||
normalizedSkillUrls = [skillUrls];
|
||||
}
|
||||
}
|
||||
|
||||
log(logId, "INFO", "推送技能到工作空间请求(v2)", {
|
||||
userId,
|
||||
cId,
|
||||
hasFile: !!file,
|
||||
fileName: file?.originalname,
|
||||
fileSize: file?.size,
|
||||
skillUrlsCount: Array.isArray(normalizedSkillUrls) ? normalizedSkillUrls.length : 0,
|
||||
});
|
||||
|
||||
const result = await pushSkillsToWorkspace(userId, cId, file, normalizedSkillUrls);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: "/get-file-list",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId, proxyPath } = req.query;
|
||||
const result = await getFileList(userId, cId, proxyPath);
|
||||
res.status(200).json({ success: true, ...result });
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/files-update",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId, files } = req.body || {};
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
log(logId, "INFO", "Files update", {
|
||||
userId,
|
||||
cId,
|
||||
filesCount: files ? files.length : 0,
|
||||
});
|
||||
|
||||
// 解码文件内容(前端使用 encodeURIComponent 编码)
|
||||
if (files && Array.isArray(files)) {
|
||||
files.forEach((fileOp) => {
|
||||
if (!fileOp) return;
|
||||
|
||||
// 只要有 contents 就解码
|
||||
if (typeof fileOp.contents === "string" && fileOp.contents) {
|
||||
try {
|
||||
fileOp.contents = decodeURIComponent(fileOp.contents);
|
||||
} catch (err) {
|
||||
log(logId, "WARN", "Decode file content failed", {
|
||||
fileName: fileOp.name,
|
||||
error: err.message,
|
||||
});
|
||||
// 如果解码失败,保持原样(可能前端没有编码)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = await updateFiles(userId, cId, files);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/upload-file",
|
||||
method: "post",
|
||||
middleware: upload.single("file"),
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId, filePath } = req.body || {};
|
||||
const file = req.file; // 从 multer 中间件获取上传的文件
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
|
||||
log(logId, "INFO", "Upload single file", {
|
||||
userId,
|
||||
cId,
|
||||
filePath,
|
||||
});
|
||||
|
||||
// 从磁盘读取文件内容(diskStorage 模式下 file.buffer 不存在)
|
||||
const fileBuffer = await fs.promises.readFile(file.path);
|
||||
|
||||
// 构建文件对象,支持文本和二进制文件
|
||||
const fileObj = {
|
||||
buffer: fileBuffer,
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await uploadFile(userId, cId, fileObj, filePath);
|
||||
res.status(200).json(result);
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(file.path)) {
|
||||
await fs.promises.unlink(file.path);
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/upload-files",
|
||||
method: "post",
|
||||
middleware: upload.array("files"),
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId, filePaths } = req.body || {};
|
||||
const files = req.files || []; // 从 multer 中间件获取上传的文件数组
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
|
||||
// 标准化 filePaths 为数组格式,兼容单文件上传时可能传入字符串的情况
|
||||
const normalizedFilePaths = Array.isArray(filePaths)
|
||||
? filePaths
|
||||
: typeof filePaths === "string"
|
||||
? [filePaths]
|
||||
: filePaths;
|
||||
|
||||
log(logId, "INFO", "Batch upload files request", {
|
||||
userId,
|
||||
cId,
|
||||
filesCount: files.length,
|
||||
filePathsCount: Array.isArray(normalizedFilePaths) ? normalizedFilePaths.length : 0,
|
||||
});
|
||||
|
||||
// 从磁盘读取所有文件内容(diskStorage 模式下 file.buffer 不存在)
|
||||
const fileObjects = [];
|
||||
const tempFilePaths = [];
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
tempFilePaths.push(file.path);
|
||||
const fileBuffer = await fs.promises.readFile(file.path);
|
||||
fileObjects.push({
|
||||
buffer: fileBuffer,
|
||||
originalname: file.originalname,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
|
||||
// 调用批量上传工具函数
|
||||
const result = await uploadFiles(userId, cId, fileObjects, normalizedFilePaths);
|
||||
res.status(200).json(result);
|
||||
} finally {
|
||||
// 清理所有临时文件
|
||||
for (const tempPath of tempFilePaths) {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
await fs.promises.unlink(tempPath);
|
||||
} catch (error) {
|
||||
log(logId, "WARN", "Clean temporary file failed", {
|
||||
tempPath,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/download-all-files",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { userId, cId } = req.query || {};
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
|
||||
log(logId, "INFO", "Download all files request", {
|
||||
userId,
|
||||
cId,
|
||||
});
|
||||
|
||||
const { archive, zipFileName } = await downloadAllFiles(
|
||||
userId,
|
||||
cId
|
||||
);
|
||||
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
// 兼容中文文件名
|
||||
const encodedName = encodeURIComponent(zipFileName);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`
|
||||
);
|
||||
|
||||
archive.on("error", (err) => {
|
||||
// 让全局错误处理中间件接管
|
||||
res.destroy(err);
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
archive.finalize();
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
routes.forEach((route) => {
|
||||
if (route.middleware) {
|
||||
// 如果有中间件,先注册中间件再注册处理器
|
||||
computerRouter[route.method](route.path, route.middleware, route.handler);
|
||||
} else if (route.customHandler) {
|
||||
// 对于有自定义处理器的路由
|
||||
const middlewares = [];
|
||||
|
||||
// 先添加multer中间件
|
||||
if (route.handler) {
|
||||
middlewares.push(route.handler);
|
||||
}
|
||||
|
||||
// 再添加文件名解码中间件(如果有)
|
||||
if (route.decodeMiddleware) {
|
||||
middlewares.push(route.decodeMiddleware);
|
||||
}
|
||||
|
||||
// 添加错误处理中间件
|
||||
middlewares.push(multerErrorHandler);
|
||||
|
||||
// 最后添加自定义处理器
|
||||
middlewares.push(route.customHandler);
|
||||
|
||||
// 注册所有中间件
|
||||
computerRouter[route.method](route.path, ...middlewares);
|
||||
} else {
|
||||
// 普通路由直接注册处理器
|
||||
computerRouter[route.method](route.path, route.handler);
|
||||
}
|
||||
});
|
||||
|
||||
export default computerRouter;
|
||||
|
||||
|
||||
552
qiming-file-server/src/routes/projectRoutes.js
Normal file
552
qiming-file-server/src/routes/projectRoutes.js
Normal file
@@ -0,0 +1,552 @@
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import iconv from "iconv-lite";
|
||||
import projectService from "../service/projectService.js";
|
||||
import {
|
||||
getProjectContent,
|
||||
getProjectContentByVersion,
|
||||
} from "../utils/project/getContentUtils.js";
|
||||
import { uploadAttachmentFile } from "../utils/project/uploadAttachmentFileUtils.js";
|
||||
import { copyProject } from "../utils/project/copyProjectUtils.js";
|
||||
import config from "../appConfig/index.js";
|
||||
import { log } from "../utils/log/logUtils.js";
|
||||
import {
|
||||
extractIsolationContext,
|
||||
resolveProjectPath,
|
||||
} from "../utils/common/projectPathUtils.js";
|
||||
import {
|
||||
ValidationError,
|
||||
SystemError,
|
||||
asyncHandler,
|
||||
} from "../utils/error/errorHandler.js";
|
||||
|
||||
const projectRouter = express.Router();
|
||||
|
||||
// 配置multer用于文件上传
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
// 先保存到临时目录,后续在路由处理器中移动到项目目录
|
||||
const uploadDir = path.join(config.UPLOAD_PROJECT_DIR, "temp");
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e6);
|
||||
cb(
|
||||
null,
|
||||
file.fieldname + "-" + uniqueSuffix + path.extname(file.originalname)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: function (req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (config.UPLOAD_ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
// 将文件类型错误转换为 ValidationError
|
||||
cb(
|
||||
new ValidationError("File type not allowed", {
|
||||
fileExtension: ext,
|
||||
allowedExtensions: config.UPLOAD_ALLOWED_EXTENSIONS,
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: config.UPLOAD_MAX_FILE_SIZE_BYTES,
|
||||
},
|
||||
});
|
||||
|
||||
// 为上传附件文件创建独立的multer配置,支持更多文件类型
|
||||
const attachmentStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadDir = path.join(config.UPLOAD_PROJECT_DIR, "temp");
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e6);
|
||||
cb(
|
||||
null,
|
||||
file.fieldname + "-" + uniqueSuffix + path.extname(file.originalname)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 允许的附件文件扩展名(常见文档、图片、PDF等)
|
||||
const ATTACHMENT_ALLOWED_EXTENSIONS = [
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".txt",
|
||||
".md",
|
||||
".csv",
|
||||
".json",
|
||||
".xml",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".bmp",
|
||||
".svg",
|
||||
".ico",
|
||||
".webp",
|
||||
".avif",
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
".tar",
|
||||
".gz",
|
||||
".mp4",
|
||||
".avi",
|
||||
".mov",
|
||||
".wmv",
|
||||
".flv",
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".m4a",
|
||||
];
|
||||
|
||||
const uploadAttachment = multer({
|
||||
storage: attachmentStorage,
|
||||
fileFilter: function (req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ATTACHMENT_ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
// 将文件类型错误转换为 ValidationError
|
||||
cb(
|
||||
new ValidationError("附件文件类型不被允许", {
|
||||
fileExtension: ext,
|
||||
allowedExtensions: ATTACHMENT_ALLOWED_EXTENSIONS,
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: config.UPLOAD_MAX_FILE_SIZE_BYTES,
|
||||
},
|
||||
});
|
||||
|
||||
// 文件名解码中间件,用于正确处理中文文件名
|
||||
function decodeFileNameMiddleware(req, res, next) {
|
||||
if (req.file && req.file.originalname) {
|
||||
try {
|
||||
// 保存原始文件名用于日志
|
||||
const beforeDecode = req.file.originalname;
|
||||
// 尝试将文件名从 latin1 解码为 UTF-8
|
||||
// 这是一个常见的编码问题,当文件名包含中文字符时
|
||||
const decodedName = iconv.decode(
|
||||
Buffer.from(req.file.originalname, "latin1"),
|
||||
"utf8"
|
||||
);
|
||||
req.file.originalname = decodedName;
|
||||
log("system", "INFO", "File name decoded successfully", {
|
||||
before: beforeDecode,
|
||||
after: decodedName,
|
||||
});
|
||||
} catch (err) {
|
||||
// 如果解码失败,记录日志但继续处理
|
||||
log("system", "WARN", "File name decoded failed", {
|
||||
originalName: req.file.originalname,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: "/push-skills-to-workspace",
|
||||
method: "post",
|
||||
handler: upload.single("file"),
|
||||
customHandler: asyncHandler(async (req, res) => {
|
||||
const { projectId, skillUrls } = req.body || {};
|
||||
const file = req.file || null;
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
const result = await projectService.pushSkillsToWorkspace(
|
||||
String(projectId),
|
||||
file,
|
||||
skillUrls,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json({ success: true, ...result });
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/create-project",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, templateType } = req.body;
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
|
||||
const result = await projectService.createProject(
|
||||
String(projectId),
|
||||
templateType,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/upload-project",
|
||||
method: "post",
|
||||
handler: upload.single("file"),
|
||||
customHandler: asyncHandler(async (req, res) => {
|
||||
const { projectId, codeVersion, pid, basePath } = req.body;
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (!codeVersion) {
|
||||
throw new ValidationError("Code version cannot be empty", { field: "codeVersion" });
|
||||
}
|
||||
if (!req.file) {
|
||||
throw new ValidationError("Please upload a zip file", { field: "zipFile" });
|
||||
}
|
||||
|
||||
// 处理文件上传(移动到项目目录)
|
||||
const uploadResult = await projectService.handleFileUpload(
|
||||
String(projectId),
|
||||
codeVersion,
|
||||
req.file
|
||||
);
|
||||
|
||||
// 更新req.file.path为新的路径
|
||||
req.file.path = uploadResult.filePath;
|
||||
|
||||
try {
|
||||
const result = await projectService.uploadProject(
|
||||
String(projectId),
|
||||
req.file.path,
|
||||
req,
|
||||
codeVersion,
|
||||
pid,
|
||||
basePath,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
// 失败时清理项目目录(uploadProject内部已经处理,这里作为额外保障)
|
||||
try {
|
||||
await projectService.cleanupProjectDirectory(
|
||||
String(projectId),
|
||||
isolationContext
|
||||
);
|
||||
} catch (cleanupErr) {
|
||||
log(projectId, "ERROR", "Route layer cleanup project directory failed", {
|
||||
projectId,
|
||||
error: cleanupErr.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 清理上传的文件
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (cleanupErr) {
|
||||
log(projectId, "ERROR", "Clean upload file failed", {
|
||||
projectId,
|
||||
error: cleanupErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw err; // 重新抛出错误,让全局错误处理器处理
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/get-project-content",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, command, proxyPath } = req.query;
|
||||
const isolationContext = extractIsolationContext(req.query || {});
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
|
||||
// 构建项目路径
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
|
||||
// 检查项目目录是否存在
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
throw new ValidationError("Project does not exist", { field: "projectId" });
|
||||
}
|
||||
|
||||
// 获取项目内容
|
||||
try {
|
||||
const result = await getProjectContent(
|
||||
projectPath,
|
||||
command,
|
||||
proxyPath
|
||||
);
|
||||
res.status(200).json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
const message = err?.message || "Query failed";
|
||||
res.status(500).json({ success: false, message });
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/get-project-content-by-version",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, codeVersion, command, proxyPath } = req.query;
|
||||
const isolationContext = extractIsolationContext(req.query || {});
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (!codeVersion) {
|
||||
throw new ValidationError("Code version cannot be empty", { field: "codeVersion" });
|
||||
}
|
||||
try {
|
||||
const result = await getProjectContentByVersion(
|
||||
String(projectId),
|
||||
codeVersion,
|
||||
command,
|
||||
proxyPath,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
const message = err?.message || "Query failed";
|
||||
res.status(500).json({ success: false, message });
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/backup-current-version",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, codeVersion } = req.body;
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
|
||||
const result = await projectService.backupCurrentVersion(
|
||||
String(projectId),
|
||||
codeVersion,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json({ success: true, ...result });
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/export-project",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, codeVersion, exportType, config } = req.body;
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
|
||||
const result = await projectService.exportProject(
|
||||
String(projectId),
|
||||
codeVersion,
|
||||
exportType,
|
||||
config,
|
||||
isolationContext
|
||||
);
|
||||
|
||||
// 检查zip文件是否存在
|
||||
if (!fs.existsSync(result.zipPath)) {
|
||||
throw new SystemError("Exported zip file does not exist", {
|
||||
zipPath: result.zipPath,
|
||||
});
|
||||
}
|
||||
|
||||
// 设置响应头,指定文件下载
|
||||
const fileName = path.basename(result.zipPath);
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${fileName}"`
|
||||
);
|
||||
|
||||
res.sendFile(result.zipPath, (err) => {
|
||||
if (err) {
|
||||
log(projectId, "ERROR", "Send zip file failed", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, message: "File send failed" });
|
||||
}
|
||||
} else {
|
||||
log(projectId, "INFO", "Zip file send successfully", {
|
||||
projectId,
|
||||
zipPath: result.zipPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: "/delete-project",
|
||||
method: "get",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { projectId, pid } = req.query;
|
||||
const isolationContext = extractIsolationContext(req.query || {});
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
|
||||
const result = await projectService.deleteProject(
|
||||
String(projectId),
|
||||
pid,
|
||||
req,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/upload-attachment-file",
|
||||
method: "post",
|
||||
handler: uploadAttachment.single("file"),
|
||||
decodeMiddleware: decodeFileNameMiddleware,
|
||||
customHandler: asyncHandler(async (req, res) => {
|
||||
const { projectId, fileName } = req.body;
|
||||
const isolationContext = extractIsolationContext(req.body || {});
|
||||
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (!req.file) {
|
||||
throw new ValidationError("Please upload a file", { field: "file" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadAttachmentFile(
|
||||
String(projectId),
|
||||
req.file,
|
||||
fileName,
|
||||
isolationContext
|
||||
);
|
||||
res.status(200).json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
// 失败时清理上传的文件
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (cleanupErr) {
|
||||
log(projectId, "ERROR", "Clean upload file failed", {
|
||||
projectId,
|
||||
error: cleanupErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
throw err; // 重新抛出错误,让全局错误处理器处理
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "/copy-project",
|
||||
method: "post",
|
||||
handler: asyncHandler(async (req, res) => {
|
||||
const { sourceProjectId, targetProjectId } = req.body;
|
||||
const sourceIsolationContext = extractIsolationContext({
|
||||
tenantId: req.body?.sourceTenantId || req.body?.tenantId,
|
||||
spaceId: req.body?.sourceSpaceId || req.body?.spaceId,
|
||||
isolationType: req.body?.sourceIsolationType || req.body?.isolationType,
|
||||
});
|
||||
const targetIsolationContext = extractIsolationContext({
|
||||
tenantId: req.body?.targetTenantId || req.body?.tenantId,
|
||||
spaceId: req.body?.targetSpaceId || req.body?.spaceId,
|
||||
isolationType: req.body?.targetIsolationType || req.body?.isolationType,
|
||||
});
|
||||
|
||||
if (!sourceProjectId) {
|
||||
throw new ValidationError("Source project ID cannot be empty", {
|
||||
field: "sourceProjectId",
|
||||
});
|
||||
}
|
||||
if (!targetProjectId) {
|
||||
throw new ValidationError("Target project ID cannot be empty", {
|
||||
field: "targetProjectId",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await copyProject(
|
||||
String(sourceProjectId),
|
||||
String(targetProjectId),
|
||||
{
|
||||
sourceIsolationContext,
|
||||
targetIsolationContext,
|
||||
}
|
||||
);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// Multer错误处理中间件
|
||||
function multerErrorHandler(err, req, res, next) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
// Multer错误会被全局错误处理器捕获并处理
|
||||
return next(err);
|
||||
}
|
||||
// 如果是 ValidationError,直接传递给错误处理器
|
||||
if (err.name === "ValidationError" || err instanceof ValidationError) {
|
||||
return next(err);
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
|
||||
// 注册路由
|
||||
routes.forEach((route) => {
|
||||
if (route.customHandler) {
|
||||
// 对于有自定义处理器的路由
|
||||
const middlewares = [];
|
||||
|
||||
// 先添加multer中间件
|
||||
if (route.handler) {
|
||||
middlewares.push(route.handler);
|
||||
}
|
||||
|
||||
// 再添加文件名解码中间件(如果有)
|
||||
if (route.decodeMiddleware) {
|
||||
middlewares.push(route.decodeMiddleware);
|
||||
}
|
||||
|
||||
// 添加错误处理中间件
|
||||
middlewares.push(multerErrorHandler);
|
||||
|
||||
// 最后添加自定义处理器
|
||||
middlewares.push(route.customHandler);
|
||||
|
||||
// 注册所有中间件
|
||||
projectRouter[route.method](route.path, ...middlewares);
|
||||
} else {
|
||||
// 普通路由直接注册处理器
|
||||
projectRouter[route.method](route.path, route.handler);
|
||||
}
|
||||
});
|
||||
|
||||
export default projectRouter;
|
||||
56
qiming-file-server/src/routes/router.js
Normal file
56
qiming-file-server/src/routes/router.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import express from "express";
|
||||
import buildRouter from "./buildRoutes.js";
|
||||
import projectRouter from "./projectRoutes.js";
|
||||
import codeRouter from "./codeRoutes.js";
|
||||
import computerRouter from "./computerRoutes.js";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// 读取 package.json 获取版本号(使用绝对路径确保跨场景兼容)
|
||||
let version = "1.0.0";
|
||||
try {
|
||||
const packageJsonPath = path.resolve(__dirname, "..", "..", "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
version = packageJson.version || "1.0.0";
|
||||
} catch (err) {
|
||||
// 使用默认值
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
res.send("Hello");
|
||||
});
|
||||
|
||||
router.get("/health", (req, res) => {
|
||||
const uptimeSeconds = Math.floor(process.uptime());
|
||||
const memoryUsage = process.memoryUsage();
|
||||
const memory = {
|
||||
heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024 * 100) / 100,
|
||||
heapTotal: Math.round(memoryUsage.heapTotal / 1024 / 1024 * 100) / 100,
|
||||
rss: Math.round(memoryUsage.rss / 1024 / 1024 * 100) / 100,
|
||||
external: Math.round(memoryUsage.external / 1024 / 1024 * 100) / 100,
|
||||
};
|
||||
const healthData = {
|
||||
status: "ok",
|
||||
timestamp: Date.now(),
|
||||
uptime: uptimeSeconds,
|
||||
version: version,
|
||||
platform: process.platform,
|
||||
nodeVersion: process.version,
|
||||
pid: process.pid,
|
||||
memory: memory,
|
||||
env: process.env.NODE_ENV || "unknown",
|
||||
};
|
||||
res.json(healthData);
|
||||
});
|
||||
|
||||
router.use("/api/build", buildRouter);
|
||||
router.use("/api/project", projectRouter);
|
||||
router.use("/api/project", codeRouter);
|
||||
router.use("/api/computer", computerRouter);
|
||||
|
||||
export default router;
|
||||
245
qiming-file-server/src/scheduler/pnpmPruneScheduler.js
Normal file
245
qiming-file-server/src/scheduler/pnpmPruneScheduler.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* pnpm store prune 定时任务调度器(ESM)
|
||||
*/
|
||||
import cron from "node-cron";
|
||||
import { exec } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log } from "../utils/log/logUtils.js";
|
||||
|
||||
class PnpmPruneScheduler {
|
||||
constructor(config = {}) {
|
||||
// 默认配置:每周日凌晨 2 点执行
|
||||
this.config = {
|
||||
enabled: process.env.PNPM_PRUNE_ENABLED !== 'false', // 默认启用(只有明确设置为 'false' 才禁用)
|
||||
schedule: process.env.PNPM_PRUNE_SCHEDULE || '0 2 * * 0', // Cron 表达式
|
||||
timezone: process.env.PNPM_PRUNE_TIMEZONE || 'Asia/Shanghai',
|
||||
runOnStart: process.env.PNPM_PRUNE_RUN_ON_START === 'true', // 启动时是否立即执行一次(明确设置为 'true' 才启用)
|
||||
...config,
|
||||
};
|
||||
|
||||
this.task = null;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时任务
|
||||
*/
|
||||
start() {
|
||||
if (!this.config.enabled) {
|
||||
log('scheduler', 'INFO', 'pnpm prune scheduler is disabled (PNPM_PRUNE_ENABLED=false)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 cron 表达式
|
||||
if (!cron.validate(this.config.schedule)) {
|
||||
log('scheduler', 'ERROR', `Invalid cron expression: ${this.config.schedule}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log('scheduler', 'INFO', 'pnpm prune scheduler is started', {
|
||||
schedule: this.config.schedule,
|
||||
timezone: this.config.timezone,
|
||||
});
|
||||
|
||||
// 创建定时任务
|
||||
this.task = cron.schedule(
|
||||
this.config.schedule,
|
||||
() => {
|
||||
this.executePrune();
|
||||
},
|
||||
{
|
||||
scheduled: true,
|
||||
timezone: this.config.timezone,
|
||||
}
|
||||
);
|
||||
|
||||
// 如果配置了启动时执行,则立即执行一次
|
||||
if (this.config.runOnStart) {
|
||||
log('scheduler', 'INFO', 'Immediately execute pnpm prune on startup');
|
||||
setTimeout(() => {
|
||||
this.executePrune();
|
||||
}, 5000); // Delay 5 seconds to ensure the application is fully started
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时任务
|
||||
*/
|
||||
stop() {
|
||||
if (this.task) {
|
||||
this.task.stop();
|
||||
log('scheduler', 'INFO', 'pnpm prune scheduler is stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 pnpm store prune
|
||||
*/
|
||||
async executePrune() {
|
||||
if (this.isRunning) {
|
||||
log('scheduler', 'WARN', 'pnpm prune is running, skipping this schedule');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
log('scheduler', 'INFO', '====================================');
|
||||
log('scheduler', 'INFO', 'Start executing pnpm store prune');
|
||||
log('scheduler', 'INFO', '====================================');
|
||||
|
||||
try {
|
||||
// 获取清理前的状态
|
||||
const beforeStatus = await this.getStoreStatus();
|
||||
if (beforeStatus) {
|
||||
log('scheduler', 'INFO', 'Before status', beforeStatus);
|
||||
}
|
||||
|
||||
// 执行清理
|
||||
const result = await this.runCommand('pnpm store prune');
|
||||
|
||||
if (result.success) {
|
||||
log('scheduler', 'INFO', '✅ pnpm store prune executed successfully');
|
||||
if (result.stdout) {
|
||||
log('scheduler', 'INFO', result.stdout);
|
||||
}
|
||||
|
||||
// 获取清理后的状态
|
||||
const afterStatus = await this.getStoreStatus();
|
||||
if (afterStatus) {
|
||||
log('scheduler', 'INFO', 'After status', afterStatus);
|
||||
}
|
||||
} else {
|
||||
log('scheduler', 'ERROR', '❌ pnpm store prune executed failed', {
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log('scheduler', 'ERROR', 'pnpm prune executed exception', {
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
log('scheduler', 'INFO', '====================================');
|
||||
log('scheduler', 'INFO', 'pnpm store prune executed successfully');
|
||||
log('scheduler', 'INFO', '====================================\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 store 状态
|
||||
*/
|
||||
async getStoreStatus() {
|
||||
try {
|
||||
const pathResult = await this.runCommand('pnpm store path');
|
||||
if (!pathResult.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storePath = pathResult.stdout.trim();
|
||||
|
||||
// 获取 store 大小
|
||||
const sizeResult = await this.runCommand(`du -sh "${storePath}"`);
|
||||
const storeSize = sizeResult.success
|
||||
? sizeResult.stdout.trim().split('\t')[0]
|
||||
: 'unknown';
|
||||
|
||||
return {
|
||||
path: storePath,
|
||||
size: storeSize,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
*/
|
||||
runCommand(command) {
|
||||
return new Promise((resolve) => {
|
||||
exec(
|
||||
command,
|
||||
{
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: error.message,
|
||||
stderr: stderr,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: true,
|
||||
stdout: stdout,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下次执行时间
|
||||
*/
|
||||
getNextRun() {
|
||||
// node-cron 没有直接提供下次执行时间的方法
|
||||
// 这里只是返回配置
|
||||
return {
|
||||
schedule: this.config.schedule,
|
||||
timezone: this.config.timezone,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 单例实例
|
||||
let schedulerInstance = null;
|
||||
|
||||
/**
|
||||
* 获取调度器实例
|
||||
*/
|
||||
function getScheduler(config) {
|
||||
if (!schedulerInstance) {
|
||||
schedulerInstance = new PnpmPruneScheduler(config);
|
||||
}
|
||||
return schedulerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动调度器
|
||||
*/
|
||||
function startScheduler(config) {
|
||||
const scheduler = getScheduler(config);
|
||||
scheduler.start();
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止调度器
|
||||
*/
|
||||
function stopScheduler() {
|
||||
if (schedulerInstance) {
|
||||
schedulerInstance.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行一次 pnpm store prune
|
||||
* @param {Object} config - 可选配置
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function executePruneManually(config = {}) {
|
||||
const scheduler = getScheduler(config);
|
||||
await scheduler.executePrune();
|
||||
}
|
||||
|
||||
export {
|
||||
PnpmPruneScheduler,
|
||||
getScheduler,
|
||||
startScheduler,
|
||||
stopScheduler,
|
||||
executePruneManually,
|
||||
};
|
||||
|
||||
253
qiming-file-server/src/server.js
Normal file
253
qiming-file-server/src/server.js
Normal file
@@ -0,0 +1,253 @@
|
||||
import express from "express";
|
||||
import JSONbig from "json-bigint";
|
||||
import swaggerUi from "swagger-ui-express";
|
||||
import swaggerSpecs from "./config/swagger.js";
|
||||
import config from "./appConfig/index.js";
|
||||
import { log, logger } from "./utils/log/logUtils.js";
|
||||
import logCacheManager from "./utils/log/logCacheManager.js";
|
||||
import { errorHandler, notFoundHandler } from "./utils/error/errorHandler.js";
|
||||
import router from "./routes/router.js";
|
||||
import { cleanupInitProjectOnStartup } from "./utils/project/initProjectCleanupUtils.js";
|
||||
import { startScheduler, stopScheduler } from "./scheduler/pnpmPruneScheduler.js";
|
||||
import path from "path";
|
||||
|
||||
const app = express();
|
||||
|
||||
// 解析 JSON 请求体,使用 json-bigint 处理大整数
|
||||
app.use(
|
||||
express.json({
|
||||
limit: config.REQUEST_BODY_LIMIT,
|
||||
reviver: (key, value) => {
|
||||
// 对于大整数,保持为字符串
|
||||
if (typeof value === "number" && !Number.isSafeInteger(value)) {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// 解析 URL 编码的请求体
|
||||
app.use(
|
||||
express.urlencoded({ extended: true, limit: config.REQUEST_BODY_LIMIT })
|
||||
);
|
||||
|
||||
// 使用日志中间件
|
||||
app.use(logger);
|
||||
|
||||
// 帮助方法:对 URL 路径做尽可能多次解码(处理客户端双重编码等情况)
|
||||
const safeDecodePath = (p) => {
|
||||
let prev = p;
|
||||
try {
|
||||
// 连续解码直到不再变化或解码失败
|
||||
while (true) {
|
||||
const decoded = decodeURIComponent(prev);
|
||||
if (decoded === prev) break;
|
||||
prev = decoded;
|
||||
}
|
||||
} catch (e) {
|
||||
// 非法编码时直接返回当前结果,避免抛错
|
||||
}
|
||||
return prev;
|
||||
};
|
||||
|
||||
// 静态文件服务:提供页面工程文件的直接访问
|
||||
// 格式1: /api/page/static/<projectId>/<path/to/file> -> config.PROJECT_SOURCE_DIR
|
||||
app.use("/api/page/static/:projectId", (req, res, next) => {
|
||||
const { projectId } = req.params;
|
||||
// req.path 是挂载点之后的路径,形如 "/path/to/file"
|
||||
let filePath = req.path || "/";
|
||||
if (!projectId || filePath === "/") {
|
||||
return res.status(404).send("Not Found");
|
||||
}
|
||||
|
||||
// 设置 CORS 头,允许跨域访问静态资源(必须在 sendFile 之前设置)
|
||||
const origin = req.headers.origin;
|
||||
// 如果请求包含 Origin,使用具体的 origin;否则使用 *
|
||||
// 注意:当使用 credentials 时,不能使用 *,必须使用具体的 origin
|
||||
const allowOrigin = origin || "*";
|
||||
res.header("Access-Control-Allow-Origin", allowOrigin);
|
||||
res.header("Access-Control-Allow-Methods", "HEAD,GET,POST,PUT,DELETE,OPTIONS");
|
||||
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Fragment");
|
||||
res.header("Access-Control-Expose-Headers", "Content-Type");
|
||||
if (origin) {
|
||||
res.header("Access-Control-Allow-Credentials", "true");
|
||||
res.header("Vary", "Origin");
|
||||
}
|
||||
|
||||
// 处理 OPTIONS 预检请求
|
||||
if (req.method === "OPTIONS") {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
|
||||
// 去掉前导 "/",转成相对路径
|
||||
filePath = filePath.replace(/^\/+/, "");
|
||||
// 对可能被多次编码的路径做安全解码(支持中文等)
|
||||
const decodedPath = safeDecodePath(filePath);
|
||||
|
||||
const fullPath = path.join(config.PROJECT_SOURCE_DIR, projectId, decodedPath);
|
||||
// 使用 headers 选项确保 CORS 头被保留
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": allowOrigin,
|
||||
"Access-Control-Allow-Methods": "HEAD,GET,POST,PUT,DELETE,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Fragment",
|
||||
};
|
||||
if (origin) {
|
||||
corsHeaders["Access-Control-Allow-Credentials"] = "true";
|
||||
corsHeaders["Vary"] = "Origin";
|
||||
}
|
||||
|
||||
return res.sendFile(
|
||||
fullPath,
|
||||
{
|
||||
// dotfiles: "ignore",
|
||||
dotfiles: "allow",
|
||||
headers: corsHeaders,
|
||||
},
|
||||
(err) => {
|
||||
if (err) return next();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// 静态文件服务:提供桌面文件的直接访问
|
||||
// 格式2: /api/computer/static/<userId>/<cId>/<path/to/file> -> config.COMPUTER_WORKSPACE_DIR
|
||||
app.use("/api/computer/static/:userId/:cId", (req, res, next) => {
|
||||
const { userId, cId } = req.params;
|
||||
let filePath = req.path || "/";
|
||||
|
||||
if (!userId || !cId || filePath === "/") {
|
||||
return res.status(404).send("Not Found");
|
||||
}
|
||||
|
||||
// 设置 CORS 头,允许跨域访问静态资源(必须在 sendFile 之前设置)
|
||||
const origin = req.headers.origin;
|
||||
// 如果请求包含 Origin,使用具体的 origin;否则使用 *
|
||||
// 注意:当使用 credentials 时,不能使用 *,必须使用具体的 origin
|
||||
const allowOrigin = origin || "*";
|
||||
res.header("Access-Control-Allow-Origin", allowOrigin);
|
||||
res.header("Access-Control-Allow-Methods", "HEAD,GET,POST,PUT,DELETE,OPTIONS");
|
||||
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Range, If-Range");
|
||||
res.header("Access-Control-Expose-Headers", "Content-Type, Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified");
|
||||
if (origin) {
|
||||
res.header("Access-Control-Allow-Credentials", "true");
|
||||
res.header("Vary", "Origin");
|
||||
}
|
||||
|
||||
// 处理 OPTIONS 预检请求
|
||||
if (req.method === "OPTIONS") {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
|
||||
// 去掉前导 "/",确保是相对路径
|
||||
filePath = filePath.replace(/^\/+/, "");
|
||||
// 安全多次解码,解决 "%25E4%25BD%25A0" 这种双重编码的中文
|
||||
const decodedPath = safeDecodePath(filePath);
|
||||
|
||||
const fullPath = path.join(
|
||||
config.COMPUTER_WORKSPACE_DIR,
|
||||
userId,
|
||||
cId,
|
||||
decodedPath
|
||||
);
|
||||
|
||||
// 使用 headers 选项确保 CORS 头被保留
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": allowOrigin,
|
||||
"Access-Control-Allow-Methods": "HEAD,GET,POST,PUT,DELETE,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Range, If-Range",
|
||||
"Access-Control-Expose-Headers": "Content-Type, Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified",
|
||||
};
|
||||
if (origin) {
|
||||
corsHeaders["Access-Control-Allow-Credentials"] = "true";
|
||||
corsHeaders["Vary"] = "Origin";
|
||||
}
|
||||
|
||||
return res.sendFile(
|
||||
fullPath,
|
||||
{
|
||||
// dotfiles: "ignore",
|
||||
dotfiles: "allow",
|
||||
headers: corsHeaders,
|
||||
},
|
||||
(err) => {
|
||||
if (err) return next();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Swagger API 文档
|
||||
app.use(
|
||||
"/api-docs",
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(swaggerSpecs, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
customSiteTitle: "qiming-file-server API Documentation",
|
||||
})
|
||||
);
|
||||
|
||||
// 使用路由配置
|
||||
app.use(router);
|
||||
|
||||
// 404处理中间件(必须在所有路由之后)
|
||||
app.use(notFoundHandler);
|
||||
|
||||
// 全局错误处理中间件(必须在最后)
|
||||
app.use(errorHandler);
|
||||
|
||||
// 启动服务器
|
||||
const server = app.listen(config.PORT, async () => {
|
||||
log(
|
||||
"default",
|
||||
"INFO",
|
||||
`Server is running on port ${config.PORT} (${config.NODE_ENV} mode)`
|
||||
);
|
||||
|
||||
// 项目启动时清理初始化项目文件夹,目的是更新初始化包时,只需要更新zip包
|
||||
await cleanupInitProjectOnStartup(config);
|
||||
|
||||
// 启动 pnpm prune 定时任务
|
||||
try {
|
||||
startScheduler();
|
||||
} catch (error) {
|
||||
log("default", "ERROR", `pnpm prune scheduled task failed to start: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 设置服务器超时时间 10 分钟(默认是 120 秒)
|
||||
// 对于长时间运行的操作(如依赖安装、构建)是必要的
|
||||
server.timeout = 600000; // 10 分钟
|
||||
server.keepAliveTimeout = 610000; // 略大于 timeout
|
||||
server.headersTimeout = 620000; // 略大于 keepAliveTimeout
|
||||
|
||||
// 优雅退出处理
|
||||
const gracefulShutdown = (signal) => {
|
||||
log("default", "INFO", `Received ${signal} signal, preparing graceful exit...`);
|
||||
|
||||
// 停止定时任务
|
||||
stopScheduler();
|
||||
|
||||
// 清理日志缓存管理器(清理定时器)
|
||||
try {
|
||||
logCacheManager.destroy();
|
||||
log("default", "INFO", "Log cache manager cleared");
|
||||
} catch (error) {
|
||||
log("default", "ERROR", `Failed to clear log cache manager: ${error.message}`);
|
||||
}
|
||||
|
||||
// 关闭服务器
|
||||
server.close(() => {
|
||||
log("default", "INFO", "Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 如果 30 秒后还未退出,强制退出
|
||||
setTimeout(() => {
|
||||
log("default", "ERROR", "Force exit (timeout)");
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
// 监听退出信号
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
855
qiming-file-server/src/service/codeService.js
Normal file
855
qiming-file-server/src/service/codeService.js
Normal file
@@ -0,0 +1,855 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import config from "../appConfig/index.js";
|
||||
import { restartDevServer } from "../utils/build/restartDevUtils.js";
|
||||
import { log } from "../utils/log/logUtils.js";
|
||||
import {
|
||||
ValidationError,
|
||||
SystemError,
|
||||
ResourceError,
|
||||
FileError,
|
||||
} from "../utils/error/errorHandler.js";
|
||||
import { sanitizeSensitivePaths } from "../utils/common/sensitiveUtils.js";
|
||||
import {
|
||||
backupProjectToZip,
|
||||
restoreProjectFromZip,
|
||||
pruneMissingFiles,
|
||||
removeEmptyDirectories,
|
||||
} from "../utils/project/backupUtils.js";
|
||||
import { extractSingleFileFromZip } from "../utils/common/zipUtils.js";
|
||||
import {
|
||||
shouldRestartForSingleFile,
|
||||
shouldRestartDevServer,
|
||||
} from "../utils/buildJudge/restartJudgeUtils.js";
|
||||
import { resolveProjectPath } from "../utils/common/projectPathUtils.js";
|
||||
|
||||
/**
|
||||
* 按行对比旧内容与新内容,返回合并后的内容和变更行数
|
||||
* - 行内容不同视为修改
|
||||
* - 旧文件多出的行视为删除
|
||||
* - 新文件多出的行视为新增
|
||||
* - 保持原文件换行符风格(\n 或 \r\n)
|
||||
*/
|
||||
function diffContentByLines(existingContent, newContentStr) {
|
||||
const oldLines = existingContent.split(/\r?\n/);
|
||||
const newLines = newContentStr.split(/\r?\n/);
|
||||
|
||||
const oldLen = oldLines.length;
|
||||
const newLen = newLines.length;
|
||||
const minLen = Math.min(oldLen, newLen);
|
||||
|
||||
let changesCount = 0;
|
||||
|
||||
// 1) 对齐范围内的行:不同则视为修改
|
||||
for (let idx = 0; idx < minLen; idx++) {
|
||||
if (oldLines[idx] !== newLines[idx]) {
|
||||
oldLines[idx] = newLines[idx];
|
||||
changesCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) old 比 new 长的部分:多出来的行视为删除,从后往前删
|
||||
if (oldLen > newLen) {
|
||||
for (let idx = oldLen - 1; idx >= newLen; idx--) {
|
||||
oldLines.splice(idx, 1);
|
||||
changesCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) new 比 old 长的部分:多出来的行视为新增,按顺序追加
|
||||
if (newLen > oldLen) {
|
||||
for (let idx = oldLen; idx < newLen; idx++) {
|
||||
oldLines.push(newLines[idx]);
|
||||
changesCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const newline = existingContent.includes("\r\n") ? "\r\n" : "\n";
|
||||
const finalContent = oldLines.join(newline);
|
||||
|
||||
return { finalContent, changesCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接使用新内容替换旧内容
|
||||
* - 若内容完全一致,则 changesCount 为 0
|
||||
* - 预留备用:当前未在业务中使用
|
||||
*/
|
||||
function replaceContentDirectly(existingContent, newContentStr) {
|
||||
if (existingContent === newContentStr) {
|
||||
return { finalContent: existingContent, changesCount: 0 };
|
||||
}
|
||||
return { finalContent: newContentStr, changesCount: -1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 部分文件更新:支持新增、删除、重命名、修改操作
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} codeVersion 代码版本号
|
||||
* @param {Array} files 文件操作列表
|
||||
* @param {Object} req 请求对象
|
||||
* @returns {Object} 更新结果
|
||||
*/
|
||||
async function specifiedFilesUpdate(
|
||||
projectId,
|
||||
codeVersion,
|
||||
files,
|
||||
req,
|
||||
isolationContext = {}
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (codeVersion === undefined || codeVersion === null) {
|
||||
throw new ValidationError("codeVersion cannot be empty", {
|
||||
field: "codeVersion",
|
||||
});
|
||||
}
|
||||
const versionNum = Number(codeVersion);
|
||||
if (!Number.isFinite(versionNum)) {
|
||||
throw new ValidationError("codeVersion must be a number", {
|
||||
field: "codeVersion",
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(files)) {
|
||||
throw new ValidationError("files must be an array", { field: "files" });
|
||||
}
|
||||
|
||||
// 验证文件操作结构
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const fileOp = files[i];
|
||||
if (!fileOp || typeof fileOp.operation !== "string") {
|
||||
throw new ValidationError(`files[${i}].operation cannot be empty`, {
|
||||
field: `files[${i}].operation`,
|
||||
});
|
||||
}
|
||||
// 使用 name 作为文件路径字段
|
||||
if (!fileOp.name || typeof fileOp.name !== "string") {
|
||||
throw new ValidationError(`files[${i}].name cannot be empty`, {
|
||||
field: `files[${i}].name`,
|
||||
});
|
||||
}
|
||||
|
||||
const operation = fileOp.operation.toLowerCase();
|
||||
if (!["create", "delete", "rename", "modify"].includes(operation)) {
|
||||
throw new ValidationError(
|
||||
`files[${i}].operation must be one of create, delete, rename or modify`,
|
||||
{ field: `files[${i}].operation` }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证特定操作所需的字段
|
||||
if (operation === "rename" && !fileOp.renameFrom) {
|
||||
throw new ValidationError(
|
||||
`files[${i}].renameFrom cannot be empty (rename operation requires)`,
|
||||
{ field: `files[${i}].renameFrom` }
|
||||
);
|
||||
}
|
||||
|
||||
if (operation === "modify") {
|
||||
if (typeof fileOp.contents !== "string") {
|
||||
throw new ValidationError(
|
||||
`files[${i}].contents must be a string (modify operation requires)`,
|
||||
{ field: `files[${i}].contents` }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
|
||||
throw new ResourceError("Project does not exist", { projectId });
|
||||
}
|
||||
|
||||
let backupZipPath = "";
|
||||
try {
|
||||
// 1) 备份
|
||||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
const zipName = `${projectId}-v${versionNum}.zip`;
|
||||
backupZipPath = path.join(backupDir, zipName);
|
||||
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
|
||||
await backupProjectToZip(projectId, projectPath, backupZipPath);
|
||||
log(projectId, "INFO", "Project backed up successfully", {
|
||||
projectId,
|
||||
zipPath: backupZipPath,
|
||||
});
|
||||
|
||||
// 2) 处理文件操作
|
||||
try {
|
||||
log(projectId, "DEBUG", "Start processing file operations", { projectId, filesCount: files.length });
|
||||
for (const fileOp of files) {
|
||||
const operation = fileOp.operation.toLowerCase();
|
||||
// 使用 name 作为文件路径
|
||||
const fileName = fileOp.name;
|
||||
|
||||
const normalizedPath = path.normalize(fileName).replace(/^[\/\\]+/, "");
|
||||
const targetPath = path.join(projectPath, normalizedPath);
|
||||
|
||||
// 安全检查:确保目标路径在项目目录内
|
||||
const resolvedTargetPath = path.resolve(targetPath);
|
||||
const resolvedProjectPath = path.resolve(projectPath);
|
||||
if (!resolvedTargetPath.startsWith(resolvedProjectPath + path.sep) &&
|
||||
resolvedTargetPath !== resolvedProjectPath) {
|
||||
log(projectId, "WARN", "Unsafe file path, skipping", {
|
||||
filePath: normalizedPath,
|
||||
resolvedPath: resolvedTargetPath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case "create": {
|
||||
// 创建新文件
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const contents = fileOp.contents || "";
|
||||
await fs.promises.writeFile(targetPath, contents, "utf8");
|
||||
log(projectId, "INFO", "File created successfully", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
// 删除文件
|
||||
if (fs.existsSync(targetPath)) {
|
||||
await fs.promises.unlink(targetPath);
|
||||
log(projectId, "INFO", "File deleted successfully", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "WARN", "File to delete does not exist", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "rename": {
|
||||
// 重命名文件
|
||||
const renameFrom = fileOp.renameFrom;
|
||||
if (!renameFrom || typeof renameFrom !== "string") {
|
||||
log(projectId, "WARN", "Rename operation missing renameFrom", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const normalizedFrom = path.normalize(renameFrom).replace(/^[\/\\]+/, "");
|
||||
const oldPath = path.join(projectPath, normalizedFrom);
|
||||
const resolvedOldPath = path.resolve(oldPath);
|
||||
|
||||
// 安全检查
|
||||
if (!resolvedOldPath.startsWith(resolvedProjectPath + path.sep) &&
|
||||
resolvedOldPath !== resolvedProjectPath) {
|
||||
log(projectId, "WARN", "Unsafe rename source path, skipping", {
|
||||
renameFrom: normalizedFrom,
|
||||
resolvedPath: resolvedOldPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (fs.existsSync(oldPath)) {
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.promises.rename(oldPath, targetPath);
|
||||
log(projectId, "INFO", "File renamed successfully", {
|
||||
oldPath: normalizedFrom,
|
||||
newPath: normalizedPath,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "WARN", "File to rename does not exist", {
|
||||
renameFrom: normalizedFrom,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "modify": {
|
||||
// 修改文件:前端传入完整 contents,这里根据新旧内容按行生成增删改
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
log(projectId, "WARN", "File to modify does not exist", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 读取现有文件内容
|
||||
const existingContent = await fs.promises.readFile(
|
||||
targetPath,
|
||||
"utf8"
|
||||
);
|
||||
const newContentStr =
|
||||
typeof fileOp.contents === "string" ? fileOp.contents : "";
|
||||
|
||||
// 使用按行 diff 的方式生成最终内容
|
||||
const { finalContent, changesCount } = diffContentByLines(
|
||||
existingContent,
|
||||
newContentStr
|
||||
);
|
||||
|
||||
// 1) 若内容完全一致,不覆写文件,避免触发 HMR
|
||||
if (changesCount === 0) {
|
||||
log(projectId, "INFO", "File content unchanged, skipping write", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 2) 写入修改后的内容
|
||||
await fs.promises.writeFile(targetPath, finalContent, "utf8");
|
||||
log(projectId, "INFO", "File modified successfully", {
|
||||
filePath: normalizedPath,
|
||||
changesCount,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
log(projectId, "WARN", "Unsupported operation type", {
|
||||
operation,
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 清理空目录
|
||||
log(projectId, "DEBUG", "Start cleaning empty directories", { projectId });
|
||||
try {
|
||||
await removeEmptyDirectories(
|
||||
projectPath,
|
||||
config.TRAVERSE_EXCLUDE_DIRS || []
|
||||
);
|
||||
} catch (e) {
|
||||
log(projectId, "WARN", "Failed to clean empty directories", {
|
||||
projectId,
|
||||
error: e && e.message,
|
||||
});
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Specified files updated successfully", {
|
||||
projectId,
|
||||
filesCount: files.length,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Specified files updated successfully",
|
||||
projectId,
|
||||
filesCount: files.length,
|
||||
};
|
||||
} catch (e) {
|
||||
log(projectId, "ERROR", "Failed to process file operations", {
|
||||
projectId,
|
||||
error: e && e.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
} catch (backupErr) {
|
||||
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
|
||||
if (!backupErr.isOperational) {
|
||||
throw new SystemError("Failed to backup project", {
|
||||
projectId,
|
||||
originalError: backupErr && backupErr.message,
|
||||
});
|
||||
}
|
||||
throw backupErr;
|
||||
}
|
||||
}
|
||||
|
||||
async function allFilesUpdate(
|
||||
projectId,
|
||||
codeVersion,
|
||||
files,
|
||||
req,
|
||||
isolationContext = {}
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
const versionNum = Number(codeVersion);
|
||||
if (!Number.isFinite(versionNum)) {
|
||||
throw new ValidationError("codeVersion must be a number", {
|
||||
field: "codeVersion",
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(files)) {
|
||||
throw new ValidationError("files must be an array", { field: "files" });
|
||||
}
|
||||
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
|
||||
throw new ResourceError("Project does not exist", { projectId });
|
||||
}
|
||||
|
||||
let backupZipPath = "";
|
||||
try {
|
||||
// 1) 备份
|
||||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
const zipName = `${projectId}-v${versionNum}.zip`;
|
||||
backupZipPath = path.join(backupDir, zipName);
|
||||
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
|
||||
await backupProjectToZip(projectId, projectPath, backupZipPath);
|
||||
|
||||
// 2) 写入文件
|
||||
try {
|
||||
log(projectId, "DEBUG", "Start writing files", { projectId, filesCount: files.length });
|
||||
for (const file of files) {
|
||||
if (!file || typeof file.name !== "string") continue;
|
||||
const targetPath = path.join(projectPath, file.name);
|
||||
|
||||
// 处理文件重命名:如果存在 renameFrom,需要先重命名原文件
|
||||
if (file.renameFrom && typeof file.renameFrom === "string") {
|
||||
const oldPath = path.join(projectPath, file.renameFrom);
|
||||
if (fs.existsSync(oldPath)) {
|
||||
// 确保目标目录存在(跨目录重命名时需要)
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.promises.rename(oldPath, targetPath);
|
||||
log(projectId, "INFO", "File renamed successfully", {
|
||||
projectId,
|
||||
oldPath: file.renameFrom,
|
||||
newPath: file.name,
|
||||
});
|
||||
continue; // 重命名后跳过后续的写入操作
|
||||
}
|
||||
}
|
||||
|
||||
const isBinary = file.binary === true;
|
||||
const isText = file.binary === false;
|
||||
const sizeExceeded = !!file.sizeExceeded;
|
||||
const hasContents =
|
||||
typeof file.contents === "string" && file.contents.length > 0;
|
||||
|
||||
// 处理二进制文件:如果已经存在就不写入,不存在才需要根据hasContents写入
|
||||
if (isBinary) {
|
||||
// 如果文件已存在,跳过写入
|
||||
if (fs.existsSync(targetPath)) {
|
||||
log(projectId, "INFO", "Binary file already exists, skipping write", {
|
||||
filePath: file.name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 文件不存在,且 hasContents 为 true,才写入
|
||||
if (hasContents) {
|
||||
// 二进制文件有 base64 编码的内容,需要解码为 Buffer 并写入
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const buffer = Buffer.from(file.contents, "base64");
|
||||
await fs.promises.writeFile(targetPath, buffer);
|
||||
log(projectId, "INFO", "Binary file written successfully", {
|
||||
filePath: file.name,
|
||||
});
|
||||
} catch (e) {
|
||||
log(projectId, "ERROR", "Failed to write binary file", {
|
||||
filePath: file.name,
|
||||
error: e && e.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 二进制文件没有内容(可能是大文件),且文件不存在
|
||||
log(projectId, "WARN", "Binary file does not exist and has no content, skipping", {
|
||||
filePath: file.name,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理文本文件
|
||||
const shouldReplace =
|
||||
isText && (!sizeExceeded || (sizeExceeded && hasContents));
|
||||
if (!shouldReplace) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.promises.writeFile(targetPath, file.contents || "", "utf8");
|
||||
}
|
||||
} catch (e) {
|
||||
log(projectId, "ERROR", "Failed to write files", {
|
||||
projectId,
|
||||
error: e && e.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 3) 清理缺失文件与空目录
|
||||
try {
|
||||
log(projectId, "DEBUG", "Start cleaning missing files and empty directories", { projectId });
|
||||
const keepSet = new Set(
|
||||
files
|
||||
.filter((f) => f && typeof f.name === "string")
|
||||
.map((f) => path.normalize(f.name))
|
||||
);
|
||||
await pruneMissingFiles(
|
||||
projectPath,
|
||||
keepSet,
|
||||
config.TRAVERSE_EXCLUDE_DIRS || []
|
||||
);
|
||||
await removeEmptyDirectories(
|
||||
projectPath,
|
||||
config.TRAVERSE_EXCLUDE_DIRS || []
|
||||
);
|
||||
} catch (e) {
|
||||
log(projectId, "ERROR", "Failed to clean missing files, starting rollback", {
|
||||
projectId,
|
||||
error: e && e.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Files submitted successfully", {
|
||||
projectId,
|
||||
filesCount: files.length,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "Files submitted successfully",
|
||||
projectId,
|
||||
restarted: false,
|
||||
};
|
||||
|
||||
} catch (backupErr) {
|
||||
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
|
||||
if (!backupErr.isOperational) {
|
||||
throw new SystemError("Failed to backup old version", {
|
||||
projectId,
|
||||
originalError: backupErr && backupErr.message,
|
||||
});
|
||||
}
|
||||
throw backupErr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件到指定路径
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} codeVersion 代码版本号
|
||||
* @param {Object} file 文件对象 (包含文件内容和元数据)
|
||||
* @param {string} filePath 文件在项目中的相对路径
|
||||
* @param {Object} req 请求对象
|
||||
* @returns {Object} 上传结果
|
||||
*/
|
||||
async function uploadSingleFile(
|
||||
projectId,
|
||||
codeVersion,
|
||||
file,
|
||||
filePath,
|
||||
req,
|
||||
isolationContext = {}
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
const versionNum = Number(codeVersion);
|
||||
if (!Number.isFinite(versionNum)) {
|
||||
throw new ValidationError("codeVersion must be a number", {
|
||||
field: "codeVersion",
|
||||
});
|
||||
}
|
||||
if (!file) {
|
||||
throw new ValidationError("File cannot be empty", { field: "file" });
|
||||
}
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
throw new ValidationError("File path cannot be empty", { field: "filePath" });
|
||||
}
|
||||
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
|
||||
throw new ResourceError("Project does not exist", { projectId });
|
||||
}
|
||||
|
||||
// 规范化文件路径,确保是相对路径
|
||||
const normalizedPath = path.normalize(filePath).replace(/^[\/\\]+/, "");
|
||||
const targetPath = path.join(projectPath, normalizedPath);
|
||||
|
||||
// 安全检查:确保目标路径在项目目录内
|
||||
const resolvedTargetPath = path.resolve(targetPath);
|
||||
const resolvedProjectPath = path.resolve(projectPath);
|
||||
if (!resolvedTargetPath.startsWith(resolvedProjectPath)) {
|
||||
throw new ValidationError("File path is not safe, cannot exceed project directory", {
|
||||
field: "filePath",
|
||||
providedPath: filePath,
|
||||
resolvedPath: resolvedTargetPath,
|
||||
});
|
||||
}
|
||||
|
||||
let backupZipPath = "";
|
||||
try {
|
||||
// 1) 备份
|
||||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
const zipName = `${projectId}-v${versionNum}.zip`;
|
||||
backupZipPath = path.join(backupDir, zipName);
|
||||
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
|
||||
await backupProjectToZip(projectId, projectPath, backupZipPath);
|
||||
log(projectId, "INFO", `Project backed up: ${backupZipPath}`, {
|
||||
projectId,
|
||||
zipPath: backupZipPath,
|
||||
});
|
||||
|
||||
// 2) 写入文件
|
||||
try {
|
||||
// 确保目标目录存在
|
||||
log(projectId, "DEBUG", "Start writing uploaded file", { projectId, filePath: normalizedPath });
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
// 写入文件内容,统一使用buffer(multer memoryStorage对所有文件类型都提供buffer)
|
||||
if (!file.buffer) {
|
||||
throw new ValidationError("File content format is incorrect, missing buffer", {
|
||||
field: "file",
|
||||
});
|
||||
}
|
||||
|
||||
// 记录写入前的文件信息,用于调试
|
||||
log(projectId, "INFO", "Prepare to write file", {
|
||||
targetPath,
|
||||
bufferLength: file.buffer.length,
|
||||
expectedSize: file.size,
|
||||
bufferIsBuffer: Buffer.isBuffer(file.buffer),
|
||||
sizeMatch: file.buffer.length === file.size,
|
||||
});
|
||||
|
||||
// 直接写入Buffer,Node.js会自动以二进制模式写入
|
||||
await fs.promises.writeFile(targetPath, file.buffer);
|
||||
|
||||
log(projectId, "INFO", "File uploaded successfully", {
|
||||
projectId,
|
||||
filePath: normalizedPath,
|
||||
targetPath: resolvedTargetPath,
|
||||
fileSize: file.buffer ? file.buffer.length : 0,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
// 判断是否需要重启开发服务器
|
||||
// const needRestart = shouldRestartForSingleFile(path.basename(normalizedPath));
|
||||
const needRestart = false;
|
||||
|
||||
if (needRestart) {
|
||||
try {
|
||||
const restartResult = await restartDevServer(req, projectId);
|
||||
log(projectId, "INFO", "Restart development server successfully", {
|
||||
projectId,
|
||||
pid: restartResult.pid,
|
||||
port: restartResult.port,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "File uploaded and restarted development server successfully",
|
||||
projectId,
|
||||
filePath: normalizedPath,
|
||||
targetPath: resolvedTargetPath,
|
||||
fileSize: file.buffer ? file.buffer.length : 0,
|
||||
pid: restartResult.pid,
|
||||
port: restartResult.port,
|
||||
restarted: true,
|
||||
};
|
||||
} catch (e) {
|
||||
log(projectId, "ERROR", "Failed to restart development server", {
|
||||
projectId,
|
||||
filePath: normalizedPath,
|
||||
error: e && e.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log(projectId, "INFO", "File modification does not require restarting development server", {
|
||||
projectId,
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "File uploaded successfully, no need to restart development server",
|
||||
projectId,
|
||||
restarted: false,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
log(projectId, "ERROR", "Failed to write file", {
|
||||
projectId,
|
||||
filePath: normalizedPath,
|
||||
error: e && e.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
} catch (backupErr) {
|
||||
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
|
||||
if (!backupErr.isOperational) {
|
||||
throw new SystemError("Failed to backup project", {
|
||||
projectId,
|
||||
filePath: normalizedPath,
|
||||
originalError: backupErr && backupErr.message,
|
||||
});
|
||||
}
|
||||
throw backupErr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚项目到指定版本
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} codeVersion 当前代码版本号
|
||||
* @param {string} rollbackTo 要回滚到的版本号
|
||||
* @param {Object} req 请求对象
|
||||
* @returns {Object} 回滚结果
|
||||
*/
|
||||
async function rollbackVersion(
|
||||
projectId,
|
||||
codeVersion,
|
||||
rollbackTo,
|
||||
req,
|
||||
isolationContext = {}
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
const versionNum = Number(codeVersion);
|
||||
if (!Number.isFinite(versionNum)) {
|
||||
throw new ValidationError("codeVersion must be a number", {
|
||||
field: "codeVersion",
|
||||
});
|
||||
}
|
||||
const rollbackToNum = Number(rollbackTo);
|
||||
if (!Number.isFinite(rollbackToNum)) {
|
||||
throw new ValidationError("rollbackTo must be a number", {
|
||||
field: "rollbackTo",
|
||||
});
|
||||
}
|
||||
if (rollbackToNum < 0) {
|
||||
throw new ValidationError("rollbackTo cannot be less than 0", {
|
||||
field: "rollbackTo",
|
||||
});
|
||||
}
|
||||
if (rollbackToNum >= versionNum) {
|
||||
throw new ValidationError("rollbackTo must be less than current codeVersion", {
|
||||
field: "rollbackTo",
|
||||
});
|
||||
}
|
||||
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
|
||||
throw new ResourceError("Project does not exist", { projectId });
|
||||
}
|
||||
|
||||
// 检查要回滚到的版本的备份文件是否存在
|
||||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||||
const rollbackZipName = `${projectId}-v${rollbackToNum}.zip`;
|
||||
const rollbackZipPath = path.join(backupDir, rollbackZipName);
|
||||
|
||||
if (!fs.existsSync(rollbackZipPath)) {
|
||||
log(projectId, "ERROR", "Rollback version backup file does not exist", {
|
||||
projectId,
|
||||
rollbackTo: rollbackToNum,
|
||||
zipPath: rollbackZipPath,
|
||||
});
|
||||
throw new ResourceError("Rollback version backup file does not exist", {
|
||||
projectId,
|
||||
rollbackTo: rollbackToNum,
|
||||
});
|
||||
}
|
||||
|
||||
let currentBackupZipPath = "";
|
||||
try {
|
||||
// 1) 备份当前版本
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
const currentZipName = `${projectId}-v${versionNum}.zip`;
|
||||
currentBackupZipPath = path.join(backupDir, currentZipName);
|
||||
|
||||
// 如果当前版本的备份已存在,跳过备份(避免覆盖)
|
||||
if (!fs.existsSync(currentBackupZipPath)) {
|
||||
await backupProjectToZip(projectId, projectPath, currentBackupZipPath);
|
||||
log(projectId, "INFO", "Current version backed up", {
|
||||
projectId,
|
||||
zipPath: currentBackupZipPath,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "INFO", "Current version backup already exists, skipping backup", {
|
||||
projectId,
|
||||
zipPath: currentBackupZipPath,
|
||||
});
|
||||
}
|
||||
|
||||
// 2) 从指定版本恢复项目
|
||||
log(projectId, "DEBUG", "Start restoring project from backup", { projectId, rollbackToNum, rollbackZipPath });
|
||||
await restoreProjectFromZip(projectId, projectPath, rollbackZipPath);
|
||||
log(projectId, "INFO", "Project rolled back successfully", {
|
||||
projectId,
|
||||
newVersion: versionNum,
|
||||
toVersion: rollbackToNum,
|
||||
rollbackZipPath,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Project rolled back successfully",
|
||||
newVersion: versionNum,
|
||||
rollbackTo: rollbackToNum,
|
||||
};
|
||||
} catch (restoreErr) {
|
||||
log(projectId, "ERROR", "Failed to rollback project", {
|
||||
projectId,
|
||||
rollbackTo: rollbackToNum,
|
||||
error: restoreErr && restoreErr.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
// 如果恢复失败,尝试从当前版本备份恢复(如果存在)
|
||||
if (currentBackupZipPath && fs.existsSync(currentBackupZipPath)) {
|
||||
try {
|
||||
log(projectId, "INFO", "Failed to rollback, trying to restore current version", {
|
||||
projectId,
|
||||
backupPath: currentBackupZipPath,
|
||||
});
|
||||
await restoreProjectFromZip(projectId, projectPath, currentBackupZipPath);
|
||||
log(projectId, "INFO", "Current version restored", {
|
||||
projectId,
|
||||
});
|
||||
} catch (recoveryErr) {
|
||||
log(projectId, "ERROR", "Failed to restore current version", {
|
||||
projectId,
|
||||
error: recoveryErr && recoveryErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!restoreErr.isOperational) {
|
||||
throw new SystemError("Failed to rollback project", {
|
||||
projectId,
|
||||
rollbackTo: rollbackToNum,
|
||||
originalError: restoreErr && restoreErr.message,
|
||||
});
|
||||
}
|
||||
throw restoreErr;
|
||||
}
|
||||
}
|
||||
|
||||
export { specifiedFilesUpdate, allFilesUpdate, uploadSingleFile, rollbackVersion };
|
||||
export default {
|
||||
specifiedFilesUpdate,
|
||||
allFilesUpdate,
|
||||
uploadSingleFile,
|
||||
rollbackVersion,
|
||||
};
|
||||
1185
qiming-file-server/src/service/projectService.js
Normal file
1185
qiming-file-server/src/service/projectService.js
Normal file
File diff suppressed because it is too large
Load Diff
94
qiming-file-server/src/utils/buildArg/extraArgsUtils.js
Normal file
94
qiming-file-server/src/utils/buildArg/extraArgsUtils.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { buildPortArgsForScript } from "./portUtils.js";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { ValidationError } from "../error/errorHandler.js";
|
||||
import portPool from "./portPool.js";
|
||||
|
||||
/**
|
||||
* 额外参数工具类
|
||||
* 封装 port 参数、basePath 参数和 environment variables 的处理逻辑
|
||||
*/
|
||||
class ExtraArgsUtils {
|
||||
/**
|
||||
* 处理启动参数和 environment variables
|
||||
* @param {Object} options 配置选项
|
||||
* @param {string} options.devScript dev脚本内容
|
||||
* @param {string} options.projectId 项目ID
|
||||
* @param {Object} options.req 请求对象(用于读取basePath)
|
||||
* @returns {Promise<Object>} { extraArgs, envExtra, port }
|
||||
*/
|
||||
static async processExtraArgs({ devScript, projectId, req }) {
|
||||
const extraArgs = [];
|
||||
const envExtra = {};
|
||||
const lower = (devScript || "").toLowerCase();
|
||||
|
||||
const isVite = lower.includes("vite");
|
||||
const isNext = lower.includes("next");
|
||||
|
||||
// 仅支持 vite 和 next.js,其余直接返回空
|
||||
if (!isVite && !isNext) {
|
||||
return { extraArgs, envExtra };
|
||||
}
|
||||
|
||||
// -- basePath --
|
||||
if (req) {
|
||||
let basePath = "";
|
||||
// 从请求中读取 basePath
|
||||
if (req.body && req.body.basePath) {
|
||||
// 支持字符串类型或通过 express 解析的其他类型
|
||||
basePath = String(req.body.basePath);
|
||||
} else if (req.query && req.query.basePath) {
|
||||
// 支持字符串类型或通过 express 解析的其他类型
|
||||
basePath = String(req.query.basePath);
|
||||
}
|
||||
|
||||
// 获取 projectId 用于日志(从 query 或 body 中)
|
||||
const projectId = (req.query && req.query.projectId) || (req.body && req.body.projectId) || "unknown";
|
||||
if (basePath) {
|
||||
log(projectId, "INFO", "Read basePath", {
|
||||
basePath: basePath,
|
||||
source: req.body && req.body.basePath ? "body" : "query"
|
||||
});
|
||||
}
|
||||
|
||||
// 规范化 basePath:以 / 开头和结尾
|
||||
if (basePath && basePath.trim()) {
|
||||
basePath = basePath.trim();
|
||||
if (!basePath.startsWith("/")) {
|
||||
basePath = "/" + basePath;
|
||||
}
|
||||
if (!basePath.endsWith("/")) {
|
||||
basePath = basePath + "/";
|
||||
}
|
||||
|
||||
if (isVite) {
|
||||
// Vite 使用 --base 参数
|
||||
extraArgs.push("--base", basePath);
|
||||
} else if (isNext) {
|
||||
// Next 不支持 --base,改为通过环境变量传递
|
||||
envExtra.NEXT_PUBLIC_BASE_PATH = basePath;
|
||||
envExtra.BASE_PATH = basePath; // 兼容项目自定义读取
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- port --
|
||||
// 从端口池分配端口(如果已分配会自动复用)
|
||||
const port = portPool.allocate(String(projectId));
|
||||
|
||||
const portArgs = buildPortArgsForScript(devScript, port);
|
||||
if (portArgs.length > 0) {
|
||||
extraArgs.push(...portArgs);
|
||||
}
|
||||
|
||||
// -- host --
|
||||
if (isVite) {
|
||||
const host = "0.0.0.0";
|
||||
extraArgs.push("--host", host);
|
||||
}
|
||||
|
||||
return { extraArgs, envExtra, port };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ExtraArgsUtils;
|
||||
153
qiming-file-server/src/utils/buildArg/portPool.js
Normal file
153
qiming-file-server/src/utils/buildArg/portPool.js
Normal file
@@ -0,0 +1,153 @@
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 端口池管理器(内存级别)
|
||||
* 适用于 Docker 容器环境:容器重启后所有进程和端口都会释放
|
||||
* 使用 Set 管理可用端口池,分配O(1),释放O(1)
|
||||
*/
|
||||
class PortPool {
|
||||
constructor() {
|
||||
// 端口范围配置
|
||||
this.portRangeStart = 4000;
|
||||
this.portRangeEnd = 55000;
|
||||
// 保留端口
|
||||
//this.reservedPorts = new Set([60000]);
|
||||
this.reservedRangeStart = 8000;
|
||||
this.reservedRangeEnd = 9000;
|
||||
|
||||
// 可用端口池(Set 结构,O(1) 取出和放入)
|
||||
this.availablePorts = new Set();
|
||||
|
||||
// 已分配的端口 Map: projectId -> port
|
||||
this.allocatedPorts = new Map();
|
||||
|
||||
// 初始化可用端口池
|
||||
this._initializePortPool();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化可用端口池
|
||||
* @private
|
||||
*/
|
||||
_initializePortPool() {
|
||||
for (let port = this.portRangeStart; port <= this.portRangeEnd; port++) {
|
||||
if (port >= this.reservedRangeStart && port <= this.reservedRangeEnd) {
|
||||
continue; // 跳过保留范围
|
||||
}
|
||||
// if (this.reservedPorts.has(port)) {
|
||||
// continue;
|
||||
// }
|
||||
this.availablePorts.add(port);
|
||||
}
|
||||
const reservedRangeCount = this.reservedRangeEnd - this.reservedRangeStart + 1;
|
||||
log("SYSTEM", "INFO", "Port pool initialized", {
|
||||
portRange: `${this.portRangeStart}-${this.portRangeEnd}`,
|
||||
totalPorts: this.availablePorts.size,
|
||||
reservedCount: reservedRangeCount
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为项目分配端口
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {number} 分配的端口号
|
||||
*/
|
||||
allocate(projectId) {
|
||||
// 如果该项目已经分配过端口,直接返回
|
||||
if (this.allocatedPorts.has(projectId)) {
|
||||
const existingPort = this.allocatedPorts.get(projectId);
|
||||
log(projectId, "INFO", "Project already has allocated port, reuse", { port: existingPort });
|
||||
return existingPort;
|
||||
}
|
||||
|
||||
// 从可用池中取出一个端口
|
||||
if (this.availablePorts.size === 0) {
|
||||
throw new Error(`Port pool exhausted: no available ports in range ${this.portRangeStart}-${this.portRangeEnd}`);
|
||||
}
|
||||
|
||||
// Set 的迭代器第一个值即为要分配的端口(Set 保持插入顺序)
|
||||
const port = this.availablePorts.values().next().value;
|
||||
|
||||
// 从可用池移除
|
||||
this.availablePorts.delete(port);
|
||||
|
||||
// 记录分配
|
||||
this.allocatedPorts.set(projectId, port);
|
||||
|
||||
log(projectId, "INFO", "Port pool allocated port", {
|
||||
port,
|
||||
totalAllocated: this.allocatedPorts.size,
|
||||
remainingAvailable: this.availablePorts.size
|
||||
});
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放项目的端口(归还到可用池)
|
||||
* @param {string} projectId 项目ID
|
||||
*/
|
||||
release(projectId) {
|
||||
const port = this.allocatedPorts.get(projectId);
|
||||
if (port) {
|
||||
// 从已分配中移除
|
||||
this.allocatedPorts.delete(projectId);
|
||||
|
||||
// 归还到可用池
|
||||
this.availablePorts.add(port);
|
||||
|
||||
log(projectId, "INFO", "Port pool released port", {
|
||||
port,
|
||||
totalAllocated: this.allocatedPorts.size,
|
||||
remainingAvailable: this.availablePorts.size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目当前分配的端口
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {number|null} 端口号,未分配则返回null
|
||||
*/
|
||||
getPort(projectId) {
|
||||
return this.allocatedPorts.get(projectId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取端口池状态
|
||||
* @returns {Object} 端口池状态信息
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
portRange: `${this.portRangeStart}-${this.portRangeEnd}`,
|
||||
totalAllocated: this.allocatedPorts.size,
|
||||
allocations: Array.from(this.allocatedPorts.entries()).map(([projectId, port]) => ({
|
||||
projectId,
|
||||
port
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空端口池(仅用于测试或维护)
|
||||
* 将所有已分配端口归还到可用池
|
||||
*/
|
||||
clear() {
|
||||
// 将已分配端口归还到可用池
|
||||
for (const port of this.allocatedPorts.values()) {
|
||||
this.availablePorts.add(port);
|
||||
}
|
||||
this.allocatedPorts.clear();
|
||||
|
||||
log("SYSTEM", "INFO", "Port pool cleared and reset", {
|
||||
availablePorts: this.availablePorts.size,
|
||||
allocatedPorts: this.allocatedPorts.size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 单例模式:全局共享一个端口池实例
|
||||
const portPool = new PortPool();
|
||||
|
||||
export default portPool;
|
||||
|
||||
793
qiming-file-server/src/utils/buildArg/portUtils.js
Normal file
793
qiming-file-server/src/utils/buildArg/portUtils.js
Normal file
@@ -0,0 +1,793 @@
|
||||
import fs from "fs";
|
||||
import net from "net";
|
||||
import { execSync } from "child_process";
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
// 从脚本字符串解析端口号(支持 --port/-p 以及内联 PORT=)
|
||||
function parsePortFromScript(script) {
|
||||
if (!script || typeof script !== "string") return undefined;
|
||||
let m = script.match(/--port\s+(\d{2,5})/);
|
||||
if (m) return Number(m[1]);
|
||||
m = script.match(/-p\s+(\d{2,5})/);
|
||||
if (m) return Number(m[1]);
|
||||
m = script.match(/PORT\s*=\s*(\d{2,5})/);
|
||||
if (m) return Number(m[1]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 获取可用端口(传入首选端口,不可用则递增寻找)
|
||||
function getAvailablePort(preferred) {
|
||||
return new Promise((resolve) => {
|
||||
const start = Number(preferred) || 3000;
|
||||
const maxAttempts = 2000; // 端口向上探测范围
|
||||
|
||||
function tryPort(p, attempts) {
|
||||
if (attempts > maxAttempts) {
|
||||
// 超出探测范围,回退到起始端口返回
|
||||
resolve(start);
|
||||
return;
|
||||
}
|
||||
|
||||
// 先用 lsof 检查是否已被占用(覆盖 IPv4/IPv6 监听差异)
|
||||
if (isPortListening(p)) {
|
||||
tryPort(p + 1, attempts + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
let resolved = false;
|
||||
server.on("error", () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
// 被占用或其他错误,尝试下一个
|
||||
tryPort(p + 1, attempts + 1);
|
||||
});
|
||||
server.listen(p, () => {
|
||||
if (resolved) {
|
||||
try {
|
||||
server.close();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
const assigned = server.address().port;
|
||||
server.close(() => resolve(assigned));
|
||||
});
|
||||
}
|
||||
|
||||
tryPort(start, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// 判断端口是否处于监听状态(优先ss,然后netstat,最后lsof)
|
||||
function isPortListening(port) {
|
||||
// 策略1: 优先使用 ss 命令(Linux,性能最好)
|
||||
if (tryIsPortListeningUsingSs(port)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 策略2: 使用 netstat 命令(跨平台,性能较好)
|
||||
if (tryIsPortListeningUsingNetstat(port)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
// 策略3: 兜底使用 lsof 命令(兼容性最好)
|
||||
//return tryIsPortListeningUsingLsof(port);
|
||||
}
|
||||
|
||||
// 使用 ss 命令检查端口是否监听
|
||||
function tryIsPortListeningUsingSs(port) {
|
||||
try {
|
||||
// ss -ltn 显示监听的 TCP 端口
|
||||
// 输出格式: State Recv-Q Send-Q Local Address:Port Peer Address:Port
|
||||
const cmd = `ss -ltn 2>/dev/null | grep ":${Number(port)} "`;
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
shell: "/bin/sh",
|
||||
timeout: 2000, // 2秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
// 检查是否有 LISTEN 状态的端口
|
||||
return out.trim().length > 0 && out.includes("LISTEN");
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 netstat 命令检查端口是否监听
|
||||
function tryIsPortListeningUsingNetstat(port) {
|
||||
try {
|
||||
let cmd;
|
||||
|
||||
// 根据操作系统选择不同的 netstat 命令
|
||||
if (process.platform === "linux") {
|
||||
// Linux: netstat -ltn
|
||||
cmd = `netstat -ltn 2>/dev/null | grep ":${Number(port)} "`;
|
||||
} else if (process.platform === "darwin") {
|
||||
// macOS: netstat -an
|
||||
cmd = `netstat -an 2>/dev/null | grep "LISTEN" | grep "\\.${Number(port)} "`;
|
||||
} else {
|
||||
// 其他系统暂不支持
|
||||
return false;
|
||||
}
|
||||
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
shell: "/bin/sh",
|
||||
timeout: 2000, // 2秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
return out.trim().length > 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 lsof 命令检查端口是否监听(兜底方案)
|
||||
function tryIsPortListeningUsingLsof(port) {
|
||||
try {
|
||||
const cmd = `lsof -Pi :${Number(port)} -sTCP:LISTEN -n`;
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 2000, // 2秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
return out.trim().length > 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取进程的所有子进程ID(兼容Linux和macOS)
|
||||
function getChildPids(pid) {
|
||||
try {
|
||||
// 方法1: 使用pgrep -P (Linux和macOS都支持)
|
||||
try {
|
||||
const cmd = `pgrep -P ${pid}`;
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 2000, // 2秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
const childPids = out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((p) => p)
|
||||
.map((p) => Number(p));
|
||||
if (childPids.length > 0) {
|
||||
return childPids;
|
||||
}
|
||||
} catch (e) {
|
||||
// pgrep失败,尝试其他方法
|
||||
}
|
||||
|
||||
// 方法2: 使用ps命令查找子进程 (兼容性更好)
|
||||
try {
|
||||
const cmd = `ps -eo pid,ppid | awk '$2==${pid} {print $1}'`;
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 2000, // 2秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
const childPids = out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((p) => p)
|
||||
.map((p) => Number(p));
|
||||
return childPids;
|
||||
} catch (e) {
|
||||
// ps命令也失败
|
||||
}
|
||||
|
||||
// 方法3: Linux特有的/proc文件系统
|
||||
if (process.platform === "linux") {
|
||||
try {
|
||||
const childrenFile = `/proc/${pid}/task/${pid}/children`;
|
||||
if (fs.existsSync(childrenFile)) {
|
||||
const content = fs.readFileSync(childrenFile, "utf8");
|
||||
const childPids = content
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter((p) => p)
|
||||
.map((p) => Number(p));
|
||||
return childPids;
|
||||
}
|
||||
} catch (e) {
|
||||
// /proc方法失败
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 通过进程ID查询该进程及其子进程监听的端口,返回端口和对应的进程ID
|
||||
function getPortsByPid(pid, projectId = "default") {
|
||||
try {
|
||||
// 首先检查进程是否存在
|
||||
const checkCmd = `ps -p ${pid} -o pid=`;
|
||||
try {
|
||||
execSync(checkCmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 2000, // 2秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
} catch (e) {
|
||||
// 进程不存在
|
||||
return [];
|
||||
}
|
||||
|
||||
// 使用递归查找获取端口映射
|
||||
const portPidMap = getPortsRecursively(pid, new Set(), projectId);
|
||||
|
||||
// 返回端口数组(按端口号排序)
|
||||
return Array.from(portPidMap.keys()).sort((a, b) => a - b);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 递归查找进程及其所有后代进程监听的端口
|
||||
function getPortsRecursively(pid, visitedPids = new Set(), projectId = "default") {
|
||||
const portPidMap = new Map();
|
||||
|
||||
// 避免循环引用
|
||||
if (visitedPids.has(pid)) {
|
||||
return portPidMap;
|
||||
}
|
||||
visitedPids.add(pid);
|
||||
|
||||
try {
|
||||
// 查询当前进程的端口
|
||||
const currentPorts = getPortsBySinglePid(pid, projectId);
|
||||
currentPorts.forEach((port) => {
|
||||
portPidMap.set(port, pid);
|
||||
});
|
||||
|
||||
// 如果当前进程有端口,直接返回(优先返回最近的进程)
|
||||
if (currentPorts.length > 0) {
|
||||
return portPidMap;
|
||||
}
|
||||
|
||||
// 递归查询子进程的端口
|
||||
const childPids = getChildPids(pid);
|
||||
for (const childPid of childPids) {
|
||||
const childPortMap = getPortsRecursively(childPid, visitedPids, projectId);
|
||||
childPortMap.forEach((childPid, port) => {
|
||||
portPidMap.set(port, childPid);
|
||||
});
|
||||
}
|
||||
|
||||
return portPidMap;
|
||||
} catch (e) {
|
||||
return portPidMap;
|
||||
}
|
||||
}
|
||||
|
||||
// 通过进程ID查询该进程及其子进程监听的端口,返回端口和进程ID的映射
|
||||
function getPortsAndPidsByPid(pid, projectId = "default") {
|
||||
try {
|
||||
log(projectId, "DEBUG", `Check if process exists`, { pid });
|
||||
|
||||
// 首先检查进程是否存在
|
||||
const checkCmd = `ps -p ${pid} -o pid=`;
|
||||
try {
|
||||
execSync(checkCmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 2000, // 2秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
log(projectId, "DEBUG", `Process exists, start recursive query ports`, { pid });
|
||||
} catch (e) {
|
||||
// 进程不存在
|
||||
log(projectId, "DEBUG", `Process does not exist`, { pid });
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// 递归查找所有后代进程的端口
|
||||
return getPortsRecursively(pid, new Set(), projectId);
|
||||
} catch (e) {
|
||||
const errMsg = e && e.message ? e.message : String(e);
|
||||
log(projectId, "ERROR", `getPortsAndPidsByPid exception`, { pid, error: errMsg });
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// 查询单个进程的端口(内部函数)
|
||||
function getPortsBySinglePid(pid, projectId = "default") {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 策略1: 优先使用 ss 命令(Linux,性能最好)
|
||||
const portsFromSs = tryGetPortsUsingSs(pid, projectId);
|
||||
if (portsFromSs.length > 0) {
|
||||
const duration = Date.now() - startTime;
|
||||
log(projectId, "INFO", `Using ss to query ports`, {
|
||||
pid,
|
||||
ports: portsFromSs,
|
||||
duration: `${duration}ms`,
|
||||
method: "ss"
|
||||
});
|
||||
return portsFromSs;
|
||||
}
|
||||
|
||||
// 策略2: 使用 netstat 命令(跨平台,性能较好)
|
||||
const portsFromNetstat = tryGetPortsUsingNetstat(pid, projectId);
|
||||
if (portsFromNetstat.length > 0) {
|
||||
const duration = Date.now() - startTime;
|
||||
log(projectId, "INFO", `Using netstat to query ports`, {
|
||||
pid,
|
||||
ports: portsFromNetstat,
|
||||
duration: `${duration}ms`,
|
||||
method: "netstat"
|
||||
});
|
||||
return portsFromNetstat;
|
||||
}
|
||||
return [];
|
||||
|
||||
// 策略3: 兜底使用 lsof 命令(兼容性最好,但可能造成进程卡死)
|
||||
/*
|
||||
const portsFromLsof = tryGetPortsUsingLsof(pid, projectId);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (portsFromLsof.length > 0) {
|
||||
log(projectId, "INFO", `Using lsof to query ports`, {
|
||||
pid,
|
||||
ports: portsFromLsof,
|
||||
duration: `${duration}ms`,
|
||||
method: "lsof"
|
||||
});
|
||||
} else {
|
||||
log(projectId, "INFO", `No ports found`, {
|
||||
pid,
|
||||
duration: `${duration}ms`,
|
||||
methods: "ss->netstat->lsof"
|
||||
});
|
||||
}
|
||||
|
||||
return portsFromLsof;
|
||||
*/
|
||||
}
|
||||
|
||||
// 使用 ss 命令获取端口(Linux)
|
||||
function tryGetPortsUsingSs(pid, projectId = "default") {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
log(projectId, "DEBUG", `Try using ss command to query ports`, { pid });
|
||||
|
||||
// ss -ltnp 显示监听的 TCP 端口及进程信息
|
||||
// 输出格式: State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
||||
const cmd = `ss -ltnp 2>/dev/null | grep "pid=${pid},"`;
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
shell: "/bin/sh",
|
||||
timeout: 3000, // 3秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
const lines = out.trim().split("\n");
|
||||
|
||||
const ports = [];
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
|
||||
// 解析 Local Address:Port 列,格式如: 0.0.0.0:3000 或 [::]:3000 或 127.0.0.1:3000
|
||||
const portMatch = line.match(/(?:0\.0\.0\.0|\[::\]|127\.0\.0\.1|\*):(\d+)/);
|
||||
if (portMatch) {
|
||||
const port = Number(portMatch[1]);
|
||||
if (port && !ports.includes(port)) {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
log(projectId, "DEBUG", `ss command executed successfully`, { pid, portsCount: ports.length, duration: `${duration}ms` });
|
||||
return ports;
|
||||
} catch (e) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errMsg = e && e.message ? e.message : String(e);
|
||||
log(projectId, "DEBUG", `ss command execution failed`, { pid, error: errMsg, duration: `${duration}ms` });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 netstat 命令获取端口(跨平台)
|
||||
function tryGetPortsUsingNetstat(pid, projectId = "default") {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
let cmd;
|
||||
|
||||
// 根据操作系统选择不同的 netstat 命令
|
||||
if (process.platform === "linux") {
|
||||
log(projectId, "DEBUG", `Try using netstat command to query ports`, { pid });
|
||||
// Linux: netstat -ltnp
|
||||
cmd = `netstat -ltnp 2>/dev/null | grep "${pid}/"`;
|
||||
} else if (process.platform === "darwin") {
|
||||
// macOS: netstat 不支持 -p,需要先用 netstat 获取端口,再验证进程
|
||||
// 这里直接跳过,让 lsof 处理 macOS 的情况
|
||||
log(projectId, "DEBUG", `macOS system skip netstat`, { pid });
|
||||
return [];
|
||||
} else {
|
||||
// 其他系统暂不支持
|
||||
log(projectId, "DEBUG", `${process.platform} system does not support netstat`, { pid });
|
||||
return [];
|
||||
}
|
||||
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
shell: "/bin/sh",
|
||||
timeout: 3000, // 3秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
const lines = out.trim().split("\n");
|
||||
|
||||
const ports = [];
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
|
||||
// 解析 Local Address 列,格式如: 0.0.0.0:3000 或 :::3000 或 127.0.0.1:3000
|
||||
const portMatch = line.match(/(?:0\.0\.0\.0|:::|\*:|127\.0\.0\.1:)(\d+)/);
|
||||
if (portMatch) {
|
||||
const port = Number(portMatch[1]);
|
||||
if (port && !ports.includes(port)) {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
log(projectId, "DEBUG", `netstat command executed successfully`, { pid, portsCount: ports.length, duration: `${duration}ms` });
|
||||
return ports;
|
||||
} catch (e) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errMsg = e && e.message ? e.message : String(e);
|
||||
log(projectId, "DEBUG", `netstat command execution failed`, { pid, error: errMsg, duration: `${duration}ms` });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 lsof 命令获取端口(兜底方案)
|
||||
function tryGetPortsUsingLsof(pid, projectId = "default") {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
log(projectId, "DEBUG", `Try using lsof command to query ports`, { pid });
|
||||
|
||||
const cmd = `lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN -n`;
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 3000, // 3秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
const lines = out.trim().split("\n");
|
||||
|
||||
// 跳过标题行
|
||||
if (lines.length <= 1) {
|
||||
const duration = Date.now() - startTime;
|
||||
log(projectId, "DEBUG", `lsof command did not find listening ports`, { pid, duration: `${duration}ms` });
|
||||
return [];
|
||||
}
|
||||
|
||||
const ports = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// 解析lsof输出格式: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
// NAME列包含端口信息,格式如: *:3000 (LISTEN) 或 localhost:3000 (LISTEN)
|
||||
const nameMatch = line.match(
|
||||
/(\*|localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)\s*\(LISTEN\)/
|
||||
);
|
||||
if (nameMatch) {
|
||||
const port = Number(nameMatch[2]);
|
||||
if (port && !ports.includes(port)) {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
log(projectId, "DEBUG", `lsof command executed successfully`, { pid, portsCount: ports.length, duration: `${duration}ms` });
|
||||
return ports;
|
||||
} catch (e) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errMsg = e && e.message ? e.message : String(e);
|
||||
log(projectId, "DEBUG", `lsof command execution failed`, { pid, error: errMsg, duration: `${duration}ms` });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 查询监听特定端口的所有进程ID
|
||||
function getPidsByPort(port) {
|
||||
try {
|
||||
const cmd = `lsof -ti:${port}`;
|
||||
const buf = execSync(cmd, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 2000, // 2秒超时
|
||||
killSignal: "SIGKILL"
|
||||
});
|
||||
const out = String(buf || "");
|
||||
const pids = out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((pid) => pid)
|
||||
.map((pid) => Number(pid));
|
||||
return pids;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 等待进程开始监听端口,带超时,返回端口和对应的进程ID
|
||||
function waitPortFromPid(pid, timeoutMs = 10000, intervalMs = 500, projectId = "default") {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = Math.ceil(timeoutMs / intervalMs);
|
||||
|
||||
log(projectId, "INFO", `Start waiting for listening port`, {
|
||||
pid,
|
||||
timeoutMs,
|
||||
intervalMs,
|
||||
maxAttempts
|
||||
});
|
||||
|
||||
function checkOnce() {
|
||||
if (resolved) return;
|
||||
attempts++;
|
||||
|
||||
log(projectId, "INFO", `Query listening port (第${attempts}/${maxAttempts}次)`, {
|
||||
pid,
|
||||
attempts
|
||||
});
|
||||
|
||||
try {
|
||||
log(projectId, "DEBUG", `Start calling getPortsAndPidsByPid`, { pid });
|
||||
const queryStartTime = Date.now();
|
||||
const portPidMap = getPortsAndPidsByPid(pid, projectId);
|
||||
const queryDuration = Date.now() - queryStartTime;
|
||||
|
||||
log(projectId, "DEBUG", `getPortsAndPidsByPid called successfully`, {
|
||||
pid,
|
||||
mapSize: portPidMap.size,
|
||||
duration: `${queryDuration}ms`
|
||||
});
|
||||
|
||||
if (portPidMap.size > 0) {
|
||||
resolved = true;
|
||||
// 返回第一个监听的端口和对应的进程ID
|
||||
const firstPort = Array.from(portPidMap.keys()).sort(
|
||||
(a, b) => a - b
|
||||
)[0];
|
||||
const actualPid = portPidMap.get(firstPort);
|
||||
|
||||
log(projectId, "INFO", `Successfully detected listening port`, {
|
||||
pid,
|
||||
actualPid,
|
||||
port: firstPort,
|
||||
attempts,
|
||||
allPorts: Array.from(portPidMap.keys())
|
||||
});
|
||||
|
||||
resolve({ port: firstPort, pid: Number(actualPid) });
|
||||
return;
|
||||
}
|
||||
|
||||
log(projectId, "DEBUG", `No listening port found in this query`, { pid, attempts });
|
||||
} catch (e) {
|
||||
const errMsg = e && e.message ? e.message : String(e);
|
||||
log(projectId, "ERROR", `Exception occurred when querying ports`, {
|
||||
pid,
|
||||
attempts,
|
||||
error: errMsg,
|
||||
stack: e && e.stack ? e.stack : ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 初次尝试
|
||||
checkOnce();
|
||||
if (resolved) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (resolved) return;
|
||||
checkOnce();
|
||||
if (resolved) {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearInterval(interval);
|
||||
|
||||
log(projectId, "WARN", `Waiting for listening port timeout`, {
|
||||
pid,
|
||||
timeoutMs,
|
||||
attempts
|
||||
});
|
||||
|
||||
resolve(undefined);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// 轮询等待端口进入监听状态
|
||||
function waitPortListening(port, timeoutMs = 10000, intervalMs = 300) {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
function tick() {
|
||||
if (isPortListening(port)) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, intervalMs);
|
||||
}
|
||||
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
// 从日志文件中提取首次出现的端口号,带超时
|
||||
function waitPortFromLog(logPath, timeoutMs = 15000) {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
function extractPortFromContent(content) {
|
||||
// 按行分割,从最新内容开始查找端口
|
||||
const lines = content.split("\n").reverse();
|
||||
|
||||
for (const line of lines) {
|
||||
// 跳过时间戳行(包含 [YYYY/M/D H:MM:SS] 格式)
|
||||
if (line.match(/^\[[\d\/\s:]+\]/)) continue;
|
||||
|
||||
// 跳过明确错误/占用语句,避免误把错误中的 :端口 解析为成功端口
|
||||
if (/(EADDRINUSE|address already in use|Error:\s*listen)/i.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过错误对象字段,如 "port: 10001"、"code: 'EADDRINUSE'" 等
|
||||
if (/^(\s|\t)*(code|errno|syscall|address|port)\s*:/i.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. 优先匹配 URL 格式: http://localhost:5173/ 或 https://127.0.0.1:3000
|
||||
const urlMatch = line.match(/https?:\/\/[^\s:]+:(\d{2,5})/);
|
||||
if (urlMatch) return Number(urlMatch[1]);
|
||||
|
||||
// 2. 匹配带有动词的端口声明,避免匹配裸 "port: 3000"
|
||||
const portTextMatch = line.match(
|
||||
/(?:listening|running|started)[^\n]*\bport\b\s*:?\s*(\d{2,5})/i
|
||||
);
|
||||
if (portTextMatch) return Number(portTextMatch[1]);
|
||||
|
||||
// 3. 匹配 "listening on :3000" 或 "server running on :8080"
|
||||
const listenMatch = line.match(
|
||||
/(?:listening|running|started)\s+on\s*:(\d{2,5})/i
|
||||
);
|
||||
if (listenMatch) return Number(listenMatch[1]);
|
||||
|
||||
// 4. 匹配 "Local: http://localhost:3000" 或 "Network: http://192.168.1.1:3000"
|
||||
const localMatch = line.match(
|
||||
/(?:Local|Network|local|network):\s*https?:\/\/[^\s:]+:(\d{2,5})/i
|
||||
);
|
||||
if (localMatch) return Number(localMatch[1]);
|
||||
|
||||
// 5. Next.js: ready - started server on 0.0.0.0:3000, url: ...
|
||||
const nextReadyMatch = line.match(
|
||||
/ready\s*-\s*started\s*server[^:]*:(\d{2,5})/i
|
||||
);
|
||||
if (nextReadyMatch) return Number(nextReadyMatch[1]);
|
||||
|
||||
// 取消泛化兜底:避免把报错里的 :端口 当成成功端口
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function checkOnce() {
|
||||
if (resolved) return;
|
||||
try {
|
||||
if (!fs.existsSync(logPath)) return;
|
||||
const stats = fs.statSync(logPath);
|
||||
const readSize = Math.min(64 * 1024, stats.size);
|
||||
const fd = fs.openSync(logPath, "r");
|
||||
const buffer = Buffer.alloc(readSize);
|
||||
fs.readSync(fd, buffer, 0, readSize, stats.size - readSize);
|
||||
fs.closeSync(fd);
|
||||
const text = buffer.toString("utf8");
|
||||
const port = extractPortFromContent(text);
|
||||
if (port) {
|
||||
resolved = true;
|
||||
resolve(port);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 初次尝试
|
||||
checkOnce();
|
||||
if (resolved) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (resolved) return;
|
||||
checkOnce();
|
||||
if (resolved) {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearInterval(interval);
|
||||
resolve(undefined);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// 根据脚本推断应传递的端口参数
|
||||
function buildPortArgsForScript(script, port) {
|
||||
const args = [];
|
||||
const p = String(port);
|
||||
if (!script || typeof script !== "string") return args;
|
||||
const lower = script.toLowerCase();
|
||||
|
||||
// Vite: 如果指定了端口,强制使用该端口,并开启严格模式(端口占用则失败)
|
||||
if (lower.includes("vite")) {
|
||||
if (port) {
|
||||
args.push("--port", p, "--strictPort");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
// 常见工具:vite、webpack-dev-server、vitepress、umi、serve
|
||||
if (
|
||||
lower.includes("webpack") ||
|
||||
lower.includes("vitepress") ||
|
||||
lower.includes("umi") ||
|
||||
lower.includes("serve")
|
||||
) {
|
||||
args.push("--port", p);
|
||||
return args;
|
||||
}
|
||||
// next/nuxt 一般支持 -p/--port
|
||||
if (lower.includes("next") || lower.includes("nuxt")) {
|
||||
// Next/Nuxt 使用 CLI 端口参数,避免使用环境变量 PORT
|
||||
args.push("-p", p);
|
||||
return args;
|
||||
}
|
||||
// 兜底:--port
|
||||
args.push("--port", p);
|
||||
return args;
|
||||
}
|
||||
|
||||
export {
|
||||
parsePortFromScript,
|
||||
getAvailablePort,
|
||||
waitPortFromLog,
|
||||
buildPortArgsForScript,
|
||||
isPortListening,
|
||||
waitPortListening,
|
||||
getPortsByPid,
|
||||
waitPortFromPid,
|
||||
getPidsByPort,
|
||||
getChildPids,
|
||||
getPortsAndPidsByPid,
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
import { exec, spawn } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 获取文件修改时间
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {number} 修改时间戳(毫秒)
|
||||
*/
|
||||
function getFileMtime(filePath) {
|
||||
try {
|
||||
//同步读取文件的元信息,并返回该文件的最后修改时间的时间戳(毫秒)
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该安装依赖
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shouldInstallDeps(projectPath) {
|
||||
const pkgPath = path.join(projectPath, "package.json");
|
||||
const lockPathNpm = path.join(projectPath, "package-lock.json");
|
||||
const lockPathYarn = path.join(projectPath, "yarn.lock");
|
||||
const nodeModulesPath = path.join(projectPath, "node_modules");
|
||||
|
||||
const nodeModulesExists = fs.existsSync(nodeModulesPath);
|
||||
if (!nodeModulesExists) return true;
|
||||
|
||||
const pkgMtime = getFileMtime(pkgPath);
|
||||
const lockMtime = Math.max(
|
||||
getFileMtime(lockPathNpm),
|
||||
getFileMtime(lockPathYarn)
|
||||
);
|
||||
const nodeModulesMtime = getFileMtime(nodeModulesPath);
|
||||
|
||||
// 如果 package.json 或锁文件比 node_modules 更新,则需要安装
|
||||
return Math.max(pkgMtime, lockMtime) > nodeModulesMtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并删除 node_modules 文件夹和 lock 文件
|
||||
* @param {string} projectPath - 项目路径
|
||||
* @param {string} projectId - 项目ID(可选,用于日志)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function removeNodeModules(projectPath, projectId = null) {
|
||||
const nodeModulesPath = path.join(projectPath, "node_modules");
|
||||
const logId = projectId || path.basename(projectPath);
|
||||
|
||||
if (fs.existsSync(nodeModulesPath)) {
|
||||
log(logId, "INFO", "Found node_modules folder, deleting", {
|
||||
projectPath,
|
||||
nodeModulesPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await fs.promises.rm(nodeModulesPath, { recursive: true, force: true });
|
||||
log(logId, "INFO", "node_modules folder deleted successfully", {
|
||||
projectPath,
|
||||
nodeModulesPath,
|
||||
});
|
||||
} catch (error) {
|
||||
log(logId, "WARN", `Failed to delete node_modules folder: ${error.message}`, {
|
||||
projectPath,
|
||||
nodeModulesPath,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 lock 文件
|
||||
const lockFiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
|
||||
|
||||
for (const lockFile of lockFiles) {
|
||||
const lockFilePath = path.join(projectPath, lockFile);
|
||||
|
||||
if (fs.existsSync(lockFilePath)) {
|
||||
log(logId, "INFO", `Found ${lockFile} file, deleting`, {
|
||||
projectPath,
|
||||
lockFilePath,
|
||||
});
|
||||
|
||||
try {
|
||||
await fs.promises.unlink(lockFilePath);
|
||||
log(logId, "INFO", `${lockFile} file deleted successfully`, {
|
||||
projectPath,
|
||||
lockFilePath,
|
||||
});
|
||||
} catch (error) {
|
||||
log(logId, "WARN", `Failed to delete ${lockFile} file: ${error.message}`, {
|
||||
projectPath,
|
||||
lockFilePath,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装项目依赖
|
||||
* @param {Object} req 请求对象
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {Object} options 可选参数
|
||||
* @param {Object} options.outStream 主日志流(可选)
|
||||
* @param {Object} options.tempOutStream 临时日志流(可选)
|
||||
* @param {Function} options.safeWrite 安全写入函数(可选)
|
||||
* @returns {Promise<string>} 安装输出
|
||||
*/
|
||||
function installDependencies(req, projectId, projectPath, options = {}) {
|
||||
const { outStream, tempOutStream, safeWrite } = options;
|
||||
|
||||
// 如果提供了日志流,使用 spawn 获取实时输出
|
||||
if (outStream && tempOutStream && safeWrite) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 优化 pnpm install 命令,提升在 Docker/容器环境中的性能
|
||||
// --prefer-offline: 优先使用本地缓存,减少网络请求
|
||||
// --reporter=silent: 静默模式,不输出进度信息,大幅提升 pipe 性能
|
||||
// 注意:--loglevel=error 只显示错误,避免频繁的 I/O 操作
|
||||
const command = `cd ${projectPath} && pnpm install --prefer-offline`;
|
||||
|
||||
// 记录开始时间
|
||||
const startTime = Date.now();
|
||||
|
||||
// 写入开始安装的日志(safeWrite 会自动添加时间戳)
|
||||
const startMessage = `Start installing dependencies\nCommand: pnpm install --prefer-offline\n`;
|
||||
safeWrite(outStream, startMessage, "Main log");
|
||||
safeWrite(tempOutStream, startMessage, "Temp log");
|
||||
|
||||
// 心跳定时器:每5秒输出一次,让用户知道还在进行中
|
||||
let heartbeatCount = 0;
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
heartbeatCount++;
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const heartbeatMsg = `Installing dependencies... (Elapsed time: ${elapsed} seconds)\n`;
|
||||
safeWrite(outStream, heartbeatMsg, "Main log");
|
||||
safeWrite(tempOutStream, heartbeatMsg, "Temp log");
|
||||
}, 5000); // 每5秒
|
||||
|
||||
// 使用 spawn 获取实时输出
|
||||
// 重要:必须传递环境变量,这样 pnpm 才能使用配置的 store 路径、镜像等配置
|
||||
const child = spawn("sh", ["-c", command], {
|
||||
cwd: projectPath,
|
||||
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
// 实时输出到日志流
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
const dataStr = data.toString();
|
||||
stdout += dataStr;
|
||||
safeWrite(outStream, dataStr, "Main log");
|
||||
safeWrite(tempOutStream, dataStr, "Temp log");
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
const dataStr = data.toString();
|
||||
stderr += dataStr;
|
||||
safeWrite(outStream, dataStr, "Main log");
|
||||
safeWrite(tempOutStream, dataStr, "Temp log");
|
||||
});
|
||||
}
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
// 清除心跳定时器
|
||||
clearInterval(heartbeatInterval);
|
||||
|
||||
// 计算总耗时
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
if (code !== 0) {
|
||||
const errorMessage = `Dependency installation failed (Elapsed time: ${totalTime} seconds)\nExit code: ${code}, Signal: ${signal}\n${stderr || 'No error information'}\n`;
|
||||
safeWrite(outStream, errorMessage, "Main log");
|
||||
safeWrite(tempOutStream, errorMessage, "Temp log");
|
||||
return reject(
|
||||
new Error(`Dependency installation failed: Exit code ${code}, Signal ${signal}\n${stderr}`)
|
||||
);
|
||||
}
|
||||
|
||||
// 即使退出码为0,也检查stderr中是否有错误信息
|
||||
if (stderr && stderr.includes("Error") && !stderr.includes("warning")) {
|
||||
const warnMessage = `Warning occurred during dependency installation: ${stderr}\n`;
|
||||
safeWrite(outStream, warnMessage, "Main log");
|
||||
safeWrite(tempOutStream, warnMessage, "Temp log");
|
||||
}
|
||||
|
||||
// 成功完成,输出摘要
|
||||
const successMessage = `✓ Dependency installation successful (Elapsed time: ${totalTime} seconds)\n`;
|
||||
safeWrite(outStream, successMessage, "Main log");
|
||||
safeWrite(tempOutStream, successMessage, "Temp log");
|
||||
|
||||
// 记录摘要信息(包括 stdout 和 stderr 的内容)
|
||||
if (stdout || stderr) {
|
||||
const summaryMessage = `Installation details:\n${stdout || '(No standard output)'}${stderr ? '\nWarning information:\n' + stderr : ''}\n`;
|
||||
safeWrite(outStream, summaryMessage, "Main log");
|
||||
safeWrite(tempOutStream, summaryMessage, "Temp log");
|
||||
} else {
|
||||
// 静默模式下可能没有输出,主动说明
|
||||
const noOutputMsg = `(Silent mode: No detailed output, dependency successfully linked from store to node_modules)\n`;
|
||||
safeWrite(outStream, noOutputMsg, "Main log");
|
||||
safeWrite(tempOutStream, noOutputMsg, "Temp log");
|
||||
}
|
||||
|
||||
resolve(stdout);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
// 清除心跳定时器
|
||||
clearInterval(heartbeatInterval);
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
const errorMessage = `Dependency installation process error (Elapsed time: ${totalTime} seconds): ${error.message}\n`;
|
||||
safeWrite(outStream, errorMessage, "Main log");
|
||||
safeWrite(tempOutStream, errorMessage, "Temp log");
|
||||
reject(new Error(`Dependency installation failed: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 如果没有提供日志流,使用原来的 exec 方式(保持向后兼容)
|
||||
return new Promise((resolve, reject) => {
|
||||
// 优化 pnpm install 命令,提升性能
|
||||
const command = `cd ${projectPath} && pnpm install --prefer-offline --reporter=silent --loglevel=error`;
|
||||
|
||||
log(projectId, "INFO", "Start executing dependency installation command", {
|
||||
command,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
exec(
|
||||
command,
|
||||
{
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
log(projectId, "ERROR", "Dependency installation failed", {
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
stderr: stderr || error.message,
|
||||
stdout: stdout || "",
|
||||
});
|
||||
return reject(
|
||||
new Error(
|
||||
`Dependency installation failed: ${error.message}\n${stderr || error.message}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 即使退出码为0,也检查stderr中是否有错误信息
|
||||
if (stderr && stderr.includes("Error") && !stderr.includes("warning")) {
|
||||
log(projectId, "WARN", "Warning or error occurred during dependency installation", {
|
||||
stderr,
|
||||
});
|
||||
// 可以选择继续执行或拒绝
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Dependency installation completed", {
|
||||
stdout: stdout.substring(0, 500), // 只记录前500个字符
|
||||
});
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { getFileMtime, shouldInstallDeps, installDependencies, removeNodeModules };
|
||||
76
qiming-file-server/src/utils/buildJudge/aliveJudgeUtils.js
Normal file
76
qiming-file-server/src/utils/buildJudge/aliveJudgeUtils.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import { URL } from "url";
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 通过对本地端口发起 HTTP 请求判断项目是否存活
|
||||
* 返回 2xx/3xx 视为存活,其余或超时/异常视为不存活
|
||||
*/
|
||||
async function isProjectAlive(projectId, port, basePath, options = {}) {
|
||||
const timeoutMs = Number(options.timeoutMs) || 1500;
|
||||
const portNum = Number(port);
|
||||
if (!Number.isFinite(portNum) || portNum <= 0) return false;
|
||||
|
||||
// 规范化 basePath,确保以 / 开头
|
||||
const normalizedBase = basePath
|
||||
? ("/" + String(basePath).replace(/^\/+/, ""))
|
||||
: "/";
|
||||
|
||||
// 默认用 http://127.0.0.1:port/basePath
|
||||
const urlStr = `http://127.0.0.1:${portNum}${normalizedBase}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let finished = false;
|
||||
const urlObj = new URL(urlStr);
|
||||
const client = urlObj.protocol === "https:" ? https : http;
|
||||
|
||||
const req = client.request(
|
||||
{
|
||||
method: "GET",
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
headers: { "User-Agent": "xagi-keepalive-check" },
|
||||
},
|
||||
(res) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
// 2xx/3xx 认为服务存活
|
||||
const alive = res.statusCode >= 200 && res.statusCode < 300;
|
||||
log(projectId, "INFO", "Alive detection HTTP response", { statusCode: res.statusCode, url: urlStr, alive });
|
||||
// 消耗数据并结束
|
||||
res.resume();
|
||||
resolve(alive);
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (err) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
log(projectId, "WARN", "Alive detection HTTP request error", { error: err && err.message, url: urlStr, });
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
log(projectId, "INFO", "Alive detection HTTP request timeout", { url: urlStr, timeoutMs });
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
try {
|
||||
req.end();
|
||||
} catch (_) {
|
||||
if (!finished) {
|
||||
finished = true;
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { isProjectAlive };
|
||||
|
||||
|
||||
74
qiming-file-server/src/utils/buildJudge/restartJudgeUtils.js
Normal file
74
qiming-file-server/src/utils/buildJudge/restartJudgeUtils.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 判断单个文件是否需要重启开发服务器
|
||||
* @param {string} fileName 文件名
|
||||
* @returns {boolean} 是否需要重启
|
||||
*/
|
||||
function shouldRestartForSingleFile(fileName) {
|
||||
if (!fileName || typeof fileName !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 定义需要重启的文件类型和路径模式
|
||||
const restartRequiredPatterns = [
|
||||
// 配置文件
|
||||
/package\.json$/i,
|
||||
/vite\.config\.(js|ts|mjs|cjs)$/i,
|
||||
/webpack\.config\.(js|ts|mjs|cjs)$/i,
|
||||
/rollup\.config\.(js|ts|mjs|cjs)$/i,
|
||||
/next\.config\.(js|ts|mjs|cjs)$/i,
|
||||
/nuxt\.config\.(js|ts|mjs|cjs)$/i,
|
||||
/tailwind\.config\.(js|ts|mjs|cjs)$/i,
|
||||
/postcss\.config\.(js|ts|mjs|cjs)$/i,
|
||||
/babel\.config\.(js|ts|mjs|cjs)$/i,
|
||||
/tsconfig\.json$/i,
|
||||
/\.env$/i,
|
||||
/\.env\..*$/i,
|
||||
|
||||
// 依赖相关
|
||||
/yarn\.lock$/i,
|
||||
/package-lock\.json$/i,
|
||||
/pnpm-lock\.yaml$/i,
|
||||
];
|
||||
|
||||
// 检查是否匹配需要重启的模式
|
||||
for (const pattern of restartRequiredPatterns) {
|
||||
if (pattern.test(fileName)) {
|
||||
log(null, "INFO", `Detected file that needs to be restarted: ${fileName}`, {
|
||||
fileName,
|
||||
pattern: pattern.toString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要重启开发服务器
|
||||
* 基于文件类型和修改内容来判断是否需要重启
|
||||
* @param {Array} files 修改的文件列表
|
||||
* @returns {boolean} 是否需要重启
|
||||
*/
|
||||
function shouldRestartDevServer(files) {
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有任何文件匹配需要重启的模式
|
||||
for (const file of files) {
|
||||
if (!file || typeof file.name !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldRestartForSingleFile(file.name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export { shouldRestartForSingleFile, shouldRestartDevServer };
|
||||
@@ -0,0 +1,128 @@
|
||||
import { exec } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 递归修复目录下所有可执行文件的权限
|
||||
* @param {string} dir 目录路径
|
||||
*/
|
||||
async function fixExecutablePermissionsRecursive(dir) {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// 递归处理子目录
|
||||
await fixExecutablePermissionsRecursive(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
// 检查是否为可执行文件(通过文件名或扩展名判断)
|
||||
const isExecutable =
|
||||
entry.name.includes("bin") ||
|
||||
entry.name.endsWith(".exe") ||
|
||||
!entry.name.includes(".") ||
|
||||
entry.name.match(/^(esbuild|vite|webpack|rollup)$/);
|
||||
|
||||
if (isExecutable) {
|
||||
try {
|
||||
await fs.promises.chmod(fullPath, 0o755);
|
||||
// 移除隔离标记
|
||||
await new Promise((resolve) => {
|
||||
exec(`xattr -d com.apple.quarantine "${fullPath}"`, () =>
|
||||
resolve()
|
||||
);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复可执行权限:为 node_modules/.bin 及被指向的目标脚本添加可执行权限
|
||||
* @param {string} projectPath 项目路径
|
||||
*/
|
||||
async function ensureDevBinariesExecutable(projectPath) {
|
||||
try {
|
||||
const nodeModulesDir = path.join(projectPath, "node_modules");
|
||||
if (!fs.existsSync(nodeModulesDir)) return;
|
||||
|
||||
// 1. 处理 .bin 目录
|
||||
const binDir = path.join(nodeModulesDir, ".bin");
|
||||
if (fs.existsSync(binDir)) {
|
||||
const entries = await fs.promises.readdir(binDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(binDir, entry.name);
|
||||
|
||||
// 给 .bin 下的文件加执行权限
|
||||
try {
|
||||
await fs.promises.chmod(filePath, 0o755);
|
||||
} catch (_) {}
|
||||
|
||||
// 处理文件内容为相对路径的情况(例如仅包含 ../vite/bin/vite.js)
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf8");
|
||||
const trimmed = content.trim();
|
||||
const looksLikeSinglePath =
|
||||
trimmed && !trimmed.startsWith("#!/") && !trimmed.includes("\n");
|
||||
if (looksLikeSinglePath) {
|
||||
const targetAbs = path.resolve(path.dirname(filePath), trimmed);
|
||||
if (fs.existsSync(targetAbs)) {
|
||||
// 先给目标加执行权限
|
||||
try {
|
||||
await fs.promises.chmod(targetAbs, 0o755);
|
||||
} catch (_) {}
|
||||
|
||||
// 将占位文件替换为指向目标的符号链接,确保相对路径相对于 .bin 目录
|
||||
try {
|
||||
const relativeFromBin = path.relative(
|
||||
path.dirname(filePath),
|
||||
targetAbs
|
||||
);
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
await fs.promises.symlink(relativeFromBin, filePath);
|
||||
} catch (_) {
|
||||
// 如果创建符号链接失败,退化为写入一个带 shebang 的 shim
|
||||
const shim = `#!/usr/bin/env bash\nDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nexec \"$DIR/${trimmed}\" \"$@\"\n`;
|
||||
try {
|
||||
await fs.promises.writeFile(filePath, shim, "utf8");
|
||||
await fs.promises.chmod(filePath, 0o755);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 尝试移除隔离标记(macOS)
|
||||
try {
|
||||
await new Promise((resolve) => {
|
||||
exec(`xattr -d com.apple.quarantine "${filePath}"`, () =>
|
||||
resolve()
|
||||
);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理常见的二进制包目录(如 @esbuild、esbuild 等)
|
||||
const binaryPackages = ["@esbuild", "esbuild", "vite", "webpack", "rollup"];
|
||||
for (const pkg of binaryPackages) {
|
||||
const pkgDir = path.join(nodeModulesDir, pkg);
|
||||
if (fs.existsSync(pkgDir)) {
|
||||
await fixExecutablePermissionsRecursive(pkgDir);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 仅记录,不阻断启动流程
|
||||
log(null, "WARN", "Problem occurred when fixing executable permissions (ignore and continue)", {
|
||||
error: e && e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { fixExecutablePermissionsRecursive, ensureDevBinariesExecutable };
|
||||
97
qiming-file-server/src/utils/common/AgentWorkspaceUtils.js
Normal file
97
qiming-file-server/src/utils/common/AgentWorkspaceUtils.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const AGENT_ROOT_MAP = {
|
||||
agents: ".agents",
|
||||
claudecode: ".claude",
|
||||
opencode: ".opencode",
|
||||
codex: ".codex",
|
||||
};
|
||||
|
||||
const ALL_AGENT_TYPES = Object.keys(AGENT_ROOT_MAP);
|
||||
const PRIMARY_AGENT_TYPE = "agents";
|
||||
|
||||
async function removePathIfExists(targetPath) {
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
await fs.promises.rm(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function ensureDir(dirPath) {
|
||||
if (fs.existsSync(dirPath)) return;
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
async function copyDirectory(srcDir, destDir) {
|
||||
const stat = await fs.promises.lstat(srcDir);
|
||||
if (stat.isDirectory()) {
|
||||
await ensureDir(destDir);
|
||||
const entries = await fs.promises.readdir(srcDir);
|
||||
for (const entry of entries) {
|
||||
await copyDirectory(path.join(srcDir, entry), path.join(destDir, entry));
|
||||
}
|
||||
} else {
|
||||
await ensureDir(path.dirname(destDir));
|
||||
await fs.promises.copyFile(srcDir, destDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅确保主 agent(.claude)目录存在,业务逻辑只写这一份
|
||||
*/
|
||||
async function ensurePrimaryAgentDirs(workspaceRoot) {
|
||||
const rootDir = path.join(workspaceRoot, AGENT_ROOT_MAP[PRIMARY_AGENT_TYPE]);
|
||||
const skillsDir = path.join(rootDir, "skills");
|
||||
const agentsDir = path.join(rootDir, "agents");
|
||||
await ensureDir(rootDir);
|
||||
await ensureDir(skillsDir);
|
||||
await ensureDir(agentsDir);
|
||||
return { rootDir, skillsDir, agentsDir, agentTypes: ALL_AGENT_TYPES };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将主 agent 目录内容同步到其他 agent 目录
|
||||
*/
|
||||
async function syncAgents(workspaceRoot) {
|
||||
const primary = await ensurePrimaryAgentDirs(workspaceRoot);
|
||||
|
||||
for (const agentType of ALL_AGENT_TYPES) {
|
||||
if (agentType === PRIMARY_AGENT_TYPE) continue;
|
||||
const targetRoot = path.join(workspaceRoot, AGENT_ROOT_MAP[agentType]);
|
||||
const targetSkills = path.join(targetRoot, "skills");
|
||||
const targetAgents = path.join(targetRoot, "agents");
|
||||
await ensureDir(targetRoot);
|
||||
|
||||
await removePathIfExists(targetSkills);
|
||||
await ensureDir(targetSkills);
|
||||
if (fs.existsSync(primary.skillsDir)) {
|
||||
const entries = await fs.promises.readdir(primary.skillsDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(primary.skillsDir, entry.name);
|
||||
const dstPath = path.join(targetSkills, entry.name);
|
||||
await copyDirectory(srcPath, dstPath);
|
||||
}
|
||||
}
|
||||
|
||||
await removePathIfExists(targetAgents);
|
||||
await ensureDir(targetAgents);
|
||||
if (fs.existsSync(primary.agentsDir)) {
|
||||
const entries = await fs.promises.readdir(primary.agentsDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(primary.agentsDir, entry.name);
|
||||
const dstPath = path.join(targetAgents, entry.name);
|
||||
await copyDirectory(srcPath, dstPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { agentTypes: ALL_AGENT_TYPES };
|
||||
}
|
||||
|
||||
export {
|
||||
ensurePrimaryAgentDirs,
|
||||
syncAgents,
|
||||
};
|
||||
72
qiming-file-server/src/utils/common/npmrcUtils.js
Normal file
72
qiming-file-server/src/utils/common/npmrcUtils.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log, getCSTDateTimeString } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 为项目创建优化的 .npmrc 配置文件
|
||||
* @param {string} projectPath - 项目路径
|
||||
* @param {string} projectId - 项目ID(用于日志)
|
||||
* @returns {Promise<Object>} 创建结果
|
||||
*/
|
||||
async function createPnpmNpmrc(projectPath, projectId = null) {
|
||||
const logId = projectId || path.basename(projectPath);
|
||||
const npmrcPath = path.join(projectPath, ".npmrc");
|
||||
|
||||
// .npmrc 配置内容
|
||||
const npmrcContent = `# pnpm 磁盘空间优化配置
|
||||
# 自动生成于 ${getCSTDateTimeString()}
|
||||
|
||||
package-import-method=hardlink
|
||||
auto-install-peers=true
|
||||
registry=https://registry.npmmirror.com
|
||||
`;
|
||||
|
||||
try {
|
||||
// 检查 .npmrc 是否已存在
|
||||
if (fs.existsSync(npmrcPath)) {
|
||||
log(logId, "INFO", ".npmrc file already exists, skip creation", {
|
||||
projectPath,
|
||||
npmrcPath,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
created: false,
|
||||
message: ".npmrc file already exists",
|
||||
npmrcPath,
|
||||
};
|
||||
}
|
||||
|
||||
// 创建 .npmrc 文件
|
||||
await fs.promises.writeFile(npmrcPath, npmrcContent, "utf8");
|
||||
|
||||
log(logId, "INFO", ".npmrc file created successfully", {
|
||||
projectPath,
|
||||
npmrcPath,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
created: true,
|
||||
message: ".npmrc file created successfully",
|
||||
npmrcPath,
|
||||
};
|
||||
} catch (error) {
|
||||
log(logId, "WARN", `.npmrc file creation failed: ${error.message}`, {
|
||||
projectPath,
|
||||
npmrcPath,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// 创建 .npmrc 失败不应该阻止主流程,只记录警告
|
||||
return {
|
||||
success: false,
|
||||
created: false,
|
||||
message: `.npmrc file creation failed: ${error.message}`,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { createPnpmNpmrc };
|
||||
|
||||
|
||||
43
qiming-file-server/src/utils/common/projectPathUtils.js
Normal file
43
qiming-file-server/src/utils/common/projectPathUtils.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import path from "path";
|
||||
import config from "../../appConfig/index.js";
|
||||
|
||||
function normalizeValue(value) {
|
||||
if (value === undefined || value === null) return "";
|
||||
const str = String(value).trim();
|
||||
return str;
|
||||
}
|
||||
|
||||
function extractIsolationContext(source = {}) {
|
||||
return {
|
||||
tenantId: normalizeValue(source.tenantId),
|
||||
spaceId: normalizeValue(source.spaceId),
|
||||
isolationType: normalizeValue(source.isolationType),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldUseIsolationPath(isolationContext = {}) {
|
||||
const tenantId = normalizeValue(isolationContext.tenantId);
|
||||
const spaceId = normalizeValue(isolationContext.spaceId);
|
||||
const isolationType = normalizeValue(isolationContext.isolationType);
|
||||
return Boolean(tenantId && spaceId && isolationType);
|
||||
}
|
||||
|
||||
function resolveProjectPath(projectId, isolationContext = {}) {
|
||||
const normalizedProjectId = normalizeValue(projectId);
|
||||
if (!normalizedProjectId) {
|
||||
return path.join(config.PROJECT_SOURCE_DIR, "");
|
||||
}
|
||||
|
||||
if (shouldUseIsolationPath(isolationContext)) {
|
||||
return path.join(
|
||||
config.PROJECT_SOURCE_DIR,
|
||||
normalizeValue(isolationContext.tenantId),
|
||||
normalizeValue(isolationContext.spaceId),
|
||||
normalizedProjectId
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(config.PROJECT_SOURCE_DIR, normalizedProjectId);
|
||||
}
|
||||
|
||||
export { extractIsolationContext, shouldUseIsolationPath, resolveProjectPath };
|
||||
56
qiming-file-server/src/utils/common/sensitiveUtils.js
Normal file
56
qiming-file-server/src/utils/common/sensitiveUtils.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 敏感信息处理工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* 通用脱敏函数 - 移除敏感路径信息
|
||||
* @param {string} text 需要脱敏的文本
|
||||
* @returns {string} 脱敏后的文本
|
||||
*/
|
||||
function sanitizeSensitivePaths(text) {
|
||||
if (!text || typeof text !== "string") {
|
||||
return text;
|
||||
}
|
||||
|
||||
let sanitized = text;
|
||||
|
||||
// 获取敏感路径列表
|
||||
const sensitivePaths = [
|
||||
process.cwd(), // 当前工作目录
|
||||
process.env.HOME, // 用户主目录
|
||||
process.env.USERPROFILE, // Windows用户目录
|
||||
// 环境变量中的敏感路径
|
||||
process.env.LOG_BASE_DIR,
|
||||
process.env.INIT_PROJECT_DIR,
|
||||
process.env.UPLOAD_PROJECT_DIR,
|
||||
process.env.PROJECT_SOURCE_DIR,
|
||||
process.env.DIST_TARGET_DIR,
|
||||
].filter(Boolean); // 过滤掉空值
|
||||
|
||||
// 替换敏感路径为空字符串
|
||||
sensitivePaths.forEach((sensitivePath) => {
|
||||
if (sensitivePath) {
|
||||
// 转义特殊字符用于正则表达式
|
||||
const escapedPath = sensitivePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(escapedPath, "gi");
|
||||
sanitized = sanitized.replace(regex, "");
|
||||
}
|
||||
});
|
||||
|
||||
// 处理路径中的敏感部分(如 /workspace/rcoder-server)
|
||||
const sensitivePatterns = [
|
||||
/\/workspace\/rcoder-server/g,
|
||||
/\/home\/[^\/]+\/workspace\/rcoder-server/g,
|
||||
/\/opt\/project_[^\/]+/g,
|
||||
/\/Users\/[^\/]+\/Work\/[^\/]+/g,
|
||||
];
|
||||
|
||||
sensitivePatterns.forEach((pattern) => {
|
||||
sanitized = sanitized.replace(pattern, "");
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export { sanitizeSensitivePaths };
|
||||
|
||||
276
qiming-file-server/src/utils/common/zipUtils.js
Normal file
276
qiming-file-server/src/utils/common/zipUtils.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import yauzl from "yauzl";
|
||||
import { FileError } from "../error/errorHandler.js";
|
||||
|
||||
/**
|
||||
* 解压zip文件到指定目录
|
||||
* @param {string} zipPath - zip文件路径
|
||||
* @param {string} extractPath - 解压目标路径
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function extractZip(zipPath, extractPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
||||
if (err) {
|
||||
return reject(
|
||||
new FileError(`无法打开压缩包: ${err.message}`, {
|
||||
zipPath,
|
||||
originalError: err.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
zipfile.readEntry();
|
||||
zipfile.on("entry", (entry) => {
|
||||
if (/\/$/.test(entry.fileName)) {
|
||||
// 目录条目
|
||||
const dirPath = path.join(extractPath, entry.fileName);
|
||||
try {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
} catch (mkdirErr) {
|
||||
// 线上实测:某些 zip 中会出现「文件路径和目录路径冲突」或其他奇怪目录结构,
|
||||
// 在深层路径上 mkdir 时可能抛出 ENOTDIR / EEXIST / 其它错误。
|
||||
// 这类错误通常不影响我们真正关心的业务文件(如 skills 下的代码),
|
||||
// 如果直接 reject,会导致整个工作空间创建失败(500)。
|
||||
//
|
||||
// 为了提升健壮性,这里统一降级为「跳过当前目录条目并继续后续解压」,同时输出 warning 方便排查。
|
||||
console.warn(
|
||||
"[extractZip] mkdir for directory entry failed, skip this dir",
|
||||
{
|
||||
dirPath,
|
||||
code: mkdirErr.code,
|
||||
message: mkdirErr.message,
|
||||
}
|
||||
);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
zipfile.readEntry();
|
||||
} else {
|
||||
// 文件条目
|
||||
zipfile.openReadStream(entry, (err, readStream) => {
|
||||
if (err) {
|
||||
return reject(
|
||||
new FileError(`Failed to read zip entry: ${err.message}`, {
|
||||
entry: entry.fileName,
|
||||
originalError: err.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = path.join(extractPath, entry.fileName);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
try {
|
||||
// 确保目录存在
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
} catch (mkdirErr) {
|
||||
// 同样存在文件/目录冲突或异常目录结构的情况。
|
||||
// 为了避免单个异常条目导致整个解压失败,这里统一跳过该文件条目,仅记录 warning 日志。
|
||||
console.warn(
|
||||
"[extractZip] mkdir for file entry failed, skip this file",
|
||||
{
|
||||
dirPath,
|
||||
filePath,
|
||||
code: mkdirErr.code,
|
||||
message: mkdirErr.message,
|
||||
}
|
||||
);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果目标路径已经存在且是目录,说明 zip 里这个条目与现有目录冲突
|
||||
// 为了避免 EISDIR 错误,这里直接跳过该条目,并继续解压后续内容
|
||||
try {
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
} catch (statErr) {
|
||||
return reject(
|
||||
new FileError(`Failed to check file status: ${statErr.message}`, {
|
||||
filePath,
|
||||
originalError: statErr.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
readStream.pipe(writeStream);
|
||||
|
||||
writeStream.on("close", () => {
|
||||
zipfile.readEntry();
|
||||
});
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
reject(
|
||||
new FileError(`Failed to write file: ${err.message}`, {
|
||||
filePath,
|
||||
originalError: err.message,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
zipfile.on("end", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
zipfile.on("error", (err) => {
|
||||
reject(
|
||||
new FileError(`Error occurred during unzip: ${err.message}`, {
|
||||
zipPath,
|
||||
originalError: err.message,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 缓存zip文件的目录索引,避免重复遍历
|
||||
// 键格式: zipPath:mtime:size,值: Map<normalizedPath, entry>
|
||||
const zipIndexCache = new Map();
|
||||
const MAX_CACHE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* 获取zip文件的目录索引(缓存)
|
||||
* @param {string} zipPath - zip文件路径
|
||||
* @returns {Promise<Map<string, yauzl.Entry>|null>} 返回文件名到Entry的映射,失败返回null
|
||||
*/
|
||||
function getZipIndex(zipPath) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// 检查缓存
|
||||
const stats = fs.statSync(zipPath);
|
||||
const cacheKey = `${zipPath}:${stats.mtimeMs}:${stats.size}`;
|
||||
if (zipIndexCache.has(cacheKey)) {
|
||||
return resolve(zipIndexCache.get(cacheKey));
|
||||
}
|
||||
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
||||
if (err) {
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
const index = new Map();
|
||||
zipfile.readEntry();
|
||||
|
||||
zipfile.on("entry", (entry) => {
|
||||
// 标准化路径并存储
|
||||
const normalizedPath = entry.fileName.replace(/\\/g, "/");
|
||||
if (!/\/$/.test(entry.fileName)) {
|
||||
// 只索引文件,不索引目录
|
||||
index.set(normalizedPath, entry);
|
||||
}
|
||||
zipfile.readEntry();
|
||||
});
|
||||
|
||||
zipfile.on("end", () => {
|
||||
zipfile.close();
|
||||
// 缓存索引(限制缓存大小,避免内存泄漏)
|
||||
if (zipIndexCache.size >= MAX_CACHE_SIZE) {
|
||||
// 删除最旧的缓存项(简单策略:清空一半)
|
||||
const keysToDelete = Array.from(zipIndexCache.keys()).slice(0, Math.floor(MAX_CACHE_SIZE / 2));
|
||||
keysToDelete.forEach(key => zipIndexCache.delete(key));
|
||||
}
|
||||
zipIndexCache.set(cacheKey, index);
|
||||
resolve(index);
|
||||
});
|
||||
|
||||
zipfile.on("error", () => {
|
||||
zipfile.close();
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从zip文件中提取单个文件(优化版:使用索引快速定位)
|
||||
* @param {string} zipPath - zip文件路径
|
||||
* @param {string} filePathInZip - 文件在zip中的路径(相对路径)
|
||||
* @param {string} targetPath - 提取到的目标路径
|
||||
* @returns {Promise<boolean>} 如果文件存在并成功提取返回true,否则返回false
|
||||
*/
|
||||
function extractSingleFileFromZip(zipPath, filePathInZip, targetPath) {
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
// 先获取索引,快速定位文件
|
||||
const index = await getZipIndex(zipPath);
|
||||
if (!index) {
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
const normalizedPathInZip = filePathInZip.replace(/^[\/\\]+/, "").replace(/\\/g, "/");
|
||||
|
||||
if (!index.has(normalizedPathInZip)) {
|
||||
// 文件不在索引中,直接返回false,避免打开zip文件
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
// 文件存在,使用 lazyEntries: false 一次性加载所有条目,然后直接定位
|
||||
yauzl.open(zipPath, { lazyEntries: false }, (err, zipfile) => {
|
||||
if (err) {
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
// 在所有条目中查找目标文件(使用索引已确认存在,这里只是获取entry对象)
|
||||
let targetEntry = null;
|
||||
for (const entry of zipfile.entries) {
|
||||
const entryPath = entry.fileName.replace(/\\/g, "/");
|
||||
if (entryPath === normalizedPathInZip && !/\/$/.test(entry.fileName)) {
|
||||
targetEntry = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetEntry) {
|
||||
zipfile.close();
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
// 直接打开目标文件的流
|
||||
zipfile.openReadStream(targetEntry, (err, readStream) => {
|
||||
if (err || !readStream) {
|
||||
zipfile.close();
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
const dirPath = path.dirname(targetPath);
|
||||
try {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
} catch (mkdirErr) {
|
||||
zipfile.close();
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
const writeStream = fs.createWriteStream(targetPath);
|
||||
readStream.pipe(writeStream);
|
||||
|
||||
writeStream.on("close", () => {
|
||||
zipfile.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
writeStream.on("error", () => {
|
||||
zipfile.close();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { extractZip, extractSingleFileFromZip };
|
||||
|
||||
979
qiming-file-server/src/utils/computer/computerFileUtils.js
Normal file
979
qiming-file-server/src/utils/computer/computerFileUtils.js
Normal file
@@ -0,0 +1,979 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import archiver from "archiver";
|
||||
import config from "../../appConfig/index.js";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { ValidationError, SystemError } from "../error/errorHandler.js";
|
||||
|
||||
const DEFAULT_DOWNLOAD_MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
|
||||
const DOWNLOAD_MAX_FILE_SIZE_BYTES =
|
||||
config.DOWNLOAD_MAX_FILE_SIZE_BYTES || DEFAULT_DOWNLOAD_MAX_FILE_SIZE_BYTES;
|
||||
|
||||
async function traverseDirectory(targetDir, basePath, logId, proxyPath) {
|
||||
const files = [];
|
||||
const entries = await fs.promises.readdir(targetDir, { withFileTypes: true });
|
||||
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
|
||||
const excludeFiles = config.CONTENT_TRAVERSE_EXCLUDE_FILES || [];
|
||||
if (excludeFiles.includes(entry.name)) continue;
|
||||
|
||||
if (entry.isDirectory() && config.TRAVERSE_EXCLUDE_DIRS.includes(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const sub = await traverseDirectory(fullPath, basePath, logId, proxyPath);
|
||||
if (sub.length === 0) {
|
||||
// 空目录,返回目录信息
|
||||
const referencePath = basePath || targetDir;
|
||||
// 统一使用正斜杠,保证 Windows/Linux 跨平台一致性及 URL 正确性
|
||||
const relativePath = path.relative(referencePath, fullPath).replace(/\\/g, "/");
|
||||
files.push({
|
||||
name: relativePath,
|
||||
isDir: true,
|
||||
});
|
||||
} else {
|
||||
files.push(...sub);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const referencePath = basePath || targetDir;
|
||||
// 统一使用正斜杠,保证 Windows/Linux 跨平台一致性及 URL 正确性
|
||||
const relativePath = path.relative(referencePath, fullPath).replace(/\\/g, "/");
|
||||
|
||||
const isLink = entry.isSymbolicLink();
|
||||
|
||||
// 生成文件代理URL,对路径段进行编码以支持空格、中文等特殊字符
|
||||
let fileProxyUrl = null;
|
||||
if (proxyPath) {
|
||||
const encodedPath = relativePath
|
||||
.split("/")
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join("/");
|
||||
fileProxyUrl = `${proxyPath}/${encodedPath}`;
|
||||
}
|
||||
|
||||
const fileInfo = {
|
||||
name: relativePath,
|
||||
isDir: false,
|
||||
fileProxyUrl: fileProxyUrl,
|
||||
isLink: isLink,
|
||||
};
|
||||
|
||||
files.push(fileInfo);
|
||||
} catch (error) {
|
||||
log(logId, "WARN", `处理文件失败: ${fullPath}`, { error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算目录中允许打包下载的文件总大小(字节)
|
||||
* 过滤规则与 downloadAllFiles 保持一致
|
||||
* @param {string} targetDir 目标目录绝对路径
|
||||
* @param {string[]} excludeFiles 需排除的文件名列表
|
||||
* @param {string[]} excludeDirs 需排除的目录名列表
|
||||
* @param {string} logId 日志ID
|
||||
* @param {string} relativeDir 相对目录(递归内部使用)
|
||||
* @returns {Promise<number>} 允许下载的总字节数
|
||||
*/
|
||||
async function calculateDownloadableDirectorySize(
|
||||
targetDir,
|
||||
excludeFiles,
|
||||
excludeDirs,
|
||||
logId,
|
||||
relativeDir = ""
|
||||
) {
|
||||
const entries = await fs.promises.readdir(path.join(targetDir, relativeDir), {
|
||||
withFileTypes: true,
|
||||
});
|
||||
let totalSize = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const nextRelativePath = relativeDir
|
||||
? path.join(relativeDir, entry.name)
|
||||
: entry.name;
|
||||
const segments = nextRelativePath.split(path.sep).filter(Boolean);
|
||||
|
||||
// 1. 任一路径片段以 "." 开头,则忽略(隐藏文件/目录)
|
||||
if (segments.some((seg) => seg.startsWith("."))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 检查文件名是否在排除列表中
|
||||
const fileName = segments[segments.length - 1];
|
||||
if (excludeFiles.includes(fileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 检查任一路径片段是否为排除的目录
|
||||
if (segments.some((seg) => excludeDirs.includes(seg))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(targetDir, nextRelativePath);
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.promises.lstat(fullPath);
|
||||
} catch (error) {
|
||||
log(logId, "WARN", "Error occurred when getting file stats, skipping", {
|
||||
filePath: nextRelativePath.replace(/\\/g, "/"),
|
||||
error: error.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 跳过符号链接和硬链接
|
||||
if (stats.isSymbolicLink() || stats.nlink > 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
totalSize += await calculateDownloadableDirectorySize(
|
||||
targetDir,
|
||||
excludeFiles,
|
||||
excludeDirs,
|
||||
logId,
|
||||
nextRelativePath
|
||||
);
|
||||
} else if (stats.isFile()) {
|
||||
totalSize += stats.size;
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件列表
|
||||
* @param {string|number} userId 用户ID
|
||||
* @param {string|number} cId 会话ID
|
||||
* @returns {Promise<{files: Array}>}
|
||||
*/
|
||||
async function getFileList(userId, cId, proxyPath) {
|
||||
const startTime = Date.now();
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError("userId 不能为空", { field: "userId" });
|
||||
}
|
||||
if (!cId) {
|
||||
throw new ValidationError("cId 不能为空", { field: "cId" });
|
||||
}
|
||||
|
||||
const normalizedUserId = String(userId);
|
||||
const normalizedCId = String(cId);
|
||||
const targetDir = path.join(workspaceRoot, normalizedUserId, normalizedCId);
|
||||
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
log(logId, "INFO", "Directory does not exist, returning empty list", {
|
||||
targetDir,
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
});
|
||||
return { files: [] };
|
||||
}
|
||||
|
||||
log(logId, "DEBUG", "Start getting user file list", {
|
||||
targetDir,
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
});
|
||||
|
||||
try {
|
||||
const files = await traverseDirectory(targetDir, targetDir, logId, proxyPath);
|
||||
|
||||
log(logId, "INFO", "User file list obtained successfully", {
|
||||
fileCount: files.length,
|
||||
targetDir,
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return { files };
|
||||
} catch (error) {
|
||||
log(logId, "ERROR", "Failed to get user file list", {
|
||||
targetDir,
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
throw new SystemError(`Failed to get file list: ${error.message}`, {
|
||||
targetDir,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件:支持新增、删除、重命名、修改操作
|
||||
* @param {string|number} userId 用户ID
|
||||
* @param {string|number} cId 会话ID
|
||||
* @param {Array} files 文件操作列表
|
||||
* @returns {Promise<Object>} 更新结果
|
||||
*/
|
||||
async function updateFiles(userId, cId, files) {
|
||||
const startTime = Date.now();
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError("userId cannot be empty", { field: "userId" });
|
||||
}
|
||||
if (!cId) {
|
||||
throw new ValidationError("cId cannot be empty", { field: "cId" });
|
||||
}
|
||||
if (!Array.isArray(files)) {
|
||||
throw new ValidationError("files must be an array", { field: "files" });
|
||||
}
|
||||
|
||||
const normalizedUserId = String(userId);
|
||||
const normalizedCId = String(cId);
|
||||
const targetDir = path.join(workspaceRoot, normalizedUserId, normalizedCId);
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 验证文件操作结构
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const fileOp = files[i];
|
||||
if (!fileOp || typeof fileOp.operation !== "string") {
|
||||
throw new ValidationError(`files[${i}].operation cannot be empty`, {
|
||||
field: `files[${i}].operation`,
|
||||
});
|
||||
}
|
||||
if (!fileOp.name || typeof fileOp.name !== "string") {
|
||||
throw new ValidationError(`files[${i}].name cannot be empty`, {
|
||||
field: `files[${i}].name`,
|
||||
});
|
||||
}
|
||||
|
||||
const operation = fileOp.operation.toLowerCase();
|
||||
if (!["create", "delete", "rename", "modify"].includes(operation)) {
|
||||
throw new ValidationError(
|
||||
`files[${i}].operation must be one of create, delete, rename or modify`,
|
||||
{ field: `files[${i}].operation` }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证特定操作所需的字段
|
||||
if (operation === "rename" && !fileOp.renameFrom) {
|
||||
throw new ValidationError(
|
||||
`files[${i}].renameFrom cannot be empty (rename operation requires)`,
|
||||
{ field: `files[${i}].renameFrom` }
|
||||
);
|
||||
}
|
||||
|
||||
if (operation === "modify" && fileOp.isDir !== true) {
|
||||
if (typeof fileOp.contents !== "string") {
|
||||
throw new ValidationError(
|
||||
`files[${i}].contents must be a string (modify operation requires)`,
|
||||
{ field: `files[${i}].contents` }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(logId, "DEBUG", "Start updating user files", {
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
filesCount: files.length,
|
||||
});
|
||||
|
||||
try {
|
||||
// 处理文件操作
|
||||
for (const fileOp of files) {
|
||||
const operation = fileOp.operation.toLowerCase();
|
||||
const fileName = fileOp.name;
|
||||
|
||||
const normalizedPath = path.normalize(fileName).replace(/^[\/\\]+/, "");
|
||||
const targetPath = path.join(targetDir, normalizedPath);
|
||||
|
||||
// 安全检查:确保目标路径在用户目录内
|
||||
const resolvedTargetPath = path.resolve(targetPath);
|
||||
const resolvedTargetDir = path.resolve(targetDir);
|
||||
if (
|
||||
!resolvedTargetPath.startsWith(resolvedTargetDir + path.sep) &&
|
||||
resolvedTargetPath !== resolvedTargetDir
|
||||
) {
|
||||
log(logId, "WARN", "File path is not secure, skipping", {
|
||||
filePath: normalizedPath,
|
||||
resolvedPath: resolvedTargetPath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case "create": {
|
||||
// 创建新文件或目录
|
||||
if (fileOp.isDir === true) {
|
||||
// 创建目录前检查是否已存在
|
||||
if (fs.existsSync(targetPath)) {
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
if (stat.isFile()) {
|
||||
throw new ValidationError("Cannot create directory, file with the same name already exists", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
}
|
||||
// 目录已存在,跳过创建
|
||||
log(logId, "INFO", "Directory already exists, skipping creation", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await fs.promises.mkdir(targetPath, { recursive: true });
|
||||
log(logId, "INFO", "Directory created successfully", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 创建文件前检查是否已存在
|
||||
if (fs.existsSync(targetPath)) {
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
throw new ValidationError("Cannot create file, directory with the same name already exists", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
}
|
||||
// 文件已存在,跳过创建
|
||||
log(logId, "INFO", "File already exists, skipping creation", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const contents = fileOp.contents || "";
|
||||
await fs.promises.writeFile(targetPath, contents, "utf8");
|
||||
log(logId, "INFO", "File created successfully", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
// 删除文件或目录
|
||||
if (fs.existsSync(targetPath)) {
|
||||
const stat = await fs.promises.stat(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
// 删除目录(递归删除)
|
||||
await fs.promises.rm(targetPath, { recursive: true, force: true });
|
||||
log(logId, "INFO", "Directory deleted successfully", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
} else {
|
||||
// 删除文件
|
||||
await fs.promises.unlink(targetPath);
|
||||
log(logId, "INFO", "File deleted successfully", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log(logId, "WARN", "The file or directory to be deleted does not exist", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "rename": {
|
||||
// 重命名文件或目录
|
||||
const renameFrom = fileOp.renameFrom;
|
||||
if (!renameFrom || typeof renameFrom !== "string") {
|
||||
log(logId, "WARN", "Rename operation missing renameFrom", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const normalizedFrom = path
|
||||
.normalize(renameFrom)
|
||||
.replace(/^[\/\\]+/, "");
|
||||
const sourcePath = path.join(targetDir, normalizedFrom);
|
||||
|
||||
// 安全检查:确保源路径在用户目录内
|
||||
const resolvedSourcePath = path.resolve(sourcePath);
|
||||
if (
|
||||
!resolvedSourcePath.startsWith(resolvedTargetDir + path.sep) &&
|
||||
resolvedSourcePath !== resolvedTargetDir
|
||||
) {
|
||||
log(logId, "WARN", "Source path is not secure, skipping rename", {
|
||||
sourcePath: normalizedFrom,
|
||||
targetPath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
const stat = await fs.promises.stat(sourcePath);
|
||||
const isDirectory = stat.isDirectory();
|
||||
|
||||
// 确保目标路径的父目录存在(文件和目录重命名都需要)
|
||||
await fs.promises.mkdir(path.dirname(targetPath), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
await fs.promises.rename(sourcePath, targetPath);
|
||||
log(logId, "INFO", isDirectory ? "Directory renamed successfully" : "File renamed successfully", {
|
||||
sourcePath: normalizedFrom,
|
||||
targetPath: normalizedPath,
|
||||
});
|
||||
} else {
|
||||
log(logId, "WARN", "The file or directory to be renamed does not exist", {
|
||||
sourcePath: normalizedFrom,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "modify": {
|
||||
// 修改文件:直接写入新内容
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
log(logId, "WARN", "The file to be modified does not exist", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果是目录,跳过修改
|
||||
const modifyStat = await fs.promises.stat(targetPath);
|
||||
if (modifyStat.isDirectory()) {
|
||||
log(logId, "INFO", "The target is a directory, skipping modification", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const newContentStr =
|
||||
typeof fileOp.contents === "string" ? fileOp.contents : "";
|
||||
|
||||
// 读取现有文件内容进行比较
|
||||
const existingContent = await fs.promises.readFile(
|
||||
targetPath,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// 若内容完全一致,不覆写文件
|
||||
if (existingContent === newContentStr) {
|
||||
log(logId, "INFO", "File content has no changes, skipping write", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 写入修改后的内容
|
||||
await fs.promises.writeFile(targetPath, newContentStr, "utf8");
|
||||
log(logId, "INFO", "File modified successfully", {
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
log(logId, "WARN", "Unsupported operation type", {
|
||||
operation,
|
||||
filePath: normalizedPath,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(logId, "INFO", "User files updated successfully", {
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
filesCount: files.length,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "User files updated successfully",
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
filesCount: files.length,
|
||||
};
|
||||
} catch (error) {
|
||||
log(logId, "ERROR", "User files updated failed", {
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
throw new SystemError(`User files updated failed: ${error.message}`, {
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件到用户工作目录
|
||||
* @param {string|number} userId 用户ID
|
||||
* @param {string|number} cId 会话ID
|
||||
* @param {Object} file 文件对象 (包含文件内容和元数据)
|
||||
* @param {string} filePath 文件在用户目录中的相对路径
|
||||
* @returns {Promise<Object>} 上传结果
|
||||
*/
|
||||
async function uploadFile(userId, cId, file, filePath) {
|
||||
const startTime = Date.now();
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError("userId cannot be empty", { field: "userId" });
|
||||
}
|
||||
if (!cId) {
|
||||
throw new ValidationError("cId cannot be empty", { field: "cId" });
|
||||
}
|
||||
if (!file) {
|
||||
throw new ValidationError("file cannot be empty", { field: "file" });
|
||||
}
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
throw new ValidationError("filePath cannot be empty", { field: "filePath" });
|
||||
}
|
||||
|
||||
const normalizedUserId = String(userId);
|
||||
const normalizedCId = String(cId);
|
||||
const targetDir = path.join(workspaceRoot, normalizedUserId, normalizedCId);
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 规范化文件路径,确保是相对路径
|
||||
const normalizedPath = path.normalize(filePath).replace(/^[\/\\]+/, "");
|
||||
const targetPath = path.join(targetDir, normalizedPath);
|
||||
|
||||
// 安全检查:确保目标路径在用户目录内
|
||||
const resolvedTargetPath = path.resolve(targetPath);
|
||||
const resolvedTargetDir = path.resolve(targetDir);
|
||||
if (
|
||||
!resolvedTargetPath.startsWith(resolvedTargetDir + path.sep) &&
|
||||
resolvedTargetPath !== resolvedTargetDir
|
||||
) {
|
||||
throw new ValidationError("File path is not secure, cannot exceed user directory", {
|
||||
field: "filePath",
|
||||
providedPath: filePath,
|
||||
resolvedPath: resolvedTargetPath,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保目标目录存在
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
// 写入文件内容
|
||||
if (file.buffer) {
|
||||
// 如果是二进制文件(buffer)
|
||||
await fs.promises.writeFile(targetPath, file.buffer);
|
||||
} else if (typeof file.contents === "string") {
|
||||
// 如果是文本文件
|
||||
await fs.promises.writeFile(targetPath, file.contents, "utf8");
|
||||
} else {
|
||||
throw new ValidationError("File content format is incorrect", {
|
||||
field: "file",
|
||||
hasBuffer: !!file.buffer,
|
||||
hasContents: typeof file.contents,
|
||||
});
|
||||
}
|
||||
|
||||
log(logId, "INFO", "File uploaded successfully", {
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
filePath: normalizedPath,
|
||||
targetPath: resolvedTargetPath,
|
||||
fileSize: file.buffer
|
||||
? file.buffer.length
|
||||
: file.contents
|
||||
? file.contents.length
|
||||
: 0,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "File uploaded successfully",
|
||||
fileSize: file.buffer
|
||||
? file.buffer.length
|
||||
: file.contents
|
||||
? file.contents.length
|
||||
: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
log(logId, "ERROR", "File upload failed", {
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
filePath: normalizedPath,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
throw new SystemError(`File upload failed: ${error.message}`, {
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
filePath: normalizedPath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传文件到用户工作目录
|
||||
* @param {string|number} userId 用户ID
|
||||
* @param {string|number} cId 会话ID
|
||||
* @param {Array<Object>} files 文件对象数组,每个文件对象包含:
|
||||
* - buffer: Buffer (二进制文件) 或 contents: string (文本文件)
|
||||
* - originalname: string 原始文件名
|
||||
* - mimetype: string MIME类型
|
||||
* - size: number 文件大小
|
||||
* @param {Array<string>} filePaths 文件路径数组,与files数组一一对应
|
||||
* @returns {Promise<Object>} 批量上传结果
|
||||
*/
|
||||
async function uploadFiles(userId, cId, files, filePaths) {
|
||||
const startTime = Date.now();
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError("userId cannot be empty", { field: "userId" });
|
||||
}
|
||||
if (!cId) {
|
||||
throw new ValidationError("cId cannot be empty", { field: "cId" });
|
||||
}
|
||||
if (!Array.isArray(files)) {
|
||||
throw new ValidationError("files must be an array", { field: "files" });
|
||||
}
|
||||
if (!Array.isArray(filePaths)) {
|
||||
throw new ValidationError("filePaths must be an array", { field: "filePaths" });
|
||||
}
|
||||
if (files.length !== filePaths.length) {
|
||||
throw new ValidationError(
|
||||
`File count (${files.length}) does not match path count (${filePaths.length})`,
|
||||
{ field: "filePaths" }
|
||||
);
|
||||
}
|
||||
|
||||
log(logId, "DEBUG", "Start batch uploading files", {
|
||||
userId,
|
||||
cId,
|
||||
filesCount: files.length,
|
||||
});
|
||||
|
||||
const results = [];
|
||||
|
||||
try {
|
||||
// 处理每个文件
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const filePath = filePaths[i];
|
||||
|
||||
if (!file) {
|
||||
log(logId, "WARN", "Empty file object encountered in batch upload, skipping", {
|
||||
index: i,
|
||||
filePath,
|
||||
});
|
||||
results.push({
|
||||
success: false,
|
||||
filePath,
|
||||
error: "Empty file object",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
log(logId, "WARN", "Invalid file path in batch upload, skipping", {
|
||||
index: i,
|
||||
originalname: file.originalname,
|
||||
});
|
||||
results.push({
|
||||
success: false,
|
||||
filePath: filePath || "",
|
||||
originalname: file.originalname,
|
||||
error: "Invalid file path",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadFile(userId, cId, file, filePath);
|
||||
results.push({
|
||||
success: true,
|
||||
filePath,
|
||||
originalname: file.originalname,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
log(logId, "ERROR", "Single file upload failed in batch upload", {
|
||||
filePath,
|
||||
originalname: file.originalname,
|
||||
error: error.message,
|
||||
});
|
||||
results.push({
|
||||
success: false,
|
||||
filePath,
|
||||
originalname: file.originalname,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failCount = results.filter((r) => !r.success).length;
|
||||
|
||||
log(logId, "INFO", "Batch upload files completed", {
|
||||
userId,
|
||||
cId,
|
||||
totalCount: files.length,
|
||||
successCount,
|
||||
failCount,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Batch upload completed",
|
||||
totalCount: files.length,
|
||||
successCount,
|
||||
failCount,
|
||||
results,
|
||||
};
|
||||
} catch (error) {
|
||||
log(logId, "ERROR", "Batch upload files failed", {
|
||||
userId,
|
||||
cId,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
throw new SystemError(`Batch upload files failed: ${error.message}`, {
|
||||
userId,
|
||||
cId,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载所有文件
|
||||
* - 压缩目录:COMPUTER_WORKSPACE_DIR/<userId>/<cId>/
|
||||
* - 顶层目录名:userId_cId
|
||||
* - 过滤规则与 get-file-list 路由保持一致:
|
||||
* 1. 忽略所有隐藏文件/目录(任一路径片段以 "." 开头)
|
||||
* 2. 排除 CONTENT_TRAVERSE_EXCLUDE_FILES 配置的文件
|
||||
* 3. 排除 TRAVERSE_EXCLUDE_DIRS 配置的目录(如 node_modules)
|
||||
* 4. 跳过符号链接(Symbolic Links)
|
||||
* 5. 跳过硬链接(Hard Links,nlink > 1 的文件)
|
||||
*
|
||||
* @param {string|number} userId 用户ID
|
||||
* @param {string|number} cId 会话ID
|
||||
* @returns {Promise<{ archive: import("archiver").Archiver, zipFileName: string }>}
|
||||
*/
|
||||
async function downloadAllFiles(userId, cId) {
|
||||
const startTime = Date.now();
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError("userId cannot be empty", { field: "userId" });
|
||||
}
|
||||
if (!cId) {
|
||||
throw new ValidationError("cId cannot be empty", { field: "cId" });
|
||||
}
|
||||
if (!workspaceRoot) {
|
||||
throw new SystemError("COMPUTER_WORKSPACE_DIR is not configured, cannot create zip");
|
||||
}
|
||||
|
||||
const normalizedUserId = String(userId);
|
||||
const normalizedCId = String(cId);
|
||||
const targetDir = path.join(workspaceRoot, normalizedUserId, normalizedCId);
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
// 目录不存在时,返回一个仅包含顶层目录的空压缩包
|
||||
const zipFileName = `${normalizedUserId}_${normalizedCId}.zip`;
|
||||
|
||||
log(logId, "WARN", "Workspace directory does not exist, returning empty zip", {
|
||||
targetDir,
|
||||
userId: normalizedUserId,
|
||||
cId: normalizedCId,
|
||||
zipFileName,
|
||||
});
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
// 创建一个空的顶层目录条目
|
||||
archive.append(null, {
|
||||
name: `${normalizedUserId}_${normalizedCId}/`,
|
||||
type: "directory",
|
||||
});
|
||||
|
||||
archive.on("warning", (err) => {
|
||||
if (err.code === "ENOENT") {
|
||||
log(logId, "WARN", "Encountered file problem when creating empty zip", {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
});
|
||||
} else {
|
||||
log(logId, "ERROR", "Encountered warning when creating empty zip", {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
archive.on("error", (err) => {
|
||||
log(logId, "ERROR", "Failed to create empty zip", {
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
return { archive, zipFileName };
|
||||
}
|
||||
|
||||
const zipFileName = `${normalizedUserId}_${normalizedCId}.zip`;
|
||||
|
||||
log(logId, "DEBUG", "Start creating workspace directory zip", {
|
||||
targetDir,
|
||||
zipFileName,
|
||||
});
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
// 获取排除配置
|
||||
const excludeFiles = config.CONTENT_TRAVERSE_EXCLUDE_FILES || [];
|
||||
const excludeDirs = config.TRAVERSE_EXCLUDE_DIRS || [];
|
||||
const downloadableSize = await calculateDownloadableDirectorySize(
|
||||
targetDir,
|
||||
excludeFiles,
|
||||
excludeDirs,
|
||||
logId
|
||||
);
|
||||
if (downloadableSize > DOWNLOAD_MAX_FILE_SIZE_BYTES) {
|
||||
const maxSizeMb = DOWNLOAD_MAX_FILE_SIZE_BYTES / 1024 / 1024;
|
||||
const currentSizeMb = (downloadableSize / 1024 / 1024).toFixed(2);
|
||||
log(logId, "WARN", "Download rejected due to oversized workspace", {
|
||||
targetDir,
|
||||
downloadableSize,
|
||||
maxSizeBytes: DOWNLOAD_MAX_FILE_SIZE_BYTES,
|
||||
});
|
||||
throw new ValidationError(
|
||||
`Download failed: total file size ${currentSizeMb}MB exceeds limit ${maxSizeMb}MB`,
|
||||
{
|
||||
field: "downloadSize",
|
||||
downloadableSize,
|
||||
maxSizeBytes: DOWNLOAD_MAX_FILE_SIZE_BYTES,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 过滤文件/目录,与 get-file-list 路由保持一致
|
||||
archive.directory(
|
||||
targetDir,
|
||||
`${normalizedUserId}_${normalizedCId}`,
|
||||
(entry) => {
|
||||
const name = entry.name || "";
|
||||
const segments = name.split(/[\/\\]/).filter(Boolean);
|
||||
|
||||
// 1. 任一路径片段以 "." 开头,则忽略(隐藏文件/目录)
|
||||
if (segments.some((seg) => seg.startsWith("."))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查文件名是否在排除列表中
|
||||
const fileName = segments[segments.length - 1];
|
||||
if (excludeFiles.includes(fileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查任一路径片段是否为排除的目录
|
||||
if (segments.some((seg) => excludeDirs.includes(seg))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 检测并跳过链接文件(符号链接和硬链接)
|
||||
try {
|
||||
const fullPath = path.join(targetDir, name);
|
||||
const stats = fs.lstatSync(fullPath);
|
||||
|
||||
// 跳过符号链接
|
||||
if (stats.isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 跳过硬链接(nlink > 1 表示文件有多个硬链接)
|
||||
if (stats.nlink > 1) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果无法获取文件信息(如文件不存在、权限问题等),记录警告但继续处理
|
||||
log(logId, "WARN", "Error occurred when detecting link file, skipping", {
|
||||
filePath: name,
|
||||
error: error.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
);
|
||||
|
||||
archive.on("warning", (err) => {
|
||||
// 一些非致命错误(如文件不存在)记录日志即可
|
||||
if (err.code === "ENOENT") {
|
||||
log(logId, "WARN", "Encountered file problem when creating zip", {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
});
|
||||
} else {
|
||||
log(logId, "ERROR", "Encountered warning when creating zip", {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
archive.on("error", (err) => {
|
||||
log(logId, "ERROR", "Failed to create zip", {
|
||||
message: err.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
});
|
||||
|
||||
archive.on("end", () => {
|
||||
log(logId, "INFO", "Workspace directory zip created successfully", {
|
||||
targetDir,
|
||||
zipFileName,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
});
|
||||
|
||||
return { archive, zipFileName };
|
||||
}
|
||||
|
||||
export { getFileList, updateFiles, uploadFile, uploadFiles, downloadAllFiles };
|
||||
872
qiming-file-server/src/utils/computer/computerUtils.js
Normal file
872
qiming-file-server/src/utils/computer/computerUtils.js
Normal file
@@ -0,0 +1,872 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import config from "../../appConfig/index.js";
|
||||
import { extractZip } from "../common/zipUtils.js";
|
||||
import {
|
||||
ValidationError,
|
||||
SystemError,
|
||||
FileError,
|
||||
} from "../error/errorHandler.js";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import {
|
||||
ensurePrimaryAgentDirs,
|
||||
syncAgents,
|
||||
} from "../common/AgentWorkspaceUtils.js";
|
||||
|
||||
/**
|
||||
* 规范化 skillUrls 参数,兼容数组/JSON 字符串/单字符串
|
||||
* @param {unknown} skillUrls
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function normalizeSkillUrls(skillUrls) {
|
||||
if (!skillUrls) return [];
|
||||
if (Array.isArray(skillUrls)) {
|
||||
return skillUrls
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof skillUrls === "string") {
|
||||
const trimmed = skillUrls.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 字符串,按单个 URL 处理
|
||||
}
|
||||
return [trimmed];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 URL 到本地文件
|
||||
* @param {string} url
|
||||
* @param {string} destinationPath
|
||||
* @param {string} logId
|
||||
*/
|
||||
async function downloadUrlToFile(url, destinationPath, logId) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch (error) {
|
||||
throw new FileError(`Failed to download skill zip from url: ${url}`, {
|
||||
url,
|
||||
reason: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new FileError(`Failed to download skill zip from url: ${url}`, {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (
|
||||
contentType &&
|
||||
!contentType.includes("zip") &&
|
||||
!contentType.includes("octet-stream")
|
||||
) {
|
||||
log(logId, "WARN", "Downloaded skill url content-type is not typical zip", {
|
||||
url,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
await fs.promises.writeFile(destinationPath, buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保工作空间根目录存在:$COMPUTER_WORKSPACE_DIR
|
||||
*/
|
||||
async function ensureWorkspaceRoot(logId = "computer") {
|
||||
const workspaceRoot = config.COMPUTER_WORKSPACE_DIR;
|
||||
|
||||
if (!workspaceRoot) {
|
||||
throw new ValidationError("COMPUTER_WORKSPACE_DIR configuration does not exist", {
|
||||
field: "COMPUTER_WORKSPACE_DIR",
|
||||
});
|
||||
}
|
||||
|
||||
if (!fs.existsSync(workspaceRoot)) {
|
||||
await fs.promises.mkdir(workspaceRoot, { recursive: true });
|
||||
log(logId, "INFO", "Created user workspace root directory", { workspaceRoot });
|
||||
}
|
||||
|
||||
return workspaceRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归查找指定目录(如果不是直接在根目录)
|
||||
* @param {string} rootDir - 根目录
|
||||
* @param {string} dirName - 要查找的目录名(如 "skills" 或 "agents")
|
||||
* @returns {Promise<string|null>} 找到的目录路径,如果不存在则返回 null
|
||||
*/
|
||||
async function findDir(rootDir, dirName) {
|
||||
const directDir = path.join(rootDir, dirName);
|
||||
if (fs.existsSync(directDir) && (await fs.promises.lstat(directDir)).isDirectory()) {
|
||||
return directDir;
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(rootDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const subDir = path.join(rootDir, entry.name);
|
||||
const candidate = path.join(subDir, dirName);
|
||||
if (fs.existsSync(candidate) && (await fs.promises.lstat(candidate)).isDirectory()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归查找 skills 目录(如果不是直接在根目录)
|
||||
* @deprecated 使用 findDir(rootDir, "skills") 代替
|
||||
*/
|
||||
async function findSkillsDir(rootDir) {
|
||||
return findDir(rootDir, "skills");
|
||||
}
|
||||
|
||||
const DYNAMIC_ADD_LOCK = ".dynamic_add.lock";
|
||||
|
||||
/**
|
||||
* 检查 skill 目录是否含有 .dynamic_add.lock(有则不应删除)
|
||||
*/
|
||||
function hasDynamicAddLock(skillDirPath) {
|
||||
const lockPath = path.join(skillDirPath, DYNAMIC_ADD_LOCK);
|
||||
return fs.existsSync(lockPath) && fs.statSync(lockPath).isFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目录(如果存在)
|
||||
*/
|
||||
async function removeDirIfExists(targetDir) {
|
||||
if (!fs.existsSync(targetDir)) return;
|
||||
await fs.promises.rm(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全移动目录(跨设备时回退为 copy)
|
||||
*/
|
||||
async function moveDirectory(srcDir, destDir) {
|
||||
try {
|
||||
await fs.promises.rename(srcDir, destDir);
|
||||
} catch (err) {
|
||||
if (err.code === "EXDEV") {
|
||||
// 跨设备,使用 copy + rm
|
||||
async function copyRecursive(src, dest) {
|
||||
const stat = await fs.promises.lstat(src);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.promises.mkdir(dest, { recursive: true });
|
||||
const items = await fs.promises.readdir(src);
|
||||
for (const item of items) {
|
||||
await copyRecursive(
|
||||
path.join(src, item),
|
||||
path.join(dest, item)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await fs.promises.copyFile(src, dest);
|
||||
}
|
||||
}
|
||||
|
||||
await copyRecursive(srcDir, destDir);
|
||||
await fs.promises.rm(srcDir, { recursive: true, force: true });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工作空间并(可选)处理上传的 zip,提取 skills 目录
|
||||
* @param {string|number} userId
|
||||
* @param {string|number} cId
|
||||
* @param {Object|null} file multer 文件对象(zip),可以为空
|
||||
* @param {string[]|string|undefined} skillUrls 技能 zip 下载地址数组
|
||||
*/
|
||||
async function createWorkspace(userId, cId, file, skillUrls) {
|
||||
const startTime = Date.now();
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
const normalizedSkillUrls = normalizeSkillUrls(skillUrls);
|
||||
const downloadedZipPaths = [];
|
||||
const extractRoots = [];
|
||||
const updatedSkillSet = new Set();
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError("userId cannot be empty", { field: "userId" });
|
||||
}
|
||||
if (!cId) {
|
||||
throw new ValidationError("cId cannot be empty", { field: "cId" });
|
||||
}
|
||||
|
||||
const workspaceRoot = await ensureWorkspaceRoot(logId);
|
||||
const tmpRoot = path.join(
|
||||
workspaceRoot,
|
||||
String(userId),
|
||||
String(cId),
|
||||
".tmp"
|
||||
);
|
||||
|
||||
// 目标:$COMPUTER_WORKSPACE_DIR/userId/cId/
|
||||
const userWorkspaceRoot = path.join(
|
||||
workspaceRoot,
|
||||
String(userId),
|
||||
String(cId)
|
||||
);
|
||||
const {
|
||||
skillsDir: targetSkillsDir,
|
||||
agentsDir: targetAgentsDir,
|
||||
agentTypes: normalizedAgentTypes,
|
||||
} =
|
||||
await ensurePrimaryAgentDirs(userWorkspaceRoot);
|
||||
|
||||
// 始终:保证工作空间目录存在,并清空(删除)现有 skills 和 agents 目录
|
||||
if (!fs.existsSync(userWorkspaceRoot)) {
|
||||
await fs.promises.mkdir(userWorkspaceRoot, { recursive: true });
|
||||
}
|
||||
|
||||
// 清除工作目录中的 skills 和 agents 目录
|
||||
// skills:若 skill 子目录含 .dynamic_add.lock 则保留
|
||||
const preservedSkillsTemp = path.join(
|
||||
tmpRoot,
|
||||
`preserved_skills_${Date.now()}_${Math.round(Math.random() * 1e6)}`
|
||||
);
|
||||
if (fs.existsSync(targetSkillsDir)) {
|
||||
const skillEntries = await fs.promises.readdir(targetSkillsDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const toPreserve = skillEntries.filter(
|
||||
(e) =>
|
||||
e.isDirectory() &&
|
||||
hasDynamicAddLock(path.join(targetSkillsDir, e.name))
|
||||
);
|
||||
if (toPreserve.length > 0) {
|
||||
await fs.promises.mkdir(preservedSkillsTemp, { recursive: true });
|
||||
for (const e of toPreserve) {
|
||||
const src = path.join(targetSkillsDir, e.name);
|
||||
const dest = path.join(preservedSkillsTemp, e.name);
|
||||
await moveDirectory(src, dest);
|
||||
}
|
||||
log(logId, "INFO", "保留含 .dynamic_add.lock 的 skill", {
|
||||
preserved: toPreserve.map((e) => e.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
await removeDirIfExists(targetSkillsDir);
|
||||
await removeDirIfExists(targetAgentsDir);
|
||||
// 清空后立即重建空目录
|
||||
await fs.promises.mkdir(targetSkillsDir, { recursive: true });
|
||||
await fs.promises.mkdir(targetAgentsDir, { recursive: true });
|
||||
|
||||
// 恢复保留的 skills
|
||||
if (fs.existsSync(preservedSkillsTemp)) {
|
||||
const preserved = await fs.promises.readdir(preservedSkillsTemp, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const e of preserved) {
|
||||
if (e.isDirectory()) {
|
||||
const src = path.join(preservedSkillsTemp, e.name);
|
||||
const dest = path.join(targetSkillsDir, e.name);
|
||||
await moveDirectory(src, dest);
|
||||
}
|
||||
}
|
||||
await removeDirIfExists(preservedSkillsTemp);
|
||||
}
|
||||
|
||||
const skillsExistsAfter = fs.existsSync(targetSkillsDir);
|
||||
const agentsExistsAfter = fs.existsSync(targetAgentsDir);
|
||||
log(logId, "INFO", "Deleted old skills and agents directories completed", {
|
||||
userId,
|
||||
cId,
|
||||
targetSkillsDir,
|
||||
targetAgentsDir,
|
||||
agentTypes: normalizedAgentTypes,
|
||||
skillsExists: skillsExistsAfter,
|
||||
agentsExists: agentsExistsAfter,
|
||||
});
|
||||
|
||||
// 如果没有上传文件也没有 URL:不写入 skills 和 agents
|
||||
if (!file && normalizedSkillUrls.length === 0) {
|
||||
await syncAgents(userWorkspaceRoot);
|
||||
log(logId, "INFO", "Created workspace (no uploaded file, no skills and agents)", {
|
||||
userId,
|
||||
cId,
|
||||
workspaceRoot,
|
||||
skillsDir: null,
|
||||
agentsDir: null,
|
||||
agentTypes: normalizedAgentTypes,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
message: "Workspace created (no uploaded file, no skills and agents)",
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
// 有上传文件时,要求是 zip
|
||||
if (file) {
|
||||
if (!file.path) {
|
||||
throw new ValidationError("Uploaded file has no valid path", { field: "file.path" });
|
||||
}
|
||||
|
||||
const ext = path.extname(file.originalname || file.filename || "").toLowerCase();
|
||||
if (ext !== ".zip") {
|
||||
throw new ValidationError("Only zip files are supported", {
|
||||
field: "file",
|
||||
originalName: file.originalname,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log(logId, "DEBUG", "Start processing workspace resources", {
|
||||
userId,
|
||||
cId,
|
||||
workspaceRoot,
|
||||
hasUploadedZip: !!file,
|
||||
skillUrlsCount: normalizedSkillUrls.length,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(tmpRoot)) {
|
||||
await fs.promises.mkdir(tmpRoot, { recursive: true });
|
||||
}
|
||||
const updatedDirs = [];
|
||||
|
||||
// 处理上传 zip(支持 skills/agents)
|
||||
if (file) {
|
||||
const uploadExtractRoot = path.join(
|
||||
tmpRoot,
|
||||
`skill_extract_${Date.now()}_${Math.round(Math.random() * 1e6)}`
|
||||
);
|
||||
extractRoots.push(uploadExtractRoot);
|
||||
await fs.promises.mkdir(uploadExtractRoot, { recursive: true });
|
||||
log(logId, "DEBUG", "Start extracting uploaded zip file", {
|
||||
extractRoot: uploadExtractRoot,
|
||||
});
|
||||
await extractZip(file.path, uploadExtractRoot);
|
||||
log(logId, "DEBUG", "Uploaded zip file extracted successfully", {
|
||||
extractRoot: uploadExtractRoot,
|
||||
});
|
||||
|
||||
// 查找压缩包中的 skills 和 agents 目录
|
||||
const skillsDir = await findDir(uploadExtractRoot, "skills");
|
||||
const agentsDir = await findDir(uploadExtractRoot, "agents");
|
||||
|
||||
// 如果压缩包中有 skills 目录,就写入(逐个 skill 移动)
|
||||
if (skillsDir) {
|
||||
await fs.promises.mkdir(targetSkillsDir, { recursive: true });
|
||||
const skillEntries = await fs.promises.readdir(skillsDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const e of skillEntries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const srcPath = path.join(skillsDir, e.name);
|
||||
const destPath = path.join(targetSkillsDir, e.name);
|
||||
if (fs.existsSync(destPath)) {
|
||||
await removeDirIfExists(destPath);
|
||||
}
|
||||
await moveDirectory(srcPath, destPath);
|
||||
updatedSkillSet.add(e.name);
|
||||
}
|
||||
updatedDirs.push("skills");
|
||||
log(logId, "INFO", "skills updated to workspace", {
|
||||
userId,
|
||||
cId,
|
||||
workspaceRoot,
|
||||
targetSkillsDir,
|
||||
agentTypes: normalizedAgentTypes,
|
||||
});
|
||||
} else {
|
||||
log(logId, "INFO", "skills directory not found in uploaded zip, skipping", {
|
||||
userId,
|
||||
cId,
|
||||
extractRoot: uploadExtractRoot,
|
||||
});
|
||||
}
|
||||
|
||||
// 如果压缩包中有 agents 目录,就写入
|
||||
// Windows:目标路径若已存在目录(此前 mkdir 建过空目录),rename 会 EPERM,须先删除
|
||||
if (agentsDir) {
|
||||
await removeDirIfExists(targetAgentsDir);
|
||||
await moveDirectory(agentsDir, targetAgentsDir);
|
||||
updatedDirs.push("agents");
|
||||
log(logId, "INFO", "agents updated to workspace", {
|
||||
userId,
|
||||
cId,
|
||||
workspaceRoot,
|
||||
targetAgentsDir,
|
||||
agentTypes: normalizedAgentTypes,
|
||||
});
|
||||
} else {
|
||||
log(logId, "INFO", "agents directory not found in uploaded zip, skipping", {
|
||||
userId,
|
||||
cId,
|
||||
extractRoot: uploadExtractRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 skillUrls(每个 zip 解压后直接是 skillName 目录)
|
||||
for (let i = 0; i < normalizedSkillUrls.length; i += 1) {
|
||||
const skillUrl = normalizedSkillUrls[i];
|
||||
const downloadedZipPath = path.join(
|
||||
tmpRoot,
|
||||
`skill_url_${Date.now()}_${i}_${Math.round(Math.random() * 1e6)}.zip`
|
||||
);
|
||||
downloadedZipPaths.push(downloadedZipPath);
|
||||
const urlExtractRoot = path.join(
|
||||
tmpRoot,
|
||||
`skill_url_extract_${Date.now()}_${i}_${Math.round(Math.random() * 1e6)}`
|
||||
);
|
||||
extractRoots.push(urlExtractRoot);
|
||||
await fs.promises.mkdir(urlExtractRoot, { recursive: true });
|
||||
|
||||
log(logId, "INFO", "Start download skill zip from url", {
|
||||
userId,
|
||||
cId,
|
||||
skillUrl,
|
||||
});
|
||||
await downloadUrlToFile(skillUrl, downloadedZipPath, logId);
|
||||
log(logId, "INFO", "Skill zip downloaded, start extracting", {
|
||||
userId,
|
||||
cId,
|
||||
skillUrl,
|
||||
downloadedZipPath,
|
||||
});
|
||||
await extractZip(downloadedZipPath, urlExtractRoot);
|
||||
|
||||
const extractedEntries = await fs.promises.readdir(urlExtractRoot, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const rootDirs = extractedEntries.filter(
|
||||
(entry) => entry.isDirectory() && !entry.name.startsWith(".")
|
||||
);
|
||||
|
||||
const skillsRootDir = rootDirs.find((entry) => entry.name === "skills");
|
||||
const candidateSkillDirs = skillsRootDir
|
||||
? (await fs.promises.readdir(path.join(urlExtractRoot, "skills"), { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
sourcePath: path.join(urlExtractRoot, "skills", entry.name),
|
||||
}))
|
||||
: rootDirs.map((entry) => ({
|
||||
name: entry.name,
|
||||
sourcePath: path.join(urlExtractRoot, entry.name),
|
||||
}));
|
||||
|
||||
if (candidateSkillDirs.length === 0) {
|
||||
log(logId, "WARN", "No skill directory found after extracting skill url zip", {
|
||||
userId,
|
||||
cId,
|
||||
skillUrl,
|
||||
extractRoot: urlExtractRoot,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(targetSkillsDir, { recursive: true });
|
||||
for (const skillDir of candidateSkillDirs) {
|
||||
const destSkillPath = path.join(targetSkillsDir, skillDir.name);
|
||||
if (fs.existsSync(destSkillPath)) {
|
||||
await removeDirIfExists(destSkillPath);
|
||||
}
|
||||
await moveDirectory(skillDir.sourcePath, destSkillPath);
|
||||
updatedSkillSet.add(skillDir.name);
|
||||
|
||||
log(logId, "INFO", "Skill from url updated to workspace", {
|
||||
userId,
|
||||
cId,
|
||||
skillUrl,
|
||||
skillName: skillDir.name,
|
||||
destSkillPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (!updatedDirs.includes("skills")) {
|
||||
updatedDirs.push("skills");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果上传 zip 和 URL 技能都没找到有效目录,记录警告但不中断流程
|
||||
if (updatedDirs.length === 0) {
|
||||
log(logId, "WARN", "No valid skills or agents directories found", {
|
||||
userId,
|
||||
cId,
|
||||
hasUploadedZip: !!file,
|
||||
skillUrlsCount: normalizedSkillUrls.length,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedSkills = Array.from(updatedSkillSet);
|
||||
const message =
|
||||
updatedDirs.length > 0
|
||||
? `Workspace created successfully, ${updatedDirs.join(" and ")} updated`
|
||||
: "Workspace created successfully (skills and agents directories not found)";
|
||||
|
||||
log(logId, "INFO", message, {
|
||||
userId,
|
||||
cId,
|
||||
updatedDirs,
|
||||
updatedSkills,
|
||||
agentTypes: normalizedAgentTypes,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
await syncAgents(userWorkspaceRoot);
|
||||
|
||||
return {
|
||||
message,
|
||||
workspaceRoot,
|
||||
updatedSkills,
|
||||
};
|
||||
} catch (error) {
|
||||
log(logId, "ERROR", "Failed to process workspace resources", {
|
||||
userId,
|
||||
cId,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
if (
|
||||
error instanceof ValidationError ||
|
||||
error instanceof FileError ||
|
||||
error instanceof SystemError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new SystemError(`Failed to create workspace: ${error.message}`, {
|
||||
userId,
|
||||
cId,
|
||||
});
|
||||
} finally {
|
||||
// 清理临时目录和临时 zip 文件
|
||||
try {
|
||||
for (const extractRoot of extractRoots) {
|
||||
if (fs.existsSync(extractRoot)) {
|
||||
await fs.promises.rm(extractRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(logId, "WARN", "Failed to clean up temporary extracted zip", {
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
// 清理下载的 zip 文件
|
||||
for (const downloadedZipPath of downloadedZipPaths) {
|
||||
try {
|
||||
if (fs.existsSync(downloadedZipPath)) {
|
||||
await fs.promises.unlink(downloadedZipPath);
|
||||
}
|
||||
} catch (e) {
|
||||
log(logId, "WARN", "Failed to clean up downloaded skill zip file", {
|
||||
downloadedZipPath,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 清理上传的 zip 文件
|
||||
try {
|
||||
if (file && file.path && fs.existsSync(file.path)) {
|
||||
await fs.promises.unlink(file.path);
|
||||
}
|
||||
} catch (e) {
|
||||
log(logId, "WARN", "Failed to clean up uploaded zip file", {
|
||||
tempZipPath: file?.path,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送技能到工作空间
|
||||
* file 为 zip 压缩包,其中应包含 skills 目录,skills 目录下为具体 skill 子目录
|
||||
* skillUrls 为技能 zip 下载地址数组,解压后可为 skills/<skillName> 或直接 <skillName>
|
||||
* 如有同名 skill 则覆盖,否则新增;不处理 agents 目录
|
||||
* @param {string|number} userId
|
||||
* @param {string|number} cId
|
||||
* @param {Object|null} file multer 文件对象(zip)
|
||||
* @param {string[]|string|undefined} skillUrls 技能 zip 下载地址数组
|
||||
*/
|
||||
async function pushSkillsToWorkspace(userId, cId, file, skillUrls) {
|
||||
const startTime = Date.now();
|
||||
const logId = `computer:${userId}:${cId}`;
|
||||
const normalizedSkillUrls = normalizeSkillUrls(skillUrls);
|
||||
const downloadedZipPaths = [];
|
||||
const extractRoots = [];
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError("userId cannot be empty", { field: "userId" });
|
||||
}
|
||||
if (!cId) {
|
||||
throw new ValidationError("cId cannot be empty", { field: "cId" });
|
||||
}
|
||||
if (!file && normalizedSkillUrls.length === 0) {
|
||||
throw new ValidationError("file or skillUrls cannot both be empty", {
|
||||
field: "file|skillUrls",
|
||||
});
|
||||
}
|
||||
|
||||
if (file) {
|
||||
if (!file.path) {
|
||||
throw new ValidationError("Uploaded file has no valid path", { field: "file.path" });
|
||||
}
|
||||
const ext = path.extname(file.originalname || file.filename || "").toLowerCase();
|
||||
if (ext !== ".zip") {
|
||||
throw new ValidationError("Only zip files are supported", {
|
||||
field: "file",
|
||||
originalName: file?.originalname,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceRoot = await ensureWorkspaceRoot(logId);
|
||||
const tmpRoot = path.join(
|
||||
workspaceRoot,
|
||||
String(userId),
|
||||
String(cId),
|
||||
".tmp"
|
||||
);
|
||||
const userWorkspaceRoot = path.join(
|
||||
workspaceRoot,
|
||||
String(userId),
|
||||
String(cId)
|
||||
);
|
||||
const { skillsDir: targetSkillsDir, agentTypes: normalizedAgentTypes } =
|
||||
await ensurePrimaryAgentDirs(userWorkspaceRoot);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(userWorkspaceRoot)) {
|
||||
await fs.promises.mkdir(userWorkspaceRoot, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(targetSkillsDir)) {
|
||||
await fs.promises.mkdir(targetSkillsDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(tmpRoot)) {
|
||||
await fs.promises.mkdir(tmpRoot, { recursive: true });
|
||||
}
|
||||
|
||||
const updatedSkills = [];
|
||||
const updatedSkillSet = new Set();
|
||||
|
||||
// 处理上传 zip:要求 zip 中包含 skills 目录
|
||||
if (file) {
|
||||
const extractRoot = path.join(
|
||||
tmpRoot,
|
||||
`skill_push_${Date.now()}_${Math.round(Math.random() * 1e6)}`
|
||||
);
|
||||
extractRoots.push(extractRoot);
|
||||
await fs.promises.mkdir(extractRoot, { recursive: true });
|
||||
log(logId, "DEBUG", "Start extracting skill zip file", { extractRoot });
|
||||
await extractZip(file.path, extractRoot);
|
||||
log(logId, "DEBUG", "Skill zip file extracted successfully", { extractRoot });
|
||||
|
||||
const skillsDir = await findDir(extractRoot, "skills");
|
||||
if (!skillsDir) {
|
||||
log(logId, "WARN", "skills directory not found in uploaded zip", {
|
||||
userId,
|
||||
cId,
|
||||
extractRoot,
|
||||
});
|
||||
} else {
|
||||
const skillEntries = await fs.promises.readdir(skillsDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const skillDirs = skillEntries.filter(
|
||||
(e) => e.isDirectory() && !e.name.startsWith(".")
|
||||
);
|
||||
|
||||
if (skillDirs.length === 0) {
|
||||
log(logId, "WARN", "skills directory in uploaded zip has no skill subdirectories", {
|
||||
userId,
|
||||
cId,
|
||||
skillsDir,
|
||||
});
|
||||
} else {
|
||||
for (const skillDir of skillDirs) {
|
||||
const srcSkillPath = path.join(skillsDir, skillDir.name);
|
||||
const destSkillPath = path.join(targetSkillsDir, skillDir.name);
|
||||
if (fs.existsSync(destSkillPath)) {
|
||||
await removeDirIfExists(destSkillPath);
|
||||
}
|
||||
await moveDirectory(srcSkillPath, destSkillPath);
|
||||
updatedSkillSet.add(skillDir.name);
|
||||
|
||||
log(logId, "INFO", "skill pushed to workspace from uploaded zip", {
|
||||
userId,
|
||||
cId,
|
||||
skillName: skillDir.name,
|
||||
destSkillPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 skillUrls:解压后可为 skills/<skillName> 或直接 <skillName>
|
||||
for (let i = 0; i < normalizedSkillUrls.length; i += 1) {
|
||||
const skillUrl = normalizedSkillUrls[i];
|
||||
const downloadedZipPath = path.join(
|
||||
tmpRoot,
|
||||
`skill_push_url_${Date.now()}_${i}_${Math.round(Math.random() * 1e6)}.zip`
|
||||
);
|
||||
downloadedZipPaths.push(downloadedZipPath);
|
||||
const urlExtractRoot = path.join(
|
||||
tmpRoot,
|
||||
`skill_push_url_extract_${Date.now()}_${i}_${Math.round(Math.random() * 1e6)}`
|
||||
);
|
||||
extractRoots.push(urlExtractRoot);
|
||||
await fs.promises.mkdir(urlExtractRoot, { recursive: true });
|
||||
|
||||
log(logId, "INFO", "Start download skill zip for push from url", {
|
||||
userId,
|
||||
cId,
|
||||
skillUrl,
|
||||
});
|
||||
await downloadUrlToFile(skillUrl, downloadedZipPath, logId);
|
||||
await extractZip(downloadedZipPath, urlExtractRoot);
|
||||
|
||||
const extractedEntries = await fs.promises.readdir(urlExtractRoot, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const rootDirs = extractedEntries.filter(
|
||||
(entry) => entry.isDirectory() && !entry.name.startsWith(".")
|
||||
);
|
||||
const skillsRootDir = rootDirs.find((entry) => entry.name === "skills");
|
||||
const candidateSkillDirs = skillsRootDir
|
||||
? (await fs.promises.readdir(path.join(urlExtractRoot, "skills"), { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
sourcePath: path.join(urlExtractRoot, "skills", entry.name),
|
||||
}))
|
||||
: rootDirs.map((entry) => ({
|
||||
name: entry.name,
|
||||
sourcePath: path.join(urlExtractRoot, entry.name),
|
||||
}));
|
||||
|
||||
if (candidateSkillDirs.length === 0) {
|
||||
log(logId, "WARN", "No skill directory found after extracting push skill url zip", {
|
||||
userId,
|
||||
cId,
|
||||
skillUrl,
|
||||
extractRoot: urlExtractRoot,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const skillDir of candidateSkillDirs) {
|
||||
const destSkillPath = path.join(targetSkillsDir, skillDir.name);
|
||||
if (fs.existsSync(destSkillPath)) {
|
||||
await removeDirIfExists(destSkillPath);
|
||||
}
|
||||
await moveDirectory(skillDir.sourcePath, destSkillPath);
|
||||
updatedSkillSet.add(skillDir.name);
|
||||
|
||||
log(logId, "INFO", "skill pushed to workspace from url zip", {
|
||||
userId,
|
||||
cId,
|
||||
skillUrl,
|
||||
skillName: skillDir.name,
|
||||
destSkillPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const skillName of updatedSkillSet) {
|
||||
updatedSkills.push(skillName);
|
||||
}
|
||||
|
||||
const message =
|
||||
updatedSkills.length > 0
|
||||
? `Pushed ${updatedSkills.length} skills: ${updatedSkills.join(", ")}`
|
||||
: "No valid skill directories found in file or skillUrls";
|
||||
|
||||
log(logId, "INFO", message, {
|
||||
userId,
|
||||
cId,
|
||||
updatedSkills,
|
||||
agentTypes: normalizedAgentTypes,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
await syncAgents(userWorkspaceRoot);
|
||||
|
||||
return {
|
||||
message,
|
||||
workspaceRoot,
|
||||
updatedSkills,
|
||||
};
|
||||
} catch (error) {
|
||||
log(logId, "ERROR", "Failed to push skill to workspace", {
|
||||
userId,
|
||||
cId,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
if (
|
||||
error instanceof ValidationError ||
|
||||
error instanceof FileError ||
|
||||
error instanceof SystemError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new SystemError(`Failed to push skill to workspace: ${error.message}`, {
|
||||
userId,
|
||||
cId,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
for (const extractRoot of extractRoots) {
|
||||
if (fs.existsSync(extractRoot)) {
|
||||
await fs.promises.rm(extractRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(logId, "WARN", "Failed to clean up temporary extracted zip", {
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
for (const downloadedZipPath of downloadedZipPaths) {
|
||||
try {
|
||||
if (fs.existsSync(downloadedZipPath)) {
|
||||
await fs.promises.unlink(downloadedZipPath);
|
||||
}
|
||||
} catch (e) {
|
||||
log(logId, "WARN", "Failed to clean up downloaded skill zip file", {
|
||||
downloadedZipPath,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (file && file.path && fs.existsSync(file.path)) {
|
||||
await fs.promises.unlink(file.path);
|
||||
}
|
||||
} catch (e) {
|
||||
log(logId, "WARN", "Failed to clean up uploaded zip file", {
|
||||
tempZipPath: file?.path,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { createWorkspace, pushSkillsToWorkspace };
|
||||
|
||||
|
||||
403
qiming-file-server/src/utils/envUtils.js
Normal file
403
qiming-file-server/src/utils/envUtils.js
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 环境变量工具模块
|
||||
*
|
||||
* 功能:
|
||||
* - 从命令行参数加载环境变量
|
||||
* - 支持通过命令行参数覆盖环境变量
|
||||
* - 支持 --env-file 指定自定义配置文件
|
||||
* - 处理 Windows 环境变量大小写不敏感问题
|
||||
*
|
||||
* 优先级:
|
||||
* 1. 命令行参数 (最高)
|
||||
* 2. 环境变量文件 (.env.production 等)
|
||||
* 3. 系统环境变量
|
||||
* 4. 默认值 (最低)
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
/**
|
||||
* 检测当前操作系统是否为 Windows
|
||||
*
|
||||
* @returns {boolean} 是否为 Windows 系统
|
||||
*/
|
||||
function isWindows() {
|
||||
return process.platform === 'win32';
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化环境变量名
|
||||
*
|
||||
* Windows 环境变量不区分大小写,统一转为大写
|
||||
*
|
||||
* @param {string} name - 环境变量名
|
||||
* @returns {string} 规范化后的变量名
|
||||
*/
|
||||
function normalizeEnvName(name) {
|
||||
// Windows 环境变量不区分大小写,统一转为大写
|
||||
if (isWindows()) {
|
||||
return name.toUpperCase();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取环境变量值
|
||||
*
|
||||
* 支持 Windows 环境变量大小写不敏感
|
||||
*
|
||||
* @param {string} name - 环境变量名
|
||||
* @param {*} defaultValue - 默认值
|
||||
* @returns {*} 环境变量值或默认值
|
||||
*/
|
||||
function getEnv(name, defaultValue = undefined) {
|
||||
const normalizedName = normalizeEnvName(name);
|
||||
|
||||
// 优先从 process.env 获取
|
||||
if (process.env[normalizedName] !== undefined) {
|
||||
return process.env[normalizedName];
|
||||
}
|
||||
|
||||
// 回退到原始名称(兼容某些库)
|
||||
if (name !== normalizedName && process.env[name] !== undefined) {
|
||||
return process.env[name];
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取布尔类型的环境变量值
|
||||
*
|
||||
* @param {string} name - 环境变量名
|
||||
* @param {boolean} defaultValue - 默认值
|
||||
* @returns {boolean} 布尔值
|
||||
*/
|
||||
function getBoolEnv(name, defaultValue = false) {
|
||||
const value = getEnv(name);
|
||||
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// 转换为小写后比较
|
||||
const lowerValue = String(value).toLowerCase();
|
||||
|
||||
return lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数字类型的环境变量值
|
||||
*
|
||||
* @param {string} name - 环境变量名
|
||||
* @param {number} defaultValue - 默认值
|
||||
* @returns {number|null} 数字值或 null(如果解析失败)
|
||||
*/
|
||||
function getNumberEnv(name, defaultValue = undefined) {
|
||||
const value = getEnv(name);
|
||||
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
console.warn(`Environment variable ${name} value "${value}" is not a valid number`);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析环境类型
|
||||
*
|
||||
* @param {string} env - 环境名称
|
||||
* @returns {string} 标准化的环境名称
|
||||
*/
|
||||
function parseEnvType(env) {
|
||||
if (!env) {
|
||||
return 'production';
|
||||
}
|
||||
|
||||
const normalizedEnv = env.toLowerCase().trim();
|
||||
|
||||
// 标准化环境名称
|
||||
const envMap = {
|
||||
'dev': 'development',
|
||||
'test': 'test',
|
||||
'prod': 'production',
|
||||
'production': 'production',
|
||||
'development': 'development',
|
||||
'staging': 'staging',
|
||||
};
|
||||
|
||||
return envMap[normalizedEnv] || 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载环境文件
|
||||
*
|
||||
* 根据环境类型加载对应的 .env 文件
|
||||
*
|
||||
* @param {string} env - 环境类型
|
||||
* @param {Object} options - 选项
|
||||
* @param {string} [options.basePath] - 基础路径(默认当前工作目录)
|
||||
* @returns {Object} 加载结果
|
||||
*/
|
||||
function loadEnvFile(env, options = {}) {
|
||||
const basePath = options.basePath || process.cwd();
|
||||
const envType = parseEnvType(env);
|
||||
|
||||
// 环境文件名映射
|
||||
const envFileNames = [
|
||||
`.env.${envType}`, // .env.production
|
||||
`.env.${envType}.local`, // .env.production.local(优先)
|
||||
'.env.local',
|
||||
'.env',
|
||||
];
|
||||
|
||||
const loadedFiles = [];
|
||||
const result = {
|
||||
loaded: false,
|
||||
files: loadedFiles,
|
||||
env: envType,
|
||||
};
|
||||
|
||||
for (const fileName of envFileNames) {
|
||||
const filePath = path.join(basePath, fileName);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
// 使用 dotenv 解析环境文件
|
||||
const parsed = dotenv.config({
|
||||
path: filePath,
|
||||
override: false, // 不覆盖已存在的环境变量
|
||||
});
|
||||
|
||||
if (parsed.error) {
|
||||
console.warn(`Load environment file ${fileName} failed: ${parsed.error.message}`);
|
||||
} else {
|
||||
loadedFiles.push(filePath);
|
||||
result.loaded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Load environment file ${fileName} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
console.log(`Loaded environment files: ${loadedFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载自定义配置文件
|
||||
*
|
||||
* 支持 JSON 和 .env 格式的配置文件
|
||||
*
|
||||
* @param {string} configPath - 配置文件路径
|
||||
* @returns {Object} 加载结果
|
||||
*/
|
||||
function loadCustomConfigFile(configPath) {
|
||||
const result = {
|
||||
loaded: false,
|
||||
path: configPath,
|
||||
data: {},
|
||||
};
|
||||
|
||||
if (!configPath) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.warn(`Configuration file does not exist: ${configPath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const ext = path.extname(configPath).toLowerCase();
|
||||
|
||||
if (ext === '.json') {
|
||||
// JSON 格式
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
result.data = JSON.parse(content);
|
||||
result.loaded = true;
|
||||
} else if (ext === '.env' || ext === '') {
|
||||
// .env 格式
|
||||
const dotenvResult = dotenv.config({
|
||||
path: configPath,
|
||||
override: true,
|
||||
});
|
||||
|
||||
if (!dotenvResult.error) {
|
||||
result.loaded = true;
|
||||
// 从 process.env 提取加载的值
|
||||
Object.keys(dotenvResult.parsed || {}).forEach(key => {
|
||||
result.data[key] = process.env[key];
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn(`不支持的配置文件格式: ${ext}`);
|
||||
}
|
||||
|
||||
console.log(`Loaded configuration file: ${configPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Load configuration file failed: ${err.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从命令行参数加载环境变量
|
||||
*
|
||||
* 解析 process.argv,提取环境变量设置
|
||||
*
|
||||
* @returns {Object} 提取的环境变量对象
|
||||
*/
|
||||
function loadEnvFromArgv() {
|
||||
const result = {};
|
||||
|
||||
// 命令行参数模式: --<name>=<value> 或 --<name> <value>
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
|
||||
// 检查是否以 -- 开头
|
||||
if (arg.startsWith('--')) {
|
||||
let key = arg.slice(2);
|
||||
let value = null;
|
||||
|
||||
// 检查是否有 = 分隔符
|
||||
if (key.includes('=')) {
|
||||
const parts = key.split('=');
|
||||
key = parts[0];
|
||||
value = parts.slice(1).join('=');
|
||||
} else {
|
||||
// 检查下一个参数是否是值(非以 -- 开头的参数)
|
||||
if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
||||
value = argv[i + 1];
|
||||
i++; // 跳过下一个参数
|
||||
} else {
|
||||
value = 'true'; // 布尔标志
|
||||
}
|
||||
}
|
||||
|
||||
// 排除已知的 CLI 选项
|
||||
const cliOptions = ['env', 'port', 'config', 'force', 'help', 'version'];
|
||||
if (!cliOptions.includes(key)) {
|
||||
result[normalizeEnvName(key)] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用环境变量到 process.env
|
||||
*
|
||||
* 优先级: CLI 参数 > 自定义配置 > 环境文件 > 系统环境变量
|
||||
*
|
||||
* @param {Object} options - 选项
|
||||
* @param {string} [options.env] - 环境类型
|
||||
* @param {string} [options.config] - 自定义配置文件路径
|
||||
* @param {string} [options.port] - 端口号
|
||||
* @param {string} [options.basePath] - 基础路径
|
||||
* @returns {Object} 最终环境配置
|
||||
*/
|
||||
function applyEnv(options = {}) {
|
||||
const { env, config, port, basePath } = options;
|
||||
|
||||
// 1. 加载环境文件
|
||||
const envResult = loadEnvFile(env, { basePath });
|
||||
|
||||
// 2. 加载自定义配置文件
|
||||
const configResult = loadCustomConfigFile(config);
|
||||
|
||||
// 3. 设置环境类型
|
||||
const envType = envResult.env;
|
||||
process.env.NODE_ENV = envType;
|
||||
|
||||
// 4. 应用端口
|
||||
if (port) {
|
||||
process.env.PORT = String(port);
|
||||
}
|
||||
|
||||
// 5. 应用自定义配置文件的值
|
||||
if (configResult.loaded) {
|
||||
Object.entries(configResult.data).forEach(([key, value]) => {
|
||||
const normalizedKey = normalizeEnvName(key);
|
||||
// CLI 参数优先级最高,不覆盖
|
||||
if (process.env[normalizedKey] === undefined) {
|
||||
process.env[normalizedKey] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 应用命令行参数
|
||||
const argvEnv = loadEnvFromArgv();
|
||||
Object.entries(argvEnv).forEach(([key, value]) => {
|
||||
process.env[key] = value;
|
||||
});
|
||||
|
||||
// 返回最终配置
|
||||
return {
|
||||
env: envType,
|
||||
port: process.env.PORT,
|
||||
config: configResult.loaded ? configResult.path : null,
|
||||
files: envResult.files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建环境配置摘要
|
||||
*
|
||||
* 用于日志输出
|
||||
*
|
||||
* @returns {string} 配置摘要字符串
|
||||
*/
|
||||
function createEnvSummary() {
|
||||
const summary = {
|
||||
env: process.env.NODE_ENV || 'unknown',
|
||||
port: process.env.PORT || 'default',
|
||||
platform: process.platform,
|
||||
nodeVersion: process.version,
|
||||
};
|
||||
|
||||
return JSON.stringify(summary, null, 2);
|
||||
}
|
||||
|
||||
// ESM 导出
|
||||
export {
|
||||
isWindows,
|
||||
normalizeEnvName,
|
||||
getEnv,
|
||||
getBoolEnv,
|
||||
getNumberEnv,
|
||||
parseEnvType,
|
||||
loadEnvFile,
|
||||
loadCustomConfigFile,
|
||||
loadEnvFromArgv,
|
||||
applyEnv,
|
||||
createEnvSummary,
|
||||
};
|
||||
|
||||
// 如果直接运行此文件,显示当前环境配置
|
||||
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").replace(/^.*[\/\\]/, ""))) {
|
||||
console.log('\nCurrent environment configuration:\n');
|
||||
console.log(createEnvSummary());
|
||||
console.log('\nDetailed environment variables:\n');
|
||||
console.log('NODE_ENV:', process.env.NODE_ENV);
|
||||
console.log('PORT:', process.env.PORT);
|
||||
console.log('CONFIG_FILE:', process.env.CONFIG_FILE);
|
||||
console.log('');
|
||||
}
|
||||
339
qiming-file-server/src/utils/error/buildErrorParser.js
Normal file
339
qiming-file-server/src/utils/error/buildErrorParser.js
Normal file
@@ -0,0 +1,339 @@
|
||||
import { log } from "../log/logUtils.js";
|
||||
import path from "path";
|
||||
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
|
||||
|
||||
/**
|
||||
* 构建错误解析器
|
||||
* 用于解析构建错误并提供用户友好的错误信息和修复建议
|
||||
*/
|
||||
class BuildErrorParser {
|
||||
constructor() {
|
||||
// 常见错误类型和对应的修复建议
|
||||
this.errorPatterns = [
|
||||
{
|
||||
name: "Regular expression HTML tag escape error",
|
||||
pattern: /html\.match\(\/<title>\(\.\*\?\)<\/title>\/i\)/,
|
||||
suggestion:
|
||||
"In regular expressions, the angle brackets of HTML tags need to be escaped. Please modify `</title>` to `</title>`",
|
||||
example: {
|
||||
wrong: "html.match(/<title>(.*?)</title>/i)",
|
||||
correct: "html.match(/<title>(.*?)<\\/title>/i)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "JavaScript syntax error",
|
||||
pattern: /Parse error|SyntaxError|Unexpected token/,
|
||||
suggestion: "Check the code syntax, ensure that the parentheses, quotes, semicolons, etc. are correctly paired",
|
||||
example: null,
|
||||
},
|
||||
{
|
||||
name: "Module import error",
|
||||
pattern: /Cannot resolve module|Module not found/,
|
||||
suggestion: "Check the import path to ensure that the module file exists",
|
||||
example: null,
|
||||
},
|
||||
{
|
||||
name: "TypeScript type error",
|
||||
pattern: /Type error|Type '.*' is not assignable/,
|
||||
suggestion: "Check the variable type definition, ensure that the type matches",
|
||||
example: null,
|
||||
},
|
||||
{
|
||||
name: "Dependency missing error",
|
||||
pattern: /Cannot find module|Module not found/,
|
||||
suggestion: "Run `pnpm install` to install the missing dependency package",
|
||||
example: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析构建错误信息并生成用户友好的指导信息
|
||||
* @param {string} errorMessage - 构建错误消息
|
||||
* @param {string} projectId - 项目ID
|
||||
* @returns {string} 用户友好的错误指导信息
|
||||
*/
|
||||
parseBuildError(errorMessage, projectId) {
|
||||
try {
|
||||
log(projectId, "INFO", "Start parsing build error", { errorMessage });
|
||||
|
||||
// 提取文件路径和位置信息
|
||||
const fileInfo = this.extractFileInfo(errorMessage);
|
||||
|
||||
// 提取错误类型和描述
|
||||
const errorDetails = this.extractErrorDetails(errorMessage);
|
||||
|
||||
// 匹配错误模式并提供建议
|
||||
const suggestions = this.getErrorSuggestions(errorMessage);
|
||||
|
||||
// 生成用户友好的错误指导信息
|
||||
const userFriendlyMessage = this.generateUserFriendlyMessage(
|
||||
errorDetails,
|
||||
fileInfo,
|
||||
suggestions,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
log(projectId, "INFO", "Build error parsing completed", {
|
||||
errorType: errorDetails.type,
|
||||
fileName: fileInfo?.path ? path.basename(fileInfo.path) : null,
|
||||
suggestionsCount: suggestions.length,
|
||||
});
|
||||
|
||||
return userFriendlyMessage;
|
||||
} catch (error) {
|
||||
log(projectId, "ERROR", "Exception occurred when parsing build error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
return "Build failed, please check the detailed error information in the build log, or contact technical support.";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文件信息
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @returns {Object|null} 文件信息
|
||||
*/
|
||||
extractFileInfo(errorMessage) {
|
||||
// 匹配文件路径和行号
|
||||
const fileMatch = errorMessage.match(/file:\s*([^\n]+):(\d+):(\d+)/);
|
||||
if (!fileMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, filePath, lineNumber, columnNumber] = fileMatch;
|
||||
|
||||
return {
|
||||
path: filePath.trim(),
|
||||
line: parseInt(lineNumber),
|
||||
column: parseInt(columnNumber),
|
||||
relativePath: this.getRelativePath(filePath.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取错误详情
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @returns {Object} 错误详情
|
||||
*/
|
||||
extractErrorDetails(errorMessage) {
|
||||
// 匹配错误类型
|
||||
const errorTypeMatch = errorMessage.match(
|
||||
/(Parse error|SyntaxError|TypeError|ReferenceError|Unexpected token)/
|
||||
);
|
||||
const errorType = errorTypeMatch ? errorTypeMatch[1] : "Build error";
|
||||
|
||||
// 提取错误描述
|
||||
let errorMessage_clean = errorMessage;
|
||||
|
||||
// 尝试提取更具体的错误描述
|
||||
const descriptionMatch = errorMessage.match(
|
||||
/(?:Parse error|SyntaxError|TypeError|ReferenceError)[^:]*:\s*([^\n]+)/
|
||||
);
|
||||
if (descriptionMatch) {
|
||||
errorMessage_clean = descriptionMatch[1].trim();
|
||||
} else {
|
||||
// 提取第一行有意义的错误信息
|
||||
const lines = errorMessage.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim() && !line.includes("file:") && !line.includes("at ")) {
|
||||
errorMessage_clean = line.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: errorType,
|
||||
message: errorMessage_clean,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误修复建议
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @returns {Array} 建议列表
|
||||
*/
|
||||
getErrorSuggestions(errorMessage) {
|
||||
const suggestions = [];
|
||||
|
||||
// 匹配预定义的错误模式
|
||||
for (const pattern of this.errorPatterns) {
|
||||
if (pattern.pattern.test(errorMessage)) {
|
||||
suggestions.push({
|
||||
type: pattern.name,
|
||||
message: pattern.suggestion,
|
||||
priority: "high",
|
||||
example: pattern.example,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配到特定模式,提供通用建议
|
||||
if (suggestions.length === 0) {
|
||||
suggestions.push({
|
||||
type: "General suggestion",
|
||||
message: "Please carefully check the files and line numbers mentioned in the error information, ensure that the code syntax is correct",
|
||||
priority: "medium",
|
||||
});
|
||||
}
|
||||
|
||||
// 添加文件检查建议
|
||||
const fileInfo = this.extractFileInfo(errorMessage);
|
||||
if (fileInfo) {
|
||||
const fileName = path.basename(fileInfo.path);
|
||||
suggestions.push({
|
||||
type: "File check",
|
||||
message: `Please check the code near line ${fileInfo.line} column ${fileInfo.column} in file ${fileName}`,
|
||||
priority: "high",
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取代码上下文
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @returns {Object|null} 代码上下文
|
||||
*/
|
||||
extractCodeContext(errorMessage) {
|
||||
const lines = errorMessage.split("\n");
|
||||
const context = {
|
||||
before: [],
|
||||
error: null,
|
||||
after: [],
|
||||
};
|
||||
|
||||
let foundErrorLine = false;
|
||||
let lineNumber = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// 匹配行号格式: "82: const titleMatch = html.match(/<title>(.*?)</title>/i)"
|
||||
const lineMatch = line.match(/^\s*(\d+):\s*(.*)$/);
|
||||
if (lineMatch) {
|
||||
const [, num, content] = lineMatch;
|
||||
lineNumber = parseInt(num);
|
||||
|
||||
if (!foundErrorLine) {
|
||||
context.before.push({ line: lineNumber, content: content.trim() });
|
||||
} else {
|
||||
context.after.push({ line: lineNumber, content: content.trim() });
|
||||
}
|
||||
}
|
||||
|
||||
// 查找错误标记行 "^"
|
||||
if (line.includes("^") && !foundErrorLine) {
|
||||
foundErrorLine = true;
|
||||
// 错误行是上一行
|
||||
if (context.before.length > 0) {
|
||||
const errorLine = context.before.pop();
|
||||
context.error = errorLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 限制上下文行数
|
||||
context.before = context.before.slice(-3); // 最多3行
|
||||
context.after = context.after.slice(0, 3); // 最多3行
|
||||
|
||||
return context.before.length > 0 ||
|
||||
context.error ||
|
||||
context.after.length > 0
|
||||
? context
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相对路径
|
||||
* @param {string} absolutePath - 绝对路径
|
||||
* @returns {string} 相对路径
|
||||
*/
|
||||
getRelativePath(absolutePath) {
|
||||
// 脱敏处理:移除敏感路径信息
|
||||
const sanitizedPath = this.sanitizePath(absolutePath);
|
||||
|
||||
// 尝试提取项目相关的相对路径
|
||||
const projectMatch = sanitizedPath.match(
|
||||
/project_workspace\/[^\/]+\/(.+)$/
|
||||
);
|
||||
if (projectMatch) {
|
||||
return projectMatch[1];
|
||||
}
|
||||
|
||||
// 如果无法提取,返回文件名
|
||||
const pathParts = sanitizedPath.split("/");
|
||||
return pathParts[pathParts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏路径信息
|
||||
* @param {string} path - 原始路径
|
||||
* @returns {string} 脱敏后的路径
|
||||
*/
|
||||
sanitizePath(path) {
|
||||
return sanitizeSensitivePaths(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户友好的错误指导信息
|
||||
* @param {Object} errorDetails - 错误详情
|
||||
* @param {Object} fileInfo - 文件信息
|
||||
* @param {Array} suggestions - 修复建议
|
||||
* @param {string} originalError - 原始错误信息
|
||||
* @returns {string} 用户友好的错误指导信息
|
||||
*/
|
||||
generateUserFriendlyMessage(
|
||||
errorDetails,
|
||||
fileInfo,
|
||||
suggestions,
|
||||
originalError
|
||||
) {
|
||||
let message = "Build failed!\n\n";
|
||||
|
||||
// 添加错误类型和基本描述
|
||||
message += `Error type: ${errorDetails.type}\n`;
|
||||
message += `Error description: ${errorDetails.message}\n\n`;
|
||||
|
||||
// 添加文件位置信息
|
||||
if (fileInfo) {
|
||||
const fileName = path.basename(fileInfo.path);
|
||||
message += `📍 Error location:\n`;
|
||||
message += ` File: ${fileName}\n`;
|
||||
message += ` Line: ${fileInfo.line}, Column: ${fileInfo.column}\n\n`;
|
||||
}
|
||||
|
||||
// 添加修复建议
|
||||
if (suggestions.length > 0) {
|
||||
message += `🔧 Repair suggestions:\n`;
|
||||
suggestions.forEach((suggestion, index) => {
|
||||
message += ` ${index + 1}. ${suggestion.message}\n`;
|
||||
|
||||
// 如果有代码示例,添加到建议中
|
||||
if (suggestion.example) {
|
||||
message += ` Wrong code: ${suggestion.example.wrong}\n`;
|
||||
message += ` Correct code: ${suggestion.example.correct}\n`;
|
||||
}
|
||||
});
|
||||
message += "\n";
|
||||
}
|
||||
|
||||
// 添加通用指导
|
||||
message += `💡 Operation steps:\n`;
|
||||
message += ` 1. Please modify the code according to the above suggestions\n`;
|
||||
message += ` 2. Save the file and rebuild the project\n`;
|
||||
message += ` 3. If the problem still exists, please check other related files\n\n`;
|
||||
|
||||
// 添加联系信息
|
||||
message += `📞 Need help?\n`;
|
||||
message += ` If you cannot solve this problem, please contact technical support and provide complete error information.`;
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
export default BuildErrorParser;
|
||||
7
qiming-file-server/src/utils/error/errorCodes.js
Normal file
7
qiming-file-server/src/utils/error/errorCodes.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// 错误码常量(ESM)
|
||||
export default {
|
||||
PROJECT_STARTING: "PROJECT_STARTING",
|
||||
INVALID_SCRIPT_TYPE: "INVALID_SCRIPT_TYPE",
|
||||
SYNTAX_CHECK_FAILED: "SYNTAX_CHECK_FAILED",
|
||||
DEV_SERVER_START_FAILED: "DEV_SERVER_START_FAILED",
|
||||
};
|
||||
335
qiming-file-server/src/utils/error/errorHandler.js
Normal file
335
qiming-file-server/src/utils/error/errorHandler.js
Normal file
@@ -0,0 +1,335 @@
|
||||
// 错误处理工具模块(ESM)
|
||||
import { log, getCSTTimestampString } from "../log/logUtils.js";
|
||||
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
|
||||
import config from "../../appConfig/index.js";
|
||||
|
||||
// 错误类型枚举
|
||||
const ERROR_TYPES = {
|
||||
VALIDATION_ERROR: "VALIDATION_ERROR", // 验证错误
|
||||
BUSINESS_ERROR: "BUSINESS_ERROR", // 业务逻辑错误
|
||||
SYSTEM_ERROR: "SYSTEM_ERROR", // 系统错误
|
||||
NETWORK_ERROR: "NETWORK_ERROR", // 网络错误
|
||||
FILE_ERROR: "FILE_ERROR", // 文件操作错误
|
||||
PROCESS_ERROR: "PROCESS_ERROR", // 进程操作错误
|
||||
PERMISSION_ERROR: "PERMISSION_ERROR", // 权限错误
|
||||
RESOURCE_ERROR: "RESOURCE_ERROR", // 资源错误
|
||||
UNKNOWN_ERROR: "UNKNOWN_ERROR", // 未知错误
|
||||
};
|
||||
|
||||
// HTTP状态码映射
|
||||
const HTTP_STATUS_MAP = {
|
||||
[ERROR_TYPES.VALIDATION_ERROR]: 400,
|
||||
[ERROR_TYPES.BUSINESS_ERROR]: 400,
|
||||
[ERROR_TYPES.PERMISSION_ERROR]: 403,
|
||||
[ERROR_TYPES.RESOURCE_ERROR]: 404,
|
||||
[ERROR_TYPES.SYSTEM_ERROR]: 500,
|
||||
[ERROR_TYPES.NETWORK_ERROR]: 502,
|
||||
[ERROR_TYPES.FILE_ERROR]: 500,
|
||||
[ERROR_TYPES.PROCESS_ERROR]: 500,
|
||||
[ERROR_TYPES.UNKNOWN_ERROR]: 500,
|
||||
};
|
||||
|
||||
// 自定义错误类
|
||||
class AppError extends Error {
|
||||
constructor(
|
||||
message,
|
||||
type = ERROR_TYPES.UNKNOWN_ERROR,
|
||||
statusCode = null,
|
||||
details = null
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
this.type = type;
|
||||
this.statusCode = statusCode || HTTP_STATUS_MAP[type] || 500;
|
||||
this.details = details;
|
||||
this.timestamp = getCSTTimestampString(); // 东八区时间
|
||||
this.isOperational = true; // 标记为可操作的错误
|
||||
|
||||
// 保持堆栈跟踪
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证错误类
|
||||
class ValidationError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, ERROR_TYPES.VALIDATION_ERROR, 400, details);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
// 业务错误类
|
||||
class BusinessError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, ERROR_TYPES.BUSINESS_ERROR, 400, details);
|
||||
this.name = "BusinessError";
|
||||
}
|
||||
}
|
||||
|
||||
// 系统错误类
|
||||
class SystemError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, ERROR_TYPES.SYSTEM_ERROR, 500, details);
|
||||
this.name = "SystemError";
|
||||
}
|
||||
}
|
||||
|
||||
// 资源错误类
|
||||
class ResourceError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, ERROR_TYPES.RESOURCE_ERROR, 404, details);
|
||||
this.name = "ResourceError";
|
||||
}
|
||||
}
|
||||
|
||||
// 权限错误类
|
||||
class PermissionError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, ERROR_TYPES.PERMISSION_ERROR, 403, details);
|
||||
this.name = "PermissionError";
|
||||
}
|
||||
}
|
||||
|
||||
// 文件错误类
|
||||
class FileError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, ERROR_TYPES.FILE_ERROR, 500, details);
|
||||
this.name = "FileError";
|
||||
}
|
||||
}
|
||||
|
||||
// 进程错误类
|
||||
class ProcessError extends AppError {
|
||||
constructor(message, details = null) {
|
||||
super(message, ERROR_TYPES.PROCESS_ERROR, 500, details);
|
||||
this.name = "ProcessError";
|
||||
}
|
||||
}
|
||||
|
||||
// 脱敏错误堆栈信息
|
||||
function sanitizeErrorStack(stack) {
|
||||
return sanitizeSensitivePaths(stack);
|
||||
}
|
||||
|
||||
// 脱敏错误详情信息
|
||||
function sanitizeErrorDetails(details) {
|
||||
if (!details) {
|
||||
return details;
|
||||
}
|
||||
|
||||
// 如果是字符串,直接脱敏
|
||||
if (typeof details === "string") {
|
||||
return sanitizeSensitivePaths(details);
|
||||
}
|
||||
|
||||
// 如果是对象,递归处理
|
||||
if (typeof details === "object") {
|
||||
const sanitized = {};
|
||||
for (const [key, value] of Object.entries(details)) {
|
||||
if (typeof value === "string") {
|
||||
sanitized[key] = sanitizeSensitivePaths(value);
|
||||
} else if (typeof value === "object") {
|
||||
sanitized[key] = sanitizeErrorDetails(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
// 统一错误响应格式
|
||||
function formatErrorResponse(error, requestId = null) {
|
||||
const response = {
|
||||
success: false,
|
||||
code: error.code || (error.details && error.details.code) || "UNKNOWN_ERROR",
|
||||
error: {
|
||||
type: error.type || ERROR_TYPES.UNKNOWN_ERROR,
|
||||
message: error.message || "Unknown error",
|
||||
timestamp:
|
||||
error.timestamp || getCSTTimestampString(), // CST time
|
||||
requestId: requestId,
|
||||
},
|
||||
};
|
||||
|
||||
// 构建错误已经包含用户友好的指导信息,无需额外处理
|
||||
|
||||
// 在开发环境下添加更多调试信息
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
response.error.stack = sanitizeErrorStack(error.stack);
|
||||
response.error.details = sanitizeErrorDetails(error.details);
|
||||
} else if (error.details) {
|
||||
// 生产环境下只添加必要的详细信息
|
||||
response.error.details = sanitizeErrorDetails(error.details);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 错误处理中间件
|
||||
function errorHandler(err, req, res, next) {
|
||||
let error = err;
|
||||
const requestId = req.requestId || "unknown";
|
||||
const projectId = req.body?.projectId || req.query?.projectId || "default";
|
||||
|
||||
// 如果不是AppError实例,转换为AppError
|
||||
if (!(error instanceof AppError)) {
|
||||
// 处理特定类型的错误
|
||||
if (error.name === "ValidationError") {
|
||||
error = new ValidationError(error.message, error.details);
|
||||
} else if (error.name === "MulterError") {
|
||||
if (error.code === "LIMIT_FILE_SIZE") {
|
||||
const maxMb =
|
||||
Math.round((config.UPLOAD_MAX_FILE_SIZE_BYTES / 1024 / 1024) * 10) /
|
||||
10;
|
||||
error = new ValidationError("File size exceeds limit", {
|
||||
maxSize: `${maxMb}MB`,
|
||||
});
|
||||
} else if (error.code === "LIMIT_FILE_COUNT") {
|
||||
error = new ValidationError("File count exceeds limit");
|
||||
} else if (error.code === "LIMIT_UNEXPECTED_FILE") {
|
||||
error = new ValidationError(
|
||||
"File field name error, please use 'file' field to upload file",
|
||||
{
|
||||
expectedField: "file",
|
||||
receivedField: error.field,
|
||||
}
|
||||
);
|
||||
} else if (error.code === "LIMIT_PART_COUNT") {
|
||||
error = new ValidationError("Form field count exceeds limit");
|
||||
} else if (error.code === "LIMIT_FIELD_KEY") {
|
||||
error = new ValidationError("Field name length exceeds limit");
|
||||
} else if (error.code === "LIMIT_FIELD_VALUE") {
|
||||
error = new ValidationError("Field value length exceeds limit");
|
||||
} else if (error.code === "LIMIT_FIELD_COUNT") {
|
||||
error = new ValidationError("Form field count exceeds limit");
|
||||
} else {
|
||||
error = new ValidationError("File upload error: " + error.message, {
|
||||
code: error.code,
|
||||
field: error.field,
|
||||
});
|
||||
}
|
||||
} else if (error.code === "ENOENT") {
|
||||
error = new ResourceError("File or directory not found");
|
||||
} else if (error.code === "EACCES") {
|
||||
error = new PermissionError("Permission denied");
|
||||
} else if (error.code === "ECONNREFUSED") {
|
||||
error = new SystemError("Connection refused", { code: error.code });
|
||||
} else {
|
||||
// 未知错误转换为系统错误
|
||||
error = new SystemError(error.message || "Internal server error", {
|
||||
originalError: error.name,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 记录错误日志
|
||||
const logLevel = error.statusCode >= 500 ? "ERROR" : "WARN";
|
||||
log(projectId, logLevel, `Error handling: ${error.message}`, {
|
||||
requestId,
|
||||
errorType: error.type,
|
||||
statusCode: error.statusCode,
|
||||
url: req.originalUrl,
|
||||
method: req.method,
|
||||
userAgent: req.headers["user-agent"],
|
||||
ip: req.ip,
|
||||
stack: error.stack,
|
||||
details: error.details,
|
||||
});
|
||||
|
||||
// 发送错误响应
|
||||
const errorResponse = formatErrorResponse(error, requestId);
|
||||
res.status(error.statusCode).json(errorResponse);
|
||||
}
|
||||
|
||||
// 404处理中间件
|
||||
function notFoundHandler(req, res, next) {
|
||||
const error = new ResourceError(`Path not found: ${req.originalUrl}`);
|
||||
const requestId = req.requestId || "unknown";
|
||||
|
||||
log("default", "WARN", `404 error: ${req.originalUrl}`, {
|
||||
requestId,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userAgent: req.headers["user-agent"],
|
||||
});
|
||||
|
||||
const errorResponse = formatErrorResponse(error, requestId);
|
||||
res.status(404).json(errorResponse);
|
||||
}
|
||||
|
||||
// 异步错误包装器
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
// 错误分类函数
|
||||
function classifyError(error) {
|
||||
if (error instanceof AppError) {
|
||||
return error.type;
|
||||
}
|
||||
|
||||
// 根据错误特征分类
|
||||
if (error.code === "ENOENT") return ERROR_TYPES.RESOURCE_ERROR;
|
||||
if (error.code === "EACCES") return ERROR_TYPES.PERMISSION_ERROR;
|
||||
if (error.code === "ECONNREFUSED") return ERROR_TYPES.NETWORK_ERROR;
|
||||
if (error.name === "ValidationError") return ERROR_TYPES.VALIDATION_ERROR;
|
||||
|
||||
return ERROR_TYPES.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
// 错误统计
|
||||
const errorStats = {
|
||||
counts: new Map(),
|
||||
lastReset: Date.now(),
|
||||
};
|
||||
|
||||
function recordError(error) {
|
||||
const type = classifyError(error);
|
||||
const count = errorStats.counts.get(type) || 0;
|
||||
errorStats.counts.set(type, count + 1);
|
||||
}
|
||||
|
||||
function getErrorStats() {
|
||||
return {
|
||||
counts: Object.fromEntries(errorStats.counts),
|
||||
lastReset: errorStats.lastReset,
|
||||
totalErrors: Array.from(errorStats.counts.values()).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function resetErrorStats() {
|
||||
errorStats.counts.clear();
|
||||
errorStats.lastReset = Date.now();
|
||||
}
|
||||
|
||||
export {
|
||||
ERROR_TYPES,
|
||||
AppError,
|
||||
ValidationError,
|
||||
BusinessError,
|
||||
SystemError,
|
||||
ResourceError,
|
||||
PermissionError,
|
||||
FileError,
|
||||
ProcessError,
|
||||
formatErrorResponse,
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
asyncHandler,
|
||||
classifyError,
|
||||
sanitizeSensitivePaths,
|
||||
sanitizeErrorStack,
|
||||
sanitizeErrorDetails,
|
||||
recordError,
|
||||
getErrorStats,
|
||||
resetErrorStats,
|
||||
};
|
||||
199
qiming-file-server/src/utils/log/getDevLogUtils.js
Normal file
199
qiming-file-server/src/utils/log/getDevLogUtils.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getLogDir, getCSTDateString } from "./logUtils.js";
|
||||
import logCacheManager from "./logCacheManager.js";
|
||||
import { SystemError } from "../error/errorHandler.js";
|
||||
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
|
||||
|
||||
/**
|
||||
* 从日志文件读取内容(支持缓存)
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} logFileName 日志文件名
|
||||
* @param {number} arrayStartIndex 数组起始索引(从0开始)
|
||||
* @param {number} startIndex 用户视角起始行号(从1开始)
|
||||
* @returns {Object} 日志读取结果
|
||||
*/
|
||||
function readLogFileWithCache(projectId, logFileName, arrayStartIndex, startIndex) {
|
||||
try {
|
||||
let lines;
|
||||
let cacheHit = false;
|
||||
let fileTooLarge = false;
|
||||
const logDir = getLogDir(String(projectId));
|
||||
const logFilePath = path.join(logDir, logFileName);
|
||||
|
||||
// 尝试从缓存获取
|
||||
if (logCacheManager.isEnabled()) {
|
||||
const cached = logCacheManager.get(String(projectId), logFilePath);
|
||||
if (cached) {
|
||||
lines = cached.lines;
|
||||
cacheHit = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果缓存未命中,从文件读取
|
||||
if (!lines) {
|
||||
// 检查文件大小
|
||||
const stats = fs.statSync(logFilePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
const logContent = fs.readFileSync(logFilePath, "utf8");
|
||||
lines = logContent.split("\n");
|
||||
|
||||
// 更新缓存(如果文件不超过大小限制)
|
||||
if (logCacheManager.isEnabled()) {
|
||||
const cached = logCacheManager.set(String(projectId), logFilePath, logContent);
|
||||
if (!cached && fileSize > logCacheManager.maxFileSize) {
|
||||
fileTooLarge = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从指定行号开始截取到文件结束
|
||||
const relevantLines = lines.slice(arrayStartIndex);
|
||||
|
||||
// 构建返回的日志数据,包含行号和内容
|
||||
const logs = relevantLines.map((content, index) => ({
|
||||
line: startIndex + index, // 行号从1开始
|
||||
content: sanitizeSensitivePaths(content), // 对日志内容进行脱敏处理
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: cacheHit ? "Get log successfully (cache)" : fileTooLarge ? "Get log successfully (file too large, not cached)" : "Get log successfully",
|
||||
logs: logs,
|
||||
totalLines: lines.length,
|
||||
startIndex: startIndex,
|
||||
cacheHit: cacheHit, // 标记是否命中缓存
|
||||
fileTooLarge: fileTooLarge, // 标记文件是否过大
|
||||
logFileName: logFileName, // 日志文件名
|
||||
};
|
||||
} catch (error) {
|
||||
throw new SystemError("Failed to read log file", {
|
||||
projectId,
|
||||
logFileName: logFileName,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取开发服务器日志
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} startIndex 起始行号(从1开始,用户视角)
|
||||
* @returns {Object} 日志查询结果
|
||||
*/
|
||||
async function getDevLogFromMainLog(projectId, startIndex = 1) {
|
||||
// 将用户行号(从1开始)转换为数组索引(从0开始)
|
||||
const arrayStartIndex = Math.max(0, startIndex - 1);
|
||||
|
||||
// 获取项目日志目录
|
||||
const logDir = getLogDir(String(projectId));
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
return {
|
||||
success: true,
|
||||
message: "Log directory does not exist",
|
||||
logs: [],
|
||||
totalLines: 0,
|
||||
startLine: startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// 查找当日主日志文件(仅匹配 dev-YYYY-MM-DD.log)
|
||||
const files = fs.readdirSync(logDir);
|
||||
const today = getCSTDateString(); // 格式: YYYY-MM-DD (东八区)
|
||||
const targetFileName = `dev-${today}.log`;
|
||||
const mainFiles = files.filter((file) => file === targetFileName);
|
||||
|
||||
if (mainFiles.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: "Main log file not found",
|
||||
logs: [],
|
||||
totalLines: 0,
|
||||
startLine: startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// 按文件名排序,取最新的文件(文件名包含日期)
|
||||
mainFiles.sort((a, b) => {
|
||||
// 提取文件名中的日期部分进行比较
|
||||
const dateA = a.match(/dev-(.+)\.log/)?.[1];
|
||||
const dateB = b.match(/dev-(.+)\.log/)?.[1];
|
||||
if (!dateA || !dateB) return 0;
|
||||
return dateB.localeCompare(dateA); // 降序排列,最新的在前
|
||||
});
|
||||
|
||||
const latestMainFile = mainFiles[0];
|
||||
|
||||
return readLogFileWithCache(projectId, latestMainFile, arrayStartIndex, startIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取开发服务器日志
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} startIndex 起始行号(从1开始,用户视角)
|
||||
* @returns {Object} 日志查询结果
|
||||
*/
|
||||
async function getDevLogFromTempLog(projectId, startIndex = 1) {
|
||||
// 将用户行号(从1开始)转换为数组索引(从0开始)
|
||||
const arrayStartIndex = Math.max(0, startIndex - 1);
|
||||
|
||||
// 获取项目日志目录
|
||||
const logDir = getLogDir(String(projectId));
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
return {
|
||||
success: true,
|
||||
message: "Log directory does not exist",
|
||||
logs: [],
|
||||
totalLines: 0,
|
||||
startLine: startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// 查找所有临时日志文件
|
||||
const files = fs.readdirSync(logDir);
|
||||
const tempFiles = files.filter(
|
||||
(file) => file.startsWith("dev-temp-") && file.endsWith(".log")
|
||||
);
|
||||
|
||||
if (tempFiles.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: "Temporary log file not found",
|
||||
logs: [],
|
||||
totalLines: 0,
|
||||
startLine: startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// 按文件名排序,取最新的文件(文件名包含时间戳)
|
||||
tempFiles.sort((a, b) => {
|
||||
// 提取文件名中的时间戳进行比较
|
||||
const timestampA = a.match(/dev-temp-(\d+)\.log/)?.[1];
|
||||
const timestampB = b.match(/dev-temp-(\d+)\.log/)?.[1];
|
||||
return Number(timestampB) - Number(timestampA); // 降序排列,最新的在前
|
||||
});
|
||||
|
||||
const latestTempFile = tempFiles[0];
|
||||
|
||||
return readLogFileWithCache(projectId, latestTempFile, arrayStartIndex, startIndex);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取开发服务器日志
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} startIndex 起始行号(从1开始,用户视角)
|
||||
* @returns {Object} 日志查询结果
|
||||
*/
|
||||
async function getDevLog(projectId, startIndex = 1, logType) {
|
||||
if (logType === "main") {
|
||||
return await getDevLogFromMainLog(projectId, startIndex);
|
||||
} else {
|
||||
return await getDevLogFromTempLog(projectId, startIndex);
|
||||
}
|
||||
}
|
||||
|
||||
export { getDevLog, getDevLogFromMainLog, getDevLogFromTempLog };
|
||||
256
qiming-file-server/src/utils/log/logCacheManager.js
Normal file
256
qiming-file-server/src/utils/log/logCacheManager.js
Normal file
@@ -0,0 +1,256 @@
|
||||
// 日志缓存管理器(ESM)
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import config from "../../appConfig/index.js";
|
||||
|
||||
/**
|
||||
* 日志缓存管理器
|
||||
* 功能:
|
||||
* 1. 缓存整个日志文件内容
|
||||
* 2. 每个子项目只缓存一个文件(dev日志)
|
||||
* 3. 支持缓存过期时间
|
||||
* 4. 提供缓存的增删改查接口
|
||||
*/
|
||||
class LogCacheManager {
|
||||
constructor() {
|
||||
// 缓存存储结构:{ projectId: { lines: Array, totalLines: number, timestamp: number, filePath: string } }
|
||||
// 只保存 lines 数组,不保存 content 字符串,节省内存
|
||||
this.cache = new Map();
|
||||
|
||||
// 从 config 对象读取配置
|
||||
this.enabled = config.LOG_CACHE_ENABLED;
|
||||
this.cacheDuration = config.LOG_CACHE_DURATION;
|
||||
this.maxCacheEntries = config.LOG_CACHE_MAX_ENTRIES;
|
||||
this.maxFileSize = config.LOG_CACHE_MAX_FILE_SIZE;
|
||||
|
||||
// 定期清理过期缓存(每分钟检查一次)
|
||||
if (this.enabled) {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this._cleanupExpiredCache();
|
||||
}, 60000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断缓存是否启用
|
||||
*/
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {Object|null} 缓存数据或null
|
||||
*/
|
||||
get(projectId, filePath) {
|
||||
if (!this.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = String(projectId);
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查文件路径是否匹配
|
||||
// 如果不匹配(比如跨天了,从 dev-2025-11-20.log 变成 dev-2025-11-21.log)
|
||||
// 删除旧缓存,释放内存
|
||||
if (cached.filePath !== filePath) {
|
||||
this.cache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 缓存命中时续期(更新时间戳)
|
||||
// 即使过期了,只要被访问就续期,不删除
|
||||
// 只有定期清理任务才会删除长期未访问的缓存
|
||||
const now = Date.now();
|
||||
cached.timestamp = now;
|
||||
|
||||
return {
|
||||
lines: cached.lines,
|
||||
totalLines: cached.totalLines,
|
||||
timestamp: cached.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} filePath 文件路径
|
||||
* @param {string} content 文件内容
|
||||
* @returns {boolean} 是否成功缓存
|
||||
*/
|
||||
set(projectId, filePath, content) {
|
||||
if (!this.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查文件大小限制(字节)
|
||||
const contentSize = Buffer.byteLength(content, 'utf8');
|
||||
if (contentSize > this.maxFileSize) {
|
||||
console.log(`Log file too large, not cached: ${projectId}, size: ${(contentSize / 1024 / 1024).toFixed(2)}MB, limit: ${(this.maxFileSize / 1024 / 1024).toFixed(2)}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const cacheKey = String(projectId);
|
||||
|
||||
// 检查缓存数量限制
|
||||
if (!this.cache.has(cacheKey) && this.cache.size >= this.maxCacheEntries) {
|
||||
// 删除最旧的缓存
|
||||
const oldestKey = this._findOldestCacheKey();
|
||||
if (oldestKey) {
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 分割行并缓存(只保存 lines,不保存 content,节省内存)
|
||||
const lines = content.split("\n");
|
||||
|
||||
this.cache.set(cacheKey, {
|
||||
lines,
|
||||
totalLines: lines.length,
|
||||
timestamp: Date.now(),
|
||||
filePath,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
* @param {string} projectId 项目ID
|
||||
*/
|
||||
delete(projectId) {
|
||||
const cacheKey = String(projectId);
|
||||
this.cache.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有缓存
|
||||
*/
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
_cleanupExpiredCache() {
|
||||
const now = Date.now();
|
||||
const expiredKeys = [];
|
||||
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
if (now - value.timestamp > this.cacheDuration) {
|
||||
expiredKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
expiredKeys.forEach((key) => {
|
||||
this.cache.delete(key);
|
||||
});
|
||||
|
||||
if (expiredKeys.length > 0) {
|
||||
console.log(`Cleaned ${expiredKeys.length} expired log caches`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 找到最旧的缓存key
|
||||
*/
|
||||
_findOldestCacheKey() {
|
||||
let oldestKey = null;
|
||||
let oldestTimestamp = Infinity;
|
||||
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
if (value.timestamp < oldestTimestamp) {
|
||||
oldestTimestamp = value.timestamp;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
return oldestKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
getStats() {
|
||||
// 计算总缓存大小和最大文件大小
|
||||
let totalCacheSize = 0;
|
||||
let maxFileSizeInCache = 0;
|
||||
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
if (value.lines && Array.isArray(value.lines)) {
|
||||
let currentFileSize = 0;
|
||||
// 计算每个缓存项的大小(所有行的字节数总和)
|
||||
for (const line of value.lines) {
|
||||
const lineSize = Buffer.byteLength(line, 'utf8');
|
||||
currentFileSize += lineSize;
|
||||
totalCacheSize += lineSize;
|
||||
}
|
||||
// 更新最大文件大小
|
||||
if (currentFileSize > maxFileSizeInCache) {
|
||||
maxFileSizeInCache = currentFileSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
cacheSize: this.cache.size,
|
||||
maxCacheEntries: this.maxCacheEntries,
|
||||
cacheDuration: this.cacheDuration,
|
||||
maxFileSizeMB: (maxFileSizeInCache / 1024 / 1024).toFixed(2),
|
||||
totalCacheSizeMB: (totalCacheSize / 1024 / 1024).toFixed(2),
|
||||
NODE_ENV: config.NODE_ENV,
|
||||
LOG_CACHE_ENABLED: config.LOG_CACHE_ENABLED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器(清理定时器和缓存)
|
||||
* 用于服务优雅关闭时
|
||||
*/
|
||||
destroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 使用延迟初始化的单例模式
|
||||
// 在第一次访问时才创建实例,此时环境变量已经完全加载
|
||||
let instance = null;
|
||||
|
||||
function getLogCacheManager() {
|
||||
if (!instance) {
|
||||
instance = new LogCacheManager();
|
||||
if (instance.enabled) {
|
||||
console.log("Log cache enabled");
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// 创建代理对象,自动转发所有方法调用到实际实例
|
||||
const logCacheManager = new Proxy({}, {
|
||||
get(target, prop) {
|
||||
const manager = getLogCacheManager();
|
||||
const value = manager[prop];
|
||||
// 如果是方法,绑定 this 到实际实例
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(manager);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
});
|
||||
|
||||
export default logCacheManager;
|
||||
|
||||
324
qiming-file-server/src/utils/log/logUtils.js
Normal file
324
qiming-file-server/src/utils/log/logUtils.js
Normal file
@@ -0,0 +1,324 @@
|
||||
// 日志工具模块(ESM)
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import config from "../../appConfig/index.js";
|
||||
|
||||
function getLogDir(projectId) {
|
||||
const base = config.LOG_BASE_DIR;
|
||||
const idStr = String(projectId);
|
||||
|
||||
// computer 相关日志:使用 COMPUTER_LOG_DIR/<userId>/<cId>
|
||||
// 约定 projectId 形如: computer:<userId>:<cId>
|
||||
if (idStr.startsWith("computer:")) {
|
||||
const [, userId, cId] = idStr.split(":");
|
||||
const computerBase = config.COMPUTER_LOG_DIR || base;
|
||||
return path.join(
|
||||
computerBase,
|
||||
String(userId || "unknown"),
|
||||
String(cId || "unknown")
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(base, idStr);
|
||||
}
|
||||
|
||||
// 获取东八区(UTC+8)时间
|
||||
// 返回格式: YYYY-MM-DD HH:mm:ss
|
||||
function getCSTDateTimeString() {
|
||||
const now = new Date();
|
||||
// 使用 Intl.DateTimeFormat 直接格式化东八区时间,更可靠
|
||||
const formatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(now);
|
||||
const year = parts.find(p => p.type === "year").value;
|
||||
const month = parts.find(p => p.type === "month").value;
|
||||
const day = parts.find(p => p.type === "day").value;
|
||||
const hours = parts.find(p => p.type === "hour").value;
|
||||
const minutes = parts.find(p => p.type === "minute").value;
|
||||
const seconds = parts.find(p => p.type === "second").value;
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// 获取东八区日期字符串(用于日志文件名)
|
||||
// 返回格式: YYYY-MM-DD
|
||||
function getCSTDateString() {
|
||||
return getCSTDateTimeString().split(" ")[0];
|
||||
}
|
||||
|
||||
// 获取格式化的东八区时间戳(用于日志显示)
|
||||
// 返回格式: YYYY/MM/DD HH:mm:ss
|
||||
function getCSTTimestampString() {
|
||||
return getCSTDateTimeString().replace(/-/g, "/");
|
||||
}
|
||||
|
||||
// 生成唯一请求ID
|
||||
function generateRequestId() {
|
||||
return Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
// 获取客户端IP地址
|
||||
function getClientIP(req) {
|
||||
return (
|
||||
req.ip ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
(req.connection.socket ? req.connection.socket.remoteAddress : null) ||
|
||||
req.headers["x-forwarded-for"]?.split(",")[0] ||
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
// 格式化日志输出
|
||||
function formatLogMessage(level, message, meta = {}) {
|
||||
const timestamp = getCSTTimestampString();
|
||||
const metaStr =
|
||||
Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : "";
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}${metaStr}`;
|
||||
}
|
||||
|
||||
// 日志级别枚举
|
||||
const LOG_LEVELS = {
|
||||
ERROR: 0,
|
||||
WARN: 1,
|
||||
INFO: 2,
|
||||
DEBUG: 3,
|
||||
};
|
||||
|
||||
// 当前日志级别
|
||||
const CURRENT_LOG_LEVEL = (function () {
|
||||
const level = (config.LOG_LEVEL || "info").toLowerCase();
|
||||
switch (level) {
|
||||
case "error":
|
||||
return LOG_LEVELS.ERROR;
|
||||
case "warn":
|
||||
return LOG_LEVELS.WARN;
|
||||
case "debug":
|
||||
return LOG_LEVELS.DEBUG;
|
||||
case "info":
|
||||
default:
|
||||
return LOG_LEVELS.INFO;
|
||||
}
|
||||
})();
|
||||
|
||||
// 获取API日志文件路径
|
||||
function getLogFilePath(projectId, prefix) {
|
||||
const logDir = getLogDir(String(projectId));
|
||||
const today = getCSTDateString(); // 格式: YYYY-MM-DD (东八区)
|
||||
|
||||
// 确保日志目录存在
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
return path.join(logDir, `${prefix}-${today}.log`);
|
||||
}
|
||||
|
||||
// 基于项目名缓存 WriteStream,按天切换文件时自动轮转
|
||||
const projectIdToLogStream = new Map(); // key: projectId, value: { stream, filePath }
|
||||
|
||||
function getLogWriteStream(projectId, prefix) {
|
||||
const stringProjectId = String(projectId);
|
||||
const targetFilePath = getLogFilePath(stringProjectId, prefix);
|
||||
const cached = projectIdToLogStream.get(stringProjectId);
|
||||
|
||||
if (
|
||||
cached &&
|
||||
cached.filePath === targetFilePath &&
|
||||
!cached.stream.destroyed
|
||||
) {
|
||||
return cached.stream;
|
||||
}
|
||||
|
||||
// 如果已有旧流且文件路径变更或已销毁,关闭旧流
|
||||
if (cached && cached.stream && !cached.stream.destroyed) {
|
||||
try {
|
||||
cached.stream.end();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const newStream = fs.createWriteStream(targetFilePath, {
|
||||
flags: "a",
|
||||
encoding: "utf8",
|
||||
});
|
||||
newStream.on("error", (err) => {
|
||||
console.error("API日志写入流错误:", err && err.message ? err.message : err);
|
||||
});
|
||||
newStream.on("close", () => {
|
||||
const current = projectIdToLogStream.get(stringProjectId);
|
||||
if (current && current.stream === newStream) {
|
||||
projectIdToLogStream.delete(stringProjectId);
|
||||
}
|
||||
});
|
||||
|
||||
projectIdToLogStream.set(stringProjectId, {
|
||||
stream: newStream,
|
||||
filePath: targetFilePath,
|
||||
});
|
||||
return newStream;
|
||||
}
|
||||
|
||||
// 写入日志到文件
|
||||
function writeToLogFile(projectId, prefix, level, message, meta = {}) {
|
||||
try {
|
||||
const logMessage = formatLogMessage(level, message, meta) + "\n";
|
||||
const stream = getLogWriteStream(String(projectId), prefix);
|
||||
const ok = stream.write(logMessage);
|
||||
if (!ok) {
|
||||
// 简单回压提示
|
||||
console.warn(`日志写入回压: ${projectId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("写入日志文件失败:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 日志输出函数,默认输出到api_${date}.log
|
||||
function log(projectId, level, message, meta = {}) {
|
||||
const upper = String(level).toUpperCase();
|
||||
if (LOG_LEVELS[upper] <= CURRENT_LOG_LEVEL) {
|
||||
if (config.LOG_CONSOLE_ENABLED) {
|
||||
console.log(formatLogMessage(upper, message, meta));
|
||||
}
|
||||
const finalProjectId = String(projectId || "default");
|
||||
writeToLogFile(
|
||||
finalProjectId,
|
||||
config.LOG_PREFIX_API || "api",
|
||||
upper,
|
||||
message,
|
||||
meta
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//build日志输出
|
||||
function logBuild(projectId, level, message, meta = {}) {
|
||||
const upper = String(level).toUpperCase();
|
||||
if (LOG_LEVELS[upper] <= CURRENT_LOG_LEVEL) {
|
||||
if (config.LOG_CONSOLE_ENABLED) {
|
||||
console.log(formatLogMessage(upper, message, meta));
|
||||
}
|
||||
const finalProjectId = String(projectId || "default");
|
||||
writeToLogFile(
|
||||
finalProjectId,
|
||||
config.LOG_PREFIX_BUILD || "build",
|
||||
upper,
|
||||
message,
|
||||
meta
|
||||
);
|
||||
writeToLogFile(
|
||||
finalProjectId,
|
||||
config.LOG_PREFIX_API || "api",
|
||||
upper,
|
||||
message,
|
||||
meta
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 日志中间件
|
||||
function logger(req, res, next) {
|
||||
const startTime = Date.now();
|
||||
const requestId = generateRequestId();
|
||||
const clientIP = getClientIP(req);
|
||||
|
||||
// 为请求添加唯一ID
|
||||
req.requestId = requestId;
|
||||
|
||||
// 记录请求开始
|
||||
const requestLogMessage = `${req.method}-[${req.requestId}] -Request ${
|
||||
req.originalUrl || req.url
|
||||
} -`;
|
||||
const requestLogMeta = {
|
||||
requestId,
|
||||
clientIP,
|
||||
userAgent: req.headers["user-agent"] || "unknown",
|
||||
contentType: req.headers["content-type"] || "unknown",
|
||||
};
|
||||
|
||||
// 根据请求方法分别处理 projectId
|
||||
let projectId = "default";
|
||||
if (req.method === "GET") {
|
||||
projectId = req.query.projectId || "default";
|
||||
} else if (
|
||||
req.method === "POST" ||
|
||||
req.method === "PUT" ||
|
||||
req.method === "PATCH"
|
||||
) {
|
||||
projectId = req.body && req.body.projectId ? req.body.projectId : "default";
|
||||
}
|
||||
|
||||
log(projectId, "INFO", requestLogMessage, requestLogMeta);
|
||||
|
||||
// 监听响应完成事件
|
||||
res.on("finish", () => {
|
||||
const endTime = Date.now();
|
||||
const responseTime = endTime - startTime;
|
||||
|
||||
// 根据状态码确定日志级别
|
||||
const level =
|
||||
res.statusCode >= 400 ? "ERROR" : res.statusCode >= 300 ? "WARN" : "INFO";
|
||||
|
||||
const responseLogMessage = `${req.method}-[${req.requestId}] -Response(${
|
||||
res.statusCode
|
||||
}) ${req.originalUrl || req.url} - `;
|
||||
const responseLogMeta = {
|
||||
requestId,
|
||||
clientIP,
|
||||
responseTime: `${responseTime}ms`,
|
||||
contentLength: res.get("content-length") || "unknown",
|
||||
statusCode: res.statusCode,
|
||||
};
|
||||
|
||||
log(projectId, level, responseLogMessage, responseLogMeta);
|
||||
});
|
||||
|
||||
// 监听响应关闭事件(处理异常情况)
|
||||
res.on("close", () => {
|
||||
if (!res.finished) {
|
||||
const endTime = Date.now();
|
||||
const responseTime = endTime - startTime;
|
||||
|
||||
const closeLogMessage = `${req.method}-[${req.requestId}] -Response(${
|
||||
res.statusCode
|
||||
}) ${req.originalUrl || req.url} - Connection closed`;
|
||||
const closeLogMeta = {
|
||||
requestId,
|
||||
clientIP,
|
||||
responseTime: `${responseTime}ms`,
|
||||
contentLength: res.get("content-length") || "unknown",
|
||||
statusCode: res.statusCode,
|
||||
};
|
||||
|
||||
log(projectId, "ERROR", closeLogMessage, closeLogMeta);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export {
|
||||
log,
|
||||
logBuild,
|
||||
logger,
|
||||
getLogDir,
|
||||
generateRequestId,
|
||||
getClientIP,
|
||||
formatLogMessage,
|
||||
getLogFilePath,
|
||||
writeToLogFile,
|
||||
getCSTDateTimeString,
|
||||
getCSTDateString,
|
||||
getCSTTimestampString,
|
||||
LOG_LEVELS,
|
||||
CURRENT_LOG_LEVEL,
|
||||
};
|
||||
388
qiming-file-server/src/utils/project/backupUtils.js
Normal file
388
qiming-file-server/src/utils/project/backupUtils.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import archiver from "archiver";
|
||||
import yauzl from "yauzl";
|
||||
import config from "../../appConfig/index.js";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { FileError } from "../error/errorHandler.js";
|
||||
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
|
||||
|
||||
/**
|
||||
* 按排除规则将目录复制到目标目录
|
||||
* @param {string} srcDir 源目录
|
||||
* @param {string} destDir 目标目录
|
||||
*/
|
||||
async function copyDirectoryFiltered(srcDir, destDir) {
|
||||
const excludeDirNames = new Set(config.TRAVERSE_EXCLUDE_DIRS || []);
|
||||
const excludeFileNames = new Set(config.BACKUP_TRAVERSE_EXCLUDE_FILES || []);
|
||||
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
const destPath = path.join(destDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (excludeDirNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.mkdir(destPath, { recursive: true });
|
||||
await copyDirectoryFiltered(srcPath, destPath);
|
||||
} else if (entry.isFile()) {
|
||||
if (excludeFileNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将项目目录备份为zip文件(按配置排除目录)
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目源路径
|
||||
* @param {string} outZipPath 输出zip文件路径
|
||||
* @returns {Promise<string>} 生成的zip文件路径
|
||||
*/
|
||||
async function backupProjectToZip(projectId, projectPath, outZipPath) {
|
||||
const startTime = Date.now();
|
||||
const tempBase = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||||
if (!fs.existsSync(tempBase)) {
|
||||
fs.mkdirSync(tempBase, { recursive: true });
|
||||
}
|
||||
const tempDir = path.join(tempBase, `backup_temp_${Date.now()}`);
|
||||
await fs.promises.mkdir(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
log(projectId, "DEBUG", "Start copying project files to temporary directory", { projectPath, tempDir });
|
||||
await copyDirectoryFiltered(projectPath, tempDir);
|
||||
log(projectId, "DEBUG", "Project files copied, start compressing", { tempDir, outZipPath });
|
||||
|
||||
// 使用 archiver 进行压缩,避免依赖系统 zip
|
||||
await fs.promises.mkdir(path.dirname(outZipPath), { recursive: true });
|
||||
await new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(outZipPath);
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
|
||||
output.on("close", () => resolve());
|
||||
output.on("error", (err) =>
|
||||
reject(
|
||||
new FileError("Backup zip compression failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
outZipPath,
|
||||
originalError: err && err.message,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
archive.on("warning", (err) => {
|
||||
if (err && err.code === "ENOENT") {
|
||||
// 记录告警但不失败
|
||||
log(projectId, "WARN", `Compression warning: ${err.message}`, {
|
||||
projectId,
|
||||
outZipPath,
|
||||
});
|
||||
} else if (err) {
|
||||
reject(
|
||||
new FileError("Backup zip compression failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
outZipPath,
|
||||
originalError: err && err.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
archive.on("error", (err) =>
|
||||
reject(
|
||||
new FileError("Backup zip compression failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
outZipPath,
|
||||
originalError: err && err.message,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(tempDir + "/", false);
|
||||
archive.finalize();
|
||||
});
|
||||
|
||||
log(projectId, "INFO", `Project backup completed: ${outZipPath}`, {
|
||||
projectId,
|
||||
outZipPath,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return outZipPath;
|
||||
} finally {
|
||||
// 清理临时目录
|
||||
try {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从zip备份回滚项目目录
|
||||
* 策略:
|
||||
* - 清空现有项目目录(保留根目录本身和被排除的目录/文件)
|
||||
* - 将zip内容解压回项目目录
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {string} zipPath zip备份文件路径
|
||||
*/
|
||||
async function restoreProjectFromZip(projectId, projectPath, zipPath) {
|
||||
const startTime = Date.now();
|
||||
// 获取排除列表(与备份时使用的规则一致)
|
||||
const excludeDirNames = new Set(config.TRAVERSE_EXCLUDE_DIRS || []);
|
||||
const excludeFileNames = new Set(config.BACKUP_TRAVERSE_EXCLUDE_FILES || []);
|
||||
|
||||
// 清空目录内容,但保留被排除的目录和文件
|
||||
log(projectId, "DEBUG", "Start clearing project directory (keep excluded items)", { projectPath });
|
||||
const entries = await fs.promises.readdir(projectPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(projectPath, entry.name);
|
||||
|
||||
// 跳过被排除的目录
|
||||
if (entry.isDirectory() && excludeDirNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过被排除的文件
|
||||
if (entry.isFile() && excludeFileNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 删除不在排除列表中的文件和目录
|
||||
try {
|
||||
await fs.promises.rm(fullPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
// 忽略个别删除失败,继续
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 yauzl 解压 zip 到项目目录,避免依赖系统 unzip
|
||||
log(projectId, "DEBUG", "Start restoring project files from zip", { zipPath, projectPath });
|
||||
await new Promise((resolve, reject) => {
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipFile) => {
|
||||
if (openErr || !zipFile) {
|
||||
return reject(
|
||||
new FileError("Rollback unzip failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
zipPath,
|
||||
originalError: openErr && openErr.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedProjectPath = path.resolve(projectPath);
|
||||
|
||||
zipFile.readEntry();
|
||||
zipFile.on("entry", (entry) => {
|
||||
// 安全处理路径,防止路径穿越
|
||||
const normalized = path
|
||||
.normalize(entry.fileName)
|
||||
.replace(/^([/\\]+)+/, "");
|
||||
const targetPath = path.join(projectPath, normalized);
|
||||
const resolvedTargetPath = path.resolve(targetPath);
|
||||
if (
|
||||
!resolvedTargetPath.startsWith(resolvedProjectPath + path.sep) &&
|
||||
resolvedTargetPath !== resolvedProjectPath
|
||||
) {
|
||||
zipFile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (/\\$|\/$/.test(entry.fileName) || entry.fileName.endsWith("/")) {
|
||||
// 目录条目
|
||||
fs.promises
|
||||
.mkdir(resolvedTargetPath, { recursive: true })
|
||||
.then(() => zipFile.readEntry())
|
||||
.catch((e) => {
|
||||
zipFile.close();
|
||||
reject(
|
||||
new FileError("Rollback unzip failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
zipPath,
|
||||
originalError:
|
||||
e && e.message
|
||||
? sanitizeSensitivePaths(e.message)
|
||||
: e && e.message,
|
||||
})
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件条目
|
||||
zipFile.openReadStream(entry, (streamErr, readStream) => {
|
||||
if (streamErr || !readStream) {
|
||||
zipFile.close();
|
||||
return reject(
|
||||
new FileError("Rollback unzip failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
zipPath,
|
||||
originalError: streamErr && streamErr.message,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fs.promises
|
||||
.mkdir(path.dirname(resolvedTargetPath), { recursive: true })
|
||||
.then(() => {
|
||||
const writeStream = fs.createWriteStream(resolvedTargetPath);
|
||||
readStream.pipe(writeStream);
|
||||
writeStream.on("close", () => zipFile.readEntry());
|
||||
writeStream.on("error", (e) => {
|
||||
zipFile.close();
|
||||
reject(
|
||||
new FileError("Rollback unzip failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
zipPath,
|
||||
originalError:
|
||||
e && e.message
|
||||
? sanitizeSensitivePaths(e.message)
|
||||
: e && e.message,
|
||||
})
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
zipFile.close();
|
||||
reject(
|
||||
new FileError("Rollback unzip failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
zipPath,
|
||||
originalError:
|
||||
e && e.message
|
||||
? sanitizeSensitivePaths(e.message)
|
||||
: e && e.message,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
zipFile.on("end", () => {
|
||||
zipFile.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
zipFile.on("error", (e) => {
|
||||
zipFile.close();
|
||||
reject(
|
||||
new FileError("Rollback unzip failed", {
|
||||
projectId,
|
||||
projectPath,
|
||||
zipPath,
|
||||
originalError:
|
||||
e && e.message
|
||||
? sanitizeSensitivePaths(e.message)
|
||||
: e && e.message,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
log(projectId, "INFO", `Project restored from backup: ${zipPath}`, {
|
||||
projectId,
|
||||
projectPath,
|
||||
zipPath,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缺失的文件
|
||||
* @param {string} baseDir 基础目录
|
||||
* @param {Set<string>} keepRelativePaths 需要保留的文件路径集合
|
||||
* @param {Array<string>} excludeDirNames 需要排除的目录名集合
|
||||
*/
|
||||
async function pruneMissingFiles(baseDir, keepRelativePaths, excludeDirNames) {
|
||||
const excludeSet = new Set(excludeDirNames || []);
|
||||
// 需要保护的文件名(返回内容时排除的文件,例如 AGENT.md/CLAUDE.md),清理缺失文件时也不能删除
|
||||
const protectedFileNames = new Set(
|
||||
(config.CONTENT_TRAVERSE_EXCLUDE_FILES || []).map((name) => String(name).trim())
|
||||
);
|
||||
async function walkAndPrune(currentDir) {
|
||||
const entries = await fs.promises.readdir(currentDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// 跳过配置中的排除目录
|
||||
if (excludeSet.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await walkAndPrune(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
// 1. 隐藏文件(以 . 开头)不能删除
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
// 2. 内容排除列表中的文件(如 AGENT.md / CLAUDE.md)不能删除
|
||||
if (protectedFileNames.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
// 其余文件如果不在本次提交的保留列表中,则删除
|
||||
if (!keepRelativePaths.has(relativePath)) {
|
||||
try {
|
||||
await fs.promises.unlink(fullPath);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await walkAndPrune(baseDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除空目录
|
||||
* @param {string} baseDir 基础目录
|
||||
* @param {Array<string>} excludeDirNames 需要排除的目录名集合
|
||||
*/
|
||||
async function removeEmptyDirectories(baseDir, excludeDirNames) {
|
||||
const excludeSet = new Set(excludeDirNames || []);
|
||||
async function postOrderRemove(dir) {
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (excludeSet.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await postOrderRemove(fullPath);
|
||||
}
|
||||
}
|
||||
// 再次读取,若为空则删除(根目录不删)
|
||||
const after = await fs.promises.readdir(dir);
|
||||
if (after.length === 0 && path.resolve(dir) !== path.resolve(baseDir)) {
|
||||
try {
|
||||
await fs.promises.rmdir(dir);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
await postOrderRemove(baseDir);
|
||||
}
|
||||
|
||||
export {
|
||||
copyDirectoryFiltered,
|
||||
backupProjectToZip,
|
||||
restoreProjectFromZip,
|
||||
pruneMissingFiles,
|
||||
removeEmptyDirectories,
|
||||
};
|
||||
|
||||
142
qiming-file-server/src/utils/project/copyProjectUtils.js
Normal file
142
qiming-file-server/src/utils/project/copyProjectUtils.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { copyDirectoryFiltered } from "./backupUtils.js";
|
||||
import {
|
||||
ValidationError,
|
||||
BusinessError,
|
||||
SystemError,
|
||||
} from "../error/errorHandler.js";
|
||||
import { createPnpmNpmrc } from "../common/npmrcUtils.js";
|
||||
import { resolveProjectPath } from "../common/projectPathUtils.js";
|
||||
|
||||
/**
|
||||
* 复制项目
|
||||
* @param {string} sourceProjectId - 源项目ID
|
||||
* @param {string} targetProjectId - 目标项目ID
|
||||
* @returns {Promise<Object>} 复制结果
|
||||
*/
|
||||
async function copyProject(
|
||||
sourceProjectId,
|
||||
targetProjectId,
|
||||
isolationContexts = {}
|
||||
) {
|
||||
if (!sourceProjectId) {
|
||||
throw new ValidationError("Source project ID cannot be empty", {field: "sourceProjectId",});
|
||||
}
|
||||
if (!targetProjectId) {
|
||||
throw new ValidationError("Target project ID cannot be empty", {field: "targetProjectId",});
|
||||
}
|
||||
|
||||
const sourceIsolationContext = isolationContexts.sourceIsolationContext || {};
|
||||
const targetIsolationContext = isolationContexts.targetIsolationContext || {};
|
||||
const sourceProjectPath = resolveProjectPath(
|
||||
sourceProjectId,
|
||||
sourceIsolationContext
|
||||
);
|
||||
const targetProjectPath = resolveProjectPath(
|
||||
targetProjectId,
|
||||
targetIsolationContext
|
||||
);
|
||||
|
||||
// 检查源项目是否存在
|
||||
if (!fs.existsSync(sourceProjectPath)) {
|
||||
throw new BusinessError(`Source project ${sourceProjectId} does not exist`, {
|
||||
sourceProjectId,
|
||||
sourceProjectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// 检查目标项目是否已存在
|
||||
if (fs.existsSync(targetProjectPath)) {
|
||||
throw new BusinessError(`Target project ${targetProjectId} already exists`, {
|
||||
targetProjectId,
|
||||
targetProjectPath,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
log(targetProjectId, "INFO", `Start copying project from ${sourceProjectId} to ${targetProjectId}`, {
|
||||
sourceProjectId,
|
||||
targetProjectId,
|
||||
});
|
||||
|
||||
// 创建目标项目目录
|
||||
fs.mkdirSync(targetProjectPath, { recursive: true });
|
||||
log(targetProjectId, "INFO", `Target project directory created successfully: ${targetProjectPath}`, {
|
||||
targetProjectId,
|
||||
});
|
||||
|
||||
// 复制源项目内容到目标项目目录
|
||||
const entries = await fs.promises.readdir(sourceProjectPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(sourceProjectPath, entry.name);
|
||||
const destPath = path.join(targetProjectPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await fs.promises.mkdir(destPath, { recursive: true });
|
||||
// 使用 copyDirectoryFiltered 来复制目录内容,排除不必要的文件
|
||||
await copyDirectoryFiltered(srcPath, destPath);
|
||||
} else if (entry.isFile()) {
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
log(targetProjectId, "INFO", `Project copied successfully: ${targetProjectId}`, {
|
||||
sourceProjectId,
|
||||
targetProjectId,
|
||||
});
|
||||
|
||||
// 为目标项目创建 .npmrc 配置文件
|
||||
await createPnpmNpmrc(targetProjectPath, targetProjectId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Project ${sourceProjectId} successfully copied to ${targetProjectId}`,
|
||||
sourceProjectId,
|
||||
targetProjectId,
|
||||
targetProjectPath,
|
||||
};
|
||||
} catch (error) {
|
||||
log(targetProjectId, "ERROR", `Copy project failed: ${error.message}`, {
|
||||
sourceProjectId,
|
||||
targetProjectId,
|
||||
});
|
||||
|
||||
// 失败时清理目标项目目录
|
||||
if (fs.existsSync(targetProjectPath)) {
|
||||
try {
|
||||
await fs.promises.rm(targetProjectPath, { recursive: true, force: true });
|
||||
log(targetProjectId, "INFO", "Copy failed, target project directory cleaned", {
|
||||
targetProjectId,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
log(targetProjectId, "ERROR", `Clean target project directory failed: ${cleanupError.message}`, {
|
||||
targetProjectId,
|
||||
originalError: cleanupError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果错误不是自定义的错误类型,包装为系统错误
|
||||
if (!error.isOperational) {
|
||||
throw new SystemError(`Copy project failed: ${error.message}`, {
|
||||
sourceProjectId,
|
||||
targetProjectId,
|
||||
sourceProjectPath,
|
||||
targetProjectPath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { copyProject };
|
||||
|
||||
|
||||
159
qiming-file-server/src/utils/project/frameworkDetectorUtils.js
Normal file
159
qiming-file-server/src/utils/project/frameworkDetectorUtils.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 检测前端框架
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {string} "react" | "vue{major}" | "vue" | "other"
|
||||
*/
|
||||
function detectFrontendFramework(projectPath) {
|
||||
try {
|
||||
// 检查 package.json 是否存在
|
||||
const packageJsonPath = path.join(projectPath, "package.json");
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(packageJsonPath, "utf-8")
|
||||
);
|
||||
const dependencies = {
|
||||
...packageJson.dependencies,
|
||||
...packageJson.devDependencies,
|
||||
};
|
||||
|
||||
// 检查是否有 react 依赖
|
||||
if (dependencies.react || dependencies["react-dom"]) {
|
||||
return "react";
|
||||
}
|
||||
|
||||
// 检查是否有 vue 依赖
|
||||
if (dependencies.vue || dependencies["vue-router"] || dependencies["@vue/cli-service"]) {
|
||||
// 尝试从多个 Vue 相关依赖中识别主版本,优先返回 vue2/vue3
|
||||
const versionCandidates = [
|
||||
dependencies.vue,
|
||||
dependencies["vue-router"],
|
||||
dependencies["@vue/cli-service"],
|
||||
];
|
||||
for (const versionRaw of versionCandidates) {
|
||||
if (typeof versionRaw !== "string") {
|
||||
continue;
|
||||
}
|
||||
const majorVersion = parseVueMajorVersion(versionRaw);
|
||||
if (Number.isFinite(majorVersion)) {
|
||||
return `vue${majorVersion}`;
|
||||
}
|
||||
}
|
||||
// 兜底:识别到 Vue 但无法判断版本
|
||||
return "vue";
|
||||
}
|
||||
}
|
||||
|
||||
return "other";
|
||||
} catch (error) {
|
||||
log(null, "WARN", `Detect frontend framework failed: ${error.message}`, {
|
||||
projectPath,
|
||||
error: error.message,
|
||||
});
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Vue 主版本号
|
||||
* @param {string} versionString Vue 版本字符串
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function parseVueMajorVersion(versionString) {
|
||||
if (!versionString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = String(versionString).trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// npm alias 形式:npm:vue@^3.4.0
|
||||
if (normalized.startsWith("npm:")) {
|
||||
const aliasMatched = normalized.match(/^npm:[^@]+@(.+)$/);
|
||||
if (aliasMatched && aliasMatched[1]) {
|
||||
normalized = aliasMatched[1];
|
||||
}
|
||||
}
|
||||
|
||||
// semver 常见形式:3.4.0 / ^3.4.0 / ~2.7.16 / >=3 / <=2 / 2.x / v3.2.0 / 3
|
||||
// 取第一个独立数字段,避免误取诸如 github 分支名中的数字片段
|
||||
const semverMatched = normalized.match(/(?:^|[^\d])v?(\d+)(?:\.|x|\b)/);
|
||||
if (semverMatched) {
|
||||
const major = Number(semverMatched[1]);
|
||||
return Number.isFinite(major) ? major : null;
|
||||
}
|
||||
|
||||
// 其余非标准格式(workspace/file/link/git/url)不强判
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测开发框架
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {string} "vite" | "nextjs" | "other"
|
||||
*/
|
||||
function detectDevFramework(projectPath) {
|
||||
try {
|
||||
// 检查是否有 next.config 文件(优先级高于 vite)
|
||||
const nextConfigPatterns = [
|
||||
"next.config.js",
|
||||
"next.config.ts",
|
||||
"next.config.mjs",
|
||||
"next.config.cjs",
|
||||
];
|
||||
|
||||
for (const configFile of nextConfigPatterns) {
|
||||
const configPath = path.join(projectPath, configFile);
|
||||
if (fs.existsSync(configPath)) {
|
||||
return "nextjs";
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有 vite.config 文件
|
||||
const viteConfigPatterns = [
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"vite.config.mjs",
|
||||
"vite.config.cjs",
|
||||
];
|
||||
|
||||
for (const configFile of viteConfigPatterns) {
|
||||
const configPath = path.join(projectPath, configFile);
|
||||
if (fs.existsSync(configPath)) {
|
||||
return "vite";
|
||||
}
|
||||
}
|
||||
|
||||
return "other";
|
||||
} catch (error) {
|
||||
log(null, "WARN", `Detect development framework failed: ${error.message}`, {
|
||||
projectPath,
|
||||
error: error.message,
|
||||
});
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目框架信息
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {Object} { frontendFramework: string, devFramework: string }
|
||||
*/
|
||||
function getFrameworkInfo(projectPath) {
|
||||
const frontendFramework = detectFrontendFramework(projectPath);
|
||||
const devFramework = detectDevFramework(projectPath);
|
||||
|
||||
return {
|
||||
frontendFramework,
|
||||
devFramework,
|
||||
};
|
||||
}
|
||||
|
||||
export { detectFrontendFramework, detectDevFramework, getFrameworkInfo };
|
||||
|
||||
|
||||
358
qiming-file-server/src/utils/project/getContentUtils.js
Normal file
358
qiming-file-server/src/utils/project/getContentUtils.js
Normal file
@@ -0,0 +1,358 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import config from "../../appConfig/index.js";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import {
|
||||
ValidationError,
|
||||
SystemError,
|
||||
ResourceError,
|
||||
} from "../error/errorHandler.js";
|
||||
import { extractZip } from "../common/zipUtils.js";
|
||||
import { getFrameworkInfo } from "./frameworkDetectorUtils.js";
|
||||
import {
|
||||
resolveProjectPath,
|
||||
shouldUseIsolationPath,
|
||||
} from "../common/projectPathUtils.js";
|
||||
|
||||
/**
|
||||
* 检查文件是否为二进制文件
|
||||
*/
|
||||
async function isBinaryFile(filePath) {
|
||||
try {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
// 检查是否包含null字节
|
||||
if (buffer.includes(0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否包含非ASCII字符
|
||||
const text = buffer.toString("utf-8");
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
if (
|
||||
charCode < 32 &&
|
||||
charCode !== 9 &&
|
||||
charCode !== 10 &&
|
||||
charCode !== 13
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否为图片文件
|
||||
*/
|
||||
function isImageFile(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"].includes(
|
||||
ext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归遍历目录,获取所有文件信息
|
||||
* @param {string} dirPath 目录路径
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} basePath 计算相对路径的基准路径,默认为 PROJECT_SOURCE_DIR + projectId
|
||||
* @returns {Array} 文件信息数组
|
||||
*/
|
||||
async function traverseDirectory(targetDir, basePath, projectId, proxyPath) {
|
||||
const files = [];
|
||||
const entries = await fs.promises.readdir(targetDir, { withFileTypes: true });
|
||||
|
||||
// 对文件条目进行排序,确保返回顺序一致
|
||||
// 先按类型排序(目录在前,文件在后),然后按名称排序
|
||||
entries.sort((a, b) => {
|
||||
// 如果一个是目录,一个是文件,目录排在前面
|
||||
if (a.isDirectory() && !b.isDirectory()) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.isDirectory() && b.isDirectory()) {
|
||||
return 1;
|
||||
}
|
||||
// 如果类型相同,按名称排序(不区分大小写)
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(targetDir, entry.name);
|
||||
|
||||
// 跳过隐藏文件(以 . 开头的文件)
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过指定的排除文件(优先使用内容专用排除列表)
|
||||
const contentExcludeFiles =
|
||||
config.CONTENT_TRAVERSE_EXCLUDE_FILES ||
|
||||
[];
|
||||
if (contentExcludeFiles.includes(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过指定的排除目录
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
config.TRAVERSE_EXCLUDE_DIRS.includes(entry.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// 递归处理子目录
|
||||
const subFiles = await traverseDirectory(fullPath, basePath, projectId, proxyPath);
|
||||
files.push(...subFiles);
|
||||
} else {
|
||||
// 处理文件
|
||||
try {
|
||||
const stats = await fs.promises.stat(fullPath);
|
||||
// 使用传入的 basePath 或默认的 PROJECT_SOURCE_DIR + projectId
|
||||
const referencePath =
|
||||
basePath || resolveProjectPath(projectId);
|
||||
const relativePath = path.relative(referencePath, fullPath);
|
||||
|
||||
const isBinary = await isBinaryFile(fullPath);
|
||||
|
||||
// 判断是否为当前项目目录
|
||||
// const isCurrentProject = !basePath ||
|
||||
// path.resolve(basePath) ===
|
||||
// path.resolve(path.join(config.PROJECT_SOURCE_DIR, projectId));
|
||||
|
||||
// 判断是否为历史版本目录
|
||||
// const isHistoryVersion = basePath &&
|
||||
// path.resolve(basePath) ===
|
||||
// path.resolve(path.join(config.PROJECT_SOURCE_DIR, "_his", projectId));
|
||||
|
||||
// 生成文件代理URL
|
||||
const fileProxyUrl = `${proxyPath}/${relativePath}`;
|
||||
|
||||
const fileInfo = {
|
||||
name: relativePath,
|
||||
binary: isBinary,
|
||||
sizeExceeded: stats.size > config.MAX_INLINE_FILE_SIZE_BYTES,
|
||||
contents: "",
|
||||
fileProxyUrl: fileProxyUrl,
|
||||
};
|
||||
|
||||
// 如果文件大小未超过阈值
|
||||
if (!fileInfo.sizeExceeded) {
|
||||
if (fileInfo.binary) {
|
||||
// 二进制文件转换为base64
|
||||
if (isImageFile(fullPath)) {
|
||||
const buffer = fs.readFileSync(fullPath);
|
||||
fileInfo.contents = buffer.toString("base64");
|
||||
}
|
||||
// 非图片的二进制文件不返回内容
|
||||
} else {
|
||||
// 文本文件直接读取内容
|
||||
fileInfo.contents = fs.readFileSync(fullPath, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
files.push(fileInfo);
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", `Process file failed: ${fullPath} - ${error.message}`, {
|
||||
filePath: fullPath,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目内容
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {string} command 命令参数,如果为 'cpage_config' 则返回 cpage_config.json,否则过滤掉它
|
||||
* @returns {Object} 包含文件列表的结果对象
|
||||
*/
|
||||
async function getProjectContent(projectPath, command, proxyPath) {
|
||||
const startTime = Date.now();
|
||||
const projectId = path.basename(projectPath);
|
||||
|
||||
try {
|
||||
log(projectId, "INFO", "Start getting project content", { projectPath, command });
|
||||
|
||||
log(projectId, "DEBUG", "Start traversing project directory", { projectPath });
|
||||
const files = await traverseDirectory(
|
||||
projectPath,
|
||||
projectPath,
|
||||
projectId,
|
||||
proxyPath
|
||||
);
|
||||
log(projectId, "DEBUG", "Project directory traversal completed", { projectPath, fileCount: files.length });
|
||||
|
||||
// 根据 command 参数过滤 cpage_config.json
|
||||
let filteredFiles = files;
|
||||
if (command !== "cpage_config") {
|
||||
filteredFiles = files.filter((file) => file.name !== "cpage_config.json");
|
||||
}
|
||||
|
||||
// 获取框架信息
|
||||
log(projectId, "DEBUG", "Start detecting framework information", { projectPath });
|
||||
const frameworkInfo = getFrameworkInfo(projectPath);
|
||||
|
||||
const result = {
|
||||
files: filteredFiles,
|
||||
...frameworkInfo,
|
||||
};
|
||||
|
||||
log(
|
||||
projectId,
|
||||
"INFO",
|
||||
`Project content obtained, total ${filteredFiles.length} files`,
|
||||
{
|
||||
projectPath,
|
||||
fileCount: filteredFiles.length,
|
||||
command,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
log(projectId, "ERROR", `Get project content failed: ${error.message}`, {
|
||||
projectPath,
|
||||
originalError: error.message,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
throw new SystemError(`Get project content failed: ${error.message}`, {
|
||||
projectPath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据版本号获取项目内容
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} codeVersion 代码版本
|
||||
* @param {string} command 命令参数,如果为 'cpage_config' 则返回 cpage_config.json,否则过滤掉它
|
||||
* @returns {Object} 包含文件列表的结果对象
|
||||
*/
|
||||
async function getProjectContentByVersion(
|
||||
projectId,
|
||||
codeVersion,
|
||||
command,
|
||||
proxyPath,
|
||||
isolationContext = {}
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
const versionNum = Number(codeVersion);
|
||||
if (!Number.isFinite(versionNum)) {
|
||||
throw new ValidationError("Code version must be a number", { field: "codeVersion" });
|
||||
}
|
||||
|
||||
// 构建备份文件路径
|
||||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||||
const backupZipPath = path.join(backupDir, `${projectId}-v${versionNum}.zip`);
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if (!fs.existsSync(backupZipPath)) {
|
||||
throw new ResourceError(`Backup file for version ${versionNum} does not exist`, {
|
||||
projectId,
|
||||
codeVersion: versionNum,
|
||||
backupZipPath,
|
||||
});
|
||||
}
|
||||
|
||||
// 创建临时解压目录
|
||||
const tempExtractDir = shouldUseIsolationPath(isolationContext)
|
||||
? path.join(
|
||||
config.PROJECT_SOURCE_DIR,
|
||||
String(isolationContext.tenantId),
|
||||
String(isolationContext.spaceId),
|
||||
"_his",
|
||||
projectId
|
||||
)
|
||||
: path.join(config.PROJECT_SOURCE_DIR, "_his", projectId);
|
||||
|
||||
try {
|
||||
// 清理临时目录(如果存在)
|
||||
if (fs.existsSync(tempExtractDir)) {
|
||||
await fs.promises.rm(tempExtractDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 创建临时目录
|
||||
await fs.promises.mkdir(tempExtractDir, { recursive: true });
|
||||
|
||||
log(projectId, "INFO", `Start extracting backup file for version ${versionNum}`, {
|
||||
projectId,
|
||||
codeVersion: versionNum,
|
||||
backupZipPath,
|
||||
tempExtractDir,
|
||||
});
|
||||
|
||||
// 解压备份文件到临时目录
|
||||
log(projectId, "DEBUG", "Start extracting version backup file", { projectId, backupZipPath, tempExtractDir });
|
||||
await extractZip(backupZipPath, tempExtractDir);
|
||||
log(projectId, "DEBUG", `Version ${versionNum} backup file extraction completed`, {
|
||||
projectId,
|
||||
codeVersion: versionNum,
|
||||
tempExtractDir,
|
||||
});
|
||||
|
||||
// 获取项目内容 - 使用统一的遍历函数,传入解压目录作为基准路径
|
||||
log(projectId, "DEBUG", "Start traversing version directory", { projectId, tempExtractDir });
|
||||
const files = await traverseDirectory(
|
||||
tempExtractDir,
|
||||
tempExtractDir,
|
||||
projectId,
|
||||
proxyPath
|
||||
);
|
||||
|
||||
// 根据 command 参数过滤 cpage_config.json
|
||||
let filteredFiles = files;
|
||||
if (command !== "cpage_config") {
|
||||
filteredFiles = files.filter((file) => file.name !== "cpage_config.json");
|
||||
}
|
||||
|
||||
const result = { files: filteredFiles };
|
||||
|
||||
log(projectId, "INFO", `Version ${versionNum} project content obtained`, {
|
||||
projectId,
|
||||
codeVersion: versionNum,
|
||||
fileCount: result.files ? result.files.length : 0,
|
||||
command,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
// 清理临时目录
|
||||
try {
|
||||
if (fs.existsSync(tempExtractDir)) {
|
||||
await fs.promises.rm(tempExtractDir, { recursive: true, force: true });
|
||||
log(projectId, "INFO", `Temporary directory cleaned: ${tempExtractDir}`, {
|
||||
projectId,
|
||||
codeVersion: versionNum,
|
||||
});
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
log(projectId, "WARN", `Clean temporary directory failed: ${cleanupError.message}`, {
|
||||
projectId,
|
||||
codeVersion: versionNum,
|
||||
tempExtractDir,
|
||||
error: cleanupError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getProjectContent,
|
||||
getProjectContentByVersion,
|
||||
traverseDirectory,
|
||||
isBinaryFile,
|
||||
isImageFile,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 删除初始化项目文件夹
|
||||
* @param {string} initProjectDir - 初始化项目目录路径
|
||||
* @param {string} initProjectName - 初始化项目名称
|
||||
* @returns {Promise<boolean>} - 删除是否成功
|
||||
*/
|
||||
async function deleteInitProjectFolder(initProjectDir, initProjectName) {
|
||||
try {
|
||||
const targetPath = path.join(initProjectDir, initProjectName);
|
||||
|
||||
// 检查目标路径是否存在
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
log("default", "INFO", `Initialization project directory does not exist: ${targetPath}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否为目录
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
log("default", "WARN", `Target path is not a directory: ${targetPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 递归删除目录
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
|
||||
log("default", "INFO", `Successfully deleted initialization project directory: ${targetPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log("default", "ERROR", `Delete initialization project directory failed: ${error.message}`);
|
||||
log(
|
||||
"default",
|
||||
"ERROR",
|
||||
`Target path: ${path.join(initProjectDir, initProjectName)}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在项目启动时清理初始化项目文件夹
|
||||
* @param {Object} config - 配置对象
|
||||
* @returns {Promise<boolean>} - 清理是否成功
|
||||
*/
|
||||
async function cleanupInitProjectOnStartup(config) {
|
||||
try {
|
||||
const {
|
||||
INIT_PROJECT_DIR,
|
||||
INIT_PROJECT_NAME_REACT,
|
||||
INIT_PROJECT_NAME_VUE3,
|
||||
} = config;
|
||||
|
||||
if (!INIT_PROJECT_DIR) {
|
||||
log("default", "WARN", "INIT_PROJECT_DIR configuration missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
const templateNameSet = new Set(
|
||||
[
|
||||
INIT_PROJECT_NAME_REACT,
|
||||
INIT_PROJECT_NAME_VUE3,
|
||||
].filter(Boolean)
|
||||
);
|
||||
if (templateNameSet.size === 0) {
|
||||
log("default", "WARN", "Initialization template name configuration missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
log("default", "INFO", "Start cleaning initialization project directory...");
|
||||
log("default", "INFO", `Target directory: ${INIT_PROJECT_DIR}`);
|
||||
log("default", "INFO", `Template names: ${Array.from(templateNameSet).join(", ")}`);
|
||||
|
||||
let allSuccess = true;
|
||||
for (const templateName of templateNameSet) {
|
||||
const success = await deleteInitProjectFolder(INIT_PROJECT_DIR, templateName);
|
||||
if (!success) {
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allSuccess) {
|
||||
log("default", "INFO", "Initialization project directory cleanup completed");
|
||||
} else {
|
||||
log("default", "ERROR", "Initialization project directory cleanup failed");
|
||||
}
|
||||
|
||||
return allSuccess;
|
||||
} catch (error) {
|
||||
log("default", "ERROR", `Clean initialization project directory failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export { deleteInitProjectFolder, cleanupInitProjectOnStartup };
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import config from "../../appConfig/index.js";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { ValidationError, SystemError, FileError } from "../error/errorHandler.js";
|
||||
import { resolveProjectPath } from "../common/projectPathUtils.js";
|
||||
|
||||
/**
|
||||
* 上传附件文件到项目的 .attachments 目录
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {Object} file multer文件对象
|
||||
* @param {string} fileName 可选,指定存储的文件名
|
||||
* @returns {Object} 包含fileName和relativePath的结果对象
|
||||
*/
|
||||
async function uploadAttachmentFile(
|
||||
projectId,
|
||||
file,
|
||||
fileName = null,
|
||||
isolationContext = {}
|
||||
) {
|
||||
try {
|
||||
// 验证参数
|
||||
if (!projectId) {
|
||||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||||
}
|
||||
if (!file) {
|
||||
throw new ValidationError("File cannot be empty", { field: "file" });
|
||||
}
|
||||
if (!file.path) {
|
||||
throw new ValidationError("File path is invalid", { field: "file.path" });
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Start uploading attachment file", {
|
||||
projectId,
|
||||
fileName,
|
||||
originalName: file.originalname,
|
||||
tempPath: file.path,
|
||||
});
|
||||
|
||||
// 构建项目目录路径
|
||||
const projectDir = resolveProjectPath(projectId, isolationContext);
|
||||
|
||||
// 检查项目目录是否存在
|
||||
if (!fs.existsSync(projectDir)) {
|
||||
throw new ValidationError("Project does not exist", { field: "projectId" });
|
||||
}
|
||||
|
||||
// 构建附件目录路径
|
||||
const attachmentsDir = path.join(projectDir, ".attachments");
|
||||
|
||||
// 创建附件目录(如果不存在)
|
||||
if (!fs.existsSync(attachmentsDir)) {
|
||||
await fs.promises.mkdir(attachmentsDir, { recursive: true });
|
||||
log(projectId, "INFO", "Create attachment directory", { attachmentsDir });
|
||||
}
|
||||
|
||||
// 确定最终的文件名
|
||||
let finalFileName;
|
||||
if (fileName) {
|
||||
finalFileName = fileName;
|
||||
} else {
|
||||
finalFileName = file.originalname;
|
||||
}
|
||||
|
||||
let finalFilePath = path.join(attachmentsDir, finalFileName);
|
||||
|
||||
// 检查文件是否已存在,如果存在则生成唯一文件名
|
||||
if (fs.existsSync(finalFilePath)) {
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.round(Math.random() * 1e6);
|
||||
const fileExtension = path.extname(finalFileName);
|
||||
const baseName = path.basename(finalFileName, fileExtension);
|
||||
finalFileName = `${baseName}_${timestamp}_${randomSuffix}${fileExtension}`;
|
||||
finalFilePath = path.join(attachmentsDir, finalFileName);
|
||||
}
|
||||
|
||||
// 移动文件从临时目录到附件目录
|
||||
// 使用copyFile + unlink代替rename,以支持跨设备(跨挂载点)的文件移动
|
||||
try {
|
||||
await fs.promises.rename(file.path, finalFilePath);
|
||||
} catch (renameError) {
|
||||
// 如果rename失败(通常是跨设备错误),则使用copyFile
|
||||
if (renameError.code === "EXDEV") {
|
||||
log(projectId, "INFO", "Cross-device move, using copy method", {
|
||||
tempPath: file.path,
|
||||
finalPath: finalFilePath,
|
||||
});
|
||||
await fs.promises.copyFile(file.path, finalFilePath);
|
||||
await fs.promises.unlink(file.path);
|
||||
} else {
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算相对于项目目录的相对路径
|
||||
const relativePath = path.relative(projectDir, finalFilePath);
|
||||
|
||||
log(projectId, "INFO", "Attachment file uploaded successfully", {
|
||||
projectId,
|
||||
originalName: file.originalname,
|
||||
fileName: finalFileName,
|
||||
relativePath,
|
||||
finalFilePath,
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: finalFileName,
|
||||
relativePath,
|
||||
};
|
||||
} catch (error) {
|
||||
// 如果出错,尝试清理临时文件
|
||||
if (file && file.path && fs.existsSync(file.path)) {
|
||||
try {
|
||||
await fs.promises.unlink(file.path);
|
||||
log(projectId, "INFO", "Clean temporary file", { tempPath: file.path });
|
||||
} catch (cleanupError) {
|
||||
log(projectId, "WARN", "Clean temporary file failed", {
|
||||
tempPath: file.path,
|
||||
error: cleanupError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log(projectId, "ERROR", "Upload attachment file failed", {
|
||||
projectId,
|
||||
originalName: file?.originalname,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// 重新抛出错误
|
||||
if (
|
||||
error instanceof ValidationError ||
|
||||
error instanceof SystemError ||
|
||||
error instanceof FileError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new SystemError(`Upload attachment file failed: ${error.message}`, {
|
||||
projectId,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { uploadAttachmentFile };
|
||||
|
||||
973
qiming-file-server/src/utils/serviceManager.js
Normal file
973
qiming-file-server/src/utils/serviceManager.js
Normal file
@@ -0,0 +1,973 @@
|
||||
/**
|
||||
* 服务管理器模块
|
||||
*
|
||||
* 功能: 提供跨平台的服务启动、停止、重启、状态查询功能
|
||||
*
|
||||
* 跨平台支持:
|
||||
* - Windows: 使用 taskkill 命令
|
||||
* - Linux/macOS: 使用 kill 信号
|
||||
*
|
||||
* PID 文件管理:
|
||||
* - 使用 os.tmpdir() 获取临时目录
|
||||
* - Windows: %TEMP%/qiming-file-server/server.pid
|
||||
* - Linux/macOS: /tmp/qiming-file-server/server.pid
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs-extra";
|
||||
import { spawn } from "cross-spawn";
|
||||
import treeKill from "tree-kill";
|
||||
import { fileURLToPath } from "url";
|
||||
import { execFileSync } from "child_process";
|
||||
import http from "http";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
|
||||
// 服务配置
|
||||
const SERVICE_CONFIG = {
|
||||
// 服务名称
|
||||
name: 'qiming-file-server',
|
||||
// PID 文件目录
|
||||
pidDir: path.join(os.tmpdir(), 'qiming-file-server'),
|
||||
// PID 文件名
|
||||
pidFileName: 'server.pid',
|
||||
lockFileName: 'start.lock',
|
||||
// 默认停止超时时间(毫秒)
|
||||
defaultStopTimeout: 30000,
|
||||
// 默认启动健康检查超时时间(毫秒)
|
||||
defaultStartTimeout: 30000,
|
||||
// 启动锁陈旧超时时间(毫秒)
|
||||
staleLockTimeout: 120000,
|
||||
// 检查进程间隔(毫秒)
|
||||
checkInterval: 500,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 PID 文件完整路径
|
||||
*
|
||||
* 使用 os.tmpdir() 确保跨平台兼容性
|
||||
*
|
||||
* @returns {string} PID 文件路径
|
||||
*/
|
||||
function getPidFilePath() {
|
||||
return path.join(SERVICE_CONFIG.pidDir, SERVICE_CONFIG.pidFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启动锁文件完整路径
|
||||
*
|
||||
* @returns {string} 锁文件路径
|
||||
*/
|
||||
function getLockFilePath() {
|
||||
return path.join(SERVICE_CONFIG.pidDir, SERVICE_CONFIG.lockFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析服务入口脚本路径(兼容 src 与 dist)
|
||||
*
|
||||
* @returns {string} server.js 完整路径
|
||||
*/
|
||||
function resolveServerScriptPath() {
|
||||
const candidates = [
|
||||
path.join(__dirname, '..', 'server.js'),
|
||||
path.join(__dirname, 'server.js'),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 package 版本号(兼容 src 与 dist)
|
||||
*
|
||||
* @returns {string} 版本号
|
||||
*/
|
||||
function resolvePackageVersion() {
|
||||
const candidates = [
|
||||
'../../package.json',
|
||||
'../package.json',
|
||||
];
|
||||
|
||||
for (const relPath of candidates) {
|
||||
try {
|
||||
const pkg = esmRequire(relPath);
|
||||
if (pkg?.version) {
|
||||
return pkg.version;
|
||||
}
|
||||
} catch (err) {
|
||||
// 尝试下一个候选路径
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 PID 文件
|
||||
*
|
||||
* 读取并解析 PID 文件内容
|
||||
*
|
||||
* @returns {Object|null} PID 信息对象,如果文件不存在或解析失败则返回 null
|
||||
* @property {number} pid - 进程 ID
|
||||
* @property {string} startedAt - 启动时间 ISO 字符串
|
||||
* @property {string} env - 环境名称
|
||||
* @property {string} port - 端口号
|
||||
* @property {string} version - 版本号
|
||||
* @property {string} platform - 操作系统平台
|
||||
*/
|
||||
function readPidFile() {
|
||||
try {
|
||||
const pidPath = getPidFilePath();
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(pidPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 读取并解析文件内容
|
||||
const content = fs.readFileSync(pidPath, 'utf8');
|
||||
const pidInfo = JSON.parse(content);
|
||||
|
||||
// 验证 PID 信息完整性
|
||||
if (!pidInfo || typeof pidInfo.pid !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pidInfo;
|
||||
} catch (err) {
|
||||
// 文件不存在或解析失败
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.error(`Read PID file failed: ${err.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 PID 文件
|
||||
*
|
||||
* 将进程信息写入 PID 文件
|
||||
*
|
||||
* @param {Object} pidInfo - PID 信息对象
|
||||
* @param {number} pidInfo.pid - 进程 ID
|
||||
* @param {string} pidInfo.startedAt - 启动时间 ISO 字符串
|
||||
* @param {string} [pidInfo.env] - 环境名称
|
||||
* @param {string} [pidInfo.port] - 端口号
|
||||
* @param {string} [pidInfo.version] - 版本号
|
||||
* @param {string} [pidInfo.platform] - 操作系统平台
|
||||
*/
|
||||
function writePidFile(pidInfo) {
|
||||
try {
|
||||
const pidPath = getPidFilePath();
|
||||
|
||||
// 确保目录存在
|
||||
fs.ensureDirSync(SERVICE_CONFIG.pidDir);
|
||||
|
||||
// 写入文件
|
||||
fs.writeFileSync(pidPath, JSON.stringify(pidInfo, null, 2));
|
||||
|
||||
console.debug(`PID file written: ${pidPath}`);
|
||||
} catch (err) {
|
||||
console.error(`Write PID file failed: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 PID 文件
|
||||
*
|
||||
* 清理 PID 文件(服务停止时调用)
|
||||
*/
|
||||
function deletePidFile() {
|
||||
try {
|
||||
const pidPath = getPidFilePath();
|
||||
|
||||
if (fs.existsSync(pidPath)) {
|
||||
fs.removeSync(pidPath);
|
||||
console.debug(`PID file deleted: ${pidPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Delete PID file failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查进程是否正在运行
|
||||
*
|
||||
* 使用 process.kill(pid, 0) 检测进程是否存在
|
||||
*
|
||||
* @param {number} pid - 进程 ID
|
||||
* @returns {boolean} 进程是否正在运行
|
||||
*/
|
||||
function isProcessRunning(pid) {
|
||||
try {
|
||||
// 发送信号 0 来检查进程是否存在
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// ESRCH: 进程不存在
|
||||
// EPERM: 进程存在但无权限(仍视为存在)
|
||||
if (err.code === 'ESRCH') {
|
||||
return false;
|
||||
}
|
||||
// 其他错误保守返回 true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进程命令行(跨平台)
|
||||
*
|
||||
* @param {number} pid - 进程 ID
|
||||
* @returns {string} 进程命令行,读取失败时返回空字符串
|
||||
*/
|
||||
function getProcessCommand(pid) {
|
||||
try {
|
||||
if (isWindows()) {
|
||||
const command = execFileSync(
|
||||
'powershell',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
|
||||
],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
return command.trim();
|
||||
}
|
||||
|
||||
const command = execFileSync(
|
||||
'ps',
|
||||
['-p', String(pid), '-o', 'command='],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
return command.trim();
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验进程是否为当前服务进程
|
||||
*
|
||||
* @param {number} pid - 进程 ID
|
||||
* @returns {boolean} 是否为目标服务进程
|
||||
*/
|
||||
function isTargetServiceProcess(pid) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = getProcessCommand(pid);
|
||||
if (!command) {
|
||||
// 无法读取命令行时不判定为目标进程,避免误杀非目标进程
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = command.toLowerCase();
|
||||
const serviceScriptName = path.basename(path.join(__dirname, '..', 'server.js')).toLowerCase();
|
||||
const serviceName = SERVICE_CONFIG.name.toLowerCase();
|
||||
return lower.includes(serviceScriptName) || lower.includes(serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启动锁,防止并发启动导致竞态
|
||||
*
|
||||
* @returns {number} 锁文件描述符
|
||||
*/
|
||||
function acquireStartLock() {
|
||||
fs.ensureDirSync(SERVICE_CONFIG.pidDir);
|
||||
const lockPath = getLockFilePath();
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const payload = JSON.stringify(
|
||||
{ pid: process.pid, token, createdAt: new Date().toISOString() },
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
try {
|
||||
const fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeSync(fd, payload);
|
||||
return { fd, token };
|
||||
} catch (err) {
|
||||
if (err.code !== 'EEXIST') {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 无人值守场景下自动清理陈旧锁,避免永远阻塞启动
|
||||
try {
|
||||
const raw = fs.readFileSync(lockPath, 'utf8');
|
||||
const lockInfo = JSON.parse(raw);
|
||||
const holderPid = Number(lockInfo?.pid);
|
||||
const createdAt = lockInfo?.createdAt ? new Date(lockInfo.createdAt).getTime() : 0;
|
||||
const lockAge = Date.now() - createdAt;
|
||||
const holderAlive = Number.isFinite(holderPid) && holderPid > 0 && isProcessRunning(holderPid);
|
||||
const expired = !createdAt || Number.isNaN(lockAge) || lockAge > SERVICE_CONFIG.staleLockTimeout;
|
||||
|
||||
if (!holderAlive || expired) {
|
||||
fs.removeSync(lockPath);
|
||||
const fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeSync(fd, payload);
|
||||
console.warn('Detected stale start lock, auto cleaned');
|
||||
return { fd, token };
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// 锁文件损坏时直接重建
|
||||
fs.removeSync(lockPath);
|
||||
const fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeSync(fd, payload);
|
||||
console.warn('Detected invalid start lock, auto cleaned');
|
||||
return { fd, token };
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放启动锁
|
||||
*
|
||||
* @param {Object|null} lockHandle - 锁信息对象
|
||||
*/
|
||||
function releaseStartLock(lockHandle) {
|
||||
const lockFd = lockHandle?.fd;
|
||||
const lockToken = lockHandle?.token;
|
||||
|
||||
try {
|
||||
if (lockFd !== null && lockFd !== undefined) {
|
||||
fs.closeSync(lockFd);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
|
||||
try {
|
||||
const lockPath = getLockFilePath();
|
||||
if (fs.existsSync(lockPath)) {
|
||||
let shouldRemove = false;
|
||||
|
||||
if (!lockToken) {
|
||||
shouldRemove = true;
|
||||
} else {
|
||||
try {
|
||||
const raw = fs.readFileSync(lockPath, 'utf8');
|
||||
const lockInfo = JSON.parse(raw);
|
||||
shouldRemove = lockInfo?.token === lockToken;
|
||||
} catch (err) {
|
||||
// 无法解析时保守清理,避免锁残留
|
||||
shouldRemove = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemove) {
|
||||
fs.removeSync(lockPath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略清理错误
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测当前操作系统是否为 Windows
|
||||
*
|
||||
* @returns {boolean} 是否为 Windows 系统
|
||||
*/
|
||||
function isWindows() {
|
||||
return process.platform === 'win32';
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动前确保没有旧服务进程残留
|
||||
*
|
||||
* 处理策略:
|
||||
* 1. 若 PID 文件不存在或进程已退出:直接清理并返回
|
||||
* 2. 若进程存在:先尝试优雅停止,再尝试强制停止
|
||||
* 3. 最终统一清理 PID 文件,避免后续启动被旧状态阻塞
|
||||
*
|
||||
* @param {number} [timeout=SERVICE_CONFIG.defaultStopTimeout] - 停止等待超时(毫秒)
|
||||
* @returns {Promise<Object>} 处理结果
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {string} message - 处理结果消息
|
||||
*/
|
||||
async function ensureNoRunningServiceBeforeStart(timeout = SERVICE_CONFIG.defaultStopTimeout) {
|
||||
const pidInfo = readPidFile();
|
||||
|
||||
// 无 PID 文件,无需处理
|
||||
if (!pidInfo) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No existing service process',
|
||||
};
|
||||
}
|
||||
|
||||
// PID 对应进程不存在,清理残留 PID 文件
|
||||
if (!isTargetServiceProcess(pidInfo.pid)) {
|
||||
console.log(`Found stale PID file (PID: ${pidInfo.pid}), clean it...`);
|
||||
deletePidFile();
|
||||
return {
|
||||
success: true,
|
||||
message: 'Stale PID file cleaned',
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Existing service process detected (PID: ${pidInfo.pid}), stopping before start...`);
|
||||
|
||||
// 先尝试优雅停止
|
||||
const gracefulStopped = await stopProcess(pidInfo.pid, false);
|
||||
if (gracefulStopped) {
|
||||
const exited = await waitForProcessStop(pidInfo.pid, timeout);
|
||||
if (exited) {
|
||||
deletePidFile();
|
||||
return {
|
||||
success: true,
|
||||
message: `Existing process ${pidInfo.pid} stopped gracefully`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 优雅停止失败后,强制停止
|
||||
console.warn(`Graceful stop timeout or failed for PID ${pidInfo.pid}, force stop...`);
|
||||
const forceStopped = await stopProcess(pidInfo.pid, true);
|
||||
if (!forceStopped) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to stop existing process ${pidInfo.pid}`,
|
||||
};
|
||||
}
|
||||
|
||||
const forceExited = await waitForProcessStop(pidInfo.pid, timeout);
|
||||
if (!forceExited) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Existing process ${pidInfo.pid} did not exit after force stop`,
|
||||
};
|
||||
}
|
||||
|
||||
deletePidFile();
|
||||
return {
|
||||
success: true,
|
||||
message: `Existing process ${pidInfo.pid} stopped forcibly`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止进程(跨平台实现)
|
||||
*
|
||||
* Windows 使用 taskkill 命令
|
||||
* Linux/macOS 使用 kill 信号
|
||||
*
|
||||
* @param {number} pid - 进程 ID
|
||||
* @param {boolean} [force=false] - 是否强制停止(SIGKILL)
|
||||
* @returns {Promise<boolean>} 是否成功停止
|
||||
*/
|
||||
async function stopProcess(pid, force = false) {
|
||||
return new Promise((resolve) => {
|
||||
// 如果进程不存在,直接返回成功
|
||||
if (!isProcessRunning(pid)) {
|
||||
console.debug(`Process ${pid} does not exist, already stopped`);
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const signal = force ? 'SIGKILL' : 'SIGTERM';
|
||||
const killMethod = isWindows() ? 'taskkill' : 'tree-kill';
|
||||
|
||||
console.debug(`Use ${killMethod} to stop process ${pid} (signal: ${signal})`);
|
||||
|
||||
if (isWindows()) {
|
||||
// Windows: 使用 taskkill 命令
|
||||
const args = force ? ['/F', '/PID', String(pid)] : ['/PID', String(pid)];
|
||||
|
||||
const child = spawn('taskkill', args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let output = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(`Stop process failed: ${err.message}`);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.debug(`Process ${pid} stopped`);
|
||||
resolve(true);
|
||||
} else {
|
||||
console.warn(`taskkill exit code: ${code}, output: ${output}`);
|
||||
|
||||
// 如果非强制模式,尝试强制停止
|
||||
if (!force) {
|
||||
stopProcess(pid, true).then(resolve);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Linux/macOS: 使用 tree-kill(确保杀死整个进程树)
|
||||
treeKill(pid, signal, (err) => {
|
||||
if (err) {
|
||||
if (err.code === 'ESRCH') {
|
||||
// 进程不存在
|
||||
console.debug(`Process ${pid} does not exist`);
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error(`Stop process failed: ${err.message}`);
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
console.debug(`Process ${pid} stopped`);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待进程停止
|
||||
*
|
||||
* 轮询检查进程是否已停止
|
||||
*
|
||||
* @param {number} pid - 进程 ID
|
||||
* @param {number} [timeout=SERVICE_CONFIG.defaultStopTimeout] - 超时时间(毫秒)
|
||||
* @returns {Promise<boolean>} 是否在超时前停止
|
||||
*/
|
||||
async function waitForProcessStop(pid, timeout = SERVICE_CONFIG.defaultStopTimeout) {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (isProcessRunning(pid)) {
|
||||
// 检查是否超时
|
||||
if (Date.now() - startTime > timeout) {
|
||||
console.warn(`Wait for process ${pid} to stop timeout (${timeout}ms)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 等待检查间隔
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, SERVICE_CONFIG.checkInterval)
|
||||
);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.debug(`Process ${pid} stopped after ${elapsed}ms`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待服务健康检查通过
|
||||
*
|
||||
* 轮询访问 /health,直到返回 2xx 或超时
|
||||
*
|
||||
* @param {string|number} port - 服务端口
|
||||
* @param {number} [timeout=SERVICE_CONFIG.defaultStartTimeout] - 超时时间(毫秒)
|
||||
* @returns {Promise<boolean>} 是否在超时前通过健康检查
|
||||
*/
|
||||
async function waitForServiceHealth(port, timeout = SERVICE_CONFIG.defaultStartTimeout) {
|
||||
const targetPort = Number(port);
|
||||
if (!Number.isFinite(targetPort) || targetPort <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime <= timeout) {
|
||||
const healthy = await new Promise((resolve) => {
|
||||
const req = http.get(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: targetPort,
|
||||
path: '/health',
|
||||
timeout: Math.min(2000, SERVICE_CONFIG.checkInterval * 4),
|
||||
},
|
||||
(res) => {
|
||||
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
||||
res.resume();
|
||||
}
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.on('error', () => resolve(false));
|
||||
});
|
||||
|
||||
if (healthy) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, SERVICE_CONFIG.checkInterval));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务
|
||||
*
|
||||
* 启动 qiming-file-server 服务进程
|
||||
*
|
||||
* @param {Object} options - 启动选项
|
||||
* @param {string} [options.env] - 环境名称
|
||||
* @param {string} [options.port] - 端口号
|
||||
* @param {string} [options.config] - 配置文件路径
|
||||
* @returns {Promise<Object>} 启动结果
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {number} pid - 进程 ID
|
||||
* @property {string} message - 状态消息
|
||||
*/
|
||||
async function startService(options = {}) {
|
||||
const { env, port, config } = options;
|
||||
let lockHandle = null;
|
||||
|
||||
console.log(`Start service ${SERVICE_CONFIG.name}...`);
|
||||
|
||||
try {
|
||||
lockHandle = acquireStartLock();
|
||||
} catch (err) {
|
||||
if (err.code === 'EEXIST') {
|
||||
return {
|
||||
success: false,
|
||||
pid: null,
|
||||
message: 'Another start operation is in progress, please retry later',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
pid: null,
|
||||
message: `Acquire start lock failed: ${err.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 启动前先清理/停止历史进程,确保本次启动可继续进行
|
||||
const stopTimeout = Number(options.timeout) || SERVICE_CONFIG.defaultStopTimeout;
|
||||
const preStartResult = await ensureNoRunningServiceBeforeStart(stopTimeout);
|
||||
if (!preStartResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
pid: null,
|
||||
message: `Service start blocked: ${preStartResult.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 构建环境变量
|
||||
const envVars = { ...process.env };
|
||||
|
||||
if (env) {
|
||||
envVars.NODE_ENV = env;
|
||||
console.log(`Environment: ${env}`);
|
||||
}
|
||||
|
||||
if (port) {
|
||||
envVars.PORT = port;
|
||||
console.log(`Port: ${port}`);
|
||||
}
|
||||
|
||||
if (config) {
|
||||
envVars.CONFIG_FILE = config;
|
||||
console.log(`Configuration file: ${config}`);
|
||||
}
|
||||
|
||||
// 构建启动参数
|
||||
const serverScript = resolveServerScriptPath();
|
||||
const args = [];
|
||||
|
||||
// 使用 cross-spawn 确保跨平台兼容性
|
||||
const child = spawn('node', [serverScript, ...args], {
|
||||
env: envVars,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
// 处理子进程输出
|
||||
child.stdout.on('data', (data) => {
|
||||
process.stdout.write(data);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(`Start service failed: ${err.message}`);
|
||||
});
|
||||
|
||||
// 等待服务启动
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// 检查服务是否成功启动
|
||||
if (!isProcessRunning(child.pid)) {
|
||||
return {
|
||||
success: false,
|
||||
pid: null,
|
||||
message: 'Service start failed',
|
||||
};
|
||||
}
|
||||
|
||||
// 写入 PID 文件
|
||||
const pidInfo = {
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
env: env || process.env.NODE_ENV || 'production',
|
||||
port: port || process.env.PORT || '60000',
|
||||
version: resolvePackageVersion(),
|
||||
platform: process.platform,
|
||||
};
|
||||
|
||||
writePidFile(pidInfo);
|
||||
|
||||
// 健康检查通过后再认定启动成功
|
||||
const healthTimeout = Number(options.startTimeout) || SERVICE_CONFIG.defaultStartTimeout;
|
||||
const healthy = await waitForServiceHealth(pidInfo.port, healthTimeout);
|
||||
if (!healthy) {
|
||||
console.error(`Service health check timeout (${healthTimeout}ms), stop failed instance...`);
|
||||
await stopProcess(child.pid, true);
|
||||
deletePidFile();
|
||||
return {
|
||||
success: false,
|
||||
pid: null,
|
||||
message: `Service health check timeout (${healthTimeout}ms)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 解除子进程关联,使其独立运行
|
||||
child.unref();
|
||||
|
||||
console.log(`Service started (PID: ${child.pid})`);
|
||||
console.log(`Service address: http://localhost:${pidInfo.port}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pid: child.pid,
|
||||
message: 'Service started successfully',
|
||||
};
|
||||
} finally {
|
||||
releaseStartLock(lockHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止服务
|
||||
*
|
||||
* 停止 qiming-file-server 服务进程
|
||||
*
|
||||
* @param {Object} options - 停止选项
|
||||
* @param {boolean} [options.force=false] - 是否强制停止
|
||||
* @param {number} [options.timeout] - 超时时间(毫秒)
|
||||
* @returns {Promise<Object>} 停止结果
|
||||
* @property {boolean} success - 是否成功
|
||||
* @property {string} message - 状态消息
|
||||
*/
|
||||
async function stopService(options = {}) {
|
||||
const { force = false, timeout = SERVICE_CONFIG.defaultStopTimeout } = options;
|
||||
|
||||
console.log(`Stop service ${SERVICE_CONFIG.name}...`);
|
||||
|
||||
// 读取 PID 文件
|
||||
const pidInfo = readPidFile();
|
||||
|
||||
if (!pidInfo) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Service not found',
|
||||
};
|
||||
}
|
||||
|
||||
// 检查进程是否正在运行
|
||||
if (!isProcessRunning(pidInfo.pid)) {
|
||||
console.log('Service process has stopped, clean PID file...');
|
||||
deletePidFile();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Service has stopped (process has exited)',
|
||||
};
|
||||
}
|
||||
|
||||
// 停止进程
|
||||
const stopped = await stopProcess(pidInfo.pid, force);
|
||||
|
||||
if (!stopped) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Stop service failed',
|
||||
};
|
||||
}
|
||||
|
||||
// 等待进程完全退出
|
||||
const exited = await waitForProcessStop(pidInfo.pid, timeout);
|
||||
|
||||
// 清理 PID 文件
|
||||
deletePidFile();
|
||||
|
||||
if (exited) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Service has stopped',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Service stop timeout',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启服务
|
||||
*
|
||||
* 先停止服务,再启动服务
|
||||
*
|
||||
* @param {Object} options - 重启选项(传递给 startService 和 stopService)
|
||||
* @returns {Promise<Object>} 重启结果
|
||||
*/
|
||||
async function restartService(options = {}) {
|
||||
console.log(`Restart service ${SERVICE_CONFIG.name}...`);
|
||||
|
||||
// 先停止服务
|
||||
const stopResult = await stopService(options);
|
||||
|
||||
// 如果停止失败但服务可能未运行,继续尝试启动
|
||||
if (!stopResult.success && stopResult.message !== 'Service not found') {
|
||||
console.warn(`Stop service failed: ${stopResult.message}`);
|
||||
}
|
||||
|
||||
// 等待一段时间
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// 启动服务
|
||||
const startResult = await startService(options);
|
||||
|
||||
if (startResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
pid: startResult.pid,
|
||||
message: 'Service has restarted',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
pid: null,
|
||||
message: `Restart failed: ${startResult.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务状态
|
||||
*
|
||||
* @returns {Object} 服务状态
|
||||
* @property {boolean} running - 服务是否运行中
|
||||
* @property {Object|null} pidInfo - PID 信息对象
|
||||
* @property {string} message - 状态消息
|
||||
*/
|
||||
function getServiceStatus() {
|
||||
const pidInfo = readPidFile();
|
||||
|
||||
if (!pidInfo) {
|
||||
return {
|
||||
running: false,
|
||||
pidInfo: null,
|
||||
message: 'Service not running',
|
||||
};
|
||||
}
|
||||
|
||||
const running = isProcessRunning(pidInfo.pid);
|
||||
|
||||
if (running) {
|
||||
return {
|
||||
running: true,
|
||||
pidInfo: pidInfo,
|
||||
message: 'Service running',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
running: false,
|
||||
pidInfo: pidInfo,
|
||||
message: 'Service process does not exist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化运行时间
|
||||
*
|
||||
* @param {string} startedAt - 启动时间 ISO 字符串
|
||||
* @returns {string} 格式化的运行时间字符串
|
||||
*/
|
||||
function formatUptime(startedAt) {
|
||||
try {
|
||||
const start = new Date(startedAt);
|
||||
const now = new Date();
|
||||
|
||||
// 检查日期是否有效
|
||||
if (isNaN(start.getTime())) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const uptime = Math.floor((now - start) / 1000);
|
||||
|
||||
const hours = Math.floor(uptime / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = uptime % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours} hours ${minutes} minutes ${seconds} seconds`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} minutes ${seconds} seconds`;
|
||||
} else {
|
||||
return `${seconds} seconds`;
|
||||
}
|
||||
} catch (err) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ESM 导出
|
||||
export {
|
||||
SERVICE_CONFIG,
|
||||
getPidFilePath,
|
||||
getLockFilePath,
|
||||
resolveServerScriptPath,
|
||||
resolvePackageVersion,
|
||||
readPidFile,
|
||||
writePidFile,
|
||||
deletePidFile,
|
||||
isProcessRunning,
|
||||
isTargetServiceProcess,
|
||||
acquireStartLock,
|
||||
releaseStartLock,
|
||||
ensureNoRunningServiceBeforeStart,
|
||||
stopProcess,
|
||||
waitForProcessStop,
|
||||
waitForServiceHealth,
|
||||
startService,
|
||||
stopService,
|
||||
restartService,
|
||||
getServiceStatus,
|
||||
isWindows,
|
||||
formatUptime,
|
||||
};
|
||||
|
||||
275
qiming-file-server/tests/README.md
Normal file
275
qiming-file-server/tests/README.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# 测试文档
|
||||
|
||||
## 测试框架
|
||||
|
||||
本项目使用 **Jest** 作为测试框架,配合 **Supertest** 进行 API 集成测试。
|
||||
|
||||
## 测试目录结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── setup.js # 测试环境配置
|
||||
├── unit/ # 单元测试
|
||||
│ ├── errorHandler.test.js
|
||||
│ ├── restartJudgeUtils.test.js
|
||||
│ └── dependencyManager.test.js
|
||||
└── integration/ # 集成测试(待添加)
|
||||
└── api.test.js
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 运行所有测试
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### 运行测试并监听文件变化
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### 只运行单元测试
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### 只运行集成测试
|
||||
|
||||
```bash
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
### 查看测试覆盖率
|
||||
|
||||
测试覆盖率报告会自动生成在 `coverage/` 目录下。
|
||||
|
||||
```bash
|
||||
# 运行测试后查看覆盖率
|
||||
npm test
|
||||
|
||||
# 在浏览器中查看详细报告
|
||||
open coverage/lcov-report/index.html
|
||||
```
|
||||
|
||||
## 测试覆盖率目标
|
||||
|
||||
当前设置的覆盖率阈值:
|
||||
|
||||
- **分支覆盖率**: 50%
|
||||
- **函数覆盖率**: 50%
|
||||
- **行覆盖率**: 50%
|
||||
- **语句覆盖率**: 50%
|
||||
|
||||
## 编写测试
|
||||
|
||||
### 单元测试示例
|
||||
|
||||
```javascript
|
||||
// tests/unit/myModule.test.js
|
||||
const { myFunction } = require("../../src/utils/myModule");
|
||||
|
||||
describe("我的模块测试", () => {
|
||||
test("应该返回正确的结果", () => {
|
||||
const result = myFunction("input");
|
||||
expect(result).toBe("expected output");
|
||||
});
|
||||
|
||||
test("应该处理错误情况", () => {
|
||||
expect(() => {
|
||||
myFunction(null);
|
||||
}).toThrow("错误信息");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 集成测试示例
|
||||
|
||||
```javascript
|
||||
// tests/integration/api.test.js
|
||||
const request = require("supertest");
|
||||
const app = require("../../src/server");
|
||||
|
||||
describe("API 集成测试", () => {
|
||||
test("GET /api/build/list-dev 应该返回进程列表", async () => {
|
||||
const response = await request(app).get("/api/build/list-dev").expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(Array.isArray(response.body.list)).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 测试工具函数
|
||||
|
||||
在 `tests/setup.js` 中提供了全局测试工具:
|
||||
|
||||
```javascript
|
||||
// 生成测试用的 projectId
|
||||
const projectId = global.testUtils.generateProjectId();
|
||||
|
||||
// 生成测试用的版本号
|
||||
const version = global.testUtils.generateVersion();
|
||||
|
||||
// 等待函数
|
||||
await global.testUtils.sleep(1000); // 等待1秒
|
||||
```
|
||||
|
||||
## 测试最佳实践
|
||||
|
||||
### 1. 测试命名
|
||||
|
||||
- 使用清晰的描述性名称
|
||||
- 使用 `describe` 分组相关测试
|
||||
- 使用 `test` 或 `it` 描述单个测试用例
|
||||
|
||||
### 2. 测试隔离
|
||||
|
||||
- 每个测试应该独立运行
|
||||
- 使用 `beforeEach` 和 `afterEach` 清理状态
|
||||
- 避免测试之间的依赖关系
|
||||
|
||||
### 3. 测试覆盖
|
||||
|
||||
- 测试正常情况
|
||||
- 测试边界条件
|
||||
- 测试错误情况
|
||||
- 测试异常输入
|
||||
|
||||
### 4. Mock 和 Stub
|
||||
|
||||
```javascript
|
||||
// Mock 外部依赖
|
||||
jest.mock("../../src/utils/externalService");
|
||||
|
||||
// Mock 函数
|
||||
const mockFn = jest.fn().mockReturnValue("mocked value");
|
||||
|
||||
// 验证调用
|
||||
expect(mockFn).toHaveBeenCalledWith("expected argument");
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
```
|
||||
|
||||
### 5. 异步测试
|
||||
|
||||
```javascript
|
||||
// 使用 async/await
|
||||
test("异步操作", async () => {
|
||||
const result = await asyncFunction();
|
||||
expect(result).toBe("expected");
|
||||
});
|
||||
|
||||
// 使用 Promise
|
||||
test("Promise 操作", () => {
|
||||
return promiseFunction().then((result) => {
|
||||
expect(result).toBe("expected");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 常用断言
|
||||
|
||||
```javascript
|
||||
// 相等性
|
||||
expect(value).toBe(expected); // ===
|
||||
expect(value).toEqual(expected); // 深度相等
|
||||
|
||||
// 真值
|
||||
expect(value).toBeTruthy();
|
||||
expect(value).toBeFalsy();
|
||||
expect(value).toBeNull();
|
||||
expect(value).toBeUndefined();
|
||||
expect(value).toBeDefined();
|
||||
|
||||
// 数字
|
||||
expect(value).toBeGreaterThan(3);
|
||||
expect(value).toBeGreaterThanOrEqual(3.5);
|
||||
expect(value).toBeLessThan(5);
|
||||
expect(value).toBeLessThanOrEqual(4.5);
|
||||
|
||||
// 字符串
|
||||
expect(string).toMatch(/pattern/);
|
||||
expect(string).toContain("substring");
|
||||
|
||||
// 数组
|
||||
expect(array).toContain(item);
|
||||
expect(array).toHaveLength(3);
|
||||
|
||||
// 对象
|
||||
expect(object).toHaveProperty("key");
|
||||
expect(object).toHaveProperty("key", "value");
|
||||
|
||||
// 异常
|
||||
expect(() => {
|
||||
throw new Error("error");
|
||||
}).toThrow("error");
|
||||
```
|
||||
|
||||
## 调试测试
|
||||
|
||||
### 1. 运行单个测试文件
|
||||
|
||||
```bash
|
||||
npm test -- tests/unit/errorHandler.test.js
|
||||
```
|
||||
|
||||
### 2. 运行匹配的测试
|
||||
|
||||
```bash
|
||||
npm test -- --testNamePattern="应该正确创建"
|
||||
```
|
||||
|
||||
### 3. 使用 Node 调试器
|
||||
|
||||
```bash
|
||||
node --inspect-brk node_modules/.bin/jest --runInBand
|
||||
```
|
||||
|
||||
## 持续集成
|
||||
|
||||
测试应该在每次提交前运行:
|
||||
|
||||
```bash
|
||||
# 提交前运行测试
|
||||
npm test
|
||||
|
||||
# 如果测试通过,再提交代码
|
||||
git add .
|
||||
git commit -m "feat: 添加新功能"
|
||||
```
|
||||
|
||||
## 测试环境配置
|
||||
|
||||
测试环境使用独立的配置:
|
||||
|
||||
- 端口: 10003
|
||||
- 日志: 禁用控制台输出
|
||||
- 超时: 10 秒
|
||||
|
||||
这些配置在 `tests/setup.js` 中设置。
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **不要提交失败的测试** - 确保所有测试通过后再提交
|
||||
2. **保持测试快速** - 单元测试应该在毫秒级完成
|
||||
3. **定期更新测试** - 代码变更时同步更新测试
|
||||
4. **编写有意义的测试** - 测试应该验证真实的业务逻辑
|
||||
5. **避免测试实现细节** - 测试行为,而非实现
|
||||
|
||||
## 贡献测试
|
||||
|
||||
欢迎为项目添加更多测试!优先添加:
|
||||
|
||||
1. 核心业务逻辑的单元测试
|
||||
2. API 端点的集成测试
|
||||
3. 边界条件和错误处理测试
|
||||
4. 工具函数的测试
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [Jest 文档](https://jestjs.io/docs/getting-started)
|
||||
- [Supertest 文档](https://github.com/visionmedia/supertest)
|
||||
- [测试最佳实践](https://github.com/goldbergyoni/javascript-testing-best-practices)
|
||||
33
qiming-file-server/tests/setup.js
Normal file
33
qiming-file-server/tests/setup.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Jest 测试环境设置
|
||||
|
||||
// 设置测试环境变量
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.PORT = "10003";
|
||||
process.env.LOG_CONSOLE_ENABLED = "false";
|
||||
|
||||
// 增加测试超时时间(ESM 兼容写法)
|
||||
if (typeof jest !== "undefined") {
|
||||
jest.setTimeout(10000);
|
||||
}
|
||||
|
||||
// 全局测试工具
|
||||
globalThis.testUtils = {
|
||||
// 生成测试用的 projectId
|
||||
generateProjectId: () => `test-project-${Date.now()}`,
|
||||
|
||||
// 生成测试用的版本号
|
||||
generateVersion: () => Math.floor(Math.random() * 100),
|
||||
|
||||
// 等待函数
|
||||
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
};
|
||||
|
||||
// 测试前清理
|
||||
beforeAll(() => {
|
||||
console.log("🧪 开始测试...");
|
||||
});
|
||||
|
||||
// 测试后清理
|
||||
afterAll(() => {
|
||||
console.log("✅ 测试完成!");
|
||||
});
|
||||
406
qiming-file-server/tests/unit/cli.test.js
Normal file
406
qiming-file-server/tests/unit/cli.test.js
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* CLI 模块测试
|
||||
*
|
||||
* 测试 qiming-file-server CLI 相关的功能
|
||||
*
|
||||
* 覆盖范围:
|
||||
* - serviceManager.js 服务管理器
|
||||
* - envUtils.js 环境变量工具
|
||||
* - PID 文件操作
|
||||
* - 跨平台兼容性
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import os from "os";
|
||||
|
||||
// 测试配置文件路径
|
||||
const testConfig = {
|
||||
testPidDir: path.join(os.tmpdir(), "qiming-file-server-test"),
|
||||
testPidFile: path.join(
|
||||
os.tmpdir(),
|
||||
"qiming-file-server-test",
|
||||
"server.pid"
|
||||
),
|
||||
};
|
||||
|
||||
describe("CLI Service Manager", () => {
|
||||
let serviceManager;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../src/utils/serviceManager.js");
|
||||
serviceManager = module.default || module;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (fs.existsSync(testConfig.testPidFile)) {
|
||||
fs.removeSync(testConfig.testPidFile);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略清理错误
|
||||
}
|
||||
});
|
||||
|
||||
describe("getPidFilePath", () => {
|
||||
it("应该返回有效的 PID 文件路径", () => {
|
||||
const pidPath = serviceManager.getPidFilePath();
|
||||
|
||||
expect(pidPath).toBeDefined();
|
||||
expect(typeof pidPath).toBe("string");
|
||||
expect(pidPath.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("应该在临时目录中", () => {
|
||||
const pidPath = serviceManager.getPidFilePath();
|
||||
const tmpDir = os.tmpdir();
|
||||
|
||||
expect(pidPath.startsWith(tmpDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWindows", () => {
|
||||
it("应该正确检测当前操作系统", () => {
|
||||
const isWin = serviceManager.isWindows();
|
||||
|
||||
const currentPlatform = process.platform;
|
||||
|
||||
if (currentPlatform === "win32") {
|
||||
expect(isWin).toBe(true);
|
||||
} else {
|
||||
expect(isWin).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProcessRunning", () => {
|
||||
it("应该正确判断不存在的进程", () => {
|
||||
const result = serviceManager.isProcessRunning(999999999);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("应该正确判断当前进程", () => {
|
||||
const result = serviceManager.isProcessRunning(process.pid);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readPidFile", () => {
|
||||
it("应该返回 null 如果文件不存在", () => {
|
||||
const result = serviceManager.readPidFile();
|
||||
|
||||
expect(result === null || result === undefined).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writePidFile and readPidFile", () => {
|
||||
it("应该能够写入和读取 PID 文件", () => {
|
||||
const testPidInfo = {
|
||||
pid: 12345,
|
||||
startedAt: new Date().toISOString(),
|
||||
env: "test",
|
||||
port: "60000",
|
||||
version: "1.0.0",
|
||||
platform: process.platform,
|
||||
};
|
||||
|
||||
const testPidPath = path.join(testConfig.testPidDir, "server.pid");
|
||||
fs.ensureDirSync(testConfig.testPidDir);
|
||||
fs.writeFileSync(testPidPath, JSON.stringify(testPidInfo, null, 2));
|
||||
|
||||
const content = fs.readFileSync(testPidPath, "utf8");
|
||||
const readPidInfo = JSON.parse(content);
|
||||
|
||||
expect(readPidInfo.pid).toBe(12345);
|
||||
expect(readPidInfo.env).toBe("test");
|
||||
expect(readPidInfo.port).toBe("60000");
|
||||
|
||||
fs.removeSync(testConfig.testPidDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUptime", () => {
|
||||
it("应该正确格式化运行时间", () => {
|
||||
const startedAt = new Date(Date.now() - 3600000).toISOString();
|
||||
const result = serviceManager.formatUptime(startedAt);
|
||||
|
||||
expect(result).toContain("小时");
|
||||
});
|
||||
|
||||
it("应该处理无效的日期", () => {
|
||||
const result = serviceManager.formatUptime("invalid-date");
|
||||
|
||||
expect(result).toBe("未知");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServiceStatus", () => {
|
||||
it("应该返回正确的服务状态", () => {
|
||||
const status = serviceManager.getServiceStatus();
|
||||
|
||||
expect(status).toHaveProperty("running");
|
||||
expect(status).toHaveProperty("pidInfo");
|
||||
expect(status).toHaveProperty("message");
|
||||
expect(typeof status.running).toBe("boolean");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Environment Utils", () => {
|
||||
let envUtils;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../src/utils/envUtils.js");
|
||||
envUtils = module.default || module;
|
||||
});
|
||||
|
||||
describe("isWindows", () => {
|
||||
it("应该正确检测 Windows 平台", () => {
|
||||
const isWin = envUtils.isWindows();
|
||||
const currentPlatform = process.platform;
|
||||
|
||||
expect(isWin).toBe(currentPlatform === "win32");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEnvName", () => {
|
||||
it("Windows 环境应该转为大写", () => {
|
||||
if (process.platform === "win32") {
|
||||
const result = envUtils.normalizeEnvName("test_env");
|
||||
expect(result).toBe("TEST_ENV");
|
||||
} else {
|
||||
const result = envUtils.normalizeEnvName("test_env");
|
||||
expect(result).toBe("test_env");
|
||||
}
|
||||
});
|
||||
|
||||
it("应该处理空字符串", () => {
|
||||
const result = envUtils.normalizeEnvName("");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnv", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TEST_VAR = "test-value";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TEST_VAR;
|
||||
});
|
||||
|
||||
it("应该返回已设置的环境变量", () => {
|
||||
const result = envUtils.getEnv("TEST_VAR");
|
||||
expect(result).toBe("test-value");
|
||||
});
|
||||
|
||||
it("应该返回默认值如果未设置", () => {
|
||||
const result = envUtils.getEnv("NON_EXISTENT_VAR", "default");
|
||||
expect(result).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBoolEnv", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TEST_BOOL_TRUE = "true";
|
||||
process.env.TEST_BOOL_FALSE = "false";
|
||||
process.env.TEST_BOOL_ONE = "1";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TEST_BOOL_TRUE;
|
||||
delete process.env.TEST_BOOL_FALSE;
|
||||
delete process.env.TEST_BOOL_ONE;
|
||||
});
|
||||
|
||||
it("应该正确解析 true 值", () => {
|
||||
expect(envUtils.getBoolEnv("TEST_BOOL_TRUE")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该正确解析 1 值", () => {
|
||||
expect(envUtils.getBoolEnv("TEST_BOOL_ONE")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该正确解析 false 值", () => {
|
||||
expect(envUtils.getBoolEnv("TEST_BOOL_FALSE")).toBe(false);
|
||||
});
|
||||
|
||||
it("应该返回默认值如果未设置", () => {
|
||||
expect(envUtils.getBoolEnv("NON_EXISTENT", true)).toBe(true);
|
||||
expect(envUtils.getBoolEnv("NON_EXISTENT", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNumberEnv", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TEST_NUMBER = "123";
|
||||
process.env.TEST_NAN = "abc";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TEST_NUMBER;
|
||||
delete process.env.TEST_NAN;
|
||||
});
|
||||
|
||||
it("应该正确解析数字", () => {
|
||||
const result = envUtils.getNumberEnv("TEST_NUMBER");
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
|
||||
it("应该返回默认值对于 NaN", () => {
|
||||
const result = envUtils.getNumberEnv("TEST_NAN", 0);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("应该返回默认值如果未设置", () => {
|
||||
const result = envUtils.getNumberEnv("NON_EXISTENT", 42);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEnvType", () => {
|
||||
it("应该正确解析 dev", () => {
|
||||
expect(envUtils.parseEnvType("dev")).toBe("development");
|
||||
});
|
||||
|
||||
it("应该正确解析 prod", () => {
|
||||
expect(envUtils.parseEnvType("prod")).toBe("production");
|
||||
});
|
||||
|
||||
it("应该正确解析 test", () => {
|
||||
expect(envUtils.parseEnvType("test")).toBe("test");
|
||||
});
|
||||
|
||||
it("应该处理大小写", () => {
|
||||
expect(envUtils.parseEnvType("DEV")).toBe("development");
|
||||
expect(envUtils.parseEnvType("PROD")).toBe("production");
|
||||
});
|
||||
|
||||
it("应该处理空值", () => {
|
||||
expect(envUtils.parseEnvType("")).toBe("production");
|
||||
expect(envUtils.parseEnvType(null)).toBe("production");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadEnvFromArgv", () => {
|
||||
it("应该解析命令行参数", () => {
|
||||
const originalArgv = process.argv;
|
||||
process.argv = ["node", "test", "--test-key", "test-value"];
|
||||
|
||||
const result = envUtils.loadEnvFromArgv();
|
||||
|
||||
process.argv = originalArgv;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Cross-Platform Compatibility", () => {
|
||||
describe("Platform Detection", () => {
|
||||
it("应该正确检测 darwin (macOS)", () => {
|
||||
if (process.platform === "darwin") {
|
||||
expect(process.platform).toBe("darwin");
|
||||
}
|
||||
});
|
||||
|
||||
it("应该正确检测 linux", () => {
|
||||
if (process.platform === "linux") {
|
||||
expect(process.platform).toBe("linux");
|
||||
}
|
||||
});
|
||||
|
||||
it("应该正确检测 win32", () => {
|
||||
if (process.platform === "win32") {
|
||||
expect(process.platform).toBe("win32");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path Handling", () => {
|
||||
it("应该使用 path.join 进行路径拼接", () => {
|
||||
const result = path.join("dir", "subdir", "file.js");
|
||||
|
||||
expect(result).toMatch(/dir[\/\\]subdir[\/\\]file\.js/);
|
||||
});
|
||||
|
||||
it("应该使用 os.tmpdir() 获取临时目录", () => {
|
||||
const tmpDir = os.tmpdir();
|
||||
|
||||
expect(tmpDir).toBeDefined();
|
||||
expect(tmpDir.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("应该使用 os.homedir() 获取主目录", () => {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
expect(homeDir).toBeDefined();
|
||||
expect(homeDir.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment Variables", () => {
|
||||
it("应该能够读取环境变量", () => {
|
||||
process.env.TEST_READ = "test";
|
||||
|
||||
expect(process.env.TEST_READ).toBe("test");
|
||||
|
||||
delete process.env.TEST_READ;
|
||||
});
|
||||
|
||||
it("应该支持设置和删除环境变量", () => {
|
||||
process.env.TEMP_TEST_VAR = "temp-value";
|
||||
|
||||
expect(process.env.TEMP_TEST_VAR).toBe("temp-value");
|
||||
|
||||
delete process.env.TEMP_TEST_VAR;
|
||||
|
||||
expect(process.env.TEMP_TEST_VAR).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI Config", () => {
|
||||
let config;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("../../src/appConfig/index.js");
|
||||
config = module.default || module;
|
||||
});
|
||||
|
||||
describe("CLI Configuration", () => {
|
||||
it("应该包含 CLI 服务名称配置", () => {
|
||||
expect(config.CLI_SERVICE_NAME).toBe("qiming-file-server");
|
||||
});
|
||||
|
||||
it("应该包含 CLI PID 目录配置", () => {
|
||||
expect(config.CLI_PID_DIR).toBeDefined();
|
||||
expect(typeof config.CLI_PID_DIR).toBe("string");
|
||||
});
|
||||
|
||||
it("应该包含 CLI 停止超时配置", () => {
|
||||
expect(config.CLI_STOP_TIMEOUT).toBeDefined();
|
||||
expect(typeof config.CLI_STOP_TIMEOUT).toBe("number");
|
||||
});
|
||||
|
||||
it("应该包含 CLI 检查间隔配置", () => {
|
||||
expect(config.CLI_CHECK_INTERVAL).toBeDefined();
|
||||
expect(typeof config.CLI_CHECK_INTERVAL).toBe("number");
|
||||
});
|
||||
|
||||
it("应该包含 Windows 平台检测配置", () => {
|
||||
expect(config.CLI_IS_WINDOWS).toBeDefined();
|
||||
expect(typeof config.CLI_IS_WINDOWS).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Standard Configuration", () => {
|
||||
it("应该包含标准配置项", () => {
|
||||
expect(config.NODE_ENV).toBeDefined();
|
||||
expect(config.PORT).toBeDefined();
|
||||
expect(config.PROJECT_SOURCE_DIR).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
70
qiming-file-server/tests/unit/dependencyManager.test.js
Normal file
70
qiming-file-server/tests/unit/dependencyManager.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import fs from "fs";
|
||||
import {
|
||||
getFileMtime,
|
||||
shouldInstallDeps,
|
||||
} from "../../src/utils/buildDependency/dependencyManager.js";
|
||||
|
||||
// 在 ESM 模块中获取当前文件的实际路径
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
describe("依赖管理器测试", () => {
|
||||
describe("getFileMtime", () => {
|
||||
test("存在的文件应该返回时间戳", () => {
|
||||
// 使用当前测试文件作为测试对象,确保它存在且可访问
|
||||
const filePath = path.join(__dirname, "dependencyManager.test.js");
|
||||
const mtime = getFileMtime(filePath);
|
||||
expect(typeof mtime).toBe("number");
|
||||
expect(mtime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("不存在的文件应该返回0", () => {
|
||||
const filePath = "/path/to/nonexistent/file.txt";
|
||||
const mtime = getFileMtime(filePath);
|
||||
expect(mtime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldInstallDeps", () => {
|
||||
const testProjectPath = "/tmp/test-project-deps";
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(testProjectPath)) {
|
||||
fs.rmSync(testProjectPath, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(testProjectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(testProjectPath)) {
|
||||
fs.rmSync(testProjectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("不存在 node_modules 应该返回true", () => {
|
||||
fs.writeFileSync(path.join(testProjectPath, "package.json"), "{}");
|
||||
const result = shouldInstallDeps(testProjectPath);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("存在 node_modules 但 package.json 更新应该返回true", async () => {
|
||||
const nodeModulesPath = path.join(testProjectPath, "node_modules");
|
||||
fs.mkdirSync(nodeModulesPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
fs.writeFileSync(path.join(testProjectPath, "package.json"), "{}");
|
||||
const result = shouldInstallDeps(testProjectPath);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("package.json 和 node_modules 都存在且时间正常应该返回false", async () => {
|
||||
fs.writeFileSync(path.join(testProjectPath, "package.json"), "{}");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const nodeModulesPath = path.join(testProjectPath, "node_modules");
|
||||
fs.mkdirSync(nodeModulesPath);
|
||||
const result = shouldInstallDeps(testProjectPath);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
qiming-file-server/tests/unit/errorHandler.test.js
Normal file
96
qiming-file-server/tests/unit/errorHandler.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ValidationError,
|
||||
BusinessError,
|
||||
SystemError,
|
||||
ResourceError,
|
||||
FileError,
|
||||
ProcessError,
|
||||
formatErrorResponse,
|
||||
classifyError,
|
||||
} from "../../src/utils/error/errorHandler.js";
|
||||
|
||||
describe("错误处理工具测试", () => {
|
||||
describe("自定义错误类", () => {
|
||||
test("ValidationError 应该正确创建", () => {
|
||||
const error = new ValidationError("测试错误", { field: "test" });
|
||||
expect(error.name).toBe("ValidationError");
|
||||
expect(error.message).toBe("测试错误");
|
||||
expect(error.statusCode).toBe(400);
|
||||
expect(error.details).toEqual({ field: "test" });
|
||||
expect(error.isOperational).toBe(true);
|
||||
});
|
||||
|
||||
test("BusinessError 应该正确创建", () => {
|
||||
const error = new BusinessError("业务错误");
|
||||
expect(error.name).toBe("BusinessError");
|
||||
expect(error.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test("SystemError 应该正确创建", () => {
|
||||
const error = new SystemError("系统错误");
|
||||
expect(error.name).toBe("SystemError");
|
||||
expect(error.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
test("ResourceError 应该正确创建", () => {
|
||||
const error = new ResourceError("资源不存在");
|
||||
expect(error.name).toBe("ResourceError");
|
||||
expect(error.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test("FileError 应该正确创建", () => {
|
||||
const error = new FileError("文件错误");
|
||||
expect(error.name).toBe("FileError");
|
||||
expect(error.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
test("ProcessError 应该正确创建", () => {
|
||||
const error = new ProcessError("进程错误");
|
||||
expect(error.name).toBe("ProcessError");
|
||||
expect(error.statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatErrorResponse", () => {
|
||||
test("应该正确格式化错误响应", () => {
|
||||
const error = new ValidationError("测试错误", { field: "test" });
|
||||
const response = formatErrorResponse(error, "req-123");
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error.type).toBe("VALIDATION_ERROR");
|
||||
expect(response.error.message).toBe("测试错误");
|
||||
expect(response.error.requestId).toBe("req-123");
|
||||
});
|
||||
|
||||
test("应该包含 timestamp", () => {
|
||||
const error = new BusinessError("测试");
|
||||
const response = formatErrorResponse(error);
|
||||
|
||||
expect(response.error.timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyError", () => {
|
||||
test("应该正确分类 ValidationError", () => {
|
||||
const error = new ValidationError("测试");
|
||||
expect(classifyError(error)).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
test("应该正确分类 ENOENT 错误", () => {
|
||||
const error = new Error("文件不存在");
|
||||
error.code = "ENOENT";
|
||||
expect(classifyError(error)).toBe("RESOURCE_ERROR");
|
||||
});
|
||||
|
||||
test("应该正确分类 EACCES 错误", () => {
|
||||
const error = new Error("权限不足");
|
||||
error.code = "EACCES";
|
||||
expect(classifyError(error)).toBe("PERMISSION_ERROR");
|
||||
});
|
||||
|
||||
test("应该正确分类未知错误", () => {
|
||||
const error = new Error("未知错误");
|
||||
expect(classifyError(error)).toBe("UNKNOWN_ERROR");
|
||||
});
|
||||
});
|
||||
});
|
||||
96
qiming-file-server/tests/unit/restartJudgeUtils.test.js
Normal file
96
qiming-file-server/tests/unit/restartJudgeUtils.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
shouldRestartForSingleFile,
|
||||
shouldRestartDevServer,
|
||||
} from "../../src/utils/buildJudge/restartJudgeUtils.js";
|
||||
|
||||
describe("重启判断工具测试", () => {
|
||||
describe("shouldRestartForSingleFile", () => {
|
||||
test("package.json 应该需要重启", () => {
|
||||
expect(shouldRestartForSingleFile("package.json")).toBe(true);
|
||||
});
|
||||
|
||||
test("vite.config.js 应该需要重启", () => {
|
||||
expect(shouldRestartForSingleFile("vite.config.js")).toBe(true);
|
||||
expect(shouldRestartForSingleFile("vite.config.ts")).toBe(true);
|
||||
});
|
||||
|
||||
test("webpack.config.js 应该需要重启", () => {
|
||||
expect(shouldRestartForSingleFile("webpack.config.js")).toBe(true);
|
||||
});
|
||||
|
||||
test(".env 文件应该需要重启", () => {
|
||||
expect(shouldRestartForSingleFile(".env")).toBe(true);
|
||||
expect(shouldRestartForSingleFile(".env.development")).toBe(true);
|
||||
});
|
||||
|
||||
test("index.html 应该需要重启", () => {
|
||||
expect(shouldRestartForSingleFile("index.html")).toBe(true);
|
||||
});
|
||||
|
||||
test("入口文件应该需要重启", () => {
|
||||
expect(shouldRestartForSingleFile("src/main.js")).toBe(true);
|
||||
expect(shouldRestartForSingleFile("src/index.ts")).toBe(true);
|
||||
expect(shouldRestartForSingleFile("main.jsx")).toBe(true);
|
||||
expect(shouldRestartForSingleFile("App.tsx")).toBe(true);
|
||||
});
|
||||
|
||||
test("lock 文件应该需要重启", () => {
|
||||
expect(shouldRestartForSingleFile("package-lock.json")).toBe(true);
|
||||
expect(shouldRestartForSingleFile("yarn.lock")).toBe(true);
|
||||
expect(shouldRestartForSingleFile("pnpm-lock.yaml")).toBe(true);
|
||||
});
|
||||
|
||||
test("普通组件文件不需要重启", () => {
|
||||
expect(shouldRestartForSingleFile("src/components/Button.tsx")).toBe(
|
||||
false
|
||||
);
|
||||
expect(shouldRestartForSingleFile("src/utils/helper.js")).toBe(false);
|
||||
expect(shouldRestartForSingleFile("src/styles/main.css")).toBe(false);
|
||||
});
|
||||
|
||||
test("空字符串不需要重启", () => {
|
||||
expect(shouldRestartForSingleFile("")).toBe(false);
|
||||
});
|
||||
|
||||
test("非字符串参数不需要重启", () => {
|
||||
expect(shouldRestartForSingleFile(null)).toBe(false);
|
||||
expect(shouldRestartForSingleFile(undefined)).toBe(false);
|
||||
expect(shouldRestartForSingleFile(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRestartDevServer", () => {
|
||||
test("包含需要重启的文件应该返回true", () => {
|
||||
const files = [
|
||||
{ name: "src/components/Button.tsx" },
|
||||
{ name: "package.json" },
|
||||
{ name: "src/App.tsx" },
|
||||
];
|
||||
expect(shouldRestartDevServer(files)).toBe(true);
|
||||
});
|
||||
|
||||
test("都是普通文件应该返回false", () => {
|
||||
const files = [
|
||||
{ name: "src/components/Button.tsx" },
|
||||
{ name: "src/utils/helper.js" },
|
||||
{ name: "src/styles/main.css" },
|
||||
];
|
||||
expect(shouldRestartDevServer(files)).toBe(false);
|
||||
});
|
||||
|
||||
test("空数组应该返回false", () => {
|
||||
expect(shouldRestartDevServer([])).toBe(false);
|
||||
});
|
||||
|
||||
test("非数组参数应该返回false", () => {
|
||||
expect(shouldRestartDevServer(null)).toBe(false);
|
||||
expect(shouldRestartDevServer(undefined)).toBe(false);
|
||||
expect(shouldRestartDevServer("test")).toBe(false);
|
||||
});
|
||||
|
||||
test("文件对象缺少name属性应该被忽略", () => {
|
||||
const files = [{ contents: "test" }, { name: "package.json" }];
|
||||
expect(shouldRestartDevServer(files)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
uploadAttachmentFile,
|
||||
} from "../../src/utils/project/uploadAttachmentFileUtils.js";
|
||||
import {
|
||||
ValidationError,
|
||||
} from "../../src/utils/error/errorHandler.js";
|
||||
|
||||
describe("uploadAttachmentFileUtils", () => {
|
||||
describe("参数验证测试", () => {
|
||||
test("项目ID为空应该抛出 ValidationError", async () => {
|
||||
const mockFile = {
|
||||
originalname: "test.txt",
|
||||
path: "/tmp/test.txt",
|
||||
size: 4,
|
||||
};
|
||||
|
||||
await expect(uploadAttachmentFile("", mockFile)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("文件对象为空应该抛出 ValidationError", async () => {
|
||||
await expect(uploadAttachmentFile("test-project", null)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("文件路径为空应该抛出 ValidationError", async () => {
|
||||
const mockFile = {
|
||||
originalname: "test.txt",
|
||||
path: "",
|
||||
size: 4,
|
||||
};
|
||||
|
||||
await expect(uploadAttachmentFile("test-project", mockFile)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user