chore(部署): 增加 Docker 部署配置
关键改动: - 新增本地和生产 Docker Compose、Web/Server Dockerfile 与 Nginx 模板 - 增加生产部署校验脚本、环境变量示例和 Prisma 初始迁移 - 更新部署文档、README 和架构/决策/流程/路线图说明 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
20
.dockerignore
Normal file
20
.dockerignore
Normal file
@@ -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
|
||||||
31
.env.local-server.example
Normal file
31
.env.local-server.example
Normal file
@@ -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=
|
||||||
32
.env.production.example
Normal file
32
.env.production.example
Normal file
@@ -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=
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ dist/
|
|||||||
.next/
|
.next/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.local-server
|
||||||
.env.production
|
.env.production
|
||||||
*.log
|
*.log
|
||||||
.turbo/
|
.turbo/
|
||||||
|
|||||||
39
Dockerfile.server
Normal file
39
Dockerfile.server
Normal file
@@ -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"]
|
||||||
44
Dockerfile.web
Normal file
44
Dockerfile.web
Normal file
@@ -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"]
|
||||||
33
README.md
33
README.md
@@ -120,6 +120,39 @@ docker-compose up -d
|
|||||||
docker-compose down
|
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
|
```text
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:deploy": "prisma migrate deploy",
|
||||||
"db:seed": "ts-node prisma/seed.ts",
|
"db:seed": "ts-node prisma/seed.ts",
|
||||||
"db:studio": "prisma studio"
|
"db:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
|
|||||||
193
apps/server/prisma/migrations/20260701000000_init/migration.sql
Normal file
193
apps/server/prisma/migrations/20260701000000_init/migration.sql
Normal file
@@ -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;
|
||||||
2
apps/server/prisma/migrations/migration_lock.toml
Normal file
2
apps/server/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Please do not edit this file manually.
|
||||||
|
provider = "postgresql"
|
||||||
@@ -232,7 +232,7 @@ export default function WenfanXiaobaoPage() {
|
|||||||
<MessageCircleQuestionMark className="h-4 w-4" strokeWidth={2} />
|
<MessageCircleQuestionMark className="h-4 w-4" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="truncate text-[15px] font-semibold text-[#171717]">问翻小宝</h1>
|
<h1 className="truncate text-[15px] font-semibold text-[#171717]">AI 助手</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">内置帮助</span>
|
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">内置帮助</span>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ test('wenfan xiaobao page provides records, chat, and voice input surfaces', ()
|
|||||||
|
|
||||||
const page = readFileSync(pagePath, 'utf8');
|
const page = readFileSync(pagePath, 'utf8');
|
||||||
|
|
||||||
|
assert.match(page, /<h1 className="truncate text-\[15px\] font-semibold text-\[#171717\]">AI 助手<\/h1>/);
|
||||||
|
assert.doesNotMatch(page, /<h1 className="truncate text-\[15px\] font-semibold text-\[#171717\]">问翻小宝<\/h1>/);
|
||||||
assert.match(page, /历史记录/);
|
assert.match(page, /历史记录/);
|
||||||
assert.match(page, /对话/);
|
assert.match(page, /对话/);
|
||||||
assert.match(page, /textarea/);
|
assert.match(page, /textarea/);
|
||||||
|
|||||||
26
deploy/nginx/default.conf.template
Normal file
26
deploy/nginx/default.conf.template
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
docker-compose.local.yml
Normal file
112
docker-compose.local.yml
Normal file
@@ -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:
|
||||||
112
docker-compose.prod.yml
Normal file
112
docker-compose.prod.yml
Normal file
@@ -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:
|
||||||
@@ -123,6 +123,25 @@ DevTask 没有"已完成"状态,"已提测"就是终态——开发交付完
|
|||||||
|
|
||||||
**后续 V2.2(计划):** 将 `app_data` 中稳定的数据形状逐步拆成关系表和领域 CRUD API。拆表前不要恢复业务 localStorage 缓存,避免线上部署后出现多端数据分叉。
|
**后续 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 仅做前端校验,无后端鉴权:
|
V1 仅做前端校验,无后端鉴权:
|
||||||
|
|||||||
@@ -460,3 +460,19 @@
|
|||||||
- 版本级统计、工作台和风险预警统计包含无需求ID分组任务/用例;需求级进度和需求关闭条件只统计带正式 `requirementId` 的 DevTask。
|
- 版本级统计、工作台和风险预警统计包含无需求ID分组任务/用例;需求级进度和需求关闭条件只统计带正式 `requirementId` 的 DevTask。
|
||||||
|
|
||||||
**理由**:关联需求代表人为确认的正式范围,不能被 AI 静默扩写;但原型批注也是当前版本真实交付范围的重要证据,不应该因为没有 REQID 被丢掉。无需求ID分组让任务和用例完整进入执行视图,同时保持需求池干净、关联需求列表可信。
|
**理由**:关联需求代表人为确认的正式范围,不能被 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 证书自动续期和域名接入在不同云环境差异较大,先作为外层能力处理,避免把生产部署模板绑死在某一种证书方案上。
|
||||||
|
|||||||
210
docs/deployment.md
Normal file
210
docs/deployment.md
Normal file
@@ -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`。
|
||||||
@@ -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**
|
**2026-06-24**
|
||||||
- 新增 NestJS `DataModule` + Prisma `AppData`,提供 `GET/PUT /api/v1/data/:key`
|
- 新增 NestJS `DataModule` + Prisma `AppData`,提供 `GET/PUT /api/v1/data/:key`
|
||||||
- 产品/项目/版本树、需求池、调研/产品方案/UI、开发任务、测试用例、Bug 改为服务端持久化
|
- 产品/项目/版本树、需求池、调研/产品方案/UI、开发任务、测试用例、Bug 改为服务端持久化
|
||||||
|
|||||||
@@ -282,3 +282,28 @@ AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自
|
|||||||
- AI 预估工时和手填执行预估工时本身是小时数,不再按日期过滤;只有从计划起止时间自动推导的执行预估会按工作日历计算。
|
- AI 预估工时和手填执行预估工时本身是小时数,不再按日期过滤;只有从计划起止时间自动推导的执行预估会按工作日历计算。
|
||||||
- 加班记录使用 `overtime.calcDuration`,不受节假日过滤,但按项目管理口径计算:每个自然日默认最多 8h,跨天中间日期按 8h,结束晚于 18:00 的当天额外计入超出时长,避免把夜间空档当作打卡工时。
|
- 加班记录使用 `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 反代。
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"type-check": "turbo type-check",
|
"type-check": "turbo type-check",
|
||||||
"test": "turbo test",
|
"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:migrate": "pnpm --filter=server db:migrate",
|
||||||
|
"db:deploy": "pnpm --filter=server db:deploy",
|
||||||
"db:seed": "pnpm --filter=server db:seed",
|
"db:seed": "pnpm --filter=server db:seed",
|
||||||
"db:studio": "pnpm --filter=server db:studio"
|
"db:studio": "pnpm --filter=server db:studio"
|
||||||
},
|
},
|
||||||
|
|||||||
120
scripts/verify-production-deploy.mjs
Normal file
120
scripts/verify-production-deploy.mjs
Normal file
@@ -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.');
|
||||||
Reference in New Issue
Block a user