Files
PicAnalysis/.project/sprints/sprint-1.md
T
congsh 1a0ebde95d feat: 初始化 PicAnalysis 项目
完整的前后端图片分析应用,包含:
- 后端: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>
2026-02-22 20:10:11 +08:00

950 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=""
```