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>
This commit is contained in:
wjl
2026-02-22 20:10:11 +08:00
commit 1a0ebde95d
122 changed files with 30760 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
/**
* Auth Controller
* Handles authentication requests (register, login, logout, me)
*/
import { Request, Response } from 'express';
import { prisma } from '../lib/prisma';
import { PasswordService } from '../services/password.service';
import { AuthService } from '../services/auth.service';
export class AuthController {
/**
* Register a new user
* POST /api/auth/register
*/
static async register(req: Request, res: Response): Promise<void> {
try {
const { username, email, password } = req.body;
// Validate required fields
if (!username || !password) {
res.status(400).json({
success: false,
error: '用户名和密码必填',
});
return;
}
// Check password strength
const strengthCheck = PasswordService.checkStrength(password);
if (!strengthCheck.isStrong) {
res.status(400).json({
success: false,
error: strengthCheck.reason,
});
return;
}
// Check if username already exists
const existingUser = await prisma.user.findUnique({
where: { username },
});
if (existingUser) {
res.status(409).json({
success: false,
error: '用户名已存在',
});
return;
}
// Check if email already exists
if (email) {
const existingEmail = await prisma.user.findUnique({
where: { email },
});
if (existingEmail) {
res.status(409).json({
success: false,
error: '邮箱已被注册',
});
return;
}
}
// Hash password
const password_hash = await PasswordService.hash(password);
// Create user
const user = await prisma.user.create({
data: {
username,
email,
password_hash,
},
});
// Generate token
const token = AuthService.generateToken({ user_id: user.id });
// Return user data (without password)
const { password_hash: _, ...userWithoutPassword } = user;
res.status(201).json({
success: true,
data: {
token,
user: userWithoutPassword,
},
});
} catch (error) {
console.error('Register error:', error);
res.status(500).json({
success: false,
error: '注册失败',
});
}
}
/**
* Login user
* POST /api/auth/login
*/
static async login(req: Request, res: Response): Promise<void> {
try {
const { username, password } = req.body;
// Validate required fields
if (!username || !password) {
res.status(400).json({
success: false,
error: '用户名和密码必填',
});
return;
}
// Find user by username or email
const user = await prisma.user.findFirst({
where: {
OR: [{ username }, { email: username }],
},
});
if (!user) {
res.status(401).json({
success: false,
error: '用户名或密码错误',
});
return;
}
// Verify password
const isValid = await PasswordService.verify(password, user.password_hash);
if (!isValid) {
res.status(401).json({
success: false,
error: '用户名或密码错误',
});
return;
}
// Generate token
const token = AuthService.generateToken({ user_id: user.id });
// Return user data (without password)
const { password_hash: _, ...userWithoutPassword } = user;
res.status(200).json({
success: true,
data: {
token,
user: userWithoutPassword,
},
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
error: '登录失败',
});
}
}
/**
* Get current user
* GET /api/auth/me
*/
static async me(req: Request, res: Response): Promise<void> {
try {
// User is attached by authenticate middleware
const userId = req.user!.user_id;
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
res.status(404).json({
success: false,
error: '用户不存在',
});
return;
}
// Return user data (without password)
const { password_hash: _, ...userWithoutPassword } = user;
res.status(200).json({
success: true,
data: userWithoutPassword,
});
} catch (error) {
console.error('Get me error:', error);
res.status(500).json({
success: false,
error: '获取用户信息失败',
});
}
}
/**
* Logout
* POST /api/auth/logout
* Note: With JWT, logout is client-side (remove token)
*/
static async logout(_req: Request, res: Response): Promise<void> {
// With JWT, logout is typically handled client-side
// Server-side may implement token blacklist if needed
res.status(200).json({
success: true,
message: '登出成功',
});
}
}

View File

