第9章 端到端实战:用AI Agent从零构建生产级应用《代码之上》
第9章 端到端实战:用 AI Agent 从零构建生产级应用
“Talk is cheap. Show me the code.”
— Linus Torvalds, Linux 创始人
前两章我们分别学习了 Harness Engineering 的理论与实践,以及 AI Coding 工具链的配置与优化。但这些知识如果不能在真实项目中落地,就只是纸上谈兵。
本章,我们将进行一次真正的端到端实战:用 AI Agent 从零构建一个生产级的全栈应用——TaskFlow 任务管理系统。
这不是一篇"Hello World"级别的教程。我们会走完完整的软件工程生命周期:需求分析 → 规格设计 → Harness 搭建 → 后端开发 → 前端开发 → 集成测试 → 部署上线。每一步都会展示真实的 Prompt、真实的 Agent 输出、真实的人类审查,以及真实的踩坑和修复过程。
9.1 项目概述:构建一个任务管理系统
项目需求:全栈任务管理应用(TaskFlow)
为什么选择任务管理系统?
任务管理系统看似简单,实际上涵盖了 Web 应用的所有核心复杂度:
- 用户认证与授权:注册、登录、JWT Token、RBAC 权限
- CRUD 操作:任务、项目、标签的增删改查
- 复杂业务逻辑:状态机(任务流转)、排序、过滤、搜索
- 数据关系:一对多(项目→任务)、多对多(任务↔标签)
- 实时交互:看板拖拽、实时更新
- 通知系统:到期提醒、分配通知
- 性能挑战:大量任务的分页查询、全文搜索
它是 AI Coding 能力的完美试金石——足够复杂以展示 Agent 的能力边界,又足够标准化以评估输出质量。
技术选型
| 层次 | 技术选择 | 选择理由 |
|---|---|---|
| 前端 | React 19 + TypeScript + Vite | 生态成熟,AI 模型理解最深 |
| 样式 | Tailwind CSS 4 | 快速开发,AI 生成质量高 |
| 状态管理 | Zustand 5 | 轻量、类型安全、样板代码少 |
| 后端框架 | Hono (Node.js) | 高性能、TypeScript-first、API 简洁 |
| 数据库 | PostgreSQL 16 | 关系型数据库标准选择 |
| ORM | Drizzle ORM | 类型安全、SQL-like API、迁移工具完善 |
| 验证 | Zod | TypeScript + Python 双生态的运行时验证 |
| 认证 | JWT + bcrypt | 无状态认证,适合 API 场景 |
| 测试 | vitest + supertest + Playwright | 快、全、稳 |
| CI/CD | GitHub Actions | 与 Git 工作流深度集成 |
| 部署 | Docker + Railway | 一键部署,运维成本低 |
开发策略:SDD + TDD + Harness Engineering
我们采用三阶段融合策略:
时间线规划
| Phase | 内容 | AI 辅助耗时 | 传统开发耗时 | 提效比 |
|---|---|---|---|---|
| Phase 1 | 需求分析与规格设计 | 1.5 小时 | 1-2 天 | ~8x |
| Phase 2 | Harness 搭建 | 1 小时 | 0.5-1 天 | ~4x |
| Phase 3 | 后端开发 | 4 小时 | 3-5 天 | ~10x |
| Phase 4 | 前端开发 | 4 小时 | 3-5 天 | ~10x |
| Phase 5 | 集成测试与质量保障 | 1.5 小时 | 1-2 天 | ~8x |
| Phase 6 | 部署与运维 | 0.5 小时 | 0.5-1 天 | ~8x |
| 总计 | ~12.5 小时 | ~10-16 天 | ~8x |
9.2 Phase 1:需求分析与规格设计(SDD)
用自然语言定义产品需求文档(PRD)
第一步,我们用自然语言定义产品需求,然后让 AI Agent 帮助我们将其结构化为正式的 PRD。
初始需求描述(人类输入):
我要做一个类似 Trello 的任务管理系统。用户可以创建项目,在项目里创建任务卡片,
任务有状态流转(待办 → 进行中 → 审核中 → 完成),可以拖拽排序。
支持用户注册登录,支持给任务分配负责人、设置截止日期、添加标签。
有通知功能,任务快到期时提醒。
让 Agent 结构化需求:
Prompt:
"请帮我将以下需求结构化为正式的 PRD(产品需求文档)。
包含:功能模块列表、用户故事、非功能需求、技术约束。
需求:[上述自然语言描述]"
完整的 PRD 文档
经过 Agent 辅助结构化后的 PRD:
# TaskFlow — 产品需求文档 (PRD)
## 1. 产品概述
TaskFlow 是一个面向小团队的任务管理工具,提供看板式任务管理、
团队协作和智能通知功能。
## 2. 功能模块
### 2.1 用户认证模块(Auth)
- US-01: 用户可以邮箱+密码注册
- US-02: 用户可以登录/登出
- US-03: 用户可以重置密码(邮箱验证)
- US-04: JWT Token 自动刷新
### 2.2 项目管理模块(Project)
- US-05: 用户可以创建项目
- US-06: 用户可以邀请团队成员加入项目
- US-07: 项目创建者拥有管理员权限
- US-08: 项目支持设置名称、描述、颜色标识
### 2.3 任务管理模块(Task)
- US-09: 用户可以在项目中创建任务
- US-10: 任务包含:标题、描述、优先级、状态、标签
- US-11: 任务状态流转:todo → in_progress → in_review → done
- US-12: 任务可以分配给项目成员
- US-13: 任务可以设置截止日期
- US-14: 任务支持拖拽排序
- US-15: 任务支持附件上传(≤ 10MB)
### 2.4 看板视图模块(Board)
- US-16: 按状态列展示任务卡片
- US-17: 支持拖拽任务到不同列
- US-18: 支持按标签/负责人/优先级过滤
- US-19: 支持全文搜索任务
### 2.5 通知模块(Notification)
- US-20: 任务分配时通知被分配者
- US-21: 任务状态变更时通知创建者
- US-22: 任务到期前 24 小时提醒
- US-23: 支持站内通知 + 邮件通知
## 3. 非功能需求
- NFR-01: API 响应时间 P99 < 200ms
- NFR-02: 支持 1000 并发用户
- NFR-03: 数据库查询优化(索引覆盖)
- NFR-04: 前端首屏加载 < 2s
- NFR-05: 测试覆盖率 ≥ 80%
## 4. 技术约束
- TC-01: RESTful API 设计
- TC-02: TypeScript 全栈
- TC-03: PostgreSQL 数据库
- TC-04: Docker 容器化部署
- TC-05: 无第三方 Auth 依赖(自建认证)
API 规格设计(OpenAPI 3.0)
# openapi.yaml — TaskFlow API 规格
openapi: '3.0.3'
info:
title: TaskFlow API
version: '1.0.0'
description: 任务管理系统 RESTful API
servers:
- url: /api/v1
# ===== 认证端点 =====
paths:
/auth/register:
post:
tags: [Auth]
summary: 用户注册
operationId: register
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterInput'
responses:
'201':
description: 注册成功
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'409':
description: 邮箱已注册
/auth/login:
post:
tags: [Auth]
summary: 用户登录
operationId: login
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginInput'
responses:
'200':
description: 登录成功
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'401':
description: 认证失败
/auth/refresh:
post:
tags: [Auth]
summary: 刷新 Token
operationId: refreshToken
security:
- BearerAuth: []
responses:
'200':
description: 刷新成功
# ===== 项目端点 =====
/projects:
get:
tags: [Project]
summary: 获取用户的项目列表
operationId: listProjects
security:
- BearerAuth: []
responses:
'200':
description: 项目列表
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Project'
post:
tags: [Project]
summary: 创建项目
operationId: createProject
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateProjectInput'
responses:
'201':
description: 创建成功
/projects/{projectId}:
get:
tags: [Project]
summary: 获取项目详情(含成员列表)
operationId: getProject
security:
- BearerAuth: []
parameters:
- name: projectId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: 项目详情
'404':
description: 项目不存在
# ===== 任务端点 =====
/projects/{projectId}/tasks:
get:
tags: [Task]
summary: 获取项目下的任务列表(看板视图)
operationId: listTasks
security:
- BearerAuth: []
parameters:
- name: projectId
in: path
required: true
schema:
type: string
format: uuid
- name: status
in: query
schema:
$ref: '#/components/schemas/TaskStatus'
- name: assigneeId
in: query
schema:
type: string
format: uuid
- name: tag
in: query
schema:
type: string
- name: search
in: query
schema:
type: string
minLength: 2
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: pageSize
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 50
responses:
'200':
description: 任务列表(按状态分组)
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
todo:
type: array
items:
$ref: '#/components/schemas/Task'
in_progress:
type: array
items:
$ref: '#/components/schemas/Task'
in_review:
type: array
items:
$ref: '#/components/schemas/Task'
done:
type: array
items:
$ref: '#/components/schemas/Task'
post:
tags: [Task]
summary: 创建任务
operationId: createTask
security:
- BearerAuth: []
parameters:
- name: projectId
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateTaskInput'
responses:
'201':
description: 创建成功
/tasks/{taskId}:
patch:
tags: [Task]
summary: 更新任务(含状态流转)
operationId: updateTask
security:
- BearerAuth: []
parameters:
- name: taskId
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateTaskInput'
responses:
'200':
description: 更新成功
'400':
description: 无效的状态转换
delete:
tags: [Task]
summary: 删除任务
operationId: deleteTask
security:
- BearerAuth: []
parameters:
- name: taskId
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: 删除成功
# ===== 通知端点 =====
/notifications:
get:
tags: [Notification]
summary: 获取当前用户的通知列表
operationId: listNotifications
security:
- BearerAuth: []
parameters:
- name: unreadOnly
in: query
schema:
type: boolean
default: false
responses:
'200':
description: 通知列表
components:
schemas:
# ===== 认证相关 =====
RegisterInput:
type: object
required: [email, password, name]
properties:
email:
type: string
format: email
maxLength: 255
password:
type: string
minLength: 8
maxLength: 128
name:
type: string
minLength: 2
maxLength: 100
LoginInput:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
AuthResponse:
type: object
properties:
success:
type: boolean
enum: [true]
data:
type: object
properties:
token:
type: string
refreshToken:
type: string
user:
$ref: '#/components/schemas/User'
User:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
name:
type: string
avatarUrl:
type: string
nullable: true
createdAt:
type: string
format: date-time
# ===== 项目相关 =====
CreateProjectInput:
type: object
required: [name]
properties:
name:
type: string
minLength: 1
maxLength: 100
description:
type: string
maxLength: 500
color:
type: string
pattern: '^#[0-9a-fA-F]{6}$'
default: '#3B82F6'
Project:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
description:
type: string
nullable: true
color:
type: string
ownerId:
type: string
format: uuid
memberCount:
type: integer
taskCount:
type: integer
createdAt:
type: string
format: date-time
# ===== 任务相关 =====
TaskStatus:
type: string
enum: [todo, in_progress, in_review, done]
TaskPriority:
type: string
enum: [low, medium, high, urgent]
CreateTaskInput:
type: object
required: [title]
properties:
title:
type: string
minLength: 1
maxLength: 200
description:
type: string
maxLength: 5000
priority:
$ref: '#/components/schemas/TaskPriority'
assigneeId:
type: string
format: uuid
nullable: true
dueDate:
type: string
format: date
nullable: true
tags:
type: array
items:
type: string
maxItems: 10
UpdateTaskInput:
type: object
properties:
title:
type: string
minLength: 1
maxLength: 200
description:
type: string
maxLength: 5000
status:
$ref: '#/components/schemas/TaskStatus'
priority:
$ref: '#/components/schemas/TaskPriority'
assigneeId:
type: string
format: uuid
nullable: true
dueDate:
type: string
format: date
nullable: true
tags:
type: array
items:
type: string
maxItems: 10
position:
type: integer
description: 看板中的排序位置
Task:
type: object
properties:
id:
type: string
format: uuid
title:
type: string
description:
type: string
nullable: true
status:
$ref: '#/components/schemas/TaskStatus'
priority:
$ref: '#/components/schemas/TaskPriority'
assignee:
$ref: '#/components/schemas/User'
nullable: true
creator:
$ref: '#/components/schemas/User'
tags:
type: array
items:
type: string
dueDate:
type: string
format: date
nullable: true
position:
type: integer
projectId:
type: string
format: uuid
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
# ===== 通用 =====
ApiResponse:
type: object
properties:
success:
type: boolean
data: {}
error:
type: object
properties:
code:
type: string
message:
type: string
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
数据库 Schema 设计
// ===== packages/backend/src/db/schema.ts =====
import {
pgTable, uuid, varchar, text, boolean,
timestamp, integer, date, index, unique,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// ===== 用户表 =====
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 100 }).notNull(),
passwordHash: text('password_hash').notNull(),
avatarUrl: text('avatar_url'),
emailVerified: boolean('email_verified').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
emailIdx: index('users_email_idx').on(table.email),
}));
// ===== 项目表 =====
export const projects = pgTable('projects', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
description: text('description'),
color: varchar('color', { length: 7 }).default('#3B82F6').notNull(),
ownerId: uuid('owner_id').notNull().references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
ownerIdx: index('projects_owner_idx').on(table.ownerId),
}));
// ===== 项目成员表(多对多) =====
export const projectMembers = pgTable('project_members', {
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: varchar('role', { length: 20 }).default('member').notNull(), // owner | admin | member
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
pk: index('project_members_pk').on(table.projectId, table.userId),
uniqueMember: unique('project_members_unique').on(table.projectId, table.userId),
}));
// ===== 任务表 =====
export const tasks = pgTable('tasks', {
id: uuid('id').defaultRandom().primaryKey(),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
status: varchar('status', { length: 20 }).default('todo').notNull(),
priority: varchar('priority', { length: 20 }).default('medium').notNull(),
position: integer('position').default(0).notNull(),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
creatorId: uuid('creator_id').notNull().references(() => users.id),
assigneeId: uuid('assignee_id').references(() => users.id),
dueDate: date('due_date'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
projectIdx: index('tasks_project_idx').on(table.projectId),
statusIdx: index('tasks_status_idx').on(table.projectId, table.status),
assigneeIdx: index('tasks_assignee_idx').on(table.assigneeId),
dueDateIdx: index('tasks_due_date_idx').on(table.dueDate),
positionIdx: index('tasks_position_idx').on(table.projectId, table.position),
}));
// ===== 任务标签表(多对多) =====
export const taskTags = pgTable('task_tags', {
taskId: uuid('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
tag: varchar('tag', { length: 50 }).notNull(),
}, (table) => ({
taskIdx: index('task_tags_task_idx').on(table.taskId),
tagIdx: index('task_tags_tag_idx').on(table.tag),
uniqueTag: unique('task_tags_unique').on(table.taskId, table.tag),
}));
// ===== 通知表 =====
export const notifications = pgTable('notifications', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
type: varchar('type', { length: 50 }).notNull(),
title: varchar('title', { length: 200 }).notNull(),
message: text('message'),
taskId: uuid('task_id').references(() => tasks.id, { onDelete: 'cascade' }),
read: boolean('read').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
userIdx: index('notifications_user_idx').on(table.userId),
unreadIdx: index('notifications_unread_idx').on(table.userId, table.read),
}));
// ===== 关系定义 =====
export const usersRelations = relations(users, ({ many }) => ({
ownedProjects: many(projects),
projectMemberships: many(projectMembers),
createdTasks: many(tasks, { relationName: 'creator' }),
assignedTasks: many(tasks, { relationName: 'assignee' }),
notifications: many(notifications),
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
owner: one(users, {
fields: [projects.ownerId],
references: [users.id],
}),
members: many(projectMembers),
tasks: many(tasks),
}));
export const projectMembersRelations = relations(projectMembers, ({ one }) => ({
project: one(projects, {
fields: [projectMembers.projectId],
references: [projects.id],
}),
user: one(users, {
fields: [projectMembers.userId],
references: [users.id],
}),
}));
export const tasksRelations = relations(tasks, ({ one, many }) => ({
project: one(projects, {
fields: [tasks.projectId],
references: [projects.id],
}),
creator: one(users, {
fields: [tasks.creatorId],
references: [users.id],
relationName: 'creator',
}),
assignee: one(users, {
fields: [tasks.assigneeId],
references: [users.id],
relationName: 'assignee',
}),
tags: many(taskTags),
}));
export const taskTagsRelations = relations(taskTags, ({ one }) => ({
task: one(tasks, {
fields: [taskTags.taskId],
references: [tasks.id],
}),
}));
让 Agent 审查并完善规格
在 PRD 和 API 规格初步完成后,我们让 Agent 进行审查:
Prompt:
"请审查以下 PRD 和 API 规格文档,检查:
1. 功能完整性:是否有遗漏的用户故事?
2. API 一致性:端点设计是否 RESTful 且自洽?
3. 数据模型:Schema 是否能支撑所有功能需求?
4. 安全性:认证/授权方案是否有漏洞?
5. 性能:查询模式是否有潜在的性能瓶颈?
[附上 PRD + OpenAPI + Schema]"
Agent 审查后的典型反馈:
## 审查结果
### ✅ 做得好的
- 状态机设计清晰(todo → in_progress → in_review → done)
- 数据库索引覆盖了主要查询模式
- API 响应格式统一
### ⚠️ 建议改进
1. **遗漏:任务评论功能** — 看板系统通常需要评论/讨论功能
→ 建议:V1 可以不做,但在 Schema 中预留 comments 表
2. **API 设计:任务排序** — 拖拽排序需要专门的端点
→ 建议:添加 PATCH /tasks/:id/reorder { position: number }
3. **安全性:密码强度** — 当前只有 8 字符最低要求
→ 建议:增加复杂度要求(大写+小写+数字)
4. **性能:全文搜索** — PostgreSQL 的 LIKE 查询效率低
→ 建议:使用 PostgreSQL 的 tsvector + GIN 索引
9.3 Phase 2:Harness 先行
搭建项目脚手架
# 初始化 monorepo
mkdir taskflow && cd taskflow
pnpm init
cat > pnpm-workspace.yaml << 'EOF'
packages:
- 'packages/*'
EOF
# 创建后端项目
mkdir -p packages/backend/src/{routes,services,repositories,schemas,middleware,utils,db}
cd packages/backend
pnpm init
# 安装后端依赖
pnpm add hono @hono/node-server drizzle-orm pg zod bcryptjs jsonwebtoken uuid
pnpm add -D typescript vitest supertest @types/node @types/bcryptjs \
@types/jsonwebtoken @types/supertest drizzle-kit tsx
# 创建前端项目
cd ../..
pnpm create vite packages/frontend --template react-ts
cd packages/frontend
pnpm add zustand @tanstack/react-query react-router-dom axios \
tailwindcss @tailwindcss/vite
pnpm add -D vitest @testing-library/react @testing-library/jest-dom \
jsdom msw
# 创建共享包
cd ../..
mkdir -p packages/shared/src/{schemas,types}
cd packages/shared
pnpm init
pnpm add zod
pnpm add -D typescript
配置 TypeScript 严格模式
// packages/backend/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
设置测试框架
// packages/backend/vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/db/migrations/**'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
setupFiles: ['./tests/setup.ts'],
testTimeout: 30000,
},
});
// packages/backend/tests/setup.ts
import { beforeAll, afterAll, beforeEach } from 'vitest';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from '../src/db/schema';
let pool: Pool;
beforeAll(async () => {
pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL ||
'postgresql://test:test@localhost:5432/taskflow_test',
});
});
afterAll(async () => {
await pool?.end();
});
beforeEach(async () => {
// 每个测试前清空数据(保留表结构)
const client = await pool.connect();
try {
await client.query('TRUNCATE notifications, task_tags, tasks, project_members, projects, users CASCADE');
} finally {
client.release();
}
});
export { pool };
export const getTestDb = (): ReturnType<typeof drizzle> =>
drizzle(pool, { schema });
编写 API 集成测试(测试先行)
这是 Harness Engineering 的核心——先写测试,再让 Agent 实现。
// packages/backend/tests/integration/auth.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../../src/app';
import type { Hono } from 'hono';
describe('Auth API', () => {
let app: Hono;
beforeAll(() => {
app = createApp();
});
describe('POST /api/v1/auth/register', () => {
it('应该成功注册新用户', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({
email: 'alice@example.com',
password: 'Alice@12345',
name: 'Alice Zhang',
});
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data).toHaveProperty('token');
expect(res.body.data).toHaveProperty('refreshToken');
expect(res.body.data.user).toMatchObject({
email: 'alice@example.com',
name: 'Alice Zhang',
});
// 密码不应该出现在响应中
expect(res.body.data.user).not.toHaveProperty('password');
expect(res.body.data.user).not.toHaveProperty('passwordHash');
});
it('邮箱格式无效应该返回 400', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({
email: 'not-an-email',
password: 'Alice@12345',
name: 'Alice',
});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
it('密码不满足强度要求应该返回 400', async () => {
const res = await request(app)
.post('/api/v1/auth/register')
.send({
email: 'alice@example.com',
password: 'weak', // 太短,无大写,无数字
name: 'Alice',
});
expect(res.status).toBe(400);
});
it('重复邮箱应该返回 409', async () => {
// 先注册一个用户
await request(app)
.post('/api/v1/auth/register')
.send({
email: 'duplicate@example.com',
password: 'Alice@12345',
name: 'First User',
});
// 再次注册相同邮箱
const res = await request(app)
.post('/api/v1/auth/register')
.send({
email: 'duplicate@example.com',
password: 'Bob@12345',
name: 'Second User',
});
expect(res.status).toBe(409);
expect(res.body.error.code).toBe('EMAIL_ALREADY_EXISTS');
});
});
describe('POST /api/v1/auth/login', () => {
beforeEach(async () => {
await request(app)
.post('/api/v1/auth/register')
.send({
email: 'login-test@example.com',
password: 'Test@12345',
name: 'Login Test',
});
});
it('正确凭据应该返回 Token', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({
email: 'login-test@example.com',
password: 'Test@12345',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.token).toBeTruthy();
expect(res.body.data.user.email).toBe('login-test@example.com');
});
it('错误密码应该返回 401', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({
email: 'login-test@example.com',
password: 'WrongPassword@123',
});
expect(res.status).toBe(401);
expect(res.body.error.code).toBe('INVALID_CREDENTIALS');
});
it('不存在的邮箱应该返回 401(不区分邮箱不存在和密码错误)', async () => {
const res = await request(app)
.post('/api/v1/auth/login')
.send({
email: 'nonexistent@example.com',
password: 'Test@12345',
});
expect(res.status).toBe(401);
// 安全考虑:不应透露邮箱是否已注册
expect(res.body.error.code).toBe('INVALID_CREDENTIALS');
});
});
});
// packages/backend/tests/integration/tasks.test.ts
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../../src/app';
import type { Hono } from 'hono';
describe('Task API', () => {
let app: Hono;
let authToken: string;
let projectId: string;
beforeAll(async () => {
app = createApp();
// 注册测试用户
const authRes = await request(app)
.post('/api/v1/auth/register')
.send({
email: 'task-test@example.com',
password: 'Task@12345',
name: 'Task Tester',
});
authToken = authRes.body.data.token;
// 创建测试项目
const projectRes = await request(app)
.post('/api/v1/projects')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Test Project' });
projectId = projectRes.body.data.id;
});
describe('POST /api/v1/projects/:projectId/tasks', () => {
it('应该创建任务并设置默认状态为 todo', async () => {
const res = await request(app)
.post(`/api/v1/projects/${projectId}/tasks`)
.set('Authorization', `Bearer ${authToken}`)
.send({
title: '实现用户认证',
priority: 'high',
tags: ['backend', 'auth'],
});
expect(res.status).toBe(201);
expect(res.body.data).toMatchObject({
title: '实现用户认证',
status: 'todo',
priority: 'high',
tags: ['backend', 'auth'],
});
expect(res.body.data.position).toBeGreaterThanOrEqual(0);
});
it('标题为空应该返回 400', async () => {
const res = await request(app)
.post(`/api/v1/projects/${projectId}/tasks`)
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '', priority: 'low' });
expect(res.status).toBe(400);
});
it('未认证应该返回 401', async () => {
const res = await request(app)
.post(`/api/v1/projects/${projectId}/tasks`)
.send({ title: 'Unauthorized Task' });
expect(res.status).toBe(401);
});
});
describe('PATCH /api/v1/tasks/:taskId — 状态流转', () => {
let taskId: string;
beforeEach(async () => {
const res = await request(app)
.post(`/api/v1/projects/${projectId}/tasks`)
.set('Authorization', `Bearer ${authToken}`)
.send({ title: 'Status Test Task' });
taskId = res.body.data.id;
});
it('应该允许 todo → in_progress', async () => {
const res = await request(app)
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ status: 'in_progress' });
expect(res.status).toBe(200);
expect(res.body.data.status).toBe('in_progress');
});
it('应该允许 in_progress → in_review', async () => {
// 先转到 in_progress
await request(app)
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ status: 'in_progress' });
// 再转到 in_review
const res = await request(app)
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ status: 'in_review' });
expect(res.status).toBe(200);
expect(res.body.data.status).toBe('in_review');
});
it('应该拒绝 todo → done(跳过中间状态)', async () => {
const res = await request(app)
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ status: 'done' });
expect(res.status).toBe(400);
expect(res.body.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('应该拒绝 done → todo(不允许回退已完成的任务)', async () => {
// 逐步转到 done
for (const status of ['in_progress', 'in_review', 'done']) {
await request(app)
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ status });
}
// 尝试回退到 todo
const res = await request(app)
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ status: 'todo' });
expect(res.status).toBe(400);
});
});
describe('GET /api/v1/projects/:projectId/tasks — 看板视图', () => {
beforeEach(async () => {
// 创建多个任务,分布在不同状态列
const tasks = [
{ title: 'Todo Task 1', priority: 'high' },
{ title: 'Todo Task 2', priority: 'low' },
{ title: 'Progress Task', priority: 'medium' },
{ title: 'Review Task', priority: 'high' },
{ title: 'Done Task', priority: 'medium' },
];
for (const task of tasks) {
await request(app)
.post(`/api/v1/projects/${projectId}/tasks`)
.set('Authorization', `Bearer ${authToken}`)
.send(task);
}
// 将部分任务移到其他状态
const listRes = await request(app)
.get(`/api/v1/projects/${projectId}/tasks`)
.set('Authorization', `Bearer ${authToken}`);
const allTasks = [
...listRes.body.data.todo,
...listRes.body.data.in_progress,
];
// 移 Progress Task 到 in_progress
const progressTask = allTasks.find(t => t.title === 'Progress Task');
if (progressTask) {
await request(app)
.patch(`/api/v1/tasks/${progressTask.id}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ status: 'in_progress' });
}
});
it('应该按状态分组返回任务', async () => {
const res = await request(app)
.get(`/api/v1/projects/${projectId}/tasks`)
.set('Authorization', `Bearer ${authToken}`);
expect(res.status).toBe(200);
expect(res.body.data).toHaveProperty('todo');
expect(res.body.data).toHaveProperty('in_progress');
expect(res.body.data).toHaveProperty('in_review');
expect(res.body.data).toHaveProperty('done');
expect(Array.isArray(res.body.data.todo)).toBe(true);
});
it('应该支持按优先级过滤', async () => {
const res = await request(app)
.get(`/api/v1/projects/${projectId}/tasks`)
.query({ priority: 'high' })
.set('Authorization', `Bearer ${authToken}`);
expect(res.status).toBe(200);
// 所有返回的任务优先级都应该是 high
for (const column of ['todo', 'in_progress', 'in_review', 'done']) {
for (const task of res.body.data[column]) {
expect(task.priority).toBe('high');
}
}
});
});
});
9.4 Phase 3:后端开发——Agent 在 Harness 约束下编码
第一步:数据模型与数据库迁移
Prompt to Agent:
"请根据 packages/backend/src/db/schema.ts 中的 Drizzle Schema 生成数据库迁移文件。
使用 drizzle-kit 工具。命令:pnpm drizzle-kit generate
然后运行迁移:pnpm drizzle-kit migrate
确保迁移成功。"
Agent 执行结果:
$ pnpm drizzle-kit generate
✓ Generated migration: 0000_initial_schema.sql
$ pnpm drizzle-kit migrate
✓ Applied migration: 0000_initial_schema.sql
第二步:用户认证模块(JWT)
Prompt to Agent:
"请实现用户认证模块。需要以下文件:
1. packages/backend/src/schemas/auth.ts — Zod Schema
2. packages/backend/src/repositories/user-repository.ts — 数据访问
3. packages/backend/src/services/auth-service.ts — 业务逻辑
4. packages/backend/src/routes/auth.ts — API 路由
5. packages/backend/src/middleware/auth.ts — JWT 认证中间件
参考 packages/backend/tests/integration/auth.test.ts 中的测试用例确保实现正确。
密码使用 bcryptjs(cost factor 12),JWT 使用 HS256。
确保运行 pnpm test 后所有 auth 相关测试通过。"
Agent 生成的核心代码(人类审查后):
// ===== packages/backend/src/schemas/auth.ts =====
import { z } from 'zod';
export const RegisterInputSchema = z.object({
email: z.string().email('无效的邮箱格式').max(255),
password: z.string()
.min(8, '密码至少 8 个字符')
.max(128, '密码最多 128 个字符')
.regex(/[A-Z]/, '密码必须包含至少一个大写字母')
.regex(/[a-z]/, '密码必须包含至少一个小写字母')
.regex(/[0-9]/, '密码必须包含至少一个数字'),
name: z.string().min(2, '名称至少 2 个字符').max(100),
});
export type RegisterInput = z.infer<typeof RegisterInputSchema>;
export const LoginInputSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export type LoginInput = z.infer<typeof LoginInputSchema>;
// ===== packages/backend/src/services/auth-service.ts =====
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import type { UserRepository } from '../repositories/user-repository';
import type { RegisterInput, LoginInput } from '../schemas/auth';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
const JWT_EXPIRES_IN = '24h';
const REFRESH_TOKEN_EXPIRES_IN = '7d';
const BCRYPT_ROUNDS = 12;
export class AuthService {
constructor(private readonly userRepo: UserRepository) {}
async register(input: RegisterInput): Promise<AuthResult> {
// 检查邮箱是否已注册
const existing = await this.userRepo.findByEmail(input.email);
if (existing) {
throw new AppError('EMAIL_ALREADY_EXISTS', '该邮箱已注册', 409);
}
// 加密密码
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
// 创建用户
const user = await this.userRepo.create({
email: input.email,
name: input.name,
passwordHash,
});
// 生成 Token
const token = this.generateToken(user.id);
const refreshToken = this.generateRefreshToken(user.id);
return {
token,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt,
},
};
}
async login(input: LoginInput): Promise<AuthResult> {
const user = await this.userRepo.findByEmail(input.email);
// 统一错误信息(不区分邮箱不存在和密码错误)
if (!user) {
throw new AppError('INVALID_CREDENTIALS', '邮箱或密码错误', 401);
}
const passwordValid = await bcrypt.compare(input.password, user.passwordHash);
if (!passwordValid) {
throw new AppError('INVALID_CREDENTIALS', '邮箱或密码错误', 401);
}
const token = this.generateToken(user.id);
const refreshToken = this.generateRefreshToken(user.id);
return {
token,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt,
},
};
}
private generateToken(userId: string): string {
return jwt.sign({ sub: userId, type: 'access' }, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
}
private generateRefreshToken(userId: string): string {
return jwt.sign({ sub: userId, type: 'refresh' }, JWT_SECRET, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
});
}
}
interface AuthResult {
token: string;
refreshToken: string;
user: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
createdAt: Date;
};
}
// ===== packages/backend/src/middleware/auth.ts =====
import type { Context, Next } from 'hono';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
export interface AuthUser {
userId: string;
}
export async function authMiddleware(c: Context, next: Next): Promise<void> {
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
c.status(401);
await c.json({
success: false,
error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' },
});
return;
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET) as { sub: string; type: string };
if (payload.type !== 'access') {
throw new Error('Invalid token type');
}
// 将用户信息注入上下文
c.set('authUser', { userId: payload.sub } satisfies AuthUser);
await next();
} catch {
c.status(401);
await c.json({
success: false,
error: { code: 'INVALID_TOKEN', message: '认证令牌无效或已过期' },
});
}
}
第三步:任务 CRUD API
Prompt to Agent:
"请实现任务管理模块。需要以下文件:
1. packages/backend/src/schemas/task.ts — Zod Schema
2. packages/backend/src/repositories/task-repository.ts — 数据访问
3. packages/backend/src/services/task-service.ts — 业务逻辑(含状态机)
4. packages/backend/src/routes/task.ts — API 路由
关键业务规则:
- 状态流转必须遵循状态机:todo ↔ in_progress → in_review → done
- 不允许跳过中间状态(todo → done 非法)
- 不允许已完成后回退(done → any 非法)
- 新任务默认状态为 todo
- 创建任务时自动计算 position(追加到 todo 列末尾)
参考测试文件:packages/backend/tests/integration/tasks.test.ts
确保 pnpm test 通过。"
状态机实现(核心逻辑):
// ===== packages/backend/src/services/task-service.ts(状态机部分)=====
/**
* 任务状态机定义
*
* 合法转换:
* todo → in_progress(开始工作)
* in_progress → in_review(提交审核)
* in_progress → todo(退回待办)
* in_review → done(审核通过)
* in_review → in_progress(审核退回)
*
* 非法转换:
* todo → done(不能跳过中间状态)
* todo → in_review(不能跳过 in_progress)
* done → any(完成后不可回退)
*/
const VALID_TRANSITIONS: Record<string, string[]> = {
todo: ['in_progress'],
in_progress: ['in_review', 'todo'],
in_review: ['done', 'in_progress'],
done: [], // 终态,不可转换
};
export class TaskService {
constructor(
private readonly taskRepo: TaskRepository,
private readonly notificationService: NotificationService,
) {}
async transitionStatus(
taskId: string,
newStatus: string,
actorId: string,
): Promise<Task> {
const task = await this.taskRepo.findById(taskId);
if (!task) {
throw new AppError('TASK_NOT_FOUND', '任务不存在', 404);
}
const currentStatus = task.status;
const allowedNext = VALID_TRANSITIONS[currentStatus];
if (!allowedNext || !allowedNext.includes(newStatus)) {
throw new AppError(
'INVALID_STATUS_TRANSITION',
`不允许从 ${currentStatus} 转换到 ${newStatus}。` +
`允许的目标状态:${allowedNext?.join(', ') || '无'}`,
400,
);
}
const updated = await this.taskRepo.update(taskId, {
status: newStatus,
updatedAt: new Date(),
});
// 状态变更时发送通知
await this.notificationService.notifyStatusChange(
task,
newStatus,
actorId,
);
return updated;
}
async createTask(
projectId: string,
input: CreateTaskInput,
creatorId: string,
): Promise<Task> {
// 计算 position(追加到 todo 列末尾)
const maxPosition = await this.taskRepo.getMaxPosition(projectId, 'todo');
const position = (maxPosition ?? -1) + 1;
const task = await this.taskRepo.create({
title: input.title,
description: input.description ?? null,
status: 'todo',
priority: input.priority ?? 'medium',
position,
projectId,
creatorId,
assigneeId: input.assigneeId ?? null,
dueDate: input.dueDate ?? null,
tags: input.tags ?? [],
});
// 如果有 assignee 且不是创建者自己,发送通知
if (input.assigneeId && input.assigneeId !== creatorId) {
await this.notificationService.notifyTaskAssigned(
task,
input.assigneeId,
);
}
return task;
}
async getBoardView(
projectId: string,
filters?: TaskFilters,
): Promise<BoardView> {
const tasks = await this.taskRepo.findByProject(projectId, filters);
// 按状态分组
const board: BoardView = {
todo: [],
in_progress: [],
in_review: [],
done: [],
};
for (const task of tasks) {
const status = task.status as keyof BoardView;
board[status].push(task);
}
// 每组内按 position 排序
for (const status of Object.keys(board) as (keyof BoardView)[]) {
board[status].sort((a, b) => a.position - b.position);
}
return board;
}
}
interface BoardView {
todo: Task[];
in_progress: Task[];
in_review: Task[];
done: Task[];
}
第四步至第五步
按照相同的模式,Agent 依次完成:
- 看板视图 API:拖拽排序(position 计算)、过滤、搜索
- 通知系统:站内通知 CRUD、到期提醒(cron job)
每一步都遵循相同的工作流:
Prompt → Agent 生成代码 → 运行测试 → 修复失败 → 人类审查 → 提交
9.5 Phase 4:前端开发——AI 辅助构建用户界面
React 项目初始化与组件架构设计
Prompt to Agent:
"请为 TaskFlow 设计前端组件架构。
技术栈:React 19 + TypeScript + Tailwind CSS + Zustand + React Router
需要的页面:
1. LoginPage — 登录/注册页面
2. DashboardPage — 项目列表(用户的项目)
3. BoardPage — 看板视图(单个项目的任务看板)
需要的核心组件:
1. TaskCard — 任务卡片
2. TaskColumn — 状态列(包含多个 TaskCard)
3. TaskModal — 任务详情弹窗
4. CreateTaskForm — 创建任务表单
5. ProjectCard — 项目卡片
6. NotificationBell — 通知铃铛
状态管理(Zustand Store):
1. useAuthStore — 认证状态
2. useProjectStore — 项目数据
3. useTaskStore — 任务数据
4. useNotificationStore — 通知数据
请生成组件树结构、Store 定义、路由配置。"
认证页面开发
// ===== packages/frontend/src/pages/LoginPage.tsx =====
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth-store';
import { apiClient } from '../lib/api-client';
export function LoginPage(): JSX.Element {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
try {
const endpoint = isLogin ? '/auth/login' : '/auth/register';
const body = isLogin
? { email, password }
: { email, password, name };
const res = await apiClient.post(endpoint, body);
setAuth({
token: res.data.token,
refreshToken: res.data.refreshToken,
user: res.data.user,
});
navigate('/dashboard');
} catch (err) {
const message = err instanceof Error ? err.message : '操作失败';
setError(message);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="w-full max-w-md p-8 bg-white rounded-2xl shadow-xl">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">TaskFlow</h1>
<p className="mt-2 text-gray-500">
{isLogin ? '欢迎回来' : '创建你的账号'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
名称
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
required={!isLogin}
minLength={2}
maxLength={100}
/>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
邮箱
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
密码
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
required
minLength={8}
/>
</div>
{error && (
<div className="p-3 rounded-lg bg-red-50 text-red-700 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-white font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 transition-colors"
>
{loading ? '处理中...' : (isLogin ? '登录' : '注册')}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
{isLogin ? '还没有账号?' : '已有账号?'}
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className="ml-1 text-blue-600 hover:text-blue-700 font-medium"
>
{isLogin ? '立即注册' : '返回登录'}
</button>
</p>
</div>
</div>
);
}
看板组件(核心交互)
// ===== packages/frontend/src/components/Board/TaskCard.tsx =====
import { useDraggable } from '@dnd-kit/core';
import type { Task } from '@taskflow/shared';
interface TaskCardProps {
task: Task;
onOpen: (task: Task) => void;
}
const PRIORITY_COLORS: Record<string, string> = {
urgent: 'bg-red-100 text-red-800 border-red-200',
high: 'bg-orange-100 text-orange-800 border-orange-200',
medium: 'bg-blue-100 text-blue-800 border-blue-200',
low: 'bg-gray-100 text-gray-800 border-gray-200',
};
const PRIORITY_LABELS: Record<string, string> = {
urgent: '紧急',
high: '高',
medium: '中',
low: '低',
};
export function TaskCard({ task, onOpen }: TaskCardProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: task.id,
data: { task },
});
const style = transform
? { transform: `translate(${transform.x}px, ${transform.y}px)` }
: undefined;
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done';
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={() => onOpen(task)}
className={`
p-3 bg-white rounded-lg border border-gray-200 shadow-sm
cursor-pointer hover:shadow-md transition-shadow
${isDragging ? 'opacity-50 shadow-lg' : ''}
${isOverdue ? 'border-red-300 bg-red-50' : ''}
`}
>
{/* 优先级标签 */}
<div className="flex items-center gap-2 mb-2">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium border ${PRIORITY_COLORS[task.priority]}`}>
{PRIORITY_LABELS[task.priority]}
</span>
</div>
{/* 标题 */}
<h3 className="text-sm font-medium text-gray-900 line-clamp-2">
{task.title}
</h3>
{/* 标签 */}
{task.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{task.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-block px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 text-xs"
>
{tag}
</span>
))}
{task.tags.length > 3 && (
<span className="text-xs text-gray-400">+{task.tags.length - 3}</span>
)}
</div>
)}
{/* 底部信息 */}
<div className="flex items-center justify-between mt-3 pt-2 border-t border-gray-100">
{/* 截止日期 */}
{task.dueDate && (
<span className={`text-xs ${isOverdue ? 'text-red-600 font-medium' : 'text-gray-500'}`}>
📅 {task.dueDate}
</span>
)}
{/* 负责人头像 */}
{task.assignee && (
<div
className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-xs font-medium text-blue-700"
title={task.assignee.name}
>
{task.assignee.name[0]}
</div>
)}
</div>
</div>
);
}
状态管理(Zustand Store)
// ===== packages/frontend/src/stores/task-store.ts =====
import { create } from 'zustand';
import type { Task, BoardView } from '@taskflow/shared';
import { apiClient } from '../lib/api-client';
interface TaskState {
board: BoardView | null;
selectedTask: Task | null;
loading: boolean;
error: string | null;
// Actions
fetchBoard: (projectId: string, filters?: TaskFilters) => Promise<void>;
createTask: (projectId: string, input: CreateTaskInput) => Promise<Task>;
updateTask: (taskId: string, input: UpdateTaskInput) => Promise<void>;
moveTask: (taskId: string, newStatus: string, newPosition: number) => Promise<void>;
deleteTask: (taskId: string) => Promise<void>;
setSelectedTask: (task: Task | null) => void;
}
export const useTaskStore = create<TaskState>((set, get) => ({
board: null,
selectedTask: null,
loading: false,
error: null,
fetchBoard: async (projectId, filters) => {
set({ loading: true, error: null });
try {
const res = await apiClient.get(`/projects/${projectId}/tasks`, {
params: filters,
});
set({ board: res.data.data, loading: false });
} catch (err) {
set({
error: err instanceof Error ? err.message : '加载任务失败',
loading: false,
});
}
},
createTask: async (projectId, input) => {
const res = await apiClient.post(`/projects/${projectId}/tasks`, input);
const newTask = res.data.data as Task;
// 乐观更新:立即添加到看板
const { board } = get();
if (board) {
set({
board: {
...board,
todo: [...board.todo, newTask],
},
});
}
return newTask;
},
updateTask: async (taskId, input) => {
const res = await apiClient.patch(`/tasks/${taskId}`, input);
const updatedTask = res.data.data as Task;
// 更新看板中的任务
const { board } = get();
if (board) {
const newBoard = { ...board };
for (const status of Object.keys(newBoard) as (keyof BoardView)[]) {
newBoard[status] = newBoard[status].map((t) =>
t.id === taskId ? updatedTask : t
);
}
set({ board: newBoard });
}
// 更新选中的任务
if (get().selectedTask?.id === taskId) {
set({ selectedTask: updatedTask });
}
},
moveTask: async (taskId, newStatus, newPosition) => {
// 乐观更新
const { board } = get();
if (!board) return;
// 找到任务当前所在的列
let movedTask: Task | undefined;
const newBoard = { ...board };
for (const status of Object.keys(newBoard) as (keyof BoardView)[]) {
const idx = newBoard[status].findIndex((t) => t.id === taskId);
if (idx !== -1) {
movedTask = { ...newBoard[status][idx], status: newStatus as Task['status'], position: newPosition };
newBoard[status] = newBoard[status].filter((t) => t.id !== taskId);
break;
}
}
if (movedTask) {
const targetColumn = newBoard[newStatus as keyof BoardView];
targetColumn.splice(newPosition, 0, movedTask);
set({ board: newBoard });
}
// API 调用
try {
await apiClient.patch(`/tasks/${taskId}`, {
status: newStatus,
position: newPosition,
});
} catch {
// 回滚
set({ board });
await get().fetchBoard(board.todo[0]?.projectId ?? '');
}
},
deleteTask: async (taskId) => {
await apiClient.delete(`/tasks/${taskId}`);
const { board } = get();
if (board) {
const newBoard = { ...board };
for (const status of Object.keys(newBoard) as (keyof BoardView)[]) {
newBoard[status] = newBoard[status].filter((t) => t.id !== taskId);
}
set({ board: newBoard });
}
},
setSelectedTask: (task) => set({ selectedTask: task }),
}));
9.6 Phase 5:集成测试与质量保障
端到端测试(Playwright)
// ===== packages/frontend/tests/e2e/task-flow.spec.ts =====
import { test, expect } from '@playwright/test';
test.describe('TaskFlow 完整用户流程', () => {
const testUser = {
email: `e2e-test-${Date.now()}@example.com`,
password: 'E2ETest@12345',
name: 'E2E Tester',
};
test('完整流程:注册 → 创建项目 → 创建任务 → 拖拽任务 → 完成任务', async ({ page }) => {
// ===== 1. 注册 =====
await page.goto('/register');
await page.fill('[name="name"]', testUser.name);
await page.fill('[name="email"]', testUser.email);
await page.fill('[name="password"]', testUser.password);
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await page.screenshot({ path: 'screenshots/01-dashboard.png' });
// ===== 2. 创建项目 =====
await page.click('[data-testid="create-project-btn"]');
await page.fill('[name="projectName"]', 'E2E 测试项目');
await page.click('[data-testid="confirm-create"]');
await expect(page.locator('[data-testid="project-card"]')).toContainText('E2E 测试项目');
await page.screenshot({ path: 'screenshots/02-project-created.png' });
// ===== 3. 进入看板 =====
await page.click('text=E2E 测试项目');
await expect(page).toHaveURL(/\/board\//);
await page.screenshot({ path: 'screenshots/03-board-empty.png' });
// ===== 4. 创建任务 =====
await page.click('[data-testid="add-task-btn"]');
await page.fill('[name="title"]', '第一个测试任务');
await page.selectOption('[name="priority"]', 'high');
await page.fill('[name="tags"]', 'testing');
await page.click('[data-testid="save-task"]');
await expect(page.locator('[data-testid="task-card"]')).toContainText('第一个测试任务');
await page.screenshot({ path: 'screenshots/04-task-created.png' });
// ===== 5. 创建更多任务 =====
const tasks = ['后端 API 开发', '前端页面开发', '数据库设计'];
for (const title of tasks) {
await page.click('[data-testid="add-task-btn"]');
await page.fill('[name="title"]', title);
await page.click('[data-testid="save-task"]');
}
await page.screenshot({ path: 'screenshots/05-multiple-tasks.png' });
// ===== 6. 拖拽任务到"进行中"列 =====
const taskCard = page.locator('[data-testid="task-card"]').first();
const inProgressColumn = page.locator('[data-testid="column-in_progress"]');
await taskCard.dragTo(inProgressColumn);
// 验证任务移动成功
await expect(inProgressColumn).toContainText(/第一个测试任务|后端 API 开发|前端页面开发|数据库设计/);
await page.screenshot({ path: 'screenshots/06-task-moved.png' });
// ===== 7. 打开任务详情 =====
await taskCard.click();
await expect(page.locator('[data-testid="task-modal"]')).toBeVisible();
// 更新任务状态
await page.selectOption('[data-testid="status-select"]', 'in_review');
await page.click('[data-testid="save-task-detail"]');
await page.screenshot({ path: 'screenshots/07-task-detail.png' });
// ===== 8. 完成任务 =====
// 再次打开任务,标记为完成
await taskCard.click();
await page.selectOption('[data-testid="status-select"]', 'done');
await page.click('[data-testid="save-task-detail"]');
await page.screenshot({ path: 'screenshots/08-task-completed.png' });
});
test('任务过滤和搜索', async ({ page }) => {
// 登录(复用 fixture)
await loginAs(page, testUser);
// 进入项目看板
await page.click('text=E2E 测试项目');
// 按优先级过滤
await page.selectOption('[data-testid="filter-priority"]', 'high');
const visibleCards = page.locator('[data-testid="task-card"]');
// 所有可见卡片应该都是 high 优先级
const count = await visibleCards.count();
for (let i = 0; i < count; i++) {
await expect(visibleCards.nth(i)).toContainText('高');
}
await page.screenshot({ path: 'screenshots/09-filtered.png' });
// 搜索任务
await page.fill('[data-testid="search-input"]', 'API');
await expect(page.locator('[data-testid="task-card"]')).toContainText('后端 API 开发');
await page.screenshot({ path: 'screenshots/10-searched.png' });
});
});
// 辅助函数
async function loginAs(
page: Page,
credentials: { email: string; password: string },
): Promise<void> {
await page.goto('/login');
await page.fill('[name="email"]', credentials.email);
await page.fill('[name="password"]', credentials.password);
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
}
性能测试与优化
// ===== packages/backend/tests/performance/api-bench.test.ts =====
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { createApp } from '../../src/app';
describe('API 性能基准测试', () => {
const app = createApp();
it('GET /api/v1/projects/:id/tasks 应该在 200ms 内返回(100 条任务)', async () => {
// 准备测试数据:创建 100 条任务
const { token, projectId } = await setupTestData(app, 100);
const start = performance.now();
const res = await request(app)
.get(`/api/v1/projects/${projectId}/tasks`)
.set('Authorization', `Bearer ${token}`);
const elapsed = performance.now() - start;
expect(res.status).toBe(200);
expect(elapsed).toBeLessThan(200);
console.log(`API 响应时间: ${elapsed.toFixed(2)}ms`);
});
it('POST /api/v1/auth/login 应该在 500ms 内完成(含 bcrypt 验证)', async () => {
const { email, password } = await createTestUser(app);
const start = performance.now();
const res = await request(app)
.post('/api/v1/auth/login')
.send({ email, password });
const elapsed = performance.now() - start;
expect(res.status).toBe(200);
expect(elapsed).toBeLessThan(500);
console.log(`登录响应时间: ${elapsed.toFixed(2)}ms`);
});
});
安全审计(OWASP 检查清单)
# 安全审计清单
## 认证安全 ✅
- [x] 密码使用 bcrypt(cost factor 12)
- [x] JWT Token 有过期时间(24h)
- [x] Refresh Token 机制
- [x] 登录错误不区分"用户不存在"和"密码错误"
- [x] 密码不出现在 API 响应中
## 输入验证 ✅
- [x] 所有 API 输入使用 Zod Schema 验证
- [x] SQL 查询使用参数化查询(Drizzle ORM 自动处理)
- [x] XSS 防护(前端使用 React 自动转义)
- [x] 文件上传大小限制(10MB)
## 授权安全 ✅
- [x] 用户只能访问自己参与的项目
- [x] 任务操作需要项目成员身份
- [x] 项目删除需要 Owner 权限
## 数据安全 ⚠️
- [x] HTTPS 强制(生产环境)
- [x] 敏感信息使用环境变量
- [ ] CORS 配置(需要生产环境域名)
- [ ] Rate Limiting(建议添加)
9.7 Phase 6:部署与运维
Docker 容器化
# ===== Dockerfile — 多阶段构建 =====
# Stage 1: 构建
FROM node:22-alpine AS builder
WORKDIR /app
# 安装 pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制依赖文件
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY packages/backend/package.json packages/backend/
COPY packages/frontend/package.json packages/frontend/
COPY packages/shared/package.json packages/shared/
# 安装依赖
RUN pnpm install --frozen-lockfile
# 复制源码
COPY . .
# 构建
RUN pnpm build
# Stage 2: 运行
FROM node:22-alpine AS runner
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
# 只复制生产依赖和构建产物
COPY --from=builder /app/packages/backend/dist ./dist
COPY --from=builder /app/packages/backend/package.json ./
COPY --from=builder /app/packages/frontend/dist ./public
COPY --from=builder /app/node_modules ./node_modules
# 非 root 用户
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001
USER appuser
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]
# docker-compose.yml — 本地开发环境
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://taskflow:taskflow@db:5432/taskflow
- JWT_SECRET=${JWT_SECRET}
- NODE_ENV=production
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: taskflow
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskflow"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
生产环境配置
# .env.production — 生产环境变量
NODE_ENV=production
PORT=3000
# 数据库(使用 Railway 提供的连接字符串)
DATABASE_URL=postgresql://...
# JWT
JWT_SECRET=<使用 openssl rand -base64 32 生成>
# 邮件服务(通知用)
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=<SendGrid API Key>
# 前端
VITE_API_BASE_URL=https://api.taskflow.example.com
CI/CD 自动化部署
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run CI
uses: ./.github/workflows/ci.yml
- name: Build Docker Image
run: |
docker build -t taskflow:${{ github.sha }} .
docker tag taskflow:${{ github.sha }} taskflow:latest
- name: Push to Registry
run: |
docker login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASS }}
docker push registry.example.com/taskflow:${{ github.sha }}
docker push registry.example.com/taskflow:latest
- name: Deploy to Railway
uses: bervProject/railway-deploy@main
with:
railway_token: ${{ secrets.RAILWAY_TOKEN }}
service: taskflow-backend
9.8 项目复盘
时间分析
| Phase | AI 辅助耗时 | 传统开发估算 | 提效比 | 备注 |
|---|---|---|---|---|
| 需求分析与规格 | 1.5h | 8-16h | 5-10x | Agent 帮助结构化 PRD,审查遗漏 |
| Harness 搭建 | 1h | 4-8h | 4-8x | 配置模板化,快速搭建 |
| 后端开发 | 4h | 24-40h | 6-10x | Agent 生成 80% 代码,人类审查调整 |
| 前端开发 | 4h | 24-40h | 6-10x | Agent 生成组件框架,人类调整交互细节 |
| 集成测试 | 1.5h | 8-16h | 5-10x | Agent 生成测试代码,人类审查覆盖度 |
| 部署运维 | 0.5h | 4-8h | 8-16x | Docker + CI/CD 模板化 |
| 总计 | 12.5h | 72-128h | ~7x | 约 1.5 个工作日 vs 2-3 周 |
代码质量分析
| 指标 | Agent 生成 | 人类审查后 | 说明 |
|---|---|---|---|
| TypeScript 类型覆盖率 | 95% | 99% | Agent 偶尔遗漏复杂泛型 |
| ESLint 通过率 | 85% | 100% | 主要是 unused imports |
| 测试覆盖率 | 78% | 85% | Agent 遗漏部分边界条件 |
| CI 首次通过率 | 70% | 95% | 主要是类型和 Lint 问题 |
| 安全漏洞 | 0 | 0 | Harness 的安全检查有效 |
人类干预统计
| 干预类型 | 频率 | 说明 |
|---|---|---|
| 架构决策 | 5 次 | Agent 提供了多个方案,人类选择 |
| 代码审查修改 | ~30 处 | 品味相关(命名、抽象层次) |
| Bug 修复 | 8 处 | Agent 生成的代码中的逻辑错误 |
| 性能优化 | 3 处 | N+1 查询、不必要的重渲染 |
| 安全加固 | 2 处 | CORS 配置、Rate Limiting |
经验教训
做得好的:
- ✅ Harness 先行策略有效:测试先行的方式让 Agent 生成的代码质量很高,CI 首次通过率达 70%
- ✅ SDD 规格驱动有效:先定义 API 规格再开发,前后端对接无缝
- ✅ 分步 Prompt 策略有效:将大任务拆成小步骤,每步都容易成功
- ✅ Cursor + Claude Code 组合高效:Cursor 做精细调整,Claude Code 做批量生成
需要改进的:
- ⚠️ 前端交互细节:Agent 在复杂交互(如拖拽排序)上仍需较多人工调整
- ⚠️ 性能优化:Agent 倾向于生成"功能正确但不够高效"的代码
- ⚠️ 错误处理:Agent 的错误处理有时过于简单,需要人类补充边界情况
AI Agent 在这个项目中的真实贡献率
代码行数统计:
- Agent 生成:~4,500 行(75%)
- 人类编写:~1,200 行(20%,主要是配置和审查修改)
- 自动生成(脚手架/迁移):~300 行(5%)
价值贡献评估:
- Agent 贡献:~70%(节省了大量编码时间)
- 人类贡献:~30%(架构决策、品味审查、边界处理)
结论:Agent 是强大的"力量放大器",但不能替代人类的判断力。
最佳模式:Agent 做 80% 的"体力活",人类做 20% 的"脑力活"。
9.9 从单体到微服务的 Agent 辅助演进
当 TaskFlow 的用户量增长到一定规模,单体架构可能成为瓶颈。这时,AI Agent 可以辅助完成从单体到微服务的演进。
何时拆分
| 信号 | 说明 | 拆分建议 |
|---|---|---|
| 部署耦合 | 改一个模块要部署整个应用 | 按模块拆分 |
| 扩展不均 | 任务模块需要 10 个实例,通知模块只需 1 个 | 按扩展需求拆分 |
| 团队规模 | 团队 > 8 人,代码冲突频繁 | 按团队边界拆分 |
| 技术栈分歧 | 通知模块想用 Go,任务模块保持 TypeScript | 按技术栈拆分 |
Agent 辅助拆分策略
Prompt to Agent:
"我们计划将 TaskFlow 的任务模块和通知模块拆分为独立的微服务。
请帮我制定拆分计划:
1. 分析当前的模块依赖关系
2. 识别需要拆分的代码边界
3. 设计服务间通信方案(gRPC vs 消息队列 vs REST)
4. 制定数据迁移策略(共享数据库 vs 独立数据库)
5. 输出分步实施计划"
Agent 辅助的微服务拆分遵循"绞杀者模式"(Strangler Fig Pattern):
微服务间通信的 Agent 辅助实现
// ===== 服务间通信:事件驱动(推荐方案)=====
// 任务服务发布事件
// packages/task-service/src/events/task-events.ts
import { EventEmitter } from 'events';
export const taskEvents = new EventEmitter();
// 任务状态变更时发布事件
export function emitTaskStatusChanged(task: Task, oldStatus: string): void {
taskEvents.emit('task.status_changed', {
taskId: task.id,
projectId: task.projectId,
title: task.title,
oldStatus,
newStatus: task.status,
assigneeId: task.assigneeId,
creatorId: task.creatorId,
timestamp: new Date().toISOString(),
});
}
// 通知服务订阅事件
// packages/notification-service/src/subscribers/task-subscriber.ts
import { taskEvents } from '@taskflow/task-service/events';
taskEvents.on('task.status_changed', async (event) => {
// 发送通知给任务创建者
if (event.newStatus === 'done') {
await createNotification({
userId: event.creatorId,
type: 'task_completed',
title: '任务已完成',
message: `"${event.title}" 已标记为完成`,
taskId: event.taskId,
});
}
// 发送通知给负责人
if (event.assigneeId && event.newStatus === 'in_review') {
await createNotification({
userId: event.creatorId,
type: 'task_submitted_for_review',
title: '任务已提交审核',
message: `"${event.title}" 已提交审核,请查看`,
taskId: event.taskId,
});
}
});
9.10 本章小结与思考题
本章小结
本章通过一个完整的端到端实战项目,展示了 AI Coding 方法论在真实项目中的应用:
-
SDD(规格驱动开发):先定义 PRD、API 规格和数据模型,为后续的 Agent 编码提供精确的"地图"。
-
Harness 先行:在 Agent 编码之前就搭建好类型系统、测试框架和 CI 流水线,让 Agent 在约束中自由发挥。
-
分步开发:将大任务拆分为多个小步骤,每步都有明确的 Prompt、测试验证和人类审查。
-
前后端协同:后端先完成 API,前端基于 API 规格并行开发,AI Agent 在两端都能高效产出。
-
质量保障:E2E 测试、性能基准、安全审计三位一体,确保交付质量。
-
项目复盘:通过时间分析、代码质量分析和人类干预统计,量化 AI Coding 的真实效果。
核心发现:
- AI Agent 贡献了约 70% 的代码价值,但人类的 30% 贡献(架构决策、品味审查、边界处理)不可替代
- 最佳提效比约 7 倍(12.5 小时 vs 72-128 小时传统开发)
- Agent 的强项是"体力活"(编码、测试生成、文档),弱项是"脑力活"(架构、品味、性能优化)
- Harness Engineering 是将 Agent 产出质量从 70% 提升到 95% 的关键
思考题
-
项目规模:本章的项目是一个中等规模的 Web 应用。如果项目规模扩大 10 倍(比如一个企业级 ERP 系统),AI Coding 的策略需要如何调整?Harness 需要如何增强?
-
遗留系统:如果 TaskFlow 不是从零开始,而是一个有 5 年历史的遗留系统,你会如何使用 AI Agent 进行渐进式重构?策略与本章有什么不同?
-
团队协作:本章的开发过程是单人+AI 的模式。如果是一个 5 人团队同时使用 AI Agent 开发,你会如何协调?CLAUDE.md 和 .cursorrules 如何共享和同步?
-
技术债务:Agent 生成的代码中,哪些类型的问题最容易成为技术债务?你会如何在 Harness 中预防这些问题?
-
成本分析:计算本章项目的 AI API 调用成本(基于 Claude Sonnet 的定价:$3/M input, $15/M output)。与传统开发的人力成本对比,AI Coding 的经济效益如何?
-
质量权衡:在什么场景下,你会接受 Agent 生成的"不完美但可用"的代码,而不是花时间让它生成"完美"的代码?你的判断标准是什么?
-
微服务拆分:在 §9.9 中我们讨论了从单体到微服务的演进。你认为 Agent 辅助微服务拆分的最大风险是什么?如何设计 Harness 来降低这个风险?
-
未来展望:假设 AI Agent 的能力在未来 2 年内提升 10 倍(代码质量接近人类高级工程师),本章的开发流程和 Harness 策略需要如何调整?人类的角色会发生什么变化?
下一篇预告:在第10章中,我们将从个人开发者的视角扩展到团队级别。你将学习如何在团队中推广 AI Coding 实践,建立团队级的 AI 工程规范,以及管理 AI 生成代码的质量和安全。
更多推荐

所有评论(0)