Files
PicAnalysis/.project/sprints/sprint-1.md
wjl 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

23 KiB
Raw Blame History

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=""