@@ -0,0 +1,158 @@
/**
* Document Controller
* Handles document API requests
*/
import { Request, Response } from 'express';
import { DocumentService } from '../services/document.service';
export class DocumentController {
/**
* Create a new document
* POST /api/documents
*/
static async create(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { content, title, category_id } = req.body;
const document = await DocumentService.create({
user_id: userId,
content,
title,
category_id,
});
res.status(201).json({
success: true,
data: document,
});
} catch (error) {
const message = error instanceof Error ? error.message : '创建文档失败';
res.status(400).json({
success: false,
error: message,
});
}
}
/**
* Get document by ID
* GET /api/documents/:id
*/
static async getById(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
const document = await DocumentService.findById(id, userId);
if (!document) {
res.status(404).json({
success: false,
error: '文档不存在',
});
return;
}
res.status(200).json({
success: true,
data: document,
});
} catch (error) {
const message = error instanceof Error ? error.message : '获取文档失败';
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* Update document
* PUT /api/documents/:id
*/
static async update(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
const { content, title, category_id } = req.body;
const document = await DocumentService.update(id, userId, {
content,
title,
category_id,
});
res.status(200).json({
success: true,
data: document,
});
} catch (error) {
const message = error instanceof Error ? error.message : '更新文档失败';
res.status(error instanceof Error && message.includes('not found') ? 404 : 400).json({
success: false,
error: message,
});
}
}
/**
* Delete document
* DELETE /api/documents/:id
*/
static async delete(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
await DocumentService.delete(id, userId);
res.status(200).json({
success: true,
message: '文档已删除',
});
} catch (error) {
const message = error instanceof Error ? error.message : '删除文档失败';
res.status(error instanceof Error && message.includes('not found') ? 404 : 400).json({
success: false,
error: message,
});
}
}
/**
* Get user documents
* GET /api/documents
*/
static async getUserDocuments(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { category_id, page, limit, search } = req.query;
let documents;
if (search && typeof search === 'string') {
documents = await DocumentService.search(userId, search);
} else {
documents = await DocumentService.findByUser(userId, {
category_id: category_id as string | undefined,
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
}
res.status(200).json({
success: true,
data: documents,
count: documents.length,
});
} catch (error) {
const message = error instanceof Error ? error.message : '获取文档列表失败';
res.status(500).json({
success: false,
error: message,
});
}
}
}

View File

@@ -0,0 +1,195 @@
/**
* Image Controller
* Handles image upload and management
*/
import { Request, Response } from 'express';
import { ImageService } from '../services/image.service';
export class ImageController {
/**
* Upload image (creates record)
* POST /api/images
*/
static async upload(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
// Assuming file is processed by multer middleware
const { file_path, file_size, mime_type } = req.body;
const { document_id } = req.body;
const image = await ImageService.create({
user_id: userId,
file_path,
file_size,
mime_type,
document_id,
});
res.status(201).json({
success: true,
data: image,
});
} catch (error) {
const message = error instanceof Error ? error.message : '上传图片失败';
res.status(400).json({
success: false,
error: message,
});
}
}
/**
* Get image by ID
* GET /api/images/:id
*/
static async getById(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
const image = await ImageService.findById(id, userId);
if (!image) {
res.status(404).json({
success: false,
error: '图片不存在',
});
return;
}
res.status(200).json({
success: true,
data: image,
});
} catch (error) {
res.status(500).json({
success: false,
error: '获取图片失败',
});
}
}
/**
* Update OCR result
* PUT /api/images/:id/ocr
*/
static async updateOCR(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
const { text, confidence } = req.body;
const image = await ImageService.updateOCRResult(id, userId, text, confidence);
res.status(200).json({
success: true,
data: image,
});
} catch (error) {
const message = error instanceof Error ? error.message : '更新OCR结果失败';
res.status(message.includes('not found') ? 404 : 400).json({
success: false,
error: message,
});
}
}
/**
* Link image to document
* PUT /api/images/:id/link
*/
static async linkToDocument(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
const { document_id } = req.body;
const image = await ImageService.linkToDocument(id, userId, document_id);
res.status(200).json({
success: true,
data: image,
});
} catch (error) {
const message = error instanceof Error ? error.message : '关联文档失败';
res.status(message.includes('not found') ? 404 : 400).json({
success: false,
error: message,
});
}
}
/**
* Get pending images
* GET /api/images/pending
*/
static async getPending(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const images = await ImageService.getPendingImages(userId);
res.status(200).json({
success: true,
data: images,
count: images.length,
});
} catch (error) {
res.status(500).json({
success: false,
error: '获取待处理图片失败',
});
}
}
/**
* Get user images
* GET /api/images
*/
static async getUserImages(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { document_id } = req.query;
const images = await ImageService.findByUser(
userId,
document_id as string | undefined
);
res.status(200).json({
success: true,
data: images,
count: images.length,
});
} catch (error) {
res.status(500).json({
success: false,
error: '获取图片列表失败',
});
}
}
/**
* Delete image
* DELETE /api/images/:id
*/
static async delete(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
await ImageService.delete(id, userId);
res.status(200).json({
success: true,
message: '图片已删除',
});
} catch (error) {
const message = error instanceof Error ? error.message : '删除图片失败';
res.status(message.includes('not found') ? 404 : 400).json({
success: false,
error: message,
});
}
}
}

View File

