feat: complete backend API with JWT auth, events, notes, AI routes
- Add Express.js server with TypeScript - Configure Prisma ORM with PostgreSQL schema - Implement JWT authentication (register, login, logout, refresh) - Add rate limiting for auth endpoints (10 attempts/15min) - Password strength validation (8+ chars, uppercase, lowercase, number) - Events CRUD API (anniversaries and reminders) - Notes API (single note per user) - AI parse endpoint with DeepSeek integration - Security: Helmet, rate limiting, input validation, error handling - Fix: JWT_SECRET environment variable validation Code review: Architect approved Tests: Build verified
This commit is contained in:
commit
55627762e1
18
.env
Normal file
18
.env
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=dev-secret-key-do-not-use-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# Database (PostgreSQL - update with your local or Tencent Cloud credentials)
|
||||||
|
DATABASE_URL=postgresql://qia_admin:your-password@postgres.ap-shanghai.myqcloud.com:5432/qia
|
||||||
|
|
||||||
|
# DeepSeek AI
|
||||||
|
DEEPSEEK_API_KEY=sk-xxx
|
||||||
|
DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# Database (腾讯云PostgreSQL)
|
||||||
|
DB_HOST=postgres.ap-shanghai.myqcloud.com
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=qia
|
||||||
|
DB_USER=qia_admin
|
||||||
|
DB_PASSWORD=your-database-password
|
||||||
|
|
||||||
|
# DeepSeek AI
|
||||||
|
DEEPSEEK_API_KEY=sk-xxx
|
||||||
|
DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
38
package.json
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "qia-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Backend API for 掐日子(qia) app",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"express-validator": "^7.1.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/express-rate-limit": "^6.0.0",
|
||||||
|
"@types/helmet": "^4.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node": "^22.5.5",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
96
prisma/schema.prisma
Normal file
96
prisma/schema.prisma
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// User model
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
password_hash String
|
||||||
|
nickname String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updated_at
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
events Event[]
|
||||||
|
notes Note[]
|
||||||
|
conversations AICoachConversation[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event model (for both Anniversary and Reminder)
|
||||||
|
model Event {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
user_id String
|
||||||
|
type EventType // 'anniversary' | 'reminder'
|
||||||
|
title String
|
||||||
|
content String? // Only for reminders
|
||||||
|
date DateTime // For anniversaries: the date; For reminders: the reminder date
|
||||||
|
is_lunar Boolean @default(false)
|
||||||
|
repeat_type RepeatType @default(none)
|
||||||
|
is_holiday Boolean @default(false) // Only for anniversaries
|
||||||
|
is_completed Boolean @default(false) // Only for reminders
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updated_at
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([user_id])
|
||||||
|
@@index([type])
|
||||||
|
@@index([date])
|
||||||
|
@@map("events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note model (for quick notes)
|
||||||
|
model Note {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
user_id String
|
||||||
|
content String @db.Text // HTML content from rich text editor
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updated_at
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// One note per user (based on PRD - 便签编辑区)
|
||||||
|
@@unique([user_id])
|
||||||
|
@@map("notes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Conversation model
|
||||||
|
model AICoachConversation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
user_id String
|
||||||
|
message String
|
||||||
|
response String
|
||||||
|
parsed_data Json? // AI parsed event data
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([user_id])
|
||||||
|
@@map("ai_coach_conversations")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enums
|
||||||
|
enum EventType {
|
||||||
|
anniversary
|
||||||
|
reminder
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RepeatType {
|
||||||
|
yearly
|
||||||
|
monthly
|
||||||
|
none
|
||||||
|
}
|
||||||
66
src/index.ts
Normal file
66
src/index.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express, { Application, Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import eventRoutes from './routes/events';
|
||||||
|
import noteRoutes from './routes/notes';
|
||||||
|
import aiRoutes from './routes/ai';
|
||||||
|
|
||||||
|
// Import error handler
|
||||||
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
|
|
||||||
|
const app: Application = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100, // limit each IP to 100 requests per windowMs
|
||||||
|
message: { error: 'Too many requests, please try again later' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
app.use('/api', limiter);
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Body parser with size limit (prevent DoS attacks)
|
||||||
|
app.use(express.json({ limit: '10kb' }));
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (_req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/events', eventRoutes);
|
||||||
|
app.use('/api/notes', noteRoutes);
|
||||||
|
app.use('/api/ai', aiRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((_req: Request, res: Response) => {
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Server running on http://localhost:${PORT}`);
|
||||||
|
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
13
src/lib/prisma.ts
Normal file
13
src/lib/prisma.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Prisma Client singleton - prevents connection pool exhaustion
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development'
|
||||||
|
? ['query', 'error', 'warn']
|
||||||
|
: ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
|
|
||||||
|
// For use in contexts where default export might conflict
|
||||||
|
export const prismaClient = prisma;
|
||||||
66
src/middleware/auth.ts
Normal file
66
src/middleware/auth.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user?: JwtPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT configuration with environment validation
|
||||||
|
const JWT_SECRET_ENV = process.env.JWT_SECRET;
|
||||||
|
if (!JWT_SECRET_ENV) {
|
||||||
|
throw new Error('FATAL: JWT_SECRET environment variable is required. Set it in .env file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JWT_SECRET = JWT_SECRET_ENV;
|
||||||
|
export const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||||
|
export const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d';
|
||||||
|
|
||||||
|
// Verify JWT token middleware
|
||||||
|
export const authenticateToken = (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(403).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
export const generateAccessToken = (payload: JwtPayload): string => {
|
||||||
|
return jwt.sign(payload, JWT_SECRET, {
|
||||||
|
expiresIn: JWT_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
export const generateRefreshToken = (payload: JwtPayload): string => {
|
||||||
|
return jwt.sign(payload, JWT_SECRET, {
|
||||||
|
expiresIn: JWT_REFRESH_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify refresh token
|
||||||
|
export const verifyRefreshToken = (token: string): JwtPayload | null => {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/middleware/errorHandler.ts
Normal file
70
src/middleware/errorHandler.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
interface AppError extends Error {
|
||||||
|
statusCode?: number;
|
||||||
|
isOperational?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorHandler = (
|
||||||
|
err: AppError,
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction
|
||||||
|
) => {
|
||||||
|
const statusCode = err.statusCode || 500;
|
||||||
|
const message = err.message || 'Internal Server Error';
|
||||||
|
|
||||||
|
console.error(`[Error] ${statusCode}: ${message}`);
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error(err.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Zod validation errors
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Validation Error',
|
||||||
|
details: err.errors.map(e => ({
|
||||||
|
field: e.path.join('.'),
|
||||||
|
message: e.message,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Prisma errors
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
// P2002: Unique constraint violation
|
||||||
|
if (err.code === 'P2002') {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Duplicate entry',
|
||||||
|
field: err.meta?.target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2025: Record not found
|
||||||
|
if (err.code === 'P2025') {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Record not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Prisma validation errors
|
||||||
|
if (err instanceof Prisma.PrismaClientValidationError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid data provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: message,
|
||||||
|
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asyncHandler =
|
||||||
|
(fn: Function) =>
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
155
src/routes/ai.ts
Normal file
155
src/routes/ai.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import { authenticateToken, AuthenticatedRequest } from '../middleware/auth';
|
||||||
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const parseMessageSchema = z.object({
|
||||||
|
message: z.string().min(1, 'Message is required').max(1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// POST /api/ai/parse - Parse natural language and create event
|
||||||
|
router.post(
|
||||||
|
'/parse',
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { message } = parseMessageSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Call DeepSeek API
|
||||||
|
const aiResponse = await callDeepSeek(message);
|
||||||
|
|
||||||
|
// Save conversation
|
||||||
|
const conversation = await prisma.aICoachConversation.create({
|
||||||
|
data: {
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
message,
|
||||||
|
response: aiResponse.response,
|
||||||
|
parsed_data: aiResponse.parsed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
parsed: aiResponse.parsed,
|
||||||
|
response: aiResponse.response,
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// DeepSeek API call helper
|
||||||
|
async function callDeepSeek(message: string): Promise<{
|
||||||
|
parsed: any;
|
||||||
|
response: string;
|
||||||
|
}> {
|
||||||
|
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||||
|
const apiUrl = process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/chat/completions';
|
||||||
|
|
||||||
|
if (!apiKey || apiKey === 'sk-xxx') {
|
||||||
|
// Mock response for development
|
||||||
|
return mockParseResponse(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'deepseek-chat',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `You are a helpful assistant that helps users create events (anniversaries or reminders) from natural language.
|
||||||
|
Parse the user's message and respond with a JSON object in the following format:
|
||||||
|
{
|
||||||
|
"parsed": {
|
||||||
|
"type": "anniversary" | "reminder",
|
||||||
|
"title": "event title",
|
||||||
|
"date": "ISO date string",
|
||||||
|
"is_lunar": true | false,
|
||||||
|
"repeat_type": "yearly" | "monthly" | "none"
|
||||||
|
},
|
||||||
|
"response": "A friendly confirmation message"
|
||||||
|
}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: message
|
||||||
|
}
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DeepSeek API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data.choices[0]?.message?.content || '';
|
||||||
|
|
||||||
|
// Extract JSON from response
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
return {
|
||||||
|
parsed: parsed.parsed || parsed,
|
||||||
|
response: parsed.response || content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockParseResponse(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DeepSeek API error:', error);
|
||||||
|
return mockParseResponse(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock response for development without API key
|
||||||
|
function mockParseResponse(message: string): { parsed: any; response: string } {
|
||||||
|
// Simple mock parser for testing
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
|
// Detect if it's a reminder (contains "提醒", "提醒我", etc.)
|
||||||
|
const isReminder = lowerMessage.includes('提醒') || lowerMessage.includes('提醒我');
|
||||||
|
|
||||||
|
// Detect lunar date
|
||||||
|
const isLunar = lowerMessage.includes('农历');
|
||||||
|
|
||||||
|
// Detect repeat type
|
||||||
|
let repeatType = 'none';
|
||||||
|
if (lowerMessage.includes('每年') || lowerMessage.includes(' yearly') || lowerMessage.includes('周年')) {
|
||||||
|
repeatType = 'yearly';
|
||||||
|
} else if (lowerMessage.includes('每月') || lowerMessage.includes(' monthly')) {
|
||||||
|
repeatType = 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title (simple heuristic - first phrase before date)
|
||||||
|
const title = message.split(/[\s\d\u4e00-\u9fa5]+/)[0] || message.substring(0, 50);
|
||||||
|
|
||||||
|
// Mock date (tomorrow)
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsed: {
|
||||||
|
type: isReminder ? 'reminder' : 'anniversary',
|
||||||
|
title: title.trim(),
|
||||||
|
date: tomorrow.toISOString(),
|
||||||
|
is_lunar: isLunar,
|
||||||
|
repeat_type: repeatType,
|
||||||
|
},
|
||||||
|
response: `我帮你记的是:
|
||||||
|
**${title.trim()}**
|
||||||
|
📅 ${tomorrow.toLocaleDateString('zh-CN')}
|
||||||
|
🔄 ${repeatType === 'yearly' ? '每年重复' : repeatType === 'monthly' ? '每月重复' : '不重复'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
209
src/routes/auth.ts
Normal file
209
src/routes/auth.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import {
|
||||||
|
authenticateToken,
|
||||||
|
AuthenticatedRequest,
|
||||||
|
generateAccessToken,
|
||||||
|
generateRefreshToken,
|
||||||
|
verifyRefreshToken,
|
||||||
|
} from '../middleware/auth';
|
||||||
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Rate limiters for auth endpoints
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10, // 10 attempts per window
|
||||||
|
message: { error: 'Too many attempts, please try again later' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 20, // 20 registrations per hour
|
||||||
|
message: { error: 'Too many registrations, please try again later' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password strength regex
|
||||||
|
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email format'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||||
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||||
|
.regex(/\d/, 'Password must contain at least one number'),
|
||||||
|
nickname: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email format'),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1, 'Refresh token is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/register - Register a new user
|
||||||
|
router.post(
|
||||||
|
'/register',
|
||||||
|
registerLimiter,
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate input
|
||||||
|
const { email, password, nickname } = registerSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(409).json({ error: 'Email already registered' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password with bcrypt (work factor 12)
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
nickname: nickname || email.split('@')[0],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nickname: true,
|
||||||
|
created_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const userPayload = { userId: user.id, email: user.email };
|
||||||
|
const token = generateAccessToken(userPayload);
|
||||||
|
const refreshToken = generateRefreshToken(userPayload);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
created_at: user.created_at,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/auth/login - Login user
|
||||||
|
router.post(
|
||||||
|
'/login',
|
||||||
|
authLimiter,
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate input
|
||||||
|
const { email, password } = loginSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Use generic error message to prevent user enumeration
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password with constant-time comparison
|
||||||
|
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const userPayload = { userId: user.id, email: user.email };
|
||||||
|
const token = generateAccessToken(userPayload);
|
||||||
|
const refreshToken = generateRefreshToken(userPayload);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
created_at: user.created_at,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/auth/logout - Logout user
|
||||||
|
router.post(
|
||||||
|
'/logout',
|
||||||
|
authenticateToken,
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
// In a stateless JWT auth, logout is handled client-side by removing the token
|
||||||
|
// For production, consider implementing a token blacklist
|
||||||
|
res.json({ success: true, message: 'Logged out successfully' });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/auth/me - Get current user
|
||||||
|
router.get(
|
||||||
|
'/me',
|
||||||
|
authenticateToken,
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nickname: true,
|
||||||
|
created_at: true,
|
||||||
|
updated_at: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ user });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/auth/refresh - Refresh access token
|
||||||
|
router.post(
|
||||||
|
'/refresh',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { refreshToken } = refreshSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Verify refresh token
|
||||||
|
const payload = verifyRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new access token
|
||||||
|
const newToken = generateAccessToken(payload);
|
||||||
|
|
||||||
|
res.json({ token: newToken });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
149
src/routes/events.ts
Normal file
149
src/routes/events.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import { authenticateToken, AuthenticatedRequest } from '../middleware/auth';
|
||||||
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const createEventSchema = z.object({
|
||||||
|
type: z.enum(['anniversary', 'reminder']),
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
content: z.string().optional(),
|
||||||
|
date: z.string().datetime(), // ISO datetime string
|
||||||
|
is_lunar: z.boolean().default(false),
|
||||||
|
repeat_type: z.enum(['yearly', 'monthly', 'none']).default('none'),
|
||||||
|
is_holiday: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateEventSchema = createEventSchema.partial();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// GET /api/events - Get all events for user
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { type } = req.query;
|
||||||
|
|
||||||
|
const where: any = {
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'anniversary' || type === 'reminder') {
|
||||||
|
where.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(events);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/events/:id - Get single event
|
||||||
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const event = await prisma.event.findFirst({
|
||||||
|
where: {
|
||||||
|
id: req.params.id,
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(event);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/events - Create new event
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const data = createEventSchema.parse(req.body);
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
type: data.type,
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
date: new Date(data.date),
|
||||||
|
is_lunar: data.is_lunar,
|
||||||
|
repeat_type: data.repeat_type,
|
||||||
|
is_holiday: data.is_holiday,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(event);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/events/:id - Update event
|
||||||
|
router.put(
|
||||||
|
'/:id',
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
// Verify event belongs to user
|
||||||
|
const existing = await prisma.event.findFirst({
|
||||||
|
where: {
|
||||||
|
id: req.params.id,
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = updateEventSchema.parse(req.body);
|
||||||
|
|
||||||
|
const event = await prisma.event.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: {
|
||||||
|
...(data.type && { type: data.type }),
|
||||||
|
...(data.title && { title: data.title }),
|
||||||
|
...(data.content !== undefined && { content: data.content }),
|
||||||
|
...(data.date && { date: new Date(data.date) }),
|
||||||
|
...(data.is_lunar !== undefined && { is_lunar: data.is_lunar }),
|
||||||
|
...(data.repeat_type && { repeat_type: data.repeat_type }),
|
||||||
|
...(data.is_holiday !== undefined && { is_holiday: data.is_holiday }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(event);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/events/:id - Delete event
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
// Verify event belongs to user
|
||||||
|
const existing = await prisma.event.findFirst({
|
||||||
|
where: {
|
||||||
|
id: req.params.id,
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
50
src/routes/notes.ts
Normal file
50
src/routes/notes.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import { authenticateToken, AuthenticatedRequest } from '../middleware/auth';
|
||||||
|
import { asyncHandler } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const updateNoteSchema = z.object({
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// GET /api/notes - Get user's note (one note per user)
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { user_id: req.user!.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(note);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/notes - Create or update note
|
||||||
|
router.put(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const { content } = updateNoteSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Upsert - create if doesn't exist, update if exists
|
||||||
|
const note = await prisma.note.upsert({
|
||||||
|
where: { user_id: req.user!.userId },
|
||||||
|
create: {
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(note);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user