category: bug

severity: medium

content: Log paths are relative, and PM2 resolves relative `out_file`/`error_file` paths against the working directory where the `pm2` command is launched — not against the app's `cwd` (DEPLOY_DIR). Since this config explicitly supports being started from any directory via DEPLOY_DIR, logs may be written to unexpected locations (or the write fails if the directory doesn't exist there), making them hard for monitoring/rotation to find. Use absolute paths built from DEPLOY_DIR, e.g. `path.join(DEPLOY_DIR, 'logs/backend-error.log')` (require('path') at the top).

existing_code:
      error_file: 'logs/backend-error.log',
      out_file: 'logs/backend-out.log',


severity: low

content: No `kill_timeout` is configured. On `pm2 startOrReload` (and restarts), PM2 sends SIGINT and then SIGKILL after a short default grace period (~1.6s), which can abort in-flight HTTP requests and cut DB connections before the Nest app finishes graceful shutdown. Consider adding `kill_timeout: 5000` (and optionally `listen_timeout`) and ensure the app handles SIGINT to drain connections cleanly during deploys.

existing_code:
      autorestart: true,
      watch: false,


severity: high

content: `API_TARGET` is read from the environment but never actually used — the proxy hardcodes `127.0.0.1:3000` (hostname, port, and host header are all hardcoded in three places). If `API_TARGET` points to a different host/port, requests are still forwarded to 127.0.0.1:3000, silently breaking the configuration. Parse the target once with `new URL(API_TARGET)` and derive hostname/port/host from it (note `http.request` also can't handle an `https://` target).

suggestion_code:
    const target = new URL(API_TARGET);
    const opts = {
      hostname: target.hostname,
      port: target.port || (target.protocol === 'https:' ? 443 : 80),
      path: (target.pathname === '/' ? '' : target.pathname) + req.url,
      method: req.method,
      headers: { ...req.headers, host: target.host },
    };

existing_code:
    const opts = {
      hostname: '127.0.0.1',
      port: 3000,
      path: req.url,
      method: req.method,
      headers: { ...req.headers, host: '127.0.0.1:3000' },
    };


content: The catch-all fallback swallows every read error (ENOENT, EACCES, etc.) and returns `index.html` with a 200 for ANY missing file — including missing `.js`/`.css` assets. This serves HTML where a script module was expected (breaking module loading and masking real deployment problems) and never produces a 404. Worse, if `apps/admin/dist/index.html` itself is missing, `fs.readFileSync` throws inside the catch block, propagates out of the request handler, and crashes the process. Only fall back for navigation requests (no file extension / `Accept: text/html`) and return a proper 404 for missing assets; guard the index.html read as well.

existing_code:
  } catch {
    // SPA fallback: return index.html
    const index = fs.readFileSync(path.join(STATIC_DIR, 'index.html'));
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(index);
  }


category: performance

content: `fs.readFileSync` blocks the Node event loop on every request. Under load, all concurrent requests are serialized behind synchronous file I/O, and large files are fully buffered in memory. Use async `fs.promises.readFile` or stream the file with `fs.createReadStream`.

suggestion_code:
    const stream = fs.createReadStream(filePath);
    res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' });
    stream.pipe(res);

existing_code:
    const content = fs.readFileSync(filePath);
    res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' });


