1a0ebde95d
完整的前后端图片分析应用,包含: - 后端:Express + Prisma + SQLite,101个单元测试全部通过 - 前端:React + TypeScript + Vite,47个单元测试,89.73%覆盖率 - E2E测试:Playwright 测试套件 - MCP集成:Playwright MCP配置完成并测试通过 功能模块: - 用户认证(JWT) - 文档管理(CRUD) - 待办管理(三态工作流) - 图片管理(上传、截图、OCR) 测试覆盖: - 后端单元测试:101/101 ✅ - 前端单元测试:47/47 ✅ - E2E测试:通过 ✅ - MCP Playwright测试:通过 ✅ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
950 lines
23 KiB
Markdown
950 lines
23 KiB
Markdown
# 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=""
|
||
```
|