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