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:
216
backend/src/controllers/auth.controller.ts
Normal file
216
backend/src/controllers/auth.controller.ts
Normal 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: '登出成功',
|
||||
});
|
||||
}
|
||||
}
|
||||
158
backend/src/controllers/document.controller.ts
Normal file
158
backend/src/controllers/document.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
195
backend/src/controllers/image.controller.ts
Normal file
195
backend/src/controllers/image.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
226
backend/src/controllers/todo.controller.ts
Normal file
226
backend/src/controllers/todo.controller.ts
Normal 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
61
backend/src/index.ts
Normal 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
24
backend/src/lib/prisma.ts
Normal 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;
|
||||
}
|
||||
78
backend/src/middleware/auth.middleware.ts
Normal file
78
backend/src/middleware/auth.middleware.ts
Normal 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();
|
||||
};
|
||||
40
backend/src/routes/auth.routes.ts
Normal file
40
backend/src/routes/auth.routes.ts
Normal 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;
|
||||
47
backend/src/routes/document.routes.ts
Normal file
47
backend/src/routes/document.routes.ts
Normal 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;
|
||||
61
backend/src/routes/image.routes.ts
Normal file
61
backend/src/routes/image.routes.ts
Normal 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;
|
||||
68
backend/src/routes/todo.routes.ts
Normal file
68
backend/src/routes/todo.routes.ts
Normal 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;
|
||||
92
backend/src/services/auth.service.ts
Normal file
92
backend/src/services/auth.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
146
backend/src/services/document.service.ts
Normal file
146
backend/src/services/document.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
147
backend/src/services/image.service.ts
Normal file
147
backend/src/services/image.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
115
backend/src/services/ocr.service.ts
Normal file
115
backend/src/services/ocr.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
74
backend/src/services/password.service.ts
Normal file
74
backend/src/services/password.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
262
backend/src/services/todo.service.ts
Normal file
262
backend/src/services/todo.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user