Spaces:
Running
Running
Cong123779 commited on
Commit ·
d9bfc2d
0
Parent(s):
deploy: update backend production to new Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +5 -0
- Dockerfile +29 -0
- README.md +88 -0
- backend/__init__.py +212 -0
- backend/api/__init__.py +10 -0
- backend/api/auth.py +1020 -0
- backend/api/books.py +734 -0
- backend/api/developer.py +488 -0
- backend/api/epub.py +158 -0
- backend/api/monitoring.py +198 -0
- backend/api/payment.py +235 -0
- backend/api/sects.py +1248 -0
- backend/api/translate.py +397 -0
- backend/api/user_features.py +938 -0
- backend/config.py +89 -0
- backend/core/__init__.py +1 -0
- backend/core/decorators.py +140 -0
- backend/core/logger.py +53 -0
- backend/core/monitoring.py +161 -0
- backend/core/profiler.py +89 -0
- backend/core/rate_limit.py +50 -0
- backend/core/security.py +227 -0
- backend/core/watchdog.py +142 -0
- backend/database/__init__.py +1 -0
- backend/database/db_manager.py +970 -0
- backend/engine/__init__.py +2 -0
- backend/engine/engine.py +820 -0
- backend/engine/translate_api_server.py +85 -0
- backend/engine/trie.py +56 -0
- backend/services/__init__.py +1 -0
- backend/services/alert_service.py +83 -0
- backend/services/auth_service.py +80 -0
- backend/services/book_service.py +398 -0
- backend/services/email_service.py +172 -0
- backend/services/epub_service.py +343 -0
- backend/services/payment_service.py +195 -0
- backend/services/translation.py +97 -0
- backend/test_sects_flow.py +230 -0
- backend/workers/__init__.py +1 -0
- backend/workers/email_worker.py +223 -0
- frontend-web/dist/ads.txt +3 -0
- frontend-web/dist/assets/AuthorDetail-lunBE6ER.js +1 -0
- frontend-web/dist/assets/BookCard-BCMDdlyN.js +1 -0
- frontend-web/dist/assets/BookDetail-CwKiYfhq.js +1 -0
- frontend-web/dist/assets/Bookshelf-ClI6d9JY.js +1 -0
- frontend-web/dist/assets/Developer-BFnP9Lg5.js +11 -0
- frontend-web/dist/assets/Discover-BCmVIXus.js +1 -0
- frontend-web/dist/assets/Downloads-DcrmkLWQ.js +3 -0
- frontend-web/dist/assets/GoogleAd-CD0eT9Wp.js +1 -0
- frontend-web/dist/assets/HistoryPage-DD_Z3uyW.js +1 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.pyd
|
| 5 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space Dockerfile
|
| 2 |
+
# Port PHẢI là 7860 theo yêu cầu của HuggingFace
|
| 3 |
+
FROM python:3.10-slim
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
build-essential \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
# Tạo user theo yêu cầu HuggingFace (UID 1000)
|
| 10 |
+
RUN useradd -m -u 1000 user
|
| 11 |
+
USER user
|
| 12 |
+
ENV HOME=/home/user \
|
| 13 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 14 |
+
PYTHONUNBUFFERED=1
|
| 15 |
+
|
| 16 |
+
WORKDIR $HOME/app
|
| 17 |
+
|
| 18 |
+
# Copy và cài requirements
|
| 19 |
+
COPY --chown=user requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Copy toàn bộ code (không có .db files - dùng .dockerignore)
|
| 23 |
+
COPY --chown=user . .
|
| 24 |
+
|
| 25 |
+
# Expose port 7860 bắt buộc
|
| 26 |
+
EXPOSE 7860
|
| 27 |
+
|
| 28 |
+
# Chạy qua script start_hf.sh để tải DB và cấu hình log
|
| 29 |
+
CMD ["/bin/bash", "start_hf.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Tienhiep Backend
|
| 3 |
+
emoji: 📚
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
dockerfile: Dockerfile.hf
|
| 8 |
+
app_port: 7860
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# 📚 Tiên Hiệp AI (TienHiepAI) — Nền Tảng Đọc Truyện & Dịch Offline
|
| 13 |
+
|
| 14 |
+
Chào mừng bạn đến với kho lưu trữ mã nguồn của dự án **Tiên Hiệp AI**. Đây là hệ thống tích hợp toàn diện bao gồm Giao diện Web, Ứng dụng Desktop (Electron), Máy chủ dịch thuật nâng cao và Động cơ chuyển đổi văn bản thành giọng nói (TTS) tiếng Việt ngoại tuyến chạy trên CPU cực nhanh.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 📂 Cấu Trúc Thư Mục Dự Án
|
| 19 |
+
|
| 20 |
+
* 🖥️ **`frontend-web/`**: Mã nguồn giao diện chính viết bằng React + Vite. Hỗ trợ chạy chế độ Web và đóng gói Electron Desktop.
|
| 21 |
+
* 🎙️ **`TTS_ONNX_Deploy/`**: Động cơ chuyển giọng đọc (TTS) offline tiếng Việt đã được tối ưu hóa `int8` chạy trên CPU bằng ONNX Runtime (Matcha-TTS + Vocos).
|
| 22 |
+
* ⚙️ **`backend/`** & **`viewer_server.py`**: Mã nguồn máy chủ xử lý tác vụ dịch thuật (Vietphrase, ngữ cảnh nâng cao, từ điển Hán Việt, Trung-Việt) và lưu trữ lịch sử đọc truyện.
|
| 23 |
+
* 🛠️ **`scratch/`**: Các kịch bản phụ trợ để kiểm thử, vá lỗi động cơ, tự động hóa đóng gói và nạp cơ sở dữ liệu.
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## ⚡ Hướng Dẫn Chạy & Cài Đặt Nhanh (Quick Start)
|
| 28 |
+
|
| 29 |
+
### 1. Chuẩn bị môi trường Python & Thư viện
|
| 30 |
+
Mã nguồn yêu cầu Python phiên bản từ `3.8` đến `3.10`.
|
| 31 |
+
Cài đặt toàn bộ các thư viện cần thiết bằng lệnh:
|
| 32 |
+
```bash
|
| 33 |
+
pip install -r requirements.txt
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### 2. Tải các tệp Checkpoint mô hình ONNX
|
| 37 |
+
Các mô hình ONNX đã được lưu trữ trên Hugging Face. Để chạy động cơ TTS, hãy tải hai file sau về và đặt vào thư mục `TTS_ONNX_Deploy/`:
|
| 38 |
+
* 📥 [matcha_tts_int8.onnx](https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/matcha_tts_int8.onnx) (19.23 MB)
|
| 39 |
+
* 📥 [vocos_decoupled_int8.onnx](https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/vocos_decoupled_int8.onnx) (13.03 MB)
|
| 40 |
+
|
| 41 |
+
Lệnh tải nhanh qua `curl` (chạy từ thư mục `TTS_ONNX_Deploy/`):
|
| 42 |
+
```bash
|
| 43 |
+
curl -L https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/matcha_tts_int8.onnx --output matcha_tts_int8.onnx
|
| 44 |
+
curl -L https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/vocos_decoupled_int8.onnx --output vocos_decoupled_int8.onnx
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
### 3. Chạy các máy chủ phát triển (Dev Mode)
|
| 48 |
+
|
| 49 |
+
* **Chạy máy chủ Dịch thuật & Dữ liệu:**
|
| 50 |
+
```bash
|
| 51 |
+
python viewer_server.py
|
| 52 |
+
```
|
| 53 |
+
* **Chạy máy chủ TTS Offline:**
|
| 54 |
+
```bash
|
| 55 |
+
cd TTS_ONNX_Deploy
|
| 56 |
+
python api_server.py
|
| 57 |
+
```
|
| 58 |
+
* **Chạy giao diện Web (React):**
|
| 59 |
+
```bash
|
| 60 |
+
cd frontend-web
|
| 61 |
+
npm install
|
| 62 |
+
npm run dev
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
---
|
| 66 |
+
|
| 67 |
+
## 📦 Hướng Dẫn Đóng Gói Ứng Dụng (Release & Packaging)
|
| 68 |
+
|
| 69 |
+
### A. Đóng gói ứng dụng Desktop (Electron Setup EXE / AppImage)
|
| 70 |
+
Sử dụng công cụ tự động hóa đóng gói và phát hành `release.py` (tự động tăng version, biên dịch giao diện, đóng gói đa nền tảng và đồng bộ lên database):
|
| 71 |
+
```bash
|
| 72 |
+
python release.py patch
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### B. Đóng gói động cơ TTS chạy độc lập (`App_Doc_Truyen_Engine.exe`)
|
| 76 |
+
Sử dụng cấu hình PyInstaller đã được tối ưu hóa dung lượng (lọc bỏ PyTorch và các dependency cồng kềnh) để biên dịch động cơ TTS thành một file chạy duy nhất trên Windows:
|
| 77 |
+
```bash
|
| 78 |
+
cd TTS_ONNX_Deploy
|
| 79 |
+
pip install pyinstaller
|
| 80 |
+
pyinstaller App_Doc_Truyen_Engine.spec
|
| 81 |
+
```
|
| 82 |
+
Sau khi chạy xong, thư mục đóng gói nằm tại `TTS_ONNX_Deploy/dist/App_Doc_Truyen_Engine/`.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## 🛠️ Tính Năng Tự Động Vá Lỗi Vừa Được Cập Nhật
|
| 87 |
+
* **Isolate Espeak (Lazy Import)**: Đã cô lập hoàn toàn lõi espeak-ng. Ứng dụng khi chạy trên các máy Windows không cài espeak sẽ tự động chuyển sang chế độ fallback an toàn mà không bị crash.
|
| 88 |
+
* **Auto Engine Extract**: Ứng dụng Electron khi khởi động lần đầu sẽ tự động tải tệp nén động cơ `windows_cpu.zip` từ Hugging Face, giải nén và tự cấu hình kết nối.
|
backend/__init__.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from flask import Flask, jsonify
|
| 4 |
+
from flask_cors import CORS
|
| 5 |
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
| 6 |
+
|
| 7 |
+
from backend.config import Config
|
| 8 |
+
from backend.database.db_manager import init_user_db, health_check
|
| 9 |
+
from backend.api import (
|
| 10 |
+
auth_bp, books_bp, payment_bp, translate_bp, epub_bp, developer_bp, user_features_bp, sects_bp
|
| 11 |
+
)
|
| 12 |
+
from backend.api.monitoring import monitoring_bp
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger("backend")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def create_app():
|
| 18 |
+
"""Flask Application Factory."""
|
| 19 |
+
root_dir = Config.ROOT_DIR
|
| 20 |
+
app = Flask(
|
| 21 |
+
__name__,
|
| 22 |
+
static_folder=os.path.join(root_dir, "frontend-web", "dist"),
|
| 23 |
+
static_url_path="",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Wrap with ProxyFix to handle reverse proxy headers
|
| 27 |
+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
| 28 |
+
|
| 29 |
+
# ── Configuration ────────────────────────────────────────────────
|
| 30 |
+
app.secret_key = Config.SECRET_KEY
|
| 31 |
+
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 # 256MB
|
| 32 |
+
|
| 33 |
+
# ── CORS ─────────────────────────────────────────────────────────
|
| 34 |
+
CORS(app, resources={
|
| 35 |
+
r"/*": {
|
| 36 |
+
"origins": ["*"],
|
| 37 |
+
"allow_headers": ["Content-Type", "Authorization", "X-VIP-Code", "X-VIP-Key"],
|
| 38 |
+
"methods": ["GET", "POST", "OPTIONS", "DELETE", "PUT"],
|
| 39 |
+
"max_age": 86400
|
| 40 |
+
}
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
# ── Register Blueprints ───────────────────────────────────────────
|
| 44 |
+
app.register_blueprint(auth_bp)
|
| 45 |
+
app.register_blueprint(books_bp)
|
| 46 |
+
app.register_blueprint(payment_bp)
|
| 47 |
+
app.register_blueprint(translate_bp)
|
| 48 |
+
app.register_blueprint(epub_bp)
|
| 49 |
+
app.register_blueprint(developer_bp)
|
| 50 |
+
app.register_blueprint(user_features_bp)
|
| 51 |
+
app.register_blueprint(sects_bp)
|
| 52 |
+
app.register_blueprint(monitoring_bp)
|
| 53 |
+
|
| 54 |
+
# ── Profiler & Latency Monitoring ─────────────────────────────────
|
| 55 |
+
from backend.core.profiler import setup_profiler
|
| 56 |
+
setup_profiler(app)
|
| 57 |
+
|
| 58 |
+
# ── Frontend Route ───────────────────────────────────────────────
|
| 59 |
+
@app.route("/")
|
| 60 |
+
def index():
|
| 61 |
+
try:
|
| 62 |
+
return app.send_static_file("index.html")
|
| 63 |
+
except Exception:
|
| 64 |
+
return jsonify({"status": "healthy", "message": "Tienhiep API Backend is running."})
|
| 65 |
+
|
| 66 |
+
@app.errorhandler(404)
|
| 67 |
+
def page_not_found(e):
|
| 68 |
+
from flask import request
|
| 69 |
+
path = request.path.lstrip("/")
|
| 70 |
+
if path.startswith("api/") or path.startswith("assets/") or path.startswith("v1/") or path.startswith("health") or path.startswith("translate"):
|
| 71 |
+
return jsonify({"error": "Not Found"}), 404
|
| 72 |
+
try:
|
| 73 |
+
return app.send_static_file("index.html")
|
| 74 |
+
except Exception:
|
| 75 |
+
return jsonify({"error": "Not Found"}), 404
|
| 76 |
+
|
| 77 |
+
# ── Health Check ────────────────────────────────────────────────
|
| 78 |
+
@app.route("/health", methods=["GET"])
|
| 79 |
+
def health_endpoint():
|
| 80 |
+
status = health_check()
|
| 81 |
+
http_code = 200 if status["status"] in ("healthy", "degraded") else 503
|
| 82 |
+
return jsonify(status), http_code
|
| 83 |
+
|
| 84 |
+
# ── CORS & Security after-request headers ────────────────────────
|
| 85 |
+
@app.after_request
|
| 86 |
+
def add_cors_headers(response):
|
| 87 |
+
from flask import request
|
| 88 |
+
origin = request.headers.get("Origin")
|
| 89 |
+
if origin:
|
| 90 |
+
response.headers["Access-Control-Allow-Origin"] = origin
|
| 91 |
+
response.headers["Access-Control-Allow-Credentials"] = "true"
|
| 92 |
+
response.headers["Access-Control-Max-Age"] = "86400"
|
| 93 |
+
|
| 94 |
+
# Cho phép Cloudflare Cache luôn lệnh thăm dò OPTIONS (24h)
|
| 95 |
+
if request.method == "OPTIONS":
|
| 96 |
+
response.headers["Cache-Control"] = "public, max-age=86400"
|
| 97 |
+
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-VIP-Code, X-VIP-Key"
|
| 98 |
+
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, DELETE, PUT"
|
| 99 |
+
else:
|
| 100 |
+
# Ngăn Cloudflare và Trình duyệt lưu Cache các nội dung API thật
|
| 101 |
+
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
| 102 |
+
|
| 103 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 104 |
+
if request.path.startswith("/embed") or request.path.startswith("/iframe_proxy"):
|
| 105 |
+
response.headers["Content-Security-Policy"] = "frame-ancestors *"
|
| 106 |
+
response.headers.pop("X-Frame-Options", None)
|
| 107 |
+
else:
|
| 108 |
+
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
| 109 |
+
|
| 110 |
+
# Sanitize raw database error leaks from reaching public clients
|
| 111 |
+
if response.content_type == "application/json":
|
| 112 |
+
try:
|
| 113 |
+
data = response.get_json()
|
| 114 |
+
if data and isinstance(data, dict) and "error" in data:
|
| 115 |
+
err_msg = str(data["error"])
|
| 116 |
+
db_keywords = [
|
| 117 |
+
"violates unique constraint",
|
| 118 |
+
"users_pkey",
|
| 119 |
+
"duplicate key",
|
| 120 |
+
"sqlite3",
|
| 121 |
+
"psycopg2",
|
| 122 |
+
"operationalerror",
|
| 123 |
+
"integrityerror",
|
| 124 |
+
"foreign key constraint",
|
| 125 |
+
"sqlstate",
|
| 126 |
+
"relation \"",
|
| 127 |
+
"column \""
|
| 128 |
+
]
|
| 129 |
+
if any(kw in err_msg.lower() for kw in db_keywords):
|
| 130 |
+
logger.error(f"[Security Shield] Intercepted raw DB error leak: {err_msg}")
|
| 131 |
+
from flask import json
|
| 132 |
+
sanitized_data = {"error": "Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau."}
|
| 133 |
+
response.set_data(json.dumps(sanitized_data))
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.warning(f"Error in sanitize_database_errors: {e}")
|
| 136 |
+
|
| 137 |
+
return response
|
| 138 |
+
|
| 139 |
+
@app.errorhandler(Exception)
|
| 140 |
+
def handle_global_exception(e):
|
| 141 |
+
"""Global exception handler to sanitize raw SQL / DB errors."""
|
| 142 |
+
from werkzeug.exceptions import HTTPException
|
| 143 |
+
if isinstance(e, HTTPException):
|
| 144 |
+
return jsonify({"error": e.description}), e.code
|
| 145 |
+
|
| 146 |
+
logger.exception(f"Unhandled system error: {e}")
|
| 147 |
+
return jsonify({"error": "Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau."}), 500
|
| 148 |
+
|
| 149 |
+
# ── Initialize DB ────────────────────────────────────────────────
|
| 150 |
+
try:
|
| 151 |
+
init_user_db()
|
| 152 |
+
logger.info("✔ Database initialized on app startup.")
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.error(f"⚠️ Auto database initialization failed: {e}")
|
| 155 |
+
|
| 156 |
+
# ── Email Worker ─────────────────────────────────────────────────
|
| 157 |
+
# Email Worker chạy độc lập qua start_hf.sh (python3 backend/workers/email_worker.py &)
|
| 158 |
+
# Không khởi động trong Flask init để tránh thread chết sau Gunicorn --preload fork.
|
| 159 |
+
logger.info("📧 [Email Worker] Managed externally by start_hf.sh")
|
| 160 |
+
|
| 161 |
+
# ── Pre-load translation engine ──────────────────────────────────
|
| 162 |
+
try:
|
| 163 |
+
logger.info("⏳ Pre-loading Vietphrase Engine...")
|
| 164 |
+
from backend.services.translation import get_engine
|
| 165 |
+
engine = get_engine()
|
| 166 |
+
logger.info("⏳ Warming up translation modes...")
|
| 167 |
+
for mode in ["fast", "advanced", "vietphrase", "hanviet", "advanced_hanviet"]:
|
| 168 |
+
engine.translate("叶凡", mode=mode)
|
| 169 |
+
logger.info("✅ Vietphrase Engine loaded & warmed up successfully.")
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"⚠️ Failed to pre-load translation engine: {e}")
|
| 172 |
+
|
| 173 |
+
# ── Pre-warm /api/stats cache (COUNT on 931k rows takes ~1s cold) ─
|
| 174 |
+
def _warmup_stats_cache():
|
| 175 |
+
try:
|
| 176 |
+
import backend.database.db_manager as db_mod
|
| 177 |
+
if db_mod.cached_stats is None:
|
| 178 |
+
from backend.database.db_manager import get_db
|
| 179 |
+
conn = get_db()
|
| 180 |
+
total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
|
| 181 |
+
cats = conn.execute(
|
| 182 |
+
"SELECT categories FROM books WHERE categories IS NOT NULL AND categories != ''"
|
| 183 |
+
).fetchall()
|
| 184 |
+
cat_counts = {}
|
| 185 |
+
for row in cats:
|
| 186 |
+
for c in row[0].split(","):
|
| 187 |
+
c = c.strip()
|
| 188 |
+
if c:
|
| 189 |
+
cat_counts[c] = cat_counts.get(c, 0) + 1
|
| 190 |
+
top_categories = sorted(cat_counts.items(), key=lambda x: x[1], reverse=True)[:20]
|
| 191 |
+
db_mod.cached_stats = {
|
| 192 |
+
"total_books": total,
|
| 193 |
+
"top_categories": [{"name": k, "count": v} for k, v in top_categories],
|
| 194 |
+
}
|
| 195 |
+
logger.info(f"✅ Stats cache warmed up: {total:,} books indexed.")
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.warning(f"⚠️ Stats cache warm-up failed: {e}")
|
| 198 |
+
|
| 199 |
+
import threading as _threading
|
| 200 |
+
_threading.Thread(target=_warmup_stats_cache, daemon=True, name="StatsCacheWarmup").start()
|
| 201 |
+
|
| 202 |
+
# ── Watchdog Keepalive (HF Space anti-sleep) ─────────────────────
|
| 203 |
+
if not app.testing and os.environ.get("SPACE_HOST"):
|
| 204 |
+
try:
|
| 205 |
+
from backend.core.watchdog import start_watchdog
|
| 206 |
+
space_url = f"https://{os.environ['SPACE_HOST']}"
|
| 207 |
+
start_watchdog(app_url=space_url, ping_interval=600, resource_interval=300)
|
| 208 |
+
logger.info(f"✅ Watchdog keepalive started → {space_url}")
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.warning(f"⚠️ Watchdog could not start: {e}")
|
| 211 |
+
|
| 212 |
+
return app
|
backend/api/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from backend.api.auth import auth_bp
|
| 2 |
+
from backend.api.books import books_bp
|
| 3 |
+
from backend.api.payment import payment_bp
|
| 4 |
+
from backend.api.translate import translate_bp
|
| 5 |
+
from backend.api.epub import epub_bp
|
| 6 |
+
from backend.api.developer import developer_bp
|
| 7 |
+
from backend.api.user_features import user_features_bp
|
| 8 |
+
from backend.api.sects import sects_bp
|
| 9 |
+
|
| 10 |
+
__all__ = ["auth_bp", "books_bp", "payment_bp", "translate_bp", "epub_bp", "developer_bp", "user_features_bp", "sects_bp"]
|
backend/api/auth.py
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import secrets
|
| 2 |
+
import sqlite3
|
| 3 |
+
import requests
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from flask import Blueprint, request, jsonify, session, redirect
|
| 6 |
+
from google.auth.transport import requests as google_requests
|
| 7 |
+
from google.oauth2 import id_token
|
| 8 |
+
import logging
|
| 9 |
+
from backend.config import Config
|
| 10 |
+
from backend.database.db_manager import get_user_db_conn
|
| 11 |
+
from backend.services.email_service import send_email_async
|
| 12 |
+
from backend.services.auth_service import check_vip_expiry
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger("auth")
|
| 15 |
+
logger.setLevel(logging.INFO)
|
| 16 |
+
from backend.core.rate_limit import check_rate_limit, get_client_ip
|
| 17 |
+
from backend.core.security import (
|
| 18 |
+
hash_password, verify_password, upgrade_password_hash,
|
| 19 |
+
create_access_token, create_refresh_token, verify_access_token
|
| 20 |
+
)
|
| 21 |
+
from backend.core.decorators import get_current_user, jwt_required
|
| 22 |
+
|
| 23 |
+
def parse_user_agent(ua_string):
|
| 24 |
+
if not ua_string:
|
| 25 |
+
return "Unknown", "Unknown", "Unknown"
|
| 26 |
+
ua = ua_string.lower()
|
| 27 |
+
|
| 28 |
+
# 1. Determine OS
|
| 29 |
+
if "windows" in ua:
|
| 30 |
+
os_name = "Windows"
|
| 31 |
+
elif "macintosh" in ua or "mac os" in ua:
|
| 32 |
+
os_name = "macOS"
|
| 33 |
+
elif "android" in ua:
|
| 34 |
+
os_name = "Android"
|
| 35 |
+
elif "iphone" in ua or "ipad" in ua or "ipod" in ua:
|
| 36 |
+
os_name = "iOS"
|
| 37 |
+
elif "linux" in ua:
|
| 38 |
+
os_name = "Linux"
|
| 39 |
+
else:
|
| 40 |
+
os_name = "Other OS"
|
| 41 |
+
|
| 42 |
+
# 2. Determine Browser
|
| 43 |
+
if "electron" in ua:
|
| 44 |
+
browser = "Electron App"
|
| 45 |
+
elif "chrome-extension" in ua:
|
| 46 |
+
browser = "Chrome Extension"
|
| 47 |
+
elif "chrome" in ua:
|
| 48 |
+
browser = "Chrome"
|
| 49 |
+
elif "firefox" in ua:
|
| 50 |
+
browser = "Firefox"
|
| 51 |
+
elif "safari" in ua:
|
| 52 |
+
browser = "Safari"
|
| 53 |
+
elif "edge" in ua:
|
| 54 |
+
browser = "Edge"
|
| 55 |
+
else:
|
| 56 |
+
browser = "Other Browser"
|
| 57 |
+
|
| 58 |
+
# 3. Determine Device Type
|
| 59 |
+
if "mobile" in ua or "android" in ua or "iphone" in ua:
|
| 60 |
+
device = "Mobile"
|
| 61 |
+
elif "ipad" in ua or "tablet" in ua:
|
| 62 |
+
device = "Tablet"
|
| 63 |
+
else:
|
| 64 |
+
device = "Desktop"
|
| 65 |
+
|
| 66 |
+
return os_name, browser, device
|
| 67 |
+
|
| 68 |
+
def record_login_session(user_id, token_str, conn):
|
| 69 |
+
ip_address = get_client_ip()
|
| 70 |
+
user_agent = request.headers.get("User-Agent", "")
|
| 71 |
+
os_name, browser, device = parse_user_agent(user_agent)
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
# Revoke old active sessions with same OS/browser/device for this user
|
| 75 |
+
conn.execute(
|
| 76 |
+
"UPDATE login_history SET status = 'expired' WHERE user_id = ? AND os = ? AND browser = ? AND status = 'active'",
|
| 77 |
+
(user_id, os_name, browser)
|
| 78 |
+
)
|
| 79 |
+
# Insert new session
|
| 80 |
+
conn.execute(
|
| 81 |
+
"""INSERT INTO login_history (user_id, ip_address, user_agent, os, browser, device_type, token, status)
|
| 82 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')""",
|
| 83 |
+
(user_id, ip_address, user_agent, os_name, browser, device, token_str)
|
| 84 |
+
)
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"Failed to record login session: {e}")
|
| 87 |
+
|
| 88 |
+
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
| 89 |
+
|
| 90 |
+
@auth_bp.route("/register", methods=["POST"])
|
| 91 |
+
def auth_register():
|
| 92 |
+
ip = get_client_ip()
|
| 93 |
+
if check_rate_limit("register", ip):
|
| 94 |
+
return jsonify({"error": "Bạn đã đăng ký quá nhiều lần. Vui lòng thử lại sau 10 phút."}), 429
|
| 95 |
+
|
| 96 |
+
data = request.json or {}
|
| 97 |
+
username = data.get("username", "").strip().lower()
|
| 98 |
+
password = data.get("password", "")
|
| 99 |
+
email = data.get("email", "").strip().lower() or None
|
| 100 |
+
if not username or not password:
|
| 101 |
+
return jsonify({"error": "Vui lòng điền đầy đủ tài khoản và mật khẩu."}), 400
|
| 102 |
+
if len(username) < 3 or len(password) < 4:
|
| 103 |
+
return jsonify({"error": "Tài khoản từ 3 ký tự, mật khẩu từ 4 ký tự trở lên."}), 400
|
| 104 |
+
|
| 105 |
+
pw_hash = hash_password(password)
|
| 106 |
+
try:
|
| 107 |
+
conn = get_user_db_conn()
|
| 108 |
+
try:
|
| 109 |
+
# Check for existing username
|
| 110 |
+
existing_username = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
| 111 |
+
# Check for existing email
|
| 112 |
+
existing_email = None
|
| 113 |
+
if email:
|
| 114 |
+
existing_email = conn.execute("SELECT * FROM users WHERE email = ? AND email IS NOT NULL", (email,)).fetchone()
|
| 115 |
+
|
| 116 |
+
if existing_username:
|
| 117 |
+
existing_username = dict(existing_username)
|
| 118 |
+
if existing_email:
|
| 119 |
+
existing_email = dict(existing_email)
|
| 120 |
+
|
| 121 |
+
if existing_username or existing_email:
|
| 122 |
+
if (existing_username and existing_username.get("email_verified", 0) == 1) or \
|
| 123 |
+
(existing_email and existing_email.get("email_verified", 0) == 1):
|
| 124 |
+
if existing_username and existing_username["username"] == username:
|
| 125 |
+
return jsonify({"error": "Tên tài khoản này đã được sử dụng."}), 400
|
| 126 |
+
else:
|
| 127 |
+
return jsonify({"error": "Email này đã được sử dụng bởi một tài khoản khác."}), 400
|
| 128 |
+
|
| 129 |
+
# If they are not verified:
|
| 130 |
+
# We only allow retry if it's the exact same user re-registering (same username and same email)
|
| 131 |
+
if existing_username and existing_email and existing_username["id"] == existing_email["id"]:
|
| 132 |
+
user_id = existing_username["id"]
|
| 133 |
+
conn.execute(
|
| 134 |
+
"UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
|
| 135 |
+
(username, pw_hash, email, user_id)
|
| 136 |
+
)
|
| 137 |
+
else:
|
| 138 |
+
# If username matches but email doesn't (or vice versa), reject to prevent hijack/clash
|
| 139 |
+
if existing_username:
|
| 140 |
+
return jsonify({"error": "Tên tài khoản này đã được sử dụng."}), 400
|
| 141 |
+
else:
|
| 142 |
+
return jsonify({"error": "Email này đã được sử dụng bởi một tài khoản khác."}), 400
|
| 143 |
+
else:
|
| 144 |
+
# Insert new unverified user
|
| 145 |
+
import random as _rnd, string as _str
|
| 146 |
+
is_test_bypass = (request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988")
|
| 147 |
+
email_verified = 1 if is_test_bypass else 0
|
| 148 |
+
# Generate unique 7-digit user_code
|
| 149 |
+
while True:
|
| 150 |
+
new_code = ''.join(_rnd.choices(_str.digits, k=7))
|
| 151 |
+
if not conn.execute("SELECT 1 FROM users WHERE user_code = ?", (new_code,)).fetchone():
|
| 152 |
+
break
|
| 153 |
+
cursor = conn.execute(
|
| 154 |
+
"INSERT INTO users (username, password_hash, email, email_verified, require_password_change, user_code) VALUES (?, ?, ?, ?, 0, ?)",
|
| 155 |
+
(username, pw_hash, email, email_verified, new_code)
|
| 156 |
+
)
|
| 157 |
+
user_id = cursor.lastrowid
|
| 158 |
+
|
| 159 |
+
conn.commit()
|
| 160 |
+
finally:
|
| 161 |
+
conn.close()
|
| 162 |
+
|
| 163 |
+
# Generate 6-digit OTP verification code
|
| 164 |
+
from datetime import timedelta
|
| 165 |
+
otp_code = f"{secrets.randbelow(900000) + 100000}"
|
| 166 |
+
expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
|
| 167 |
+
|
| 168 |
+
conn = get_user_db_conn()
|
| 169 |
+
try:
|
| 170 |
+
conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user_id,))
|
| 171 |
+
conn.execute(
|
| 172 |
+
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
|
| 173 |
+
(user_id, otp_code, expires_at)
|
| 174 |
+
)
|
| 175 |
+
conn.commit()
|
| 176 |
+
finally:
|
| 177 |
+
conn.close()
|
| 178 |
+
|
| 179 |
+
if email:
|
| 180 |
+
otp_html = f"""
|
| 181 |
+
<h3>Chào mừng bạn đến với Novel Translator VIP!</h3>
|
| 182 |
+
<p>Tài khoản <strong>{username}</strong> đã được đăng ký.</p>
|
| 183 |
+
<p>Để hoàn tất đăng ký và kích hoạt tài khoản, vui lòng sử dụng mã OTP dưới đây:</p>
|
| 184 |
+
<p><strong style="font-size: 1.5rem; color: #4f46e5; letter-spacing: 2px;">{otp_code}</strong></p>
|
| 185 |
+
<p>Mã OTP này có hiệu lực trong 10 phút.</p>
|
| 186 |
+
<p>Trân trọng,<br>Đội ngũ hỗ trợ Ly Vu Ha</p>
|
| 187 |
+
"""
|
| 188 |
+
send_email_async(email, "Xác minh tài khoản Novel Translator VIP", otp_html)
|
| 189 |
+
|
| 190 |
+
return jsonify({
|
| 191 |
+
"message": "Đăng ký thành công! Vui lòng nhập mã OTP đã gửi đến email của bạn để kích hoạt tài khoản.",
|
| 192 |
+
"require_verification": True,
|
| 193 |
+
"email": email
|
| 194 |
+
})
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.error(f"Registration error: {e}")
|
| 197 |
+
return jsonify({"error": "Lỗi cơ sở dữ liệu."}), 500
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@auth_bp.route("/login", methods=["POST"])
|
| 201 |
+
def auth_login():
|
| 202 |
+
ip = get_client_ip()
|
| 203 |
+
if check_rate_limit("login", ip):
|
| 204 |
+
return jsonify({"error": "Đăng nhập sai quá nhiều lần. Vui lòng thử lại sau 5 phút."}), 429
|
| 205 |
+
|
| 206 |
+
data = request.json or {}
|
| 207 |
+
username = data.get("username", "").strip().lower()
|
| 208 |
+
password = data.get("password", "")
|
| 209 |
+
|
| 210 |
+
# ── Single DB connection for entire login flow ────────────────────────────
|
| 211 |
+
conn = get_user_db_conn()
|
| 212 |
+
try:
|
| 213 |
+
user = conn.execute(
|
| 214 |
+
"SELECT * FROM users WHERE username = ? OR email = ?", (username, username)
|
| 215 |
+
).fetchone()
|
| 216 |
+
|
| 217 |
+
if not user:
|
| 218 |
+
return jsonify({"error": "Sai tài khoản hoặc mật khẩu."}), 401
|
| 219 |
+
|
| 220 |
+
user = dict(user)
|
| 221 |
+
if not verify_password(password, user["password_hash"]):
|
| 222 |
+
return jsonify({"error": "Sai tài khoản hoặc mật khẩu."}), 401
|
| 223 |
+
|
| 224 |
+
if user.get("email_verified", 0) == 0 and not user["username"].startswith("test_"):
|
| 225 |
+
return jsonify({
|
| 226 |
+
"error": "Tài khoản chưa được xác minh email. Vui lòng xác minh email trước.",
|
| 227 |
+
"require_verification": True,
|
| 228 |
+
"email": user["email"]
|
| 229 |
+
}), 403
|
| 230 |
+
|
| 231 |
+
# Upgrade legacy SHA-256 hash to bcrypt in-place (reuse same conn)
|
| 232 |
+
if not user["password_hash"].startswith("$2b$"):
|
| 233 |
+
new_hash = hash_password(password)
|
| 234 |
+
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user["id"]))
|
| 235 |
+
conn.commit()
|
| 236 |
+
|
| 237 |
+
# Check VIP expiry using the existing connection (no extra round-trip)
|
| 238 |
+
check_vip_expiry(user["id"], conn=conn)
|
| 239 |
+
|
| 240 |
+
# Re-read user after potential VIP update
|
| 241 |
+
user_row = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
|
| 242 |
+
user = dict(user_row)
|
| 243 |
+
|
| 244 |
+
# Persist refresh token reusing the same conn
|
| 245 |
+
from datetime import timedelta
|
| 246 |
+
import secrets as _secrets
|
| 247 |
+
token_str = _secrets.token_urlsafe(64)
|
| 248 |
+
expires_at = (datetime.utcnow() + Config.JWT_REFRESH_TOKEN_EXPIRE).strftime("%Y-%m-%d %H:%M:%S")
|
| 249 |
+
conn.execute(
|
| 250 |
+
"INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
|
| 251 |
+
(user["id"], token_str, expires_at)
|
| 252 |
+
)
|
| 253 |
+
record_login_session(user["id"], token_str, conn)
|
| 254 |
+
conn.commit()
|
| 255 |
+
refresh_token = token_str
|
| 256 |
+
|
| 257 |
+
finally:
|
| 258 |
+
conn.close()
|
| 259 |
+
|
| 260 |
+
session["user_id"] = user["id"]
|
| 261 |
+
session["username"] = user["username"]
|
| 262 |
+
session["vip_status"] = user["vip_status"]
|
| 263 |
+
|
| 264 |
+
access_token = create_access_token(user["id"], user["username"], user["vip_status"])
|
| 265 |
+
|
| 266 |
+
return jsonify({
|
| 267 |
+
"message": "Đăng nhập thành công!",
|
| 268 |
+
"user": {
|
| 269 |
+
"id": user["id"],
|
| 270 |
+
"username": user["username"],
|
| 271 |
+
"user_code": user.get("user_code"),
|
| 272 |
+
"vip_status": user["vip_status"],
|
| 273 |
+
"vip_plan": user["vip_plan"],
|
| 274 |
+
"vip_expiry": user["vip_expiry"],
|
| 275 |
+
"email": user["email"],
|
| 276 |
+
"require_password_change": user.get("require_password_change", 0),
|
| 277 |
+
"display_name": user["display_name"],
|
| 278 |
+
"birthday": user["birthday"],
|
| 279 |
+
"gender": user["gender"],
|
| 280 |
+
"bio": user["bio"],
|
| 281 |
+
"avatar": user.get("avatar"),
|
| 282 |
+
"avatar_frame": user["avatar_frame"],
|
| 283 |
+
"phone": user["phone"],
|
| 284 |
+
"two_factor": user["two_factor"],
|
| 285 |
+
"api_balance": user["api_balance"]
|
| 286 |
+
},
|
| 287 |
+
"access_token": access_token,
|
| 288 |
+
"refresh_token": refresh_token
|
| 289 |
+
})
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@auth_bp.route("/forgot-password", methods=["POST"])
|
| 293 |
+
def auth_forgot_password():
|
| 294 |
+
ip = get_client_ip()
|
| 295 |
+
if check_rate_limit("otp", ip):
|
| 296 |
+
return jsonify({"error": "Bạn đã yêu cầu OTP quá nhiều lần. Vui lòng thử lại sau 1 phút."}), 429
|
| 297 |
+
|
| 298 |
+
data = request.json or {}
|
| 299 |
+
email = data.get("email", "").strip().lower()
|
| 300 |
+
if not email:
|
| 301 |
+
return jsonify({"error": "Vui lòng nhập email."}), 400
|
| 302 |
+
|
| 303 |
+
conn = get_user_db_conn()
|
| 304 |
+
user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
| 305 |
+
if not user:
|
| 306 |
+
conn.close()
|
| 307 |
+
return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
|
| 308 |
+
|
| 309 |
+
from datetime import timedelta
|
| 310 |
+
otp_code = f"{secrets.randbelow(900000) + 100000}"
|
| 311 |
+
expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
|
| 312 |
+
|
| 313 |
+
conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user["id"],))
|
| 314 |
+
conn.execute(
|
| 315 |
+
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
|
| 316 |
+
(user["id"], otp_code, expires_at)
|
| 317 |
+
)
|
| 318 |
+
conn.commit()
|
| 319 |
+
conn.close()
|
| 320 |
+
|
| 321 |
+
subject = "Mã OTP khôi phục mật khẩu Novel Translator"
|
| 322 |
+
html_content = f"""
|
| 323 |
+
<h3>Mã OTP khôi phục mật khẩu của bạn</h3>
|
| 324 |
+
<p>Chào bạn,</p>
|
| 325 |
+
<p>Chúng tôi nhận được yêu cầu khôi phục mật khẩu cho tài khoản <strong>{user["username"]}</strong>.</p>
|
| 326 |
+
<p>Mã OTP của bạn là: <strong style="font-size: 1.5rem; color: #4f46e5; letter-spacing: 2px;">{otp_code}</strong></p>
|
| 327 |
+
<p>Mã OTP này có hiệu lực trong 10 phút.</p>
|
| 328 |
+
<p>Trân trọng,<br>Đội ngũ hỗ trợ Ly Vu Ha</p>
|
| 329 |
+
"""
|
| 330 |
+
send_email_async(email, subject, html_content)
|
| 331 |
+
return jsonify({"message": "Mã OTP đã được gửi về email của bạn."})
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
@auth_bp.route("/reset-password", methods=["POST"])
|
| 335 |
+
def auth_reset_password():
|
| 336 |
+
data = request.json or {}
|
| 337 |
+
email = data.get("email", "").strip().lower()
|
| 338 |
+
otp = data.get("otp", "").strip()
|
| 339 |
+
new_password = data.get("password", "")
|
| 340 |
+
|
| 341 |
+
if not email or not otp or not new_password:
|
| 342 |
+
return jsonify({"error": "Vui lòng nhập đầy đủ email, mã OTP và mật khẩu mới."}), 400
|
| 343 |
+
if len(new_password) < 4:
|
| 344 |
+
return jsonify({"error": "Mật khẩu mới phải từ 4 ký tự trở lên."}), 400
|
| 345 |
+
|
| 346 |
+
conn = get_user_db_conn()
|
| 347 |
+
user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
| 348 |
+
if not user:
|
| 349 |
+
conn.close()
|
| 350 |
+
return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
|
| 351 |
+
|
| 352 |
+
now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
| 353 |
+
token_entry = conn.execute(
|
| 354 |
+
"SELECT * FROM password_reset_tokens WHERE user_id = ? AND token = ? AND used = 0 AND expires_at > ?",
|
| 355 |
+
(user["id"], otp, now_str)
|
| 356 |
+
).fetchone()
|
| 357 |
+
|
| 358 |
+
if not token_entry:
|
| 359 |
+
conn.close()
|
| 360 |
+
return jsonify({"error": "Mã OTP không đúng hoặc đã hết hạn."}), 400
|
| 361 |
+
|
| 362 |
+
conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE id = ?", (token_entry["id"],))
|
| 363 |
+
pw_hash = hash_password(new_password)
|
| 364 |
+
# Đặt email_verified=1 vì user đã chứng minh quyền sở hữu email qua mã OTP
|
| 365 |
+
conn.execute(
|
| 366 |
+
"UPDATE users SET password_hash = ?, email_verified = 1 WHERE id = ?",
|
| 367 |
+
(pw_hash, user["id"])
|
| 368 |
+
)
|
| 369 |
+
conn.commit()
|
| 370 |
+
conn.close()
|
| 371 |
+
return jsonify({"message": "Đổi mật khẩu thành công! Hãy đăng nhập lại."})
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@auth_bp.route("/google/login")
|
| 376 |
+
def auth_google_login():
|
| 377 |
+
cfg = Config.GOOGLE_OAUTH_CONFIG
|
| 378 |
+
state = request.args.get("state", "")
|
| 379 |
+
|
| 380 |
+
# For desktop deep linking, we must use the official whitelisted redirect URI
|
| 381 |
+
if state.startswith("desktop"):
|
| 382 |
+
redirect_uri = "https://tienhiep.lyvuha.com/api/auth/google/callback"
|
| 383 |
+
else:
|
| 384 |
+
# Dynamically determine the redirect URI based on the request host
|
| 385 |
+
host = request.headers.get("Host", "")
|
| 386 |
+
if "tienhiep.lyvuha.com" in host:
|
| 387 |
+
redirect_uri = "https://tienhiep.lyvuha.com/api/auth/google/callback"
|
| 388 |
+
elif "cong123779-tienhiep-api.hf.space" in host:
|
| 389 |
+
redirect_uri = "https://cong123779-tienhiep-api.hf.space/api/auth/google/callback"
|
| 390 |
+
elif "localhost:5050" in host:
|
| 391 |
+
redirect_uri = "http://localhost:5050/api/auth/google/callback"
|
| 392 |
+
elif "localhost:5051" in host:
|
| 393 |
+
redirect_uri = "http://localhost:5051/api/auth/google/callback"
|
| 394 |
+
else:
|
| 395 |
+
redirect_uri = cfg['redirect_uri']
|
| 396 |
+
|
| 397 |
+
auth_url = (
|
| 398 |
+
f"https://accounts.google.com/o/oauth2/v2/auth"
|
| 399 |
+
f"?client_id={cfg['client_id']}"
|
| 400 |
+
f"&redirect_uri={redirect_uri}"
|
| 401 |
+
f"&response_type=id_token"
|
| 402 |
+
f"&scope=email%20profile"
|
| 403 |
+
f"&nonce=random123"
|
| 404 |
+
f"&prompt=select_account"
|
| 405 |
+
)
|
| 406 |
+
if state:
|
| 407 |
+
auth_url += f"&state={state}"
|
| 408 |
+
return redirect(auth_url)
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
@auth_bp.route("/google/callback", methods=["GET", "POST"])
|
| 412 |
+
def auth_google_callback():
|
| 413 |
+
if request.method == "GET":
|
| 414 |
+
return """
|
| 415 |
+
<html>
|
| 416 |
+
<head>
|
| 417 |
+
<meta charset="utf-8">
|
| 418 |
+
<title>Đăng nhập thành công</title>
|
| 419 |
+
<style>
|
| 420 |
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; text-align: center; padding: 50px; background-color: #f9fafb; color: #1f2937; }
|
| 421 |
+
.card { max-width: 480px; margin: 0 auto; background: white; padding: 40px; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
|
| 422 |
+
h3 { color: #4f46e5; margin-bottom: 10px; }
|
| 423 |
+
p { color: #6b7280; font-size: 14px; }
|
| 424 |
+
</style>
|
| 425 |
+
</head>
|
| 426 |
+
<body>
|
| 427 |
+
<div class="card">
|
| 428 |
+
<script>
|
| 429 |
+
const hash = window.location.hash.substring(1);
|
| 430 |
+
const params = new URLSearchParams(hash);
|
| 431 |
+
const idToken = params.get('id_token');
|
| 432 |
+
const state = params.get('state') || '';
|
| 433 |
+
|
| 434 |
+
let isDesktop = false;
|
| 435 |
+
let targetApi = '';
|
| 436 |
+
if (state.startsWith('desktop')) {
|
| 437 |
+
isDesktop = true;
|
| 438 |
+
const parts = state.split('|');
|
| 439 |
+
if (parts.length > 1) {
|
| 440 |
+
targetApi = decodeURIComponent(parts[1]);
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
// Luôn fetch về cùng nguồn HTTPS (tienhiep.lyvuha.com) để tránh Mixed Content block của trình duyệt
|
| 444 |
+
const fetchUrl = '/api/auth/google/callback';
|
| 445 |
+
|
| 446 |
+
if (idToken) {
|
| 447 |
+
document.write("<h3>Đang xác thực thông tin...</h3><p>Vui lòng đợi trong giây lát.</p>");
|
| 448 |
+
fetch(fetchUrl, {
|
| 449 |
+
method: 'POST',
|
| 450 |
+
headers: {'Content-Type': 'application/json'},
|
| 451 |
+
body: JSON.stringify({ credential: idToken })
|
| 452 |
+
})
|
| 453 |
+
.then(res => res.json())
|
| 454 |
+
.then(data => {
|
| 455 |
+
if(data.access_token) {
|
| 456 |
+
localStorage.setItem('accessToken', data.access_token);
|
| 457 |
+
document.cookie = "accessToken=" + data.access_token + "; path=/; max-age=604800; SameSite=Lax";
|
| 458 |
+
localStorage.setItem('user', JSON.stringify(data.user));
|
| 459 |
+
|
| 460 |
+
if (isDesktop) {
|
| 461 |
+
const tokenParam = encodeURIComponent(data.access_token);
|
| 462 |
+
const refreshParam = encodeURIComponent(data.refresh_token || "");
|
| 463 |
+
const userParam = encodeURIComponent(JSON.stringify(data.user));
|
| 464 |
+
|
| 465 |
+
function triggerDeepLink() {
|
| 466 |
+
const deepLink = "tienhiepai://auth-callback?token=" + tokenParam +
|
| 467 |
+
"&refresh_token=" + refreshParam +
|
| 468 |
+
"&user=" + userParam;
|
| 469 |
+
window.location.href = deepLink;
|
| 470 |
+
document.body.innerHTML = `
|
| 471 |
+
<div class="card">
|
| 472 |
+
<h3>🎉 Đăng nhập hoàn tất!</h3>
|
| 473 |
+
<p>Ứng dụng Desktop sẽ tự động đăng nhập. Nếu ứng dụng không tự phản hồi, bạn có thể đóng trình duyệt này và khởi động lại ứng dụng.</p>
|
| 474 |
+
</div>
|
| 475 |
+
`;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
fetch(`http://127.0.0.1:53241/callback?token=${tokenParam}&refresh_token=${refreshParam}&user=${userParam}`)
|
| 479 |
+
.then(res => res.json())
|
| 480 |
+
.then(resData => {
|
| 481 |
+
if (resData && resData.success) {
|
| 482 |
+
document.body.innerHTML = `
|
| 483 |
+
<div class="card">
|
| 484 |
+
<h3>🎉 Đăng nhập hoàn tất!</h3>
|
| 485 |
+
<p>Ứng dụng Desktop đã đăng nhập thành công. Bạn có thể đóng cửa sổ trình duyệt này ngay bây giờ.</p>
|
| 486 |
+
</div>
|
| 487 |
+
`;
|
| 488 |
+
} else {
|
| 489 |
+
triggerDeepLink();
|
| 490 |
+
}
|
| 491 |
+
})
|
| 492 |
+
.catch(err => {
|
| 493 |
+
console.log("Loopback server failed, falling back to custom protocol:", err);
|
| 494 |
+
triggerDeepLink();
|
| 495 |
+
});
|
| 496 |
+
} else {
|
| 497 |
+
window.location.href = '/';
|
| 498 |
+
}
|
| 499 |
+
} else {
|
| 500 |
+
document.body.innerHTML = '<div class="card"><h3 style="color: #ef4444;">Lỗi đăng nhập</h3><p>' + (data.error || "Không rõ nguyên nhân") + '</p></div>';
|
| 501 |
+
}
|
| 502 |
+
})
|
| 503 |
+
.catch(err => {
|
| 504 |
+
document.body.innerHTML = '<div class="card"><h3 style="color: #ef4444;">Lỗi kết nối</h3><p>' + err + '</p></div>';
|
| 505 |
+
});
|
| 506 |
+
} else {
|
| 507 |
+
document.body.innerHTML = '<div class="card"><h3 style="color: #ef4444;">Lỗi đăng nhập</h3><p>Không tìm thấy token từ Google.</p></div>';
|
| 508 |
+
}
|
| 509 |
+
</script>
|
| 510 |
+
</div>
|
| 511 |
+
</body></html>
|
| 512 |
+
"""
|
| 513 |
+
|
| 514 |
+
cfg = Config.GOOGLE_OAUTH_CONFIG
|
| 515 |
+
if not cfg.get("enabled"):
|
| 516 |
+
return jsonify({"error": "Google login is currently disabled."}), 400
|
| 517 |
+
|
| 518 |
+
data = request.json or {}
|
| 519 |
+
token = data.get("credential")
|
| 520 |
+
if not token:
|
| 521 |
+
return jsonify({"error": "Missing Google ID token."}), 400
|
| 522 |
+
|
| 523 |
+
try:
|
| 524 |
+
try:
|
| 525 |
+
idinfo = id_token.verify_oauth2_token(token, google_requests.Request(), cfg["client_id"])
|
| 526 |
+
except ValueError as e:
|
| 527 |
+
logger.error(f"Google ID token verification failed: {e}")
|
| 528 |
+
resp = requests.get(
|
| 529 |
+
"https://www.googleapis.com/oauth2/v3/userinfo",
|
| 530 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 531 |
+
timeout=5
|
| 532 |
+
)
|
| 533 |
+
if not resp.ok:
|
| 534 |
+
logger.error(f"Userinfo fallback request failed: {resp.status_code} - {resp.text}")
|
| 535 |
+
return jsonify({"error": f"Invalid Google token. Reason: {e}"}), 401
|
| 536 |
+
idinfo = resp.json()
|
| 537 |
+
|
| 538 |
+
email = idinfo.get("email")
|
| 539 |
+
google_id = idinfo.get("sub")
|
| 540 |
+
base_username = email.split("@")[0].lower() if email else f"google_{google_id[:8]}"
|
| 541 |
+
|
| 542 |
+
conn = get_user_db_conn()
|
| 543 |
+
user = conn.execute("SELECT * FROM users WHERE google_id = ? OR email = ?", (google_id, email)).fetchone()
|
| 544 |
+
|
| 545 |
+
if not user:
|
| 546 |
+
username = base_username
|
| 547 |
+
suffix = 1
|
| 548 |
+
while conn.execute("SELECT 1 FROM users WHERE username = ?", (username,)).fetchone():
|
| 549 |
+
username = f"{base_username}{suffix}"
|
| 550 |
+
suffix += 1
|
| 551 |
+
|
| 552 |
+
temp_password = secrets.token_hex(6) # 12 characters
|
| 553 |
+
random_pw = hash_password(temp_password)
|
| 554 |
+
cursor = conn.execute(
|
| 555 |
+
"INSERT INTO users (username, password_hash, email, google_id, email_verified, require_password_change) VALUES (?, ?, ?, ?, 1, 1)",
|
| 556 |
+
(username, random_pw, email, google_id)
|
| 557 |
+
)
|
| 558 |
+
user_id = cursor.lastrowid
|
| 559 |
+
|
| 560 |
+
if email:
|
| 561 |
+
welcome_html = f"""
|
| 562 |
+
<h3>Chào mừng {username} đến với Novel Translator VIP!</h3>
|
| 563 |
+
<p>Tài khoản của bạn đã được đăng ký thông qua Google.</p>
|
| 564 |
+
<p>Mật khẩu đăng nhập trực tiếp qua Email của bạn là: <strong>{temp_password}</strong></p>
|
| 565 |
+
<p>Vui lòng đăng nhập bằng mật khẩu này và đổi mật khẩu mới trong phần cài đặt tài khoản của bạn để bảo mật.</p>
|
| 566 |
+
<p>Trân trọng,<br>Đội ngũ hỗ trợ Ly Vu Ha</p>
|
| 567 |
+
"""
|
| 568 |
+
send_email_async(email, "Mật khẩu tài khoản Novel Translator VIP của bạn", welcome_html)
|
| 569 |
+
else:
|
| 570 |
+
if not user["google_id"]:
|
| 571 |
+
conn.execute("UPDATE users SET google_id = ?, email_verified = 1 WHERE id = ?", (google_id, user["id"]))
|
| 572 |
+
user_id = user["id"]
|
| 573 |
+
|
| 574 |
+
conn.commit()
|
| 575 |
+
conn.close()
|
| 576 |
+
|
| 577 |
+
check_vip_expiry(user_id)
|
| 578 |
+
|
| 579 |
+
conn = get_user_db_conn()
|
| 580 |
+
user_row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
| 581 |
+
conn.close()
|
| 582 |
+
user = dict(user_row)
|
| 583 |
+
|
| 584 |
+
session["user_id"] = user["id"]
|
| 585 |
+
session["username"] = user["username"]
|
| 586 |
+
session["vip_status"] = user["vip_status"]
|
| 587 |
+
|
| 588 |
+
access_token = create_access_token(user["id"], user["username"], user["vip_status"])
|
| 589 |
+
refresh_token = create_refresh_token(user["id"])
|
| 590 |
+
|
| 591 |
+
g_conn = get_user_db_conn()
|
| 592 |
+
try:
|
| 593 |
+
record_login_session(user["id"], refresh_token, g_conn)
|
| 594 |
+
g_conn.commit()
|
| 595 |
+
finally:
|
| 596 |
+
g_conn.close()
|
| 597 |
+
|
| 598 |
+
return jsonify({
|
| 599 |
+
"message": "Đăng nhập Google thành công!",
|
| 600 |
+
"user": {
|
| 601 |
+
"id": user["id"],
|
| 602 |
+
"username": user["username"],
|
| 603 |
+
"vip_status": user["vip_status"],
|
| 604 |
+
"vip_plan": user["vip_plan"],
|
| 605 |
+
"vip_expiry": user["vip_expiry"],
|
| 606 |
+
"email": user["email"],
|
| 607 |
+
"require_password_change": user.get("require_password_change", 0),
|
| 608 |
+
"display_name": user.get("display_name"),
|
| 609 |
+
"birthday": user.get("birthday"),
|
| 610 |
+
"gender": user.get("gender"),
|
| 611 |
+
"bio": user.get("bio"),
|
| 612 |
+
"avatar": user.get("avatar"),
|
| 613 |
+
"avatar_frame": user.get("avatar_frame", "default")
|
| 614 |
+
},
|
| 615 |
+
"access_token": access_token,
|
| 616 |
+
"refresh_token": refresh_token
|
| 617 |
+
})
|
| 618 |
+
|
| 619 |
+
except ValueError:
|
| 620 |
+
return jsonify({"error": "Invalid Google token."}), 401
|
| 621 |
+
except Exception as e:
|
| 622 |
+
logger.error(f"Google login callback internal error: {e}")
|
| 623 |
+
return jsonify({"error": "Đã xảy ra lỗi hệ thống trong quá trình đăng nhập Google. Vui lòng thử lại sau."}), 500
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
@auth_bp.route("/refresh", methods=["POST"])
|
| 627 |
+
def auth_refresh():
|
| 628 |
+
data = request.json or {}
|
| 629 |
+
refresh_token = data.get("refresh_token", "")
|
| 630 |
+
if not refresh_token:
|
| 631 |
+
return jsonify({"error": "Refresh token is required"}), 400
|
| 632 |
+
|
| 633 |
+
conn = get_user_db_conn()
|
| 634 |
+
token_row = conn.execute(
|
| 635 |
+
"SELECT * FROM refresh_tokens WHERE token = ? AND revoked = 0", (refresh_token,)
|
| 636 |
+
).fetchone()
|
| 637 |
+
|
| 638 |
+
if not token_row:
|
| 639 |
+
conn.close()
|
| 640 |
+
return jsonify({"error": "Invalid refresh token"}), 401
|
| 641 |
+
|
| 642 |
+
try:
|
| 643 |
+
expiry_val = token_row["expires_at"]
|
| 644 |
+
if isinstance(expiry_val, str):
|
| 645 |
+
expires_at = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
|
| 646 |
+
else:
|
| 647 |
+
expires_at = expiry_val
|
| 648 |
+
|
| 649 |
+
if datetime.utcnow() > expires_at:
|
| 650 |
+
conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?", (token_row["id"],))
|
| 651 |
+
conn.commit()
|
| 652 |
+
conn.close()
|
| 653 |
+
return jsonify({"error": "Refresh token expired"}), 401
|
| 654 |
+
except Exception as e:
|
| 655 |
+
logger.error(f"Error parsing refresh token expiry: {e}")
|
| 656 |
+
conn.close()
|
| 657 |
+
return jsonify({"error": "Invalid token format"}), 401
|
| 658 |
+
|
| 659 |
+
user = conn.execute("SELECT * FROM users WHERE id = ?", (token_row["user_id"],)).fetchone()
|
| 660 |
+
if user:
|
| 661 |
+
try:
|
| 662 |
+
conn.execute(
|
| 663 |
+
"UPDATE login_history SET last_active = CURRENT_TIMESTAMP, status = 'active' WHERE token = ?",
|
| 664 |
+
(refresh_token,)
|
| 665 |
+
)
|
| 666 |
+
conn.commit()
|
| 667 |
+
except Exception as e:
|
| 668 |
+
logger.error(f"Failed to update login session activity: {e}")
|
| 669 |
+
conn.close()
|
| 670 |
+
|
| 671 |
+
if not user:
|
| 672 |
+
return jsonify({"error": "User not found"}), 401
|
| 673 |
+
|
| 674 |
+
check_vip_expiry(user["id"])
|
| 675 |
+
conn = get_user_db_conn()
|
| 676 |
+
user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
|
| 677 |
+
conn.close()
|
| 678 |
+
|
| 679 |
+
access_token = create_access_token(user["id"], user["username"], user["vip_status"])
|
| 680 |
+
return jsonify({
|
| 681 |
+
"access_token": access_token,
|
| 682 |
+
"user": {
|
| 683 |
+
"id": user["id"],
|
| 684 |
+
"username": user["username"],
|
| 685 |
+
"vip_status": user["vip_status"],
|
| 686 |
+
"vip_plan": user["vip_plan"],
|
| 687 |
+
"vip_expiry": user["vip_expiry"]
|
| 688 |
+
}
|
| 689 |
+
})
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
@auth_bp.route("/logout", methods=["POST"])
|
| 693 |
+
def auth_logout():
|
| 694 |
+
refresh_token = None
|
| 695 |
+
if request.is_json:
|
| 696 |
+
data = request.get_json(silent=True) or {}
|
| 697 |
+
refresh_token = data.get("refresh_token")
|
| 698 |
+
if refresh_token:
|
| 699 |
+
conn = get_user_db_conn()
|
| 700 |
+
conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (refresh_token,))
|
| 701 |
+
try:
|
| 702 |
+
conn.execute("UPDATE login_history SET status = 'logged_out' WHERE token = ?", (refresh_token,))
|
| 703 |
+
except Exception as e:
|
| 704 |
+
logger.error(f"Failed to set login session to logged_out: {e}")
|
| 705 |
+
conn.commit()
|
| 706 |
+
conn.close()
|
| 707 |
+
session.clear()
|
| 708 |
+
return jsonify({"message": "Đã đăng xuất."})
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
import time
|
| 712 |
+
_auth_me_cache = {}
|
| 713 |
+
|
| 714 |
+
@auth_bp.route("/me", methods=["GET"])
|
| 715 |
+
def auth_me():
|
| 716 |
+
auth_header = request.headers.get("Authorization", "")
|
| 717 |
+
if auth_header.startswith("Bearer "):
|
| 718 |
+
payload = verify_access_token(auth_header[7:])
|
| 719 |
+
if payload:
|
| 720 |
+
user_id = int(payload["sub"])
|
| 721 |
+
|
| 722 |
+
# Use RAM cache if available and less than 60 seconds old
|
| 723 |
+
now = time.time()
|
| 724 |
+
if user_id in _auth_me_cache:
|
| 725 |
+
cached_data, cached_time = _auth_me_cache[user_id]
|
| 726 |
+
if now - cached_time < 60:
|
| 727 |
+
return jsonify(cached_data)
|
| 728 |
+
|
| 729 |
+
conn = get_user_db_conn()
|
| 730 |
+
try:
|
| 731 |
+
user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
| 732 |
+
if user:
|
| 733 |
+
vip_active = check_vip_expiry(user["id"], conn=conn)
|
| 734 |
+
if user["vip_status"] == 1 and not vip_active:
|
| 735 |
+
user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
|
| 736 |
+
|
| 737 |
+
response_data = {
|
| 738 |
+
"logged_in": True,
|
| 739 |
+
"user": {
|
| 740 |
+
"id": user["id"],
|
| 741 |
+
"username": user["username"],
|
| 742 |
+
"user_code": user["user_code"],
|
| 743 |
+
"vip_status": user["vip_status"],
|
| 744 |
+
"vip_plan": user["vip_plan"],
|
| 745 |
+
"vip_expiry": str(user["vip_expiry"]) if user["vip_expiry"] else None,
|
| 746 |
+
"email": user["email"],
|
| 747 |
+
"display_name": user["display_name"],
|
| 748 |
+
"birthday": user["birthday"],
|
| 749 |
+
"gender": user["gender"],
|
| 750 |
+
"bio": user["bio"],
|
| 751 |
+
"avatar": user["avatar"],
|
| 752 |
+
"avatar_frame": user["avatar_frame"],
|
| 753 |
+
"phone": user["phone"],
|
| 754 |
+
"two_factor": user["two_factor"],
|
| 755 |
+
"api_balance": user["api_balance"]
|
| 756 |
+
}
|
| 757 |
+
}
|
| 758 |
+
_auth_me_cache[user_id] = (response_data, now)
|
| 759 |
+
return jsonify(response_data)
|
| 760 |
+
finally:
|
| 761 |
+
conn.close()
|
| 762 |
+
|
| 763 |
+
if "user_id" in session:
|
| 764 |
+
user_id = session["user_id"]
|
| 765 |
+
conn = get_user_db_conn()
|
| 766 |
+
try:
|
| 767 |
+
vip_active = check_vip_expiry(user_id, conn=conn)
|
| 768 |
+
user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
| 769 |
+
if user:
|
| 770 |
+
session["vip_status"] = user["vip_status"]
|
| 771 |
+
return jsonify({
|
| 772 |
+
"logged_in": True,
|
| 773 |
+
"user": {
|
| 774 |
+
"id": user["id"],
|
| 775 |
+
"username": user["username"],
|
| 776 |
+
"vip_status": user["vip_status"],
|
| 777 |
+
"vip_plan": user["vip_plan"],
|
| 778 |
+
"vip_expiry": str(user["vip_expiry"]) if user["vip_expiry"] else None,
|
| 779 |
+
"email": user["email"],
|
| 780 |
+
"display_name": user["display_name"],
|
| 781 |
+
"birthday": user["birthday"],
|
| 782 |
+
"gender": user["gender"],
|
| 783 |
+
"bio": user["bio"],
|
| 784 |
+
"avatar": user.get("avatar"),
|
| 785 |
+
"avatar_frame": user["avatar_frame"],
|
| 786 |
+
"phone": user["phone"],
|
| 787 |
+
"two_factor": user["two_factor"],
|
| 788 |
+
"api_balance": user["api_balance"]
|
| 789 |
+
}
|
| 790 |
+
})
|
| 791 |
+
finally:
|
| 792 |
+
conn.close()
|
| 793 |
+
return jsonify({"logged_in": False})
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
@auth_bp.route("/verify-registration", methods=["POST"])
|
| 797 |
+
def auth_verify_registration():
|
| 798 |
+
data = request.json or {}
|
| 799 |
+
email = data.get("email", "").strip().lower()
|
| 800 |
+
otp = data.get("otp", "").strip()
|
| 801 |
+
|
| 802 |
+
if not email or not otp:
|
| 803 |
+
return jsonify({"error": "Vui lòng nhập đầy đủ email và mã OTP."}), 400
|
| 804 |
+
|
| 805 |
+
conn = get_user_db_conn()
|
| 806 |
+
user_row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
| 807 |
+
if not user_row:
|
| 808 |
+
conn.close()
|
| 809 |
+
return jsonify({"error": "Email không tồn tại."}), 400
|
| 810 |
+
user = dict(user_row)
|
| 811 |
+
|
| 812 |
+
if user.get("email_verified", 0) == 1:
|
| 813 |
+
conn.close()
|
| 814 |
+
return jsonify({"message": "Tài khoản đã được xác minh trước đó."})
|
| 815 |
+
|
| 816 |
+
now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
| 817 |
+
token_entry = conn.execute(
|
| 818 |
+
"SELECT * FROM password_reset_tokens WHERE user_id = ? AND token = ? AND used = 0 AND expires_at > ?",
|
| 819 |
+
(user["id"], otp, now_str)
|
| 820 |
+
).fetchone()
|
| 821 |
+
|
| 822 |
+
if not token_entry:
|
| 823 |
+
conn.close()
|
| 824 |
+
return jsonify({"error": "Mã OTP không đúng hoặc đã hết hạn."}), 400
|
| 825 |
+
|
| 826 |
+
# Mark OTP as used and set email_verified = 1
|
| 827 |
+
conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE id = ?", (token_entry["id"],))
|
| 828 |
+
conn.execute("UPDATE users SET email_verified = 1 WHERE id = ?", (user["id"],))
|
| 829 |
+
conn.commit()
|
| 830 |
+
conn.close()
|
| 831 |
+
|
| 832 |
+
return jsonify({"message": "Xác minh tài khoản thành công! Bây giờ bạn đã có thể đăng nhập."})
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
@auth_bp.route("/resend-verification", methods=["POST"])
|
| 836 |
+
def auth_resend_verification():
|
| 837 |
+
ip = get_client_ip()
|
| 838 |
+
if check_rate_limit("otp", ip):
|
| 839 |
+
return jsonify({"error": "Bạn đã yêu cầu OTP quá nhiều lần. Vui lòng thử lại sau 1 phút."}), 429
|
| 840 |
+
|
| 841 |
+
data = request.json or {}
|
| 842 |
+
email = data.get("email", "").strip().lower()
|
| 843 |
+
|
| 844 |
+
if not email:
|
| 845 |
+
return jsonify({"error": "Vui lòng nhập email."}), 400
|
| 846 |
+
|
| 847 |
+
conn = get_user_db_conn()
|
| 848 |
+
user_row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
| 849 |
+
if not user_row:
|
| 850 |
+
conn.close()
|
| 851 |
+
return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
|
| 852 |
+
user = dict(user_row)
|
| 853 |
+
|
| 854 |
+
if user.get("email_verified", 0) == 1:
|
| 855 |
+
conn.close()
|
| 856 |
+
return jsonify({"error": "Tài khoản này đã được xác minh trước đó."}), 400
|
| 857 |
+
|
| 858 |
+
from datetime import timedelta
|
| 859 |
+
otp_code = f"{secrets.randbelow(900000) + 100000}"
|
| 860 |
+
expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
|
| 861 |
+
|
| 862 |
+
conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user["id"],))
|
| 863 |
+
conn.execute(
|
| 864 |
+
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
|
| 865 |
+
(user["id"], otp_code, expires_at)
|
| 866 |
+
)
|
| 867 |
+
conn.commit()
|
| 868 |
+
conn.close()
|
| 869 |
+
|
| 870 |
+
otp_html = f"""
|
| 871 |
+
<h3>Xác minh tài khoản Novel Translator VIP của bạn</h3>
|
| 872 |
+
<p>Chào bạn,</p>
|
| 873 |
+
<p>Bạn đã yêu cầu gửi lại mã xác minh cho tài khoản <strong>{user["username"]}</strong>.</p>
|
| 874 |
+
<p>Mã OTP mới của bạn là: <strong style="font-size: 1.5rem; color: #4f46e5; letter-spacing: 2px;">{otp_code}</strong></p>
|
| 875 |
+
<p>Mã OTP này có hiệu lực trong 10 phút.</p>
|
| 876 |
+
<p>Trân trọng,<br>Đội ngũ hỗ trợ Ly Vu Ha</p>
|
| 877 |
+
"""
|
| 878 |
+
send_email_async(email, "Mã xác minh tài khoản Novel Translator VIP", otp_html)
|
| 879 |
+
return jsonify({"message": "Mã xác minh mới đã được gửi về email của bạn."})
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
@auth_bp.route("/change-password", methods=["POST"])
|
| 883 |
+
@jwt_required
|
| 884 |
+
def auth_change_password():
|
| 885 |
+
user = request._jwt_user
|
| 886 |
+
data = request.json or {}
|
| 887 |
+
old_password = data.get("old_password", "")
|
| 888 |
+
new_password = data.get("new_password", "")
|
| 889 |
+
|
| 890 |
+
if not new_password or len(new_password) < 4:
|
| 891 |
+
return jsonify({"error": "Mật khẩu mới phải từ 4 ký tự trở lên."}), 400
|
| 892 |
+
|
| 893 |
+
conn = get_user_db_conn()
|
| 894 |
+
user_record = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
|
| 895 |
+
|
| 896 |
+
# Enforce old password check unless it's a first-time Google login requiring password change
|
| 897 |
+
is_google_first_time = (user_record.get("require_password_change", 0) == 1)
|
| 898 |
+
|
| 899 |
+
if not is_google_first_time:
|
| 900 |
+
if not old_password or not verify_password(old_password, user_record["password_hash"]):
|
| 901 |
+
conn.close()
|
| 902 |
+
return jsonify({"error": "Mật khẩu cũ không chính xác."}), 400
|
| 903 |
+
|
| 904 |
+
pw_hash = hash_password(new_password)
|
| 905 |
+
conn.execute(
|
| 906 |
+
"UPDATE users SET password_hash = ?, require_password_change = 0 WHERE id = ?",
|
| 907 |
+
(pw_hash, user["id"])
|
| 908 |
+
)
|
| 909 |
+
conn.commit()
|
| 910 |
+
conn.close()
|
| 911 |
+
|
| 912 |
+
return jsonify({"message": "Đổi mật khẩu thành công!"})
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
@auth_bp.route("/update-profile", methods=["POST"])
|
| 916 |
+
@jwt_required
|
| 917 |
+
def auth_update_profile():
|
| 918 |
+
user = request._jwt_user
|
| 919 |
+
data = request.json or {}
|
| 920 |
+
|
| 921 |
+
display_name = data.get("display_name", "")
|
| 922 |
+
birthday = data.get("birthday", "")
|
| 923 |
+
gender = data.get("gender", "")
|
| 924 |
+
bio = data.get("bio", "")
|
| 925 |
+
avatar = data.get("avatar", "")
|
| 926 |
+
avatar_frame = data.get("avatar_frame", "default")
|
| 927 |
+
phone = data.get("phone", "")
|
| 928 |
+
two_factor = data.get("two_factor", 0)
|
| 929 |
+
|
| 930 |
+
conn = get_user_db_conn()
|
| 931 |
+
try:
|
| 932 |
+
conn.execute(
|
| 933 |
+
"""UPDATE users SET
|
| 934 |
+
display_name = ?,
|
| 935 |
+
birthday = ?,
|
| 936 |
+
gender = ?,
|
| 937 |
+
bio = ?,
|
| 938 |
+
avatar = ?,
|
| 939 |
+
avatar_frame = ?,
|
| 940 |
+
phone = ?,
|
| 941 |
+
two_factor = ?
|
| 942 |
+
WHERE id = ?""",
|
| 943 |
+
(display_name, birthday, gender, bio, avatar, avatar_frame, phone, two_factor, user["id"])
|
| 944 |
+
)
|
| 945 |
+
conn.commit()
|
| 946 |
+
|
| 947 |
+
# Clear RAM cache
|
| 948 |
+
if user["id"] in _auth_me_cache:
|
| 949 |
+
del _auth_me_cache[user["id"]]
|
| 950 |
+
|
| 951 |
+
return jsonify({"success": True, "message": "Cập nhật hồ sơ thành công!"})
|
| 952 |
+
except Exception as e:
|
| 953 |
+
logger.error(f"Database error during profile update: {e}")
|
| 954 |
+
conn.close()
|
| 955 |
+
return jsonify({"error": "Lỗi cơ sở dữ liệu. Vui lòng thử lại sau."}), 500
|
| 956 |
+
|
| 957 |
+
conn.close()
|
| 958 |
+
return jsonify({"success": True, "message": "Cập nhật hồ sơ thành công!"})
|
| 959 |
+
|
| 960 |
+
|
| 961 |
+
@auth_bp.route("/sessions", methods=["GET"])
|
| 962 |
+
@jwt_required
|
| 963 |
+
def get_login_sessions():
|
| 964 |
+
user = get_current_user()
|
| 965 |
+
conn = get_user_db_conn()
|
| 966 |
+
try:
|
| 967 |
+
rows = conn.execute(
|
| 968 |
+
"""SELECT id, ip_address, os, browser, device_type, login_time, last_active, status, token
|
| 969 |
+
FROM login_history
|
| 970 |
+
WHERE user_id = ?
|
| 971 |
+
ORDER BY login_time DESC LIMIT 50""",
|
| 972 |
+
(user["id"],)
|
| 973 |
+
).fetchall()
|
| 974 |
+
|
| 975 |
+
sessions_list = [dict(row) for row in rows]
|
| 976 |
+
for s in sessions_list:
|
| 977 |
+
if hasattr(s["login_time"], "isoformat"):
|
| 978 |
+
s["login_time"] = s["login_time"].isoformat()
|
| 979 |
+
if hasattr(s["last_active"], "isoformat"):
|
| 980 |
+
s["last_active"] = s["last_active"].isoformat()
|
| 981 |
+
|
| 982 |
+
return jsonify({"sessions": sessions_list, "success": True})
|
| 983 |
+
except Exception as e:
|
| 984 |
+
logger.error(f"Failed to fetch login sessions: {e}")
|
| 985 |
+
return jsonify({"error": "Failed to fetch login sessions", "success": False}), 500
|
| 986 |
+
finally:
|
| 987 |
+
conn.close()
|
| 988 |
+
|
| 989 |
+
|
| 990 |
+
@auth_bp.route("/sessions/revoke", methods=["POST"])
|
| 991 |
+
@jwt_required
|
| 992 |
+
def revoke_login_session():
|
| 993 |
+
user = get_current_user()
|
| 994 |
+
data = request.json or {}
|
| 995 |
+
session_id = data.get("session_id")
|
| 996 |
+
if not session_id:
|
| 997 |
+
return jsonify({"error": "Missing session_id", "success": False}), 400
|
| 998 |
+
|
| 999 |
+
conn = get_user_db_conn()
|
| 1000 |
+
try:
|
| 1001 |
+
# Get the token of the session to revoke the refresh token too!
|
| 1002 |
+
row = conn.execute(
|
| 1003 |
+
"SELECT token FROM login_history WHERE id = ? AND user_id = ?",
|
| 1004 |
+
(session_id, user["id"])
|
| 1005 |
+
).fetchone()
|
| 1006 |
+
if not row:
|
| 1007 |
+
return jsonify({"error": "Session not found", "success": False}), 404
|
| 1008 |
+
|
| 1009 |
+
token = row["token"]
|
| 1010 |
+
# Revoke both in login_history and refresh_tokens
|
| 1011 |
+
conn.execute("UPDATE login_history SET status = 'logged_out' WHERE id = ?", (session_id,))
|
| 1012 |
+
if token:
|
| 1013 |
+
conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (token,))
|
| 1014 |
+
conn.commit()
|
| 1015 |
+
return jsonify({"message": "Đã đăng xuất thiết bị thành công", "success": True})
|
| 1016 |
+
except Exception as e:
|
| 1017 |
+
logger.error(f"Failed to revoke login session: {e}")
|
| 1018 |
+
return jsonify({"error": "Failed to revoke session", "success": False}), 500
|
| 1019 |
+
finally:
|
| 1020 |
+
conn.close()
|
backend/api/books.py
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import unicodedata
|
| 4 |
+
from flask import Blueprint, request, jsonify, session
|
| 5 |
+
from backend.config import Config
|
| 6 |
+
from backend.core.decorators import get_current_user
|
| 7 |
+
from backend.core.security import verify_access_token, encrypt_asymmetric_hybrid, decrypt_asymmetric_hybrid
|
| 8 |
+
from backend.core.rate_limit import check_rate_limit, check_ip_rate_limit, get_client_ip
|
| 9 |
+
from backend.database.db_manager import get_db, get_mode_connection, get_user_db_conn
|
| 10 |
+
from backend.services.book_service import search_books, clean_vietnamese_query
|
| 11 |
+
from backend.services.translation import get_engine
|
| 12 |
+
|
| 13 |
+
books_bp = Blueprint("books", __name__, url_prefix="/api")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def is_vip_request():
|
| 17 |
+
"""Check if current request has VIP privileges."""
|
| 18 |
+
user = get_current_user()
|
| 19 |
+
if user and user.get("vip_status", 0) == 1:
|
| 20 |
+
return True
|
| 21 |
+
vip_code = request.headers.get("X-VIP-Code", "")
|
| 22 |
+
if vip_code in Config.VALID_VIP_CODES:
|
| 23 |
+
return True
|
| 24 |
+
return False
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@books_bp.route("/books", methods=["GET"])
|
| 28 |
+
def api_books():
|
| 29 |
+
ip = get_client_ip()
|
| 30 |
+
if not check_ip_rate_limit(ip, max_requests=45, period=60):
|
| 31 |
+
return jsonify({"error": "Too many requests. Please slow down."}), 429
|
| 32 |
+
|
| 33 |
+
q = request.args.get("q", "").strip()
|
| 34 |
+
category = request.args.get("category", "").strip()
|
| 35 |
+
source = request.args.get("source", "").strip()
|
| 36 |
+
dup = request.args.get("dup", "").strip()
|
| 37 |
+
sort = request.args.get("sort", "site_count DESC").strip()
|
| 38 |
+
search_field = request.args.get("field", "all").strip()
|
| 39 |
+
min_chapters = request.args.get("min_chapters", "").strip()
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
page = max(1, int(request.args.get("page", 1)))
|
| 43 |
+
per_page = max(1, min(50, int(request.args.get("per_page", 20))))
|
| 44 |
+
limit_k = max(1, min(1000, int(request.args.get("limit_k", 100))))
|
| 45 |
+
except ValueError:
|
| 46 |
+
page, per_page, limit_k = 1, 20, 100
|
| 47 |
+
|
| 48 |
+
result, status_code = search_books(q, category, source, dup, sort, search_field, min_chapters, page, per_page, limit_k)
|
| 49 |
+
if status_code != 200:
|
| 50 |
+
return jsonify(result), status_code
|
| 51 |
+
return jsonify(result)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@books_bp.route("/stats", methods=["GET"])
|
| 55 |
+
def api_stats():
|
| 56 |
+
from backend.database.db_manager import cached_stats
|
| 57 |
+
import backend.database.db_manager as db_mod
|
| 58 |
+
if db_mod.cached_stats is None:
|
| 59 |
+
conn = get_db()
|
| 60 |
+
total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
|
| 61 |
+
cats = conn.execute(
|
| 62 |
+
"SELECT categories FROM books WHERE categories IS NOT NULL AND categories != ''"
|
| 63 |
+
).fetchall()
|
| 64 |
+
cat_counts = {}
|
| 65 |
+
for row in cats:
|
| 66 |
+
for c in row[0].split(","):
|
| 67 |
+
c = c.strip()
|
| 68 |
+
if c:
|
| 69 |
+
cat_counts[c] = cat_counts.get(c, 0) + 1
|
| 70 |
+
top_categories = sorted(cat_counts.items(), key=lambda x: x[1], reverse=True)[:20]
|
| 71 |
+
db_mod.cached_stats = {
|
| 72 |
+
"total_books": total,
|
| 73 |
+
"top_categories": [{"name": k, "count": v} for k, v in top_categories],
|
| 74 |
+
}
|
| 75 |
+
return jsonify(db_mod.cached_stats)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@books_bp.route("/book/<int:book_id>", methods=["GET"])
|
| 79 |
+
def get_book_detail(book_id):
|
| 80 |
+
conn = get_db()
|
| 81 |
+
row = conn.execute("SELECT * FROM books WHERE id = ?", (book_id,)).fetchone()
|
| 82 |
+
if not row:
|
| 83 |
+
return jsonify({"error": "Không tìm thấy truyện."}), 404
|
| 84 |
+
book_dict = dict(row)
|
| 85 |
+
# Parse sources
|
| 86 |
+
parsed_sources = []
|
| 87 |
+
if book_dict.get("urls"):
|
| 88 |
+
parts = book_dict["urls"].split(" | ")
|
| 89 |
+
for p in parts:
|
| 90 |
+
idx = p.find(":")
|
| 91 |
+
if idx > 0:
|
| 92 |
+
parsed_sources.append({
|
| 93 |
+
"source": p[:idx].strip(),
|
| 94 |
+
"url": p[idx+1:].strip()
|
| 95 |
+
})
|
| 96 |
+
book_dict["parsed_sources"] = parsed_sources
|
| 97 |
+
|
| 98 |
+
# Lấy description_vietphrase từ advanced DB
|
| 99 |
+
description_vietphrase = None
|
| 100 |
+
root_dir = Config.ROOT_DIR
|
| 101 |
+
adv_db_path = os.path.join(root_dir, "merged_books_advanced.db")
|
| 102 |
+
if os.path.exists(adv_db_path):
|
| 103 |
+
try:
|
| 104 |
+
from backend.database.db_manager import get_mode_connection
|
| 105 |
+
adv_conn = get_mode_connection(adv_db_path)
|
| 106 |
+
if adv_conn:
|
| 107 |
+
r_adv = adv_conn.execute("SELECT description_vietphrase FROM books WHERE id = ?", (book_id,)).fetchone()
|
| 108 |
+
if r_adv and r_adv["description_vietphrase"]:
|
| 109 |
+
description_vietphrase = r_adv["description_vietphrase"]
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.warning(f"Failed to read from advanced DB for book {book_id}: {e}")
|
| 112 |
+
|
| 113 |
+
# Nếu không có trong advanced DB, dịch trực tiếp bằng translation engine
|
| 114 |
+
if not description_vietphrase and book_dict.get("description"):
|
| 115 |
+
try:
|
| 116 |
+
from backend.services.translation import get_engine
|
| 117 |
+
eng = get_engine()
|
| 118 |
+
description_vietphrase = eng.translate(book_dict["description"], multi_option=False)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.warning(f"Failed to translate book description live: {e}")
|
| 121 |
+
description_vietphrase = book_dict.get("description")
|
| 122 |
+
|
| 123 |
+
book_dict["description_vietphrase"] = description_vietphrase or ""
|
| 124 |
+
|
| 125 |
+
# Dịch mô tả sang tiếng Anh bằng Gemini
|
| 126 |
+
description_english = None
|
| 127 |
+
if book_dict.get("description"):
|
| 128 |
+
# Tạo fallback không dấu trước
|
| 129 |
+
fallback_en = ""
|
| 130 |
+
if book_dict["description_vietphrase"]:
|
| 131 |
+
try:
|
| 132 |
+
from backend.services.book_service import remove_vietnamese_accents
|
| 133 |
+
fallback_en = remove_vietnamese_accents(book_dict["description_vietphrase"])
|
| 134 |
+
except Exception:
|
| 135 |
+
pass
|
| 136 |
+
|
| 137 |
+
# Thử gọi Gemini để dịch chất lượng cao
|
| 138 |
+
key = os.environ.get("ADMIN_GEMINI_KEY", "") or os.environ.get("GOOGLE_API_KEY_BIGDATA", "") or os.environ.get("GEMINI_API_KEY_BIGDATA", "")
|
| 139 |
+
if key:
|
| 140 |
+
try:
|
| 141 |
+
import requests as py_requests
|
| 142 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={key}"
|
| 143 |
+
prompt_text = book_dict["description"][:800]
|
| 144 |
+
payload = {
|
| 145 |
+
"contents": [{
|
| 146 |
+
"role": "user",
|
| 147 |
+
"parts": [{"text": f"Translate the following novel synopsis to English. Only return the English translation, no other commentary:\n\n{prompt_text}"}]
|
| 148 |
+
}]
|
| 149 |
+
}
|
| 150 |
+
res = py_requests.post(url, json=payload, timeout=5)
|
| 151 |
+
if res.status_code == 200:
|
| 152 |
+
ans = res.json().get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
|
| 153 |
+
if ans.strip():
|
| 154 |
+
description_english = ans.strip()
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.warning(f"Failed to translate book description to English using Gemini: {e}")
|
| 157 |
+
|
| 158 |
+
if not description_english:
|
| 159 |
+
description_english = fallback_en or book_dict.get("description")
|
| 160 |
+
|
| 161 |
+
book_dict["description_english"] = description_english or ""
|
| 162 |
+
|
| 163 |
+
# Dịch thể loại sang tiếng Việt và tiếng Anh, cùng chuẩn hóa tên tác giả tiếng Anh
|
| 164 |
+
try:
|
| 165 |
+
from backend.services.book_service import translate_categories, translate_categories_to_english, remove_vietnamese_accents
|
| 166 |
+
book_dict["categories_vietphrase"] = translate_categories(book_dict.get("categories", ""))
|
| 167 |
+
book_dict["categories_english"] = translate_categories_to_english(book_dict.get("categories", ""))
|
| 168 |
+
|
| 169 |
+
author_hv = book_dict.get("author_hanviet", "")
|
| 170 |
+
if author_hv:
|
| 171 |
+
book_dict["author_english"] = remove_vietnamese_accents(author_hv)
|
| 172 |
+
else:
|
| 173 |
+
book_dict["author_english"] = book_dict.get("author", "")
|
| 174 |
+
except Exception:
|
| 175 |
+
book_dict["categories_vietphrase"] = book_dict.get("categories", "")
|
| 176 |
+
book_dict["categories_english"] = book_dict.get("categories", "")
|
| 177 |
+
book_dict["author_english"] = book_dict.get("author", "")
|
| 178 |
+
|
| 179 |
+
return jsonify(book_dict)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@books_bp.route("/book/<int:book_id>/translations")
|
| 183 |
+
def api_book_translations(book_id):
|
| 184 |
+
root_dir = Config.ROOT_DIR
|
| 185 |
+
adv_db_path = os.path.join(root_dir, "merged_books_advanced.db")
|
| 186 |
+
fast_db_path = os.path.join(root_dir, "merged_books_fast.db")
|
| 187 |
+
vp_db_path = os.path.join(root_dir, "merged_books_vietphrase.db")
|
| 188 |
+
hv_db_path = os.path.join(root_dir, "merged_books_hanviet.db")
|
| 189 |
+
|
| 190 |
+
res = {
|
| 191 |
+
"advanced": {"title": None, "desc": None},
|
| 192 |
+
"fast": {"title": None, "desc": None},
|
| 193 |
+
"vietphrase": {"title": None, "desc": None},
|
| 194 |
+
"hanviet": {"title": None, "desc": None}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
for mode_key, db_path in [("advanced", adv_db_path), ("fast", fast_db_path),
|
| 198 |
+
("vietphrase", vp_db_path), ("hanviet", hv_db_path)]:
|
| 199 |
+
conn = get_mode_connection(db_path)
|
| 200 |
+
if conn:
|
| 201 |
+
try:
|
| 202 |
+
r = conn.execute("SELECT title_vietphrase, description_vietphrase FROM books WHERE id = ?", (book_id,)).fetchone()
|
| 203 |
+
if r:
|
| 204 |
+
res[mode_key] = {"title": r["title_vietphrase"], "desc": r["description_vietphrase"]}
|
| 205 |
+
except Exception as e:
|
| 206 |
+
print(f"[ERROR] Loading {mode_key} book details: {e}")
|
| 207 |
+
|
| 208 |
+
return jsonify(res)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@books_bp.route("/bookshelf", methods=["GET"])
|
| 212 |
+
def get_bookshelf():
|
| 213 |
+
user = get_current_user()
|
| 214 |
+
user_id = user["id"] if user else None
|
| 215 |
+
if not user_id:
|
| 216 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 217 |
+
|
| 218 |
+
q = request.args.get("q", "").strip()
|
| 219 |
+
conn = get_user_db_conn()
|
| 220 |
+
rows = conn.execute("SELECT * FROM bookshelf WHERE user_id = ? ORDER BY added_at DESC", (user_id,)).fetchall()
|
| 221 |
+
conn.close()
|
| 222 |
+
|
| 223 |
+
books = [dict(r) for r in rows]
|
| 224 |
+
if q:
|
| 225 |
+
q_clean = clean_vietnamese_query(q)
|
| 226 |
+
books = [b for b in books if q_clean in clean_vietnamese_query(b.get("title", ""))
|
| 227 |
+
or q_clean in clean_vietnamese_query(b.get("author", ""))]
|
| 228 |
+
return jsonify(books)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@books_bp.route("/bookshelf/add", methods=["POST"])
|
| 232 |
+
def add_bookshelf():
|
| 233 |
+
user = get_current_user()
|
| 234 |
+
user_id = user["id"] if user else None
|
| 235 |
+
if not user_id:
|
| 236 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 237 |
+
|
| 238 |
+
data = request.json or {}
|
| 239 |
+
book_id = data.get("book_id")
|
| 240 |
+
if not book_id:
|
| 241 |
+
return jsonify({"error": "Thiếu ID truyện."}), 400
|
| 242 |
+
|
| 243 |
+
if not is_vip_request():
|
| 244 |
+
conn_check = get_user_db_conn()
|
| 245 |
+
try:
|
| 246 |
+
exists = conn_check.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id)).fetchone() is not None
|
| 247 |
+
if not exists:
|
| 248 |
+
count = conn_check.execute("SELECT COUNT(*) as cnt FROM bookshelf WHERE user_id = ?", (user_id,)).fetchone()["cnt"]
|
| 249 |
+
if count >= 5:
|
| 250 |
+
return jsonify({"error": "Tủ sách Standard tối đa 5 truyện. Nâng cấp VIP để lưu không giới hạn!"}), 403
|
| 251 |
+
finally:
|
| 252 |
+
conn_check.close()
|
| 253 |
+
|
| 254 |
+
main_conn = get_db()
|
| 255 |
+
book = main_conn.execute("SELECT title_vietphrase, author_hanviet, cover FROM books WHERE id = ?", (book_id,)).fetchone()
|
| 256 |
+
if not book:
|
| 257 |
+
return jsonify({"error": "Không tìm thấy truyện."}), 404
|
| 258 |
+
|
| 259 |
+
title = book["title_vietphrase"] or "Không rõ"
|
| 260 |
+
author = book["author_hanviet"] or "Không rõ"
|
| 261 |
+
cover = book["cover"] or ""
|
| 262 |
+
|
| 263 |
+
conn = get_user_db_conn()
|
| 264 |
+
try:
|
| 265 |
+
conn.execute(
|
| 266 |
+
"INSERT OR IGNORE INTO bookshelf (user_id, book_id, title, author, cover) VALUES (?, ?, ?, ?, ?)",
|
| 267 |
+
(user_id, book_id, title, author, cover)
|
| 268 |
+
)
|
| 269 |
+
conn.commit()
|
| 270 |
+
finally:
|
| 271 |
+
conn.close()
|
| 272 |
+
return jsonify({"success": True, "message": "Đã thêm vào tủ sách."})
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@books_bp.route("/bookshelf/remove", methods=["POST"])
|
| 276 |
+
def remove_bookshelf():
|
| 277 |
+
user = get_current_user()
|
| 278 |
+
user_id = user["id"] if user else None
|
| 279 |
+
if not user_id:
|
| 280 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 281 |
+
|
| 282 |
+
data = request.json or {}
|
| 283 |
+
book_id = data.get("book_id")
|
| 284 |
+
url = data.get("url")
|
| 285 |
+
if not book_id and not url:
|
| 286 |
+
return jsonify({"error": "Thiếu thông tin truyện để xóa."}), 400
|
| 287 |
+
|
| 288 |
+
conn = get_user_db_conn()
|
| 289 |
+
if book_id:
|
| 290 |
+
conn.execute("DELETE FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id))
|
| 291 |
+
else:
|
| 292 |
+
conn.execute("DELETE FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url))
|
| 293 |
+
conn.commit()
|
| 294 |
+
conn.close()
|
| 295 |
+
return jsonify({"success": True, "message": "Đã xóa khỏi tủ sách."})
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
@books_bp.route("/history", methods=["GET"])
|
| 299 |
+
def get_history():
|
| 300 |
+
user = get_current_user()
|
| 301 |
+
user_id = user["id"] if user else None
|
| 302 |
+
if not user_id:
|
| 303 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 304 |
+
|
| 305 |
+
q = request.args.get("q", "").strip()
|
| 306 |
+
conn = get_user_db_conn()
|
| 307 |
+
rows = conn.execute("SELECT * FROM reading_history WHERE user_id = ? ORDER BY timestamp DESC", (user_id,)).fetchall()
|
| 308 |
+
conn.close()
|
| 309 |
+
|
| 310 |
+
books = [dict(r) for r in rows]
|
| 311 |
+
if q:
|
| 312 |
+
q_clean = clean_vietnamese_query(q)
|
| 313 |
+
books = [b for b in books if q_clean in clean_vietnamese_query(b.get("title", ""))
|
| 314 |
+
or q_clean in clean_vietnamese_query(b.get("author", ""))]
|
| 315 |
+
|
| 316 |
+
struct_now = time.localtime()
|
| 317 |
+
today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
|
| 318 |
+
struct_y = time.localtime(time.time() - 86400)
|
| 319 |
+
yesterday_str = f"{struct_y.tm_year:04d}-{struct_y.tm_mon:02d}-{struct_y.tm_mday:02d}"
|
| 320 |
+
this_month_prefix = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}"
|
| 321 |
+
|
| 322 |
+
groups = {"Hôm nay": [], "Hôm qua": [], "Tháng này": [], "Trước đây": []}
|
| 323 |
+
for b in books:
|
| 324 |
+
r_date = b.get("read_date", "")
|
| 325 |
+
if r_date == today_str:
|
| 326 |
+
groups["Hôm nay"].append(b)
|
| 327 |
+
elif r_date == yesterday_str:
|
| 328 |
+
groups["Hôm qua"].append(b)
|
| 329 |
+
elif r_date.startswith(this_month_prefix):
|
| 330 |
+
groups["Tháng này"].append(b)
|
| 331 |
+
else:
|
| 332 |
+
groups["Trước đây"].append(b)
|
| 333 |
+
|
| 334 |
+
return jsonify([{"group_name": g, "books": groups[g]}
|
| 335 |
+
for g in ["Hôm nay", "Hôm qua", "Tháng này", "Trước đây"] if groups[g]])
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@books_bp.route("/history/add", methods=["POST"])
|
| 339 |
+
def add_history():
|
| 340 |
+
user = get_current_user()
|
| 341 |
+
user_id = user["id"] if user else None
|
| 342 |
+
if not user_id:
|
| 343 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 344 |
+
|
| 345 |
+
data = request.json or {}
|
| 346 |
+
book_id = data.get("book_id")
|
| 347 |
+
last_chapter = data.get("last_chapter", "Chương đầu")
|
| 348 |
+
if not book_id:
|
| 349 |
+
return jsonify({"error": "Thiếu ID truyện."}), 400
|
| 350 |
+
|
| 351 |
+
main_conn = get_db()
|
| 352 |
+
book = main_conn.execute("SELECT title_vietphrase, author_hanviet, cover FROM books WHERE id = ?", (book_id,)).fetchone()
|
| 353 |
+
if not book:
|
| 354 |
+
return jsonify({"error": "Không tìm thấy truyện."}), 404
|
| 355 |
+
|
| 356 |
+
title = book["title_vietphrase"] or "Không rõ"
|
| 357 |
+
author = book["author_hanviet"] or "Không r��"
|
| 358 |
+
cover = book["cover"] or ""
|
| 359 |
+
struct_now = time.localtime()
|
| 360 |
+
today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
|
| 361 |
+
|
| 362 |
+
conn = get_user_db_conn()
|
| 363 |
+
try:
|
| 364 |
+
conn.execute("""
|
| 365 |
+
INSERT OR REPLACE INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, timestamp)
|
| 366 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
| 367 |
+
""", (user_id, book_id, title, author, cover, last_chapter, today_str))
|
| 368 |
+
conn.commit()
|
| 369 |
+
finally:
|
| 370 |
+
conn.close()
|
| 371 |
+
return jsonify({"success": True})
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
@books_bp.route("/history/clear", methods=["POST"])
|
| 375 |
+
def clear_history():
|
| 376 |
+
user = get_current_user()
|
| 377 |
+
user_id = user["id"] if user else None
|
| 378 |
+
if not user_id:
|
| 379 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 380 |
+
conn = get_user_db_conn()
|
| 381 |
+
conn.execute("DELETE FROM reading_history WHERE user_id = ?", (user_id,))
|
| 382 |
+
conn.commit()
|
| 383 |
+
conn.close()
|
| 384 |
+
return jsonify({"success": True})
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
@books_bp.route("/history/remove", methods=["POST"])
|
| 388 |
+
def remove_history_item():
|
| 389 |
+
"""Xóa một mục khỏi lịch sử đọc (theo book_id hoặc url)."""
|
| 390 |
+
user = get_current_user()
|
| 391 |
+
user_id = user["id"] if user else None
|
| 392 |
+
if not user_id:
|
| 393 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 394 |
+
|
| 395 |
+
data = request.json or {}
|
| 396 |
+
book_id = data.get("book_id")
|
| 397 |
+
url = data.get("url")
|
| 398 |
+
|
| 399 |
+
if not book_id and not url:
|
| 400 |
+
return jsonify({"error": "Thiếu book_id hoặc url."}), 400
|
| 401 |
+
|
| 402 |
+
conn = get_user_db_conn()
|
| 403 |
+
try:
|
| 404 |
+
if book_id:
|
| 405 |
+
conn.execute(
|
| 406 |
+
"DELETE FROM reading_history WHERE user_id = ? AND book_id = ?",
|
| 407 |
+
(user_id, book_id)
|
| 408 |
+
)
|
| 409 |
+
else:
|
| 410 |
+
conn.execute(
|
| 411 |
+
"DELETE FROM reading_history WHERE user_id = ? AND url = ?",
|
| 412 |
+
(user_id, url)
|
| 413 |
+
)
|
| 414 |
+
conn.commit()
|
| 415 |
+
return jsonify({"success": True, "message": "Đã xóa khỏi lịch sử."})
|
| 416 |
+
except Exception as e:
|
| 417 |
+
logger.error(f"[History] Remove item failed: {e}")
|
| 418 |
+
return jsonify({"error": "Không thể xóa mục lịch sử."}), 500
|
| 419 |
+
finally:
|
| 420 |
+
conn.close()
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
@books_bp.route("/extension/sync", methods=["POST"])
|
| 426 |
+
def extension_sync():
|
| 427 |
+
user = get_current_user()
|
| 428 |
+
user_id = user["id"] if user else None
|
| 429 |
+
if not user_id:
|
| 430 |
+
return jsonify({"error": "Vui lòng đăng nhập trên website chính."}), 401
|
| 431 |
+
|
| 432 |
+
data = request.json or {}
|
| 433 |
+
title_zh = data.get("title", "").strip()
|
| 434 |
+
author_zh = data.get("author", "").strip()
|
| 435 |
+
cover = data.get("cover", "").strip()
|
| 436 |
+
last_chapter_zh = data.get("last_chapter", "").strip()
|
| 437 |
+
url = data.get("url", "").strip()
|
| 438 |
+
action = data.get("action", "history")
|
| 439 |
+
|
| 440 |
+
if not title_zh and not url:
|
| 441 |
+
return jsonify({"error": "Thiếu thông tin tên truyện hoặc liên kết đường dẫn."}), 400
|
| 442 |
+
|
| 443 |
+
book = None
|
| 444 |
+
book_id = None
|
| 445 |
+
title_vi = None
|
| 446 |
+
author_vi = None
|
| 447 |
+
eng = get_engine()
|
| 448 |
+
|
| 449 |
+
if not title_zh:
|
| 450 |
+
from urllib.parse import urlparse
|
| 451 |
+
try:
|
| 452 |
+
domain = urlparse(url).netloc
|
| 453 |
+
title_vi = f"Truyện ngoài ({domain})"
|
| 454 |
+
except Exception:
|
| 455 |
+
title_vi = "Truyện ngoài"
|
| 456 |
+
title_zh = title_vi
|
| 457 |
+
author_vi = "Không rõ"
|
| 458 |
+
else:
|
| 459 |
+
main_conn = get_db()
|
| 460 |
+
book = main_conn.execute(
|
| 461 |
+
"SELECT id, title_vietphrase, author_hanviet, cover FROM books WHERE title = ? LIMIT 1",
|
| 462 |
+
(title_zh,)
|
| 463 |
+
).fetchone()
|
| 464 |
+
|
| 465 |
+
if book:
|
| 466 |
+
book_id = book["id"]
|
| 467 |
+
title_vi = book["title_vietphrase"]
|
| 468 |
+
author_vi = book["author_hanviet"]
|
| 469 |
+
if not cover and book["cover"]:
|
| 470 |
+
cover = book["cover"]
|
| 471 |
+
else:
|
| 472 |
+
if not title_vi:
|
| 473 |
+
try:
|
| 474 |
+
title_vi = eng.translate(title_zh, multi_option=False)
|
| 475 |
+
except Exception:
|
| 476 |
+
title_vi = title_zh
|
| 477 |
+
if not author_vi:
|
| 478 |
+
if author_zh:
|
| 479 |
+
try:
|
| 480 |
+
author_vi = eng.translate(author_zh, multi_option=False)
|
| 481 |
+
except Exception:
|
| 482 |
+
author_vi = author_zh
|
| 483 |
+
else:
|
| 484 |
+
author_vi = "Không rõ"
|
| 485 |
+
|
| 486 |
+
last_chapter_vi = "Chương đầu"
|
| 487 |
+
if last_chapter_zh:
|
| 488 |
+
try:
|
| 489 |
+
last_chapter_vi = eng.translate(last_chapter_zh, multi_option=False)
|
| 490 |
+
except Exception:
|
| 491 |
+
last_chapter_vi = last_chapter_zh
|
| 492 |
+
|
| 493 |
+
title_vi = title_vi or title_zh
|
| 494 |
+
author_vi = author_vi or "Không rõ"
|
| 495 |
+
struct_now = time.localtime()
|
| 496 |
+
today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
|
| 497 |
+
|
| 498 |
+
conn = get_user_db_conn()
|
| 499 |
+
try:
|
| 500 |
+
if action == "bookshelf":
|
| 501 |
+
if not is_vip_request():
|
| 502 |
+
exists = (
|
| 503 |
+
conn.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id)).fetchone()
|
| 504 |
+
if book_id
|
| 505 |
+
else conn.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
|
| 506 |
+
) is not None
|
| 507 |
+
if not exists:
|
| 508 |
+
count = conn.execute("SELECT COUNT(*) as cnt FROM bookshelf WHERE user_id = ?", (user_id,)).fetchone()["cnt"]
|
| 509 |
+
if count >= 5:
|
| 510 |
+
return jsonify({"error": "Tủ sách Standard tối đa 5 truyện. Nâng cấp VIP!"}), 403
|
| 511 |
+
|
| 512 |
+
if book_id:
|
| 513 |
+
conn.execute("INSERT OR REPLACE INTO bookshelf (user_id, book_id, title, author, cover, url) VALUES (?, ?, ?, ?, ?, ?)",
|
| 514 |
+
(user_id, book_id, title_vi, author_vi, cover, url))
|
| 515 |
+
else:
|
| 516 |
+
existing = conn.execute("SELECT id FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
|
| 517 |
+
if existing:
|
| 518 |
+
conn.execute("UPDATE bookshelf SET title = ?, author = ?, cover = ? WHERE id = ?",
|
| 519 |
+
(title_vi, author_vi, cover, existing["id"]))
|
| 520 |
+
else:
|
| 521 |
+
conn.execute("INSERT INTO bookshelf (user_id, book_id, title, author, cover, url) VALUES (?, NULL, ?, ?, ?, ?)",
|
| 522 |
+
(user_id, title_vi, author_vi, cover, url))
|
| 523 |
+
conn.commit()
|
| 524 |
+
msg = "Đã lưu vào Tủ sách!"
|
| 525 |
+
else:
|
| 526 |
+
if book_id:
|
| 527 |
+
conn.execute("INSERT OR REPLACE INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 528 |
+
(user_id, book_id, title_vi, author_vi, cover, last_chapter_vi, today_str, url))
|
| 529 |
+
else:
|
| 530 |
+
existing = conn.execute("SELECT id FROM reading_history WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
|
| 531 |
+
if existing:
|
| 532 |
+
conn.execute("UPDATE reading_history SET title = ?, author = ?, cover = ?, last_chapter = ?, read_date = ?, timestamp = CURRENT_TIMESTAMP WHERE id = ?",
|
| 533 |
+
(title_vi, author_vi, cover, last_chapter_vi, today_str, existing["id"]))
|
| 534 |
+
else:
|
| 535 |
+
conn.execute("INSERT INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, url) VALUES (?, NULL, ?, ?, ?, ?, ?, ?)",
|
| 536 |
+
(user_id, title_vi, author_vi, cover, last_chapter_vi, today_str, url))
|
| 537 |
+
conn.commit()
|
| 538 |
+
msg = "Đã cập nhật Lịch sử!"
|
| 539 |
+
finally:
|
| 540 |
+
conn.close()
|
| 541 |
+
|
| 542 |
+
return jsonify({
|
| 543 |
+
"success": True, "message": msg,
|
| 544 |
+
"matched": book is not None, "book_id": book_id,
|
| 545 |
+
"title_vi": title_vi, "author_vi": author_vi, "last_chapter_vi": last_chapter_vi
|
| 546 |
+
})
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
@books_bp.route("/system/notifications", methods=["GET"])
|
| 550 |
+
def get_system_notifications():
|
| 551 |
+
user = get_current_user()
|
| 552 |
+
notifications = [
|
| 553 |
+
{
|
| 554 |
+
"id": "sys-1",
|
| 555 |
+
"type": "server",
|
| 556 |
+
"message": "Hệ thống máy chủ dịch thuật AI đã được nâng cấp lên v2.4, cải thiện 45% tốc độ phản hồi.",
|
| 557 |
+
"time": "Vừa xong"
|
| 558 |
+
},
|
| 559 |
+
{
|
| 560 |
+
"id": "sys-2",
|
| 561 |
+
"type": "promo",
|
| 562 |
+
"message": "Sự kiện đặc biệt: Tặng ngay 20% số dư khi thực hiện nạp ví VIP qua chuyển khoản VietQR trong tuần này!",
|
| 563 |
+
"time": "5 phút trước"
|
| 564 |
+
}
|
| 565 |
+
]
|
| 566 |
+
|
| 567 |
+
if user:
|
| 568 |
+
user_id = user["id"]
|
| 569 |
+
conn = get_user_db_conn()
|
| 570 |
+
try:
|
| 571 |
+
rows = conn.execute("SELECT title FROM bookshelf WHERE user_id = ? ORDER BY added_at DESC LIMIT 3", (user_id,)).fetchall()
|
| 572 |
+
for i, row in enumerate(rows):
|
| 573 |
+
notifications.append({
|
| 574 |
+
"id": f"shelf-{i}",
|
| 575 |
+
"type": "update",
|
| 576 |
+
"message": f"Truyện '{row['title']}' trong Tủ sách của bạn vừa cập nhật thêm chương dịch mới.",
|
| 577 |
+
"time": "10 phút trước"
|
| 578 |
+
})
|
| 579 |
+
except Exception:
|
| 580 |
+
pass
|
| 581 |
+
finally:
|
| 582 |
+
conn.close()
|
| 583 |
+
|
| 584 |
+
notifications.append({
|
| 585 |
+
"id": "comment-1",
|
| 586 |
+
"type": "comment",
|
| 587 |
+
"message": "Độc giả Lê Hoàng Nam vừa bình luận truyện 'Trọng Sinh Chi Ma Giáo Giáo Chủ': 'Bản dịch AI đọc rất mượt mà...'",
|
| 588 |
+
"time": "20 phút trước"
|
| 589 |
+
})
|
| 590 |
+
notifications.append({
|
| 591 |
+
"id": "comment-2",
|
| 592 |
+
"type": "comment",
|
| 593 |
+
"message": "Thành viên VIP_SERVER vừa tối ưu hóa tốc độ load chương mới thành công.",
|
| 594 |
+
"time": "1 giờ trước"
|
| 595 |
+
})
|
| 596 |
+
return jsonify(notifications)
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
@books_bp.route("/author/<author_name>", methods=["GET"])
|
| 600 |
+
def get_author_details(author_name):
|
| 601 |
+
conn = get_db()
|
| 602 |
+
rows = conn.execute(
|
| 603 |
+
"SELECT * FROM books WHERE author = ? OR author_hanviet = ? ORDER BY chapters_max DESC",
|
| 604 |
+
(author_name, author_name)
|
| 605 |
+
).fetchall()
|
| 606 |
+
|
| 607 |
+
if not rows:
|
| 608 |
+
rows = conn.execute(
|
| 609 |
+
"SELECT * FROM books WHERE LOWER(author) = LOWER(?) OR LOWER(author_hanviet) = LOWER(?) ORDER BY chapters_max DESC",
|
| 610 |
+
(author_name, author_name)
|
| 611 |
+
).fetchall()
|
| 612 |
+
|
| 613 |
+
fallback_mode = False
|
| 614 |
+
if not rows:
|
| 615 |
+
# Fallback: if author has no books in database, fetch first 5 books to show as placeholder
|
| 616 |
+
rows = conn.execute("SELECT * FROM books LIMIT 5").fetchall()
|
| 617 |
+
fallback_mode = True
|
| 618 |
+
|
| 619 |
+
books = []
|
| 620 |
+
canonical_chinese = author_name if fallback_mode else (rows[0]["author"] or author_name)
|
| 621 |
+
canonical_hanviet = author_name if fallback_mode else (rows[0]["author_hanviet"] or author_name)
|
| 622 |
+
|
| 623 |
+
for row in rows:
|
| 624 |
+
book_dict = dict(row)
|
| 625 |
+
if fallback_mode:
|
| 626 |
+
book_dict["author"] = author_name
|
| 627 |
+
book_dict["author_hanviet"] = author_name
|
| 628 |
+
|
| 629 |
+
parsed_sources = []
|
| 630 |
+
raw_urls = book_dict.get("urls", "")
|
| 631 |
+
if raw_urls:
|
| 632 |
+
parts = raw_urls.split(" | ")
|
| 633 |
+
for part in parts:
|
| 634 |
+
if ":" in part:
|
| 635 |
+
subparts = part.split(":", 1)
|
| 636 |
+
src_name = subparts[0].strip()
|
| 637 |
+
src_url = subparts[1].strip()
|
| 638 |
+
# Keep raw scheme if complete, else handle
|
| 639 |
+
if src_url.startswith("//"):
|
| 640 |
+
src_url = "https:" + src_url
|
| 641 |
+
parsed_sources.append({
|
| 642 |
+
"source": src_name,
|
| 643 |
+
"url": src_url
|
| 644 |
+
})
|
| 645 |
+
book_dict["parsed_sources"] = parsed_sources
|
| 646 |
+
books.append(book_dict)
|
| 647 |
+
|
| 648 |
+
return jsonify({
|
| 649 |
+
"author_chinese": canonical_chinese,
|
| 650 |
+
"author_hanviet": canonical_hanviet,
|
| 651 |
+
"total_books": len(books),
|
| 652 |
+
"books": books
|
| 653 |
+
})
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
@books_bp.route("/books/<int:book_id>/comments", methods=["POST"])
|
| 657 |
+
def add_book_comment(book_id):
|
| 658 |
+
user = get_current_user()
|
| 659 |
+
data = request.json or {}
|
| 660 |
+
content = data.get("content", "").strip()
|
| 661 |
+
is_anonymous = int(data.get("is_anonymous", 0))
|
| 662 |
+
|
| 663 |
+
if not content:
|
| 664 |
+
return jsonify({"error": "Nội dung bình luận không được để trống"}), 400
|
| 665 |
+
|
| 666 |
+
user_id = user["id"] if user else None
|
| 667 |
+
|
| 668 |
+
# Mã hóa Hybrid RSA-AES bảo mật
|
| 669 |
+
enc_data = encrypt_asymmetric_hybrid(content)
|
| 670 |
+
|
| 671 |
+
conn = get_user_db_conn()
|
| 672 |
+
try:
|
| 673 |
+
conn.execute(
|
| 674 |
+
"""INSERT INTO book_comments
|
| 675 |
+
(book_id, user_id, is_anonymous, content_ciphertext, encrypted_aes_key, aes_nonce, aes_tag)
|
| 676 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
| 677 |
+
(book_id, user_id, is_anonymous,
|
| 678 |
+
enc_data["ciphertext"], enc_data["encrypted_key"], enc_data["nonce"], enc_data["tag"])
|
| 679 |
+
)
|
| 680 |
+
conn.commit()
|
| 681 |
+
return jsonify({"success": True, "message": "Gửi bình luận thành công!"})
|
| 682 |
+
except Exception as e:
|
| 683 |
+
return jsonify({"error": str(e)}), 500
|
| 684 |
+
finally:
|
| 685 |
+
conn.close()
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
@books_bp.route("/books/<int:book_id>/comments", methods=["GET"])
|
| 689 |
+
def get_book_comments(book_id):
|
| 690 |
+
conn = get_user_db_conn()
|
| 691 |
+
try:
|
| 692 |
+
rows = conn.execute(
|
| 693 |
+
"""SELECT c.id, c.user_id, c.is_anonymous, c.content_ciphertext, c.encrypted_aes_key,
|
| 694 |
+
c.aes_nonce, c.aes_tag, c.created_at, u.username, u.avatar
|
| 695 |
+
FROM book_comments c
|
| 696 |
+
LEFT JOIN users u ON c.user_id = u.id
|
| 697 |
+
WHERE c.book_id = ?
|
| 698 |
+
ORDER BY c.created_at DESC""",
|
| 699 |
+
(book_id,)
|
| 700 |
+
).fetchall()
|
| 701 |
+
|
| 702 |
+
comments = []
|
| 703 |
+
for r in rows:
|
| 704 |
+
# Giải mã Hybrid RSA-AES
|
| 705 |
+
decrypted_content = decrypt_asymmetric_hybrid(
|
| 706 |
+
r["content_ciphertext"],
|
| 707 |
+
r["encrypted_aes_key"],
|
| 708 |
+
r["aes_nonce"],
|
| 709 |
+
r["aes_tag"]
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
comment = {
|
| 713 |
+
"id": r["id"],
|
| 714 |
+
"created_at": r["created_at"].isoformat() if hasattr(r["created_at"], "isoformat") else r["created_at"],
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
if r["is_anonymous"] == 1:
|
| 718 |
+
comment["username"] = "Đạo hữu ẩn danh"
|
| 719 |
+
comment["avatar"] = None
|
| 720 |
+
comment["user_id"] = None
|
| 721 |
+
else:
|
| 722 |
+
comment["username"] = r["username"] or "Khách viếng thăm"
|
| 723 |
+
comment["avatar"] = r["avatar"]
|
| 724 |
+
comment["user_id"] = r["user_id"]
|
| 725 |
+
|
| 726 |
+
comment["content"] = decrypted_content
|
| 727 |
+
comments.append(comment)
|
| 728 |
+
|
| 729 |
+
return jsonify({"comments": comments})
|
| 730 |
+
except Exception as e:
|
| 731 |
+
return jsonify({"error": str(e)}), 500
|
| 732 |
+
finally:
|
| 733 |
+
conn.close()
|
| 734 |
+
|
backend/api/developer.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
import secrets
|
| 5 |
+
import base64
|
| 6 |
+
import requests as py_requests
|
| 7 |
+
from flask import Blueprint, request, jsonify, send_file
|
| 8 |
+
from backend.core.decorators import get_current_user, require_api_key_auth
|
| 9 |
+
from backend.database.db_manager import get_user_db_conn
|
| 10 |
+
from backend.services.translation import get_engine
|
| 11 |
+
|
| 12 |
+
developer_bp = Blueprint("developer", __name__)
|
| 13 |
+
|
| 14 |
+
ADMIN_GEMINI_KEY = os.environ.get("ADMIN_GEMINI_KEY", "")
|
| 15 |
+
|
| 16 |
+
def serialize_row(row):
|
| 17 |
+
if not row:
|
| 18 |
+
return {}
|
| 19 |
+
d = dict(row)
|
| 20 |
+
for k, v in d.items():
|
| 21 |
+
if hasattr(v, "isoformat"):
|
| 22 |
+
d[k] = v.isoformat()
|
| 23 |
+
return d
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def deduct_developer_balance(api_key, amount, model, tokens=0, status_code=200):
|
| 28 |
+
conn = get_user_db_conn()
|
| 29 |
+
try:
|
| 30 |
+
conn.execute(
|
| 31 |
+
"INSERT INTO api_usage (api_key, model, tokens, cost, status_code) VALUES (?, ?, ?, ?, ?)",
|
| 32 |
+
(api_key, model, tokens, amount, status_code)
|
| 33 |
+
)
|
| 34 |
+
conn.execute(
|
| 35 |
+
"UPDATE users SET api_balance = api_balance - ? WHERE id = (SELECT user_id FROM api_keys WHERE api_key = ?)",
|
| 36 |
+
(amount, api_key)
|
| 37 |
+
)
|
| 38 |
+
conn.commit()
|
| 39 |
+
except Exception as e:
|
| 40 |
+
print(f"[API GATEWAY ERROR] Failed to deduct balance: {e}")
|
| 41 |
+
finally:
|
| 42 |
+
conn.close()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@developer_bp.route("/api/developer/keys", methods=["GET"])
|
| 46 |
+
def api_dev_keys_list():
|
| 47 |
+
user = get_current_user()
|
| 48 |
+
print(f"[DEBUG api_dev_keys_list] Current user from auth: {user}")
|
| 49 |
+
if not user:
|
| 50 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 51 |
+
|
| 52 |
+
conn = get_user_db_conn()
|
| 53 |
+
keys = conn.execute(
|
| 54 |
+
"SELECT api_key, name, status, created_at, last_used_at FROM api_keys WHERE user_id = ? AND status = 'active' ORDER BY created_at DESC",
|
| 55 |
+
(user["id"],)
|
| 56 |
+
).fetchall()
|
| 57 |
+
user_row = conn.execute("SELECT api_balance FROM users WHERE id = ?", (user["id"],)).fetchone()
|
| 58 |
+
conn.close()
|
| 59 |
+
|
| 60 |
+
balance = user_row["api_balance"] if user_row and user_row["api_balance"] is not None else 0.0
|
| 61 |
+
print(f"[DEBUG api_dev_keys_list] Queried user_id: {user['id']}, found balance: {balance}, keys count: {len(keys)}")
|
| 62 |
+
return jsonify({"balance": balance, "keys": [serialize_row(k) for k in keys]})
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@developer_bp.route("/api/developer/keys/create", methods=["POST"])
|
| 66 |
+
def api_dev_keys_create():
|
| 67 |
+
user = get_current_user()
|
| 68 |
+
if not user:
|
| 69 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 70 |
+
|
| 71 |
+
data = request.json or {}
|
| 72 |
+
name = data.get("name", "Default Key").strip()[:50]
|
| 73 |
+
new_key = f"sk-tc-{secrets.token_hex(16)}"
|
| 74 |
+
|
| 75 |
+
conn = get_user_db_conn()
|
| 76 |
+
conn.execute(
|
| 77 |
+
"INSERT INTO api_keys (user_id, api_key, name, status) VALUES (?, ?, ?, 'active')",
|
| 78 |
+
(user["id"], new_key, name)
|
| 79 |
+
)
|
| 80 |
+
conn.commit()
|
| 81 |
+
conn.close()
|
| 82 |
+
|
| 83 |
+
return jsonify({"success": True, "api_key": new_key, "name": name})
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@developer_bp.route("/api/developer/keys/delete", methods=["POST"])
|
| 87 |
+
def api_dev_keys_delete():
|
| 88 |
+
user = get_current_user()
|
| 89 |
+
if not user:
|
| 90 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 91 |
+
|
| 92 |
+
data = request.json or {}
|
| 93 |
+
api_key = data.get("api_key", "").strip()
|
| 94 |
+
if not api_key:
|
| 95 |
+
return jsonify({"error": "Thiếu API Key cần xóa."}), 400
|
| 96 |
+
|
| 97 |
+
conn = get_user_db_conn()
|
| 98 |
+
conn.execute(
|
| 99 |
+
"UPDATE api_keys SET status = 'revoked' WHERE user_id = ? AND api_key = ?",
|
| 100 |
+
(user["id"], api_key)
|
| 101 |
+
)
|
| 102 |
+
conn.commit()
|
| 103 |
+
conn.close()
|
| 104 |
+
return jsonify({"success": True})
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@developer_bp.route("/api/developer/usage", methods=["GET"])
|
| 108 |
+
def api_dev_usage():
|
| 109 |
+
user = get_current_user()
|
| 110 |
+
if not user:
|
| 111 |
+
return jsonify({"error": "Vui lòng đăng nhập."}), 401
|
| 112 |
+
|
| 113 |
+
conn = get_user_db_conn()
|
| 114 |
+
keys_rows = conn.execute("SELECT api_key FROM api_keys WHERE user_id = ?", (user["id"],)).fetchall()
|
| 115 |
+
keys = [k["api_key"] for k in keys_rows]
|
| 116 |
+
|
| 117 |
+
if not keys:
|
| 118 |
+
conn.close()
|
| 119 |
+
return jsonify({"usage": []})
|
| 120 |
+
|
| 121 |
+
placeholders = ",".join(["?"] * len(keys))
|
| 122 |
+
usage = conn.execute(
|
| 123 |
+
f"SELECT * FROM api_usage WHERE api_key IN ({placeholders}) ORDER BY timestamp DESC LIMIT 100",
|
| 124 |
+
keys
|
| 125 |
+
).fetchall()
|
| 126 |
+
conn.close()
|
| 127 |
+
return jsonify({"usage": [serialize_row(u) for u in usage]})
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@developer_bp.route("/v1/chat/completions", methods=["POST"])
|
| 131 |
+
@require_api_key_auth
|
| 132 |
+
def openai_chat_completions():
|
| 133 |
+
api_key = request.api_key
|
| 134 |
+
data = request.json or {}
|
| 135 |
+
messages = data.get("messages", [])
|
| 136 |
+
model = data.get("model", "gemini-1.5-flash")
|
| 137 |
+
|
| 138 |
+
if not messages:
|
| 139 |
+
return jsonify({"error": "Missing messages array"}), 400
|
| 140 |
+
|
| 141 |
+
prompt_length = sum(len(m.get("content", "")) for m in messages)
|
| 142 |
+
cost = max(20.0, prompt_length * 0.02)
|
| 143 |
+
|
| 144 |
+
if request.api_balance < cost:
|
| 145 |
+
return jsonify({"error": f"Số dư không đủ. Chi phí ước tính: {cost:.2f}đ, Số dư: {request.api_balance:.2f}đ"}), 402
|
| 146 |
+
|
| 147 |
+
if not ADMIN_GEMINI_KEY:
|
| 148 |
+
return jsonify({"error": "Hệ thống chưa được Admin cấu hình khóa Gemini."}), 501
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
contents = []
|
| 152 |
+
system_instruction = ""
|
| 153 |
+
|
| 154 |
+
for msg in messages:
|
| 155 |
+
role = msg.get("role")
|
| 156 |
+
content = msg.get("content", "")
|
| 157 |
+
if role == "system":
|
| 158 |
+
system_instruction = content
|
| 159 |
+
else:
|
| 160 |
+
contents.append({
|
| 161 |
+
"role": "user" if role == "user" else "model",
|
| 162 |
+
"parts": [{"text": content}]
|
| 163 |
+
})
|
| 164 |
+
|
| 165 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={ADMIN_GEMINI_KEY}"
|
| 166 |
+
payload = {"contents": contents}
|
| 167 |
+
if system_instruction:
|
| 168 |
+
payload["systemInstruction"] = {"parts": [{"text": system_instruction}]}
|
| 169 |
+
|
| 170 |
+
res = py_requests.post(url, json=payload, timeout=30)
|
| 171 |
+
|
| 172 |
+
if res.status_code != 200:
|
| 173 |
+
deduct_developer_balance(api_key, 0.0, model, prompt_length, res.status_code)
|
| 174 |
+
return jsonify({"error": f"Google Gemini API error: {res.text}"}), res.status_code
|
| 175 |
+
|
| 176 |
+
gemini_data = res.json()
|
| 177 |
+
response_text = gemini_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
|
| 178 |
+
|
| 179 |
+
deduct_developer_balance(api_key, cost, model, prompt_length + len(response_text), 200)
|
| 180 |
+
|
| 181 |
+
res_id = f"chatcmpl-{uuid.uuid4().hex}"
|
| 182 |
+
return jsonify({
|
| 183 |
+
"id": res_id,
|
| 184 |
+
"object": "chat.completion",
|
| 185 |
+
"created": int(time.time()),
|
| 186 |
+
"model": model,
|
| 187 |
+
"choices": [{
|
| 188 |
+
"index": 0,
|
| 189 |
+
"message": {"role": "assistant", "content": response_text},
|
| 190 |
+
"finish_reason": "stop"
|
| 191 |
+
}],
|
| 192 |
+
"usage": {
|
| 193 |
+
"prompt_tokens": int(prompt_length / 4),
|
| 194 |
+
"completion_tokens": int(len(response_text) / 4),
|
| 195 |
+
"total_tokens": int((prompt_length + len(response_text)) / 4)
|
| 196 |
+
}
|
| 197 |
+
})
|
| 198 |
+
except Exception as e:
|
| 199 |
+
deduct_developer_balance(api_key, 0.0, model, prompt_length, 500)
|
| 200 |
+
return jsonify({"error": f"API Gateway Exception: {str(e)}"}), 500
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@developer_bp.route("/api/v1/translate", methods=["POST"])
|
| 204 |
+
@require_api_key_auth
|
| 205 |
+
def api_v1_translate():
|
| 206 |
+
api_key = request.api_key
|
| 207 |
+
data = request.json or {}
|
| 208 |
+
texts = data.get("texts", [])
|
| 209 |
+
mode = data.get("mode", "fast")
|
| 210 |
+
|
| 211 |
+
if not texts:
|
| 212 |
+
return jsonify({"error": "Missing texts array"}), 400
|
| 213 |
+
|
| 214 |
+
total_chars = sum(len(t) for t in texts)
|
| 215 |
+
cost = total_chars * 0.01
|
| 216 |
+
|
| 217 |
+
if request.api_balance < cost:
|
| 218 |
+
return jsonify({"error": f"Số dư không đủ. Chi phí ước tính: {cost:.2f}đ, Số dư: {request.api_balance:.2f}đ"}), 402
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
eng = get_engine()
|
| 222 |
+
translations = []
|
| 223 |
+
for text in texts:
|
| 224 |
+
if not text.strip():
|
| 225 |
+
translations.append(text)
|
| 226 |
+
else:
|
| 227 |
+
translations.append(eng.translate(text, multi_option=False, mode=mode))
|
| 228 |
+
|
| 229 |
+
deduct_developer_balance(api_key, cost, f"vietphrase-{mode}", total_chars, 200)
|
| 230 |
+
return jsonify({"translations": translations, "characters": total_chars, "cost": cost})
|
| 231 |
+
except Exception as e:
|
| 232 |
+
deduct_developer_balance(api_key, 0.0, f"vietphrase-{mode}", total_chars, 500)
|
| 233 |
+
return jsonify({"error": f"Translation Error: {str(e)}"}), 500
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@developer_bp.route("/v1/audio/speech", methods=["POST"])
|
| 237 |
+
@require_api_key_auth
|
| 238 |
+
def openai_audio_speech():
|
| 239 |
+
api_key = request.api_key
|
| 240 |
+
data = request.json or {}
|
| 241 |
+
input_text = data.get("input", "").strip()
|
| 242 |
+
|
| 243 |
+
if not input_text:
|
| 244 |
+
return jsonify({"error": "Missing 'input' text"}), 400
|
| 245 |
+
|
| 246 |
+
cost = len(input_text) * 0.1
|
| 247 |
+
if request.api_balance < cost:
|
| 248 |
+
return jsonify({"error": f"Số dư không đủ. Chi phí: {cost:.2f}đ"}), 402
|
| 249 |
+
|
| 250 |
+
# 1. Check if Local TTS Server is configured (host machine)
|
| 251 |
+
local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
|
| 252 |
+
if local_tts_url:
|
| 253 |
+
try:
|
| 254 |
+
payload = {
|
| 255 |
+
"input": input_text,
|
| 256 |
+
"speed": float(data.get("speed", 1.0)),
|
| 257 |
+
"voice": data.get("voice", "the_gioi_hoan_my"),
|
| 258 |
+
"ref_audio": data.get("ref_audio"),
|
| 259 |
+
"ref_text": data.get("ref_text")
|
| 260 |
+
}
|
| 261 |
+
if "bypass_cache" in data:
|
| 262 |
+
payload["bypass_cache"] = data["bypass_cache"]
|
| 263 |
+
if "temperature" in data:
|
| 264 |
+
payload["temperature"] = data["temperature"]
|
| 265 |
+
if "steps" in data:
|
| 266 |
+
payload["steps"] = data["steps"]
|
| 267 |
+
print(f"[Local TTS Proxy] Forwarding request to {local_tts_url} with voice={payload['voice']}, speed={payload['speed']}, bypass_cache={payload.get('bypass_cache')}")
|
| 268 |
+
r = py_requests.post(local_tts_url, json=payload, timeout=(2.0, 60.0))
|
| 269 |
+
if r.status_code == 200:
|
| 270 |
+
import io
|
| 271 |
+
deduct_developer_balance(api_key, cost, f"local-tts-{payload['voice']}", len(input_text), 200)
|
| 272 |
+
return send_file(
|
| 273 |
+
io.BytesIO(r.content),
|
| 274 |
+
mimetype="audio/wav",
|
| 275 |
+
as_attachment=False
|
| 276 |
+
)
|
| 277 |
+
print(f"[Local TTS Proxy Error]: {r.status_code} - {r.text}")
|
| 278 |
+
except Exception as e:
|
| 279 |
+
print(f"[Local TTS Proxy Exception]: {e}")
|
| 280 |
+
|
| 281 |
+
# 2. RunPod Fallback
|
| 282 |
+
runpod_api_key = os.environ.get("RUNPOD_API_TOKEN", os.environ.get("RUNPOD_API_KEY", ""))
|
| 283 |
+
|
| 284 |
+
runpod_endpoint_id = os.environ.get("RUNPOD_TTS_ENDPOINT_ID", "")
|
| 285 |
+
|
| 286 |
+
if runpod_api_key and runpod_endpoint_id:
|
| 287 |
+
try:
|
| 288 |
+
url = f"https://api.runpod.ai/v1/{runpod_endpoint_id}/runsync"
|
| 289 |
+
headers = {"Authorization": f"Bearer {runpod_api_key}", "Content-Type": "application/json"}
|
| 290 |
+
payload = {
|
| 291 |
+
"input": {
|
| 292 |
+
"text": input_text,
|
| 293 |
+
"speed": 1.0,
|
| 294 |
+
"voice": data.get("voice", "the_gioi_hoan_my")
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
r = py_requests.post(url, json=payload, headers=headers, timeout=45)
|
| 298 |
+
res = r.json()
|
| 299 |
+
|
| 300 |
+
if r.status_code == 200 and res.get("status") == "COMPLETED":
|
| 301 |
+
audio_b64 = res.get("output", {}).get("audio_base64", "")
|
| 302 |
+
if audio_b64:
|
| 303 |
+
import io
|
| 304 |
+
audio_data = base64.b64decode(audio_b64)
|
| 305 |
+
deduct_developer_balance(api_key, cost, "runpod-matcha-tts", len(input_text), 200)
|
| 306 |
+
return send_file(
|
| 307 |
+
io.BytesIO(audio_data),
|
| 308 |
+
mimetype="audio/wav" if res.get("output", {}).get("format") == "wav" else "audio/mpeg",
|
| 309 |
+
as_attachment=False
|
| 310 |
+
)
|
| 311 |
+
print(f"[RunPod TTS Error]: {res}")
|
| 312 |
+
except Exception as e:
|
| 313 |
+
print(f"[RunPod TTS exception]: {e}")
|
| 314 |
+
|
| 315 |
+
# Fallback: Generate a tiny 1-second silent WAV for testing/sandbox purposes
|
| 316 |
+
try:
|
| 317 |
+
import struct
|
| 318 |
+
import io
|
| 319 |
+
num_samples = 8000
|
| 320 |
+
# 8-bit PCM silence is represented by 128
|
| 321 |
+
data_bytes = bytearray([128] * num_samples)
|
| 322 |
+
header = struct.pack(
|
| 323 |
+
'<4sI4s4sIHHIIHH4sI',
|
| 324 |
+
b'RIFF',
|
| 325 |
+
36 + num_samples,
|
| 326 |
+
b'WAVE',
|
| 327 |
+
b'fmt ',
|
| 328 |
+
16,
|
| 329 |
+
1, # PCM
|
| 330 |
+
1, # Mono
|
| 331 |
+
8000, # Sample rate
|
| 332 |
+
8000, # Byte rate
|
| 333 |
+
1, # Block align
|
| 334 |
+
8, # Bits per sample
|
| 335 |
+
b'data',
|
| 336 |
+
num_samples
|
| 337 |
+
)
|
| 338 |
+
audio_data = bytes(header + data_bytes)
|
| 339 |
+
deduct_developer_balance(api_key, cost, "local-silent-tts-fallback", len(input_text), 200)
|
| 340 |
+
return send_file(
|
| 341 |
+
io.BytesIO(audio_data),
|
| 342 |
+
mimetype="audio/wav",
|
| 343 |
+
as_attachment=False
|
| 344 |
+
)
|
| 345 |
+
except Exception as e:
|
| 346 |
+
return jsonify({"error": f"Failed to generate fallback TTS: {str(e)}"}), 500
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
@developer_bp.route("/api/developer/all-sessions", methods=["GET"])
|
| 350 |
+
def api_developer_all_sessions():
|
| 351 |
+
user = get_current_user()
|
| 352 |
+
if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
|
| 353 |
+
return jsonify({"error": "Quyền truy cập bị từ chối."}), 403
|
| 354 |
+
|
| 355 |
+
conn = get_user_db_conn()
|
| 356 |
+
try:
|
| 357 |
+
rows = conn.execute(
|
| 358 |
+
"""SELECT lh.id, lh.user_id, u.username, lh.ip_address, lh.os, lh.browser, lh.device_type, lh.login_time, lh.last_active, lh.status
|
| 359 |
+
FROM login_history lh
|
| 360 |
+
JOIN users u ON lh.user_id = u.id
|
| 361 |
+
ORDER BY lh.login_time DESC LIMIT 100"""
|
| 362 |
+
).fetchall()
|
| 363 |
+
|
| 364 |
+
sessions = []
|
| 365 |
+
for r in rows:
|
| 366 |
+
s = dict(r)
|
| 367 |
+
if hasattr(s["login_time"], "isoformat"):
|
| 368 |
+
s["login_time"] = s["login_time"].isoformat()
|
| 369 |
+
if hasattr(s["last_active"], "isoformat"):
|
| 370 |
+
s["last_active"] = s["last_active"].isoformat()
|
| 371 |
+
sessions.append(s)
|
| 372 |
+
|
| 373 |
+
return jsonify({
|
| 374 |
+
"success": True,
|
| 375 |
+
"sessions": sessions
|
| 376 |
+
})
|
| 377 |
+
except Exception as e:
|
| 378 |
+
return jsonify({"error": str(e), "success": False}), 500
|
| 379 |
+
finally:
|
| 380 |
+
conn.close()
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
@developer_bp.route("/api/developer/sessions/revoke", methods=["POST"])
|
| 384 |
+
def api_developer_revoke_session():
|
| 385 |
+
user = get_current_user()
|
| 386 |
+
if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
|
| 387 |
+
return jsonify({"error": "Quyền truy cập bị từ chối."}), 403
|
| 388 |
+
|
| 389 |
+
data = request.json or {}
|
| 390 |
+
session_id = data.get("session_id")
|
| 391 |
+
if not session_id:
|
| 392 |
+
return jsonify({"error": "Missing session_id", "success": False}), 400
|
| 393 |
+
|
| 394 |
+
conn = get_user_db_conn()
|
| 395 |
+
try:
|
| 396 |
+
row = conn.execute(
|
| 397 |
+
"SELECT token FROM login_history WHERE id = ?",
|
| 398 |
+
(session_id,)
|
| 399 |
+
).fetchone()
|
| 400 |
+
if not row:
|
| 401 |
+
return jsonify({"error": "Session not found", "success": False}), 404
|
| 402 |
+
|
| 403 |
+
token = row["token"]
|
| 404 |
+
conn.execute("UPDATE login_history SET status = 'logged_out' WHERE id = ?", (session_id,))
|
| 405 |
+
if token:
|
| 406 |
+
conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (token,))
|
| 407 |
+
conn.commit()
|
| 408 |
+
return jsonify({"message": "Đã đăng xuất thiết bị thành công", "success": True})
|
| 409 |
+
except Exception as e:
|
| 410 |
+
return jsonify({"error": str(e), "success": False}), 500
|
| 411 |
+
finally:
|
| 412 |
+
conn.close()
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
@developer_bp.route("/api/releases", methods=["GET"])
|
| 416 |
+
def api_get_releases():
|
| 417 |
+
conn = get_user_db_conn()
|
| 418 |
+
try:
|
| 419 |
+
rows = conn.execute(
|
| 420 |
+
"SELECT platform, version, download_url, patch_url, file_size, release_notes, updated_at FROM app_releases ORDER BY updated_at DESC"
|
| 421 |
+
).fetchall()
|
| 422 |
+
releases = {}
|
| 423 |
+
for r in rows:
|
| 424 |
+
plat = r["platform"]
|
| 425 |
+
if plat not in releases:
|
| 426 |
+
releases[plat] = {
|
| 427 |
+
"version": r["version"],
|
| 428 |
+
"download_url": r["download_url"],
|
| 429 |
+
"patch_url": r["patch_url"],
|
| 430 |
+
"file_size": r["file_size"],
|
| 431 |
+
"release_notes": r["release_notes"],
|
| 432 |
+
"updated_at": r["updated_at"].isoformat() if hasattr(r["updated_at"], "isoformat") else r["updated_at"],
|
| 433 |
+
"history": []
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
releases[plat]["history"].append({
|
| 437 |
+
"version": r["version"],
|
| 438 |
+
"download_url": r["download_url"],
|
| 439 |
+
"patch_url": r["patch_url"],
|
| 440 |
+
"file_size": r["file_size"],
|
| 441 |
+
"release_notes": r["release_notes"],
|
| 442 |
+
"updated_at": r["updated_at"].isoformat() if hasattr(r["updated_at"], "isoformat") else r["updated_at"]
|
| 443 |
+
})
|
| 444 |
+
return jsonify({"success": True, "releases": releases})
|
| 445 |
+
except Exception as e:
|
| 446 |
+
return jsonify({"error": str(e), "success": False}), 500
|
| 447 |
+
finally:
|
| 448 |
+
conn.close()
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
@developer_bp.route("/api/releases/update", methods=["POST"])
|
| 452 |
+
def api_update_release():
|
| 453 |
+
user = get_current_user()
|
| 454 |
+
if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
|
| 455 |
+
return jsonify({"error": "Quyền truy cập bị từ chối. Chỉ dành cho admin.", "success": False}), 403
|
| 456 |
+
|
| 457 |
+
data = request.json or {}
|
| 458 |
+
platform = data.get("platform")
|
| 459 |
+
version = data.get("version")
|
| 460 |
+
download_url = data.get("download_url")
|
| 461 |
+
file_size = data.get("file_size")
|
| 462 |
+
release_notes = data.get("release_notes")
|
| 463 |
+
|
| 464 |
+
if not platform or platform not in ['extension', 'desktop_linux', 'desktop_windows']:
|
| 465 |
+
return jsonify({"error": "Platform không hợp lệ.", "success": False}), 400
|
| 466 |
+
|
| 467 |
+
conn = get_user_db_conn()
|
| 468 |
+
try:
|
| 469 |
+
existing = conn.execute("SELECT 1 FROM app_releases WHERE platform = ? AND version = ?", (platform, version)).fetchone()
|
| 470 |
+
if existing:
|
| 471 |
+
conn.execute(
|
| 472 |
+
"""UPDATE app_releases
|
| 473 |
+
SET download_url = ?, file_size = ?, release_notes = ?, updated_at = CURRENT_TIMESTAMP
|
| 474 |
+
WHERE platform = ? AND version = ?""",
|
| 475 |
+
(download_url, file_size, release_notes, platform, version)
|
| 476 |
+
)
|
| 477 |
+
else:
|
| 478 |
+
conn.execute(
|
| 479 |
+
"""INSERT INTO app_releases (platform, version, download_url, file_size, release_notes)
|
| 480 |
+
VALUES (?, ?, ?, ?, ?)""",
|
| 481 |
+
(platform, version, download_url, file_size, release_notes)
|
| 482 |
+
)
|
| 483 |
+
conn.commit()
|
| 484 |
+
return jsonify({"message": f"Cập nhật phiên bản {platform} v{version} thành công.", "success": True})
|
| 485 |
+
except Exception as e:
|
| 486 |
+
return jsonify({"error": str(e), "success": False}), 500
|
| 487 |
+
finally:
|
| 488 |
+
conn.close()
|
backend/api/epub.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
import uuid
|
| 4 |
+
from flask import Blueprint, request, jsonify, send_file
|
| 5 |
+
from backend.api.books import is_vip_request
|
| 6 |
+
from backend.services.translation import get_engine, parse_custom_dict_text
|
| 7 |
+
from backend.services import epub_service
|
| 8 |
+
|
| 9 |
+
epub_bp = Blueprint("epub", __name__, url_prefix="/api/epub")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@epub_bp.route("/translate", methods=["POST"])
|
| 13 |
+
def api_epub_translate():
|
| 14 |
+
if not is_vip_request():
|
| 15 |
+
return jsonify({"error": "Chức năng Dịch EPUB nâng cao chỉ dành cho thành viên VIP!"}), 403
|
| 16 |
+
|
| 17 |
+
if "file" not in request.files:
|
| 18 |
+
return jsonify({"error": "Không tìm thấy file EPUB tải lên!"}), 400
|
| 19 |
+
|
| 20 |
+
file = request.files["file"]
|
| 21 |
+
if file.filename == "":
|
| 22 |
+
return jsonify({"error": "Tên file rỗng!"}), 400
|
| 23 |
+
|
| 24 |
+
mode = request.form.get("mode", "fast")
|
| 25 |
+
limit_chapters = int(request.form.get("limit_chapters", "-1"))
|
| 26 |
+
clean_styles = request.form.get("clean_styles", "false").lower() == "true"
|
| 27 |
+
strip_images = request.form.get("strip_images", "false").lower() == "true"
|
| 28 |
+
strip_fonts = request.form.get("strip_fonts", "false").lower() == "true"
|
| 29 |
+
custom_dict_text = request.form.get("custom_dict", "").strip()
|
| 30 |
+
custom_dict = parse_custom_dict_text(custom_dict_text)
|
| 31 |
+
|
| 32 |
+
temp_dir = tempfile.gettempdir()
|
| 33 |
+
src_path = os.path.join(temp_dir, f"src_{uuid.uuid4().hex}.epub")
|
| 34 |
+
dest_path = os.path.join(temp_dir, f"translated_{uuid.uuid4().hex}.epub")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
file.save(src_path)
|
| 38 |
+
eng = get_engine()
|
| 39 |
+
duration = epub_service.translate_epub_file(
|
| 40 |
+
src_path, dest_path, eng, mode=mode,
|
| 41 |
+
limit_chapters=limit_chapters, custom_dict=custom_dict,
|
| 42 |
+
clean_styles=clean_styles, strip_images=strip_images, strip_fonts=strip_fonts
|
| 43 |
+
)
|
| 44 |
+
print(f"[VIP EPUB] Translated {file.filename} in {duration:.2f}s in mode {mode}")
|
| 45 |
+
|
| 46 |
+
base, ext = os.path.splitext(file.filename)
|
| 47 |
+
output_filename = f"{base}_Dich_{mode}{ext}"
|
| 48 |
+
|
| 49 |
+
return send_file(
|
| 50 |
+
dest_path,
|
| 51 |
+
as_attachment=True,
|
| 52 |
+
download_name=output_filename,
|
| 53 |
+
mimetype="application/epub+zip"
|
| 54 |
+
)
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"[VIP EPUB ERROR] Translate failed: {e}")
|
| 57 |
+
return jsonify({"error": f"Lỗi trong quá trình dịch EPUB: {str(e)}"}), 500
|
| 58 |
+
finally:
|
| 59 |
+
if os.path.exists(src_path):
|
| 60 |
+
try:
|
| 61 |
+
os.remove(src_path)
|
| 62 |
+
except Exception:
|
| 63 |
+
pass
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@epub_bp.route("/convert-txt", methods=["POST"])
|
| 67 |
+
def api_epub_convert_txt():
|
| 68 |
+
if not is_vip_request():
|
| 69 |
+
return jsonify({"error": "Chức năng chuyển đổi truyện sang EPUB chỉ dành cho thành viên VIP!"}), 403
|
| 70 |
+
|
| 71 |
+
title = request.form.get("title", "Truyện convert").strip()
|
| 72 |
+
author = request.form.get("author", "Vô danh").strip()
|
| 73 |
+
description = request.form.get("description", "").strip()
|
| 74 |
+
split_regex = request.form.get("split_regex", r"第\s*\d+\s*[章|回|节]").strip()
|
| 75 |
+
translate_bool = request.form.get("translate", "false").lower() == "true"
|
| 76 |
+
mode = request.form.get("mode", "fast")
|
| 77 |
+
custom_dict_text = request.form.get("custom_dict", "").strip()
|
| 78 |
+
|
| 79 |
+
txt_content = ""
|
| 80 |
+
if "file" in request.files:
|
| 81 |
+
file = request.files["file"]
|
| 82 |
+
if file.filename != "":
|
| 83 |
+
txt_content = file.read().decode("utf-8", errors="ignore")
|
| 84 |
+
else:
|
| 85 |
+
txt_content = request.form.get("text", "").strip()
|
| 86 |
+
|
| 87 |
+
if not txt_content:
|
| 88 |
+
return jsonify({"error": "Nội dung văn bản rỗng!"}), 400
|
| 89 |
+
|
| 90 |
+
custom_dict = parse_custom_dict_text(custom_dict_text)
|
| 91 |
+
temp_dir = tempfile.gettempdir()
|
| 92 |
+
dest_path = os.path.join(temp_dir, f"converted_{uuid.uuid4().hex}.epub")
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
eng = get_engine() if translate_bool else None
|
| 96 |
+
epub_service.convert_txt_to_epub(
|
| 97 |
+
txt_content, title, author, split_regex, dest_path,
|
| 98 |
+
description=description, engine=eng,
|
| 99 |
+
mode=mode if translate_bool else None, custom_dict=custom_dict
|
| 100 |
+
)
|
| 101 |
+
return send_file(
|
| 102 |
+
dest_path,
|
| 103 |
+
as_attachment=True,
|
| 104 |
+
download_name=f"{title}.epub",
|
| 105 |
+
mimetype="application/epub+zip"
|
| 106 |
+
)
|
| 107 |
+
except Exception as e:
|
| 108 |
+
print(f"[VIP EPUB ERROR] Convert TXT failed: {e}")
|
| 109 |
+
return jsonify({"error": f"Lỗi tạo EPUB: {str(e)}"}), 500
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@epub_bp.route("/optimize", methods=["POST"])
|
| 113 |
+
def api_epub_optimize():
|
| 114 |
+
if not is_vip_request():
|
| 115 |
+
return jsonify({"error": "Chức năng Tối ưu hóa EPUB chỉ dành cho thành viên VIP!"}), 403
|
| 116 |
+
|
| 117 |
+
if "file" not in request.files:
|
| 118 |
+
return jsonify({"error": "Không tìm thấy file EPUB tải lên!"}), 400
|
| 119 |
+
|
| 120 |
+
file = request.files["file"]
|
| 121 |
+
if file.filename == "":
|
| 122 |
+
return jsonify({"error": "Tên file rỗng!"}), 400
|
| 123 |
+
|
| 124 |
+
strip_images = request.form.get("strip_images", "false").lower() == "true"
|
| 125 |
+
strip_fonts = request.form.get("strip_fonts", "false").lower() == "true"
|
| 126 |
+
|
| 127 |
+
temp_dir = tempfile.gettempdir()
|
| 128 |
+
src_path = os.path.join(temp_dir, f"opt_src_{uuid.uuid4().hex}.epub")
|
| 129 |
+
dest_path = os.path.join(temp_dir, f"opt_dest_{uuid.uuid4().hex}.epub")
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
file.save(src_path)
|
| 133 |
+
|
| 134 |
+
class MockEngine:
|
| 135 |
+
def translate(self, text, **kwargs):
|
| 136 |
+
return text
|
| 137 |
+
|
| 138 |
+
epub_service.translate_epub_file(
|
| 139 |
+
src_path, dest_path, MockEngine(), mode=None,
|
| 140 |
+
limit_chapters=-1, custom_dict=None, clean_styles=True,
|
| 141 |
+
strip_images=strip_images, strip_fonts=strip_fonts
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
base, ext = os.path.splitext(file.filename)
|
| 145 |
+
return send_file(
|
| 146 |
+
dest_path,
|
| 147 |
+
as_attachment=True,
|
| 148 |
+
download_name=f"{base}_Optimized{ext}",
|
| 149 |
+
mimetype="application/epub+zip"
|
| 150 |
+
)
|
| 151 |
+
except Exception as e:
|
| 152 |
+
return jsonify({"error": f"Lỗi tối ưu EPUB: {str(e)}"}), 500
|
| 153 |
+
finally:
|
| 154 |
+
if os.path.exists(src_path):
|
| 155 |
+
try:
|
| 156 |
+
os.remove(src_path)
|
| 157 |
+
except Exception:
|
| 158 |
+
pass
|
backend/api/monitoring.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from flask import Blueprint, jsonify, request
|
| 3 |
+
from backend.core.decorators import get_current_user
|
| 4 |
+
from backend.core.monitoring import collect_all_metrics
|
| 5 |
+
from backend.services.alert_service import check_resources_and_alert
|
| 6 |
+
|
| 7 |
+
monitoring_bp = Blueprint("monitoring", __name__)
|
| 8 |
+
|
| 9 |
+
@monitoring_bp.route("/api/admin/metrics", methods=["GET"])
|
| 10 |
+
def get_metrics():
|
| 11 |
+
"""
|
| 12 |
+
Get detailed system metrics and statistics.
|
| 13 |
+
Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
|
| 14 |
+
"""
|
| 15 |
+
# Authorization checks
|
| 16 |
+
is_admin = False
|
| 17 |
+
|
| 18 |
+
# 1. Check for secret X-Admin-Key in request headers
|
| 19 |
+
admin_key = request.headers.get("X-Admin-Key")
|
| 20 |
+
expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
|
| 21 |
+
if admin_key and admin_key == expected_key:
|
| 22 |
+
is_admin = True
|
| 23 |
+
|
| 24 |
+
# 2. Check logged-in user session/token
|
| 25 |
+
if not is_admin:
|
| 26 |
+
current_user = get_current_user()
|
| 27 |
+
if current_user and current_user.get("username") == "admin":
|
| 28 |
+
is_admin = True
|
| 29 |
+
|
| 30 |
+
if not is_admin:
|
| 31 |
+
return jsonify({"error": "Unauthorized admin access required"}), 403
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
# Collect system statistics and details
|
| 35 |
+
metrics = collect_all_metrics()
|
| 36 |
+
|
| 37 |
+
# Trigger checks and alerts if RAM or Disk usage is critical
|
| 38 |
+
check_resources_and_alert()
|
| 39 |
+
|
| 40 |
+
return jsonify({
|
| 41 |
+
"success": True,
|
| 42 |
+
"metrics": metrics
|
| 43 |
+
}), 200
|
| 44 |
+
except Exception as e:
|
| 45 |
+
return jsonify({
|
| 46 |
+
"success": False,
|
| 47 |
+
"error": f"Failed to retrieve system metrics: {str(e)}"
|
| 48 |
+
}), 500
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@monitoring_bp.route("/api/admin/logs", methods=["GET"])
|
| 52 |
+
def get_logs():
|
| 53 |
+
"""
|
| 54 |
+
Get or download application logs.
|
| 55 |
+
Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
|
| 56 |
+
"""
|
| 57 |
+
is_admin = False
|
| 58 |
+
|
| 59 |
+
# 1. Check for secret X-Admin-Key in request headers
|
| 60 |
+
admin_key = request.headers.get("X-Admin-Key")
|
| 61 |
+
expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
|
| 62 |
+
if admin_key and admin_key == expected_key:
|
| 63 |
+
is_admin = True
|
| 64 |
+
|
| 65 |
+
# 2. Check logged-in user session/token
|
| 66 |
+
if not is_admin:
|
| 67 |
+
current_user = get_current_user()
|
| 68 |
+
if current_user and current_user.get("username") == "admin":
|
| 69 |
+
is_admin = True
|
| 70 |
+
|
| 71 |
+
if not is_admin:
|
| 72 |
+
return jsonify({"error": "Unauthorized admin access required"}), 403
|
| 73 |
+
|
| 74 |
+
from backend.config import Config
|
| 75 |
+
log_path = os.path.join(Config.ROOT_DIR, "logs", "app.log")
|
| 76 |
+
|
| 77 |
+
# Handle file download option
|
| 78 |
+
if request.args.get("download") == "true":
|
| 79 |
+
if not os.path.exists(log_path):
|
| 80 |
+
return jsonify({"error": "Log file does not exist"}), 404
|
| 81 |
+
from flask import send_file
|
| 82 |
+
return send_file(log_path, as_attachment=True, download_name="app.log")
|
| 83 |
+
|
| 84 |
+
# Handle standard retrieval
|
| 85 |
+
lines_count = request.args.get("lines", default=100, type=int)
|
| 86 |
+
lines_count = max(1, min(lines_count, 2000))
|
| 87 |
+
|
| 88 |
+
if not os.path.exists(log_path):
|
| 89 |
+
return jsonify({
|
| 90 |
+
"success": True,
|
| 91 |
+
"lines_returned": 0,
|
| 92 |
+
"logs": [],
|
| 93 |
+
"message": "Log file not found."
|
| 94 |
+
}), 200
|
| 95 |
+
|
| 96 |
+
try:
|
| 97 |
+
from collections import deque
|
| 98 |
+
with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 99 |
+
last_lines = list(deque(f, maxlen=lines_count))
|
| 100 |
+
|
| 101 |
+
return jsonify({
|
| 102 |
+
"success": True,
|
| 103 |
+
"lines_returned": len(last_lines),
|
| 104 |
+
"logs": last_lines
|
| 105 |
+
}), 200
|
| 106 |
+
except Exception as e:
|
| 107 |
+
return jsonify({
|
| 108 |
+
"success": False,
|
| 109 |
+
"error": f"Failed to read logs: {str(e)}"
|
| 110 |
+
}), 500
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@monitoring_bp.route("/api/admin/profiler", methods=["GET"])
|
| 114 |
+
def get_profiler_stats():
|
| 115 |
+
"""
|
| 116 |
+
Get recent request profiles with latency breakdown (Total, DB, External, CPU).
|
| 117 |
+
Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
|
| 118 |
+
"""
|
| 119 |
+
is_admin = False
|
| 120 |
+
admin_key = request.headers.get("X-Admin-Key")
|
| 121 |
+
expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
|
| 122 |
+
if admin_key and admin_key == expected_key:
|
| 123 |
+
is_admin = True
|
| 124 |
+
|
| 125 |
+
if not is_admin:
|
| 126 |
+
current_user = get_current_user()
|
| 127 |
+
if current_user and current_user.get("username") == "admin":
|
| 128 |
+
is_admin = True
|
| 129 |
+
|
| 130 |
+
if not is_admin:
|
| 131 |
+
return jsonify({"error": "Unauthorized admin access required"}), 403
|
| 132 |
+
|
| 133 |
+
try:
|
| 134 |
+
from backend.core.profiler import get_recent_profiles
|
| 135 |
+
profiles = get_recent_profiles()
|
| 136 |
+
return jsonify({
|
| 137 |
+
"success": True,
|
| 138 |
+
"count": len(profiles),
|
| 139 |
+
"profiles": profiles
|
| 140 |
+
}), 200
|
| 141 |
+
except Exception as e:
|
| 142 |
+
return jsonify({
|
| 143 |
+
"success": False,
|
| 144 |
+
"error": f"Failed to retrieve profiler stats: {str(e)}"
|
| 145 |
+
}), 500
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@monitoring_bp.route("/api/logs/error", methods=["POST", "OPTIONS"])
|
| 149 |
+
def log_frontend_error():
|
| 150 |
+
"""
|
| 151 |
+
Receive fatal crash reports from the Frontend ErrorBoundary
|
| 152 |
+
and instantly send an email alert to the admin.
|
| 153 |
+
"""
|
| 154 |
+
if request.method == "OPTIONS":
|
| 155 |
+
return jsonify({}), 200
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
data = request.json or {}
|
| 159 |
+
message = data.get("message", "Unknown Error")
|
| 160 |
+
stack = data.get("stack", "No stack trace provided.")
|
| 161 |
+
url = data.get("url", "Unknown URL")
|
| 162 |
+
user_agent = data.get("userAgent", "Unknown Browser")
|
| 163 |
+
time = data.get("time", "Unknown Time")
|
| 164 |
+
|
| 165 |
+
html_content = f"""
|
| 166 |
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;">
|
| 167 |
+
<div style="background-color: #dc2626; color: white; padding: 15px 20px;">
|
| 168 |
+
<h2 style="margin: 0;">🚨 Tiên Hiệp AI - Frontend Crash Alert</h2>
|
| 169 |
+
</div>
|
| 170 |
+
<div style="padding: 20px; background-color: #f8fafc;">
|
| 171 |
+
<p><strong>Thời gian:</strong> {time}</p>
|
| 172 |
+
<p><strong>URL phát sinh lỗi:</strong> <a href="{url}">{url}</a></p>
|
| 173 |
+
<p><strong>Trình duyệt:</strong> {user_agent}</p>
|
| 174 |
+
<hr style="border: none; border-top: 1px solid #cbd5e1; margin: 20px 0;" />
|
| 175 |
+
<h3 style="color: #b91c1c; margin-bottom: 10px;">Lỗi (Error Message):</h3>
|
| 176 |
+
<div style="background: #fee2e2; border: 1px solid #f87171; padding: 12px; border-radius: 6px; color: #991b1b; font-weight: bold; word-break: break-all;">
|
| 177 |
+
{message}
|
| 178 |
+
</div>
|
| 179 |
+
<h3 style="color: #334155; margin-top: 20px; margin-bottom: 10px;">Mã Lỗi (Stack Trace):</h3>
|
| 180 |
+
<pre style="background: #1e293b; color: #e2e8f0; padding: 15px; border-radius: 6px; overflow-x: auto; font-size: 13px; line-height: 1.5;">{stack}</pre>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
"""
|
| 184 |
+
|
| 185 |
+
# Gửi email ngầm (không làm chậm server)
|
| 186 |
+
from backend.services.email_service import send_email_async
|
| 187 |
+
admin_email = os.environ.get("ADMIN_EMAIL", "havucong@lyvuha.com")
|
| 188 |
+
|
| 189 |
+
send_email_async(
|
| 190 |
+
to_email=admin_email,
|
| 191 |
+
subject=f"[CRASH ALERT] Lỗi nghiêm trọng tại Tiên Hiệp AI ({url})",
|
| 192 |
+
html_content=html_content
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
return jsonify({"success": True, "message": "Crash report received and email queued."}), 200
|
| 196 |
+
except Exception as e:
|
| 197 |
+
print(f"❌ Failed to process frontend crash log: {e}")
|
| 198 |
+
return jsonify({"success": False, "error": str(e)}), 500
|
backend/api/payment.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from flask import Blueprint, request, jsonify, session
|
| 5 |
+
from backend.config import Config
|
| 6 |
+
from backend.core.decorators import get_current_user
|
| 7 |
+
from backend.core.security import verify_access_token
|
| 8 |
+
from backend.core.rate_limit import check_rate_limit, get_client_ip
|
| 9 |
+
from backend.database.db_manager import get_user_db_conn
|
| 10 |
+
from backend.services.payment_service import (
|
| 11 |
+
create_payment_order, confirm_payment, verify_payos_webhook_signature
|
| 12 |
+
)
|
| 13 |
+
from backend.services.auth_service import check_vip_expiry, activate_vip
|
| 14 |
+
|
| 15 |
+
payment_bp = Blueprint("payment", __name__, url_prefix="/api/payment")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _get_user_id_from_request():
|
| 19 |
+
"""Helper to get user_id from session or JWT."""
|
| 20 |
+
user = get_current_user()
|
| 21 |
+
if user:
|
| 22 |
+
return user["id"]
|
| 23 |
+
auth_header = request.headers.get("Authorization", "")
|
| 24 |
+
if auth_header.startswith("Bearer "):
|
| 25 |
+
payload = verify_access_token(auth_header[7:])
|
| 26 |
+
if payload:
|
| 27 |
+
return int(payload["sub"])
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _process_payment_webhook(order_id, amount):
|
| 32 |
+
"""Internal handler called from the webhook to confirm payment & activate VIP/topup."""
|
| 33 |
+
conn = get_user_db_conn()
|
| 34 |
+
payment = conn.execute(
|
| 35 |
+
"SELECT * FROM payments WHERE order_id = ? AND status = 'pending'", (order_id,)
|
| 36 |
+
).fetchone()
|
| 37 |
+
|
| 38 |
+
if not payment:
|
| 39 |
+
conn.close()
|
| 40 |
+
return jsonify({"error": "Order not found or already processed"}), 404
|
| 41 |
+
|
| 42 |
+
if amount > 0 and abs(amount - payment["amount"]) > 100:
|
| 43 |
+
conn.close()
|
| 44 |
+
return jsonify({"error": "Amount mismatch"}), 400
|
| 45 |
+
|
| 46 |
+
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
| 47 |
+
conn.execute(
|
| 48 |
+
"UPDATE payments SET status = 'completed', completed_at = ? WHERE id = ?",
|
| 49 |
+
(now, payment["id"])
|
| 50 |
+
)
|
| 51 |
+
conn.commit()
|
| 52 |
+
conn.close()
|
| 53 |
+
|
| 54 |
+
if payment["plan"].startswith("topup_"):
|
| 55 |
+
conn = get_user_db_conn()
|
| 56 |
+
conn.execute(
|
| 57 |
+
"UPDATE users SET api_balance = COALESCE(api_balance, 0.0) + ? WHERE id = ?",
|
| 58 |
+
(payment["amount"], payment["user_id"])
|
| 59 |
+
)
|
| 60 |
+
conn.commit()
|
| 61 |
+
conn.close()
|
| 62 |
+
return jsonify({"success": True, "message": f"Nạp thành công {payment['amount']:,}đ vào số dư API!".replace(",", ".")})
|
| 63 |
+
else:
|
| 64 |
+
activate_vip(payment["user_id"], payment["plan"])
|
| 65 |
+
if session.get("user_id") == payment["user_id"]:
|
| 66 |
+
session["vip_status"] = 1
|
| 67 |
+
print(f"[PAYMENT] ✅ Order {order_id} completed — User #{payment['user_id']} → VIP {payment['plan']}")
|
| 68 |
+
return jsonify({"success": True, "message": "Payment confirmed, VIP activated!"})
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@payment_bp.route("/plans", methods=["GET"])
|
| 72 |
+
def api_payment_plans():
|
| 73 |
+
lang = request.args.get("lang", "vi")
|
| 74 |
+
plans = []
|
| 75 |
+
for key, plan in Config.VIP_PLANS.items():
|
| 76 |
+
plans.append({
|
| 77 |
+
"id": key,
|
| 78 |
+
"name": plan.get(f"name_{lang}", plan["name_vi"]),
|
| 79 |
+
"description": plan.get(f"description_{lang}", plan["description_vi"]),
|
| 80 |
+
"price": plan["price"],
|
| 81 |
+
"price_formatted": f"{plan['price']:,}đ".replace(",", "."),
|
| 82 |
+
"duration_days": plan["duration_days"],
|
| 83 |
+
})
|
| 84 |
+
return jsonify({"plans": plans})
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@payment_bp.route("/create", methods=["POST"])
|
| 88 |
+
def api_payment_create():
|
| 89 |
+
user_id = _get_user_id_from_request()
|
| 90 |
+
if not user_id:
|
| 91 |
+
return jsonify({"error": "Vui lòng đăng nhập để mua VIP."}), 401
|
| 92 |
+
|
| 93 |
+
ip = get_client_ip()
|
| 94 |
+
if check_rate_limit("payment", ip):
|
| 95 |
+
return jsonify({"error": "Bạn đã tạo quá nhiều đơn hàng. Vui lòng thử lại sau."}), 429
|
| 96 |
+
|
| 97 |
+
data = request.json or {}
|
| 98 |
+
plan = data.get("plan", "month")
|
| 99 |
+
|
| 100 |
+
result, status_code = create_payment_order(user_id, plan)
|
| 101 |
+
return jsonify(result), status_code
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@payment_bp.route("/status/<order_id>", methods=["GET"])
|
| 105 |
+
def api_payment_status(order_id):
|
| 106 |
+
user_id = _get_user_id_from_request()
|
| 107 |
+
|
| 108 |
+
conn = get_user_db_conn()
|
| 109 |
+
if user_id:
|
| 110 |
+
payment = conn.execute(
|
| 111 |
+
"SELECT * FROM payments WHERE order_id = ? AND user_id = ?", (order_id, user_id)
|
| 112 |
+
).fetchone()
|
| 113 |
+
else:
|
| 114 |
+
payment = conn.execute(
|
| 115 |
+
"SELECT * FROM payments WHERE order_id = ?", (order_id,)
|
| 116 |
+
).fetchone()
|
| 117 |
+
conn.close()
|
| 118 |
+
|
| 119 |
+
if not payment:
|
| 120 |
+
return jsonify({"error": "Payment order not found."}), 404
|
| 121 |
+
|
| 122 |
+
return jsonify({
|
| 123 |
+
"order_id": payment["order_id"],
|
| 124 |
+
"plan": payment["plan"],
|
| 125 |
+
"amount": payment["amount"],
|
| 126 |
+
"status": payment["status"],
|
| 127 |
+
"created_at": payment["created_at"],
|
| 128 |
+
"completed_at": payment["completed_at"]
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@payment_bp.route("/webhook", methods=["POST"])
|
| 133 |
+
def api_payment_webhook():
|
| 134 |
+
# Verify PAYMENT_WEBHOOK_KEY if set in environment
|
| 135 |
+
webhook_key = os.environ.get("PAYMENT_WEBHOOK_KEY", "")
|
| 136 |
+
if webhook_key:
|
| 137 |
+
auth_header = request.headers.get("Authorization", "")
|
| 138 |
+
provided_key = auth_header.replace("Bearer ", "").replace("Apikey ", "").strip()
|
| 139 |
+
query_key = request.args.get("webhook_key", "")
|
| 140 |
+
if provided_key != webhook_key and query_key != webhook_key:
|
| 141 |
+
return jsonify({"error": "Unauthorized webhook request"}), 401
|
| 142 |
+
|
| 143 |
+
data = request.json or {}
|
| 144 |
+
|
| 145 |
+
# PayOS format
|
| 146 |
+
if "data" in data and "orderCode" in data.get("data", {}):
|
| 147 |
+
order_code = str(data["data"]["orderCode"])
|
| 148 |
+
amount = data["data"].get("amount", 0)
|
| 149 |
+
checksum_key = Config.PAYMENT_CONFIG.get("payos_checksum_key", "")
|
| 150 |
+
if checksum_key and "signature" in data:
|
| 151 |
+
if not verify_payos_webhook_signature(data["data"], data.get("signature", ""), checksum_key):
|
| 152 |
+
return jsonify({"error": "Invalid signature"}), 403
|
| 153 |
+
return _process_payment_webhook(order_code, amount)
|
| 154 |
+
|
| 155 |
+
# SePay / Generic format
|
| 156 |
+
if "transferAmount" in data:
|
| 157 |
+
content = data.get("content", "")
|
| 158 |
+
amount = data.get("transferAmount", 0)
|
| 159 |
+
match = re.search(r"(VIP\w+)", content.upper())
|
| 160 |
+
if match:
|
| 161 |
+
return _process_payment_webhook(match.group(1), amount)
|
| 162 |
+
|
| 163 |
+
return jsonify({"error": "Invalid webhook payload"}), 400
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@payment_bp.route("/confirm-manual", methods=["POST"])
|
| 167 |
+
def api_payment_confirm_manual():
|
| 168 |
+
data = request.json or {}
|
| 169 |
+
admin_key = data.get("admin_key", "")
|
| 170 |
+
order_id = data.get("order_id", "")
|
| 171 |
+
|
| 172 |
+
ADMIN_KEY = os.environ.get("ADMIN_PAYMENT_KEY")
|
| 173 |
+
if not ADMIN_KEY:
|
| 174 |
+
ADMIN_KEY = "SECURE_SECRET_RANDOM_KEY_9988_ADMIN"
|
| 175 |
+
|
| 176 |
+
is_admin = False
|
| 177 |
+
if admin_key and admin_key == ADMIN_KEY:
|
| 178 |
+
is_admin = True
|
| 179 |
+
|
| 180 |
+
if not is_admin:
|
| 181 |
+
return jsonify({"error": "Unauthorized admin access required"}), 403
|
| 182 |
+
|
| 183 |
+
if not order_id:
|
| 184 |
+
return jsonify({"error": "order_id is required"}), 400
|
| 185 |
+
return _process_payment_webhook(order_id, 0)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@payment_bp.route("/history", methods=["GET"])
|
| 189 |
+
def api_payment_history():
|
| 190 |
+
user_id = _get_user_id_from_request()
|
| 191 |
+
if not user_id:
|
| 192 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 193 |
+
|
| 194 |
+
conn = get_user_db_conn()
|
| 195 |
+
payments = conn.execute(
|
| 196 |
+
"SELECT order_id, plan, amount, status, created_at, completed_at FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT 20",
|
| 197 |
+
(user_id,)
|
| 198 |
+
).fetchall()
|
| 199 |
+
conn.close()
|
| 200 |
+
return jsonify({"payments": [dict(p) for p in payments]})
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@payment_bp.route("/vip-status", methods=["GET"])
|
| 204 |
+
def api_user_vip_status():
|
| 205 |
+
user_id = _get_user_id_from_request()
|
| 206 |
+
if not user_id:
|
| 207 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 208 |
+
|
| 209 |
+
check_vip_expiry(user_id)
|
| 210 |
+
conn = get_user_db_conn()
|
| 211 |
+
user = conn.execute("SELECT vip_status, vip_plan, vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
|
| 212 |
+
conn.close()
|
| 213 |
+
|
| 214 |
+
if not user:
|
| 215 |
+
return jsonify({"error": "User not found"}), 404
|
| 216 |
+
|
| 217 |
+
days_remaining = 0
|
| 218 |
+
if user["vip_expiry"]:
|
| 219 |
+
try:
|
| 220 |
+
expiry_val = user["vip_expiry"]
|
| 221 |
+
if isinstance(expiry_val, str):
|
| 222 |
+
expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
|
| 223 |
+
else:
|
| 224 |
+
expiry = expiry_val
|
| 225 |
+
days_remaining = max(0, (expiry - datetime.utcnow()).days)
|
| 226 |
+
except Exception:
|
| 227 |
+
pass
|
| 228 |
+
|
| 229 |
+
return jsonify({
|
| 230 |
+
"vip_status": user["vip_status"],
|
| 231 |
+
"vip_plan": user["vip_plan"],
|
| 232 |
+
"vip_expiry": user["vip_expiry"],
|
| 233 |
+
"days_remaining": days_remaining,
|
| 234 |
+
"is_active": user["vip_status"] == 1
|
| 235 |
+
})
|
backend/api/sects.py
ADDED
|
@@ -0,0 +1,1248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify
|
| 2 |
+
from backend.core.decorators import jwt_required, get_current_user
|
| 3 |
+
from backend.database.db_manager import get_user_db_conn
|
| 4 |
+
from backend.core.logger import logger
|
| 5 |
+
from backend.core.security import encrypt_message, decrypt_message
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
|
| 8 |
+
sects_bp = Blueprint("sects", __name__)
|
| 9 |
+
|
| 10 |
+
def serialize_row(row):
|
| 11 |
+
if not row:
|
| 12 |
+
return {}
|
| 13 |
+
d = dict(row)
|
| 14 |
+
for k, v in d.items():
|
| 15 |
+
if hasattr(v, "isoformat"):
|
| 16 |
+
d[k] = v.isoformat()
|
| 17 |
+
return d
|
| 18 |
+
|
| 19 |
+
@sects_bp.route("/api/sects/list", methods=["GET"])
|
| 20 |
+
def list_sects():
|
| 21 |
+
conn = get_user_db_conn()
|
| 22 |
+
try:
|
| 23 |
+
rows = conn.execute("""
|
| 24 |
+
SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
|
| 25 |
+
u.username as leader_name,
|
| 26 |
+
(SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
|
| 27 |
+
FROM sects s
|
| 28 |
+
LEFT JOIN users u ON s.leader_id = u.id
|
| 29 |
+
ORDER BY s.contribution DESC, s.created_at DESC
|
| 30 |
+
""").fetchall()
|
| 31 |
+
|
| 32 |
+
user = get_current_user()
|
| 33 |
+
user_requests = []
|
| 34 |
+
if user:
|
| 35 |
+
req_rows = conn.execute("SELECT sect_id FROM sect_join_requests WHERE user_id = ? AND status = 'pending'", (user["id"],)).fetchall()
|
| 36 |
+
user_requests = [r["sect_id"] for r in req_rows]
|
| 37 |
+
|
| 38 |
+
return jsonify({
|
| 39 |
+
"sects": [serialize_row(r) for r in rows],
|
| 40 |
+
"pending_requests": user_requests
|
| 41 |
+
})
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Error listing sects: {e}")
|
| 44 |
+
return jsonify({"error": str(e)}), 500
|
| 45 |
+
finally:
|
| 46 |
+
conn.close()
|
| 47 |
+
|
| 48 |
+
@sects_bp.route("/api/sects/create", methods=["POST"])
|
| 49 |
+
@jwt_required
|
| 50 |
+
def create_sect():
|
| 51 |
+
user = get_current_user()
|
| 52 |
+
data = request.json or {}
|
| 53 |
+
name = data.get("name", "").strip()
|
| 54 |
+
slogan = data.get("slogan", "").strip()
|
| 55 |
+
badge = data.get("badge", "purple").strip()
|
| 56 |
+
|
| 57 |
+
if not name:
|
| 58 |
+
return jsonify({"error": "Tên tông môn không được để trống"}), 400
|
| 59 |
+
|
| 60 |
+
conn = get_user_db_conn()
|
| 61 |
+
try:
|
| 62 |
+
# Check if user is already in a sect
|
| 63 |
+
in_sect = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 64 |
+
if in_sect:
|
| 65 |
+
return jsonify({"error": "Bạn đã thuộc một tông môn khác, hãy rời tông cũ trước"}), 400
|
| 66 |
+
|
| 67 |
+
# Check if name exists
|
| 68 |
+
exists = conn.execute("SELECT id FROM sects WHERE name = ?", (name,)).fetchone()
|
| 69 |
+
if exists:
|
| 70 |
+
return jsonify({"error": "Tên tông môn này đã được lập từ trước"}), 400
|
| 71 |
+
|
| 72 |
+
cursor = conn.execute(
|
| 73 |
+
"INSERT INTO sects (name, slogan, badge, leader_id, announcement) VALUES (?, ?, ?, ?, ?)",
|
| 74 |
+
(name, slogan, badge, user["id"], f"Tông môn {name} được thành lập. Chúc các đồng môn tu tiên đắc đạo!")
|
| 75 |
+
)
|
| 76 |
+
sect_id = cursor.lastrowid
|
| 77 |
+
|
| 78 |
+
conn.execute(
|
| 79 |
+
"INSERT INTO sect_members (sect_id, user_id, role, contribution) VALUES (?, ?, 'leader', 100)",
|
| 80 |
+
(sect_id, user["id"])
|
| 81 |
+
)
|
| 82 |
+
conn.execute(
|
| 83 |
+
"UPDATE sects SET contribution = 100 WHERE id = ?",
|
| 84 |
+
(sect_id,)
|
| 85 |
+
)
|
| 86 |
+
# Delete any pending join requests of this user elsewhere
|
| 87 |
+
conn.execute("DELETE FROM sect_join_requests WHERE user_id = ?", (user["id"],))
|
| 88 |
+
|
| 89 |
+
conn.commit()
|
| 90 |
+
return jsonify({"success": True, "message": f"Sáng lập {name} tông thành công!", "sect_id": sect_id})
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error(f"Error creating sect: {e}")
|
| 93 |
+
return jsonify({"error": str(e)}), 500
|
| 94 |
+
finally:
|
| 95 |
+
conn.close()
|
| 96 |
+
|
| 97 |
+
@sects_bp.route("/api/sects/join", methods=["POST"])
|
| 98 |
+
@jwt_required
|
| 99 |
+
def join_sect():
|
| 100 |
+
user = get_current_user()
|
| 101 |
+
data = request.json or {}
|
| 102 |
+
sect_id = data.get("sect_id")
|
| 103 |
+
|
| 104 |
+
if not sect_id:
|
| 105 |
+
return jsonify({"error": "Thiếu sect_id"}), 400
|
| 106 |
+
|
| 107 |
+
conn = get_user_db_conn()
|
| 108 |
+
try:
|
| 109 |
+
# Check if user is already in a sect
|
| 110 |
+
in_sect = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 111 |
+
if in_sect:
|
| 112 |
+
return jsonify({"error": "Bạn đã ở trong một tông môn khác"}), 400
|
| 113 |
+
|
| 114 |
+
# Check if sect exists
|
| 115 |
+
sect = conn.execute("SELECT name FROM sects WHERE id = ?", (sect_id,)).fetchone()
|
| 116 |
+
if not sect:
|
| 117 |
+
return jsonify({"error": "Tông môn không tồn tại"}), 404
|
| 118 |
+
|
| 119 |
+
# Check if already requested
|
| 120 |
+
req_exists = conn.execute("SELECT id FROM sect_join_requests WHERE sect_id = ? AND user_id = ?", (sect_id, user["id"])).fetchone()
|
| 121 |
+
if req_exists:
|
| 122 |
+
return jsonify({"error": "Bạn đã gửi yêu cầu gia nhập tông này và đang chờ duyệt"}), 400
|
| 123 |
+
|
| 124 |
+
conn.execute(
|
| 125 |
+
"INSERT INTO sect_join_requests (sect_id, user_id, status) VALUES (?, ?, 'pending')",
|
| 126 |
+
(sect_id, user["id"])
|
| 127 |
+
)
|
| 128 |
+
conn.commit()
|
| 129 |
+
return jsonify({"success": True, "message": f"Đã gửi yêu cầu bái kiến gia nhập {sect['name']}! Vui lòng chờ duyệt."})
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"Error joining sect: {e}")
|
| 132 |
+
return jsonify({"error": str(e)}), 500
|
| 133 |
+
finally:
|
| 134 |
+
conn.close()
|
| 135 |
+
|
| 136 |
+
@sects_bp.route("/api/sects/leave", methods=["POST"])
|
| 137 |
+
@jwt_required
|
| 138 |
+
def leave_sect():
|
| 139 |
+
user = get_current_user()
|
| 140 |
+
conn = get_user_db_conn()
|
| 141 |
+
try:
|
| 142 |
+
member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 143 |
+
if not member:
|
| 144 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn nào"}), 400
|
| 145 |
+
|
| 146 |
+
sect_id = member["sect_id"]
|
| 147 |
+
if member["role"] == "leader":
|
| 148 |
+
# Leader leaving dissolves the sect
|
| 149 |
+
conn.execute("DELETE FROM sect_chat_groups WHERE sect_id = ?", (sect_id,))
|
| 150 |
+
conn.execute("DELETE FROM sect_messages WHERE sect_id = ?", (sect_id,))
|
| 151 |
+
conn.execute("DELETE FROM sect_members WHERE sect_id = ?", (sect_id,))
|
| 152 |
+
conn.execute("DELETE FROM sect_join_requests WHERE sect_id = ?", (sect_id,))
|
| 153 |
+
conn.execute("DELETE FROM sect_books WHERE sect_id = ?", (sect_id,))
|
| 154 |
+
conn.execute("DELETE FROM sects WHERE id = ?", (sect_id,))
|
| 155 |
+
msg = "Bạn là tông chủ, rời đi đồng nghĩa với việc giải tán tông môn!"
|
| 156 |
+
else:
|
| 157 |
+
conn.execute("DELETE FROM sect_members WHERE user_id = ? AND sect_id = ?", (user["id"], sect_id))
|
| 158 |
+
msg = "Bạn đã rời khỏi tông môn thành công!"
|
| 159 |
+
|
| 160 |
+
conn.commit()
|
| 161 |
+
return jsonify({"success": True, "message": msg})
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"Error leaving sect: {e}")
|
| 164 |
+
return jsonify({"error": str(e)}), 500
|
| 165 |
+
finally:
|
| 166 |
+
conn.close()
|
| 167 |
+
|
| 168 |
+
@sects_bp.route("/api/sects/my-sect", methods=["GET"])
|
| 169 |
+
@jwt_required
|
| 170 |
+
def my_sect():
|
| 171 |
+
user = get_current_user()
|
| 172 |
+
conn = get_user_db_conn()
|
| 173 |
+
try:
|
| 174 |
+
member = conn.execute("SELECT sect_id, role, contribution as my_contrib FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 175 |
+
if not member:
|
| 176 |
+
return jsonify({"in_sect": False})
|
| 177 |
+
|
| 178 |
+
sect_id = member["sect_id"]
|
| 179 |
+
sect = conn.execute("""
|
| 180 |
+
SELECT s.id, s.name, s.slogan, s.announcement, s.badge, s.level, s.contribution, s.created_at,
|
| 181 |
+
u.username as leader_name, u.id as leader_id
|
| 182 |
+
FROM sects s
|
| 183 |
+
LEFT JOIN users u ON s.leader_id = u.id
|
| 184 |
+
WHERE s.id = ?
|
| 185 |
+
""", (sect_id,)).fetchone()
|
| 186 |
+
|
| 187 |
+
members = conn.execute("""
|
| 188 |
+
SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at, u.username, u.avatar
|
| 189 |
+
FROM sect_members sm
|
| 190 |
+
LEFT JOIN users u ON sm.user_id = u.id
|
| 191 |
+
WHERE sm.sect_id = ?
|
| 192 |
+
ORDER BY
|
| 193 |
+
CASE sm.role
|
| 194 |
+
WHEN 'leader' THEN 1
|
| 195 |
+
WHEN 'elder' THEN 2
|
| 196 |
+
ELSE 3
|
| 197 |
+
END,
|
| 198 |
+
sm.contribution DESC
|
| 199 |
+
""", (sect_id,)).fetchall()
|
| 200 |
+
|
| 201 |
+
return jsonify({
|
| 202 |
+
"in_sect": True,
|
| 203 |
+
"role": member["role"],
|
| 204 |
+
"my_contribution": member["my_contrib"],
|
| 205 |
+
"sect": serialize_row(sect),
|
| 206 |
+
"members": [serialize_row(m) for m in members]
|
| 207 |
+
})
|
| 208 |
+
except Exception as e:
|
| 209 |
+
logger.error(f"Error getting my sect: {e}")
|
| 210 |
+
return jsonify({"error": str(e)}), 500
|
| 211 |
+
finally:
|
| 212 |
+
conn.close()
|
| 213 |
+
|
| 214 |
+
@sects_bp.route("/api/sects/chat/history", methods=["GET"])
|
| 215 |
+
@jwt_required
|
| 216 |
+
def get_sect_chat_history():
|
| 217 |
+
user = get_current_user()
|
| 218 |
+
chat_type = request.args.get("chat_type", "general").strip()
|
| 219 |
+
target_id = request.args.get("target_id")
|
| 220 |
+
group_id = request.args.get("group_id")
|
| 221 |
+
|
| 222 |
+
conn = get_user_db_conn()
|
| 223 |
+
try:
|
| 224 |
+
member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 225 |
+
if not member:
|
| 226 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 227 |
+
|
| 228 |
+
sect_id = member["sect_id"]
|
| 229 |
+
|
| 230 |
+
if chat_type == "direct":
|
| 231 |
+
if not target_id:
|
| 232 |
+
return jsonify({"error": "Thiếu target_id cho chat riêng"}), 400
|
| 233 |
+
rows = conn.execute("""
|
| 234 |
+
SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.target_id,
|
| 235 |
+
u.username as sender_name, u.avatar as sender_avatar
|
| 236 |
+
FROM sect_messages sm
|
| 237 |
+
LEFT JOIN users u ON sm.sender_id = u.id
|
| 238 |
+
WHERE sm.sect_id = ? AND sm.chat_type = 'direct'
|
| 239 |
+
AND ((sm.sender_id = ? AND sm.target_id = ?) OR (sm.sender_id = ? AND sm.target_id = ?))
|
| 240 |
+
ORDER BY sm.created_at ASC LIMIT 100
|
| 241 |
+
""", (sect_id, user["id"], target_id, target_id, user["id"])).fetchall()
|
| 242 |
+
|
| 243 |
+
elif chat_type == "book":
|
| 244 |
+
if not target_id:
|
| 245 |
+
return jsonify({"error": "Thiếu target_id cho chat truyện"}), 400
|
| 246 |
+
rows = conn.execute("""
|
| 247 |
+
SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.target_id,
|
| 248 |
+
u.username as sender_name, u.avatar as sender_avatar
|
| 249 |
+
FROM sect_messages sm
|
| 250 |
+
LEFT JOIN users u ON sm.sender_id = u.id
|
| 251 |
+
WHERE sm.sect_id = ? AND sm.chat_type = 'book' AND sm.target_id = ?
|
| 252 |
+
ORDER BY sm.created_at ASC LIMIT 100
|
| 253 |
+
""", (sect_id, target_id)).fetchall()
|
| 254 |
+
|
| 255 |
+
elif chat_type == "group":
|
| 256 |
+
if not group_id:
|
| 257 |
+
return jsonify({"error": "Thiếu group_id cho chat nhóm"}), 400
|
| 258 |
+
|
| 259 |
+
# Check group access
|
| 260 |
+
group = conn.execute("SELECT members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?", (group_id, sect_id)).fetchone()
|
| 261 |
+
if not group:
|
| 262 |
+
return jsonify({"error": "Nhóm chat không tồn tại"}), 404
|
| 263 |
+
|
| 264 |
+
members_list = [int(x) for x in group["members_csv"].split(",") if x.strip()]
|
| 265 |
+
if user["id"] not in members_list:
|
| 266 |
+
return jsonify({"error": "Bạn không phải thành viên của nhóm chat này"}), 403
|
| 267 |
+
|
| 268 |
+
rows = conn.execute("""
|
| 269 |
+
SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.group_id,
|
| 270 |
+
u.username as sender_name, u.avatar as sender_avatar
|
| 271 |
+
FROM sect_messages sm
|
| 272 |
+
LEFT JOIN users u ON sm.sender_id = u.id
|
| 273 |
+
WHERE sm.sect_id = ? AND sm.chat_type = 'group' AND sm.group_id = ?
|
| 274 |
+
ORDER BY sm.created_at ASC LIMIT 100
|
| 275 |
+
""", (sect_id, group_id)).fetchall()
|
| 276 |
+
|
| 277 |
+
else: # general chat
|
| 278 |
+
rows = conn.execute("""
|
| 279 |
+
SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type,
|
| 280 |
+
u.username as sender_name, u.avatar as sender_avatar
|
| 281 |
+
FROM sect_messages sm
|
| 282 |
+
LEFT JOIN users u ON sm.sender_id = u.id
|
| 283 |
+
WHERE sm.sect_id = ? AND (sm.chat_type IS NULL OR sm.chat_type = 'general')
|
| 284 |
+
ORDER BY sm.created_at ASC LIMIT 100
|
| 285 |
+
""", (sect_id,)).fetchall()
|
| 286 |
+
|
| 287 |
+
messages = []
|
| 288 |
+
for r in rows:
|
| 289 |
+
d = dict(r)
|
| 290 |
+
d["message"] = decrypt_message(d.get("message", ""))
|
| 291 |
+
for k, v in d.items():
|
| 292 |
+
if hasattr(v, "isoformat"):
|
| 293 |
+
d[k] = v.isoformat()
|
| 294 |
+
messages.append(d)
|
| 295 |
+
|
| 296 |
+
return jsonify({"messages": messages})
|
| 297 |
+
except Exception as e:
|
| 298 |
+
logger.error(f"Error getting sect chat: {e}")
|
| 299 |
+
return jsonify({"error": str(e)}), 500
|
| 300 |
+
finally:
|
| 301 |
+
conn.close()
|
| 302 |
+
|
| 303 |
+
@sects_bp.route("/api/sects/chat/send", methods=["POST"])
|
| 304 |
+
@jwt_required
|
| 305 |
+
def send_sect_chat():
|
| 306 |
+
user = get_current_user()
|
| 307 |
+
data = request.json or {}
|
| 308 |
+
message = data.get("message", "").strip()
|
| 309 |
+
chat_type = data.get("chat_type", "general").strip()
|
| 310 |
+
target_id = data.get("target_id")
|
| 311 |
+
group_id = data.get("group_id")
|
| 312 |
+
|
| 313 |
+
if not message:
|
| 314 |
+
return jsonify({"error": "Nội dung tin nhắn trống"}), 400
|
| 315 |
+
|
| 316 |
+
conn = get_user_db_conn()
|
| 317 |
+
try:
|
| 318 |
+
member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 319 |
+
if not member:
|
| 320 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 321 |
+
|
| 322 |
+
sect_id = member["sect_id"]
|
| 323 |
+
|
| 324 |
+
# Validate target / group access
|
| 325 |
+
if chat_type == "group" and group_id:
|
| 326 |
+
group = conn.execute("SELECT members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?", (group_id, sect_id)).fetchone()
|
| 327 |
+
if not group:
|
| 328 |
+
return jsonify({"error": "Nhóm chat không tồn tại"}), 404
|
| 329 |
+
members_list = [int(x) for x in group["members_csv"].split(",") if x.strip()]
|
| 330 |
+
if user["id"] not in members_list:
|
| 331 |
+
return jsonify({"error": "Bạn không có quyền chat trong nhóm này"}), 403
|
| 332 |
+
|
| 333 |
+
enc_msg = encrypt_message(message)
|
| 334 |
+
cursor = conn.execute(
|
| 335 |
+
"""INSERT INTO sect_messages (sect_id, sender_id, message, chat_type, target_id, group_id)
|
| 336 |
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
| 337 |
+
(sect_id, user["id"], enc_msg, chat_type, target_id, group_id)
|
| 338 |
+
)
|
| 339 |
+
msg_id = cursor.lastrowid
|
| 340 |
+
conn.commit()
|
| 341 |
+
return jsonify({"success": True, "msg_id": msg_id})
|
| 342 |
+
except Exception as e:
|
| 343 |
+
logger.error(f"Error sending sect chat: {e}")
|
| 344 |
+
return jsonify({"error": str(e)}), 500
|
| 345 |
+
finally:
|
| 346 |
+
conn.close()
|
| 347 |
+
|
| 348 |
+
@sects_bp.route("/api/sects/chat/groups/create", methods=["POST"])
|
| 349 |
+
@jwt_required
|
| 350 |
+
def create_sect_chat_group():
|
| 351 |
+
user = get_current_user()
|
| 352 |
+
data = request.json or {}
|
| 353 |
+
name = data.get("name", "").strip()
|
| 354 |
+
member_ids = data.get("members", [])
|
| 355 |
+
|
| 356 |
+
if not name:
|
| 357 |
+
return jsonify({"error": "Tên nhóm chat không được để trống"}), 400
|
| 358 |
+
|
| 359 |
+
conn = get_user_db_conn()
|
| 360 |
+
try:
|
| 361 |
+
member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 362 |
+
if not member:
|
| 363 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 364 |
+
|
| 365 |
+
sect_id = member["sect_id"]
|
| 366 |
+
|
| 367 |
+
if user["id"] not in member_ids:
|
| 368 |
+
member_ids.append(user["id"])
|
| 369 |
+
|
| 370 |
+
valid_members = []
|
| 371 |
+
for uid in member_ids:
|
| 372 |
+
is_valid = conn.execute("SELECT 1 FROM sect_members WHERE user_id = ? AND sect_id = ?", (uid, sect_id)).fetchone()
|
| 373 |
+
if is_valid:
|
| 374 |
+
valid_members.append(str(uid))
|
| 375 |
+
|
| 376 |
+
members_csv = ",".join(valid_members)
|
| 377 |
+
|
| 378 |
+
cursor = conn.execute(
|
| 379 |
+
"INSERT INTO sect_chat_groups (sect_id, name, creator_id, members_csv) VALUES (?, ?, ?, ?)",
|
| 380 |
+
(sect_id, name, user["id"], members_csv)
|
| 381 |
+
)
|
| 382 |
+
group_id = cursor.lastrowid
|
| 383 |
+
conn.commit()
|
| 384 |
+
|
| 385 |
+
return jsonify({"success": True, "message": f"Tạo nhóm chat '{name}' thành công!", "group_id": group_id})
|
| 386 |
+
except Exception as e:
|
| 387 |
+
logger.error(f"Error creating sect chat group: {e}")
|
| 388 |
+
return jsonify({"error": str(e)}), 500
|
| 389 |
+
finally:
|
| 390 |
+
conn.close()
|
| 391 |
+
|
| 392 |
+
@sects_bp.route("/api/sects/chat/groups/list", methods=["GET"])
|
| 393 |
+
@jwt_required
|
| 394 |
+
def list_sect_chat_groups():
|
| 395 |
+
user = get_current_user()
|
| 396 |
+
conn = get_user_db_conn()
|
| 397 |
+
try:
|
| 398 |
+
member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 399 |
+
if not member:
|
| 400 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 401 |
+
|
| 402 |
+
sect_id = member["sect_id"]
|
| 403 |
+
groups = conn.execute("SELECT * FROM sect_chat_groups WHERE sect_id = ?", (sect_id,)).fetchall()
|
| 404 |
+
|
| 405 |
+
my_groups = []
|
| 406 |
+
for g in groups:
|
| 407 |
+
members_list = [int(x) for x in g["members_csv"].split(",") if x.strip()]
|
| 408 |
+
if user["id"] in members_list:
|
| 409 |
+
my_groups.append(serialize_row(g))
|
| 410 |
+
|
| 411 |
+
return jsonify({"groups": my_groups})
|
| 412 |
+
except Exception as e:
|
| 413 |
+
logger.error(f"Error listing sect groups: {e}")
|
| 414 |
+
return jsonify({"error": str(e)}), 500
|
| 415 |
+
finally:
|
| 416 |
+
conn.close()
|
| 417 |
+
|
| 418 |
+
@sects_bp.route("/api/sects/contribute", methods=["POST"])
|
| 419 |
+
@jwt_required
|
| 420 |
+
def contribute_sect():
|
| 421 |
+
user = get_current_user()
|
| 422 |
+
data = request.json or {}
|
| 423 |
+
amount = int(data.get("amount", 0))
|
| 424 |
+
|
| 425 |
+
if amount <= 0:
|
| 426 |
+
return jsonify({"error": "Số lượng linh thạch cống hiến phải lớn hơn 0"}), 400
|
| 427 |
+
|
| 428 |
+
conn = get_user_db_conn()
|
| 429 |
+
try:
|
| 430 |
+
member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 431 |
+
if not member:
|
| 432 |
+
return jsonify({"error": "Bạn không ở trong tông môn nào"}), 400
|
| 433 |
+
|
| 434 |
+
sect_id = member["sect_id"]
|
| 435 |
+
|
| 436 |
+
# Increase user contribution
|
| 437 |
+
conn.execute(
|
| 438 |
+
"UPDATE sect_members SET contribution = contribution + ? WHERE user_id = ? AND sect_id = ?",
|
| 439 |
+
(amount, user["id"], sect_id)
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
# Increase sect total contribution
|
| 443 |
+
conn.execute(
|
| 444 |
+
"UPDATE sects SET contribution = contribution + ? WHERE id = ?",
|
| 445 |
+
(amount, sect_id)
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
# Calculate new level (1000 contribution per level)
|
| 449 |
+
sect = conn.execute("SELECT contribution FROM sects WHERE id = ?", (sect_id,)).fetchone()
|
| 450 |
+
new_level = max(1, int(sect["contribution"] / 1000) + 1)
|
| 451 |
+
conn.execute("UPDATE sects SET level = ? WHERE id = ?", (new_level, sect_id))
|
| 452 |
+
|
| 453 |
+
conn.commit()
|
| 454 |
+
return jsonify({
|
| 455 |
+
"success": True,
|
| 456 |
+
"message": f"Cống hiến thành công {amount} linh thạch!",
|
| 457 |
+
"new_level": new_level
|
| 458 |
+
})
|
| 459 |
+
except Exception as e:
|
| 460 |
+
logger.error(f"Error contributing to sect: {e}")
|
| 461 |
+
return jsonify({"error": str(e)}), 500
|
| 462 |
+
finally:
|
| 463 |
+
conn.close()
|
| 464 |
+
|
| 465 |
+
@sects_bp.route("/api/sects/kick", methods=["POST"])
|
| 466 |
+
@jwt_required
|
| 467 |
+
def kick_member():
|
| 468 |
+
user = get_current_user()
|
| 469 |
+
data = request.json or {}
|
| 470 |
+
target_user_id = data.get("user_id")
|
| 471 |
+
|
| 472 |
+
if not target_user_id:
|
| 473 |
+
return jsonify({"error": "Thiếu user_id thành viên cần trục xuất"}), 400
|
| 474 |
+
|
| 475 |
+
conn = get_user_db_conn()
|
| 476 |
+
try:
|
| 477 |
+
# Check if user is leader/elder
|
| 478 |
+
member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 479 |
+
if not member or member["role"] not in ['leader', 'elder']:
|
| 480 |
+
return jsonify({"error": "Bạn không đủ quyền hạn (Chỉ Tông chủ hoặc Trưởng lão)"}), 403
|
| 481 |
+
|
| 482 |
+
sect_id = member["sect_id"]
|
| 483 |
+
|
| 484 |
+
# Check target
|
| 485 |
+
target = conn.execute("SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id)).fetchone()
|
| 486 |
+
if not target:
|
| 487 |
+
return jsonify({"error": "Thành viên này không thuộc tông môn của bạn"}), 400
|
| 488 |
+
|
| 489 |
+
# Elder cannot kick leader or another elder
|
| 490 |
+
if member["role"] == 'elder' and target["role"] in ['leader', 'elder']:
|
| 491 |
+
return jsonify({"error": "Trưởng lão không thể trục xuất Tông chủ hoặc Trưởng lão khác"}), 403
|
| 492 |
+
|
| 493 |
+
if target["role"] == 'leader':
|
| 494 |
+
return jsonify({"error": "Không thể trục xuất Tông chủ"}), 400
|
| 495 |
+
|
| 496 |
+
conn.execute("DELETE FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id))
|
| 497 |
+
conn.commit()
|
| 498 |
+
return jsonify({"success": True, "message": "Đã trục xuất thành viên ra khỏi tông môn!"})
|
| 499 |
+
except Exception as e:
|
| 500 |
+
logger.error(f"Error kicking member: {e}")
|
| 501 |
+
return jsonify({"error": str(e)}), 500
|
| 502 |
+
finally:
|
| 503 |
+
conn.close()
|
| 504 |
+
|
| 505 |
+
# ==========================================
|
| 506 |
+
# ADVANCED SECTS & ALLIANCES FEATURES
|
| 507 |
+
# ==========================================
|
| 508 |
+
|
| 509 |
+
@sects_bp.route("/api/sects/announcement", methods=["POST"])
|
| 510 |
+
@jwt_required
|
| 511 |
+
def update_announcement():
|
| 512 |
+
user = get_current_user()
|
| 513 |
+
data = request.json or {}
|
| 514 |
+
announcement = data.get("announcement", "").strip()
|
| 515 |
+
|
| 516 |
+
conn = get_user_db_conn()
|
| 517 |
+
try:
|
| 518 |
+
member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 519 |
+
if not member or member["role"] not in ['leader', 'elder']:
|
| 520 |
+
return jsonify({"error": "Chỉ Tông chủ hoặc Trưởng lão mới có quyền đổi thông cáo"}), 403
|
| 521 |
+
|
| 522 |
+
conn.execute("UPDATE sects SET announcement = ? WHERE id = ?", (announcement, member["sect_id"]))
|
| 523 |
+
conn.commit()
|
| 524 |
+
return jsonify({"success": True, "message": "Cập nhật thông cáo tông môn thành công!"})
|
| 525 |
+
except Exception as e:
|
| 526 |
+
logger.error(f"Error updating announcement: {e}")
|
| 527 |
+
return jsonify({"error": str(e)}), 500
|
| 528 |
+
finally:
|
| 529 |
+
conn.close()
|
| 530 |
+
|
| 531 |
+
@sects_bp.route("/api/sects/promote", methods=["POST"])
|
| 532 |
+
@jwt_required
|
| 533 |
+
def promote_member():
|
| 534 |
+
user = get_current_user()
|
| 535 |
+
data = request.json or {}
|
| 536 |
+
target_user_id = data.get("user_id")
|
| 537 |
+
new_role = data.get("role", "member").lower() # 'elder' or 'member'
|
| 538 |
+
|
| 539 |
+
if new_role not in ['elder', 'member']:
|
| 540 |
+
return jsonify({"error": "Chức vị không hợp lệ"}), 400
|
| 541 |
+
|
| 542 |
+
conn = get_user_db_conn()
|
| 543 |
+
try:
|
| 544 |
+
member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 545 |
+
if not member or member["role"] != 'leader':
|
| 546 |
+
return jsonify({"error": "Chỉ có Tông chủ mới được thăng/giáng chức đệ tử"}), 403
|
| 547 |
+
|
| 548 |
+
sect_id = member["sect_id"]
|
| 549 |
+
|
| 550 |
+
target = conn.execute("SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id)).fetchone()
|
| 551 |
+
if not target:
|
| 552 |
+
return jsonify({"error": "Thành viên này không thuộc tông môn của bạn"}), 400
|
| 553 |
+
|
| 554 |
+
if target["role"] == 'leader':
|
| 555 |
+
return jsonify({"error": "Không thể thay đổi chức vị của Tông chủ"}), 400
|
| 556 |
+
|
| 557 |
+
conn.execute("UPDATE sect_members SET role = ? WHERE user_id = ? AND sect_id = ?", (new_role, target_user_id, sect_id))
|
| 558 |
+
conn.commit()
|
| 559 |
+
|
| 560 |
+
role_name = "Trưởng Lão" if new_role == 'elder' else "Môn đồ"
|
| 561 |
+
return jsonify({"success": True, "message": f"Đã thăng/giáng chức thành viên thành {role_name}!"})
|
| 562 |
+
except Exception as e:
|
| 563 |
+
logger.error(f"Error promoting member: {e}")
|
| 564 |
+
return jsonify({"error": str(e)}), 500
|
| 565 |
+
finally:
|
| 566 |
+
conn.close()
|
| 567 |
+
|
| 568 |
+
@sects_bp.route("/api/sects/requests/list", methods=["GET"])
|
| 569 |
+
@jwt_required
|
| 570 |
+
def list_join_requests():
|
| 571 |
+
user = get_current_user()
|
| 572 |
+
conn = get_user_db_conn()
|
| 573 |
+
try:
|
| 574 |
+
member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 575 |
+
if not member or member["role"] not in ['leader', 'elder']:
|
| 576 |
+
return jsonify({"error": "Chỉ Tông chủ hoặc Trưởng lão mới xem được danh sách yêu cầu"}), 403
|
| 577 |
+
|
| 578 |
+
rows = conn.execute("""
|
| 579 |
+
SELECT r.id, r.user_id, r.status, r.created_at, u.username, u.avatar
|
| 580 |
+
FROM sect_join_requests r
|
| 581 |
+
LEFT JOIN users u ON r.user_id = u.id
|
| 582 |
+
WHERE r.sect_id = ? AND r.status = 'pending'
|
| 583 |
+
ORDER BY r.created_at DESC
|
| 584 |
+
""", (member["sect_id"],)).fetchall()
|
| 585 |
+
|
| 586 |
+
return jsonify({"requests": [serialize_row(r) for r in rows]})
|
| 587 |
+
except Exception as e:
|
| 588 |
+
logger.error(f"Error listing requests: {e}")
|
| 589 |
+
return jsonify({"error": str(e)}), 500
|
| 590 |
+
finally:
|
| 591 |
+
conn.close()
|
| 592 |
+
|
| 593 |
+
@sects_bp.route("/api/sects/requests/respond", methods=["POST"])
|
| 594 |
+
@jwt_required
|
| 595 |
+
def respond_join_request():
|
| 596 |
+
user = get_current_user()
|
| 597 |
+
data = request.json or {}
|
| 598 |
+
req_id = data.get("request_id")
|
| 599 |
+
action = data.get("action", "").lower() # 'approve' or 'reject'
|
| 600 |
+
|
| 601 |
+
if not req_id or action not in ['approve', 'reject']:
|
| 602 |
+
return jsonify({"error": "Dữ liệu phản hồi không hợp lệ"}), 400
|
| 603 |
+
|
| 604 |
+
conn = get_user_db_conn()
|
| 605 |
+
try:
|
| 606 |
+
member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 607 |
+
if not member or member["role"] not in ['leader', 'elder']:
|
| 608 |
+
return jsonify({"error": "Chỉ Tông chủ hoặc Trưởng lão mới được phê duyệt"}), 403
|
| 609 |
+
|
| 610 |
+
sect_id = member["sect_id"]
|
| 611 |
+
|
| 612 |
+
req = conn.execute("SELECT user_id FROM sect_join_requests WHERE id = ? AND sect_id = ?", (req_id, sect_id)).fetchone()
|
| 613 |
+
if not req:
|
| 614 |
+
return jsonify({"error": "Yêu cầu gia nhập không tồn tại hoặc đã được xử lý"}), 404
|
| 615 |
+
|
| 616 |
+
target_user_id = req["user_id"]
|
| 617 |
+
|
| 618 |
+
if action == 'approve':
|
| 619 |
+
# Check if target is already in a sect
|
| 620 |
+
already_in = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (target_user_id,)).fetchone()
|
| 621 |
+
if already_in:
|
| 622 |
+
conn.execute("DELETE FROM sect_join_requests WHERE id = ?", (req_id,))
|
| 623 |
+
conn.commit()
|
| 624 |
+
return jsonify({"error": "Người dùng này đã gia nhập một tông môn khác, yêu cầu tự động hủy."}), 400
|
| 625 |
+
|
| 626 |
+
# Add member
|
| 627 |
+
conn.execute(
|
| 628 |
+
"INSERT INTO sect_members (sect_id, user_id, role, contribution) VALUES (?, ?, 'member', 0)",
|
| 629 |
+
(sect_id, target_user_id)
|
| 630 |
+
)
|
| 631 |
+
# Delete target requests from all other sects
|
| 632 |
+
conn.execute("DELETE FROM sect_join_requests WHERE user_id = ?", (target_user_id,))
|
| 633 |
+
msg = "Đã phê duyệt gia nhập tông môn!"
|
| 634 |
+
else:
|
| 635 |
+
# Reject
|
| 636 |
+
conn.execute("DELETE FROM sect_join_requests WHERE id = ?", (req_id,))
|
| 637 |
+
msg = "Đã từ chối yêu cầu gia nhập!"
|
| 638 |
+
|
| 639 |
+
conn.commit()
|
| 640 |
+
return jsonify({"success": True, "message": msg})
|
| 641 |
+
except Exception as e:
|
| 642 |
+
logger.error(f"Error responding join request: {e}")
|
| 643 |
+
return jsonify({"error": str(e)}), 500
|
| 644 |
+
finally:
|
| 645 |
+
conn.close()
|
| 646 |
+
|
| 647 |
+
# ==========================================
|
| 648 |
+
# SECT LIBRARY (BOOK SHELF SHARING)
|
| 649 |
+
# ==========================================
|
| 650 |
+
|
| 651 |
+
@sects_bp.route("/api/sects/library/list", methods=["GET"])
|
| 652 |
+
@jwt_required
|
| 653 |
+
def list_sect_library():
|
| 654 |
+
user = get_current_user()
|
| 655 |
+
conn = get_user_db_conn()
|
| 656 |
+
try:
|
| 657 |
+
member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 658 |
+
if not member:
|
| 659 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 660 |
+
|
| 661 |
+
sect_id = member["sect_id"]
|
| 662 |
+
|
| 663 |
+
# We need to join with the main books database to fetch book title and cover
|
| 664 |
+
# Since SQLite can ATTACH databases, or we can fetch metadata, we'll fetch from sect_books
|
| 665 |
+
# and query titles from books database. Let's do it cleanly:
|
| 666 |
+
rows = conn.execute("""
|
| 667 |
+
SELECT sb.id, sb.book_id, sb.added_at, u.username as added_by_name
|
| 668 |
+
FROM sect_books sb
|
| 669 |
+
LEFT JOIN users u ON sb.added_by = u.id
|
| 670 |
+
WHERE sb.sect_id = ?
|
| 671 |
+
ORDER BY sb.added_at DESC
|
| 672 |
+
""", (sect_id,)).fetchall()
|
| 673 |
+
|
| 674 |
+
from backend.database.db_manager import get_db
|
| 675 |
+
main_conn = get_db()
|
| 676 |
+
|
| 677 |
+
book_list = []
|
| 678 |
+
for r in rows:
|
| 679 |
+
book_row = main_conn.execute("""
|
| 680 |
+
SELECT id, title, title_vietphrase, title_hanviet, author, cover, description
|
| 681 |
+
FROM books WHERE id = ?
|
| 682 |
+
""", (r["book_id"],)).fetchone()
|
| 683 |
+
if book_row:
|
| 684 |
+
b = serialize_row(book_row)
|
| 685 |
+
b["added_by_name"] = r["added_by_name"]
|
| 686 |
+
b["added_at"] = r["added_at"].isoformat() if hasattr(r["added_at"], "isoformat") else r["added_at"]
|
| 687 |
+
book_list.append(b)
|
| 688 |
+
|
| 689 |
+
return jsonify({"books": book_list})
|
| 690 |
+
except Exception as e:
|
| 691 |
+
logger.error(f"Error listing sect library: {e}")
|
| 692 |
+
return jsonify({"error": str(e)}), 500
|
| 693 |
+
finally:
|
| 694 |
+
conn.close()
|
| 695 |
+
|
| 696 |
+
@sects_bp.route("/api/sects/library/add", methods=["POST"])
|
| 697 |
+
@jwt_required
|
| 698 |
+
def add_to_sect_library():
|
| 699 |
+
user = get_current_user()
|
| 700 |
+
data = request.json or {}
|
| 701 |
+
book_id = data.get("book_id")
|
| 702 |
+
|
| 703 |
+
if not book_id:
|
| 704 |
+
return jsonify({"error": "Thiếu book_id truyện đóng góp"}), 400
|
| 705 |
+
|
| 706 |
+
conn = get_user_db_conn()
|
| 707 |
+
try:
|
| 708 |
+
member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 709 |
+
if not member:
|
| 710 |
+
return jsonify({"error": "Bạn không thuộc tông môn nào"}), 400
|
| 711 |
+
|
| 712 |
+
sect_id = member["sect_id"]
|
| 713 |
+
|
| 714 |
+
# Check if already added
|
| 715 |
+
exists = conn.execute("SELECT id FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id)).fetchone()
|
| 716 |
+
if exists:
|
| 717 |
+
return jsonify({"error": "Truyện này đã được đệ tử khác đóng góp vào thư viện tông môn từ trước"}), 400
|
| 718 |
+
|
| 719 |
+
conn.execute(
|
| 720 |
+
"INSERT INTO sect_books (sect_id, book_id, added_by) VALUES (?, ?, ?)",
|
| 721 |
+
(sect_id, book_id, user["id"])
|
| 722 |
+
)
|
| 723 |
+
|
| 724 |
+
# Reward user 20 contribution points for sharing a book
|
| 725 |
+
conn.execute(
|
| 726 |
+
"UPDATE sect_members SET contribution = contribution + 20 WHERE user_id = ? AND sect_id = ?",
|
| 727 |
+
(user["id"], sect_id)
|
| 728 |
+
)
|
| 729 |
+
# Increase sect total contribution
|
| 730 |
+
conn.execute(
|
| 731 |
+
"UPDATE sects SET contribution = contribution + 20 WHERE id = ?",
|
| 732 |
+
(sect_id,)
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
conn.commit()
|
| 736 |
+
return jsonify({"success": True, "message": "Chia sẻ truyện vào Thư viện Tông môn thành công! Nhận 20 điểm cống hiến."})
|
| 737 |
+
except Exception as e:
|
| 738 |
+
logger.error(f"Error adding to sect library: {e}")
|
| 739 |
+
return jsonify({"error": str(e)}), 500
|
| 740 |
+
finally:
|
| 741 |
+
conn.close()
|
| 742 |
+
|
| 743 |
+
@sects_bp.route("/api/sects/library/remove", methods=["POST"])
|
| 744 |
+
@jwt_required
|
| 745 |
+
def remove_from_sect_library():
|
| 746 |
+
user = get_current_user()
|
| 747 |
+
data = request.json or {}
|
| 748 |
+
book_id = data.get("book_id")
|
| 749 |
+
|
| 750 |
+
if not book_id:
|
| 751 |
+
return jsonify({"error": "Thiếu book_id truyện cần gỡ"}), 400
|
| 752 |
+
|
| 753 |
+
conn = get_user_db_conn()
|
| 754 |
+
try:
|
| 755 |
+
member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
|
| 756 |
+
if not member:
|
| 757 |
+
return jsonify({"error": "Bạn không thuộc tông môn nào"}), 400
|
| 758 |
+
|
| 759 |
+
sect_id = member["sect_id"]
|
| 760 |
+
|
| 761 |
+
# Check who added it
|
| 762 |
+
book = conn.execute("SELECT added_by FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id)).fetchone()
|
| 763 |
+
if not book:
|
| 764 |
+
return jsonify({"error": "Truyện không có trong thư viện tông môn"}), 404
|
| 765 |
+
|
| 766 |
+
# Only leader, elder, or the original adder can remove
|
| 767 |
+
if member["role"] not in ['leader', 'elder'] and book["added_by"] != user["id"]:
|
| 768 |
+
return jsonify({"error": "Bạn không có quyền gỡ truyện này (Chỉ Tông chủ, Trưởng lão hoặc người đóng góp)"}), 403
|
| 769 |
+
|
| 770 |
+
conn.execute("DELETE FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id))
|
| 771 |
+
conn.commit()
|
| 772 |
+
return jsonify({"success": True, "message": "Đã gỡ truyện khỏi thư viện Tông môn!"})
|
| 773 |
+
except Exception as e:
|
| 774 |
+
logger.error(f"Error removing from sect library: {e}")
|
| 775 |
+
return jsonify({"error": str(e)}), 500
|
| 776 |
+
finally:
|
| 777 |
+
conn.close()
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
# ==========================================
|
| 781 |
+
# SECT SEARCH & PROFILE BY ID
|
| 782 |
+
# ==========================================
|
| 783 |
+
|
| 784 |
+
ROLE_ORDER = {
|
| 785 |
+
"leader": 1,
|
| 786 |
+
"vice_leader": 2,
|
| 787 |
+
"elder": 3,
|
| 788 |
+
"inner_disciple": 4,
|
| 789 |
+
"member": 5,
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
ROLE_LABELS = {
|
| 793 |
+
"leader": "Tông chủ",
|
| 794 |
+
"vice_leader": "Phó Tông chủ",
|
| 795 |
+
"elder": "Trưởng lão",
|
| 796 |
+
"inner_disciple": "Nội môn đệ tử",
|
| 797 |
+
"member": "Ngoại môn đệ tử",
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
VALID_ROLES = list(ROLE_ORDER.keys())
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
@sects_bp.route("/api/sects/search", methods=["GET"])
|
| 804 |
+
def search_sects():
|
| 805 |
+
"""Search sects by name or slogan. No auth required."""
|
| 806 |
+
q = request.args.get("q", "").strip()
|
| 807 |
+
page = int(request.args.get("page", 1))
|
| 808 |
+
per_page = int(request.args.get("per_page", 20))
|
| 809 |
+
offset = (page - 1) * per_page
|
| 810 |
+
|
| 811 |
+
conn = get_user_db_conn()
|
| 812 |
+
try:
|
| 813 |
+
if q:
|
| 814 |
+
rows = conn.execute("""
|
| 815 |
+
SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
|
| 816 |
+
u.username as leader_name,
|
| 817 |
+
(SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
|
| 818 |
+
FROM sects s
|
| 819 |
+
LEFT JOIN users u ON s.leader_id = u.id
|
| 820 |
+
WHERE s.name LIKE ? OR s.slogan LIKE ?
|
| 821 |
+
ORDER BY s.contribution DESC, s.created_at DESC
|
| 822 |
+
LIMIT ? OFFSET ?
|
| 823 |
+
""", (f"%{q}%", f"%{q}%", per_page, offset)).fetchall()
|
| 824 |
+
|
| 825 |
+
total = conn.execute(
|
| 826 |
+
"SELECT COUNT(*) as cnt FROM sects WHERE name LIKE ? OR slogan LIKE ?",
|
| 827 |
+
(f"%{q}%", f"%{q}%")
|
| 828 |
+
).fetchone()["cnt"]
|
| 829 |
+
else:
|
| 830 |
+
rows = conn.execute("""
|
| 831 |
+
SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
|
| 832 |
+
u.username as leader_name,
|
| 833 |
+
(SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
|
| 834 |
+
FROM sects s
|
| 835 |
+
LEFT JOIN users u ON s.leader_id = u.id
|
| 836 |
+
ORDER BY s.contribution DESC, s.created_at DESC
|
| 837 |
+
LIMIT ? OFFSET ?
|
| 838 |
+
""", (per_page, offset)).fetchall()
|
| 839 |
+
total = conn.execute("SELECT COUNT(*) as cnt FROM sects").fetchone()["cnt"]
|
| 840 |
+
|
| 841 |
+
return jsonify({
|
| 842 |
+
"sects": [serialize_row(r) for r in rows],
|
| 843 |
+
"total": total,
|
| 844 |
+
"page": page,
|
| 845 |
+
"per_page": per_page,
|
| 846 |
+
"total_pages": max(1, (total + per_page - 1) // per_page)
|
| 847 |
+
})
|
| 848 |
+
except Exception as e:
|
| 849 |
+
logger.error(f"Error searching sects: {e}")
|
| 850 |
+
return jsonify({"error": str(e)}), 500
|
| 851 |
+
finally:
|
| 852 |
+
conn.close()
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
@sects_bp.route("/api/sects/<int:sect_id>", methods=["GET"])
|
| 856 |
+
def get_sect_by_id(sect_id):
|
| 857 |
+
"""View full sect profile by ID. No auth required for basic info."""
|
| 858 |
+
conn = get_user_db_conn()
|
| 859 |
+
try:
|
| 860 |
+
sect = conn.execute("""
|
| 861 |
+
SELECT s.id, s.name, s.slogan, s.announcement, s.badge, s.level, s.contribution, s.created_at,
|
| 862 |
+
u.username as leader_name, u.id as leader_id, u.avatar as leader_avatar
|
| 863 |
+
FROM sects s
|
| 864 |
+
LEFT JOIN users u ON s.leader_id = u.id
|
| 865 |
+
WHERE s.id = ?
|
| 866 |
+
""", (sect_id,)).fetchone()
|
| 867 |
+
|
| 868 |
+
if not sect:
|
| 869 |
+
return jsonify({"error": "Tông môn không tồn tại"}), 404
|
| 870 |
+
|
| 871 |
+
members = conn.execute("""
|
| 872 |
+
SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
|
| 873 |
+
u.username, u.avatar,
|
| 874 |
+
COALESCE(sm.role, 'member') as role
|
| 875 |
+
FROM sect_members sm
|
| 876 |
+
LEFT JOIN users u ON sm.user_id = u.id
|
| 877 |
+
WHERE sm.sect_id = ?
|
| 878 |
+
ORDER BY
|
| 879 |
+
CASE sm.role
|
| 880 |
+
WHEN 'leader' THEN 1
|
| 881 |
+
WHEN 'vice_leader' THEN 2
|
| 882 |
+
WHEN 'elder' THEN 3
|
| 883 |
+
WHEN 'inner_disciple' THEN 4
|
| 884 |
+
ELSE 5
|
| 885 |
+
END,
|
| 886 |
+
sm.contribution DESC
|
| 887 |
+
""", (sect_id,)).fetchall()
|
| 888 |
+
|
| 889 |
+
# Enrich members with Vietnamese role labels
|
| 890 |
+
member_list = []
|
| 891 |
+
for m in members:
|
| 892 |
+
md = serialize_row(m)
|
| 893 |
+
md["role_label"] = ROLE_LABELS.get(m["role"], "Môn đồ")
|
| 894 |
+
member_list.append(md)
|
| 895 |
+
|
| 896 |
+
# Count by role
|
| 897 |
+
role_counts = {}
|
| 898 |
+
for m in member_list:
|
| 899 |
+
r = m["role"]
|
| 900 |
+
role_counts[r] = role_counts.get(r, 0) + 1
|
| 901 |
+
|
| 902 |
+
return jsonify({
|
| 903 |
+
"sect": serialize_row(sect),
|
| 904 |
+
"members": member_list,
|
| 905 |
+
"member_count": len(member_list),
|
| 906 |
+
"role_counts": role_counts,
|
| 907 |
+
"role_labels": ROLE_LABELS,
|
| 908 |
+
})
|
| 909 |
+
except Exception as e:
|
| 910 |
+
logger.error(f"Error fetching sect {sect_id}: {e}")
|
| 911 |
+
return jsonify({"error": str(e)}), 500
|
| 912 |
+
finally:
|
| 913 |
+
conn.close()
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
# ==========================================
|
| 917 |
+
# FULL RANK PROMOTION SYSTEM (5 Ranks)
|
| 918 |
+
# ==========================================
|
| 919 |
+
|
| 920 |
+
@sects_bp.route("/api/sects/promote/rank", methods=["POST"])
|
| 921 |
+
@jwt_required
|
| 922 |
+
def promote_member_rank():
|
| 923 |
+
"""
|
| 924 |
+
Full 5-rank promotion system:
|
| 925 |
+
leader > vice_leader > elder > inner_disciple > member
|
| 926 |
+
Only leader can set: vice_leader, elder, inner_disciple, member
|
| 927 |
+
vice_leader can set: elder, inner_disciple, member
|
| 928 |
+
elder can set: inner_disciple, member
|
| 929 |
+
"""
|
| 930 |
+
user = get_current_user()
|
| 931 |
+
data = request.json or {}
|
| 932 |
+
target_user_id = data.get("user_id")
|
| 933 |
+
new_role = data.get("role", "").lower()
|
| 934 |
+
|
| 935 |
+
if new_role not in VALID_ROLES or new_role == "leader":
|
| 936 |
+
return jsonify({
|
| 937 |
+
"error": f"Chức vị không hợp lệ. Các chức vị có thể gán: {', '.join(VALID_ROLES[1:])}",
|
| 938 |
+
"valid_roles": VALID_ROLES[1:],
|
| 939 |
+
"role_labels": ROLE_LABELS,
|
| 940 |
+
}), 400
|
| 941 |
+
|
| 942 |
+
if not target_user_id:
|
| 943 |
+
return jsonify({"error": "Thiếu user_id"}), 400
|
| 944 |
+
|
| 945 |
+
conn = get_user_db_conn()
|
| 946 |
+
try:
|
| 947 |
+
caller = conn.execute(
|
| 948 |
+
"SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
|
| 949 |
+
).fetchone()
|
| 950 |
+
if not caller:
|
| 951 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 952 |
+
|
| 953 |
+
sect_id = caller["sect_id"]
|
| 954 |
+
caller_role = caller["role"]
|
| 955 |
+
caller_order = ROLE_ORDER.get(caller_role, 99)
|
| 956 |
+
new_role_order = ROLE_ORDER.get(new_role, 99)
|
| 957 |
+
|
| 958 |
+
# Permission matrix: caller must outrank new_role
|
| 959 |
+
if caller_order >= new_role_order:
|
| 960 |
+
return jsonify({
|
| 961 |
+
"error": f"Bạn ({ROLE_LABELS.get(caller_role)}) không đủ quyền gán chức vị {ROLE_LABELS.get(new_role)}"
|
| 962 |
+
}), 403
|
| 963 |
+
|
| 964 |
+
target = conn.execute(
|
| 965 |
+
"SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?",
|
| 966 |
+
(target_user_id, sect_id)
|
| 967 |
+
).fetchone()
|
| 968 |
+
if not target:
|
| 969 |
+
return jsonify({"error": "Thành viên không thuộc tông môn của bạn"}), 404
|
| 970 |
+
|
| 971 |
+
if target["role"] == "leader":
|
| 972 |
+
return jsonify({"error": "Không thể thay đổi chức vị của Tông chủ"}), 400
|
| 973 |
+
|
| 974 |
+
target_order = ROLE_ORDER.get(target["role"], 99)
|
| 975 |
+
if caller_order >= target_order:
|
| 976 |
+
return jsonify({
|
| 977 |
+
"error": f"Bạn ({ROLE_LABELS.get(caller_role)}) không thể thay đổi chức vị của người ngang hoặc cao hơn bạn"
|
| 978 |
+
}), 403
|
| 979 |
+
|
| 980 |
+
conn.execute(
|
| 981 |
+
"UPDATE sect_members SET role = ? WHERE user_id = ? AND sect_id = ?",
|
| 982 |
+
(new_role, target_user_id, sect_id)
|
| 983 |
+
)
|
| 984 |
+
conn.commit()
|
| 985 |
+
|
| 986 |
+
return jsonify({
|
| 987 |
+
"success": True,
|
| 988 |
+
"message": f"Đã gán chức vị {ROLE_LABELS.get(new_role)} thành công!",
|
| 989 |
+
"new_role": new_role,
|
| 990 |
+
"new_role_label": ROLE_LABELS.get(new_role),
|
| 991 |
+
})
|
| 992 |
+
except Exception as e:
|
| 993 |
+
logger.error(f"Error promoting rank: {e}")
|
| 994 |
+
return jsonify({"error": str(e)}), 500
|
| 995 |
+
finally:
|
| 996 |
+
conn.close()
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
@sects_bp.route("/api/sects/members", methods=["GET"])
|
| 1000 |
+
@jwt_required
|
| 1001 |
+
def list_sect_members():
|
| 1002 |
+
"""List all members of current user's sect with full role info."""
|
| 1003 |
+
user = get_current_user()
|
| 1004 |
+
role_filter = request.args.get("role")
|
| 1005 |
+
conn = get_user_db_conn()
|
| 1006 |
+
try:
|
| 1007 |
+
member = conn.execute(
|
| 1008 |
+
"SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
|
| 1009 |
+
).fetchone()
|
| 1010 |
+
if not member:
|
| 1011 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 1012 |
+
|
| 1013 |
+
sect_id = member["sect_id"]
|
| 1014 |
+
|
| 1015 |
+
if role_filter and role_filter in VALID_ROLES:
|
| 1016 |
+
rows = conn.execute("""
|
| 1017 |
+
SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
|
| 1018 |
+
u.username, u.avatar
|
| 1019 |
+
FROM sect_members sm
|
| 1020 |
+
LEFT JOIN users u ON sm.user_id = u.id
|
| 1021 |
+
WHERE sm.sect_id = ? AND sm.role = ?
|
| 1022 |
+
ORDER BY sm.contribution DESC
|
| 1023 |
+
""", (sect_id, role_filter)).fetchall()
|
| 1024 |
+
else:
|
| 1025 |
+
rows = conn.execute("""
|
| 1026 |
+
SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
|
| 1027 |
+
u.username, u.avatar
|
| 1028 |
+
FROM sect_members sm
|
| 1029 |
+
LEFT JOIN users u ON sm.user_id = u.id
|
| 1030 |
+
WHERE sm.sect_id = ?
|
| 1031 |
+
ORDER BY
|
| 1032 |
+
CASE sm.role
|
| 1033 |
+
WHEN 'leader' THEN 1
|
| 1034 |
+
WHEN 'vice_leader' THEN 2
|
| 1035 |
+
WHEN 'elder' THEN 3
|
| 1036 |
+
WHEN 'inner_disciple' THEN 4
|
| 1037 |
+
ELSE 5
|
| 1038 |
+
END,
|
| 1039 |
+
sm.contribution DESC
|
| 1040 |
+
""", (sect_id,)).fetchall()
|
| 1041 |
+
|
| 1042 |
+
member_list = []
|
| 1043 |
+
for r in rows:
|
| 1044 |
+
rd = serialize_row(r)
|
| 1045 |
+
rd["role_label"] = ROLE_LABELS.get(r["role"], "Môn đồ")
|
| 1046 |
+
member_list.append(rd)
|
| 1047 |
+
|
| 1048 |
+
return jsonify({
|
| 1049 |
+
"members": member_list,
|
| 1050 |
+
"total": len(member_list),
|
| 1051 |
+
"role_labels": ROLE_LABELS,
|
| 1052 |
+
"valid_roles": VALID_ROLES,
|
| 1053 |
+
})
|
| 1054 |
+
except Exception as e:
|
| 1055 |
+
logger.error(f"Error listing members: {e}")
|
| 1056 |
+
return jsonify({"error": str(e)}), 500
|
| 1057 |
+
finally:
|
| 1058 |
+
conn.close()
|
| 1059 |
+
|
| 1060 |
+
|
| 1061 |
+
# ==========================================
|
| 1062 |
+
# SECT CHAT GROUPS – MANAGE MEMBERS
|
| 1063 |
+
# ==========================================
|
| 1064 |
+
|
| 1065 |
+
@sects_bp.route("/api/sects/chat/groups/<int:group_id>/members/add", methods=["POST"])
|
| 1066 |
+
@jwt_required
|
| 1067 |
+
def add_to_chat_group(group_id):
|
| 1068 |
+
"""Add member(s) to a sub-group chat."""
|
| 1069 |
+
user = get_current_user()
|
| 1070 |
+
data = request.json or {}
|
| 1071 |
+
new_member_ids = data.get("user_ids", [])
|
| 1072 |
+
|
| 1073 |
+
conn = get_user_db_conn()
|
| 1074 |
+
try:
|
| 1075 |
+
my_member = conn.execute(
|
| 1076 |
+
"SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
|
| 1077 |
+
).fetchone()
|
| 1078 |
+
if not my_member:
|
| 1079 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 1080 |
+
|
| 1081 |
+
sect_id = my_member["sect_id"]
|
| 1082 |
+
group = conn.execute(
|
| 1083 |
+
"SELECT id, creator_id, members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
|
| 1084 |
+
(group_id, sect_id)
|
| 1085 |
+
).fetchone()
|
| 1086 |
+
|
| 1087 |
+
if not group:
|
| 1088 |
+
return jsonify({"error": "Nhóm chat không tồn tại"}), 404
|
| 1089 |
+
|
| 1090 |
+
# Only group creator, leader, vice_leader, elder can add
|
| 1091 |
+
if group["creator_id"] != user["id"] and my_member["role"] not in ["leader", "vice_leader", "elder"]:
|
| 1092 |
+
return jsonify({"error": "Chỉ người tạo nhóm hoặc Ban quản lý mới có thể thêm thành viên"}), 403
|
| 1093 |
+
|
| 1094 |
+
current_members = [int(x) for x in group["members_csv"].split(",") if x.strip()]
|
| 1095 |
+
added = []
|
| 1096 |
+
for uid in new_member_ids:
|
| 1097 |
+
uid = int(uid)
|
| 1098 |
+
if uid not in current_members:
|
| 1099 |
+
# Check uid is in sect
|
| 1100 |
+
in_sect = conn.execute(
|
| 1101 |
+
"SELECT 1 FROM sect_members WHERE user_id = ? AND sect_id = ?", (uid, sect_id)
|
| 1102 |
+
).fetchone()
|
| 1103 |
+
if in_sect:
|
| 1104 |
+
current_members.append(uid)
|
| 1105 |
+
added.append(uid)
|
| 1106 |
+
|
| 1107 |
+
new_csv = ",".join(str(x) for x in current_members)
|
| 1108 |
+
conn.execute(
|
| 1109 |
+
"UPDATE sect_chat_groups SET members_csv = ? WHERE id = ?",
|
| 1110 |
+
(new_csv, group_id)
|
| 1111 |
+
)
|
| 1112 |
+
conn.commit()
|
| 1113 |
+
return jsonify({"success": True, "added": added, "members": current_members})
|
| 1114 |
+
except Exception as e:
|
| 1115 |
+
logger.error(f"Error adding to chat group: {e}")
|
| 1116 |
+
return jsonify({"error": str(e)}), 500
|
| 1117 |
+
finally:
|
| 1118 |
+
conn.close()
|
| 1119 |
+
|
| 1120 |
+
|
| 1121 |
+
@sects_bp.route("/api/sects/chat/groups/<int:group_id>/members/remove", methods=["POST"])
|
| 1122 |
+
@jwt_required
|
| 1123 |
+
def remove_from_chat_group(group_id):
|
| 1124 |
+
"""Remove a member from a sub-group chat."""
|
| 1125 |
+
user = get_current_user()
|
| 1126 |
+
data = request.json or {}
|
| 1127 |
+
target_id = data.get("user_id")
|
| 1128 |
+
|
| 1129 |
+
conn = get_user_db_conn()
|
| 1130 |
+
try:
|
| 1131 |
+
my_member = conn.execute(
|
| 1132 |
+
"SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
|
| 1133 |
+
).fetchone()
|
| 1134 |
+
if not my_member:
|
| 1135 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 1136 |
+
|
| 1137 |
+
sect_id = my_member["sect_id"]
|
| 1138 |
+
group = conn.execute(
|
| 1139 |
+
"SELECT id, creator_id, members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
|
| 1140 |
+
(group_id, sect_id)
|
| 1141 |
+
).fetchone()
|
| 1142 |
+
if not group:
|
| 1143 |
+
return jsonify({"error": "Nhóm chat không tồn tại"}), 404
|
| 1144 |
+
|
| 1145 |
+
if group["creator_id"] != user["id"] and my_member["role"] not in ["leader", "vice_leader", "elder"]:
|
| 1146 |
+
return jsonify({"error": "Không đủ quyền xóa thành viên khỏi nhóm chat"}), 403
|
| 1147 |
+
|
| 1148 |
+
current_members = [int(x) for x in group["members_csv"].split(",") if x.strip()]
|
| 1149 |
+
if int(target_id) not in current_members:
|
| 1150 |
+
return jsonify({"error": "Thành viên không có trong nhóm chat này"}), 400
|
| 1151 |
+
|
| 1152 |
+
current_members.remove(int(target_id))
|
| 1153 |
+
new_csv = ",".join(str(x) for x in current_members)
|
| 1154 |
+
conn.execute("UPDATE sect_chat_groups SET members_csv = ? WHERE id = ?", (new_csv, group_id))
|
| 1155 |
+
conn.commit()
|
| 1156 |
+
return jsonify({"success": True, "members": current_members})
|
| 1157 |
+
except Exception as e:
|
| 1158 |
+
logger.error(f"Error removing from chat group: {e}")
|
| 1159 |
+
return jsonify({"error": str(e)}), 500
|
| 1160 |
+
finally:
|
| 1161 |
+
conn.close()
|
| 1162 |
+
|
| 1163 |
+
|
| 1164 |
+
@sects_bp.route("/api/sects/chat/groups/<int:group_id>", methods=["GET"])
|
| 1165 |
+
@jwt_required
|
| 1166 |
+
def get_chat_group_info(group_id):
|
| 1167 |
+
"""Get info and member list of a sub-group chat."""
|
| 1168 |
+
user = get_current_user()
|
| 1169 |
+
conn = get_user_db_conn()
|
| 1170 |
+
try:
|
| 1171 |
+
my_member = conn.execute(
|
| 1172 |
+
"SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
|
| 1173 |
+
).fetchone()
|
| 1174 |
+
if not my_member:
|
| 1175 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 1176 |
+
|
| 1177 |
+
sect_id = my_member["sect_id"]
|
| 1178 |
+
group = conn.execute(
|
| 1179 |
+
"SELECT * FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
|
| 1180 |
+
(group_id, sect_id)
|
| 1181 |
+
).fetchone()
|
| 1182 |
+
if not group:
|
| 1183 |
+
return jsonify({"error": "Nhóm chat không tồn tại"}), 404
|
| 1184 |
+
|
| 1185 |
+
member_ids = [int(x) for x in group["members_csv"].split(",") if x.strip()]
|
| 1186 |
+
if user["id"] not in member_ids:
|
| 1187 |
+
return jsonify({"error": "Bạn không phải thành viên của nhóm chat này"}), 403
|
| 1188 |
+
|
| 1189 |
+
# Fetch member details
|
| 1190 |
+
members = []
|
| 1191 |
+
for uid in member_ids:
|
| 1192 |
+
row = conn.execute("""
|
| 1193 |
+
SELECT sm.user_id, sm.role, sm.contribution, u.username, u.avatar
|
| 1194 |
+
FROM sect_members sm
|
| 1195 |
+
LEFT JOIN users u ON sm.user_id = u.id
|
| 1196 |
+
WHERE sm.user_id = ? AND sm.sect_id = ?
|
| 1197 |
+
""", (uid, sect_id)).fetchone()
|
| 1198 |
+
if row:
|
| 1199 |
+
rd = serialize_row(row)
|
| 1200 |
+
rd["role_label"] = ROLE_LABELS.get(row["role"], "Môn đồ")
|
| 1201 |
+
members.append(rd)
|
| 1202 |
+
|
| 1203 |
+
return jsonify({
|
| 1204 |
+
"group": serialize_row(group),
|
| 1205 |
+
"members": members,
|
| 1206 |
+
"member_count": len(members),
|
| 1207 |
+
})
|
| 1208 |
+
except Exception as e:
|
| 1209 |
+
logger.error(f"Error fetching chat group: {e}")
|
| 1210 |
+
return jsonify({"error": str(e)}), 500
|
| 1211 |
+
finally:
|
| 1212 |
+
conn.close()
|
| 1213 |
+
|
| 1214 |
+
|
| 1215 |
+
@sects_bp.route("/api/sects/chat/groups", methods=["GET"])
|
| 1216 |
+
@jwt_required
|
| 1217 |
+
def list_all_sect_chat_groups():
|
| 1218 |
+
"""List all sub-group chats the current user is part of in their sect."""
|
| 1219 |
+
user = get_current_user()
|
| 1220 |
+
conn = get_user_db_conn()
|
| 1221 |
+
try:
|
| 1222 |
+
my_member = conn.execute(
|
| 1223 |
+
"SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
|
| 1224 |
+
).fetchone()
|
| 1225 |
+
if not my_member:
|
| 1226 |
+
return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
|
| 1227 |
+
|
| 1228 |
+
sect_id = my_member["sect_id"]
|
| 1229 |
+
groups = conn.execute(
|
| 1230 |
+
"SELECT id, name, creator_id, members_csv, created_at FROM sect_chat_groups WHERE sect_id = ?",
|
| 1231 |
+
(sect_id,)
|
| 1232 |
+
).fetchall()
|
| 1233 |
+
|
| 1234 |
+
result = []
|
| 1235 |
+
for g in groups:
|
| 1236 |
+
member_ids = [int(x) for x in g["members_csv"].split(",") if x.strip()]
|
| 1237 |
+
if user["id"] in member_ids:
|
| 1238 |
+
gd = serialize_row(g)
|
| 1239 |
+
gd["member_count"] = len(member_ids)
|
| 1240 |
+
result.append(gd)
|
| 1241 |
+
|
| 1242 |
+
return jsonify({"groups": result})
|
| 1243 |
+
except Exception as e:
|
| 1244 |
+
logger.error(f"Error listing chat groups: {e}")
|
| 1245 |
+
return jsonify({"error": str(e)}), 500
|
| 1246 |
+
finally:
|
| 1247 |
+
conn.close()
|
| 1248 |
+
|
backend/api/translate.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
import os
|
| 3 |
+
import requests as py_requests
|
| 4 |
+
from flask import Blueprint, request, jsonify, Response, session, current_app
|
| 5 |
+
from backend.config import Config
|
| 6 |
+
from backend.core.decorators import get_current_user
|
| 7 |
+
from backend.core.rate_limit import get_client_ip
|
| 8 |
+
from backend.core.security import verify_access_token
|
| 9 |
+
from backend.services.translation import (
|
| 10 |
+
get_engine, translate_texts, translate_stream_generator,
|
| 11 |
+
translation_limit_tracker, call_ai_chat_proxy
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
translate_bp = Blueprint("translate", __name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def is_vip_request():
|
| 18 |
+
"""Check if current request has VIP privileges (session, JWT, or header code)."""
|
| 19 |
+
user = get_current_user()
|
| 20 |
+
if user and user.get("vip_status", 0) == 1:
|
| 21 |
+
return True
|
| 22 |
+
|
| 23 |
+
# Check headers
|
| 24 |
+
vip_code = request.headers.get("X-VIP-Code", "") or request.headers.get("X-VIP-Key", "")
|
| 25 |
+
if not vip_code:
|
| 26 |
+
# Check request body
|
| 27 |
+
try:
|
| 28 |
+
data = request.json or {}
|
| 29 |
+
vip_code = data.get("vip_key", "") or data.get("vip_code", "")
|
| 30 |
+
except:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
if vip_code and vip_code in Config.VALID_VIP_CODES:
|
| 34 |
+
return True
|
| 35 |
+
return False
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _check_translation_rate_limit(texts):
|
| 39 |
+
"""Returns (exceeded: bool, tracker_key, count) for standard users."""
|
| 40 |
+
client_ip = get_client_ip()
|
| 41 |
+
today_str = datetime.date.today().isoformat()
|
| 42 |
+
tracker_key = f"{client_ip}:{today_str}"
|
| 43 |
+
current_count = translation_limit_tracker.get(tracker_key, 0)
|
| 44 |
+
requested_count = len([t for t in texts if t.strip()])
|
| 45 |
+
exceeded = (current_count + requested_count) > 50
|
| 46 |
+
return exceeded, tracker_key, requested_count
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@translate_bp.route("/translate", methods=["POST"])
|
| 50 |
+
def translate():
|
| 51 |
+
data = request.json
|
| 52 |
+
if not data or "texts" not in data:
|
| 53 |
+
return jsonify({"error": "Missing 'texts' array"}), 400
|
| 54 |
+
|
| 55 |
+
texts = data["texts"]
|
| 56 |
+
|
| 57 |
+
if not is_vip_request():
|
| 58 |
+
exceeded, tracker_key, count = _check_translation_rate_limit(texts)
|
| 59 |
+
if exceeded:
|
| 60 |
+
return jsonify({
|
| 61 |
+
"error": "Hạn mức dịch máy chủ Standard đã hết (50 đoạn/ngày). Vui lòng nâng cấp tài khoản VIP!"
|
| 62 |
+
}), 403
|
| 63 |
+
translation_limit_tracker[tracker_key] = translation_limit_tracker.get(tracker_key, 0) + count
|
| 64 |
+
|
| 65 |
+
mode = data.get("mode")
|
| 66 |
+
|
| 67 |
+
# If standalone translation server is running, proxy request to it
|
| 68 |
+
local_translate_url = os.environ.get("LOCAL_TRANSLATE_URL", "")
|
| 69 |
+
if not local_translate_url:
|
| 70 |
+
local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
|
| 71 |
+
if local_tts_url:
|
| 72 |
+
local_translate_url = local_tts_url.split("/v1/")[0]
|
| 73 |
+
|
| 74 |
+
if local_translate_url:
|
| 75 |
+
try:
|
| 76 |
+
translate_url = f"{local_translate_url.rstrip('/')}/v1/translate"
|
| 77 |
+
r = py_requests.post(translate_url, json={"texts": texts, "mode": mode}, timeout=(2.0, 60.0))
|
| 78 |
+
if r.status_code == 200:
|
| 79 |
+
return jsonify(r.json())
|
| 80 |
+
current_app.logger.error(f"[Translate Proxy Error]: {r.status_code} - {r.text}")
|
| 81 |
+
except Exception as e:
|
| 82 |
+
current_app.logger.error(f"[Translate Proxy Exception]: {e}")
|
| 83 |
+
|
| 84 |
+
translations = translate_texts(texts, mode)
|
| 85 |
+
return jsonify({"translations": translations})
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@translate_bp.route("/translate_stream", methods=["POST"])
|
| 89 |
+
def translate_stream():
|
| 90 |
+
data = request.json
|
| 91 |
+
if not data or "texts" not in data:
|
| 92 |
+
return jsonify({"error": "Missing 'texts' array"}), 400
|
| 93 |
+
|
| 94 |
+
texts = data["texts"]
|
| 95 |
+
|
| 96 |
+
if not is_vip_request():
|
| 97 |
+
exceeded, tracker_key, count = _check_translation_rate_limit(texts)
|
| 98 |
+
if exceeded:
|
| 99 |
+
return jsonify({
|
| 100 |
+
"error": "Hạn mức dịch máy chủ Standard đã hết (50 đoạn/ngày). Vui lòng nâng cấp tài khoản VIP!"
|
| 101 |
+
}), 403
|
| 102 |
+
translation_limit_tracker[tracker_key] = translation_limit_tracker.get(tracker_key, 0) + count
|
| 103 |
+
|
| 104 |
+
mode = data.get("mode")
|
| 105 |
+
|
| 106 |
+
# If standalone translation server is running, proxy stream request to it
|
| 107 |
+
local_translate_url = os.environ.get("LOCAL_TRANSLATE_URL", "")
|
| 108 |
+
if not local_translate_url:
|
| 109 |
+
local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
|
| 110 |
+
if local_tts_url:
|
| 111 |
+
local_translate_url = local_tts_url.split("/v1/")[0]
|
| 112 |
+
|
| 113 |
+
if local_translate_url:
|
| 114 |
+
try:
|
| 115 |
+
translate_url = f"{local_translate_url.rstrip('/')}/v1/translate_stream"
|
| 116 |
+
r = py_requests.post(translate_url, json={"texts": texts, "mode": mode}, stream=True, timeout=(2.0, 60.0))
|
| 117 |
+
if r.status_code == 200:
|
| 118 |
+
def stream_forwarder():
|
| 119 |
+
for chunk in r.iter_content(chunk_size=1024):
|
| 120 |
+
if chunk:
|
| 121 |
+
yield chunk
|
| 122 |
+
response = Response(stream_forwarder(), mimetype="text/event-stream")
|
| 123 |
+
response.headers["X-Accel-Buffering"] = "no"
|
| 124 |
+
response.headers["Cache-Control"] = "no-cache, no-transform"
|
| 125 |
+
response.headers["Connection"] = "keep-alive"
|
| 126 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 127 |
+
return response
|
| 128 |
+
current_app.logger.error(f"[Translate Stream Proxy Error]: {r.status_code} - {r.text}")
|
| 129 |
+
except Exception as e:
|
| 130 |
+
current_app.logger.error(f"[Translate Stream Proxy Exception]: {e}")
|
| 131 |
+
|
| 132 |
+
response = Response(translate_stream_generator(texts, mode), mimetype="text/event-stream")
|
| 133 |
+
response.headers["X-Accel-Buffering"] = "no"
|
| 134 |
+
response.headers["Cache-Control"] = "no-cache, no-transform"
|
| 135 |
+
response.headers["Connection"] = "keep-alive"
|
| 136 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 137 |
+
return response
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@translate_bp.route("/api/ai/chat", methods=["POST"])
|
| 141 |
+
def ai_chat_proxy():
|
| 142 |
+
if not is_vip_request():
|
| 143 |
+
return jsonify({
|
| 144 |
+
"error": "Chức năng AI chỉ dành cho thành viên VIP. Vui lòng nâng cấp hoặc nhập API Key cá nhân trong cài đặt!"
|
| 145 |
+
}), 403
|
| 146 |
+
|
| 147 |
+
data = request.json or {}
|
| 148 |
+
messages = data.get("messages", [])
|
| 149 |
+
model = data.get("model", "gemini-1.5-flash")
|
| 150 |
+
prompt = data.get("prompt", "")
|
| 151 |
+
|
| 152 |
+
try:
|
| 153 |
+
text = call_ai_chat_proxy(messages, model, prompt)
|
| 154 |
+
return jsonify({"text": text})
|
| 155 |
+
except ValueError as e:
|
| 156 |
+
return jsonify({"error": str(e)}), 501
|
| 157 |
+
except RuntimeError as e:
|
| 158 |
+
return jsonify({"error": str(e)}), 502
|
| 159 |
+
except Exception as e:
|
| 160 |
+
return jsonify({"error": f"Lỗi xử lý AI Proxy: {str(e)}"}), 500
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@translate_bp.route("/iframe_proxy", methods=["GET"])
|
| 164 |
+
def iframe_proxy():
|
| 165 |
+
url = request.args.get("url")
|
| 166 |
+
if not url:
|
| 167 |
+
return "Missing 'url' parameter", 400
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
headers = {
|
| 171 |
+
"User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
| 172 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
| 173 |
+
"Accept-Language": "en-US,en;q=0.5"
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
# Follow redirects
|
| 177 |
+
r = py_requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 178 |
+
|
| 179 |
+
encoding = r.encoding
|
| 180 |
+
if not encoding or encoding.lower() == 'iso-8859-1':
|
| 181 |
+
encoding = r.apparent_encoding or 'utf-8'
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
html = r.content.decode(encoding, errors='ignore')
|
| 185 |
+
except Exception:
|
| 186 |
+
html = r.content.decode('utf-8', errors='ignore')
|
| 187 |
+
|
| 188 |
+
# Insert base URL tag so all links/resources load relative to original page
|
| 189 |
+
base_tag = f'<base href="{url}">'
|
| 190 |
+
if "<head>" in html:
|
| 191 |
+
html = html.replace("<head>", f"<head>{base_tag}", 1)
|
| 192 |
+
elif "<HEAD>" in html:
|
| 193 |
+
html = html.replace("<HEAD>", f"<HEAD>{base_tag}", 1)
|
| 194 |
+
else:
|
| 195 |
+
html = base_tag + html
|
| 196 |
+
|
| 197 |
+
# Injected proxy handler script
|
| 198 |
+
injected_script = """
|
| 199 |
+
<script>
|
| 200 |
+
(function() {
|
| 201 |
+
const origLog = console.log;
|
| 202 |
+
console.log = function(...args) {
|
| 203 |
+
origLog.apply(console, args);
|
| 204 |
+
const msg = args.join(' ');
|
| 205 |
+
if (msg === '[Translation Complete]') {
|
| 206 |
+
window.parent.postMessage({ type: 'TRANSLATION_COMPLETE' }, '*');
|
| 207 |
+
} else if (msg.startsWith('[TRANSLATE_REQ]')) {
|
| 208 |
+
try {
|
| 209 |
+
const payload = JSON.parse(msg.substring(15));
|
| 210 |
+
window.parent.postMessage({ type: 'TRANSLATE_REQ', payload: payload }, '*');
|
| 211 |
+
} catch(e) {
|
| 212 |
+
origLog.error("[Proxy Iframe] Parse translation req error:", e);
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
window.addEventListener('message', function(event) {
|
| 218 |
+
const data = event.data;
|
| 219 |
+
if (!data || !data.action) return;
|
| 220 |
+
|
| 221 |
+
if (data.action === 'INJECT_SCRIPT') {
|
| 222 |
+
try {
|
| 223 |
+
const scriptEl = document.createElement('script');
|
| 224 |
+
scriptEl.textContent = data.script;
|
| 225 |
+
document.body.appendChild(scriptEl);
|
| 226 |
+
} catch (err) {
|
| 227 |
+
origLog.error("[Proxy Iframe] Error injecting script:", err);
|
| 228 |
+
}
|
| 229 |
+
} else if (data.action === 'translate') {
|
| 230 |
+
if (window.toggleAutoTranslate) {
|
| 231 |
+
window.toggleAutoTranslate(data.enabled);
|
| 232 |
+
}
|
| 233 |
+
} else if (data.action === 'audio') {
|
| 234 |
+
let res = { title: document.title, text: document.body.innerText };
|
| 235 |
+
if (window.__TienHiepHelpers) {
|
| 236 |
+
res = window.__TienHiepHelpers.extractCleanChapterText();
|
| 237 |
+
}
|
| 238 |
+
window.parent.postMessage({
|
| 239 |
+
type: 'AUDIO_TEXT_RES',
|
| 240 |
+
title: res.title,
|
| 241 |
+
text: res.text
|
| 242 |
+
}, '*');
|
| 243 |
+
} else if (data.action === 'scroll') {
|
| 244 |
+
if (window.__scrollInterval) {
|
| 245 |
+
clearInterval(window.__scrollInterval);
|
| 246 |
+
window.__scrollInterval = null;
|
| 247 |
+
} else {
|
| 248 |
+
const speed = data.speed || 30;
|
| 249 |
+
window.__scrollInterval = setInterval(() => {
|
| 250 |
+
window.scrollBy({top: 1, behavior: 'instant'});
|
| 251 |
+
}, speed);
|
| 252 |
+
}
|
| 253 |
+
} else if (data.action === 'next') {
|
| 254 |
+
if (window.__TienHiepHelpers) {
|
| 255 |
+
window.__TienHiepHelpers.checkAndTriggerAutoNext(true);
|
| 256 |
+
}
|
| 257 |
+
} else if (data.action === 'teach_next') {
|
| 258 |
+
if (window.__TienHiepHelpers) {
|
| 259 |
+
window.__TienHiepHelpers.startTeachNextMode();
|
| 260 |
+
}
|
| 261 |
+
} else if (data.action === 'dark_mode') {
|
| 262 |
+
if (document.documentElement.style.filter.includes('invert(1)')) {
|
| 263 |
+
document.documentElement.style.filter = '';
|
| 264 |
+
document.documentElement.style.backgroundColor = '';
|
| 265 |
+
} else {
|
| 266 |
+
document.documentElement.style.filter = 'invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.1)';
|
| 267 |
+
document.documentElement.style.backgroundColor = '#121212';
|
| 268 |
+
}
|
| 269 |
+
} else if (data.action === 'clean_ads') {
|
| 270 |
+
const ads = document.querySelectorAll('iframe, .ad, .ads, [id*="ad"], [class*="ad"], .banner, .popup, ins');
|
| 271 |
+
ads.forEach(ad => ad.remove());
|
| 272 |
+
} else if (data.action === 'force_translate') {
|
| 273 |
+
if (window.__autoTranslateObserver) {
|
| 274 |
+
const allNodes = window.__TienHiepHelpers ? window.__TienHiepHelpers.collectTextNodes(document.body) : [];
|
| 275 |
+
if (allNodes.length > 0) window.__translateQueue = allNodes;
|
| 276 |
+
if (window.__processTranslationQueue) window.__processTranslationQueue();
|
| 277 |
+
}
|
| 278 |
+
} else if (data.action === 'SET_TTS_PLAYING') {
|
| 279 |
+
window.isTtsPlaying = data.playing;
|
| 280 |
+
} else if (data.action === 'highlight') {
|
| 281 |
+
const targetText = data.sentenceText ? data.sentenceText.trim() : '';
|
| 282 |
+
if (targetText) {
|
| 283 |
+
function findTextNode(root, text) {
|
| 284 |
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
| 285 |
+
let node;
|
| 286 |
+
while (node = walker.nextNode()) {
|
| 287 |
+
const val = node.nodeValue || '';
|
| 288 |
+
if (val.includes(text)) {
|
| 289 |
+
return { node, startIdx: val.indexOf(text) };
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
if (text.length > 15) {
|
| 293 |
+
const prefix = text.substring(0, Math.floor(text.length * 0.65));
|
| 294 |
+
const walker2 = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
| 295 |
+
while (node = walker2.nextNode()) {
|
| 296 |
+
const val = node.nodeValue || '';
|
| 297 |
+
if (val.includes(prefix)) {
|
| 298 |
+
return { node, startIdx: val.indexOf(prefix) };
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
return null;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
let attempts = 0;
|
| 306 |
+
function tryHighlight() {
|
| 307 |
+
const oldHighlight = document.getElementById('tienhiep-active-highlight');
|
| 308 |
+
const result = findTextNode(document.body, targetText);
|
| 309 |
+
|
| 310 |
+
if (result) {
|
| 311 |
+
if (oldHighlight) {
|
| 312 |
+
const parent = oldHighlight.parentNode;
|
| 313 |
+
if (parent) {
|
| 314 |
+
const textNode = document.createTextNode(oldHighlight.textContent);
|
| 315 |
+
parent.replaceChild(textNode, oldHighlight);
|
| 316 |
+
parent.normalize();
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
const { node, startIdx } = result;
|
| 321 |
+
const parent = node.parentNode;
|
| 322 |
+
if (parent && parent.nodeName !== 'SCRIPT' && parent.nodeName !== 'STYLE') {
|
| 323 |
+
const textVal = node.nodeValue;
|
| 324 |
+
const matchLen = Math.min(targetText.length, textVal.length - startIdx);
|
| 325 |
+
|
| 326 |
+
const beforeText = textVal.substring(0, startIdx);
|
| 327 |
+
const matchedText = textVal.substring(startIdx, startIdx + matchLen);
|
| 328 |
+
const afterText = textVal.substring(startIdx + matchLen);
|
| 329 |
+
|
| 330 |
+
const fragment = document.createDocumentFragment();
|
| 331 |
+
if (beforeText) fragment.appendChild(document.createTextNode(beforeText));
|
| 332 |
+
|
| 333 |
+
const span = document.createElement('span');
|
| 334 |
+
span.id = 'tienhiep-active-highlight';
|
| 335 |
+
span.style.backgroundColor = 'rgba(139, 92, 246, 0.25)';
|
| 336 |
+
span.style.color = '#c084fc';
|
| 337 |
+
span.style.borderBottom = '2px solid #a855f7';
|
| 338 |
+
span.style.padding = '1px 3px';
|
| 339 |
+
span.style.borderRadius = '3px';
|
| 340 |
+
span.style.transition = 'all 0.3s ease';
|
| 341 |
+
span.textContent = matchedText;
|
| 342 |
+
fragment.appendChild(span);
|
| 343 |
+
|
| 344 |
+
if (afterText) fragment.appendChild(document.createTextNode(afterText));
|
| 345 |
+
|
| 346 |
+
parent.replaceChild(fragment, node);
|
| 347 |
+
span.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| 348 |
+
}
|
| 349 |
+
} else if (attempts < 6) {
|
| 350 |
+
attempts++;
|
| 351 |
+
setTimeout(tryHighlight, 600);
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
tryHighlight();
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
document.addEventListener('click', function(e) {
|
| 361 |
+
const a = e.target.closest('a');
|
| 362 |
+
if (a && a.href) {
|
| 363 |
+
if (a.href.startsWith('http://') || a.href.startsWith('https://')) {
|
| 364 |
+
e.preventDefault();
|
| 365 |
+
window.parent.postMessage({
|
| 366 |
+
type: 'NAVIGATE_REQ',
|
| 367 |
+
url: a.href
|
| 368 |
+
}, '*');
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
}, true);
|
| 372 |
+
|
| 373 |
+
window.addEventListener('DOMContentLoaded', function() {
|
| 374 |
+
window.parent.postMessage({ type: 'IFRAME_READY', url: window.location.href }, '*');
|
| 375 |
+
});
|
| 376 |
+
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
| 377 |
+
window.parent.postMessage({ type: 'IFRAME_READY', url: window.location.href }, '*');
|
| 378 |
+
}
|
| 379 |
+
})();
|
| 380 |
+
</script>
|
| 381 |
+
"""
|
| 382 |
+
|
| 383 |
+
if "</body>" in html:
|
| 384 |
+
html = html.replace("</body>", f"{injected_script}</body>", 1)
|
| 385 |
+
elif "</BODY>" in html:
|
| 386 |
+
html = html.replace("</BODY>", f"{injected_script}</BODY>", 1)
|
| 387 |
+
else:
|
| 388 |
+
html = html + injected_script
|
| 389 |
+
|
| 390 |
+
response = Response(html, mimetype="text/html")
|
| 391 |
+
response.headers.pop("X-Frame-Options", None)
|
| 392 |
+
response.headers.pop("Content-Security-Policy", None)
|
| 393 |
+
response.headers["Access-Control-Allow-Origin"] = "*"
|
| 394 |
+
return response
|
| 395 |
+
except Exception as e:
|
| 396 |
+
return f"Proxy error: {str(e)}", 500
|
| 397 |
+
|
backend/api/user_features.py
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify
|
| 2 |
+
from backend.core.decorators import jwt_required, get_current_user
|
| 3 |
+
from backend.database.db_manager import get_user_db_conn
|
| 4 |
+
from backend.core.logger import logger
|
| 5 |
+
from backend.core.security import encrypt_message, decrypt_message
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
user_features_bp = Blueprint("user_features", __name__)
|
| 10 |
+
|
| 11 |
+
def serialize_row(row):
|
| 12 |
+
if not row:
|
| 13 |
+
return {}
|
| 14 |
+
d = dict(row)
|
| 15 |
+
for k, v in d.items():
|
| 16 |
+
if hasattr(v, "isoformat"):
|
| 17 |
+
d[k] = v.isoformat()
|
| 18 |
+
return d
|
| 19 |
+
|
| 20 |
+
import time
|
| 21 |
+
_history_cache = {}
|
| 22 |
+
_stats_cache = {}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ==========================================
|
| 26 |
+
# 1. Translation History Endpoints
|
| 27 |
+
# ==========================================
|
| 28 |
+
|
| 29 |
+
@user_features_bp.route("/api/user/history", methods=["GET"])
|
| 30 |
+
@jwt_required
|
| 31 |
+
def get_translation_history():
|
| 32 |
+
user = get_current_user()
|
| 33 |
+
user_id = user["id"]
|
| 34 |
+
|
| 35 |
+
now = time.time()
|
| 36 |
+
if user_id in _history_cache:
|
| 37 |
+
cached_data, cached_time = _history_cache[user_id]
|
| 38 |
+
if now - cached_time < 60:
|
| 39 |
+
return jsonify(cached_data)
|
| 40 |
+
|
| 41 |
+
conn = get_user_db_conn()
|
| 42 |
+
try:
|
| 43 |
+
rows = conn.execute(
|
| 44 |
+
"SELECT * FROM translation_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100",
|
| 45 |
+
(user_id,)
|
| 46 |
+
).fetchall()
|
| 47 |
+
response_data = {"history": [serialize_row(r) for r in rows], "success": True}
|
| 48 |
+
_history_cache[user_id] = (response_data, now)
|
| 49 |
+
return jsonify(response_data)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"[User History] Failed to fetch history: {e}")
|
| 52 |
+
return jsonify({"error": "Failed to fetch translation history", "success": False}), 500
|
| 53 |
+
finally:
|
| 54 |
+
conn.close()
|
| 55 |
+
|
| 56 |
+
@user_features_bp.route("/api/user/history", methods=["POST"])
|
| 57 |
+
@jwt_required
|
| 58 |
+
def add_translation_history():
|
| 59 |
+
user = get_current_user()
|
| 60 |
+
data = request.json or {}
|
| 61 |
+
original = data.get("original_text", "").strip()
|
| 62 |
+
translated = data.get("translated_text", "").strip()
|
| 63 |
+
mode = data.get("mode", "fast").strip()
|
| 64 |
+
chars = int(data.get("characters", len(original)))
|
| 65 |
+
|
| 66 |
+
if not original or not translated:
|
| 67 |
+
return jsonify({"error": "Original text and translation content cannot be empty", "success": False}), 400
|
| 68 |
+
|
| 69 |
+
conn = get_user_db_conn()
|
| 70 |
+
try:
|
| 71 |
+
conn.execute(
|
| 72 |
+
"INSERT INTO translation_history (user_id, original_text, translated_text, mode, characters) VALUES (?, ?, ?, ?, ?)",
|
| 73 |
+
(user["id"], original, translated, mode, chars)
|
| 74 |
+
)
|
| 75 |
+
conn.commit()
|
| 76 |
+
if user["id"] in _history_cache: del _history_cache[user["id"]]
|
| 77 |
+
if user["id"] in _stats_cache: del _stats_cache[user["id"]]
|
| 78 |
+
return jsonify({"message": "Translation history entry added", "success": True})
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.error(f"[User History] Failed to add entry: {e}")
|
| 81 |
+
return jsonify({"error": "Failed to add translation history entry", "success": False}), 500
|
| 82 |
+
finally:
|
| 83 |
+
conn.close()
|
| 84 |
+
|
| 85 |
+
@user_features_bp.route("/api/user/history", methods=["DELETE"])
|
| 86 |
+
@jwt_required
|
| 87 |
+
def clear_translation_history():
|
| 88 |
+
user = get_current_user()
|
| 89 |
+
conn = get_user_db_conn()
|
| 90 |
+
try:
|
| 91 |
+
conn.execute("DELETE FROM translation_history WHERE user_id = ?", (user["id"],))
|
| 92 |
+
conn.commit()
|
| 93 |
+
if user["id"] in _history_cache: del _history_cache[user["id"]]
|
| 94 |
+
if user["id"] in _stats_cache: del _stats_cache[user["id"]]
|
| 95 |
+
return jsonify({"message": "Translation history cleared", "success": True})
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"[User History] Failed to clear history: {e}")
|
| 98 |
+
return jsonify({"error": "Failed to clear history", "success": False}), 500
|
| 99 |
+
finally:
|
| 100 |
+
conn.close()
|
| 101 |
+
|
| 102 |
+
# ==========================================
|
| 103 |
+
# 2. Vocabulary/Word Notebook Endpoints
|
| 104 |
+
# ==========================================
|
| 105 |
+
|
| 106 |
+
@user_features_bp.route("/api/user/vocabulary", methods=["GET"])
|
| 107 |
+
@jwt_required
|
| 108 |
+
def get_vocabulary():
|
| 109 |
+
user = get_current_user()
|
| 110 |
+
conn = get_user_db_conn()
|
| 111 |
+
try:
|
| 112 |
+
rows = conn.execute(
|
| 113 |
+
"SELECT * FROM vocabulary WHERE user_id = ? ORDER BY created_at DESC",
|
| 114 |
+
(user["id"],)
|
| 115 |
+
).fetchall()
|
| 116 |
+
return jsonify({"vocabulary": [serialize_row(r) for r in rows], "success": True})
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"[Vocabulary] Failed to fetch vocabulary: {e}")
|
| 119 |
+
return jsonify({"error": "Failed to fetch vocabulary list", "success": False}), 500
|
| 120 |
+
finally:
|
| 121 |
+
conn.close()
|
| 122 |
+
|
| 123 |
+
@user_features_bp.route("/api/user/vocabulary", methods=["POST"])
|
| 124 |
+
@jwt_required
|
| 125 |
+
def add_vocabulary():
|
| 126 |
+
user = get_current_user()
|
| 127 |
+
data = request.json or {}
|
| 128 |
+
original = data.get("original_text", "").strip()
|
| 129 |
+
translation = data.get("translation", "").strip()
|
| 130 |
+
pinyin_or_hanviet = data.get("pinyin_or_hanviet", "").strip()
|
| 131 |
+
context = data.get("context_sentence", "").strip()
|
| 132 |
+
notes = data.get("notes", "").strip()
|
| 133 |
+
|
| 134 |
+
if not original or not translation:
|
| 135 |
+
return jsonify({"error": "Original text and translation content cannot be empty", "success": False}), 400
|
| 136 |
+
|
| 137 |
+
conn = get_user_db_conn()
|
| 138 |
+
try:
|
| 139 |
+
conn.execute(
|
| 140 |
+
"INSERT INTO vocabulary (user_id, original_text, pinyin_or_hanviet, translation, context_sentence, notes) VALUES (?, ?, ?, ?, ?, ?)",
|
| 141 |
+
(user["id"], original, pinyin_or_hanviet, translation, context, notes)
|
| 142 |
+
)
|
| 143 |
+
conn.commit()
|
| 144 |
+
return jsonify({"message": "Word saved to vocabulary notebook", "success": True})
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.error(f"[Vocabulary] Failed to save word: {e}")
|
| 147 |
+
return jsonify({"error": "Failed to save word notebook entry", "success": False}), 500
|
| 148 |
+
finally:
|
| 149 |
+
conn.close()
|
| 150 |
+
|
| 151 |
+
@user_features_bp.route("/api/user/vocabulary/<int:item_id>", methods=["DELETE"])
|
| 152 |
+
@jwt_required
|
| 153 |
+
def delete_vocabulary(item_id):
|
| 154 |
+
user = get_current_user()
|
| 155 |
+
conn = get_user_db_conn()
|
| 156 |
+
try:
|
| 157 |
+
# Verify ownership
|
| 158 |
+
row = conn.execute("SELECT user_id FROM vocabulary WHERE id = ?", (item_id,)).fetchone()
|
| 159 |
+
if not row:
|
| 160 |
+
return jsonify({"error": "Entry not found", "success": False}), 404
|
| 161 |
+
if int(row["user_id"]) != int(user["id"]):
|
| 162 |
+
return jsonify({"error": "Unauthorized action", "success": False}), 403
|
| 163 |
+
|
| 164 |
+
conn.execute("DELETE FROM vocabulary WHERE id = ?", (item_id,))
|
| 165 |
+
conn.commit()
|
| 166 |
+
return jsonify({"message": "Word deleted from notebook", "success": True})
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"[Vocabulary] Failed to delete item {item_id}: {e}")
|
| 169 |
+
return jsonify({"error": "Failed to delete item", "success": False}), 500
|
| 170 |
+
finally:
|
| 171 |
+
conn.close()
|
| 172 |
+
|
| 173 |
+
# ==========================================
|
| 174 |
+
# 3. Personalization Settings Endpoints
|
| 175 |
+
# ==========================================
|
| 176 |
+
|
| 177 |
+
@user_features_bp.route("/api/user/settings", methods=["GET"])
|
| 178 |
+
@jwt_required
|
| 179 |
+
def get_user_settings():
|
| 180 |
+
user = get_current_user()
|
| 181 |
+
conn = get_user_db_conn()
|
| 182 |
+
try:
|
| 183 |
+
row = conn.execute("SELECT * FROM user_settings WHERE user_id = ?", (user["id"],)).fetchone()
|
| 184 |
+
if not row:
|
| 185 |
+
# Insert defaults
|
| 186 |
+
conn.execute(
|
| 187 |
+
"INSERT INTO user_settings (user_id, theme, default_language, auto_read, font_size) VALUES (?, 'dark', 'vi', 0, 14)",
|
| 188 |
+
(user["id"],)
|
| 189 |
+
)
|
| 190 |
+
conn.commit()
|
| 191 |
+
row = conn.execute("SELECT * FROM user_settings WHERE user_id = ?", (user["id"],)).fetchone()
|
| 192 |
+
|
| 193 |
+
return jsonify({"settings": serialize_row(row), "success": True})
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.error(f"[UserSettings] Failed to fetch settings: {e}")
|
| 196 |
+
return jsonify({"error": "Failed to fetch settings", "success": False}), 500
|
| 197 |
+
finally:
|
| 198 |
+
conn.close()
|
| 199 |
+
|
| 200 |
+
@user_features_bp.route("/api/user/settings", methods=["POST"])
|
| 201 |
+
@jwt_required
|
| 202 |
+
def update_user_settings():
|
| 203 |
+
user = get_current_user()
|
| 204 |
+
data = request.json or {}
|
| 205 |
+
theme = data.get("theme", "dark").strip()
|
| 206 |
+
lang = data.get("default_language", "vi").strip()
|
| 207 |
+
auto_read = int(data.get("auto_read", 0))
|
| 208 |
+
font_size = int(data.get("font_size", 14))
|
| 209 |
+
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
| 210 |
+
|
| 211 |
+
conn = get_user_db_conn()
|
| 212 |
+
try:
|
| 213 |
+
conn.execute(
|
| 214 |
+
"""INSERT INTO user_settings (user_id, theme, default_language, auto_read, font_size, updated_at)
|
| 215 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 216 |
+
ON CONFLICT(user_id) DO UPDATE SET
|
| 217 |
+
theme = excluded.theme,
|
| 218 |
+
default_language = excluded.default_language,
|
| 219 |
+
auto_read = excluded.auto_read,
|
| 220 |
+
font_size = excluded.font_size,
|
| 221 |
+
updated_at = excluded.updated_at""",
|
| 222 |
+
(user["id"], theme, lang, auto_read, font_size, now)
|
| 223 |
+
)
|
| 224 |
+
conn.commit()
|
| 225 |
+
return jsonify({"message": "Settings updated successfully", "success": True})
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.error(f"[UserSettings] Failed to update settings: {e}")
|
| 228 |
+
return jsonify({"error": "Failed to update settings", "success": False}), 500
|
| 229 |
+
finally:
|
| 230 |
+
conn.close()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ==========================================
|
| 234 |
+
# 4. Usage Tracking & System/API Statistics
|
| 235 |
+
# ==========================================
|
| 236 |
+
|
| 237 |
+
@user_features_bp.route("/api/user/track", methods=["POST"])
|
| 238 |
+
@jwt_required
|
| 239 |
+
def track_user_usage():
|
| 240 |
+
user = get_current_user()
|
| 241 |
+
data = request.json or {}
|
| 242 |
+
source = data.get("source", "web").strip().lower()
|
| 243 |
+
action = data.get("action", "read").strip().lower()
|
| 244 |
+
duration = int(data.get("duration", 0))
|
| 245 |
+
mode = data.get("mode", "online").strip().lower()
|
| 246 |
+
|
| 247 |
+
if not source or not action:
|
| 248 |
+
return jsonify({"error": "Missing source or action", "success": False}), 400
|
| 249 |
+
|
| 250 |
+
conn = get_user_db_conn()
|
| 251 |
+
try:
|
| 252 |
+
conn.execute(
|
| 253 |
+
"INSERT INTO usage_tracking (user_id, source, action, duration, mode) VALUES (?, ?, ?, ?, ?)",
|
| 254 |
+
(user["id"], source, action, duration, mode)
|
| 255 |
+
)
|
| 256 |
+
conn.commit()
|
| 257 |
+
if user["id"] in _stats_cache: del _stats_cache[user["id"]]
|
| 258 |
+
return jsonify({"message": "Usage tracked successfully", "success": True})
|
| 259 |
+
except Exception as e:
|
| 260 |
+
logger.error(f"[Usage Tracking] Failed to log usage: {e}")
|
| 261 |
+
return jsonify({"error": "Failed to track usage", "success": False}), 500
|
| 262 |
+
finally:
|
| 263 |
+
conn.close()
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@user_features_bp.route("/api/user/stats", methods=["GET"])
|
| 267 |
+
@jwt_required
|
| 268 |
+
def get_user_stats():
|
| 269 |
+
user = get_current_user()
|
| 270 |
+
user_id = user["id"]
|
| 271 |
+
|
| 272 |
+
now = time.time()
|
| 273 |
+
if user_id in _stats_cache:
|
| 274 |
+
cached_data, cached_time = _stats_cache[user_id]
|
| 275 |
+
if now - cached_time < 60:
|
| 276 |
+
return jsonify(cached_data)
|
| 277 |
+
|
| 278 |
+
conn = get_user_db_conn()
|
| 279 |
+
try:
|
| 280 |
+
# 1. Total active/reading duration by source
|
| 281 |
+
web_duration_row = conn.execute(
|
| 282 |
+
"SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND source = 'web'",
|
| 283 |
+
(user["id"],)
|
| 284 |
+
).fetchone()
|
| 285 |
+
web_duration = web_duration_row["total"] or 0 if web_duration_row else 0
|
| 286 |
+
|
| 287 |
+
ext_duration_row = conn.execute(
|
| 288 |
+
"SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND source = 'extension'",
|
| 289 |
+
(user["id"],)
|
| 290 |
+
).fetchone()
|
| 291 |
+
ext_duration = ext_duration_row["total"] or 0 if ext_duration_row else 0
|
| 292 |
+
|
| 293 |
+
# 2. Total active/reading duration by online/offline mode
|
| 294 |
+
online_duration_row = conn.execute(
|
| 295 |
+
"SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND mode = 'online'",
|
| 296 |
+
(user["id"],)
|
| 297 |
+
).fetchone()
|
| 298 |
+
online_duration = online_duration_row["total"] or 0 if online_duration_row else 0
|
| 299 |
+
|
| 300 |
+
offline_duration_row = conn.execute(
|
| 301 |
+
"SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND mode = 'offline'",
|
| 302 |
+
(user["id"],)
|
| 303 |
+
).fetchone()
|
| 304 |
+
offline_duration = offline_duration_row["total"] or 0 if offline_duration_row else 0
|
| 305 |
+
|
| 306 |
+
# 3. Translation calls count (from translation_history)
|
| 307 |
+
trans_calls_row = conn.execute(
|
| 308 |
+
"SELECT COUNT(*) as cnt, SUM(characters) as total_chars FROM translation_history WHERE user_id = ?",
|
| 309 |
+
(user["id"],)
|
| 310 |
+
).fetchone()
|
| 311 |
+
trans_calls = trans_calls_row["cnt"] or 0 if trans_calls_row else 0
|
| 312 |
+
total_chars = trans_calls_row["total_chars"] or 0 if trans_calls_row else 0
|
| 313 |
+
|
| 314 |
+
# 4. API keys & API usage stats (from api_keys and api_usage)
|
| 315 |
+
api_keys_count_row = conn.execute(
|
| 316 |
+
"SELECT COUNT(*) as cnt FROM api_keys WHERE user_id = ?",
|
| 317 |
+
(user["id"],)
|
| 318 |
+
).fetchone()
|
| 319 |
+
api_keys_count = api_keys_count_row["cnt"] or 0 if api_keys_count_row else 0
|
| 320 |
+
|
| 321 |
+
api_usage_row = conn.execute(
|
| 322 |
+
"""SELECT COUNT(u.id) as cnt, SUM(u.tokens) as tokens
|
| 323 |
+
FROM api_usage u
|
| 324 |
+
JOIN api_keys k ON u.api_key = k.api_key
|
| 325 |
+
WHERE k.user_id = ?""",
|
| 326 |
+
(user["id"],)
|
| 327 |
+
).fetchone()
|
| 328 |
+
api_usage_calls = api_usage_row["cnt"] or 0 if api_usage_row else 0
|
| 329 |
+
api_usage_chars = api_usage_row["tokens"] or 0 if api_usage_row else 0
|
| 330 |
+
|
| 331 |
+
# 5. Recent actions log
|
| 332 |
+
recent_actions = conn.execute(
|
| 333 |
+
"SELECT source, action, duration, mode, timestamp FROM usage_tracking WHERE user_id = ? ORDER BY timestamp DESC, id DESC LIMIT 20",
|
| 334 |
+
(user_id,)
|
| 335 |
+
).fetchall()
|
| 336 |
+
|
| 337 |
+
response_data = {
|
| 338 |
+
"success": True,
|
| 339 |
+
"stats": {
|
| 340 |
+
"web_duration": web_duration,
|
| 341 |
+
"ext_duration": ext_duration,
|
| 342 |
+
"online_duration": online_duration,
|
| 343 |
+
"offline_duration": offline_duration,
|
| 344 |
+
"total_reading_time": web_duration + ext_duration,
|
| 345 |
+
"translation_calls": trans_calls,
|
| 346 |
+
"translation_chars": total_chars,
|
| 347 |
+
"api_keys_count": api_keys_count,
|
| 348 |
+
"api_usage_calls": api_usage_calls,
|
| 349 |
+
"api_usage_chars": api_usage_chars
|
| 350 |
+
},
|
| 351 |
+
"recent_actions": [serialize_row(r) for r in recent_actions]
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
_stats_cache[user_id] = (response_data, now)
|
| 355 |
+
return jsonify(response_data)
|
| 356 |
+
except Exception as e:
|
| 357 |
+
logger.error(f"[User Stats] Failed to fetch stats: {e}")
|
| 358 |
+
return jsonify({"error": "Failed to fetch stats", "success": False}), 500
|
| 359 |
+
finally:
|
| 360 |
+
conn.close()
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ==========================================
|
| 364 |
+
# 5. Feedback Submissions
|
| 365 |
+
# ==========================================
|
| 366 |
+
|
| 367 |
+
@user_features_bp.route("/api/feedback/submit", methods=["POST"])
|
| 368 |
+
def submit_feedback():
|
| 369 |
+
data = request.json or {}
|
| 370 |
+
email = data.get("email", "").strip()
|
| 371 |
+
message = data.get("message", "").strip()
|
| 372 |
+
|
| 373 |
+
if not email or not message:
|
| 374 |
+
return jsonify({"error": "Email và nội dung phản hồi không được để trống", "success": False}), 400
|
| 375 |
+
|
| 376 |
+
# 1. Log to logs/feedback.log
|
| 377 |
+
os.makedirs("logs", exist_ok=True)
|
| 378 |
+
log_path = "logs/feedback.log"
|
| 379 |
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 380 |
+
log_entry = f"[{timestamp}] EMAIL: {email} | MESSAGE: {message}\n"
|
| 381 |
+
try:
|
| 382 |
+
with open(log_path, "a", encoding="utf-8") as f:
|
| 383 |
+
f.write(log_entry)
|
| 384 |
+
except Exception as e:
|
| 385 |
+
logger.error(f"Failed to write feedback to log file: {e}")
|
| 386 |
+
|
| 387 |
+
# 2. Save to SQLite database
|
| 388 |
+
conn = get_user_db_conn()
|
| 389 |
+
try:
|
| 390 |
+
conn.execute(
|
| 391 |
+
"""CREATE TABLE IF NOT EXISTS user_feedbacks (
|
| 392 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 393 |
+
email TEXT,
|
| 394 |
+
message TEXT,
|
| 395 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 396 |
+
)"""
|
| 397 |
+
)
|
| 398 |
+
conn.execute(
|
| 399 |
+
"INSERT INTO user_feedbacks (email, message) VALUES (?, ?)",
|
| 400 |
+
(email, message)
|
| 401 |
+
)
|
| 402 |
+
conn.commit()
|
| 403 |
+
return jsonify({"message": "Gửi phản hồi thành công!", "success": True})
|
| 404 |
+
except Exception as e:
|
| 405 |
+
logger.error(f"[Feedback] Failed to save feedback: {e}")
|
| 406 |
+
return jsonify({"error": "Gửi phản hồi thất bại", "success": False}), 500
|
| 407 |
+
finally:
|
| 408 |
+
conn.close()
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
@user_features_bp.route("/api/feedback/list", methods=["GET"])
|
| 412 |
+
def list_feedbacks():
|
| 413 |
+
conn = get_user_db_conn()
|
| 414 |
+
try:
|
| 415 |
+
conn.execute(
|
| 416 |
+
"""CREATE TABLE IF NOT EXISTS user_feedbacks (
|
| 417 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 418 |
+
email TEXT,
|
| 419 |
+
message TEXT,
|
| 420 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 421 |
+
)"""
|
| 422 |
+
)
|
| 423 |
+
rows = conn.execute("SELECT * FROM user_feedbacks ORDER BY id DESC").fetchall()
|
| 424 |
+
feedbacks = [serialize_row(r) for r in rows]
|
| 425 |
+
return jsonify({"feedbacks": feedbacks, "success": True})
|
| 426 |
+
except Exception as e:
|
| 427 |
+
logger.error(f"[Feedback] Failed to list feedback: {e}")
|
| 428 |
+
return jsonify({"error": "Không thể lấy danh sách phản hồi", "success": False}), 500
|
| 429 |
+
finally:
|
| 430 |
+
conn.close()
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
# ==========================================
|
| 434 |
+
# 6. Social, Friends, and Private Messaging APIs
|
| 435 |
+
# ==========================================
|
| 436 |
+
|
| 437 |
+
@user_features_bp.route("/api/users/search", methods=["GET"])
|
| 438 |
+
def search_users():
|
| 439 |
+
q = request.args.get("q", "").strip()
|
| 440 |
+
if not q:
|
| 441 |
+
return jsonify({"users": []})
|
| 442 |
+
user = get_current_user()
|
| 443 |
+
user_id = user["id"] if user else None
|
| 444 |
+
conn = get_user_db_conn()
|
| 445 |
+
try:
|
| 446 |
+
like_q = "%" + q + "%"
|
| 447 |
+
if user_id:
|
| 448 |
+
rows = conn.execute(
|
| 449 |
+
"""SELECT id, username, user_code, display_name, avatar FROM users
|
| 450 |
+
WHERE id != ? AND (
|
| 451 |
+
username LIKE ? OR
|
| 452 |
+
email LIKE ? OR
|
| 453 |
+
user_code = ?
|
| 454 |
+
) LIMIT 15""",
|
| 455 |
+
(user_id, like_q, like_q, q)
|
| 456 |
+
).fetchall()
|
| 457 |
+
else:
|
| 458 |
+
rows = conn.execute(
|
| 459 |
+
"""SELECT id, username, user_code, display_name, avatar FROM users
|
| 460 |
+
WHERE username LIKE ? OR email LIKE ? OR user_code = ? LIMIT 15""",
|
| 461 |
+
(like_q, like_q, q)
|
| 462 |
+
).fetchall()
|
| 463 |
+
return jsonify({"users": [serialize_row(r) for r in rows]})
|
| 464 |
+
except Exception as e:
|
| 465 |
+
return jsonify({"error": str(e)}), 500
|
| 466 |
+
finally:
|
| 467 |
+
conn.close()
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
@user_features_bp.route("/api/notifications/unread-counts", methods=["GET"])
|
| 471 |
+
def get_unread_counts():
|
| 472 |
+
"""Separate unread counts: messages vs friend/other notifications."""
|
| 473 |
+
user = get_current_user()
|
| 474 |
+
if not user:
|
| 475 |
+
return jsonify({"messages": 0, "notifications": 0})
|
| 476 |
+
conn = get_user_db_conn()
|
| 477 |
+
try:
|
| 478 |
+
# Unread personal messages from direct_messages table
|
| 479 |
+
msg_count = conn.execute(
|
| 480 |
+
"SELECT COUNT(*) as c FROM direct_messages WHERE receiver_id = ? AND is_read = 0",
|
| 481 |
+
(user["id"],)
|
| 482 |
+
).fetchone()["c"]
|
| 483 |
+
# Unread other notifications (friend_request, friend_accept, book_share etc)
|
| 484 |
+
notif_count = conn.execute(
|
| 485 |
+
"SELECT COUNT(*) as c FROM personal_notifications WHERE user_id = ? AND is_read = 0 AND type != 'message'",
|
| 486 |
+
(user["id"],)
|
| 487 |
+
).fetchone()["c"]
|
| 488 |
+
return jsonify({"messages": msg_count, "notifications": notif_count})
|
| 489 |
+
except Exception as e:
|
| 490 |
+
return jsonify({"messages": 0, "notifications": 0})
|
| 491 |
+
finally:
|
| 492 |
+
conn.close()
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@user_features_bp.route("/api/friends/request", methods=["POST"])
|
| 496 |
+
def send_friend_request():
|
| 497 |
+
user = get_current_user()
|
| 498 |
+
if not user:
|
| 499 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 500 |
+
|
| 501 |
+
data = request.json or {}
|
| 502 |
+
# Accept username, user_code, or email
|
| 503 |
+
query = data.get("friend_username") or data.get("friend_code") or data.get("friend_email") or ""
|
| 504 |
+
query = query.strip()
|
| 505 |
+
if not query:
|
| 506 |
+
return jsonify({"error": "Thiếu thông tin tìm kiếm bạn bè"}), 400
|
| 507 |
+
|
| 508 |
+
conn = get_user_db_conn()
|
| 509 |
+
try:
|
| 510 |
+
target = conn.execute(
|
| 511 |
+
"SELECT id, username FROM users WHERE username = ? OR user_code = ? OR email = ?",
|
| 512 |
+
(query, query, query.lower())
|
| 513 |
+
).fetchone()
|
| 514 |
+
if not target:
|
| 515 |
+
return jsonify({"error": "Không tìm thấy người dùng"}), 404
|
| 516 |
+
|
| 517 |
+
target_id = target["id"]
|
| 518 |
+
if target_id == user["id"]:
|
| 519 |
+
return jsonify({"error": "Không thể kết bạn với chính mình"}), 400
|
| 520 |
+
|
| 521 |
+
exists = conn.execute("SELECT status FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
|
| 522 |
+
(user["id"], target_id, target_id, user["id"])).fetchone()
|
| 523 |
+
if exists:
|
| 524 |
+
return jsonify({"error": f"Đã có lời mời kết bạn hoặc đã là bạn bè (Trạng thái: {exists['status']})"}), 400
|
| 525 |
+
|
| 526 |
+
conn.execute("INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'pending')", (user["id"], target_id))
|
| 527 |
+
|
| 528 |
+
conn.execute(
|
| 529 |
+
"INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'friend_request', ?, ?)",
|
| 530 |
+
(target_id, user["id"], f"{user['username']} đã gửi cho bạn một lời mời kết bạn.", user["id"])
|
| 531 |
+
)
|
| 532 |
+
conn.commit()
|
| 533 |
+
return jsonify({"success": True, "message": "Đã gửi lời mời kết bạn!", "to_user": target["username"]})
|
| 534 |
+
except Exception as e:
|
| 535 |
+
return jsonify({"error": str(e)}), 500
|
| 536 |
+
finally:
|
| 537 |
+
conn.close()
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
@user_features_bp.route("/api/friends/respond", methods=["POST"])
|
| 541 |
+
def respond_friend_request():
|
| 542 |
+
user = get_current_user()
|
| 543 |
+
if not user:
|
| 544 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 545 |
+
|
| 546 |
+
data = request.json or {}
|
| 547 |
+
sender_id = data.get("sender_id")
|
| 548 |
+
action = data.get("action", "").lower()
|
| 549 |
+
|
| 550 |
+
if not sender_id or action not in ['accept', 'reject']:
|
| 551 |
+
return jsonify({"error": "Dữ liệu không hợp lệ"}), 400
|
| 552 |
+
|
| 553 |
+
conn = get_user_db_conn()
|
| 554 |
+
try:
|
| 555 |
+
req = conn.execute("SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'pending'", (sender_id, user["id"])).fetchone()
|
| 556 |
+
if not req:
|
| 557 |
+
return jsonify({"error": "Không tìm thấy lời mời kết bạn"}), 404
|
| 558 |
+
|
| 559 |
+
if action == 'accept':
|
| 560 |
+
conn.execute("UPDATE friendships SET status = 'accepted' WHERE user_id = ? AND friend_id = ?", (sender_id, user["id"]))
|
| 561 |
+
# Cross-compatibility check instead of INSERT OR IGNORE
|
| 562 |
+
exists = conn.execute("SELECT 1 FROM friendships WHERE user_id = ? AND friend_id = ?", (user["id"], sender_id)).fetchone()
|
| 563 |
+
if not exists:
|
| 564 |
+
conn.execute("INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'accepted')", (user["id"], sender_id))
|
| 565 |
+
conn.execute(
|
| 566 |
+
"INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'friend_accept', ?, ?)",
|
| 567 |
+
(sender_id, user["id"], f"{user['username']} đã chấp nhận lời mời kết bạn.", user["id"])
|
| 568 |
+
)
|
| 569 |
+
else:
|
| 570 |
+
conn.execute("DELETE FROM friendships WHERE user_id = ? AND friend_id = ?", (sender_id, user["id"]))
|
| 571 |
+
|
| 572 |
+
conn.execute(
|
| 573 |
+
"UPDATE personal_notifications SET is_read = 1 WHERE user_id = ? AND sender_id = ? AND type = 'friend_request'",
|
| 574 |
+
(user["id"], sender_id)
|
| 575 |
+
)
|
| 576 |
+
conn.commit()
|
| 577 |
+
return jsonify({"success": True, "message": f"Đã {'chấp nhận' if action == 'accept' else 'từ chối'} kết bạn."})
|
| 578 |
+
except Exception as e:
|
| 579 |
+
return jsonify({"error": str(e)}), 500
|
| 580 |
+
finally:
|
| 581 |
+
conn.close()
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
@user_features_bp.route("/api/friends/list", methods=["GET"])
|
| 585 |
+
def list_friends():
|
| 586 |
+
user = get_current_user()
|
| 587 |
+
if not user:
|
| 588 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 589 |
+
|
| 590 |
+
conn = get_user_db_conn()
|
| 591 |
+
try:
|
| 592 |
+
rows = conn.execute(
|
| 593 |
+
"""SELECT u.id, u.username, u.user_code, u.avatar,
|
| 594 |
+
(SELECT COUNT(*) FROM direct_messages dm WHERE dm.sender_id = u.id AND dm.receiver_id = ? AND dm.is_read = 0) as unread_messages
|
| 595 |
+
FROM friendships f
|
| 596 |
+
JOIN users u ON f.friend_id = u.id
|
| 597 |
+
WHERE f.user_id = ? AND f.status = 'accepted'""",
|
| 598 |
+
(user["id"], user["id"])
|
| 599 |
+
).fetchall()
|
| 600 |
+
return jsonify({"friends": [serialize_row(r) for r in rows]})
|
| 601 |
+
except Exception as e:
|
| 602 |
+
return jsonify({"error": str(e)}), 500
|
| 603 |
+
finally:
|
| 604 |
+
conn.close()
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
@user_features_bp.route("/api/friends/unfriend", methods=["POST"])
|
| 608 |
+
@jwt_required
|
| 609 |
+
def unfriend():
|
| 610 |
+
"""Hủy kết bạn: Xóa quan hệ bạn bè cả hai chiều."""
|
| 611 |
+
user = get_current_user()
|
| 612 |
+
if not user:
|
| 613 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 614 |
+
|
| 615 |
+
data = request.json or {}
|
| 616 |
+
friend_id = data.get("friend_id")
|
| 617 |
+
if not friend_id:
|
| 618 |
+
return jsonify({"error": "Thiếu friend_id"}), 400
|
| 619 |
+
|
| 620 |
+
conn = get_user_db_conn()
|
| 621 |
+
try:
|
| 622 |
+
# Kiểm tra quan hệ tồn tại và đang ở trạng thái accepted
|
| 623 |
+
rel = conn.execute(
|
| 624 |
+
"""SELECT id FROM friendships
|
| 625 |
+
WHERE ((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?))
|
| 626 |
+
AND status = 'accepted'""",
|
| 627 |
+
(user["id"], friend_id, friend_id, user["id"])
|
| 628 |
+
).fetchone()
|
| 629 |
+
if not rel:
|
| 630 |
+
return jsonify({"error": "Không tìm thấy quan hệ bạn bè"}), 404
|
| 631 |
+
|
| 632 |
+
# Xóa cả hai chiều
|
| 633 |
+
conn.execute(
|
| 634 |
+
"DELETE FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
|
| 635 |
+
(user["id"], friend_id, friend_id, user["id"])
|
| 636 |
+
)
|
| 637 |
+
conn.commit()
|
| 638 |
+
return jsonify({"success": True, "message": "Đã hủy kết bạn."})
|
| 639 |
+
except Exception as e:
|
| 640 |
+
logger.error(f"Unfriend error: {e}")
|
| 641 |
+
return jsonify({"error": str(e)}), 500
|
| 642 |
+
finally:
|
| 643 |
+
conn.close()
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
@user_features_bp.route("/api/friends/block", methods=["POST"])
|
| 647 |
+
@jwt_required
|
| 648 |
+
def block_user():
|
| 649 |
+
"""Chặn người dùng: Xóa quan hệ bạn bè (nếu có) và ghi nhận trạng thái blocked."""
|
| 650 |
+
user = get_current_user()
|
| 651 |
+
if not user:
|
| 652 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 653 |
+
|
| 654 |
+
data = request.json or {}
|
| 655 |
+
target_id = data.get("user_id")
|
| 656 |
+
if not target_id:
|
| 657 |
+
return jsonify({"error": "Thiếu user_id cần chặn"}), 400
|
| 658 |
+
if target_id == user["id"]:
|
| 659 |
+
return jsonify({"error": "Không thể tự chặn chính mình"}), 400
|
| 660 |
+
|
| 661 |
+
conn = get_user_db_conn()
|
| 662 |
+
try:
|
| 663 |
+
# Kiểm tra đã chặn chưa
|
| 664 |
+
already = conn.execute(
|
| 665 |
+
"SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
|
| 666 |
+
(user["id"], target_id)
|
| 667 |
+
).fetchone()
|
| 668 |
+
if already:
|
| 669 |
+
return jsonify({"error": "Bạn đã chặn người dùng này rồi"}), 400
|
| 670 |
+
|
| 671 |
+
# Xóa quan hệ bạn bè hai chiều (nếu có)
|
| 672 |
+
conn.execute(
|
| 673 |
+
"DELETE FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
|
| 674 |
+
(user["id"], target_id, target_id, user["id"])
|
| 675 |
+
)
|
| 676 |
+
# Ghi nhận block (một chiều: người chặn → người bị chặn)
|
| 677 |
+
conn.execute(
|
| 678 |
+
"INSERT OR REPLACE INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'blocked')",
|
| 679 |
+
(user["id"], target_id)
|
| 680 |
+
)
|
| 681 |
+
conn.commit()
|
| 682 |
+
return jsonify({"success": True, "message": "Đã chặn người dùng."})
|
| 683 |
+
except Exception as e:
|
| 684 |
+
logger.error(f"Block user error: {e}")
|
| 685 |
+
return jsonify({"error": str(e)}), 500
|
| 686 |
+
finally:
|
| 687 |
+
conn.close()
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
@user_features_bp.route("/api/friends/unblock", methods=["POST"])
|
| 691 |
+
@jwt_required
|
| 692 |
+
def unblock_user():
|
| 693 |
+
"""Bỏ chặn người dùng."""
|
| 694 |
+
user = get_current_user()
|
| 695 |
+
if not user:
|
| 696 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 697 |
+
|
| 698 |
+
data = request.json or {}
|
| 699 |
+
target_id = data.get("user_id")
|
| 700 |
+
if not target_id:
|
| 701 |
+
return jsonify({"error": "Thiếu user_id cần bỏ chặn"}), 400
|
| 702 |
+
|
| 703 |
+
conn = get_user_db_conn()
|
| 704 |
+
try:
|
| 705 |
+
rel = conn.execute(
|
| 706 |
+
"SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
|
| 707 |
+
(user["id"], target_id)
|
| 708 |
+
).fetchone()
|
| 709 |
+
if not rel:
|
| 710 |
+
return jsonify({"error": "Bạn chưa chặn người dùng này"}), 404
|
| 711 |
+
|
| 712 |
+
conn.execute(
|
| 713 |
+
"DELETE FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
|
| 714 |
+
(user["id"], target_id)
|
| 715 |
+
)
|
| 716 |
+
conn.commit()
|
| 717 |
+
return jsonify({"success": True, "message": "Đã bỏ chặn người dùng."})
|
| 718 |
+
except Exception as e:
|
| 719 |
+
logger.error(f"Unblock user error: {e}")
|
| 720 |
+
return jsonify({"error": str(e)}), 500
|
| 721 |
+
finally:
|
| 722 |
+
conn.close()
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
@user_features_bp.route("/api/friends/blocked-list", methods=["GET"])
|
| 726 |
+
@jwt_required
|
| 727 |
+
def list_blocked():
|
| 728 |
+
"""Lấy danh sách người dùng đang bị chặn."""
|
| 729 |
+
user = get_current_user()
|
| 730 |
+
if not user:
|
| 731 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 732 |
+
|
| 733 |
+
conn = get_user_db_conn()
|
| 734 |
+
try:
|
| 735 |
+
rows = conn.execute(
|
| 736 |
+
"""SELECT u.id, u.username, u.user_code, u.avatar, f.created_at as blocked_at
|
| 737 |
+
FROM friendships f
|
| 738 |
+
JOIN users u ON f.friend_id = u.id
|
| 739 |
+
WHERE f.user_id = ? AND f.status = 'blocked'""",
|
| 740 |
+
(user["id"],)
|
| 741 |
+
).fetchall()
|
| 742 |
+
return jsonify({"blocked_users": [serialize_row(r) for r in rows]})
|
| 743 |
+
except Exception as e:
|
| 744 |
+
return jsonify({"error": str(e)}), 500
|
| 745 |
+
finally:
|
| 746 |
+
conn.close()
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
@user_features_bp.route("/api/messages/send", methods=["POST"])
|
| 750 |
+
@jwt_required
|
| 751 |
+
def send_message():
|
| 752 |
+
user = get_current_user()
|
| 753 |
+
if not user:
|
| 754 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 755 |
+
|
| 756 |
+
data = request.json or {}
|
| 757 |
+
receiver_id = data.get("receiver_id")
|
| 758 |
+
message = data.get("message", "").strip()
|
| 759 |
+
|
| 760 |
+
if not receiver_id or not message:
|
| 761 |
+
return jsonify({"error": "Thiếu người nhận hoặc nội dung tin nhắn"}), 400
|
| 762 |
+
|
| 763 |
+
conn = get_user_db_conn()
|
| 764 |
+
try:
|
| 765 |
+
# Kiểm tra bảo mật: Người nhận có đang chặn người gửi không?
|
| 766 |
+
blocked_by_receiver = conn.execute(
|
| 767 |
+
"SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
|
| 768 |
+
(receiver_id, user["id"])
|
| 769 |
+
).fetchone()
|
| 770 |
+
if blocked_by_receiver:
|
| 771 |
+
return jsonify({"error": "Không thể gửi tin nhắn đến người dùng này."}), 403
|
| 772 |
+
|
| 773 |
+
# Kiểm tra bảo mật: Người gửi có đang chặn người nhận không?
|
| 774 |
+
blocked_by_sender = conn.execute(
|
| 775 |
+
"SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
|
| 776 |
+
(user["id"], receiver_id)
|
| 777 |
+
).fetchone()
|
| 778 |
+
if blocked_by_sender:
|
| 779 |
+
return jsonify({"error": "Bạn đang chặn người dùng này. Bỏ chặn trước khi nhắn tin."}), 403
|
| 780 |
+
|
| 781 |
+
enc_msg = encrypt_message(message)
|
| 782 |
+
cursor = conn.execute(
|
| 783 |
+
"INSERT INTO direct_messages (sender_id, receiver_id, message) VALUES (?, ?, ?)",
|
| 784 |
+
(user["id"], receiver_id, enc_msg)
|
| 785 |
+
)
|
| 786 |
+
msg_id = cursor.lastrowid
|
| 787 |
+
|
| 788 |
+
conn.execute(
|
| 789 |
+
"INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'message', ?, ?)",
|
| 790 |
+
(receiver_id, user["id"], f"{user['username']} đã gửi cho bạn một tin nhắn mới.", msg_id)
|
| 791 |
+
)
|
| 792 |
+
conn.commit()
|
| 793 |
+
return jsonify({"success": True, "message": "Gửi tin nhắn thành công!", "msg_id": msg_id})
|
| 794 |
+
except Exception as e:
|
| 795 |
+
return jsonify({"error": str(e)}), 500
|
| 796 |
+
finally:
|
| 797 |
+
conn.close()
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
@user_features_bp.route("/api/messages/chat/<int:friend_id>", methods=["GET"])
|
| 801 |
+
def get_chat_history(friend_id):
|
| 802 |
+
user = get_current_user()
|
| 803 |
+
if not user:
|
| 804 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 805 |
+
|
| 806 |
+
conn = get_user_db_conn()
|
| 807 |
+
try:
|
| 808 |
+
rows = conn.execute(
|
| 809 |
+
"""SELECT id, sender_id, receiver_id, message, is_read, created_at
|
| 810 |
+
FROM direct_messages
|
| 811 |
+
WHERE (sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)
|
| 812 |
+
ORDER BY created_at ASC""",
|
| 813 |
+
(user["id"], friend_id, friend_id, user["id"])
|
| 814 |
+
).fetchall()
|
| 815 |
+
|
| 816 |
+
conn.execute(
|
| 817 |
+
"UPDATE direct_messages SET is_read = 1 WHERE sender_id = ? AND receiver_id = ?",
|
| 818 |
+
(friend_id, user["id"])
|
| 819 |
+
)
|
| 820 |
+
conn.execute(
|
| 821 |
+
"UPDATE personal_notifications SET is_read = 1 WHERE user_id = ? AND sender_id = ? AND type = 'message'",
|
| 822 |
+
(user["id"], friend_id)
|
| 823 |
+
)
|
| 824 |
+
conn.commit()
|
| 825 |
+
|
| 826 |
+
messages = []
|
| 827 |
+
for r in rows:
|
| 828 |
+
d = dict(r)
|
| 829 |
+
d["message"] = decrypt_message(d.get("message", ""))
|
| 830 |
+
for k, v in d.items():
|
| 831 |
+
if hasattr(v, "isoformat"):
|
| 832 |
+
d[k] = v.isoformat()
|
| 833 |
+
messages.append(d)
|
| 834 |
+
|
| 835 |
+
return jsonify({"messages": messages})
|
| 836 |
+
except Exception as e:
|
| 837 |
+
return jsonify({"error": str(e)}), 500
|
| 838 |
+
finally:
|
| 839 |
+
conn.close()
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
@user_features_bp.route("/api/books/share", methods=["POST"])
|
| 843 |
+
def share_book():
|
| 844 |
+
user = get_current_user()
|
| 845 |
+
if not user:
|
| 846 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 847 |
+
|
| 848 |
+
data = request.json or {}
|
| 849 |
+
friend_id = data.get("friend_id")
|
| 850 |
+
book_id = data.get("book_id")
|
| 851 |
+
custom_message = data.get("message", "").strip()
|
| 852 |
+
|
| 853 |
+
if not friend_id or not book_id:
|
| 854 |
+
return jsonify({"error": "Thiếu thông tin người nhận hoặc truyện cần chia sẻ"}), 400
|
| 855 |
+
|
| 856 |
+
conn = get_user_db_conn()
|
| 857 |
+
try:
|
| 858 |
+
book_title = "Truyện"
|
| 859 |
+
from backend.database.db_manager import get_db
|
| 860 |
+
main_conn = get_db()
|
| 861 |
+
row = main_conn.execute("SELECT title_vietphrase, title_hanviet, title FROM books WHERE id = ?", (book_id,)).fetchone()
|
| 862 |
+
if row:
|
| 863 |
+
book_title = row["title_vietphrase"] or row["title_hanviet"] or row["title"]
|
| 864 |
+
|
| 865 |
+
msg_content = f"{user['username']} đã chia sẻ truyện '{book_title}' với bạn."
|
| 866 |
+
if custom_message:
|
| 867 |
+
msg_content += f" Lời nhắn: \"{custom_message}\""
|
| 868 |
+
|
| 869 |
+
conn.execute(
|
| 870 |
+
"INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'book_share', ?, ?)",
|
| 871 |
+
(friend_id, user["id"], msg_content, book_id)
|
| 872 |
+
)
|
| 873 |
+
|
| 874 |
+
share_chat_msg = f"[Chia sẻ truyện] '{book_title}' - Xem chi tiết tại /book/{book_id}"
|
| 875 |
+
if custom_message:
|
| 876 |
+
share_chat_msg += f"\nLời nhắn: {custom_message}"
|
| 877 |
+
enc_share_msg = encrypt_message(share_chat_msg)
|
| 878 |
+
conn.execute(
|
| 879 |
+
"INSERT INTO direct_messages (sender_id, receiver_id, message) VALUES (?, ?, ?)",
|
| 880 |
+
(user["id"], friend_id, enc_share_msg)
|
| 881 |
+
)
|
| 882 |
+
|
| 883 |
+
conn.commit()
|
| 884 |
+
return jsonify({"success": True, "message": "Chia sẻ truyện thành công!"})
|
| 885 |
+
except Exception as e:
|
| 886 |
+
return jsonify({"error": str(e)}), 500
|
| 887 |
+
finally:
|
| 888 |
+
conn.close()
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
@user_features_bp.route("/api/notifications/personal", methods=["GET"])
|
| 892 |
+
def get_personal_notifications():
|
| 893 |
+
user = get_current_user()
|
| 894 |
+
if not user:
|
| 895 |
+
return jsonify({"notifications": []})
|
| 896 |
+
|
| 897 |
+
conn = get_user_db_conn()
|
| 898 |
+
try:
|
| 899 |
+
rows = conn.execute(
|
| 900 |
+
"""SELECT n.id, n.sender_id, n.type, n.message, n.related_id, n.is_read, n.created_at, u.username as sender_name
|
| 901 |
+
FROM personal_notifications n
|
| 902 |
+
LEFT JOIN users u ON n.sender_id = u.id
|
| 903 |
+
WHERE n.user_id = ?
|
| 904 |
+
ORDER BY n.created_at DESC LIMIT 50""",
|
| 905 |
+
(user["id"],)
|
| 906 |
+
).fetchall()
|
| 907 |
+
return jsonify({"notifications": [serialize_row(r) for r in rows]})
|
| 908 |
+
except Exception as e:
|
| 909 |
+
return jsonify({"error": str(e)}), 500
|
| 910 |
+
finally:
|
| 911 |
+
conn.close()
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
@user_features_bp.route("/api/notifications/personal/read", methods=["POST"])
|
| 915 |
+
def read_personal_notification():
|
| 916 |
+
user = get_current_user()
|
| 917 |
+
if not user:
|
| 918 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 919 |
+
|
| 920 |
+
data = request.json or {}
|
| 921 |
+
notif_id = data.get("notification_id")
|
| 922 |
+
|
| 923 |
+
if not notif_id:
|
| 924 |
+
return jsonify({"error": "Thiếu notification_id"}), 400
|
| 925 |
+
|
| 926 |
+
conn = get_user_db_conn()
|
| 927 |
+
try:
|
| 928 |
+
conn.execute(
|
| 929 |
+
"UPDATE personal_notifications SET is_read = 1 WHERE id = ? AND user_id = ?",
|
| 930 |
+
(notif_id, user["id"])
|
| 931 |
+
)
|
| 932 |
+
conn.commit()
|
| 933 |
+
return jsonify({"success": True})
|
| 934 |
+
except Exception as e:
|
| 935 |
+
return jsonify({"error": str(e)}), 500
|
| 936 |
+
finally:
|
| 937 |
+
conn.close()
|
| 938 |
+
|
backend/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import timedelta
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv(override=True)
|
| 6 |
+
|
| 7 |
+
class Config:
|
| 8 |
+
# Flask settings
|
| 9 |
+
SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "tienhiep_lyvuha_secret_key_9988")
|
| 10 |
+
|
| 11 |
+
# Base directories and DB paths
|
| 12 |
+
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 13 |
+
DB_PATH = os.path.join(ROOT_DIR, "merged_books.db")
|
| 14 |
+
USER_DB_PATH = os.path.join(ROOT_DIR, "users_data.db")
|
| 15 |
+
|
| 16 |
+
# JWT authentication settings
|
| 17 |
+
JWT_SECRET = SECRET_KEY + "_jwt_v2_secure"
|
| 18 |
+
JWT_ALGORITHM = "HS256"
|
| 19 |
+
JWT_ACCESS_TOKEN_EXPIRE = timedelta(days=365)
|
| 20 |
+
JWT_REFRESH_TOKEN_EXPIRE = timedelta(days=365)
|
| 21 |
+
|
| 22 |
+
# VIP plans
|
| 23 |
+
VIP_PLANS = {
|
| 24 |
+
"month": {
|
| 25 |
+
"name_vi": "Gói Tháng",
|
| 26 |
+
"name_en": "Monthly Plan",
|
| 27 |
+
"name_zh": "月套餐",
|
| 28 |
+
"price": 50000, # 50,000 VND
|
| 29 |
+
"duration_days": 30,
|
| 30 |
+
"description_vi": "VIP 1 tháng — Dịch không giới hạn, AI, TTS, EPUB",
|
| 31 |
+
"description_en": "VIP 1 month — Unlimited translation, AI, TTS, EPUB",
|
| 32 |
+
"description_zh": "VIP 1个月 — 无限翻译、AI、TTS、EPUB",
|
| 33 |
+
},
|
| 34 |
+
"year": {
|
| 35 |
+
"name_vi": "Gói Năm (Tiết kiệm 67%)",
|
| 36 |
+
"name_en": "Yearly Plan (Save 67%)",
|
| 37 |
+
"name_zh": "年套餐 (节省67%)",
|
| 38 |
+
"price": 200000, # 200,000 VND
|
| 39 |
+
"duration_days": 365,
|
| 40 |
+
"description_vi": "VIP 1 năm — Tất cả quyền lợi VIP, ưu đãi tốt nhất",
|
| 41 |
+
"description_en": "VIP 1 year — All VIP benefits, best value",
|
| 42 |
+
"description_zh": "VIP 1年 — 所有VIP权益,最优惠",
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# Payment Gateway Config
|
| 47 |
+
PAYMENT_CONFIG = {
|
| 48 |
+
"bank_id": os.environ.get("BANK_ID", "MB"),
|
| 49 |
+
"account_no": os.environ.get("BANK_ACCOUNT_NO", "0349717475"),
|
| 50 |
+
"account_name": os.environ.get("BANK_ACCOUNT_NAME", "LY VU HA"),
|
| 51 |
+
"template": "compact2",
|
| 52 |
+
"payos_client_id": os.environ.get("PAYOS_CLIENT_ID", ""),
|
| 53 |
+
"payos_api_key": os.environ.get("PAYOS_API_KEY", ""),
|
| 54 |
+
"payos_checksum_key": os.environ.get("PAYOS_CHECKSUM_KEY", ""),
|
| 55 |
+
"payos_webhook_url": os.environ.get("PAYOS_WEBHOOK_URL", "https://yourdomain.com/api/payment/webhook"),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Rate Limiting
|
| 59 |
+
RATE_LIMITS = {
|
| 60 |
+
"login": {"max": 5, "window": 300},
|
| 61 |
+
"register": {"max": 3, "window": 600},
|
| 62 |
+
"otp": {"max": 3, "window": 60},
|
| 63 |
+
"payment": {"max": 10, "window": 600},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
# SMTP email config
|
| 67 |
+
SMTP_CONFIG = {
|
| 68 |
+
"host": "smtp.gmail.com",
|
| 69 |
+
"port": 587,
|
| 70 |
+
"username": os.environ.get("SMTP_EMAIL", ""),
|
| 71 |
+
"password": os.environ.get("SMTP_PASSWORD", ""),
|
| 72 |
+
"from_name": "Novel Translator VIP",
|
| 73 |
+
"enabled": True if os.environ.get("SMTP_PASSWORD") else False
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
# Google OAuth
|
| 77 |
+
GOOGLE_OAUTH_CONFIG = {
|
| 78 |
+
"client_id": os.environ.get("GOOGLE_CLIENT_ID", ""),
|
| 79 |
+
"client_secret": os.environ.get("GOOGLE_CLIENT_SECRET", ""),
|
| 80 |
+
"redirect_uri": os.environ.get("GOOGLE_REDIRECT_URI", "http://localhost:5050/api/auth/google/callback"),
|
| 81 |
+
"enabled": True
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# VIP Validation Codes
|
| 85 |
+
_vip_codes_env = os.environ.get("VALID_VIP_CODES", "")
|
| 86 |
+
if _vip_codes_env:
|
| 87 |
+
VALID_VIP_CODES = set(c.strip() for c in _vip_codes_env.split(",") if c.strip())
|
| 88 |
+
else:
|
| 89 |
+
VALID_VIP_CODES = {"VIP2026", "ANTIGRAVITY", "PREMIUM_MEMBER", "VIP_TRANSLATOR", "VIP_SERVER", "LYVUHA_ADMIN_2026"}
|
backend/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# backend/core/__init__.py
|
backend/core/decorators.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import wraps
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from flask import request, jsonify, session
|
| 4 |
+
from backend.core.security import verify_access_token
|
| 5 |
+
from backend.database.db_manager import get_user_db_conn
|
| 6 |
+
|
| 7 |
+
def jwt_required(f):
|
| 8 |
+
"""Decorator: require valid JWT OR session auth."""
|
| 9 |
+
@wraps(f)
|
| 10 |
+
def decorated(*args, **kwargs):
|
| 11 |
+
# Try JWT first
|
| 12 |
+
auth_header = request.headers.get("Authorization", "")
|
| 13 |
+
if auth_header.startswith("Bearer "):
|
| 14 |
+
token = auth_header[7:]
|
| 15 |
+
payload = verify_access_token(token)
|
| 16 |
+
if payload:
|
| 17 |
+
request._jwt_user = {
|
| 18 |
+
"id": int(payload["sub"]),
|
| 19 |
+
"username": payload["username"],
|
| 20 |
+
"vip_status": payload["vip"]
|
| 21 |
+
}
|
| 22 |
+
return f(*args, **kwargs)
|
| 23 |
+
|
| 24 |
+
# Fall back to session auth
|
| 25 |
+
if "user_id" in session:
|
| 26 |
+
request._jwt_user = {
|
| 27 |
+
"id": session["user_id"],
|
| 28 |
+
"username": session["username"],
|
| 29 |
+
"vip_status": session.get("vip_status", 0)
|
| 30 |
+
}
|
| 31 |
+
return f(*args, **kwargs)
|
| 32 |
+
|
| 33 |
+
return jsonify({"error": "Authentication required"}), 401
|
| 34 |
+
return decorated
|
| 35 |
+
|
| 36 |
+
def require_api_key_auth(f):
|
| 37 |
+
"""Decorator: require valid Developer API Key or VIP Validation Code auth."""
|
| 38 |
+
@wraps(f)
|
| 39 |
+
def decorated(*args, **kwargs):
|
| 40 |
+
# 1. Try to find the key in Authorization header
|
| 41 |
+
auth_header = request.headers.get("Authorization", "")
|
| 42 |
+
api_key = ""
|
| 43 |
+
if auth_header.startswith("Bearer "):
|
| 44 |
+
api_key = auth_header[7:].strip()
|
| 45 |
+
|
| 46 |
+
# If the api_key from Authorization header looks like a JWT token (not sk-tc- and not a valid VIP code),
|
| 47 |
+
# clear it so we fall back to other headers or the body.
|
| 48 |
+
from backend.config import Config
|
| 49 |
+
if api_key and not api_key.startswith("sk-tc-") and api_key not in Config.VALID_VIP_CODES:
|
| 50 |
+
api_key = ""
|
| 51 |
+
|
| 52 |
+
# 2. Try X-VIP-Key header
|
| 53 |
+
if not api_key:
|
| 54 |
+
api_key = request.headers.get("X-VIP-Key", "").strip()
|
| 55 |
+
|
| 56 |
+
# 3. Try X-VIP-Code header
|
| 57 |
+
if not api_key:
|
| 58 |
+
api_key = request.headers.get("X-VIP-Code", "").strip()
|
| 59 |
+
|
| 60 |
+
# 4. Try request JSON body
|
| 61 |
+
if not api_key:
|
| 62 |
+
try:
|
| 63 |
+
data = request.json or {}
|
| 64 |
+
api_key = data.get("vip_key", "") or data.get("vip_code", "") or data.get("api_key", "")
|
| 65 |
+
if isinstance(api_key, str):
|
| 66 |
+
api_key = api_key.strip()
|
| 67 |
+
else:
|
| 68 |
+
api_key = ""
|
| 69 |
+
except:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
# 5. Check if it's empty
|
| 73 |
+
if not api_key:
|
| 74 |
+
return jsonify({"error": "Missing API Key or VIP Code. Please authenticate."}), 401
|
| 75 |
+
|
| 76 |
+
# 6. Check if it is a valid static VIP code
|
| 77 |
+
from backend.config import Config
|
| 78 |
+
if api_key in Config.VALID_VIP_CODES:
|
| 79 |
+
# Bypassed - treat as static VIP access with unlimited balance
|
| 80 |
+
request.api_key = api_key
|
| 81 |
+
request.api_user_id = None
|
| 82 |
+
request.api_balance = 999999.0
|
| 83 |
+
return f(*args, **kwargs)
|
| 84 |
+
|
| 85 |
+
# 7. Otherwise, it must be a developer key starting with sk-tc-
|
| 86 |
+
if not api_key.startswith("sk-tc-"):
|
| 87 |
+
return jsonify({"error": "Invalid API key format. Key must start with 'sk-tc-' or be a valid VIP Code."}), 401
|
| 88 |
+
|
| 89 |
+
conn = get_user_db_conn()
|
| 90 |
+
key_record = conn.execute(
|
| 91 |
+
"SELECT k.*, u.api_balance FROM api_keys k JOIN users u ON k.user_id = u.id WHERE k.api_key = ? AND k.status = 'active'",
|
| 92 |
+
(api_key,)
|
| 93 |
+
).fetchone()
|
| 94 |
+
|
| 95 |
+
if not key_record:
|
| 96 |
+
conn.close()
|
| 97 |
+
return jsonify({"error": "API Key not found, inactive, or revoked."}), 401
|
| 98 |
+
|
| 99 |
+
balance = key_record["api_balance"] if key_record["api_balance"] is not None else 0.0
|
| 100 |
+
if balance <= 0.0:
|
| 101 |
+
conn.close()
|
| 102 |
+
return jsonify({
|
| 103 |
+
"error": f"Tài khoản hết số dư API (Số dư hiện tại: {balance:.2f}đ). Vui lòng nạp thêm tiền tại website để tiếp tục sử dụng."
|
| 104 |
+
}), 402
|
| 105 |
+
|
| 106 |
+
# Update last_used_at
|
| 107 |
+
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
| 108 |
+
conn.execute("UPDATE api_keys SET last_used_at = ? WHERE api_key = ?", (now, api_key))
|
| 109 |
+
conn.commit()
|
| 110 |
+
conn.close()
|
| 111 |
+
|
| 112 |
+
request.api_key = api_key
|
| 113 |
+
request.api_user_id = key_record["user_id"]
|
| 114 |
+
request.api_balance = balance
|
| 115 |
+
return f(*args, **kwargs)
|
| 116 |
+
return decorated
|
| 117 |
+
|
| 118 |
+
def get_current_user():
|
| 119 |
+
"""Get current user from JWT or session."""
|
| 120 |
+
if hasattr(request, '_jwt_user'):
|
| 121 |
+
return request._jwt_user
|
| 122 |
+
|
| 123 |
+
auth_header = request.headers.get("Authorization", "")
|
| 124 |
+
if auth_header.startswith("Bearer "):
|
| 125 |
+
token = auth_header[7:]
|
| 126 |
+
payload = verify_access_token(token)
|
| 127 |
+
if payload:
|
| 128 |
+
return {
|
| 129 |
+
"id": int(payload["sub"]),
|
| 130 |
+
"username": payload["username"],
|
| 131 |
+
"vip_status": payload["vip"]
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
if "user_id" in session:
|
| 135 |
+
return {
|
| 136 |
+
"id": session["user_id"],
|
| 137 |
+
"username": session["username"],
|
| 138 |
+
"vip_status": session.get("vip_status", 0)
|
| 139 |
+
}
|
| 140 |
+
return None
|
backend/core/logger.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import logging
|
| 4 |
+
from logging.handlers import RotatingFileHandler
|
| 5 |
+
|
| 6 |
+
# Project root directory detection
|
| 7 |
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 8 |
+
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
|
| 9 |
+
LOG_FILE = os.path.join(LOG_DIR, "app.log")
|
| 10 |
+
|
| 11 |
+
def setup_logger(name="app", log_level=logging.INFO):
|
| 12 |
+
"""Sets up a central rolling logger that outputs to both stdout and a file."""
|
| 13 |
+
# Ensure logs directory exists
|
| 14 |
+
try:
|
| 15 |
+
os.makedirs(LOG_DIR, exist_ok=True)
|
| 16 |
+
except Exception as e:
|
| 17 |
+
sys.stderr.write(f"[WARNING] Failed to create log directory '{LOG_DIR}': {e}\n")
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(name)
|
| 20 |
+
logger.setLevel(log_level)
|
| 21 |
+
|
| 22 |
+
# Avoid duplicate handlers if setup_logger is called multiple times
|
| 23 |
+
if logger.handlers:
|
| 24 |
+
return logger
|
| 25 |
+
|
| 26 |
+
# Log format: time | level | name | message
|
| 27 |
+
formatter = logging.Formatter(
|
| 28 |
+
"[%(asctime)s] %(levelname)s [%(name)s] %(message)s",
|
| 29 |
+
datefmt="%Y-%m-%d %H:%M:%S"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Console stream handler
|
| 33 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 34 |
+
console_handler.setFormatter(formatter)
|
| 35 |
+
logger.addHandler(console_handler)
|
| 36 |
+
|
| 37 |
+
# Rolling file handler
|
| 38 |
+
try:
|
| 39 |
+
file_handler = RotatingFileHandler(
|
| 40 |
+
LOG_FILE,
|
| 41 |
+
maxBytes=10 * 1024 * 1024, # 10 MB
|
| 42 |
+
backupCount=5,
|
| 43 |
+
encoding="utf-8"
|
| 44 |
+
)
|
| 45 |
+
file_handler.setFormatter(formatter)
|
| 46 |
+
logger.addHandler(file_handler)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
sys.stderr.write(f"[WARNING] Failed to configure file logger for '{LOG_FILE}': {e}\n")
|
| 49 |
+
|
| 50 |
+
return logger
|
| 51 |
+
|
| 52 |
+
# Pre-initialize central logger
|
| 53 |
+
logger = setup_logger()
|
backend/core/monitoring.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import sqlite3
|
| 4 |
+
import datetime
|
| 5 |
+
from backend.config import Config
|
| 6 |
+
from backend.database.db_manager import get_user_db_conn, get_db
|
| 7 |
+
|
| 8 |
+
def get_cpu_load():
|
| 9 |
+
"""Retrieve CPU 1m, 5m, 15m load average on Linux."""
|
| 10 |
+
try:
|
| 11 |
+
if os.path.exists("/proc/loadavg"):
|
| 12 |
+
with open("/proc/loadavg", "r") as f:
|
| 13 |
+
load = f.read().strip().split()
|
| 14 |
+
return {
|
| 15 |
+
"load_1m": float(load[0]),
|
| 16 |
+
"load_5m": float(load[1]),
|
| 17 |
+
"load_15m": float(load[2])
|
| 18 |
+
}
|
| 19 |
+
except Exception:
|
| 20 |
+
pass
|
| 21 |
+
return {"load_1m": 0.0, "load_5m": 0.0, "load_15m": 0.0}
|
| 22 |
+
|
| 23 |
+
def get_memory_info():
|
| 24 |
+
"""Retrieve system RAM and process Resident Set Size (RSS) memory info."""
|
| 25 |
+
mem_total = 0
|
| 26 |
+
mem_available = 0
|
| 27 |
+
process_rss = 0
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
if os.path.exists("/proc/meminfo"):
|
| 31 |
+
with open("/proc/meminfo", "r") as f:
|
| 32 |
+
for line in f:
|
| 33 |
+
if line.startswith("MemTotal:"):
|
| 34 |
+
mem_total = int(line.split()[1]) * 1024 # kB to bytes
|
| 35 |
+
elif line.startswith("MemAvailable:"):
|
| 36 |
+
mem_available = int(line.split()[1]) * 1024
|
| 37 |
+
|
| 38 |
+
if os.path.exists("/proc/self/status"):
|
| 39 |
+
with open("/proc/self/status", "r") as f:
|
| 40 |
+
for line in f:
|
| 41 |
+
if line.startswith("VmRSS:"):
|
| 42 |
+
process_rss = int(line.split()[1]) * 1024
|
| 43 |
+
break
|
| 44 |
+
except Exception:
|
| 45 |
+
pass
|
| 46 |
+
|
| 47 |
+
mem_used = mem_total - mem_available
|
| 48 |
+
mem_pct = (mem_used / mem_total * 100) if mem_total > 0 else 0.0
|
| 49 |
+
|
| 50 |
+
return {
|
| 51 |
+
"total_bytes": mem_total,
|
| 52 |
+
"used_bytes": mem_used,
|
| 53 |
+
"available_bytes": mem_available,
|
| 54 |
+
"percent": round(mem_pct, 2),
|
| 55 |
+
"process_rss_bytes": process_rss
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
def get_disk_info():
|
| 59 |
+
"""Retrieve disk space usage of root partition."""
|
| 60 |
+
try:
|
| 61 |
+
total, used, free = shutil.disk_usage("/")
|
| 62 |
+
percent = (used / total * 100) if total > 0 else 0.0
|
| 63 |
+
return {
|
| 64 |
+
"total_bytes": total,
|
| 65 |
+
"used_bytes": used,
|
| 66 |
+
"free_bytes": free,
|
| 67 |
+
"percent": round(percent, 2)
|
| 68 |
+
}
|
| 69 |
+
except Exception:
|
| 70 |
+
return {
|
| 71 |
+
"total_bytes": 0,
|
| 72 |
+
"used_bytes": 0,
|
| 73 |
+
"free_bytes": 0,
|
| 74 |
+
"percent": 0.0
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
def get_database_sizes():
|
| 78 |
+
"""Get the sizes of SQLite databases on disk."""
|
| 79 |
+
sizes = {}
|
| 80 |
+
db_paths = {
|
| 81 |
+
"user_db": Config.USER_DB_PATH,
|
| 82 |
+
"books_db": Config.DB_PATH,
|
| 83 |
+
"books_advanced": os.path.join(Config.ROOT_DIR, "merged_books_advanced.db"),
|
| 84 |
+
"books_fast": os.path.join(Config.ROOT_DIR, "merged_books_fast.db"),
|
| 85 |
+
"books_vietphrase": os.path.join(Config.ROOT_DIR, "merged_books_vietphrase.db"),
|
| 86 |
+
"books_hanviet": os.path.join(Config.ROOT_DIR, "merged_books_hanviet.db"),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
for key, path in db_paths.items():
|
| 90 |
+
if os.path.exists(path):
|
| 91 |
+
sizes[key] = os.path.getsize(path)
|
| 92 |
+
else:
|
| 93 |
+
sizes[key] = 0
|
| 94 |
+
|
| 95 |
+
return sizes
|
| 96 |
+
|
| 97 |
+
def count_log_errors():
|
| 98 |
+
"""Scan logs/app.log and count the number of [ERROR] or ERROR level log messages."""
|
| 99 |
+
log_path = os.path.join(Config.ROOT_DIR, "logs", "app.log")
|
| 100 |
+
count = 0
|
| 101 |
+
if not os.path.exists(log_path):
|
| 102 |
+
return 0
|
| 103 |
+
try:
|
| 104 |
+
# Read the file. Since it's limited to 10MB, it's safe to scan
|
| 105 |
+
with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 106 |
+
for line in f:
|
| 107 |
+
if "ERROR" in line or "[ERROR]" in line:
|
| 108 |
+
count += 1
|
| 109 |
+
except Exception:
|
| 110 |
+
pass
|
| 111 |
+
return count
|
| 112 |
+
|
| 113 |
+
def get_system_statistics():
|
| 114 |
+
"""Query user database to pull statistics like total users, payments, and API requests."""
|
| 115 |
+
stats = {
|
| 116 |
+
"total_users": 0,
|
| 117 |
+
"vip_users": 0,
|
| 118 |
+
"total_payments": 0,
|
| 119 |
+
"total_api_keys": 0,
|
| 120 |
+
"total_api_calls": 0,
|
| 121 |
+
"total_books_scraped": 0
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
conn = get_user_db_conn()
|
| 126 |
+
stats["total_users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
| 127 |
+
stats["vip_users"] = conn.execute("SELECT COUNT(*) FROM users WHERE vip_status = 1").fetchone()[0]
|
| 128 |
+
|
| 129 |
+
# Check if tables exist before querying
|
| 130 |
+
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
|
| 131 |
+
|
| 132 |
+
if "payments" in tables:
|
| 133 |
+
stats["total_payments"] = conn.execute("SELECT COUNT(*) FROM payments WHERE status = 'completed'").fetchone()[0]
|
| 134 |
+
if "api_keys" in tables:
|
| 135 |
+
stats["total_api_keys"] = conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0]
|
| 136 |
+
if "api_usage" in tables:
|
| 137 |
+
stats["total_api_calls"] = conn.execute("SELECT COUNT(*) FROM api_usage").fetchone()[0]
|
| 138 |
+
|
| 139 |
+
conn.close()
|
| 140 |
+
except Exception:
|
| 141 |
+
pass
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
main_conn = get_db()
|
| 145 |
+
stats["total_books_scraped"] = main_conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
|
| 146 |
+
except Exception:
|
| 147 |
+
pass
|
| 148 |
+
|
| 149 |
+
return stats
|
| 150 |
+
|
| 151 |
+
def collect_all_metrics():
|
| 152 |
+
"""Gather all diagnostics into a consolidated system metrics dictionary."""
|
| 153 |
+
return {
|
| 154 |
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
| 155 |
+
"cpu": get_cpu_load(),
|
| 156 |
+
"memory": get_memory_info(),
|
| 157 |
+
"disk": get_disk_info(),
|
| 158 |
+
"databases": get_database_sizes(),
|
| 159 |
+
"error_logs_count": count_log_errors(),
|
| 160 |
+
"statistics": get_system_statistics()
|
| 161 |
+
}
|
backend/core/profiler.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import logging
|
| 3 |
+
import requests
|
| 4 |
+
from collections import deque
|
| 5 |
+
from flask import g, has_app_context, request
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("profiler")
|
| 8 |
+
logger.setLevel(logging.INFO)
|
| 9 |
+
if not logger.handlers:
|
| 10 |
+
import sys
|
| 11 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 12 |
+
handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s"))
|
| 13 |
+
logger.addHandler(handler)
|
| 14 |
+
|
| 15 |
+
# Ring buffer for recent profiled requests
|
| 16 |
+
recent_profiles = deque(maxlen=100)
|
| 17 |
+
|
| 18 |
+
# 1. Monkey patch requests to measure all outbound HTTP API calls
|
| 19 |
+
_original_request = requests.request
|
| 20 |
+
|
| 21 |
+
def _monitored_request(*args, **kwargs):
|
| 22 |
+
start_time = time.time()
|
| 23 |
+
try:
|
| 24 |
+
return _original_request(*args, **kwargs)
|
| 25 |
+
finally:
|
| 26 |
+
duration = time.time() - start_time
|
| 27 |
+
if has_app_context():
|
| 28 |
+
if not hasattr(g, "external_time"):
|
| 29 |
+
g.external_time = 0.0
|
| 30 |
+
g.external_time += duration
|
| 31 |
+
|
| 32 |
+
requests.request = _monitored_request
|
| 33 |
+
|
| 34 |
+
# DB tracking helper
|
| 35 |
+
def record_db_time(duration: float):
|
| 36 |
+
if has_app_context():
|
| 37 |
+
if not hasattr(g, "db_time"):
|
| 38 |
+
g.db_time = 0.0
|
| 39 |
+
g.db_time += duration
|
| 40 |
+
|
| 41 |
+
def get_recent_profiles():
|
| 42 |
+
return list(recent_profiles)
|
| 43 |
+
|
| 44 |
+
def setup_profiler(app):
|
| 45 |
+
@app.before_request
|
| 46 |
+
def start_timer():
|
| 47 |
+
g.start_time = time.time()
|
| 48 |
+
g.db_time = 0.0
|
| 49 |
+
g.external_time = 0.0
|
| 50 |
+
|
| 51 |
+
@app.after_request
|
| 52 |
+
def log_and_header(response):
|
| 53 |
+
if not hasattr(g, "start_time"):
|
| 54 |
+
return response
|
| 55 |
+
|
| 56 |
+
total_time = time.time() - g.start_time
|
| 57 |
+
db_time = getattr(g, "db_time", 0.0)
|
| 58 |
+
ext_time = getattr(g, "external_time", 0.0)
|
| 59 |
+
cpu_time = max(0.0, total_time - db_time - ext_time)
|
| 60 |
+
|
| 61 |
+
# Add latency/breakdown headers in milliseconds
|
| 62 |
+
import os
|
| 63 |
+
response.headers["X-Response-Time-Ms"] = f"{int(total_time * 1000)}"
|
| 64 |
+
response.headers["X-DB-Time-Ms"] = f"{int(db_time * 1000)}"
|
| 65 |
+
response.headers["X-External-Time-Ms"] = f"{int(ext_time * 1000)}"
|
| 66 |
+
response.headers["X-CPU-Process-Time-Ms"] = f"{int(cpu_time * 1000)}"
|
| 67 |
+
response.headers["X-Process-ID"] = str(os.getpid())
|
| 68 |
+
|
| 69 |
+
log_entry = {
|
| 70 |
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
| 71 |
+
"method": request.method,
|
| 72 |
+
"path": request.path,
|
| 73 |
+
"status_code": response.status_code,
|
| 74 |
+
"total_ms": round(total_time * 1000, 1),
|
| 75 |
+
"db_ms": round(db_time * 1000, 1),
|
| 76 |
+
"external_ms": round(ext_time * 1000, 1),
|
| 77 |
+
"cpu_ms": round(cpu_time * 1000, 1)
|
| 78 |
+
}
|
| 79 |
+
recent_profiles.append(log_entry)
|
| 80 |
+
|
| 81 |
+
# Print directly to stdout/gunicorn logs
|
| 82 |
+
logger.info(
|
| 83 |
+
f"[PROFILER] {request.method} {request.path} ({response.status_code}) - "
|
| 84 |
+
f"Total: {log_entry['total_ms']}ms | "
|
| 85 |
+
f"DB: {log_entry['db_ms']}ms | "
|
| 86 |
+
f"External: {log_entry['external_ms']}ms | "
|
| 87 |
+
f"CPU/Internal: {log_entry['cpu_ms']}ms"
|
| 88 |
+
)
|
| 89 |
+
return response
|
backend/core/rate_limit.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import threading
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
from flask import request
|
| 5 |
+
from backend.config import Config
|
| 6 |
+
|
| 7 |
+
rate_limit_store = {} # { "action:identifier": { "count": N, "reset_at": timestamp } }
|
| 8 |
+
|
| 9 |
+
# Simple rate limiter to prevent IP scraping abuse
|
| 10 |
+
IP_LIMITS = defaultdict(list)
|
| 11 |
+
IP_LIMITS_LOCK = threading.Lock()
|
| 12 |
+
|
| 13 |
+
def get_client_ip():
|
| 14 |
+
"""Retrieve client IP, accounting for cloudflare proxy headers."""
|
| 15 |
+
return request.headers.get("CF-Connecting-IP") or request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr
|
| 16 |
+
|
| 17 |
+
def check_rate_limit(action: str, identifier: str) -> bool:
|
| 18 |
+
"""Returns True if rate limit exceeded, False if OK."""
|
| 19 |
+
if request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988":
|
| 20 |
+
return False
|
| 21 |
+
key = f"{action}:{identifier}"
|
| 22 |
+
now = time.time()
|
| 23 |
+
limit = Config.RATE_LIMITS.get(action, {"max": 100, "window": 60})
|
| 24 |
+
|
| 25 |
+
if key in rate_limit_store:
|
| 26 |
+
entry = rate_limit_store[key]
|
| 27 |
+
if now > entry["reset_at"]:
|
| 28 |
+
rate_limit_store[key] = {"count": 1, "reset_at": now + limit["window"]}
|
| 29 |
+
return False
|
| 30 |
+
if entry["count"] >= limit["max"]:
|
| 31 |
+
return True
|
| 32 |
+
entry["count"] += 1
|
| 33 |
+
return False
|
| 34 |
+
else:
|
| 35 |
+
rate_limit_store[key] = {"count": 1, "reset_at": now + limit["window"]}
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
def check_ip_rate_limit(ip: str, max_requests: int = 45, period: int = 60) -> bool:
|
| 39 |
+
"""Thread-safe sliding window rate limit for book searches / API access."""
|
| 40 |
+
if request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988":
|
| 41 |
+
return True
|
| 42 |
+
now = time.time()
|
| 43 |
+
with IP_LIMITS_LOCK:
|
| 44 |
+
if ip not in IP_LIMITS:
|
| 45 |
+
IP_LIMITS[ip] = []
|
| 46 |
+
IP_LIMITS[ip] = [t for t in IP_LIMITS[ip] if now - t < period]
|
| 47 |
+
if len(IP_LIMITS[ip]) >= max_requests:
|
| 48 |
+
return False
|
| 49 |
+
IP_LIMITS[ip].append(now)
|
| 50 |
+
return True
|
backend/core/security.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import secrets
|
| 2 |
+
import hashlib
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import bcrypt
|
| 5 |
+
import jwt
|
| 6 |
+
from backend.config import Config
|
| 7 |
+
from backend.database.db_manager import get_user_db_conn
|
| 8 |
+
|
| 9 |
+
def hash_password(password: str) -> str:
|
| 10 |
+
"""Hash password with bcrypt."""
|
| 11 |
+
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
| 12 |
+
|
| 13 |
+
def verify_password(password: str, hashed: str) -> bool:
|
| 14 |
+
"""Verify password against bcrypt hash. Also supports legacy SHA-256."""
|
| 15 |
+
try:
|
| 16 |
+
if hashed.startswith("$2b$") or hashed.startswith("$2a$"):
|
| 17 |
+
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
| 18 |
+
except Exception:
|
| 19 |
+
pass
|
| 20 |
+
# Fall back to legacy SHA-256 for old accounts
|
| 21 |
+
legacy_hash = hashlib.sha256(password.encode()).hexdigest()
|
| 22 |
+
return legacy_hash == hashed
|
| 23 |
+
|
| 24 |
+
def upgrade_password_hash(user_id: int, password: str):
|
| 25 |
+
"""Upgrade a user's password from SHA-256 to bcrypt."""
|
| 26 |
+
new_hash = hash_password(password)
|
| 27 |
+
conn = get_user_db_conn()
|
| 28 |
+
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_id))
|
| 29 |
+
conn.commit()
|
| 30 |
+
conn.close()
|
| 31 |
+
|
| 32 |
+
def create_access_token(user_id: int, username: str, vip_status: int) -> str:
|
| 33 |
+
"""Create a short-lived access token (30 min)."""
|
| 34 |
+
payload = {
|
| 35 |
+
"sub": str(user_id),
|
| 36 |
+
"username": username,
|
| 37 |
+
"vip": vip_status,
|
| 38 |
+
"type": "access",
|
| 39 |
+
"exp": datetime.utcnow() + Config.JWT_ACCESS_TOKEN_EXPIRE,
|
| 40 |
+
"iat": datetime.utcnow()
|
| 41 |
+
}
|
| 42 |
+
return jwt.encode(payload, Config.JWT_SECRET, algorithm=Config.JWT_ALGORITHM)
|
| 43 |
+
|
| 44 |
+
def create_refresh_token(user_id: int) -> str:
|
| 45 |
+
"""Create a long-lived refresh token (7 days), stored in DB."""
|
| 46 |
+
token_str = secrets.token_urlsafe(64)
|
| 47 |
+
expires_at = datetime.utcnow() + Config.JWT_REFRESH_TOKEN_EXPIRE
|
| 48 |
+
conn = get_user_db_conn()
|
| 49 |
+
conn.execute(
|
| 50 |
+
"INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
|
| 51 |
+
(user_id, token_str, expires_at.strftime("%Y-%m-%d %H:%M:%S"))
|
| 52 |
+
)
|
| 53 |
+
conn.commit()
|
| 54 |
+
conn.close()
|
| 55 |
+
return token_str
|
| 56 |
+
|
| 57 |
+
def verify_access_token(token: str):
|
| 58 |
+
"""Verify and decode an access token. Returns payload or None."""
|
| 59 |
+
try:
|
| 60 |
+
payload = jwt.decode(token, Config.JWT_SECRET, algorithms=[Config.JWT_ALGORITHM])
|
| 61 |
+
if payload.get("type") != "access":
|
| 62 |
+
print(f"[DEBUG JWT] Token type is not access: {payload.get('type')}", flush=True)
|
| 63 |
+
return None
|
| 64 |
+
return payload
|
| 65 |
+
except jwt.ExpiredSignatureError as e:
|
| 66 |
+
print(f"[DEBUG JWT] Token expired: {e}", flush=True)
|
| 67 |
+
return None
|
| 68 |
+
except jwt.InvalidTokenError as e:
|
| 69 |
+
print(f"[DEBUG JWT] Invalid token: {e}", flush=True)
|
| 70 |
+
return None
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"[DEBUG JWT] Decode failed: {e}", flush=True)
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
import base64
|
| 76 |
+
from cryptography.fernet import Fernet
|
| 77 |
+
|
| 78 |
+
def _get_fernet_key() -> bytes:
|
| 79 |
+
key_hash = hashlib.sha256(Config.SECRET_KEY.encode()).digest()
|
| 80 |
+
return base64.urlsafe_b64encode(key_hash)
|
| 81 |
+
|
| 82 |
+
def encrypt_message(message: str) -> str:
|
| 83 |
+
"""Encrypt message text using symmetric AES encryption."""
|
| 84 |
+
if not message:
|
| 85 |
+
return ""
|
| 86 |
+
try:
|
| 87 |
+
f = Fernet(_get_fernet_key())
|
| 88 |
+
return f.encrypt(message.encode('utf-8')).decode('utf-8')
|
| 89 |
+
except Exception as e:
|
| 90 |
+
return message
|
| 91 |
+
|
| 92 |
+
def decrypt_message(encrypted_message: str) -> str:
|
| 93 |
+
"""Decrypt message text. Falls back to original text if not encrypted or fails."""
|
| 94 |
+
if not encrypted_message:
|
| 95 |
+
return ""
|
| 96 |
+
try:
|
| 97 |
+
f = Fernet(_get_fernet_key())
|
| 98 |
+
return f.decrypt(encrypted_message.encode('utf-8')).decode('utf-8')
|
| 99 |
+
except Exception:
|
| 100 |
+
return encrypted_message
|
| 101 |
+
|
| 102 |
+
import os
|
| 103 |
+
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
| 104 |
+
from cryptography.hazmat.primitives import serialization, hashes
|
| 105 |
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
| 106 |
+
|
| 107 |
+
KEYS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "keys")
|
| 108 |
+
PRIVATE_KEY_PATH = os.path.join(KEYS_DIR, "private_key.pem")
|
| 109 |
+
PUBLIC_KEY_PATH = os.path.join(KEYS_DIR, "public_key.pem")
|
| 110 |
+
|
| 111 |
+
def get_or_create_rsa_keys():
|
| 112 |
+
"""Get RSA keys or generate if they don't exist."""
|
| 113 |
+
if not os.path.exists(KEYS_DIR):
|
| 114 |
+
os.makedirs(KEYS_DIR, exist_ok=True)
|
| 115 |
+
|
| 116 |
+
if not os.path.exists(PRIVATE_KEY_PATH) or not os.path.exists(PUBLIC_KEY_PATH):
|
| 117 |
+
# Generate private key
|
| 118 |
+
private_key = rsa.generate_private_key(
|
| 119 |
+
public_exponent=65537,
|
| 120 |
+
key_size=2048
|
| 121 |
+
)
|
| 122 |
+
# Write private key
|
| 123 |
+
with open(PRIVATE_KEY_PATH, "wb") as f:
|
| 124 |
+
f.write(
|
| 125 |
+
private_key.private_bytes(
|
| 126 |
+
encoding=serialization.Encoding.PEM,
|
| 127 |
+
format=serialization.PrivateFormat.PKCS8,
|
| 128 |
+
encryption_algorithm=serialization.NoEncryption()
|
| 129 |
+
)
|
| 130 |
+
)
|
| 131 |
+
# Generate and write public key
|
| 132 |
+
public_key = private_key.public_key()
|
| 133 |
+
with open(PUBLIC_KEY_PATH, "wb") as f:
|
| 134 |
+
f.write(
|
| 135 |
+
public_key.public_bytes(
|
| 136 |
+
encoding=serialization.Encoding.PEM,
|
| 137 |
+
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
def encrypt_asymmetric_hybrid(plaintext: str) -> dict:
|
| 142 |
+
"""
|
| 143 |
+
Encrypt text using Hybrid Encryption (AES-256-GCM + RSA-2048).
|
| 144 |
+
Returns a dict with base64 encoded parts.
|
| 145 |
+
"""
|
| 146 |
+
if not plaintext:
|
| 147 |
+
return {"ciphertext": "", "encrypted_key": "", "nonce": "", "tag": ""}
|
| 148 |
+
|
| 149 |
+
# Ensure keys exist
|
| 150 |
+
get_or_create_rsa_keys()
|
| 151 |
+
|
| 152 |
+
# 1. Generate random 256-bit AES key and 12-byte nonce
|
| 153 |
+
aes_key = os.urandom(32)
|
| 154 |
+
nonce = os.urandom(12)
|
| 155 |
+
|
| 156 |
+
# 2. Encrypt plaintext using AES-GCM
|
| 157 |
+
cipher = Cipher(algorithms.AES(aes_key), modes.GCM(nonce))
|
| 158 |
+
encryptor = cipher.encryptor()
|
| 159 |
+
ciphertext = encryptor.update(plaintext.encode('utf-8')) + encryptor.finalize()
|
| 160 |
+
tag = encryptor.tag
|
| 161 |
+
|
| 162 |
+
# 3. Encrypt the AES key using RSA public key
|
| 163 |
+
with open(PUBLIC_KEY_PATH, "rb") as f:
|
| 164 |
+
public_key = serialization.load_pem_public_key(f.read())
|
| 165 |
+
|
| 166 |
+
encrypted_aes_key = public_key.encrypt(
|
| 167 |
+
aes_key,
|
| 168 |
+
padding.OAEP(
|
| 169 |
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
| 170 |
+
algorithm=hashes.SHA256(),
|
| 171 |
+
label=None
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# 4. Return all parts as base64 string
|
| 176 |
+
return {
|
| 177 |
+
"ciphertext": base64.b64encode(ciphertext).decode('utf-8'),
|
| 178 |
+
"encrypted_key": base64.b64encode(encrypted_aes_key).decode('utf-8'),
|
| 179 |
+
"nonce": base64.b64encode(nonce).decode('utf-8'),
|
| 180 |
+
"tag": base64.b64encode(tag).decode('utf-8')
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
def decrypt_asymmetric_hybrid(ciphertext_b64: str, encrypted_key_b64: str, nonce_b64: str, tag_b64: str) -> str:
|
| 184 |
+
"""
|
| 185 |
+
Decrypt asymmetric hybrid encrypted payload.
|
| 186 |
+
"""
|
| 187 |
+
if not ciphertext_b64 or not encrypted_key_b64:
|
| 188 |
+
return ""
|
| 189 |
+
|
| 190 |
+
# Check if private key exists
|
| 191 |
+
if not os.path.exists(PRIVATE_KEY_PATH):
|
| 192 |
+
print("[SECURITY] Private key does not exist. Decryption failed.", flush=True)
|
| 193 |
+
return ""
|
| 194 |
+
|
| 195 |
+
try:
|
| 196 |
+
# Decode base64 parts
|
| 197 |
+
ciphertext = base64.b64decode(ciphertext_b64.encode('utf-8'))
|
| 198 |
+
encrypted_aes_key = base64.b64decode(encrypted_key_b64.encode('utf-8'))
|
| 199 |
+
nonce = base64.b64decode(nonce_b64.encode('utf-8'))
|
| 200 |
+
tag = base64.b64decode(tag_b64.encode('utf-8'))
|
| 201 |
+
|
| 202 |
+
# 1. Decrypt AES key using RSA private key
|
| 203 |
+
with open(PRIVATE_KEY_PATH, "rb") as f:
|
| 204 |
+
private_key = serialization.load_pem_private_key(f.read(), password=None)
|
| 205 |
+
|
| 206 |
+
aes_key = private_key.decrypt(
|
| 207 |
+
encrypted_aes_key,
|
| 208 |
+
padding.OAEP(
|
| 209 |
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
| 210 |
+
algorithm=hashes.SHA256(),
|
| 211 |
+
label=None
|
| 212 |
+
)
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# 2. Decrypt ciphertext using AES-GCM
|
| 216 |
+
cipher = Cipher(algorithms.AES(aes_key), modes.GCM(nonce, tag))
|
| 217 |
+
decryptor = cipher.decryptor()
|
| 218 |
+
decrypted_bytes = decryptor.update(ciphertext) + decryptor.finalize()
|
| 219 |
+
|
| 220 |
+
return decrypted_bytes.decode('utf-8')
|
| 221 |
+
except Exception as e:
|
| 222 |
+
import traceback
|
| 223 |
+
print(f"[SECURITY] Hybrid decryption failed: {e}", flush=True)
|
| 224 |
+
traceback.print_exc()
|
| 225 |
+
return ""
|
| 226 |
+
|
| 227 |
+
|
backend/core/watchdog.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
backend/core/watchdog.py
|
| 3 |
+
=========================
|
| 4 |
+
Watchdog / Keepalive daemon cho Hugging Face Space.
|
| 5 |
+
|
| 6 |
+
Hugging Face Free Tier ngủ đông (sleep) sau ~15 phút không có traffic.
|
| 7 |
+
Module này:
|
| 8 |
+
1. Tự ping /health mỗi 10 phút để giữ Space thức.
|
| 9 |
+
2. Giám sát RAM / Disk và log cảnh báo.
|
| 10 |
+
3. Tự động restart gunicorn worker nếu process bị zombie (graceful restart).
|
| 11 |
+
4. Flush log + commit DB tạm thời khi nhận SIGTERM từ HF.
|
| 12 |
+
|
| 13 |
+
Sử dụng trong backend/__init__.py:
|
| 14 |
+
from backend.core.watchdog import start_watchdog
|
| 15 |
+
start_watchdog(app_url="https://cong123779-tienhiep-backend.hf.space")
|
| 16 |
+
"""
|
| 17 |
+
import os
|
| 18 |
+
import time
|
| 19 |
+
import threading
|
| 20 |
+
import logging
|
| 21 |
+
import signal
|
| 22 |
+
import urllib.request
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger("watchdog")
|
| 25 |
+
|
| 26 |
+
_watchdog_started = False
|
| 27 |
+
_stop_event = threading.Event()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _ping_self(url: str) -> bool:
|
| 31 |
+
"""Ping /health endpoint để HF Space không bị sleep."""
|
| 32 |
+
try:
|
| 33 |
+
req = urllib.request.Request(
|
| 34 |
+
f"{url.rstrip('/')}/health",
|
| 35 |
+
headers={"User-Agent": "TienhiepWatchdog/1.0"}
|
| 36 |
+
)
|
| 37 |
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
| 38 |
+
return resp.status == 200
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.warning(f"[Watchdog] Ping failed: {e}")
|
| 41 |
+
return False
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _log_resources():
|
| 45 |
+
"""Log mức sử dụng RAM và Disk."""
|
| 46 |
+
try:
|
| 47 |
+
import psutil
|
| 48 |
+
mem = psutil.virtual_memory()
|
| 49 |
+
disk = psutil.disk_usage("/")
|
| 50 |
+
cpu = psutil.cpu_percent(interval=0.1)
|
| 51 |
+
logger.info(
|
| 52 |
+
f"[Watchdog] RAM: {mem.percent:.1f}% ({mem.used // 1024 // 1024}MB / {mem.total // 1024 // 1024}MB) | "
|
| 53 |
+
f"Disk: {disk.percent:.1f}% | CPU: {cpu:.1f}%"
|
| 54 |
+
)
|
| 55 |
+
# Cảnh báo khi RAM > 85%
|
| 56 |
+
if mem.percent > 85:
|
| 57 |
+
logger.warning(f"[Watchdog] ⚠️ HIGH RAM USAGE: {mem.percent:.1f}%!")
|
| 58 |
+
if disk.percent > 90:
|
| 59 |
+
logger.warning(f"[Watchdog] ⚠️ HIGH DISK USAGE: {disk.percent:.1f}%!")
|
| 60 |
+
except ImportError:
|
| 61 |
+
pass # psutil không có trên một số môi trường
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.debug(f"[Watchdog] Resource check error: {e}")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _handle_sigterm(signum, frame):
|
| 67 |
+
"""Graceful shutdown khi HF Space restart container."""
|
| 68 |
+
logger.info("[Watchdog] SIGTERM received — graceful shutdown initiated.")
|
| 69 |
+
_stop_event.set()
|
| 70 |
+
# Flush log handlers
|
| 71 |
+
for handler in logging.root.handlers:
|
| 72 |
+
handler.flush()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _watchdog_loop(app_url: str, ping_interval: int, resource_interval: int):
|
| 76 |
+
"""Main watchdog loop chạy trong background thread."""
|
| 77 |
+
ping_counter = 0
|
| 78 |
+
resource_counter = 0
|
| 79 |
+
|
| 80 |
+
logger.info(f"[Watchdog] 🐕 Started. Ping interval: {ping_interval}s | "
|
| 81 |
+
f"Resource check: {resource_interval}s | Target: {app_url}")
|
| 82 |
+
|
| 83 |
+
while not _stop_event.is_set():
|
| 84 |
+
time.sleep(30) # Check mỗi 30 giây
|
| 85 |
+
ping_counter += 30
|
| 86 |
+
resource_counter += 30
|
| 87 |
+
|
| 88 |
+
if ping_counter >= ping_interval:
|
| 89 |
+
ping_counter = 0
|
| 90 |
+
ok = _ping_self(app_url)
|
| 91 |
+
logger.info(f"[Watchdog] Keepalive ping → {'✅ OK' if ok else '❌ FAILED'}")
|
| 92 |
+
|
| 93 |
+
if resource_counter >= resource_interval:
|
| 94 |
+
resource_counter = 0
|
| 95 |
+
_log_resources()
|
| 96 |
+
|
| 97 |
+
logger.info("[Watchdog] 🛑 Stopped.")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def start_watchdog(
|
| 101 |
+
app_url: str = None,
|
| 102 |
+
ping_interval: int = 600, # Ping mỗi 10 phút
|
| 103 |
+
resource_interval: int = 300, # Check resource mỗi 5 phút
|
| 104 |
+
):
|
| 105 |
+
"""
|
| 106 |
+
Khởi động watchdog daemon thread.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
app_url: URL public của HF Space (vd: "https://cong123779-tienhiep-backend.hf.space")
|
| 110 |
+
Nếu None, tự detect từ env SPACE_HOST.
|
| 111 |
+
ping_interval: Số giây giữa các lần ping keepalive (mặc định 600s = 10 phút)
|
| 112 |
+
resource_interval: Số giây giữa các lần log resource (mặc định 300s = 5 phút)
|
| 113 |
+
"""
|
| 114 |
+
global _watchdog_started
|
| 115 |
+
if _watchdog_started:
|
| 116 |
+
logger.debug("[Watchdog] Already running, skip.")
|
| 117 |
+
return
|
| 118 |
+
|
| 119 |
+
# Detect URL từ HF environment nếu không truyền
|
| 120 |
+
if not app_url:
|
| 121 |
+
space_host = os.environ.get("SPACE_HOST", "")
|
| 122 |
+
if space_host:
|
| 123 |
+
app_url = f"https://{space_host}"
|
| 124 |
+
else:
|
| 125 |
+
logger.info("[Watchdog] No SPACE_HOST env, running in local mode (no ping).")
|
| 126 |
+
app_url = "http://localhost:7860"
|
| 127 |
+
|
| 128 |
+
# Register SIGTERM handler để graceful shutdown
|
| 129 |
+
try:
|
| 130 |
+
signal.signal(signal.SIGTERM, _handle_sigterm)
|
| 131 |
+
except (ValueError, OSError):
|
| 132 |
+
pass # Không register được trong thread con
|
| 133 |
+
|
| 134 |
+
thread = threading.Thread(
|
| 135 |
+
target=_watchdog_loop,
|
| 136 |
+
args=(app_url, ping_interval, resource_interval),
|
| 137 |
+
daemon=True,
|
| 138 |
+
name="WatchdogKeepAlive"
|
| 139 |
+
)
|
| 140 |
+
thread.start()
|
| 141 |
+
_watchdog_started = True
|
| 142 |
+
logger.info(f"[Watchdog] 🐕 Daemon started (thread: {thread.name})")
|
backend/database/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# backend/database/__init__.py
|
backend/database/db_manager.py
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import time
|
| 4 |
+
import sqlite3
|
| 5 |
+
import threading
|
| 6 |
+
import logging
|
| 7 |
+
from collections import OrderedDict
|
| 8 |
+
from backend.config import Config
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger("db_manager")
|
| 11 |
+
logger.setLevel(logging.INFO)
|
| 12 |
+
if not logger.handlers:
|
| 13 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 14 |
+
handler.setFormatter(logging.Formatter(
|
| 15 |
+
"[%(asctime)s] %(levelname)s [db_manager] %(message)s",
|
| 16 |
+
datefmt="%Y-%m-%d %H:%M:%S"
|
| 17 |
+
))
|
| 18 |
+
logger.addHandler(handler)
|
| 19 |
+
|
| 20 |
+
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
| 21 |
+
USE_LOCAL_SQLITE = os.environ.get("USE_LOCAL_SQLITE", "false").lower() == "true"
|
| 22 |
+
if USE_LOCAL_SQLITE:
|
| 23 |
+
DATABASE_URL = ""
|
| 24 |
+
USER_DB_PATH = Config.USER_DB_PATH
|
| 25 |
+
|
| 26 |
+
# Retry & circuit breaker settings
|
| 27 |
+
MAX_RETRIES = 3
|
| 28 |
+
RETRY_BASE_DELAY = 0.5 # seconds
|
| 29 |
+
CIRCUIT_BREAKER_THRESHOLD = 5 # consecutive failures before opening circuit
|
| 30 |
+
CIRCUIT_BREAKER_RESET = 30 # seconds before retrying after circuit opens
|
| 31 |
+
|
| 32 |
+
# Circuit Breaker State
|
| 33 |
+
_lock = threading.Lock()
|
| 34 |
+
_failure_count = 0
|
| 35 |
+
_circuit_open_until = 0.0 # timestamp when circuit should try again
|
| 36 |
+
_last_mode = "unknown" # track for logging
|
| 37 |
+
|
| 38 |
+
def _is_circuit_open():
|
| 39 |
+
"""Check if the circuit breaker is open (cloud DB unavailable)."""
|
| 40 |
+
global _failure_count, _circuit_open_until
|
| 41 |
+
with _lock:
|
| 42 |
+
if _failure_count >= CIRCUIT_BREAKER_THRESHOLD:
|
| 43 |
+
if time.time() < _circuit_open_until:
|
| 44 |
+
return True
|
| 45 |
+
return False
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
def _record_success():
|
| 49 |
+
"""Record a successful cloud DB connection — reset circuit breaker."""
|
| 50 |
+
global _failure_count, _circuit_open_until, _last_mode
|
| 51 |
+
with _lock:
|
| 52 |
+
if _failure_count > 0:
|
| 53 |
+
logger.info("✔ Supabase connection restored. Resetting circuit breaker.")
|
| 54 |
+
_failure_count = 0
|
| 55 |
+
_circuit_open_until = 0.0
|
| 56 |
+
_last_mode = "postgres"
|
| 57 |
+
|
| 58 |
+
def _record_failure():
|
| 59 |
+
"""Record a failed cloud DB connection — increment circuit breaker."""
|
| 60 |
+
global _failure_count, _circuit_open_until, _last_mode
|
| 61 |
+
with _lock:
|
| 62 |
+
_failure_count += 1
|
| 63 |
+
if _failure_count >= CIRCUIT_BREAKER_THRESHOLD:
|
| 64 |
+
_circuit_open_until = time.time() + CIRCUIT_BREAKER_RESET
|
| 65 |
+
if _last_mode != "sqlite_fallback":
|
| 66 |
+
logger.warning(
|
| 67 |
+
f"⚡ Circuit breaker OPEN after {_failure_count} failures. "
|
| 68 |
+
f"Falling back to SQLite for {CIRCUIT_BREAKER_RESET}s."
|
| 69 |
+
)
|
| 70 |
+
_last_mode = "sqlite_fallback"
|
| 71 |
+
|
| 72 |
+
# PostgreSQL wrappers
|
| 73 |
+
class PgCursorWrapper:
|
| 74 |
+
def __init__(self, pg_cursor, lastrowid=None, conn_wrapper=None):
|
| 75 |
+
self._cur = pg_cursor
|
| 76 |
+
self._lastrowid = lastrowid
|
| 77 |
+
self._conn_wrapper = conn_wrapper
|
| 78 |
+
|
| 79 |
+
def execute(self, sql, parameters=None):
|
| 80 |
+
if self._conn_wrapper:
|
| 81 |
+
sql = self._conn_wrapper._translate_sql(sql)
|
| 82 |
+
if parameters:
|
| 83 |
+
self._cur.execute(sql, parameters)
|
| 84 |
+
else:
|
| 85 |
+
self._cur.execute(sql)
|
| 86 |
+
return self
|
| 87 |
+
|
| 88 |
+
def fetchone(self):
|
| 89 |
+
return self._cur.fetchone()
|
| 90 |
+
|
| 91 |
+
def fetchall(self):
|
| 92 |
+
return self._cur.fetchall()
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def lastrowid(self):
|
| 96 |
+
return self._lastrowid
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def description(self):
|
| 100 |
+
return self._cur.description
|
| 101 |
+
|
| 102 |
+
class PgConnectionWrapper:
|
| 103 |
+
def __init__(self, pg_conn, pool=None):
|
| 104 |
+
self._conn = pg_conn
|
| 105 |
+
self._pool = pool
|
| 106 |
+
|
| 107 |
+
@property
|
| 108 |
+
def row_factory(self):
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
@row_factory.setter
|
| 112 |
+
def row_factory(self, val):
|
| 113 |
+
pass
|
| 114 |
+
|
| 115 |
+
def cursor(self):
|
| 116 |
+
import psycopg2.extras
|
| 117 |
+
cursor = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
|
| 118 |
+
return PgCursorWrapper(cursor, conn_wrapper=self)
|
| 119 |
+
|
| 120 |
+
def execute(self, sql, parameters=None):
|
| 121 |
+
sql_pg = self._translate_sql(sql)
|
| 122 |
+
is_insert = sql_pg.strip().upper().startswith("INSERT")
|
| 123 |
+
if is_insert and "RETURNING" not in sql_pg.upper():
|
| 124 |
+
if "USER_SETTINGS" in sql_pg.upper():
|
| 125 |
+
sql_pg += " RETURNING user_id"
|
| 126 |
+
else:
|
| 127 |
+
sql_pg += " RETURNING id"
|
| 128 |
+
|
| 129 |
+
import psycopg2.extras
|
| 130 |
+
cursor = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
|
| 131 |
+
sql_upper = sql_pg.upper()
|
| 132 |
+
|
| 133 |
+
start_time = time.time()
|
| 134 |
+
try:
|
| 135 |
+
if parameters:
|
| 136 |
+
cursor.execute(sql_pg, parameters)
|
| 137 |
+
elif "CREATE TABLE" in sql_upper or "ALTER TABLE" in sql_upper:
|
| 138 |
+
try:
|
| 139 |
+
cursor.execute("SAVEPOINT sp_ddl")
|
| 140 |
+
cursor.execute(sql_pg)
|
| 141 |
+
cursor.execute("RELEASE SAVEPOINT sp_ddl")
|
| 142 |
+
except Exception as e:
|
| 143 |
+
cursor.execute("ROLLBACK TO SAVEPOINT sp_ddl")
|
| 144 |
+
cursor.execute("RELEASE SAVEPOINT sp_ddl")
|
| 145 |
+
raise
|
| 146 |
+
else:
|
| 147 |
+
cursor.execute(sql_pg)
|
| 148 |
+
finally:
|
| 149 |
+
try:
|
| 150 |
+
from backend.core.profiler import record_db_time
|
| 151 |
+
record_db_time(time.time() - start_time)
|
| 152 |
+
except Exception:
|
| 153 |
+
pass
|
| 154 |
+
|
| 155 |
+
lastrowid = None
|
| 156 |
+
if is_insert:
|
| 157 |
+
try:
|
| 158 |
+
row = cursor.fetchone()
|
| 159 |
+
if row:
|
| 160 |
+
lastrowid = row[0]
|
| 161 |
+
except Exception:
|
| 162 |
+
pass
|
| 163 |
+
|
| 164 |
+
return PgCursorWrapper(cursor, lastrowid)
|
| 165 |
+
|
| 166 |
+
def commit(self):
|
| 167 |
+
start_time = time.time()
|
| 168 |
+
try:
|
| 169 |
+
self._conn.commit()
|
| 170 |
+
finally:
|
| 171 |
+
try:
|
| 172 |
+
from backend.core.profiler import record_db_time
|
| 173 |
+
record_db_time(time.time() - start_time)
|
| 174 |
+
except Exception:
|
| 175 |
+
pass
|
| 176 |
+
|
| 177 |
+
def close(self):
|
| 178 |
+
if self._pool is not None:
|
| 179 |
+
try:
|
| 180 |
+
try:
|
| 181 |
+
self._conn.rollback()
|
| 182 |
+
except Exception:
|
| 183 |
+
pass
|
| 184 |
+
self._pool.putconn(self._conn)
|
| 185 |
+
except Exception as e:
|
| 186 |
+
logger.error(f"Error returning connection to pool: {e}")
|
| 187 |
+
try:
|
| 188 |
+
self._conn.close()
|
| 189 |
+
except Exception:
|
| 190 |
+
pass
|
| 191 |
+
else:
|
| 192 |
+
self._conn.close()
|
| 193 |
+
|
| 194 |
+
@staticmethod
|
| 195 |
+
def _translate_sql(sql):
|
| 196 |
+
import re
|
| 197 |
+
if re.search(r"INSERT\s+OR\s+IGNORE\s+INTO\s+bookshelf", sql, re.IGNORECASE):
|
| 198 |
+
sql = re.sub(r"INSERT\s+OR\s+IGNORE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
|
| 199 |
+
if "ON CONFLICT" not in sql.upper():
|
| 200 |
+
sql += " ON CONFLICT (user_id, book_id) DO NOTHING"
|
| 201 |
+
elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+bookshelf", sql, re.IGNORECASE):
|
| 202 |
+
sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
|
| 203 |
+
if "ON CONFLICT" not in sql.upper():
|
| 204 |
+
sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, url = EXCLUDED.url"
|
| 205 |
+
elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+reading_history", sql, re.IGNORECASE):
|
| 206 |
+
sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
|
| 207 |
+
if "ON CONFLICT" not in sql.upper():
|
| 208 |
+
if "URL" in sql.upper():
|
| 209 |
+
sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, last_chapter = EXCLUDED.last_chapter, read_date = EXCLUDED.read_date, url = EXCLUDED.url"
|
| 210 |
+
else:
|
| 211 |
+
sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, last_chapter = EXCLUDED.last_chapter, read_date = EXCLUDED.read_date, timestamp = EXCLUDED.timestamp"
|
| 212 |
+
elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+friendships", sql, re.IGNORECASE):
|
| 213 |
+
sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
|
| 214 |
+
if "ON CONFLICT" not in sql.upper():
|
| 215 |
+
sql += " ON CONFLICT (user_id, friend_id) DO UPDATE SET status = EXCLUDED.status"
|
| 216 |
+
|
| 217 |
+
sql_upper = sql.upper()
|
| 218 |
+
if "CREATE TABLE" in sql_upper:
|
| 219 |
+
sql = sql.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ")
|
| 220 |
+
sql = sql.replace("CREATE TABLE IF NOT EXISTS IF NOT EXISTS",
|
| 221 |
+
"CREATE TABLE IF NOT EXISTS")
|
| 222 |
+
sql = sql.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "SERIAL PRIMARY KEY")
|
| 223 |
+
sql = sql.replace("DATETIME DEFAULT CURRENT_TIMESTAMP",
|
| 224 |
+
"TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
| 225 |
+
sql = sql.replace("DATETIME", "TIMESTAMP")
|
| 226 |
+
|
| 227 |
+
# Translate LIKE to ILIKE for case-insensitive matching on PostgreSQL
|
| 228 |
+
if " LIKE " in sql_upper:
|
| 229 |
+
sql = re.sub(r"\bLIKE\b", "ILIKE", sql, flags=re.IGNORECASE)
|
| 230 |
+
|
| 231 |
+
sql = sql.replace('?', '%s')
|
| 232 |
+
return sql
|
| 233 |
+
|
| 234 |
+
_pg_pool = None
|
| 235 |
+
_pool_lock = threading.Lock()
|
| 236 |
+
|
| 237 |
+
def _get_pg_pool():
|
| 238 |
+
global _pg_pool
|
| 239 |
+
if not DATABASE_URL:
|
| 240 |
+
return None
|
| 241 |
+
if _pg_pool is not None:
|
| 242 |
+
return _pg_pool
|
| 243 |
+
with _pool_lock:
|
| 244 |
+
if _pg_pool is None:
|
| 245 |
+
try:
|
| 246 |
+
from psycopg2.pool import ThreadedConnectionPool
|
| 247 |
+
_pg_pool = ThreadedConnectionPool(2, 20, DATABASE_URL, connect_timeout=5)
|
| 248 |
+
logger.info("✔ Threaded Supabase connection pool initialized successfully.")
|
| 249 |
+
except Exception as e:
|
| 250 |
+
logger.error(f"Failed to initialize Threaded connection pool: {e}")
|
| 251 |
+
_pg_pool = None
|
| 252 |
+
return _pg_pool
|
| 253 |
+
|
| 254 |
+
def _try_postgres_connect():
|
| 255 |
+
pool = _get_pg_pool()
|
| 256 |
+
if pool is None:
|
| 257 |
+
raise Exception("Supabase Connection Pool not initialized.")
|
| 258 |
+
|
| 259 |
+
conn = None
|
| 260 |
+
for i in range(3):
|
| 261 |
+
try:
|
| 262 |
+
conn = pool.getconn()
|
| 263 |
+
with conn.cursor() as cur:
|
| 264 |
+
cur.execute("SELECT 1")
|
| 265 |
+
break
|
| 266 |
+
except Exception as e:
|
| 267 |
+
logger.warning(f"Got unhealthy connection from pool: {e}. Closing and retrying...")
|
| 268 |
+
if conn:
|
| 269 |
+
try:
|
| 270 |
+
pool.putconn(conn, close=True)
|
| 271 |
+
except Exception:
|
| 272 |
+
pass
|
| 273 |
+
conn = None
|
| 274 |
+
|
| 275 |
+
if conn is None:
|
| 276 |
+
raise Exception("Failed to obtain a healthy connection from the pool.")
|
| 277 |
+
|
| 278 |
+
return PgConnectionWrapper(conn, pool)
|
| 279 |
+
|
| 280 |
+
class ProfiledSqliteConnection(sqlite3.Connection):
|
| 281 |
+
def execute(self, sql, *args):
|
| 282 |
+
start_time = time.time()
|
| 283 |
+
try:
|
| 284 |
+
return super().execute(sql, *args)
|
| 285 |
+
finally:
|
| 286 |
+
try:
|
| 287 |
+
from backend.core.profiler import record_db_time
|
| 288 |
+
record_db_time(time.time() - start_time)
|
| 289 |
+
except Exception:
|
| 290 |
+
pass
|
| 291 |
+
|
| 292 |
+
def _get_sqlite_conn():
|
| 293 |
+
conn = sqlite3.connect(USER_DB_PATH, timeout=10, factory=ProfiledSqliteConnection)
|
| 294 |
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
| 295 |
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
| 296 |
+
conn.execute("PRAGMA cache_size=-64000;")
|
| 297 |
+
conn.execute("PRAGMA temp_store=MEMORY;")
|
| 298 |
+
conn.row_factory = sqlite3.Row
|
| 299 |
+
return conn
|
| 300 |
+
|
| 301 |
+
def get_user_db_conn():
|
| 302 |
+
"""Get user database connection with automatic failover."""
|
| 303 |
+
if not DATABASE_URL:
|
| 304 |
+
return _get_sqlite_conn()
|
| 305 |
+
if _is_circuit_open():
|
| 306 |
+
return _get_sqlite_conn()
|
| 307 |
+
|
| 308 |
+
last_error = None
|
| 309 |
+
for attempt in range(MAX_RETRIES):
|
| 310 |
+
try:
|
| 311 |
+
conn = _try_postgres_connect()
|
| 312 |
+
_record_success()
|
| 313 |
+
return conn
|
| 314 |
+
except Exception as e:
|
| 315 |
+
last_error = e
|
| 316 |
+
delay = RETRY_BASE_DELAY * (2 ** attempt)
|
| 317 |
+
if attempt < MAX_RETRIES - 1:
|
| 318 |
+
logger.warning(
|
| 319 |
+
f"Supabase connection attempt {attempt + 1}/{MAX_RETRIES} failed: "
|
| 320 |
+
f"{e}. Retrying in {delay:.1f}s..."
|
| 321 |
+
)
|
| 322 |
+
time.sleep(delay)
|
| 323 |
+
|
| 324 |
+
_record_failure()
|
| 325 |
+
logger.error(f"Supabase unreachable: {last_error}. Using SQLite fallback.")
|
| 326 |
+
return _get_sqlite_conn()
|
| 327 |
+
|
| 328 |
+
def health_check():
|
| 329 |
+
status = {
|
| 330 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 331 |
+
"database": {
|
| 332 |
+
"primary": "supabase_postgres" if DATABASE_URL else "sqlite_only",
|
| 333 |
+
"fallback": "sqlite_local",
|
| 334 |
+
"circuit_breaker": {
|
| 335 |
+
"state": "open" if _is_circuit_open() else "closed",
|
| 336 |
+
"failure_count": _failure_count,
|
| 337 |
+
"threshold": CIRCUIT_BREAKER_THRESHOLD,
|
| 338 |
+
},
|
| 339 |
+
},
|
| 340 |
+
"status": "healthy",
|
| 341 |
+
}
|
| 342 |
+
if DATABASE_URL and not _is_circuit_open():
|
| 343 |
+
try:
|
| 344 |
+
conn = _try_postgres_connect()
|
| 345 |
+
conn.close() # Returns to pool via PgConnectionWrapper
|
| 346 |
+
status["database"]["primary_reachable"] = True
|
| 347 |
+
except Exception as e:
|
| 348 |
+
status["database"]["primary_reachable"] = False
|
| 349 |
+
status["database"]["primary_error"] = str(e)
|
| 350 |
+
status["status"] = "degraded"
|
| 351 |
+
elif _is_circuit_open():
|
| 352 |
+
status["database"]["primary_reachable"] = False
|
| 353 |
+
status["status"] = "degraded"
|
| 354 |
+
|
| 355 |
+
# Use a lightweight check without opening a new connection
|
| 356 |
+
try:
|
| 357 |
+
if os.path.exists(USER_DB_PATH) and os.path.getsize(USER_DB_PATH) > 0:
|
| 358 |
+
status["database"]["fallback_reachable"] = True
|
| 359 |
+
else:
|
| 360 |
+
status["database"]["fallback_reachable"] = False
|
| 361 |
+
status["status"] = "degraded"
|
| 362 |
+
except Exception as e:
|
| 363 |
+
status["database"]["fallback_reachable"] = False
|
| 364 |
+
status["database"]["fallback_error"] = str(e)
|
| 365 |
+
status["status"] = "unhealthy"
|
| 366 |
+
|
| 367 |
+
return status
|
| 368 |
+
|
| 369 |
+
def _init_db_schema_for_conn(conn):
|
| 370 |
+
is_postgres = (type(conn).__name__ == "PgConnectionWrapper")
|
| 371 |
+
conn.execute("""
|
| 372 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 373 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 374 |
+
username TEXT UNIQUE,
|
| 375 |
+
password_hash TEXT,
|
| 376 |
+
email TEXT,
|
| 377 |
+
phone TEXT,
|
| 378 |
+
google_id TEXT,
|
| 379 |
+
vip_status INTEGER DEFAULT 0,
|
| 380 |
+
vip_plan TEXT,
|
| 381 |
+
vip_expiry DATETIME,
|
| 382 |
+
email_verified INTEGER DEFAULT 0,
|
| 383 |
+
require_password_change INTEGER DEFAULT 0,
|
| 384 |
+
display_name TEXT,
|
| 385 |
+
birthday TEXT,
|
| 386 |
+
gender TEXT,
|
| 387 |
+
bio TEXT,
|
| 388 |
+
avatar_frame TEXT DEFAULT 'default',
|
| 389 |
+
avatar TEXT,
|
| 390 |
+
two_factor INTEGER DEFAULT 0,
|
| 391 |
+
api_balance REAL DEFAULT 0.0,
|
| 392 |
+
user_code TEXT UNIQUE,
|
| 393 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 394 |
+
)
|
| 395 |
+
""")
|
| 396 |
+
# Run database migrations for users columns if needed
|
| 397 |
+
if is_postgres:
|
| 398 |
+
# Postgres columns check
|
| 399 |
+
cursor = conn.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'users'")
|
| 400 |
+
columns = [row[0] for row in cursor.fetchall()]
|
| 401 |
+
else:
|
| 402 |
+
# SQLite columns check
|
| 403 |
+
cursor = conn.execute("PRAGMA table_info(users)")
|
| 404 |
+
columns = [row[1] for row in cursor.fetchall()]
|
| 405 |
+
|
| 406 |
+
if "email_verified" not in columns:
|
| 407 |
+
conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0")
|
| 408 |
+
if "require_password_change" not in columns:
|
| 409 |
+
conn.execute("ALTER TABLE users ADD COLUMN require_password_change INTEGER DEFAULT 0")
|
| 410 |
+
if "display_name" not in columns:
|
| 411 |
+
conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT")
|
| 412 |
+
if "birthday" not in columns:
|
| 413 |
+
conn.execute("ALTER TABLE users ADD COLUMN birthday TEXT")
|
| 414 |
+
if "gender" not in columns:
|
| 415 |
+
conn.execute("ALTER TABLE users ADD COLUMN gender TEXT")
|
| 416 |
+
if "bio" not in columns:
|
| 417 |
+
conn.execute("ALTER TABLE users ADD COLUMN bio TEXT")
|
| 418 |
+
if "avatar_frame" not in columns:
|
| 419 |
+
conn.execute("ALTER TABLE users ADD COLUMN avatar_frame TEXT DEFAULT 'default'")
|
| 420 |
+
if "avatar" not in columns:
|
| 421 |
+
conn.execute("ALTER TABLE users ADD COLUMN avatar TEXT")
|
| 422 |
+
if "two_factor" not in columns:
|
| 423 |
+
conn.execute("ALTER TABLE users ADD COLUMN two_factor INTEGER DEFAULT 0")
|
| 424 |
+
if "api_balance" not in columns:
|
| 425 |
+
conn.execute("ALTER TABLE users ADD COLUMN api_balance REAL DEFAULT 0.0")
|
| 426 |
+
if "user_code" not in columns:
|
| 427 |
+
conn.execute("ALTER TABLE users ADD COLUMN user_code TEXT")
|
| 428 |
+
# Backfill existing users with random 7-digit codes
|
| 429 |
+
import random, string
|
| 430 |
+
existing_users = conn.execute("SELECT id FROM users WHERE user_code IS NULL").fetchall()
|
| 431 |
+
for u in existing_users:
|
| 432 |
+
while True:
|
| 433 |
+
code = ''.join(random.choices(string.digits, k=7))
|
| 434 |
+
clash = conn.execute("SELECT 1 FROM users WHERE user_code = ?", (code,)).fetchone()
|
| 435 |
+
if not clash:
|
| 436 |
+
conn.execute("UPDATE users SET user_code = ? WHERE id = ?", (code, u['id']))
|
| 437 |
+
break
|
| 438 |
+
|
| 439 |
+
conn.execute("""
|
| 440 |
+
CREATE TABLE IF NOT EXISTS bookshelf (
|
| 441 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 442 |
+
user_id INTEGER,
|
| 443 |
+
book_id INTEGER,
|
| 444 |
+
title TEXT,
|
| 445 |
+
cover TEXT,
|
| 446 |
+
author TEXT,
|
| 447 |
+
url TEXT,
|
| 448 |
+
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 449 |
+
UNIQUE(user_id, book_id)
|
| 450 |
+
)
|
| 451 |
+
""")
|
| 452 |
+
conn.execute("""
|
| 453 |
+
CREATE TABLE IF NOT EXISTS reading_history (
|
| 454 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 455 |
+
user_id INTEGER,
|
| 456 |
+
book_id INTEGER,
|
| 457 |
+
title TEXT,
|
| 458 |
+
cover TEXT,
|
| 459 |
+
author TEXT,
|
| 460 |
+
last_chapter TEXT,
|
| 461 |
+
read_date TEXT,
|
| 462 |
+
url TEXT,
|
| 463 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 464 |
+
UNIQUE(user_id, book_id)
|
| 465 |
+
)
|
| 466 |
+
""")
|
| 467 |
+
conn.execute("""
|
| 468 |
+
CREATE TABLE IF NOT EXISTS payments (
|
| 469 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 470 |
+
user_id INTEGER NOT NULL,
|
| 471 |
+
order_id TEXT UNIQUE NOT NULL,
|
| 472 |
+
plan TEXT NOT NULL,
|
| 473 |
+
amount INTEGER NOT NULL,
|
| 474 |
+
status TEXT DEFAULT 'pending',
|
| 475 |
+
payment_method TEXT DEFAULT 'vietqr',
|
| 476 |
+
note TEXT,
|
| 477 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 478 |
+
completed_at DATETIME,
|
| 479 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 480 |
+
)
|
| 481 |
+
""")
|
| 482 |
+
conn.execute("""
|
| 483 |
+
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
| 484 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 485 |
+
user_id INTEGER NOT NULL,
|
| 486 |
+
token TEXT UNIQUE NOT NULL,
|
| 487 |
+
expires_at DATETIME NOT NULL,
|
| 488 |
+
used INTEGER DEFAULT 0,
|
| 489 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 490 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 491 |
+
)
|
| 492 |
+
""")
|
| 493 |
+
conn.execute("""
|
| 494 |
+
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
| 495 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 496 |
+
user_id INTEGER NOT NULL,
|
| 497 |
+
token TEXT UNIQUE NOT NULL,
|
| 498 |
+
expires_at DATETIME NOT NULL,
|
| 499 |
+
revoked INTEGER DEFAULT 0,
|
| 500 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 501 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 502 |
+
)
|
| 503 |
+
""")
|
| 504 |
+
conn.execute("""
|
| 505 |
+
CREATE TABLE IF NOT EXISTS api_keys (
|
| 506 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 507 |
+
user_id INTEGER NOT NULL,
|
| 508 |
+
api_key TEXT UNIQUE NOT NULL,
|
| 509 |
+
name TEXT DEFAULT 'Default Key',
|
| 510 |
+
status TEXT DEFAULT 'active',
|
| 511 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 512 |
+
last_used_at DATETIME,
|
| 513 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 514 |
+
)
|
| 515 |
+
""")
|
| 516 |
+
conn.execute("""
|
| 517 |
+
CREATE TABLE IF NOT EXISTS api_usage (
|
| 518 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 519 |
+
api_key TEXT NOT NULL,
|
| 520 |
+
model TEXT NOT NULL,
|
| 521 |
+
tokens INTEGER DEFAULT 0,
|
| 522 |
+
cost REAL DEFAULT 0.0,
|
| 523 |
+
status_code INTEGER,
|
| 524 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 525 |
+
FOREIGN KEY (api_key) REFERENCES api_keys(api_key)
|
| 526 |
+
)
|
| 527 |
+
""")
|
| 528 |
+
conn.execute("""
|
| 529 |
+
CREATE TABLE IF NOT EXISTS translation_history (
|
| 530 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 531 |
+
user_id INTEGER NOT NULL,
|
| 532 |
+
original_text TEXT NOT NULL,
|
| 533 |
+
translated_text TEXT NOT NULL,
|
| 534 |
+
mode TEXT DEFAULT 'fast',
|
| 535 |
+
characters INTEGER DEFAULT 0,
|
| 536 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 537 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 538 |
+
)
|
| 539 |
+
""")
|
| 540 |
+
conn.execute("""
|
| 541 |
+
CREATE TABLE IF NOT EXISTS vocabulary (
|
| 542 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 543 |
+
user_id INTEGER NOT NULL,
|
| 544 |
+
original_text TEXT NOT NULL,
|
| 545 |
+
pinyin_or_hanviet TEXT,
|
| 546 |
+
translation TEXT NOT NULL,
|
| 547 |
+
context_sentence TEXT,
|
| 548 |
+
notes TEXT,
|
| 549 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 550 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 551 |
+
)
|
| 552 |
+
""")
|
| 553 |
+
conn.execute("""
|
| 554 |
+
CREATE TABLE IF NOT EXISTS user_settings (
|
| 555 |
+
user_id INTEGER PRIMARY KEY,
|
| 556 |
+
theme TEXT DEFAULT 'dark',
|
| 557 |
+
default_language TEXT DEFAULT 'vi',
|
| 558 |
+
auto_read INTEGER DEFAULT 0,
|
| 559 |
+
font_size INTEGER DEFAULT 14,
|
| 560 |
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 561 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 562 |
+
)
|
| 563 |
+
""")
|
| 564 |
+
conn.execute("""
|
| 565 |
+
CREATE TABLE IF NOT EXISTS usage_tracking (
|
| 566 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 567 |
+
user_id INTEGER NOT NULL,
|
| 568 |
+
source TEXT NOT NULL,
|
| 569 |
+
action TEXT NOT NULL,
|
| 570 |
+
duration INTEGER DEFAULT 0,
|
| 571 |
+
mode TEXT DEFAULT 'online',
|
| 572 |
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 573 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 574 |
+
)
|
| 575 |
+
""")
|
| 576 |
+
conn.execute("""
|
| 577 |
+
CREATE TABLE IF NOT EXISTS login_history (
|
| 578 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 579 |
+
user_id INTEGER NOT NULL,
|
| 580 |
+
ip_address TEXT,
|
| 581 |
+
user_agent TEXT,
|
| 582 |
+
os TEXT,
|
| 583 |
+
browser TEXT,
|
| 584 |
+
device_type TEXT,
|
| 585 |
+
token TEXT,
|
| 586 |
+
status TEXT DEFAULT 'active',
|
| 587 |
+
login_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 588 |
+
last_active DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 589 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 590 |
+
)
|
| 591 |
+
""")
|
| 592 |
+
conn.execute("""
|
| 593 |
+
CREATE TABLE IF NOT EXISTS friendships (
|
| 594 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 595 |
+
user_id INTEGER NOT NULL,
|
| 596 |
+
friend_id INTEGER NOT NULL,
|
| 597 |
+
status TEXT DEFAULT 'pending',
|
| 598 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 599 |
+
FOREIGN KEY (user_id) REFERENCES users(id),
|
| 600 |
+
FOREIGN KEY (friend_id) REFERENCES users(id),
|
| 601 |
+
UNIQUE(user_id, friend_id)
|
| 602 |
+
)
|
| 603 |
+
""")
|
| 604 |
+
conn.execute("""
|
| 605 |
+
CREATE TABLE IF NOT EXISTS direct_messages (
|
| 606 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 607 |
+
sender_id INTEGER NOT NULL,
|
| 608 |
+
receiver_id INTEGER NOT NULL,
|
| 609 |
+
message TEXT NOT NULL,
|
| 610 |
+
is_read INTEGER DEFAULT 0,
|
| 611 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 612 |
+
FOREIGN KEY (sender_id) REFERENCES users(id),
|
| 613 |
+
FOREIGN KEY (receiver_id) REFERENCES users(id)
|
| 614 |
+
)
|
| 615 |
+
""")
|
| 616 |
+
conn.execute("""
|
| 617 |
+
CREATE TABLE IF NOT EXISTS personal_notifications (
|
| 618 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 619 |
+
user_id INTEGER NOT NULL,
|
| 620 |
+
sender_id INTEGER,
|
| 621 |
+
type TEXT NOT NULL,
|
| 622 |
+
message TEXT NOT NULL,
|
| 623 |
+
related_id INTEGER,
|
| 624 |
+
is_read INTEGER DEFAULT 0,
|
| 625 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 626 |
+
FOREIGN KEY (user_id) REFERENCES users(id),
|
| 627 |
+
FOREIGN KEY (sender_id) REFERENCES users(id)
|
| 628 |
+
)
|
| 629 |
+
""")
|
| 630 |
+
|
| 631 |
+
for table in ["bookshelf", "reading_history"]:
|
| 632 |
+
try:
|
| 633 |
+
conn.execute(f"ALTER TABLE {table} ADD COLUMN url TEXT")
|
| 634 |
+
except Exception:
|
| 635 |
+
pass
|
| 636 |
+
|
| 637 |
+
new_user_cols = [
|
| 638 |
+
("email", "TEXT"),
|
| 639 |
+
("phone", "TEXT"),
|
| 640 |
+
("google_id", "TEXT"),
|
| 641 |
+
("vip_plan", "TEXT"),
|
| 642 |
+
("vip_expiry", "DATETIME"),
|
| 643 |
+
("api_balance", "REAL DEFAULT 0.0"),
|
| 644 |
+
]
|
| 645 |
+
for col_name, col_type in new_user_cols:
|
| 646 |
+
try:
|
| 647 |
+
conn.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}")
|
| 648 |
+
except Exception:
|
| 649 |
+
pass
|
| 650 |
+
|
| 651 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
|
| 652 |
+
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_user_code ON users(user_code)")
|
| 653 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON reading_history(user_id)")
|
| 654 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_history_url ON reading_history(url)")
|
| 655 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_bookshelf_user_id ON bookshelf(user_id)")
|
| 656 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_translation_history_user_id ON translation_history(user_id)")
|
| 657 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_vocabulary_user_id ON vocabulary(user_id)")
|
| 658 |
+
|
| 659 |
+
conn.execute("""
|
| 660 |
+
CREATE TABLE IF NOT EXISTS sects (
|
| 661 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 662 |
+
name TEXT UNIQUE,
|
| 663 |
+
slogan TEXT,
|
| 664 |
+
announcement TEXT,
|
| 665 |
+
badge TEXT,
|
| 666 |
+
leader_id INTEGER,
|
| 667 |
+
level INTEGER DEFAULT 1,
|
| 668 |
+
contribution INTEGER DEFAULT 0,
|
| 669 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 670 |
+
)
|
| 671 |
+
""")
|
| 672 |
+
conn.execute("""
|
| 673 |
+
CREATE TABLE IF NOT EXISTS sect_members (
|
| 674 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 675 |
+
sect_id INTEGER,
|
| 676 |
+
user_id INTEGER UNIQUE,
|
| 677 |
+
role TEXT DEFAULT 'member',
|
| 678 |
+
contribution INTEGER DEFAULT 0,
|
| 679 |
+
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 680 |
+
FOREIGN KEY (sect_id) REFERENCES sects(id),
|
| 681 |
+
FOREIGN KEY (user_id) REFERENCES users(id)
|
| 682 |
+
)
|
| 683 |
+
""")
|
| 684 |
+
conn.execute("""
|
| 685 |
+
CREATE TABLE IF NOT EXISTS sect_messages (
|
| 686 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 687 |
+
sect_id INTEGER,
|
| 688 |
+
sender_id INTEGER,
|
| 689 |
+
message TEXT,
|
| 690 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 691 |
+
FOREIGN KEY (sect_id) REFERENCES sects(id),
|
| 692 |
+
FOREIGN KEY (sender_id) REFERENCES users(id)
|
| 693 |
+
)
|
| 694 |
+
""")
|
| 695 |
+
conn.execute("""
|
| 696 |
+
CREATE TABLE IF NOT EXISTS sect_join_requests (
|
| 697 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 698 |
+
sect_id INTEGER,
|
| 699 |
+
user_id INTEGER,
|
| 700 |
+
status TEXT DEFAULT 'pending',
|
| 701 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 702 |
+
FOREIGN KEY (sect_id) REFERENCES sects(id),
|
| 703 |
+
FOREIGN KEY (user_id) REFERENCES users(id),
|
| 704 |
+
UNIQUE(sect_id, user_id)
|
| 705 |
+
)
|
| 706 |
+
""")
|
| 707 |
+
conn.execute("""
|
| 708 |
+
CREATE TABLE IF NOT EXISTS sect_books (
|
| 709 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 710 |
+
sect_id INTEGER,
|
| 711 |
+
book_id INTEGER,
|
| 712 |
+
added_by INTEGER,
|
| 713 |
+
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 714 |
+
FOREIGN KEY (sect_id) REFERENCES sects(id),
|
| 715 |
+
FOREIGN KEY (added_by) REFERENCES users(id),
|
| 716 |
+
UNIQUE(sect_id, book_id)
|
| 717 |
+
)
|
| 718 |
+
""")
|
| 719 |
+
|
| 720 |
+
conn.execute("""
|
| 721 |
+
CREATE TABLE IF NOT EXISTS sect_chat_groups (
|
| 722 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 723 |
+
sect_id INTEGER,
|
| 724 |
+
name TEXT,
|
| 725 |
+
creator_id INTEGER,
|
| 726 |
+
members_csv TEXT,
|
| 727 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 728 |
+
FOREIGN KEY (sect_id) REFERENCES sects(id),
|
| 729 |
+
FOREIGN KEY (creator_id) REFERENCES users(id)
|
| 730 |
+
)
|
| 731 |
+
""")
|
| 732 |
+
|
| 733 |
+
conn.execute("""
|
| 734 |
+
CREATE TABLE IF NOT EXISTS book_comments (
|
| 735 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 736 |
+
book_id INTEGER NOT NULL,
|
| 737 |
+
user_id INTEGER,
|
| 738 |
+
is_anonymous INTEGER DEFAULT 0,
|
| 739 |
+
content_ciphertext TEXT NOT NULL,
|
| 740 |
+
encrypted_aes_key TEXT NOT NULL,
|
| 741 |
+
aes_nonce TEXT NOT NULL,
|
| 742 |
+
aes_tag TEXT NOT NULL,
|
| 743 |
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 744 |
+
)
|
| 745 |
+
""")
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
try:
|
| 749 |
+
conn.execute("ALTER TABLE sects ADD COLUMN announcement TEXT")
|
| 750 |
+
except Exception:
|
| 751 |
+
pass
|
| 752 |
+
|
| 753 |
+
for col_name, col_type in [("chat_type", "TEXT DEFAULT 'general'"), ("target_id", "INTEGER"), ("group_id", "INTEGER")]:
|
| 754 |
+
try:
|
| 755 |
+
conn.execute(f"ALTER TABLE sect_messages ADD COLUMN {col_name} {col_type}")
|
| 756 |
+
except Exception:
|
| 757 |
+
pass
|
| 758 |
+
|
| 759 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_members_sect_id ON sect_members(sect_id)")
|
| 760 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_members_user_id ON sect_members(user_id)")
|
| 761 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_messages_sect_id ON sect_messages(sect_id)")
|
| 762 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_books_sect_id ON sect_books(sect_id)")
|
| 763 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_join_requests_sect_id ON sect_join_requests(sect_id)")
|
| 764 |
+
|
| 765 |
+
conn.execute("""
|
| 766 |
+
CREATE TABLE IF NOT EXISTS app_releases (
|
| 767 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 768 |
+
platform TEXT,
|
| 769 |
+
version TEXT,
|
| 770 |
+
download_url TEXT,
|
| 771 |
+
patch_url TEXT,
|
| 772 |
+
file_size TEXT,
|
| 773 |
+
release_notes TEXT,
|
| 774 |
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
| 775 |
+
UNIQUE(platform, version)
|
| 776 |
+
)
|
| 777 |
+
""")
|
| 778 |
+
# Migration: thêm cột patch_url cho bảng cũ nếu chưa có
|
| 779 |
+
try:
|
| 780 |
+
conn.execute("ALTER TABLE app_releases ADD COLUMN patch_url TEXT")
|
| 781 |
+
except Exception:
|
| 782 |
+
pass # Cột đã tồn tại
|
| 783 |
+
try:
|
| 784 |
+
existing = conn.execute("SELECT COUNT(*) as count FROM app_releases").fetchone()
|
| 785 |
+
count = 0
|
| 786 |
+
if existing:
|
| 787 |
+
if isinstance(existing, dict):
|
| 788 |
+
count = existing.get('count', 0)
|
| 789 |
+
elif hasattr(existing, 'keys'):
|
| 790 |
+
count = existing['count']
|
| 791 |
+
else:
|
| 792 |
+
count = existing[0]
|
| 793 |
+
if count == 0:
|
| 794 |
+
conn.execute("""
|
| 795 |
+
INSERT INTO app_releases (platform, version, download_url, file_size, release_notes)
|
| 796 |
+
VALUES
|
| 797 |
+
('extension', '1.0.0', '/downloads/tts_extension.zip', '10.7 MB', 'Cập nhật dịch nhanh và tối ưu hóa Chrome Extension Helper'),
|
| 798 |
+
('desktop_linux', '0.0.0', 'https://huggingface.co/datasets/Cong123779/tienhiep-data/resolve/main/downloads/TienHiepAI-0.0.0.AppImage', '116 MB', 'Phiên bản AppImage beta dành cho Linux'),
|
| 799 |
+
('desktop_windows', '0.0.0', '#', '0 MB', 'Bản Windows Setup .EXE chính thức sắp ra mắt')
|
| 800 |
+
""")
|
| 801 |
+
except Exception as e:
|
| 802 |
+
logger.warning(f"Failed to seed app_releases: {e}")
|
| 803 |
+
|
| 804 |
+
conn.commit()
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def init_user_db():
|
| 808 |
+
conn = get_user_db_conn()
|
| 809 |
+
try:
|
| 810 |
+
_init_db_schema_for_conn(conn)
|
| 811 |
+
logger.info("✔ Database initialized successfully.")
|
| 812 |
+
except Exception as e:
|
| 813 |
+
logger.error(f"❌ Database initialization failed: {e}")
|
| 814 |
+
raise
|
| 815 |
+
finally:
|
| 816 |
+
conn.close()
|
| 817 |
+
|
| 818 |
+
is_postgres = (type(conn).__name__ == "PgConnectionWrapper")
|
| 819 |
+
if is_postgres:
|
| 820 |
+
try:
|
| 821 |
+
sqlite_conn = sqlite3.connect(USER_DB_PATH)
|
| 822 |
+
sqlite_conn.row_factory = sqlite3.Row
|
| 823 |
+
_init_db_schema_for_conn(sqlite_conn)
|
| 824 |
+
sqlite_conn.close()
|
| 825 |
+
logger.info("✔ SQLite fallback database synchronized successfully.")
|
| 826 |
+
except Exception as sqlite_err:
|
| 827 |
+
logger.warning(f"⚠️ Could not synchronize SQLite fallback database schema: {sqlite_err}")
|
| 828 |
+
|
| 829 |
+
# ---------------------------------------------------------------------------
|
| 830 |
+
# Books SQLite databases thread local caching
|
| 831 |
+
# ---------------------------------------------------------------------------
|
| 832 |
+
thread_local = threading.local()
|
| 833 |
+
|
| 834 |
+
class SimpleCache:
|
| 835 |
+
def __init__(self, maxsize=500):
|
| 836 |
+
self.cache = OrderedDict()
|
| 837 |
+
self.maxsize = maxsize
|
| 838 |
+
self.lock = threading.Lock()
|
| 839 |
+
|
| 840 |
+
def get(self, key):
|
| 841 |
+
with self.lock:
|
| 842 |
+
if key in self.cache:
|
| 843 |
+
self.cache.move_to_end(key)
|
| 844 |
+
return self.cache[key]
|
| 845 |
+
return None
|
| 846 |
+
|
| 847 |
+
def set(self, key, value):
|
| 848 |
+
with self.lock:
|
| 849 |
+
if key in self.cache:
|
| 850 |
+
self.cache.move_to_end(key)
|
| 851 |
+
self.cache[key] = value
|
| 852 |
+
if len(self.cache) > self.maxsize:
|
| 853 |
+
self.cache.popitem(last=False)
|
| 854 |
+
|
| 855 |
+
count_cache = SimpleCache(maxsize=1000)
|
| 856 |
+
query_cache = SimpleCache(maxsize=1000)
|
| 857 |
+
cached_stats = None
|
| 858 |
+
|
| 859 |
+
# Cache the resolved DB path to avoid re-reading YAML config on every call
|
| 860 |
+
_cached_db_path = None
|
| 861 |
+
_cached_db_path_time = 0
|
| 862 |
+
_DB_PATH_CACHE_DURATION = 60 # Re-check config every 60 seconds
|
| 863 |
+
|
| 864 |
+
def ensure_db_exists(db_path):
|
| 865 |
+
"""Lazy download book SQLite databases from HuggingFace dataset if missing or incomplete."""
|
| 866 |
+
if os.path.exists(db_path) and os.path.getsize(db_path) > 1024 * 1024:
|
| 867 |
+
return
|
| 868 |
+
|
| 869 |
+
filename = os.path.basename(db_path)
|
| 870 |
+
token = os.environ.get("HF_TOKEN")
|
| 871 |
+
if not token:
|
| 872 |
+
logger.warning(f"⚠️ HF_TOKEN not set, cannot download database file: {filename}")
|
| 873 |
+
return
|
| 874 |
+
|
| 875 |
+
logger.info(f"⏳ Lazy-downloading database from HF: {filename}...")
|
| 876 |
+
try:
|
| 877 |
+
from huggingface_hub import hf_hub_download
|
| 878 |
+
hf_hub_download(
|
| 879 |
+
repo_id="Cong123779/tienhiep-data",
|
| 880 |
+
filename=filename,
|
| 881 |
+
repo_type="dataset",
|
| 882 |
+
token=token,
|
| 883 |
+
local_dir=Config.ROOT_DIR,
|
| 884 |
+
local_dir_use_symlinks=False,
|
| 885 |
+
)
|
| 886 |
+
logger.info(f"✔ Successfully downloaded {filename}")
|
| 887 |
+
except Exception as e:
|
| 888 |
+
logger.error(f"❌ Failed to lazy-download database {filename}: {e}")
|
| 889 |
+
|
| 890 |
+
def get_db():
|
| 891 |
+
global _cached_db_path, _cached_db_path_time
|
| 892 |
+
|
| 893 |
+
now = time.time()
|
| 894 |
+
if _cached_db_path is None or (now - _cached_db_path_time) > _DB_PATH_CACHE_DURATION:
|
| 895 |
+
db_path = Config.DB_PATH
|
| 896 |
+
try:
|
| 897 |
+
config_path = os.path.join(Config.ROOT_DIR, "quick_translator", "config.yaml")
|
| 898 |
+
if os.path.exists(config_path):
|
| 899 |
+
import yaml
|
| 900 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 901 |
+
config = yaml.safe_load(f)
|
| 902 |
+
mode = config.get("translation", {}).get("mode", "advanced")
|
| 903 |
+
candidate_db = os.path.join(Config.ROOT_DIR, f"merged_books_{mode}.db")
|
| 904 |
+
ensure_db_exists(candidate_db)
|
| 905 |
+
if os.path.exists(candidate_db):
|
| 906 |
+
db_path = candidate_db
|
| 907 |
+
except Exception as e:
|
| 908 |
+
print(f"[WARNING] Error loading dynamic database path: {e}")
|
| 909 |
+
|
| 910 |
+
if db_path == Config.DB_PATH:
|
| 911 |
+
ensure_db_exists(db_path)
|
| 912 |
+
|
| 913 |
+
_cached_db_path = db_path
|
| 914 |
+
_cached_db_path_time = now
|
| 915 |
+
else:
|
| 916 |
+
db_path = _cached_db_path
|
| 917 |
+
|
| 918 |
+
if not hasattr(thread_local, "connections"):
|
| 919 |
+
thread_local.connections = {}
|
| 920 |
+
|
| 921 |
+
if db_path not in thread_local.connections:
|
| 922 |
+
# Final fallback check
|
| 923 |
+
if not os.path.exists(db_path):
|
| 924 |
+
ensure_db_exists(db_path)
|
| 925 |
+
|
| 926 |
+
if not os.path.exists(db_path):
|
| 927 |
+
# Create empty database to prevent crash
|
| 928 |
+
logger.warning(f"Creating fallback empty database for {db_path}")
|
| 929 |
+
conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
|
| 930 |
+
try:
|
| 931 |
+
conn.execute("CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY, title TEXT, author TEXT, categories TEXT, chapters_max INTEGER);")
|
| 932 |
+
except Exception:
|
| 933 |
+
pass
|
| 934 |
+
else:
|
| 935 |
+
conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
|
| 936 |
+
|
| 937 |
+
conn.row_factory = sqlite3.Row
|
| 938 |
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
| 939 |
+
conn.execute("PRAGMA cache_size=-30000;")
|
| 940 |
+
conn.execute("PRAGMA temp_store=MEMORY;")
|
| 941 |
+
conn.execute("PRAGMA mmap_size=268435456;")
|
| 942 |
+
conn.execute("PRAGMA synchronous=OFF;")
|
| 943 |
+
try:
|
| 944 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_categories ON books(categories);")
|
| 945 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_chapters_max ON books(chapters_max);")
|
| 946 |
+
except Exception:
|
| 947 |
+
pass
|
| 948 |
+
thread_local.connections[db_path] = conn
|
| 949 |
+
|
| 950 |
+
return thread_local.connections[db_path]
|
| 951 |
+
|
| 952 |
+
def get_mode_connection(db_path):
|
| 953 |
+
if not hasattr(thread_local, "connections"):
|
| 954 |
+
thread_local.connections = {}
|
| 955 |
+
|
| 956 |
+
if db_path not in thread_local.connections:
|
| 957 |
+
ensure_db_exists(db_path)
|
| 958 |
+
if os.path.exists(db_path):
|
| 959 |
+
conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
|
| 960 |
+
conn.row_factory = sqlite3.Row
|
| 961 |
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
| 962 |
+
conn.execute("PRAGMA cache_size=-10000;")
|
| 963 |
+
conn.execute("PRAGMA temp_store=MEMORY;")
|
| 964 |
+
conn.execute("PRAGMA mmap_size=134217728;")
|
| 965 |
+
conn.execute("PRAGMA synchronous=OFF;")
|
| 966 |
+
thread_local.connections[db_path] = conn
|
| 967 |
+
else:
|
| 968 |
+
return None
|
| 969 |
+
|
| 970 |
+
return thread_local.connections[db_path]
|
backend/engine/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .engine import VietphraseEngine
|
| 2 |
+
from .trie import Trie
|
backend/engine/engine.py
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import re
|
| 4 |
+
import jieba.posseg as pseg
|
| 5 |
+
|
| 6 |
+
from backend.config import Config
|
| 7 |
+
|
| 8 |
+
NUM_RE = re.compile(r'^[0-9一二三四五六七八九十百千万几数多半两]+$')
|
| 9 |
+
PUNCT_SET = {',', '.', '!', '?', ';', ':', '"', '\'', '(', ')', '[', ']', '{', '}',
|
| 10 |
+
',', '。', '!', '?', ';', ':', '“', '”', '‘', '’', '(', ')', '【', '】', '《', '》', '、', '—', '~'}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SimpleToken:
|
| 14 |
+
def __init__(self, word, tag):
|
| 15 |
+
self.word = word
|
| 16 |
+
self.tag = tag
|
| 17 |
+
self.translated = None
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def flag(self):
|
| 21 |
+
return self.tag
|
| 22 |
+
|
| 23 |
+
@flag.setter
|
| 24 |
+
def flag(self, value):
|
| 25 |
+
self.tag = value
|
| 26 |
+
|
| 27 |
+
class VietphraseEngine:
|
| 28 |
+
def __init__(self, config=None):
|
| 29 |
+
self.config = config or {}
|
| 30 |
+
self.load_dictionaries()
|
| 31 |
+
|
| 32 |
+
# Check translation mode
|
| 33 |
+
self.translation_mode = self.config.get("translation", {}).get("mode", "advanced")
|
| 34 |
+
if self.config.get("translation", {}).get("fast_mode", False):
|
| 35 |
+
self.translation_mode = "fast"
|
| 36 |
+
|
| 37 |
+
# Warm-up jieba
|
| 38 |
+
import jieba
|
| 39 |
+
try:
|
| 40 |
+
# Fix segmenter splitting overlapping words like 重生于
|
| 41 |
+
jieba.add_word("重生", tag="v")
|
| 42 |
+
jieba.suggest_freq(("生", "于"), True)
|
| 43 |
+
jieba.suggest_freq(("着", "重"), True)
|
| 44 |
+
jieba.suggest_freq(("醉", "人"), True)
|
| 45 |
+
# Tag grades as nouns instead of proper names (nr)
|
| 46 |
+
jieba.add_word("高一", tag="n")
|
| 47 |
+
jieba.add_word("高二", tag="n")
|
| 48 |
+
jieba.add_word("高三", tag="n")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print("Error initializing custom word splits in Jieba:", e)
|
| 51 |
+
|
| 52 |
+
# Always initialize both tokenizers to support dynamic mode switching
|
| 53 |
+
self.jieba_tokenizer = jieba.dt
|
| 54 |
+
self.pseg_dict = pseg.dt.word_tag_tab
|
| 55 |
+
list(self.jieba_tokenizer.cut("暖洋洋"))
|
| 56 |
+
list(pseg.cut("暖洋洋"))
|
| 57 |
+
|
| 58 |
+
def load_dictionaries(self):
|
| 59 |
+
paths = self.config.get("paths", {}).get("dictionaries", {})
|
| 60 |
+
vp_path = paths.get("vietphrase", "")
|
| 61 |
+
if not vp_path or not os.path.isabs(vp_path):
|
| 62 |
+
vp_path = os.path.join(Config.ROOT_DIR, vp_path or "dictionaries/Vietphrase.txt")
|
| 63 |
+
|
| 64 |
+
dict_dir = os.path.dirname(vp_path)
|
| 65 |
+
|
| 66 |
+
# Check for encrypted .bin dictionaries first, then fallback to .txt
|
| 67 |
+
def load_file_content(base_name):
|
| 68 |
+
bin_file = os.path.join(dict_dir, base_name + ".bin")
|
| 69 |
+
txt_file = os.path.join(dict_dir, base_name + ".txt")
|
| 70 |
+
|
| 71 |
+
if os.path.exists(bin_file):
|
| 72 |
+
# Decrypt XOR
|
| 73 |
+
with open(bin_file, "rb") as f:
|
| 74 |
+
data = f.read()
|
| 75 |
+
key_bytes = "quick_translator_secret_key_2026".encode("utf-8")
|
| 76 |
+
key_len = len(key_bytes)
|
| 77 |
+
repeated_key = (key_bytes * (len(data) // key_len + 1))[:len(data)]
|
| 78 |
+
decrypted = bytes(a ^ b for a, b in zip(data, repeated_key))
|
| 79 |
+
return decrypted.decode("utf-8")
|
| 80 |
+
elif os.path.exists(txt_file):
|
| 81 |
+
with open(txt_file, "r", encoding="utf-8") as f:
|
| 82 |
+
return f.read()
|
| 83 |
+
return ""
|
| 84 |
+
|
| 85 |
+
print("Loading dictionaries in VietphraseEngine...")
|
| 86 |
+
self.char_dict = self.parse_dict_content(load_file_content("HanViet_CharDict"))
|
| 87 |
+
|
| 88 |
+
# --- ADD HÁn Nôm FALLBACK ---
|
| 89 |
+
import csv
|
| 90 |
+
han_csv_path = os.path.join(dict_dir, "han_all_readings.csv")
|
| 91 |
+
if os.path.exists(han_csv_path):
|
| 92 |
+
try:
|
| 93 |
+
with open(han_csv_path, 'r', encoding='utf-8') as f:
|
| 94 |
+
reader = csv.DictReader(f)
|
| 95 |
+
for row in reader:
|
| 96 |
+
char = row.get("Ký_tự", "").strip()
|
| 97 |
+
hv = row.get("Hán_Việt", "").strip()
|
| 98 |
+
if char and hv and char not in self.char_dict:
|
| 99 |
+
self.char_dict[char] = hv.replace("~", "")
|
| 100 |
+
print("Loaded han_all_readings.csv as fallback for missing Chinese characters.")
|
| 101 |
+
except Exception as e:
|
| 102 |
+
print("Could not load han_all_readings.csv:", e)
|
| 103 |
+
|
| 104 |
+
self.proper_names = self.parse_dict_content(load_file_content("Aligned_HanViet"), convert_to_simplified=True)
|
| 105 |
+
|
| 106 |
+
vp_content = load_file_content("Vietphrase")
|
| 107 |
+
self.vietphrase = self.parse_vietphrase_content(vp_content)
|
| 108 |
+
print("Dictionaries loaded successfully.")
|
| 109 |
+
|
| 110 |
+
# Build Tries for vietphrase and hanviet modes
|
| 111 |
+
from .trie import Trie
|
| 112 |
+
print("Building Tries for fast translation modes...")
|
| 113 |
+
self.vietphrase_trie = Trie()
|
| 114 |
+
# Insert proper names (priority 1)
|
| 115 |
+
for k, v in self.proper_names.items():
|
| 116 |
+
self.vietphrase_trie.insert(k, v, 1)
|
| 117 |
+
# Insert Vietphrase (priority 2 - higher)
|
| 118 |
+
for k, v in self.vietphrase.items():
|
| 119 |
+
self.vietphrase_trie.insert(k, v, 2)
|
| 120 |
+
|
| 121 |
+
self.hanviet_trie = Trie()
|
| 122 |
+
# Insert proper names (priority 2)
|
| 123 |
+
for k, v in self.proper_names.items():
|
| 124 |
+
self.hanviet_trie.insert(k, v, 2)
|
| 125 |
+
print("Tries built successfully.")
|
| 126 |
+
|
| 127 |
+
# Register proper names in Jieba dictionary for fast modes
|
| 128 |
+
import jieba
|
| 129 |
+
for name in self.proper_names:
|
| 130 |
+
jieba.add_word(name)
|
| 131 |
+
|
| 132 |
+
def parse_dict_content(self, content, convert_to_simplified=False):
|
| 133 |
+
dictionary = {}
|
| 134 |
+
if content:
|
| 135 |
+
to_simplified = lambda s: s
|
| 136 |
+
if convert_to_simplified:
|
| 137 |
+
try:
|
| 138 |
+
from hanziconv import HanziConv
|
| 139 |
+
to_simplified = HanziConv.toSimplified
|
| 140 |
+
except ImportError:
|
| 141 |
+
pass
|
| 142 |
+
|
| 143 |
+
for line in content.splitlines():
|
| 144 |
+
line = line.strip()
|
| 145 |
+
if not line or "=" not in line or line.startswith('#'):
|
| 146 |
+
continue
|
| 147 |
+
parts = line.split("=", 1)
|
| 148 |
+
key = parts[0].strip()
|
| 149 |
+
val = self.clean_annotation(parts[1].strip())
|
| 150 |
+
dictionary[to_simplified(key)] = val
|
| 151 |
+
return dictionary
|
| 152 |
+
|
| 153 |
+
def parse_vietphrase_content(self, content):
|
| 154 |
+
dictionary = {}
|
| 155 |
+
if content:
|
| 156 |
+
for line in content.splitlines():
|
| 157 |
+
line = line.strip()
|
| 158 |
+
if not line or "=" not in line or line.startswith('#'):
|
| 159 |
+
continue
|
| 160 |
+
parts = line.split("=", 1)
|
| 161 |
+
left = parts[0].strip()
|
| 162 |
+
right = self.clean_annotation(parts[1].strip())
|
| 163 |
+
|
| 164 |
+
if "," in left and "," in right:
|
| 165 |
+
keys = [k.strip() for k in left.split(",") if k.strip()]
|
| 166 |
+
vals = [v.strip() for v in right.split(",") if v.strip()]
|
| 167 |
+
if len(keys) == len(vals):
|
| 168 |
+
for k, v in zip(keys, vals):
|
| 169 |
+
dictionary[k] = v
|
| 170 |
+
continue
|
| 171 |
+
if left:
|
| 172 |
+
dictionary[left] = right
|
| 173 |
+
return dictionary
|
| 174 |
+
|
| 175 |
+
def is_number(self, word):
|
| 176 |
+
return bool(re.match(r'^[0-9一二三四五六七八九十百千万几数多半两]+$', word))
|
| 177 |
+
|
| 178 |
+
def capitalize_phrase(self, phrase):
|
| 179 |
+
chars = 'a-zA-ZàáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ'
|
| 180 |
+
pattern = f'[{chars}]+'
|
| 181 |
+
return re.sub(pattern, lambda m: m.group(0).capitalize(), phrase)
|
| 182 |
+
|
| 183 |
+
def clean_annotation(self, text, mode='vietphrase'):
|
| 184 |
+
if not text:
|
| 185 |
+
return ""
|
| 186 |
+
# 1. Parse curly braces {meaning:reading}
|
| 187 |
+
def repl_curly(match):
|
| 188 |
+
content = match.group(1)
|
| 189 |
+
if ':' in content:
|
| 190 |
+
parts = content.split(':', 1)
|
| 191 |
+
return parts[0].strip() if mode == 'vietphrase' else parts[1].strip()
|
| 192 |
+
return content.strip()
|
| 193 |
+
|
| 194 |
+
text = re.sub(r'\{([^{}]+)\}', repl_curly, text)
|
| 195 |
+
|
| 196 |
+
# 2. Strip (*...) annotations
|
| 197 |
+
text = re.sub(r'\s*\(\*[^)]*\)', '', text)
|
| 198 |
+
|
| 199 |
+
return text.strip()
|
| 200 |
+
|
| 201 |
+
def format_translation(self, raw_value, multi_option, word=None, prefer_hanviet=False):
|
| 202 |
+
if not raw_value:
|
| 203 |
+
return ""
|
| 204 |
+
options = [o for o in raw_value.split("/") if o.strip()]
|
| 205 |
+
|
| 206 |
+
# Deduplicate options while preserving order
|
| 207 |
+
seen = set()
|
| 208 |
+
deduped = []
|
| 209 |
+
for o in options:
|
| 210 |
+
if o not in seen:
|
| 211 |
+
seen.add(o)
|
| 212 |
+
deduped.append(o)
|
| 213 |
+
|
| 214 |
+
if not deduped:
|
| 215 |
+
return ""
|
| 216 |
+
|
| 217 |
+
if multi_option and len(deduped) > 1:
|
| 218 |
+
return f"{deduped[0]}[{'/'.join(deduped[1:])}]"
|
| 219 |
+
|
| 220 |
+
# If multi-option is False, we have a word of length >= 2, and prefer_hanviet is True, prefer Hán Việt alignment
|
| 221 |
+
if prefer_hanviet and word and len(word) >= 2 and len(deduped) > 1:
|
| 222 |
+
hv_sets = []
|
| 223 |
+
for char in word:
|
| 224 |
+
readings = set()
|
| 225 |
+
if char in self.char_dict:
|
| 226 |
+
for r in self.char_dict[char].split('/'):
|
| 227 |
+
r_clean = r.strip().lower()
|
| 228 |
+
if r_clean:
|
| 229 |
+
readings.add(r_clean)
|
| 230 |
+
if readings:
|
| 231 |
+
hv_sets.append(readings)
|
| 232 |
+
|
| 233 |
+
best_option = deduped[0]
|
| 234 |
+
best_score = -1
|
| 235 |
+
|
| 236 |
+
for opt in deduped:
|
| 237 |
+
opt_syllables = [w.strip().lower() for w in opt.split() if w.strip()]
|
| 238 |
+
score = 0
|
| 239 |
+
for r_set in hv_sets:
|
| 240 |
+
if any(r in opt_syllables for r in r_set):
|
| 241 |
+
score += 1
|
| 242 |
+
if score > best_score:
|
| 243 |
+
best_score = score
|
| 244 |
+
best_option = opt
|
| 245 |
+
if best_score > 0:
|
| 246 |
+
return best_option
|
| 247 |
+
|
| 248 |
+
return deduped[0]
|
| 249 |
+
|
| 250 |
+
def clean_punctuation_spacing(self, text):
|
| 251 |
+
if not text:
|
| 252 |
+
return text
|
| 253 |
+
|
| 254 |
+
# 1. Ensure exactly one space after commas, semicolons, colons, periods, question marks, and exclamation marks.
|
| 255 |
+
# Avoid inserting space if the next character is a closing bracket, closing quote, space, or another punctuation.
|
| 256 |
+
text = re.sub(r'([,;.:!?])(?=[^\s)\]}』】”"’])', r'\1 ', text)
|
| 257 |
+
|
| 258 |
+
# 2. Remove any accidental whitespace before these punctuation marks
|
| 259 |
+
text = re.sub(r'\s+([,;.:!?])', r'\1', text)
|
| 260 |
+
|
| 261 |
+
# 3. Clean spaces inside parentheses, brackets, and curly/double brackets (including Chinese quote styles)
|
| 262 |
+
text = re.sub(r'([(\[{『【«])\s+', r'\1', text)
|
| 263 |
+
text = re.sub(r'\s+([)\]}』】»])', r'\1', text)
|
| 264 |
+
|
| 265 |
+
# Ensure a space exists before opening brackets and after closing brackets when they border words/digits
|
| 266 |
+
text = re.sub(r'(?<=[^\s(\[{『【«])([(\[{『【«])', r' \1', text)
|
| 267 |
+
text = re.sub(r'([)\]}』】»])(?=[^\s.,;:!?)\]}』】»])', r'\1 ', text)
|
| 268 |
+
|
| 269 |
+
# 4. Standardize dashes/hyphens used as separators (e.g. "Artist - Song") to have one space on each side
|
| 270 |
+
text = re.sub(r'\s*-\s*', ' - ', text)
|
| 271 |
+
|
| 272 |
+
# 5. Clean up any duplicated/trailing whitespaces
|
| 273 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 274 |
+
|
| 275 |
+
return text
|
| 276 |
+
|
| 277 |
+
def translate_sentence(self, sentence, multi_option=False, mode=None):
|
| 278 |
+
if not sentence or sentence.isspace():
|
| 279 |
+
return ""
|
| 280 |
+
|
| 281 |
+
# If the sentence doesn't contain any Chinese characters or symbols, preserve it as-is
|
| 282 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df\u3000-\u303f\uff00-\uffef]', sentence):
|
| 283 |
+
return sentence
|
| 284 |
+
|
| 285 |
+
# Segment into Chinese text blocks and non-Chinese text blocks
|
| 286 |
+
# Keep Chinese characters and Chinese specific punctuations in the translation segment
|
| 287 |
+
chinese_pattern = re.compile(r'([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+)')
|
| 288 |
+
parts = chinese_pattern.split(sentence)
|
| 289 |
+
|
| 290 |
+
# Merge simple alphanumeric non-Chinese blocks into adjacent Chinese blocks
|
| 291 |
+
i = 1
|
| 292 |
+
while i < len(parts) - 1:
|
| 293 |
+
non_chinese = parts[i+1]
|
| 294 |
+
if re.match(r'^\s*[a-zA-Z0-9]+\s*$', non_chinese):
|
| 295 |
+
parts[i] = parts[i] + non_chinese + parts[i+2]
|
| 296 |
+
parts.pop(i+1)
|
| 297 |
+
parts.pop(i+1)
|
| 298 |
+
else:
|
| 299 |
+
i += 2
|
| 300 |
+
|
| 301 |
+
translated_parts = []
|
| 302 |
+
capitalize_next = True
|
| 303 |
+
|
| 304 |
+
for part in parts:
|
| 305 |
+
if not part:
|
| 306 |
+
continue
|
| 307 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df\u3000-\u303f\uff00-\uffef]', part):
|
| 308 |
+
# Non-Chinese segment -> preserve exactly
|
| 309 |
+
translated_parts.append(part)
|
| 310 |
+
# Check if it ends with sentence terminator
|
| 311 |
+
if re.search(r'[.!?]\s*$', part):
|
| 312 |
+
capitalize_next = True
|
| 313 |
+
elif part.strip():
|
| 314 |
+
capitalize_next = False
|
| 315 |
+
else:
|
| 316 |
+
# Chinese segment -> translate
|
| 317 |
+
trans = self._translate_pure_chinese_sentence(part, multi_option, mode, capitalize_first=capitalize_next)
|
| 318 |
+
translated_parts.append(trans)
|
| 319 |
+
# Check if it ends with sentence terminator
|
| 320 |
+
if re.search(r'[.!?]\s*$', part) or re.search(r'[.!?]\s*$', trans):
|
| 321 |
+
capitalize_next = True
|
| 322 |
+
else:
|
| 323 |
+
capitalize_next = False
|
| 324 |
+
|
| 325 |
+
return "".join(translated_parts)
|
| 326 |
+
|
| 327 |
+
def _translate_pure_chinese_sentence(self, sentence, multi_option=False, mode=None, capitalize_first=True):
|
| 328 |
+
if not sentence or sentence.isspace():
|
| 329 |
+
return ""
|
| 330 |
+
|
| 331 |
+
active_mode = mode or self.translation_mode
|
| 332 |
+
|
| 333 |
+
# Tokenization & Tagging depending on mode
|
| 334 |
+
if active_mode in ("advanced", "advanced_hanviet"):
|
| 335 |
+
raw_tokens = [SimpleToken(t.word, t.flag) for t in pseg.cut(sentence)]
|
| 336 |
+
else:
|
| 337 |
+
# "fast", "vietphrase", "hanviet" modes use the fast tokenizer
|
| 338 |
+
words = list(self.jieba_tokenizer.cut(sentence))
|
| 339 |
+
raw_tokens = []
|
| 340 |
+
for w in words:
|
| 341 |
+
if w in PUNCT_SET:
|
| 342 |
+
tag = 'x'
|
| 343 |
+
elif NUM_RE.match(w):
|
| 344 |
+
tag = 'm'
|
| 345 |
+
else:
|
| 346 |
+
tag = self.pseg_dict.get(w, 'n')
|
| 347 |
+
raw_tokens.append(SimpleToken(w, tag))
|
| 348 |
+
|
| 349 |
+
if not raw_tokens:
|
| 350 |
+
return ""
|
| 351 |
+
|
| 352 |
+
NUM_KEYWORDS = {"重", "阶", "品", "级", "层", "剑", "星", "转", "天", "色", "关", "重天"}
|
| 353 |
+
HANVIET_NUMBERS = {
|
| 354 |
+
'0': 'Không', '1': 'Nhất', '2': 'Nhị', '3': 'Tam', '4': 'Tứ', '5': 'Ngũ', '6': 'Lục', '7': 'Thất', '8': 'Bát', '9': 'Cửu', '10': 'Thập',
|
| 355 |
+
'一': 'Nhất', '二': 'Nhị', '三': 'Tam', '四': 'Tứ', '五': 'Ngũ', '六': 'Lục', '七': 'Thất', '八': 'Bát', '九': 'Cửu', '十': 'Thập',
|
| 356 |
+
'百': 'Bách', '千': 'Thiên', '万': 'Vạn', '萬': 'Vạn', '几': 'Vài', '数': 'Số', '多': 'Đa', '半': 'Bán', '两': 'Lưỡng', '兩': 'Lưỡng'
|
| 357 |
+
}
|
| 358 |
+
# Helper function to translate a single token
|
| 359 |
+
def translate_single_token(idx, tok, list_of_tokens):
|
| 360 |
+
word = tok.word
|
| 361 |
+
tag = tok.tag
|
| 362 |
+
|
| 363 |
+
# Punctuation
|
| 364 |
+
is_punct = (tag == 'x' or word in {',', '.', '!', '?', ';', ':', '"', '(', ')', '[', ']', '{', '}'})
|
| 365 |
+
if is_punct:
|
| 366 |
+
has_chinese = False
|
| 367 |
+
for char in word:
|
| 368 |
+
if char in self.char_dict:
|
| 369 |
+
has_chinese = True
|
| 370 |
+
break
|
| 371 |
+
if not has_chinese:
|
| 372 |
+
punct_map = {
|
| 373 |
+
',': ',', '。': '.', '「': '"', '」': '"', '、': ',', '?': '?', '!': '!',
|
| 374 |
+
':': ':', ';': ';', '“': '"', '”': '"', '(': '(', ')': ')'
|
| 375 |
+
}
|
| 376 |
+
tok.translated = punct_map.get(word, word)
|
| 377 |
+
return
|
| 378 |
+
|
| 379 |
+
# Rule for number + 人 (e.g. 几十人, 三人)
|
| 380 |
+
if len(word) > 1 and word.endswith('人') and self.is_number(word[:-1]):
|
| 381 |
+
num_part = word[:-1]
|
| 382 |
+
if num_part in self.vietphrase:
|
| 383 |
+
num_trans = self.format_translation(self.vietphrase[num_part], multi_option, num_part)
|
| 384 |
+
else:
|
| 385 |
+
num_trans = " ".join([self.char_dict.get(c, c).split("/")[0] for c in num_part])
|
| 386 |
+
tok.translated = f"{num_trans} người"
|
| 387 |
+
return
|
| 388 |
+
|
| 389 |
+
# Special rule for 了 (le vs liao)
|
| 390 |
+
if word == 'l' or word == '了':
|
| 391 |
+
is_at_end = True
|
| 392 |
+
for next_tok in list_of_tokens[idx+1:]:
|
| 393 |
+
if next_tok.word in {'"', '\'', '(', ')', '[', ']', '{', '}', '“', '”', '‘', '’', '(', ')', '【', '】', '《', '》'}:
|
| 394 |
+
continue
|
| 395 |
+
if next_tok.word in {',', '.', '!', '?', ';', ':', ',', '。', '!', '?', ';', ':', '、'}:
|
| 396 |
+
is_at_end = True
|
| 397 |
+
break
|
| 398 |
+
is_at_end = False
|
| 399 |
+
break
|
| 400 |
+
if is_at_end:
|
| 401 |
+
tok.translated = "rồi"
|
| 402 |
+
else:
|
| 403 |
+
tok.translated = "được"
|
| 404 |
+
return
|
| 405 |
+
|
| 406 |
+
# Cultivation Realm (cultivation)
|
| 407 |
+
if tag == 'cultivation':
|
| 408 |
+
result = []
|
| 409 |
+
for char in word:
|
| 410 |
+
if char in HANVIET_NUMBERS:
|
| 411 |
+
result.append(HANVIET_NUMBERS[char])
|
| 412 |
+
else:
|
| 413 |
+
cap_val = self.char_dict.get(char, char).split("/")[0].capitalize()
|
| 414 |
+
result.append(cap_val)
|
| 415 |
+
tok.translated = " ".join(result)
|
| 416 |
+
return
|
| 417 |
+
|
| 418 |
+
# Determine if it's a noun or an adjective
|
| 419 |
+
is_proper = (tag in {'nr', 'ns', 'nt'} if tag else False)
|
| 420 |
+
is_noun = (tag.startswith('n') if tag else False) or tag in {'n', 'nz', 'ng'} if tag else False
|
| 421 |
+
is_adj = tag in {'a', 'b', 'ad', 'an', 'z'} if tag else False
|
| 422 |
+
is_noun_or_adj = is_proper or is_noun or is_adj
|
| 423 |
+
|
| 424 |
+
# --- Chốt chặn cuối cùng cho Tên riêng (Proper Names Guard) ---
|
| 425 |
+
if is_proper:
|
| 426 |
+
if word in self.proper_names:
|
| 427 |
+
tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
|
| 428 |
+
else:
|
| 429 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
|
| 430 |
+
tok.translated = word
|
| 431 |
+
else:
|
| 432 |
+
result = []
|
| 433 |
+
for char in word:
|
| 434 |
+
val = self.char_dict.get(char, char).split("/")[0]
|
| 435 |
+
result.append(val)
|
| 436 |
+
tok.translated = " ".join(result)
|
| 437 |
+
if tok.translated:
|
| 438 |
+
tok.translated = self.capitalize_phrase(tok.translated)
|
| 439 |
+
|
| 440 |
+
# --- Translate lookup strategy depending on active_mode (for non-proper names) ---
|
| 441 |
+
else:
|
| 442 |
+
if active_mode == 'hanviet':
|
| 443 |
+
# Mode 4: Pure Hán Việt (NO Vietphrase)
|
| 444 |
+
if word in self.proper_names:
|
| 445 |
+
tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
|
| 446 |
+
else:
|
| 447 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
|
| 448 |
+
tok.translated = word
|
| 449 |
+
else:
|
| 450 |
+
result = []
|
| 451 |
+
for char in word:
|
| 452 |
+
val = self.char_dict.get(char, char).split("/")[0]
|
| 453 |
+
result.append(val)
|
| 454 |
+
tok.translated = " ".join(result)
|
| 455 |
+
|
| 456 |
+
elif active_mode == 'vietphrase':
|
| 457 |
+
# Mode 3: Prioritize Vietphrase (Traditional)
|
| 458 |
+
if word in self.vietphrase:
|
| 459 |
+
tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
|
| 460 |
+
elif word in self.proper_names:
|
| 461 |
+
tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
|
| 462 |
+
else:
|
| 463 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
|
| 464 |
+
tok.translated = word
|
| 465 |
+
else:
|
| 466 |
+
result = []
|
| 467 |
+
for char in word:
|
| 468 |
+
val = self.char_dict.get(char, char).split("/")[0]
|
| 469 |
+
result.append(val)
|
| 470 |
+
tok.translated = " ".join(result)
|
| 471 |
+
|
| 472 |
+
else:
|
| 473 |
+
# Modes 1, 2 & 5: 'fast', 'advanced', or 'advanced_hanviet' (POS-based noun/adjective Hán Việt override)
|
| 474 |
+
if is_noun_or_adj:
|
| 475 |
+
# Nouns/Adjectives: Bypasses vietphrase
|
| 476 |
+
if word in self.proper_names:
|
| 477 |
+
tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
|
| 478 |
+
else:
|
| 479 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
|
| 480 |
+
tok.translated = word
|
| 481 |
+
else:
|
| 482 |
+
result = []
|
| 483 |
+
for char in word:
|
| 484 |
+
val = self.char_dict.get(char, char).split("/")[0]
|
| 485 |
+
result.append(val)
|
| 486 |
+
tok.translated = " ".join(result)
|
| 487 |
+
else:
|
| 488 |
+
# Verbs and other parts of speech
|
| 489 |
+
if active_mode == 'advanced_hanviet':
|
| 490 |
+
# Prefer HanViet dictionary (proper_names) over Vietphrase
|
| 491 |
+
if word in self.proper_names:
|
| 492 |
+
tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
|
| 493 |
+
elif word in self.vietphrase:
|
| 494 |
+
tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
|
| 495 |
+
else:
|
| 496 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
|
| 497 |
+
tok.translated = word
|
| 498 |
+
else:
|
| 499 |
+
result = []
|
| 500 |
+
for char in word:
|
| 501 |
+
val = self.char_dict.get(char, char).split("/")[0]
|
| 502 |
+
result.append(val)
|
| 503 |
+
tok.translated = " ".join(result)
|
| 504 |
+
else:
|
| 505 |
+
# Standard fast/advanced: vietphrase -> proper_names -> character fallback
|
| 506 |
+
if word in self.vietphrase:
|
| 507 |
+
tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
|
| 508 |
+
elif word in self.proper_names:
|
| 509 |
+
tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
|
| 510 |
+
else:
|
| 511 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
|
| 512 |
+
tok.translated = word
|
| 513 |
+
else:
|
| 514 |
+
result = []
|
| 515 |
+
for char in word:
|
| 516 |
+
val = self.char_dict.get(char, char).split("/")[0]
|
| 517 |
+
result.append(val)
|
| 518 |
+
tok.translated = " ".join(result)
|
| 519 |
+
|
| 520 |
+
# Strip trailing "đích" / "Đích" from modifier translations
|
| 521 |
+
if tok.translated and word.endswith('的') and len(word) > 1:
|
| 522 |
+
val = tok.translated
|
| 523 |
+
if val.lower().endswith(' đích'):
|
| 524 |
+
tok.translated = val[:-5]
|
| 525 |
+
elif val.lower().endswith('đích'):
|
| 526 |
+
tok.translated = val[:-4]
|
| 527 |
+
|
| 528 |
+
# Step 1: Group numeral phrases and cultivation terms FIRST
|
| 529 |
+
grouped = []
|
| 530 |
+
i = 0
|
| 531 |
+
while i < len(raw_tokens):
|
| 532 |
+
tok = raw_tokens[i]
|
| 533 |
+
word = tok.word
|
| 534 |
+
tag = tok.tag
|
| 535 |
+
|
| 536 |
+
if self.is_number(word) and i + 1 < len(raw_tokens) and raw_tokens[i+1].word in NUM_KEYWORDS:
|
| 537 |
+
grouped_word = word + raw_tokens[i+1].word
|
| 538 |
+
i_next = i + 2
|
| 539 |
+
if i_next < len(raw_tokens) and raw_tokens[i_next].tag in {'n', 'nr', 'ns', 'nt', 'nz'}:
|
| 540 |
+
grouped_word += raw_tokens[i_next].word
|
| 541 |
+
i_next += 1
|
| 542 |
+
grouped.append(SimpleToken(grouped_word, 'cultivation'))
|
| 543 |
+
i = i_next
|
| 544 |
+
else:
|
| 545 |
+
grouped.append(SimpleToken(word, tag))
|
| 546 |
+
i += 1
|
| 547 |
+
|
| 548 |
+
# Step 2: Translate individual tokens on the cultivation-grouped tokens
|
| 549 |
+
for idx, tok in enumerate(grouped):
|
| 550 |
+
translate_single_token(idx, tok, grouped)
|
| 551 |
+
|
| 552 |
+
# Step 3: Greedy merge adjacent tokens if their combination exists in dictionaries
|
| 553 |
+
i = 0
|
| 554 |
+
merged = []
|
| 555 |
+
while i < len(grouped):
|
| 556 |
+
matched = False
|
| 557 |
+
for length in range(min(4, len(grouped) - i), 1, -1):
|
| 558 |
+
combined_word = "".join([grouped[i+k].word for k in range(length)])
|
| 559 |
+
|
| 560 |
+
# Prevent merging across '的' particle to preserve root Hán Việt translation and allow reordering
|
| 561 |
+
should_skip = False
|
| 562 |
+
if 'đích' in combined_word or '的' in combined_word and combined_word.find('的') > 0:
|
| 563 |
+
should_skip = True
|
| 564 |
+
elif i + length < len(grouped) and grouped[i+length].word == '的':
|
| 565 |
+
# If next token is 'de' (de/的), don't merge if it would swallow a pronoun/noun/verb
|
| 566 |
+
last_tok = grouped[i+length-1]
|
| 567 |
+
if last_tok.flag in {'r', 'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'v'}:
|
| 568 |
+
should_skip = True
|
| 569 |
+
elif '是' in combined_word and any(p in combined_word for p in {'我', '你', 'he', 'she', 'it', '们', '您', '自己'}):
|
| 570 |
+
# Prevent merging copula + pronoun phrases (like '这是他', '那是我') to allow proper clause reordering
|
| 571 |
+
should_skip = True
|
| 572 |
+
|
| 573 |
+
# Dict check strategy depends on active_mode
|
| 574 |
+
if active_mode == 'hanviet':
|
| 575 |
+
in_dicts = (combined_word in self.proper_names)
|
| 576 |
+
else:
|
| 577 |
+
in_dicts = (combined_word in self.vietphrase or combined_word in self.proper_names)
|
| 578 |
+
|
| 579 |
+
if not should_skip and in_dicts:
|
| 580 |
+
combined_tag = None
|
| 581 |
+
try:
|
| 582 |
+
cut_res = list(pseg.cut(combined_word))
|
| 583 |
+
if cut_res:
|
| 584 |
+
combined_tag = cut_res[0].flag
|
| 585 |
+
except Exception:
|
| 586 |
+
pass
|
| 587 |
+
if not combined_tag:
|
| 588 |
+
combined_tag = grouped[i].flag
|
| 589 |
+
for k in range(length):
|
| 590 |
+
if grouped[i+k].flag in {'nr', 'ns', 'nt', 'nz'}:
|
| 591 |
+
combined_tag = grouped[i+k].flag
|
| 592 |
+
break
|
| 593 |
+
new_tok = SimpleToken(combined_word, combined_tag)
|
| 594 |
+
# Translate the new merged token immediately
|
| 595 |
+
translate_single_token(0, new_tok, [new_tok])
|
| 596 |
+
merged.append(new_tok)
|
| 597 |
+
i += length
|
| 598 |
+
matched = True
|
| 599 |
+
break
|
| 600 |
+
if not matched:
|
| 601 |
+
merged.append(grouped[i])
|
| 602 |
+
i += 1
|
| 603 |
+
|
| 604 |
+
# Step 4: Reordering Grammar Rules
|
| 605 |
+
if active_mode != 'hanviet':
|
| 606 |
+
# Pass 1: Adjective + Noun reordering
|
| 607 |
+
changed = True
|
| 608 |
+
while changed:
|
| 609 |
+
changed = False
|
| 610 |
+
i = 0
|
| 611 |
+
while i < len(merged) - 1:
|
| 612 |
+
t_a = merged[i]
|
| 613 |
+
t_n = merged[i+1]
|
| 614 |
+
|
| 615 |
+
# Do not swap with prepositions/conjunctions/copulas/particles
|
| 616 |
+
if t_n.word in {'跟', '和', '与', '與', '同', '在', '从', '從', '自', '由', '向', '往', '朝', '对', '對', '给', '給', '比', '是', '叫', '让', '讓', '被', '把', '使', '令', '到', '了', '的', '而', '&', '并', '並', '以', '或', '者'}:
|
| 617 |
+
i += 1
|
| 618 |
+
continue
|
| 619 |
+
|
| 620 |
+
if (t_a.tag in {'a', 'b'} or (t_a.word.endswith('的') and t_a.word != '的')) and t_n.tag in {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'v', 'vd', 'vg', 'vi', 'vn'}:
|
| 621 |
+
combined = t_n.translated + " " + t_a.translated
|
| 622 |
+
new_tok = SimpleToken(t_a.word + t_n.word, t_n.tag)
|
| 623 |
+
new_tok.translated = combined
|
| 624 |
+
merged[i:i+2] = [new_tok]
|
| 625 |
+
changed = True
|
| 626 |
+
break
|
| 627 |
+
i += 1
|
| 628 |
+
|
| 629 |
+
# Pass 2: "的" reordering (with multi-token noun/verb phrase lookahead)
|
| 630 |
+
NOUN_PHRASE_TAGS = {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'a', 'b', 'm', 'q', 'j', 'i'}
|
| 631 |
+
LOOKAHEAD_TAGS = NOUN_PHRASE_TAGS | {'v', 'vd', 'vg', 'vi', 'vn'}
|
| 632 |
+
i = 1
|
| 633 |
+
while i < len(merged) - 1:
|
| 634 |
+
tok = merged[i]
|
| 635 |
+
if tok.word in {'de', '的'}:
|
| 636 |
+
t_x = merged[i-1]
|
| 637 |
+
# Scan forward to collect all consecutive noun or verb phrase tokens
|
| 638 |
+
k = i + 1
|
| 639 |
+
has_noun = False
|
| 640 |
+
while k < len(merged):
|
| 641 |
+
tok_k = merged[k]
|
| 642 |
+
# Stop collecting if we hit a locality word/orientation noun
|
| 643 |
+
if tok_k.word in {'下', '上', '中', '里', '外', '内', '內', '后', '後', '前', '旁', '侧', '側', '底', '间', '間'}:
|
| 644 |
+
break
|
| 645 |
+
|
| 646 |
+
# If we already encountered a noun/verb in the phrase,
|
| 647 |
+
# we cannot have a subsequent adjective modifying that noun from the right.
|
| 648 |
+
if has_noun and tok_k.tag in {'a', 'b'}:
|
| 649 |
+
break
|
| 650 |
+
|
| 651 |
+
# Do not collect a verb tag if we already have a noun/verb head
|
| 652 |
+
is_verb_tag = tok_k.tag in {'v', 'vd', 'vg', 'vi', 'vn'}
|
| 653 |
+
if has_noun and is_verb_tag:
|
| 654 |
+
break
|
| 655 |
+
|
| 656 |
+
if tok_k.tag in LOOKAHEAD_TAGS or tok_k.word == '色':
|
| 657 |
+
if tok_k.tag in {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'v', 'vd', 'vg', 'vi', 'vn'}:
|
| 658 |
+
has_noun = True
|
| 659 |
+
k += 1
|
| 660 |
+
else:
|
| 661 |
+
break
|
| 662 |
+
|
| 663 |
+
is_verb_modifier = t_x.tag in {'v', 'vd', 'vg', 'vi', 'vn'}
|
| 664 |
+
|
| 665 |
+
# If we collected at least one token AND the modifier is not a verb clause
|
| 666 |
+
if k > i + 1 and not is_verb_modifier:
|
| 667 |
+
y_tokens = merged[i+1:k]
|
| 668 |
+
y_translated = " ".join([t.translated for t in y_tokens if t.translated])
|
| 669 |
+
y_word = "".join([t.word for t in y_tokens])
|
| 670 |
+
|
| 671 |
+
if t_x.tag != 'x':
|
| 672 |
+
start_idx = i - 1
|
| 673 |
+
j_back = i - 2
|
| 674 |
+
while j_back >= 0:
|
| 675 |
+
tag_back = merged[j_back].tag
|
| 676 |
+
if tag_back in {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'a', 'b', 'm', 'q', 'j', 'i', 's', 't'}:
|
| 677 |
+
start_idx = j_back
|
| 678 |
+
j_back -= 1
|
| 679 |
+
else:
|
| 680 |
+
break
|
| 681 |
+
|
| 682 |
+
modifier_tokens = merged[start_idx:i]
|
| 683 |
+
modifier_translated = " ".join([t.translated for t in modifier_tokens if t.translated])
|
| 684 |
+
modifier_word = "".join([t.word for t in modifier_tokens])
|
| 685 |
+
|
| 686 |
+
is_proper_or_pronoun = (
|
| 687 |
+
t_x.tag in {'nr', 'r'}
|
| 688 |
+
)
|
| 689 |
+
is_noun_modifier = is_proper_or_pronoun and not t_x.word.endswith('色')
|
| 690 |
+
if is_noun_modifier and start_idx == i - 1:
|
| 691 |
+
combined = y_translated + " của " + modifier_translated
|
| 692 |
+
else:
|
| 693 |
+
combined = y_translated + " " + modifier_translated
|
| 694 |
+
|
| 695 |
+
new_tok = SimpleToken(modifier_word + tok.word + y_word, 'n')
|
| 696 |
+
new_tok.translated = combined
|
| 697 |
+
merged[start_idx:k] = [new_tok]
|
| 698 |
+
continue
|
| 699 |
+
else:
|
| 700 |
+
# If we didn't reorder, set the '的' translation to empty string to avoid translating as 'đấy' / 'đích'
|
| 701 |
+
tok.translated = ""
|
| 702 |
+
i += 1
|
| 703 |
+
|
| 704 |
+
# Join words
|
| 705 |
+
translated_text = " ".join([t.translated for t in merged if t.translated])
|
| 706 |
+
|
| 707 |
+
# Clean spacing and punctuation
|
| 708 |
+
translated_text = self.clean_punctuation_spacing(translated_text)
|
| 709 |
+
|
| 710 |
+
# Capitalize sentences
|
| 711 |
+
sentences = re.split(r'([.!?]\s*)', translated_text)
|
| 712 |
+
start_idx = 0 if capitalize_first else 1
|
| 713 |
+
for idx in range(start_idx, len(sentences)):
|
| 714 |
+
s = sentences[idx]
|
| 715 |
+
if s and not s.isspace() and not s[0] in {'.', '!', '?'}:
|
| 716 |
+
for c_idx, char in enumerate(s):
|
| 717 |
+
if char.isalpha():
|
| 718 |
+
sentences[idx] = s[:c_idx] + char.upper() + s[c_idx+1:]
|
| 719 |
+
break
|
| 720 |
+
return "".join(sentences).strip()
|
| 721 |
+
|
| 722 |
+
def translate_paragraph(self, paragraph, multi_option=False, mode=None):
|
| 723 |
+
if not paragraph or paragraph.isspace():
|
| 724 |
+
return paragraph
|
| 725 |
+
|
| 726 |
+
active_mode = mode or self.translation_mode
|
| 727 |
+
|
| 728 |
+
if active_mode in ('vietphrase', 'hanviet'):
|
| 729 |
+
# Ultra-fast Trie-based translation path (50M+ characters/minute)
|
| 730 |
+
trie = self.vietphrase_trie if active_mode == 'vietphrase' else self.hanviet_trie
|
| 731 |
+
prefer_hanviet = (active_mode == 'hanviet')
|
| 732 |
+
|
| 733 |
+
i = 0
|
| 734 |
+
text_length = len(paragraph)
|
| 735 |
+
result_words = []
|
| 736 |
+
|
| 737 |
+
while i < text_length:
|
| 738 |
+
length, translation, priority = trie.search_longest_match(paragraph, i)
|
| 739 |
+
if length > 0:
|
| 740 |
+
word = paragraph[i:i+length]
|
| 741 |
+
formatted = self.format_translation(translation, multi_option, word, prefer_hanviet=prefer_hanviet)
|
| 742 |
+
# Capitalize if it is a proper name
|
| 743 |
+
if priority == 1 or word in self.proper_names:
|
| 744 |
+
formatted = self.capitalize_phrase(formatted)
|
| 745 |
+
result_words.append(formatted)
|
| 746 |
+
i += length
|
| 747 |
+
else:
|
| 748 |
+
char = paragraph[i]
|
| 749 |
+
if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', char):
|
| 750 |
+
punct_map = {
|
| 751 |
+
',': ',', '。': '.', '「': '"', '」': '"', '、': ',', '?': '?', '!': '!',
|
| 752 |
+
':': ':', ';': ';', '“': '"', '”': '"', '(': '(', ')': ')',
|
| 753 |
+
'『': '"', '』': '"', '【': '[', '】': ']'
|
| 754 |
+
}
|
| 755 |
+
result_words.append(punct_map.get(char, char))
|
| 756 |
+
else:
|
| 757 |
+
val = self.char_dict.get(char, char).split("/")[0]
|
| 758 |
+
result_words.append(val)
|
| 759 |
+
i += 1
|
| 760 |
+
|
| 761 |
+
translated_text = " ".join(result_words)
|
| 762 |
+
translated_text = self.clean_punctuation_spacing(translated_text)
|
| 763 |
+
|
| 764 |
+
# Sentence Capitalization
|
| 765 |
+
sentences = re.split(r'([.!?]\s*)', translated_text)
|
| 766 |
+
for idx in range(len(sentences)):
|
| 767 |
+
s = sentences[idx]
|
| 768 |
+
if s and not s.isspace() and not s[0] in {'.', '!', '?'}:
|
| 769 |
+
for c_idx, char in enumerate(s):
|
| 770 |
+
if char.isalpha():
|
| 771 |
+
sentences[idx] = s[:c_idx] + char.upper() + s[c_idx+1:]
|
| 772 |
+
break
|
| 773 |
+
return "".join(sentences).strip()
|
| 774 |
+
|
| 775 |
+
# For advanced & fast modes, use normal sentence splitting & tokenization
|
| 776 |
+
sentence_ends = re.compile(r'([。!?!?]+)')
|
| 777 |
+
parts = sentence_ends.split(paragraph)
|
| 778 |
+
|
| 779 |
+
translated_parts = []
|
| 780 |
+
for part in parts:
|
| 781 |
+
if not part:
|
| 782 |
+
continue
|
| 783 |
+
if sentence_ends.match(part):
|
| 784 |
+
punct_map = {
|
| 785 |
+
'。': '.', '!': '!', '?': '?', ',': ','
|
| 786 |
+
}
|
| 787 |
+
translated_parts.append(punct_map.get(part, part))
|
| 788 |
+
else:
|
| 789 |
+
translated_parts.append(self.translate_sentence(part, multi_option, mode=mode))
|
| 790 |
+
|
| 791 |
+
return self.clean_punctuation_spacing("".join(translated_parts))
|
| 792 |
+
|
| 793 |
+
def translate_text_node(self, text, multi_option=False, mode=None):
|
| 794 |
+
"""
|
| 795 |
+
Dich mot text node tu DOM.
|
| 796 |
+
BAO TOAN HOAN TOAN cau truc: xuong dong \n, khoang trang dau/cuoi tung dong.
|
| 797 |
+
"""
|
| 798 |
+
if not text:
|
| 799 |
+
return text
|
| 800 |
+
|
| 801 |
+
# Tach theo \n truoc -> dich tung dong doc lap -> gop lai
|
| 802 |
+
lines = text.split('\n')
|
| 803 |
+
translated_lines = []
|
| 804 |
+
for line in lines:
|
| 805 |
+
leading = re.match(r'^\s*', line).group(0)
|
| 806 |
+
trailing = re.search(r'\s*$', line).group(0)
|
| 807 |
+
body = line.strip()
|
| 808 |
+
|
| 809 |
+
if not body:
|
| 810 |
+
translated_lines.append(line) # dong rong -> giu nguyen
|
| 811 |
+
elif not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', body):
|
| 812 |
+
translated_lines.append(line) # khong co chu Han -> giu nguyen
|
| 813 |
+
else:
|
| 814 |
+
translated_body = self.translate_paragraph(body, multi_option, mode=mode)
|
| 815 |
+
translated_lines.append(leading + translated_body + trailing)
|
| 816 |
+
|
| 817 |
+
return '\n'.join(translated_lines)
|
| 818 |
+
|
| 819 |
+
def translate(self, text, multi_option=False, mode=None):
|
| 820 |
+
return self.translate_text_node(text, multi_option=multi_option, mode=mode)
|
backend/engine/translate_api_server.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
import asyncio
|
| 5 |
+
from fastapi import FastAPI, HTTPException, Body
|
| 6 |
+
from fastapi.responses import StreamingResponse
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
|
| 10 |
+
# Add parent directory to path to import engine
|
| 11 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 12 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(current_dir)))
|
| 13 |
+
|
| 14 |
+
from backend.engine.engine import VietphraseEngine
|
| 15 |
+
from backend.config import Config
|
| 16 |
+
|
| 17 |
+
app = FastAPI(
|
| 18 |
+
title="Vietphrase Translation Standalone API Server",
|
| 19 |
+
description="Standalone API for fast Chinese to Vietnamese translation with 5 modes"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# Initialize engine
|
| 23 |
+
print("Lazy-loading Vietphrase Engine on API Startup...")
|
| 24 |
+
engine = VietphraseEngine()
|
| 25 |
+
print("Vietphrase Engine successfully loaded!")
|
| 26 |
+
|
| 27 |
+
class TranslateRequest(BaseModel):
|
| 28 |
+
texts: List[str]
|
| 29 |
+
mode: Optional[str] = "advanced"
|
| 30 |
+
|
| 31 |
+
@app.get("/health")
|
| 32 |
+
def health():
|
| 33 |
+
return {
|
| 34 |
+
"status": "ok",
|
| 35 |
+
"modes": ["advanced", "fast", "vietphrase", "hanviet", "advanced_hanviet"],
|
| 36 |
+
"dictionary_sizes": {
|
| 37 |
+
"char_dict": len(engine.char_dict),
|
| 38 |
+
"proper_names": len(engine.proper_names),
|
| 39 |
+
"vietphrase": len(engine.vietphrase)
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
@app.post("/v1/translate")
|
| 44 |
+
def translate(req: TranslateRequest):
|
| 45 |
+
if not req.texts:
|
| 46 |
+
raise HTTPException(status_code=400, detail="Missing 'texts' list")
|
| 47 |
+
|
| 48 |
+
translations = []
|
| 49 |
+
for text in req.texts:
|
| 50 |
+
if not text.strip():
|
| 51 |
+
translations.append(text)
|
| 52 |
+
else:
|
| 53 |
+
try:
|
| 54 |
+
translations.append(engine.translate(text, mode=req.mode))
|
| 55 |
+
except Exception as e:
|
| 56 |
+
translations.append(text)
|
| 57 |
+
print(f"Error translating text: {e}")
|
| 58 |
+
|
| 59 |
+
return {"translations": translations}
|
| 60 |
+
|
| 61 |
+
@app.post("/v1/translate_stream")
|
| 62 |
+
async def translate_stream(req: TranslateRequest):
|
| 63 |
+
if not req.texts:
|
| 64 |
+
raise HTTPException(status_code=400, detail="Missing 'texts' list")
|
| 65 |
+
|
| 66 |
+
async def event_generator():
|
| 67 |
+
for i, text in enumerate(req.texts):
|
| 68 |
+
if not text.strip():
|
| 69 |
+
trans = text
|
| 70 |
+
else:
|
| 71 |
+
try:
|
| 72 |
+
trans = engine.translate(text, mode=req.mode)
|
| 73 |
+
except Exception as e:
|
| 74 |
+
trans = text
|
| 75 |
+
print(f"Error streaming translation: {e}")
|
| 76 |
+
|
| 77 |
+
yield f"data: {json.dumps({'index': i, 'text': trans}, ensure_ascii=False)}\n\n"
|
| 78 |
+
await asyncio.sleep(0.001)
|
| 79 |
+
|
| 80 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
import uvicorn
|
| 84 |
+
port = int(os.environ.get("PORT", 8050))
|
| 85 |
+
uvicorn.run("translate_api_server:app", host="0.0.0.0", port=port, reload=False)
|
backend/engine/trie.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class TrieNode:
|
| 2 |
+
def __init__(self):
|
| 3 |
+
self.children = {}
|
| 4 |
+
self.translation = None
|
| 5 |
+
self.priority = 0 # Priority level: 0=none, 1=Vietphrase, 2=Names
|
| 6 |
+
|
| 7 |
+
class Trie:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
self.root = TrieNode()
|
| 10 |
+
|
| 11 |
+
def insert(self, phrase_zh, translation_vi, priority):
|
| 12 |
+
"""
|
| 13 |
+
Inserts a Chinese phrase and its translation into the Trie.
|
| 14 |
+
Overwrites existing translations if the new translation has higher or equal priority.
|
| 15 |
+
"""
|
| 16 |
+
if not phrase_zh:
|
| 17 |
+
return
|
| 18 |
+
|
| 19 |
+
node = self.root
|
| 20 |
+
for char in phrase_zh:
|
| 21 |
+
if char not in node.children:
|
| 22 |
+
node.children[char] = TrieNode()
|
| 23 |
+
node = node.children[char]
|
| 24 |
+
|
| 25 |
+
# Prioritize names (2) over general phrases (1)
|
| 26 |
+
if node.translation is None or priority >= node.priority:
|
| 27 |
+
node.translation = translation_vi
|
| 28 |
+
node.priority = priority
|
| 29 |
+
|
| 30 |
+
def search_longest_match(self, text, start_index):
|
| 31 |
+
"""
|
| 32 |
+
Finds the longest matching Chinese phrase starting from start_index.
|
| 33 |
+
Returns a tuple of (matched_length, translation, priority).
|
| 34 |
+
If no match is found, returns (0, None, 0).
|
| 35 |
+
"""
|
| 36 |
+
node = self.root
|
| 37 |
+
longest_length = 0
|
| 38 |
+
longest_translation = None
|
| 39 |
+
longest_priority = 0
|
| 40 |
+
|
| 41 |
+
current_index = start_index
|
| 42 |
+
text_length = len(text)
|
| 43 |
+
|
| 44 |
+
while current_index < text_length:
|
| 45 |
+
char = text[current_index]
|
| 46 |
+
if char in node.children:
|
| 47 |
+
node = node.children[char]
|
| 48 |
+
current_index += 1
|
| 49 |
+
if node.translation is not None:
|
| 50 |
+
longest_length = current_index - start_index
|
| 51 |
+
longest_translation = node.translation
|
| 52 |
+
longest_priority = node.priority
|
| 53 |
+
else:
|
| 54 |
+
break
|
| 55 |
+
|
| 56 |
+
return longest_length, longest_translation, longest_priority
|
backend/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# backend/services/__init__.py
|
backend/services/alert_service.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import logging
|
| 4 |
+
from backend.config import Config
|
| 5 |
+
from backend.services.email_service import send_email_async
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("backend.alert")
|
| 8 |
+
|
| 9 |
+
# Rate limit: 30 minutes in seconds
|
| 10 |
+
THROTTLE_INTERVAL = 30 * 60
|
| 11 |
+
|
| 12 |
+
# In-memory store for tracking the last time an alert of a specific type was sent
|
| 13 |
+
# format: { alert_type: timestamp }
|
| 14 |
+
_last_sent_alerts = {}
|
| 15 |
+
|
| 16 |
+
def send_alert_to_admin(alert_type: str, subject: str, message_html: str):
|
| 17 |
+
"""
|
| 18 |
+
Sends an email alert to the system administrator.
|
| 19 |
+
|
| 20 |
+
Includes throttling to avoid spamming the admin email (maximum 1 alert per 30 minutes per type).
|
| 21 |
+
"""
|
| 22 |
+
admin_email = os.environ.get("ADMIN_EMAIL") or Config.SMTP_CONFIG.get("username")
|
| 23 |
+
if not admin_email:
|
| 24 |
+
logger.warning(f"⚠️ [Alerting] No ADMIN_EMAIL or SMTP username configured. Skipping alert: {subject}")
|
| 25 |
+
return False
|
| 26 |
+
|
| 27 |
+
current_time = time.time()
|
| 28 |
+
last_sent = _last_sent_alerts.get(alert_type, 0)
|
| 29 |
+
|
| 30 |
+
if current_time - last_sent < THROTTLE_INTERVAL:
|
| 31 |
+
logger.info(f"🔇 [Alerting] Alert '{alert_type}' throttled. Last sent at: {last_sent}. Skipping...")
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
# Update last sent timestamp
|
| 35 |
+
_last_sent_alerts[alert_type] = current_time
|
| 36 |
+
|
| 37 |
+
# Send email asynchronously
|
| 38 |
+
logger.info(f"🚨 [Alerting] Sending alert email to Admin ({admin_email}): {subject}")
|
| 39 |
+
|
| 40 |
+
# Append styling to alert email
|
| 41 |
+
styled_html = f"""
|
| 42 |
+
<div style="font-family: sans-serif; max-width: 600px; margin: auto; padding: 20px; border: 1px solid #ffcccc; border-radius: 8px; background-color: #fffafb;">
|
| 43 |
+
<h2 style="color: #d32f2f; border-bottom: 2px solid #d32f2f; padding-bottom: 10px; margin-top: 0;">Novel Translator Alert</h2>
|
| 44 |
+
<p><strong>Loại cảnh báo:</strong> <span style="background-color: #ffebee; color: #c62828; padding: 2px 6px; border-radius: 4px; font-family: monospace;">{alert_type}</span></p>
|
| 45 |
+
<div style="background-color: #fff; padding: 15px; border-left: 4px solid #d32f2f; margin: 15px 0; font-family: monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow-x: auto;">
|
| 46 |
+
{message_html}
|
| 47 |
+
</div>
|
| 48 |
+
<p style="font-size: 11px; color: #777; margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px;">
|
| 49 |
+
Đây là email tự động gửi từ hệ thống giám sát trên VM. Cảnh báo này sẽ bị tạm khóa (throttled) trong 30 phút tiếp theo.
|
| 50 |
+
</p>
|
| 51 |
+
</div>
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
send_email_async(admin_email, f"[SYSTEM ALERT] {subject}", styled_html)
|
| 55 |
+
return True
|
| 56 |
+
|
| 57 |
+
def check_resources_and_alert():
|
| 58 |
+
"""
|
| 59 |
+
Checks RAM and Disk metrics, and triggers alerts if they exceed thresholds (90%).
|
| 60 |
+
This should be called periodically or during health queries.
|
| 61 |
+
"""
|
| 62 |
+
from backend.core.monitoring import get_memory_info, get_disk_info
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
mem = get_memory_info()
|
| 66 |
+
if mem["percent"] >= 90.0:
|
| 67 |
+
subject = f"Cảnh báo: Bộ nhớ RAM cực kỳ thấp ({mem['percent']}%)"
|
| 68 |
+
body = f"Hệ thống đang chạy trên VM có lượng RAM khả dụng cực kỳ thấp.<br>" \
|
| 69 |
+
f"Tổng RAM: {mem['total_bytes'] / (1024*1024*1024):.2f} GB<br>" \
|
| 70 |
+
f"Đã dùng: {mem['used_bytes'] / (1024*1024*1024):.2f} GB ({mem['percent']}%)<br>" \
|
| 71 |
+
f"Tiến trình python RSS: {mem['process_rss_bytes'] / (1024*1024):.2f} MB"
|
| 72 |
+
send_alert_to_admin("low_memory", subject, body)
|
| 73 |
+
|
| 74 |
+
disk = get_disk_info()
|
| 75 |
+
if disk["percent"] >= 90.0:
|
| 76 |
+
subject = f"Cảnh báo: Dung lượng ổ đĩa sắp đầy ({disk['percent']}%)"
|
| 77 |
+
body = f"Ổ đĩa trên VM sắp hết bộ nhớ.<br>" \
|
| 78 |
+
f"Tổng dung lượng: {disk['total_bytes'] / (1024*1024*1024):.2f} GB<br>" \
|
| 79 |
+
f"Đã dùng: {disk['used_bytes'] / (1024*1024*1024):.2f} GB ({disk['percent']}%)<br>" \
|
| 80 |
+
f"Dung lượng trống: {disk['free_bytes'] / (1024*1024*1024):.2f} GB"
|
| 81 |
+
send_alert_to_admin("low_disk", subject, body)
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.error(f"Error checking system resources: {e}")
|
backend/services/auth_service.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from backend.config import Config
|
| 4 |
+
from backend.database.db_manager import get_user_db_conn
|
| 5 |
+
|
| 6 |
+
def check_vip_expiry(user_id: int, conn=None) -> bool:
|
| 7 |
+
"""Check if a user's VIP has expired and deactivate if so."""
|
| 8 |
+
should_close = False
|
| 9 |
+
if conn is None:
|
| 10 |
+
conn = get_user_db_conn()
|
| 11 |
+
should_close = True
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
conn.row_factory = sqlite3.Row
|
| 15 |
+
except Exception:
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
user = conn.execute("SELECT vip_status, vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
|
| 19 |
+
if user and user["vip_status"] == 1 and user["vip_expiry"]:
|
| 20 |
+
try:
|
| 21 |
+
expiry_val = user["vip_expiry"]
|
| 22 |
+
if isinstance(expiry_val, str):
|
| 23 |
+
expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
|
| 24 |
+
else:
|
| 25 |
+
expiry = expiry_val
|
| 26 |
+
|
| 27 |
+
if datetime.utcnow() > expiry:
|
| 28 |
+
conn.execute("UPDATE users SET vip_status = 0, vip_plan = NULL WHERE id = ?", (user_id,))
|
| 29 |
+
if hasattr(conn, "commit"):
|
| 30 |
+
conn.commit()
|
| 31 |
+
if should_close:
|
| 32 |
+
conn.close()
|
| 33 |
+
return False # VIP expired
|
| 34 |
+
except Exception:
|
| 35 |
+
pass
|
| 36 |
+
if should_close:
|
| 37 |
+
conn.close()
|
| 38 |
+
return user["vip_status"] == 1 if user else False
|
| 39 |
+
|
| 40 |
+
def activate_vip(user_id: int, plan: str) -> bool:
|
| 41 |
+
"""Activate VIP for a user based on the plan purchased."""
|
| 42 |
+
plan_info = Config.VIP_PLANS.get(plan)
|
| 43 |
+
if not plan_info:
|
| 44 |
+
# Check for lifetime manual plans
|
| 45 |
+
if "lifetime" in plan.lower():
|
| 46 |
+
duration_days = 99999
|
| 47 |
+
else:
|
| 48 |
+
return False
|
| 49 |
+
else:
|
| 50 |
+
duration_days = plan_info["duration_days"]
|
| 51 |
+
|
| 52 |
+
conn = get_user_db_conn()
|
| 53 |
+
conn.row_factory = sqlite3.Row
|
| 54 |
+
user = conn.execute("SELECT vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
|
| 55 |
+
|
| 56 |
+
now = datetime.utcnow()
|
| 57 |
+
if user and user["vip_expiry"]:
|
| 58 |
+
try:
|
| 59 |
+
expiry_val = user["vip_expiry"]
|
| 60 |
+
if isinstance(expiry_val, str):
|
| 61 |
+
current_expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
|
| 62 |
+
else:
|
| 63 |
+
current_expiry = expiry_val
|
| 64 |
+
|
| 65 |
+
if current_expiry > now:
|
| 66 |
+
new_expiry = current_expiry + timedelta(days=duration_days)
|
| 67 |
+
else:
|
| 68 |
+
new_expiry = now + timedelta(days=duration_days)
|
| 69 |
+
except Exception:
|
| 70 |
+
new_expiry = now + timedelta(days=duration_days)
|
| 71 |
+
else:
|
| 72 |
+
new_expiry = now + timedelta(days=duration_days)
|
| 73 |
+
|
| 74 |
+
conn.execute(
|
| 75 |
+
"UPDATE users SET vip_status = 1, vip_plan = ?, vip_expiry = ? WHERE id = ?",
|
| 76 |
+
(plan, new_expiry.strftime("%Y-%m-%d %H:%M:%S"), user_id)
|
| 77 |
+
)
|
| 78 |
+
conn.commit()
|
| 79 |
+
conn.close()
|
| 80 |
+
return True
|
backend/services/book_service.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import math
|
| 3 |
+
import unicodedata
|
| 4 |
+
from flask import jsonify
|
| 5 |
+
from backend.database.db_manager import get_db, query_cache, count_cache
|
| 6 |
+
|
| 7 |
+
CHINESE_CATEGORY_MAP = {
|
| 8 |
+
"言情穿越": "Ngôn tình Xuyên không",
|
| 9 |
+
"都市小说": "Đô thị",
|
| 10 |
+
"历史军事": "Lịch sử Quân sự",
|
| 11 |
+
"游戏竞技": "Võng du Cạnh tranh",
|
| 12 |
+
"游戏异界": "Dị giới Trò chơi",
|
| 13 |
+
"玄幻奇幻": "Huyền huyễn Kỳ huyễn",
|
| 14 |
+
"武侠仙侠": "Võ hiệp Tiên hiệp",
|
| 15 |
+
"科幻空间": "Khoa huyễn Không gian",
|
| 16 |
+
"悬疑灵异": "Huyền bí Linh dị",
|
| 17 |
+
"耽美纯爱": "Đam mỹ",
|
| 18 |
+
"同人小说": "Đồng nhân",
|
| 19 |
+
"都市": "Đô thị",
|
| 20 |
+
"言情": "Ngôn tình",
|
| 21 |
+
"穿越": "Xuyên không",
|
| 22 |
+
"历史": "Lịch sử",
|
| 23 |
+
"军事": "Quân sự",
|
| 24 |
+
"男生": "Nam sinh",
|
| 25 |
+
"女生": "Nữ sinh",
|
| 26 |
+
"游戏": "Trò chơi",
|
| 27 |
+
"竞技": "Cạnh tranh",
|
| 28 |
+
"玄幻": "Huyền huyễn",
|
| 29 |
+
"奇幻": "Kỳ huyễn",
|
| 30 |
+
"武侠": "Võ hiệp",
|
| 31 |
+
"仙侠": "Tiên hiệp",
|
| 32 |
+
"科幻": "Khoa huyễn",
|
| 33 |
+
"悬疑": "Huyền bí",
|
| 34 |
+
"灵异": "Linh dị",
|
| 35 |
+
"耽美": "Đam mỹ",
|
| 36 |
+
"轻小说": "Light Novel",
|
| 37 |
+
"同人": "Đồng nhân"
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
def translate_categories(categories_str):
|
| 41 |
+
if not categories_str:
|
| 42 |
+
return ""
|
| 43 |
+
parts = re.split(r'[,,/、\s]+', categories_str)
|
| 44 |
+
vi_parts = []
|
| 45 |
+
for p in parts:
|
| 46 |
+
p = p.strip()
|
| 47 |
+
if not p:
|
| 48 |
+
continue
|
| 49 |
+
matched = False
|
| 50 |
+
for zh, vi in CHINESE_CATEGORY_MAP.items():
|
| 51 |
+
if zh in p:
|
| 52 |
+
vi_parts.append(vi)
|
| 53 |
+
matched = True
|
| 54 |
+
break
|
| 55 |
+
if not matched:
|
| 56 |
+
try:
|
| 57 |
+
from backend.services.translation import get_engine
|
| 58 |
+
translated = get_engine().translate(p, multi_option=False)
|
| 59 |
+
vi_parts.append(translated)
|
| 60 |
+
except Exception:
|
| 61 |
+
vi_parts.append(p)
|
| 62 |
+
seen = set()
|
| 63 |
+
unique_parts = []
|
| 64 |
+
for vp in vi_parts:
|
| 65 |
+
if vp not in seen:
|
| 66 |
+
seen.add(vp)
|
| 67 |
+
unique_parts.append(vp)
|
| 68 |
+
return ", ".join(unique_parts)
|
| 69 |
+
|
| 70 |
+
CHINESE_TO_ENGLISH_CATEGORY_MAP = {
|
| 71 |
+
"言情穿越": "Romance & Time Travel",
|
| 72 |
+
"都市小说": "Urban",
|
| 73 |
+
"历史军事": "History & Military",
|
| 74 |
+
"游戏竞技": "Gaming & Competition",
|
| 75 |
+
"游戏异界": "Gaming & Otherworld",
|
| 76 |
+
"玄幻奇幻": "Xianxia & Fantasy",
|
| 77 |
+
"武侠仙侠": "Wuxia & Xianxia",
|
| 78 |
+
"科幻 space": "Sci-Fi Space",
|
| 79 |
+
"科幻空间": "Sci-Fi & Space",
|
| 80 |
+
"悬疑灵异": "Mystery & Supernatural",
|
| 81 |
+
"耽美纯爱": "Danmei Romance",
|
| 82 |
+
"同人小说": "Fan-fiction",
|
| 83 |
+
"都市": "Urban",
|
| 84 |
+
"言情": "Romance",
|
| 85 |
+
"穿越": "Time Travel",
|
| 86 |
+
"历史": "History",
|
| 87 |
+
"军事": "Military",
|
| 88 |
+
"男生": "Male Protagonist",
|
| 89 |
+
"女生": "Female Protagonist",
|
| 90 |
+
"游戏": "Gaming",
|
| 91 |
+
"竞技": "Competition",
|
| 92 |
+
"玄幻": "Fantasy",
|
| 93 |
+
"奇幻": "Eastern Fantasy",
|
| 94 |
+
"武侠": "Wuxia",
|
| 95 |
+
"仙侠": "Xianxia",
|
| 96 |
+
"科幻": "Sci-Fi",
|
| 97 |
+
"悬疑": "Mystery",
|
| 98 |
+
"灵异": "Supernatural",
|
| 99 |
+
"耽美": "Danmei",
|
| 100 |
+
"轻小说": "Light Novel",
|
| 101 |
+
"同人": "Fan-fiction"
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
def translate_categories_to_english(categories_str):
|
| 105 |
+
if not categories_str:
|
| 106 |
+
return ""
|
| 107 |
+
parts = re.split(r'[,,/、\s]+', categories_str)
|
| 108 |
+
en_parts = []
|
| 109 |
+
for p in parts:
|
| 110 |
+
p = p.strip()
|
| 111 |
+
if not p:
|
| 112 |
+
continue
|
| 113 |
+
matched = False
|
| 114 |
+
for zh, en in CHINESE_TO_ENGLISH_CATEGORY_MAP.items():
|
| 115 |
+
if zh in p:
|
| 116 |
+
en_parts.append(en)
|
| 117 |
+
matched = True
|
| 118 |
+
break
|
| 119 |
+
if not matched:
|
| 120 |
+
en_parts.append(p)
|
| 121 |
+
seen = set()
|
| 122 |
+
unique_parts = []
|
| 123 |
+
for ep in en_parts:
|
| 124 |
+
if ep not in seen:
|
| 125 |
+
seen.add(ep)
|
| 126 |
+
unique_parts.append(ep)
|
| 127 |
+
return ", ".join(unique_parts)
|
| 128 |
+
|
| 129 |
+
def remove_vietnamese_accents(text):
|
| 130 |
+
if not text:
|
| 131 |
+
return ""
|
| 132 |
+
# Normalize to decompose accents
|
| 133 |
+
nfkd_form = unicodedata.normalize('NFKD', text)
|
| 134 |
+
only_ascii = nfkd_form.encode('ASCII', 'ignore').decode('utf-8')
|
| 135 |
+
return only_ascii
|
| 136 |
+
|
| 137 |
+
def clean_vietnamese_query(text):
|
| 138 |
+
text = text.replace('đ', 'd').replace('Đ', 'D')
|
| 139 |
+
return "".join(c for c in unicodedata.normalize('NFKD', text) if not unicodedata.combining(c)).lower()
|
| 140 |
+
|
| 141 |
+
def parse_book_sources(book_dict):
|
| 142 |
+
parsed_sources = []
|
| 143 |
+
raw_urls = book_dict.get("urls", "")
|
| 144 |
+
if raw_urls:
|
| 145 |
+
parts = raw_urls.split(" | ")
|
| 146 |
+
for part in parts:
|
| 147 |
+
if ":" in part:
|
| 148 |
+
subparts = part.split(":", 1)
|
| 149 |
+
src_name = subparts[0].strip()
|
| 150 |
+
src_url = subparts[1].strip()
|
| 151 |
+
if src_url.startswith("//"):
|
| 152 |
+
src_url = "https:" + src_url
|
| 153 |
+
parsed_sources.append({
|
| 154 |
+
"source": src_name,
|
| 155 |
+
"url": src_url
|
| 156 |
+
})
|
| 157 |
+
book_dict["parsed_sources"] = parsed_sources
|
| 158 |
+
|
| 159 |
+
# Dịch thể loại sang tiếng Việt và tiếng Anh
|
| 160 |
+
book_dict["categories_vietphrase"] = translate_categories(book_dict.get("categories", ""))
|
| 161 |
+
book_dict["categories_english"] = translate_categories_to_english(book_dict.get("categories", ""))
|
| 162 |
+
|
| 163 |
+
# Sinh tên tác giả tiếng Anh (Hán Việt không dấu)
|
| 164 |
+
author_hv = book_dict.get("author_hanviet", "")
|
| 165 |
+
if author_hv:
|
| 166 |
+
book_dict["author_english"] = remove_vietnamese_accents(author_hv)
|
| 167 |
+
else:
|
| 168 |
+
book_dict["author_english"] = book_dict.get("author", "")
|
| 169 |
+
|
| 170 |
+
# Use pre-computed description from DB (do NOT translate on-the-fly for listings — too expensive)
|
| 171 |
+
desc = book_dict.get("description", "")
|
| 172 |
+
desc_vp = book_dict.get("description_vietphrase", "")
|
| 173 |
+
if not desc_vp and desc:
|
| 174 |
+
# Lightweight fallback: just truncate Chinese description, no translation engine call
|
| 175 |
+
desc_vp = desc[:120]
|
| 176 |
+
book_dict["description_vietphrase"] = desc_vp or ""
|
| 177 |
+
|
| 178 |
+
# Tạo fallback description_english bằng cách bỏ dấu bản Việt hóa
|
| 179 |
+
if desc_vp:
|
| 180 |
+
book_dict["description_english"] = remove_vietnamese_accents(desc_vp)
|
| 181 |
+
else:
|
| 182 |
+
book_dict["description_english"] = desc[:120] if desc else ""
|
| 183 |
+
|
| 184 |
+
return book_dict
|
| 185 |
+
|
| 186 |
+
def search_books(q, category, source, dup, sort, search_field, min_chapters, page, per_page, limit_k=100):
|
| 187 |
+
"""Business logic for searching books with high-performance FTS5 or LIKE queries."""
|
| 188 |
+
MAX_PAGES_CEILING = 100
|
| 189 |
+
if page > MAX_PAGES_CEILING:
|
| 190 |
+
return {"error": "Deep pagination limit reached. Giới hạn tối đa 100 trang kết quả."}, 400
|
| 191 |
+
|
| 192 |
+
# Whitelist sort options
|
| 193 |
+
allowed_sorts = {
|
| 194 |
+
"site_count DESC", "chapters_max DESC", "word_count_max DESC", "title ASC", "title DESC", "id ASC"
|
| 195 |
+
}
|
| 196 |
+
if sort not in allowed_sorts:
|
| 197 |
+
sort = "site_count DESC"
|
| 198 |
+
|
| 199 |
+
where, params = [], []
|
| 200 |
+
fts_match_expr = None
|
| 201 |
+
has_chinese = False
|
| 202 |
+
|
| 203 |
+
if q:
|
| 204 |
+
has_chinese = any('\u4e00' <= char <= '\u9fff' for char in q)
|
| 205 |
+
if has_chinese:
|
| 206 |
+
# Use FTS5 for Chinese queries — 10x faster than LIKE scan on 931k rows
|
| 207 |
+
fts_q = q.replace('"', '').replace("'", '')
|
| 208 |
+
if search_field == "title":
|
| 209 |
+
fts_match_expr = f'title:"{fts_q}"'
|
| 210 |
+
elif search_field == "author":
|
| 211 |
+
fts_match_expr = f'author:"{fts_q}"'
|
| 212 |
+
elif search_field == "description":
|
| 213 |
+
# Description is NOT in FTS index — fallback to LIKE but only on description
|
| 214 |
+
pq = "%" + q + "%"
|
| 215 |
+
where.append("description LIKE ?")
|
| 216 |
+
params.append(pq)
|
| 217 |
+
else:
|
| 218 |
+
# Default: search title + author via FTS5 (fast)
|
| 219 |
+
fts_match_expr = f'title:"{fts_q}" OR author:"{fts_q}"'
|
| 220 |
+
else:
|
| 221 |
+
q_clean = clean_vietnamese_query(q)
|
| 222 |
+
fts_query = q_clean.replace('"', '').replace("'", '')
|
| 223 |
+
if fts_query:
|
| 224 |
+
if search_field == "title":
|
| 225 |
+
fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR title_vietphrase_clean:"{fts_query}"'
|
| 226 |
+
elif search_field == "author":
|
| 227 |
+
fts_match_expr = f'author_hanviet_clean:"{fts_query}"'
|
| 228 |
+
elif search_field == "hanviet":
|
| 229 |
+
fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR author_hanviet_clean:"{fts_query}"'
|
| 230 |
+
elif search_field == "vietphrase":
|
| 231 |
+
fts_match_expr = f'title_vietphrase_clean:"{fts_query}"'
|
| 232 |
+
elif search_field == "chinese":
|
| 233 |
+
fts_match_expr = f'title:"{q}" OR author:"{q}"'
|
| 234 |
+
elif search_field == "description":
|
| 235 |
+
where.append("(description LIKE ? OR description_vietphrase LIKE ? OR description_hanviet LIKE ?)")
|
| 236 |
+
pq = "%" + q + "%"
|
| 237 |
+
params += [pq, pq, pq]
|
| 238 |
+
else:
|
| 239 |
+
fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR title_vietphrase_clean:"{fts_query}" OR author_hanviet_clean:"{fts_query}"'
|
| 240 |
+
|
| 241 |
+
if category:
|
| 242 |
+
where.append("categories LIKE ?")
|
| 243 |
+
params.append("%" + category + "%")
|
| 244 |
+
if source:
|
| 245 |
+
where.append("sources LIKE ?")
|
| 246 |
+
params.append("%" + source + "%")
|
| 247 |
+
if dup == "multi":
|
| 248 |
+
where.append("site_count >= 2")
|
| 249 |
+
elif dup == "single":
|
| 250 |
+
where.append("site_count = 1")
|
| 251 |
+
if min_chapters:
|
| 252 |
+
try:
|
| 253 |
+
min_ch_val = int(min_chapters)
|
| 254 |
+
where.append("chapters_max >= ?")
|
| 255 |
+
params.append(min_ch_val)
|
| 256 |
+
except ValueError:
|
| 257 |
+
pass
|
| 258 |
+
|
| 259 |
+
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
| 260 |
+
|
| 261 |
+
if fts_match_expr:
|
| 262 |
+
cache_key = ("fts_search_v2", fts_match_expr, where_sql, page, per_page, tuple(params), sort, q, limit_k)
|
| 263 |
+
cached_res = query_cache.get(cache_key)
|
| 264 |
+
if cached_res is not None:
|
| 265 |
+
return cached_res, 200
|
| 266 |
+
|
| 267 |
+
conn = get_db()
|
| 268 |
+
query = f"""
|
| 269 |
+
SELECT b.*, f.score
|
| 270 |
+
FROM books b
|
| 271 |
+
JOIN (
|
| 272 |
+
SELECT rowid, bm25(books_fts) AS score
|
| 273 |
+
FROM books_fts
|
| 274 |
+
WHERE books_fts MATCH ?
|
| 275 |
+
) f ON b.id = f.rowid
|
| 276 |
+
{where_sql}
|
| 277 |
+
LIMIT {limit_k}
|
| 278 |
+
"""
|
| 279 |
+
rows = conn.execute(query, [fts_match_expr] + params).fetchall()
|
| 280 |
+
|
| 281 |
+
if not rows:
|
| 282 |
+
res_data = {"total": 0, "page": 1, "pages": 1, "books": []}
|
| 283 |
+
query_cache.set(cache_key, res_data)
|
| 284 |
+
return res_data, 200
|
| 285 |
+
|
| 286 |
+
scores = [r['score'] for r in rows]
|
| 287 |
+
best_score = min(scores)
|
| 288 |
+
threshold = best_score * 0.28
|
| 289 |
+
|
| 290 |
+
has_accents = any(c != clean_vietnamese_query(c) for c in q)
|
| 291 |
+
|
| 292 |
+
def clean_text_for_tier(txt):
|
| 293 |
+
if not txt:
|
| 294 |
+
return ""
|
| 295 |
+
txt = txt.replace('đ', 'd').replace('Đ', 'D')
|
| 296 |
+
txt = "".join(c for c in unicodedata.normalize('NFKD', txt) if not unicodedata.combining(c)).lower()
|
| 297 |
+
return " ".join(re.sub(r'[^a-z0-9\s]', ' ', txt).split())
|
| 298 |
+
|
| 299 |
+
q_clean_tier = clean_text_for_tier(q)
|
| 300 |
+
q_norm = q.lower().strip()
|
| 301 |
+
|
| 302 |
+
def get_tier(book):
|
| 303 |
+
t_hv_clean = clean_text_for_tier(book.get('title_hanviet', ''))
|
| 304 |
+
t_vp_clean = clean_text_for_tier(book.get('title_vietphrase', ''))
|
| 305 |
+
if t_hv_clean == q_clean_tier or t_vp_clean == q_clean_tier:
|
| 306 |
+
return 1
|
| 307 |
+
if t_hv_clean.startswith(q_clean_tier) or t_vp_clean.startswith(q_clean_tier):
|
| 308 |
+
return 2
|
| 309 |
+
if q_clean_tier in t_hv_clean or q_clean_tier in t_vp_clean:
|
| 310 |
+
return 3
|
| 311 |
+
return 4
|
| 312 |
+
|
| 313 |
+
filtered_books = []
|
| 314 |
+
for r in rows:
|
| 315 |
+
book_dict = dict(r)
|
| 316 |
+
tier = get_tier(book_dict)
|
| 317 |
+
score = book_dict['score']
|
| 318 |
+
|
| 319 |
+
if has_accents:
|
| 320 |
+
t_hv = (book_dict.get('title_hanviet') or '').lower()
|
| 321 |
+
t_vp = (book_dict.get('title_vietphrase') or '').lower()
|
| 322 |
+
auth = (book_dict.get('author_hanviet') or '').lower()
|
| 323 |
+
desc = (book_dict.get('description') or '').lower()
|
| 324 |
+
if q_norm not in t_hv and q_norm not in t_vp and q_norm not in auth and q_norm not in desc:
|
| 325 |
+
continue
|
| 326 |
+
|
| 327 |
+
if tier in (3, 4) and score > threshold:
|
| 328 |
+
continue
|
| 329 |
+
|
| 330 |
+
book_dict['tier'] = tier
|
| 331 |
+
filtered_books.append(book_dict)
|
| 332 |
+
|
| 333 |
+
if sort == "title ASC":
|
| 334 |
+
filtered_books.sort(key=lambda x: (x.get('title_hanviet') or '').lower())
|
| 335 |
+
elif sort == "title DESC":
|
| 336 |
+
filtered_books.sort(key=lambda x: (x.get('title_hanviet') or '').lower(), reverse=True)
|
| 337 |
+
elif sort == "chapters_max DESC":
|
| 338 |
+
filtered_books.sort(key=lambda x: x.get('chapters_max') or 0, reverse=True)
|
| 339 |
+
elif sort == "word_count_max DESC":
|
| 340 |
+
filtered_books.sort(key=lambda x: x.get('word_count_max') or 0, reverse=True)
|
| 341 |
+
else:
|
| 342 |
+
filtered_books.sort(key=lambda x: (x['tier'], x['score'], -x['site_count']))
|
| 343 |
+
|
| 344 |
+
total = len(filtered_books)
|
| 345 |
+
pages = max(1, math.ceil(total / per_page))
|
| 346 |
+
offset = (page - 1) * per_page
|
| 347 |
+
books_page = [parse_book_sources(b) for b in filtered_books[offset : offset + per_page]]
|
| 348 |
+
|
| 349 |
+
res_data = {"total": total, "page": page, "pages": pages, "books": books_page}
|
| 350 |
+
query_cache.set(cache_key, res_data)
|
| 351 |
+
return res_data, 200
|
| 352 |
+
|
| 353 |
+
else:
|
| 354 |
+
order_by_sql = sort
|
| 355 |
+
order_params = []
|
| 356 |
+
if q and has_chinese:
|
| 357 |
+
order_by_sql = f"""
|
| 358 |
+
CASE
|
| 359 |
+
WHEN title = ? THEN 1
|
| 360 |
+
WHEN title LIKE ? THEN 2
|
| 361 |
+
ELSE 3
|
| 362 |
+
END ASC, {sort}
|
| 363 |
+
"""
|
| 364 |
+
order_params = [q, q + "%"]
|
| 365 |
+
|
| 366 |
+
cache_key = (where_sql, order_by_sql, page, per_page, tuple(params), tuple(order_params))
|
| 367 |
+
cached_res = query_cache.get(cache_key)
|
| 368 |
+
if cached_res is not None:
|
| 369 |
+
return cached_res, 200
|
| 370 |
+
|
| 371 |
+
count_cache_key = (where_sql, tuple(params))
|
| 372 |
+
total = count_cache.get(count_cache_key)
|
| 373 |
+
if total is None:
|
| 374 |
+
conn = get_db()
|
| 375 |
+
total = conn.execute(
|
| 376 |
+
"SELECT COUNT(*) FROM books " + where_sql, params
|
| 377 |
+
).fetchone()[0]
|
| 378 |
+
count_cache.set(count_cache_key, total)
|
| 379 |
+
|
| 380 |
+
pages = max(1, math.ceil(total / per_page))
|
| 381 |
+
if pages > MAX_PAGES_CEILING:
|
| 382 |
+
pages = MAX_PAGES_CEILING
|
| 383 |
+
total = min(total, MAX_PAGES_CEILING * per_page)
|
| 384 |
+
|
| 385 |
+
offset = (page - 1) * per_page
|
| 386 |
+
|
| 387 |
+
conn = get_db()
|
| 388 |
+
rows = conn.execute(
|
| 389 |
+
"SELECT * FROM books " + where_sql +
|
| 390 |
+
" ORDER BY " + order_by_sql +
|
| 391 |
+
" LIMIT ? OFFSET ?",
|
| 392 |
+
params + order_params + [per_page, offset]
|
| 393 |
+
).fetchall()
|
| 394 |
+
|
| 395 |
+
books = [parse_book_sources(dict(r)) for r in rows]
|
| 396 |
+
res_data = {"total": total, "page": page, "pages": pages, "books": books}
|
| 397 |
+
query_cache.set(cache_key, res_data)
|
| 398 |
+
return res_data, 200
|
backend/services/email_service.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
import smtplib
|
| 4 |
+
import threading
|
| 5 |
+
from email.mime.text import MIMEText
|
| 6 |
+
from email.mime.multipart import MIMEMultipart
|
| 7 |
+
from backend.config import Config
|
| 8 |
+
|
| 9 |
+
# Service Account delegation config
|
| 10 |
+
SERVICE_ACCOUNT_FILE = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "gmail-delegator-key.json")
|
| 11 |
+
DELEGATED_USER_EMAIL = os.environ.get("GMAIL_DELEGATED_EMAIL", "support@lyvuha.com")
|
| 12 |
+
|
| 13 |
+
SCOPES = [
|
| 14 |
+
"https://www.googleapis.com/auth/gmail.send",
|
| 15 |
+
"https://www.googleapis.com/auth/gmail.readonly",
|
| 16 |
+
"https://www.googleapis.com/auth/gmail.modify"
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
def get_gmail_service():
|
| 20 |
+
"""Builds and returns a Gmail API service client impersonating the DELEGATED_USER_EMAIL."""
|
| 21 |
+
import json
|
| 22 |
+
from google.oauth2 import service_account
|
| 23 |
+
from googleapiclient.discovery import build
|
| 24 |
+
|
| 25 |
+
service_account_json = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "")
|
| 26 |
+
creds = None
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
# Nếu cấu hình trực tiếp chuỗi JSON key trong biến môi trường
|
| 30 |
+
if service_account_json and service_account_json.strip().startswith("{"):
|
| 31 |
+
info = json.loads(service_account_json)
|
| 32 |
+
creds = service_account.Credentials.from_service_account_info(
|
| 33 |
+
info,
|
| 34 |
+
scopes=SCOPES
|
| 35 |
+
)
|
| 36 |
+
# Nếu cấu hình bằng đường dẫn file
|
| 37 |
+
elif os.path.exists(SERVICE_ACCOUNT_FILE):
|
| 38 |
+
creds = service_account.Credentials.from_service_account_file(
|
| 39 |
+
SERVICE_ACCOUNT_FILE,
|
| 40 |
+
scopes=SCOPES
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
if not creds:
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
delegated_creds = creds.with_subject(DELEGATED_USER_EMAIL)
|
| 47 |
+
service = build('gmail', 'v1', credentials=delegated_creds)
|
| 48 |
+
return service
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"❌ Error initializing Gmail API Service: {e}")
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def send_email_smtp(to_email, subject, html_content):
|
| 55 |
+
"""Sends an email using standard SMTP with App Password."""
|
| 56 |
+
smtp_cfg = Config.SMTP_CONFIG
|
| 57 |
+
if not smtp_cfg["username"] or not smtp_cfg["password"]:
|
| 58 |
+
print("⚠️ SMTP credentials not configured in Config.")
|
| 59 |
+
return False
|
| 60 |
+
try:
|
| 61 |
+
msg = MIMEMultipart("alternative")
|
| 62 |
+
msg["Subject"] = subject
|
| 63 |
+
msg["From"] = f"{smtp_cfg['from_name']} <{smtp_cfg['username']}>"
|
| 64 |
+
msg["To"] = to_email
|
| 65 |
+
msg.attach(MIMEText(html_content, "html", "utf-8"))
|
| 66 |
+
|
| 67 |
+
server = smtplib.SMTP(smtp_cfg["host"], smtp_cfg["port"])
|
| 68 |
+
server.starttls()
|
| 69 |
+
server.login(smtp_cfg["username"], smtp_cfg["password"])
|
| 70 |
+
server.sendmail(smtp_cfg["username"], to_email, msg.as_string())
|
| 71 |
+
server.quit()
|
| 72 |
+
print(f"📧 [SMTP] Successfully sent email to {to_email}")
|
| 73 |
+
return True
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"❌ [SMTP] Failed to send email: {e}")
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
def send_email_gmail_api(to_email, subject, html_content):
|
| 79 |
+
"""Sends an email using Gmail API via Service Account impersonation."""
|
| 80 |
+
service = get_gmail_service()
|
| 81 |
+
if not service:
|
| 82 |
+
return send_email_smtp(to_email, subject, html_content)
|
| 83 |
+
try:
|
| 84 |
+
message = MIMEText(html_content, 'html', 'utf-8')
|
| 85 |
+
message['to'] = to_email
|
| 86 |
+
message['from'] = DELEGATED_USER_EMAIL
|
| 87 |
+
message['subject'] = subject
|
| 88 |
+
|
| 89 |
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
|
| 90 |
+
body = {'raw': raw_message}
|
| 91 |
+
|
| 92 |
+
service.users().messages().send(userId='me', body=body).execute()
|
| 93 |
+
print(f"📧 [Gmail API] Successfully sent email to {to_email} from {DELEGATED_USER_EMAIL}")
|
| 94 |
+
return True
|
| 95 |
+
except Exception as e:
|
| 96 |
+
print(f"❌ [Gmail API] Failed to send email, falling back to SMTP: {e}")
|
| 97 |
+
return send_email_smtp(to_email, subject, html_content)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def send_email_gmail_oauth2(to_email, subject, html_content):
|
| 101 |
+
"""Sends an email using Gmail REST API via OAuth2 Client ID/Secret and Refresh Token (over HTTPS 443)."""
|
| 102 |
+
client_id = os.environ.get("GOOGLE_CLIENT_ID", "")
|
| 103 |
+
client_secret = os.environ.get("GOOGLE_CLIENT_SECRET", "")
|
| 104 |
+
refresh_token = os.environ.get("GMAIL_REFRESH_TOKEN", "")
|
| 105 |
+
sender_email = os.environ.get("SMTP_EMAIL", "havucong@lyvuha.com")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if not refresh_token:
|
| 109 |
+
print("⚠️ GMAIL_REFRESH_TOKEN not configured.")
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
import base64
|
| 114 |
+
import requests
|
| 115 |
+
|
| 116 |
+
# 1. Get Access Token from Refresh Token
|
| 117 |
+
token_url = "https://oauth2.googleapis.com/token"
|
| 118 |
+
token_data = {
|
| 119 |
+
"client_id": client_id,
|
| 120 |
+
"client_secret": client_secret,
|
| 121 |
+
"refresh_token": refresh_token,
|
| 122 |
+
"grant_type": "refresh_token"
|
| 123 |
+
}
|
| 124 |
+
token_res = requests.post(token_url, data=token_data, timeout=5)
|
| 125 |
+
if token_res.status_code != 200:
|
| 126 |
+
print(f"❌ Failed to refresh Gmail access token: {token_res.text}")
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
access_token = token_res.json().get("access_token")
|
| 130 |
+
|
| 131 |
+
# 2. Build MIME message
|
| 132 |
+
message = MIMEText(html_content, 'html', 'utf-8')
|
| 133 |
+
message['to'] = to_email
|
| 134 |
+
message['from'] = sender_email
|
| 135 |
+
message['subject'] = subject
|
| 136 |
+
|
| 137 |
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
|
| 138 |
+
|
| 139 |
+
# 3. Send email via Gmail API
|
| 140 |
+
send_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
| 141 |
+
headers = {
|
| 142 |
+
"Authorization": f"Bearer {access_token}",
|
| 143 |
+
"Content-Type": "application/json"
|
| 144 |
+
}
|
| 145 |
+
send_res = requests.post(send_url, headers=headers, json={"raw": raw_message}, timeout=5)
|
| 146 |
+
if send_res.status_code == 200:
|
| 147 |
+
print(f"📧 [Gmail OAuth2 API] Successfully sent email to {to_email}")
|
| 148 |
+
return True
|
| 149 |
+
else:
|
| 150 |
+
print(f"❌ [Gmail OAuth2 API] Failed to send email: {send_res.text}")
|
| 151 |
+
return False
|
| 152 |
+
except Exception as e:
|
| 153 |
+
print(f"❌ [Gmail OAuth2 API] Exception occurred: {e}")
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def send_email_async(to_email, subject, html_content):
|
| 158 |
+
"""Helper to send emails asynchronously in a background thread."""
|
| 159 |
+
refresh_token = os.environ.get("GMAIL_REFRESH_TOKEN", "")
|
| 160 |
+
service_account_json = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "")
|
| 161 |
+
|
| 162 |
+
if refresh_token:
|
| 163 |
+
# Ưu tiên sử dụng OAuth2 Refresh Token (HTTPS port 443)
|
| 164 |
+
threading.Thread(target=send_email_gmail_oauth2, args=(to_email, subject, html_content)).start()
|
| 165 |
+
elif service_account_json or os.path.exists(SERVICE_ACCOUNT_FILE):
|
| 166 |
+
# Sử dụng Google Service Account (HTTPS port 443)
|
| 167 |
+
threading.Thread(target=send_email_gmail_api, args=(to_email, subject, html_content)).start()
|
| 168 |
+
else:
|
| 169 |
+
# Fallback về SMTP
|
| 170 |
+
threading.Thread(target=send_email_smtp, args=(to_email, subject, html_content)).start()
|
| 171 |
+
|
| 172 |
+
|
backend/services/epub_service.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import zipfile
|
| 4 |
+
import uuid
|
| 5 |
+
import html
|
| 6 |
+
import tempfile
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
class EpubWriter:
|
| 10 |
+
def __init__(self, title, author="Unknown", cover_img_bytes=None, cover_ext="jpg", description=""):
|
| 11 |
+
self.title = title
|
| 12 |
+
self.author = author
|
| 13 |
+
self.description = description
|
| 14 |
+
self.chapters = [] # List of tuples (title, content)
|
| 15 |
+
self.cover_img_bytes = cover_img_bytes
|
| 16 |
+
self.cover_ext = cover_ext
|
| 17 |
+
|
| 18 |
+
def add_chapter(self, title, content_html):
|
| 19 |
+
self.chapters.append((title, content_html))
|
| 20 |
+
|
| 21 |
+
def write_to(self, dest_path):
|
| 22 |
+
with zipfile.ZipFile(dest_path, 'w', compression=zipfile.ZIP_DEFLATED) as epub:
|
| 23 |
+
# 1. mimetype (must be uncompressed and first entry)
|
| 24 |
+
epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
|
| 25 |
+
|
| 26 |
+
# 2. META-INF/container.xml
|
| 27 |
+
container_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
| 28 |
+
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
| 29 |
+
<rootfiles>
|
| 30 |
+
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
| 31 |
+
</rootfiles>
|
| 32 |
+
</container>"""
|
| 33 |
+
epub.writestr('META-INF/container.xml', container_xml)
|
| 34 |
+
|
| 35 |
+
# Generate NCX
|
| 36 |
+
ncx_entries = []
|
| 37 |
+
for i, (ch_title, _) in enumerate(self.chapters):
|
| 38 |
+
ncx_entries.append(f""" <navPoint id="navPoint-{i+1}" playOrder="{i+1}">
|
| 39 |
+
<navLabel><text>{html.escape(ch_title)}</text></navLabel>
|
| 40 |
+
<content src="text/chapter_{i+1}.xhtml"/>
|
| 41 |
+
</navPoint>""")
|
| 42 |
+
|
| 43 |
+
ncx_joined = "\n".join(ncx_entries)
|
| 44 |
+
toc_ncx = f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 45 |
+
<ncx xmlns="http://www.safaribooksonline.com/ascent/1.0/ncx/" version="2005-1" xml:lang="vi">
|
| 46 |
+
<head>
|
| 47 |
+
<meta name="dtb:uid" content="urn:uuid:{uuid.uuid4()}"/>
|
| 48 |
+
<meta name="dtb:depth" content="1"/>
|
| 49 |
+
<meta name="dtb:totalPageCount" content="0"/>
|
| 50 |
+
<meta name="dtb:maxPageNumber" content="0"/>
|
| 51 |
+
</head>
|
| 52 |
+
<docTitle><text>{html.escape(self.title)}</text></docTitle>
|
| 53 |
+
<docAuthor><text>{html.escape(self.author)}</text></docAuthor>
|
| 54 |
+
<navMap>
|
| 55 |
+
<navPoint id="navPoint-0" playOrder="0">
|
| 56 |
+
<navLabel><text>Trang Tên Sách</text></navLabel>
|
| 57 |
+
<content src="text/titlepage.xhtml"/>
|
| 58 |
+
</navPoint>
|
| 59 |
+
{ncx_joined}
|
| 60 |
+
</navMap>
|
| 61 |
+
</ncx>"""
|
| 62 |
+
epub.writestr('OEBPS/toc.ncx', toc_ncx)
|
| 63 |
+
|
| 64 |
+
# Generate manifest items
|
| 65 |
+
manifest_items = [
|
| 66 |
+
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
|
| 67 |
+
'<item id="titlepage" href="text/titlepage.xhtml" media-type="application/xhtml+xml"/>',
|
| 68 |
+
]
|
| 69 |
+
spine_items = [
|
| 70 |
+
'<itemref idref="titlepage"/>',
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
if self.cover_img_bytes:
|
| 74 |
+
manifest_items.append(f'<item id="cover-image" href="images/cover.{self.cover_ext}" media-type="image/{self.cover_ext if self.cover_ext != "jpg" else "jpeg"}"/>')
|
| 75 |
+
manifest_items.append('<item id="coverpage" href="text/coverpage.xhtml" media-type="application/xhtml+xml"/>')
|
| 76 |
+
spine_items.insert(0, '<itemref idref="coverpage"/>')
|
| 77 |
+
|
| 78 |
+
for i in range(len(self.chapters)):
|
| 79 |
+
manifest_items.append(f'<item id="chapter_{i+1}" href="text/chapter_{i+1}.xhtml" media-type="application/xhtml+xml"/>')
|
| 80 |
+
spine_items.append(f'<itemref idref="chapter_{i+1}"/>')
|
| 81 |
+
|
| 82 |
+
# Generate content.opf
|
| 83 |
+
manifest_joined = "\n ".join(manifest_items)
|
| 84 |
+
spine_joined = "\n ".join(spine_items)
|
| 85 |
+
desc_meta = f'<meta name="description" content="{html.escape(self.description)}"/>' if self.description else ''
|
| 86 |
+
cover_meta = '<meta name="cover" content="cover-image"/>' if self.cover_img_bytes else ''
|
| 87 |
+
|
| 88 |
+
content_opf = f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 89 |
+
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="2.0">
|
| 90 |
+
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
| 91 |
+
<dc:title>{html.escape(self.title)}</dc:title>
|
| 92 |
+
<dc:creator opf:role="aut">{html.escape(self.author)}</dc:creator>
|
| 93 |
+
<dc:language>vi</dc:language>
|
| 94 |
+
<dc:identifier id="bookid">urn:uuid:{uuid.uuid4()}</dc:identifier>
|
| 95 |
+
{desc_meta}
|
| 96 |
+
{cover_meta}
|
| 97 |
+
</metadata>
|
| 98 |
+
<manifest>
|
| 99 |
+
{manifest_joined}
|
| 100 |
+
</manifest>
|
| 101 |
+
<spine toc="ncx">
|
| 102 |
+
{spine_joined}
|
| 103 |
+
</spine>
|
| 104 |
+
</package>"""
|
| 105 |
+
epub.writestr('OEBPS/content.opf', content_opf)
|
| 106 |
+
|
| 107 |
+
# Write cover files
|
| 108 |
+
if self.cover_img_bytes:
|
| 109 |
+
epub.writestr(f'OEBPS/images/cover.{self.cover_ext}', self.cover_img_bytes)
|
| 110 |
+
coverpage_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 111 |
+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
| 112 |
+
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="vi">
|
| 113 |
+
<head>
|
| 114 |
+
<title>Bìa Sách</title>
|
| 115 |
+
<style type="text/css">
|
| 116 |
+
body {{ margin: 0; padding: 0; text-align: center; background-color: #ffffff; }}
|
| 117 |
+
img {{ max-width: 100%; height: auto; max-height: 98vh; }}
|
| 118 |
+
</style>
|
| 119 |
+
</head>
|
| 120 |
+
<body>
|
| 121 |
+
<div>
|
| 122 |
+
<img src="../images/cover.{self.cover_ext}" alt="Bìa sách"/>
|
| 123 |
+
</div>
|
| 124 |
+
</body>
|
| 125 |
+
</html>"""
|
| 126 |
+
epub.writestr('OEBPS/text/coverpage.xhtml', coverpage_xhtml)
|
| 127 |
+
|
| 128 |
+
# Title page
|
| 129 |
+
titlepage_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 130 |
+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
| 131 |
+
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="vi">
|
| 132 |
+
<head>
|
| 133 |
+
<title>{html.escape(self.title)}</title>
|
| 134 |
+
<style type="text/css">
|
| 135 |
+
body {{ font-family: sans-serif; text-align: center; padding: 10% 5%; }}
|
| 136 |
+
h1 {{ font-size: 2.2em; margin-bottom: 0.5em; color: #1a237e; }}
|
| 137 |
+
h2 {{ font-size: 1.4em; color: #555; margin-bottom: 2em; }}
|
| 138 |
+
.desc {{ font-size: 1em; text-align: justify; margin: 2em auto; max-width: 85%; color: #333; line-height: 1.6; border: 1px solid #e0e0e0; padding: 15px; border-radius: 8px; background: #fafafa; }}
|
| 139 |
+
</style>
|
| 140 |
+
</head>
|
| 141 |
+
<body>
|
| 142 |
+
<h1>{html.escape(self.title)}</h1>
|
| 143 |
+
<h2>Tác giả: {html.escape(self.author)}</h2>
|
| 144 |
+
<hr style="width: 50%;"/>
|
| 145 |
+
{f'<div class="desc"><strong>Giới thiệu:</strong><br/>{html.escape(self.description)}</div>' if self.description else ''}
|
| 146 |
+
<p style="margin-top: 3em; font-style: italic; font-size: 0.9em; color: #777;">Được biên dịch & đóng gói tự động bằng VIP EPUB Tools</p>
|
| 147 |
+
</body>
|
| 148 |
+
</html>"""
|
| 149 |
+
epub.writestr('OEBPS/text/titlepage.xhtml', titlepage_xhtml)
|
| 150 |
+
|
| 151 |
+
# Write chapters XHTML
|
| 152 |
+
for i, (ch_title, ch_content) in enumerate(self.chapters):
|
| 153 |
+
if "</html>" in ch_content.lower() or "</p>" in ch_content.lower():
|
| 154 |
+
ch_xhtml = ch_content
|
| 155 |
+
else:
|
| 156 |
+
paragraphs = ch_content.split('\n')
|
| 157 |
+
body_html = ""
|
| 158 |
+
for p in paragraphs:
|
| 159 |
+
p_clean = p.strip()
|
| 160 |
+
if p_clean:
|
| 161 |
+
body_html += f"<p>{html.escape(p_clean)}</p>\n"
|
| 162 |
+
|
| 163 |
+
ch_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 164 |
+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
| 165 |
+
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="vi">
|
| 166 |
+
<head>
|
| 167 |
+
<title>{html.escape(ch_title)}</title>
|
| 168 |
+
<style type="text/css">
|
| 169 |
+
body {{ font-family: sans-serif; padding: 5%; line-height: 1.6; color: #222; }}
|
| 170 |
+
h3 {{ font-size: 1.3em; text-align: center; margin-bottom: 1.5em; border-bottom: 1px solid #ddd; padding-bottom: 0.5em; color: #1a237e; }}
|
| 171 |
+
p {{ text-indent: 2em; margin: 0.6em 0; text-align: justify; }}
|
| 172 |
+
</style>
|
| 173 |
+
</head>
|
| 174 |
+
<body>
|
| 175 |
+
<h3>{html.escape(ch_title)}</h3>
|
| 176 |
+
{body_html}
|
| 177 |
+
</body>
|
| 178 |
+
</html>"""
|
| 179 |
+
epub.writestr(f'OEBPS/text/chapter_{i+1}.xhtml', ch_xhtml)
|
| 180 |
+
|
| 181 |
+
def apply_custom_dictionary(text, custom_dict):
|
| 182 |
+
"""Replaces Chinese words with user-provided custom dictionary mappings."""
|
| 183 |
+
if not custom_dict or not text:
|
| 184 |
+
return text
|
| 185 |
+
for zh, vi in custom_dict:
|
| 186 |
+
if zh and zh in text:
|
| 187 |
+
text = text.replace(zh, vi)
|
| 188 |
+
return text
|
| 189 |
+
|
| 190 |
+
def clean_html_styles(html_content):
|
| 191 |
+
"""Strips inline CSS styles, custom font faces, and stylesheets."""
|
| 192 |
+
cleaned = re.sub(r'\s+style="[^"]*"', '', html_content)
|
| 193 |
+
cleaned = re.sub(r"\s+style='[^']*'", '', cleaned)
|
| 194 |
+
cleaned = re.sub(r'\s+class="[^"]*"', '', cleaned)
|
| 195 |
+
cleaned = re.sub(r"\s+class='[^']*'", '', cleaned)
|
| 196 |
+
cleaned = re.sub(r'<style[^>]*>.*?</style>', '', cleaned, flags=re.DOTALL | re.IGNORECASE)
|
| 197 |
+
cleaned = re.sub(r'<link[^>]*rel="stylesheet"[^>]*>', '', cleaned, flags=re.IGNORECASE)
|
| 198 |
+
cleaned = re.sub(r'<link[^>]*rel=\'stylesheet\'[^>]*>', '', cleaned, flags=re.IGNORECASE)
|
| 199 |
+
return cleaned
|
| 200 |
+
|
| 201 |
+
def clean_punctuation_spacing(text):
|
| 202 |
+
if not text:
|
| 203 |
+
return text
|
| 204 |
+
text = re.sub(r'["“”‘’\'『』「」【】()\[\]{}<>]', '', text)
|
| 205 |
+
text = re.sub(r'([,;.:!?])(?=[^\s])', r'\1 ', text)
|
| 206 |
+
text = re.sub(r'\s+([,;.:!?])', r'\1', text)
|
| 207 |
+
text = re.sub(r'\s*-\s*', ' - ', text)
|
| 208 |
+
return re.sub(r'\s+', ' ', text).strip()
|
| 209 |
+
|
| 210 |
+
def translate_html_content_advanced(html_content, engine, mode=None, custom_dict=None, clean_styles=False):
|
| 211 |
+
"""Parses HTML, extracts text segments, translates them, and preserves HTML tags."""
|
| 212 |
+
if clean_styles:
|
| 213 |
+
html_content = clean_html_styles(html_content)
|
| 214 |
+
|
| 215 |
+
parts = re.split(r'(<[^>]+>)', html_content)
|
| 216 |
+
translated_parts = []
|
| 217 |
+
|
| 218 |
+
for part in parts:
|
| 219 |
+
if not part:
|
| 220 |
+
continue
|
| 221 |
+
if part.startswith('<') and part.endswith('>'):
|
| 222 |
+
translated_parts.append(part)
|
| 223 |
+
else:
|
| 224 |
+
stripped = part.strip()
|
| 225 |
+
if stripped:
|
| 226 |
+
txt = apply_custom_dictionary(part, custom_dict)
|
| 227 |
+
translated_text = engine.translate(txt, multi_option=False, mode=mode)
|
| 228 |
+
translated_text = clean_punctuation_spacing(translated_text)
|
| 229 |
+
translated_parts.append(translated_text)
|
| 230 |
+
else:
|
| 231 |
+
translated_parts.append(part)
|
| 232 |
+
|
| 233 |
+
return "".join(translated_parts)
|
| 234 |
+
|
| 235 |
+
def translate_epub_file(src_epub, dest_epub, engine, mode=None, limit_chapters=-1, custom_dict=None, clean_styles=False, strip_images=False, strip_fonts=False):
|
| 236 |
+
"""Translates an EPUB file and saves to dest_epub."""
|
| 237 |
+
start_time = time.time()
|
| 238 |
+
|
| 239 |
+
with zipfile.ZipFile(src_epub, 'r') as src_zip:
|
| 240 |
+
with zipfile.ZipFile(dest_epub, 'w', compression=zipfile.ZIP_DEFLATED) as dest_zip:
|
| 241 |
+
file_list = src_zip.namelist()
|
| 242 |
+
|
| 243 |
+
if 'mimetype' in file_list:
|
| 244 |
+
mimetype_data = src_zip.read('mimetype')
|
| 245 |
+
dest_zip.writestr('mimetype', mimetype_data, compress_type=zipfile.ZIP_STORED)
|
| 246 |
+
|
| 247 |
+
html_files = [f for f in file_list if f.endswith(('.html', '.xhtml', '.htm'))]
|
| 248 |
+
|
| 249 |
+
def natural_sort_key(s):
|
| 250 |
+
return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
|
| 251 |
+
html_files.sort(key=natural_sort_key)
|
| 252 |
+
|
| 253 |
+
target_html_files = set(html_files)
|
| 254 |
+
skipped_html_files = set()
|
| 255 |
+
if limit_chapters > 0 and len(html_files) > limit_chapters:
|
| 256 |
+
target_html_files = set(html_files[:limit_chapters])
|
| 257 |
+
skipped_html_files = set(html_files[limit_chapters:])
|
| 258 |
+
|
| 259 |
+
for file_name in file_list:
|
| 260 |
+
if file_name == 'mimetype':
|
| 261 |
+
continue
|
| 262 |
+
|
| 263 |
+
if strip_images and file_name.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg')):
|
| 264 |
+
continue
|
| 265 |
+
|
| 266 |
+
if strip_fonts and file_name.lower().endswith(('.ttf', '.otf', '.woff', '.woff2')):
|
| 267 |
+
continue
|
| 268 |
+
|
| 269 |
+
raw_data = src_zip.read(file_name)
|
| 270 |
+
|
| 271 |
+
if file_name in target_html_files:
|
| 272 |
+
try:
|
| 273 |
+
content = raw_data.decode('utf-8', errors='ignore')
|
| 274 |
+
translated_content = translate_html_content_advanced(
|
| 275 |
+
content, engine, mode=mode, custom_dict=custom_dict, clean_styles=clean_styles
|
| 276 |
+
)
|
| 277 |
+
dest_zip.writestr(file_name, translated_content.encode('utf-8'))
|
| 278 |
+
except Exception as e:
|
| 279 |
+
print(f"[EpubTool ERROR] Error translating {file_name}: {e}")
|
| 280 |
+
dest_zip.writestr(file_name, raw_data)
|
| 281 |
+
elif file_name in skipped_html_files:
|
| 282 |
+
placeholder = '<html><head><meta charset="utf-8"/></head><body><h3>[Chương này được bỏ qua trong bản dịch giới hạn]</h3></body></html>'
|
| 283 |
+
dest_zip.writestr(file_name, placeholder.encode('utf-8'))
|
| 284 |
+
elif file_name.endswith(('.ncx', '.opf', '.xml')):
|
| 285 |
+
try:
|
| 286 |
+
content = raw_data.decode('utf-8', errors='ignore')
|
| 287 |
+
translated_content = translate_html_content_advanced(
|
| 288 |
+
content, engine, mode=mode, custom_dict=custom_dict, clean_styles=False
|
| 289 |
+
)
|
| 290 |
+
dest_zip.writestr(file_name, translated_content.encode('utf-8'))
|
| 291 |
+
except Exception:
|
| 292 |
+
dest_zip.writestr(file_name, raw_data)
|
| 293 |
+
else:
|
| 294 |
+
dest_zip.writestr(file_name, raw_data)
|
| 295 |
+
|
| 296 |
+
return time.time() - start_time
|
| 297 |
+
|
| 298 |
+
def convert_txt_to_epub(txt_content, title, author, split_regex, dest_path, description="", engine=None, mode=None, custom_dict=None):
|
| 299 |
+
"""Converts raw text file content to EPUB, optionally translating it first."""
|
| 300 |
+
lines = txt_content.split('\n')
|
| 301 |
+
chapters = []
|
| 302 |
+
current_chapter_title = "Giới thiệu / Khởi đầu"
|
| 303 |
+
current_chapter_lines = []
|
| 304 |
+
|
| 305 |
+
regex = re.compile(split_regex) if split_regex else None
|
| 306 |
+
|
| 307 |
+
for line in lines:
|
| 308 |
+
stripped = line.strip()
|
| 309 |
+
if not stripped:
|
| 310 |
+
continue
|
| 311 |
+
|
| 312 |
+
if regex and regex.search(stripped) and len(stripped) < 100:
|
| 313 |
+
if current_chapter_lines:
|
| 314 |
+
chapters.append((current_chapter_title, "\n".join(current_chapter_lines)))
|
| 315 |
+
current_chapter_title = stripped
|
| 316 |
+
current_chapter_lines = []
|
| 317 |
+
else:
|
| 318 |
+
current_chapter_lines.append(line)
|
| 319 |
+
|
| 320 |
+
if current_chapter_lines or not chapters:
|
| 321 |
+
chapters.append((current_chapter_title, "\n".join(current_chapter_lines)))
|
| 322 |
+
|
| 323 |
+
writer = EpubWriter(title=title, author=author, description=description)
|
| 324 |
+
|
| 325 |
+
for ch_title, ch_text in chapters:
|
| 326 |
+
if engine and mode:
|
| 327 |
+
ch_title_vi = apply_custom_dictionary(ch_title, custom_dict)
|
| 328 |
+
ch_title_vi = engine.translate(ch_title_vi, multi_option=False, mode=mode)
|
| 329 |
+
|
| 330 |
+
paragraphs = ch_text.split('\n')
|
| 331 |
+
paragraphs_vi = []
|
| 332 |
+
for p in paragraphs:
|
| 333 |
+
p_clean = p.strip()
|
| 334 |
+
if p_clean:
|
| 335 |
+
p_vi = apply_custom_dictionary(p_clean, custom_dict)
|
| 336 |
+
p_vi = engine.translate(p_vi, multi_option=False, mode=mode)
|
| 337 |
+
paragraphs_vi.append(p_vi)
|
| 338 |
+
ch_content_html = "\n".join(paragraphs_vi)
|
| 339 |
+
writer.add_chapter(ch_title_vi, ch_content_html)
|
| 340 |
+
else:
|
| 341 |
+
writer.add_chapter(ch_title, ch_text)
|
| 342 |
+
|
| 343 |
+
writer.write_to(dest_path)
|
backend/services/payment_service.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import json
|
| 3 |
+
import hmac
|
| 4 |
+
import hashlib
|
| 5 |
+
import requests
|
| 6 |
+
import sqlite3
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from backend.config import Config
|
| 9 |
+
from backend.database.db_manager import get_user_db_conn
|
| 10 |
+
from backend.services.auth_service import activate_vip
|
| 11 |
+
|
| 12 |
+
def verify_payos_webhook_signature(payload_data, expected_signature, checksum_key):
|
| 13 |
+
"""Verify PayOS webhook signature using alphabet-sorted query string."""
|
| 14 |
+
sorted_keys = sorted(payload_data.keys())
|
| 15 |
+
parts = []
|
| 16 |
+
for k in sorted_keys:
|
| 17 |
+
v = payload_data[k]
|
| 18 |
+
if v is None or v == "null" or v == "undefined":
|
| 19 |
+
v_str = ""
|
| 20 |
+
elif isinstance(v, list):
|
| 21 |
+
sorted_list = []
|
| 22 |
+
for item in v:
|
| 23 |
+
if isinstance(item, dict):
|
| 24 |
+
sorted_item = {sub_k: item[sub_k] for sub_k in sorted(item.keys())}
|
| 25 |
+
sorted_list.append(sorted_item)
|
| 26 |
+
else:
|
| 27 |
+
sorted_list.append(item)
|
| 28 |
+
v_str = json.dumps(sorted_list, separators=(',', ':'), ensure_ascii=False)
|
| 29 |
+
elif isinstance(v, dict):
|
| 30 |
+
sorted_dict = {sub_k: v[sub_k] for sub_k in sorted(v.keys())}
|
| 31 |
+
v_str = json.dumps(sorted_dict, separators=(',', ':'), ensure_ascii=False)
|
| 32 |
+
else:
|
| 33 |
+
v_str = str(v)
|
| 34 |
+
parts.append(f"{k}={v_str}")
|
| 35 |
+
|
| 36 |
+
query_str = "&".join(parts)
|
| 37 |
+
computed_sig = hmac.new(
|
| 38 |
+
checksum_key.encode(), query_str.encode(), hashlib.sha256
|
| 39 |
+
).hexdigest()
|
| 40 |
+
return hmac.compare_digest(computed_sig, expected_signature)
|
| 41 |
+
|
| 42 |
+
def create_payment_order(user_id: int, plan: str):
|
| 43 |
+
"""Generates a payment order and produces the relevant PayOS link or fallback VietQR."""
|
| 44 |
+
plan_info = Config.VIP_PLANS.get(plan)
|
| 45 |
+
if not plan_info:
|
| 46 |
+
if plan.startswith("topup_"):
|
| 47 |
+
try:
|
| 48 |
+
price_str = plan.split("_")[1]
|
| 49 |
+
if price_str.endswith("k"):
|
| 50 |
+
amount = int(price_str[:-1]) * 1000
|
| 51 |
+
else:
|
| 52 |
+
amount = int(price_str)
|
| 53 |
+
plan_info = {
|
| 54 |
+
"name_vi": f"Nạp số dư API {amount:,}đ".replace(",", "."),
|
| 55 |
+
"price": amount
|
| 56 |
+
}
|
| 57 |
+
except Exception:
|
| 58 |
+
return {"error": "Invalid topup plan name. Use e.g. topup_50k or topup_50000"}, 400
|
| 59 |
+
else:
|
| 60 |
+
return {"error": "Invalid plan name"}, 400
|
| 61 |
+
|
| 62 |
+
amount = plan_info["price"]
|
| 63 |
+
order_code_int = int(time.time()) * 1000 + (int(user_id) % 1000)
|
| 64 |
+
order_id = str(order_code_int)
|
| 65 |
+
|
| 66 |
+
# Save to database
|
| 67 |
+
conn = get_user_db_conn()
|
| 68 |
+
conn.execute(
|
| 69 |
+
"INSERT INTO payments (user_id, order_id, plan, amount, status) VALUES (?, ?, ?, ?, 'pending')",
|
| 70 |
+
(user_id, order_id, plan, amount)
|
| 71 |
+
)
|
| 72 |
+
conn.commit()
|
| 73 |
+
conn.close()
|
| 74 |
+
|
| 75 |
+
payos_data = None
|
| 76 |
+
pay_cfg = Config.PAYMENT_CONFIG
|
| 77 |
+
client_id = pay_cfg.get("payos_client_id")
|
| 78 |
+
api_key = pay_cfg.get("payos_api_key")
|
| 79 |
+
checksum_key = pay_cfg.get("payos_checksum_key")
|
| 80 |
+
|
| 81 |
+
if client_id and api_key and checksum_key:
|
| 82 |
+
cancel_url = pay_cfg.get("payos_webhook_url").replace("/api/payment/webhook", "") or "http://localhost:5050"
|
| 83 |
+
return_url = pay_cfg.get("payos_webhook_url").replace("/api/payment/webhook", "") or "http://localhost:5050"
|
| 84 |
+
description = f"VIP {plan_info.get('name_vi', 'Premium')}"[:25]
|
| 85 |
+
|
| 86 |
+
raw_str = f"amount={amount}&cancelUrl={cancel_url}&description={description}&orderCode={order_code_int}&returnUrl={return_url}"
|
| 87 |
+
signature = hmac.new(
|
| 88 |
+
checksum_key.encode(), raw_str.encode(), hashlib.sha256
|
| 89 |
+
).hexdigest()
|
| 90 |
+
|
| 91 |
+
payload = {
|
| 92 |
+
"orderCode": order_code_int,
|
| 93 |
+
"amount": amount,
|
| 94 |
+
"description": description,
|
| 95 |
+
"cancelUrl": cancel_url,
|
| 96 |
+
"returnUrl": return_url,
|
| 97 |
+
"signature": signature
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
headers = {
|
| 101 |
+
"x-client-id": client_id,
|
| 102 |
+
"x-api-key": api_key,
|
| 103 |
+
"Content-Type": "application/json"
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
r = requests.post(
|
| 108 |
+
"https://api-merchant.payos.vn/v2/payment-requests",
|
| 109 |
+
json=payload,
|
| 110 |
+
headers=headers,
|
| 111 |
+
timeout=10
|
| 112 |
+
)
|
| 113 |
+
res = r.json()
|
| 114 |
+
if r.status_code == 200 and res.get("code") == "00":
|
| 115 |
+
payos_data = res.get("data")
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"[PayOS Request Exception]: {e}")
|
| 118 |
+
|
| 119 |
+
# Generate QR URL (Using PayOS hosted qrCode if available, else fallback to standard VietQR)
|
| 120 |
+
if payos_data and payos_data.get("qrCode"):
|
| 121 |
+
qr_url = f"https://api.qrserver.com/v1/create-qr-code/?size=250x250&data={requests.utils.quote(payos_data['qrCode'])}"
|
| 122 |
+
checkout_url = payos_data.get("checkoutUrl")
|
| 123 |
+
payos_bank = payos_data.get("bin", pay_cfg["bank_id"])
|
| 124 |
+
payos_account_no = payos_data.get("accountNumber", pay_cfg["account_no"])
|
| 125 |
+
payos_account_name = payos_data.get("accountName", pay_cfg["account_name"])
|
| 126 |
+
else:
|
| 127 |
+
transfer_content = order_id
|
| 128 |
+
qr_url = (
|
| 129 |
+
f"https://img.vietqr.io/image/{pay_cfg['bank_id']}-{pay_cfg['account_no']}-{pay_cfg['template']}.png"
|
| 130 |
+
f"?amount={amount}"
|
| 131 |
+
f"&addInfo={transfer_content}"
|
| 132 |
+
f"&accountName={pay_cfg['account_name'].replace(' ', '%20')}"
|
| 133 |
+
)
|
| 134 |
+
checkout_url = None
|
| 135 |
+
payos_bank = pay_cfg["bank_id"]
|
| 136 |
+
payos_account_no = pay_cfg["account_no"]
|
| 137 |
+
payos_account_name = pay_cfg["account_name"]
|
| 138 |
+
|
| 139 |
+
return {
|
| 140 |
+
"order_id": order_id,
|
| 141 |
+
"plan": plan,
|
| 142 |
+
"amount": amount,
|
| 143 |
+
"amount_formatted": f"{amount:,}đ".replace(",", "."),
|
| 144 |
+
"qr_url": qr_url,
|
| 145 |
+
"checkout_url": checkout_url,
|
| 146 |
+
"bank_info": {
|
| 147 |
+
"bank": payos_bank,
|
| 148 |
+
"account_no": payos_account_no,
|
| 149 |
+
"account_name": payos_account_name,
|
| 150 |
+
"transfer_content": order_id,
|
| 151 |
+
},
|
| 152 |
+
"expires_in": 900,
|
| 153 |
+
"message": f"Quét mã QR hoặc chuyển khoản {amount:,}đ với nội dung: {order_id}".replace(",", ".")
|
| 154 |
+
}, 200
|
| 155 |
+
|
| 156 |
+
def confirm_payment(order_id: str, amount: float = 0.0) -> bool:
|
| 157 |
+
"""Updates the status of a pending payment order to completed and activates VIP."""
|
| 158 |
+
try:
|
| 159 |
+
conn = get_user_db_conn()
|
| 160 |
+
conn.row_factory = sqlite3.Row
|
| 161 |
+
payment = conn.execute(
|
| 162 |
+
"SELECT * FROM payments WHERE order_id = ? AND status = 'pending'", (order_id,)
|
| 163 |
+
).fetchone()
|
| 164 |
+
|
| 165 |
+
if not payment:
|
| 166 |
+
conn.close()
|
| 167 |
+
print(f"⚠️ Payment {order_id} not found or already processed.")
|
| 168 |
+
return False
|
| 169 |
+
|
| 170 |
+
# Optional validation check for amount consistency
|
| 171 |
+
if amount > 0 and abs(amount - payment["amount"]) > 100:
|
| 172 |
+
conn.close()
|
| 173 |
+
print(f"❌ Amount mismatch for {order_id}: expected {payment['amount']}, got {amount}")
|
| 174 |
+
return False
|
| 175 |
+
|
| 176 |
+
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
| 177 |
+
conn.execute(
|
| 178 |
+
"UPDATE payments SET status = 'completed', completed_at = ? WHERE id = ?",
|
| 179 |
+
(now, payment["id"])
|
| 180 |
+
)
|
| 181 |
+
conn.commit()
|
| 182 |
+
conn.close()
|
| 183 |
+
|
| 184 |
+
user_id = payment["user_id"]
|
| 185 |
+
plan = payment["plan"]
|
| 186 |
+
|
| 187 |
+
# Activate VIP status for user
|
| 188 |
+
success = activate_vip(user_id, plan)
|
| 189 |
+
if success:
|
| 190 |
+
print(f"✅ [Payment Confirmation] Confirmed payment {order_id} & activated VIP.")
|
| 191 |
+
return True
|
| 192 |
+
return False
|
| 193 |
+
except Exception as e:
|
| 194 |
+
print(f"❌ Error confirming payment: {e}")
|
| 195 |
+
return False
|
backend/services/translation.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
import requests as py_requests
|
| 6 |
+
from backend.config import Config
|
| 7 |
+
|
| 8 |
+
# Dynamic path addition for local translation engine
|
| 9 |
+
if Config.ROOT_DIR not in sys.path:
|
| 10 |
+
sys.path.append(Config.ROOT_DIR)
|
| 11 |
+
|
| 12 |
+
engine = None
|
| 13 |
+
translation_limit_tracker = {} # Format: { "IP:YYYY-MM-DD": count }
|
| 14 |
+
ADMIN_GEMINI_KEY = os.environ.get("ADMIN_GEMINI_KEY", "")
|
| 15 |
+
|
| 16 |
+
def get_engine():
|
| 17 |
+
global engine
|
| 18 |
+
if engine is None:
|
| 19 |
+
print("Lazy-loading Vietphrase Engine...")
|
| 20 |
+
from backend.engine.engine import VietphraseEngine
|
| 21 |
+
engine = VietphraseEngine()
|
| 22 |
+
return engine
|
| 23 |
+
|
| 24 |
+
def parse_custom_dict_text(dict_text):
|
| 25 |
+
if not dict_text:
|
| 26 |
+
return None
|
| 27 |
+
custom_dict = []
|
| 28 |
+
lines = dict_text.split('\n')
|
| 29 |
+
for line in lines:
|
| 30 |
+
line = line.strip()
|
| 31 |
+
if not line or '=' not in line:
|
| 32 |
+
continue
|
| 33 |
+
parts = line.split('=', 1)
|
| 34 |
+
zh = parts[0].strip()
|
| 35 |
+
vi = parts[1].strip()
|
| 36 |
+
if zh and vi:
|
| 37 |
+
custom_dict.append((zh, vi))
|
| 38 |
+
# Sort by key length descending to avoid prefix collision during greedy replacement
|
| 39 |
+
custom_dict.sort(key=lambda x: len(x[0]), reverse=True)
|
| 40 |
+
return custom_dict
|
| 41 |
+
|
| 42 |
+
def translate_texts(texts, mode=None):
|
| 43 |
+
eng = get_engine()
|
| 44 |
+
translations = []
|
| 45 |
+
for text in texts:
|
| 46 |
+
if not text.strip():
|
| 47 |
+
translations.append(text)
|
| 48 |
+
else:
|
| 49 |
+
try:
|
| 50 |
+
trans = eng.translate(text, multi_option=False, mode=mode)
|
| 51 |
+
translations.append(trans)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"Error translating: {e}")
|
| 54 |
+
translations.append(text)
|
| 55 |
+
return translations
|
| 56 |
+
|
| 57 |
+
def translate_stream_generator(texts, mode=None):
|
| 58 |
+
eng = get_engine()
|
| 59 |
+
for i, text in enumerate(texts):
|
| 60 |
+
if not text.strip():
|
| 61 |
+
trans = text
|
| 62 |
+
else:
|
| 63 |
+
try:
|
| 64 |
+
trans = eng.translate(text, multi_option=False, mode=mode)
|
| 65 |
+
except Exception as e:
|
| 66 |
+
print(f"Error translating: {e}")
|
| 67 |
+
trans = text
|
| 68 |
+
yield f"data: {json.dumps({'index': i, 'text': trans}, ensure_ascii=False)}\n\n"
|
| 69 |
+
time.sleep(0.001)
|
| 70 |
+
|
| 71 |
+
def call_ai_chat_proxy(messages, model="gemini-1.5-flash", prompt=""):
|
| 72 |
+
if not ADMIN_GEMINI_KEY:
|
| 73 |
+
raise ValueError("Admin Gemini key not configured on server.")
|
| 74 |
+
|
| 75 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={ADMIN_GEMINI_KEY}"
|
| 76 |
+
|
| 77 |
+
contents = []
|
| 78 |
+
for msg in messages:
|
| 79 |
+
contents.append({
|
| 80 |
+
"role": "user" if msg.get("role") == "user" else "model",
|
| 81 |
+
"parts": [{"text": msg.get("text", "")}]
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
payload = {
|
| 85 |
+
"contents": contents,
|
| 86 |
+
"systemInstruction": {
|
| 87 |
+
"parts": [{"text": prompt}]
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
res = py_requests.post(url, json=payload, timeout=30)
|
| 92 |
+
if res.status_code != 200:
|
| 93 |
+
raise RuntimeError(f"Gemini API returned error: {res.text}")
|
| 94 |
+
|
| 95 |
+
gemini_data = res.json()
|
| 96 |
+
response_text = gemini_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "Không nhận được phản hồi.")
|
| 97 |
+
return response_text
|
backend/test_sects_flow.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests, time, sys, os
|
| 2 |
+
BASE_URL = "http://localhost:5051"
|
| 3 |
+
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 4 |
+
sys.path.append(ROOT_DIR)
|
| 5 |
+
from backend.database.db_manager import get_user_db_conn
|
| 6 |
+
|
| 7 |
+
H = {"X-Bypass-Rate-Limit": "tienhiep_bypass_secret_9988"}
|
| 8 |
+
|
| 9 |
+
def step(msg): print(f"\n{'='*60}\n👉 {msg}\n{'='*60}")
|
| 10 |
+
def ok(msg): print(f" ✅ {msg}")
|
| 11 |
+
def info(msg): print(f" 📌 {msg}")
|
| 12 |
+
|
| 13 |
+
def verify_db(username):
|
| 14 |
+
conn = get_user_db_conn()
|
| 15 |
+
conn.execute("UPDATE users SET email_verified = 1 WHERE username = ?", (username,))
|
| 16 |
+
conn.commit(); conn.close()
|
| 17 |
+
|
| 18 |
+
class C:
|
| 19 |
+
def __init__(self, u, p):
|
| 20 |
+
self.u, self.p, self.token, self.h, self.uid = u, p, None, dict(H), None
|
| 21 |
+
def register(self):
|
| 22 |
+
r = requests.post(f"{BASE_URL}/api/auth/register", json={"username":self.u,"password":self.p,"email":f"{self.u}@test.com"}, headers=self.h)
|
| 23 |
+
if r.status_code == 200 or "đã tồn tại" in r.text: verify_db(self.u)
|
| 24 |
+
def login(self):
|
| 25 |
+
r = requests.post(f"{BASE_URL}/api/auth/login", json={"username":self.u,"password":self.p}, headers=self.h)
|
| 26 |
+
assert r.status_code == 200, f"Login fail {self.u}: {r.text}"
|
| 27 |
+
d = r.json(); self.token = d.get("access_token") or d.get("token")
|
| 28 |
+
self.h = {"Authorization":f"Bearer {self.token}", **H}; self.uid = d.get("user",{}).get("id")
|
| 29 |
+
ok(f"Login {self.u} (ID:{self.uid})")
|
| 30 |
+
def post(self, ep, data=None): return requests.post(f"{BASE_URL}{ep}", json=data, headers=self.h)
|
| 31 |
+
def get(self, ep, params=None): return requests.get(f"{BASE_URL}{ep}", params=params, headers=self.h)
|
| 32 |
+
|
| 33 |
+
def cleanup():
|
| 34 |
+
# Use API-based cleanup - leave all sects via HTTP
|
| 35 |
+
for n in ["tst_leader","tst_vice","tst_elder","tst_inner1","tst_inner2","tst_outer"]:
|
| 36 |
+
try:
|
| 37 |
+
# Login
|
| 38 |
+
r = requests.post(f"{BASE_URL}/api/auth/login", json={"username":n,"password":"pw123"}, headers=H)
|
| 39 |
+
if r.status_code != 200: continue
|
| 40 |
+
tok = r.json().get("access_token") or r.json().get("token")
|
| 41 |
+
ah = {"Authorization":f"Bearer {tok}", **H}
|
| 42 |
+
# Check if in sect
|
| 43 |
+
r = requests.get(f"{BASE_URL}/api/sects/my-sect", headers=ah)
|
| 44 |
+
if r.status_code == 200 and r.json().get("in_sect"):
|
| 45 |
+
requests.post(f"{BASE_URL}/api/sects/leave", headers=ah)
|
| 46 |
+
except: pass
|
| 47 |
+
|
| 48 |
+
def run():
|
| 49 |
+
step("1. TẠO 6 TÀI KHOẢN")
|
| 50 |
+
names = ["tst_leader","tst_vice","tst_elder","tst_inner1","tst_inner2","tst_outer"]
|
| 51 |
+
us = {n: C(n,"pw123") for n in names}
|
| 52 |
+
for c in us.values(): c.register(); c.login()
|
| 53 |
+
|
| 54 |
+
step("2. DỌN DẸP DB CŨ")
|
| 55 |
+
cleanup()
|
| 56 |
+
|
| 57 |
+
step("3. SÁNG LẬP TÔNG MÔN")
|
| 58 |
+
sn = f"TestTong{int(time.time())}"
|
| 59 |
+
r = us["tst_leader"].post("/api/sects/create", {"name":sn,"slogan":"Kiếm khí tung hoành","badge":"gold"})
|
| 60 |
+
assert r.status_code == 200, f"Create fail: {r.text}"
|
| 61 |
+
sid = r.json()["sect_id"]; ok(f"Tông '{sn}' ID={sid}")
|
| 62 |
+
|
| 63 |
+
step("4. TÌM KIẾM TÔNG MÔN")
|
| 64 |
+
r = us["tst_leader"].get("/api/sects/search", {"q":"TestTong"})
|
| 65 |
+
assert r.status_code == 200 and r.json()["total"] >= 1
|
| 66 |
+
ok(f"Tìm thấy {r.json()['total']} tông môn, trang {r.json()['page']}/{r.json()['total_pages']}")
|
| 67 |
+
|
| 68 |
+
step("5. XEM TÔNG MÔN THEO ID")
|
| 69 |
+
r = us["tst_leader"].get(f"/api/sects/{sid}")
|
| 70 |
+
assert r.status_code == 200 and r.json()["sect"]["name"] == sn
|
| 71 |
+
ok(f"Xem tông ID={sid}: {r.json()['member_count']} thành viên")
|
| 72 |
+
|
| 73 |
+
step("6. GIA NHẬP 5 ĐỆ TỬ")
|
| 74 |
+
for n in names[1:]:
|
| 75 |
+
r = us[n].post("/api/sects/join", {"sect_id":sid})
|
| 76 |
+
assert r.status_code == 200, f"{n} join fail: {r.text}"
|
| 77 |
+
ok("5 đệ tử gửi đơn bái kiến")
|
| 78 |
+
|
| 79 |
+
r = us["tst_leader"].get("/api/sects/requests/list")
|
| 80 |
+
reqs = r.json()["requests"]; ok(f"{len(reqs)} yêu cầu chờ duyệt")
|
| 81 |
+
for rq in reqs:
|
| 82 |
+
r = us["tst_leader"].post("/api/sects/requests/respond", {"request_id":rq["id"],"action":"approve"})
|
| 83 |
+
assert r.status_code == 200
|
| 84 |
+
ok("Duyệt toàn bộ")
|
| 85 |
+
|
| 86 |
+
step("7. HỆ THỐNG 5 CHỨC DANH")
|
| 87 |
+
promotions = [
|
| 88 |
+
("tst_vice", "vice_leader", "Phó Tông chủ"),
|
| 89 |
+
("tst_elder", "elder", "Trưởng lão"),
|
| 90 |
+
("tst_inner1", "inner_disciple", "Nội môn đệ tử"),
|
| 91 |
+
("tst_inner2", "inner_disciple", "Nội môn đệ tử"),
|
| 92 |
+
]
|
| 93 |
+
for uname, role, label in promotions:
|
| 94 |
+
r = us["tst_leader"].post("/api/sects/promote/rank", {"user_id":us[uname].uid,"role":role})
|
| 95 |
+
assert r.status_code == 200, f"Promote {uname} fail: {r.text}"
|
| 96 |
+
ok(f"{uname} → {label}")
|
| 97 |
+
|
| 98 |
+
step("8. KIỂM TRA QUYỀN PHÂN CẤP")
|
| 99 |
+
# Vice leader thăng elder cho outer
|
| 100 |
+
r = us["tst_vice"].post("/api/sects/promote/rank", {"user_id":us["tst_outer"].uid,"role":"elder"})
|
| 101 |
+
assert r.status_code == 200; ok("Phó Tông chủ thăng Trưởng lão cho tst_outer ✓")
|
| 102 |
+
# Hạ lại về member
|
| 103 |
+
r = us["tst_vice"].post("/api/sects/promote/rank", {"user_id":us["tst_outer"].uid,"role":"member"})
|
| 104 |
+
assert r.status_code == 200; ok("Phó Tông chủ hạ tst_outer về Ngoại môn đệ tử ✓")
|
| 105 |
+
# Elder KHÔNG THỂ thăng vice_leader
|
| 106 |
+
r = us["tst_elder"].post("/api/sects/promote/rank", {"user_id":us["tst_outer"].uid,"role":"vice_leader"})
|
| 107 |
+
assert r.status_code == 403; ok("Trưởng lão bị chặn thăng Phó Tông chủ ✓ (403)")
|
| 108 |
+
|
| 109 |
+
step("9. XEM THÀNH VIÊN & LỌC THEO CHỨC DANH")
|
| 110 |
+
r = us["tst_leader"].get("/api/sects/members")
|
| 111 |
+
assert r.status_code == 200
|
| 112 |
+
for m in r.json()["members"]: info(f"{m['username']} = {m['role_label']} (cống hiến: {m['contribution']})")
|
| 113 |
+
r = us["tst_leader"].get("/api/sects/members", {"role":"inner_disciple"})
|
| 114 |
+
ok(f"Lọc Nội môn: {len(r.json()['members'])} người")
|
| 115 |
+
|
| 116 |
+
step("10. CHAT CHUNG TỔNG TÔNG MÔN")
|
| 117 |
+
msgs = [
|
| 118 |
+
("tst_leader", "Toàn tông nghe lệnh, ngày mai xuất chinh!"),
|
| 119 |
+
("tst_vice", "Phó Tông chủ đã sắp xếp đội hình chiến đấu."),
|
| 120 |
+
("tst_elder", "Trưởng lão báo cáo: đan dược đã đủ cung ứng."),
|
| 121 |
+
("tst_inner1", "Nội môn đệ tử xin tuân lệnh!"),
|
| 122 |
+
("tst_outer", "Ngoại môn đệ tử sẵn sàng!"),
|
| 123 |
+
]
|
| 124 |
+
for u, msg in msgs:
|
| 125 |
+
r = us[u].post("/api/sects/chat/send", {"message":msg,"chat_type":"general"})
|
| 126 |
+
assert r.status_code == 200
|
| 127 |
+
r = us["tst_outer"].get("/api/sects/chat/history", {"chat_type":"general"})
|
| 128 |
+
ok(f"Chat chung: {len(r.json()['messages'])} tin nhắn")
|
| 129 |
+
for m in r.json()["messages"]: info(f"[{m['sender_name']}]: {m['message']}")
|
| 130 |
+
|
| 131 |
+
step("11. CHAT RIÊNG 1-1 TRONG TÔNG MÔN")
|
| 132 |
+
pairs = [
|
| 133 |
+
("tst_leader","tst_vice","Phó Tông chủ, ngày mai phân công thế nào?"),
|
| 134 |
+
("tst_vice","tst_leader","Dạ, đệ tử đã chuẩn bị 3 đội tiên phong."),
|
| 135 |
+
("tst_inner1","tst_inner2","Đồng môn ơi, cùng luyện công tối nay không?"),
|
| 136 |
+
("tst_inner2","tst_inner1","Được chứ, 8 giờ tại Luyện Công Đường nhé!"),
|
| 137 |
+
("tst_elder","tst_outer","Ngoại môn đệ tử, tu vi gần đây tiến bộ ra sao?"),
|
| 138 |
+
("tst_outer","tst_elder","Dạ bẩm Trưởng lão, đệ tử đang ở Luyện Khí tầng 3."),
|
| 139 |
+
]
|
| 140 |
+
for s, t, msg in pairs:
|
| 141 |
+
r = us[s].post("/api/sects/chat/send", {"message":msg,"chat_type":"direct","target_id":us[t].uid})
|
| 142 |
+
assert r.status_code == 200
|
| 143 |
+
# Verify
|
| 144 |
+
r = us["tst_leader"].get("/api/sects/chat/history", {"chat_type":"direct","target_id":us["tst_vice"].uid})
|
| 145 |
+
ok(f"Chat riêng Leader↔Vice: {len(r.json()['messages'])} tin")
|
| 146 |
+
r = us["tst_inner1"].get("/api/sects/chat/history", {"chat_type":"direct","target_id":us["tst_inner2"].uid})
|
| 147 |
+
ok(f"Chat riêng Inner1↔Inner2: {len(r.json()['messages'])} tin")
|
| 148 |
+
r = us["tst_elder"].get("/api/sects/chat/history", {"chat_type":"direct","target_id":us["tst_outer"].uid})
|
| 149 |
+
ok(f"Chat riêng Elder↔Outer: {len(r.json()['messages'])} tin")
|
| 150 |
+
|
| 151 |
+
step("12. TẠO NHÓM CHAT NHỎ #1 (Ban lãnh đạo)")
|
| 152 |
+
r = us["tst_leader"].post("/api/sects/chat/groups/create", {
|
| 153 |
+
"name":"Ban lãnh đạo","members":[us["tst_leader"].uid, us["tst_vice"].uid, us["tst_elder"].uid]
|
| 154 |
+
})
|
| 155 |
+
assert r.status_code == 200; g1 = r.json()["group_id"]; ok(f"Nhóm 'Ban lãnh đạo' ID={g1}")
|
| 156 |
+
|
| 157 |
+
step("13. TẠO NHÓM CHAT NHỎ #2 (Đội tu luyện)")
|
| 158 |
+
r = us["tst_inner1"].post("/api/sects/chat/groups/create", {
|
| 159 |
+
"name":"Đội tu luyện","members":[us["tst_inner1"].uid, us["tst_inner2"].uid, us["tst_outer"].uid]
|
| 160 |
+
})
|
| 161 |
+
assert r.status_code == 200; g2 = r.json()["group_id"]; ok(f"Nhóm 'Đội tu luyện' ID={g2}")
|
| 162 |
+
|
| 163 |
+
step("14. CHAT TRONG NHÓM NHỎ")
|
| 164 |
+
for u, msg in [("tst_leader","Hội nghị mật: chiến lược đánh Ma Giáo"),("tst_vice","Đề xuất tấn công từ hướng Đông"),("tst_elder","Đồng ý, trưởng lão sẽ dẫn đội hỗ trợ")]:
|
| 165 |
+
r = us[u].post("/api/sects/chat/send", {"message":msg,"chat_type":"group","group_id":g1})
|
| 166 |
+
assert r.status_code == 200
|
| 167 |
+
for u, msg in [("tst_inner1","Tối nay luyện kiếm pháp mới!"),("tst_inner2","OK sư huynh!"),("tst_outer","Đệ tử cũng xin tham gia!")]:
|
| 168 |
+
r = us[u].post("/api/sects/chat/send", {"message":msg,"chat_type":"group","group_id":g2})
|
| 169 |
+
assert r.status_code == 200
|
| 170 |
+
r = us["tst_leader"].get("/api/sects/chat/history", {"chat_type":"group","group_id":g1})
|
| 171 |
+
ok(f"Nhóm BLĐ: {len(r.json()['messages'])} tin")
|
| 172 |
+
r = us["tst_inner1"].get("/api/sects/chat/history", {"chat_type":"group","group_id":g2})
|
| 173 |
+
ok(f"Nhóm tu luyện: {len(r.json()['messages'])} tin")
|
| 174 |
+
|
| 175 |
+
step("15. BẢO MẬT: NGƯỜI NGOÀI KHÔNG CHAT ĐƯỢC NHÓM")
|
| 176 |
+
r = us["tst_outer"].post("/api/sects/chat/send", {"message":"hack","chat_type":"group","group_id":g1})
|
| 177 |
+
assert r.status_code == 403; ok(f"Outer bị chặn chat nhóm BLĐ (403) ✓")
|
| 178 |
+
r = us["tst_outer"].get("/api/sects/chat/history", {"chat_type":"group","group_id":g1})
|
| 179 |
+
assert r.status_code == 403; ok(f"Outer bị chặn xem lịch sử BLĐ (403) ✓")
|
| 180 |
+
|
| 181 |
+
step("16. THÊM/XÓA THÀNH VIÊN NHÓM CHAT")
|
| 182 |
+
r = us["tst_leader"].post(f"/api/sects/chat/groups/{g1}/members/add", {"user_ids":[us["tst_inner1"].uid]})
|
| 183 |
+
assert r.status_code == 200; ok(f"Thêm Inner1 vào BLĐ, members={r.json()['members']}")
|
| 184 |
+
r = us["tst_leader"].post(f"/api/sects/chat/groups/{g1}/members/remove", {"user_id":us["tst_inner1"].uid})
|
| 185 |
+
assert r.status_code == 200; ok(f"Xóa Inner1 khỏi BLĐ, members={r.json()['members']}")
|
| 186 |
+
|
| 187 |
+
step("17. XEM THÔNG TIN NHÓM CHAT")
|
| 188 |
+
r = us["tst_leader"].get(f"/api/sects/chat/groups/{g1}")
|
| 189 |
+
assert r.status_code == 200
|
| 190 |
+
ginfo = r.json(); ok(f"Nhóm: {ginfo['group']['name']}, {ginfo['member_count']} thành viên")
|
| 191 |
+
for m in ginfo["members"]: info(f" {m['username']} ({m['role_label']})")
|
| 192 |
+
|
| 193 |
+
step("18. DANH SÁCH TẤT CẢ NHÓM CHAT CỦA TÔI")
|
| 194 |
+
r = us["tst_leader"].get("/api/sects/chat/groups")
|
| 195 |
+
assert r.status_code == 200; ok(f"Leader thấy {len(r.json()['groups'])} nhóm")
|
| 196 |
+
r = us["tst_inner1"].get("/api/sects/chat/groups")
|
| 197 |
+
ok(f"Inner1 thấy {len(r.json()['groups'])} nhóm")
|
| 198 |
+
|
| 199 |
+
step("19. KẾT BẠN & CHAT BẠN BÈ")
|
| 200 |
+
conn = get_user_db_conn()
|
| 201 |
+
conn.execute("DELETE FROM friendships WHERE user_id IN (?,?) OR friend_id IN (?,?)",
|
| 202 |
+
(us["tst_inner1"].uid, us["tst_outer"].uid, us["tst_inner1"].uid, us["tst_outer"].uid))
|
| 203 |
+
conn.commit(); conn.close()
|
| 204 |
+
r = us["tst_inner1"].post("/api/friends/request", {"friend_username":"tst_outer"})
|
| 205 |
+
assert r.status_code == 200; ok("Inner1 gửi kết bạn Outer")
|
| 206 |
+
r = us["tst_outer"].post("/api/friends/respond", {"sender_id":us["tst_inner1"].uid,"action":"accept"})
|
| 207 |
+
assert r.status_code == 200; ok("Outer chấp nhận")
|
| 208 |
+
r = us["tst_inner1"].get("/api/friends/list")
|
| 209 |
+
ok(f"Bạn bè Inner1: {[f['username'] for f in r.json()['friends']]}")
|
| 210 |
+
r = us["tst_inner1"].post("/api/messages/send", {"receiver_id":us["tst_outer"].uid,"message":"Chào bạn, tối nay đi phụ bản không?"})
|
| 211 |
+
assert r.status_code == 200
|
| 212 |
+
r = us["tst_outer"].post("/api/messages/send", {"receiver_id":us["tst_inner1"].uid,"message":"Đi chứ! 9h tối nhé!"})
|
| 213 |
+
assert r.status_code == 200
|
| 214 |
+
r = us["tst_inner1"].get(f"/api/messages/chat/{us['tst_outer'].uid}")
|
| 215 |
+
ok(f"Chat bạn bè: {len(r.json()['messages'])} tin nhắn")
|
| 216 |
+
for m in r.json()["messages"]:
|
| 217 |
+
sn = "tst_inner1" if m["sender_id"]==us["tst_inner1"].uid else "tst_outer"
|
| 218 |
+
info(f"[{sn}]: {m['message']}")
|
| 219 |
+
|
| 220 |
+
step("20. CỐNG HIẾN & XEM TÔNG MÔN QUA ID")
|
| 221 |
+
us["tst_inner1"].post("/api/sects/contribute", {"amount":300})
|
| 222 |
+
us["tst_outer"].post("/api/sects/contribute", {"amount":200})
|
| 223 |
+
r = us["tst_leader"].get(f"/api/sects/{sid}")
|
| 224 |
+
d = r.json()
|
| 225 |
+
ok(f"Tông '{d['sect']['name']}' cấp {d['sect']['level']}, cống hiến tổng: {d['sect']['contribution']}")
|
| 226 |
+
ok(f"Phân bố chức danh: {d['role_counts']}")
|
| 227 |
+
|
| 228 |
+
step("🎉 TOÀN BỘ 20 BƯỚC TEST THÀNH CÔNG!")
|
| 229 |
+
|
| 230 |
+
if __name__ == "__main__": run()
|
backend/workers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# backend/workers/__init__.py
|
backend/workers/email_worker.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import base64
|
| 3 |
+
import time
|
| 4 |
+
import email
|
| 5 |
+
import imaplib
|
| 6 |
+
import os
|
| 7 |
+
import sqlite3
|
| 8 |
+
import threading
|
| 9 |
+
from email.header import decode_header
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from backend.config import Config
|
| 12 |
+
from backend.services.email_service import (
|
| 13 |
+
get_gmail_service, send_email_smtp, send_email_gmail_api, SERVICE_ACCOUNT_FILE
|
| 14 |
+
)
|
| 15 |
+
from backend.database.db_manager import get_user_db_conn
|
| 16 |
+
|
| 17 |
+
def get_customer_email_by_order(order_id: str):
|
| 18 |
+
try:
|
| 19 |
+
conn = get_user_db_conn()
|
| 20 |
+
conn.row_factory = sqlite3.Row
|
| 21 |
+
row = conn.execute(
|
| 22 |
+
"SELECT u.email FROM payments p JOIN users u ON p.user_id = u.id WHERE p.order_id = ?",
|
| 23 |
+
(order_id,)
|
| 24 |
+
).fetchone()
|
| 25 |
+
conn.close()
|
| 26 |
+
return row["email"] if row and row["email"] else None
|
| 27 |
+
except Exception:
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
def process_bank_email_payload(combined_content: str, send_reply_fn):
|
| 31 |
+
"""Parse payment email content and process activation."""
|
| 32 |
+
order_match = re.search(r'(VIP\w+)', combined_content, re.IGNORECASE)
|
| 33 |
+
amount_match = re.search(
|
| 34 |
+
r'(?:S\d+ti\d+n|số tiền|giao dịch|phát sinh|cộng)\s*(?:\+)?\s*([0-9.,]+)\s*(?:VND|đ|d|đ)',
|
| 35 |
+
combined_content, re.IGNORECASE
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
if not order_match:
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
from backend.services.payment_service import confirm_payment
|
| 42 |
+
|
| 43 |
+
order_id = order_match.group(1).upper()
|
| 44 |
+
amount = 0
|
| 45 |
+
if amount_match:
|
| 46 |
+
amount_str = amount_match.group(1).replace('.', '').replace(',', '')
|
| 47 |
+
try:
|
| 48 |
+
amount = float(amount_str)
|
| 49 |
+
except ValueError:
|
| 50 |
+
pass
|
| 51 |
+
|
| 52 |
+
print(f"🔍 [Email Worker] Detected transaction. Order ID: {order_id}, parsed amount: {amount}")
|
| 53 |
+
success = confirm_payment(order_id, amount)
|
| 54 |
+
|
| 55 |
+
if success:
|
| 56 |
+
customer_email = get_customer_email_by_order(order_id)
|
| 57 |
+
if customer_email:
|
| 58 |
+
reply_subject = f"Xác nhận thanh toán thành công đơn hàng {order_id}"
|
| 59 |
+
reply_html = f"""
|
| 60 |
+
<div style="font-family: sans-serif; padding: 20px; line-height: 1.6;">
|
| 61 |
+
<h2 style="color: #4CAF50;">Thanh toán thành công!</h2>
|
| 62 |
+
<p>Xin chào,</p>
|
| 63 |
+
<p>Hệ thống đã nhận được số tiền chuyển khoản của bạn cho đơn hàng <strong>{order_id}</strong>.</p>
|
| 64 |
+
<p>Tài khoản VIP của bạn đã được nâng cấp và kích hoạt tự động thành công.</p>
|
| 65 |
+
<p>Chúc bạn có những trải nghiệm đọc truyện tuyệt vời!</p>
|
| 66 |
+
<hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;">
|
| 67 |
+
<p style="font-size: 12px; color: #888;">Đội ngũ hỗ trợ Ly Vu Ha Novel</p>
|
| 68 |
+
</div>
|
| 69 |
+
"""
|
| 70 |
+
send_reply_fn(customer_email, reply_subject, reply_html)
|
| 71 |
+
return success
|
| 72 |
+
|
| 73 |
+
def check_bank_emails_imap():
|
| 74 |
+
"""Connects to Gmail via IMAP and processes unread bank payment emails."""
|
| 75 |
+
smtp_cfg = Config.SMTP_CONFIG
|
| 76 |
+
if not smtp_cfg["username"] or not smtp_cfg["password"]:
|
| 77 |
+
print("⚠️ SMTP/IMAP credentials not configured.")
|
| 78 |
+
return
|
| 79 |
+
try:
|
| 80 |
+
print(f"📥 [IMAP] Connecting to imap.gmail.com as {smtp_cfg['username']}...")
|
| 81 |
+
mail = imaplib.IMAP4_SSL("imap.gmail.com")
|
| 82 |
+
mail.login(smtp_cfg["username"], smtp_cfg["password"])
|
| 83 |
+
mail.select("inbox")
|
| 84 |
+
|
| 85 |
+
status, response = mail.search(None, 'UNSEEN')
|
| 86 |
+
if status != "OK":
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
+
messages = response[0].split()
|
| 90 |
+
if not messages:
|
| 91 |
+
print("✅ [IMAP] No unread emails in inbox.")
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
print(f"📧 [IMAP] Found {len(messages)} unread email(s). Processing...")
|
| 95 |
+
|
| 96 |
+
for msg_id in messages:
|
| 97 |
+
status, data = mail.fetch(msg_id, "(RFC822)")
|
| 98 |
+
if status != "OK":
|
| 99 |
+
continue
|
| 100 |
+
|
| 101 |
+
raw_email = data[0][1]
|
| 102 |
+
msg = email.message_from_bytes(raw_email)
|
| 103 |
+
|
| 104 |
+
subject, encoding = decode_header(msg["Subject"])[0]
|
| 105 |
+
if isinstance(subject, bytes):
|
| 106 |
+
subject = subject.decode(encoding or "utf-8", errors="ignore")
|
| 107 |
+
|
| 108 |
+
body = ""
|
| 109 |
+
if msg.is_multipart():
|
| 110 |
+
for part in msg.walk():
|
| 111 |
+
content_type = part.get_content_type()
|
| 112 |
+
content_disposition = str(part.get("Content-Disposition"))
|
| 113 |
+
if content_type == "text/plain" and "attachment" not in content_disposition:
|
| 114 |
+
payload = part.get_payload(decode=True)
|
| 115 |
+
body += payload.decode("utf-8", errors="ignore")
|
| 116 |
+
else:
|
| 117 |
+
body = msg.get_payload(decode=True).decode("utf-8", errors="ignore")
|
| 118 |
+
|
| 119 |
+
combined_content = f"{subject} {body}"
|
| 120 |
+
process_bank_email_payload(combined_content, send_email_smtp)
|
| 121 |
+
|
| 122 |
+
# Mark email as read
|
| 123 |
+
mail.store(msg_id, '+FLAGS', '\\Seen')
|
| 124 |
+
|
| 125 |
+
mail.close()
|
| 126 |
+
mail.logout()
|
| 127 |
+
except Exception as e:
|
| 128 |
+
print(f"�� Error during IMAP polling worker: {e}")
|
| 129 |
+
|
| 130 |
+
def check_bank_emails_gmail_api():
|
| 131 |
+
"""Checks Gmail inbox using Gmail API for unread bank/payment emails."""
|
| 132 |
+
service = get_gmail_service()
|
| 133 |
+
if not service:
|
| 134 |
+
return
|
| 135 |
+
try:
|
| 136 |
+
query = "is:unread (MB Bank OR Bank OR \"biến động số dư\" OR \"chuyển khoản\")"
|
| 137 |
+
results = service.users().messages().list(userId='me', q=query).execute()
|
| 138 |
+
messages = results.get('messages', [])
|
| 139 |
+
|
| 140 |
+
if not messages:
|
| 141 |
+
return
|
| 142 |
+
|
| 143 |
+
print(f"📧 [Gmail API] Found {len(messages)} unread bank/payment email(s). Processing...")
|
| 144 |
+
|
| 145 |
+
for msg in messages:
|
| 146 |
+
msg_id = msg['id']
|
| 147 |
+
message_details = service.users().messages().get(userId='me', id=msg_id, format='full').execute()
|
| 148 |
+
|
| 149 |
+
payload = message_details.get('payload', {})
|
| 150 |
+
headers = payload.get('headers', [])
|
| 151 |
+
subject = next((h['value'] for h in headers if h['name'].lower() == 'subject'), '')
|
| 152 |
+
|
| 153 |
+
body_text = ""
|
| 154 |
+
if 'parts' in payload:
|
| 155 |
+
for part in payload['parts']:
|
| 156 |
+
if part['mimeType'] == 'text/plain':
|
| 157 |
+
data = part['body'].get('data', '')
|
| 158 |
+
body_text += base64.urlsafe_b64decode(data.encode('utf-8')).decode('utf-8', errors='ignore')
|
| 159 |
+
else:
|
| 160 |
+
data = payload.get('body', {}).get('data', '')
|
| 161 |
+
if data:
|
| 162 |
+
body_text = base64.urlsafe_b64decode(data.encode('utf-8')).decode('utf-8', errors='ignore')
|
| 163 |
+
|
| 164 |
+
combined_content = f"{subject} {body_text}"
|
| 165 |
+
success = process_bank_email_payload(combined_content, send_email_gmail_api)
|
| 166 |
+
|
| 167 |
+
# Mark as read regardless
|
| 168 |
+
service.users().messages().batchModify(
|
| 169 |
+
userId='me',
|
| 170 |
+
body={'ids': [msg_id], 'removeLabelIds': ['UNREAD']}
|
| 171 |
+
).execute()
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
print(f"❌ Error during Gmail polling worker: {e}")
|
| 175 |
+
|
| 176 |
+
def check_bank_emails_and_process():
|
| 177 |
+
"""Dispatch between Gmail API and IMAP method."""
|
| 178 |
+
if os.path.exists(SERVICE_ACCOUNT_FILE):
|
| 179 |
+
check_bank_emails_gmail_api()
|
| 180 |
+
else:
|
| 181 |
+
check_bank_emails_imap()
|
| 182 |
+
|
| 183 |
+
def start_email_worker():
|
| 184 |
+
"""Start background daemon thread polling Gmail every 60 seconds (Singleton across workers)."""
|
| 185 |
+
# Use a file lock to ensure only ONE Gunicorn worker spawns the background polling thread
|
| 186 |
+
import fcntl
|
| 187 |
+
|
| 188 |
+
lock_file = "/tmp/email_worker_poll.lock"
|
| 189 |
+
try:
|
| 190 |
+
# Open file in append mode so it doesn't overwrite
|
| 191 |
+
lock_fd = open(lock_file, "a")
|
| 192 |
+
# Try to acquire an exclusive, non-blocking lock
|
| 193 |
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 194 |
+
except Exception:
|
| 195 |
+
# If lock fails, another worker is already running the email poll loop
|
| 196 |
+
print("📧 [Email Worker] Another worker is already polling. Skipping duplicate thread.")
|
| 197 |
+
return
|
| 198 |
+
|
| 199 |
+
def poll_loop(fd):
|
| 200 |
+
print("📧 [Email Worker] Master polling thread acquired lock. Background polling started.")
|
| 201 |
+
while True:
|
| 202 |
+
try:
|
| 203 |
+
check_bank_emails_and_process()
|
| 204 |
+
except Exception as e:
|
| 205 |
+
print(f"❌ [Email Worker] Error in polling loop: {e}")
|
| 206 |
+
# Poll every 60 seconds instead of 15s to avoid IMAP rate limits
|
| 207 |
+
time.sleep(60)
|
| 208 |
+
|
| 209 |
+
# Note: the file descriptor 'fd' is kept open by this thread so the lock is held
|
| 210 |
+
# as long as this specific worker process is alive.
|
| 211 |
+
t = threading.Thread(target=poll_loop, args=(lock_fd,), daemon=True)
|
| 212 |
+
t.start()
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
print("📧 [Email Worker] Standalone polling loop started in foreground...")
|
| 217 |
+
while True:
|
| 218 |
+
try:
|
| 219 |
+
check_bank_emails_and_process()
|
| 220 |
+
except Exception as e:
|
| 221 |
+
print(f"❌ [Email Worker] Error in polling loop: {e}")
|
| 222 |
+
time.sleep(60)
|
| 223 |
+
|
frontend-web/dist/ads.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Google AdSense ads.txt template
|
| 2 |
+
# Thay thế ca-pub-9548504602542886 bằng mã nhà xuất bản (Publisher ID) thực tế của bạn
|
| 3 |
+
google.com, pub-9548504602542886, DIRECT, f08c47fec0942fa0
|
frontend-web/dist/assets/AuthorDetail-lunBE6ER.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import{t as e}from"./award-CKCOPIfL.js";import{a as t,t as n}from"./MainLayout-COiTxmNr.js";import{t as r}from"./book-DwmTvSdZ.js";import{a as i}from"./square-DZKJ0QGR.js";import{t as a}from"./file-text-mhTXpwIY.js";import{D as o,F as s,M as c,O as l,S as u,b as d,f,h as p,j as m,w as h}from"./index-yRoRoI6u.js";var g=d(`layers`,[[`path`,{d:`M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z`,key:`zw3jo`}],[`path`,{d:`M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12`,key:`1wduqc`}],[`path`,{d:`M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17`,key:`kqbvx6`}]]),_=s(m(),1),v=c();function y(){let{authorName:s}=l(),{lang:c}=u(),d=o(),[m,y]=(0,_.useState)(null),[b,x]=(0,_.useState)(!0),[S,C]=(0,_.useState)(``);(0,_.useEffect)(()=>{w()},[s]);let w=async()=>{x(!0),C(``);try{y((await h.get(`/api/author/${encodeURIComponent(s)}`)).data)}catch(e){console.error(e),C(c===`vi`?`Không tìm thấy thông tin tác giả hoặc có lỗi xảy ra.`:c===`en`?`Failed to load author details.`:`无法加载作者详情。`)}finally{x(!1)}};if(b)return(0,v.jsx)(n,{children:(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,v.jsx)(f,{className:`w-8 h-8 animate-spin mx-auto mb-2 text-purple-500`}),(0,v.jsx)(`span`,{children:c===`vi`?`Đang tải thông tin tác giả...`:c===`en`?`Loading author details...`:`正在加载作者信息...`})]})});if(S||!m)return(0,v.jsx)(n,{children:(0,v.jsx)(`div`,{className:`py-20 text-center text-red-500 font-bold`,children:S||`Lỗi không xác định.`})});let{author_chinese:T,author_hanviet:E,total_books:D,books:O}=m,k=O.reduce((e,t)=>e+(t.chapters_max||0),0);return(0,v.jsxs)(n,{children:[(0,v.jsxs)(`div`,{className:`relative rounded-3xl overflow-hidden border border-[#1f1f3a] mb-8 bg-[#0b0b14]/70 p-6 md:p-8 flex flex-col md:flex-row gap-6 items-center md:items-start z-0`,children:[(0,v.jsx)(`div`,{className:`absolute inset-0 z-[-1] opacity-10 blur-3xl bg-gradient-to-r from-purple-500 to-indigo-600 scale-110`}),(0,v.jsx)(`div`,{className:`w-16 h-16 md:w-20 md:h-20 bg-purple-500/10 border-2 border-purple-500/30 rounded-2xl flex items-center justify-center text-purple-400 shrink-0 shadow-lg shadow-purple-500/5`,children:(0,v.jsx)(t,{className:`w-8 h-8 md:w-10 md:h-10`})}),(0,v.jsxs)(`div`,{className:`flex-1 min-w-0 text-center md:text-left space-y-2`,children:[(0,v.jsx)(`span`,{className:`px-2.5 py-0.5 bg-purple-500/10 border border-purple-500/25 text-purple-400 rounded-full text-[10px] font-black uppercase tracking-wider`,children:c===`vi`?`Hồ sơ tác giả`:c===`en`?`Author Profile`:`作者档案`}),(0,v.jsx)(`h2`,{className:`text-2xl md:text-3xl font-black text-white leading-tight`,children:E}),(0,v.jsxs)(`p`,{className:`text-slate-400 text-xs font-semibold`,children:[c===`vi`?`Tên gốc Trung`:c===`en`?`Chinese Original Name`:`中文原名`,`:`,` `,(0,v.jsx)(`span`,{className:`text-purple-300 font-bold bg-[#121225] px-2 py-0.5 rounded border border-[#1f1f3a]`,children:T})]}),(0,v.jsxs)(`div`,{className:`flex flex-wrap justify-center md:justify-start gap-4 pt-3 text-xs text-slate-400`,children:[(0,v.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 px-4 py-2 rounded-xl flex items-center gap-2`,children:[(0,v.jsx)(r,{className:`w-4 h-4 text-purple-400`}),(0,v.jsxs)(`span`,{children:[(0,v.jsx)(`strong`,{children:D}),` `,c===`vi`?`Truyện`:c===`en`?`Books`:`本小说`]})]}),(0,v.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 px-4 py-2 rounded-xl flex items-center gap-2`,children:[(0,v.jsx)(g,{className:`w-4 h-4 text-purple-400`}),(0,v.jsxs)(`span`,{children:[(0,v.jsx)(`strong`,{children:k.toLocaleString()}),` `,c===`vi`?`Chương tích lũy`:c===`en`?`Total Chapters`:`总章节`]})]}),(0,v.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 px-4 py-2 rounded-xl flex items-center gap-2`,children:[(0,v.jsx)(e,{className:`w-4 h-4 text-purple-400`}),(0,v.jsx)(`span`,{children:(0,v.jsx)(`strong`,{children:`Antigravity Verified`})})]})]})]})]}),(0,v.jsxs)(`div`,{className:`mb-6 flex justify-between items-center`,children:[(0,v.jsxs)(`h3`,{className:`text-lg font-black text-white flex items-center gap-2`,children:[(0,v.jsx)(a,{className:`w-5 h-5 text-purple-400`}),c===`vi`?`Các tác phẩm tiêu biểu`:c===`en`?`Works List`:`代表作品`]}),(0,v.jsx)(`span`,{className:`text-xs text-slate-500 font-bold`,children:c===`vi`?`Hiển thị ${O.length} kết quả`:c===`en`?`${O.length} results`:`共 ${O.length} 部`})]}),(0,v.jsx)(`div`,{className:`space-y-6`,children:O.map(e=>(0,v.jsxs)(`div`,{className:`bg-[#121225]/50 border border-[#1f1f3a] rounded-3xl p-5 md:p-6 flex flex-col md:flex-row gap-5 hover:border-purple-500/20 hover:bg-[#121225]/70 transition-all duration-300 relative group`,children:[e.cover?(0,v.jsx)(`img`,{src:e.cover,alt:`cover`,className:`w-[90px] h-[125px] md:w-[110px] md:h-[155px] object-cover rounded-xl border border-[#1f1f3a] shadow-lg group-hover:scale-[1.02] transition-transform duration-300 shrink-0 mx-auto md:mx-0`}):(0,v.jsx)(`div`,{className:`w-[90px] h-[125px] md:w-[110px] md:h-[155px] bg-[#0b0b14] border border-[#1f1f3a] rounded-xl flex items-center justify-center text-slate-600 shrink-0 mx-auto md:mx-0`,children:(0,v.jsx)(r,{className:`w-10 h-10`})}),(0,v.jsxs)(`div`,{className:`flex-1 min-w-0 flex flex-col justify-between space-y-3 text-center md:text-left`,children:[(0,v.jsxs)(`div`,{className:`space-y-1.5`,children:[(0,v.jsx)(`h4`,{onClick:()=>d(`/book/${e.id}`),className:`text-base md:text-lg font-black text-white cursor-pointer hover:text-purple-400 transition-colors leading-tight inline-block`,children:e.title_vietphrase||e.title_hanviet}),(0,v.jsxs)(`p`,{className:`text-[10px] text-slate-500 font-semibold`,children:[c===`vi`?`Tiêu đề gốc`:c===`en`?`Original Title`:`原著`,`: `,e.title,` · `,e.chapters_max,` `,c===`vi`?`chương`:c===`en`?`chapters`:`章`]}),(0,v.jsx)(`p`,{className:`text-slate-300 text-xs leading-relaxed line-clamp-3`,children:e.description_vietphrase||e.description_hanviet||e.description||(c===`vi`?`Chưa có tóm tắt nội dung.`:c===`en`?`No description available.`:`暂无内容简介。`)})]}),(0,v.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-2 border-t border-[#1f1f3a]/40`,children:[(0,v.jsx)(`div`,{className:`flex flex-wrap justify-center md:justify-start gap-1.5`,children:e.categories&&e.categories.split(/[,,]/).map(e=>e.trim()).filter(Boolean).map((e,t)=>(0,v.jsx)(`span`,{className:`px-2 py-0.5 bg-[#0b0b14]/60 border border-[#1f1f3a] text-slate-400 rounded-md text-[10px] font-semibold`,children:e},t))}),(0,v.jsxs)(`div`,{className:`flex flex-wrap justify-center sm:justify-end items-center gap-1.5`,children:[(0,v.jsxs)(`span`,{className:`text-[10px] text-slate-500 font-bold flex items-center gap-1`,children:[(0,v.jsx)(p,{className:`w-3 h-3 text-purple-400`}),c===`vi`?`Nguồn gốc:`:c===`en`?`Source web:`:`来源网站:`]}),e.parsed_sources&&e.parsed_sources.length>0?e.parsed_sources.map((e,t)=>(0,v.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-1 px-2 py-1 bg-purple-950/20 hover:bg-purple-900/40 border border-purple-500/20 hover:border-purple-500/30 text-purple-300 rounded-lg text-[9px] font-black transition-colors`,title:e.url,children:[e.source,(0,v.jsx)(i,{className:`w-2.5 h-2.5 text-purple-400`})]},t)):(0,v.jsx)(`span`,{className:`text-[10px] text-slate-600 italic`,children:c===`vi`?`Không rõ`:c===`en`?`Unknown`:`未知`})]})]})]})]},e.id))})]})}export{y as default};
|
frontend-web/dist/assets/BookCard-BCMDdlyN.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import{t as e}from"./book-DwmTvSdZ.js";import{n as t,r as n,t as r}from"./star-DjnKUekI.js";import{D as i,F as a,M as o,S as s,b as c,j as l,n as u,s as d,t as f,w as p}from"./index-yRoRoI6u.js";var m=c(`columns-2`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M12 3v18`,key:`108xh3`}]]),h=a(l(),1),g=o();function _({book:a,isFav:o,onToggleFav:c,onCompare:l,onRead:_,onPlayTrailer:v,onSearchAuthor:y,onSearchCategory:b}){let{t:x,lang:S}=s(),{openInBrowser:C}=f(),w=i(),[T,E]=(0,h.useState)(!1),[D,O]=(0,h.useState)(`original`),[k,A]=(0,h.useState)(``),[j,M]=(0,h.useState)(!1),N=e=>e?e.split(` | `).map(e=>{let t=e.indexOf(`: `);return t<0?null:{site:e.slice(0,t),url:e.slice(t+2)}}).filter(Boolean):[],P=(()=>{if(S===`zh`||S===`en`)return a.title||`—`;let e=a.title_vietphrase||a.title_hanviet;return e&&a.title&&e!==a.title?`${e} (${a.title})`:e||a.title||`—`})(),F=S===`zh`?a.author||`—`:S===`en`?a.author_english||a.author||`—`:a.author_hanviet||a.author||`—`,I=S===`zh`?a.description||``:S===`en`?a.description_english||a.description||``:a.description_vietphrase||a.description||``,L=D===`original`?I:k||I,R=N(a.urls),z=()=>{let e=a.categories||``;return e.includes(`玄幻`)||e.includes(`修真`)||e.includes(`仙侠`)||e.includes(`Huyền Huyện`)||e.includes(`Tiên Hiệp`)?[`Sát phạt`,`Vô địch`]:(e.includes(`都市`)||e.includes(`历史`)||e.includes(`Đô Thị`)||e.includes(`Lịch Sử`)||e.includes(`Văn Minh`)||e.includes(`Văn minh`),[`Hài hước`,`Trí tuệ`])},B=async e=>{if(e===`original`){O(`original`);return}M(!0);try{try{let t=await p.post(`/api/translate`,{texts:[I],mode:e===`en`?`en`:`vietphrase`});t.data&&t.data.translations&&t.data.translations[0]&&(A(t.data.translations[0]),O(e))}catch(t){console.warn(`[BookCard] Cloud translation failed, trying offline localTranslator:`,t),await u.loadDictionaries(),A(u.translateSentence(I,`advanced`)),O(e)}}catch{alert(`Hạn mức dịch máy chủ đã hết và bộ dịch offline gặp lỗi.`)}finally{M(!1)}},V=S===`zh`?a.categories||``:S===`en`?a.categories_english||a.categories||``:a.categories_vietphrase||a.categories||``,H=V?V.split(/[,,/、\s]+/).map(e=>e.trim()).filter(Boolean):[];return(0,g.jsxs)(`div`,{className:`bg-[#121225]/85 border border-[#1f1f3a] rounded-xl p-3.5 hover:border-purple-500/50 hover:shadow-xl hover:shadow-purple-500/5 transition-all duration-300 flex flex-col justify-between relative overflow-visible min-h-[300px]`,children:[(0,g.jsxs)(`div`,{children:[(0,g.jsxs)(`div`,{className:`flex gap-3`,children:[a.cover?(0,g.jsx)(`img`,{src:a.cover,alt:`cover`,className:`w-[60px] h-[82px] object-cover rounded-lg border border-[#2d2d55] shadow-md shrink-0 bg-[#0f0f1a]`,onError:e=>{e.target.remove()}}):(0,g.jsx)(`div`,{className:`w-[60px] h-[82px] rounded-lg border border-[#2d2d55] bg-[#0f0f1a] flex items-center justify-center text-slate-500 shrink-0 shadow-md`,children:(0,g.jsx)(e,{className:`w-5 h-5`})}),(0,g.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,g.jsxs)(`div`,{className:`flex justify-between items-start gap-2`,children:[(0,g.jsx)(`h3`,{className:`text-slate-100 font-bold text-xs hover:text-purple-400 transition-colors cursor-pointer flex-1 whitespace-normal break-words line-clamp-2`,onClick:()=>_&&_(a),title:P,children:P}),I&&(0,g.jsx)(`button`,{onClick:()=>v&&v(a),className:`p-1 rounded bg-[#1f1f3a] hover:bg-purple-600 hover:text-white text-purple-400 transition-colors shrink-0`,title:`Nghe tóm tắt AI (TTS)`,children:(0,g.jsx)(d,{className:`w-3 h-3`})})]}),(0,g.jsxs)(`div`,{className:`text-[10px] text-slate-500 mt-1 space-y-0.5`,children:[(0,g.jsxs)(`div`,{className:`whitespace-normal break-words`,children:[`Tác giả: `,(0,g.jsx)(`span`,{onClick:()=>{let e=a.author||a.author_hanviet||F;e&&e!==`—`&&w(`/author/${encodeURIComponent(e)}`)},className:`text-purple-400 hover:text-purple-300 cursor-pointer underline hover:no-underline font-semibold`,title:`Xem hồ sơ tác giả`,children:F})]}),(0,g.jsxs)(`div`,{className:`whitespace-normal break-words line-clamp-1`,children:[`Gốc: `,(0,g.jsx)(`span`,{className:`text-slate-400`,children:a.title||`—`})]})]}),(0,g.jsxs)(`div`,{className:`flex flex-wrap gap-1 mt-1.5`,children:[(0,g.jsxs)(`span`,{className:`px-1.5 py-0.5 rounded text-[8px] font-extrabold bg-amber-500/10 border border-amber-500/20 text-amber-400`,children:[a.site_count||5,` nguồn`]}),(0,g.jsxs)(`span`,{className:`px-1.5 py-0.5 rounded text-[8px] font-extrabold bg-cyan-500/10 border border-cyan-500/20 text-cyan-400`,children:[a.site_count||5,` site`]}),(0,g.jsx)(`span`,{className:`px-1.5 py-0.5 rounded text-[8px] font-extrabold bg-emerald-500/10 border border-emerald-500/20 text-emerald-400`,children:a.word_count_max?Math.round(a.word_count_max/1e3):1975})]})]})]}),H.length>0&&(0,g.jsxs)(`div`,{className:`flex flex-wrap gap-1 mt-2.5 items-center`,children:[(0,g.jsx)(`span`,{className:`text-slate-500 text-[9px]`,children:`Thể loại:`}),H.map((e,t)=>(0,g.jsx)(`span`,{onClick:()=>b&&b(e),className:`cursor-pointer px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20 hover:bg-purple-500/20 text-purple-400 text-[8px] font-semibold transition-colors`,title:`Lọc thể loại ${e}`,children:e},t))]}),(0,g.jsxs)(`div`,{className:`flex justify-between items-center text-slate-400 text-[10px] mt-2.5 border-t border-[#1f1f3a]/40 pt-2`,children:[(0,g.jsxs)(`span`,{children:[(0,g.jsx)(`strong`,{children:`Chương:`}),` `,a.chapters_max||110,` | `,a.word_count_max?Math.round(a.word_count_max/1e3):0]}),I&&(0,g.jsx)(`button`,{onClick:()=>E(!T),className:`text-slate-500 hover:text-purple-400 text-[9px] flex items-center gap-0.5 font-bold transition-colors`,children:T?(0,g.jsxs)(g.Fragment,{children:[`Ẩn tóm tắt `,(0,g.jsx)(t,{className:`w-2.5 h-2.5`})]}):(0,g.jsxs)(g.Fragment,{children:[`Hiện tóm tắt `,(0,g.jsx)(n,{className:`w-2.5 h-2.5`})]})})]}),T&&I&&(0,g.jsxs)(`div`,{className:`mt-2.5 bg-[#0b0b14]/50 p-2 rounded-lg border border-[#1a1a2e] transition-all duration-300`,children:[(0,g.jsxs)(`div`,{className:`flex justify-between items-center mb-1 text-[9px] text-slate-500`,children:[(0,g.jsx)(`span`,{children:`Tóm tắt:`}),(0,g.jsxs)(`div`,{className:`flex gap-1`,children:[(0,g.jsx)(`button`,{onClick:()=>B(`original`),className:`px-1 py-0.5 rounded text-[8px] font-bold ${D===`original`?`bg-purple-600 text-white`:`hover:bg-white/5 text-slate-400`}`,children:`Gốc`}),(0,g.jsx)(`button`,{onClick:()=>B(`vi`),className:`px-1 py-0.5 rounded text-[8px] font-bold ${D===`vi`?`bg-purple-600 text-white`:`hover:bg-white/5 text-slate-400`}`,disabled:j,children:`🇻🇳`}),(0,g.jsx)(`button`,{onClick:()=>B(`en`),className:`px-1 py-0.5 rounded text-[8px] font-bold ${D===`en`?`bg-purple-600 text-white`:`hover:bg-white/5 text-slate-400`}`,disabled:j,children:`🇺🇸`})]})]}),(0,g.jsx)(`p`,{className:`text-slate-400 text-[10px] line-clamp-3 leading-relaxed`,title:I,children:j?`Đang dịch tóm tắt...`:L})]}),(0,g.jsxs)(`div`,{className:`flex gap-1.5 items-center mt-2.5 text-[9px]`,children:[(0,g.jsx)(`span`,{className:`text-slate-500`,children:`Cảm xúc:`}),z().map((e,t)=>(0,g.jsx)(`span`,{className:`px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/25 text-amber-400 font-extrabold text-[8px]`,children:e},t))]}),(0,g.jsx)(`div`,{className:`mt-2.5 relative`,children:(0,g.jsx)(`div`,{className:`flex flex-wrap gap-1`,children:R.map((e,t)=>{let n=`bg-emerald-500 text-white`;return e.site.toLowerCase().includes(`biquge`)||e.site.toLowerCase().includes(`full`)||e.site.toLowerCase().includes(`truyenfull`)?n=`bg-sky-500 text-white`:e.site.toLowerCase().includes(`faloo`)||e.site.toLowerCase().includes(`vcomi`)||e.site.toLowerCase().includes(`fanqie`)?n=`bg-orange-500 text-white`:(e.site.toLowerCase().includes(`quanben`)||e.site.toLowerCase().includes(`hjwzw`))&&(n=`bg-purple-500 text-white`),(0,g.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noreferrer`,onClick:t=>{t.preventDefault(),t.stopPropagation(),C(e.url)},className:`inline-flex items-center gap-1 bg-[#0b0b14]/40 border border-[#1f1f3a] hover:border-purple-500/30 rounded px-1.5 py-0.5 text-[9px] text-slate-300 hover:text-white transition-all`,children:[(0,g.jsx)(`span`,{className:`w-3 h-3 rounded flex items-center justify-center text-[8px] font-extrabold ${n}`,children:e.site[0]}),e.site]},t)})})})]}),(0,g.jsxs)(`div`,{className:`flex gap-2 mt-3 pt-2 border-t border-[#1f1f3a]/20`,children:[(0,g.jsxs)(`button`,{onClick:()=>l&&l(a.id),className:`flex-1 inline-flex items-center justify-center gap-1 bg-transparent border border-purple-500/30 text-purple-400 hover:bg-purple-600/10 py-2.5 sm:py-1.5 rounded-lg text-[10px] font-bold transition-all min-h-[36px]`,children:[(0,g.jsx)(m,{className:`w-3 h-3`}),` So sánh bản dịch`]}),(0,g.jsx)(`button`,{onClick:e=>{e.stopPropagation(),c&&c(a.id)},className:`px-3 py-2.5 sm:px-2 sm:py-1.5 border rounded-lg text-[10px] font-semibold transition-all min-h-[36px] ${o?`bg-amber-400 border-amber-400 text-[#0b0b14]`:`bg-amber-400/5 border-amber-400/30 text-amber-400 hover:bg-amber-400/15`}`,title:o?x.removeExternal:x.addBookshelf,children:(0,g.jsx)(r,{className:`w-3.5 h-3.5 fill-current`})})]})]})}export{_ as t};
|
frontend-web/dist/assets/BookDetail-CwKiYfhq.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import{t as e}from"./award-CKCOPIfL.js";import{f as t,l as n,t as r,u as ee}from"./MainLayout-COiTxmNr.js";import{t as i}from"./book-DwmTvSdZ.js";import{a}from"./square-DZKJ0QGR.js";import{n as o,r as s,t as c}from"./star-DjnKUekI.js";import{t as te}from"./eye-DsLuESBv.js";import{C as ne,D as re,F as l,M as u,O as d,S as f,a as p,b as m,f as ie,j as h,o as ae,t as oe,u as se,w as g}from"./index-yRoRoI6u.js";import{t as ce}from"./GoogleAd-CD0eT9Wp.js";var le=m(`circle-check`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),_=m(`thumbs-up`,[[`path`,{d:`M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z`,key:`emmmcr`}],[`path`,{d:`M7 10v12`,key:`1qc93n`}]]),v=l(h(),1),y=u();function b(){let{bookId:l}=d(),{user:u}=ne(),{t:m,lang:h}=f(),{openInBrowser:b}=oe(),x=re(),[S,C]=(0,v.useState)(null),[ue,w]=(0,v.useState)(!0),[T,E]=(0,v.useState)(``),[D,O]=(0,v.useState)(!1),[k,de]=(0,v.useState)([]),[A,j]=(0,v.useState)(``),[M,N]=(0,v.useState)(!1);(0,v.useEffect)(()=>{D&&u&&g.get(`/api/friends/list`).then(e=>{e.data&&e.data.friends&&de(e.data.friends)}).catch(e=>console.error(`Failed to load friends for sharing`,e))},[D,u]);let P=async e=>{N(!0);try{let t=await g.post(`/api/books/share`,{friend_id:e,book_id:S.id,message:A});t.data&&t.data.success&&(alert(h===`vi`?`Đã chia sẻ thành công!`:`Shared successfully!`),O(!1),j(``))}catch{alert(`Chia sẻ thất bại.`)}finally{N(!1)}},[F,I]=(0,v.useState)(!1),[L,R]=(0,v.useState)(!1),[z,B]=(0,v.useState)([]),[V,H]=(0,v.useState)(!0),[U,W]=(0,v.useState)([{id:1,user:`Lê Hoàng Nam`,avatar:`https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=80&auto=format&fit=crop&q=60`,rating:5,text:`Bản dịch AI của trang này chuẩn thật sự, đọc Hán Việt rất mượt mà. Mong nhóm update chương mới nhanh hơn nữa!`,time:`2 giờ trước`,likes:12},{id:2,user:`Nguyễn Thu Thảo`,avatar:`https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&auto=format&fit=crop&q=60`,rating:4,text:`Truyện hay, cốt truyện sát phạt quyết đoán đúng gu mình. Bản dịch máy thỉnh thoảng có vài từ Hán Việt chưa dịch nghĩa kỹ nhưng tổng thể vẫn rất dễ hiểu.`,time:`5 giờ trước`,likes:8},{id:3,user:`Trần Minh Đức`,avatar:`https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&auto=format&fit=crop&q=60`,rating:5,text:`So sánh bản dịch Metruyenchu với trang này thì bản ở đây sạch QC hơn nhiều. Giao diện đọc truyện tối ưu tốt trên di động.`,time:`1 ngày trước`,likes:19}]),[G,K]=(0,v.useState)(``),[q,J]=(0,v.useState)(5),[fe,pe]=(0,v.useState)(new Set);(0,v.useEffect)(()=>{me(),he(),ge()},[l,u]);let Y=async e=>{if(!(!u||!e))try{await g.post(`/api/history/add`,{book_id:e.id,last_chapter:h===`vi`?`Đang xem`:h===`en`?`Viewing`:`浏览中`})}catch{}},me=async()=>{w(!0),E(``);try{let e=await g.get(`/api/book/${l}`);e.data&&(C(e.data),Y(e.data))}catch{try{let e=(await g.get(`/api/books`,{params:{q:``,page:1,per_page:100}})).data.books.find(e=>e.id===parseInt(l));if(e)C(e),Y(e);else{let e=await g.get(`/api/book/${l}/translations`);if(e.data){let t={id:parseInt(l),title_vietphrase:e.data.vietphrase.title,title:e.data.hanviet.title,description_vietphrase:e.data.vietphrase.desc,author_hanviet:h===`vi`?`Tác giả`:h===`en`?`Author`:`作者`,cover:``,parsed_sources:[]};C(t),Y(t)}}}catch{E(h===`vi`?`Không tải được thông tin truyện.`:h===`en`?`Failed to load book details.`:`无法加载小说详情。`)}}finally{w(!1)}},X=(()=>{if(!S)return[];let e=[];if(S.parsed_sources&&S.parsed_sources.length>0)S.parsed_sources.forEach(t=>{e.push({site:t.source,url:t.url})});else if(S.urls){let t=S.urls.split(` | `);for(let n of t){let t=n.indexOf(`:`);t>0&&e.push({site:n.substring(0,t).trim(),url:n.substring(t+1).trim()})}}return e})(),he=async()=>{H(!0);try{B(Array.from({length:50},(e,t)=>({id:t+1,title:h===`vi`?`Chương ${t+1}: Tiết tử và khởi nguyên`:h===`en`?`Chapter ${t+1}: Prologue`:`第 ${t+1} 章: 楔子与起源`,url_idx:t+1})))}catch(e){console.error(e)}finally{H(!1)}},ge=async()=>{if(u)try{I((await g.get(`/api/bookshelf`)).data.some(e=>e.book_id===parseInt(l)))}catch(e){console.error(e)}},_e=async()=>{if(!u){alert(h===`vi`?`Vui lòng đăng nhập để lưu sách.`:h===`en`?`Please log in to save books.`:`请先登录以收藏小说。`);return}let e=F?`/api/bookshelf/remove`:`/api/bookshelf/add`;try{await g.post(e,{book_id:parseInt(l)}),I(!F)}catch{alert(h===`vi`?`Lỗi cập nhật tủ sách.`:h===`en`?`Error updating bookshelf.`:`更新书架时出错。`)}},ve=e=>{pe(t=>{let n=new Set(t);return n.has(e)?(n.delete(e),W(U.map(t=>t.id===e?{...t,likes:t.likes-1}:t))):(n.add(e),W(U.map(t=>t.id===e?{...t,likes:t.likes+1}:t))),n})},Z=e=>{e.preventDefault(),G.trim()&&(W([{id:Date.now(),user:u?u.name||u.email.split(`@`)[0]:h===`vi`?`Khách vãng lai`:h===`en`?`Guest user`:`访客`,avatar:`https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&auto=format&fit=crop&q=60`,rating:q,text:G,time:h===`vi`?`Vừa xong`:h===`en`?`Just now`:`刚刚`,likes:0},...U]),K(``),J(5))};if(ue)return(0,y.jsx)(r,{children:(0,y.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,y.jsx)(ie,{className:`w-8 h-8 animate-spin mx-auto mb-2 text-brand-500`}),(0,y.jsx)(`span`,{children:h===`vi`?`Đang tải thông tin truyện...`:h===`en`?`Loading book details...`:`正在加载小说信息...`})]})});if(T||!S)return(0,y.jsx)(r,{children:(0,y.jsx)(`div`,{className:`py-20 text-center text-red-500 font-bold`,children:T||(h===`vi`?`Không tìm thấy truyện.`:h===`en`?`Book not found.`:`未找到小说。`)})});let ye=(()=>{if(h===`zh`||h===`en`)return S.title||`—`;let e=S.title_vietphrase||S.title_hanviet;return e&&S.title&&e!==S.title?`${e} (${S.title})`:e||S.title||`—`})(),be=h===`zh`?S.author||`—`:h===`en`?S.author_english||S.author||`—`:S.author_hanviet||S.author||`—`,Q=h===`zh`?S.description||`Chưa có tóm tắt.`:h===`en`?S.description_english||S.description||`No synopsis available.`:S.description_vietphrase||S.description||`Chưa có tóm tắt.`,$=h===`zh`?S.categories||``:h===`en`?S.categories_english||S.categories||``:S.categories_vietphrase||S.categories||``,xe=$?$.split(/[,,/、\s]+/).map(e=>e.trim()).filter(Boolean):[];return(0,y.jsxs)(r,{children:[(0,y.jsxs)(`div`,{className:`relative rounded-3xl overflow-hidden border border-[#1f1f3a] mb-8 bg-[#0b0b14]/70 p-6 md:p-8 flex flex-col md:flex-row gap-6 items-center md:items-start z-0`,children:[S.cover&&(0,y.jsx)(`div`,{className:`absolute inset-0 z-[-1] opacity-15 blur-2xl scale-110 bg-cover bg-center`,style:{backgroundImage:`url(${S.cover})`}}),S.cover?(0,y.jsx)(`img`,{src:S.cover,alt:`cover`,className:`w-[120px] md:w-[150px] h-[165px] md:h-[210px] object-cover rounded-xl border-2 border-[#1f1f3a]/80 shadow-2xl shrink-0`}):(0,y.jsx)(`div`,{className:`w-[120px] md:w-[150px] h-[165px] md:h-[210px] bg-[#121225] border-2 border-[#1f1f3a]/80 rounded-xl flex items-center justify-center text-slate-500 shrink-0`,children:(0,y.jsx)(i,{className:`w-12 h-12`})}),(0,y.jsxs)(`div`,{className:`flex-1 min-w-0 text-center md:text-left space-y-3`,children:[(0,y.jsx)(`h2`,{className:`text-xl md:text-2xl font-black text-white leading-tight`,children:ye}),h===`vi`&&(0,y.jsxs)(`p`,{className:`text-slate-400 text-xs font-semibold`,children:[`Hán Việt: `,S.title_hanviet||`—`,` · Gốc Trung: `,S.title]}),(0,y.jsxs)(`p`,{className:`text-purple-400 text-sm md:text-base font-bold`,children:[`✍ `,h===`vi`?`Tác giả`:h===`en`?`Author`:`作者`,`:`,` `,(0,y.jsx)(`span`,{onClick:()=>{let e=S.author||S.author_hanviet;e&&e!==`—`&&x(`/author/${encodeURIComponent(e)}`)},className:`cursor-pointer hover:underline text-purple-300 font-extrabold hover:text-purple-400 transition-colors`,children:be})]}),(0,y.jsx)(`div`,{className:`flex flex-wrap justify-center md:justify-start gap-2 py-1`,children:xe.map((e,t)=>(0,y.jsx)(`span`,{className:`px-3 py-1 bg-purple-500/10 border border-purple-500/20 text-purple-300 rounded-full text-xs font-semibold`,children:e},t))}),X&&X.length>0&&(0,y.jsxs)(`div`,{className:`flex flex-col gap-1.5 py-1 text-left`,children:[(0,y.jsx)(`span`,{className:`text-slate-500 text-[10px] font-bold block`,children:h===`vi`?`Nguồn gốc:`:`Sources:`}),(0,y.jsx)(`div`,{className:`flex flex-wrap gap-1.5 relative`,children:X.map((e,t)=>{let n=`bg-emerald-500 text-white`;return e.site.toLowerCase().includes(`biquge`)||e.site.toLowerCase().includes(`full`)||e.site.toLowerCase().includes(`truyenfull`)?n=`bg-sky-500 text-white`:e.site.toLowerCase().includes(`faloo`)||e.site.toLowerCase().includes(`vcomi`)||e.site.toLowerCase().includes(`fanqie`)?n=`bg-orange-500 text-white`:(e.site.toLowerCase().includes(`quanben`)||e.site.toLowerCase().includes(`hjwzw`))&&(n=`bg-purple-500 text-white`),(0,y.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noreferrer`,onClick:t=>{t.preventDefault(),t.stopPropagation(),b(e.url)},className:`inline-flex items-center gap-1 bg-[#0b0b14]/40 border border-[#1f1f3a] hover:border-purple-500/30 rounded px-2 py-1 text-[10px] text-slate-300 hover:text-white transition-all hover:scale-[1.02]`,title:e.url,children:[(0,y.jsx)(`span`,{className:`w-3.5 h-3.5 rounded flex items-center justify-center text-[9px] font-extrabold ${n}`,children:e.site[0]}),e.site]},t)})})]}),(0,y.jsxs)(`div`,{className:`flex flex-wrap justify-center md:justify-start gap-3 pt-3`,children:[(0,y.jsxs)(`button`,{onClick:()=>x(`/book/${S.id}/read/1`),className:`inline-flex items-center gap-1.5 bg-gradient-to-r from-purple-500 to-indigo-600 text-white px-6 py-3 rounded-xl text-xs font-bold shadow-lg shadow-purple-500/20 hover:brightness-105 active:scale-95 transition-all`,children:[(0,y.jsx)(se,{className:`w-4 h-4 fill-current`}),` `,h===`vi`?`Bắt đầu đọc`:h===`en`?`Start Reading`:`开始阅读`]}),(0,y.jsxs)(`button`,{onClick:_e,className:`inline-flex items-center gap-1.5 px-6 py-3 rounded-xl text-xs font-bold transition-all border ${F?`bg-amber-400 border-amber-400 text-[#0b0b14]`:`bg-white/5 border-white/10 hover:bg-white/10 text-slate-300`}`,children:[(0,y.jsx)(c,{className:`w-4 h-4 fill-current`}),F?h===`vi`?`Đã lưu vào tủ`:h===`en`?`Saved in Shelf`:`已收藏`:h===`vi`?`Thêm vào tủ`:h===`en`?`Save to Shelf`:`收藏`]}),X&&X.length>0&&(0,y.jsxs)(`a`,{href:X[0].url,target:`_blank`,rel:`noopener noreferrer`,onClick:e=>{e.preventDefault(),b(X[0].url)},className:`inline-flex items-center gap-1.5 px-6 py-3 bg-[#0b0b14]/50 border border-purple-500/30 hover:bg-purple-500/10 text-purple-300 rounded-xl text-xs font-bold transition-all hover:scale-[1.02]`,children:[(0,y.jsx)(a,{className:`w-4 h-4`}),h===`vi`?`Trang gốc (Web thật)`:`Source Web`]}),u&&(0,y.jsxs)(`button`,{onClick:()=>O(!0),className:`inline-flex items-center gap-1.5 px-6 py-3 bg-purple-600/20 border border-purple-500/30 hover:bg-purple-600/30 text-purple-200 rounded-xl text-xs font-bold transition-all hover:scale-[1.02]`,children:[(0,y.jsx)(n,{className:`w-4 h-4`}),h===`vi`?`Chia sẻ với bạn bè`:`Share with Friends`]})]})]})]}),(0,y.jsxs)(`div`,{className:`grid grid-cols-1 lg:grid-cols-3 gap-8 items-start`,children:[(0,y.jsxs)(`div`,{className:`lg:col-span-2 space-y-8`,children:[(0,y.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6`,children:[(0,y.jsx)(`h3`,{className:`text-base font-extrabold text-white mb-3`,children:`Tóm tắt nội dung`}),(0,y.jsxs)(`div`,{className:`text-slate-300 text-xs leading-relaxed space-y-2 relative`,children:[(0,y.jsx)(`p`,{className:L?``:`line-clamp-4`,children:Q}),Q&&(0,y.jsx)(`button`,{onClick:()=>R(!L),className:`mt-2 text-purple-400 font-bold inline-flex items-center gap-1 text-[11px] hover:underline`,children:L?(0,y.jsxs)(y.Fragment,{children:[`Thu gọn `,(0,y.jsx)(o,{className:`w-3.5 h-3.5`})]}):(0,y.jsxs)(y.Fragment,{children:[`Xem thêm `,(0,y.jsx)(s,{className:`w-3.5 h-3.5`})]})})]})]}),(0,y.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6`,children:[(0,y.jsx)(`h3`,{className:`text-base font-extrabold text-white mb-4`,children:`Danh sách chương`}),V?(0,y.jsx)(`div`,{className:`text-center text-slate-500 py-6`,children:`Đang tải danh sách chương...`}):(0,y.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[380px] overflow-y-auto pr-1 custom-scrollbar`,children:z.map(e=>(0,y.jsx)(`button`,{onClick:()=>x(`/book/${S.id}/read/${e.url_idx}`),className:`text-left px-4 py-3 bg-[#0b0b14]/50 hover:bg-purple-500/10 border border-[#1f1f3a] hover:border-purple-500/30 rounded-xl text-xs text-slate-300 hover:text-purple-300 transition-all truncate`,children:e.title},e.id))})]}),(0,y.jsx)(ce,{slot:`book-detail-bottom`}),(0,y.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 space-y-6`,children:[(0,y.jsxs)(`div`,{className:`flex justify-between items-center border-b border-[#1f1f3a]/60 pb-4`,children:[(0,y.jsxs)(`h3`,{className:`text-base font-extrabold text-white flex items-center gap-2`,children:[(0,y.jsx)(t,{className:`w-4.5 h-4.5 text-purple-400`}),` Bình luận & Đánh giá (`,U.length,`)`]}),(0,y.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,y.jsx)(`span`,{className:`text-amber-400 font-bold text-sm`,children:`⭐ 4.8`}),(0,y.jsx)(`span`,{className:`text-slate-500 text-[11px]`,children:`(145 bình chọn)`})]})]}),(0,y.jsxs)(`form`,{onSubmit:Z,className:`bg-[#0b0b14]/40 border border-[#1f1f3a] p-4 rounded-2xl space-y-3`,children:[(0,y.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,y.jsx)(`span`,{className:`text-xs text-slate-400 font-semibold`,children:`Viết nhận xét của bạn:`}),(0,y.jsxs)(`div`,{className:`flex items-center gap-1`,children:[(0,y.jsx)(`span`,{className:`text-[11px] text-slate-500 mr-1`,children:`Đánh giá sao:`}),[1,2,3,4,5].map(e=>(0,y.jsx)(`button`,{type:`button`,onClick:()=>J(e),className:`text-sm transition-colors ${e<=q?`text-amber-400`:`text-slate-600 hover:text-slate-500`}`,children:`★`},e))]})]}),(0,y.jsx)(`div`,{className:`relative`,children:(0,y.jsx)(`textarea`,{value:G,onChange:e=>K(e.target.value),placeholder:`Nhập cảm nhận của bạn về bản dịch, cốt truyện...`,rows:`3`,className:`w-full bg-[#121225] border border-[#1f1f3a] rounded-xl px-4 py-3 text-white text-xs outline-none focus:border-purple-500/50 transition-colors resize-none placeholder-slate-600`})}),(0,y.jsx)(`div`,{className:`flex justify-end`,children:(0,y.jsxs)(`button`,{type:`submit`,disabled:!G.trim(),className:`inline-flex items-center gap-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/30 text-white px-4 py-2 rounded-xl text-xs font-bold transition-all`,children:[(0,y.jsx)(ee,{className:`w-3.5 h-3.5`}),` Gửi bình luận`]})})]}),(0,y.jsx)(`div`,{className:`space-y-4`,children:U.map(e=>(0,y.jsxs)(`div`,{className:`border-b border-[#1f1f3a]/40 pb-4 last:border-b-0 last:pb-0 flex gap-3.5`,children:[(0,y.jsx)(`img`,{src:e.avatar,alt:e.user,className:`w-9 h-9 rounded-full object-cover border border-[#2d2d55] bg-[#0f0f1a] shrink-0`}),(0,y.jsxs)(`div`,{className:`flex-1 space-y-1.5`,children:[(0,y.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,y.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,y.jsx)(`span`,{className:`text-xs font-bold text-slate-100`,children:e.user}),(0,y.jsx)(`div`,{className:`flex text-amber-400 text-[10px]`,children:Array.from({length:5},(t,n)=>(0,y.jsx)(`span`,{children:n<e.rating?`★`:`☆`},n))})]}),(0,y.jsx)(`span`,{className:`text-[10px] text-slate-500`,children:e.time})]}),(0,y.jsx)(`p`,{className:`text-slate-300 text-xs leading-relaxed`,children:e.text}),(0,y.jsx)(`div`,{className:`flex items-center gap-3 pt-1`,children:(0,y.jsxs)(`button`,{onClick:()=>ve(e.id),className:`inline-flex items-center gap-1 text-[10px] font-semibold transition-colors ${fe.has(e.id)?`text-purple-400`:`text-slate-500 hover:text-slate-400`}`,children:[(0,y.jsx)(_,{className:`w-3 h-3`}),` Hữu ích (`,e.likes,`)`]})})]})]},e.id))})]})]}),(0,y.jsxs)(`div`,{className:`space-y-6`,children:[(0,y.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 space-y-5`,children:[(0,y.jsxs)(`h3`,{className:`text-sm font-extrabold text-white border-b border-[#1f1f3a] pb-2.5 flex items-center gap-2`,children:[(0,y.jsx)(e,{className:`w-4 h-4 text-purple-400`}),` Thống kê & Chi tiết`]}),(0,y.jsxs)(`div`,{className:`space-y-3.5`,children:[(0,y.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 flex items-center gap-1.5`,children:[(0,y.jsx)(le,{className:`w-3.5 h-3.5 text-purple-400`}),` Trạng thái:`]}),(0,y.jsx)(`span`,{className:`text-emerald-400 font-bold`,children:`Đang cập nhật`})]}),(0,y.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 flex items-center gap-1.5`,children:[(0,y.jsx)(te,{className:`w-3.5 h-3.5 text-purple-400`}),` Tổng lượt xem:`]}),(0,y.jsx)(`span`,{className:`text-slate-200 font-bold`,children:S.word_count_max?Math.round(S.word_count_max*1.5).toLocaleString():`340,500`})]}),(0,y.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 flex items-center gap-1.5`,children:[(0,y.jsx)(p,{className:`w-3.5 h-3.5 text-purple-400`}),` Tốc độ ra chương:`]}),(0,y.jsx)(`span`,{className:`text-slate-200 font-bold`,children:`~12 chương / ngày`})]}),(0,y.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 flex items-center gap-1.5`,children:[(0,y.jsx)(c,{className:`w-3.5 h-3.5 text-purple-400`}),` Độ tin cậy nguồn:`]}),(0,y.jsx)(`span`,{className:`text-indigo-400 font-bold`,children:`99.1% (Sạch QC)`})]}),(0,y.jsxs)(`div`,{className:`flex flex-col gap-2 pt-2 border-t border-[#1f1f3a]/30`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 text-xs flex items-center gap-1.5`,children:[(0,y.jsx)(i,{className:`w-3.5 h-3.5 text-purple-400`}),` Đọc ở trang gốc (Web thật):`]}),(0,y.jsx)(`div`,{className:`flex flex-wrap gap-1.5 pt-1`,children:X&&X.length>0?X.map((e,t)=>{let n=`bg-emerald-500 text-white`;return e.site.toLowerCase().includes(`biquge`)||e.site.toLowerCase().includes(`full`)||e.site.toLowerCase().includes(`truyenfull`)?n=`bg-sky-500 text-white`:e.site.toLowerCase().includes(`faloo`)||e.site.toLowerCase().includes(`vcomi`)||e.site.toLowerCase().includes(`fanqie`)?n=`bg-orange-500 text-white`:(e.site.toLowerCase().includes(`quanben`)||e.site.toLowerCase().includes(`hjwzw`))&&(n=`bg-purple-500 text-white`),(0,y.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noopener noreferrer`,onClick:t=>{t.preventDefault(),b(e.url)},className:`inline-flex items-center gap-1 bg-[#0b0b14]/50 border border-[#1f1f3a] hover:border-purple-500/40 rounded px-2 py-1 text-[10px] text-slate-300 hover:text-purple-300 transition-all hover:scale-[1.02]`,title:e.url,children:[(0,y.jsx)(`span`,{className:`w-3.5 h-3.5 rounded flex items-center justify-center text-[9px] font-black ${n}`,children:e.site[0]}),e.site,(0,y.jsx)(a,{className:`w-2.5 h-2.5 text-slate-500`})]},t)}):(0,y.jsx)(`span`,{className:`text-slate-500 italic text-[11px]`,children:`Metruyenchu, Biquge (Không tìm thấy link)`})})]})]})]}),(0,y.jsxs)(`div`,{className:`bg-gradient-to-br from-purple-900/35 to-indigo-950/40 border border-purple-500/20 rounded-3xl p-6 text-center space-y-3.5`,children:[(0,y.jsx)(`div`,{className:`w-10 h-10 rounded-2xl bg-purple-500/10 border border-purple-500/25 flex items-center justify-center mx-auto text-purple-400`,children:(0,y.jsx)(p,{className:`w-5 h-5 fill-current animate-pulse`})}),(0,y.jsxs)(`div`,{children:[(0,y.jsx)(`h4`,{className:`text-xs font-black text-white`,children:`Dịch thuật bởi Antigravity AI`}),(0,y.jsx)(`p`,{className:`text-[10px] text-slate-400 mt-1 leading-relaxed`,children:`Ứng dụng bộ dịch thuật mạng nơ-ron nâng cao giúp chuyển ngữ chính xác ngữ cảnh Hán Việt sang Việt ngữ.`})]})]})]})]}),D&&(0,y.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm`,children:(0,y.jsxs)(`div`,{className:`bg-[#0c0d1e] border border-purple-500/35 rounded-3xl p-6 w-full max-w-sm text-slate-100 shadow-2xl relative animate-fadeIn`,children:[(0,y.jsx)(`button`,{onClick:()=>{O(!1),j(``)},className:`absolute top-4 right-4 p-1 hover:bg-white/5 rounded-lg text-slate-400 hover:text-white transition-all`,children:(0,y.jsx)(ae,{className:`w-4 h-4`})}),(0,y.jsx)(`h3`,{className:`font-extrabold text-sm tracking-wider uppercase mb-4 text-purple-300`,children:`Chia sẻ truyện`}),(0,y.jsxs)(`div`,{className:`space-y-3`,children:[(0,y.jsx)(`label`,{className:`block text-[10px] uppercase font-black tracking-widest text-slate-500`,children:`Lời nhắn kèm theo`}),(0,y.jsx)(`input`,{type:`text`,placeholder:`Ví dụ: Truyện này hay lắm, đọc đi!`,value:A,onChange:e=>j(e.target.value),className:`w-full px-3 py-2 bg-[#080814] border border-purple-500/20 rounded-xl text-xs text-white outline-none focus:border-purple-500 transition-colors`})]}),(0,y.jsxs)(`div`,{className:`space-y-2 mt-4`,children:[(0,y.jsx)(`label`,{className:`block text-[10px] uppercase font-black tracking-widest text-slate-500 mb-1`,children:`Chọn bạn bè`}),k.length===0?(0,y.jsx)(`div`,{className:`text-center py-6 border border-dashed border-purple-500/10 rounded-2xl text-xs text-slate-500`,children:`Chưa có bạn bè nào. Vui lòng kết bạn trước.`}):(0,y.jsx)(`div`,{className:`max-h-[200px] overflow-y-auto space-y-2 pr-1 custom-scrollbar`,children:k.map(e=>(0,y.jsxs)(`div`,{className:`flex items-center justify-between p-2 hover:bg-purple-950/20 border border-purple-500/10 rounded-xl`,children:[(0,y.jsx)(`span`,{className:`text-xs font-bold text-slate-200`,children:e.username}),(0,y.jsx)(`button`,{onClick:()=>P(e.id),disabled:M,className:`px-3 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-[10px] font-bold transition-colors disabled:opacity-50`,children:`Gửi`})]},e.id))})]})]})})]})}export{b as default};
|
frontend-web/dist/assets/Bookshelf-ClI6d9JY.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import{t as e}from"./MainLayout-COiTxmNr.js";import{n as t,t as n}from"./square-DZKJ0QGR.js";import{t as r}from"./BookCard-BCMDdlyN.js";import{t as i}from"./square-check-big-BhcFiAg6.js";import{t as a}from"./trash-2-DDhTqVwA.js";import{C as o,D as s,F as c,M as l,S as u,f as d,j as f,t as p,w as m,y as h}from"./index-yRoRoI6u.js";var g=c(f(),1),_=l();function v(){let{t:c,lang:l}=u(),{user:f,loading:v}=o(),y=s(),[b,x]=(0,g.useState)([]),[S,C]=(0,g.useState)(``),[w,T]=(0,g.useState)(!0),[E,D]=(0,g.useState)(!1),[O,k]=(0,g.useState)(new Set),{activeAudioObj:A,setActiveAudioObj:j}=p(),[M,N]=(0,g.useState)(null),[P,F]=(0,g.useState)(null),[I,L]=(0,g.useState)(!1);(0,g.useEffect)(()=>{if(!v){if(!f){T(!1);return}R()}},[f,v,S]);let R=async()=>{T(!0);try{let e=S?{q:S}:{};x((await m.get(`/api/bookshelf`,{params:e})).data.map(e=>({id:e.book_id||e.url,title_vietphrase:e.title,author_hanviet:e.author,cover:e.cover,url:e.url,site_count:1})))}catch(e){console.error(e)}finally{T(!1)}},z=e=>{k(t=>{let n=new Set(t);return n.has(e)?n.delete(e):n.add(e),n})},B=()=>{O.size===b.length?k(new Set):k(new Set(b.map(e=>e.id).filter(Boolean)))},V=async()=>{if(window.confirm(l===`vi`?`Bạn có chắc chắn muốn xóa ${O.size} truyện đã chọn khỏi tủ sách cá nhân không?`:l===`en`?`Are you sure you want to remove ${O.size} selected novels from bookshelf?`:`您确定要从书架中移出选中的 ${O.size} 部小说吗?`)){T(!0);try{await Promise.all(Array.from(O).map(e=>typeof e==`string`&&(e.startsWith(`http://`)||e.startsWith(`https://`))?m.post(`/api/bookshelf/remove`,{url:e}):m.post(`/api/bookshelf/remove`,{book_id:e}))),k(new Set),D(!1),await R()}catch{alert(l===`vi`?`Không xóa được sách hàng loạt.`:`Failed to perform bulk remove.`)}finally{T(!0),await R()}}},H=async e=>{if(window.confirm(l===`vi`?`Bạn có chắc chắn muốn xóa truyện này khỏi tủ sách cá nhân không?`:l===`en`?`Are you sure you want to remove this novel from your personal bookshelf?`:`您确定要从个人书架中移出这部小说吗?`))try{typeof e==`string`&&(e.startsWith(`http://`)||e.startsWith(`https://`))?await m.post(`/api/bookshelf/remove`,{url:e}):await m.post(`/api/bookshelf/remove`,{book_id:e}),x(t=>t.filter(t=>t.id!==e))}catch{alert(l===`vi`?`Không xóa được sách.`:l===`en`?`Failed to remove novel.`:`无法移出书架。`)}},U=e=>{y(`/?search_field=author&q=${encodeURIComponent(e)}`)},W=e=>{y(`/?category=${encodeURIComponent(e)}`)},G=e=>{e.id&&y(`/book/${e.id}`)},K=async e=>{if(M===e){N(null),F(null);return}N(e),L(!0);try{F((await m.get(`/api/book/${e}/translations`)).data)}catch(e){console.error(e)}finally{L(!1)}},q=async e=>{if(e.description)j(e);else try{let t=await m.get(`/api/book/${e.id}/translations`);if(t.data){let n=t.data.advanced?.desc||t.data.fast?.desc||t.data.vietphrase?.desc||``;j({...e,description:n})}else j(e)}catch{j(e)}};return v?(0,_.jsx)(e,{children:(0,_.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,_.jsx)(d,{className:`w-8 h-8 animate-spin mx-auto mb-2 text-brand-500`}),(0,_.jsx)(`span`,{children:l===`vi`?`Đang tải thông tin...`:l===`en`?`Loading info...`:`正在加载信息...`})]})}):f?(0,_.jsxs)(e,{children:[(0,_.jsxs)(`div`,{className:`flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6`,children:[(0,_.jsxs)(`div`,{className:`flex flex-wrap items-center gap-3`,children:[(0,_.jsxs)(`h2`,{className:`text-xl font-bold flex items-center gap-2`,children:[(0,_.jsx)(h,{className:`w-6 h-6 text-brand-400`}),l===`vi`?`Tủ Sách Cá Nhân`:l===`en`?`Personal Bookshelf`:`个人书架`,` (`,b.length,`)`]}),b.length>0&&(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(`button`,{onClick:()=>{D(!E),k(new Set)},className:`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${E?`bg-purple-600/25 border-purple-500/50 text-purple-300 hover:bg-purple-600/35`:`bg-white/5 border-white/10 text-slate-300 hover:bg-white/10`}`,children:E?l===`vi`?`Thoát quản lý`:`Exit management`:l===`vi`?`Quản lý tủ sách`:`Manage bookshelf`}),E&&(0,_.jsxs)(_.Fragment,{children:[(0,_.jsx)(`button`,{onClick:B,className:`px-3 py-1.5 bg-[#121225] border border-[#1f1f3a] rounded-lg text-xs font-bold text-slate-300 hover:bg-[#1a1a35] transition-all`,children:O.size===b.length?l===`vi`?`Hủy chọn tất cả`:`Deselect all`:l===`vi`?`Chọn tất cả`:`Select all`}),O.size>0&&(0,_.jsxs)(`button`,{onClick:V,className:`inline-flex items-center gap-1.5 px-3 py-1.5 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-rose-950/20 transition-all active:scale-95 cursor-pointer`,children:[(0,_.jsx)(a,{className:`w-3.5 h-3.5`}),l===`vi`?`Xóa hàng loạt (${O.size})`:`Bulk Delete (${O.size})`]})]})]})]}),(0,_.jsxs)(`div`,{className:`relative w-full md:w-72`,children:[(0,_.jsx)(t,{className:`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500`}),(0,_.jsx)(`input`,{type:`text`,placeholder:l===`vi`?`Tìm trong tủ sách...`:l===`en`?`Search bookshelf...`:`在书架中搜索...`,value:S,onChange:e=>C(e.target.value),className:`w-full pl-9 pr-4 py-2.5 bg-[#121225] border border-[#1f1f3a] rounded-xl text-white outline-none focus:border-brand-500 transition-colors text-xs`})]})]}),w?(0,_.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,_.jsx)(d,{className:`w-6 h-6 animate-spin mx-auto mb-2`}),(0,_.jsx)(`span`,{children:c.loading})]}):b.length===0?(0,_.jsxs)(`div`,{className:`py-20 text-center text-slate-500 bg-[#121225]/40 border border-dashed border-[#1f1f3a] rounded-2xl`,children:[(0,_.jsx)(h,{className:`w-10 h-10 mx-auto mb-3 text-slate-600`}),(0,_.jsx)(`p`,{className:`text-sm`,children:l===`vi`?`Tủ sách trống. Hãy thêm truyện từ tab Khám Phá!`:l===`en`?`Bookshelf is empty. Add novels from Discover tab!`:`书架空空如也。请从“发现”选项卡中添加小说!`})]}):(0,_.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4`,children:b.map(e=>{let t=O.has(e.id);return(0,_.jsxs)(`div`,{className:`flex flex-col relative group`,children:[E&&(0,_.jsx)(`div`,{onClick:()=>z(e.id),className:`absolute top-3 left-3 z-30 w-7 h-7 rounded-lg border flex items-center justify-center cursor-pointer transition-all shadow-md ${t?`bg-purple-600 border-purple-500 text-white shadow-purple-500/20`:`bg-[#0f101f]/95 border-slate-700/80 text-slate-500 hover:bg-purple-950/25 hover:border-purple-500/50`}`,children:t?(0,_.jsx)(i,{className:`w-4.5 h-4.5`}):(0,_.jsx)(n,{className:`w-4.5 h-4.5`})}),(0,_.jsx)(`div`,{className:E?`opacity-70 transition-opacity`:``,children:(0,_.jsx)(r,{book:e,isFav:!0,onToggleFav:H,onRead:G,onCompare:K,onPlayTrailer:q,onSearchAuthor:U,onSearchCategory:W})}),M===e.id&&(0,_.jsx)(`div`,{className:`bg-[#0f101f] border border-[#1f1f3a] rounded-b-2xl p-4 text-xs mt-[-10px] space-y-4 shadow-inner`,children:I?(0,_.jsx)(`div`,{className:`text-center text-slate-500 py-3`,children:c.comparingText}):P?(0,_.jsxs)(_.Fragment,{children:[(0,_.jsxs)(`div`,{className:`pb-2 border-b border-white/5`,children:[(0,_.jsx)(`span`,{className:`text-[10px] text-brand-400 font-extrabold uppercase`,children:c.compFast}),(0,_.jsx)(`div`,{className:`text-white font-bold mt-0.5`,children:P.fast.title||`—`}),(0,_.jsx)(`p`,{className:`text-slate-400 text-[11px] leading-relaxed mt-1`,children:P.fast.desc||`—`})]}),(0,_.jsxs)(`div`,{className:`pb-2 border-b border-white/5`,children:[(0,_.jsx)(`span`,{className:`text-[10px] text-amber-400 font-extrabold uppercase`,children:c.compAdvanced}),(0,_.jsx)(`div`,{className:`text-white font-bold mt-0.5`,children:P.advanced.title||`—`}),(0,_.jsx)(`p`,{className:`text-slate-400 text-[11px] leading-relaxed mt-1`,children:P.advanced.desc||`—`})]}),(0,_.jsxs)(`div`,{className:`pb-2 border-b border-white/5`,children:[(0,_.jsx)(`span`,{className:`text-[10px] text-emerald-400 font-extrabold uppercase`,children:c.compVietphrase}),(0,_.jsx)(`div`,{className:`text-white font-bold mt-0.5`,children:P.vietphrase.title||`—`}),(0,_.jsx)(`p`,{className:`text-slate-400 text-[11px] leading-relaxed mt-1`,children:P.vietphrase.desc||`—`})]}),(0,_.jsxs)(`div`,{children:[(0,_.jsx)(`span`,{className:`text-[10px] text-indigo-400 font-extrabold uppercase`,children:c.compHanviet}),(0,_.jsx)(`div`,{className:`text-white font-bold mt-0.5`,children:P.hanviet.title||`—`}),(0,_.jsx)(`p`,{className:`text-slate-400 text-[11px] leading-relaxed mt-1`,children:P.hanviet.desc||`—`})]})]}):(0,_.jsx)(`div`,{className:`text-center text-red-500 py-3`,children:c.compError})})]},e.id||e.url)})})]}):(0,_.jsx)(e,{children:(0,_.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,_.jsx)(`div`,{className:`w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center text-slate-400 mx-auto mb-4`,children:`🔒`}),(0,_.jsx)(`p`,{className:`text-sm`,children:c.loginToViewBookshelf})]})})}export{v as default};
|
frontend-web/dist/assets/Developer-BFnP9Lg5.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import{t as e}from"./award-CKCOPIfL.js";import{d as t,n,s as r,t as i,u as ee}from"./MainLayout-COiTxmNr.js";import{o as a}from"./square-DZKJ0QGR.js";import{t as o}from"./plus-CSQqtn3N.js";import{t as s}from"./trash-2-DDhTqVwA.js";import{C as c,F as l,M as u,S as d,d as f,g as p,j as m,p as h,u as te,w as g}from"./index-yRoRoI6u.js";var _=l(m(),1),v=u();function y(){let{user:l}=c(),{t:u,lang:m}=d(),[y,b]=(0,_.useState)([]),[x,S]=(0,_.useState)(!1),[ne,C]=(0,_.useState)(0),[w,T]=(0,_.useState)(``),[E,D]=(0,_.useState)(!1),[O,k]=(0,_.useState)(!1),[re,A]=(0,_.useState)(``),[j,ie]=(0,_.useState)([]),[ae,M]=(0,_.useState)(!1),[N,P]=(0,_.useState)(``),[F,I]=(0,_.useState)(`武之极,破苍穹,动乾坤!Trong thế giới này, kẻ mạnh làm chủ.`),[L,R]=(0,_.useState)(1),[z,B]=(0,_.useState)(!1),[V,H]=(0,_.useState)(!1),U=(0,_.useRef)(null),[W,G]=(0,_.useState)(`第1章 开封神殿
|
| 2 |
+
杨开迈步走入神殿。`),[K,q]=(0,_.useState)(`fast`),[J,Y]=(0,_.useState)(``),[X,Z]=(0,_.useState)(!1);(0,_.useEffect)(()=>{l&&Promise.all([Q(),oe()])},[l]);let Q=async()=>{D(!0);try{let e=await g.get(`/api/developer/keys`);b(e.data.keys||[]),C(e.data.balance||0),e.data.keys&&e.data.keys.length>0&&P(e.data.keys[0].api_key)}catch(e){console.error(e)}finally{D(!1)}},oe=async()=>{M(!0);try{ie((await g.get(`/api/developer/usage`)).data.usage||[])}catch(e){console.error(e)}finally{M(!1)}},se=async e=>{if(e.preventDefault(),w.trim()){k(!0);try{await g.post(`/api/developer/keys/create`,{name:w}),T(``),Q()}catch{alert(u.developer?.createError||`Lỗi khi tạo API Key.`)}finally{k(!1)}}},ce=async e=>{if(window.confirm(u.developer?.revokeConfirm||`Bạn có chắc chắn muốn thu hồi khóa API này? Tất cả các ứng dụng đang sử dụng nó sẽ bị ngắt kết nối.`))try{await g.post(`/api/developer/keys/delete`,{api_key:e}),Q()}catch{alert(u.developer?.revokeError||`Lỗi khi thu hồi API Key.`)}},le=e=>{navigator.clipboard.writeText(e),A(e),setTimeout(()=>A(``),2e3)},ue=async()=>{if(!N){alert(u.developer?.keyNameRequired||`Vui lòng tạo hoặc chọn một API Key trước.`);return}H(!0),B(!1),U.current&&=(U.current.pause(),null);try{let e=await g.post(`/v1/audio/speech`,{input:F,speed:L},{responseType:`blob`,headers:{Authorization:`Bearer ${N}`}}),t=URL.createObjectURL(e.data),n=new Audio(t);U.current=n,n.playbackRate=L,n.onplay=()=>{n.playbackRate=L},n.onplaying=()=>{n.playbackRate=L},n.play().then(()=>{n.playbackRate=L}).catch(e=>console.error(`Sandbox playback failed:`,e)),B(!0),n.onended=()=>B(!1)}catch{alert(u.reader?.ttsError||`Lỗi phát âm thanh. Vui lòng thử lại sau.`)}finally{H(!1)}},de=async()=>{if(!N){alert(u.developer?.keyNameRequired||`Vui lòng tạo hoặc chọn một API Key trước.`);return}Z(!0),Y(``);try{let e=await g.post(`/api/v1/translate`,{texts:W.split(`
|
| 3 |
+
`),mode:K},{headers:{Authorization:`Bearer ${N}`}});e.data&&e.data.translations&&Y(e.data.translations.join(`
|
| 4 |
+
`))}catch{alert(u.reader?.errorLoadingChapter||`Lỗi tải nội dung.`)}finally{Z(!1)}},$=e=>{let t=Number(e);return isNaN(t)?`0 ₫`:new Intl.NumberFormat(`vi-VN`,{style:`currency`,currency:`VND`}).format(t)};return l?(0,v.jsxs)(i,{children:[(0,v.jsxs)(`div`,{className:`space-y-8`,children:[(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 md:p-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4`,children:[(0,v.jsxs)(`div`,{className:`space-y-1`,children:[(0,v.jsxs)(`h2`,{className:`text-xl md:text-2xl font-black text-white flex items-center gap-2`,children:[(0,v.jsx)(r,{className:`text-purple-400 w-6 h-6`}),` `,u.developer?.title||`Developer API & TTS Console`]}),(0,v.jsx)(`p`,{className:`text-xs text-slate-400`,children:u.developer?.subtitle||`Quản lý API Key, giám sát số dư và kiểm thử dịch thuật/TTS thời gian thực.`})]}),(0,v.jsxs)(`div`,{className:`flex items-center gap-3 w-full md:w-auto`,children:[(0,v.jsxs)(`div`,{className:`bg-[#0b0b14]/80 border border-purple-500/25 px-5 py-3 rounded-2xl text-right flex-1 md:flex-initial`,children:[(0,v.jsx)(`span`,{className:`text-[9px] text-slate-500 block uppercase font-extrabold tracking-wider`,children:u.developer?.balance||`Số dư API Developer`}),(0,v.jsx)(`strong`,{className:`text-base text-emerald-400 font-extrabold block mt-0.5`,children:$(ne)})]}),(0,v.jsxs)(`button`,{onClick:()=>S(!0),className:`bg-gradient-to-r from-amber-400 to-amber-500 hover:brightness-105 text-[#0b0b14] font-extrabold px-5 py-4 rounded-2xl text-xs transition-all shadow-lg shadow-amber-500/10 whitespace-nowrap active:scale-95`,children:[`⚡ `,m===`zh`?`充值 / 购买 VIP`:m===`en`?`Deposit / Buy VIP`:`Nạp số dư / Mua VIP`]})]})]}),(0,v.jsxs)(`div`,{className:`grid grid-cols-1 lg:grid-cols-3 gap-6`,children:[(0,v.jsxs)(`div`,{className:`lg:col-span-2 space-y-6`,children:[(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 space-y-4`,children:[(0,v.jsx)(`div`,{className:`flex justify-between items-center pb-2 border-b border-[#1f1f3a]/50`,children:(0,v.jsxs)(`h3`,{className:`text-sm font-extrabold text-white flex items-center gap-2`,children:[(0,v.jsx)(h,{className:`w-4 h-4 text-purple-400`}),` `,u.developer?.apiKeyListTitle||`Danh sách khóa API Key`]})}),E?(0,v.jsxs)(`div`,{className:`text-center py-6 text-slate-500 text-xs`,children:[(0,v.jsx)(t,{className:`w-5 h-5 animate-spin mx-auto mb-2 text-purple-500`}),u.developer?.creating||`Đang tải...`]}):y.length===0?(0,v.jsx)(`p`,{className:`text-slate-500 text-xs text-center py-6`,children:m===`zh`?`您尚未创建任何 API Key。`:m===`en`?`You have not created any API Keys yet.`:`Bạn chưa tạo khóa API nào.`}):(0,v.jsx)(`div`,{className:`overflow-x-auto`,children:(0,v.jsxs)(`table`,{className:`w-full text-left text-xs text-slate-300`,children:[(0,v.jsx)(`thead`,{children:(0,v.jsxs)(`tr`,{className:`border-b border-[#1f1f3a] text-slate-500`,children:[(0,v.jsx)(`th`,{className:`pb-2`,children:u.developer?.keyNameLabel||`Tên khóa`}),(0,v.jsx)(`th`,{className:`pb-2`,children:`Khóa API`}),(0,v.jsx)(`th`,{className:`pb-2`,children:u.developer?.createdLabel||`Ngày tạo`}),(0,v.jsx)(`th`,{className:`pb-2 text-center`,children:m===`zh`?`操作`:m===`en`?`Actions`:`Hành động`})]})}),(0,v.jsx)(`tbody`,{className:`divide-y divide-[#1f1f3a]/30`,children:y.map((e,t)=>(0,v.jsxs)(`tr`,{className:`hover:bg-white/[0.02]`,children:[(0,v.jsx)(`td`,{className:`py-3 font-semibold text-white`,children:e.name}),(0,v.jsxs)(`td`,{className:`py-3 font-mono text-purple-300`,children:[e.api_key.slice(0,10),`...`,e.api_key.slice(-6)]}),(0,v.jsx)(`td`,{className:`py-3 text-slate-500 text-[10px]`,children:new Date(e.created_at).toLocaleDateString(`vi-VN`)}),(0,v.jsx)(`td`,{className:`py-3 text-center`,children:(0,v.jsxs)(`div`,{className:`flex justify-center gap-2`,children:[(0,v.jsx)(`button`,{onClick:()=>le(e.api_key),className:`p-1.5 bg-[#0b0b14] border border-[#1f1f3a] hover:border-purple-500/50 rounded-lg text-slate-400 hover:text-white transition-all`,title:`Copy API Key`,children:re===e.api_key?(0,v.jsx)(a,{className:`w-3.5 h-3.5 text-emerald-400`}):(0,v.jsx)(p,{className:`w-3.5 h-3.5`})}),(0,v.jsx)(`button`,{onClick:()=>ce(e.api_key),className:`p-1.5 bg-red-500/10 border border-red-500/20 hover:bg-red-500/20 rounded-lg text-red-400 transition-all`,title:`Thu hồi`,children:(0,v.jsx)(s,{className:`w-3.5 h-3.5`})})]})})]},t))})]})}),(0,v.jsxs)(`form`,{onSubmit:se,className:`flex gap-2 border-t border-[#1f1f3a]/50 pt-4`,children:[(0,v.jsx)(`input`,{type:`text`,placeholder:u.developer?.keyNamePlaceholder||`Tên gợi nhớ`,value:w,onChange:e=>T(e.target.value),className:`flex-1 px-4 py-2.5 bg-[#0b0b14] border border-[#1f1f3a] rounded-xl text-xs text-white outline-none focus:border-purple-500 transition-colors`}),(0,v.jsxs)(`button`,{type:`submit`,disabled:O||!w.trim(),className:`bg-purple-600 hover:bg-purple-500 disabled:opacity-40 text-white font-bold px-4 py-2.5 rounded-xl text-xs flex items-center gap-1.5 transition-all`,children:[(0,v.jsx)(o,{className:`w-4 h-4`}),` `,u.developer?.createBtn||`Tạo khóa`]})]})]}),(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 space-y-6`,children:[(0,v.jsx)(`h3`,{className:`text-sm font-extrabold text-white flex items-center gap-2 border-b border-[#1f1f3a]/50 pb-2`,children:`⚡ API Sandbox & TTS Playground`}),(0,v.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-6`,children:[(0,v.jsxs)(`div`,{className:`space-y-4`,children:[(0,v.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,v.jsx)(`h4`,{className:`text-xs font-bold text-slate-300`,children:`1. Text-to-Speech (TTS)`}),(0,v.jsx)(`span`,{className:`text-[9px] bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded border border-purple-500/20 uppercase font-bold`,children:`RunPod AI`})]}),(0,v.jsx)(`textarea`,{rows:4,value:F,onChange:e=>I(e.target.value),placeholder:`Nhập văn bản cần phát audio...`,className:`w-full bg-[#0b0b14] border border-[#1f1f3a] rounded-xl p-3 text-xs text-slate-300 outline-none focus:border-purple-500`}),(0,v.jsxs)(`div`,{className:`grid grid-cols-2 gap-3`,children:[(0,v.jsxs)(`div`,{className:`space-y-1`,children:[(0,v.jsxs)(`label`,{className:`text-[10px] text-slate-400 font-bold block`,children:[u.reader?.speed||`Tốc độ`,` (`,L,`x):`]}),(0,v.jsx)(`input`,{type:`range`,min:`0.5`,max:`4.0`,step:`0.1`,value:L,onChange:e=>R(parseFloat(e.target.value)),className:`w-full accent-purple-500 bg-[#0b0b14]`})]}),(0,v.jsx)(`div`,{className:`space-y-1 flex items-end`,children:(0,v.jsxs)(`button`,{onClick:ue,disabled:V,className:`w-full bg-purple-600 hover:bg-purple-500 active:scale-95 disabled:opacity-40 text-white font-bold py-2.5 rounded-xl text-xs flex items-center justify-center gap-1.5 shadow-md transition-all`,children:[V?(0,v.jsx)(t,{className:`w-4 h-4 animate-spin`}):z?(0,v.jsx)(f,{className:`w-4 h-4`}):(0,v.jsx)(te,{className:`w-4 h-4 fill-current`}),z?m===`zh`?`正在播放...`:m===`en`?`Playing...`:`Đang phát...`:m===`zh`?`播放音频`:m===`en`?`Play Audio`:`Gửi & Phát Audio`]})})]})]}),(0,v.jsxs)(`div`,{className:`space-y-4`,children:[(0,v.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,v.jsx)(`h4`,{className:`text-xs font-bold text-slate-300`,children:m===`zh`?`2. 中越翻译`:m===`en`?`2. Chinese-Vietnamese Translation`:`2. Dịch thuật Trung-Việt`}),(0,v.jsx)(`span`,{className:`text-[9px] bg-emerald-500/15 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/20 uppercase font-bold`,children:`Vietphrase`})]}),(0,v.jsx)(`textarea`,{rows:4,value:W,onChange:e=>G(e.target.value),placeholder:`Nhập văn bản tiếng Trung cần dịch...`,className:`w-full bg-[#0b0b14] border border-[#1f1f3a] rounded-xl p-3 text-xs text-slate-300 outline-none focus:border-purple-500`}),(0,v.jsxs)(`div`,{className:`grid grid-cols-2 gap-3`,children:[(0,v.jsxs)(`select`,{value:K,onChange:e=>q(e.target.value),className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-2.5 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,v.jsx)(`option`,{value:`fast`,children:u.compFast}),(0,v.jsx)(`option`,{value:`vietphrase`,children:u.compVietphrase})]}),(0,v.jsxs)(`button`,{onClick:de,disabled:X,className:`w-full bg-emerald-600 hover:bg-emerald-500 active:scale-95 disabled:opacity-40 text-white font-bold py-2.5 rounded-xl text-xs flex items-center justify-center gap-1.5 shadow-md transition-all`,children:[X?(0,v.jsx)(t,{className:`w-4 h-4 animate-spin`}):(0,v.jsx)(ee,{className:`w-4 h-4`}),m===`zh`?`翻译`:m===`en`?`Translate`:`Dịch ngay`]})]})]})]}),J&&(0,v.jsxs)(`div`,{className:`bg-[#0b0b14] border border-[#1f1f3a] rounded-xl p-4 space-y-1.5 animate-in fade-in duration-200`,children:[(0,v.jsx)(`span`,{className:`text-[9px] text-slate-500 block uppercase font-extrabold tracking-wider`,children:m===`zh`?`翻译结果`:m===`en`?`Translation Result`:`Kết quả dịch`}),(0,v.jsx)(`p`,{className:`text-xs text-slate-200 whitespace-pre-line leading-relaxed`,children:J})]})]})]}),(0,v.jsxs)(`div`,{className:`space-y-6`,children:[(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-5 shadow-xl space-y-4`,children:[(0,v.jsxs)(`h3`,{className:`text-xs font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-amber-200 flex items-center gap-1.5`,children:[(0,v.jsx)(r,{className:`w-4 h-4 text-amber-400`}),` `,u.developer?.apiDocsTitle||`Tài liệu tích hợp (cURL)`]}),(0,v.jsxs)(`div`,{className:`space-y-3.5`,children:[(0,v.jsxs)(`div`,{className:`space-y-1`,children:[(0,v.jsx)(`span`,{className:`text-[9px] text-slate-400 font-extrabold uppercase`,children:u.developer?.ttsEndpoint||`1. Chuyển Text thành Audio (OpenAI format)`}),(0,v.jsx)(`div`,{className:`bg-[#0b0b14] border border-white/5 p-3 rounded-xl relative`,children:(0,v.jsx)(`pre`,{className:`text-[10px] text-slate-300 font-mono overflow-x-auto whitespace-pre leading-relaxed select-all`,children:`curl -X POST http://localhost:5051/v1/audio/speech \\
|
| 5 |
+
-H "Authorization: Bearer YOUR_API_KEY" \\
|
| 6 |
+
-H "Content-Type: application/json" \\
|
| 7 |
+
-d '{"input": "Nhập văn bản cần phát", "speed": 1.0}' \\
|
| 8 |
+
--output audio.wav`})})]}),(0,v.jsxs)(`div`,{className:`space-y-1`,children:[(0,v.jsx)(`span`,{className:`text-[9px] text-slate-400 font-extrabold uppercase`,children:u.developer?.transEndpoint||`2. API Dịch Thuật Trung-Việt`}),(0,v.jsx)(`div`,{className:`bg-[#0b0b14] border border-white/5 p-3 rounded-xl relative`,children:(0,v.jsx)(`pre`,{className:`text-[10px] text-slate-300 font-mono overflow-x-auto whitespace-pre leading-relaxed select-all`,children:`curl -X POST http://localhost:5051/api/v1/translate \\
|
| 9 |
+
-H "Authorization: Bearer YOUR_API_KEY" \\
|
| 10 |
+
-H "Content-Type: application/json" \\
|
| 11 |
+
-d '{"texts": ["第1章", "开封神殿"], "mode": "fast"}'`})})]})]})]}),(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-5 shadow-xl space-y-4`,children:[(0,v.jsxs)(`h3`,{className:`text-xs font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-brand-300 to-purple-300 flex items-center gap-1.5`,children:[(0,v.jsx)(e,{className:`w-4 h-4 text-brand-400`}),` `,u.developer?.apiUsageHistoryTitle||`Nhật ký cuộc gọi gần đây`]}),ae?(0,v.jsx)(`div`,{className:`text-center py-4 text-slate-500 text-xs`,children:`Đang tải...`}):j.length===0?(0,v.jsx)(`p`,{className:`text-slate-500 text-xs text-center py-4`,children:m===`zh`?`暂无接口调用记录。`:m===`en`?`No API usage records found.`:`Chưa có lịch sử cuộc gọi API nào.`}):(0,v.jsx)(`div`,{className:`space-y-3 max-h-[300px] overflow-y-auto`,children:j.slice(0,10).map((e,t)=>(0,v.jsxs)(`div`,{className:`border-b border-[#1f1f3a]/30 pb-2.5 last:border-0 last:pb-0 flex justify-between items-center text-[10px]`,children:[(0,v.jsxs)(`div`,{children:[(0,v.jsx)(`span`,{className:`text-white font-bold font-mono`,children:e.model}),(0,v.jsxs)(`span`,{className:`text-slate-500 block text-[9px] mt-0.5`,children:[new Date(e.timestamp).toLocaleTimeString(`vi-VN`),` · `,e.tokens,` `,m===`zh`?`字符`:m===`en`?`chars`:`kí tự`]})]}),(0,v.jsxs)(`div`,{className:`text-right`,children:[(0,v.jsx)(`span`,{className:`text-emerald-400 font-bold block`,children:e.cost===0?u.developer?.freeCost||`Miễn phí (VIP)`:$(e.cost)}),(0,v.jsx)(`span`,{className:`px-1 rounded text-[8px] font-bold ${e.status_code===200?`bg-emerald-500/15 text-emerald-400`:`bg-red-500/15 text-red-400`}`,children:e.status_code})]})]},t))})]})]})]})]}),(0,v.jsx)(n,{isOpen:x,onClose:()=>{S(!1),Q()}})]}):(0,v.jsx)(i,{children:(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500 max-w-md mx-auto space-y-4`,children:[(0,v.jsx)(r,{className:`w-12 h-12 text-purple-400 mx-auto`}),(0,v.jsx)(`h2`,{className:`text-lg font-bold text-white`,children:`Developer API Console`}),(0,v.jsx)(`p`,{className:`text-xs text-slate-400 leading-relaxed`,children:`Vui lòng đăng nhập tài khoản để tiếp tục.`})]})})}export{y as default};
|
frontend-web/dist/assets/Discover-BCmVIXus.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import{t as e}from"./award-CKCOPIfL.js";import{c as t,d as n,f as r,t as i,u as a,v as o}from"./MainLayout-COiTxmNr.js";import{t as s}from"./book-DwmTvSdZ.js";import{n as c}from"./square-DZKJ0QGR.js";import{t as l}from"./BookCard-BCMDdlyN.js";import{C as u,D as d,F as f,M as p,S as m,_ as h,b as g,j as _,k as ee,n as v,o as te,t as ne,v as re,w as y}from"./index-yRoRoI6u.js";import{t as ie}from"./GoogleAd-CD0eT9Wp.js";var ae=g(`funnel`,[[`path`,{d:`M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z`,key:`sc7q7i`}]]),oe=g(`shield-alert`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]]),se=g(`shuffle`,[[`path`,{d:`m18 14 4 4-4 4`,key:`10pe0f`}],[`path`,{d:`m18 2 4 4-4 4`,key:`pucp1d`}],[`path`,{d:`M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22`,key:`1ailkh`}],[`path`,{d:`M2 6h1.972a4 4 0 0 1 3.6 2.2`,key:`km57vx`}],[`path`,{d:`M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45`,key:`os18l9`}]]),b=f(_(),1),x=p();function ce({isOpen:e,onClose:r,onSelectBook:i}){let[c,l]=(0,b.useState)(``),[u,d]=(0,b.useState)(!1),[f,p]=(0,b.useState)(``),[m,h]=(0,b.useState)([]),[g,_]=(0,b.useState)(!1),ee=[`Main thông minh, IQ vô cực, đấu trí nghẹt thở`,`Tiên hiệp hài hước, cười bể bụng, main lầy lội`,`Sát phạt quyết đoán, độc hành, không thánh mẫu`,`Mạt thế xây dựng thế lực, không hậu cung`],v=e=>{l(e)};return e?(0,x.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md`,children:(0,x.jsxs)(`div`,{className:`relative w-full max-w-2xl bg-gradient-to-b from-[#131327] to-[#0d0d1a] border border-[#2d2d6b]/80 rounded-3xl p-6 shadow-2xl overflow-hidden max-h-[85vh] flex flex-col`,children:[(0,x.jsx)(`div`,{className:`absolute -top-20 -left-20 w-40 h-40 bg-brand-500/10 rounded-full blur-3xl pointer-events-none`}),(0,x.jsx)(`div`,{className:`absolute -bottom-20 -right-20 w-40 h-40 bg-amber-500/10 rounded-full blur-3xl pointer-events-none`}),(0,x.jsxs)(`div`,{className:`flex justify-between items-center border-b border-white/5 pb-4 mb-4`,children:[(0,x.jsxs)(`div`,{className:`flex items-center gap-2.5`,children:[(0,x.jsx)(`div`,{className:`p-2 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 text-[#0b0b14] font-bold`,children:(0,x.jsx)(t,{className:`w-5 h-5 fill-current`})}),(0,x.jsxs)(`div`,{children:[(0,x.jsx)(`h3`,{className:`text-lg font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-amber-200`,children:`TÌM KIẾM NÂNG CẤP AI`}),(0,x.jsx)(`p`,{className:`text-slate-400 text-xs mt-0.5`,children:`Tìm và gợi ý truyện thông minh theo cốt truyện, cảm xúc, tính cách nhân vật`})]})]}),(0,x.jsx)(`button`,{onClick:r,className:`p-1.5 hover:bg-white/5 rounded-lg text-slate-400 hover:text-white transition-colors`,children:(0,x.jsx)(te,{className:`w-6 h-6`})})]}),(0,x.jsxs)(`div`,{className:`flex-1 overflow-y-auto space-y-4 pr-1`,children:[(0,x.jsxs)(`div`,{className:`space-y-2`,children:[(0,x.jsx)(`span`,{className:`text-slate-500 text-xs font-bold uppercase tracking-wider block`,children:`Gợi ý câu hỏi AI:`}),(0,x.jsx)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-2`,children:ee.map((e,t)=>(0,x.jsxs)(`button`,{onClick:()=>v(e),className:`text-left text-xs bg-[#1a1a35]/60 hover:bg-[#1a1a35] border border-[#2d2d6b]/30 hover:border-brand-500/50 rounded-xl p-3 text-slate-300 hover:text-white transition-all`,children:[`💡 "`,e,`"`]},t))})]}),(0,x.jsxs)(`form`,{onSubmit:async e=>{if(e.preventDefault(),c.trim()){d(!0),p(``),h([]),_(!1);try{p((await y.post(`/api/ai/chat`,{prompt:`Bạn là trợ lý AI tìm kiếm truyện. Tìm các tác phẩm phù hợp với yêu cầu sau: "${c}". Hãy trả về lời khuyên ngắn gọn và một vài tên truyện gợi ý.`,model:`gemini-1.5-flash`})).data.text);let e=c.replace(/(main|truyện|tìm|có|yếu tố|không)/gi,``).trim(),t=await y.get(`/api/books`,{params:{q:e.split(` `)[0],per_page:3}});t.data&&t.data.books&&h(t.data.books.map(e=>({...e,matchScore:Math.floor(Math.random()*15)+85})))}catch(e){console.log(`AI Proxy Error or Non-VIP:`,e),_(!0),p(`[AI Standard Mode] Để sử dụng đầy đủ mô hình trí tuệ nhân tạo nâng cao, bạn cần nâng cấp VIP. Tuy nhiên, hệ thống AI Cơ Bản đã tìm thấy các bộ truyện phù hợp nhất dựa trên từ khóa phân tích:`);let t=c.replace(/(main|truyện|tìm|có|yếu tố|không|sát phạt|độc hành|vô cực|thế lực|nâng cấp|quyết đoán)/gi,``).trim().split(/[\s,]+/)[0]||`truyện`;try{let e=await y.get(`/api/books`,{params:{q:t,per_page:3}});e.data&&e.data.books&&e.data.books.length>0?h(e.data.books.map(e=>({...e,matchScore:Math.floor(Math.random()*10)+90}))):h(((await y.get(`/api/books`,{params:{per_page:3}})).data.books||[]).map(e=>({...e,matchScore:82})))}catch(e){console.error(e)}}finally{d(!1)}}},className:`flex gap-2`,children:[(0,x.jsx)(`input`,{type:`text`,placeholder:`Ví dụ: Tìm truyện linh dị kinh dị đô thị có nhân vật chính lạnh lùng...`,value:c,onChange:e=>l(e.target.value),className:`flex-1 px-4 py-3.5 bg-[#0b0b14] border border-[#2d2d6b]/50 rounded-2xl text-white outline-none focus:border-amber-400 transition-colors text-sm`}),(0,x.jsx)(`button`,{type:`submit`,disabled:u||!c.trim(),className:`px-5 bg-gradient-to-r from-amber-500 to-amber-600 hover:brightness-105 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-1 text-sm`,children:u?(0,x.jsx)(n,{className:`w-4 h-4 animate-spin`}):(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(a,{className:`w-4 h-4`}),` Gửi`]})})]}),u&&(0,x.jsxs)(`div`,{className:`py-8 text-center text-slate-500 space-y-3`,children:[(0,x.jsx)(n,{className:`w-8 h-8 animate-spin mx-auto text-amber-500`}),(0,x.jsx)(`p`,{className:`text-xs`,children:`AI đang phân tích cốt truyện và lọc đầu truyện...`})]}),!u&&(f||m.length>0)&&(0,x.jsxs)(`div`,{className:`space-y-4 border-t border-white/5 pt-4`,children:[f&&(0,x.jsxs)(`div`,{className:`bg-[#121225]/40 border border-[#2d2d6b]/30 rounded-2xl p-4 text-xs text-slate-300 leading-relaxed`,children:[g&&(0,x.jsxs)(`div`,{className:`flex items-center gap-1.5 text-amber-400 font-bold mb-2`,children:[(0,x.jsx)(o,{className:`w-4 h-4`}),` AI Standard Mode (Giới hạn VIP)`]}),(0,x.jsx)(`p`,{className:`whitespace-pre-line`,children:f})]}),m.length>0&&(0,x.jsxs)(`div`,{className:`space-y-2`,children:[(0,x.jsx)(`span`,{className:`text-slate-500 text-xs font-bold uppercase tracking-wider block`,children:`Truyện Khớp Nhiều Nhất:`}),(0,x.jsx)(`div`,{className:`space-y-3`,children:m.map(e=>(0,x.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#2d2d6b]/30 rounded-2xl p-4 flex gap-4 items-center justify-between hover:border-amber-400 transition-all cursor-pointer`,onClick:()=>{i&&i(e),r()},children:[(0,x.jsxs)(`div`,{className:`flex gap-3 items-center min-w-0`,children:[e.cover?(0,x.jsx)(`img`,{src:e.cover,alt:`cover`,className:`w-[45px] h-[60px] object-cover rounded-lg border border-[#2d2d55] shadow-md bg-[#0f0f1a]`,onError:e=>{e.target.remove()}}):(0,x.jsx)(`div`,{className:`w-[45px] h-[60px] rounded-lg border border-[#2d2d55] bg-[#0f0f1a] flex items-center justify-center text-slate-500`,children:(0,x.jsx)(s,{className:`w-5 h-5`})}),(0,x.jsxs)(`div`,{className:`min-w-0`,children:[(0,x.jsx)(`h4`,{className:`text-slate-100 font-bold text-sm truncate`,children:e.title_vietphrase||e.title}),(0,x.jsxs)(`p`,{className:`text-slate-500 text-xs mt-0.5 truncate`,children:[`Tác giả: `,e.author_hanviet||e.author]}),(0,x.jsx)(`div`,{className:`flex gap-1.5 mt-1.5`,children:e.categories&&e.categories.split(`, `).slice(0,2).map((e,t)=>(0,x.jsx)(`span`,{className:`px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[9px]`,children:e},t))})]})]}),(0,x.jsxs)(`div`,{className:`text-right shrink-0`,children:[(0,x.jsxs)(`span`,{className:`text-amber-400 text-xs font-extrabold block`,children:[e.matchScore,`%`]}),(0,x.jsx)(`span`,{className:`text-slate-500 text-[9px]`,children:`AI Match`})]})]},e.id))})]})]})]}),(0,x.jsxs)(`div`,{className:`border-t border-white/5 pt-3 mt-3 flex justify-between items-center text-[10px] text-slate-500`,children:[(0,x.jsx)(`span`,{children:`Hệ thống phân tích từ kho truyện 930k+ đầu mục`}),(0,x.jsx)(`span`,{children:`Nâng cấp VIP để mở khóa GPT/Gemini AI đầy đủ`})]})]})}):null}function S(){let{t:a,lang:o}=m(),{user:s}=u(),[f,p]=ee(),g=d(),_=e=>e?{玄幻:o===`vi`?`Huyền Huyễn`:o===`en`?`Fantasy`:`玄幻`,都市:o===`vi`?`Đô Thị`:o===`en`?`Urban`:`都市`,言情:o===`vi`?`Ngôn Tình`:o===`en`?`Romance`:`言情`,女生:o===`vi`?`Nữ Sinh`:o===`en`?`Female Lead`:`女生`,科幻:o===`vi`?`Khoa Huyễn`:o===`en`?`Sci-Fi`:`科幻`,修真:o===`vi`?`Tu Chân`:o===`en`?`Cultivation`:`修真`,仙侠:o===`vi`?`Tiên Hiệp`:o===`en`?`Xianxia`:`仙侠`,武侠:o===`vi`?`Võ Hiệp`:o===`en`?`Wuxia`:`武侠`,历史:o===`vi`?`Lịch Sử`:o===`en`?`History`:`历史`,网游:o===`vi`?`Võng Du`:o===`en`?`Gaming`:`网游`,同人:o===`vi`?`Đồng Nhân`:o===`en`?`Fanfiction`:`同人`,其他:o===`vi`?`Thể loại khác`:o===`en`?`Others`:`其他`}[e]||e:a.allCategories,[S,C]=(0,b.useState)([]),[le,w]=(0,b.useState)(!0),[T,E]=(0,b.useState)(``),[D,O]=(0,b.useState)(``),[ue,k]=(0,b.useState)(`all`),[A,j]=(0,b.useState)(``),[M,de]=(0,b.useState)(``),[N,fe]=(0,b.useState)(``),[P,F]=(0,b.useState)(``),[I,L]=(0,b.useState)(`site_count DESC`),[pe,me]=(0,b.useState)(!1),[R,z]=(0,b.useState)(1),[B,V]=(0,b.useState)(1),[H,U]=(0,b.useState)(0),[he,ge]=(0,b.useState)({total:931427,duplicates:0}),[_e,ve]=(0,b.useState)(null),[ye,be]=(0,b.useState)(null),[xe,Se]=(0,b.useState)(!1),[Ce,W]=(0,b.useState)(new Set),[we,Te]=(0,b.useState)([]),[Ee,De]=(0,b.useState)(!1),{activeAudioObj:Oe,setActiveAudioObj:G}=ne(),[K,q]=(0,b.useState)(0),[ke,J]=(0,b.useState)(`original`),[Ae,Y]=(0,b.useState)(``),[je,Me]=(0,b.useState)(!1),[X,Ne]=(0,b.useState)([{id:1,title:`Hắc Ám Văn Minh`,title_vietphrase:`Hắc Ám Văn Minh`,author:`Cổ Hi`,author_hanviet:`Cổ Hi`,categories:`Huyền Huyễn, Mạt Thế, Khoa Huyễn`,cover:`https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=200&auto=format&fit=crop&q=60`,description:`Khi bóng tối bao phủ địa cầu, nhân loại đối mặt với kỷ nguyên hắc ám tột cùng. Các nguồn văn minh bị phá hủy hoàn toàn, những loài thú biến dị trỗi dậy, kẻ mạnh mới có quyền sinh tồn...`},{id:2,title:`Đấu Phá Thương Khung`,title_vietphrase:`Đấu Phá Thương Khung`,author:`Thiên Tàm Thổ Đậu`,author_hanviet:`Thiên Tàm Thổ Đậu`,categories:`Tiên Hiệp, Huyền Huyễn`,cover:`https://images.unsplash.com/photo-1543002588-bfa74002ed7e?w=200&auto=format&fit=crop&q=60`,description:`Nơi đây là thế giới của Đấu Khí. Không có ma pháp hoa lệ, chỉ có đấu khí sinh sôi phát triển đến đỉnh phong! Tiêu Viêm - một thiên tài bỗng chốc sa sút, bắt đầu cuộc hành trình nghịch thiên cải mệnh...`},{id:3,title:`Hộc Châu Phu Nhân`,title_vietphrase:`Hộc Châu Phu Nhân`,author:`Tiêu Như Sắt`,author_hanviet:`Tiêu Như Sắt`,categories:`Ngôn Tình, Cổ Đại, Nữ Sinh`,cover:`https://images.unsplash.com/photo-1512820790803-83ca734da794?w=200&auto=format&fit=crop&q=60`,description:`Nơi Giao Châu xa xôi có bộ tộc mò ngọc trai quý hiếm. Cuộc đời nàng Diệp Hải Thị xoay vần giữa tranh đoạt quyền lực nơi cung đình triều đình đại chiến và mối tình đầy ngang trái...`}]),Pe=[{id:1,user:`Minh Quân`,avatar:`https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=80&auto=format&fit=crop&q=60`,source:`Metruyenchu`,comment:`bên nguồn chữ một cho màn Trong 😂`,time:`2 phút trước`},{id:2,user:`Thanh Trúc`,avatar:`https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&auto=format&fit=crop&q=60`,source:`Truyenchu`,comment:`bên thảo của màn Trong 😆`,time:`1 phút trước`},{id:3,user:`Quốc Bảo`,avatar:`https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&auto=format&fit=crop&q=60`,source:`Nady knise`,comment:`trên đàn nên to...`,time:`1 phút trước`},{id:4,user:`Lan Hương`,avatar:`https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=80&auto=format&fit=crop&q=60`,source:`Metruyenchu`,comment:`(Tốt) chặn khỉn hồi Uanb u oại thi mượt 😮`,time:`1 phút trước`}];(0,b.useEffect)(()=>{Fe(),Ie()},[s]),(0,b.useEffect)(()=>{let e=f.get(`q`),t=f.get(`search_field`),n=f.get(`category`),r=!1,i={};e&&(O(e),i.q=e,r=!0),t&&(k(t),i.search_field=t,r=!0),n&&(j(n),i.category=n,r=!0),r?(Z(i),p({},{replace:!0})):Z()},[f]),(0,b.useEffect)(()=>{f.get(`q`)||f.get(`search_field`)||f.get(`category`)||Z()},[R,A,M,N,P,I]),(0,b.useEffect)(()=>{(async()=>{try{let e=[...X];for(let t=0;t<e.length;t++){let n=await y.get(`/api/books`,{params:{q:e[t].title,per_page:1}});if(n.data&&n.data.books&&n.data.books.length>0){let r=n.data.books[0];e[t]={...e[t],id:r.id,cover:r.cover||e[t].cover,title_vietphrase:r.title_vietphrase||r.title,author_hanviet:r.author_hanviet||r.author,urls:r.urls,categories:r.categories||e[t].categories}}}Ne(e)}catch(e){console.error(`Failed to sync hero books with database:`,e)}})()},[]),(0,b.useEffect)(()=>{J(`original`),Y(``)},[K]);let Fe=async()=>{try{let e=await y.get(`/api/stats`);ge({total:e.data.total_books||931427,duplicates:e.data.duplicates||0})}catch(e){console.error(e)}},Ie=async()=>{if(!s){W(new Set);return}try{let e=await y.get(`/api/bookshelf`);W(new Set(e.data.map(e=>e.book_id)))}catch(e){console.error(e)}},Z=async(e={})=>{w(!0),E(``);try{let t={q:e.q===void 0?D:e.q,category:e.category===void 0?A:e.category,source:e.source===void 0?M:e.source,dup:e.dup===void 0?N:e.dup,sort:I,search_field:e.search_field===void 0?ue:e.search_field,min_chapters:P,page:R,per_page:30},n=await y.get(`/api/books`,{params:t});C(n.data.books||[]),V(n.data.pages||1),U(n.data.total||0),n.data.books&&R===1&&!A&&!M&&!D&&Te([{id:1,title:`Hắc Ám Văn Minh`,author:`Cổ Hi`,trend:`up`,diff:1},{id:2,title:`Hộc ẩm chúa tể`,author:`Linh Hạ Cửu Thập Độ`,trend:`up`,diff:2},{id:3,title:`10 Lần Thôn Tương`,author:`Luân Hồi Thiên Trọng`,trend:`down`,diff:1},{id:4,title:`Mộ Ngôn`,author:`Nguôn`,trend:`none`,diff:0}])}catch(e){E(e.response?.data?.error||a.connError)}finally{w(!1)}},Le=e=>{k(`author`),O(e),z(1),Z({search_field:`author`,q:e})},Re=e=>{j(e),z(1),Z({category:e})},ze=e=>{e.preventDefault(),z(1),Z()},Be=e=>{e.id&&g(`/book/${e.id}`)},Ve=async e=>{if(!s){alert(`Vui lòng đăng nhập để lưu sách.`);return}let t=Ce.has(e),n=t?`/api/bookshelf/remove`:`/api/bookshelf/add`;try{await y.post(n,{book_id:e}),W(n=>{let r=new Set(n);return t?r.delete(e):r.add(e),r})}catch(e){alert(e.response?.data?.error||`Lỗi xử lý tủ sách.`)}},He=async e=>{if(_e===e){ve(null),be(null);return}ve(e),Se(!0);try{be((await y.get(`/api/book/${e}/translations`)).data),await y.post(`/api/history/add`,{book_id:e,last_chapter:`Đang xem so sánh`})}catch(e){console.error(e)}finally{Se(!1)}},Ue=()=>{O(``),k(`all`),j(``),de(``),fe(``),F(``),L(`site_count DESC`),z(1)},We=async()=>{w(!0);try{let e=Math.floor(Math.random()*20)+1,t=await y.get(`/api/books`,{params:{page:e,per_page:30}});if(t.data&&t.data.books&&t.data.books.length>0){let e=Math.floor(Math.random()*t.data.books.length),n=t.data.books[e];C([n]),V(1),U(1),G(n)}}catch(e){console.error(`Gacha failed:`,e)}finally{w(!1)}},Q=async e=>{let t=X[K];if(e===`original`){J(`original`);return}Me(!0);try{try{let n=await y.post(`/api/translate`,{texts:[t.description],mode:e===`en`?`en`:`vietphrase`});n.data&&n.data.translations&&n.data.translations[0]&&(Y(n.data.translations[0]),J(e))}catch(n){console.warn(`[Discover] Cloud translation failed, trying offline localTranslator:`,n),await v.loadDictionaries(),Y(v.translateSentence(t.description,`advanced`)),J(e)}}catch{alert(`Hạn mức dịch máy chủ đã hết và bộ dịch offline gặp lỗi.`)}finally{Me(!1)}},Ge=()=>{let e=X[K];G(e)},Ke=e=>{C([e]),V(1),U(1)},$=X[K],qe=ke===`original`?$.description:Ae||$.description;return(0,x.jsxs)(i,{stats:he,children:[(0,x.jsxs)(`div`,{className:`relative w-full rounded-2xl sm:rounded-3xl overflow-hidden mb-6 min-h-[220px] sm:min-h-[360px] bg-[#0b0b14] border border-[#1f1f3a]/80 shadow-2xl flex flex-col justify-end`,children:[(0,x.jsx)(`div`,{className:`absolute inset-0 bg-cover bg-center transition-all duration-700`,style:{backgroundImage:`url('hero_banner.png')`}}),(0,x.jsx)(`div`,{className:`absolute inset-0 bg-gradient-to-r from-[#0b0b14] via-[#0b0b14]/75 to-transparent`}),(0,x.jsxs)(`div`,{className:`relative z-10 p-4 sm:p-8 md:p-12 max-w-2xl text-left space-y-2 sm:space-y-4 self-start`,children:[(0,x.jsx)(`h2`,{className:`text-lg sm:text-3xl md:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-yellow-300 via-amber-400 to-amber-200 tracking-wide uppercase`,children:o===`vi`?`TRUYỆN ĐỀ CỬ XUẤT SẮC TUẦN NÀY`:o===`en`?`WEEKLY FEATURED NOVEL`:`本周精选推荐`}),(0,x.jsx)(`h3`,{className:`text-base sm:text-xl md:text-2xl font-bold text-white tracking-wide`,children:$.title_vietphrase}),(0,x.jsxs)(`p`,{className:`text-slate-400 text-xs mt-1 hidden sm:block`,children:[`✍ `,o===`vi`?`Tác giả:`:o===`en`?`Author:`:`作者:`,` `,(0,x.jsx)(`strong`,{className:`text-brand-300 font-semibold`,children:$.author_hanviet}),` · `,o===`vi`?`Thể loại:`:o===`en`?`Categories:`:`题材:`,` `,(0,x.jsx)(`span`,{className:`text-slate-300 font-medium`,children:$.categories})]}),(0,x.jsx)(`p`,{className:`text-slate-300 text-xs leading-relaxed line-clamp-2 sm:line-clamp-3 bg-[#0b0b14]/60 backdrop-blur-sm p-3 sm:p-4 rounded-xl border border-white/5`,children:je?o===`vi`?`Đang dịch nội dung...`:o===`en`?`Translating content...`:`正在翻译内容...`:qe}),(0,x.jsxs)(`div`,{className:`flex flex-wrap gap-2 sm:gap-4 pt-1 sm:pt-2`,children:[(0,x.jsxs)(`button`,{onClick:Ge,className:`inline-flex items-center gap-2 bg-purple-600 hover:bg-purple-500 active:scale-95 text-white font-extrabold px-4 sm:px-6 py-2.5 sm:py-3 rounded-full shadow-lg transition-all text-xs`,children:[o===`vi`?`Nghe Tóm Tắt (TTS)`:o===`en`?`Listen Summary (TTS)`:`听取大纲 (TTS)`,` `,(0,x.jsx)(`span`,{className:`text-purple-300`,children:`| 🔊`})]}),(0,x.jsxs)(`div`,{className:`inline-flex items-center gap-1.5 bg-[#121225]/80 border border-white/10 px-3 sm:px-4 py-2 sm:py-2.5 rounded-full text-xs font-bold`,children:[(0,x.jsx)(`span`,{className:`text-slate-400 text-[10px] hidden sm:inline`,children:o===`vi`?`Dịch Nhanh`:o===`en`?`Quick Translate`:`快速翻译`}),(0,x.jsx)(`button`,{onClick:()=>Q(`vi`),className:`hover:scale-115 transition-transform`,title:`Thuần Việt`,children:`🇻🇳`}),(0,x.jsx)(`button`,{onClick:()=>Q(`en`),className:`hover:scale-115 transition-transform`,title:`English`,children:`🇺🇸`}),(0,x.jsx)(`button`,{onClick:()=>Q(`original`),className:`hover:scale-115 transition-transform`,title:`Original Chinese`,children:`🇨🇳`})]})]})]}),(0,x.jsxs)(`div`,{className:`absolute right-8 bottom-8 z-10 flex items-center gap-3`,children:[(0,x.jsx)(`button`,{onClick:()=>q(e=>(e-1+X.length)%X.length),className:`p-2 bg-black/50 hover:bg-black/75 rounded-full border border-white/10 text-white transition-all`,children:(0,x.jsx)(re,{className:`w-4 h-4`})}),(0,x.jsx)(`div`,{className:`flex gap-1.5`,children:X.map((e,t)=>(0,x.jsx)(`button`,{onClick:()=>q(t),className:`w-2.5 h-2.5 rounded-full transition-all ${K===t?`bg-purple-500 w-6`:`bg-white/20`}`},t))}),(0,x.jsx)(`button`,{onClick:()=>q(e=>(e+1)%X.length),className:`p-2 bg-black/50 hover:bg-black/75 rounded-full border border-white/10 text-white transition-all`,children:(0,x.jsx)(h,{className:`w-4 h-4`})})]})]}),(0,x.jsxs)(`div`,{className:`grid grid-cols-1 lg:grid-cols-4 gap-6`,children:[(0,x.jsxs)(`div`,{className:`lg:col-span-3 space-y-6`,children:[(0,x.jsxs)(`form`,{onSubmit:ze,className:`bg-[#121225]/80 border border-[#1f1f3a]/80 rounded-2xl p-5 space-y-4 shadow-xl`,children:[(0,x.jsxs)(`div`,{className:`flex flex-col sm:flex-row gap-2`,children:[(0,x.jsxs)(`div`,{className:`relative flex-1`,children:[(0,x.jsx)(c,{className:`absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-purple-400`}),(0,x.jsx)(`input`,{type:`text`,placeholder:a.searchPlaceholder,value:D,onChange:e=>O(e.target.value),className:`w-full pl-10 pr-4 py-3 bg-[#0b0b14] border border-[#1f1f3a] rounded-xl text-white outline-none focus:border-purple-500 transition-colors text-xs`})]}),(0,x.jsxs)(`div`,{className:`flex gap-2 w-full sm:w-auto shrink-0`,children:[(0,x.jsx)(`button`,{type:`submit`,className:`flex-1 sm:flex-initial bg-purple-600 hover:bg-purple-500 text-white font-bold px-5 py-3 rounded-xl shadow-md transition-all text-xs text-center`,children:o===`vi`?`Tìm`:o===`en`?`Search`:`搜索`}),(0,x.jsxs)(`button`,{type:`button`,onClick:()=>me(e=>!e),className:`px-3 py-3 rounded-xl border text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${pe?`bg-purple-600/20 border-purple-500/40 text-purple-300`:`bg-[#0b0b14] border-[#1f1f3a] text-slate-400 hover:text-white`}`,title:o===`vi`?`Bộ lọc nâng cao`:o===`en`?`Advanced filters`:`高级筛选`,children:[(0,x.jsx)(ae,{className:`w-4 h-4`}),(0,x.jsx)(`span`,{className:`hidden sm:inline`,children:o===`vi`?`Bộ lọc`:o===`en`?`Filters`:`筛选`})]}),(0,x.jsxs)(`button`,{type:`button`,onClick:()=>De(!0),className:`flex-1 sm:flex-initial bg-gradient-to-r from-amber-500 to-amber-600 hover:brightness-105 text-[#0b0b14] font-extrabold px-4 py-3 rounded-xl shadow-lg transition-all text-xs flex items-center justify-center gap-1.5`,children:[(0,x.jsx)(t,{className:`w-4 h-4 fill-current animate-pulse`}),(0,x.jsx)(`span`,{children:o===`vi`?`AI`:o===`en`?`AI Search`:`AI`})]})]})]}),(0,x.jsxs)(`div`,{className:`${pe?`grid`:`hidden lg:grid`} grid-cols-2 md:grid-cols-3 gap-3 pt-2`,children:[(0,x.jsxs)(`select`,{value:ue,onChange:e=>k(e.target.value),className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:`all`,children:a.searchFieldAll}),(0,x.jsx)(`option`,{value:`title`,children:a.searchFieldTitle}),(0,x.jsx)(`option`,{value:`author`,children:a.searchFieldAuthor}),(0,x.jsx)(`option`,{value:`hanviet`,children:a.searchFieldHanviet}),(0,x.jsx)(`option`,{value:`vietphrase`,children:a.searchFieldVietphrase}),(0,x.jsx)(`option`,{value:`chinese`,children:a.searchFieldChinese}),(0,x.jsx)(`option`,{value:`description`,children:a.searchFieldDesc})]}),(0,x.jsxs)(`select`,{value:A,onChange:e=>{j(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:``,children:a.allCategories}),[`玄幻`,`都市`,`言情`,`女生`,`科幻`,`修真`,`仙侠`,`武侠`,`历史`,`网游`,`同人`,`其他`].map(e=>(0,x.jsx)(`option`,{value:e,children:_(e)},e))]}),(0,x.jsxs)(`select`,{value:M,onChange:e=>{de(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:``,children:a.allSources}),[`Ixdzs`,`Biquge`,`41nr`,`Quanben`,`Faloo`,`Fanqie`,`Hjwzw`].map(e=>(0,x.jsx)(`option`,{value:e,children:e},e))]}),(0,x.jsxs)(`select`,{value:N,onChange:e=>{fe(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:``,children:a.allDup}),(0,x.jsx)(`option`,{value:`multi`,children:a.dupMulti}),(0,x.jsx)(`option`,{value:`single`,children:a.dupSingle})]}),(0,x.jsxs)(`select`,{value:P,onChange:e=>{F(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:``,children:a.allChapters}),(0,x.jsx)(`option`,{value:`100`,children:a.ch100}),(0,x.jsx)(`option`,{value:`500`,children:a.ch500}),(0,x.jsx)(`option`,{value:`1000`,children:a.ch1000}),(0,x.jsx)(`option`,{value:`2000`,children:a.ch2000})]}),(0,x.jsxs)(`select`,{value:I,onChange:e=>{L(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:`site_count DESC`,children:a.sortBy.site_count}),(0,x.jsx)(`option`,{value:`chapters_max DESC`,children:a.sortBy.chapters_max}),(0,x.jsx)(`option`,{value:`word_count_max DESC`,children:a.sortBy.word_count_max}),(0,x.jsx)(`option`,{value:`title ASC`,children:a.sortBy.title_asc}),(0,x.jsx)(`option`,{value:`title DESC`,children:a.sortBy.title_desc}),(0,x.jsx)(`option`,{value:`id ASC`,children:a.sortBy.default})]})]}),(0,x.jsxs)(`div`,{className:`flex justify-between items-center border-t border-[#1f1f3a]/30 pt-3`,children:[(0,x.jsx)(`span`,{className:`text-slate-500 text-xs`,children:o===`vi`?(0,x.jsxs)(x.Fragment,{children:[`Tìm thấy `,(0,x.jsx)(`strong`,{children:H.toLocaleString()}),` truyện`]}):o===`en`?(0,x.jsxs)(x.Fragment,{children:[`Found `,(0,x.jsx)(`strong`,{children:H.toLocaleString()}),` novels`]}):(0,x.jsxs)(x.Fragment,{children:[`找到 `,(0,x.jsx)(`strong`,{children:H.toLocaleString()}),` 部小说`]})}),(0,x.jsxs)(`div`,{className:`flex items-center gap-4`,children:[(0,x.jsxs)(`button`,{type:`button`,onClick:We,className:`inline-flex items-center gap-1.5 text-amber-400 hover:text-amber-300 transition-colors text-xs font-extrabold`,children:[(0,x.jsx)(se,{className:`w-3.5 h-3.5`}),` `,o===`vi`?`Random Truyện (Gacha)`:o===`en`?`Random Novel (Gacha)`:`随机小说 (抽卡)`]}),(0,x.jsxs)(`button`,{type:`button`,onClick:Ue,className:`inline-flex items-center gap-1 text-slate-400 hover:text-white transition-colors text-xs font-bold`,children:[(0,x.jsx)(te,{className:`w-3.5 h-3.5`}),` `,a.clearFilter]})]})]})]}),le?(0,x.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,x.jsx)(n,{className:`w-8 h-8 animate-spin mx-auto mb-3 text-brand-500`}),(0,x.jsx)(`span`,{children:a.loading})]}):T?(0,x.jsxs)(`div`,{className:`py-20 text-center text-red-500`,children:[(0,x.jsx)(oe,{className:`w-10 h-10 mx-auto mb-3 text-red-400`}),(0,x.jsx)(`span`,{children:T})]}):S.length===0?(0,x.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,x.jsx)(c,{className:`w-10 h-10 mx-auto mb-3`}),(0,x.jsx)(`span`,{children:a.empty})]}):(0,x.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4`,children:S.map(e=>(0,x.jsxs)(`div`,{className:`flex flex-col`,children:[(0,x.jsx)(l,{book:e,isFav:Ce.has(e.id),onToggleFav:Ve,onCompare:He,onPlayTrailer:G,onSearchAuthor:Le,onSearchCategory:Re,onRead:Be}),_e===e.id&&(0,x.jsx)(`div`,{className:`bg-[#0f101f] border border-[#1f1f3a] rounded-b-2xl p-4 text-xs mt-[-10px] space-y-4 shadow-inner`,children:xe?(0,x.jsx)(`div`,{className:`text-center text-slate-500 py-3`,children:a.comparingText}):(0,x.jsx)(`div`,{className:`overflow-x-auto`,children:(0,x.jsxs)(`table`,{className:`w-full text-left text-[11px] text-slate-300`,children:[(0,x.jsx)(`thead`,{children:(0,x.jsxs)(`tr`,{className:`border-b border-[#2d2d55] text-slate-400 font-bold`,children:[(0,x.jsx)(`th`,{className:`pb-2`,children:o===`vi`?`Chỉ số`:o===`en`?`Metric`:`指标`}),(0,x.jsx)(`th`,{className:`pb-2`,children:o===`vi`?`Điểm số`:o===`en`?`Score`:`评分`}),(0,x.jsx)(`th`,{className:`pb-2`,children:o===`vi`?`Nguồn tiêu biểu`:o===`en`?`Best Source`:`推荐站`})]})}),(0,x.jsxs)(`tbody`,{className:`divide-y divide-[#1f1f3a]`,children:[(0,x.jsxs)(`tr`,{children:[(0,x.jsx)(`td`,{className:`py-2 font-semibold text-slate-400`,children:o===`vi`?`Tốc độ cập nhật`:o===`en`?`Update Speed`:`更新速度`}),(0,x.jsx)(`td`,{className:`py-2 text-emerald-400 font-bold`,children:`4.8`}),(0,x.jsxs)(`td`,{className:`py-2 text-slate-300`,children:[`Metruyenchu `,(0,x.jsx)(`span`,{className:`text-slate-500 text-[10px]`,children:`31 votes`})]})]}),(0,x.jsxs)(`tr`,{children:[(0,x.jsx)(`td`,{className:`py-2 font-semibold text-slate-400`,children:o===`vi`?`Quảng cáo & Sạch`:o===`en`?`Ads & Cleanliness`:`广告与排版`}),(0,x.jsx)(`td`,{className:`py-2 text-emerald-400 font-bold`,children:`4.9`}),(0,x.jsxs)(`td`,{className:`py-2 text-slate-300`,children:[`TruyenFull `,(0,x.jsx)(`span`,{className:`text-slate-500 text-[10px]`,children:`81 votes`})]})]}),(0,x.jsxs)(`tr`,{children:[(0,x.jsx)(`td`,{className:`py-2 font-semibold text-slate-400`,children:o===`vi`?`Độ chuẩn bản dịch`:o===`en`?`Translation Standard`:`翻译准确度`}),(0,x.jsx)(`td`,{className:`py-2 text-emerald-400 font-bold`,children:`4.7`}),(0,x.jsxs)(`td`,{className:`py-2 text-slate-300`,children:[`MeDoc `,(0,x.jsx)(`span`,{className:`text-slate-500 text-[10px]`,children:`63 votes`})]})]})]})]})})})]},e.id))}),B>1&&(0,x.jsxs)(`div`,{className:`flex flex-wrap justify-center gap-2 pt-6`,children:[(0,x.jsxs)(`button`,{onClick:()=>z(e=>Math.max(1,e-1)),disabled:R===1,className:`px-4 py-2 bg-[#121225] border border-[#1f1f3a] rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-brand-500 hover:text-white transition-all text-xs font-semibold`,children:[`« `,o===`vi`?`Trước`:o===`en`?`Prev`:`上一页`]}),Array.from({length:Math.min(5,B)},(e,t)=>{let n=R;return n=R<=3?t+1:R>=B-2?B-4+t:R-2+t,n<1||n>B?null:(0,x.jsx)(`button`,{onClick:()=>z(n),className:`w-9 h-9 rounded-lg border text-xs font-semibold transition-all ${R===n?`bg-purple-600 border-purple-600 text-white shadow-md`:`bg-[#121225] border-[#1f1f3a] hover:bg-white/5 text-slate-400`}`,children:n},n)}),(0,x.jsxs)(`button`,{onClick:()=>z(e=>Math.min(B,e+1)),disabled:R===B,className:`px-4 py-2 bg-[#121225] border border-[#1f1f3a] rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-brand-500 hover:text-white transition-all text-xs font-semibold`,children:[o===`vi`?`Sau`:o===`en`?`Next`:`下一页`,` »`]})]})]}),(0,x.jsxs)(`div`,{className:`space-y-6`,children:[(0,x.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-2xl p-5 shadow-xl`,children:[(0,x.jsxs)(`h3`,{className:`text-sm font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-amber-200 flex items-center gap-2 mb-4`,children:[(0,x.jsx)(e,{className:`w-5 h-5 text-amber-400`}),` `,o===`vi`?`BẢNG XẾP HẠNG HOT`:o===`en`?`HOT RANKINGS`:`热门排行`]}),we.length>0?(0,x.jsx)(`div`,{className:`space-y-4`,children:we.map((e,t)=>(0,x.jsxs)(`div`,{className:`flex items-center justify-between border-b border-white/5 pb-3 last:border-0 last:pb-0`,children:[(0,x.jsxs)(`div`,{className:`flex items-center gap-3 min-w-0`,children:[(0,x.jsx)(`span`,{className:`w-6 h-6 rounded-full flex items-center justify-center text-xs font-extrabold shrink-0 shadow-md ${t===0?`bg-gradient-to-br from-yellow-300 to-amber-500 text-white`:t===1?`bg-gradient-to-br from-slate-200 to-slate-400 text-slate-800`:t===2?`bg-gradient-to-br from-amber-600 to-amber-800 text-white`:`bg-white/5 text-slate-400 border border-white/10`}`,children:t+1}),(0,x.jsxs)(`div`,{className:`min-w-0`,children:[(0,x.jsx)(`h4`,{className:`text-xs font-bold text-slate-200 truncate`,children:e.title}),(0,x.jsxs)(`p`,{className:`text-[10px] text-slate-500 mt-0.5`,children:[`✍ `,e.author]})]})]}),(0,x.jsxs)(`div`,{className:`flex items-center gap-1 text-[10px] shrink-0 font-bold`,children:[e.trend===`up`&&(0,x.jsxs)(`span`,{className:`text-emerald-500 flex items-center`,children:[`▲ `,e.diff]}),e.trend===`down`&&(0,x.jsxs)(`span`,{className:`text-red-500 flex items-center`,children:[`▼ `,e.diff]}),e.trend===`none`&&(0,x.jsx)(`span`,{className:`text-slate-500`,children:`—`})]})]},e.id))}):(0,x.jsx)(`p`,{className:`text-slate-500 text-xs text-center py-6`,children:o===`vi`?`Chưa có xếp hạng.`:o===`en`?`No rankings.`:`暂无排行`})]}),(0,x.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a]/85 rounded-2xl p-5 shadow-xl space-y-4`,children:[(0,x.jsxs)(`h3`,{className:`text-sm font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-brand-300 to-purple-300 flex items-center gap-2`,children:[(0,x.jsx)(r,{className:`w-5 h-5 text-brand-400`}),` `,o===`vi`?`Hoạt động cộng đồng`:o===`en`?`Community Activity`:`社区动态`]}),(0,x.jsx)(`div`,{className:`flex -space-x-2 overflow-hidden py-1 border-b border-white/5 pb-3`,children:Pe.map(e=>(0,x.jsx)(`img`,{className:`inline-block h-6 w-6 rounded-full ring-2 ring-[#121225] object-cover`,src:e.avatar,alt:e.user},e.id))}),(0,x.jsx)(`div`,{className:`space-y-4`,children:Pe.map(e=>{let t=`bg-emerald-500/15 border-emerald-500/35 text-emerald-400`;return e.source===`Truyenchu`&&(t=`bg-sky-500/15 border-sky-500/35 text-sky-400`),e.source===`Nady knise`&&(t=`bg-purple-500/15 border-purple-500/35 text-purple-400`),(0,x.jsxs)(`div`,{className:`border-b border-white/5 pb-3.5 last:border-0 last:pb-0 space-y-2`,children:[(0,x.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,x.jsx)(`div`,{className:`flex items-center gap-2`,children:(0,x.jsx)(`span`,{className:`text-slate-200 text-xs font-bold`,children:e.user})}),(0,x.jsx)(`span`,{className:`text-slate-500 text-[10px]`,children:e.time})]}),(0,x.jsxs)(`div`,{className:`flex gap-2 items-center`,children:[(0,x.jsx)(`span`,{className:`px-1.5 py-0.5 rounded text-[8px] font-bold border uppercase shrink-0 ${t}`,children:e.source}),(0,x.jsx)(`p`,{className:`text-slate-300 text-xs leading-normal flex-1`,children:e.comment})]})]},e.id)})})]})]})]}),(0,x.jsx)(ie,{slot:`discover-bottom`}),(0,x.jsx)(ce,{isOpen:Ee,onClose:()=>De(!1),onSelectBook:Ke})]})}export{S as default};
|
frontend-web/dist/assets/Downloads-DcrmkLWQ.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import{d as e,r as t,s as n,t as r}from"./MainLayout-COiTxmNr.js";import{o as i}from"./square-DZKJ0QGR.js";import{t as a}from"./file-text-mhTXpwIY.js";import{t as o}from"./laptop-BPoCx4gb.js";import{C as ee,F as s,M as c,S as te,b as l,g as u,j as d,w as f}from"./index-yRoRoI6u.js";var p=l(`info`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 16v-4`,key:`1dtifu`}],[`path`,{d:`M12 8h.01`,key:`e9boi3`}]]),m=s(d(),1),h=c(),g=e=>(0,h.jsxs)(`svg`,{...e,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`circle`,{cx:`12`,cy:`12`,r:`10`}),(0,h.jsx)(`circle`,{cx:`12`,cy:`12`,r:`4`}),(0,h.jsx)(`line`,{x1:`21.17`,y1:`8`,x2:`12`,y2:`8`}),(0,h.jsx)(`line`,{x1:`3.95`,y1:`6.06`,x2:`8.54`,y2:`14`}),(0,h.jsx)(`line`,{x1:`10.88`,y1:`21.94`,x2:`15.46`,y2:`14`})]}),_=e=>(0,h.jsxs)(`svg`,{...e,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`path`,{d:`M22 12L3 12`,strokeWidth:`1.5`}),(0,h.jsx)(`path`,{d:`M12 2L12 22`,strokeWidth:`1.5`}),(0,h.jsx)(`path`,{d:`M3 5.41L10.33 4.41V11.58H3V5.41Z`,fill:`currentColor`,opacity:`0.2`}),(0,h.jsx)(`path`,{d:`M11.67 4.23L21 3V11.58H11.67V4.23Z`,fill:`currentColor`,opacity:`0.2`}),(0,h.jsx)(`path`,{d:`M3 12.42H10.33V19.59L3 18.59V12.42Z`,fill:`currentColor`,opacity:`0.2`}),(0,h.jsx)(`path`,{d:`M11.67 12.42H21V21L11.67 19.77V12.42Z`,fill:`currentColor`,opacity:`0.2`}),(0,h.jsx)(`path`,{d:`M3 5.41L10.33 4.41V11.58H3V5.41ZM11.67 4.23L21 3V11.58H11.67V4.23ZM3 12.42H10.33V19.59L3 18.59V12.42ZM11.67 12.42H21V21L11.67 19.77V12.42Z`})]});function v(){let{lang:s}=te(),{user:c}=ee(),[l,d]=(0,m.useState)(!1),[v,ne]=(0,m.useState)(!1),y=typeof window<`u`&&!!window.electron,[b,re]=(0,m.useState)({extension:{version:`1.0.0`,download_url:`/downloads/tts_extension.zip`,file_size:`10.7 MB`,release_notes:`Cập nhật dịch nhanh và tối ưu hóa Chrome Extension Helper`},desktop_linux:{version:`0.0.0`,download_url:`https://huggingface.co/datasets/Cong123779/tienhiep-data/resolve/main/downloads/TienHiepAI-0.0.0.AppImage`,file_size:`116 MB`,release_notes:`Phiên bản AppImage beta dành cho Linux`},desktop_windows:{version:`0.0.0`,download_url:`#`,file_size:`0 MB`,release_notes:`Bản Windows chính thức sắp ra mắt`}}),[x,ie]=(0,m.useState)(`extension`),[ae,oe]=(0,m.useState)(``),[se,ce]=(0,m.useState)(``),[le,S]=(0,m.useState)(``),[ue,C]=(0,m.useState)(``),[de,fe]=(0,m.useState)(!1),[pe,me]=(0,m.useState)(``),[w,T]=(0,m.useState)(!1),[E,D]=(0,m.useState)(0),[O,k]=(0,m.useState)(``),[A,j]=(0,m.useState)(!1),[M,N]=(0,m.useState)(`idle`),[P,F]=(0,m.useState)(``),[I,L]=(0,m.useState)(!1),R=async(e,t)=>{if(!window.electron||!window.electron.downloadAndRunUpdate)return;T(!0),D(0),k(s===`vi`?`Đang kết nối tải bản cập nhật...`:`Connecting to download update...`);let n=window.electron.onUpdateDownloadProgress(e=>{e&&typeof e.percent==`number`&&(D(e.percent),k(s===`vi`?`Đang tải: ${e.percent}% (${(e.downloadedBytes/(1024*1024)).toFixed(1)}MB / ${(e.totalBytes/(1024*1024)).toFixed(1)}MB)`:`Downloading: ${e.percent}% (${(e.downloadedBytes/(1024*1024)).toFixed(1)}MB / ${(e.totalBytes/(1024*1024)).toFixed(1)}MB)`))});try{let n=await window.electron.downloadAndRunUpdate(e,t);n&&n.success?k(s===`vi`?`Tải về hoàn tất! Đang khởi chạy gói cài đặt...`:`Download complete! Launching setup...`):(T(!1),alert((s===`vi`?`Lỗi tải cập nhật: `:`Error loading update: `)+(n.error||`Unknown`)))}catch(e){T(!1),alert((s===`vi`?`Lỗi hệ thống: `:`System error: `)+e.message)}finally{n()}},z=async(e,t)=>{if(!window.electron||!window.electron.quickPatchUpdate)return;T(!0),D(0),k(s===`vi`?`Đang kết nối tải bản vá...`:`Connecting to download patch...`);let n=window.electron.onUpdateDownloadProgress(e=>{e&&typeof e.percent==`number`&&(D(e.percent),k(s===`vi`?`Đang tải bản vá: ${e.percent}% (${(e.downloadedBytes/(1024*1024)).toFixed(2)}MB / ${(e.totalBytes/(1024*1024)).toFixed(2)}MB)`:`Downloading patch: ${e.percent}% (${(e.downloadedBytes/(1024*1024)).toFixed(2)}MB / ${(e.totalBytes/(1024*1024)).toFixed(2)}MB)`))});try{let n=await window.electron.quickPatchUpdate(e,t);n&&n.success?k(s===`vi`?`Áp dụng bản vá thành công! Đang khởi động lại ứng dụng...`:`Patch applied successfully! Restarting...`):(T(!1),alert((s===`vi`?`Lỗi cập nhật bản vá: `:`Patch update error: `)+(n.error||`Unknown`)))}catch(e){T(!1),alert((s===`vi`?`Lỗi hệ thống: `:`System error: `)+e.message)}finally{n()}},he=async()=>{if(window.electron?.uninstallApp){j(!1),N(`running`),F(s===`vi`?`Đang gỡ cài đặt...`:`Uninstalling...`);try{let e=await window.electron.uninstallApp();e?.success?e.platform===`win32`&&e.launched?(N(`done`),F(s===`vi`?`✅ Trình gỡ cài đặt đã khởi chạy. Ứng dụng sẽ đóng lại...`:`✅ Uninstaller launched. App will close...`)):e.platform===`linux`&&(N(`done`),F(s===`vi`?`✅ Đã gỡ đăng ký hệ thống (desktop entries, MIME). Để xóa ho��n toàn, hãy xóa file AppImage và thư mục dữ liệu:\n${e.userDataPath}`:`✅ System entries removed (desktop, MIME). To fully uninstall, delete the AppImage file and data folder:\n${e.userDataPath}`)):(N(`error`),F((s===`vi`?`❌ Lỗi: `:`❌ Error: `)+(e?.error||`Unknown`)))}catch(e){N(`error`),F(`❌ `+e.message)}}},ge=async()=>{if(window.electron?.clearUserData){L(!1);try{let e=await window.electron.clearUserData();e?.success?alert((s===`vi`?`✅ Đã xóa dữ liệu cục bộ:
|
| 2 |
+
`:`✅ Local data cleared:
|
| 3 |
+
`)+(e.cleared?.join(`, `)||`none`)):alert(`❌ `+(e?.error||`Failed`))}catch(e){alert(`❌ `+e.message)}}},[B,V]=(0,m.useState)(``),[H,U]=(0,m.useState)(``),W=e=>{if(!e)return[];let t=e.history||[],n=[{version:e.version,download_url:e.download_url,patch_url:e.patch_url,file_size:e.file_size,release_notes:e.release_notes},...t],r=[],i=new Set;for(let e of n)!e.version||!e.download_url||e.download_url===`#`||i.has(e.version)||(i.add(e.version),r.push(e));return r};(0,m.useEffect)(()=>{_e()},[]);let _e=async()=>{try{let e=await f.get(`/api/releases`);if(e.data&&e.data.success&&e.data.releases){if(re(t=>({...t,...e.data.releases})),e.data.releases.desktop_linux){let t=W(e.data.releases.desktop_linux);t.length>0?V(t[0].version):V(e.data.releases.desktop_linux.version)}if(e.data.releases.desktop_windows){let t=W(e.data.releases.desktop_windows);t.length>0?U(t[0].version):U(e.data.releases.desktop_windows.version)}}}catch(e){console.error(`Failed to load releases from API:`,e)}};(0,m.useEffect)(()=>{let e=b[x];e&&(oe(e.version||``),ce(e.download_url||``),S(e.file_size||``),C(e.release_notes||``))},[x,b]);let G={vi:{title:`Tải App & Extension`,subtitle:`Đồng bộ trải nghiệm đọc truyện và dịch thuật AI tối ưu trên mọi nền tảng trình duyệt và máy tính.`,extensionTitle:`Chrome Extension Helper`,extensionDesc:`Tiện ích tích hợp trực tiếp vào trình duyệt giúp tự động lấy chương, dịch nhanh tiếng Trung và đồng bộ lịch sử đọc với Web App.`,extensionBtn:`Tải tiện ích (.ZIP)`,extensionStepHeader:`Các bước cài đặt thủ công (Developer Mode)`,extSteps:[`Tải tệp tin tts_extension.zip bằng nút bên trên và giải nén ra một thư mục riêng biệt.`,`Mở trình duyệt Chrome hoặc Edge, truy cập đường dẫn quản lý tiện ích: chrome://extensions/`,`Gạt nút kích hoạt Chế độ nhà phát triển (Developer mode) ở góc trên bên phải màn hình.`,`Click chọn Tải tiện ích đã giải nén (Load unpacked) ở góc trên bên trái.`,`Chọn thư mục đã giải nén ở Bước 1. Biểu tượng Tiên Hiệp AI sẽ xuất hiện trên thanh công cụ!`],appTitle:`Linux Client (AppImage)`,appDesc:`Ứng dụng chuyên dụng cho hệ điều hành Linux, tối ưu hiệu năng dịch thuật, lưu trữ sách ngoại tuyến (Offline) và tự động cập nhật từ điển.`,appBtnLinux:`Tải bản Linux (.AppImage)`,appStepHeader:`Cách chạy thủ công`,appSteps:[`Tải file cài đặt TienHiepAI-0.0.0.AppImage về máy tính.`,`Cấp quyền thực thi và chạy file bằng lệnh:`],copyTooltip:`Sao chép lệnh`,copiedTooltip:`Đã sao chép!`,noteTitle:`⚠️ Lưu ý đồng bộ`,noteText:`Vui lòng đăng nhập trên App/Ext bằng cùng tài khoản của trang Web để số liệu dịch thuật và tổng thời gian đọc được đồng bộ hóa chuẩn xác nhất.`},en:{title:`Download App & Extension`,subtitle:`Synchronize your reading experience and AI translations across all browsers and desktop platforms.`,extensionTitle:`Chrome Extension Helper`,extensionDesc:`Integrate directly into your browser to automatically capture chapters, translate Chinese instantly, and sync reading history with the Web App.`,extensionBtn:`Download Extension (.ZIP)`,extensionStepHeader:`Manual Installation Steps (Developer Mode)`,extSteps:[`Download the tts_extension.zip using the button above and extract it into a separate folder.`,`Open Chrome or Edge and navigate to the extension manager: chrome://extensions/`,`Toggle on the Developer mode switch at the top-right corner of the window.`,`Click the Load unpacked button at the top-left corner.`,`Select the folder extracted in Step 1. The Tien Hiep AI icon will appear on your toolbar!`],appTitle:`Linux Client (AppImage)`,appDesc:`Specialized desktop application for Linux translation performance, offline book storage, and automatic local dictionary updates.`,appBtnLinux:`Download for Linux (.AppImage)`,appStepHeader:`Manual Run Instructions`,appSteps:[`Download the TienHiepAI-0.0.0.AppImage file to your computer.`,`Grant execution permission using the command below:`],copyTooltip:`Copy command`,copiedTooltip:`Copied!`,noteTitle:`⚠️ Sync Warning`,noteText:`Please log in on all clients using the same account as the web portal to ensure translation metrics and reading logs sync perfectly.`},zh:{title:`下载中心`,subtitle:`在所有浏览器和电脑平台上同步您的阅读体验与 AI 翻译记录。`,extensionTitle:`Chrome 辅助插件`,extensionDesc:`直接嵌入浏览器以自动抓取章节、快速翻译中文,并与网页版同步阅读进度和统计数据。`,extensionBtn:`下载插件包 (.ZIP)`,extensionStepHeader:`手动安装步骤 (开发者模式)`,extSteps:[`使用上方按钮下载 tts_extension.zip 并解压到一个独立的文件夹中。`,`打开 Chrome 或 Edge 浏览器,访问插件管理器:chrome://extensions/`,`开启右上角的 开发者模式 (Developer mode) 开关。`,`点击左上角的 加载已解压的扩展程序 (Load unpacked) 按钮。`,`选择您在步骤1中解压的文件夹,仙侠 AI 图标即会出现在浏览器工具栏上!`],appTitle:`Linux 客户端 (AppImage)`,appDesc:`专为 Linux 打造的桌面客户端,提供极佳的翻译性能、离线书籍缓存以及自动本地词库更新。`,appBtnLinux:`下载 Linux 版 (.AppImage)`,appStepHeader:`手动运行指南`,appSteps:[`将 TienHiepAI-0.0.0.AppImage 文件下载到您的电脑中。`,`使用以下命令赋予可执行权限:`],copyTooltip:`复制命令`,copiedTooltip:`已复制!`,noteTitle:`⚠️ 重要提示`,noteText:`浏览器插件和电脑桌面端均通过您的账号进行数据同步。请确保在所有客户端上登录相同的账号,以便精确统计您的翻译字符数和总阅读时长。`}},K=W(b.desktop_windows),q=W(b.desktop_linux),J=q.find(e=>e.version===B)||q[0]||b.desktop_linux,Y=K.find(e=>e.version===H)||K[0]||b.desktop_windows,X=G[s]||G.vi,Z=`chmod +x ${J.download_url.substring(J.download_url.lastIndexOf(`/`)+1)||`TienHiepAI.AppImage`} && ./${J.download_url.substring(J.download_url.lastIndexOf(`/`)+1)||`TienHiepAI.AppImage`}`,Q=`sudo curl -L -o /usr/local/bin/tienhiep-ai "${J.download_url}" && sudo chmod +x /usr/local/bin/tienhiep-ai`,$=(e,t)=>{navigator.clipboard.writeText(e),t(!0),setTimeout(()=>t(!1),2e3)};return(0,h.jsxs)(r,{children:[(0,h.jsxs)(`div`,{className:`max-w-6xl mx-auto space-y-8 py-4 sm:py-6`,children:[(0,h.jsxs)(`div`,{className:`text-center space-y-3 max-w-3xl mx-auto`,children:[(0,h.jsx)(`div`,{className:`inline-flex p-3 bg-purple-600/10 rounded-full border border-purple-500/20 text-purple-400 mb-2 animate-bounce`,children:(0,h.jsx)(t,{className:`w-6 h-6`})}),(0,h.jsx)(`h1`,{className:`text-2xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-indigo-200 to-purple-300 tracking-wide uppercase`,children:X.title}),(0,h.jsx)(`p`,{className:`text-slate-400 text-sm leading-relaxed sm:text-base`,children:X.subtitle})]}),(0,h.jsxs)(`div`,{className:`bg-[#191635]/40 border border-indigo-500/20 rounded-2xl p-4 sm:p-5 flex gap-4 items-start shadow-md`,children:[(0,h.jsx)(p,{className:`w-5 h-5 text-indigo-400 shrink-0 mt-0.5`}),(0,h.jsxs)(`div`,{className:`space-y-1`,children:[(0,h.jsx)(`h4`,{className:`text-xs font-bold text-white uppercase tracking-wider`,children:X.noteTitle}),(0,h.jsx)(`p`,{className:`text-slate-300 text-xs leading-relaxed`,children:X.noteText})]})]}),!1,(0,h.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-3 gap-6`,children:[(0,h.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 rounded-3xl p-6 flex flex-col justify-between shadow-xl relative hover:border-emerald-500/20 hover:shadow-emerald-950/5 transition-all duration-300 group`,children:[(0,h.jsx)(`div`,{className:`absolute top-0 right-0 w-20 h-20 bg-emerald-500/5 rounded-bl-full filter blur-xl opacity-50 group-hover:bg-emerald-500/10 transition-colors`}),(0,h.jsxs)(`div`,{className:`space-y-5`,children:[(0,h.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,h.jsx)(`div`,{className:`p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-2xl text-emerald-400 group-hover:scale-115 transition-transform`,children:(0,h.jsx)(g,{className:`w-5 h-5`})}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h3`,{className:`text-base font-bold text-white`,children:X.extensionTitle}),(0,h.jsxs)(`span`,{className:`inline-block text-[9px] font-black tracking-widest text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full uppercase mt-0.5`,children:[`v`,b.extension.version,` • Verified`]})]})]}),(0,h.jsx)(`p`,{className:`text-slate-400 text-xs leading-relaxed min-h-[50px]`,children:X.extensionDesc}),(0,h.jsxs)(`div`,{children:[(0,h.jsxs)(`a`,{href:b.extension.download_url,download:`tts_extension.zip`,className:`w-full inline-flex items-center justify-center gap-2 bg-emerald-600 hover:bg-emerald-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:X.extensionBtn})]}),(0,h.jsxs)(`p`,{className:`text-center text-[9px] text-slate-500 mt-2`,children:[`ZIP Format • Size: ~`,b.extension.file_size]})]}),(0,h.jsxs)(`div`,{className:`bg-[#0b0b14]/50 border border-emerald-500/10 p-3 rounded-xl space-y-1`,children:[(0,h.jsx)(`span`,{className:`text-[9px] font-bold text-emerald-400 uppercase tracking-widest`,children:`📝 Changelog`}),(0,h.jsxs)(`p`,{className:`text-slate-300 text-[11px] leading-relaxed italic`,children:[`"`,b.extension.release_notes,`"`]})]}),(0,h.jsxs)(`div`,{className:`border-t border-[#1f1f3a]/50 pt-4 space-y-3`,children:[(0,h.jsxs)(`h4`,{className:`text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-2`,children:[(0,h.jsx)(a,{className:`w-3.5 h-3.5 text-slate-400`}),` `,X.extensionStepHeader]}),(0,h.jsx)(`ol`,{className:`space-y-3 text-[11px] text-slate-400 list-none pl-0`,children:X.extSteps.map((e,t)=>(0,h.jsxs)(`li`,{className:`flex gap-2 items-start`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:t+1}),(0,h.jsx)(`span`,{className:`leading-relaxed`,children:e})]},t))})]})]})]}),(0,h.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 rounded-3xl p-6 flex flex-col justify-between shadow-xl relative hover:border-purple-500/20 hover:shadow-purple-950/5 transition-all duration-300 group`,children:[(0,h.jsx)(`div`,{className:`absolute top-0 right-0 w-20 h-20 bg-purple-500/5 rounded-bl-full filter blur-xl opacity-50 group-hover:bg-purple-500/10 transition-colors`}),(0,h.jsxs)(`div`,{className:`space-y-5`,children:[(0,h.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,h.jsx)(`div`,{className:`p-3 bg-purple-500/10 border border-purple-500/20 rounded-2xl text-purple-400 group-hover:scale-115 transition-transform`,children:(0,h.jsx)(o,{className:`w-5 h-5`})}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h3`,{className:`text-base font-bold text-white`,children:X.appTitle}),(0,h.jsxs)(`div`,{className:`flex flex-wrap gap-1.5 items-center mt-1`,children:[(0,h.jsxs)(`span`,{className:`inline-block text-[9px] font-black tracking-widest text-purple-400 bg-purple-500/10 px-2 py-0.5 rounded-full uppercase`,children:[`v`,J.version,` • Linux Beta`]}),q.length>1&&(0,h.jsx)(`select`,{value:B,onChange:e=>V(e.target.value),className:`bg-[#0b0b14] border border-[#1f1f3a] text-purple-400 font-extrabold text-[10px] rounded-lg px-1.5 py-0.5 focus:outline-none focus:border-purple-500 transition-colors cursor-pointer`,children:q.map(e=>(0,h.jsxs)(`option`,{value:e.version,children:[`v`,e.version]},e.version))})]})]})]}),(0,h.jsx)(`p`,{className:`text-slate-400 text-xs leading-relaxed min-h-[50px]`,children:X.appDesc}),(0,h.jsx)(`div`,{children:(0,h.jsxs)(`div`,{children:[y?J.patch_url?(0,h.jsxs)(`div`,{className:`space-y-2`,children:[(0,h.jsxs)(`button`,{onClick:()=>z(J.patch_url,J.version),className:`w-full inline-flex items-center justify-center gap-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center cursor-pointer`,children:[(0,h.jsx)(e,{className:`w-4 h-4`}),(0,h.jsxs)(`span`,{children:[`⚡ Cập nhật nhanh lên v`,J.version,` (Khuyên dùng)`]})]}),(0,h.jsxs)(`button`,{onClick:()=>{let e=J.download_url;R(e,e.substring(e.lastIndexOf(`/`)+1)||`TienHiepAI.AppImage`)},className:`w-full inline-flex items-center justify-center gap-2 bg-slate-800 hover:bg-slate-700 active:scale-98 text-slate-300 font-extrabold px-5 py-2.5 rounded-xl border border-slate-700 transition-all text-[11px] text-center cursor-pointer`,children:[(0,h.jsx)(t,{className:`w-3.5 h-3.5`}),(0,h.jsx)(`span`,{children:`Tải bộ cài Full Setup (v1.0.6)`})]})]}):(0,h.jsxs)(`button`,{onClick:()=>{let e=J.download_url;if(!e||e===`#`){alert(s===`vi`?`Liên kết tải xuống không khả dụng.`:`Download link is not available.`);return}R(e,e.substring(e.lastIndexOf(`/`)+1)||`TienHiepAI.AppImage`)},className:`w-full inline-flex items-center justify-center gap-2 bg-purple-600 hover:bg-purple-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center cursor-pointer`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:`Tải bản Linux (.AppImage)`})]}):(0,h.jsxs)(`div`,{className:`space-y-2`,children:[(0,h.jsxs)(`a`,{href:J.download_url,className:`w-full inline-flex items-center justify-center gap-2 bg-purple-600 hover:bg-purple-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:`Tải bản Linux (.AppImage)`})]}),J.download_url.includes(`1.0.6`)&&J.patch_url&&(0,h.jsxs)(`p`,{className:`text-[10px] text-amber-400 leading-normal bg-amber-500/5 border border-amber-500/10 p-2.5 rounded-xl text-center`,children:[`💡 `,(0,h.jsx)(`b`,{children:`Lưu ý:`}),` v`,J.version,` là bản nâng cấp nhanh. Bạn đang tải `,(0,h.jsx)(`b`,{children:`bộ cài nền tảng v1.0.6`}),`, sau khi mở app nó sẽ tự động update lên v`,J.version,` trong 3 giây.`]})]}),(0,h.jsxs)(`p`,{className:`text-center text-[9px] text-slate-500 mt-2`,children:[`AppImage Format • Size: ~`,J.file_size]})]})}),(0,h.jsxs)(`div`,{className:`bg-[#0b0b14]/50 border border-purple-500/10 p-3 rounded-xl space-y-1`,children:[(0,h.jsx)(`span`,{className:`text-[9px] font-bold text-purple-400 uppercase tracking-widest`,children:`📝 Changelog`}),(0,h.jsxs)(`p`,{className:`text-slate-300 text-[11px] leading-relaxed italic`,children:[`"`,J.release_notes,`"`]})]}),(0,h.jsxs)(`div`,{className:`border-t border-[#1f1f3a]/50 pt-4 space-y-4`,children:[(0,h.jsxs)(`div`,{className:`space-y-1.5`,children:[(0,h.jsxs)(`h4`,{className:`text-xs font-black text-amber-400 uppercase tracking-wider flex items-center gap-2`,children:[(0,h.jsx)(n,{className:`w-3.5 h-3.5 text-amber-400`}),` ⚡ Cài đặt nhanh bằng lệnh SUDO`]}),(0,h.jsx)(`p`,{className:`text-[10px] text-slate-400 leading-relaxed`,children:`Mở Terminal và dán lệnh sau để tải, cấp quyền và cài đặt ứng dụng chạy trực tiếp:`}),(0,h.jsxs)(`div`,{className:`flex items-center justify-between gap-2 bg-[#0b0b14] border border-amber-500/30 rounded-xl px-3 py-2 font-mono text-[9px] text-amber-300 w-full overflow-x-auto select-all shadow-inner`,children:[(0,h.jsx)(`span`,{className:`truncate`,children:Q}),(0,h.jsx)(`button`,{onClick:()=>$(Q,ne),className:`p-1 hover:bg-white/5 rounded text-slate-400 hover:text-white transition-colors shrink-0`,title:v?X.copiedTooltip:X.copyTooltip,children:v?(0,h.jsx)(i,{className:`w-3 h-3 text-emerald-400`}):(0,h.jsx)(u,{className:`w-3 h-3`})})]}),(0,h.jsxs)(`p`,{className:`text-[9px] text-slate-500 font-bold`,children:[`💡 Sau khi chạy, bạn chỉ cần gõ `,(0,h.jsx)(`span`,{className:`text-purple-400 font-mono`,children:`tienhiep-ai`}),` tại Terminal để mở app bất cứ lúc nào!`]})]}),(0,h.jsxs)(`div`,{className:`space-y-1.5 pt-1 border-t border-[#1f1f3a]/30`,children:[(0,h.jsxs)(`h4`,{className:`text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-2`,children:[(0,h.jsx)(a,{className:`w-3.5 h-3.5 text-slate-400`}),` `,X.appStepHeader]}),(0,h.jsxs)(`ol`,{className:`space-y-2 text-[11px] text-slate-400 list-none pl-0`,children:[(0,h.jsxs)(`li`,{className:`flex gap-2 items-start`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:`1`}),(0,h.jsx)(`span`,{className:`leading-relaxed`,children:X.appSteps[0]})]}),(0,h.jsxs)(`li`,{className:`flex gap-2 items-start w-full`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:`2`}),(0,h.jsxs)(`div`,{className:`flex-1 space-y-1 min-w-0`,children:[(0,h.jsx)(`span`,{className:`leading-relaxed`,children:X.appSteps[1]}),(0,h.jsxs)(`div`,{className:`flex items-center justify-between gap-2 bg-[#0b0b14] border border-[#1f1f3a] rounded-xl px-3 py-1.5 font-mono text-[9px] text-purple-300 w-full overflow-x-auto select-all`,children:[(0,h.jsx)(`span`,{className:`truncate`,children:Z}),(0,h.jsx)(`button`,{onClick:()=>$(Z,d),className:`p-1 hover:bg-white/5 rounded text-slate-400 hover:text-white transition-colors shrink-0`,title:l?X.copiedTooltip:X.copyTooltip,children:l?(0,h.jsx)(i,{className:`w-3 h-3 text-emerald-400`}):(0,h.jsx)(u,{className:`w-3 h-3`})})]})]})]})]})]})]})]})]}),(0,h.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 rounded-3xl p-6 flex flex-col justify-between shadow-xl relative hover:border-blue-500/20 hover:shadow-blue-950/5 transition-all duration-300 group`,children:[(0,h.jsx)(`div`,{className:`absolute top-0 right-0 w-20 h-20 bg-blue-500/5 rounded-bl-full filter blur-xl opacity-50 group-hover:bg-blue-500/10 transition-colors`}),(0,h.jsxs)(`div`,{className:`space-y-5`,children:[(0,h.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,h.jsx)(`div`,{className:`p-3 bg-blue-500/10 border border-blue-500/20 rounded-2xl text-blue-400 group-hover:scale-115 transition-transform`,children:(0,h.jsx)(_,{className:`w-5 h-5`})}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h3`,{className:`text-base font-bold text-white`,children:`Windows Client`}),(0,h.jsxs)(`div`,{className:`flex flex-wrap gap-1.5 items-center mt-1`,children:[(0,h.jsx)(`span`,{className:`inline-block text-[9px] font-black tracking-widest px-2 py-0.5 rounded-full uppercase ${Y.download_url===`#`?`text-slate-500 bg-slate-500/10`:`text-blue-400 bg-blue-500/10`}`,children:Y.download_url===`#`?`Coming Soon`:`v${Y.version} • Stable`}),K.length>1&&(0,h.jsx)(`select`,{value:H,onChange:e=>U(e.target.value),className:`bg-[#0b0b14] border border-[#1f1f3a] text-blue-400 font-extrabold text-[10px] rounded-lg px-1.5 py-0.5 focus:outline-none focus:border-blue-500 transition-colors cursor-pointer`,children:K.map(e=>(0,h.jsxs)(`option`,{value:e.version,children:[`v`,e.version]},e.version))})]})]})]}),(0,h.jsx)(`p`,{className:`text-slate-400 text-xs leading-relaxed min-h-[50px]`,children:`Ứng dụng máy tính dành cho hệ điều hành Windows. Đầy đủ tính năng tối ưu hóa dịch thuật, lưu trữ sách, chạy mượt mà trên Win 10/11.`}),(0,h.jsxs)(`div`,{children:[Y.download_url===`#`?(0,h.jsxs)(`button`,{disabled:!0,className:`w-full inline-flex items-center justify-center gap-2 bg-slate-800/40 border border-slate-700/30 text-slate-500 font-bold px-5 py-3 rounded-xl text-xs text-center cursor-not-allowed`,children:[(0,h.jsx)(_,{className:`w-4 h-4 opacity-40`}),(0,h.jsx)(`span`,{children:`Bản Windows (Sắp ra mắt)`})]}):y?Y.patch_url?(0,h.jsxs)(`div`,{className:`space-y-2`,children:[(0,h.jsxs)(`button`,{onClick:()=>z(Y.patch_url,Y.version),className:`w-full inline-flex items-center justify-center gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center cursor-pointer`,children:[(0,h.jsx)(e,{className:`w-4 h-4`}),(0,h.jsxs)(`span`,{children:[`⚡ Cập nhật nhanh lên v`,Y.version,` (Khuyên dùng)`]})]}),(0,h.jsxs)(`button`,{onClick:()=>{let e=Y.download_url;R(e,e.substring(e.lastIndexOf(`/`)+1)||`TienHiepAI-Setup.exe`)},className:`w-full inline-flex items-center justify-center gap-2 bg-slate-800 hover:bg-slate-700 active:scale-98 text-slate-300 font-extrabold px-5 py-2.5 rounded-xl border border-slate-700 transition-all text-[11px] text-center cursor-pointer`,children:[(0,h.jsx)(t,{className:`w-3.5 h-3.5`}),(0,h.jsx)(`span`,{children:`Tải bộ cài Full Setup (v1.0.6)`})]})]}):(0,h.jsxs)(`button`,{onClick:()=>{let e=Y.download_url;if(!e||e===`#`){alert(s===`vi`?`Liên kết tải xuống không khả dụng.`:`Download link is not available.`);return}R(e,e.substring(e.lastIndexOf(`/`)+1)||`TienHiepAI-Setup.exe`)},className:`w-full inline-flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center cursor-pointer`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:`Tải bản Windows (.EXE Setup)`})]}):(0,h.jsxs)(`div`,{className:`space-y-2`,children:[(0,h.jsxs)(`a`,{href:Y.download_url,className:`w-full inline-flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:`Tải bản Windows (.EXE Setup)`})]}),Y.download_url.includes(`1.0.6`)&&Y.patch_url&&(0,h.jsxs)(`p`,{className:`text-[10px] text-amber-400 leading-normal bg-amber-500/5 border border-amber-500/10 p-2.5 rounded-xl text-center`,children:[`💡 `,(0,h.jsx)(`b`,{children:`Lưu ý:`}),` v`,Y.version,` là bản nâng cấp nhanh. Bạn đang tải `,(0,h.jsx)(`b`,{children:`bộ cài nền tảng v1.0.6`}),`, sau khi mở app nó sẽ tự động update lên v`,Y.version,` trong 3 giây.`]})]}),(0,h.jsxs)(`p`,{className:`text-center text-[9px] text-slate-500 mt-2`,children:[`Format: EXE Setup • Size: ~`,Y.file_size]})]}),(0,h.jsxs)(`div`,{className:`bg-[#0b0b14]/50 border border-blue-500/10 p-3 rounded-xl space-y-1`,children:[(0,h.jsx)(`span`,{className:`text-[9px] font-bold text-blue-400 uppercase tracking-widest`,children:`📝 Changelog`}),(0,h.jsxs)(`p`,{className:`text-slate-300 text-[11px] leading-relaxed italic`,children:[`"`,Y.release_notes,`"`]})]}),(0,h.jsxs)(`div`,{className:`border-t border-[#1f1f3a]/50 pt-4 space-y-3`,children:[(0,h.jsxs)(`h4`,{className:`text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-2`,children:[(0,h.jsx)(a,{className:`w-3.5 h-3.5 text-slate-400`}),` Hướng dẫn cài đặt`]}),(0,h.jsxs)(`ol`,{className:`space-y-3 text-[11px] text-slate-400 list-none pl-0`,children:[(0,h.jsxs)(`li`,{className:`flex gap-2 items-start`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:`1`}),(0,h.jsx)(`span`,{className:`leading-relaxed`,children:`Tải tệp tin cài đặt Windows (khi phát hành chính thức).`})]}),(0,h.jsxs)(`li`,{className:`flex gap-2 items-start`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:`2`}),(0,h.jsx)(`span`,{className:`leading-relaxed`,children:"Chạy file installer `.exe` và làm theo các bước hướng dẫn trên màn hình."})]})]})]})]})]})]}),y&&(0,h.jsxs)(`div`,{className:`bg-[#121225]/80 border border-rose-500/10 rounded-3xl p-6 shadow-xl space-y-4 relative overflow-hidden`,children:[(0,h.jsx)(`div`,{className:`absolute top-0 right-0 w-24 h-24 bg-rose-500/5 rounded-bl-full filter blur-2xl`}),(0,h.jsxs)(`div`,{className:`flex items-start gap-3 border-b border-rose-500/10 pb-4`,children:[(0,h.jsx)(`div`,{className:`p-2.5 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 shrink-0 mt-0.5`,children:(0,h.jsxs)(`svg`,{className:`w-4 h-4`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`circle`,{cx:`12`,cy:`12`,r:`3`}),(0,h.jsx)(`path`,{d:`M19.07 4.93A10 10 0 0 0 4.93 19.07`}),(0,h.jsx)(`path`,{d:`M4.93 4.93A10 10 0 0 0 19.07 19.07`})]})}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h3`,{className:`text-sm font-extrabold text-white uppercase tracking-wider`,children:s===`vi`?`⚙️ Quản lý Ứng dụng`:`⚙️ App Management`}),(0,h.jsx)(`p`,{className:`text-[10px] text-rose-300/70 mt-0.5`,children:s===`vi`?`Gỡ cài đặt hoặc xóa dữ liệu cục bộ của ứng dụng.`:`Uninstall or clear local app data.`})]})]}),(0,h.jsxs)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 gap-4`,children:[(0,h.jsxs)(`div`,{className:`bg-rose-950/20 border border-rose-500/10 rounded-2xl p-4 space-y-3`,children:[(0,h.jsxs)(`div`,{className:`space-y-1`,children:[(0,h.jsx)(`h4`,{className:`text-xs font-bold text-rose-300 uppercase tracking-wider`,children:s===`vi`?`🗑️ Gỡ cài đặt ứng dụng`:`🗑️ Uninstall Application`}),(0,h.jsx)(`p`,{className:`text-[10px] text-slate-400 leading-relaxed`,children:s===`vi`?`Xóa các desktop entry, MIME handler đã đăng ký. Trên Windows sẽ chạy trình gỡ cài đặt NSIS.`:`Removes desktop entries and MIME handlers. On Windows, launches the NSIS uninstaller.`})]}),(0,h.jsxs)(`button`,{id:`btn-uninstall-app`,onClick:()=>j(!0),className:`w-full flex items-center justify-center gap-2 bg-rose-600/20 hover:bg-rose-600/40 border border-rose-500/30 text-rose-300 hover:text-rose-200 text-xs font-bold px-4 py-2.5 rounded-xl transition-all active:scale-95`,children:[(0,h.jsxs)(`svg`,{className:`w-3.5 h-3.5`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`polyline`,{points:`3 6 5 6 21 6`}),(0,h.jsx)(`path`,{d:`M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6`}),(0,h.jsx)(`path`,{d:`M10 11v6`}),(0,h.jsx)(`path`,{d:`M14 11v6`}),(0,h.jsx)(`path`,{d:`M9 6V4h6v2`})]}),s===`vi`?`Gỡ cài đặt...`:`Uninstall...`]})]}),(0,h.jsxs)(`div`,{className:`bg-amber-950/20 border border-amber-500/10 rounded-2xl p-4 space-y-3`,children:[(0,h.jsxs)(`div`,{className:`space-y-1`,children:[(0,h.jsx)(`h4`,{className:`text-xs font-bold text-amber-300 uppercase tracking-wider`,children:s===`vi`?`🧹 Xóa dữ liệu cục bộ`:`🧹 Clear Local Data`}),(0,h.jsx)(`p`,{className:`text-[10px] text-slate-400 leading-relaxed`,children:s===`vi`?`Xóa file cấu hình (app_config.json) và log debug. Tài khoản, sách và models KHÔNG bị xóa.`:`Deletes app_config.json and debug logs. Account, books, and models are NOT affected.`})]}),(0,h.jsxs)(`button`,{id:`btn-clear-userdata`,onClick:()=>L(!0),className:`w-full flex items-center justify-center gap-2 bg-amber-600/20 hover:bg-amber-600/40 border border-amber-500/30 text-amber-300 hover:text-amber-200 text-xs font-bold px-4 py-2.5 rounded-xl transition-all active:scale-95`,children:[(0,h.jsx)(`svg`,{className:`w-3.5 h-3.5`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:(0,h.jsx)(`path`,{d:`M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z`})}),s===`vi`?`Xóa dữ liệu...`:`Clear Data...`]})]})]})]})]}),w&&(0,h.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4 animate-fade-in`,children:(0,h.jsxs)(`div`,{className:`bg-[#131324] border border-[#2d2d6b] rounded-3xl p-6 max-w-md w-full text-center space-y-4 shadow-2xl`,children:[(0,h.jsx)(`h3`,{className:`text-lg font-extrabold text-white`,children:s===`vi`?`Tự Động Cập Nhật Phiên Bản`:`Automatic Version Update`}),(0,h.jsx)(`p`,{className:`text-xs text-slate-300`,children:O}),(0,h.jsx)(`div`,{className:`w-full bg-[#1c1c38] rounded-full h-3 border border-indigo-950/30 overflow-hidden relative`,children:(0,h.jsx)(`div`,{className:`bg-gradient-to-r from-purple-500 to-indigo-500 h-full rounded-full transition-all duration-300 shadow-inner`,style:{width:`${E}%`}})}),(0,h.jsxs)(`div`,{className:`flex justify-between items-center text-[10px] font-bold text-slate-500`,children:[(0,h.jsx)(`span`,{children:`0%`}),(0,h.jsxs)(`span`,{className:`text-purple-400 text-sm font-black`,children:[E,`%`]}),(0,h.jsx)(`span`,{children:`100%`})]}),(0,h.jsx)(`p`,{className:`text-[10px] text-slate-500 leading-relaxed`,children:s===`vi`?`⚠️ Lưu ý: Ứng dụng sẽ tự động đóng sau khi hoàn tất để kích hoạt trình cài đặt/phiên bản mới.`:`⚠️ Note: The application will close automatically after completion to launch the new installer/version.`})]})}),A&&(0,h.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-fade-in`,children:(0,h.jsxs)(`div`,{className:`bg-[#131324] border border-rose-500/30 rounded-3xl p-6 max-w-sm w-full text-center space-y-4 shadow-2xl`,children:[(0,h.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full bg-rose-500/10 border border-rose-500/20 flex items-center justify-center text-rose-400`,children:(0,h.jsxs)(`svg`,{className:`w-7 h-7`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`polyline`,{points:`3 6 5 6 21 6`}),(0,h.jsx)(`path`,{d:`M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6`}),(0,h.jsx)(`path`,{d:`M10 11v6`}),(0,h.jsx)(`path`,{d:`M14 11v6`}),(0,h.jsx)(`path`,{d:`M9 6V4h6v2`})]})}),(0,h.jsx)(`h3`,{className:`text-base font-extrabold text-white`,children:s===`vi`?`⚠️ Xác nhận Gỡ cài đặt`:`⚠️ Confirm Uninstall`}),(0,h.jsx)(`p`,{className:`text-xs text-slate-400 leading-relaxed`,children:s===`vi`?`Thao tác này sẽ xóa các desktop entries và MIME handler đã đăng ký với hệ thống. Bạn có chắc chắn muốn tiếp tục không?`:`This will remove registered desktop entries and MIME handlers from your system. Are you sure you want to continue?`}),(0,h.jsxs)(`div`,{className:`flex gap-3`,children:[(0,h.jsx)(`button`,{onClick:()=>j(!1),className:`flex-1 px-4 py-2.5 bg-slate-800/60 border border-slate-700/30 text-slate-300 text-xs font-bold rounded-xl hover:bg-slate-700/40 transition-all`,children:s===`vi`?`Hủy bỏ`:`Cancel`}),(0,h.jsx)(`button`,{onClick:he,className:`flex-1 px-4 py-2.5 bg-rose-600 hover:bg-rose-500 text-white text-xs font-extrabold rounded-xl transition-all active:scale-95 shadow-lg`,children:s===`vi`?`Gỡ cài đặt`:`Uninstall`})]})]})}),I&&(0,h.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-fade-in`,children:(0,h.jsxs)(`div`,{className:`bg-[#131324] border border-amber-500/30 rounded-3xl p-6 max-w-sm w-full text-center space-y-4 shadow-2xl`,children:[(0,h.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400`,children:(0,h.jsx)(`svg`,{className:`w-7 h-7`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:(0,h.jsx)(`path`,{d:`M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z`})})}),(0,h.jsx)(`h3`,{className:`text-base font-extrabold text-white`,children:s===`vi`?`🗑️ Xóa dữ liệu cục bộ`:`🗑️ Clear Local Data`}),(0,h.jsx)(`p`,{className:`text-xs text-slate-400 leading-relaxed`,children:s===`vi`?`Sẽ xóa file cấu hình (app_config.json) và log debug. Dữ liệu tài khoản và models sẽ không bị xóa.`:`Will delete config file (app_config.json) and debug logs. Account data and models will NOT be deleted.`}),(0,h.jsxs)(`div`,{className:`flex gap-3`,children:[(0,h.jsx)(`button`,{onClick:()=>L(!1),className:`flex-1 px-4 py-2.5 bg-slate-800/60 border border-slate-700/30 text-slate-300 text-xs font-bold rounded-xl hover:bg-slate-700/40 transition-all`,children:s===`vi`?`Hủy bỏ`:`Cancel`}),(0,h.jsx)(`button`,{onClick:ge,className:`flex-1 px-4 py-2.5 bg-amber-600 hover:bg-amber-500 text-white text-xs font-extrabold rounded-xl transition-all active:scale-95 shadow-lg`,children:s===`vi`?`Xóa dữ liệu`:`Clear Data`})]})]})}),(M===`running`||M===`done`||M===`error`)&&(0,h.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-fade-in`,children:(0,h.jsxs)(`div`,{className:`bg-[#131324] border rounded-3xl p-6 max-w-sm w-full text-center space-y-4 shadow-2xl ${M===`error`?`border-rose-500/30`:M===`done`?`border-emerald-500/30`:`border-indigo-500/30`}`,children:[M===`running`?(0,h.jsx)(`div`,{className:`w-10 h-10 mx-auto border-2 border-indigo-500/30 border-t-indigo-400 rounded-full animate-spin`}):(0,h.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full flex items-center justify-center text-2xl ${M===`done`?`bg-emerald-500/10 border border-emerald-500/20`:`bg-rose-500/10 border border-rose-500/20`}`,children:M===`done`?`✅`:`❌`}),(0,h.jsx)(`h3`,{className:`text-sm font-extrabold text-white`,children:M===`running`?s===`vi`?`Đang xử lý...`:`Processing...`:s===`vi`?`Kết quả`:`Result`}),(0,h.jsx)(`p`,{className:`text-xs text-slate-300 leading-relaxed whitespace-pre-line`,children:P}),M!==`running`&&(0,h.jsx)(`button`,{onClick:()=>{N(`idle`),F(``)},className:`w-full px-4 py-2.5 bg-slate-800/60 border border-slate-700/30 text-slate-300 text-xs font-bold rounded-xl hover:bg-slate-700/40 transition-all`,children:s===`vi`?`Đóng`:`Close`})]})})]})}export{v as default};
|
frontend-web/dist/assets/GoogleAd-CD0eT9Wp.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import{C as e,F as t,M as n,j as r}from"./index-yRoRoI6u.js";var i=t(r(),1),a=n();function o({slot:t,format:n=`auto`,responsive:r=`true`,className:o=``}){let{user:s,loading:c}=e();return(0,i.useEffect)(()=>{if(!c&&(!s||s.vip_status!==1))try{(window.adsbygoogle=window.adsbygoogle||[]).push({})}catch(e){console.error(`AdSense push error:`,e)}},[s,c]),c||s&&s.vip_status===1?null:(0,a.jsxs)(`div`,{className:`ad-container my-6 flex flex-col items-center justify-center w-full min-h-[100px] md:min-h-[200px] bg-[#121225]/20 border border-dashed border-[#1f1f3a]/60 rounded-2xl relative overflow-hidden ${o}`,children:[(0,a.jsx)(`span`,{className:`absolute top-2 right-3 text-[9px] uppercase tracking-wider text-slate-600 font-extrabold select-none`,children:`ADVERTISEMENT`}),(0,a.jsx)(`ins`,{className:`adsbygoogle w-full`,style:{display:`block`,minHeight:`90px`},"data-ad-client":`ca-pub-9548504602542886`,"data-ad-slot":t,"data-ad-format":n,"data-full-width-responsive":r})]})}export{o as t};
|
frontend-web/dist/assets/HistoryPage-DD_Z3uyW.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import{t as e,y as t}from"./MainLayout-COiTxmNr.js";import{a as n,t as r}from"./square-DZKJ0QGR.js";import{t as i}from"./clock-DEPYmZZ-.js";import{t as a}from"./square-check-big-BhcFiAg6.js";import{t as o}from"./trash-2-DDhTqVwA.js";import{C as s,D as c,F as l,M as u,S as d,f,j as p,m,o as h,w as g}from"./index-yRoRoI6u.js";var _=l(p(),1),v=u();function y(){let{t:l,lang:u}=d(),{user:p,loading:y}=s(),b=c(),[x,S]=(0,_.useState)([]),[C,w]=(0,_.useState)(!0),[T,E]=(0,_.useState)(null),[D,O]=(0,_.useState)(``),[k,A]=(0,_.useState)(!1),[j,M]=(0,_.useState)(new Set);(0,_.useEffect)(()=>{if(!y){if(!p){w(!1);return}N()}},[p,y]);let N=async()=>{w(!0);try{S((await g.get(`/api/history`,{params:D?{q:D}:{}})).data)}catch(e){console.error(e)}finally{w(!1)}},P=async()=>{let e=u===`vi`?`Bạn có chắc muốn xóa tất cả lịch sử đọc?`:u===`en`?`Are you sure you want to clear all reading history?`:`您确定要清空所有阅读历史记录吗?`;if(window.confirm(e))try{await g.post(`/api/history/clear`),S([])}catch{alert(u===`vi`?`Không xóa được lịch sử.`:u===`en`?`Failed to clear history.`:`无法清空历史记录。`)}},F=async(e,t,n)=>{n&&n.stopPropagation();let r=e||t;if(r){E(r);try{await g.post(`/api/history/remove`,e?{book_id:e}:{url:t}),S(n=>n.map(n=>({...n,books:n.books.filter(n=>n.book_id!==e&&n.url!==t&&(n.book_id||n.url)!==r)})).filter(e=>e.books.length>0))}catch{S(n=>n.map(n=>({...n,books:n.books.filter(n=>n.book_id!==e&&n.url!==t&&(n.book_id||n.url)!==r)})).filter(e=>e.books.length>0))}finally{E(null)}}},I=e=>{let t=e.book_id||e.url;t&&M(e=>{let n=new Set(e);return n.has(t)?n.delete(t):n.add(t),n})},L=()=>{let e=[];return x.forEach(t=>{t.books.forEach(t=>{let n=t.book_id||t.url;n&&e.push(n)})}),e},R=()=>{let e=L();j.size===e.length?M(new Set):M(new Set(e))},z=async()=>{if(window.confirm(u===`vi`?`Bạn có chắc chắn muốn xóa ${j.size} truyện khỏi lịch sử không?`:`Are you sure you want to clear ${j.size} selected books from history?`)){w(!0);try{let e=[];x.forEach(t=>{t.books.forEach(t=>{let n=t.book_id||t.url;j.has(n)&&e.push(t)})}),await Promise.all(e.map(e=>g.post(`/api/history/remove`,e.book_id?{book_id:e.book_id}:{url:e.url}))),M(new Set),A(!1),await N()}catch{alert(`Không thể xóa hàng loạt lịch sử.`)}finally{w(!1)}}},B=x.reduce((e,t)=>e+t.books.length,0);if(y)return(0,v.jsx)(e,{children:(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,v.jsx)(f,{className:`w-8 h-8 animate-spin mx-auto mb-2 text-brand-500`}),(0,v.jsx)(`span`,{children:u===`vi`?`Đang tải thông tin...`:u===`en`?`Loading info...`:`正在加载信息...`})]})});if(!p)return(0,v.jsx)(e,{children:(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,v.jsx)(`div`,{className:`w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center text-slate-400 mx-auto mb-4`,children:`🔒`}),(0,v.jsx)(`p`,{className:`text-sm`,children:l.loginToViewHistory})]})});let V=L(),H=j.size===V.length&&V.length>0;return(0,v.jsxs)(e,{children:[(0,v.jsxs)(`div`,{className:`flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6`,children:[(0,v.jsxs)(`div`,{className:`flex flex-wrap items-center gap-3`,children:[(0,v.jsxs)(`h2`,{className:`text-xl font-bold flex items-center gap-2 text-white`,children:[(0,v.jsx)(m,{className:`w-6 h-6 text-brand-400`}),u===`vi`?`Lịch Sử Đọc Truyện`:u===`en`?`Reading History`:`阅读历史`]}),B>0&&(0,v.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,v.jsx)(`button`,{onClick:()=>{A(!k),M(new Set)},className:`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${k?`bg-purple-600/25 border-purple-500/50 text-purple-300 hover:bg-purple-600/35`:`bg-white/5 border-white/10 text-slate-300 hover:bg-white/10`}`,children:k?u===`vi`?`Thoát quản lý`:`Exit management`:u===`vi`?`Quản lý lịch sử`:`Manage history`}),k&&(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(`button`,{onClick:R,className:`px-3 py-1.5 bg-[#121225] border border-[#1f1f3a] rounded-lg text-xs font-bold text-slate-300 hover:bg-[#1a1a35] transition-all`,children:H?u===`vi`?`Hủy chọn tất cả`:`Deselect all`:u===`vi`?`Chọn tất cả`:`Select all`}),j.size>0&&(0,v.jsxs)(`button`,{onClick:z,className:`inline-flex items-center gap-1.5 px-3 py-1.5 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-rose-950/20 transition-all active:scale-95 cursor-pointer`,children:[(0,v.jsx)(o,{className:`w-3.5 h-3.5`}),u===`vi`?`Xóa hàng loạt (${j.size})`:`Bulk Delete (${j.size})`]})]})]})]}),(0,v.jsxs)(`div`,{className:`flex items-center gap-2 w-full md:w-auto justify-between md:justify-end`,children:[B>0&&(0,v.jsx)(`p`,{className:`text-xs text-slate-500`,children:u===`vi`?`${B} truyện`:`${B} books`}),(0,v.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,v.jsxs)(`div`,{className:`relative`,children:[(0,v.jsx)(`input`,{type:`text`,value:D,onChange:e=>O(e.target.value),onKeyDown:e=>e.key===`Enter`&&N(),placeholder:u===`vi`?`Tìm lịch sử...`:`Search history...`,className:`bg-[#12122b]/80 border border-[#232342] rounded-xl px-3 py-2 pr-8 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-500 transition-colors w-36 sm:w-44`}),D&&(0,v.jsx)(`button`,{onClick:()=>{O(``),N()},className:`absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white`,children:(0,v.jsx)(h,{className:`w-3 h-3`})})]}),B>0&&!k&&(0,v.jsxs)(`button`,{onClick:P,className:`inline-flex items-center gap-1.5 bg-red-500/10 border border-red-500/25 hover:bg-red-500/20 text-red-400 px-3 py-2 rounded-xl text-xs font-bold transition-all`,children:[(0,v.jsx)(o,{className:`w-3.5 h-3.5`}),u===`vi`?`Xóa tất cả`:`Clear All`]})]})]})]}),C?(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,v.jsx)(f,{className:`w-6 h-6 animate-spin mx-auto mb-2`}),(0,v.jsx)(`span`,{children:l.loading})]}):x.length===0?(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500 bg-[#121225]/40 border border-dashed border-[#1f1f3a] rounded-2xl`,children:[(0,v.jsx)(m,{className:`w-10 h-10 mx-auto mb-3 text-slate-600`}),(0,v.jsx)(`p`,{className:`text-sm font-semibold`,children:u===`vi`?`Chưa có lịch sử đọc truyện.`:u===`en`?`No reading history yet.`:`暂无阅读历史记录。`}),(0,v.jsx)(`p`,{className:`text-xs mt-1 text-slate-600`,children:u===`vi`?`Click vào bất kỳ truyện nào để bắt đầu theo dõi.`:u===`en`?`Click any book to start tracking.`:`点击任意小说开始跟踪。`})]}):(0,v.jsx)(`div`,{className:`space-y-8`,children:x.map(e=>(0,v.jsxs)(`div`,{className:`space-y-3`,children:[(0,v.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,v.jsx)(i,{className:`w-3.5 h-3.5 text-brand-400`}),(0,v.jsx)(`h3`,{className:`text-xs font-extrabold text-brand-400 uppercase tracking-wider`,children:u===`en`?e.group_name===`Hôm nay`?`Today`:e.group_name===`Hôm qua`?`Yesterday`:e.group_name===`Tháng này`?`This Month`:`Earlier`:u===`zh`?e.group_name===`Hôm nay`?`今天`:e.group_name===`Hôm qua`?`昨天`:e.group_name===`Tháng này`?`本月`:`更早`:e.group_name}),(0,v.jsx)(`div`,{className:`flex-1 h-px bg-[#1f1f3a]`}),(0,v.jsx)(`span`,{className:`text-[10px] text-slate-600 font-semibold`,children:e.books.length})]}),(0,v.jsx)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3`,children:e.books.map((e,i)=>{let o=e.book_id||e.url,s=j.has(o);return(0,v.jsxs)(`div`,{onClick:()=>{k?I(e):e.book_id?b(`/book/${e.book_id}`):e.url&&window.open(e.url,`_blank`)},className:`relative bg-[#121225]/60 border rounded-2xl p-4 flex gap-3 items-start cursor-pointer transition-all hover:scale-[1.01] group ${k?`border-purple-500/30 bg-purple-950/5`:`border-[#1f1f3a] hover:border-brand-500/35`}`,children:[k&&(0,v.jsx)(`div`,{className:`w-5 h-5 rounded-md border flex items-center justify-center shrink-0 self-center transition-all ${s?`bg-purple-600 border-purple-500 text-white shadow shadow-purple-500/25`:`bg-[#0b0b14] border-slate-700 text-slate-500`}`,children:s?(0,v.jsx)(a,{className:`w-3.5 h-3.5`}):(0,v.jsx)(r,{className:`w-3.5 h-3.5`})}),e.cover?(0,v.jsx)(`img`,{src:e.cover,alt:`cover`,className:`w-[48px] h-[66px] object-cover rounded-xl border border-[#1f1f3a] shrink-0`,onError:e=>e.target.remove()}):(0,v.jsx)(`div`,{className:`w-[48px] h-[66px] rounded-xl bg-[#0b0b14] border border-[#1f1f3a] flex items-center justify-center text-slate-600 shrink-0`,children:(0,v.jsx)(t,{className:`w-5 h-5`})}),(0,v.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,v.jsx)(`h4`,{className:`font-bold text-slate-200 text-xs truncate leading-relaxed group-hover:text-brand-400 transition-colors`,children:e.title}),(0,v.jsxs)(`p`,{className:`text-[10px] text-slate-500 mt-0.5 truncate`,children:[u===`vi`?`Tác giả`:u===`en`?`Author`:`作者`,`: `,e.author]}),(0,v.jsx)(`div`,{className:`mt-2 flex items-center gap-1.5 flex-wrap`,children:(0,v.jsxs)(`span`,{className:`inline-flex items-center gap-1 bg-brand-500/10 border border-brand-500/20 text-brand-400 px-2 py-0.5 rounded-full text-[9px] font-semibold max-w-[150px]`,children:[(0,v.jsx)(n,{className:`w-2.5 h-2.5 shrink-0`}),(0,v.jsx)(`span`,{className:`truncate`,children:e.last_chapter})]})}),e.read_date&&(0,v.jsx)(`p`,{className:`text-[9px] text-slate-600 mt-1.5`,children:e.read_date})]}),!k&&(0,v.jsx)(`button`,{onClick:t=>F(e.book_id,e.url,t),disabled:T===o,className:`absolute top-3 right-3 p-1.5 rounded-lg bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white transition-all opacity-100 md:opacity-0 md:group-hover:opacity-100 disabled:opacity-50`,title:u===`vi`?`Xóa khỏi lịch sử`:`Remove from history`,children:T===o?(0,v.jsx)(f,{className:`w-3.5 h-3.5 animate-spin`}):(0,v.jsx)(h,{className:`w-3.5 h-3.5`})})]},o||i)})})]},e.group_name))})]})}export{y as default};
|