# Sprint 1: 基础架构 - 详细计划 **时间**: Days 1-5 (2026-02-21 ~ 2026-02-26) **目标**: 搭建项目基础架构,实现用户认证系统 **状态**: 🔄 进行中 --- ## Sprint 目标 ### 主要目标 - ✅ 初始化前后端项目结构 - ✅ 设计并创建数据库Schema - ✅ 实现用户认证系统(注册、登录、JWT) - ✅ 搭建基础API框架 - ✅ 配置Docker环境 ### 验收标准 - [ ] 所有测试通过(单元+集成) - [ ] 代码覆盖率 ≥ 80% - [ ] 可以通过API完成注册登录 - [ ] Docker一键启动成功 --- ## 任务列表 ### Task 1.1: 项目初始化 (0.5天) **负责人**: - **优先级**: P0 **依赖**: 无 #### 子任务 - [ ] 创建前端项目 (React + Vite + TypeScript) - [ ] 创建后端项目 (Express + TypeScript) - [ ] 配置Prisma ORM - [ ] 配置测试框架 (Jest + Vitest) - [ ] 配置ESLint + Prettier - [ ] 创建.gitignore #### 测试任务 ```typescript // tests/config/build.config.test.ts describe('Build Configuration', () => { it('should have valid package.json', () => { // 验证依赖、脚本等 }); it('should compile TypeScript without errors', () => { // 验证TypeScript配置 }); }); ``` #### Ralph 问题 - **开始前**: 我是否需要所有这些依赖? - **实现中**: 配置是否最小化? - **完成后**: 项目结构是否清晰? #### 验收标准 - [ ] `npm install` 成功 - [ ] `npm run build` 成功 - [ ] `npm run test` 运行成功 - [ ] 目录结构符合规范 --- ### Task 1.2: 数据库Schema设计 (1天) **负责人**: - **优先级**: P0 **依赖**: Task 1.1 #### 子任务 - [ ] 定义Prisma Schema - [ ] User模型 - [ ] Document模型 - [ ] Image模型 - [ ] Todo模型 - [ ] Category模型 - [ ] Tag模型 - [ ] AIAnalysis模型 - [ ] Config模型 - [ ] 定义实体关系 - [ ] 添加索引 - [ ] 创建Migration - [ ] 创建Seed脚本 #### Prisma Schema (草案) ```prisma // prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(uuid()) username String @unique email String? @unique password_hash String created_at DateTime @default(now()) updated_at DateTime @updatedAt documents Document[] todos Todo[] categories Category[] tags Tag[] images Image[] configs Config[] } model Document { id String @id @default(uuid()) user_id String title String? content String category_id String? created_at DateTime @default(now()) updated_at DateTime @updatedAt user User @relation(fields: [user_id], references: [id]) category Category? @relation(fields: [category_id], references: [id]) images Image[] aiAnalysis AIAnalysis? @@index([user_id]) @@index([category_id]) } model Image { id String @id @default(uuid()) user_id String document_id String? file_path String file_size Int mime_type String ocr_result String? ocr_confidence Float? processing_status String @default("pending") // pending/processing/success/failed quality_score Float? error_message String? created_at DateTime @default(now()) updated_at DateTime @updatedAt user User @relation(fields: [user_id], references: [id]) document Document? @relation(fields: [document_id], references: [id]) @@index([user_id]) @@index([processing_status]) } model Todo { id String @id @default(uuid()) user_id String document_id String? title String description String? priority String @default("medium") // high/medium/low status String @default("pending") // pending/completed/confirmed due_date DateTime? category_id String? completed_at DateTime? confirmed_at DateTime? created_at DateTime @default(now()) updated_at DateTime @updatedAt user User @relation(fields: [user_id], references: [id]) document Document? @relation(fields: [document_id], references: [id]) category Category? @relation(fields: [category_id], references: [id]) @@index([user_id]) @@index([status]) @@index([category_id]) } model Category { id String @id @default(uuid()) user_id String name String type String // document/todo color String? icon String? parent_id String? sort_order Int @default(0) usage_count Int @default(0) is_ai_created Boolean @default(false) created_at DateTime @default(now()) user User @relation(fields: [user_id], references: [id]) parent Category? @relation("CategoryToCategory", fields: [parent_id], references: [id]) children Category[] @relation("CategoryToCategory") documents Document[] todos Todo[] @@index([user_id]) @@index([type]) } model Tag { id String @id @default(uuid()) user_id String name String color String? usage_count Int @default(0) is_ai_created Boolean @default(false) created_at DateTime @default(now()) user User @relation(fields: [user_id], references: [id]) @@unique([user_id, name]) @@index([user_id]) } model AIAnalysis { id String @id @default(uuid()) document_id String @unique provider String model String suggested_tags String // JSON suggested_category String? summary String? raw_response String // JSON created_at DateTime @default(now()) document Document @relation(fields: [document_id], references: [id]) } model Config { id String @id @default(uuid()) user_id String key String value String // JSON created_at DateTime @default(now()) updated_at DateTime @updatedAt user User @relation(fields: [user_id], references: [id]) @@unique([user_id, key]) @@index([user_id]) } ``` #### 测试任务 ```typescript // tests/database/schema.test.ts describe('Database Schema', () => { beforeAll(async () => { await prisma.$executeRawUnsafe('DELETE FROM User'); }); describe('User Model', () => { it('should create user with valid data', async () => { const user = await prisma.user.create({ data: { username: 'testuser', email: 'test@example.com', password_hash: 'hash123' } }); expect(user).toHaveProperty('id'); expect(user.username).toBe('testuser'); }); it('should enforce unique username', async () => { await prisma.user.create({ data: { username: 'duplicate', email: 'a@test.com', password_hash: 'hash' } }); await expect( prisma.user.create({ data: { username: 'duplicate', email: 'b@test.com', password_hash: 'hash' } }) ).rejects.toThrow(); }); it('should hash password (application layer)', async () => { // This tests the PasswordService, not Prisma directly const hash = await PasswordService.hash('password123'); expect(hash).not.toBe('password123'); expect(hash.length).toBe(60); }); }); describe('Image Model', () => { it('should allow image without document', async () => { const image = await prisma.image.create({ data: { user_id: userId, file_path: '/path/to/image.png', file_size: 1024, mime_type: 'image/png', document_id: null } }); expect(image.document_id).toBeNull(); }); }); describe('Todo Status', () => { it('should support three states', async () => { const statuses = ['pending', 'completed', 'confirmed']; for (const status of statuses) { const todo = await prisma.todo.create({ data: { user_id: userId, title: `Test ${status}`, status: status as any } }); expect(todo.status).toBe(status); } }); }); }); ``` #### Ralph 问题 - **开始前**: 数据模型是否完整? - **实现中**: 关系设计是否正确? - **完成后**: 索引是否足够? #### 验收标准 - [ ] Migration成功执行 - [ ] Seed脚本运行成功 - [ ] 所有测试通过 - [ ] 数据可以正确CRUD --- ### Task 1.3: 用户认证系统 (1.5天) **负责人**: - **优先级**: P0 **依赖**: Task 1.2 #### 子任务 - [ ] PasswordService (bcrypt) - [ ] AuthService (JWT) - [ ] UserService (CRUD) - [ ] AuthController - [ ] 认证中间件 - [ ] 注册API - [ ] 登录API - [ ] 登出API - [ ] 获取当前用户API #### 测试任务 **密码服务测试** ```typescript // tests/services/password.service.test.ts describe('PasswordService', () => { describe('hash', () => { it('should hash password with bcrypt', async () => { const plainPassword = 'MySecurePassword123!'; const hash = await PasswordService.hash(plainPassword); expect(hash).toBeDefined(); expect(hash).not.toBe(plainPassword); expect(hash.length).toBe(60); // bcrypt hash length }); it('should generate different hashes for same password', async () => { const password = 'test123'; const hash1 = await PasswordService.hash(password); const hash2 = await PasswordService.hash(password); expect(hash1).not.toBe(hash2); // salt is different }); it('should handle empty string', async () => { const hash = await PasswordService.hash(''); expect(hash).toBeDefined(); expect(hash.length).toBe(60); }); }); describe('verify', () => { it('should verify correct password', async () => { const password = 'test123'; const hash = await PasswordService.hash(password); const isValid = await PasswordService.verify(password, hash); expect(isValid).toBe(true); }); it('should reject wrong password', async () => { const hash = await PasswordService.hash('test123'); const isValid = await PasswordService.verify('wrong', hash); expect(isValid).toBe(false); }); it('should reject invalid hash format', async () => { await expect( PasswordService.verify('test', 'invalid-hash') ).rejects.toThrow(); }); }); }); ``` **JWT服务测试** ```typescript // tests/services/auth.service.test.ts describe('AuthService', () => { describe('generateToken', () => { it('should generate valid JWT token', () => { const payload = { user_id: 'user-123' }; const token = AuthService.generateToken(payload); expect(token).toBeDefined(); expect(typeof token).toBe('string'); const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any; expect(decoded.user_id).toBe('user-123'); }); it('should set appropriate expiration', () => { const token = AuthService.generateToken({ user_id: 'test' }); const decoded = jwt.decode(token) as any; // Verify expiration is set (24 hours) const exp = decoded.exp; const iat = decoded.iat; expect(exp - iat).toBe(24 * 60 * 60); // 24 hours in seconds }); }); describe('verifyToken', () => { it('should verify valid token', () => { const payload = { user_id: 'user-123' }; const token = AuthService.generateToken(payload); const decoded = AuthService.verifyToken(token); expect(decoded.user_id).toBe('user-123'); }); it('should reject expired token', () => { const expiredToken = jwt.sign( { user_id: 'test' }, process.env.JWT_SECRET!, { expiresIn: '0s' } ); // Wait a moment for token to expire setTimeout(() => { expect(() => AuthService.verifyToken(expiredToken)) .toThrow(); }, 100); }); it('should reject malformed token', () => { expect(() => AuthService.verifyToken('not-a-token')) .toThrow(); }); it('should reject token with wrong secret', () => { const token = jwt.sign({ user_id: 'test' }, 'wrong-secret'); expect(() => AuthService.verifyToken(token)) .toThrow(); }); }); }); ``` **认证API集成测试** ```typescript // tests/integration/auth.api.test.ts describe('Auth API', () => { describe('POST /api/auth/register', () => { it('should register new user', async () => { const response = await request(app) .post('/api/auth/register') .send({ username: 'newuser', email: 'new@test.com', password: 'password123' }); expect(response.status).toBe(201); expect(response.body.success).toBe(true); expect(response.body.data).toHaveProperty('token'); expect(response.body.data.user).toHaveProperty('id'); expect(response.body.data.user.username).toBe('newuser'); expect(response.body.data.user).not.toHaveProperty('password_hash'); }); it('should reject duplicate username', async () => { await prisma.user.create({ data: { username: 'existing', email: 'existing@test.com', password_hash: 'hash' } }); const response = await request(app) .post('/api/auth/register') .send({ username: 'existing', email: 'another@test.com', password: 'password123' }); expect(response.status).toBe(409); expect(response.body.success).toBe(false); expect(response.body.error).toContain('用户名已存在'); }); it('should reject weak password', async () => { const response = await request(app) .post('/api/auth/register') .send({ username: 'test', email: 'test@test.com', password: '123' // too short }); expect(response.status).toBe(400); expect(response.body.success).toBe(false); }); it('should reject missing fields', async () => { const response = await request(app) .post('/api/auth/register') .send({ username: 'test' // missing email and password }); expect(response.status).toBe(400); }); }); describe('POST /api/auth/login', () => { beforeEach(async () => { const hash = await PasswordService.hash('password123'); await prisma.user.create({ data: { username: 'loginuser', email: 'login@test.com', password_hash: hash } }); }); it('should login with correct credentials', async () => { const response = await request(app) .post('/api/auth/login') .send({ username: 'loginuser', password: 'password123' }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data).toHaveProperty('token'); }); it('should reject wrong password', async () => { const response = await request(app) .post('/api/auth/login') .send({ username: 'loginuser', password: 'wrongpassword' }); expect(response.status).toBe(401); expect(response.body.success).toBe(false); }); it('should reject non-existent user', async () => { const response = await request(app) .post('/api/auth/login') .send({ username: 'nonexistent', password: 'password123' }); expect(response.status).toBe(401); }); }); describe('GET /api/auth/me', () => { it('should return current user with valid token', async () => { const user = await prisma.user.create({ data: { username: 'meuser', email: 'me@test.com', password_hash: 'hash' } }); const token = AuthService.generateToken({ user_id: user.id }); const response = await request(app) .get('/api/auth/me') .set('Authorization', `Bearer ${token}`); expect(response.status).toBe(200); expect(response.body.data.id).toBe(user.id); expect(response.body.data.username).toBe('meuser'); }); it('should reject request without token', async () => { const response = await request(app) .get('/api/auth/me'); expect(response.status).toBe(401); }); it('should reject request with invalid token', async () => { const response = await request(app) .get('/api/auth/me') .set('Authorization', 'Bearer invalid-token'); expect(response.status).toBe(401); }); }); describe('Data Isolation', () => { it('should not allow user to access other user data', async () => { const user1 = await createTestUser('user1'); const user2 = await createTestUser('user2'); // Create document for user1 await prisma.document.create({ data: { user_id: user1.id, content: 'User 1 document' } }); // Try to access with user2 token const token = AuthService.generateToken({ user_id: user2.id }); const response = await request(app) .get('/api/documents') .set('Authorization', `Bearer ${token}`); // Should only return user2's documents (empty) expect(response.body.data).toHaveLength(0); }); }); }); ``` #### Ralph 问题 - **开始前**: 安全性考虑是否充分? - **实现中**: Token过期是否正确处理? - **完成后**: 数据隔离是否验证? #### 验收标准 - [ ] 密码使用bcrypt加密 - [ ] JWT生成和验证正确 - [ ] 所有API测试通过 - [ ] 数据隔离验证通过 - [ ] 代码覆盖率 ≥ 85% --- ### Task 1.4: 基础API框架 (1天) **负责人**: - **优先级**: P0 **依赖**: Task 1.3 #### 子任务 - [ ] 统一响应格式中间件 - [ ] 错误处理中间件 - [ ] 请求验证中间件 - [ ] CORS配置 - [ ] 日志中间件 - [ ] 请求日志 #### 测试任务 ```typescript // tests/middleware/response-format.test.ts describe('Response Format Middleware', () => { it('should format success response', async () => { const response = await request(app) .get('/api/test-success'); expect(response.body).toEqual({ success: true, data: { message: 'test' } }); }); it('should format error response', async () => { const response = await request(app) .get('/api/test-error'); expect(response.body).toEqual({ success: false, error: expect.any(String) }); }); }); describe('Error Handler Middleware', () => { it('should handle 404 errors', async () => { const response = await request(app) .get('/api/non-existent'); expect(response.status).toBe(404); expect(response.body.success).toBe(false); }); it('should handle validation errors', async () => { const response = await request(app) .post('/api/auth/register') .send({ username: '' }); // invalid expect(response.status).toBe(400); }); }); describe('CORS Middleware', () => { it('should set CORS headers', async () => { const response = await request(app) .get('/api/test') .set('Origin', 'http://localhost:3000'); expect(response.headers['access-control-allow-origin']).toBeDefined(); }); }); ``` #### Ralph 问题 - **开始前**: API设计是否RESTful? - **实现中**: 错误处理是否统一? - **完成后**: 响应格式是否一致? #### 验收标准 - [ ] 统一响应格式 - [ ] 错误正确处理 - [ ] CORS正确配置 - [ ] 日志正常输出 --- ### Task 1.5: Docker配置 (0.5天) **负责人**: - **优先级**: P1 **依赖**: Task 1.4 #### 子任务 - [ ] 后端Dockerfile - [ ] 前端Dockerfile - [ ] docker-compose.yml - [ ] .dockerignore - [ ] 启动脚本 #### Dockerfile示例 **后端 Dockerfile** ```dockerfile # backend/Dockerfile FROM node:18-alpine WORKDIR /app # Install dependencies COPY package*.json ./ RUN npm ci --only=production # Copy source COPY . . # Generate Prisma Client RUN npx prisma generate # Expose port EXPOSE 4000 # Start server CMD ["npm", "start"] ``` **前端 Dockerfile** ```dockerfile # frontend/Dockerfile FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` **docker-compose.yml** ```yaml version: '3.8' services: backend: build: ./backend ports: - "4000:4000" environment: - DATABASE_URL=file:./dev.db - JWT_SECRET=${JWT_SECRET} - NODE_ENV=production volumes: - ./backend/uploads:/app/uploads - ./backend/data:/app/data frontend: build: ./frontend ports: - "80:80" depends_on: - backend ``` #### 测试任务 ```bash # 测试Docker构建 docker-compose build # 测试启动 docker-compose up -d # 验证服务 curl http://localhost:4000/api/health curl http://localhost/ ``` #### Ralph 问题 - **开始前**: 需要多少容器? - **实现中**: 数据卷是否持久化? - **完成后**: 是否可以一键启动? #### 验收标准 - [ ] Docker构建成功 - [ ] docker-compose启动成功 - [ ] 服务正常访问 - [ ] 数据持久化 --- ## Sprint 回顾 ### 完成情况 - [ ] Task 1.1: 项目初始化 - [ ] Task 1.2: 数据库Schema设计 - [ ] Task 1.3: 用户认证系统 - [ ] Task 1.4: 基础API框架 - [ ] Task 1.5: Docker配置 ### 测试覆盖率 | 模块 | 目标 | 实际 | 状态 | |------|------|------|------| | 密码服务 | 90% | - | - | | JWT服务 | 90% | - | - | | 认证API | 85% | - | - | | 中间件 | 80% | - | - | | 数据库 | 80% | - | - | ### 风险与问题 | 问题 | 影响 | 状态 | 解决方案 | |------|------|------|----------| | - | - | - | - | ### 下一步 - Sprint 2: 图片与OCR功能 --- ## 附录 ### 目录结构 ``` picAnalysis/ ├── backend/ │ ├── prisma/ │ │ ├── schema.prisma │ │ └── migrations/ │ ├── src/ │ │ ├── controllers/ │ │ ├── services/ │ │ ├── middleware/ │ │ ├── routes/ │ │ ├── utils/ │ │ └── index.ts │ ├── tests/ │ │ ├── unit/ │ │ └── integration/ │ ├── package.json │ ├── tsconfig.json │ └── Dockerfile ├── frontend/ │ ├── src/ │ │ ├── components/ │ │ ├── pages/ │ │ ├── services/ │ │ ├── hooks/ │ │ ├── utils/ │ │ └── main.tsx │ ├── tests/ │ ├── package.json │ ├── vite.config.ts │ └── Dockerfile ├── .project/ │ ├── requirements.md │ ├── development-plan.md │ └── sprints/ ├── docker-compose.yml └── README.md ``` ### 环境变量模板 ```env # .env.example DATABASE_URL="file:./dev.db" JWT_SECRET="your-secret-key-here" NODE_ENV="development" PORT=4000 # OCR OCR_PROVIDER="local" # local/baidu/tencent # AI AI_PROVIDER="glm" # glm/minimax/deepseek AI_API_KEY="" AI_API_URL="" ```