diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a1d4300 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.turbo +.worktrees +node_modules +**/node_modules +dist +**/dist +.next +**/.next +coverage +**/coverage +.tmp +apps/web/.tmp-test +*.log +.env +.env.local +.env.local-server +.env.production +apps/server/data +*.tsbuildinfo diff --git a/.env.local-server.example b/.env.local-server.example new file mode 100644 index 0000000..874ebbd --- /dev/null +++ b/.env.local-server.example @@ -0,0 +1,31 @@ +# Copy to .env.local-server for an all-in-one local/LAN deployment. + +COMPOSE_PROJECT_NAME=ftb_local + +# Local/LAN entrypoint +LOCAL_HTTP_PORT=8080 +LOCAL_ACCESS_HOST=localhost +CLIENT_MAX_BODY_SIZE=20m + +# Database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=ftb_pm +DATABASE_URL=postgresql://postgres:postgres@postgres:5432/ftb_pm + +# Redis +REDIS_URL=redis://redis:6379 + +# Web +NEXT_PUBLIC_API_URL=/api/v1 +NEXTAUTH_URL=http://localhost:8080 +NEXTAUTH_SECRET=local-server-change-me + +# AI provider fallback. Admins can also configure providers in /admin/ai-config. +ANTHROPIC_API_KEY=sk-ant-xxx + +# Optional mail notification settings +SMTP_HOST= +SMTP_PORT=465 +SMTP_USER= +SMTP_PASS= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..a5d7eb0 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,32 @@ +# Copy to .env.production on the server and replace every change-me value. + +COMPOSE_PROJECT_NAME=ftb_pm + +# Public entrypoint +SERVER_NAME=pm.example.com +HTTP_PORT=80 +CLIENT_MAX_BODY_SIZE=20m + +# Database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=change-me-strong-postgres-password +POSTGRES_DB=ftb_pm +DATABASE_URL=postgresql://postgres:change-me-strong-postgres-password@postgres:5432/ftb_pm + +# Redis +REDIS_URL=redis://redis:6379 + +# Web +# For same-domain Nginx deployment, keep this relative path so browser requests go to /api/v1. +NEXT_PUBLIC_API_URL=/api/v1 +NEXTAUTH_URL=https://pm.example.com +NEXTAUTH_SECRET=change-me-random-32-byte-secret + +# AI provider fallback. Admins can also configure providers in /admin/ai-config. +ANTHROPIC_API_KEY=sk-ant-xxx + +# Optional mail notification settings +SMTP_HOST= +SMTP_PORT=465 +SMTP_USER= +SMTP_PASS= diff --git a/.gitignore b/.gitignore index ea06a67..1eb6fa2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ .next/ .env .env.local +.env.local-server .env.production *.log .turbo/ diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..15f4c5b --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-alpine AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +WORKDIR /app +RUN apk add --no-cache libc6-compat openssl && corepack enable + +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/server/package.json apps/server/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN pnpm config set fetch-retries 5 \ + && pnpm config set fetch-retry-mintimeout 10000 \ + && pnpm config set fetch-retry-maxtimeout 120000 \ + && pnpm install --frozen-lockfile + +FROM deps AS builder +ENV NODE_ENV=production +COPY packages/shared packages/shared +COPY apps/server apps/server +RUN pnpm --filter @ftb/shared build +RUN pnpm --filter server exec prisma generate --schema prisma/schema.prisma +RUN pnpm --filter server build + +FROM base AS runner +ENV NODE_ENV=production +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml +COPY --from=builder /app/turbo.json ./turbo.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages/shared ./packages/shared +COPY --from=builder /app/apps/server ./apps/server +RUN mkdir -p /app/apps/server/data && chown -R node:node /app +USER node +EXPOSE 3001 +CMD ["pnpm", "--filter", "server", "start:prod"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..de3d88f --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-alpine AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +WORKDIR /app +RUN apk add --no-cache libc6-compat && corepack enable + +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/server/package.json apps/server/package.json +COPY packages/shared/package.json packages/shared/package.json +RUN pnpm config set fetch-retries 5 \ + && pnpm config set fetch-retry-mintimeout 10000 \ + && pnpm config set fetch-retry-maxtimeout 120000 \ + && pnpm install --frozen-lockfile + +FROM deps AS builder +ARG NEXT_PUBLIC_API_URL=/api/v1 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production +COPY packages/shared packages/shared +COPY apps/web apps/web +RUN pnpm --filter @ftb/shared build +RUN pnpm --filter web build + +FROM base AS runner +ARG NEXT_PUBLIC_API_URL=/api/v1 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml +COPY --from=builder /app/turbo.json ./turbo.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages/shared ./packages/shared +COPY --from=builder /app/apps/web ./apps/web +RUN chown -R node:node /app +USER node +EXPOSE 3000 +CMD ["pnpm", "--filter", "web", "start"] diff --git a/README.md b/README.md index dee0866..257825e 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,39 @@ docker-compose up -d docker-compose down ``` +## 生产部署 + +仓库已内置本地服务器和云服务器生产部署基线: + +- `Dockerfile.web`:构建并运行 Next.js 前端。 +- `Dockerfile.server`:构建并运行 NestJS 后端,包含 Prisma Client 生成。 +- `docker-compose.prod.yml`:编排 Web、Server、PostgreSQL、Redis、Nginx。 +- `docker-compose.local.yml`:本机/局域网全栈部署,默认 `http://localhost:8080`。 +- `.env.production.example`:生产环境变量模板。 +- `.env.local-server.example`:本地服务器环境变量模板。 +- `deploy/nginx/default.conf.template`:同域反向代理,`/api/` 转发到后端,其余转发到前端。 +- `docs/deployment.md`:完整部署、升级、备份和排查流程。 + +本地服务器快速入口: + +```bash +cp .env.local-server.example .env.local-server +pnpm deploy:verify +pnpm deploy:local:build +pnpm deploy:local:up +docker compose --env-file .env.local-server -f docker-compose.local.yml exec server pnpm --filter server db:deploy +``` + +云服务器快速入口: + +```bash +cp .env.production.example .env.production +pnpm deploy:verify +docker compose --env-file .env.production -f docker-compose.prod.yml build +docker compose --env-file .env.production -f docker-compose.prod.yml up -d +docker compose --env-file .env.production -f docker-compose.prod.yml exec server pnpm --filter server db:deploy +``` + ## 项目结构 ```text diff --git a/apps/server/package.json b/apps/server/package.json index 3a24a0c..86578e2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -12,6 +12,7 @@ "test": "jest", "test:watch": "jest --watch", "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", "db:seed": "ts-node prisma/seed.ts", "db:studio": "prisma studio" }, diff --git a/apps/server/prisma/migrations/20260701000000_init/migration.sql b/apps/server/prisma/migrations/20260701000000_init/migration.sql new file mode 100644 index 0000000..7ad496a --- /dev/null +++ b/apps/server/prisma/migrations/20260701000000_init/migration.sql @@ -0,0 +1,193 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "avatar" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "products" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "products_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "projects" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sprints" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "start_date" TIMESTAMP(3) NOT NULL, + "end_date" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sprints_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "versions" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "release_date" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "versions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "requirements" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'draft', + "priority" INTEGER NOT NULL DEFAULT 0, + "creator_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "requirements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "tasks" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "sprint_id" TEXT, + "version_id" TEXT, + "parent_id" TEXT, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'todo', + "priority" INTEGER NOT NULL DEFAULT 0, + "assignee_id" TEXT, + "creator_id" TEXT NOT NULL, + "start_date" TIMESTAMP(3), + "due_date" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "tasks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "task_watchers" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + + CONSTRAINT "task_watchers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comments" ( + "id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "author_id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "comments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "project_members" ( + "id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'member', + + CONSTRAINT "project_members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "app_data" ( + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_data_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "task_watchers_task_id_user_id_key" ON "task_watchers"("task_id", "user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "project_members_project_id_user_id_key" ON "project_members"("project_id", "user_id"); + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sprints" ADD CONSTRAINT "sprints_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "versions" ADD CONSTRAINT "versions_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "requirements" ADD CONSTRAINT "requirements_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "requirements" ADD CONSTRAINT "requirements_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_watchers" ADD CONSTRAINT "task_watchers_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "task_watchers" ADD CONSTRAINT "task_watchers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comments" ADD CONSTRAINT "comments_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_members" ADD CONSTRAINT "project_members_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "project_members" ADD CONSTRAINT "project_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/server/prisma/migrations/migration_lock.toml b/apps/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..6a0ff75 --- /dev/null +++ b/apps/server/prisma/migrations/migration_lock.toml @@ -0,0 +1,2 @@ +# Please do not edit this file manually. +provider = "postgresql" diff --git a/apps/web/app/wenfan-xiaobao/page.tsx b/apps/web/app/wenfan-xiaobao/page.tsx index 7ac4b0e..181963f 100644 --- a/apps/web/app/wenfan-xiaobao/page.tsx +++ b/apps/web/app/wenfan-xiaobao/page.tsx @@ -232,7 +232,7 @@ export default function WenfanXiaobaoPage() {
-

