diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c8131ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.gitignore +.cursor +.DS_Store +coverage +*.log +.env +docs +deploy/nginx diff --git a/.gitea/scripts/prepare-workspace.sh b/.gitea/scripts/prepare-workspace.sh new file mode 100644 index 0000000..929651d --- /dev/null +++ b/.gitea/scripts/prepare-workspace.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reusable bootstrap for Gitea workflows: +# 1) checkout target commit +# 2) ensure Node version (install from domestic mirror if missing/mismatch) +# 3) ensure Yarn is available (and optionally pin version) +# 通用准备脚本:用于 CI/Deploy 前的代码检出、Node 准备、Yarn 准备。 + +REPO_URL="${1:?repo url required}" +GIT_SHA="${2:?git sha required}" +# 优先使用精确版本 NODE_VERSION;未指定时按 NODE_MAJOR 自动解析最新小版本。 +NODE_MAJOR="${NODE_MAJOR:-22}" +NODE_VERSION="${NODE_VERSION:-}" +YARN_VERSION="${YARN_VERSION:-stable}" +# 默认使用国内镜像,减少下载失败概率。 +NODEJS_MIRROR="${NODEJS_MIRROR:-https://npmmirror.com/mirrors/node}" +NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}" +# 安装到用户目录,避免依赖 /opt 写权限。 +NODE_INSTALL_ROOT="${NODE_INSTALL_ROOT:-$HOME/.local/node-ci}" + +resolve_node_version() { + # 指定了精确版本时,直接使用。 + if [ -n "${NODE_VERSION}" ]; then + echo "${NODE_VERSION#v}" + return + fi + + # 未指定精确版本时,从镜像索引中选定 NODE_MAJOR 对应的最新版本。 + local resolved + resolved="$(curl -fsSL "${NODEJS_MIRROR}/index.tab" | awk -F'\t' -v major="${NODE_MAJOR}" ' + NR > 1 && $1 ~ ("^v" major "\\.") { print substr($1, 2); exit } + ')" + if [ -z "${resolved}" ]; then + echo "failed to resolve latest Node ${NODE_MAJOR}.x from ${NODEJS_MIRROR}" + exit 1 + fi + echo "${resolved}" +} + +detect_arch() { + # 仅支持常见 Linux 架构;其他架构显式失败,避免下载错误包。 + case "$(uname -m)" in + x86_64) echo "x64" ;; + aarch64 | arm64) echo "arm64" ;; + *) + echo "unsupported architecture: $(uname -m)" + exit 1 + ;; + esac +} + +install_node() { + local target_version="$1" + local arch name url tarball install_dir bindir + arch="$(detect_arch)" + name="node-v${target_version}-linux-${arch}" + url="${NODEJS_MIRROR}/v${target_version}/${name}.tar.xz" + tarball="/tmp/${name}.tar.xz" + install_dir="${NODE_INSTALL_ROOT}/${name}" + bindir="${install_dir}/bin" + + if [ ! -x "${bindir}/node" ]; then + # 下载并解压 Node 二进制包到本地缓存目录。 + mkdir -p "${NODE_INSTALL_ROOT}" + curl -fsSL "${url}" -o "${tarball}" + tar -xJf "${tarball}" -C "${NODE_INSTALL_ROOT}" + rm -f "${tarball}" + fi + + export PATH="${bindir}:${PATH}" +} + +ensure_node() { + local target_version node_version + target_version="$(resolve_node_version)" + + # 版本完全一致则直接复用,避免重复下载。 + if command -v node >/dev/null 2>&1; then + node_version="$(node -v | sed 's/^v//')" + if [ "${node_version}" = "${target_version}" ]; then + return + fi + fi + + install_node "${target_version}" + + node_version="$(node -v | sed 's/^v//')" + if [ "${node_version}" != "${target_version}" ]; then + echo "failed to switch to Node ${target_version}, current=${node_version}" + exit 1 + fi +} + +# 首次执行时检出目标提交;已存在仓库则更新并强制切到指定 SHA。 +if [ ! -d .git ]; then + git clone "${REPO_URL}" . +fi + +git fetch --all --tags --prune +git checkout -f "${GIT_SHA}" + +ensure_node + +# Use domestic npm mirror for package-manager metadata/download. +npm config set registry "${NPM_REGISTRY}" >/dev/null 2>&1 || true + +if ! command -v yarn >/dev/null 2>&1; then + # 通过 corepack 激活指定 Yarn 版本。 + corepack enable + export COREPACK_NPM_REGISTRY="${NPM_REGISTRY}" + corepack prepare "yarn@${YARN_VERSION}" --activate +fi + +echo "Node: $(node -v)" +echo "Yarn: $(yarn -v)" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..46e687a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + +jobs: + # workflow_call 调用示例(先保留注释,不启用): + # ci: + # uses: ./.gitea/workflows/reusable-ci.yml + # with: + # node_major: "22" + # yarn_version: "stable" + # run_lint: true + # run_tsc: true + + ci: + runs-on: ubuntu-latest + env: + NODE_MAJOR: "22" + YARN_VERSION: "stable" + steps: + - name: Prepare workspace (checkout + node/yarn) + run: | + set -euo pipefail + chmod +x .gitea/scripts/prepare-workspace.sh + ./.gitea/scripts/prepare-workspace.sh \ + "${{ github.server_url }}/${{ github.repository }}.git" \ + "${{ github.sha }}" + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Lint + run: yarn lint + - name: TypeScript check + run: yarn tsc --noEmit diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..6f6a589 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,80 @@ +name: Deploy Production + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + deploy: + if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + env: + NODE_MAJOR: "22" + YARN_VERSION: "stable" + DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} + DEPLOY_PORT: ${{ vars.DEPLOY_PORT }} + DEPLOY_USER: ${{ vars.DEPLOY_USER }} + DEPLOY_PATH: ${{ vars.DEPLOY_PATH }} + IMAGE_REPO: ${{ vars.IMAGE_REPO }} + REGISTRY: ${{ vars.REGISTRY }} + + steps: + - name: Prepare workspace (checkout + node/yarn) + run: | + set -euo pipefail + chmod +x .gitea/scripts/prepare-workspace.sh + ./.gitea/scripts/prepare-workspace.sh \ + "${{ github.server_url }}/${{ github.repository }}.git" \ + "${{ github.sha }}" + + - name: Setup SSH + run: | + set -euo pipefail + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -p "${DEPLOY_PORT:-22}" "${DEPLOY_HOST}" >> ~/.ssh/known_hosts + + - name: Prepare image tag + run: | + set -euo pipefail + if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then + IMAGE_TAG="${GITHUB_REF_NAME}" + else + IMAGE_TAG="${GITHUB_SHA:0:12}" + fi + echo "IMAGE_TAG=${IMAGE_TAG}" >> "$GITHUB_ENV" + + - name: Build and push image + run: | + set -euo pipefail + if [ -z "${REGISTRY}" ] || [ -z "${IMAGE_REPO}" ]; then + echo "REGISTRY and IMAGE_REPO vars are required" + exit 1 + fi + + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + docker build -f deploy/docker/Dockerfile -t "${IMAGE_REPO}:${IMAGE_TAG}" . + docker push "${IMAGE_REPO}:${IMAGE_TAG}" + + - name: Deploy + run: | + set -euo pipefail + ssh -p "${DEPLOY_PORT:-22}" "${DEPLOY_USER}@${DEPLOY_HOST}" < deploy/docker/.env </dev/null + EOF diff --git a/.gitea/workflows/reusable-ci.yml b/.gitea/workflows/reusable-ci.yml new file mode 100644 index 0000000..d20ca6a --- /dev/null +++ b/.gitea/workflows/reusable-ci.yml @@ -0,0 +1,51 @@ +name: Reusable CI + +on: + workflow_call: + inputs: + node_major: + description: "Node 主版本(例如 22)" + required: false + type: string + default: "22" + yarn_version: + description: "Yarn 版本(例如 stable 或 1.22.22)" + required: false + type: string + default: "stable" + run_lint: + description: "是否执行 yarn lint" + required: false + type: boolean + default: true + run_tsc: + description: "是否执行 yarn tsc --noEmit" + required: false + type: boolean + default: true + +jobs: + ci: + runs-on: ubuntu-latest + env: + NODE_MAJOR: ${{ inputs.node_major }} + YARN_VERSION: ${{ inputs.yarn_version }} + steps: + - name: Prepare workspace (checkout + node/yarn) + run: | + set -euo pipefail + chmod +x .gitea/scripts/prepare-workspace.sh + ./.gitea/scripts/prepare-workspace.sh \ + "${{ github.server_url }}/${{ github.repository }}.git" \ + "${{ github.sha }}" + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Lint + if: ${{ inputs.run_lint }} + run: yarn lint + + - name: TypeScript check + if: ${{ inputs.run_tsc }} + run: yarn tsc --noEmit diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example new file mode 100644 index 0000000..c78c542 --- /dev/null +++ b/deploy/docker/.env.example @@ -0,0 +1,42 @@ +# App +NODE_ENV=production +PORT=3000 +HOST_BIND_PORT=3000 +APP_NAME=chat-one-service +IMAGE_REPO=registry.example.com/team/chat-one-service +IMAGE_TAG=v0.0.1 + +# JWT +JWT_ACCESS_SECRET=replace_with_random_string_min_32_chars +JWT_REFRESH_SECRET=replace_with_random_string_min_32_chars +JWT_ACCESS_EXPIRES_IN=2h +JWT_REFRESH_EXPIRES_IN=30d + +# AI Providers +QWEN_API_KEY= +QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +VOLC_API_KEY= +VOLC_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 + +# SMS +SPUG_PUSH_SMS_TEMPLATE_ID= +SPUG_PUSH_BASE_URL=https://push.spug.cc +SMS_CODE_TTL_SECONDS=300 +SMS_MOCK=false + +# Redis (remote) +REDIS_HOST=your-remote-redis-host +REDIS_PORT=6379 +REDIS_PASSWORD=your-redis-password +REDIS_DB=1 +REDIS_KEY_PREFIX_CLIENT=chatone:client:prod +REDIS_KEY_PREFIX_ADMIN=chatone:admin:prod + +# PostgreSQL (remote) +DATABASE_URL=postgresql://user:password@your-remote-postgres-host:5432/chat_one?schema=public + +# AI Route +AI_ROUTE_RETRY_TIMES=1 +AI_ROUTE_TIMEOUT_MS=45000 diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..e6aab51 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,30 @@ +FROM node:22-alpine AS base +WORKDIR /app +RUN apk add --no-cache libc6-compat openssl +RUN npm i -g pm2 + +FROM base AS deps +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile + +FROM deps AS build +COPY tsconfig.json ./ +COPY prisma ./prisma +COPY src ./src +RUN yarn build + +FROM base AS runner +ENV NODE_ENV=production +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/prisma ./prisma +COPY package.json yarn.lock ./ +COPY deploy/docker/app.entrypoint.sh ./app.entrypoint.sh +COPY deploy/docker/ecosystem.config.cjs ./ecosystem.config.cjs + +RUN chmod +x ./app.entrypoint.sh + +EXPOSE 3000 +CMD ["./app.entrypoint.sh"] diff --git a/deploy/docker/app.entrypoint.sh b/deploy/docker/app.entrypoint.sh new file mode 100644 index 0000000..f40e4f2 --- /dev/null +++ b/deploy/docker/app.entrypoint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +echo "[entrypoint] running prisma migrate deploy..." +npx prisma migrate deploy + +echo "[entrypoint] starting service with pm2-runtime..." +pm2-runtime start ecosystem.config.cjs --env production diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..9c23ce5 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,9 @@ +services: + app: + image: ${IMAGE_REPO}:${IMAGE_TAG} + container_name: ${APP_NAME}-app + restart: unless-stopped + env_file: + - ./.env + ports: + - "127.0.0.1:${HOST_BIND_PORT}:${PORT}" diff --git a/deploy/docker/ecosystem.config.cjs b/deploy/docker/ecosystem.config.cjs new file mode 100644 index 0000000..a8406e9 --- /dev/null +++ b/deploy/docker/ecosystem.config.cjs @@ -0,0 +1,18 @@ +/* eslint-env node */ +module.exports = { + apps: [ + { + name: process.env.APP_NAME, + script: 'dist/main.js', + cwd: '/app', + instances: 1, + exec_mode: 'fork', + autorestart: true, + watch: false, + max_memory_restart: '1G', + env_production: { + NODE_ENV: 'production', + }, + }, + ], +}; diff --git a/deploy/nginx/chat-one-service.conf b/deploy/nginx/chat-one-service.conf deleted file mode 100644 index 129d009..0000000 --- a/deploy/nginx/chat-one-service.conf +++ /dev/null @@ -1,22 +0,0 @@ -server { - listen 80; - server_name api.example.com; - - client_max_body_size 20m; - - location / { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # SSE support - proxy_buffering off; - proxy_cache off; - proxy_read_timeout 3600; - proxy_send_timeout 3600; - chunked_transfer_encoding off; - } -} diff --git a/docs/deploy-docker-compose.md b/docs/deploy-docker-compose.md new file mode 100644 index 0000000..efce3a6 --- /dev/null +++ b/docs/deploy-docker-compose.md @@ -0,0 +1,167 @@ +# ChatOne Service Docker 部署(Compose) + +本文提供单机 Docker Compose 部署方案,包含 `app`(数据库与 Redis 使用远程实例)。 +域名、HTTPS、反向代理统一在宿主机 Nginx 处理。 + +## 1. 文件位置 + +- `deploy/docker/Dockerfile` +- `deploy/docker/app.entrypoint.sh` +- `deploy/docker/ecosystem.config.cjs` +- `deploy/docker/docker-compose.yml` +- `deploy/docker/.env.example` + +## 2. 初始化 + +```bash +cd deploy/docker +cp .env.example .env +``` + +修改 `.env` 中所有密钥与密码,至少包括: + +- `JWT_ACCESS_SECRET` +- `JWT_REFRESH_SECRET` +- `QWEN_API_KEY`(如使用) +- `DATABASE_URL` +- `REDIS_PASSWORD` +- `IMAGE_REPO` +- `IMAGE_TAG` + +请将 `DATABASE_URL`、`REDIS_HOST`、`REDIS_PASSWORD` 替换为远程实例配置。 +镜像发布模式说明: + +- CI 负责构建并推送 `${IMAGE_REPO}:${IMAGE_TAG}` +- 目标服务器仅执行 `docker compose pull + up` + +端口统一约定: + +- `PORT`:容器内应用监听端口 +- `HOST_BIND_PORT`:宿主机绑定端口(供宿主机 Nginx 反代) + +## 3. 启动 + +在项目根目录执行: + +```bash +docker compose -f deploy/docker/docker-compose.yml --env-file deploy/docker/.env up -d --build +``` + +查看状态: + +```bash +docker compose -f deploy/docker/docker-compose.yml ps +docker compose -f deploy/docker/docker-compose.yml logs -f app +``` + +## 4. 验证 + +```bash +curl -i http://127.0.0.1:${HOST_BIND_PORT}/api/docs +``` + +SSE 冒烟: + +- `POST /api/client/v1/chat/completions/stream` + +## 5. 发布升级 + +拉取新代码后执行: + +```bash +docker compose -f deploy/docker/docker-compose.yml --env-file deploy/docker/.env up -d --build app +``` + +数据库迁移会在 `app` 容器启动时自动执行(`npx prisma migrate deploy`)。 +应用进程由容器内 `pm2-runtime` 管理。 + +## 6. 回滚 + +推荐在发布前打镜像标签,例如: + +```bash +docker build -f deploy/docker/Dockerfile -t chat-one-service:20260426 . +``` + +回滚时将 compose 中 `app.image` 改为旧标签,然后: + +```bash +docker compose -f deploy/docker/docker-compose.yml --env-file deploy/docker/.env up -d app +``` + +## 7. 生产建议 + +- 不要把 `.env` 提交到仓库。 +- `app` 仅监听本机 `127.0.0.1:${HOST_BIND_PORT}`,由宿主机 Nginx 反向代理。 +- PostgreSQL/Redis 建议使用托管或远程实例,并通过白名单限制访问来源。 +- HTTPS 由宿主机 Nginx 统一终止,保留 SSE 反代配置(`proxy_buffering off`、长超时)。 +- 定时备份 Postgres: + - `pg_dump` 每日一次,保留 7-14 天并异地存储。 + +## 8. 宿主机 Nginx 配置示例 + +将域名流量转发到本机 `127.0.0.1:${HOST_BIND_PORT}`(容器 app 端口),并确保 SSE 可用。 + +### 8.1 HTTP 示例 + +```nginx +server { + listen 80; + server_name api.your-domain.com; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:${HOST_BIND_PORT}; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE support + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600; + proxy_send_timeout 3600; + chunked_transfer_encoding off; + } +} +``` + +### 8.2 HTTPS 示例(推荐) + +```nginx +server { + listen 443 ssl http2; + server_name api.your-domain.com; + + ssl_certificate /etc/letsencrypt/live/api.your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.your-domain.com/privkey.pem; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:${HOST_BIND_PORT}; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600; + proxy_send_timeout 3600; + chunked_transfer_encoding off; + } +} +``` + +应用配置后执行: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` diff --git a/docs/deploy-ubuntu-pm2-nginx.md b/docs/deploy-ubuntu-pm2-nginx.md deleted file mode 100644 index d3221cf..0000000 --- a/docs/deploy-ubuntu-pm2-nginx.md +++ /dev/null @@ -1,249 +0,0 @@ -# ChatOne Service 单机部署指南(Ubuntu + PM2 + Nginx) - -本文给出 `chat-one-service` 在单机 Linux 环境的可执行部署流程,覆盖: -- 服务器基线检查 -- 生产环境变量合同 -- 发布与回滚流程 -- Nginx 反向代理(含 SSE) -- 监控、备份与巡检 - -## 1. 适用范围 - -- OS:Ubuntu 22.04 LTS -- Runtime:Node.js 22.x -- 进程管理:PM2 -- 反向代理:Nginx -- 依赖:PostgreSQL、Redis(可为外部托管) - -## 2. 服务器基线检查(部署前) - -### 2.1 主机与网络 - -- [ ] 已准备公网域名(示例:`api.example.com`) -- [ ] `22/80/443` 已开放 -- [ ] 应用端口(默认 `3000`)仅允许本机访问 -- [ ] 时区与 NTP 已校准(`timedatectl status`) - -### 2.2 账号与权限 - -- [ ] 创建专用用户:`chatone` -- [ ] 禁止 root 直登,仅 SSH Key 登录 -- [ ] `chatone` 拥有 `/srv/chat-one-service` 读写权限 - -### 2.3 依赖连通性 - -- [ ] PostgreSQL 可连接(`DATABASE_URL` 可用) -- [ ] Redis 可连接(`REDIS_HOST/PORT` 可用) -- [ ] 第三方 AI API Key 已可用(如 `QWEN_API_KEY`) - -## 3. 目录约定 - -```bash -/srv/chat-one-service/ - releases/ - 20260423_120000/ - current -> /srv/chat-one-service/releases/20260423_120000 - shared/ - .env - logs/ -``` - -初始化目录: - -```bash -sudo mkdir -p /srv/chat-one-service/{releases,shared/logs} -sudo chown -R chatone:chatone /srv/chat-one-service -``` - -## 4. 运行时安装 - -```bash -# Node.js 22(nvm 方式,推荐) -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash -source ~/.nvm/nvm.sh -nvm install 22 -nvm use 22 - -# Yarn + PM2 -npm i -g yarn pm2 - -# Nginx + certbot -sudo apt-get update -sudo apt-get install -y nginx certbot python3-certbot-nginx postgresql-client redis-tools -``` - -## 5. 生产环境变量合同(shared/.env) - -在 `/srv/chat-one-service/shared/.env` 写入: - -```dotenv -# app -NODE_ENV=production -PORT=3000 -APP_NAME=chat-one-service - -# jwt(至少 32 位随机串) -JWT_ACCESS_SECRET=replace-with-long-random-string -JWT_REFRESH_SECRET=replace-with-long-random-string -JWT_ACCESS_EXPIRES_IN=2h -JWT_REFRESH_EXPIRES_IN=30d - -# database -DATABASE_URL=postgresql://user:password@host:5432/chat_one?schema=public - -# redis -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_PASSWORD= -REDIS_DB=0 -REDIS_KEY_PREFIX_CLIENT=chatone:client -REDIS_KEY_PREFIX_ADMIN=chatone:admin - -# ai -QWEN_API_KEY= -QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 -DEEPSEEK_API_KEY= -VOLC_API_KEY= - -# ai route -AI_ROUTE_RETRY_TIMES=1 -AI_ROUTE_TIMEOUT_MS=45000 -``` - -安全要求: -- `.env` 仅服务器本地保存,不入仓库。 -- 定期轮换密钥,变更后执行 `pm2 reload chat-one-service --update-env`。 - -## 6. 首次部署流程 - -以下命令以 `chatone` 用户执行: - -```bash -set -e -APP_ROOT=/srv/chat-one-service -RELEASE="$APP_ROOT/releases/$(date +%Y%m%d_%H%M%S)" - -mkdir -p "$RELEASE" -git clone "$RELEASE" -cd "$RELEASE" - -yarn install --frozen-lockfile -yarn build -npx prisma migrate deploy - -ln -sfn "$RELEASE" "$APP_ROOT/current" -``` - -启动: - -```bash -cd /srv/chat-one-service/current -pm2 start ecosystem.config.js --env production -pm2 save -pm2 startup -``` - -## 7. 日常发布流程(零停机) - -```bash -set -e -APP_ROOT=/srv/chat-one-service -RELEASE="$APP_ROOT/releases/$(date +%Y%m%d_%H%M%S)" - -mkdir -p "$RELEASE" -git clone "$RELEASE" -cd "$RELEASE" - -yarn install --frozen-lockfile -yarn build -npx prisma migrate deploy - -ln -sfn "$RELEASE" "$APP_ROOT/current" -pm2 reload chat-one-service --update-env -``` - -发布后验证: - -```bash -curl -fsS http://127.0.0.1:3000/api/docs >/dev/null -curl -fsS https://api.example.com/api/docs >/dev/null -``` - -如需 SSE 冒烟测试,调用: -- `POST /api/client/v1/chat/completions/stream` - -## 8. Nginx 配置(含 SSE) - -参考文件:`deploy/nginx/chat-one-service.conf` - -生效步骤: - -```bash -sudo cp deploy/nginx/chat-one-service.conf /etc/nginx/sites-available/chat-one-service.conf -sudo ln -sfn /etc/nginx/sites-available/chat-one-service.conf /etc/nginx/sites-enabled/chat-one-service.conf -sudo nginx -t -sudo systemctl reload nginx -``` - -申请 HTTPS: - -```bash -sudo certbot --nginx -d api.example.com -``` - -## 9. 回滚策略 - -### 9.1 代码回滚(快速) - -```bash -APP_ROOT=/srv/chat-one-service -ls -1dt "$APP_ROOT"/releases/* | sed -n '1,2p' # 找到上一个版本 -ln -sfn "$APP_ROOT/current" -pm2 reload chat-one-service --update-env -``` - -### 9.2 数据回滚(高风险) - -- 仅在迁移引发严重问题时执行。 -- 使用最近一次全量备份恢复(建议维护停机窗口)。 - -## 10. 备份与监控最小集 - -### 10.1 PostgreSQL 备份 - -每日定时(cron): - -```bash -pg_dump "$DATABASE_URL" | gzip > /data/backup/chat_one_$(date +%F).sql.gz -``` - -建议: -- 保留 7-14 天 -- 同步到对象存储(异地) - -### 10.2 应用观测 - -- PM2:重启次数、内存、CPU -- Nginx:`4xx/5xx` 比率、延迟 -- Redis:连接数与内存 -- 日志:按天滚动,至少保留 7 天 - -## 11. 常用排障命令 - -```bash -pm2 ls -pm2 logs chat-one-service --lines 200 -pm2 describe chat-one-service - -sudo systemctl status nginx -sudo tail -n 200 /var/log/nginx/error.log - -curl -i http://127.0.0.1:3000/api/docs -``` - -## 12. 安全加固建议 - -- 安全组白名单限制 SSH 来源 -- 开启 UFW,仅放行 `22/80/443` -- 禁止将 `.env`、备份明文文件提交到 Git -- 配置 fail2ban(可选) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..57367c2 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,32 @@ +import js from "@eslint/js"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + { + ignores: ["dist/**", "node_modules/**"], + linterOptions: { + reportUnusedDisableDirectives: "off", + }, + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/**/*.ts"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + globals: { + ...globals.node, + }, + }, + rules: { + "no-console": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + }, + }, +); diff --git a/package.json b/package.json index bbd05d1..6c8f469 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,16 @@ "undici": "^8.1.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^25.6.0", "eslint": "^10.2.0", + "globals": "^17.5.0", "pino-pretty": "^13.1.3", "prettier": "^3.8.3", "prisma": "^7.7.0", "ts-node": "^10.9.2", "tsx": "^4.21.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "typescript-eslint": "^8.59.0" } } diff --git a/yarn.lock b/yarn.lock index d6fffc0..5a6b2f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -159,7 +159,7 @@ resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== -"@eslint-community/eslint-utils@^4.8.0": +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== @@ -194,6 +194,11 @@ dependencies: "@types/json-schema" "^7.0.15" +"@eslint/js@^10.0.1": + version "10.0.1" + resolved "https://registry.npmmirror.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" + integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + "@eslint/object-schema@^3.0.5": version "3.0.5" resolved "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" @@ -748,6 +753,102 @@ resolved "https://registry.npmmirror.com/@types/validator/-/validator-13.15.10.tgz#742b77ec34d58554b94a76a14cef30d59e3c16b9" integrity sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA== +"@typescript-eslint/eslint-plugin@8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz#fcbe76b693ce2412410cf4d48aefd617d345f2d9" + integrity sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.59.0" + "@typescript-eslint/type-utils" "8.59.0" + "@typescript-eslint/utils" "8.59.0" + "@typescript-eslint/visitor-keys" "8.59.0" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.0.tgz#57a138280b3ceaf07904fbd62c433d5cc1ee1573" + integrity sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg== + dependencies: + "@typescript-eslint/scope-manager" "8.59.0" + "@typescript-eslint/types" "8.59.0" + "@typescript-eslint/typescript-estree" "8.59.0" + "@typescript-eslint/visitor-keys" "8.59.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.0.tgz#914bf62069d870faa0389ffd725774a200f511bf" + integrity sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.59.0" + "@typescript-eslint/types" "^8.59.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz#f71be268bd31da1c160815c689e4dde7c9bc9e8e" + integrity sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg== + dependencies: + "@typescript-eslint/types" "8.59.0" + "@typescript-eslint/visitor-keys" "8.59.0" + +"@typescript-eslint/tsconfig-utils@8.59.0", "@typescript-eslint/tsconfig-utils@^8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz#1276077f5ad77e384446ea28a2474e8f8be1af41" + integrity sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg== + +"@typescript-eslint/type-utils@8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz#2834ea3b179cedfc9244dcd4f74105a27751a439" + integrity sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg== + dependencies: + "@typescript-eslint/types" "8.59.0" + "@typescript-eslint/typescript-estree" "8.59.0" + "@typescript-eslint/utils" "8.59.0" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.59.0", "@typescript-eslint/types@^8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.0.tgz#cfcc643c6e879016479775850d86d84c14492738" + integrity sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A== + +"@typescript-eslint/typescript-estree@8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz#feba58a70ab6ea7ac53a2f3ae900db28ce3454c2" + integrity sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw== + dependencies: + "@typescript-eslint/project-service" "8.59.0" + "@typescript-eslint/tsconfig-utils" "8.59.0" + "@typescript-eslint/types" "8.59.0" + "@typescript-eslint/visitor-keys" "8.59.0" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.5.0" + +"@typescript-eslint/utils@8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.0.tgz#f50df9bd6967881ef64fba62230111153179ead5" + integrity sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.59.0" + "@typescript-eslint/types" "8.59.0" + "@typescript-eslint/typescript-estree" "8.59.0" + +"@typescript-eslint/visitor-keys@8.59.0": + version "8.59.0" + resolved "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz#2e80de30e7e944ed4bd47d751e37dcb04db03795" + integrity sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q== + dependencies: + "@typescript-eslint/types" "8.59.0" + eslint-visitor-keys "^5.0.0" + abstract-logging@^2.0.1: version "2.0.1" resolved "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839" @@ -1125,7 +1226,7 @@ eslint-visitor-keys@^3.4.3: resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint-visitor-keys@^5.0.1: +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: version "5.0.1" resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== @@ -1298,6 +1399,11 @@ fastq@^1.17.1: dependencies: reusify "^1.0.4" +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -1410,6 +1516,11 @@ glob@^13.0.0: minipass "^7.1.3" path-scurry "^2.0.2" +globals@^17.5.0: + version "17.5.0" + resolved "https://registry.npmmirror.com/globals/-/globals-17.5.0.tgz#a82c641d898f8dfbe0e81f66fdff7d0de43f88c6" + integrity sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g== + graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -1473,6 +1584,11 @@ ignore@^5.2.0: resolved "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -1955,6 +2071,11 @@ pgpass@1.0.5: dependencies: split2 "^4.1.0" +picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + pino-abstract-transport@^3.0.0: version "3.0.0" resolved "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23" @@ -2222,7 +2343,7 @@ secure-json-parse@^4.0.0: resolved "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c" integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA== -semver@^7.5.4, semver@^7.6.0: +semver@^7.5.4, semver@^7.6.0, semver@^7.7.3: version "7.7.4" resolved "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -2327,6 +2448,14 @@ tinyexec@^1.0.2: resolved "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.1.tgz#e1ff45dfa60d1dedb91b734956b78f6c2a3e821b" integrity sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg== +tinyglobby@^0.2.15: + version "0.2.16" + resolved "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + toad-cache@^3.7.0: version "3.7.0" resolved "https://registry.npmmirror.com/toad-cache/-/toad-cache-3.7.0.tgz#b9b63304ea7c45ec34d91f1d2fa513517025c441" @@ -2346,6 +2475,11 @@ token-types@^6.1.1: "@tokenizer/token" "^0.3.0" ieee754 "^1.2.1" +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + ts-node@^10.9.2: version "10.9.2" resolved "https://registry.npmmirror.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" @@ -2387,6 +2521,16 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +typescript-eslint@^8.59.0: + version "8.59.0" + resolved "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.0.tgz#d1cc7c63559ce7116aeb66d35ec9dbe0063379fd" + integrity sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw== + dependencies: + "@typescript-eslint/eslint-plugin" "8.59.0" + "@typescript-eslint/parser" "8.59.0" + "@typescript-eslint/typescript-estree" "8.59.0" + "@typescript-eslint/utils" "8.59.0" + typescript@^6.0.2: version "6.0.2" resolved "https://registry.npmmirror.com/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f"