@@ -0,0 +1,226 @@
/**
* Todo Controller
* Handles todo API requests with three-state workflow
*/
import { Request, Response } from 'express';
import { TodoService } from '../services/todo.service';
export class TodoController {
/**
* Create a new todo
* POST /api/todos
*/
static async create(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { title, description, priority, due_date, category_id, document_id } = req.body;
const todo = await TodoService.create({
user_id: userId,
title,
description,
priority,
due_date: due_date ? new Date(due_date) : undefined,
category_id,
document_id,
});
res.status(201).json({
success: true,
data: todo,
});
} catch (error) {
const message = error instanceof Error ? error.message : '创建待办失败';
res.status(400).json({
success: false,
error: message,
});
}
}
/**
* Get todo by ID
* GET /api/todos/:id
*/
static async getById(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
const todo = await TodoService.findById(id, userId);
if (!todo) {
res.status(404).json({
success: false,
error: '待办不存在',
});
return;
}
res.status(200).json({
success: true,
data: todo,
});
} catch (error) {
const message = error instanceof Error ? error.message : '获取待办失败';
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* Update todo
* PUT /api/todos/:id
*/
static async update(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
const { title, description, priority, status, due_date, category_id } = req.body;
const todo = await TodoService.update(id, userId, {
title,
description,
priority,
status,
due_date: due_date ? new Date(due_date) : undefined,
category_id,
});
res.status(200).json({
success: true,
data: todo,
});
} catch (error) {
const message = error instanceof Error ? error.message : '更新待办失败';
res.status(error instanceof Error && message.includes('not found') ? 404 : 400).json({
success: false,
error: message,
});
}
}
/**
* Delete todo
* DELETE /api/todos/:id
*/
static async delete(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { id } = req.params as { id: string };
await TodoService.delete(id, userId);
res.status(200).json({
success: true,
message: '待办已删除',
});
} catch (error) {
const message = error instanceof Error ? error.message : '删除待办失败';
res.status(error instanceof Error && message.includes('not found') ? 404 : 400).json({
success: false,
error: message,
});
}
}
/**
* Get user todos
* GET /api/todos
*/
static async getUserTodos(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const { status, priority, category_id, page, limit } = req.query;
const todos = await TodoService.findByUser(userId, {
status: status as any,
priority: priority as any,
category_id: category_id as string | undefined,
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
});
res.status(200).json({
success: true,
data: todos,
count: todos.length,
});
} catch (error) {
const message = error instanceof Error ? error.message : '获取待办列表失败';
res.status(500).json({
success: false,
error: message,
});
}
}
/**
* Get pending todos
* GET /api/todos/pending
*/
static async getPending(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const todos = await TodoService.getPendingTodos(userId);
res.status(200).json({
success: true,
data: todos,
count: todos.length,
});
} catch (error) {
res.status(500).json({
success: false,
error: '获取待办列表失败',
});
}
}
/**
* Get completed todos
* GET /api/todos/completed
*/
static async getCompleted(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const todos = await TodoService.getCompletedTodos(userId);
res.status(200).json({
success: true,
data: todos,
count: todos.length,
});
} catch (error) {
res.status(500).json({
success: false,
error: '获取已完成列表失败',
});
}
}
/**
* Get confirmed todos
* GET /api/todos/confirmed
*/
static async getConfirmed(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.user_id;
const todos = await TodoService.getConfirmedTodos(userId);
res.status(200).json({
success: true,
data: todos,
count: todos.length,
});
} catch (error) {
res.status(500).json({
success: false,
error: '获取已确认列表失败',
});
}
}
}

61
backend/src/index.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* Express Application Entry Point
*/
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.routes';
import documentRoutes from './routes/document.routes';
import todoRoutes from './routes/todo.routes';
import imageRoutes from './routes/image.routes';
// Load environment variables
dotenv.config();
const app = express();
const PORT = process.env.PORT || 4000;
// Middleware
app.use(cors({
exposedHeaders: ['Content-Type'],
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/api/health', (_req, res) => {
res.json({ success: true, message: 'API is running' });
});
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/documents', documentRoutes);
app.use('/api/todos', todoRoutes);
app.use('/api/images', imageRoutes);
// 404 handler
app.use((_req, res) => {
res.status(404).json({
success: false,
error: 'Not found',
});
});
// Error handler
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(err.status || 500).json({
success: false,
error: err.message || 'Internal server error',
});
});
// Start server
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
export { app };

24
backend/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Prisma Client Singleton
*/
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

View File