问翻小宝

+

AI 助手

内置帮助 diff --git a/apps/web/lib/wenfan-xiaobao-ui.test.ts b/apps/web/lib/wenfan-xiaobao-ui.test.ts index c164b38..4949ca4 100644 --- a/apps/web/lib/wenfan-xiaobao-ui.test.ts +++ b/apps/web/lib/wenfan-xiaobao-ui.test.ts @@ -23,6 +23,8 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', () const page = readFileSync(pagePath, 'utf8'); + assert.match(page, /

AI 助手<\/h1>/); + assert.doesNotMatch(page, /

问翻小宝<\/h1>/); assert.match(page, /历史记录/); assert.match(page, /对话/); assert.match(page, /textarea/); diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template new file mode 100644 index 0000000..8ce4e6e --- /dev/null +++ b/deploy/nginx/default.conf.template @@ -0,0 +1,26 @@ +server { + listen 80; + server_name ${SERVER_NAME}; + client_max_body_size ${CLIENT_MAX_BODY_SIZE}; + + 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-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + + location /api/ { + proxy_pass http://server:3001; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + location / { + proxy_pass http://web:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..e603e4e --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,112 @@ +name: ftb-local-server + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-ftb_pm} + volumes: + - local_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ['redis-server', '--appendonly', 'yes'] + volumes: + - local_redis_data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 10 + + server: + build: + context: . + dockerfile: Dockerfile.server + restart: unless-stopped + environment: + NODE_ENV: production + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ftb_pm}} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - local_server_data:/app/apps/server/data + expose: + - '3001' + healthcheck: + test: + [ + 'CMD-SHELL', + 'node -e "fetch(''http://127.0.0.1:3001/api/v1/config/ai'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"', + ] + interval: 15s + timeout: 5s + retries: 20 + start_period: 20s + + web: + build: + context: . + dockerfile: Dockerfile.web + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1} + restart: unless-stopped + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1} + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:8080} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-local-server-change-me} + depends_on: + server: + condition: service_healthy + expose: + - '3000' + healthcheck: + test: + [ + 'CMD-SHELL', + 'node -e "fetch(''http://127.0.0.1:3000'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"', + ] + interval: 15s + timeout: 5s + retries: 20 + start_period: 20s + + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + ports: + - '${LOCAL_HTTP_PORT:-8080}:80' + environment: + SERVER_NAME: ${LOCAL_ACCESS_HOST:-localhost} + CLIENT_MAX_BODY_SIZE: ${CLIENT_MAX_BODY_SIZE:-20m} + volumes: + - ./deploy/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro + depends_on: + web: + condition: service_healthy + server: + condition: service_healthy + +volumes: + local_postgres_data: + local_redis_data: + local_server_data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..322a098 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,112 @@ +name: ftb-project-management + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production} + POSTGRES_DB: ${POSTGRES_DB:-ftb_pm} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ['redis-server', '--appendonly', 'yes'] + volumes: + - redis_data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 10 + + server: + build: + context: . + dockerfile: Dockerfile.server + restart: unless-stopped + environment: + NODE_ENV: production + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ftb_pm}} + REDIS_URL: ${REDIS_URL:-redis://redis:6379} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - server_data:/app/apps/server/data + expose: + - '3001' + healthcheck: + test: + [ + 'CMD-SHELL', + 'node -e "fetch(''http://127.0.0.1:3001/api/v1/config/ai'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"', + ] + interval: 15s + timeout: 5s + retries: 20 + start_period: 20s + + web: + build: + context: . + dockerfile: Dockerfile.web + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1} + restart: unless-stopped + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-/api/v1} + NEXTAUTH_URL: ${NEXTAUTH_URL:-} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-} + depends_on: + server: + condition: service_healthy + expose: + - '3000' + healthcheck: + test: + [ + 'CMD-SHELL', + 'node -e "fetch(''http://127.0.0.1:3000'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"', + ] + interval: 15s + timeout: 5s + retries: 20 + start_period: 20s + + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + ports: + - '${HTTP_PORT:-80}:80' + environment: + SERVER_NAME: ${SERVER_NAME:-_} + CLIENT_MAX_BODY_SIZE: ${CLIENT_MAX_BODY_SIZE:-20m} + volumes: + - ./deploy/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro + depends_on: + web: + condition: service_healthy + server: + condition: service_healthy + +volumes: + postgres_data: + redis_data: + server_data: diff --git a/docs/architecture.md b/docs/architecture.md index 5d2b152..b0afbd5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,6 +123,25 @@ DevTask 没有"已完成"状态,"已提测"就是终态——开发交付完 **后续 V2.2(计划):** 将 `app_data` 中稳定的数据形状逐步拆成关系表和领域 CRUD API。拆表前不要恢复业务 localStorage 缓存,避免线上部署后出现多端数据分叉。 +## 生产部署层(2026-07-01) + +当前仓库已补齐云服务器生产部署基线: + +- `Dockerfile.web`:以 monorepo 根目录为 build context,构建 `@ftb/shared` 和 Next.js 前端,运行 `pnpm --filter web start`。 +- `Dockerfile.server`:构建 `@ftb/shared` 和 NestJS 后端,执行 `prisma generate`,运行 `pnpm --filter server start:prod`。 +- `docker-compose.prod.yml`:编排 `web`、`server`、`postgres`、`redis`、`nginx` 五个服务,服务间通过 Docker 内网通信,对外只暴露 Nginx 80 端口。 +- `docker-compose.local.yml`:本地服务器/局域网部署入口,同样编排五个服务,默认对外暴露 `8080`,使用独立 `local_*` volumes,避免与云服务器生产数据混用。 +- `deploy/nginx/default.conf.template`:同域反向代理,`/api/` 转发到 NestJS,其他路径转发到 Next.js。 +- `.env.production.example` / `.env.local-server.example`:生产和本地服务器配置模板;正式部署复制为 `.env.production` 或 `.env.local-server`,不提交真实密钥。 + +生产持久化边界: + +- 业务主数据在 PostgreSQL `postgres_data` volume 中。 +- Redis AOF 在 `redis_data` volume 中。 +- AI Provider 配置文件在 `server_data` volume 中,对应容器路径 `/app/apps/server/data`。 + +生产数据库初始化使用 Prisma migration:`pnpm --filter server db:deploy`。本地开发仍可使用 `pnpm db:migrate`。 + ## 权限模型(轻量) V1 仅做前端校验,无后端鉴权: diff --git a/docs/decisions.md b/docs/decisions.md index 7203908..24f139e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -460,3 +460,19 @@ - 版本级统计、工作台和风险预警统计包含无需求ID分组任务/用例;需求级进度和需求关闭条件只统计带正式 `requirementId` 的 DevTask。 **理由**:关联需求代表人为确认的正式范围,不能被 AI 静默扩写;但原型批注也是当前版本真实交付范围的重要证据,不应该因为没有 REQID 被丢掉。无需求ID分组让任务和用例完整进入执行视图,同时保持需求池干净、关联需求列表可信。 + +## 38. 生产部署采用 Docker Compose + Nginx 同域反代 + +**问题**:系统已经从浏览器 localStorage 迁到 NestJS + PostgreSQL `app_data`,可以部署到云服务器,但如果只依赖开发命令,生产环境会缺少统一编排、健康检查、持久卷、反向代理和数据库初始化流程。 + +**决策**: +- 生产部署使用 `docker-compose.prod.yml` 编排 `web`、`server`、`postgres`、`redis`、`nginx`。 +- 本地服务器/局域网部署使用 `docker-compose.local.yml`,同样跑完整五服务栈,但默认绑定宿主机 `8080`,并使用独立 `local_*` volumes。 +- `web` 和 `server` 分别用 `Dockerfile.web`、`Dockerfile.server` 从 monorepo 根目录构建,先构建 `@ftb/shared`,再构建各自应用。 +- 对外只暴露 Nginx 80 端口;`/api/` 转发到 NestJS,其他路径转发到 Next.js。 +- 前端生产默认使用同域 API:`NEXT_PUBLIC_API_URL=/api/v1`,避免浏览器跨域配置。 +- PostgreSQL、Redis 和 server 文件数据分别使用 `postgres_data`、`redis_data`、`server_data` 命名卷持久化。 +- 数据库生产初始化走 `prisma migrate deploy`,仓库保留初始 migration;本地开发仍使用 `prisma migrate dev`。 +- HTTPS 先交给云负载均衡、CDN、宿主机证书工具或外层 Nginx 终止;Compose 内置 Nginx 保持 HTTP 反代基线。 + +**理由**:Docker Compose 足够覆盖当前单机云服务器和本地服务器形态,部署成本低、可读性强,也符合现阶段 2 核 4G 云主机目标。同域反代能减少 CORS 和公网端口暴露面。本地服务器默认 8080,避免占用 80 端口或要求管理员权限;云服务器继续使用 80 作为外层入口。HTTPS 证书自动续期和域名接入在不同云环境差异较大,先作为外层能力处理,避免把生产部署模板绑死在某一种证书方案上。 diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..c1f583f --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,210 @@ +# 部署指南 + +本文档覆盖三种部署方式: + +- 本地开发:前端、后端直接用 pnpm 跑,PostgreSQL 和 Redis 用 `docker-compose.yml` 启动。 +- 本地服务器部署:一台本机或局域网服务器用 `docker-compose.local.yml` 跑完整 Web、Server、PostgreSQL、Redis、Nginx。 +- 云服务器生产:所有服务用 `docker-compose.prod.yml` 编排,Nginx 对外暴露 HTTP,业务数据写入 PostgreSQL volume,AI 配置文件写入 server volume。 + +## 本地开发部署 + +```bash +pnpm install +cp apps/server/.env.example apps/server/.env +docker compose up -d +pnpm db:migrate +pnpm dev +``` + +访问地址: + +- Web: `http://localhost:3000` +- API: `http://localhost:3001/api/v1` +- API 健康探测:`http://localhost:3001/api/v1/config/ai` + +## 本地服务器部署 + +本地服务器部署适合这些场景: + +- 在自己的电脑上用接近生产的方式跑完整系统。 +- 在办公室内网或测试机上给同事访问。 +- 不想安装 Node/pnpm 到目标机器,只想用 Docker Compose 管理全栈服务。 + +它不同于“本地开发”:不会用 `pnpm dev`,而是构建生产镜像并通过 Nginx 统一入口访问。 + +### 1. 准备配置 + +```bash +cp .env.local-server.example .env.local-server +``` + +默认访问地址是: + +```text +http://localhost:8080 +``` + +如果要给局域网其他人访问,修改 `.env.local-server`: + +```env +LOCAL_ACCESS_HOST=192.168.1.50 +NEXTAUTH_URL=http://192.168.1.50:8080 +``` + +其中 `192.168.1.50` 换成这台机器在局域网里的 IP。`NEXT_PUBLIC_API_URL` 保持 `/api/v1`,让浏览器通过同一个 Nginx 入口访问后端。 + +### 2. 校验并启动 + +```bash +pnpm deploy:verify +pnpm deploy:local:build +pnpm deploy:local:up +``` + +首次启动后执行数据库 migration: + +```bash +docker compose --env-file .env.local-server -f docker-compose.local.yml exec server pnpm --filter server db:deploy +``` + +检查服务: + +```bash +docker compose --env-file .env.local-server -f docker-compose.local.yml ps +curl http://localhost:8080/api/v1/config/ai +``` + +停止本地服务器部署: + +```bash +pnpm deploy:local:down +``` + +本地服务器部署使用独立 volume: + +- `local_postgres_data` +- `local_redis_data` +- `local_server_data` + +这些 volume 与云服务器生产部署的 `postgres_data`、`redis_data`、`server_data` 分开,避免本机测试数据和生产数据混在一起。 + +## 云服务器生产部署 + +### 1. 准备服务器 + +推荐一台 2 核 4G 以上 Linux 云服务器,并安装: + +- Docker Engine +- Docker Compose v2 +- Git + +开放 80 端口。如果 HTTPS 由云厂商负载均衡、CDN 或宝塔/Nginx 外层托管,则在外层终止 TLS 后转发到本机 80。 + +### 2. 准备配置 + +```bash +cp .env.production.example .env.production +``` + +必须修改这些值: + +- `POSTGRES_PASSWORD`:强密码。 +- `DATABASE_URL`:密码要与 `POSTGRES_PASSWORD` 一致,主机保持 `postgres`。 +- `SERVER_NAME`:你的域名,例如 `pm.example.com`。 +- `NEXTAUTH_URL`:正式访问地址,例如 `https://pm.example.com`。 +- `NEXTAUTH_SECRET`:随机长密钥。 +- `NEXT_PUBLIC_API_URL`:同域部署保持 `/api/v1`;如果前后端分域,填完整 API 地址。 +- `ANTHROPIC_API_KEY`:可先留空或占位,后续也可以在 `/admin/ai-config` 页面配置。 + +### 3. 校验部署文件 + +```bash +pnpm deploy:verify +``` + +该命令会检查 `Dockerfile.web`、`Dockerfile.server`、`docker-compose.prod.yml`、`docker-compose.local.yml`、Nginx 模板、env 示例和本文档是否齐全。 + +### 4. 构建并启动 + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml build +docker compose --env-file .env.production -f docker-compose.prod.yml up -d +``` + +首次启动后执行数据库迁移: + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml exec server pnpm --filter server db:deploy +``` + +然后检查服务状态: + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml ps +curl http://localhost/api/v1/config/ai +``` + +## Nginx 路由 + +`deploy/nginx/default.conf.template` 使用官方 Nginx 镜像的模板机制生成配置: + +- `/api/` 转发到 `server:3001` +- `/` 转发到 `web:3000` +- `X-Forwarded-Proto`、`X-Forwarded-For` 等反向代理头会透传给后端 + +生产 Compose 默认只监听 HTTP 80。HTTPS 建议优先交给云负载均衡、CDN 或宿主机外层证书管理工具;如果要让本 Compose 内的 Nginx 直接处理 HTTPS,可以在后续增加证书 volume 和 443 server block。 + +## 数据持久化 + +生产 Compose 使用三个命名 volume: + +- `postgres_data`:PostgreSQL 数据库,包含业务主数据和 `app_data`。 +- `redis_data`:Redis AOF 数据。 +- `server_data`:NestJS 写入的 AI Provider 配置文件,例如 `data/ai-config.json`。 + +浏览器不再作为业务数据主存储。清浏览器缓存不会删除产品、项目、版本、任务、测试用例、Bug 等业务数据。 + +## 升级流程 + +```bash +git pull +pnpm deploy:verify +docker compose --env-file .env.production -f docker-compose.prod.yml build +docker compose --env-file .env.production -f docker-compose.prod.yml up -d +docker compose --env-file .env.production -f docker-compose.prod.yml exec server pnpm --filter server db:deploy +``` + +升级前建议先备份数据库: + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > ftb_pm_backup.sql +``` + +## 常见排查 + +后端是否启动: + +```bash +curl http://localhost/api/v1/config/ai +``` + +数据库是否可用: + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" +``` + +查看日志: + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml logs -f server +docker compose --env-file .env.production -f docker-compose.prod.yml logs -f web +docker compose --env-file .env.production -f docker-compose.prod.yml logs -f nginx +``` + +如果前端能打开但数据为空,优先检查: + +1. `NEXT_PUBLIC_API_URL` 是否为 `/api/v1` 或正确的完整 API 地址。 +2. `curl http://localhost/api/v1/config/ai` 是否返回 200。 +3. `server` 日志里是否有 Prisma 或 `DATABASE_URL` 错误。 +4. 是否已执行 `pnpm --filter server db:deploy`。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d0e435..04d3f79 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,13 @@ ### 已完成(按时间倒序) +**2026-07-01** +- 补齐云服务器生产部署基线:`Dockerfile.web`、`Dockerfile.server`、`docker-compose.prod.yml`、Nginx 反代模板和 `.env.production.example` +- 补齐本地服务器/局域网部署基线:`docker-compose.local.yml`、`.env.local-server.example`、`deploy:local:*` 脚本 +- 新增生产数据库初始化 migration,并提供 `pnpm db:deploy` / `pnpm --filter server db:deploy` +- 新增 `pnpm deploy:verify` 校验生产部署文件完整性 +- 新增 `docs/deployment.md`,覆盖本地开发、云服务器部署、升级、备份和排查流程 + **2026-06-24** - 新增 NestJS `DataModule` + Prisma `AppData`,提供 `GET/PUT /api/v1/data/:key` - 产品/项目/版本树、需求池、调研/产品方案/UI、开发任务、测试用例、Bug 改为服务端持久化 diff --git a/docs/workflow.md b/docs/workflow.md index 282bad7..f1aa497 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -282,3 +282,28 @@ AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自 - AI 预估工时和手填执行预估工时本身是小时数,不再按日期过滤;只有从计划起止时间自动推导的执行预估会按工作日历计算。 - 加班记录使用 `overtime.calcDuration`,不受节假日过滤,但按项目管理口径计算:每个自然日默认最多 8h,跨天中间日期按 8h,结束晚于 18:00 的当天额外计入超出时长,避免把夜间空档当作打卡工时。 - 新建加班记录也使用工作日历日期选择器;节假日和周末可选,只提示不阻止。 + +## 生产部署流程 + +生产部署以 `docs/deployment.md` 为准,标准顺序如下: + +本地服务器/局域网部署: + +1. 复制 `.env.local-server.example` 为 `.env.local-server`,按需把 `LOCAL_ACCESS_HOST` 和 `NEXTAUTH_URL` 改为本机局域网 IP。 +2. 运行 `pnpm deploy:verify`。 +3. 运行 `pnpm deploy:local:build` 构建镜像。 +4. 运行 `pnpm deploy:local:up` 启动服务,默认访问 `http://localhost:8080`。 +5. 首次部署或升级后运行 `docker compose --env-file .env.local-server -f docker-compose.local.yml exec server pnpm --filter server db:deploy`。 +6. 用 `curl http://localhost:8080/api/v1/config/ai` 验证 Nginx 到后端链路。 + +云服务器部署: + +1. 复制 `.env.production.example` 为 `.env.production`,替换密码、域名、`NEXTAUTH_SECRET`、AI key 等真实值。 +2. 运行 `pnpm deploy:verify`,确认生产 Docker、Compose、Nginx、env 示例和文档齐全。 +3. 运行 `docker compose --env-file .env.production -f docker-compose.prod.yml build` 构建镜像。 +4. 运行 `docker compose --env-file .env.production -f docker-compose.prod.yml up -d` 启动服务。 +5. 首次部署或升级后运行 `docker compose --env-file .env.production -f docker-compose.prod.yml exec server pnpm --filter server db:deploy`。 +6. 用 `curl http://localhost/api/v1/config/ai` 验证 Nginx 到后端链路。 +7. 用 `docker compose --env-file .env.production -f docker-compose.prod.yml ps` 和 `logs -f server/web/nginx` 排查健康状态。 + +生产环境不提交 `.env.production`。HTTPS 默认由外层负载均衡、CDN、宿主机证书工具或外层 Nginx 终止;Compose 内置 Nginx 只承担同域 HTTP 反代。 diff --git a/package.json b/package.json index 8c0c444..60544ce 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,12 @@ "lint": "turbo lint", "type-check": "turbo type-check", "test": "turbo test", + "deploy:verify": "node scripts/verify-production-deploy.mjs", + "deploy:local:build": "docker compose --env-file .env.local-server -f docker-compose.local.yml build", + "deploy:local:up": "docker compose --env-file .env.local-server -f docker-compose.local.yml up -d", + "deploy:local:down": "docker compose --env-file .env.local-server -f docker-compose.local.yml down", "db:migrate": "pnpm --filter=server db:migrate", + "db:deploy": "pnpm --filter=server db:deploy", "db:seed": "pnpm --filter=server db:seed", "db:studio": "pnpm --filter=server db:studio" }, diff --git a/scripts/verify-production-deploy.mjs b/scripts/verify-production-deploy.mjs new file mode 100644 index 0000000..00b34e3 --- /dev/null +++ b/scripts/verify-production-deploy.mjs @@ -0,0 +1,120 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); + +const checks = [ + { + file: 'Dockerfile.web', + snippets: [ + 'FROM node:20-alpine', + 'pnpm --filter @ftb/shared build', + 'pnpm --filter web build', + 'NEXT_PUBLIC_API_URL', + 'CMD ["pnpm", "--filter", "web", "start"]', + ], + }, + { + file: 'Dockerfile.server', + snippets: [ + 'FROM node:20-alpine', + 'pnpm --filter @ftb/shared build', + 'pnpm --filter server build', + 'prisma generate', + 'CMD ["pnpm", "--filter", "server", "start:prod"]', + ], + }, + { + file: 'docker-compose.prod.yml', + snippets: [ + 'web:', + 'server:', + 'postgres:', + 'redis:', + 'nginx:', + 'server_data:', + 'NEXT_PUBLIC_API_URL', + '/api/v1/config/ai', + ], + }, + { + file: 'docker-compose.local.yml', + snippets: [ + 'web:', + 'server:', + 'postgres:', + 'redis:', + 'nginx:', + 'local_server_data:', + 'NEXT_PUBLIC_API_URL', + '${LOCAL_HTTP_PORT:-8080}:80', + ], + }, + { + file: 'deploy/nginx/default.conf.template', + snippets: [ + 'proxy_pass http://web:3000', + 'proxy_pass http://server:3001', + 'client_max_body_size', + 'X-Forwarded-Proto', + ], + }, + { + file: '.env.production.example', + snippets: [ + 'POSTGRES_PASSWORD=', + 'DATABASE_URL=postgresql://', + 'NEXT_PUBLIC_API_URL=', + 'NEXTAUTH_SECRET=', + 'ANTHROPIC_API_KEY=', + ], + }, + { + file: '.env.local-server.example', + snippets: [ + 'LOCAL_HTTP_PORT=8080', + 'LOCAL_ACCESS_HOST=localhost', + 'DATABASE_URL=postgresql://', + 'NEXT_PUBLIC_API_URL=/api/v1', + 'NEXTAUTH_URL=http://localhost:8080', + ], + }, + { + file: 'docs/deployment.md', + snippets: [ + 'docker-compose.prod.yml', + 'docker-compose.local.yml', + '本地服务器部署', + 'pnpm deploy:verify', + 'pnpm db:migrate', + 'NEXT_PUBLIC_API_URL', + 'Nginx', + ], + }, +]; + +let failed = false; + +for (const check of checks) { + const abs = join(root, check.file); + if (!existsSync(abs)) { + console.error(`Missing deployment artifact: ${check.file}`); + failed = true; + continue; + } + + const content = readFileSync(abs, 'utf8'); + for (const snippet of check.snippets) { + if (!content.includes(snippet)) { + console.error(`Missing snippet in ${check.file}: ${snippet}`); + failed = true; + } + } +} + +if (failed) { + process.exit(1); +} + +console.log('Production deployment artifacts verified.');