完整的前后端图片分析应用,包含: - 后端: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>
23 KiB
23 KiB
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
测试任务
// 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/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])
}
测试任务
// 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
测试任务
密码服务测试
// 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服务测试
// 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集成测试
// 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配置
- 日志中间件
- 请求日志
测试任务
// 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
# 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
# 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
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
测试任务
# 测试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.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=""