@@ -0,0 +1,78 @@
/**
* Authentication Middleware
* Verifies JWT tokens and protects routes
*/
import { Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/auth.service';
/**
* Extend Express Request to include user property
*/
declare global {
namespace Express {
interface Request {
user?: {
user_id: string;
};
}
}
}
/**
* Authentication middleware
* Protects routes by verifying JWT tokens
*/
export const authenticate = (req: Request, res: Response, next: NextFunction): void => {
try {
// Extract token from Authorization header
const authHeader = req.headers.authorization;
const token = AuthService.extractTokenFromHeader(authHeader);
if (!token) {
res.status(401).json({
success: false,
error: 'No token provided',
});
return;
}
// Verify token
const payload = AuthService.verifyToken(token);
// Attach user info to request
req.user = {
user_id: payload.user_id,
};
next();
} catch (error) {
const message = error instanceof Error ? error.message : 'Authentication failed';
res.status(401).json({
success: false,
error: message,
});
}
};
/**
* Optional authentication middleware
* Attaches user info if token is present, but doesn't block if missing
*/
export const optionalAuthenticate = (req: Request, _res: Response, next: NextFunction): void => {
try {
const authHeader = req.headers.authorization;
const token = AuthService.extractTokenFromHeader(authHeader);
if (token) {
const payload = AuthService.verifyToken(token);
req.user = {
user_id: payload.user_id,
};
}
} catch {
// Ignore errors, continue without user
}
next();
};

View File

@@ -0,0 +1,40 @@
/**
* Auth Routes
* Authentication API endpoints
*/
import { Router } from 'express';
import { AuthController } from '../controllers/auth.controller';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
/**
* @route POST /api/auth/register
* @desc Register a new user
* @access Public
*/
router.post('/register', AuthController.register);
/**
* @route POST /api/auth/login
* @desc Login user
* @access Public
*/
router.post('/login', AuthController.login);
/**
* @route POST /api/auth/logout
* @desc Logout user
* @access Private
*/
router.post('/logout', authenticate, AuthController.logout);
/**
* @route GET /api/auth/me
* @desc Get current user info
* @access Private
*/
router.get('/me', authenticate, AuthController.me);
export default router;

View File

@@ -0,0 +1,47 @@
/**
* Document Routes
* Document API endpoints
*/
import { Router } from 'express';
import { DocumentController } from '../controllers/document.controller';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
/**
* @route POST /api/documents
* @desc Create a new document
* @access Private
*/
router.post('/', authenticate, DocumentController.create);
/**
* @route GET /api/documents
* @desc Get user documents
* @access Private
*/
router.get('/', authenticate, DocumentController.getUserDocuments);
/**
* @route GET /api/documents/:id
* @desc Get document by ID
* @access Private
*/
router.get('/:id', authenticate, DocumentController.getById);
/**
* @route PUT /api/documents/:id
* @desc Update document
* @access Private
*/
router.put('/:id', authenticate, DocumentController.update);
/**
* @route DELETE /api/documents/:id
* @desc Delete document
* @access Private
*/
router.delete('/:id', authenticate, DocumentController.delete);
export default router;

View File

@@ -0,0 +1,61 @@
/**
* Image Routes
* Image API endpoints
*/
import { Router } from 'express';
import { ImageController } from '../controllers/image.controller';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
/**
* @route POST /api/images
* @desc Upload image
* @access Private
*/
router.post('/', authenticate, ImageController.upload);
/**
* @route GET /api/images
* @desc Get user images
* @access Private
*/
router.get('/', authenticate, ImageController.getUserImages);
/**
* @route GET /api/images/pending
* @desc Get pending images (OCR failed)
* @access Private
*/
router.get('/pending', authenticate, ImageController.getPending);
/**
* @route GET /api/images/:id
* @desc Get image by ID
* @access Private
*/
router.get('/:id', authenticate, ImageController.getById);
/**
* @route PUT /api/images/:id/ocr
* @desc Update OCR result
* @access Private
*/
router.put('/:id/ocr', authenticate, ImageController.updateOCR);
/**
* @route PUT /api/images/:id/link
* @desc Link image to document
* @access Private
*/
router.put('/:id/link', authenticate, ImageController.linkToDocument);
/**
* @route DELETE /api/images/:id
* @desc Delete image
* @access Private
*/
router.delete('/:id', authenticate, ImageController.delete);
export default router;

View File

@@ -0,0 +1,68 @@
/**
* Todo Routes
* Todo API endpoints with three-state workflow
*/
import { Router } from 'express';
import { TodoController } from '../controllers/todo.controller';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
/**
* @route POST /api/todos
* @desc Create a new todo
* @access Private
*/
router.post('/', authenticate, TodoController.create);
/**
* @route GET /api/todos
* @desc Get user todos
* @access Private
*/
router.get('/', authenticate, TodoController.getUserTodos);
/**
* @route GET /api/todos/pending
* @desc Get pending todos
* @access Private
*/
router.get('/pending', authenticate, TodoController.getPending);
/**
* @route GET /api/todos/completed
* @desc Get completed todos
* @access Private
*/
router.get('/completed', authenticate, TodoController.getCompleted);
/**
* @route GET /api/todos/confirmed
* @desc Get confirmed todos
* @access Private
*/
router.get('/confirmed', authenticate, TodoController.getConfirmed);
/**
* @route GET /api/todos/:id
* @desc Get todo by ID
* @access Private
*/
router.get('/:id', authenticate, TodoController.getById);
/**
* @route PUT /api/todos/:id
* @desc Update todo
* @access Private
*/
router.put('/:id', authenticate, TodoController.update);
/**
* @route DELETE /api/todos/:id
* @desc Delete todo
* @access Private
*/
router.delete('/:id', authenticate, TodoController.delete);
export default router;

View File

@@ -0,0 +1,92 @@
/**
* Auth Service
* Handles JWT token generation and verification
*/
import jwt from 'jsonwebtoken';
export interface TokenPayload {
user_id: string;
[key: string]: any;
}
export class AuthService {
private static get SECRET(): string {
const secret = process.env.JWT_SECRET;
if (!secret || secret === 'default-secret' || secret.length < 10) {
throw new Error('JWT_SECRET environment variable is not set or is too weak');
}
return secret;
}
private static readonly EXPIRES_IN = '24h';
/**
* Generate a JWT token
* @param payload - Token payload
* @returns string - JWT token
*/
static generateToken(payload: TokenPayload): string {
// Get secret which will throw if invalid
const secret = this.SECRET;
return jwt.sign(payload, secret, {
expiresIn: this.EXPIRES_IN,
});
}
/**
* Verify a JWT token
* @param token - JWT token
* @returns TokenPayload - Decoded token payload
*/
static verifyToken(token: string): TokenPayload {
try {
const secret = this.SECRET;
const decoded = jwt.verify(token, secret) as TokenPayload;
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new Error('Token expired');
}
if (error instanceof jwt.JsonWebTokenError) {
throw new Error('Invalid token');
}
throw error;
}
}
/**
* Extract token from Authorization header
* @param authHeader - Authorization header value
* @returns string | null - Extracted token or null
*/
static extractTokenFromHeader(authHeader: string | undefined): string | null {
if (!authHeader) {
return null;
}
// Handle "Bearer <token>" format
if (authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Handle raw token
return authHeader;
}
/**
* Refresh an existing token
* @param token - Existing token
* @returns string - New token
*/
static refreshToken(token: string): string {
const decoded = this.verifyToken(token);
// Add a small delay to ensure different iat
// Generate new token with same payload
const secret = this.SECRET;
return jwt.sign({ user_id: decoded.user_id }, secret, {
expiresIn: this.EXPIRES_IN,
});
}
}

View File

@@ -0,0 +1,146 @@
/**
* Document Service
* Handles document CRUD operations
*/
import { prisma } from '../lib/prisma';
import { Prisma } from '@prisma/client';
export interface CreateDocumentInput {
user_id: string;
content: string;
title?: string | null;
category_id?: string | null;
}
export interface UpdateDocumentInput {
content?: string;
title?: string | null;
category_id?: string | null;
}
export interface FindDocumentOptions {
category_id?: string;
page?: number;
limit?: number;
}
export class DocumentService {
/**
* Create a new document
*/
static async create(input: CreateDocumentInput) {
// Validate content is not empty
if (!input.content || input.content.trim().length === 0) {
throw new Error('Document content cannot be empty');
}
return prisma.document.create({
data: {
user_id: input.user_id,
content: input.content,
title: input.title ?? null,
category_id: input.category_id ?? null,
},
});
}
/**
* Find document by ID (ensuring user owns it)
*/
static async findById(id: string, userId: string) {
return prisma.document.findFirst({
where: {
id,
user_id: userId,
},
});
}
/**
* Update document
*/
static async update(id: string, userId: string, input: UpdateDocumentInput) {
// Verify document exists and user owns it
const existing = await this.findById(id, userId);
if (!existing) {
throw new Error('Document not found or access denied');
}
const updateData: Prisma.DocumentUpdateInput = {};
if (input.content !== undefined) {
updateData.content = input.content;
}
if (input.title !== undefined) {
updateData.title = input.title;
}
if (input.category_id !== undefined) {
updateData.category = input.category_id ? { connect: { id: input.category_id } } : { disconnect: true };
}
return prisma.document.update({
where: { id },
data: updateData,
});
}
/**
* Delete document
*/
static async delete(id: string, userId: string) {
// Verify document exists and user owns it
const existing = await this.findById(id, userId);
if (!existing) {
throw new Error('Document not found or access denied');
}
await prisma.document.delete({
where: { id },
});
}
/**
* Find all documents for a user
*/
static async findByUser(userId: string, options: FindDocumentOptions = {}) {
const where: Prisma.DocumentWhereInput = {
user_id: userId,
};
if (options.category_id) {
where.category_id = options.category_id;
}
const page = options.page ?? 1;
const limit = options.limit ?? 50;
const skip = (page - 1) * limit;
return prisma.document.findMany({
where,
orderBy: { created_at: 'desc' },
skip,
take: limit,
});
}
/**
* Search documents by content or title
*/
static async search(userId: string, searchTerm: string) {
const where: Prisma.DocumentWhereInput = {
user_id: userId,
};
if (searchTerm && searchTerm.trim().length > 0) {
where.OR = [
{ content: { contains: searchTerm } },
{ title: { contains: searchTerm } },
];
}
return prisma.document.findMany({
where,
orderBy: { created_at: 'desc' },
});
}
}

View File

@@ -0,0 +1,147 @@
/**
* Image Service
* Handles image upload and management
*/
import { prisma } from '../lib/prisma';
export interface CreateImageInput {
user_id: string;
file_path: string;
file_size: number;
mime_type: string;
document_id?: string | null;
}
export class ImageService {
/**
* Create a new image record
*/
static async create(input: CreateImageInput) {
return prisma.image.create({
data: {
user_id: input.user_id,
file_path: input.file_path,
file_size: input.file_size,
mime_type: input.mime_type,
document_id: input.document_id ?? null,
processing_status: 'pending',
},
});
}
/**
* Find image by ID
*/
static async findById(id: string, userId: string) {
return prisma.image.findFirst({
where: {
id,
user_id: userId,
},
});
}
/**
* Update OCR result
*/
static async updateOCRResult(id: string, userId: string, text: string, confidence: number) {
const existing = await this.findById(id, userId);
if (!existing) {
throw new Error('Image not found or access denied');
}
return prisma.image.update({
where: { id },
data: {
ocr_result: text,
ocr_confidence: confidence,
processing_status: confidence >= 0.3 ? 'completed' : 'failed',
},
});
}
/**
* Link image to document
*/
static async linkToDocument(imageId: string, userId: string, documentId: string) {
const image = await this.findById(imageId, userId);
if (!image) {
throw new Error('Image not found or access denied');
}
// Verify document belongs to user
const document = await prisma.document.findFirst({
where: {
id: documentId,
user_id: userId,
},
});
if (!document) {
throw new Error('Document not found or access denied');
}
return prisma.image.update({
where: { id: imageId },
data: {
document_id: documentId,
},
});
}
/**
* Get pending images (OCR failed or low confidence)
*/
static async getPendingImages(userId: string) {
return prisma.image.findMany({
where: {
user_id: userId,
processing_status: 'failed',
OR: [
{
ocr_confidence: {
lt: 0.3,
},
},
{
ocr_confidence: null,
},
],
},
orderBy: { created_at: 'desc' },
});
}
/**
* Find all images for a user
*/
static async findByUser(userId: string, documentId?: string) {
const where: any = {
user_id: userId,
};
if (documentId) {
where.document_id = documentId;
}
return prisma.image.findMany({
where,
orderBy: { created_at: 'desc' },
});
}
/**
* Delete image
*/
static async delete(id: string, userId: string) {
const existing = await this.findById(id, userId);
if (!existing) {
throw new Error('Image not found or access denied');
}
await prisma.image.delete({
where: { id },
});
}
}

View File

@@ -0,0 +1,115 @@
/**
* OCR Service
* Handles OCR processing and confidence validation
*/
export interface OCRResult {
text: string;
confidence: number;
shouldCreateDocument: boolean;
}
export interface OCRProviderOptions {
timeout?: number;
retries?: number;
}
export class OCRService {
private static readonly DEFAULT_TIMEOUT = 10000; // 10 seconds
private static readonly DEFAULT_RETRIES = 2;
/**
* Determine if document should be created based on confidence
* @param confidence - OCR confidence score (0-1)
* @param threshold - Minimum threshold (default 0.3)
* @returns boolean - True if document should be created
*/
static shouldCreateDocument(
confidence: number,
threshold: number = 0.3
): boolean {
// Validate inputs
if (!this.isValidConfidence(confidence)) {
return false;
}
return confidence >= threshold;
}
/**
* Validate confidence score is in valid range
* @param confidence - Confidence score to validate
* @returns boolean - True if valid
*/
static isValidConfidence(confidence: number): boolean {
return typeof confidence === 'number' &&
!isNaN(confidence) &&
confidence >= 0 &&
confidence <= 1;
}
/**
* Get initial processing status
* @returns string - Initial status
*/
static getInitialStatus(): string {
return 'pending';
}
/**
* Process OCR with retry logic
* @param imageId - Image ID to process
* @param provider - OCR provider function
* @param options - OCR options
* @returns Promise<OCRResult> - OCR result
*/
static async process(
imageId: string,
provider: (id: string) => Promise<{ text: string; confidence: number }>,
options: OCRProviderOptions = {}
): Promise<OCRResult> {
const timeout = options.timeout ?? this.DEFAULT_TIMEOUT;
const retries = options.retries ?? this.DEFAULT_RETRIES;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
// Add timeout to provider call
const result = await Promise.race([
provider(imageId),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('OCR timeout')), timeout)
),
]);
const shouldCreate = this.shouldCreateDocument(result.confidence);
return {
text: result.text,
confidence: result.confidence,
shouldCreateDocument: shouldCreate,
};
} catch (error) {
lastError = error as Error;
// Don't retry on certain errors
if (
error instanceof Error &&
(error.message === 'invalid image format' ||
error.message.includes('Invalid'))
) {
throw error;
}
// Retry on transient errors
if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
continue;
}
}
}
throw lastError || new Error('OCR processing failed');
}
}

View File

@@ -0,0 +1,74 @@
/**
* Password Service
* Handles password hashing and verification using bcrypt
*/
import bcrypt from 'bcrypt';
export interface StrengthResult {
isStrong: boolean;
reason?: string;
}
export class PasswordService {
private static readonly SALT_ROUNDS = 10;
/**
* Hash a plain text password using bcrypt
* @param password - Plain text password
* @returns Promise<string> - Hashed password
*/
static async hash(password: string): Promise<string> {
return bcrypt.hash(password, this.SALT_ROUNDS);
}
/**
* Verify a plain text password against a hash
* @param password - Plain text password
* @param hash - Hashed password
* @returns Promise<boolean> - True if password matches
* @throws Error if hash format is invalid
*/
static async verify(password: string, hash: string): Promise<boolean> {
// Validate hash format (bcrypt hashes are 60 chars and start with $2a$, $2b$, or $2y$)
if (!hash || typeof hash !== 'string') {
throw new Error('Invalid hash format');
}
const bcryptHashRegex = /^\$2[aby]\$[\d]+\$./;
if (!bcryptHashRegex.test(hash)) {
throw new Error('Invalid hash format');
}
return bcrypt.compare(password, hash);
}
/**
* Check password strength
* @param password - Plain text password
* @returns StrengthResult - Strength check result
*/
static checkStrength(password: string): StrengthResult {
if (password.length < 8) {
return { isStrong: false, reason: '密码长度至少8个字符' };
}
if (!/[A-Z]/.test(password)) {
return { isStrong: false, reason: '密码需要包含大写字母' };
}
if (!/[a-z]/.test(password)) {
return { isStrong: false, reason: '密码需要包含小写字母' };
}
if (!/[0-9]/.test(password)) {
return { isStrong: false, reason: '密码需要包含数字' };
}
if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) {
return { isStrong: false, reason: '密码需要包含特殊字符' };
}
return { isStrong: true };
}
}

View File

@@ -0,0 +1,262 @@
/**
* Todo Service
* Handles todo CRUD operations with three-state workflow
*/
import { prisma } from '../lib/prisma';
import { Prisma } from '@prisma/client';
export type TodoStatus = 'pending' | 'completed' | 'confirmed';
export type TodoPriority = 'low' | 'medium' | 'high' | 'urgent';
export interface CreateTodoInput {
user_id: string;
title: string;
description?: string | null;
priority?: TodoPriority;
status?: TodoStatus;
due_date?: Date | null;
category_id?: string | null;
document_id?: string | null;
}
export interface UpdateTodoInput {
title?: string;
description?: string | null;
priority?: TodoPriority;
status?: TodoStatus;
due_date?: Date | null;
category_id?: string | null;
document_id?: string | null;
}
export interface FindTodoOptions {
status?: TodoStatus;
priority?: TodoPriority;
category_id?: string;
document_id?: string;
page?: number;
limit?: number;
overdue?: boolean;
}
export class TodoService {
/**
* Create a new todo
*/
static async create(input: CreateTodoInput) {
// Validate title is not empty
if (!input.title || input.title.trim().length === 0) {
throw new Error('Todo title cannot be empty');
}
// Validate priority if provided
const validPriorities: TodoPriority[] = ['low', 'medium', 'high', 'urgent'];
if (input.priority && !validPriorities.includes(input.priority)) {
throw new Error(`Invalid priority. Must be one of: ${validPriorities.join(', ')}`);
}
// Validate status if provided
const validStatuses: TodoStatus[] = ['pending', 'completed', 'confirmed'];
if (input.status && !validStatuses.includes(input.status)) {
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
return prisma.todo.create({
data: {
user_id: input.user_id,
title: input.title,
description: input.description ?? null,
priority: input.priority ?? 'medium',
status: input.status ?? 'pending',
due_date: input.due_date ?? null,
category_id: input.category_id ?? null,
document_id: input.document_id ?? null,
},
});
}
/**
* Find todo by ID (ensuring user owns it)
*/
static async findById(id: string, userId: string) {
return prisma.todo.findFirst({
where: {
id,
user_id: userId,
},
});
}
/**
* Update todo
*/
static async update(id: string, userId: string, input: UpdateTodoInput) {
// Verify todo exists and user owns it
const existing = await this.findById(id, userId);
if (!existing) {
throw new Error('Todo not found or access denied');
}
const updateData: Prisma.TodoUpdateInput = {};
if (input.title !== undefined) {
updateData.title = input.title;
}
if (input.description !== undefined) {
updateData.description = input.description;
}
if (input.priority !== undefined) {
updateData.priority = input.priority;
}
if (input.category_id !== undefined) {
updateData.category = input.category_id ? { connect: { id: input.category_id } } : { disconnect: true };
}
if (input.document_id !== undefined) {
updateData.document = input.document_id ? { connect: { id: input.document_id } } : { disconnect: true };
}
if (input.due_date !== undefined) {
updateData.due_date = input.due_date;
}
// Handle status transitions with timestamps
if (input.status !== undefined) {
updateData.status = input.status;
if (input.status === 'completed' && existing.status !== 'completed' && existing.status !== 'confirmed') {
updateData.completed_at = new Date();
} else if (input.status === 'confirmed') {
updateData.confirmed_at = new Date();
// Ensure completed_at is set if not already
if (!existing.completed_at) {
updateData.completed_at = new Date();
}
} else if (input.status === 'pending') {
// Clear timestamps when reverting to pending
updateData.completed_at = null;
updateData.confirmed_at = null;
}
}
return prisma.todo.update({
where: { id },
data: updateData,
});
}
/**
* Delete todo
*/
static async delete(id: string, userId: string) {
// Verify todo exists and user owns it
const existing = await this.findById(id, userId);
if (!existing) {
throw new Error('Todo not found or access denied');
}
await prisma.todo.delete({
where: { id },
});
}
/**
* Find all todos for a user with filters
*/
static async findByUser(userId: string, options: FindTodoOptions = {}) {
const where: Prisma.TodoWhereInput = {
user_id: userId,
};
if (options.status) {
where.status = options.status;
}
if (options.priority) {
where.priority = options.priority;
}
if (options.category_id) {
where.category_id = options.category_id;
}
if (options.document_id) {
where.document_id = options.document_id;
}
if (options.overdue) {
where.due_date = {
lt: new Date(),
};
// Only pending todos can be overdue
where.status = 'pending';
}
const page = options.page ?? 1;
const limit = options.limit ?? 50;
const skip = (page - 1) * limit;
// Fetch more than needed to allow for post-sorting
const fetchLimit = limit * 2;
let todos = await prisma.todo.findMany({
where,
orderBy: [{ created_at: 'desc' }],
skip: 0,
take: fetchLimit,
});
// Sort by priority in application layer
const priorityOrder: Record<TodoPriority, number> = { urgent: 0, high: 1, medium: 2, low: 3 };
todos = todos.sort((a, b) => {
const priorityDiff = priorityOrder[a.priority as TodoPriority] - priorityOrder[b.priority as TodoPriority];
if (priorityDiff !== 0) return priorityDiff;
// If same priority, sort by created date
return b.created_at.getTime() - a.created_at.getTime();
});
// Apply pagination after sorting
return todos.slice(skip, skip + limit);
}
/**
* Get pending todos
*/
static async getPendingTodos(userId: string, limit = 50) {
return prisma.todo.findMany({
where: {
user_id: userId,
status: 'pending',
},
orderBy: [{ created_at: 'desc' }],
take: limit,
});
}
/**
* Get completed todos
*/
static async getCompletedTodos(userId: string, limit = 50) {
return prisma.todo.findMany({
where: {
user_id: userId,
status: 'completed',
},
orderBy: [{ completed_at: 'desc' }],
take: limit,
});
}
/**
* Get confirmed todos
*/
static async getConfirmedTodos(userId: string, limit = 50) {
return prisma.todo.findMany({
where: {
user_id: userId,
status: 'confirmed',
},
orderBy: [{ confirmed_at: 'desc' }],
take: limit,
});
}
}