Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.venv/
|
||||
.git/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.next/
|
||||
@@ -0,0 +1,15 @@
|
||||
# PostgreSQL + MongoDB
|
||||
DATABASE_URL=postgresql://reader:reader@localhost:5432/reader
|
||||
MONGODB_URI=mongodb://localhost:27017/reader
|
||||
|
||||
# Auth / OAuth
|
||||
NEXTAUTH_SECRET=replace-with-strong-secret
|
||||
MOBILE_JWT_SECRET=replace-with-strong-secret
|
||||
# Comma-separated allowed Google OAuth client IDs (web + android if needed)
|
||||
GOOGLE_CLIENT_ID=web-client-id.apps.googleusercontent.com,android-client-id.apps.googleusercontent.com
|
||||
|
||||
# CORS (comma-separated), * for all in local dev
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# Environment label
|
||||
APP_ENV=development
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
fevirtus/reader-api:latest
|
||||
ghcr.io/fevirtus/reader-api:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+11
-9
@@ -1,12 +1,14 @@
|
||||
# v0 runtime files
|
||||
__v0_runtime_loader.js
|
||||
__v0_devtools.tsx
|
||||
__v0_jsx-dev-runtime.ts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Common ignores
|
||||
node_modules/
|
||||
.next/
|
||||
.env*.local
|
||||
.DS_Store
|
||||
.env
|
||||
test-ebook/
|
||||
.env.local
|
||||
# Local debug/test scripts
|
||||
test_*.py
|
||||
@@ -0,0 +1,256 @@
|
||||
# 🐛 Chapter Save Debugging Guide
|
||||
|
||||
## Vấn Đề Đã Fix
|
||||
|
||||
### ✅ Fix 1: Ownership Check (Line 932)
|
||||
- **Vấn đề:** MOD không thể tạo chapter cho truyện mặc định (uploaderId = NULL)
|
||||
- **Fix:** Thêm `OR uploaderId IS NULL` vào WHERE clause
|
||||
- **Dòng:** 932, 1002
|
||||
|
||||
### ✅ Fix 2: Input Validation
|
||||
- **Vấn đề:** Content có thể trống, number có thể âm
|
||||
- **Fix:** Thêm validation trước insert
|
||||
- **Dòng:** 920-927, 997-1004
|
||||
|
||||
### ✅ Fix 3: Data Consistency Logging
|
||||
- **Vấn đề:** Nếu MongoDB insert succeed nhưng PostgreSQL fail → dữ liệu inconsistent
|
||||
- **Fix:** Thêm separate error handling và logging [CRITICAL]
|
||||
- **Dòng:** 956-974
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Testing Checklist
|
||||
|
||||
### A. Network Debugging (Browser DevTools)
|
||||
|
||||
1. **Mở DevTools:** F12 → Network tab
|
||||
2. **Ấn "Lưu chương"**
|
||||
3. **Kiểm tra request `POST /api/mod/chuong`:**
|
||||
- ✅ Status code: `201` = success, `4xx`/`5xx` = error
|
||||
- ✅ Response body: Phần lấy `id`, `number`, `title`
|
||||
- ❌ Status 403: Ownership issue
|
||||
- ❌ Status 400: Duplicate or validation error
|
||||
- ❌ Status 500: Server error (check [CRITICAL] logs)
|
||||
|
||||
### B. MongoDB Verification
|
||||
|
||||
```bash
|
||||
# Access MongoDB
|
||||
mongosh # hoặc mongo
|
||||
|
||||
# Switch to database
|
||||
use reader_db # (thay bằng tên DB thực tế)
|
||||
|
||||
# List recent chapters
|
||||
db.chapters.find({}, {novelId: 1, number: 1, title: 1, createdAt: 1})
|
||||
.sort({_id: -1})
|
||||
.limit(5)
|
||||
|
||||
# Check specific novel
|
||||
db.chapters.countDocuments({novelId: "YOUR_NOVEL_ID"})
|
||||
|
||||
# Check for duplicates (race condition)
|
||||
db.chapters.find({novelId: "YOUR_NOVEL_ID", number: 1})
|
||||
```
|
||||
|
||||
### C. PostgreSQL Verification
|
||||
|
||||
```bash
|
||||
# Access PostgreSQL
|
||||
psql # hoặc your database client
|
||||
|
||||
# Check novel total chapters count
|
||||
SELECT id, title, "totalChapters" FROM "Novel" WHERE id = 'YOUR_NOVEL_ID';
|
||||
|
||||
# Verify it matches MongoDB count
|
||||
-- MongoDB should have same count as "totalChapters"
|
||||
```
|
||||
|
||||
### D. Server Log Analysis
|
||||
|
||||
Look for these patterns in backend logs:
|
||||
|
||||
```
|
||||
✅ Success:
|
||||
[timestamp] POST /mod/chuong - Status 201
|
||||
[timestamp] Inserted chapter id: xxx
|
||||
|
||||
❌ Issues:
|
||||
[CRITICAL] ⚠️ INCONSISTENT STATE: Chapter inserted in MongoDB...
|
||||
[timestamp] Lỗi MongoDB: [error message]
|
||||
[timestamp] Ownership check failed: 403
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Common Scenarios & Solutions
|
||||
|
||||
### Scenario 1: Network Shows 201 But Chapter Not Visible
|
||||
|
||||
**Cause:** Chapter saved but not refreshed in UI
|
||||
**Solution:**
|
||||
- Press F5 to refresh page
|
||||
- Check MongoDB to confirm chapter exists
|
||||
- Check if `fetchChapters()` was called after save
|
||||
|
||||
### Scenario 2: Network Shows 403 Forbidden
|
||||
|
||||
**Cause:** Novel ownership check failed
|
||||
**Solution:**
|
||||
- Verify you are MOD or ADMIN user
|
||||
- Verify novel exists in PostgreSQL:
|
||||
```sql
|
||||
SELECT id, title, "uploaderId" FROM "Novel" WHERE id = 'YOUR_ID';
|
||||
```
|
||||
- If uploaderId is NULL (default), ensure you're MOD user
|
||||
|
||||
### Scenario 3: Network Shows 400 Bad Request
|
||||
|
||||
**Causes:**
|
||||
- Chapter number already exists
|
||||
- Title or content empty
|
||||
- Chapter number ≤ 0
|
||||
|
||||
**Solution:** Check response detail message and fix input
|
||||
|
||||
### Scenario 4: Network Shows 500 Server Error
|
||||
|
||||
**Cause:** MongoDB or PostgreSQL failure
|
||||
**Solution:**
|
||||
- Check server logs for [CRITICAL] message
|
||||
- If MongoDB failed: Check MongoDB connection
|
||||
- If PostgreSQL failed: Check PostgreSQL connection
|
||||
- Contact admin with error message
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Advanced Debug Commands
|
||||
|
||||
### Check MongoDB Connection Status
|
||||
|
||||
```bash
|
||||
# From backend terminal
|
||||
python3 -c "
|
||||
import asyncio
|
||||
from app.database import mongo_db
|
||||
|
||||
async def check():
|
||||
await mongo_db.command('ping')
|
||||
print('✓ MongoDB Connected!')
|
||||
|
||||
asyncio.run(check())
|
||||
"
|
||||
```
|
||||
|
||||
### Check PostgreSQL Connection Status
|
||||
|
||||
```bash
|
||||
# From backend terminal
|
||||
python3 -c "
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from app.database import SessionLocal
|
||||
|
||||
async def check():
|
||||
async with SessionLocal() as session:
|
||||
result = await session.execute('SELECT 1')
|
||||
print('✓ PostgreSQL Connected!')
|
||||
|
||||
asyncio.run(check())
|
||||
"
|
||||
```
|
||||
|
||||
### Manually Sync Total Chapters
|
||||
|
||||
```bash
|
||||
# If totalChapters is out of sync
|
||||
mongosh
|
||||
use reader_db
|
||||
|
||||
# Get count
|
||||
db.chapters.countDocuments({novelId: "YOUR_NOVEL_ID"})
|
||||
|
||||
# Then update PostgreSQL manually:
|
||||
# psql
|
||||
UPDATE "Novel" SET "totalChapters" = 123 WHERE id = 'YOUR_NOVEL_ID';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Test Cases
|
||||
|
||||
### Test 1: Basic Chapter Save
|
||||
```
|
||||
1. Create novel
|
||||
2. Save chapter #1
|
||||
3. ✅ Should appear in chapter list
|
||||
4. ✅ totalChapters should be 1
|
||||
```
|
||||
|
||||
### Test 2: Sequential Chapters
|
||||
```
|
||||
1. Save chapters 1, 2, 3
|
||||
2. ✅ All should appear with correct numbers
|
||||
3. ✅ Next chapter field should suggest 4
|
||||
```
|
||||
|
||||
### Test 3: Duplicate Prevention
|
||||
```
|
||||
1. Save chapter #5
|
||||
2. Try to save chapter #5 again
|
||||
3. ✅ Should show "Chương 5 đã tồn tại"
|
||||
```
|
||||
|
||||
### Test 4: Default Novel (MOD Permission)
|
||||
```
|
||||
1. Verify a novel with uploaderId = NULL exists
|
||||
2. As MOD user, save chapter to that novel
|
||||
3. ✅ Should succeed (not 403 Forbidden)
|
||||
```
|
||||
|
||||
### Test 5: No Empty Content
|
||||
```
|
||||
1. Try to save chapter with empty title
|
||||
2. ✅ Should show "Tiêu đề chương không được trống"
|
||||
3. Try to save chapter with empty content
|
||||
4. ✅ Should show "Nội dung chương không được trống"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Still Having Issues?
|
||||
|
||||
1. **Run checklist A, B, C above** and collect outputs
|
||||
2. **Screenshot of Network tab response**
|
||||
3. **MongoDB output from `find()`**
|
||||
4. **Server log output (especially [CRITICAL] lines)**
|
||||
5. Share these with: [your-dev-contact]
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Health Check Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# save as monitor-save.sh
|
||||
|
||||
echo "=== Chapter Save System Health Check ==="
|
||||
echo ""
|
||||
echo "1. MongoDB Connection:"
|
||||
# mongosh check here
|
||||
|
||||
echo ""
|
||||
echo "2. PostgreSQL Connection:"
|
||||
# psql check here
|
||||
|
||||
echo ""
|
||||
echo "3. Backend API:"
|
||||
curl -s http://localhost:8000/docs | head -20
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
```
|
||||
|
||||
Run: `bash monitor-save.sh`
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN uv sync --no-dev
|
||||
|
||||
COPY app ./app
|
||||
COPY prisma ./prisma
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,186 @@
|
||||
# ✅ Chapter Save System - Fixed Issues Summary
|
||||
|
||||
**Date:** 2026-03-24
|
||||
**Status:** All HIGH priority issues fixed ✅
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Issues Identified & Fixed
|
||||
|
||||
### 1️⃣ **Ownership Check Bypass (HIGH)**
|
||||
- **File:** `app/routers/mod.py`
|
||||
- **Lines:** 932-936 (POST), 1000-1007 (PUT)
|
||||
- **Issue:** MOD couldn't create chapters for default novels (uploaderId = NULL)
|
||||
- **Fix:** Changed query from:
|
||||
```python
|
||||
WHERE id = :nid AND "uploaderId" = :uid
|
||||
```
|
||||
To:
|
||||
```python
|
||||
WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)
|
||||
```
|
||||
- **Impact:** ✅ Fixed - MOD can now manage default novels
|
||||
|
||||
### 2️⃣ **Missing Input Validation (HIGH)**
|
||||
- **File:** `app/routers/mod.py`
|
||||
- **Lines:** 920-927 (POST), 997-1004 (PUT)
|
||||
- **Issue:** Could save empty title/content, negative chapter numbers
|
||||
- **Fix:** Added validation:
|
||||
```python
|
||||
if not body.title or not body.title.strip():
|
||||
raise HTTPException(400, "Tiêu đề chương không được trống")
|
||||
if body.number <= 0:
|
||||
raise HTTPException(400, "Số chương phải > 0")
|
||||
```
|
||||
- **Impact:** ✅ Fixed - Invalid data rejected at API level
|
||||
|
||||
### 3️⃣ **Data Inconsistency on PostgreSQL Failure (HIGH)**
|
||||
- **File:** `app/routers/mod.py`
|
||||
- **Lines:** 956-974 (POST)
|
||||
- **Issue:** If MongoDB insert succeeds but PostgreSQL sync fails → inconsistent state
|
||||
- **Fix:** Added separate error handling:
|
||||
```python
|
||||
try:
|
||||
result = await mongo_db.chapters.insert_one(doc)
|
||||
except Exception as mongo_err:
|
||||
raise HTTPException(500, f"Lỗi MongoDB: {str(mongo_err)}")
|
||||
|
||||
try:
|
||||
total = await _sync_total_chapters(db, body.novelId)
|
||||
except Exception as pg_err:
|
||||
# Log [CRITICAL] and alert user
|
||||
raise HTTPException(500, "Dữ liệu has được lưu nhưng...")
|
||||
```
|
||||
- **Impact:** ✅ Fixed - Clear error messages identify MongoDB vs PostgreSQL failures
|
||||
|
||||
### 4️⃣ **Frontend Error Display (MEDIUM)**
|
||||
- **File:** `reader/app/mod/chuong/chapter-client.tsx`
|
||||
- **Line:** 356
|
||||
- **Issue:** Only checked `error` field, not FastAPI's `detail` field
|
||||
- **Fix:** Changed:
|
||||
```javascript
|
||||
if (!res.ok) throw new Error(resData.error || resData.detail || "...")
|
||||
```
|
||||
- **Impact:** ✅ Fixed - Users see actual backend error messages
|
||||
|
||||
### 5️⃣ **Missing Novel Existence Check (MEDIUM)**
|
||||
- **File:** `app/routers/mod.py`
|
||||
- **Lines:** 948-951 (POST)
|
||||
- **Issue:** Could try to save chapter for non-existent novel
|
||||
- **Fix:** Added explicit check:
|
||||
```python
|
||||
novel_check = await db.execute(
|
||||
text('SELECT id FROM "Novel" WHERE id = :nid'),
|
||||
{"nid": body.novelId},
|
||||
)
|
||||
if not novel_check.first():
|
||||
raise HTTPException(404, "Truyện không tồn tại")
|
||||
```
|
||||
- **Impact:** ✅ Fixed - Better error message (404 instead of 500)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Testing Results
|
||||
|
||||
### Build Verification
|
||||
```
|
||||
✅ Python syntax: OK (py_compile passed)
|
||||
✅ Next.js build: OK (all 11 routes successfully compiled)
|
||||
✅ No type errors in modified files
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
| File | Changes | Status |
|
||||
|------|---------|--------|
|
||||
| `app/routers/mod.py` | POST/PUT endpoints refactored with validation | ✅ Fixed |
|
||||
| `reader/app/mod/chuong/chapter-client.tsx` | Error handling improved | ✅ Fixed |
|
||||
| `CHAPTER_SAVE_DEBUG.md` | Debug guide created | ✅ New |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps for User
|
||||
|
||||
### ⚠️ IMPORTANT: Test the following scenarios
|
||||
|
||||
1. **Basic Save:** Create new novel → save Chapter 1 → verify appears in list
|
||||
2. **Ownership:** Try saving to default novel as MOD user → should succeed now
|
||||
3. **Duplicate:** Try saving same chapter twice → should show "đã tồn tại"
|
||||
4. **Empty Content:** Try saving without title → should show validation error
|
||||
5. **Negative Number:** Try chapter #-1 → should reject
|
||||
|
||||
### If Still Failing:
|
||||
|
||||
1. Open **DevTools Network tab** → F12 → Network
|
||||
2. Try to save chapter
|
||||
3. Look for **POST /api/mod/chuong** request
|
||||
4. Check **Status code** and **Response body**
|
||||
5. Use guide in `CHAPTER_SAVE_DEBUG.md` to troubleshoot
|
||||
|
||||
### Possible Remaining Issues
|
||||
|
||||
⚠️ **Not yet fixed (MEDIUM/LOW priority):**
|
||||
- [ ] Race condition on duplicate chapter check (add MongoDB unique index)
|
||||
- [ ] No MongoDB/PostgreSQL timeout configuration
|
||||
- [ ] Generic exception handler logging (uses traceback.print_exc)
|
||||
- [ ] Missing structured logging system
|
||||
|
||||
These are less critical but could cause issues under high load.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Code Changes Summary
|
||||
|
||||
**Total lines modified:** ~150
|
||||
**Files affected:** 2
|
||||
**Breaking changes:** None (backward compatible)
|
||||
**Rollback difficulty:** Low (simple validation additions)
|
||||
|
||||
---
|
||||
|
||||
## ✨ What Changed
|
||||
|
||||
```diff
|
||||
# POST /mod/chuong
|
||||
- Missing input validation
|
||||
- Missing novel existence check
|
||||
- Ownership query doesn't allow NULL uploaderId
|
||||
- No separation of MongoDB vs PostgreSQL error handling
|
||||
|
||||
+ Full input validation (title, content, number)
|
||||
+ Novel existence check with clear 404 error
|
||||
+ Ownership check allows both user-owned and default novels
|
||||
+ Separate error handling for MongoDB and PostgreSQL
|
||||
+ Better error messages for debugging
|
||||
+ Data consistency logging ([CRITICAL] alerts)
|
||||
|
||||
# PUT /mod/chuong
|
||||
- Same issues as POST
|
||||
|
||||
+ Same fixes applied
|
||||
|
||||
# Frontend error handling
|
||||
- Ignored FastAPI's 'detail' field
|
||||
|
||||
+ Now checks both 'error' and 'detail' fields
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Monitoring Recommendations
|
||||
|
||||
1. **Set up log monitoring** for `[CRITICAL]` messages
|
||||
2. **Verify MongoDB connection** on startup
|
||||
3. **Verify PostgreSQL connection** on startup
|
||||
4. **Add request logging** to track save operations
|
||||
5. **Monitor totalChapters sync** for discrepancies
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
If issues persist after testing:
|
||||
1. Follow debugging guide in `CHAPTER_SAVE_DEBUG.md`
|
||||
2. Check Network tab for response codes
|
||||
3. Verify MongoDB and PostgreSQL connectivity
|
||||
4. Look for [CRITICAL] messages in server logs
|
||||
5. Check browser console for JavaScript errors
|
||||
@@ -1,180 +1,101 @@
|
||||
# reader-api
|
||||
# reader-api (FastAPI + UV)
|
||||
|
||||
Đây là backend API dùng chung cho cả web app `reader` và mobile app `reader-app`.
|
||||
Shared backend API for both:
|
||||
- Web app: reader
|
||||
- Mobile app: reader-app
|
||||
|
||||
## 🚀 Tính năng nổi bật
|
||||
This project is Python-first (FastAPI), with production-focused Docker setup and healthcheck.
|
||||
|
||||
- **Xác thực & Phân quyền**: Đăng nhập bằng Google Authentication (NextAuth). Hỗ trợ phân quyền người dùng (USER, MOD, ADMIN).
|
||||
- **Quản lý nội dung (Dành cho MOD/ADMIN)**: Dashboard quản lý truyện, tải lên chương mới, quản lý trạng thái truyện (Đang ra, Hoàn thành, Tạm ngưng).
|
||||
- **Trải nghiệm đọc**: Khám phá truyện theo thể loại, tìm kiếm truyện, đọc chương truyện với hiệu suất cao (nội dung lưu ở MongoDB).
|
||||
- **Tương tác người dùng**: Tính năng tủ sách (bookmark) giúp lưu lại tiến độ đọc, hỗ trợ bình luận ở truyện và từng chương.
|
||||
## Stack
|
||||
|
||||
## 🛠 Tech Stack
|
||||
- Python 3.11+
|
||||
- FastAPI
|
||||
- UV (package manager / runner)
|
||||
- PostgreSQL (structured data)
|
||||
- MongoDB (chapter content + user recommendations)
|
||||
|
||||
- **Framework**: [Next.js](https://nextjs.org/) (App Router), React 19
|
||||
- **Styling**: [TailwindCSS v4](https://tailwindcss.com/) & [Radix UI](https://www.radix-ui.com/) (shadcn/ui)
|
||||
- **Database Hybrid**:
|
||||
- **PostgreSQL**: Lưu trữ dữ liệu cấu trúc (Tài khoản, Truyện, Thể loại, Bình luận, Tủ sách) thông qua **Prisma ORM**.
|
||||
- **MongoDB**: Lưu trữ nội dung lớn (Chương truyện) thông qua **Mongoose**.
|
||||
- **Auth**: [NextAuth.js](https://next-auth.js.org/)
|
||||
## API Base URL
|
||||
|
||||
---
|
||||
- Local dev: http://localhost:8000
|
||||
- Healthcheck: GET /api/health
|
||||
|
||||
## 💻 Hướng dẫn chạy Local (Phát triển)
|
||||
## Environment
|
||||
|
||||
### 1. Yêu cầu cài đặt
|
||||
- [Node.js](https://nodejs.org/) (Khuyến nghị bản LTS)
|
||||
- [pnpm](https://pnpm.io/) (Tool quản lý package)
|
||||
- Database: PostgreSQL và MongoDB đang chạy cục bộ hoặc trên máy chủ.
|
||||
Create `.env` from `.env.example`.
|
||||
|
||||
### 2. Cấu hình môi trường
|
||||
Tạo file `.env` ở thư mục gốc dựa trên `.env.example` (nếu có) hoặc điền các thông tin sau:
|
||||
Required keys:
|
||||
|
||||
```env
|
||||
# URL kết nối PostgreSQL
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/reader?schema=public"
|
||||
|
||||
# URL kết nối MongoDB
|
||||
MONGODB_URI="mongodb://user:password@localhost:27017/reader?authSource=admin"
|
||||
|
||||
# Cấu hình NextAuth
|
||||
NEXTAUTH_SECRET="your-super-secret-key"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# Cấu hình Google Login
|
||||
GOOGLE_CLIENT_ID="your_google_client_id"
|
||||
GOOGLE_CLIENT_SECRET="your_google_client_secret"
|
||||
|
||||
# AI Tool cho MOD (LLM + web search)
|
||||
OPENAI_API_KEY="your_openai_api_key"
|
||||
# Tùy chọn, mặc định: gpt-4o-mini-search-preview
|
||||
OPENAI_WEB_MODEL="gpt-4o-mini-search-preview"
|
||||
|
||||
# Cloudflare R2 (lưu ảnh bìa)
|
||||
R2_ACCOUNT_ID="your_cloudflare_account_id"
|
||||
R2_ACCESS_KEY_ID="your_r2_access_key_id"
|
||||
R2_SECRET_ACCESS_KEY="your_r2_secret_access_key"
|
||||
R2_BUCKET_NAME="your_r2_bucket_name"
|
||||
R2_PUBLIC_BASE_URL="https://your-public-r2-domain"
|
||||
DATABASE_URL=postgresql://reader:reader@localhost:5432/reader
|
||||
MONGODB_URI=mongodb://localhost:27017/reader
|
||||
NEXTAUTH_SECRET=replace-with-strong-secret
|
||||
MOBILE_JWT_SECRET=replace-with-strong-secret
|
||||
# Comma-separated allowed Google OAuth client IDs
|
||||
GOOGLE_CLIENT_ID=web-client-id.apps.googleusercontent.com,android-client-id.apps.googleusercontent.com
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
APP_ENV=development
|
||||
```
|
||||
|
||||
### 3. Cài đặt dependencies và khởi tạo DB
|
||||
## Dev Setup (UV)
|
||||
|
||||
1. Install UV
|
||||
|
||||
```bash
|
||||
# Cài đặt các gói thư viện
|
||||
pnpm install
|
||||
|
||||
# Đồng bộ schema xuống PostgreSQL và generate Prisma client
|
||||
npx prisma db push
|
||||
# hoặc (nếu muốn dùng migrate)
|
||||
# npx prisma migrate dev
|
||||
|
||||
# Generate thư viện Prisma
|
||||
npx prisma generate
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
### 4. Chạy môi trường phát triển
|
||||
2. Sync dependencies
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
uv sync
|
||||
```
|
||||
API chạy mặc định ở [http://localhost:3001](http://localhost:3001).
|
||||
Ví dụ endpoint: [http://localhost:3001/api/novels/browse](http://localhost:3001/api/novels/browse).
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Hướng dẫn Build
|
||||
|
||||
Để build project cho môi trường production:
|
||||
3. Run API in dev mode
|
||||
|
||||
```bash
|
||||
# Đảm bảo Prisma Client đã được generate
|
||||
npx prisma generate
|
||||
|
||||
# Chạy lệnh build của Next.js
|
||||
pnpm build
|
||||
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Sau khi build xong, bạn có thể khởi chạy server production bằng:
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Triển khai dưới dạng Docker
|
||||
|
||||
Bạn có thể dễ dàng triển khai ứng dụng bằng nền tảng Docker. Dưới đây là cách đóng gói và chạy thông qua `docker-compose`.
|
||||
|
||||
### 1. Tạo file `Dockerfile`
|
||||
Tạo file `Dockerfile` ở thư mục gốc của dự án với cấu hình multi-stage build để tối ưu dung lượng:
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Dependencies
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Tạo prisma client
|
||||
RUN npx prisma generate
|
||||
# Chạy build
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
*(Lưu ý: Để build mục `standalone` hoạt động, bạn cần bổ sung `output: 'standalone'` trong file `next.config.mjs`)*
|
||||
|
||||
### 2. Tạo file `docker-compose.yml`
|
||||
Sử dụng Docker Compose để chạy ứng dụng (giả sử Database của bạn được host riêng hoặc bạn có thể thêm service DB vào file này):
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: reader-web:latest
|
||||
container_name: reader-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 3. Khởi chạy bằng Docker
|
||||
Chạy lệnh sau để build image và start container:
|
||||
4. Verify health
|
||||
|
||||
```bash
|
||||
# Build và chạy ngầm (detached mode)
|
||||
docker-compose up -d --build
|
||||
curl http://localhost:8000/api/health
|
||||
```
|
||||
|
||||
Để xem log của container:
|
||||
## Docker Compose
|
||||
|
||||
### Production-style API only (external DBs)
|
||||
|
||||
```bash
|
||||
docker-compose logs -f web
|
||||
docker compose up -d --build api
|
||||
```
|
||||
Dừng và xóa container:
|
||||
|
||||
### Full local stack (API + Postgres + Mongo)
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
docker compose --profile localdb up -d --build
|
||||
```
|
||||
|
||||
## Implemented Endpoints
|
||||
|
||||
- GET /api/health
|
||||
- POST /api/auth/mobile-login
|
||||
- GET /api/user/profile
|
||||
- GET/POST /api/user/bookmarks
|
||||
- DELETE /api/user/bookmarks/{novelId}
|
||||
- POST /api/user/reading-progress
|
||||
- GET/POST /api/user/settings
|
||||
- GET/POST/DELETE /api/user/recommendations
|
||||
- GET /api/genres
|
||||
- GET /api/novels/browse
|
||||
- GET /api/novels/{idOrSlug}
|
||||
- GET /api/truyen/{id}/chapters
|
||||
- GET/POST /api/truyen/{id}/comments
|
||||
- POST /api/truyen/{id}/rate
|
||||
- GET /api/truyen/suggest
|
||||
- GET /api/chapters/{chapterId}
|
||||
|
||||
## Notes
|
||||
|
||||
- Web session auth is supported via NextAuth session cookies (next-auth.session-token and secure variants).
|
||||
- Mobile auth is supported via Bearer JWT from /api/auth/mobile-login.
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db_session
|
||||
|
||||
SESSION_COOKIE_KEYS = [
|
||||
"next-auth.session-token",
|
||||
"__Secure-next-auth.session-token",
|
||||
"authjs.session-token",
|
||||
"__Secure-authjs.session-token",
|
||||
]
|
||||
|
||||
|
||||
def _jwt_secret() -> str:
|
||||
return settings.mobile_jwt_secret or settings.nextauth_secret
|
||||
|
||||
|
||||
def create_access_token(user_id: str) -> str:
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + dt.timedelta(days=7)).timestamp()),
|
||||
}
|
||||
secret = _jwt_secret()
|
||||
if not secret:
|
||||
raise RuntimeError("Missing MOBILE_JWT_SECRET or NEXTAUTH_SECRET")
|
||||
return jwt.encode(payload, secret, algorithm="HS256")
|
||||
|
||||
|
||||
async def _get_user_by_id(db: AsyncSession, user_id: str) -> dict[str, Any] | None:
|
||||
result = await db.execute(
|
||||
text(
|
||||
'SELECT id, email, name, image, role FROM "User" WHERE id = :user_id LIMIT 1'
|
||||
),
|
||||
{"user_id": user_id},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def _get_user_from_session_cookie(db: AsyncSession, request: Request) -> dict[str, Any] | None:
|
||||
token = None
|
||||
for key in SESSION_COOKIE_KEYS:
|
||||
value = request.cookies.get(key)
|
||||
if value:
|
||||
token = value
|
||||
break
|
||||
|
||||
if not token:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
'SELECT u.id, u.email, u.name, u.image, u.role '
|
||||
'FROM "Session" s '
|
||||
'JOIN "User" u ON u.id = s."userId" '
|
||||
'WHERE s."sessionToken" = :token AND s.expires > NOW() '
|
||||
'LIMIT 1'
|
||||
),
|
||||
{"token": token},
|
||||
)
|
||||
row = result.mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def resolve_current_user(db: AsyncSession, request: Request) -> dict[str, Any] | None:
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth.split(" ", 1)[1].strip()
|
||||
secret = _jwt_secret()
|
||||
if not secret:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, secret, algorithms=["HS256"])
|
||||
subject = payload.get("sub")
|
||||
if isinstance(subject, str) and subject:
|
||||
return await _get_user_by_id(db, subject)
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
return await _get_user_from_session_cookie(db, request)
|
||||
|
||||
|
||||
async def require_current_user(db: AsyncSession, request: Request) -> dict[str, Any]:
|
||||
user = await resolve_current_user(db, request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return user
|
||||
|
||||
|
||||
async def require_mod_user(request: Request, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]:
|
||||
user = await resolve_current_user(db, request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if user.get("role") not in ("MOD", "ADMIN"):
|
||||
raise HTTPException(status_code=403, detail="Forbidden: MOD or ADMIN role required")
|
||||
return user
|
||||
@@ -0,0 +1,45 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_name: str = "reader-api"
|
||||
app_env: str = "development"
|
||||
|
||||
database_url: str
|
||||
mongodb_uri: str
|
||||
|
||||
google_client_id: str = ""
|
||||
nextauth_secret: str = ""
|
||||
mobile_jwt_secret: str = ""
|
||||
|
||||
cors_origins: str = "*"
|
||||
r2_account_id: str = ""
|
||||
r2_access_key_id: str = ""
|
||||
r2_secret_access_key: str = ""
|
||||
r2_bucket_name: str = ""
|
||||
r2_public_base_url: str = ""
|
||||
|
||||
deepseek_key: str = ""
|
||||
deepseek_model: str = "deepseek-chat"
|
||||
openrouter_key: str = ""
|
||||
openrouter_paused: str = "true"
|
||||
|
||||
@property
|
||||
def google_client_id_list(self) -> list[str]:
|
||||
raw = (self.google_client_id or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
raw = (self.cors_origins or "*").strip()
|
||||
if raw == "*":
|
||||
return ["*"]
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,45 @@
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _normalize_database_url(url: str) -> str:
|
||||
# Strip Prisma-only query params (e.g. ?schema=public) that asyncpg doesn't accept
|
||||
from urllib.parse import urlparse, urlencode, parse_qsl, urlunparse
|
||||
|
||||
_PRISMA_ONLY_PARAMS = {"schema", "connection_limit", "pool_timeout", "connect_timeout", "sslmode"}
|
||||
|
||||
if url.startswith("postgresql+asyncpg://"):
|
||||
scheme = "postgresql+asyncpg"
|
||||
elif url.startswith("postgresql://") or url.startswith("postgres://"):
|
||||
scheme = "postgresql+asyncpg"
|
||||
else:
|
||||
return url
|
||||
|
||||
parsed = urlparse(url)
|
||||
clean_params = [(k, v) for k, v in parse_qsl(parsed.query) if k not in _PRISMA_ONLY_PARAMS]
|
||||
clean_url = urlunparse((
|
||||
scheme,
|
||||
parsed.netloc,
|
||||
parsed.path,
|
||||
parsed.params,
|
||||
urlencode(clean_params),
|
||||
parsed.fragment,
|
||||
))
|
||||
return clean_url
|
||||
|
||||
|
||||
engine = create_async_engine(_normalize_database_url(settings.database_url), pool_pre_ping=True)
|
||||
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
mongo_client = AsyncIOMotorClient(settings.mongodb_uri)
|
||||
mongo_db = mongo_client.get_default_database()
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncSession:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
+1202
File diff suppressed because it is too large
Load Diff
+2417
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: reader-api:latest
|
||||
container_name: reader-api
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health').read()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: reader-api-postgres
|
||||
profiles: ["localdb"]
|
||||
environment:
|
||||
POSTGRES_DB: reader
|
||||
POSTGRES_USER: reader
|
||||
POSTGRES_PASSWORD: reader
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U reader -d reader"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
mongo:
|
||||
image: mongo:7
|
||||
container_name: reader-api-mongo
|
||||
profiles: ["localdb"]
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
mongo_data:
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "reader-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
"@aws-sdk/client-s3": "^3.1006.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.8",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-context-menu": "2.2.16",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-hover-card": "1.1.15",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-menubar": "1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "1.2.14",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"epub2": "^3.0.2",
|
||||
"html-to-text": "^9.0.5",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"mongoose": "^9.2.4",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "^5.22.0",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.13.2",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"pg": "^8.20.0",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
[project]
|
||||
name = "reader-api"
|
||||
version = "0.1.0"
|
||||
description = "Shared backend API for reader web and reader-app mobile clients"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.116.1",
|
||||
"uvicorn[standard]>=0.35.0",
|
||||
"sqlalchemy[asyncio]>=2.0.43",
|
||||
"greenlet>=3.0.0",
|
||||
"asyncpg>=0.30.0",
|
||||
"motor>=3.7.1",
|
||||
"python-jose[cryptography]>=3.5.0",
|
||||
"google-auth>=2.40.3",
|
||||
"requests>=2.32.5",
|
||||
"pydantic-settings>=2.10.1",
|
||||
# mod routes deps
|
||||
"boto3>=1.35.0",
|
||||
"httpx>=0.27.0",
|
||||
"ebooklib>=0.18",
|
||||
"html2text>=2024.2.26",
|
||||
"python-slugify>=8.0.4",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.12.11",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
Reference in New Issue
Block a user