content: Two proxy robustness issues: (1) `proxyRes.headers` is forwarded verbatim, including hop-by-hop headers (`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, …). Because the response is piped, Node sets its own `transfer-encoding`/`content-length`, which can conflict with the forwarded headers and cause protocol errors or truncated responses — strip hop-by-hop headers before `writeHead`. (2) `proxy.on('error')` can fire after headers have already been written (mid-stream upstream failure or client disconnect), so the `res.writeHead(502)` call will throw uncaught; guard with `res.headersSent`, add a timeout for the upstream connection, and handle errors from `req.pipe(proxy)` / `proxyRes.pipe(res)`.

existing_code:
    const proxy = http.request(opts, (proxyRes) => {
      res.writeHead(proxyRes.statusCode, proxyRes.headers);
      proxyRes.pipe(res);
    });


category: security

content: Because every request URL is absolute (starts with `/`), `path.normalize` already clips leading `..` at the root, so the `replace(/^((\.\.(\/|\\|$))+)/, '')` regex never matches anything — it's misleading dead code. Percent-encoded traversal (`%2e%2e%2f`) is neither decoded nor checked, and `path.join`/`normalize` semantics are platform-dependent. Decode the URL with `decodeURIComponent` and explicitly verify the resolved path stays inside `STATIC_DIR` (e.g., `path.resolve(...)` and `startsWith(STATIC_DIR + path.sep)`), returning 403/404 otherwise; this also fixes serving filenames containing spaces/encoded characters.

existing_code:
  const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
  const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, '');
  serveStatic(res, path.join(STATIC_DIR, safePath));


content: 密码以 `-p"${MYSQL_PASS}"` 形式拼在命令行参数中，会出现在 `ps aux` 进程列表里，同机其他用户可直接看到明文密码。建议改用 `docker compose exec -T -e MYSQL_PWD="${MYSQL_PASS}" mysql mysql -u root ...` 方式传入，避免密码暴露在进程列表中。

suggestion_code: gunzip -c "$DUMP" | docker compose exec -T -e MYSQL_PWD="${MYSQL_PASS}" mysql mysql -u root dorm_billing

existing_code: gunzip -c "$DUMP" | docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" dorm_billing


content: 硬编码默认密码 `gongxue_2024` 作为 `.env` 缺失时的回退值。该凭据一旦随代码库泄露即等于泄露，且会静默使用弱口令/已知口令部署。建议强制要求 MYSQL_ROOT_PASSWORD 从 `.env` 或环境变量注入，缺失时直接报错退出，而不是回退到默认值。


content: 脚本最后把含明文密码的完整命令 `-p${MYSQL_PASS}` echo 到终端/日志，直接泄露凭据（也会进入 CI/终端历史）。建议只输出不含密码的验证命令，例如提示使用 MYSQL_PWD 或提示输入密码。

suggestion_code: echo "  docker compose exec -e MYSQL_PWD=<password> mysql mysql -u root gongxue -e 'SELECT COUNT(*) FROM students'"

existing_code: echo "  docker compose exec mysql mysql -u root -p${MYSQL_PASS} gongxue -e 'SELECT COUNT(*) FROM students'"


content: 固定 `sleep 8` 无法保证 TypeORM synchronize 已完成建表：机器较慢或应用启动耗时较长时，脚本会提前进入步骤 3，导致 migrate-legacy.sql 因目标表不存在而失败（且失败后处于不一致状态）。建议改为轮询等待就绪条件（如循环检查目标表 `gongxue.students` 是否存在，或等待应用健康检查通过）再继续。

existing_code: sleep 8


content: `migrate-legacy.sql` 通过 stdin 直接执行，未包裹在事务中；若中途失败，数据将部分迁移、库处于不一致状态，且脚本没有备份/回滚机制。建议在 SQL 内显式使用 `START TRANSACTION ... COMMIT`（确认目标表为 InnoDB），或迁移前先备份目标库。

existing_code: docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" < migrate-legacy.sql


content: 脚本没有 trap 清理逻辑：若在 `pm2 start` 之后、`pm2 stop` 之前任一步失败，`set -e` 直接退出会留下运行中的应用；反之在 `pm2 stop` 后失败，应用会一直处于停止状态。另外，若 backend 已在运行（非严格首次运行场景），`pm2 start` 会因重复启动失败直接终止脚本。建议用 trap 在失败时恢复应用状态，并在 start 前先检测/处理已运行实例。

existing_code: DB_SYNCHRONIZE=true pm2 start ecosystem.config.cjs --only gongxue-backend


category: maintainability

content: `migrate-legacy.sql` 使用相对路径，脚本从其他工作目录执行时会找不到文件。建议在脚本开头 `cd "$(dirname "$0")"` 或使用相对脚本目录的绝对路径，保证脚本在任意目录下可运行。


content: Remote commands run without `set -e` (only the local shell enables `set -euo pipefail`). The ssh exit code is determined solely by the last remote command (`pm2 status`), so if `npm ci` or `npm run migration:run -w @gongxue/server` fails, the script still reloads PM2 and prints "部署完成！" — a failed migration is silently swallowed and the deploy is reported as successful. Add `set -euo pipefail` at the start of the remote command string (right after `cd ${REMOTE_DIR}`) or check each step's exit code explicitly.

existing_code:
  echo '执行数据库迁移...'
  npm run migration:run -w @gongxue/server
  echo 'PM2 重载...'
  pm2 startOrReload ecosystem.config.cjs --update-env
  pm2 save
  pm2 status


content: `--delete` will remove any server-only files that are not excluded. The repo contains no committed `.env` (only `.env.example`), so the production `.env` created manually on the server would be deleted on every deploy, and a local dev `.env` (if present) would overwrite the production one — leaking/erasing secrets. Add `--exclude='.env'` / `--exclude='.env.*'` and exclude any other server-only runtime data (e.g. `uploads/`) that must survive deploys.

existing_code:
rsync -avz --delete \
  --exclude='node_modules' \
  --exclude='.git' \
  --exclude='*.db' \
  --exclude='.DS_Store' \
  --exclude='logs/' \
  --exclude='.turbo/' \
  ./ "${SSH_HOST}:${REMOTE_DIR}/"


content: `npm ci` runs only when `node_modules` does not exist on the server. On subsequent deploys, changes to `package.json`/`package-lock.json` (new or removed dependencies) are never installed because `node_modules` is excluded from rsync and already present remotely — production can run with stale dependencies while the local build (used to produce the deployed bundle) was built with new ones. Always run `npm ci --omit=dev` on the server, or trigger it by comparing the remote and local `package-lock.json` checksums.

existing_code:
  if [ ! -d node_modules ]; then
    echo '首次部署，安装依赖...'
    npm ci --omit=dev
  fi


content: The fallback `|| curl -s ifconfig.me` is ineffective: `hostname -I 2>/dev/null | awk "{print $1}"` is a pipeline whose exit status is that of `awk`, which still exits 0 (with empty output) when `hostname -I` fails. The script then prints `http://` with no host. Capture the IP into a variable, validate it is non-empty, and only then fall back to curl.

existing_code: echo "访问: http://$(ssh "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')"


content: The React version is hardcoded to '19.0.0' with no match found in any package.json in this repo. This setting can silently drift from the actual dependency version, causing react-plugin rules to evaluate against a stale version. Prefer `version: 'detect'` (auto-detect from package.json) or keep this value in sync with the real dependency.


content: Globally disabling `typescript/no-explicit-any` removes the type-safety guardrail for the entire codebase, contradicting the project's TypeScript quality requirement of avoiding `any`. If `any` is only needed in a few places, keep the rule enabled and use targeted `// eslint-disable` comments (or annotate the `any` usage with a justification) instead of turning it off globally.

existing_code: 'typescript/no-explicit-any': 'off',


content: `--delete` will remove any files on the server that are not present in the source tree. Runtime config such as `.env` / `.env.*` (typically gitignored and thus absent from the repo) will be silently deleted on every deploy, breaking DB credentials and other environment settings. Add `--exclude='.env'` (and `--exclude='.env.*'`) or keep server-side config outside REMOTE_DIR.

existing_code:
          rsync -avz --delete \
            --exclude='node_modules' \
            --exclude='.git' \
            --exclude='*.db' \


content: Dependencies are only installed when `node_modules` does not exist. On subsequent deploys, changes to `package.json` / `package-lock.json` will never be installed, so the server keeps running stale dependency versions after the code has been updated. Run `npm ci --omit=dev` unconditionally (or compare a lockfile hash / mtime) instead of gating on directory existence.

existing_code:
            if [ ! -d node_modules ]; then
              echo '首次部署，安装生产依赖...'
              npm ci --omit=dev
            fi


content: No `concurrency` control is defined for this workflow. Two manual dispatches run at the same time can race on rsync `--delete`, run database migrations concurrently, and interfere with PM2 reload, potentially corrupting the deployed directory. Add a `concurrency` group (e.g. with `cancel-in-progress: true`) keyed to the deploy target.

existing_code:
on:
  workflow_dispatch: