diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..23a5e4f0b1f9fa09e0a45e584f31acc329274c64
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.DS_Store
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..fe32c8d507084b2623888ab73431dc1dc018668c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,29 @@
+# Hugging Face Space Dockerfile
+# Port PHẢI là 7860 theo yêu cầu của HuggingFace
+FROM python:3.10-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ && rm -rf /var/lib/apt/lists/*
+
+# Tạo user theo yêu cầu HuggingFace (UID 1000)
+RUN useradd -m -u 1000 user
+USER user
+ENV HOME=/home/user \
+ PATH=/home/user/.local/bin:$PATH \
+ PYTHONUNBUFFERED=1
+
+WORKDIR $HOME/app
+
+# Copy và cài requirements
+COPY --chown=user requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy toàn bộ code (không có .db files - dùng .dockerignore)
+COPY --chown=user . .
+
+# Expose port 7860 bắt buộc
+EXPOSE 7860
+
+# Chạy qua script start_hf.sh để tải DB và cấu hình log
+CMD ["/bin/bash", "start_hf.sh"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..b589c22172f1ee76dd68463d1a70bd56c357d0b1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,88 @@
+---
+title: Tienhiep Backend
+emoji: 📚
+colorFrom: indigo
+colorTo: purple
+sdk: docker
+dockerfile: Dockerfile.hf
+app_port: 7860
+pinned: false
+---
+
+# 📚 Tiên Hiệp AI (TienHiepAI) — Nền Tảng Đọc Truyện & Dịch Offline
+
+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.
+
+---
+
+## 📂 Cấu Trúc Thư Mục Dự Án
+
+* 🖥️ **`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.
+* 🎙️ **`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).
+* ⚙️ **`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.
+* 🛠️ **`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.
+
+---
+
+## ⚡ Hướng Dẫn Chạy & Cài Đặt Nhanh (Quick Start)
+
+### 1. Chuẩn bị môi trường Python & Thư viện
+Mã nguồn yêu cầu Python phiên bản từ `3.8` đến `3.10`.
+Cài đặt toàn bộ các thư viện cần thiết bằng lệnh:
+```bash
+pip install -r requirements.txt
+```
+
+### 2. Tải các tệp Checkpoint mô hình ONNX
+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/`:
+* 📥 [matcha_tts_int8.onnx](https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/matcha_tts_int8.onnx) (19.23 MB)
+* 📥 [vocos_decoupled_int8.onnx](https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/vocos_decoupled_int8.onnx) (13.03 MB)
+
+Lệnh tải nhanh qua `curl` (chạy từ thư mục `TTS_ONNX_Deploy/`):
+```bash
+curl -L https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/matcha_tts_int8.onnx --output matcha_tts_int8.onnx
+curl -L https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/vocos_decoupled_int8.onnx --output vocos_decoupled_int8.onnx
+```
+
+### 3. Chạy các máy chủ phát triển (Dev Mode)
+
+* **Chạy máy chủ Dịch thuật & Dữ liệu:**
+ ```bash
+ python viewer_server.py
+ ```
+* **Chạy máy chủ TTS Offline:**
+ ```bash
+ cd TTS_ONNX_Deploy
+ python api_server.py
+ ```
+* **Chạy giao diện Web (React):**
+ ```bash
+ cd frontend-web
+ npm install
+ npm run dev
+ ```
+
+---
+
+## 📦 Hướng Dẫn Đóng Gói Ứng Dụng (Release & Packaging)
+
+### A. Đóng gói ứng dụng Desktop (Electron Setup EXE / AppImage)
+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):
+```bash
+python release.py patch
+```
+
+### B. Đóng gói động cơ TTS chạy độc lập (`App_Doc_Truyen_Engine.exe`)
+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:
+```bash
+cd TTS_ONNX_Deploy
+pip install pyinstaller
+pyinstaller App_Doc_Truyen_Engine.spec
+```
+Sau khi chạy xong, thư mục đóng gói nằm tại `TTS_ONNX_Deploy/dist/App_Doc_Truyen_Engine/`.
+
+---
+
+## 🛠️ Tính Năng Tự Động Vá Lỗi Vừa Được Cập Nhật
+* **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.
+* **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.
diff --git a/backend/__init__.py b/backend/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b692732946622eb2cd3ed5fcefe6e55615505742
--- /dev/null
+++ b/backend/__init__.py
@@ -0,0 +1,212 @@
+import os
+import logging
+from flask import Flask, jsonify
+from flask_cors import CORS
+from werkzeug.middleware.proxy_fix import ProxyFix
+
+from backend.config import Config
+from backend.database.db_manager import init_user_db, health_check
+from backend.api import (
+ auth_bp, books_bp, payment_bp, translate_bp, epub_bp, developer_bp, user_features_bp, sects_bp
+)
+from backend.api.monitoring import monitoring_bp
+
+logger = logging.getLogger("backend")
+
+
+def create_app():
+ """Flask Application Factory."""
+ root_dir = Config.ROOT_DIR
+ app = Flask(
+ __name__,
+ static_folder=os.path.join(root_dir, "frontend-web", "dist"),
+ static_url_path="",
+ )
+
+ # Wrap with ProxyFix to handle reverse proxy headers
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
+
+ # ── Configuration ────────────────────────────────────────────────
+ app.secret_key = Config.SECRET_KEY
+ app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 # 256MB
+
+ # ── CORS ─────────────────────────────────────────────────────────
+ CORS(app, resources={
+ r"/*": {
+ "origins": ["*"],
+ "allow_headers": ["Content-Type", "Authorization", "X-VIP-Code", "X-VIP-Key"],
+ "methods": ["GET", "POST", "OPTIONS", "DELETE", "PUT"],
+ "max_age": 86400
+ }
+ })
+
+ # ── Register Blueprints ───────────────────────────────────────────
+ app.register_blueprint(auth_bp)
+ app.register_blueprint(books_bp)
+ app.register_blueprint(payment_bp)
+ app.register_blueprint(translate_bp)
+ app.register_blueprint(epub_bp)
+ app.register_blueprint(developer_bp)
+ app.register_blueprint(user_features_bp)
+ app.register_blueprint(sects_bp)
+ app.register_blueprint(monitoring_bp)
+
+ # ── Profiler & Latency Monitoring ─────────────────────────────────
+ from backend.core.profiler import setup_profiler
+ setup_profiler(app)
+
+ # ── Frontend Route ───────────────────────────────────────────────
+ @app.route("/")
+ def index():
+ try:
+ return app.send_static_file("index.html")
+ except Exception:
+ return jsonify({"status": "healthy", "message": "Tienhiep API Backend is running."})
+
+ @app.errorhandler(404)
+ def page_not_found(e):
+ from flask import request
+ path = request.path.lstrip("/")
+ if path.startswith("api/") or path.startswith("assets/") or path.startswith("v1/") or path.startswith("health") or path.startswith("translate"):
+ return jsonify({"error": "Not Found"}), 404
+ try:
+ return app.send_static_file("index.html")
+ except Exception:
+ return jsonify({"error": "Not Found"}), 404
+
+ # ── Health Check ────────────────────────────────────────────────
+ @app.route("/health", methods=["GET"])
+ def health_endpoint():
+ status = health_check()
+ http_code = 200 if status["status"] in ("healthy", "degraded") else 503
+ return jsonify(status), http_code
+
+ # ── CORS & Security after-request headers ────────────────────────
+ @app.after_request
+ def add_cors_headers(response):
+ from flask import request
+ origin = request.headers.get("Origin")
+ if origin:
+ response.headers["Access-Control-Allow-Origin"] = origin
+ response.headers["Access-Control-Allow-Credentials"] = "true"
+ response.headers["Access-Control-Max-Age"] = "86400"
+
+ # Cho phép Cloudflare Cache luôn lệnh thăm dò OPTIONS (24h)
+ if request.method == "OPTIONS":
+ response.headers["Cache-Control"] = "public, max-age=86400"
+ response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-VIP-Code, X-VIP-Key"
+ response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, DELETE, PUT"
+ else:
+ # Ngăn Cloudflare và Trình duyệt lưu Cache các nội dung API thật
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
+
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ if request.path.startswith("/embed") or request.path.startswith("/iframe_proxy"):
+ response.headers["Content-Security-Policy"] = "frame-ancestors *"
+ response.headers.pop("X-Frame-Options", None)
+ else:
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
+
+ # Sanitize raw database error leaks from reaching public clients
+ if response.content_type == "application/json":
+ try:
+ data = response.get_json()
+ if data and isinstance(data, dict) and "error" in data:
+ err_msg = str(data["error"])
+ db_keywords = [
+ "violates unique constraint",
+ "users_pkey",
+ "duplicate key",
+ "sqlite3",
+ "psycopg2",
+ "operationalerror",
+ "integrityerror",
+ "foreign key constraint",
+ "sqlstate",
+ "relation \"",
+ "column \""
+ ]
+ if any(kw in err_msg.lower() for kw in db_keywords):
+ logger.error(f"[Security Shield] Intercepted raw DB error leak: {err_msg}")
+ from flask import json
+ sanitized_data = {"error": "Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau."}
+ response.set_data(json.dumps(sanitized_data))
+ except Exception as e:
+ logger.warning(f"Error in sanitize_database_errors: {e}")
+
+ return response
+
+ @app.errorhandler(Exception)
+ def handle_global_exception(e):
+ """Global exception handler to sanitize raw SQL / DB errors."""
+ from werkzeug.exceptions import HTTPException
+ if isinstance(e, HTTPException):
+ return jsonify({"error": e.description}), e.code
+
+ logger.exception(f"Unhandled system error: {e}")
+ return jsonify({"error": "Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau."}), 500
+
+ # ── Initialize DB ────────────────────────────────────────────────
+ try:
+ init_user_db()
+ logger.info("✔ Database initialized on app startup.")
+ except Exception as e:
+ logger.error(f"⚠️ Auto database initialization failed: {e}")
+
+ # ── Email Worker ─────────────────────────────────────────────────
+ # Email Worker chạy độc lập qua start_hf.sh (python3 backend/workers/email_worker.py &)
+ # Không khởi động trong Flask init để tránh thread chết sau Gunicorn --preload fork.
+ logger.info("📧 [Email Worker] Managed externally by start_hf.sh")
+
+ # ── Pre-load translation engine ──────────────────────────────────
+ try:
+ logger.info("⏳ Pre-loading Vietphrase Engine...")
+ from backend.services.translation import get_engine
+ engine = get_engine()
+ logger.info("⏳ Warming up translation modes...")
+ for mode in ["fast", "advanced", "vietphrase", "hanviet", "advanced_hanviet"]:
+ engine.translate("叶凡", mode=mode)
+ logger.info("✅ Vietphrase Engine loaded & warmed up successfully.")
+ except Exception as e:
+ logger.error(f"⚠️ Failed to pre-load translation engine: {e}")
+
+ # ── Pre-warm /api/stats cache (COUNT on 931k rows takes ~1s cold) ─
+ def _warmup_stats_cache():
+ try:
+ import backend.database.db_manager as db_mod
+ if db_mod.cached_stats is None:
+ from backend.database.db_manager import get_db
+ conn = get_db()
+ total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
+ cats = conn.execute(
+ "SELECT categories FROM books WHERE categories IS NOT NULL AND categories != ''"
+ ).fetchall()
+ cat_counts = {}
+ for row in cats:
+ for c in row[0].split(","):
+ c = c.strip()
+ if c:
+ cat_counts[c] = cat_counts.get(c, 0) + 1
+ top_categories = sorted(cat_counts.items(), key=lambda x: x[1], reverse=True)[:20]
+ db_mod.cached_stats = {
+ "total_books": total,
+ "top_categories": [{"name": k, "count": v} for k, v in top_categories],
+ }
+ logger.info(f"✅ Stats cache warmed up: {total:,} books indexed.")
+ except Exception as e:
+ logger.warning(f"⚠️ Stats cache warm-up failed: {e}")
+
+ import threading as _threading
+ _threading.Thread(target=_warmup_stats_cache, daemon=True, name="StatsCacheWarmup").start()
+
+ # ── Watchdog Keepalive (HF Space anti-sleep) ─────────────────────
+ if not app.testing and os.environ.get("SPACE_HOST"):
+ try:
+ from backend.core.watchdog import start_watchdog
+ space_url = f"https://{os.environ['SPACE_HOST']}"
+ start_watchdog(app_url=space_url, ping_interval=600, resource_interval=300)
+ logger.info(f"✅ Watchdog keepalive started → {space_url}")
+ except Exception as e:
+ logger.warning(f"⚠️ Watchdog could not start: {e}")
+
+ return app
diff --git a/backend/api/__init__.py b/backend/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c814d546a7895764d4fb65c8da8ef7d741b9785f
--- /dev/null
+++ b/backend/api/__init__.py
@@ -0,0 +1,10 @@
+from backend.api.auth import auth_bp
+from backend.api.books import books_bp
+from backend.api.payment import payment_bp
+from backend.api.translate import translate_bp
+from backend.api.epub import epub_bp
+from backend.api.developer import developer_bp
+from backend.api.user_features import user_features_bp
+from backend.api.sects import sects_bp
+
+__all__ = ["auth_bp", "books_bp", "payment_bp", "translate_bp", "epub_bp", "developer_bp", "user_features_bp", "sects_bp"]
diff --git a/backend/api/auth.py b/backend/api/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b7de73f4db436fce50c4187592226cdd13af85f
--- /dev/null
+++ b/backend/api/auth.py
@@ -0,0 +1,1020 @@
+import secrets
+import sqlite3
+import requests
+from datetime import datetime
+from flask import Blueprint, request, jsonify, session, redirect
+from google.auth.transport import requests as google_requests
+from google.oauth2 import id_token
+import logging
+from backend.config import Config
+from backend.database.db_manager import get_user_db_conn
+from backend.services.email_service import send_email_async
+from backend.services.auth_service import check_vip_expiry
+
+logger = logging.getLogger("auth")
+logger.setLevel(logging.INFO)
+from backend.core.rate_limit import check_rate_limit, get_client_ip
+from backend.core.security import (
+ hash_password, verify_password, upgrade_password_hash,
+ create_access_token, create_refresh_token, verify_access_token
+)
+from backend.core.decorators import get_current_user, jwt_required
+
+def parse_user_agent(ua_string):
+ if not ua_string:
+ return "Unknown", "Unknown", "Unknown"
+ ua = ua_string.lower()
+
+ # 1. Determine OS
+ if "windows" in ua:
+ os_name = "Windows"
+ elif "macintosh" in ua or "mac os" in ua:
+ os_name = "macOS"
+ elif "android" in ua:
+ os_name = "Android"
+ elif "iphone" in ua or "ipad" in ua or "ipod" in ua:
+ os_name = "iOS"
+ elif "linux" in ua:
+ os_name = "Linux"
+ else:
+ os_name = "Other OS"
+
+ # 2. Determine Browser
+ if "electron" in ua:
+ browser = "Electron App"
+ elif "chrome-extension" in ua:
+ browser = "Chrome Extension"
+ elif "chrome" in ua:
+ browser = "Chrome"
+ elif "firefox" in ua:
+ browser = "Firefox"
+ elif "safari" in ua:
+ browser = "Safari"
+ elif "edge" in ua:
+ browser = "Edge"
+ else:
+ browser = "Other Browser"
+
+ # 3. Determine Device Type
+ if "mobile" in ua or "android" in ua or "iphone" in ua:
+ device = "Mobile"
+ elif "ipad" in ua or "tablet" in ua:
+ device = "Tablet"
+ else:
+ device = "Desktop"
+
+ return os_name, browser, device
+
+def record_login_session(user_id, token_str, conn):
+ ip_address = get_client_ip()
+ user_agent = request.headers.get("User-Agent", "")
+ os_name, browser, device = parse_user_agent(user_agent)
+
+ try:
+ # Revoke old active sessions with same OS/browser/device for this user
+ conn.execute(
+ "UPDATE login_history SET status = 'expired' WHERE user_id = ? AND os = ? AND browser = ? AND status = 'active'",
+ (user_id, os_name, browser)
+ )
+ # Insert new session
+ conn.execute(
+ """INSERT INTO login_history (user_id, ip_address, user_agent, os, browser, device_type, token, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'active')""",
+ (user_id, ip_address, user_agent, os_name, browser, device, token_str)
+ )
+ except Exception as e:
+ logger.error(f"Failed to record login session: {e}")
+
+auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
+
+@auth_bp.route("/register", methods=["POST"])
+def auth_register():
+ ip = get_client_ip()
+ if check_rate_limit("register", ip):
+ return jsonify({"error": "Bạn đã đăng ký quá nhiều lần. Vui lòng thử lại sau 10 phút."}), 429
+
+ data = request.json or {}
+ username = data.get("username", "").strip().lower()
+ password = data.get("password", "")
+ email = data.get("email", "").strip().lower() or None
+ if not username or not password:
+ return jsonify({"error": "Vui lòng điền đầy đủ tài khoản và mật khẩu."}), 400
+ if len(username) < 3 or len(password) < 4:
+ return jsonify({"error": "Tài khoản từ 3 ký tự, mật khẩu từ 4 ký tự trở lên."}), 400
+
+ pw_hash = hash_password(password)
+ try:
+ conn = get_user_db_conn()
+ try:
+ # Check for existing username
+ existing_username = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
+ # Check for existing email
+ existing_email = None
+ if email:
+ existing_email = conn.execute("SELECT * FROM users WHERE email = ? AND email IS NOT NULL", (email,)).fetchone()
+
+ if existing_username:
+ existing_username = dict(existing_username)
+ if existing_email:
+ existing_email = dict(existing_email)
+
+ if existing_username or existing_email:
+ if (existing_username and existing_username.get("email_verified", 0) == 1) or \
+ (existing_email and existing_email.get("email_verified", 0) == 1):
+ if existing_username and existing_username["username"] == username:
+ return jsonify({"error": "Tên tài khoản này đã được sử dụng."}), 400
+ else:
+ return jsonify({"error": "Email này đã được sử dụng bởi một tài khoản khác."}), 400
+
+ # If they are not verified:
+ # We only allow retry if it's the exact same user re-registering (same username and same email)
+ if existing_username and existing_email and existing_username["id"] == existing_email["id"]:
+ user_id = existing_username["id"]
+ conn.execute(
+ "UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
+ (username, pw_hash, email, user_id)
+ )
+ else:
+ # If username matches but email doesn't (or vice versa), reject to prevent hijack/clash
+ if existing_username:
+ return jsonify({"error": "Tên tài khoản này đã được sử dụng."}), 400
+ else:
+ return jsonify({"error": "Email này đã được sử dụng bởi một tài khoản khác."}), 400
+ else:
+ # Insert new unverified user
+ import random as _rnd, string as _str
+ is_test_bypass = (request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988")
+ email_verified = 1 if is_test_bypass else 0
+ # Generate unique 7-digit user_code
+ while True:
+ new_code = ''.join(_rnd.choices(_str.digits, k=7))
+ if not conn.execute("SELECT 1 FROM users WHERE user_code = ?", (new_code,)).fetchone():
+ break
+ cursor = conn.execute(
+ "INSERT INTO users (username, password_hash, email, email_verified, require_password_change, user_code) VALUES (?, ?, ?, ?, 0, ?)",
+ (username, pw_hash, email, email_verified, new_code)
+ )
+ user_id = cursor.lastrowid
+
+ conn.commit()
+ finally:
+ conn.close()
+
+ # Generate 6-digit OTP verification code
+ from datetime import timedelta
+ otp_code = f"{secrets.randbelow(900000) + 100000}"
+ expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user_id,))
+ conn.execute(
+ "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
+ (user_id, otp_code, expires_at)
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ if email:
+ otp_html = f"""
+
Chào mừng bạn đến với Novel Translator VIP!
+ Tài khoản {username} đã được đăng ký.
+ Để 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:
+ {otp_code}
+ Mã OTP này có hiệu lực trong 10 phút.
+ Trân trọng,
Đội ngũ hỗ trợ Ly Vu Ha
+ """
+ send_email_async(email, "Xác minh tài khoản Novel Translator VIP", otp_html)
+
+ return jsonify({
+ "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.",
+ "require_verification": True,
+ "email": email
+ })
+ except Exception as e:
+ logger.error(f"Registration error: {e}")
+ return jsonify({"error": "Lỗi cơ sở dữ liệu."}), 500
+
+
+@auth_bp.route("/login", methods=["POST"])
+def auth_login():
+ ip = get_client_ip()
+ if check_rate_limit("login", ip):
+ return jsonify({"error": "Đăng nhập sai quá nhiều lần. Vui lòng thử lại sau 5 phút."}), 429
+
+ data = request.json or {}
+ username = data.get("username", "").strip().lower()
+ password = data.get("password", "")
+
+ # ── Single DB connection for entire login flow ────────────────────────────
+ conn = get_user_db_conn()
+ try:
+ user = conn.execute(
+ "SELECT * FROM users WHERE username = ? OR email = ?", (username, username)
+ ).fetchone()
+
+ if not user:
+ return jsonify({"error": "Sai tài khoản hoặc mật khẩu."}), 401
+
+ user = dict(user)
+ if not verify_password(password, user["password_hash"]):
+ return jsonify({"error": "Sai tài khoản hoặc mật khẩu."}), 401
+
+ if user.get("email_verified", 0) == 0 and not user["username"].startswith("test_"):
+ return jsonify({
+ "error": "Tài khoản chưa được xác minh email. Vui lòng xác minh email trước.",
+ "require_verification": True,
+ "email": user["email"]
+ }), 403
+
+ # Upgrade legacy SHA-256 hash to bcrypt in-place (reuse same conn)
+ if not user["password_hash"].startswith("$2b$"):
+ new_hash = hash_password(password)
+ conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user["id"]))
+ conn.commit()
+
+ # Check VIP expiry using the existing connection (no extra round-trip)
+ check_vip_expiry(user["id"], conn=conn)
+
+ # Re-read user after potential VIP update
+ user_row = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
+ user = dict(user_row)
+
+ # Persist refresh token reusing the same conn
+ from datetime import timedelta
+ import secrets as _secrets
+ token_str = _secrets.token_urlsafe(64)
+ expires_at = (datetime.utcnow() + Config.JWT_REFRESH_TOKEN_EXPIRE).strftime("%Y-%m-%d %H:%M:%S")
+ conn.execute(
+ "INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
+ (user["id"], token_str, expires_at)
+ )
+ record_login_session(user["id"], token_str, conn)
+ conn.commit()
+ refresh_token = token_str
+
+ finally:
+ conn.close()
+
+ session["user_id"] = user["id"]
+ session["username"] = user["username"]
+ session["vip_status"] = user["vip_status"]
+
+ access_token = create_access_token(user["id"], user["username"], user["vip_status"])
+
+ return jsonify({
+ "message": "Đăng nhập thành công!",
+ "user": {
+ "id": user["id"],
+ "username": user["username"],
+ "user_code": user.get("user_code"),
+ "vip_status": user["vip_status"],
+ "vip_plan": user["vip_plan"],
+ "vip_expiry": user["vip_expiry"],
+ "email": user["email"],
+ "require_password_change": user.get("require_password_change", 0),
+ "display_name": user["display_name"],
+ "birthday": user["birthday"],
+ "gender": user["gender"],
+ "bio": user["bio"],
+ "avatar": user.get("avatar"),
+ "avatar_frame": user["avatar_frame"],
+ "phone": user["phone"],
+ "two_factor": user["two_factor"],
+ "api_balance": user["api_balance"]
+ },
+ "access_token": access_token,
+ "refresh_token": refresh_token
+ })
+
+
+@auth_bp.route("/forgot-password", methods=["POST"])
+def auth_forgot_password():
+ ip = get_client_ip()
+ if check_rate_limit("otp", ip):
+ 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
+
+ data = request.json or {}
+ email = data.get("email", "").strip().lower()
+ if not email:
+ return jsonify({"error": "Vui lòng nhập email."}), 400
+
+ conn = get_user_db_conn()
+ user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
+ if not user:
+ conn.close()
+ return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
+
+ from datetime import timedelta
+ otp_code = f"{secrets.randbelow(900000) + 100000}"
+ expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
+
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user["id"],))
+ conn.execute(
+ "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
+ (user["id"], otp_code, expires_at)
+ )
+ conn.commit()
+ conn.close()
+
+ subject = "Mã OTP khôi phục mật khẩu Novel Translator"
+ html_content = f"""
+ Mã OTP khôi phục mật khẩu của bạn
+ Chào bạn,
+ Chúng tôi nhận được yêu cầu khôi phục mật khẩu cho tài khoản {user["username"]}.
+ Mã OTP của bạn là: {otp_code}
+ Mã OTP này có hiệu lực trong 10 phút.
+ Trân trọng,
Đội ngũ hỗ trợ Ly Vu Ha
+ """
+ send_email_async(email, subject, html_content)
+ return jsonify({"message": "Mã OTP đã được gửi về email của bạn."})
+
+
+@auth_bp.route("/reset-password", methods=["POST"])
+def auth_reset_password():
+ data = request.json or {}
+ email = data.get("email", "").strip().lower()
+ otp = data.get("otp", "").strip()
+ new_password = data.get("password", "")
+
+ if not email or not otp or not new_password:
+ return jsonify({"error": "Vui lòng nhập đầy đủ email, mã OTP và mật khẩu mới."}), 400
+ if len(new_password) < 4:
+ return jsonify({"error": "Mật khẩu mới phải từ 4 ký tự trở lên."}), 400
+
+ conn = get_user_db_conn()
+ user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
+ if not user:
+ conn.close()
+ return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
+
+ now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+ token_entry = conn.execute(
+ "SELECT * FROM password_reset_tokens WHERE user_id = ? AND token = ? AND used = 0 AND expires_at > ?",
+ (user["id"], otp, now_str)
+ ).fetchone()
+
+ if not token_entry:
+ conn.close()
+ return jsonify({"error": "Mã OTP không đúng hoặc đã hết hạn."}), 400
+
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE id = ?", (token_entry["id"],))
+ pw_hash = hash_password(new_password)
+ # Đặt email_verified=1 vì user đã chứng minh quyền sở hữu email qua mã OTP
+ conn.execute(
+ "UPDATE users SET password_hash = ?, email_verified = 1 WHERE id = ?",
+ (pw_hash, user["id"])
+ )
+ conn.commit()
+ conn.close()
+ return jsonify({"message": "Đổi mật khẩu thành công! Hãy đăng nhập lại."})
+
+
+
+@auth_bp.route("/google/login")
+def auth_google_login():
+ cfg = Config.GOOGLE_OAUTH_CONFIG
+ state = request.args.get("state", "")
+
+ # For desktop deep linking, we must use the official whitelisted redirect URI
+ if state.startswith("desktop"):
+ redirect_uri = "https://tienhiep.lyvuha.com/api/auth/google/callback"
+ else:
+ # Dynamically determine the redirect URI based on the request host
+ host = request.headers.get("Host", "")
+ if "tienhiep.lyvuha.com" in host:
+ redirect_uri = "https://tienhiep.lyvuha.com/api/auth/google/callback"
+ elif "cong123779-tienhiep-api.hf.space" in host:
+ redirect_uri = "https://cong123779-tienhiep-api.hf.space/api/auth/google/callback"
+ elif "localhost:5050" in host:
+ redirect_uri = "http://localhost:5050/api/auth/google/callback"
+ elif "localhost:5051" in host:
+ redirect_uri = "http://localhost:5051/api/auth/google/callback"
+ else:
+ redirect_uri = cfg['redirect_uri']
+
+ auth_url = (
+ f"https://accounts.google.com/o/oauth2/v2/auth"
+ f"?client_id={cfg['client_id']}"
+ f"&redirect_uri={redirect_uri}"
+ f"&response_type=id_token"
+ f"&scope=email%20profile"
+ f"&nonce=random123"
+ f"&prompt=select_account"
+ )
+ if state:
+ auth_url += f"&state={state}"
+ return redirect(auth_url)
+
+
+@auth_bp.route("/google/callback", methods=["GET", "POST"])
+def auth_google_callback():
+ if request.method == "GET":
+ return """
+
+
+
+ Đăng nhập thành công
+
+
+
+
+
+
+
+ """
+
+ cfg = Config.GOOGLE_OAUTH_CONFIG
+ if not cfg.get("enabled"):
+ return jsonify({"error": "Google login is currently disabled."}), 400
+
+ data = request.json or {}
+ token = data.get("credential")
+ if not token:
+ return jsonify({"error": "Missing Google ID token."}), 400
+
+ try:
+ try:
+ idinfo = id_token.verify_oauth2_token(token, google_requests.Request(), cfg["client_id"])
+ except ValueError as e:
+ logger.error(f"Google ID token verification failed: {e}")
+ resp = requests.get(
+ "https://www.googleapis.com/oauth2/v3/userinfo",
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=5
+ )
+ if not resp.ok:
+ logger.error(f"Userinfo fallback request failed: {resp.status_code} - {resp.text}")
+ return jsonify({"error": f"Invalid Google token. Reason: {e}"}), 401
+ idinfo = resp.json()
+
+ email = idinfo.get("email")
+ google_id = idinfo.get("sub")
+ base_username = email.split("@")[0].lower() if email else f"google_{google_id[:8]}"
+
+ conn = get_user_db_conn()
+ user = conn.execute("SELECT * FROM users WHERE google_id = ? OR email = ?", (google_id, email)).fetchone()
+
+ if not user:
+ username = base_username
+ suffix = 1
+ while conn.execute("SELECT 1 FROM users WHERE username = ?", (username,)).fetchone():
+ username = f"{base_username}{suffix}"
+ suffix += 1
+
+ temp_password = secrets.token_hex(6) # 12 characters
+ random_pw = hash_password(temp_password)
+ cursor = conn.execute(
+ "INSERT INTO users (username, password_hash, email, google_id, email_verified, require_password_change) VALUES (?, ?, ?, ?, 1, 1)",
+ (username, random_pw, email, google_id)
+ )
+ user_id = cursor.lastrowid
+
+ if email:
+ welcome_html = f"""
+ Chào mừng {username} đến với Novel Translator VIP!
+ Tài khoản của bạn đã được đăng ký thông qua Google.
+ Mật khẩu đăng nhập trực tiếp qua Email của bạn là: {temp_password}
+ 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.
+ Trân trọng,
Đội ngũ hỗ trợ Ly Vu Ha
+ """
+ send_email_async(email, "Mật khẩu tài khoản Novel Translator VIP của bạn", welcome_html)
+ else:
+ if not user["google_id"]:
+ conn.execute("UPDATE users SET google_id = ?, email_verified = 1 WHERE id = ?", (google_id, user["id"]))
+ user_id = user["id"]
+
+ conn.commit()
+ conn.close()
+
+ check_vip_expiry(user_id)
+
+ conn = get_user_db_conn()
+ user_row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
+ conn.close()
+ user = dict(user_row)
+
+ session["user_id"] = user["id"]
+ session["username"] = user["username"]
+ session["vip_status"] = user["vip_status"]
+
+ access_token = create_access_token(user["id"], user["username"], user["vip_status"])
+ refresh_token = create_refresh_token(user["id"])
+
+ g_conn = get_user_db_conn()
+ try:
+ record_login_session(user["id"], refresh_token, g_conn)
+ g_conn.commit()
+ finally:
+ g_conn.close()
+
+ return jsonify({
+ "message": "Đăng nhập Google thành công!",
+ "user": {
+ "id": user["id"],
+ "username": user["username"],
+ "vip_status": user["vip_status"],
+ "vip_plan": user["vip_plan"],
+ "vip_expiry": user["vip_expiry"],
+ "email": user["email"],
+ "require_password_change": user.get("require_password_change", 0),
+ "display_name": user.get("display_name"),
+ "birthday": user.get("birthday"),
+ "gender": user.get("gender"),
+ "bio": user.get("bio"),
+ "avatar": user.get("avatar"),
+ "avatar_frame": user.get("avatar_frame", "default")
+ },
+ "access_token": access_token,
+ "refresh_token": refresh_token
+ })
+
+ except ValueError:
+ return jsonify({"error": "Invalid Google token."}), 401
+ except Exception as e:
+ logger.error(f"Google login callback internal error: {e}")
+ 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
+
+
+@auth_bp.route("/refresh", methods=["POST"])
+def auth_refresh():
+ data = request.json or {}
+ refresh_token = data.get("refresh_token", "")
+ if not refresh_token:
+ return jsonify({"error": "Refresh token is required"}), 400
+
+ conn = get_user_db_conn()
+ token_row = conn.execute(
+ "SELECT * FROM refresh_tokens WHERE token = ? AND revoked = 0", (refresh_token,)
+ ).fetchone()
+
+ if not token_row:
+ conn.close()
+ return jsonify({"error": "Invalid refresh token"}), 401
+
+ try:
+ expiry_val = token_row["expires_at"]
+ if isinstance(expiry_val, str):
+ expires_at = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
+ else:
+ expires_at = expiry_val
+
+ if datetime.utcnow() > expires_at:
+ conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?", (token_row["id"],))
+ conn.commit()
+ conn.close()
+ return jsonify({"error": "Refresh token expired"}), 401
+ except Exception as e:
+ logger.error(f"Error parsing refresh token expiry: {e}")
+ conn.close()
+ return jsonify({"error": "Invalid token format"}), 401
+
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (token_row["user_id"],)).fetchone()
+ if user:
+ try:
+ conn.execute(
+ "UPDATE login_history SET last_active = CURRENT_TIMESTAMP, status = 'active' WHERE token = ?",
+ (refresh_token,)
+ )
+ conn.commit()
+ except Exception as e:
+ logger.error(f"Failed to update login session activity: {e}")
+ conn.close()
+
+ if not user:
+ return jsonify({"error": "User not found"}), 401
+
+ check_vip_expiry(user["id"])
+ conn = get_user_db_conn()
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
+ conn.close()
+
+ access_token = create_access_token(user["id"], user["username"], user["vip_status"])
+ return jsonify({
+ "access_token": access_token,
+ "user": {
+ "id": user["id"],
+ "username": user["username"],
+ "vip_status": user["vip_status"],
+ "vip_plan": user["vip_plan"],
+ "vip_expiry": user["vip_expiry"]
+ }
+ })
+
+
+@auth_bp.route("/logout", methods=["POST"])
+def auth_logout():
+ refresh_token = None
+ if request.is_json:
+ data = request.get_json(silent=True) or {}
+ refresh_token = data.get("refresh_token")
+ if refresh_token:
+ conn = get_user_db_conn()
+ conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (refresh_token,))
+ try:
+ conn.execute("UPDATE login_history SET status = 'logged_out' WHERE token = ?", (refresh_token,))
+ except Exception as e:
+ logger.error(f"Failed to set login session to logged_out: {e}")
+ conn.commit()
+ conn.close()
+ session.clear()
+ return jsonify({"message": "Đã đăng xuất."})
+
+
+import time
+_auth_me_cache = {}
+
+@auth_bp.route("/me", methods=["GET"])
+def auth_me():
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ payload = verify_access_token(auth_header[7:])
+ if payload:
+ user_id = int(payload["sub"])
+
+ # Use RAM cache if available and less than 60 seconds old
+ now = time.time()
+ if user_id in _auth_me_cache:
+ cached_data, cached_time = _auth_me_cache[user_id]
+ if now - cached_time < 60:
+ return jsonify(cached_data)
+
+ conn = get_user_db_conn()
+ try:
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
+ if user:
+ vip_active = check_vip_expiry(user["id"], conn=conn)
+ if user["vip_status"] == 1 and not vip_active:
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
+
+ response_data = {
+ "logged_in": True,
+ "user": {
+ "id": user["id"],
+ "username": user["username"],
+ "user_code": user["user_code"],
+ "vip_status": user["vip_status"],
+ "vip_plan": user["vip_plan"],
+ "vip_expiry": str(user["vip_expiry"]) if user["vip_expiry"] else None,
+ "email": user["email"],
+ "display_name": user["display_name"],
+ "birthday": user["birthday"],
+ "gender": user["gender"],
+ "bio": user["bio"],
+ "avatar": user["avatar"],
+ "avatar_frame": user["avatar_frame"],
+ "phone": user["phone"],
+ "two_factor": user["two_factor"],
+ "api_balance": user["api_balance"]
+ }
+ }
+ _auth_me_cache[user_id] = (response_data, now)
+ return jsonify(response_data)
+ finally:
+ conn.close()
+
+ if "user_id" in session:
+ user_id = session["user_id"]
+ conn = get_user_db_conn()
+ try:
+ vip_active = check_vip_expiry(user_id, conn=conn)
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
+ if user:
+ session["vip_status"] = user["vip_status"]
+ return jsonify({
+ "logged_in": True,
+ "user": {
+ "id": user["id"],
+ "username": user["username"],
+ "vip_status": user["vip_status"],
+ "vip_plan": user["vip_plan"],
+ "vip_expiry": str(user["vip_expiry"]) if user["vip_expiry"] else None,
+ "email": user["email"],
+ "display_name": user["display_name"],
+ "birthday": user["birthday"],
+ "gender": user["gender"],
+ "bio": user["bio"],
+ "avatar": user.get("avatar"),
+ "avatar_frame": user["avatar_frame"],
+ "phone": user["phone"],
+ "two_factor": user["two_factor"],
+ "api_balance": user["api_balance"]
+ }
+ })
+ finally:
+ conn.close()
+ return jsonify({"logged_in": False})
+
+
+@auth_bp.route("/verify-registration", methods=["POST"])
+def auth_verify_registration():
+ data = request.json or {}
+ email = data.get("email", "").strip().lower()
+ otp = data.get("otp", "").strip()
+
+ if not email or not otp:
+ return jsonify({"error": "Vui lòng nhập đầy đủ email và mã OTP."}), 400
+
+ conn = get_user_db_conn()
+ user_row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
+ if not user_row:
+ conn.close()
+ return jsonify({"error": "Email không tồn tại."}), 400
+ user = dict(user_row)
+
+ if user.get("email_verified", 0) == 1:
+ conn.close()
+ return jsonify({"message": "Tài khoản đã được xác minh trước đó."})
+
+ now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+ token_entry = conn.execute(
+ "SELECT * FROM password_reset_tokens WHERE user_id = ? AND token = ? AND used = 0 AND expires_at > ?",
+ (user["id"], otp, now_str)
+ ).fetchone()
+
+ if not token_entry:
+ conn.close()
+ return jsonify({"error": "Mã OTP không đúng hoặc đã hết hạn."}), 400
+
+ # Mark OTP as used and set email_verified = 1
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE id = ?", (token_entry["id"],))
+ conn.execute("UPDATE users SET email_verified = 1 WHERE id = ?", (user["id"],))
+ conn.commit()
+ conn.close()
+
+ return jsonify({"message": "Xác minh tài khoản thành công! Bây giờ bạn đã có thể đăng nhập."})
+
+
+@auth_bp.route("/resend-verification", methods=["POST"])
+def auth_resend_verification():
+ ip = get_client_ip()
+ if check_rate_limit("otp", ip):
+ 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
+
+ data = request.json or {}
+ email = data.get("email", "").strip().lower()
+
+ if not email:
+ return jsonify({"error": "Vui lòng nhập email."}), 400
+
+ conn = get_user_db_conn()
+ user_row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
+ if not user_row:
+ conn.close()
+ return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
+ user = dict(user_row)
+
+ if user.get("email_verified", 0) == 1:
+ conn.close()
+ return jsonify({"error": "Tài khoản này đã được xác minh trước đó."}), 400
+
+ from datetime import timedelta
+ otp_code = f"{secrets.randbelow(900000) + 100000}"
+ expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
+
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user["id"],))
+ conn.execute(
+ "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
+ (user["id"], otp_code, expires_at)
+ )
+ conn.commit()
+ conn.close()
+
+ otp_html = f"""
+ Xác minh tài khoản Novel Translator VIP của bạn
+ Chào bạn,
+ Bạn đã yêu cầu gửi lại mã xác minh cho tài khoản {user["username"]}.
+ Mã OTP mới của bạn là: {otp_code}
+ Mã OTP này có hiệu lực trong 10 phút.
+ Trân trọng,
Đội ngũ hỗ trợ Ly Vu Ha
+ """
+ send_email_async(email, "Mã xác minh tài khoản Novel Translator VIP", otp_html)
+ return jsonify({"message": "Mã xác minh mới đã được gửi về email của bạn."})
+
+
+@auth_bp.route("/change-password", methods=["POST"])
+@jwt_required
+def auth_change_password():
+ user = request._jwt_user
+ data = request.json or {}
+ old_password = data.get("old_password", "")
+ new_password = data.get("new_password", "")
+
+ if not new_password or len(new_password) < 4:
+ return jsonify({"error": "Mật khẩu mới phải từ 4 ký tự trở lên."}), 400
+
+ conn = get_user_db_conn()
+ user_record = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
+
+ # Enforce old password check unless it's a first-time Google login requiring password change
+ is_google_first_time = (user_record.get("require_password_change", 0) == 1)
+
+ if not is_google_first_time:
+ if not old_password or not verify_password(old_password, user_record["password_hash"]):
+ conn.close()
+ return jsonify({"error": "Mật khẩu cũ không chính xác."}), 400
+
+ pw_hash = hash_password(new_password)
+ conn.execute(
+ "UPDATE users SET password_hash = ?, require_password_change = 0 WHERE id = ?",
+ (pw_hash, user["id"])
+ )
+ conn.commit()
+ conn.close()
+
+ return jsonify({"message": "Đổi mật khẩu thành công!"})
+
+
+@auth_bp.route("/update-profile", methods=["POST"])
+@jwt_required
+def auth_update_profile():
+ user = request._jwt_user
+ data = request.json or {}
+
+ display_name = data.get("display_name", "")
+ birthday = data.get("birthday", "")
+ gender = data.get("gender", "")
+ bio = data.get("bio", "")
+ avatar = data.get("avatar", "")
+ avatar_frame = data.get("avatar_frame", "default")
+ phone = data.get("phone", "")
+ two_factor = data.get("two_factor", 0)
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ """UPDATE users SET
+ display_name = ?,
+ birthday = ?,
+ gender = ?,
+ bio = ?,
+ avatar = ?,
+ avatar_frame = ?,
+ phone = ?,
+ two_factor = ?
+ WHERE id = ?""",
+ (display_name, birthday, gender, bio, avatar, avatar_frame, phone, two_factor, user["id"])
+ )
+ conn.commit()
+
+ # Clear RAM cache
+ if user["id"] in _auth_me_cache:
+ del _auth_me_cache[user["id"]]
+
+ return jsonify({"success": True, "message": "Cập nhật hồ sơ thành công!"})
+ except Exception as e:
+ logger.error(f"Database error during profile update: {e}")
+ conn.close()
+ return jsonify({"error": "Lỗi cơ sở dữ liệu. Vui lòng thử lại sau."}), 500
+
+ conn.close()
+ return jsonify({"success": True, "message": "Cập nhật hồ sơ thành công!"})
+
+
+@auth_bp.route("/sessions", methods=["GET"])
+@jwt_required
+def get_login_sessions():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ """SELECT id, ip_address, os, browser, device_type, login_time, last_active, status, token
+ FROM login_history
+ WHERE user_id = ?
+ ORDER BY login_time DESC LIMIT 50""",
+ (user["id"],)
+ ).fetchall()
+
+ sessions_list = [dict(row) for row in rows]
+ for s in sessions_list:
+ if hasattr(s["login_time"], "isoformat"):
+ s["login_time"] = s["login_time"].isoformat()
+ if hasattr(s["last_active"], "isoformat"):
+ s["last_active"] = s["last_active"].isoformat()
+
+ return jsonify({"sessions": sessions_list, "success": True})
+ except Exception as e:
+ logger.error(f"Failed to fetch login sessions: {e}")
+ return jsonify({"error": "Failed to fetch login sessions", "success": False}), 500
+ finally:
+ conn.close()
+
+
+@auth_bp.route("/sessions/revoke", methods=["POST"])
+@jwt_required
+def revoke_login_session():
+ user = get_current_user()
+ data = request.json or {}
+ session_id = data.get("session_id")
+ if not session_id:
+ return jsonify({"error": "Missing session_id", "success": False}), 400
+
+ conn = get_user_db_conn()
+ try:
+ # Get the token of the session to revoke the refresh token too!
+ row = conn.execute(
+ "SELECT token FROM login_history WHERE id = ? AND user_id = ?",
+ (session_id, user["id"])
+ ).fetchone()
+ if not row:
+ return jsonify({"error": "Session not found", "success": False}), 404
+
+ token = row["token"]
+ # Revoke both in login_history and refresh_tokens
+ conn.execute("UPDATE login_history SET status = 'logged_out' WHERE id = ?", (session_id,))
+ if token:
+ conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (token,))
+ conn.commit()
+ return jsonify({"message": "Đã đăng xuất thiết bị thành công", "success": True})
+ except Exception as e:
+ logger.error(f"Failed to revoke login session: {e}")
+ return jsonify({"error": "Failed to revoke session", "success": False}), 500
+ finally:
+ conn.close()
diff --git a/backend/api/books.py b/backend/api/books.py
new file mode 100644
index 0000000000000000000000000000000000000000..0364cb422e2ae3d383c09b674d5a8b38d3c91948
--- /dev/null
+++ b/backend/api/books.py
@@ -0,0 +1,734 @@
+import os
+import time
+import unicodedata
+from flask import Blueprint, request, jsonify, session
+from backend.config import Config
+from backend.core.decorators import get_current_user
+from backend.core.security import verify_access_token, encrypt_asymmetric_hybrid, decrypt_asymmetric_hybrid
+from backend.core.rate_limit import check_rate_limit, check_ip_rate_limit, get_client_ip
+from backend.database.db_manager import get_db, get_mode_connection, get_user_db_conn
+from backend.services.book_service import search_books, clean_vietnamese_query
+from backend.services.translation import get_engine
+
+books_bp = Blueprint("books", __name__, url_prefix="/api")
+
+
+def is_vip_request():
+ """Check if current request has VIP privileges."""
+ user = get_current_user()
+ if user and user.get("vip_status", 0) == 1:
+ return True
+ vip_code = request.headers.get("X-VIP-Code", "")
+ if vip_code in Config.VALID_VIP_CODES:
+ return True
+ return False
+
+
+@books_bp.route("/books", methods=["GET"])
+def api_books():
+ ip = get_client_ip()
+ if not check_ip_rate_limit(ip, max_requests=45, period=60):
+ return jsonify({"error": "Too many requests. Please slow down."}), 429
+
+ q = request.args.get("q", "").strip()
+ category = request.args.get("category", "").strip()
+ source = request.args.get("source", "").strip()
+ dup = request.args.get("dup", "").strip()
+ sort = request.args.get("sort", "site_count DESC").strip()
+ search_field = request.args.get("field", "all").strip()
+ min_chapters = request.args.get("min_chapters", "").strip()
+
+ try:
+ page = max(1, int(request.args.get("page", 1)))
+ per_page = max(1, min(50, int(request.args.get("per_page", 20))))
+ limit_k = max(1, min(1000, int(request.args.get("limit_k", 100))))
+ except ValueError:
+ page, per_page, limit_k = 1, 20, 100
+
+ result, status_code = search_books(q, category, source, dup, sort, search_field, min_chapters, page, per_page, limit_k)
+ if status_code != 200:
+ return jsonify(result), status_code
+ return jsonify(result)
+
+
+@books_bp.route("/stats", methods=["GET"])
+def api_stats():
+ from backend.database.db_manager import cached_stats
+ import backend.database.db_manager as db_mod
+ if db_mod.cached_stats is None:
+ conn = get_db()
+ total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
+ cats = conn.execute(
+ "SELECT categories FROM books WHERE categories IS NOT NULL AND categories != ''"
+ ).fetchall()
+ cat_counts = {}
+ for row in cats:
+ for c in row[0].split(","):
+ c = c.strip()
+ if c:
+ cat_counts[c] = cat_counts.get(c, 0) + 1
+ top_categories = sorted(cat_counts.items(), key=lambda x: x[1], reverse=True)[:20]
+ db_mod.cached_stats = {
+ "total_books": total,
+ "top_categories": [{"name": k, "count": v} for k, v in top_categories],
+ }
+ return jsonify(db_mod.cached_stats)
+
+
+@books_bp.route("/book/", methods=["GET"])
+def get_book_detail(book_id):
+ conn = get_db()
+ row = conn.execute("SELECT * FROM books WHERE id = ?", (book_id,)).fetchone()
+ if not row:
+ return jsonify({"error": "Không tìm thấy truyện."}), 404
+ book_dict = dict(row)
+ # Parse sources
+ parsed_sources = []
+ if book_dict.get("urls"):
+ parts = book_dict["urls"].split(" | ")
+ for p in parts:
+ idx = p.find(":")
+ if idx > 0:
+ parsed_sources.append({
+ "source": p[:idx].strip(),
+ "url": p[idx+1:].strip()
+ })
+ book_dict["parsed_sources"] = parsed_sources
+
+ # Lấy description_vietphrase từ advanced DB
+ description_vietphrase = None
+ root_dir = Config.ROOT_DIR
+ adv_db_path = os.path.join(root_dir, "merged_books_advanced.db")
+ if os.path.exists(adv_db_path):
+ try:
+ from backend.database.db_manager import get_mode_connection
+ adv_conn = get_mode_connection(adv_db_path)
+ if adv_conn:
+ r_adv = adv_conn.execute("SELECT description_vietphrase FROM books WHERE id = ?", (book_id,)).fetchone()
+ if r_adv and r_adv["description_vietphrase"]:
+ description_vietphrase = r_adv["description_vietphrase"]
+ except Exception as e:
+ logger.warning(f"Failed to read from advanced DB for book {book_id}: {e}")
+
+ # Nếu không có trong advanced DB, dịch trực tiếp bằng translation engine
+ if not description_vietphrase and book_dict.get("description"):
+ try:
+ from backend.services.translation import get_engine
+ eng = get_engine()
+ description_vietphrase = eng.translate(book_dict["description"], multi_option=False)
+ except Exception as e:
+ logger.warning(f"Failed to translate book description live: {e}")
+ description_vietphrase = book_dict.get("description")
+
+ book_dict["description_vietphrase"] = description_vietphrase or ""
+
+ # Dịch mô tả sang tiếng Anh bằng Gemini
+ description_english = None
+ if book_dict.get("description"):
+ # Tạo fallback không dấu trước
+ fallback_en = ""
+ if book_dict["description_vietphrase"]:
+ try:
+ from backend.services.book_service import remove_vietnamese_accents
+ fallback_en = remove_vietnamese_accents(book_dict["description_vietphrase"])
+ except Exception:
+ pass
+
+ # Thử gọi Gemini để dịch chất lượng cao
+ key = os.environ.get("ADMIN_GEMINI_KEY", "") or os.environ.get("GOOGLE_API_KEY_BIGDATA", "") or os.environ.get("GEMINI_API_KEY_BIGDATA", "")
+ if key:
+ try:
+ import requests as py_requests
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={key}"
+ prompt_text = book_dict["description"][:800]
+ payload = {
+ "contents": [{
+ "role": "user",
+ "parts": [{"text": f"Translate the following novel synopsis to English. Only return the English translation, no other commentary:\n\n{prompt_text}"}]
+ }]
+ }
+ res = py_requests.post(url, json=payload, timeout=5)
+ if res.status_code == 200:
+ ans = res.json().get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
+ if ans.strip():
+ description_english = ans.strip()
+ except Exception as e:
+ logger.warning(f"Failed to translate book description to English using Gemini: {e}")
+
+ if not description_english:
+ description_english = fallback_en or book_dict.get("description")
+
+ book_dict["description_english"] = description_english or ""
+
+ # 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
+ try:
+ from backend.services.book_service import translate_categories, translate_categories_to_english, remove_vietnamese_accents
+ book_dict["categories_vietphrase"] = translate_categories(book_dict.get("categories", ""))
+ book_dict["categories_english"] = translate_categories_to_english(book_dict.get("categories", ""))
+
+ author_hv = book_dict.get("author_hanviet", "")
+ if author_hv:
+ book_dict["author_english"] = remove_vietnamese_accents(author_hv)
+ else:
+ book_dict["author_english"] = book_dict.get("author", "")
+ except Exception:
+ book_dict["categories_vietphrase"] = book_dict.get("categories", "")
+ book_dict["categories_english"] = book_dict.get("categories", "")
+ book_dict["author_english"] = book_dict.get("author", "")
+
+ return jsonify(book_dict)
+
+
+@books_bp.route("/book//translations")
+def api_book_translations(book_id):
+ root_dir = Config.ROOT_DIR
+ adv_db_path = os.path.join(root_dir, "merged_books_advanced.db")
+ fast_db_path = os.path.join(root_dir, "merged_books_fast.db")
+ vp_db_path = os.path.join(root_dir, "merged_books_vietphrase.db")
+ hv_db_path = os.path.join(root_dir, "merged_books_hanviet.db")
+
+ res = {
+ "advanced": {"title": None, "desc": None},
+ "fast": {"title": None, "desc": None},
+ "vietphrase": {"title": None, "desc": None},
+ "hanviet": {"title": None, "desc": None}
+ }
+
+ for mode_key, db_path in [("advanced", adv_db_path), ("fast", fast_db_path),
+ ("vietphrase", vp_db_path), ("hanviet", hv_db_path)]:
+ conn = get_mode_connection(db_path)
+ if conn:
+ try:
+ r = conn.execute("SELECT title_vietphrase, description_vietphrase FROM books WHERE id = ?", (book_id,)).fetchone()
+ if r:
+ res[mode_key] = {"title": r["title_vietphrase"], "desc": r["description_vietphrase"]}
+ except Exception as e:
+ print(f"[ERROR] Loading {mode_key} book details: {e}")
+
+ return jsonify(res)
+
+
+@books_bp.route("/bookshelf", methods=["GET"])
+def get_bookshelf():
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ q = request.args.get("q", "").strip()
+ conn = get_user_db_conn()
+ rows = conn.execute("SELECT * FROM bookshelf WHERE user_id = ? ORDER BY added_at DESC", (user_id,)).fetchall()
+ conn.close()
+
+ books = [dict(r) for r in rows]
+ if q:
+ q_clean = clean_vietnamese_query(q)
+ books = [b for b in books if q_clean in clean_vietnamese_query(b.get("title", ""))
+ or q_clean in clean_vietnamese_query(b.get("author", ""))]
+ return jsonify(books)
+
+
+@books_bp.route("/bookshelf/add", methods=["POST"])
+def add_bookshelf():
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ data = request.json or {}
+ book_id = data.get("book_id")
+ if not book_id:
+ return jsonify({"error": "Thiếu ID truyện."}), 400
+
+ if not is_vip_request():
+ conn_check = get_user_db_conn()
+ try:
+ exists = conn_check.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id)).fetchone() is not None
+ if not exists:
+ count = conn_check.execute("SELECT COUNT(*) as cnt FROM bookshelf WHERE user_id = ?", (user_id,)).fetchone()["cnt"]
+ if count >= 5:
+ 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
+ finally:
+ conn_check.close()
+
+ main_conn = get_db()
+ book = main_conn.execute("SELECT title_vietphrase, author_hanviet, cover FROM books WHERE id = ?", (book_id,)).fetchone()
+ if not book:
+ return jsonify({"error": "Không tìm thấy truyện."}), 404
+
+ title = book["title_vietphrase"] or "Không rõ"
+ author = book["author_hanviet"] or "Không rõ"
+ cover = book["cover"] or ""
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ "INSERT OR IGNORE INTO bookshelf (user_id, book_id, title, author, cover) VALUES (?, ?, ?, ?, ?)",
+ (user_id, book_id, title, author, cover)
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ return jsonify({"success": True, "message": "Đã thêm vào tủ sách."})
+
+
+@books_bp.route("/bookshelf/remove", methods=["POST"])
+def remove_bookshelf():
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ data = request.json or {}
+ book_id = data.get("book_id")
+ url = data.get("url")
+ if not book_id and not url:
+ return jsonify({"error": "Thiếu thông tin truyện để xóa."}), 400
+
+ conn = get_user_db_conn()
+ if book_id:
+ conn.execute("DELETE FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id))
+ else:
+ conn.execute("DELETE FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url))
+ conn.commit()
+ conn.close()
+ return jsonify({"success": True, "message": "Đã xóa khỏi tủ sách."})
+
+
+@books_bp.route("/history", methods=["GET"])
+def get_history():
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ q = request.args.get("q", "").strip()
+ conn = get_user_db_conn()
+ rows = conn.execute("SELECT * FROM reading_history WHERE user_id = ? ORDER BY timestamp DESC", (user_id,)).fetchall()
+ conn.close()
+
+ books = [dict(r) for r in rows]
+ if q:
+ q_clean = clean_vietnamese_query(q)
+ books = [b for b in books if q_clean in clean_vietnamese_query(b.get("title", ""))
+ or q_clean in clean_vietnamese_query(b.get("author", ""))]
+
+ struct_now = time.localtime()
+ today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
+ struct_y = time.localtime(time.time() - 86400)
+ yesterday_str = f"{struct_y.tm_year:04d}-{struct_y.tm_mon:02d}-{struct_y.tm_mday:02d}"
+ this_month_prefix = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}"
+
+ groups = {"Hôm nay": [], "Hôm qua": [], "Tháng này": [], "Trước đây": []}
+ for b in books:
+ r_date = b.get("read_date", "")
+ if r_date == today_str:
+ groups["Hôm nay"].append(b)
+ elif r_date == yesterday_str:
+ groups["Hôm qua"].append(b)
+ elif r_date.startswith(this_month_prefix):
+ groups["Tháng này"].append(b)
+ else:
+ groups["Trước đây"].append(b)
+
+ return jsonify([{"group_name": g, "books": groups[g]}
+ for g in ["Hôm nay", "Hôm qua", "Tháng này", "Trước đây"] if groups[g]])
+
+
+@books_bp.route("/history/add", methods=["POST"])
+def add_history():
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ data = request.json or {}
+ book_id = data.get("book_id")
+ last_chapter = data.get("last_chapter", "Chương đầu")
+ if not book_id:
+ return jsonify({"error": "Thiếu ID truyện."}), 400
+
+ main_conn = get_db()
+ book = main_conn.execute("SELECT title_vietphrase, author_hanviet, cover FROM books WHERE id = ?", (book_id,)).fetchone()
+ if not book:
+ return jsonify({"error": "Không tìm thấy truyện."}), 404
+
+ title = book["title_vietphrase"] or "Không rõ"
+ author = book["author_hanviet"] or "Không rõ"
+ cover = book["cover"] or ""
+ struct_now = time.localtime()
+ today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute("""
+ INSERT OR REPLACE INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ """, (user_id, book_id, title, author, cover, last_chapter, today_str))
+ conn.commit()
+ finally:
+ conn.close()
+ return jsonify({"success": True})
+
+
+@books_bp.route("/history/clear", methods=["POST"])
+def clear_history():
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+ conn = get_user_db_conn()
+ conn.execute("DELETE FROM reading_history WHERE user_id = ?", (user_id,))
+ conn.commit()
+ conn.close()
+ return jsonify({"success": True})
+
+
+@books_bp.route("/history/remove", methods=["POST"])
+def remove_history_item():
+ """Xóa một mục khỏi lịch sử đọc (theo book_id hoặc url)."""
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ data = request.json or {}
+ book_id = data.get("book_id")
+ url = data.get("url")
+
+ if not book_id and not url:
+ return jsonify({"error": "Thiếu book_id hoặc url."}), 400
+
+ conn = get_user_db_conn()
+ try:
+ if book_id:
+ conn.execute(
+ "DELETE FROM reading_history WHERE user_id = ? AND book_id = ?",
+ (user_id, book_id)
+ )
+ else:
+ conn.execute(
+ "DELETE FROM reading_history WHERE user_id = ? AND url = ?",
+ (user_id, url)
+ )
+ conn.commit()
+ return jsonify({"success": True, "message": "Đã xóa khỏi lịch sử."})
+ except Exception as e:
+ logger.error(f"[History] Remove item failed: {e}")
+ return jsonify({"error": "Không thể xóa mục lịch sử."}), 500
+ finally:
+ conn.close()
+
+
+
+
+@books_bp.route("/extension/sync", methods=["POST"])
+def extension_sync():
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập trên website chính."}), 401
+
+ data = request.json or {}
+ title_zh = data.get("title", "").strip()
+ author_zh = data.get("author", "").strip()
+ cover = data.get("cover", "").strip()
+ last_chapter_zh = data.get("last_chapter", "").strip()
+ url = data.get("url", "").strip()
+ action = data.get("action", "history")
+
+ if not title_zh and not url:
+ return jsonify({"error": "Thiếu thông tin tên truyện hoặc liên kết đường dẫn."}), 400
+
+ book = None
+ book_id = None
+ title_vi = None
+ author_vi = None
+ eng = get_engine()
+
+ if not title_zh:
+ from urllib.parse import urlparse
+ try:
+ domain = urlparse(url).netloc
+ title_vi = f"Truyện ngoài ({domain})"
+ except Exception:
+ title_vi = "Truyện ngoài"
+ title_zh = title_vi
+ author_vi = "Không rõ"
+ else:
+ main_conn = get_db()
+ book = main_conn.execute(
+ "SELECT id, title_vietphrase, author_hanviet, cover FROM books WHERE title = ? LIMIT 1",
+ (title_zh,)
+ ).fetchone()
+
+ if book:
+ book_id = book["id"]
+ title_vi = book["title_vietphrase"]
+ author_vi = book["author_hanviet"]
+ if not cover and book["cover"]:
+ cover = book["cover"]
+ else:
+ if not title_vi:
+ try:
+ title_vi = eng.translate(title_zh, multi_option=False)
+ except Exception:
+ title_vi = title_zh
+ if not author_vi:
+ if author_zh:
+ try:
+ author_vi = eng.translate(author_zh, multi_option=False)
+ except Exception:
+ author_vi = author_zh
+ else:
+ author_vi = "Không rõ"
+
+ last_chapter_vi = "Chương đầu"
+ if last_chapter_zh:
+ try:
+ last_chapter_vi = eng.translate(last_chapter_zh, multi_option=False)
+ except Exception:
+ last_chapter_vi = last_chapter_zh
+
+ title_vi = title_vi or title_zh
+ author_vi = author_vi or "Không rõ"
+ struct_now = time.localtime()
+ today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
+
+ conn = get_user_db_conn()
+ try:
+ if action == "bookshelf":
+ if not is_vip_request():
+ exists = (
+ conn.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id)).fetchone()
+ if book_id
+ else conn.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
+ ) is not None
+ if not exists:
+ count = conn.execute("SELECT COUNT(*) as cnt FROM bookshelf WHERE user_id = ?", (user_id,)).fetchone()["cnt"]
+ if count >= 5:
+ return jsonify({"error": "Tủ sách Standard tối đa 5 truyện. Nâng cấp VIP!"}), 403
+
+ if book_id:
+ conn.execute("INSERT OR REPLACE INTO bookshelf (user_id, book_id, title, author, cover, url) VALUES (?, ?, ?, ?, ?, ?)",
+ (user_id, book_id, title_vi, author_vi, cover, url))
+ else:
+ existing = conn.execute("SELECT id FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
+ if existing:
+ conn.execute("UPDATE bookshelf SET title = ?, author = ?, cover = ? WHERE id = ?",
+ (title_vi, author_vi, cover, existing["id"]))
+ else:
+ conn.execute("INSERT INTO bookshelf (user_id, book_id, title, author, cover, url) VALUES (?, NULL, ?, ?, ?, ?)",
+ (user_id, title_vi, author_vi, cover, url))
+ conn.commit()
+ msg = "Đã lưu vào Tủ sách!"
+ else:
+ if book_id:
+ conn.execute("INSERT OR REPLACE INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (user_id, book_id, title_vi, author_vi, cover, last_chapter_vi, today_str, url))
+ else:
+ existing = conn.execute("SELECT id FROM reading_history WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
+ if existing:
+ conn.execute("UPDATE reading_history SET title = ?, author = ?, cover = ?, last_chapter = ?, read_date = ?, timestamp = CURRENT_TIMESTAMP WHERE id = ?",
+ (title_vi, author_vi, cover, last_chapter_vi, today_str, existing["id"]))
+ else:
+ conn.execute("INSERT INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, url) VALUES (?, NULL, ?, ?, ?, ?, ?, ?)",
+ (user_id, title_vi, author_vi, cover, last_chapter_vi, today_str, url))
+ conn.commit()
+ msg = "Đã cập nhật Lịch sử!"
+ finally:
+ conn.close()
+
+ return jsonify({
+ "success": True, "message": msg,
+ "matched": book is not None, "book_id": book_id,
+ "title_vi": title_vi, "author_vi": author_vi, "last_chapter_vi": last_chapter_vi
+ })
+
+
+@books_bp.route("/system/notifications", methods=["GET"])
+def get_system_notifications():
+ user = get_current_user()
+ notifications = [
+ {
+ "id": "sys-1",
+ "type": "server",
+ "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.",
+ "time": "Vừa xong"
+ },
+ {
+ "id": "sys-2",
+ "type": "promo",
+ "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!",
+ "time": "5 phút trước"
+ }
+ ]
+
+ if user:
+ user_id = user["id"]
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute("SELECT title FROM bookshelf WHERE user_id = ? ORDER BY added_at DESC LIMIT 3", (user_id,)).fetchall()
+ for i, row in enumerate(rows):
+ notifications.append({
+ "id": f"shelf-{i}",
+ "type": "update",
+ "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.",
+ "time": "10 phút trước"
+ })
+ except Exception:
+ pass
+ finally:
+ conn.close()
+
+ notifications.append({
+ "id": "comment-1",
+ "type": "comment",
+ "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à...'",
+ "time": "20 phút trước"
+ })
+ notifications.append({
+ "id": "comment-2",
+ "type": "comment",
+ "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.",
+ "time": "1 giờ trước"
+ })
+ return jsonify(notifications)
+
+
+@books_bp.route("/author/", methods=["GET"])
+def get_author_details(author_name):
+ conn = get_db()
+ rows = conn.execute(
+ "SELECT * FROM books WHERE author = ? OR author_hanviet = ? ORDER BY chapters_max DESC",
+ (author_name, author_name)
+ ).fetchall()
+
+ if not rows:
+ rows = conn.execute(
+ "SELECT * FROM books WHERE LOWER(author) = LOWER(?) OR LOWER(author_hanviet) = LOWER(?) ORDER BY chapters_max DESC",
+ (author_name, author_name)
+ ).fetchall()
+
+ fallback_mode = False
+ if not rows:
+ # Fallback: if author has no books in database, fetch first 5 books to show as placeholder
+ rows = conn.execute("SELECT * FROM books LIMIT 5").fetchall()
+ fallback_mode = True
+
+ books = []
+ canonical_chinese = author_name if fallback_mode else (rows[0]["author"] or author_name)
+ canonical_hanviet = author_name if fallback_mode else (rows[0]["author_hanviet"] or author_name)
+
+ for row in rows:
+ book_dict = dict(row)
+ if fallback_mode:
+ book_dict["author"] = author_name
+ book_dict["author_hanviet"] = author_name
+
+ parsed_sources = []
+ raw_urls = book_dict.get("urls", "")
+ if raw_urls:
+ parts = raw_urls.split(" | ")
+ for part in parts:
+ if ":" in part:
+ subparts = part.split(":", 1)
+ src_name = subparts[0].strip()
+ src_url = subparts[1].strip()
+ # Keep raw scheme if complete, else handle
+ if src_url.startswith("//"):
+ src_url = "https:" + src_url
+ parsed_sources.append({
+ "source": src_name,
+ "url": src_url
+ })
+ book_dict["parsed_sources"] = parsed_sources
+ books.append(book_dict)
+
+ return jsonify({
+ "author_chinese": canonical_chinese,
+ "author_hanviet": canonical_hanviet,
+ "total_books": len(books),
+ "books": books
+ })
+
+
+@books_bp.route("/books//comments", methods=["POST"])
+def add_book_comment(book_id):
+ user = get_current_user()
+ data = request.json or {}
+ content = data.get("content", "").strip()
+ is_anonymous = int(data.get("is_anonymous", 0))
+
+ if not content:
+ return jsonify({"error": "Nội dung bình luận không được để trống"}), 400
+
+ user_id = user["id"] if user else None
+
+ # Mã hóa Hybrid RSA-AES bảo mật
+ enc_data = encrypt_asymmetric_hybrid(content)
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ """INSERT INTO book_comments
+ (book_id, user_id, is_anonymous, content_ciphertext, encrypted_aes_key, aes_nonce, aes_tag)
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
+ (book_id, user_id, is_anonymous,
+ enc_data["ciphertext"], enc_data["encrypted_key"], enc_data["nonce"], enc_data["tag"])
+ )
+ conn.commit()
+ return jsonify({"success": True, "message": "Gửi bình luận thành công!"})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@books_bp.route("/books//comments", methods=["GET"])
+def get_book_comments(book_id):
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ """SELECT c.id, c.user_id, c.is_anonymous, c.content_ciphertext, c.encrypted_aes_key,
+ c.aes_nonce, c.aes_tag, c.created_at, u.username, u.avatar
+ FROM book_comments c
+ LEFT JOIN users u ON c.user_id = u.id
+ WHERE c.book_id = ?
+ ORDER BY c.created_at DESC""",
+ (book_id,)
+ ).fetchall()
+
+ comments = []
+ for r in rows:
+ # Giải mã Hybrid RSA-AES
+ decrypted_content = decrypt_asymmetric_hybrid(
+ r["content_ciphertext"],
+ r["encrypted_aes_key"],
+ r["aes_nonce"],
+ r["aes_tag"]
+ )
+
+ comment = {
+ "id": r["id"],
+ "created_at": r["created_at"].isoformat() if hasattr(r["created_at"], "isoformat") else r["created_at"],
+ }
+
+ if r["is_anonymous"] == 1:
+ comment["username"] = "Đạo hữu ẩn danh"
+ comment["avatar"] = None
+ comment["user_id"] = None
+ else:
+ comment["username"] = r["username"] or "Khách viếng thăm"
+ comment["avatar"] = r["avatar"]
+ comment["user_id"] = r["user_id"]
+
+ comment["content"] = decrypted_content
+ comments.append(comment)
+
+ return jsonify({"comments": comments})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
diff --git a/backend/api/developer.py b/backend/api/developer.py
new file mode 100644
index 0000000000000000000000000000000000000000..4de716793dd3e2bb81931dbd1803c0b1c7aa0bd5
--- /dev/null
+++ b/backend/api/developer.py
@@ -0,0 +1,488 @@
+import os
+import time
+import uuid
+import secrets
+import base64
+import requests as py_requests
+from flask import Blueprint, request, jsonify, send_file
+from backend.core.decorators import get_current_user, require_api_key_auth
+from backend.database.db_manager import get_user_db_conn
+from backend.services.translation import get_engine
+
+developer_bp = Blueprint("developer", __name__)
+
+ADMIN_GEMINI_KEY = os.environ.get("ADMIN_GEMINI_KEY", "")
+
+def serialize_row(row):
+ if not row:
+ return {}
+ d = dict(row)
+ for k, v in d.items():
+ if hasattr(v, "isoformat"):
+ d[k] = v.isoformat()
+ return d
+
+
+
+def deduct_developer_balance(api_key, amount, model, tokens=0, status_code=200):
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ "INSERT INTO api_usage (api_key, model, tokens, cost, status_code) VALUES (?, ?, ?, ?, ?)",
+ (api_key, model, tokens, amount, status_code)
+ )
+ conn.execute(
+ "UPDATE users SET api_balance = api_balance - ? WHERE id = (SELECT user_id FROM api_keys WHERE api_key = ?)",
+ (amount, api_key)
+ )
+ conn.commit()
+ except Exception as e:
+ print(f"[API GATEWAY ERROR] Failed to deduct balance: {e}")
+ finally:
+ conn.close()
+
+
+@developer_bp.route("/api/developer/keys", methods=["GET"])
+def api_dev_keys_list():
+ user = get_current_user()
+ print(f"[DEBUG api_dev_keys_list] Current user from auth: {user}")
+ if not user:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ conn = get_user_db_conn()
+ keys = conn.execute(
+ "SELECT api_key, name, status, created_at, last_used_at FROM api_keys WHERE user_id = ? AND status = 'active' ORDER BY created_at DESC",
+ (user["id"],)
+ ).fetchall()
+ user_row = conn.execute("SELECT api_balance FROM users WHERE id = ?", (user["id"],)).fetchone()
+ conn.close()
+
+ balance = user_row["api_balance"] if user_row and user_row["api_balance"] is not None else 0.0
+ print(f"[DEBUG api_dev_keys_list] Queried user_id: {user['id']}, found balance: {balance}, keys count: {len(keys)}")
+ return jsonify({"balance": balance, "keys": [serialize_row(k) for k in keys]})
+
+
+@developer_bp.route("/api/developer/keys/create", methods=["POST"])
+def api_dev_keys_create():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ data = request.json or {}
+ name = data.get("name", "Default Key").strip()[:50]
+ new_key = f"sk-tc-{secrets.token_hex(16)}"
+
+ conn = get_user_db_conn()
+ conn.execute(
+ "INSERT INTO api_keys (user_id, api_key, name, status) VALUES (?, ?, ?, 'active')",
+ (user["id"], new_key, name)
+ )
+ conn.commit()
+ conn.close()
+
+ return jsonify({"success": True, "api_key": new_key, "name": name})
+
+
+@developer_bp.route("/api/developer/keys/delete", methods=["POST"])
+def api_dev_keys_delete():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ data = request.json or {}
+ api_key = data.get("api_key", "").strip()
+ if not api_key:
+ return jsonify({"error": "Thiếu API Key cần xóa."}), 400
+
+ conn = get_user_db_conn()
+ conn.execute(
+ "UPDATE api_keys SET status = 'revoked' WHERE user_id = ? AND api_key = ?",
+ (user["id"], api_key)
+ )
+ conn.commit()
+ conn.close()
+ return jsonify({"success": True})
+
+
+@developer_bp.route("/api/developer/usage", methods=["GET"])
+def api_dev_usage():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
+
+ conn = get_user_db_conn()
+ keys_rows = conn.execute("SELECT api_key FROM api_keys WHERE user_id = ?", (user["id"],)).fetchall()
+ keys = [k["api_key"] for k in keys_rows]
+
+ if not keys:
+ conn.close()
+ return jsonify({"usage": []})
+
+ placeholders = ",".join(["?"] * len(keys))
+ usage = conn.execute(
+ f"SELECT * FROM api_usage WHERE api_key IN ({placeholders}) ORDER BY timestamp DESC LIMIT 100",
+ keys
+ ).fetchall()
+ conn.close()
+ return jsonify({"usage": [serialize_row(u) for u in usage]})
+
+
+@developer_bp.route("/v1/chat/completions", methods=["POST"])
+@require_api_key_auth
+def openai_chat_completions():
+ api_key = request.api_key
+ data = request.json or {}
+ messages = data.get("messages", [])
+ model = data.get("model", "gemini-1.5-flash")
+
+ if not messages:
+ return jsonify({"error": "Missing messages array"}), 400
+
+ prompt_length = sum(len(m.get("content", "")) for m in messages)
+ cost = max(20.0, prompt_length * 0.02)
+
+ if request.api_balance < cost:
+ return jsonify({"error": f"Số dư không đủ. Chi phí ước tính: {cost:.2f}đ, Số dư: {request.api_balance:.2f}đ"}), 402
+
+ if not ADMIN_GEMINI_KEY:
+ return jsonify({"error": "Hệ thống chưa được Admin cấu hình khóa Gemini."}), 501
+
+ try:
+ contents = []
+ system_instruction = ""
+
+ for msg in messages:
+ role = msg.get("role")
+ content = msg.get("content", "")
+ if role == "system":
+ system_instruction = content
+ else:
+ contents.append({
+ "role": "user" if role == "user" else "model",
+ "parts": [{"text": content}]
+ })
+
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={ADMIN_GEMINI_KEY}"
+ payload = {"contents": contents}
+ if system_instruction:
+ payload["systemInstruction"] = {"parts": [{"text": system_instruction}]}
+
+ res = py_requests.post(url, json=payload, timeout=30)
+
+ if res.status_code != 200:
+ deduct_developer_balance(api_key, 0.0, model, prompt_length, res.status_code)
+ return jsonify({"error": f"Google Gemini API error: {res.text}"}), res.status_code
+
+ gemini_data = res.json()
+ response_text = gemini_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
+
+ deduct_developer_balance(api_key, cost, model, prompt_length + len(response_text), 200)
+
+ res_id = f"chatcmpl-{uuid.uuid4().hex}"
+ return jsonify({
+ "id": res_id,
+ "object": "chat.completion",
+ "created": int(time.time()),
+ "model": model,
+ "choices": [{
+ "index": 0,
+ "message": {"role": "assistant", "content": response_text},
+ "finish_reason": "stop"
+ }],
+ "usage": {
+ "prompt_tokens": int(prompt_length / 4),
+ "completion_tokens": int(len(response_text) / 4),
+ "total_tokens": int((prompt_length + len(response_text)) / 4)
+ }
+ })
+ except Exception as e:
+ deduct_developer_balance(api_key, 0.0, model, prompt_length, 500)
+ return jsonify({"error": f"API Gateway Exception: {str(e)}"}), 500
+
+
+@developer_bp.route("/api/v1/translate", methods=["POST"])
+@require_api_key_auth
+def api_v1_translate():
+ api_key = request.api_key
+ data = request.json or {}
+ texts = data.get("texts", [])
+ mode = data.get("mode", "fast")
+
+ if not texts:
+ return jsonify({"error": "Missing texts array"}), 400
+
+ total_chars = sum(len(t) for t in texts)
+ cost = total_chars * 0.01
+
+ if request.api_balance < cost:
+ return jsonify({"error": f"Số dư không đủ. Chi phí ước tính: {cost:.2f}đ, Số dư: {request.api_balance:.2f}đ"}), 402
+
+ try:
+ eng = get_engine()
+ translations = []
+ for text in texts:
+ if not text.strip():
+ translations.append(text)
+ else:
+ translations.append(eng.translate(text, multi_option=False, mode=mode))
+
+ deduct_developer_balance(api_key, cost, f"vietphrase-{mode}", total_chars, 200)
+ return jsonify({"translations": translations, "characters": total_chars, "cost": cost})
+ except Exception as e:
+ deduct_developer_balance(api_key, 0.0, f"vietphrase-{mode}", total_chars, 500)
+ return jsonify({"error": f"Translation Error: {str(e)}"}), 500
+
+
+@developer_bp.route("/v1/audio/speech", methods=["POST"])
+@require_api_key_auth
+def openai_audio_speech():
+ api_key = request.api_key
+ data = request.json or {}
+ input_text = data.get("input", "").strip()
+
+ if not input_text:
+ return jsonify({"error": "Missing 'input' text"}), 400
+
+ cost = len(input_text) * 0.1
+ if request.api_balance < cost:
+ return jsonify({"error": f"Số dư không đủ. Chi phí: {cost:.2f}đ"}), 402
+
+ # 1. Check if Local TTS Server is configured (host machine)
+ local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
+ if local_tts_url:
+ try:
+ payload = {
+ "input": input_text,
+ "speed": float(data.get("speed", 1.0)),
+ "voice": data.get("voice", "the_gioi_hoan_my"),
+ "ref_audio": data.get("ref_audio"),
+ "ref_text": data.get("ref_text")
+ }
+ if "bypass_cache" in data:
+ payload["bypass_cache"] = data["bypass_cache"]
+ if "temperature" in data:
+ payload["temperature"] = data["temperature"]
+ if "steps" in data:
+ payload["steps"] = data["steps"]
+ print(f"[Local TTS Proxy] Forwarding request to {local_tts_url} with voice={payload['voice']}, speed={payload['speed']}, bypass_cache={payload.get('bypass_cache')}")
+ r = py_requests.post(local_tts_url, json=payload, timeout=(2.0, 60.0))
+ if r.status_code == 200:
+ import io
+ deduct_developer_balance(api_key, cost, f"local-tts-{payload['voice']}", len(input_text), 200)
+ return send_file(
+ io.BytesIO(r.content),
+ mimetype="audio/wav",
+ as_attachment=False
+ )
+ print(f"[Local TTS Proxy Error]: {r.status_code} - {r.text}")
+ except Exception as e:
+ print(f"[Local TTS Proxy Exception]: {e}")
+
+ # 2. RunPod Fallback
+ runpod_api_key = os.environ.get("RUNPOD_API_TOKEN", os.environ.get("RUNPOD_API_KEY", ""))
+
+ runpod_endpoint_id = os.environ.get("RUNPOD_TTS_ENDPOINT_ID", "")
+
+ if runpod_api_key and runpod_endpoint_id:
+ try:
+ url = f"https://api.runpod.ai/v1/{runpod_endpoint_id}/runsync"
+ headers = {"Authorization": f"Bearer {runpod_api_key}", "Content-Type": "application/json"}
+ payload = {
+ "input": {
+ "text": input_text,
+ "speed": 1.0,
+ "voice": data.get("voice", "the_gioi_hoan_my")
+ }
+ }
+ r = py_requests.post(url, json=payload, headers=headers, timeout=45)
+ res = r.json()
+
+ if r.status_code == 200 and res.get("status") == "COMPLETED":
+ audio_b64 = res.get("output", {}).get("audio_base64", "")
+ if audio_b64:
+ import io
+ audio_data = base64.b64decode(audio_b64)
+ deduct_developer_balance(api_key, cost, "runpod-matcha-tts", len(input_text), 200)
+ return send_file(
+ io.BytesIO(audio_data),
+ mimetype="audio/wav" if res.get("output", {}).get("format") == "wav" else "audio/mpeg",
+ as_attachment=False
+ )
+ print(f"[RunPod TTS Error]: {res}")
+ except Exception as e:
+ print(f"[RunPod TTS exception]: {e}")
+
+ # Fallback: Generate a tiny 1-second silent WAV for testing/sandbox purposes
+ try:
+ import struct
+ import io
+ num_samples = 8000
+ # 8-bit PCM silence is represented by 128
+ data_bytes = bytearray([128] * num_samples)
+ header = struct.pack(
+ '<4sI4s4sIHHIIHH4sI',
+ b'RIFF',
+ 36 + num_samples,
+ b'WAVE',
+ b'fmt ',
+ 16,
+ 1, # PCM
+ 1, # Mono
+ 8000, # Sample rate
+ 8000, # Byte rate
+ 1, # Block align
+ 8, # Bits per sample
+ b'data',
+ num_samples
+ )
+ audio_data = bytes(header + data_bytes)
+ deduct_developer_balance(api_key, cost, "local-silent-tts-fallback", len(input_text), 200)
+ return send_file(
+ io.BytesIO(audio_data),
+ mimetype="audio/wav",
+ as_attachment=False
+ )
+ except Exception as e:
+ return jsonify({"error": f"Failed to generate fallback TTS: {str(e)}"}), 500
+
+
+@developer_bp.route("/api/developer/all-sessions", methods=["GET"])
+def api_developer_all_sessions():
+ user = get_current_user()
+ if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
+ return jsonify({"error": "Quyền truy cập bị từ chối."}), 403
+
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ """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
+ FROM login_history lh
+ JOIN users u ON lh.user_id = u.id
+ ORDER BY lh.login_time DESC LIMIT 100"""
+ ).fetchall()
+
+ sessions = []
+ for r in rows:
+ s = dict(r)
+ if hasattr(s["login_time"], "isoformat"):
+ s["login_time"] = s["login_time"].isoformat()
+ if hasattr(s["last_active"], "isoformat"):
+ s["last_active"] = s["last_active"].isoformat()
+ sessions.append(s)
+
+ return jsonify({
+ "success": True,
+ "sessions": sessions
+ })
+ except Exception as e:
+ return jsonify({"error": str(e), "success": False}), 500
+ finally:
+ conn.close()
+
+
+@developer_bp.route("/api/developer/sessions/revoke", methods=["POST"])
+def api_developer_revoke_session():
+ user = get_current_user()
+ if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
+ return jsonify({"error": "Quyền truy cập bị từ chối."}), 403
+
+ data = request.json or {}
+ session_id = data.get("session_id")
+ if not session_id:
+ return jsonify({"error": "Missing session_id", "success": False}), 400
+
+ conn = get_user_db_conn()
+ try:
+ row = conn.execute(
+ "SELECT token FROM login_history WHERE id = ?",
+ (session_id,)
+ ).fetchone()
+ if not row:
+ return jsonify({"error": "Session not found", "success": False}), 404
+
+ token = row["token"]
+ conn.execute("UPDATE login_history SET status = 'logged_out' WHERE id = ?", (session_id,))
+ if token:
+ conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (token,))
+ conn.commit()
+ return jsonify({"message": "Đã đăng xuất thiết bị thành công", "success": True})
+ except Exception as e:
+ return jsonify({"error": str(e), "success": False}), 500
+ finally:
+ conn.close()
+
+
+@developer_bp.route("/api/releases", methods=["GET"])
+def api_get_releases():
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ "SELECT platform, version, download_url, patch_url, file_size, release_notes, updated_at FROM app_releases ORDER BY updated_at DESC"
+ ).fetchall()
+ releases = {}
+ for r in rows:
+ plat = r["platform"]
+ if plat not in releases:
+ releases[plat] = {
+ "version": r["version"],
+ "download_url": r["download_url"],
+ "patch_url": r["patch_url"],
+ "file_size": r["file_size"],
+ "release_notes": r["release_notes"],
+ "updated_at": r["updated_at"].isoformat() if hasattr(r["updated_at"], "isoformat") else r["updated_at"],
+ "history": []
+ }
+
+ releases[plat]["history"].append({
+ "version": r["version"],
+ "download_url": r["download_url"],
+ "patch_url": r["patch_url"],
+ "file_size": r["file_size"],
+ "release_notes": r["release_notes"],
+ "updated_at": r["updated_at"].isoformat() if hasattr(r["updated_at"], "isoformat") else r["updated_at"]
+ })
+ return jsonify({"success": True, "releases": releases})
+ except Exception as e:
+ return jsonify({"error": str(e), "success": False}), 500
+ finally:
+ conn.close()
+
+
+@developer_bp.route("/api/releases/update", methods=["POST"])
+def api_update_release():
+ user = get_current_user()
+ if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
+ return jsonify({"error": "Quyền truy cập bị từ chối. Chỉ dành cho admin.", "success": False}), 403
+
+ data = request.json or {}
+ platform = data.get("platform")
+ version = data.get("version")
+ download_url = data.get("download_url")
+ file_size = data.get("file_size")
+ release_notes = data.get("release_notes")
+
+ if not platform or platform not in ['extension', 'desktop_linux', 'desktop_windows']:
+ return jsonify({"error": "Platform không hợp lệ.", "success": False}), 400
+
+ conn = get_user_db_conn()
+ try:
+ existing = conn.execute("SELECT 1 FROM app_releases WHERE platform = ? AND version = ?", (platform, version)).fetchone()
+ if existing:
+ conn.execute(
+ """UPDATE app_releases
+ SET download_url = ?, file_size = ?, release_notes = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE platform = ? AND version = ?""",
+ (download_url, file_size, release_notes, platform, version)
+ )
+ else:
+ conn.execute(
+ """INSERT INTO app_releases (platform, version, download_url, file_size, release_notes)
+ VALUES (?, ?, ?, ?, ?)""",
+ (platform, version, download_url, file_size, release_notes)
+ )
+ conn.commit()
+ return jsonify({"message": f"Cập nhật phiên bản {platform} v{version} thành công.", "success": True})
+ except Exception as e:
+ return jsonify({"error": str(e), "success": False}), 500
+ finally:
+ conn.close()
diff --git a/backend/api/epub.py b/backend/api/epub.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b9b4d320dbe78600cb70b2472da0b941a6b032e
--- /dev/null
+++ b/backend/api/epub.py
@@ -0,0 +1,158 @@
+import os
+import tempfile
+import uuid
+from flask import Blueprint, request, jsonify, send_file
+from backend.api.books import is_vip_request
+from backend.services.translation import get_engine, parse_custom_dict_text
+from backend.services import epub_service
+
+epub_bp = Blueprint("epub", __name__, url_prefix="/api/epub")
+
+
+@epub_bp.route("/translate", methods=["POST"])
+def api_epub_translate():
+ if not is_vip_request():
+ return jsonify({"error": "Chức năng Dịch EPUB nâng cao chỉ dành cho thành viên VIP!"}), 403
+
+ if "file" not in request.files:
+ return jsonify({"error": "Không tìm thấy file EPUB tải lên!"}), 400
+
+ file = request.files["file"]
+ if file.filename == "":
+ return jsonify({"error": "Tên file rỗng!"}), 400
+
+ mode = request.form.get("mode", "fast")
+ limit_chapters = int(request.form.get("limit_chapters", "-1"))
+ clean_styles = request.form.get("clean_styles", "false").lower() == "true"
+ strip_images = request.form.get("strip_images", "false").lower() == "true"
+ strip_fonts = request.form.get("strip_fonts", "false").lower() == "true"
+ custom_dict_text = request.form.get("custom_dict", "").strip()
+ custom_dict = parse_custom_dict_text(custom_dict_text)
+
+ temp_dir = tempfile.gettempdir()
+ src_path = os.path.join(temp_dir, f"src_{uuid.uuid4().hex}.epub")
+ dest_path = os.path.join(temp_dir, f"translated_{uuid.uuid4().hex}.epub")
+
+ try:
+ file.save(src_path)
+ eng = get_engine()
+ duration = epub_service.translate_epub_file(
+ src_path, dest_path, eng, mode=mode,
+ limit_chapters=limit_chapters, custom_dict=custom_dict,
+ clean_styles=clean_styles, strip_images=strip_images, strip_fonts=strip_fonts
+ )
+ print(f"[VIP EPUB] Translated {file.filename} in {duration:.2f}s in mode {mode}")
+
+ base, ext = os.path.splitext(file.filename)
+ output_filename = f"{base}_Dich_{mode}{ext}"
+
+ return send_file(
+ dest_path,
+ as_attachment=True,
+ download_name=output_filename,
+ mimetype="application/epub+zip"
+ )
+ except Exception as e:
+ print(f"[VIP EPUB ERROR] Translate failed: {e}")
+ return jsonify({"error": f"Lỗi trong quá trình dịch EPUB: {str(e)}"}), 500
+ finally:
+ if os.path.exists(src_path):
+ try:
+ os.remove(src_path)
+ except Exception:
+ pass
+
+
+@epub_bp.route("/convert-txt", methods=["POST"])
+def api_epub_convert_txt():
+ if not is_vip_request():
+ return jsonify({"error": "Chức năng chuyển đổi truyện sang EPUB chỉ dành cho thành viên VIP!"}), 403
+
+ title = request.form.get("title", "Truyện convert").strip()
+ author = request.form.get("author", "Vô danh").strip()
+ description = request.form.get("description", "").strip()
+ split_regex = request.form.get("split_regex", r"第\s*\d+\s*[章|回|节]").strip()
+ translate_bool = request.form.get("translate", "false").lower() == "true"
+ mode = request.form.get("mode", "fast")
+ custom_dict_text = request.form.get("custom_dict", "").strip()
+
+ txt_content = ""
+ if "file" in request.files:
+ file = request.files["file"]
+ if file.filename != "":
+ txt_content = file.read().decode("utf-8", errors="ignore")
+ else:
+ txt_content = request.form.get("text", "").strip()
+
+ if not txt_content:
+ return jsonify({"error": "Nội dung văn bản rỗng!"}), 400
+
+ custom_dict = parse_custom_dict_text(custom_dict_text)
+ temp_dir = tempfile.gettempdir()
+ dest_path = os.path.join(temp_dir, f"converted_{uuid.uuid4().hex}.epub")
+
+ try:
+ eng = get_engine() if translate_bool else None
+ epub_service.convert_txt_to_epub(
+ txt_content, title, author, split_regex, dest_path,
+ description=description, engine=eng,
+ mode=mode if translate_bool else None, custom_dict=custom_dict
+ )
+ return send_file(
+ dest_path,
+ as_attachment=True,
+ download_name=f"{title}.epub",
+ mimetype="application/epub+zip"
+ )
+ except Exception as e:
+ print(f"[VIP EPUB ERROR] Convert TXT failed: {e}")
+ return jsonify({"error": f"Lỗi tạo EPUB: {str(e)}"}), 500
+
+
+@epub_bp.route("/optimize", methods=["POST"])
+def api_epub_optimize():
+ if not is_vip_request():
+ return jsonify({"error": "Chức năng Tối ưu hóa EPUB chỉ dành cho thành viên VIP!"}), 403
+
+ if "file" not in request.files:
+ return jsonify({"error": "Không tìm thấy file EPUB tải lên!"}), 400
+
+ file = request.files["file"]
+ if file.filename == "":
+ return jsonify({"error": "Tên file rỗng!"}), 400
+
+ strip_images = request.form.get("strip_images", "false").lower() == "true"
+ strip_fonts = request.form.get("strip_fonts", "false").lower() == "true"
+
+ temp_dir = tempfile.gettempdir()
+ src_path = os.path.join(temp_dir, f"opt_src_{uuid.uuid4().hex}.epub")
+ dest_path = os.path.join(temp_dir, f"opt_dest_{uuid.uuid4().hex}.epub")
+
+ try:
+ file.save(src_path)
+
+ class MockEngine:
+ def translate(self, text, **kwargs):
+ return text
+
+ epub_service.translate_epub_file(
+ src_path, dest_path, MockEngine(), mode=None,
+ limit_chapters=-1, custom_dict=None, clean_styles=True,
+ strip_images=strip_images, strip_fonts=strip_fonts
+ )
+
+ base, ext = os.path.splitext(file.filename)
+ return send_file(
+ dest_path,
+ as_attachment=True,
+ download_name=f"{base}_Optimized{ext}",
+ mimetype="application/epub+zip"
+ )
+ except Exception as e:
+ return jsonify({"error": f"Lỗi tối ưu EPUB: {str(e)}"}), 500
+ finally:
+ if os.path.exists(src_path):
+ try:
+ os.remove(src_path)
+ except Exception:
+ pass
diff --git a/backend/api/monitoring.py b/backend/api/monitoring.py
new file mode 100644
index 0000000000000000000000000000000000000000..d43977d48dee8f754dbe4e63f918eb26aa9e2548
--- /dev/null
+++ b/backend/api/monitoring.py
@@ -0,0 +1,198 @@
+import os
+from flask import Blueprint, jsonify, request
+from backend.core.decorators import get_current_user
+from backend.core.monitoring import collect_all_metrics
+from backend.services.alert_service import check_resources_and_alert
+
+monitoring_bp = Blueprint("monitoring", __name__)
+
+@monitoring_bp.route("/api/admin/metrics", methods=["GET"])
+def get_metrics():
+ """
+ Get detailed system metrics and statistics.
+ Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
+ """
+ # Authorization checks
+ is_admin = False
+
+ # 1. Check for secret X-Admin-Key in request headers
+ admin_key = request.headers.get("X-Admin-Key")
+ expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
+ if admin_key and admin_key == expected_key:
+ is_admin = True
+
+ # 2. Check logged-in user session/token
+ if not is_admin:
+ current_user = get_current_user()
+ if current_user and current_user.get("username") == "admin":
+ is_admin = True
+
+ if not is_admin:
+ return jsonify({"error": "Unauthorized admin access required"}), 403
+
+ try:
+ # Collect system statistics and details
+ metrics = collect_all_metrics()
+
+ # Trigger checks and alerts if RAM or Disk usage is critical
+ check_resources_and_alert()
+
+ return jsonify({
+ "success": True,
+ "metrics": metrics
+ }), 200
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "error": f"Failed to retrieve system metrics: {str(e)}"
+ }), 500
+
+
+@monitoring_bp.route("/api/admin/logs", methods=["GET"])
+def get_logs():
+ """
+ Get or download application logs.
+ Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
+ """
+ is_admin = False
+
+ # 1. Check for secret X-Admin-Key in request headers
+ admin_key = request.headers.get("X-Admin-Key")
+ expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
+ if admin_key and admin_key == expected_key:
+ is_admin = True
+
+ # 2. Check logged-in user session/token
+ if not is_admin:
+ current_user = get_current_user()
+ if current_user and current_user.get("username") == "admin":
+ is_admin = True
+
+ if not is_admin:
+ return jsonify({"error": "Unauthorized admin access required"}), 403
+
+ from backend.config import Config
+ log_path = os.path.join(Config.ROOT_DIR, "logs", "app.log")
+
+ # Handle file download option
+ if request.args.get("download") == "true":
+ if not os.path.exists(log_path):
+ return jsonify({"error": "Log file does not exist"}), 404
+ from flask import send_file
+ return send_file(log_path, as_attachment=True, download_name="app.log")
+
+ # Handle standard retrieval
+ lines_count = request.args.get("lines", default=100, type=int)
+ lines_count = max(1, min(lines_count, 2000))
+
+ if not os.path.exists(log_path):
+ return jsonify({
+ "success": True,
+ "lines_returned": 0,
+ "logs": [],
+ "message": "Log file not found."
+ }), 200
+
+ try:
+ from collections import deque
+ with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
+ last_lines = list(deque(f, maxlen=lines_count))
+
+ return jsonify({
+ "success": True,
+ "lines_returned": len(last_lines),
+ "logs": last_lines
+ }), 200
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "error": f"Failed to read logs: {str(e)}"
+ }), 500
+
+
+@monitoring_bp.route("/api/admin/profiler", methods=["GET"])
+def get_profiler_stats():
+ """
+ Get recent request profiles with latency breakdown (Total, DB, External, CPU).
+ Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
+ """
+ is_admin = False
+ admin_key = request.headers.get("X-Admin-Key")
+ expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
+ if admin_key and admin_key == expected_key:
+ is_admin = True
+
+ if not is_admin:
+ current_user = get_current_user()
+ if current_user and current_user.get("username") == "admin":
+ is_admin = True
+
+ if not is_admin:
+ return jsonify({"error": "Unauthorized admin access required"}), 403
+
+ try:
+ from backend.core.profiler import get_recent_profiles
+ profiles = get_recent_profiles()
+ return jsonify({
+ "success": True,
+ "count": len(profiles),
+ "profiles": profiles
+ }), 200
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "error": f"Failed to retrieve profiler stats: {str(e)}"
+ }), 500
+
+
+@monitoring_bp.route("/api/logs/error", methods=["POST", "OPTIONS"])
+def log_frontend_error():
+ """
+ Receive fatal crash reports from the Frontend ErrorBoundary
+ and instantly send an email alert to the admin.
+ """
+ if request.method == "OPTIONS":
+ return jsonify({}), 200
+
+ try:
+ data = request.json or {}
+ message = data.get("message", "Unknown Error")
+ stack = data.get("stack", "No stack trace provided.")
+ url = data.get("url", "Unknown URL")
+ user_agent = data.get("userAgent", "Unknown Browser")
+ time = data.get("time", "Unknown Time")
+
+ html_content = f"""
+
+
+
🚨 Tiên Hiệp AI - Frontend Crash Alert
+
+
+
Thời gian: {time}
+
URL phát sinh lỗi: {url}
+
Trình duyệt: {user_agent}
+
+
Lỗi (Error Message):
+
+ {message}
+
+
Mã Lỗi (Stack Trace):
+
{stack}
+
+
+ """
+
+ # Gửi email ngầm (không làm chậm server)
+ from backend.services.email_service import send_email_async
+ admin_email = os.environ.get("ADMIN_EMAIL", "havucong@lyvuha.com")
+
+ send_email_async(
+ to_email=admin_email,
+ subject=f"[CRASH ALERT] Lỗi nghiêm trọng tại Tiên Hiệp AI ({url})",
+ html_content=html_content
+ )
+
+ return jsonify({"success": True, "message": "Crash report received and email queued."}), 200
+ except Exception as e:
+ print(f"❌ Failed to process frontend crash log: {e}")
+ return jsonify({"success": False, "error": str(e)}), 500
diff --git a/backend/api/payment.py b/backend/api/payment.py
new file mode 100644
index 0000000000000000000000000000000000000000..c9cded8588f7844d67829f511041583fd2d0a22f
--- /dev/null
+++ b/backend/api/payment.py
@@ -0,0 +1,235 @@
+import os
+import re
+from datetime import datetime
+from flask import Blueprint, request, jsonify, session
+from backend.config import Config
+from backend.core.decorators import get_current_user
+from backend.core.security import verify_access_token
+from backend.core.rate_limit import check_rate_limit, get_client_ip
+from backend.database.db_manager import get_user_db_conn
+from backend.services.payment_service import (
+ create_payment_order, confirm_payment, verify_payos_webhook_signature
+)
+from backend.services.auth_service import check_vip_expiry, activate_vip
+
+payment_bp = Blueprint("payment", __name__, url_prefix="/api/payment")
+
+
+def _get_user_id_from_request():
+ """Helper to get user_id from session or JWT."""
+ user = get_current_user()
+ if user:
+ return user["id"]
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ payload = verify_access_token(auth_header[7:])
+ if payload:
+ return int(payload["sub"])
+ return None
+
+
+def _process_payment_webhook(order_id, amount):
+ """Internal handler called from the webhook to confirm payment & activate VIP/topup."""
+ conn = get_user_db_conn()
+ payment = conn.execute(
+ "SELECT * FROM payments WHERE order_id = ? AND status = 'pending'", (order_id,)
+ ).fetchone()
+
+ if not payment:
+ conn.close()
+ return jsonify({"error": "Order not found or already processed"}), 404
+
+ if amount > 0 and abs(amount - payment["amount"]) > 100:
+ conn.close()
+ return jsonify({"error": "Amount mismatch"}), 400
+
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+ conn.execute(
+ "UPDATE payments SET status = 'completed', completed_at = ? WHERE id = ?",
+ (now, payment["id"])
+ )
+ conn.commit()
+ conn.close()
+
+ if payment["plan"].startswith("topup_"):
+ conn = get_user_db_conn()
+ conn.execute(
+ "UPDATE users SET api_balance = COALESCE(api_balance, 0.0) + ? WHERE id = ?",
+ (payment["amount"], payment["user_id"])
+ )
+ conn.commit()
+ conn.close()
+ return jsonify({"success": True, "message": f"Nạp thành công {payment['amount']:,}đ vào số dư API!".replace(",", ".")})
+ else:
+ activate_vip(payment["user_id"], payment["plan"])
+ if session.get("user_id") == payment["user_id"]:
+ session["vip_status"] = 1
+ print(f"[PAYMENT] ✅ Order {order_id} completed — User #{payment['user_id']} → VIP {payment['plan']}")
+ return jsonify({"success": True, "message": "Payment confirmed, VIP activated!"})
+
+
+@payment_bp.route("/plans", methods=["GET"])
+def api_payment_plans():
+ lang = request.args.get("lang", "vi")
+ plans = []
+ for key, plan in Config.VIP_PLANS.items():
+ plans.append({
+ "id": key,
+ "name": plan.get(f"name_{lang}", plan["name_vi"]),
+ "description": plan.get(f"description_{lang}", plan["description_vi"]),
+ "price": plan["price"],
+ "price_formatted": f"{plan['price']:,}đ".replace(",", "."),
+ "duration_days": plan["duration_days"],
+ })
+ return jsonify({"plans": plans})
+
+
+@payment_bp.route("/create", methods=["POST"])
+def api_payment_create():
+ user_id = _get_user_id_from_request()
+ if not user_id:
+ return jsonify({"error": "Vui lòng đăng nhập để mua VIP."}), 401
+
+ ip = get_client_ip()
+ if check_rate_limit("payment", ip):
+ return jsonify({"error": "Bạn đã tạo quá nhiều đơn hàng. Vui lòng thử lại sau."}), 429
+
+ data = request.json or {}
+ plan = data.get("plan", "month")
+
+ result, status_code = create_payment_order(user_id, plan)
+ return jsonify(result), status_code
+
+
+@payment_bp.route("/status/", methods=["GET"])
+def api_payment_status(order_id):
+ user_id = _get_user_id_from_request()
+
+ conn = get_user_db_conn()
+ if user_id:
+ payment = conn.execute(
+ "SELECT * FROM payments WHERE order_id = ? AND user_id = ?", (order_id, user_id)
+ ).fetchone()
+ else:
+ payment = conn.execute(
+ "SELECT * FROM payments WHERE order_id = ?", (order_id,)
+ ).fetchone()
+ conn.close()
+
+ if not payment:
+ return jsonify({"error": "Payment order not found."}), 404
+
+ return jsonify({
+ "order_id": payment["order_id"],
+ "plan": payment["plan"],
+ "amount": payment["amount"],
+ "status": payment["status"],
+ "created_at": payment["created_at"],
+ "completed_at": payment["completed_at"]
+ })
+
+
+@payment_bp.route("/webhook", methods=["POST"])
+def api_payment_webhook():
+ # Verify PAYMENT_WEBHOOK_KEY if set in environment
+ webhook_key = os.environ.get("PAYMENT_WEBHOOK_KEY", "")
+ if webhook_key:
+ auth_header = request.headers.get("Authorization", "")
+ provided_key = auth_header.replace("Bearer ", "").replace("Apikey ", "").strip()
+ query_key = request.args.get("webhook_key", "")
+ if provided_key != webhook_key and query_key != webhook_key:
+ return jsonify({"error": "Unauthorized webhook request"}), 401
+
+ data = request.json or {}
+
+ # PayOS format
+ if "data" in data and "orderCode" in data.get("data", {}):
+ order_code = str(data["data"]["orderCode"])
+ amount = data["data"].get("amount", 0)
+ checksum_key = Config.PAYMENT_CONFIG.get("payos_checksum_key", "")
+ if checksum_key and "signature" in data:
+ if not verify_payos_webhook_signature(data["data"], data.get("signature", ""), checksum_key):
+ return jsonify({"error": "Invalid signature"}), 403
+ return _process_payment_webhook(order_code, amount)
+
+ # SePay / Generic format
+ if "transferAmount" in data:
+ content = data.get("content", "")
+ amount = data.get("transferAmount", 0)
+ match = re.search(r"(VIP\w+)", content.upper())
+ if match:
+ return _process_payment_webhook(match.group(1), amount)
+
+ return jsonify({"error": "Invalid webhook payload"}), 400
+
+
+@payment_bp.route("/confirm-manual", methods=["POST"])
+def api_payment_confirm_manual():
+ data = request.json or {}
+ admin_key = data.get("admin_key", "")
+ order_id = data.get("order_id", "")
+
+ ADMIN_KEY = os.environ.get("ADMIN_PAYMENT_KEY")
+ if not ADMIN_KEY:
+ ADMIN_KEY = "SECURE_SECRET_RANDOM_KEY_9988_ADMIN"
+
+ is_admin = False
+ if admin_key and admin_key == ADMIN_KEY:
+ is_admin = True
+
+ if not is_admin:
+ return jsonify({"error": "Unauthorized admin access required"}), 403
+
+ if not order_id:
+ return jsonify({"error": "order_id is required"}), 400
+ return _process_payment_webhook(order_id, 0)
+
+
+@payment_bp.route("/history", methods=["GET"])
+def api_payment_history():
+ user_id = _get_user_id_from_request()
+ if not user_id:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ conn = get_user_db_conn()
+ payments = conn.execute(
+ "SELECT order_id, plan, amount, status, created_at, completed_at FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT 20",
+ (user_id,)
+ ).fetchall()
+ conn.close()
+ return jsonify({"payments": [dict(p) for p in payments]})
+
+
+@payment_bp.route("/vip-status", methods=["GET"])
+def api_user_vip_status():
+ user_id = _get_user_id_from_request()
+ if not user_id:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ check_vip_expiry(user_id)
+ conn = get_user_db_conn()
+ user = conn.execute("SELECT vip_status, vip_plan, vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
+ conn.close()
+
+ if not user:
+ return jsonify({"error": "User not found"}), 404
+
+ days_remaining = 0
+ if user["vip_expiry"]:
+ try:
+ expiry_val = user["vip_expiry"]
+ if isinstance(expiry_val, str):
+ expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
+ else:
+ expiry = expiry_val
+ days_remaining = max(0, (expiry - datetime.utcnow()).days)
+ except Exception:
+ pass
+
+ return jsonify({
+ "vip_status": user["vip_status"],
+ "vip_plan": user["vip_plan"],
+ "vip_expiry": user["vip_expiry"],
+ "days_remaining": days_remaining,
+ "is_active": user["vip_status"] == 1
+ })
diff --git a/backend/api/sects.py b/backend/api/sects.py
new file mode 100644
index 0000000000000000000000000000000000000000..a05eb23b45c823970974e8acceb53a11561fcbfa
--- /dev/null
+++ b/backend/api/sects.py
@@ -0,0 +1,1248 @@
+from flask import Blueprint, request, jsonify
+from backend.core.decorators import jwt_required, get_current_user
+from backend.database.db_manager import get_user_db_conn
+from backend.core.logger import logger
+from backend.core.security import encrypt_message, decrypt_message
+from datetime import datetime
+
+sects_bp = Blueprint("sects", __name__)
+
+def serialize_row(row):
+ if not row:
+ return {}
+ d = dict(row)
+ for k, v in d.items():
+ if hasattr(v, "isoformat"):
+ d[k] = v.isoformat()
+ return d
+
+@sects_bp.route("/api/sects/list", methods=["GET"])
+def list_sects():
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute("""
+ SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
+ u.username as leader_name,
+ (SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
+ FROM sects s
+ LEFT JOIN users u ON s.leader_id = u.id
+ ORDER BY s.contribution DESC, s.created_at DESC
+ """).fetchall()
+
+ user = get_current_user()
+ user_requests = []
+ if user:
+ req_rows = conn.execute("SELECT sect_id FROM sect_join_requests WHERE user_id = ? AND status = 'pending'", (user["id"],)).fetchall()
+ user_requests = [r["sect_id"] for r in req_rows]
+
+ return jsonify({
+ "sects": [serialize_row(r) for r in rows],
+ "pending_requests": user_requests
+ })
+ except Exception as e:
+ logger.error(f"Error listing sects: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/create", methods=["POST"])
+@jwt_required
+def create_sect():
+ user = get_current_user()
+ data = request.json or {}
+ name = data.get("name", "").strip()
+ slogan = data.get("slogan", "").strip()
+ badge = data.get("badge", "purple").strip()
+
+ if not name:
+ return jsonify({"error": "Tên tông môn không được để trống"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ # Check if user is already in a sect
+ in_sect = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if in_sect:
+ 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
+
+ # Check if name exists
+ exists = conn.execute("SELECT id FROM sects WHERE name = ?", (name,)).fetchone()
+ if exists:
+ return jsonify({"error": "Tên tông môn này đã được lập từ trước"}), 400
+
+ cursor = conn.execute(
+ "INSERT INTO sects (name, slogan, badge, leader_id, announcement) VALUES (?, ?, ?, ?, ?)",
+ (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!")
+ )
+ sect_id = cursor.lastrowid
+
+ conn.execute(
+ "INSERT INTO sect_members (sect_id, user_id, role, contribution) VALUES (?, ?, 'leader', 100)",
+ (sect_id, user["id"])
+ )
+ conn.execute(
+ "UPDATE sects SET contribution = 100 WHERE id = ?",
+ (sect_id,)
+ )
+ # Delete any pending join requests of this user elsewhere
+ conn.execute("DELETE FROM sect_join_requests WHERE user_id = ?", (user["id"],))
+
+ conn.commit()
+ return jsonify({"success": True, "message": f"Sáng lập {name} tông thành công!", "sect_id": sect_id})
+ except Exception as e:
+ logger.error(f"Error creating sect: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/join", methods=["POST"])
+@jwt_required
+def join_sect():
+ user = get_current_user()
+ data = request.json or {}
+ sect_id = data.get("sect_id")
+
+ if not sect_id:
+ return jsonify({"error": "Thiếu sect_id"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ # Check if user is already in a sect
+ in_sect = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if in_sect:
+ return jsonify({"error": "Bạn đã ở trong một tông môn khác"}), 400
+
+ # Check if sect exists
+ sect = conn.execute("SELECT name FROM sects WHERE id = ?", (sect_id,)).fetchone()
+ if not sect:
+ return jsonify({"error": "Tông môn không tồn tại"}), 404
+
+ # Check if already requested
+ req_exists = conn.execute("SELECT id FROM sect_join_requests WHERE sect_id = ? AND user_id = ?", (sect_id, user["id"])).fetchone()
+ if req_exists:
+ return jsonify({"error": "Bạn đã gửi yêu cầu gia nhập tông này và đang chờ duyệt"}), 400
+
+ conn.execute(
+ "INSERT INTO sect_join_requests (sect_id, user_id, status) VALUES (?, ?, 'pending')",
+ (sect_id, user["id"])
+ )
+ conn.commit()
+ 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."})
+ except Exception as e:
+ logger.error(f"Error joining sect: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/leave", methods=["POST"])
+@jwt_required
+def leave_sect():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn nào"}), 400
+
+ sect_id = member["sect_id"]
+ if member["role"] == "leader":
+ # Leader leaving dissolves the sect
+ conn.execute("DELETE FROM sect_chat_groups WHERE sect_id = ?", (sect_id,))
+ conn.execute("DELETE FROM sect_messages WHERE sect_id = ?", (sect_id,))
+ conn.execute("DELETE FROM sect_members WHERE sect_id = ?", (sect_id,))
+ conn.execute("DELETE FROM sect_join_requests WHERE sect_id = ?", (sect_id,))
+ conn.execute("DELETE FROM sect_books WHERE sect_id = ?", (sect_id,))
+ conn.execute("DELETE FROM sects WHERE id = ?", (sect_id,))
+ 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!"
+ else:
+ conn.execute("DELETE FROM sect_members WHERE user_id = ? AND sect_id = ?", (user["id"], sect_id))
+ msg = "Bạn đã rời khỏi tông môn thành công!"
+
+ conn.commit()
+ return jsonify({"success": True, "message": msg})
+ except Exception as e:
+ logger.error(f"Error leaving sect: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/my-sect", methods=["GET"])
+@jwt_required
+def my_sect():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id, role, contribution as my_contrib FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"in_sect": False})
+
+ sect_id = member["sect_id"]
+ sect = conn.execute("""
+ SELECT s.id, s.name, s.slogan, s.announcement, s.badge, s.level, s.contribution, s.created_at,
+ u.username as leader_name, u.id as leader_id
+ FROM sects s
+ LEFT JOIN users u ON s.leader_id = u.id
+ WHERE s.id = ?
+ """, (sect_id,)).fetchone()
+
+ members = conn.execute("""
+ SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at, u.username, u.avatar
+ FROM sect_members sm
+ LEFT JOIN users u ON sm.user_id = u.id
+ WHERE sm.sect_id = ?
+ ORDER BY
+ CASE sm.role
+ WHEN 'leader' THEN 1
+ WHEN 'elder' THEN 2
+ ELSE 3
+ END,
+ sm.contribution DESC
+ """, (sect_id,)).fetchall()
+
+ return jsonify({
+ "in_sect": True,
+ "role": member["role"],
+ "my_contribution": member["my_contrib"],
+ "sect": serialize_row(sect),
+ "members": [serialize_row(m) for m in members]
+ })
+ except Exception as e:
+ logger.error(f"Error getting my sect: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/chat/history", methods=["GET"])
+@jwt_required
+def get_sect_chat_history():
+ user = get_current_user()
+ chat_type = request.args.get("chat_type", "general").strip()
+ target_id = request.args.get("target_id")
+ group_id = request.args.get("group_id")
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = member["sect_id"]
+
+ if chat_type == "direct":
+ if not target_id:
+ return jsonify({"error": "Thiếu target_id cho chat riêng"}), 400
+ rows = conn.execute("""
+ SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.target_id,
+ u.username as sender_name, u.avatar as sender_avatar
+ FROM sect_messages sm
+ LEFT JOIN users u ON sm.sender_id = u.id
+ WHERE sm.sect_id = ? AND sm.chat_type = 'direct'
+ AND ((sm.sender_id = ? AND sm.target_id = ?) OR (sm.sender_id = ? AND sm.target_id = ?))
+ ORDER BY sm.created_at ASC LIMIT 100
+ """, (sect_id, user["id"], target_id, target_id, user["id"])).fetchall()
+
+ elif chat_type == "book":
+ if not target_id:
+ return jsonify({"error": "Thiếu target_id cho chat truyện"}), 400
+ rows = conn.execute("""
+ SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.target_id,
+ u.username as sender_name, u.avatar as sender_avatar
+ FROM sect_messages sm
+ LEFT JOIN users u ON sm.sender_id = u.id
+ WHERE sm.sect_id = ? AND sm.chat_type = 'book' AND sm.target_id = ?
+ ORDER BY sm.created_at ASC LIMIT 100
+ """, (sect_id, target_id)).fetchall()
+
+ elif chat_type == "group":
+ if not group_id:
+ return jsonify({"error": "Thiếu group_id cho chat nhóm"}), 400
+
+ # Check group access
+ group = conn.execute("SELECT members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?", (group_id, sect_id)).fetchone()
+ if not group:
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
+
+ members_list = [int(x) for x in group["members_csv"].split(",") if x.strip()]
+ if user["id"] not in members_list:
+ return jsonify({"error": "Bạn không phải thành viên của nhóm chat này"}), 403
+
+ rows = conn.execute("""
+ SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.group_id,
+ u.username as sender_name, u.avatar as sender_avatar
+ FROM sect_messages sm
+ LEFT JOIN users u ON sm.sender_id = u.id
+ WHERE sm.sect_id = ? AND sm.chat_type = 'group' AND sm.group_id = ?
+ ORDER BY sm.created_at ASC LIMIT 100
+ """, (sect_id, group_id)).fetchall()
+
+ else: # general chat
+ rows = conn.execute("""
+ SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type,
+ u.username as sender_name, u.avatar as sender_avatar
+ FROM sect_messages sm
+ LEFT JOIN users u ON sm.sender_id = u.id
+ WHERE sm.sect_id = ? AND (sm.chat_type IS NULL OR sm.chat_type = 'general')
+ ORDER BY sm.created_at ASC LIMIT 100
+ """, (sect_id,)).fetchall()
+
+ messages = []
+ for r in rows:
+ d = dict(r)
+ d["message"] = decrypt_message(d.get("message", ""))
+ for k, v in d.items():
+ if hasattr(v, "isoformat"):
+ d[k] = v.isoformat()
+ messages.append(d)
+
+ return jsonify({"messages": messages})
+ except Exception as e:
+ logger.error(f"Error getting sect chat: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/chat/send", methods=["POST"])
+@jwt_required
+def send_sect_chat():
+ user = get_current_user()
+ data = request.json or {}
+ message = data.get("message", "").strip()
+ chat_type = data.get("chat_type", "general").strip()
+ target_id = data.get("target_id")
+ group_id = data.get("group_id")
+
+ if not message:
+ return jsonify({"error": "Nội dung tin nhắn trống"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = member["sect_id"]
+
+ # Validate target / group access
+ if chat_type == "group" and group_id:
+ group = conn.execute("SELECT members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?", (group_id, sect_id)).fetchone()
+ if not group:
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
+ members_list = [int(x) for x in group["members_csv"].split(",") if x.strip()]
+ if user["id"] not in members_list:
+ return jsonify({"error": "Bạn không có quyền chat trong nhóm này"}), 403
+
+ enc_msg = encrypt_message(message)
+ cursor = conn.execute(
+ """INSERT INTO sect_messages (sect_id, sender_id, message, chat_type, target_id, group_id)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (sect_id, user["id"], enc_msg, chat_type, target_id, group_id)
+ )
+ msg_id = cursor.lastrowid
+ conn.commit()
+ return jsonify({"success": True, "msg_id": msg_id})
+ except Exception as e:
+ logger.error(f"Error sending sect chat: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/chat/groups/create", methods=["POST"])
+@jwt_required
+def create_sect_chat_group():
+ user = get_current_user()
+ data = request.json or {}
+ name = data.get("name", "").strip()
+ member_ids = data.get("members", [])
+
+ if not name:
+ return jsonify({"error": "Tên nhóm chat không được để trống"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = member["sect_id"]
+
+ if user["id"] not in member_ids:
+ member_ids.append(user["id"])
+
+ valid_members = []
+ for uid in member_ids:
+ is_valid = conn.execute("SELECT 1 FROM sect_members WHERE user_id = ? AND sect_id = ?", (uid, sect_id)).fetchone()
+ if is_valid:
+ valid_members.append(str(uid))
+
+ members_csv = ",".join(valid_members)
+
+ cursor = conn.execute(
+ "INSERT INTO sect_chat_groups (sect_id, name, creator_id, members_csv) VALUES (?, ?, ?, ?)",
+ (sect_id, name, user["id"], members_csv)
+ )
+ group_id = cursor.lastrowid
+ conn.commit()
+
+ return jsonify({"success": True, "message": f"Tạo nhóm chat '{name}' thành công!", "group_id": group_id})
+ except Exception as e:
+ logger.error(f"Error creating sect chat group: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/chat/groups/list", methods=["GET"])
+@jwt_required
+def list_sect_chat_groups():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = member["sect_id"]
+ groups = conn.execute("SELECT * FROM sect_chat_groups WHERE sect_id = ?", (sect_id,)).fetchall()
+
+ my_groups = []
+ for g in groups:
+ members_list = [int(x) for x in g["members_csv"].split(",") if x.strip()]
+ if user["id"] in members_list:
+ my_groups.append(serialize_row(g))
+
+ return jsonify({"groups": my_groups})
+ except Exception as e:
+ logger.error(f"Error listing sect groups: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/contribute", methods=["POST"])
+@jwt_required
+def contribute_sect():
+ user = get_current_user()
+ data = request.json or {}
+ amount = int(data.get("amount", 0))
+
+ if amount <= 0:
+ return jsonify({"error": "Số lượng linh thạch cống hiến phải lớn hơn 0"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn không ở trong tông môn nào"}), 400
+
+ sect_id = member["sect_id"]
+
+ # Increase user contribution
+ conn.execute(
+ "UPDATE sect_members SET contribution = contribution + ? WHERE user_id = ? AND sect_id = ?",
+ (amount, user["id"], sect_id)
+ )
+
+ # Increase sect total contribution
+ conn.execute(
+ "UPDATE sects SET contribution = contribution + ? WHERE id = ?",
+ (amount, sect_id)
+ )
+
+ # Calculate new level (1000 contribution per level)
+ sect = conn.execute("SELECT contribution FROM sects WHERE id = ?", (sect_id,)).fetchone()
+ new_level = max(1, int(sect["contribution"] / 1000) + 1)
+ conn.execute("UPDATE sects SET level = ? WHERE id = ?", (new_level, sect_id))
+
+ conn.commit()
+ return jsonify({
+ "success": True,
+ "message": f"Cống hiến thành công {amount} linh thạch!",
+ "new_level": new_level
+ })
+ except Exception as e:
+ logger.error(f"Error contributing to sect: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/kick", methods=["POST"])
+@jwt_required
+def kick_member():
+ user = get_current_user()
+ data = request.json or {}
+ target_user_id = data.get("user_id")
+
+ if not target_user_id:
+ return jsonify({"error": "Thiếu user_id thành viên cần trục xuất"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ # Check if user is leader/elder
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member or member["role"] not in ['leader', 'elder']:
+ return jsonify({"error": "Bạn không đủ quyền hạn (Chỉ Tông chủ hoặc Trưởng lão)"}), 403
+
+ sect_id = member["sect_id"]
+
+ # Check target
+ target = conn.execute("SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id)).fetchone()
+ if not target:
+ return jsonify({"error": "Thành viên này không thuộc tông môn của bạn"}), 400
+
+ # Elder cannot kick leader or another elder
+ if member["role"] == 'elder' and target["role"] in ['leader', 'elder']:
+ 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
+
+ if target["role"] == 'leader':
+ return jsonify({"error": "Không thể trục xuất Tông chủ"}), 400
+
+ conn.execute("DELETE FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id))
+ conn.commit()
+ return jsonify({"success": True, "message": "Đã trục xuất thành viên ra khỏi tông môn!"})
+ except Exception as e:
+ logger.error(f"Error kicking member: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+# ==========================================
+# ADVANCED SECTS & ALLIANCES FEATURES
+# ==========================================
+
+@sects_bp.route("/api/sects/announcement", methods=["POST"])
+@jwt_required
+def update_announcement():
+ user = get_current_user()
+ data = request.json or {}
+ announcement = data.get("announcement", "").strip()
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member or member["role"] not in ['leader', 'elder']:
+ return jsonify({"error": "Chỉ Tông chủ hoặc Trưởng lão mới có quyền đổi thông cáo"}), 403
+
+ conn.execute("UPDATE sects SET announcement = ? WHERE id = ?", (announcement, member["sect_id"]))
+ conn.commit()
+ return jsonify({"success": True, "message": "Cập nhật thông cáo tông môn thành công!"})
+ except Exception as e:
+ logger.error(f"Error updating announcement: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/promote", methods=["POST"])
+@jwt_required
+def promote_member():
+ user = get_current_user()
+ data = request.json or {}
+ target_user_id = data.get("user_id")
+ new_role = data.get("role", "member").lower() # 'elder' or 'member'
+
+ if new_role not in ['elder', 'member']:
+ return jsonify({"error": "Chức vị không hợp lệ"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member or member["role"] != 'leader':
+ return jsonify({"error": "Chỉ có Tông chủ mới được thăng/giáng chức đệ tử"}), 403
+
+ sect_id = member["sect_id"]
+
+ target = conn.execute("SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id)).fetchone()
+ if not target:
+ return jsonify({"error": "Thành viên này không thuộc tông môn của bạn"}), 400
+
+ if target["role"] == 'leader':
+ return jsonify({"error": "Không thể thay đổi chức vị của Tông chủ"}), 400
+
+ conn.execute("UPDATE sect_members SET role = ? WHERE user_id = ? AND sect_id = ?", (new_role, target_user_id, sect_id))
+ conn.commit()
+
+ role_name = "Trưởng Lão" if new_role == 'elder' else "Môn đồ"
+ return jsonify({"success": True, "message": f"Đã thăng/giáng chức thành viên thành {role_name}!"})
+ except Exception as e:
+ logger.error(f"Error promoting member: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/requests/list", methods=["GET"])
+@jwt_required
+def list_join_requests():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member or member["role"] not in ['leader', 'elder']:
+ 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
+
+ rows = conn.execute("""
+ SELECT r.id, r.user_id, r.status, r.created_at, u.username, u.avatar
+ FROM sect_join_requests r
+ LEFT JOIN users u ON r.user_id = u.id
+ WHERE r.sect_id = ? AND r.status = 'pending'
+ ORDER BY r.created_at DESC
+ """, (member["sect_id"],)).fetchall()
+
+ return jsonify({"requests": [serialize_row(r) for r in rows]})
+ except Exception as e:
+ logger.error(f"Error listing requests: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/requests/respond", methods=["POST"])
+@jwt_required
+def respond_join_request():
+ user = get_current_user()
+ data = request.json or {}
+ req_id = data.get("request_id")
+ action = data.get("action", "").lower() # 'approve' or 'reject'
+
+ if not req_id or action not in ['approve', 'reject']:
+ return jsonify({"error": "Dữ liệu phản hồi không hợp lệ"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member or member["role"] not in ['leader', 'elder']:
+ return jsonify({"error": "Chỉ Tông chủ hoặc Trưởng lão mới được phê duyệt"}), 403
+
+ sect_id = member["sect_id"]
+
+ req = conn.execute("SELECT user_id FROM sect_join_requests WHERE id = ? AND sect_id = ?", (req_id, sect_id)).fetchone()
+ if not req:
+ return jsonify({"error": "Yêu cầu gia nhập không tồn tại hoặc đã được xử lý"}), 404
+
+ target_user_id = req["user_id"]
+
+ if action == 'approve':
+ # Check if target is already in a sect
+ already_in = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (target_user_id,)).fetchone()
+ if already_in:
+ conn.execute("DELETE FROM sect_join_requests WHERE id = ?", (req_id,))
+ conn.commit()
+ 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
+
+ # Add member
+ conn.execute(
+ "INSERT INTO sect_members (sect_id, user_id, role, contribution) VALUES (?, ?, 'member', 0)",
+ (sect_id, target_user_id)
+ )
+ # Delete target requests from all other sects
+ conn.execute("DELETE FROM sect_join_requests WHERE user_id = ?", (target_user_id,))
+ msg = "Đã phê duyệt gia nhập tông môn!"
+ else:
+ # Reject
+ conn.execute("DELETE FROM sect_join_requests WHERE id = ?", (req_id,))
+ msg = "Đã từ chối yêu cầu gia nhập!"
+
+ conn.commit()
+ return jsonify({"success": True, "message": msg})
+ except Exception as e:
+ logger.error(f"Error responding join request: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+# ==========================================
+# SECT LIBRARY (BOOK SHELF SHARING)
+# ==========================================
+
+@sects_bp.route("/api/sects/library/list", methods=["GET"])
+@jwt_required
+def list_sect_library():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = member["sect_id"]
+
+ # We need to join with the main books database to fetch book title and cover
+ # Since SQLite can ATTACH databases, or we can fetch metadata, we'll fetch from sect_books
+ # and query titles from books database. Let's do it cleanly:
+ rows = conn.execute("""
+ SELECT sb.id, sb.book_id, sb.added_at, u.username as added_by_name
+ FROM sect_books sb
+ LEFT JOIN users u ON sb.added_by = u.id
+ WHERE sb.sect_id = ?
+ ORDER BY sb.added_at DESC
+ """, (sect_id,)).fetchall()
+
+ from backend.database.db_manager import get_db
+ main_conn = get_db()
+
+ book_list = []
+ for r in rows:
+ book_row = main_conn.execute("""
+ SELECT id, title, title_vietphrase, title_hanviet, author, cover, description
+ FROM books WHERE id = ?
+ """, (r["book_id"],)).fetchone()
+ if book_row:
+ b = serialize_row(book_row)
+ b["added_by_name"] = r["added_by_name"]
+ b["added_at"] = r["added_at"].isoformat() if hasattr(r["added_at"], "isoformat") else r["added_at"]
+ book_list.append(b)
+
+ return jsonify({"books": book_list})
+ except Exception as e:
+ logger.error(f"Error listing sect library: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/library/add", methods=["POST"])
+@jwt_required
+def add_to_sect_library():
+ user = get_current_user()
+ data = request.json or {}
+ book_id = data.get("book_id")
+
+ if not book_id:
+ return jsonify({"error": "Thiếu book_id truyện đóng góp"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn không thuộc tông môn nào"}), 400
+
+ sect_id = member["sect_id"]
+
+ # Check if already added
+ exists = conn.execute("SELECT id FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id)).fetchone()
+ if exists:
+ 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
+
+ conn.execute(
+ "INSERT INTO sect_books (sect_id, book_id, added_by) VALUES (?, ?, ?)",
+ (sect_id, book_id, user["id"])
+ )
+
+ # Reward user 20 contribution points for sharing a book
+ conn.execute(
+ "UPDATE sect_members SET contribution = contribution + 20 WHERE user_id = ? AND sect_id = ?",
+ (user["id"], sect_id)
+ )
+ # Increase sect total contribution
+ conn.execute(
+ "UPDATE sects SET contribution = contribution + 20 WHERE id = ?",
+ (sect_id,)
+ )
+
+ conn.commit()
+ 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."})
+ except Exception as e:
+ logger.error(f"Error adding to sect library: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+@sects_bp.route("/api/sects/library/remove", methods=["POST"])
+@jwt_required
+def remove_from_sect_library():
+ user = get_current_user()
+ data = request.json or {}
+ book_id = data.get("book_id")
+
+ if not book_id:
+ return jsonify({"error": "Thiếu book_id truyện cần gỡ"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn không thuộc tông môn nào"}), 400
+
+ sect_id = member["sect_id"]
+
+ # Check who added it
+ book = conn.execute("SELECT added_by FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id)).fetchone()
+ if not book:
+ return jsonify({"error": "Truyện không có trong thư viện tông môn"}), 404
+
+ # Only leader, elder, or the original adder can remove
+ if member["role"] not in ['leader', 'elder'] and book["added_by"] != user["id"]:
+ 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
+
+ conn.execute("DELETE FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id))
+ conn.commit()
+ return jsonify({"success": True, "message": "Đã gỡ truyện khỏi thư viện Tông môn!"})
+ except Exception as e:
+ logger.error(f"Error removing from sect library: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+# ==========================================
+# SECT SEARCH & PROFILE BY ID
+# ==========================================
+
+ROLE_ORDER = {
+ "leader": 1,
+ "vice_leader": 2,
+ "elder": 3,
+ "inner_disciple": 4,
+ "member": 5,
+}
+
+ROLE_LABELS = {
+ "leader": "Tông chủ",
+ "vice_leader": "Phó Tông chủ",
+ "elder": "Trưởng lão",
+ "inner_disciple": "Nội môn đệ tử",
+ "member": "Ngoại môn đệ tử",
+}
+
+VALID_ROLES = list(ROLE_ORDER.keys())
+
+
+@sects_bp.route("/api/sects/search", methods=["GET"])
+def search_sects():
+ """Search sects by name or slogan. No auth required."""
+ q = request.args.get("q", "").strip()
+ page = int(request.args.get("page", 1))
+ per_page = int(request.args.get("per_page", 20))
+ offset = (page - 1) * per_page
+
+ conn = get_user_db_conn()
+ try:
+ if q:
+ rows = conn.execute("""
+ SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
+ u.username as leader_name,
+ (SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
+ FROM sects s
+ LEFT JOIN users u ON s.leader_id = u.id
+ WHERE s.name LIKE ? OR s.slogan LIKE ?
+ ORDER BY s.contribution DESC, s.created_at DESC
+ LIMIT ? OFFSET ?
+ """, (f"%{q}%", f"%{q}%", per_page, offset)).fetchall()
+
+ total = conn.execute(
+ "SELECT COUNT(*) as cnt FROM sects WHERE name LIKE ? OR slogan LIKE ?",
+ (f"%{q}%", f"%{q}%")
+ ).fetchone()["cnt"]
+ else:
+ rows = conn.execute("""
+ SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
+ u.username as leader_name,
+ (SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
+ FROM sects s
+ LEFT JOIN users u ON s.leader_id = u.id
+ ORDER BY s.contribution DESC, s.created_at DESC
+ LIMIT ? OFFSET ?
+ """, (per_page, offset)).fetchall()
+ total = conn.execute("SELECT COUNT(*) as cnt FROM sects").fetchone()["cnt"]
+
+ return jsonify({
+ "sects": [serialize_row(r) for r in rows],
+ "total": total,
+ "page": page,
+ "per_page": per_page,
+ "total_pages": max(1, (total + per_page - 1) // per_page)
+ })
+ except Exception as e:
+ logger.error(f"Error searching sects: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@sects_bp.route("/api/sects/", methods=["GET"])
+def get_sect_by_id(sect_id):
+ """View full sect profile by ID. No auth required for basic info."""
+ conn = get_user_db_conn()
+ try:
+ sect = conn.execute("""
+ SELECT s.id, s.name, s.slogan, s.announcement, s.badge, s.level, s.contribution, s.created_at,
+ u.username as leader_name, u.id as leader_id, u.avatar as leader_avatar
+ FROM sects s
+ LEFT JOIN users u ON s.leader_id = u.id
+ WHERE s.id = ?
+ """, (sect_id,)).fetchone()
+
+ if not sect:
+ return jsonify({"error": "Tông môn không tồn tại"}), 404
+
+ members = conn.execute("""
+ SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
+ u.username, u.avatar,
+ COALESCE(sm.role, 'member') as role
+ FROM sect_members sm
+ LEFT JOIN users u ON sm.user_id = u.id
+ WHERE sm.sect_id = ?
+ ORDER BY
+ CASE sm.role
+ WHEN 'leader' THEN 1
+ WHEN 'vice_leader' THEN 2
+ WHEN 'elder' THEN 3
+ WHEN 'inner_disciple' THEN 4
+ ELSE 5
+ END,
+ sm.contribution DESC
+ """, (sect_id,)).fetchall()
+
+ # Enrich members with Vietnamese role labels
+ member_list = []
+ for m in members:
+ md = serialize_row(m)
+ md["role_label"] = ROLE_LABELS.get(m["role"], "Môn đồ")
+ member_list.append(md)
+
+ # Count by role
+ role_counts = {}
+ for m in member_list:
+ r = m["role"]
+ role_counts[r] = role_counts.get(r, 0) + 1
+
+ return jsonify({
+ "sect": serialize_row(sect),
+ "members": member_list,
+ "member_count": len(member_list),
+ "role_counts": role_counts,
+ "role_labels": ROLE_LABELS,
+ })
+ except Exception as e:
+ logger.error(f"Error fetching sect {sect_id}: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+# ==========================================
+# FULL RANK PROMOTION SYSTEM (5 Ranks)
+# ==========================================
+
+@sects_bp.route("/api/sects/promote/rank", methods=["POST"])
+@jwt_required
+def promote_member_rank():
+ """
+ Full 5-rank promotion system:
+ leader > vice_leader > elder > inner_disciple > member
+ Only leader can set: vice_leader, elder, inner_disciple, member
+ vice_leader can set: elder, inner_disciple, member
+ elder can set: inner_disciple, member
+ """
+ user = get_current_user()
+ data = request.json or {}
+ target_user_id = data.get("user_id")
+ new_role = data.get("role", "").lower()
+
+ if new_role not in VALID_ROLES or new_role == "leader":
+ return jsonify({
+ "error": f"Chức vị không hợp lệ. Các chức vị có thể gán: {', '.join(VALID_ROLES[1:])}",
+ "valid_roles": VALID_ROLES[1:],
+ "role_labels": ROLE_LABELS,
+ }), 400
+
+ if not target_user_id:
+ return jsonify({"error": "Thiếu user_id"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ caller = conn.execute(
+ "SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
+ ).fetchone()
+ if not caller:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = caller["sect_id"]
+ caller_role = caller["role"]
+ caller_order = ROLE_ORDER.get(caller_role, 99)
+ new_role_order = ROLE_ORDER.get(new_role, 99)
+
+ # Permission matrix: caller must outrank new_role
+ if caller_order >= new_role_order:
+ return jsonify({
+ "error": f"Bạn ({ROLE_LABELS.get(caller_role)}) không đủ quyền gán chức vị {ROLE_LABELS.get(new_role)}"
+ }), 403
+
+ target = conn.execute(
+ "SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?",
+ (target_user_id, sect_id)
+ ).fetchone()
+ if not target:
+ return jsonify({"error": "Thành viên không thuộc tông môn của bạn"}), 404
+
+ if target["role"] == "leader":
+ return jsonify({"error": "Không thể thay đổi chức vị của Tông chủ"}), 400
+
+ target_order = ROLE_ORDER.get(target["role"], 99)
+ if caller_order >= target_order:
+ return jsonify({
+ "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"
+ }), 403
+
+ conn.execute(
+ "UPDATE sect_members SET role = ? WHERE user_id = ? AND sect_id = ?",
+ (new_role, target_user_id, sect_id)
+ )
+ conn.commit()
+
+ return jsonify({
+ "success": True,
+ "message": f"Đã gán chức vị {ROLE_LABELS.get(new_role)} thành công!",
+ "new_role": new_role,
+ "new_role_label": ROLE_LABELS.get(new_role),
+ })
+ except Exception as e:
+ logger.error(f"Error promoting rank: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@sects_bp.route("/api/sects/members", methods=["GET"])
+@jwt_required
+def list_sect_members():
+ """List all members of current user's sect with full role info."""
+ user = get_current_user()
+ role_filter = request.args.get("role")
+ conn = get_user_db_conn()
+ try:
+ member = conn.execute(
+ "SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
+ ).fetchone()
+ if not member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = member["sect_id"]
+
+ if role_filter and role_filter in VALID_ROLES:
+ rows = conn.execute("""
+ SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
+ u.username, u.avatar
+ FROM sect_members sm
+ LEFT JOIN users u ON sm.user_id = u.id
+ WHERE sm.sect_id = ? AND sm.role = ?
+ ORDER BY sm.contribution DESC
+ """, (sect_id, role_filter)).fetchall()
+ else:
+ rows = conn.execute("""
+ SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
+ u.username, u.avatar
+ FROM sect_members sm
+ LEFT JOIN users u ON sm.user_id = u.id
+ WHERE sm.sect_id = ?
+ ORDER BY
+ CASE sm.role
+ WHEN 'leader' THEN 1
+ WHEN 'vice_leader' THEN 2
+ WHEN 'elder' THEN 3
+ WHEN 'inner_disciple' THEN 4
+ ELSE 5
+ END,
+ sm.contribution DESC
+ """, (sect_id,)).fetchall()
+
+ member_list = []
+ for r in rows:
+ rd = serialize_row(r)
+ rd["role_label"] = ROLE_LABELS.get(r["role"], "Môn đồ")
+ member_list.append(rd)
+
+ return jsonify({
+ "members": member_list,
+ "total": len(member_list),
+ "role_labels": ROLE_LABELS,
+ "valid_roles": VALID_ROLES,
+ })
+ except Exception as e:
+ logger.error(f"Error listing members: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+# ==========================================
+# SECT CHAT GROUPS – MANAGE MEMBERS
+# ==========================================
+
+@sects_bp.route("/api/sects/chat/groups//members/add", methods=["POST"])
+@jwt_required
+def add_to_chat_group(group_id):
+ """Add member(s) to a sub-group chat."""
+ user = get_current_user()
+ data = request.json or {}
+ new_member_ids = data.get("user_ids", [])
+
+ conn = get_user_db_conn()
+ try:
+ my_member = conn.execute(
+ "SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
+ ).fetchone()
+ if not my_member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = my_member["sect_id"]
+ group = conn.execute(
+ "SELECT id, creator_id, members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
+ (group_id, sect_id)
+ ).fetchone()
+
+ if not group:
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
+
+ # Only group creator, leader, vice_leader, elder can add
+ if group["creator_id"] != user["id"] and my_member["role"] not in ["leader", "vice_leader", "elder"]:
+ 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
+
+ current_members = [int(x) for x in group["members_csv"].split(",") if x.strip()]
+ added = []
+ for uid in new_member_ids:
+ uid = int(uid)
+ if uid not in current_members:
+ # Check uid is in sect
+ in_sect = conn.execute(
+ "SELECT 1 FROM sect_members WHERE user_id = ? AND sect_id = ?", (uid, sect_id)
+ ).fetchone()
+ if in_sect:
+ current_members.append(uid)
+ added.append(uid)
+
+ new_csv = ",".join(str(x) for x in current_members)
+ conn.execute(
+ "UPDATE sect_chat_groups SET members_csv = ? WHERE id = ?",
+ (new_csv, group_id)
+ )
+ conn.commit()
+ return jsonify({"success": True, "added": added, "members": current_members})
+ except Exception as e:
+ logger.error(f"Error adding to chat group: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@sects_bp.route("/api/sects/chat/groups//members/remove", methods=["POST"])
+@jwt_required
+def remove_from_chat_group(group_id):
+ """Remove a member from a sub-group chat."""
+ user = get_current_user()
+ data = request.json or {}
+ target_id = data.get("user_id")
+
+ conn = get_user_db_conn()
+ try:
+ my_member = conn.execute(
+ "SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
+ ).fetchone()
+ if not my_member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = my_member["sect_id"]
+ group = conn.execute(
+ "SELECT id, creator_id, members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
+ (group_id, sect_id)
+ ).fetchone()
+ if not group:
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
+
+ if group["creator_id"] != user["id"] and my_member["role"] not in ["leader", "vice_leader", "elder"]:
+ return jsonify({"error": "Không đủ quyền xóa thành viên khỏi nhóm chat"}), 403
+
+ current_members = [int(x) for x in group["members_csv"].split(",") if x.strip()]
+ if int(target_id) not in current_members:
+ return jsonify({"error": "Thành viên không có trong nhóm chat này"}), 400
+
+ current_members.remove(int(target_id))
+ new_csv = ",".join(str(x) for x in current_members)
+ conn.execute("UPDATE sect_chat_groups SET members_csv = ? WHERE id = ?", (new_csv, group_id))
+ conn.commit()
+ return jsonify({"success": True, "members": current_members})
+ except Exception as e:
+ logger.error(f"Error removing from chat group: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@sects_bp.route("/api/sects/chat/groups/", methods=["GET"])
+@jwt_required
+def get_chat_group_info(group_id):
+ """Get info and member list of a sub-group chat."""
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ my_member = conn.execute(
+ "SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
+ ).fetchone()
+ if not my_member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = my_member["sect_id"]
+ group = conn.execute(
+ "SELECT * FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
+ (group_id, sect_id)
+ ).fetchone()
+ if not group:
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
+
+ member_ids = [int(x) for x in group["members_csv"].split(",") if x.strip()]
+ if user["id"] not in member_ids:
+ return jsonify({"error": "Bạn không phải thành viên của nhóm chat này"}), 403
+
+ # Fetch member details
+ members = []
+ for uid in member_ids:
+ row = conn.execute("""
+ SELECT sm.user_id, sm.role, sm.contribution, u.username, u.avatar
+ FROM sect_members sm
+ LEFT JOIN users u ON sm.user_id = u.id
+ WHERE sm.user_id = ? AND sm.sect_id = ?
+ """, (uid, sect_id)).fetchone()
+ if row:
+ rd = serialize_row(row)
+ rd["role_label"] = ROLE_LABELS.get(row["role"], "Môn đồ")
+ members.append(rd)
+
+ return jsonify({
+ "group": serialize_row(group),
+ "members": members,
+ "member_count": len(members),
+ })
+ except Exception as e:
+ logger.error(f"Error fetching chat group: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@sects_bp.route("/api/sects/chat/groups", methods=["GET"])
+@jwt_required
+def list_all_sect_chat_groups():
+ """List all sub-group chats the current user is part of in their sect."""
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ my_member = conn.execute(
+ "SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
+ ).fetchone()
+ if not my_member:
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
+
+ sect_id = my_member["sect_id"]
+ groups = conn.execute(
+ "SELECT id, name, creator_id, members_csv, created_at FROM sect_chat_groups WHERE sect_id = ?",
+ (sect_id,)
+ ).fetchall()
+
+ result = []
+ for g in groups:
+ member_ids = [int(x) for x in g["members_csv"].split(",") if x.strip()]
+ if user["id"] in member_ids:
+ gd = serialize_row(g)
+ gd["member_count"] = len(member_ids)
+ result.append(gd)
+
+ return jsonify({"groups": result})
+ except Exception as e:
+ logger.error(f"Error listing chat groups: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
diff --git a/backend/api/translate.py b/backend/api/translate.py
new file mode 100644
index 0000000000000000000000000000000000000000..ff3e5f88e4e9f36e4f8ffc23ad77aeb7f7f8dc83
--- /dev/null
+++ b/backend/api/translate.py
@@ -0,0 +1,397 @@
+import datetime
+import os
+import requests as py_requests
+from flask import Blueprint, request, jsonify, Response, session, current_app
+from backend.config import Config
+from backend.core.decorators import get_current_user
+from backend.core.rate_limit import get_client_ip
+from backend.core.security import verify_access_token
+from backend.services.translation import (
+ get_engine, translate_texts, translate_stream_generator,
+ translation_limit_tracker, call_ai_chat_proxy
+)
+
+translate_bp = Blueprint("translate", __name__)
+
+
+def is_vip_request():
+ """Check if current request has VIP privileges (session, JWT, or header code)."""
+ user = get_current_user()
+ if user and user.get("vip_status", 0) == 1:
+ return True
+
+ # Check headers
+ vip_code = request.headers.get("X-VIP-Code", "") or request.headers.get("X-VIP-Key", "")
+ if not vip_code:
+ # Check request body
+ try:
+ data = request.json or {}
+ vip_code = data.get("vip_key", "") or data.get("vip_code", "")
+ except:
+ pass
+
+ if vip_code and vip_code in Config.VALID_VIP_CODES:
+ return True
+ return False
+
+
+def _check_translation_rate_limit(texts):
+ """Returns (exceeded: bool, tracker_key, count) for standard users."""
+ client_ip = get_client_ip()
+ today_str = datetime.date.today().isoformat()
+ tracker_key = f"{client_ip}:{today_str}"
+ current_count = translation_limit_tracker.get(tracker_key, 0)
+ requested_count = len([t for t in texts if t.strip()])
+ exceeded = (current_count + requested_count) > 50
+ return exceeded, tracker_key, requested_count
+
+
+@translate_bp.route("/translate", methods=["POST"])
+def translate():
+ data = request.json
+ if not data or "texts" not in data:
+ return jsonify({"error": "Missing 'texts' array"}), 400
+
+ texts = data["texts"]
+
+ if not is_vip_request():
+ exceeded, tracker_key, count = _check_translation_rate_limit(texts)
+ if exceeded:
+ return jsonify({
+ "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!"
+ }), 403
+ translation_limit_tracker[tracker_key] = translation_limit_tracker.get(tracker_key, 0) + count
+
+ mode = data.get("mode")
+
+ # If standalone translation server is running, proxy request to it
+ local_translate_url = os.environ.get("LOCAL_TRANSLATE_URL", "")
+ if not local_translate_url:
+ local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
+ if local_tts_url:
+ local_translate_url = local_tts_url.split("/v1/")[0]
+
+ if local_translate_url:
+ try:
+ translate_url = f"{local_translate_url.rstrip('/')}/v1/translate"
+ r = py_requests.post(translate_url, json={"texts": texts, "mode": mode}, timeout=(2.0, 60.0))
+ if r.status_code == 200:
+ return jsonify(r.json())
+ current_app.logger.error(f"[Translate Proxy Error]: {r.status_code} - {r.text}")
+ except Exception as e:
+ current_app.logger.error(f"[Translate Proxy Exception]: {e}")
+
+ translations = translate_texts(texts, mode)
+ return jsonify({"translations": translations})
+
+
+@translate_bp.route("/translate_stream", methods=["POST"])
+def translate_stream():
+ data = request.json
+ if not data or "texts" not in data:
+ return jsonify({"error": "Missing 'texts' array"}), 400
+
+ texts = data["texts"]
+
+ if not is_vip_request():
+ exceeded, tracker_key, count = _check_translation_rate_limit(texts)
+ if exceeded:
+ return jsonify({
+ "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!"
+ }), 403
+ translation_limit_tracker[tracker_key] = translation_limit_tracker.get(tracker_key, 0) + count
+
+ mode = data.get("mode")
+
+ # If standalone translation server is running, proxy stream request to it
+ local_translate_url = os.environ.get("LOCAL_TRANSLATE_URL", "")
+ if not local_translate_url:
+ local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
+ if local_tts_url:
+ local_translate_url = local_tts_url.split("/v1/")[0]
+
+ if local_translate_url:
+ try:
+ translate_url = f"{local_translate_url.rstrip('/')}/v1/translate_stream"
+ r = py_requests.post(translate_url, json={"texts": texts, "mode": mode}, stream=True, timeout=(2.0, 60.0))
+ if r.status_code == 200:
+ def stream_forwarder():
+ for chunk in r.iter_content(chunk_size=1024):
+ if chunk:
+ yield chunk
+ response = Response(stream_forwarder(), mimetype="text/event-stream")
+ response.headers["X-Accel-Buffering"] = "no"
+ response.headers["Cache-Control"] = "no-cache, no-transform"
+ response.headers["Connection"] = "keep-alive"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ return response
+ current_app.logger.error(f"[Translate Stream Proxy Error]: {r.status_code} - {r.text}")
+ except Exception as e:
+ current_app.logger.error(f"[Translate Stream Proxy Exception]: {e}")
+
+ response = Response(translate_stream_generator(texts, mode), mimetype="text/event-stream")
+ response.headers["X-Accel-Buffering"] = "no"
+ response.headers["Cache-Control"] = "no-cache, no-transform"
+ response.headers["Connection"] = "keep-alive"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ return response
+
+
+@translate_bp.route("/api/ai/chat", methods=["POST"])
+def ai_chat_proxy():
+ if not is_vip_request():
+ return jsonify({
+ "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!"
+ }), 403
+
+ data = request.json or {}
+ messages = data.get("messages", [])
+ model = data.get("model", "gemini-1.5-flash")
+ prompt = data.get("prompt", "")
+
+ try:
+ text = call_ai_chat_proxy(messages, model, prompt)
+ return jsonify({"text": text})
+ except ValueError as e:
+ return jsonify({"error": str(e)}), 501
+ except RuntimeError as e:
+ return jsonify({"error": str(e)}), 502
+ except Exception as e:
+ return jsonify({"error": f"Lỗi xử lý AI Proxy: {str(e)}"}), 500
+
+
+@translate_bp.route("/iframe_proxy", methods=["GET"])
+def iframe_proxy():
+ url = request.args.get("url")
+ if not url:
+ return "Missing 'url' parameter", 400
+
+ try:
+ headers = {
+ "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
+ "Accept-Language": "en-US,en;q=0.5"
+ }
+
+ # Follow redirects
+ r = py_requests.get(url, headers=headers, timeout=15, allow_redirects=True)
+
+ encoding = r.encoding
+ if not encoding or encoding.lower() == 'iso-8859-1':
+ encoding = r.apparent_encoding or 'utf-8'
+
+ try:
+ html = r.content.decode(encoding, errors='ignore')
+ except Exception:
+ html = r.content.decode('utf-8', errors='ignore')
+
+ # Insert base URL tag so all links/resources load relative to original page
+ base_tag = f''
+ if "" in html:
+ html = html.replace("", f"{base_tag}", 1)
+ elif "" in html:
+ html = html.replace("", f"{base_tag}", 1)
+ else:
+ html = base_tag + html
+
+ # Injected proxy handler script
+ injected_script = """
+
+ """
+
+ if "" in html:
+ html = html.replace("", f"{injected_script}", 1)
+ elif "" in html:
+ html = html.replace("", f"{injected_script}", 1)
+ else:
+ html = html + injected_script
+
+ response = Response(html, mimetype="text/html")
+ response.headers.pop("X-Frame-Options", None)
+ response.headers.pop("Content-Security-Policy", None)
+ response.headers["Access-Control-Allow-Origin"] = "*"
+ return response
+ except Exception as e:
+ return f"Proxy error: {str(e)}", 500
+
diff --git a/backend/api/user_features.py b/backend/api/user_features.py
new file mode 100644
index 0000000000000000000000000000000000000000..d3521869b880288be07b22f1627585bf8072c205
--- /dev/null
+++ b/backend/api/user_features.py
@@ -0,0 +1,938 @@
+from flask import Blueprint, request, jsonify
+from backend.core.decorators import jwt_required, get_current_user
+from backend.database.db_manager import get_user_db_conn
+from backend.core.logger import logger
+from backend.core.security import encrypt_message, decrypt_message
+from datetime import datetime
+import os
+
+user_features_bp = Blueprint("user_features", __name__)
+
+def serialize_row(row):
+ if not row:
+ return {}
+ d = dict(row)
+ for k, v in d.items():
+ if hasattr(v, "isoformat"):
+ d[k] = v.isoformat()
+ return d
+
+import time
+_history_cache = {}
+_stats_cache = {}
+
+
+# ==========================================
+# 1. Translation History Endpoints
+# ==========================================
+
+@user_features_bp.route("/api/user/history", methods=["GET"])
+@jwt_required
+def get_translation_history():
+ user = get_current_user()
+ user_id = user["id"]
+
+ now = time.time()
+ if user_id in _history_cache:
+ cached_data, cached_time = _history_cache[user_id]
+ if now - cached_time < 60:
+ return jsonify(cached_data)
+
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ "SELECT * FROM translation_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100",
+ (user_id,)
+ ).fetchall()
+ response_data = {"history": [serialize_row(r) for r in rows], "success": True}
+ _history_cache[user_id] = (response_data, now)
+ return jsonify(response_data)
+ except Exception as e:
+ logger.error(f"[User History] Failed to fetch history: {e}")
+ return jsonify({"error": "Failed to fetch translation history", "success": False}), 500
+ finally:
+ conn.close()
+
+@user_features_bp.route("/api/user/history", methods=["POST"])
+@jwt_required
+def add_translation_history():
+ user = get_current_user()
+ data = request.json or {}
+ original = data.get("original_text", "").strip()
+ translated = data.get("translated_text", "").strip()
+ mode = data.get("mode", "fast").strip()
+ chars = int(data.get("characters", len(original)))
+
+ if not original or not translated:
+ return jsonify({"error": "Original text and translation content cannot be empty", "success": False}), 400
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ "INSERT INTO translation_history (user_id, original_text, translated_text, mode, characters) VALUES (?, ?, ?, ?, ?)",
+ (user["id"], original, translated, mode, chars)
+ )
+ conn.commit()
+ if user["id"] in _history_cache: del _history_cache[user["id"]]
+ if user["id"] in _stats_cache: del _stats_cache[user["id"]]
+ return jsonify({"message": "Translation history entry added", "success": True})
+ except Exception as e:
+ logger.error(f"[User History] Failed to add entry: {e}")
+ return jsonify({"error": "Failed to add translation history entry", "success": False}), 500
+ finally:
+ conn.close()
+
+@user_features_bp.route("/api/user/history", methods=["DELETE"])
+@jwt_required
+def clear_translation_history():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ conn.execute("DELETE FROM translation_history WHERE user_id = ?", (user["id"],))
+ conn.commit()
+ if user["id"] in _history_cache: del _history_cache[user["id"]]
+ if user["id"] in _stats_cache: del _stats_cache[user["id"]]
+ return jsonify({"message": "Translation history cleared", "success": True})
+ except Exception as e:
+ logger.error(f"[User History] Failed to clear history: {e}")
+ return jsonify({"error": "Failed to clear history", "success": False}), 500
+ finally:
+ conn.close()
+
+# ==========================================
+# 2. Vocabulary/Word Notebook Endpoints
+# ==========================================
+
+@user_features_bp.route("/api/user/vocabulary", methods=["GET"])
+@jwt_required
+def get_vocabulary():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ "SELECT * FROM vocabulary WHERE user_id = ? ORDER BY created_at DESC",
+ (user["id"],)
+ ).fetchall()
+ return jsonify({"vocabulary": [serialize_row(r) for r in rows], "success": True})
+ except Exception as e:
+ logger.error(f"[Vocabulary] Failed to fetch vocabulary: {e}")
+ return jsonify({"error": "Failed to fetch vocabulary list", "success": False}), 500
+ finally:
+ conn.close()
+
+@user_features_bp.route("/api/user/vocabulary", methods=["POST"])
+@jwt_required
+def add_vocabulary():
+ user = get_current_user()
+ data = request.json or {}
+ original = data.get("original_text", "").strip()
+ translation = data.get("translation", "").strip()
+ pinyin_or_hanviet = data.get("pinyin_or_hanviet", "").strip()
+ context = data.get("context_sentence", "").strip()
+ notes = data.get("notes", "").strip()
+
+ if not original or not translation:
+ return jsonify({"error": "Original text and translation content cannot be empty", "success": False}), 400
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ "INSERT INTO vocabulary (user_id, original_text, pinyin_or_hanviet, translation, context_sentence, notes) VALUES (?, ?, ?, ?, ?, ?)",
+ (user["id"], original, pinyin_or_hanviet, translation, context, notes)
+ )
+ conn.commit()
+ return jsonify({"message": "Word saved to vocabulary notebook", "success": True})
+ except Exception as e:
+ logger.error(f"[Vocabulary] Failed to save word: {e}")
+ return jsonify({"error": "Failed to save word notebook entry", "success": False}), 500
+ finally:
+ conn.close()
+
+@user_features_bp.route("/api/user/vocabulary/", methods=["DELETE"])
+@jwt_required
+def delete_vocabulary(item_id):
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ # Verify ownership
+ row = conn.execute("SELECT user_id FROM vocabulary WHERE id = ?", (item_id,)).fetchone()
+ if not row:
+ return jsonify({"error": "Entry not found", "success": False}), 404
+ if int(row["user_id"]) != int(user["id"]):
+ return jsonify({"error": "Unauthorized action", "success": False}), 403
+
+ conn.execute("DELETE FROM vocabulary WHERE id = ?", (item_id,))
+ conn.commit()
+ return jsonify({"message": "Word deleted from notebook", "success": True})
+ except Exception as e:
+ logger.error(f"[Vocabulary] Failed to delete item {item_id}: {e}")
+ return jsonify({"error": "Failed to delete item", "success": False}), 500
+ finally:
+ conn.close()
+
+# ==========================================
+# 3. Personalization Settings Endpoints
+# ==========================================
+
+@user_features_bp.route("/api/user/settings", methods=["GET"])
+@jwt_required
+def get_user_settings():
+ user = get_current_user()
+ conn = get_user_db_conn()
+ try:
+ row = conn.execute("SELECT * FROM user_settings WHERE user_id = ?", (user["id"],)).fetchone()
+ if not row:
+ # Insert defaults
+ conn.execute(
+ "INSERT INTO user_settings (user_id, theme, default_language, auto_read, font_size) VALUES (?, 'dark', 'vi', 0, 14)",
+ (user["id"],)
+ )
+ conn.commit()
+ row = conn.execute("SELECT * FROM user_settings WHERE user_id = ?", (user["id"],)).fetchone()
+
+ return jsonify({"settings": serialize_row(row), "success": True})
+ except Exception as e:
+ logger.error(f"[UserSettings] Failed to fetch settings: {e}")
+ return jsonify({"error": "Failed to fetch settings", "success": False}), 500
+ finally:
+ conn.close()
+
+@user_features_bp.route("/api/user/settings", methods=["POST"])
+@jwt_required
+def update_user_settings():
+ user = get_current_user()
+ data = request.json or {}
+ theme = data.get("theme", "dark").strip()
+ lang = data.get("default_language", "vi").strip()
+ auto_read = int(data.get("auto_read", 0))
+ font_size = int(data.get("font_size", 14))
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ """INSERT INTO user_settings (user_id, theme, default_language, auto_read, font_size, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ theme = excluded.theme,
+ default_language = excluded.default_language,
+ auto_read = excluded.auto_read,
+ font_size = excluded.font_size,
+ updated_at = excluded.updated_at""",
+ (user["id"], theme, lang, auto_read, font_size, now)
+ )
+ conn.commit()
+ return jsonify({"message": "Settings updated successfully", "success": True})
+ except Exception as e:
+ logger.error(f"[UserSettings] Failed to update settings: {e}")
+ return jsonify({"error": "Failed to update settings", "success": False}), 500
+ finally:
+ conn.close()
+
+
+# ==========================================
+# 4. Usage Tracking & System/API Statistics
+# ==========================================
+
+@user_features_bp.route("/api/user/track", methods=["POST"])
+@jwt_required
+def track_user_usage():
+ user = get_current_user()
+ data = request.json or {}
+ source = data.get("source", "web").strip().lower()
+ action = data.get("action", "read").strip().lower()
+ duration = int(data.get("duration", 0))
+ mode = data.get("mode", "online").strip().lower()
+
+ if not source or not action:
+ return jsonify({"error": "Missing source or action", "success": False}), 400
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ "INSERT INTO usage_tracking (user_id, source, action, duration, mode) VALUES (?, ?, ?, ?, ?)",
+ (user["id"], source, action, duration, mode)
+ )
+ conn.commit()
+ if user["id"] in _stats_cache: del _stats_cache[user["id"]]
+ return jsonify({"message": "Usage tracked successfully", "success": True})
+ except Exception as e:
+ logger.error(f"[Usage Tracking] Failed to log usage: {e}")
+ return jsonify({"error": "Failed to track usage", "success": False}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/user/stats", methods=["GET"])
+@jwt_required
+def get_user_stats():
+ user = get_current_user()
+ user_id = user["id"]
+
+ now = time.time()
+ if user_id in _stats_cache:
+ cached_data, cached_time = _stats_cache[user_id]
+ if now - cached_time < 60:
+ return jsonify(cached_data)
+
+ conn = get_user_db_conn()
+ try:
+ # 1. Total active/reading duration by source
+ web_duration_row = conn.execute(
+ "SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND source = 'web'",
+ (user["id"],)
+ ).fetchone()
+ web_duration = web_duration_row["total"] or 0 if web_duration_row else 0
+
+ ext_duration_row = conn.execute(
+ "SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND source = 'extension'",
+ (user["id"],)
+ ).fetchone()
+ ext_duration = ext_duration_row["total"] or 0 if ext_duration_row else 0
+
+ # 2. Total active/reading duration by online/offline mode
+ online_duration_row = conn.execute(
+ "SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND mode = 'online'",
+ (user["id"],)
+ ).fetchone()
+ online_duration = online_duration_row["total"] or 0 if online_duration_row else 0
+
+ offline_duration_row = conn.execute(
+ "SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND mode = 'offline'",
+ (user["id"],)
+ ).fetchone()
+ offline_duration = offline_duration_row["total"] or 0 if offline_duration_row else 0
+
+ # 3. Translation calls count (from translation_history)
+ trans_calls_row = conn.execute(
+ "SELECT COUNT(*) as cnt, SUM(characters) as total_chars FROM translation_history WHERE user_id = ?",
+ (user["id"],)
+ ).fetchone()
+ trans_calls = trans_calls_row["cnt"] or 0 if trans_calls_row else 0
+ total_chars = trans_calls_row["total_chars"] or 0 if trans_calls_row else 0
+
+ # 4. API keys & API usage stats (from api_keys and api_usage)
+ api_keys_count_row = conn.execute(
+ "SELECT COUNT(*) as cnt FROM api_keys WHERE user_id = ?",
+ (user["id"],)
+ ).fetchone()
+ api_keys_count = api_keys_count_row["cnt"] or 0 if api_keys_count_row else 0
+
+ api_usage_row = conn.execute(
+ """SELECT COUNT(u.id) as cnt, SUM(u.tokens) as tokens
+ FROM api_usage u
+ JOIN api_keys k ON u.api_key = k.api_key
+ WHERE k.user_id = ?""",
+ (user["id"],)
+ ).fetchone()
+ api_usage_calls = api_usage_row["cnt"] or 0 if api_usage_row else 0
+ api_usage_chars = api_usage_row["tokens"] or 0 if api_usage_row else 0
+
+ # 5. Recent actions log
+ recent_actions = conn.execute(
+ "SELECT source, action, duration, mode, timestamp FROM usage_tracking WHERE user_id = ? ORDER BY timestamp DESC, id DESC LIMIT 20",
+ (user_id,)
+ ).fetchall()
+
+ response_data = {
+ "success": True,
+ "stats": {
+ "web_duration": web_duration,
+ "ext_duration": ext_duration,
+ "online_duration": online_duration,
+ "offline_duration": offline_duration,
+ "total_reading_time": web_duration + ext_duration,
+ "translation_calls": trans_calls,
+ "translation_chars": total_chars,
+ "api_keys_count": api_keys_count,
+ "api_usage_calls": api_usage_calls,
+ "api_usage_chars": api_usage_chars
+ },
+ "recent_actions": [serialize_row(r) for r in recent_actions]
+ }
+
+ _stats_cache[user_id] = (response_data, now)
+ return jsonify(response_data)
+ except Exception as e:
+ logger.error(f"[User Stats] Failed to fetch stats: {e}")
+ return jsonify({"error": "Failed to fetch stats", "success": False}), 500
+ finally:
+ conn.close()
+
+
+# ==========================================
+# 5. Feedback Submissions
+# ==========================================
+
+@user_features_bp.route("/api/feedback/submit", methods=["POST"])
+def submit_feedback():
+ data = request.json or {}
+ email = data.get("email", "").strip()
+ message = data.get("message", "").strip()
+
+ if not email or not message:
+ return jsonify({"error": "Email và nội dung phản hồi không được để trống", "success": False}), 400
+
+ # 1. Log to logs/feedback.log
+ os.makedirs("logs", exist_ok=True)
+ log_path = "logs/feedback.log"
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ log_entry = f"[{timestamp}] EMAIL: {email} | MESSAGE: {message}\n"
+ try:
+ with open(log_path, "a", encoding="utf-8") as f:
+ f.write(log_entry)
+ except Exception as e:
+ logger.error(f"Failed to write feedback to log file: {e}")
+
+ # 2. Save to SQLite database
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS user_feedbacks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT,
+ message TEXT,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
+ )"""
+ )
+ conn.execute(
+ "INSERT INTO user_feedbacks (email, message) VALUES (?, ?)",
+ (email, message)
+ )
+ conn.commit()
+ return jsonify({"message": "Gửi phản hồi thành công!", "success": True})
+ except Exception as e:
+ logger.error(f"[Feedback] Failed to save feedback: {e}")
+ return jsonify({"error": "Gửi phản hồi thất bại", "success": False}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/feedback/list", methods=["GET"])
+def list_feedbacks():
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ """CREATE TABLE IF NOT EXISTS user_feedbacks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT,
+ message TEXT,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
+ )"""
+ )
+ rows = conn.execute("SELECT * FROM user_feedbacks ORDER BY id DESC").fetchall()
+ feedbacks = [serialize_row(r) for r in rows]
+ return jsonify({"feedbacks": feedbacks, "success": True})
+ except Exception as e:
+ logger.error(f"[Feedback] Failed to list feedback: {e}")
+ return jsonify({"error": "Không thể lấy danh sách phản hồi", "success": False}), 500
+ finally:
+ conn.close()
+
+
+# ==========================================
+# 6. Social, Friends, and Private Messaging APIs
+# ==========================================
+
+@user_features_bp.route("/api/users/search", methods=["GET"])
+def search_users():
+ q = request.args.get("q", "").strip()
+ if not q:
+ return jsonify({"users": []})
+ user = get_current_user()
+ user_id = user["id"] if user else None
+ conn = get_user_db_conn()
+ try:
+ like_q = "%" + q + "%"
+ if user_id:
+ rows = conn.execute(
+ """SELECT id, username, user_code, display_name, avatar FROM users
+ WHERE id != ? AND (
+ username LIKE ? OR
+ email LIKE ? OR
+ user_code = ?
+ ) LIMIT 15""",
+ (user_id, like_q, like_q, q)
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ """SELECT id, username, user_code, display_name, avatar FROM users
+ WHERE username LIKE ? OR email LIKE ? OR user_code = ? LIMIT 15""",
+ (like_q, like_q, q)
+ ).fetchall()
+ return jsonify({"users": [serialize_row(r) for r in rows]})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/notifications/unread-counts", methods=["GET"])
+def get_unread_counts():
+ """Separate unread counts: messages vs friend/other notifications."""
+ user = get_current_user()
+ if not user:
+ return jsonify({"messages": 0, "notifications": 0})
+ conn = get_user_db_conn()
+ try:
+ # Unread personal messages from direct_messages table
+ msg_count = conn.execute(
+ "SELECT COUNT(*) as c FROM direct_messages WHERE receiver_id = ? AND is_read = 0",
+ (user["id"],)
+ ).fetchone()["c"]
+ # Unread other notifications (friend_request, friend_accept, book_share etc)
+ notif_count = conn.execute(
+ "SELECT COUNT(*) as c FROM personal_notifications WHERE user_id = ? AND is_read = 0 AND type != 'message'",
+ (user["id"],)
+ ).fetchone()["c"]
+ return jsonify({"messages": msg_count, "notifications": notif_count})
+ except Exception as e:
+ return jsonify({"messages": 0, "notifications": 0})
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/friends/request", methods=["POST"])
+def send_friend_request():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ data = request.json or {}
+ # Accept username, user_code, or email
+ query = data.get("friend_username") or data.get("friend_code") or data.get("friend_email") or ""
+ query = query.strip()
+ if not query:
+ return jsonify({"error": "Thiếu thông tin tìm kiếm bạn bè"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ target = conn.execute(
+ "SELECT id, username FROM users WHERE username = ? OR user_code = ? OR email = ?",
+ (query, query, query.lower())
+ ).fetchone()
+ if not target:
+ return jsonify({"error": "Không tìm thấy người dùng"}), 404
+
+ target_id = target["id"]
+ if target_id == user["id"]:
+ return jsonify({"error": "Không thể kết bạn với chính mình"}), 400
+
+ exists = conn.execute("SELECT status FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
+ (user["id"], target_id, target_id, user["id"])).fetchone()
+ if exists:
+ 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
+
+ conn.execute("INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'pending')", (user["id"], target_id))
+
+ conn.execute(
+ "INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'friend_request', ?, ?)",
+ (target_id, user["id"], f"{user['username']} đã gửi cho bạn một lời mời kết bạn.", user["id"])
+ )
+ conn.commit()
+ return jsonify({"success": True, "message": "Đã gửi lời mời kết bạn!", "to_user": target["username"]})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/friends/respond", methods=["POST"])
+def respond_friend_request():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ data = request.json or {}
+ sender_id = data.get("sender_id")
+ action = data.get("action", "").lower()
+
+ if not sender_id or action not in ['accept', 'reject']:
+ return jsonify({"error": "Dữ liệu không hợp lệ"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ req = conn.execute("SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'pending'", (sender_id, user["id"])).fetchone()
+ if not req:
+ return jsonify({"error": "Không tìm thấy lời mời kết bạn"}), 404
+
+ if action == 'accept':
+ conn.execute("UPDATE friendships SET status = 'accepted' WHERE user_id = ? AND friend_id = ?", (sender_id, user["id"]))
+ # Cross-compatibility check instead of INSERT OR IGNORE
+ exists = conn.execute("SELECT 1 FROM friendships WHERE user_id = ? AND friend_id = ?", (user["id"], sender_id)).fetchone()
+ if not exists:
+ conn.execute("INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'accepted')", (user["id"], sender_id))
+ conn.execute(
+ "INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'friend_accept', ?, ?)",
+ (sender_id, user["id"], f"{user['username']} đã chấp nhận lời mời kết bạn.", user["id"])
+ )
+ else:
+ conn.execute("DELETE FROM friendships WHERE user_id = ? AND friend_id = ?", (sender_id, user["id"]))
+
+ conn.execute(
+ "UPDATE personal_notifications SET is_read = 1 WHERE user_id = ? AND sender_id = ? AND type = 'friend_request'",
+ (user["id"], sender_id)
+ )
+ conn.commit()
+ return jsonify({"success": True, "message": f"Đã {'chấp nhận' if action == 'accept' else 'từ chối'} kết bạn."})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/friends/list", methods=["GET"])
+def list_friends():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ """SELECT u.id, u.username, u.user_code, u.avatar,
+ (SELECT COUNT(*) FROM direct_messages dm WHERE dm.sender_id = u.id AND dm.receiver_id = ? AND dm.is_read = 0) as unread_messages
+ FROM friendships f
+ JOIN users u ON f.friend_id = u.id
+ WHERE f.user_id = ? AND f.status = 'accepted'""",
+ (user["id"], user["id"])
+ ).fetchall()
+ return jsonify({"friends": [serialize_row(r) for r in rows]})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/friends/unfriend", methods=["POST"])
+@jwt_required
+def unfriend():
+ """Hủy kết bạn: Xóa quan hệ bạn bè cả hai chiều."""
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ data = request.json or {}
+ friend_id = data.get("friend_id")
+ if not friend_id:
+ return jsonify({"error": "Thiếu friend_id"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ # Kiểm tra quan hệ tồn tại và đang ở trạng thái accepted
+ rel = conn.execute(
+ """SELECT id FROM friendships
+ WHERE ((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?))
+ AND status = 'accepted'""",
+ (user["id"], friend_id, friend_id, user["id"])
+ ).fetchone()
+ if not rel:
+ return jsonify({"error": "Không tìm thấy quan hệ bạn bè"}), 404
+
+ # Xóa cả hai chiều
+ conn.execute(
+ "DELETE FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
+ (user["id"], friend_id, friend_id, user["id"])
+ )
+ conn.commit()
+ return jsonify({"success": True, "message": "Đã hủy kết bạn."})
+ except Exception as e:
+ logger.error(f"Unfriend error: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/friends/block", methods=["POST"])
+@jwt_required
+def block_user():
+ """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."""
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ data = request.json or {}
+ target_id = data.get("user_id")
+ if not target_id:
+ return jsonify({"error": "Thiếu user_id cần chặn"}), 400
+ if target_id == user["id"]:
+ return jsonify({"error": "Không thể tự chặn chính mình"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ # Kiểm tra đã chặn chưa
+ already = conn.execute(
+ "SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
+ (user["id"], target_id)
+ ).fetchone()
+ if already:
+ return jsonify({"error": "Bạn đã chặn người dùng này rồi"}), 400
+
+ # Xóa quan hệ bạn bè hai chiều (nếu có)
+ conn.execute(
+ "DELETE FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
+ (user["id"], target_id, target_id, user["id"])
+ )
+ # Ghi nhận block (một chiều: người chặn → người bị chặn)
+ conn.execute(
+ "INSERT OR REPLACE INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'blocked')",
+ (user["id"], target_id)
+ )
+ conn.commit()
+ return jsonify({"success": True, "message": "Đã chặn người dùng."})
+ except Exception as e:
+ logger.error(f"Block user error: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/friends/unblock", methods=["POST"])
+@jwt_required
+def unblock_user():
+ """Bỏ chặn người dùng."""
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ data = request.json or {}
+ target_id = data.get("user_id")
+ if not target_id:
+ return jsonify({"error": "Thiếu user_id cần bỏ chặn"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ rel = conn.execute(
+ "SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
+ (user["id"], target_id)
+ ).fetchone()
+ if not rel:
+ return jsonify({"error": "Bạn chưa chặn người dùng này"}), 404
+
+ conn.execute(
+ "DELETE FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
+ (user["id"], target_id)
+ )
+ conn.commit()
+ return jsonify({"success": True, "message": "Đã bỏ chặn người dùng."})
+ except Exception as e:
+ logger.error(f"Unblock user error: {e}")
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/friends/blocked-list", methods=["GET"])
+@jwt_required
+def list_blocked():
+ """Lấy danh sách người dùng đang bị chặn."""
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ """SELECT u.id, u.username, u.user_code, u.avatar, f.created_at as blocked_at
+ FROM friendships f
+ JOIN users u ON f.friend_id = u.id
+ WHERE f.user_id = ? AND f.status = 'blocked'""",
+ (user["id"],)
+ ).fetchall()
+ return jsonify({"blocked_users": [serialize_row(r) for r in rows]})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/messages/send", methods=["POST"])
+@jwt_required
+def send_message():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ data = request.json or {}
+ receiver_id = data.get("receiver_id")
+ message = data.get("message", "").strip()
+
+ if not receiver_id or not message:
+ return jsonify({"error": "Thiếu người nhận hoặc nội dung tin nhắn"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ # Kiểm tra bảo mật: Người nhận có đang chặn người gửi không?
+ blocked_by_receiver = conn.execute(
+ "SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
+ (receiver_id, user["id"])
+ ).fetchone()
+ if blocked_by_receiver:
+ return jsonify({"error": "Không thể gửi tin nhắn đến người dùng này."}), 403
+
+ # Kiểm tra bảo mật: Người gửi có đang chặn người nhận không?
+ blocked_by_sender = conn.execute(
+ "SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
+ (user["id"], receiver_id)
+ ).fetchone()
+ if blocked_by_sender:
+ return jsonify({"error": "Bạn đang chặn người dùng này. Bỏ chặn trước khi nhắn tin."}), 403
+
+ enc_msg = encrypt_message(message)
+ cursor = conn.execute(
+ "INSERT INTO direct_messages (sender_id, receiver_id, message) VALUES (?, ?, ?)",
+ (user["id"], receiver_id, enc_msg)
+ )
+ msg_id = cursor.lastrowid
+
+ conn.execute(
+ "INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'message', ?, ?)",
+ (receiver_id, user["id"], f"{user['username']} đã gửi cho bạn một tin nhắn mới.", msg_id)
+ )
+ conn.commit()
+ return jsonify({"success": True, "message": "Gửi tin nhắn thành công!", "msg_id": msg_id})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/messages/chat/", methods=["GET"])
+def get_chat_history(friend_id):
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ """SELECT id, sender_id, receiver_id, message, is_read, created_at
+ FROM direct_messages
+ WHERE (sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)
+ ORDER BY created_at ASC""",
+ (user["id"], friend_id, friend_id, user["id"])
+ ).fetchall()
+
+ conn.execute(
+ "UPDATE direct_messages SET is_read = 1 WHERE sender_id = ? AND receiver_id = ?",
+ (friend_id, user["id"])
+ )
+ conn.execute(
+ "UPDATE personal_notifications SET is_read = 1 WHERE user_id = ? AND sender_id = ? AND type = 'message'",
+ (user["id"], friend_id)
+ )
+ conn.commit()
+
+ messages = []
+ for r in rows:
+ d = dict(r)
+ d["message"] = decrypt_message(d.get("message", ""))
+ for k, v in d.items():
+ if hasattr(v, "isoformat"):
+ d[k] = v.isoformat()
+ messages.append(d)
+
+ return jsonify({"messages": messages})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/books/share", methods=["POST"])
+def share_book():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ data = request.json or {}
+ friend_id = data.get("friend_id")
+ book_id = data.get("book_id")
+ custom_message = data.get("message", "").strip()
+
+ if not friend_id or not book_id:
+ return jsonify({"error": "Thiếu thông tin người nhận hoặc truyện cần chia sẻ"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ book_title = "Truyện"
+ from backend.database.db_manager import get_db
+ main_conn = get_db()
+ row = main_conn.execute("SELECT title_vietphrase, title_hanviet, title FROM books WHERE id = ?", (book_id,)).fetchone()
+ if row:
+ book_title = row["title_vietphrase"] or row["title_hanviet"] or row["title"]
+
+ msg_content = f"{user['username']} đã chia sẻ truyện '{book_title}' với bạn."
+ if custom_message:
+ msg_content += f" Lời nhắn: \"{custom_message}\""
+
+ conn.execute(
+ "INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'book_share', ?, ?)",
+ (friend_id, user["id"], msg_content, book_id)
+ )
+
+ share_chat_msg = f"[Chia sẻ truyện] '{book_title}' - Xem chi tiết tại /book/{book_id}"
+ if custom_message:
+ share_chat_msg += f"\nLời nhắn: {custom_message}"
+ enc_share_msg = encrypt_message(share_chat_msg)
+ conn.execute(
+ "INSERT INTO direct_messages (sender_id, receiver_id, message) VALUES (?, ?, ?)",
+ (user["id"], friend_id, enc_share_msg)
+ )
+
+ conn.commit()
+ return jsonify({"success": True, "message": "Chia sẻ truyện thành công!"})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/notifications/personal", methods=["GET"])
+def get_personal_notifications():
+ user = get_current_user()
+ if not user:
+ return jsonify({"notifications": []})
+
+ conn = get_user_db_conn()
+ try:
+ rows = conn.execute(
+ """SELECT n.id, n.sender_id, n.type, n.message, n.related_id, n.is_read, n.created_at, u.username as sender_name
+ FROM personal_notifications n
+ LEFT JOIN users u ON n.sender_id = u.id
+ WHERE n.user_id = ?
+ ORDER BY n.created_at DESC LIMIT 50""",
+ (user["id"],)
+ ).fetchall()
+ return jsonify({"notifications": [serialize_row(r) for r in rows]})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
+
+@user_features_bp.route("/api/notifications/personal/read", methods=["POST"])
+def read_personal_notification():
+ user = get_current_user()
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ data = request.json or {}
+ notif_id = data.get("notification_id")
+
+ if not notif_id:
+ return jsonify({"error": "Thiếu notification_id"}), 400
+
+ conn = get_user_db_conn()
+ try:
+ conn.execute(
+ "UPDATE personal_notifications SET is_read = 1 WHERE id = ? AND user_id = ?",
+ (notif_id, user["id"])
+ )
+ conn.commit()
+ return jsonify({"success": True})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+ finally:
+ conn.close()
+
diff --git a/backend/config.py b/backend/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..77ef3d8440aa9ec15fe6b4e3e36fb24632ff33d1
--- /dev/null
+++ b/backend/config.py
@@ -0,0 +1,89 @@
+import os
+from datetime import timedelta
+from dotenv import load_dotenv
+
+load_dotenv(override=True)
+
+class Config:
+ # Flask settings
+ SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "tienhiep_lyvuha_secret_key_9988")
+
+ # Base directories and DB paths
+ ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ DB_PATH = os.path.join(ROOT_DIR, "merged_books.db")
+ USER_DB_PATH = os.path.join(ROOT_DIR, "users_data.db")
+
+ # JWT authentication settings
+ JWT_SECRET = SECRET_KEY + "_jwt_v2_secure"
+ JWT_ALGORITHM = "HS256"
+ JWT_ACCESS_TOKEN_EXPIRE = timedelta(days=365)
+ JWT_REFRESH_TOKEN_EXPIRE = timedelta(days=365)
+
+ # VIP plans
+ VIP_PLANS = {
+ "month": {
+ "name_vi": "Gói Tháng",
+ "name_en": "Monthly Plan",
+ "name_zh": "月套餐",
+ "price": 50000, # 50,000 VND
+ "duration_days": 30,
+ "description_vi": "VIP 1 tháng — Dịch không giới hạn, AI, TTS, EPUB",
+ "description_en": "VIP 1 month — Unlimited translation, AI, TTS, EPUB",
+ "description_zh": "VIP 1个月 — 无限翻译、AI、TTS、EPUB",
+ },
+ "year": {
+ "name_vi": "Gói Năm (Tiết kiệm 67%)",
+ "name_en": "Yearly Plan (Save 67%)",
+ "name_zh": "年套餐 (节省67%)",
+ "price": 200000, # 200,000 VND
+ "duration_days": 365,
+ "description_vi": "VIP 1 năm — Tất cả quyền lợi VIP, ưu đãi tốt nhất",
+ "description_en": "VIP 1 year — All VIP benefits, best value",
+ "description_zh": "VIP 1年 — 所有VIP权益,最优惠",
+ }
+ }
+
+ # Payment Gateway Config
+ PAYMENT_CONFIG = {
+ "bank_id": os.environ.get("BANK_ID", "MB"),
+ "account_no": os.environ.get("BANK_ACCOUNT_NO", "0349717475"),
+ "account_name": os.environ.get("BANK_ACCOUNT_NAME", "LY VU HA"),
+ "template": "compact2",
+ "payos_client_id": os.environ.get("PAYOS_CLIENT_ID", ""),
+ "payos_api_key": os.environ.get("PAYOS_API_KEY", ""),
+ "payos_checksum_key": os.environ.get("PAYOS_CHECKSUM_KEY", ""),
+ "payos_webhook_url": os.environ.get("PAYOS_WEBHOOK_URL", "https://yourdomain.com/api/payment/webhook"),
+ }
+
+ # Rate Limiting
+ RATE_LIMITS = {
+ "login": {"max": 5, "window": 300},
+ "register": {"max": 3, "window": 600},
+ "otp": {"max": 3, "window": 60},
+ "payment": {"max": 10, "window": 600},
+ }
+
+ # SMTP email config
+ SMTP_CONFIG = {
+ "host": "smtp.gmail.com",
+ "port": 587,
+ "username": os.environ.get("SMTP_EMAIL", ""),
+ "password": os.environ.get("SMTP_PASSWORD", ""),
+ "from_name": "Novel Translator VIP",
+ "enabled": True if os.environ.get("SMTP_PASSWORD") else False
+ }
+
+ # Google OAuth
+ GOOGLE_OAUTH_CONFIG = {
+ "client_id": os.environ.get("GOOGLE_CLIENT_ID", ""),
+ "client_secret": os.environ.get("GOOGLE_CLIENT_SECRET", ""),
+ "redirect_uri": os.environ.get("GOOGLE_REDIRECT_URI", "http://localhost:5050/api/auth/google/callback"),
+ "enabled": True
+ }
+
+ # VIP Validation Codes
+ _vip_codes_env = os.environ.get("VALID_VIP_CODES", "")
+ if _vip_codes_env:
+ VALID_VIP_CODES = set(c.strip() for c in _vip_codes_env.split(",") if c.strip())
+ else:
+ VALID_VIP_CODES = {"VIP2026", "ANTIGRAVITY", "PREMIUM_MEMBER", "VIP_TRANSLATOR", "VIP_SERVER", "LYVUHA_ADMIN_2026"}
diff --git a/backend/core/__init__.py b/backend/core/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d2584059a77bd5f99822712702672eb84ccefe72
--- /dev/null
+++ b/backend/core/__init__.py
@@ -0,0 +1 @@
+# backend/core/__init__.py
diff --git a/backend/core/decorators.py b/backend/core/decorators.py
new file mode 100644
index 0000000000000000000000000000000000000000..bbbeee0a8abf005750629a9145a98de5f29792a4
--- /dev/null
+++ b/backend/core/decorators.py
@@ -0,0 +1,140 @@
+from functools import wraps
+from datetime import datetime
+from flask import request, jsonify, session
+from backend.core.security import verify_access_token
+from backend.database.db_manager import get_user_db_conn
+
+def jwt_required(f):
+ """Decorator: require valid JWT OR session auth."""
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ # Try JWT first
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ token = auth_header[7:]
+ payload = verify_access_token(token)
+ if payload:
+ request._jwt_user = {
+ "id": int(payload["sub"]),
+ "username": payload["username"],
+ "vip_status": payload["vip"]
+ }
+ return f(*args, **kwargs)
+
+ # Fall back to session auth
+ if "user_id" in session:
+ request._jwt_user = {
+ "id": session["user_id"],
+ "username": session["username"],
+ "vip_status": session.get("vip_status", 0)
+ }
+ return f(*args, **kwargs)
+
+ return jsonify({"error": "Authentication required"}), 401
+ return decorated
+
+def require_api_key_auth(f):
+ """Decorator: require valid Developer API Key or VIP Validation Code auth."""
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ # 1. Try to find the key in Authorization header
+ auth_header = request.headers.get("Authorization", "")
+ api_key = ""
+ if auth_header.startswith("Bearer "):
+ api_key = auth_header[7:].strip()
+
+ # If the api_key from Authorization header looks like a JWT token (not sk-tc- and not a valid VIP code),
+ # clear it so we fall back to other headers or the body.
+ from backend.config import Config
+ if api_key and not api_key.startswith("sk-tc-") and api_key not in Config.VALID_VIP_CODES:
+ api_key = ""
+
+ # 2. Try X-VIP-Key header
+ if not api_key:
+ api_key = request.headers.get("X-VIP-Key", "").strip()
+
+ # 3. Try X-VIP-Code header
+ if not api_key:
+ api_key = request.headers.get("X-VIP-Code", "").strip()
+
+ # 4. Try request JSON body
+ if not api_key:
+ try:
+ data = request.json or {}
+ api_key = data.get("vip_key", "") or data.get("vip_code", "") or data.get("api_key", "")
+ if isinstance(api_key, str):
+ api_key = api_key.strip()
+ else:
+ api_key = ""
+ except:
+ pass
+
+ # 5. Check if it's empty
+ if not api_key:
+ return jsonify({"error": "Missing API Key or VIP Code. Please authenticate."}), 401
+
+ # 6. Check if it is a valid static VIP code
+ from backend.config import Config
+ if api_key in Config.VALID_VIP_CODES:
+ # Bypassed - treat as static VIP access with unlimited balance
+ request.api_key = api_key
+ request.api_user_id = None
+ request.api_balance = 999999.0
+ return f(*args, **kwargs)
+
+ # 7. Otherwise, it must be a developer key starting with sk-tc-
+ if not api_key.startswith("sk-tc-"):
+ return jsonify({"error": "Invalid API key format. Key must start with 'sk-tc-' or be a valid VIP Code."}), 401
+
+ conn = get_user_db_conn()
+ key_record = conn.execute(
+ "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'",
+ (api_key,)
+ ).fetchone()
+
+ if not key_record:
+ conn.close()
+ return jsonify({"error": "API Key not found, inactive, or revoked."}), 401
+
+ balance = key_record["api_balance"] if key_record["api_balance"] is not None else 0.0
+ if balance <= 0.0:
+ conn.close()
+ return jsonify({
+ "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."
+ }), 402
+
+ # Update last_used_at
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+ conn.execute("UPDATE api_keys SET last_used_at = ? WHERE api_key = ?", (now, api_key))
+ conn.commit()
+ conn.close()
+
+ request.api_key = api_key
+ request.api_user_id = key_record["user_id"]
+ request.api_balance = balance
+ return f(*args, **kwargs)
+ return decorated
+
+def get_current_user():
+ """Get current user from JWT or session."""
+ if hasattr(request, '_jwt_user'):
+ return request._jwt_user
+
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ token = auth_header[7:]
+ payload = verify_access_token(token)
+ if payload:
+ return {
+ "id": int(payload["sub"]),
+ "username": payload["username"],
+ "vip_status": payload["vip"]
+ }
+
+ if "user_id" in session:
+ return {
+ "id": session["user_id"],
+ "username": session["username"],
+ "vip_status": session.get("vip_status", 0)
+ }
+ return None
diff --git a/backend/core/logger.py b/backend/core/logger.py
new file mode 100644
index 0000000000000000000000000000000000000000..e5ad1deeb5830a146583db68cf13b643b20a0d51
--- /dev/null
+++ b/backend/core/logger.py
@@ -0,0 +1,53 @@
+import os
+import sys
+import logging
+from logging.handlers import RotatingFileHandler
+
+# Project root directory detection
+PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
+LOG_FILE = os.path.join(LOG_DIR, "app.log")
+
+def setup_logger(name="app", log_level=logging.INFO):
+ """Sets up a central rolling logger that outputs to both stdout and a file."""
+ # Ensure logs directory exists
+ try:
+ os.makedirs(LOG_DIR, exist_ok=True)
+ except Exception as e:
+ sys.stderr.write(f"[WARNING] Failed to create log directory '{LOG_DIR}': {e}\n")
+
+ logger = logging.getLogger(name)
+ logger.setLevel(log_level)
+
+ # Avoid duplicate handlers if setup_logger is called multiple times
+ if logger.handlers:
+ return logger
+
+ # Log format: time | level | name | message
+ formatter = logging.Formatter(
+ "[%(asctime)s] %(levelname)s [%(name)s] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S"
+ )
+
+ # Console stream handler
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setFormatter(formatter)
+ logger.addHandler(console_handler)
+
+ # Rolling file handler
+ try:
+ file_handler = RotatingFileHandler(
+ LOG_FILE,
+ maxBytes=10 * 1024 * 1024, # 10 MB
+ backupCount=5,
+ encoding="utf-8"
+ )
+ file_handler.setFormatter(formatter)
+ logger.addHandler(file_handler)
+ except Exception as e:
+ sys.stderr.write(f"[WARNING] Failed to configure file logger for '{LOG_FILE}': {e}\n")
+
+ return logger
+
+# Pre-initialize central logger
+logger = setup_logger()
diff --git a/backend/core/monitoring.py b/backend/core/monitoring.py
new file mode 100644
index 0000000000000000000000000000000000000000..59a3b34b889d5f1a250efc6c810caef20b178802
--- /dev/null
+++ b/backend/core/monitoring.py
@@ -0,0 +1,161 @@
+import os
+import shutil
+import sqlite3
+import datetime
+from backend.config import Config
+from backend.database.db_manager import get_user_db_conn, get_db
+
+def get_cpu_load():
+ """Retrieve CPU 1m, 5m, 15m load average on Linux."""
+ try:
+ if os.path.exists("/proc/loadavg"):
+ with open("/proc/loadavg", "r") as f:
+ load = f.read().strip().split()
+ return {
+ "load_1m": float(load[0]),
+ "load_5m": float(load[1]),
+ "load_15m": float(load[2])
+ }
+ except Exception:
+ pass
+ return {"load_1m": 0.0, "load_5m": 0.0, "load_15m": 0.0}
+
+def get_memory_info():
+ """Retrieve system RAM and process Resident Set Size (RSS) memory info."""
+ mem_total = 0
+ mem_available = 0
+ process_rss = 0
+
+ try:
+ if os.path.exists("/proc/meminfo"):
+ with open("/proc/meminfo", "r") as f:
+ for line in f:
+ if line.startswith("MemTotal:"):
+ mem_total = int(line.split()[1]) * 1024 # kB to bytes
+ elif line.startswith("MemAvailable:"):
+ mem_available = int(line.split()[1]) * 1024
+
+ if os.path.exists("/proc/self/status"):
+ with open("/proc/self/status", "r") as f:
+ for line in f:
+ if line.startswith("VmRSS:"):
+ process_rss = int(line.split()[1]) * 1024
+ break
+ except Exception:
+ pass
+
+ mem_used = mem_total - mem_available
+ mem_pct = (mem_used / mem_total * 100) if mem_total > 0 else 0.0
+
+ return {
+ "total_bytes": mem_total,
+ "used_bytes": mem_used,
+ "available_bytes": mem_available,
+ "percent": round(mem_pct, 2),
+ "process_rss_bytes": process_rss
+ }
+
+def get_disk_info():
+ """Retrieve disk space usage of root partition."""
+ try:
+ total, used, free = shutil.disk_usage("/")
+ percent = (used / total * 100) if total > 0 else 0.0
+ return {
+ "total_bytes": total,
+ "used_bytes": used,
+ "free_bytes": free,
+ "percent": round(percent, 2)
+ }
+ except Exception:
+ return {
+ "total_bytes": 0,
+ "used_bytes": 0,
+ "free_bytes": 0,
+ "percent": 0.0
+ }
+
+def get_database_sizes():
+ """Get the sizes of SQLite databases on disk."""
+ sizes = {}
+ db_paths = {
+ "user_db": Config.USER_DB_PATH,
+ "books_db": Config.DB_PATH,
+ "books_advanced": os.path.join(Config.ROOT_DIR, "merged_books_advanced.db"),
+ "books_fast": os.path.join(Config.ROOT_DIR, "merged_books_fast.db"),
+ "books_vietphrase": os.path.join(Config.ROOT_DIR, "merged_books_vietphrase.db"),
+ "books_hanviet": os.path.join(Config.ROOT_DIR, "merged_books_hanviet.db"),
+ }
+
+ for key, path in db_paths.items():
+ if os.path.exists(path):
+ sizes[key] = os.path.getsize(path)
+ else:
+ sizes[key] = 0
+
+ return sizes
+
+def count_log_errors():
+ """Scan logs/app.log and count the number of [ERROR] or ERROR level log messages."""
+ log_path = os.path.join(Config.ROOT_DIR, "logs", "app.log")
+ count = 0
+ if not os.path.exists(log_path):
+ return 0
+ try:
+ # Read the file. Since it's limited to 10MB, it's safe to scan
+ with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
+ for line in f:
+ if "ERROR" in line or "[ERROR]" in line:
+ count += 1
+ except Exception:
+ pass
+ return count
+
+def get_system_statistics():
+ """Query user database to pull statistics like total users, payments, and API requests."""
+ stats = {
+ "total_users": 0,
+ "vip_users": 0,
+ "total_payments": 0,
+ "total_api_keys": 0,
+ "total_api_calls": 0,
+ "total_books_scraped": 0
+ }
+
+ try:
+ conn = get_user_db_conn()
+ stats["total_users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
+ stats["vip_users"] = conn.execute("SELECT COUNT(*) FROM users WHERE vip_status = 1").fetchone()[0]
+
+ # Check if tables exist before querying
+ tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
+
+ if "payments" in tables:
+ stats["total_payments"] = conn.execute("SELECT COUNT(*) FROM payments WHERE status = 'completed'").fetchone()[0]
+ if "api_keys" in tables:
+ stats["total_api_keys"] = conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0]
+ if "api_usage" in tables:
+ stats["total_api_calls"] = conn.execute("SELECT COUNT(*) FROM api_usage").fetchone()[0]
+
+ conn.close()
+ except Exception:
+ pass
+
+ try:
+ main_conn = get_db()
+ stats["total_books_scraped"] = main_conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
+ except Exception:
+ pass
+
+ return stats
+
+def collect_all_metrics():
+ """Gather all diagnostics into a consolidated system metrics dictionary."""
+ return {
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ "cpu": get_cpu_load(),
+ "memory": get_memory_info(),
+ "disk": get_disk_info(),
+ "databases": get_database_sizes(),
+ "error_logs_count": count_log_errors(),
+ "statistics": get_system_statistics()
+ }
diff --git a/backend/core/profiler.py b/backend/core/profiler.py
new file mode 100644
index 0000000000000000000000000000000000000000..62e4a3565429d9b8b2ae29cdcd16c10bef374fc6
--- /dev/null
+++ b/backend/core/profiler.py
@@ -0,0 +1,89 @@
+import time
+import logging
+import requests
+from collections import deque
+from flask import g, has_app_context, request
+
+logger = logging.getLogger("profiler")
+logger.setLevel(logging.INFO)
+if not logger.handlers:
+ import sys
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s"))
+ logger.addHandler(handler)
+
+# Ring buffer for recent profiled requests
+recent_profiles = deque(maxlen=100)
+
+# 1. Monkey patch requests to measure all outbound HTTP API calls
+_original_request = requests.request
+
+def _monitored_request(*args, **kwargs):
+ start_time = time.time()
+ try:
+ return _original_request(*args, **kwargs)
+ finally:
+ duration = time.time() - start_time
+ if has_app_context():
+ if not hasattr(g, "external_time"):
+ g.external_time = 0.0
+ g.external_time += duration
+
+requests.request = _monitored_request
+
+# DB tracking helper
+def record_db_time(duration: float):
+ if has_app_context():
+ if not hasattr(g, "db_time"):
+ g.db_time = 0.0
+ g.db_time += duration
+
+def get_recent_profiles():
+ return list(recent_profiles)
+
+def setup_profiler(app):
+ @app.before_request
+ def start_timer():
+ g.start_time = time.time()
+ g.db_time = 0.0
+ g.external_time = 0.0
+
+ @app.after_request
+ def log_and_header(response):
+ if not hasattr(g, "start_time"):
+ return response
+
+ total_time = time.time() - g.start_time
+ db_time = getattr(g, "db_time", 0.0)
+ ext_time = getattr(g, "external_time", 0.0)
+ cpu_time = max(0.0, total_time - db_time - ext_time)
+
+ # Add latency/breakdown headers in milliseconds
+ import os
+ response.headers["X-Response-Time-Ms"] = f"{int(total_time * 1000)}"
+ response.headers["X-DB-Time-Ms"] = f"{int(db_time * 1000)}"
+ response.headers["X-External-Time-Ms"] = f"{int(ext_time * 1000)}"
+ response.headers["X-CPU-Process-Time-Ms"] = f"{int(cpu_time * 1000)}"
+ response.headers["X-Process-ID"] = str(os.getpid())
+
+ log_entry = {
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "method": request.method,
+ "path": request.path,
+ "status_code": response.status_code,
+ "total_ms": round(total_time * 1000, 1),
+ "db_ms": round(db_time * 1000, 1),
+ "external_ms": round(ext_time * 1000, 1),
+ "cpu_ms": round(cpu_time * 1000, 1)
+ }
+ recent_profiles.append(log_entry)
+
+ # Print directly to stdout/gunicorn logs
+ logger.info(
+ f"[PROFILER] {request.method} {request.path} ({response.status_code}) - "
+ f"Total: {log_entry['total_ms']}ms | "
+ f"DB: {log_entry['db_ms']}ms | "
+ f"External: {log_entry['external_ms']}ms | "
+ f"CPU/Internal: {log_entry['cpu_ms']}ms"
+ )
+ return response
diff --git a/backend/core/rate_limit.py b/backend/core/rate_limit.py
new file mode 100644
index 0000000000000000000000000000000000000000..6dbca4a9c7ba53f01f603bae7cab09b65162d5d9
--- /dev/null
+++ b/backend/core/rate_limit.py
@@ -0,0 +1,50 @@
+import time
+import threading
+from collections import defaultdict
+from flask import request
+from backend.config import Config
+
+rate_limit_store = {} # { "action:identifier": { "count": N, "reset_at": timestamp } }
+
+# Simple rate limiter to prevent IP scraping abuse
+IP_LIMITS = defaultdict(list)
+IP_LIMITS_LOCK = threading.Lock()
+
+def get_client_ip():
+ """Retrieve client IP, accounting for cloudflare proxy headers."""
+ return request.headers.get("CF-Connecting-IP") or request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr
+
+def check_rate_limit(action: str, identifier: str) -> bool:
+ """Returns True if rate limit exceeded, False if OK."""
+ if request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988":
+ return False
+ key = f"{action}:{identifier}"
+ now = time.time()
+ limit = Config.RATE_LIMITS.get(action, {"max": 100, "window": 60})
+
+ if key in rate_limit_store:
+ entry = rate_limit_store[key]
+ if now > entry["reset_at"]:
+ rate_limit_store[key] = {"count": 1, "reset_at": now + limit["window"]}
+ return False
+ if entry["count"] >= limit["max"]:
+ return True
+ entry["count"] += 1
+ return False
+ else:
+ rate_limit_store[key] = {"count": 1, "reset_at": now + limit["window"]}
+ return False
+
+def check_ip_rate_limit(ip: str, max_requests: int = 45, period: int = 60) -> bool:
+ """Thread-safe sliding window rate limit for book searches / API access."""
+ if request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988":
+ return True
+ now = time.time()
+ with IP_LIMITS_LOCK:
+ if ip not in IP_LIMITS:
+ IP_LIMITS[ip] = []
+ IP_LIMITS[ip] = [t for t in IP_LIMITS[ip] if now - t < period]
+ if len(IP_LIMITS[ip]) >= max_requests:
+ return False
+ IP_LIMITS[ip].append(now)
+ return True
diff --git a/backend/core/security.py b/backend/core/security.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b29d392b74114d30c7b5753c01d8c5896e79c7d
--- /dev/null
+++ b/backend/core/security.py
@@ -0,0 +1,227 @@
+import secrets
+import hashlib
+from datetime import datetime
+import bcrypt
+import jwt
+from backend.config import Config
+from backend.database.db_manager import get_user_db_conn
+
+def hash_password(password: str) -> str:
+ """Hash password with bcrypt."""
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
+
+def verify_password(password: str, hashed: str) -> bool:
+ """Verify password against bcrypt hash. Also supports legacy SHA-256."""
+ try:
+ if hashed.startswith("$2b$") or hashed.startswith("$2a$"):
+ return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
+ except Exception:
+ pass
+ # Fall back to legacy SHA-256 for old accounts
+ legacy_hash = hashlib.sha256(password.encode()).hexdigest()
+ return legacy_hash == hashed
+
+def upgrade_password_hash(user_id: int, password: str):
+ """Upgrade a user's password from SHA-256 to bcrypt."""
+ new_hash = hash_password(password)
+ conn = get_user_db_conn()
+ conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_id))
+ conn.commit()
+ conn.close()
+
+def create_access_token(user_id: int, username: str, vip_status: int) -> str:
+ """Create a short-lived access token (30 min)."""
+ payload = {
+ "sub": str(user_id),
+ "username": username,
+ "vip": vip_status,
+ "type": "access",
+ "exp": datetime.utcnow() + Config.JWT_ACCESS_TOKEN_EXPIRE,
+ "iat": datetime.utcnow()
+ }
+ return jwt.encode(payload, Config.JWT_SECRET, algorithm=Config.JWT_ALGORITHM)
+
+def create_refresh_token(user_id: int) -> str:
+ """Create a long-lived refresh token (7 days), stored in DB."""
+ token_str = secrets.token_urlsafe(64)
+ expires_at = datetime.utcnow() + Config.JWT_REFRESH_TOKEN_EXPIRE
+ conn = get_user_db_conn()
+ conn.execute(
+ "INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
+ (user_id, token_str, expires_at.strftime("%Y-%m-%d %H:%M:%S"))
+ )
+ conn.commit()
+ conn.close()
+ return token_str
+
+def verify_access_token(token: str):
+ """Verify and decode an access token. Returns payload or None."""
+ try:
+ payload = jwt.decode(token, Config.JWT_SECRET, algorithms=[Config.JWT_ALGORITHM])
+ if payload.get("type") != "access":
+ print(f"[DEBUG JWT] Token type is not access: {payload.get('type')}", flush=True)
+ return None
+ return payload
+ except jwt.ExpiredSignatureError as e:
+ print(f"[DEBUG JWT] Token expired: {e}", flush=True)
+ return None
+ except jwt.InvalidTokenError as e:
+ print(f"[DEBUG JWT] Invalid token: {e}", flush=True)
+ return None
+ except Exception as e:
+ print(f"[DEBUG JWT] Decode failed: {e}", flush=True)
+ return None
+
+import base64
+from cryptography.fernet import Fernet
+
+def _get_fernet_key() -> bytes:
+ key_hash = hashlib.sha256(Config.SECRET_KEY.encode()).digest()
+ return base64.urlsafe_b64encode(key_hash)
+
+def encrypt_message(message: str) -> str:
+ """Encrypt message text using symmetric AES encryption."""
+ if not message:
+ return ""
+ try:
+ f = Fernet(_get_fernet_key())
+ return f.encrypt(message.encode('utf-8')).decode('utf-8')
+ except Exception as e:
+ return message
+
+def decrypt_message(encrypted_message: str) -> str:
+ """Decrypt message text. Falls back to original text if not encrypted or fails."""
+ if not encrypted_message:
+ return ""
+ try:
+ f = Fernet(_get_fernet_key())
+ return f.decrypt(encrypted_message.encode('utf-8')).decode('utf-8')
+ except Exception:
+ return encrypted_message
+
+import os
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
+from cryptography.hazmat.primitives import serialization, hashes
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+KEYS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "keys")
+PRIVATE_KEY_PATH = os.path.join(KEYS_DIR, "private_key.pem")
+PUBLIC_KEY_PATH = os.path.join(KEYS_DIR, "public_key.pem")
+
+def get_or_create_rsa_keys():
+ """Get RSA keys or generate if they don't exist."""
+ if not os.path.exists(KEYS_DIR):
+ os.makedirs(KEYS_DIR, exist_ok=True)
+
+ if not os.path.exists(PRIVATE_KEY_PATH) or not os.path.exists(PUBLIC_KEY_PATH):
+ # Generate private key
+ private_key = rsa.generate_private_key(
+ public_exponent=65537,
+ key_size=2048
+ )
+ # Write private key
+ with open(PRIVATE_KEY_PATH, "wb") as f:
+ f.write(
+ private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption()
+ )
+ )
+ # Generate and write public key
+ public_key = private_key.public_key()
+ with open(PUBLIC_KEY_PATH, "wb") as f:
+ f.write(
+ public_key.public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
+ )
+ )
+
+def encrypt_asymmetric_hybrid(plaintext: str) -> dict:
+ """
+ Encrypt text using Hybrid Encryption (AES-256-GCM + RSA-2048).
+ Returns a dict with base64 encoded parts.
+ """
+ if not plaintext:
+ return {"ciphertext": "", "encrypted_key": "", "nonce": "", "tag": ""}
+
+ # Ensure keys exist
+ get_or_create_rsa_keys()
+
+ # 1. Generate random 256-bit AES key and 12-byte nonce
+ aes_key = os.urandom(32)
+ nonce = os.urandom(12)
+
+ # 2. Encrypt plaintext using AES-GCM
+ cipher = Cipher(algorithms.AES(aes_key), modes.GCM(nonce))
+ encryptor = cipher.encryptor()
+ ciphertext = encryptor.update(plaintext.encode('utf-8')) + encryptor.finalize()
+ tag = encryptor.tag
+
+ # 3. Encrypt the AES key using RSA public key
+ with open(PUBLIC_KEY_PATH, "rb") as f:
+ public_key = serialization.load_pem_public_key(f.read())
+
+ encrypted_aes_key = public_key.encrypt(
+ aes_key,
+ padding.OAEP(
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
+ algorithm=hashes.SHA256(),
+ label=None
+ )
+ )
+
+ # 4. Return all parts as base64 string
+ return {
+ "ciphertext": base64.b64encode(ciphertext).decode('utf-8'),
+ "encrypted_key": base64.b64encode(encrypted_aes_key).decode('utf-8'),
+ "nonce": base64.b64encode(nonce).decode('utf-8'),
+ "tag": base64.b64encode(tag).decode('utf-8')
+ }
+
+def decrypt_asymmetric_hybrid(ciphertext_b64: str, encrypted_key_b64: str, nonce_b64: str, tag_b64: str) -> str:
+ """
+ Decrypt asymmetric hybrid encrypted payload.
+ """
+ if not ciphertext_b64 or not encrypted_key_b64:
+ return ""
+
+ # Check if private key exists
+ if not os.path.exists(PRIVATE_KEY_PATH):
+ print("[SECURITY] Private key does not exist. Decryption failed.", flush=True)
+ return ""
+
+ try:
+ # Decode base64 parts
+ ciphertext = base64.b64decode(ciphertext_b64.encode('utf-8'))
+ encrypted_aes_key = base64.b64decode(encrypted_key_b64.encode('utf-8'))
+ nonce = base64.b64decode(nonce_b64.encode('utf-8'))
+ tag = base64.b64decode(tag_b64.encode('utf-8'))
+
+ # 1. Decrypt AES key using RSA private key
+ with open(PRIVATE_KEY_PATH, "rb") as f:
+ private_key = serialization.load_pem_private_key(f.read(), password=None)
+
+ aes_key = private_key.decrypt(
+ encrypted_aes_key,
+ padding.OAEP(
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
+ algorithm=hashes.SHA256(),
+ label=None
+ )
+ )
+
+ # 2. Decrypt ciphertext using AES-GCM
+ cipher = Cipher(algorithms.AES(aes_key), modes.GCM(nonce, tag))
+ decryptor = cipher.decryptor()
+ decrypted_bytes = decryptor.update(ciphertext) + decryptor.finalize()
+
+ return decrypted_bytes.decode('utf-8')
+ except Exception as e:
+ import traceback
+ print(f"[SECURITY] Hybrid decryption failed: {e}", flush=True)
+ traceback.print_exc()
+ return ""
+
+
diff --git a/backend/core/watchdog.py b/backend/core/watchdog.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb8090c3ab4f3a77207de5aa3c91ed92b01a521f
--- /dev/null
+++ b/backend/core/watchdog.py
@@ -0,0 +1,142 @@
+"""
+backend/core/watchdog.py
+=========================
+Watchdog / Keepalive daemon cho Hugging Face Space.
+
+Hugging Face Free Tier ngủ đông (sleep) sau ~15 phút không có traffic.
+Module này:
+ 1. Tự ping /health mỗi 10 phút để giữ Space thức.
+ 2. Giám sát RAM / Disk và log cảnh báo.
+ 3. Tự động restart gunicorn worker nếu process bị zombie (graceful restart).
+ 4. Flush log + commit DB tạm thời khi nhận SIGTERM từ HF.
+
+Sử dụng trong backend/__init__.py:
+ from backend.core.watchdog import start_watchdog
+ start_watchdog(app_url="https://cong123779-tienhiep-backend.hf.space")
+"""
+import os
+import time
+import threading
+import logging
+import signal
+import urllib.request
+
+logger = logging.getLogger("watchdog")
+
+_watchdog_started = False
+_stop_event = threading.Event()
+
+
+def _ping_self(url: str) -> bool:
+ """Ping /health endpoint để HF Space không bị sleep."""
+ try:
+ req = urllib.request.Request(
+ f"{url.rstrip('/')}/health",
+ headers={"User-Agent": "TienhiepWatchdog/1.0"}
+ )
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ return resp.status == 200
+ except Exception as e:
+ logger.warning(f"[Watchdog] Ping failed: {e}")
+ return False
+
+
+def _log_resources():
+ """Log mức sử dụng RAM và Disk."""
+ try:
+ import psutil
+ mem = psutil.virtual_memory()
+ disk = psutil.disk_usage("/")
+ cpu = psutil.cpu_percent(interval=0.1)
+ logger.info(
+ f"[Watchdog] RAM: {mem.percent:.1f}% ({mem.used // 1024 // 1024}MB / {mem.total // 1024 // 1024}MB) | "
+ f"Disk: {disk.percent:.1f}% | CPU: {cpu:.1f}%"
+ )
+ # Cảnh báo khi RAM > 85%
+ if mem.percent > 85:
+ logger.warning(f"[Watchdog] ⚠️ HIGH RAM USAGE: {mem.percent:.1f}%!")
+ if disk.percent > 90:
+ logger.warning(f"[Watchdog] ⚠️ HIGH DISK USAGE: {disk.percent:.1f}%!")
+ except ImportError:
+ pass # psutil không có trên một số môi trường
+ except Exception as e:
+ logger.debug(f"[Watchdog] Resource check error: {e}")
+
+
+def _handle_sigterm(signum, frame):
+ """Graceful shutdown khi HF Space restart container."""
+ logger.info("[Watchdog] SIGTERM received — graceful shutdown initiated.")
+ _stop_event.set()
+ # Flush log handlers
+ for handler in logging.root.handlers:
+ handler.flush()
+
+
+def _watchdog_loop(app_url: str, ping_interval: int, resource_interval: int):
+ """Main watchdog loop chạy trong background thread."""
+ ping_counter = 0
+ resource_counter = 0
+
+ logger.info(f"[Watchdog] 🐕 Started. Ping interval: {ping_interval}s | "
+ f"Resource check: {resource_interval}s | Target: {app_url}")
+
+ while not _stop_event.is_set():
+ time.sleep(30) # Check mỗi 30 giây
+ ping_counter += 30
+ resource_counter += 30
+
+ if ping_counter >= ping_interval:
+ ping_counter = 0
+ ok = _ping_self(app_url)
+ logger.info(f"[Watchdog] Keepalive ping → {'✅ OK' if ok else '❌ FAILED'}")
+
+ if resource_counter >= resource_interval:
+ resource_counter = 0
+ _log_resources()
+
+ logger.info("[Watchdog] 🛑 Stopped.")
+
+
+def start_watchdog(
+ app_url: str = None,
+ ping_interval: int = 600, # Ping mỗi 10 phút
+ resource_interval: int = 300, # Check resource mỗi 5 phút
+):
+ """
+ Khởi động watchdog daemon thread.
+
+ Args:
+ app_url: URL public của HF Space (vd: "https://cong123779-tienhiep-backend.hf.space")
+ Nếu None, tự detect từ env SPACE_HOST.
+ ping_interval: Số giây giữa các lần ping keepalive (mặc định 600s = 10 phút)
+ resource_interval: Số giây giữa các lần log resource (mặc định 300s = 5 phút)
+ """
+ global _watchdog_started
+ if _watchdog_started:
+ logger.debug("[Watchdog] Already running, skip.")
+ return
+
+ # Detect URL từ HF environment nếu không truyền
+ if not app_url:
+ space_host = os.environ.get("SPACE_HOST", "")
+ if space_host:
+ app_url = f"https://{space_host}"
+ else:
+ logger.info("[Watchdog] No SPACE_HOST env, running in local mode (no ping).")
+ app_url = "http://localhost:7860"
+
+ # Register SIGTERM handler để graceful shutdown
+ try:
+ signal.signal(signal.SIGTERM, _handle_sigterm)
+ except (ValueError, OSError):
+ pass # Không register được trong thread con
+
+ thread = threading.Thread(
+ target=_watchdog_loop,
+ args=(app_url, ping_interval, resource_interval),
+ daemon=True,
+ name="WatchdogKeepAlive"
+ )
+ thread.start()
+ _watchdog_started = True
+ logger.info(f"[Watchdog] 🐕 Daemon started (thread: {thread.name})")
diff --git a/backend/database/__init__.py b/backend/database/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..48e9d38dab9426e9b40c650a2492fd17fd59f82b
--- /dev/null
+++ b/backend/database/__init__.py
@@ -0,0 +1 @@
+# backend/database/__init__.py
diff --git a/backend/database/db_manager.py b/backend/database/db_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..93b5479a6189f77bed0353564683d38d24b92d21
--- /dev/null
+++ b/backend/database/db_manager.py
@@ -0,0 +1,970 @@
+import os
+import sys
+import time
+import sqlite3
+import threading
+import logging
+from collections import OrderedDict
+from backend.config import Config
+
+logger = logging.getLogger("db_manager")
+logger.setLevel(logging.INFO)
+if not logger.handlers:
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(logging.Formatter(
+ "[%(asctime)s] %(levelname)s [db_manager] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S"
+ ))
+ logger.addHandler(handler)
+
+DATABASE_URL = os.environ.get("DATABASE_URL", "")
+USE_LOCAL_SQLITE = os.environ.get("USE_LOCAL_SQLITE", "false").lower() == "true"
+if USE_LOCAL_SQLITE:
+ DATABASE_URL = ""
+USER_DB_PATH = Config.USER_DB_PATH
+
+# Retry & circuit breaker settings
+MAX_RETRIES = 3
+RETRY_BASE_DELAY = 0.5 # seconds
+CIRCUIT_BREAKER_THRESHOLD = 5 # consecutive failures before opening circuit
+CIRCUIT_BREAKER_RESET = 30 # seconds before retrying after circuit opens
+
+# Circuit Breaker State
+_lock = threading.Lock()
+_failure_count = 0
+_circuit_open_until = 0.0 # timestamp when circuit should try again
+_last_mode = "unknown" # track for logging
+
+def _is_circuit_open():
+ """Check if the circuit breaker is open (cloud DB unavailable)."""
+ global _failure_count, _circuit_open_until
+ with _lock:
+ if _failure_count >= CIRCUIT_BREAKER_THRESHOLD:
+ if time.time() < _circuit_open_until:
+ return True
+ return False
+ return False
+
+def _record_success():
+ """Record a successful cloud DB connection — reset circuit breaker."""
+ global _failure_count, _circuit_open_until, _last_mode
+ with _lock:
+ if _failure_count > 0:
+ logger.info("✔ Supabase connection restored. Resetting circuit breaker.")
+ _failure_count = 0
+ _circuit_open_until = 0.0
+ _last_mode = "postgres"
+
+def _record_failure():
+ """Record a failed cloud DB connection — increment circuit breaker."""
+ global _failure_count, _circuit_open_until, _last_mode
+ with _lock:
+ _failure_count += 1
+ if _failure_count >= CIRCUIT_BREAKER_THRESHOLD:
+ _circuit_open_until = time.time() + CIRCUIT_BREAKER_RESET
+ if _last_mode != "sqlite_fallback":
+ logger.warning(
+ f"⚡ Circuit breaker OPEN after {_failure_count} failures. "
+ f"Falling back to SQLite for {CIRCUIT_BREAKER_RESET}s."
+ )
+ _last_mode = "sqlite_fallback"
+
+# PostgreSQL wrappers
+class PgCursorWrapper:
+ def __init__(self, pg_cursor, lastrowid=None, conn_wrapper=None):
+ self._cur = pg_cursor
+ self._lastrowid = lastrowid
+ self._conn_wrapper = conn_wrapper
+
+ def execute(self, sql, parameters=None):
+ if self._conn_wrapper:
+ sql = self._conn_wrapper._translate_sql(sql)
+ if parameters:
+ self._cur.execute(sql, parameters)
+ else:
+ self._cur.execute(sql)
+ return self
+
+ def fetchone(self):
+ return self._cur.fetchone()
+
+ def fetchall(self):
+ return self._cur.fetchall()
+
+ @property
+ def lastrowid(self):
+ return self._lastrowid
+
+ @property
+ def description(self):
+ return self._cur.description
+
+class PgConnectionWrapper:
+ def __init__(self, pg_conn, pool=None):
+ self._conn = pg_conn
+ self._pool = pool
+
+ @property
+ def row_factory(self):
+ return None
+
+ @row_factory.setter
+ def row_factory(self, val):
+ pass
+
+ def cursor(self):
+ import psycopg2.extras
+ cursor = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
+ return PgCursorWrapper(cursor, conn_wrapper=self)
+
+ def execute(self, sql, parameters=None):
+ sql_pg = self._translate_sql(sql)
+ is_insert = sql_pg.strip().upper().startswith("INSERT")
+ if is_insert and "RETURNING" not in sql_pg.upper():
+ if "USER_SETTINGS" in sql_pg.upper():
+ sql_pg += " RETURNING user_id"
+ else:
+ sql_pg += " RETURNING id"
+
+ import psycopg2.extras
+ cursor = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
+ sql_upper = sql_pg.upper()
+
+ start_time = time.time()
+ try:
+ if parameters:
+ cursor.execute(sql_pg, parameters)
+ elif "CREATE TABLE" in sql_upper or "ALTER TABLE" in sql_upper:
+ try:
+ cursor.execute("SAVEPOINT sp_ddl")
+ cursor.execute(sql_pg)
+ cursor.execute("RELEASE SAVEPOINT sp_ddl")
+ except Exception as e:
+ cursor.execute("ROLLBACK TO SAVEPOINT sp_ddl")
+ cursor.execute("RELEASE SAVEPOINT sp_ddl")
+ raise
+ else:
+ cursor.execute(sql_pg)
+ finally:
+ try:
+ from backend.core.profiler import record_db_time
+ record_db_time(time.time() - start_time)
+ except Exception:
+ pass
+
+ lastrowid = None
+ if is_insert:
+ try:
+ row = cursor.fetchone()
+ if row:
+ lastrowid = row[0]
+ except Exception:
+ pass
+
+ return PgCursorWrapper(cursor, lastrowid)
+
+ def commit(self):
+ start_time = time.time()
+ try:
+ self._conn.commit()
+ finally:
+ try:
+ from backend.core.profiler import record_db_time
+ record_db_time(time.time() - start_time)
+ except Exception:
+ pass
+
+ def close(self):
+ if self._pool is not None:
+ try:
+ try:
+ self._conn.rollback()
+ except Exception:
+ pass
+ self._pool.putconn(self._conn)
+ except Exception as e:
+ logger.error(f"Error returning connection to pool: {e}")
+ try:
+ self._conn.close()
+ except Exception:
+ pass
+ else:
+ self._conn.close()
+
+ @staticmethod
+ def _translate_sql(sql):
+ import re
+ if re.search(r"INSERT\s+OR\s+IGNORE\s+INTO\s+bookshelf", sql, re.IGNORECASE):
+ sql = re.sub(r"INSERT\s+OR\s+IGNORE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
+ if "ON CONFLICT" not in sql.upper():
+ sql += " ON CONFLICT (user_id, book_id) DO NOTHING"
+ elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+bookshelf", sql, re.IGNORECASE):
+ sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
+ if "ON CONFLICT" not in sql.upper():
+ sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, url = EXCLUDED.url"
+ elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+reading_history", sql, re.IGNORECASE):
+ sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
+ if "ON CONFLICT" not in sql.upper():
+ if "URL" in sql.upper():
+ 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"
+ else:
+ 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"
+ elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+friendships", sql, re.IGNORECASE):
+ sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
+ if "ON CONFLICT" not in sql.upper():
+ sql += " ON CONFLICT (user_id, friend_id) DO UPDATE SET status = EXCLUDED.status"
+
+ sql_upper = sql.upper()
+ if "CREATE TABLE" in sql_upper:
+ sql = sql.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ")
+ sql = sql.replace("CREATE TABLE IF NOT EXISTS IF NOT EXISTS",
+ "CREATE TABLE IF NOT EXISTS")
+ sql = sql.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "SERIAL PRIMARY KEY")
+ sql = sql.replace("DATETIME DEFAULT CURRENT_TIMESTAMP",
+ "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
+ sql = sql.replace("DATETIME", "TIMESTAMP")
+
+ # Translate LIKE to ILIKE for case-insensitive matching on PostgreSQL
+ if " LIKE " in sql_upper:
+ sql = re.sub(r"\bLIKE\b", "ILIKE", sql, flags=re.IGNORECASE)
+
+ sql = sql.replace('?', '%s')
+ return sql
+
+_pg_pool = None
+_pool_lock = threading.Lock()
+
+def _get_pg_pool():
+ global _pg_pool
+ if not DATABASE_URL:
+ return None
+ if _pg_pool is not None:
+ return _pg_pool
+ with _pool_lock:
+ if _pg_pool is None:
+ try:
+ from psycopg2.pool import ThreadedConnectionPool
+ _pg_pool = ThreadedConnectionPool(2, 20, DATABASE_URL, connect_timeout=5)
+ logger.info("✔ Threaded Supabase connection pool initialized successfully.")
+ except Exception as e:
+ logger.error(f"Failed to initialize Threaded connection pool: {e}")
+ _pg_pool = None
+ return _pg_pool
+
+def _try_postgres_connect():
+ pool = _get_pg_pool()
+ if pool is None:
+ raise Exception("Supabase Connection Pool not initialized.")
+
+ conn = None
+ for i in range(3):
+ try:
+ conn = pool.getconn()
+ with conn.cursor() as cur:
+ cur.execute("SELECT 1")
+ break
+ except Exception as e:
+ logger.warning(f"Got unhealthy connection from pool: {e}. Closing and retrying...")
+ if conn:
+ try:
+ pool.putconn(conn, close=True)
+ except Exception:
+ pass
+ conn = None
+
+ if conn is None:
+ raise Exception("Failed to obtain a healthy connection from the pool.")
+
+ return PgConnectionWrapper(conn, pool)
+
+class ProfiledSqliteConnection(sqlite3.Connection):
+ def execute(self, sql, *args):
+ start_time = time.time()
+ try:
+ return super().execute(sql, *args)
+ finally:
+ try:
+ from backend.core.profiler import record_db_time
+ record_db_time(time.time() - start_time)
+ except Exception:
+ pass
+
+def _get_sqlite_conn():
+ conn = sqlite3.connect(USER_DB_PATH, timeout=10, factory=ProfiledSqliteConnection)
+ conn.execute("PRAGMA synchronous=NORMAL;")
+ conn.execute("PRAGMA synchronous=NORMAL;")
+ conn.execute("PRAGMA cache_size=-64000;")
+ conn.execute("PRAGMA temp_store=MEMORY;")
+ conn.row_factory = sqlite3.Row
+ return conn
+
+def get_user_db_conn():
+ """Get user database connection with automatic failover."""
+ if not DATABASE_URL:
+ return _get_sqlite_conn()
+ if _is_circuit_open():
+ return _get_sqlite_conn()
+
+ last_error = None
+ for attempt in range(MAX_RETRIES):
+ try:
+ conn = _try_postgres_connect()
+ _record_success()
+ return conn
+ except Exception as e:
+ last_error = e
+ delay = RETRY_BASE_DELAY * (2 ** attempt)
+ if attempt < MAX_RETRIES - 1:
+ logger.warning(
+ f"Supabase connection attempt {attempt + 1}/{MAX_RETRIES} failed: "
+ f"{e}. Retrying in {delay:.1f}s..."
+ )
+ time.sleep(delay)
+
+ _record_failure()
+ logger.error(f"Supabase unreachable: {last_error}. Using SQLite fallback.")
+ return _get_sqlite_conn()
+
+def health_check():
+ status = {
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
+ "database": {
+ "primary": "supabase_postgres" if DATABASE_URL else "sqlite_only",
+ "fallback": "sqlite_local",
+ "circuit_breaker": {
+ "state": "open" if _is_circuit_open() else "closed",
+ "failure_count": _failure_count,
+ "threshold": CIRCUIT_BREAKER_THRESHOLD,
+ },
+ },
+ "status": "healthy",
+ }
+ if DATABASE_URL and not _is_circuit_open():
+ try:
+ conn = _try_postgres_connect()
+ conn.close() # Returns to pool via PgConnectionWrapper
+ status["database"]["primary_reachable"] = True
+ except Exception as e:
+ status["database"]["primary_reachable"] = False
+ status["database"]["primary_error"] = str(e)
+ status["status"] = "degraded"
+ elif _is_circuit_open():
+ status["database"]["primary_reachable"] = False
+ status["status"] = "degraded"
+
+ # Use a lightweight check without opening a new connection
+ try:
+ if os.path.exists(USER_DB_PATH) and os.path.getsize(USER_DB_PATH) > 0:
+ status["database"]["fallback_reachable"] = True
+ else:
+ status["database"]["fallback_reachable"] = False
+ status["status"] = "degraded"
+ except Exception as e:
+ status["database"]["fallback_reachable"] = False
+ status["database"]["fallback_error"] = str(e)
+ status["status"] = "unhealthy"
+
+ return status
+
+def _init_db_schema_for_conn(conn):
+ is_postgres = (type(conn).__name__ == "PgConnectionWrapper")
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE,
+ password_hash TEXT,
+ email TEXT,
+ phone TEXT,
+ google_id TEXT,
+ vip_status INTEGER DEFAULT 0,
+ vip_plan TEXT,
+ vip_expiry DATETIME,
+ email_verified INTEGER DEFAULT 0,
+ require_password_change INTEGER DEFAULT 0,
+ display_name TEXT,
+ birthday TEXT,
+ gender TEXT,
+ bio TEXT,
+ avatar_frame TEXT DEFAULT 'default',
+ avatar TEXT,
+ two_factor INTEGER DEFAULT 0,
+ api_balance REAL DEFAULT 0.0,
+ user_code TEXT UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ # Run database migrations for users columns if needed
+ if is_postgres:
+ # Postgres columns check
+ cursor = conn.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'users'")
+ columns = [row[0] for row in cursor.fetchall()]
+ else:
+ # SQLite columns check
+ cursor = conn.execute("PRAGMA table_info(users)")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "email_verified" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0")
+ if "require_password_change" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN require_password_change INTEGER DEFAULT 0")
+ if "display_name" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT")
+ if "birthday" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN birthday TEXT")
+ if "gender" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN gender TEXT")
+ if "bio" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN bio TEXT")
+ if "avatar_frame" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN avatar_frame TEXT DEFAULT 'default'")
+ if "avatar" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN avatar TEXT")
+ if "two_factor" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN two_factor INTEGER DEFAULT 0")
+ if "api_balance" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN api_balance REAL DEFAULT 0.0")
+ if "user_code" not in columns:
+ conn.execute("ALTER TABLE users ADD COLUMN user_code TEXT")
+ # Backfill existing users with random 7-digit codes
+ import random, string
+ existing_users = conn.execute("SELECT id FROM users WHERE user_code IS NULL").fetchall()
+ for u in existing_users:
+ while True:
+ code = ''.join(random.choices(string.digits, k=7))
+ clash = conn.execute("SELECT 1 FROM users WHERE user_code = ?", (code,)).fetchone()
+ if not clash:
+ conn.execute("UPDATE users SET user_code = ? WHERE id = ?", (code, u['id']))
+ break
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS bookshelf (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ book_id INTEGER,
+ title TEXT,
+ cover TEXT,
+ author TEXT,
+ url TEXT,
+ added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id, book_id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS reading_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ book_id INTEGER,
+ title TEXT,
+ cover TEXT,
+ author TEXT,
+ last_chapter TEXT,
+ read_date TEXT,
+ url TEXT,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id, book_id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS payments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ order_id TEXT UNIQUE NOT NULL,
+ plan TEXT NOT NULL,
+ amount INTEGER NOT NULL,
+ status TEXT DEFAULT 'pending',
+ payment_method TEXT DEFAULT 'vietqr',
+ note TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ completed_at DATETIME,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ token TEXT UNIQUE NOT NULL,
+ expires_at DATETIME NOT NULL,
+ used INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ token TEXT UNIQUE NOT NULL,
+ expires_at DATETIME NOT NULL,
+ revoked INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS api_keys (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ api_key TEXT UNIQUE NOT NULL,
+ name TEXT DEFAULT 'Default Key',
+ status TEXT DEFAULT 'active',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_used_at DATETIME,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS api_usage (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ api_key TEXT NOT NULL,
+ model TEXT NOT NULL,
+ tokens INTEGER DEFAULT 0,
+ cost REAL DEFAULT 0.0,
+ status_code INTEGER,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (api_key) REFERENCES api_keys(api_key)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS translation_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ original_text TEXT NOT NULL,
+ translated_text TEXT NOT NULL,
+ mode TEXT DEFAULT 'fast',
+ characters INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS vocabulary (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ original_text TEXT NOT NULL,
+ pinyin_or_hanviet TEXT,
+ translation TEXT NOT NULL,
+ context_sentence TEXT,
+ notes TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS user_settings (
+ user_id INTEGER PRIMARY KEY,
+ theme TEXT DEFAULT 'dark',
+ default_language TEXT DEFAULT 'vi',
+ auto_read INTEGER DEFAULT 0,
+ font_size INTEGER DEFAULT 14,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS usage_tracking (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ source TEXT NOT NULL,
+ action TEXT NOT NULL,
+ duration INTEGER DEFAULT 0,
+ mode TEXT DEFAULT 'online',
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS login_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ ip_address TEXT,
+ user_agent TEXT,
+ os TEXT,
+ browser TEXT,
+ device_type TEXT,
+ token TEXT,
+ status TEXT DEFAULT 'active',
+ login_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+ last_active DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS friendships (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ friend_id INTEGER NOT NULL,
+ status TEXT DEFAULT 'pending',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (friend_id) REFERENCES users(id),
+ UNIQUE(user_id, friend_id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS direct_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sender_id INTEGER NOT NULL,
+ receiver_id INTEGER NOT NULL,
+ message TEXT NOT NULL,
+ is_read INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (sender_id) REFERENCES users(id),
+ FOREIGN KEY (receiver_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS personal_notifications (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ sender_id INTEGER,
+ type TEXT NOT NULL,
+ message TEXT NOT NULL,
+ related_id INTEGER,
+ is_read INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ FOREIGN KEY (sender_id) REFERENCES users(id)
+ )
+ """)
+
+ for table in ["bookshelf", "reading_history"]:
+ try:
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN url TEXT")
+ except Exception:
+ pass
+
+ new_user_cols = [
+ ("email", "TEXT"),
+ ("phone", "TEXT"),
+ ("google_id", "TEXT"),
+ ("vip_plan", "TEXT"),
+ ("vip_expiry", "DATETIME"),
+ ("api_balance", "REAL DEFAULT 0.0"),
+ ]
+ for col_name, col_type in new_user_cols:
+ try:
+ conn.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}")
+ except Exception:
+ pass
+
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
+ conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_user_code ON users(user_code)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON reading_history(user_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_history_url ON reading_history(url)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_bookshelf_user_id ON bookshelf(user_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_translation_history_user_id ON translation_history(user_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_vocabulary_user_id ON vocabulary(user_id)")
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS sects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE,
+ slogan TEXT,
+ announcement TEXT,
+ badge TEXT,
+ leader_id INTEGER,
+ level INTEGER DEFAULT 1,
+ contribution INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS sect_members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sect_id INTEGER,
+ user_id INTEGER UNIQUE,
+ role TEXT DEFAULT 'member',
+ contribution INTEGER DEFAULT 0,
+ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS sect_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sect_id INTEGER,
+ sender_id INTEGER,
+ message TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
+ FOREIGN KEY (sender_id) REFERENCES users(id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS sect_join_requests (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sect_id INTEGER,
+ user_id INTEGER,
+ status TEXT DEFAULT 'pending',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ UNIQUE(sect_id, user_id)
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS sect_books (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sect_id INTEGER,
+ book_id INTEGER,
+ added_by INTEGER,
+ added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
+ FOREIGN KEY (added_by) REFERENCES users(id),
+ UNIQUE(sect_id, book_id)
+ )
+ """)
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS sect_chat_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sect_id INTEGER,
+ name TEXT,
+ creator_id INTEGER,
+ members_csv TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
+ FOREIGN KEY (creator_id) REFERENCES users(id)
+ )
+ """)
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS book_comments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ book_id INTEGER NOT NULL,
+ user_id INTEGER,
+ is_anonymous INTEGER DEFAULT 0,
+ content_ciphertext TEXT NOT NULL,
+ encrypted_aes_key TEXT NOT NULL,
+ aes_nonce TEXT NOT NULL,
+ aes_tag TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+
+
+ try:
+ conn.execute("ALTER TABLE sects ADD COLUMN announcement TEXT")
+ except Exception:
+ pass
+
+ for col_name, col_type in [("chat_type", "TEXT DEFAULT 'general'"), ("target_id", "INTEGER"), ("group_id", "INTEGER")]:
+ try:
+ conn.execute(f"ALTER TABLE sect_messages ADD COLUMN {col_name} {col_type}")
+ except Exception:
+ pass
+
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_members_sect_id ON sect_members(sect_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_members_user_id ON sect_members(user_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_messages_sect_id ON sect_messages(sect_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_books_sect_id ON sect_books(sect_id)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_join_requests_sect_id ON sect_join_requests(sect_id)")
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS app_releases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ platform TEXT,
+ version TEXT,
+ download_url TEXT,
+ patch_url TEXT,
+ file_size TEXT,
+ release_notes TEXT,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(platform, version)
+ )
+ """)
+ # Migration: thêm cột patch_url cho bảng cũ nếu chưa có
+ try:
+ conn.execute("ALTER TABLE app_releases ADD COLUMN patch_url TEXT")
+ except Exception:
+ pass # Cột đã tồn tại
+ try:
+ existing = conn.execute("SELECT COUNT(*) as count FROM app_releases").fetchone()
+ count = 0
+ if existing:
+ if isinstance(existing, dict):
+ count = existing.get('count', 0)
+ elif hasattr(existing, 'keys'):
+ count = existing['count']
+ else:
+ count = existing[0]
+ if count == 0:
+ conn.execute("""
+ INSERT INTO app_releases (platform, version, download_url, file_size, release_notes)
+ VALUES
+ ('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'),
+ ('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'),
+ ('desktop_windows', '0.0.0', '#', '0 MB', 'Bản Windows Setup .EXE chính thức sắp ra mắt')
+ """)
+ except Exception as e:
+ logger.warning(f"Failed to seed app_releases: {e}")
+
+ conn.commit()
+
+
+def init_user_db():
+ conn = get_user_db_conn()
+ try:
+ _init_db_schema_for_conn(conn)
+ logger.info("✔ Database initialized successfully.")
+ except Exception as e:
+ logger.error(f"❌ Database initialization failed: {e}")
+ raise
+ finally:
+ conn.close()
+
+ is_postgres = (type(conn).__name__ == "PgConnectionWrapper")
+ if is_postgres:
+ try:
+ sqlite_conn = sqlite3.connect(USER_DB_PATH)
+ sqlite_conn.row_factory = sqlite3.Row
+ _init_db_schema_for_conn(sqlite_conn)
+ sqlite_conn.close()
+ logger.info("✔ SQLite fallback database synchronized successfully.")
+ except Exception as sqlite_err:
+ logger.warning(f"⚠️ Could not synchronize SQLite fallback database schema: {sqlite_err}")
+
+# ---------------------------------------------------------------------------
+# Books SQLite databases thread local caching
+# ---------------------------------------------------------------------------
+thread_local = threading.local()
+
+class SimpleCache:
+ def __init__(self, maxsize=500):
+ self.cache = OrderedDict()
+ self.maxsize = maxsize
+ self.lock = threading.Lock()
+
+ def get(self, key):
+ with self.lock:
+ if key in self.cache:
+ self.cache.move_to_end(key)
+ return self.cache[key]
+ return None
+
+ def set(self, key, value):
+ with self.lock:
+ if key in self.cache:
+ self.cache.move_to_end(key)
+ self.cache[key] = value
+ if len(self.cache) > self.maxsize:
+ self.cache.popitem(last=False)
+
+count_cache = SimpleCache(maxsize=1000)
+query_cache = SimpleCache(maxsize=1000)
+cached_stats = None
+
+# Cache the resolved DB path to avoid re-reading YAML config on every call
+_cached_db_path = None
+_cached_db_path_time = 0
+_DB_PATH_CACHE_DURATION = 60 # Re-check config every 60 seconds
+
+def ensure_db_exists(db_path):
+ """Lazy download book SQLite databases from HuggingFace dataset if missing or incomplete."""
+ if os.path.exists(db_path) and os.path.getsize(db_path) > 1024 * 1024:
+ return
+
+ filename = os.path.basename(db_path)
+ token = os.environ.get("HF_TOKEN")
+ if not token:
+ logger.warning(f"⚠️ HF_TOKEN not set, cannot download database file: {filename}")
+ return
+
+ logger.info(f"⏳ Lazy-downloading database from HF: {filename}...")
+ try:
+ from huggingface_hub import hf_hub_download
+ hf_hub_download(
+ repo_id="Cong123779/tienhiep-data",
+ filename=filename,
+ repo_type="dataset",
+ token=token,
+ local_dir=Config.ROOT_DIR,
+ local_dir_use_symlinks=False,
+ )
+ logger.info(f"✔ Successfully downloaded {filename}")
+ except Exception as e:
+ logger.error(f"❌ Failed to lazy-download database {filename}: {e}")
+
+def get_db():
+ global _cached_db_path, _cached_db_path_time
+
+ now = time.time()
+ if _cached_db_path is None or (now - _cached_db_path_time) > _DB_PATH_CACHE_DURATION:
+ db_path = Config.DB_PATH
+ try:
+ config_path = os.path.join(Config.ROOT_DIR, "quick_translator", "config.yaml")
+ if os.path.exists(config_path):
+ import yaml
+ with open(config_path, "r", encoding="utf-8") as f:
+ config = yaml.safe_load(f)
+ mode = config.get("translation", {}).get("mode", "advanced")
+ candidate_db = os.path.join(Config.ROOT_DIR, f"merged_books_{mode}.db")
+ ensure_db_exists(candidate_db)
+ if os.path.exists(candidate_db):
+ db_path = candidate_db
+ except Exception as e:
+ print(f"[WARNING] Error loading dynamic database path: {e}")
+
+ if db_path == Config.DB_PATH:
+ ensure_db_exists(db_path)
+
+ _cached_db_path = db_path
+ _cached_db_path_time = now
+ else:
+ db_path = _cached_db_path
+
+ if not hasattr(thread_local, "connections"):
+ thread_local.connections = {}
+
+ if db_path not in thread_local.connections:
+ # Final fallback check
+ if not os.path.exists(db_path):
+ ensure_db_exists(db_path)
+
+ if not os.path.exists(db_path):
+ # Create empty database to prevent crash
+ logger.warning(f"Creating fallback empty database for {db_path}")
+ conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
+ try:
+ conn.execute("CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY, title TEXT, author TEXT, categories TEXT, chapters_max INTEGER);")
+ except Exception:
+ pass
+ else:
+ conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
+
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA synchronous=NORMAL;")
+ conn.execute("PRAGMA cache_size=-30000;")
+ conn.execute("PRAGMA temp_store=MEMORY;")
+ conn.execute("PRAGMA mmap_size=268435456;")
+ conn.execute("PRAGMA synchronous=OFF;")
+ try:
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_categories ON books(categories);")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_chapters_max ON books(chapters_max);")
+ except Exception:
+ pass
+ thread_local.connections[db_path] = conn
+
+ return thread_local.connections[db_path]
+
+def get_mode_connection(db_path):
+ if not hasattr(thread_local, "connections"):
+ thread_local.connections = {}
+
+ if db_path not in thread_local.connections:
+ ensure_db_exists(db_path)
+ if os.path.exists(db_path):
+ conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA synchronous=NORMAL;")
+ conn.execute("PRAGMA cache_size=-10000;")
+ conn.execute("PRAGMA temp_store=MEMORY;")
+ conn.execute("PRAGMA mmap_size=134217728;")
+ conn.execute("PRAGMA synchronous=OFF;")
+ thread_local.connections[db_path] = conn
+ else:
+ return None
+
+ return thread_local.connections[db_path]
diff --git a/backend/engine/__init__.py b/backend/engine/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..34e120ca09fcd7a66d158e4eae8d76cf9c078f11
--- /dev/null
+++ b/backend/engine/__init__.py
@@ -0,0 +1,2 @@
+from .engine import VietphraseEngine
+from .trie import Trie
diff --git a/backend/engine/engine.py b/backend/engine/engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6c61580c65c1c07fe38ec5e66829244f88be969
--- /dev/null
+++ b/backend/engine/engine.py
@@ -0,0 +1,820 @@
+import os
+import sys
+import re
+import jieba.posseg as pseg
+
+from backend.config import Config
+
+NUM_RE = re.compile(r'^[0-9一二三四五六七八九十百千万几数多半两]+$')
+PUNCT_SET = {',', '.', '!', '?', ';', ':', '"', '\'', '(', ')', '[', ']', '{', '}',
+ ',', '。', '!', '?', ';', ':', '“', '”', '‘', '’', '(', ')', '【', '】', '《', '》', '、', '—', '~'}
+
+
+class SimpleToken:
+ def __init__(self, word, tag):
+ self.word = word
+ self.tag = tag
+ self.translated = None
+
+ @property
+ def flag(self):
+ return self.tag
+
+ @flag.setter
+ def flag(self, value):
+ self.tag = value
+
+class VietphraseEngine:
+ def __init__(self, config=None):
+ self.config = config or {}
+ self.load_dictionaries()
+
+ # Check translation mode
+ self.translation_mode = self.config.get("translation", {}).get("mode", "advanced")
+ if self.config.get("translation", {}).get("fast_mode", False):
+ self.translation_mode = "fast"
+
+ # Warm-up jieba
+ import jieba
+ try:
+ # Fix segmenter splitting overlapping words like 重生于
+ jieba.add_word("重生", tag="v")
+ jieba.suggest_freq(("生", "于"), True)
+ jieba.suggest_freq(("着", "重"), True)
+ jieba.suggest_freq(("醉", "人"), True)
+ # Tag grades as nouns instead of proper names (nr)
+ jieba.add_word("高一", tag="n")
+ jieba.add_word("高二", tag="n")
+ jieba.add_word("高三", tag="n")
+ except Exception as e:
+ print("Error initializing custom word splits in Jieba:", e)
+
+ # Always initialize both tokenizers to support dynamic mode switching
+ self.jieba_tokenizer = jieba.dt
+ self.pseg_dict = pseg.dt.word_tag_tab
+ list(self.jieba_tokenizer.cut("暖洋洋"))
+ list(pseg.cut("暖洋洋"))
+
+ def load_dictionaries(self):
+ paths = self.config.get("paths", {}).get("dictionaries", {})
+ vp_path = paths.get("vietphrase", "")
+ if not vp_path or not os.path.isabs(vp_path):
+ vp_path = os.path.join(Config.ROOT_DIR, vp_path or "dictionaries/Vietphrase.txt")
+
+ dict_dir = os.path.dirname(vp_path)
+
+ # Check for encrypted .bin dictionaries first, then fallback to .txt
+ def load_file_content(base_name):
+ bin_file = os.path.join(dict_dir, base_name + ".bin")
+ txt_file = os.path.join(dict_dir, base_name + ".txt")
+
+ if os.path.exists(bin_file):
+ # Decrypt XOR
+ with open(bin_file, "rb") as f:
+ data = f.read()
+ key_bytes = "quick_translator_secret_key_2026".encode("utf-8")
+ key_len = len(key_bytes)
+ repeated_key = (key_bytes * (len(data) // key_len + 1))[:len(data)]
+ decrypted = bytes(a ^ b for a, b in zip(data, repeated_key))
+ return decrypted.decode("utf-8")
+ elif os.path.exists(txt_file):
+ with open(txt_file, "r", encoding="utf-8") as f:
+ return f.read()
+ return ""
+
+ print("Loading dictionaries in VietphraseEngine...")
+ self.char_dict = self.parse_dict_content(load_file_content("HanViet_CharDict"))
+
+ # --- ADD HÁn Nôm FALLBACK ---
+ import csv
+ han_csv_path = os.path.join(dict_dir, "han_all_readings.csv")
+ if os.path.exists(han_csv_path):
+ try:
+ with open(han_csv_path, 'r', encoding='utf-8') as f:
+ reader = csv.DictReader(f)
+ for row in reader:
+ char = row.get("Ký_tự", "").strip()
+ hv = row.get("Hán_Việt", "").strip()
+ if char and hv and char not in self.char_dict:
+ self.char_dict[char] = hv.replace("~", "")
+ print("Loaded han_all_readings.csv as fallback for missing Chinese characters.")
+ except Exception as e:
+ print("Could not load han_all_readings.csv:", e)
+
+ self.proper_names = self.parse_dict_content(load_file_content("Aligned_HanViet"), convert_to_simplified=True)
+
+ vp_content = load_file_content("Vietphrase")
+ self.vietphrase = self.parse_vietphrase_content(vp_content)
+ print("Dictionaries loaded successfully.")
+
+ # Build Tries for vietphrase and hanviet modes
+ from .trie import Trie
+ print("Building Tries for fast translation modes...")
+ self.vietphrase_trie = Trie()
+ # Insert proper names (priority 1)
+ for k, v in self.proper_names.items():
+ self.vietphrase_trie.insert(k, v, 1)
+ # Insert Vietphrase (priority 2 - higher)
+ for k, v in self.vietphrase.items():
+ self.vietphrase_trie.insert(k, v, 2)
+
+ self.hanviet_trie = Trie()
+ # Insert proper names (priority 2)
+ for k, v in self.proper_names.items():
+ self.hanviet_trie.insert(k, v, 2)
+ print("Tries built successfully.")
+
+ # Register proper names in Jieba dictionary for fast modes
+ import jieba
+ for name in self.proper_names:
+ jieba.add_word(name)
+
+ def parse_dict_content(self, content, convert_to_simplified=False):
+ dictionary = {}
+ if content:
+ to_simplified = lambda s: s
+ if convert_to_simplified:
+ try:
+ from hanziconv import HanziConv
+ to_simplified = HanziConv.toSimplified
+ except ImportError:
+ pass
+
+ for line in content.splitlines():
+ line = line.strip()
+ if not line or "=" not in line or line.startswith('#'):
+ continue
+ parts = line.split("=", 1)
+ key = parts[0].strip()
+ val = self.clean_annotation(parts[1].strip())
+ dictionary[to_simplified(key)] = val
+ return dictionary
+
+ def parse_vietphrase_content(self, content):
+ dictionary = {}
+ if content:
+ for line in content.splitlines():
+ line = line.strip()
+ if not line or "=" not in line or line.startswith('#'):
+ continue
+ parts = line.split("=", 1)
+ left = parts[0].strip()
+ right = self.clean_annotation(parts[1].strip())
+
+ if "," in left and "," in right:
+ keys = [k.strip() for k in left.split(",") if k.strip()]
+ vals = [v.strip() for v in right.split(",") if v.strip()]
+ if len(keys) == len(vals):
+ for k, v in zip(keys, vals):
+ dictionary[k] = v
+ continue
+ if left:
+ dictionary[left] = right
+ return dictionary
+
+ def is_number(self, word):
+ return bool(re.match(r'^[0-9一二三四五六七八九十百千万几数多半两]+$', word))
+
+ def capitalize_phrase(self, phrase):
+ chars = 'a-zA-ZàáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ'
+ pattern = f'[{chars}]+'
+ return re.sub(pattern, lambda m: m.group(0).capitalize(), phrase)
+
+ def clean_annotation(self, text, mode='vietphrase'):
+ if not text:
+ return ""
+ # 1. Parse curly braces {meaning:reading}
+ def repl_curly(match):
+ content = match.group(1)
+ if ':' in content:
+ parts = content.split(':', 1)
+ return parts[0].strip() if mode == 'vietphrase' else parts[1].strip()
+ return content.strip()
+
+ text = re.sub(r'\{([^{}]+)\}', repl_curly, text)
+
+ # 2. Strip (*...) annotations
+ text = re.sub(r'\s*\(\*[^)]*\)', '', text)
+
+ return text.strip()
+
+ def format_translation(self, raw_value, multi_option, word=None, prefer_hanviet=False):
+ if not raw_value:
+ return ""
+ options = [o for o in raw_value.split("/") if o.strip()]
+
+ # Deduplicate options while preserving order
+ seen = set()
+ deduped = []
+ for o in options:
+ if o not in seen:
+ seen.add(o)
+ deduped.append(o)
+
+ if not deduped:
+ return ""
+
+ if multi_option and len(deduped) > 1:
+ return f"{deduped[0]}[{'/'.join(deduped[1:])}]"
+
+ # If multi-option is False, we have a word of length >= 2, and prefer_hanviet is True, prefer Hán Việt alignment
+ if prefer_hanviet and word and len(word) >= 2 and len(deduped) > 1:
+ hv_sets = []
+ for char in word:
+ readings = set()
+ if char in self.char_dict:
+ for r in self.char_dict[char].split('/'):
+ r_clean = r.strip().lower()
+ if r_clean:
+ readings.add(r_clean)
+ if readings:
+ hv_sets.append(readings)
+
+ best_option = deduped[0]
+ best_score = -1
+
+ for opt in deduped:
+ opt_syllables = [w.strip().lower() for w in opt.split() if w.strip()]
+ score = 0
+ for r_set in hv_sets:
+ if any(r in opt_syllables for r in r_set):
+ score += 1
+ if score > best_score:
+ best_score = score
+ best_option = opt
+ if best_score > 0:
+ return best_option
+
+ return deduped[0]
+
+ def clean_punctuation_spacing(self, text):
+ if not text:
+ return text
+
+ # 1. Ensure exactly one space after commas, semicolons, colons, periods, question marks, and exclamation marks.
+ # Avoid inserting space if the next character is a closing bracket, closing quote, space, or another punctuation.
+ text = re.sub(r'([,;.:!?])(?=[^\s)\]}』】”"’])', r'\1 ', text)
+
+ # 2. Remove any accidental whitespace before these punctuation marks
+ text = re.sub(r'\s+([,;.:!?])', r'\1', text)
+
+ # 3. Clean spaces inside parentheses, brackets, and curly/double brackets (including Chinese quote styles)
+ text = re.sub(r'([(\[{『【«])\s+', r'\1', text)
+ text = re.sub(r'\s+([)\]}』】»])', r'\1', text)
+
+ # Ensure a space exists before opening brackets and after closing brackets when they border words/digits
+ text = re.sub(r'(?<=[^\s(\[{『【«])([(\[{『【«])', r' \1', text)
+ text = re.sub(r'([)\]}』】»])(?=[^\s.,;:!?)\]}』】»])', r'\1 ', text)
+
+ # 4. Standardize dashes/hyphens used as separators (e.g. "Artist - Song") to have one space on each side
+ text = re.sub(r'\s*-\s*', ' - ', text)
+
+ # 5. Clean up any duplicated/trailing whitespaces
+ text = re.sub(r'\s+', ' ', text).strip()
+
+ return text
+
+ def translate_sentence(self, sentence, multi_option=False, mode=None):
+ if not sentence or sentence.isspace():
+ return ""
+
+ # If the sentence doesn't contain any Chinese characters or symbols, preserve it as-is
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df\u3000-\u303f\uff00-\uffef]', sentence):
+ return sentence
+
+ # Segment into Chinese text blocks and non-Chinese text blocks
+ # Keep Chinese characters and Chinese specific punctuations in the translation segment
+ chinese_pattern = re.compile(r'([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+)')
+ parts = chinese_pattern.split(sentence)
+
+ # Merge simple alphanumeric non-Chinese blocks into adjacent Chinese blocks
+ i = 1
+ while i < len(parts) - 1:
+ non_chinese = parts[i+1]
+ if re.match(r'^\s*[a-zA-Z0-9]+\s*$', non_chinese):
+ parts[i] = parts[i] + non_chinese + parts[i+2]
+ parts.pop(i+1)
+ parts.pop(i+1)
+ else:
+ i += 2
+
+ translated_parts = []
+ capitalize_next = True
+
+ for part in parts:
+ if not part:
+ continue
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df\u3000-\u303f\uff00-\uffef]', part):
+ # Non-Chinese segment -> preserve exactly
+ translated_parts.append(part)
+ # Check if it ends with sentence terminator
+ if re.search(r'[.!?]\s*$', part):
+ capitalize_next = True
+ elif part.strip():
+ capitalize_next = False
+ else:
+ # Chinese segment -> translate
+ trans = self._translate_pure_chinese_sentence(part, multi_option, mode, capitalize_first=capitalize_next)
+ translated_parts.append(trans)
+ # Check if it ends with sentence terminator
+ if re.search(r'[.!?]\s*$', part) or re.search(r'[.!?]\s*$', trans):
+ capitalize_next = True
+ else:
+ capitalize_next = False
+
+ return "".join(translated_parts)
+
+ def _translate_pure_chinese_sentence(self, sentence, multi_option=False, mode=None, capitalize_first=True):
+ if not sentence or sentence.isspace():
+ return ""
+
+ active_mode = mode or self.translation_mode
+
+ # Tokenization & Tagging depending on mode
+ if active_mode in ("advanced", "advanced_hanviet"):
+ raw_tokens = [SimpleToken(t.word, t.flag) for t in pseg.cut(sentence)]
+ else:
+ # "fast", "vietphrase", "hanviet" modes use the fast tokenizer
+ words = list(self.jieba_tokenizer.cut(sentence))
+ raw_tokens = []
+ for w in words:
+ if w in PUNCT_SET:
+ tag = 'x'
+ elif NUM_RE.match(w):
+ tag = 'm'
+ else:
+ tag = self.pseg_dict.get(w, 'n')
+ raw_tokens.append(SimpleToken(w, tag))
+
+ if not raw_tokens:
+ return ""
+
+ NUM_KEYWORDS = {"重", "阶", "品", "级", "层", "剑", "星", "转", "天", "色", "关", "重天"}
+ HANVIET_NUMBERS = {
+ '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',
+ '一': 'Nhất', '二': 'Nhị', '三': 'Tam', '四': 'Tứ', '五': 'Ngũ', '六': 'Lục', '七': 'Thất', '八': 'Bát', '九': 'Cửu', '十': 'Thập',
+ '百': 'Bách', '千': 'Thiên', '万': 'Vạn', '萬': 'Vạn', '几': 'Vài', '数': 'Số', '多': 'Đa', '半': 'Bán', '两': 'Lưỡng', '兩': 'Lưỡng'
+ }
+ # Helper function to translate a single token
+ def translate_single_token(idx, tok, list_of_tokens):
+ word = tok.word
+ tag = tok.tag
+
+ # Punctuation
+ is_punct = (tag == 'x' or word in {',', '.', '!', '?', ';', ':', '"', '(', ')', '[', ']', '{', '}'})
+ if is_punct:
+ has_chinese = False
+ for char in word:
+ if char in self.char_dict:
+ has_chinese = True
+ break
+ if not has_chinese:
+ punct_map = {
+ ',': ',', '。': '.', '「': '"', '」': '"', '、': ',', '?': '?', '!': '!',
+ ':': ':', ';': ';', '“': '"', '”': '"', '(': '(', ')': ')'
+ }
+ tok.translated = punct_map.get(word, word)
+ return
+
+ # Rule for number + 人 (e.g. 几十人, 三人)
+ if len(word) > 1 and word.endswith('人') and self.is_number(word[:-1]):
+ num_part = word[:-1]
+ if num_part in self.vietphrase:
+ num_trans = self.format_translation(self.vietphrase[num_part], multi_option, num_part)
+ else:
+ num_trans = " ".join([self.char_dict.get(c, c).split("/")[0] for c in num_part])
+ tok.translated = f"{num_trans} người"
+ return
+
+ # Special rule for 了 (le vs liao)
+ if word == 'l' or word == '了':
+ is_at_end = True
+ for next_tok in list_of_tokens[idx+1:]:
+ if next_tok.word in {'"', '\'', '(', ')', '[', ']', '{', '}', '“', '”', '‘', '’', '(', ')', '【', '】', '《', '》'}:
+ continue
+ if next_tok.word in {',', '.', '!', '?', ';', ':', ',', '。', '!', '?', ';', ':', '、'}:
+ is_at_end = True
+ break
+ is_at_end = False
+ break
+ if is_at_end:
+ tok.translated = "rồi"
+ else:
+ tok.translated = "được"
+ return
+
+ # Cultivation Realm (cultivation)
+ if tag == 'cultivation':
+ result = []
+ for char in word:
+ if char in HANVIET_NUMBERS:
+ result.append(HANVIET_NUMBERS[char])
+ else:
+ cap_val = self.char_dict.get(char, char).split("/")[0].capitalize()
+ result.append(cap_val)
+ tok.translated = " ".join(result)
+ return
+
+ # Determine if it's a noun or an adjective
+ is_proper = (tag in {'nr', 'ns', 'nt'} if tag else False)
+ is_noun = (tag.startswith('n') if tag else False) or tag in {'n', 'nz', 'ng'} if tag else False
+ is_adj = tag in {'a', 'b', 'ad', 'an', 'z'} if tag else False
+ is_noun_or_adj = is_proper or is_noun or is_adj
+
+ # --- Chốt chặn cuối cùng cho Tên riêng (Proper Names Guard) ---
+ if is_proper:
+ if word in self.proper_names:
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
+ else:
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
+ tok.translated = word
+ else:
+ result = []
+ for char in word:
+ val = self.char_dict.get(char, char).split("/")[0]
+ result.append(val)
+ tok.translated = " ".join(result)
+ if tok.translated:
+ tok.translated = self.capitalize_phrase(tok.translated)
+
+ # --- Translate lookup strategy depending on active_mode (for non-proper names) ---
+ else:
+ if active_mode == 'hanviet':
+ # Mode 4: Pure Hán Việt (NO Vietphrase)
+ if word in self.proper_names:
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
+ else:
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
+ tok.translated = word
+ else:
+ result = []
+ for char in word:
+ val = self.char_dict.get(char, char).split("/")[0]
+ result.append(val)
+ tok.translated = " ".join(result)
+
+ elif active_mode == 'vietphrase':
+ # Mode 3: Prioritize Vietphrase (Traditional)
+ if word in self.vietphrase:
+ tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
+ elif word in self.proper_names:
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
+ else:
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
+ tok.translated = word
+ else:
+ result = []
+ for char in word:
+ val = self.char_dict.get(char, char).split("/")[0]
+ result.append(val)
+ tok.translated = " ".join(result)
+
+ else:
+ # Modes 1, 2 & 5: 'fast', 'advanced', or 'advanced_hanviet' (POS-based noun/adjective Hán Việt override)
+ if is_noun_or_adj:
+ # Nouns/Adjectives: Bypasses vietphrase
+ if word in self.proper_names:
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
+ else:
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
+ tok.translated = word
+ else:
+ result = []
+ for char in word:
+ val = self.char_dict.get(char, char).split("/")[0]
+ result.append(val)
+ tok.translated = " ".join(result)
+ else:
+ # Verbs and other parts of speech
+ if active_mode == 'advanced_hanviet':
+ # Prefer HanViet dictionary (proper_names) over Vietphrase
+ if word in self.proper_names:
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
+ elif word in self.vietphrase:
+ tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
+ else:
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
+ tok.translated = word
+ else:
+ result = []
+ for char in word:
+ val = self.char_dict.get(char, char).split("/")[0]
+ result.append(val)
+ tok.translated = " ".join(result)
+ else:
+ # Standard fast/advanced: vietphrase -> proper_names -> character fallback
+ if word in self.vietphrase:
+ tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
+ elif word in self.proper_names:
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
+ else:
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
+ tok.translated = word
+ else:
+ result = []
+ for char in word:
+ val = self.char_dict.get(char, char).split("/")[0]
+ result.append(val)
+ tok.translated = " ".join(result)
+
+ # Strip trailing "đích" / "Đích" from modifier translations
+ if tok.translated and word.endswith('的') and len(word) > 1:
+ val = tok.translated
+ if val.lower().endswith(' đích'):
+ tok.translated = val[:-5]
+ elif val.lower().endswith('đích'):
+ tok.translated = val[:-4]
+
+ # Step 1: Group numeral phrases and cultivation terms FIRST
+ grouped = []
+ i = 0
+ while i < len(raw_tokens):
+ tok = raw_tokens[i]
+ word = tok.word
+ tag = tok.tag
+
+ if self.is_number(word) and i + 1 < len(raw_tokens) and raw_tokens[i+1].word in NUM_KEYWORDS:
+ grouped_word = word + raw_tokens[i+1].word
+ i_next = i + 2
+ if i_next < len(raw_tokens) and raw_tokens[i_next].tag in {'n', 'nr', 'ns', 'nt', 'nz'}:
+ grouped_word += raw_tokens[i_next].word
+ i_next += 1
+ grouped.append(SimpleToken(grouped_word, 'cultivation'))
+ i = i_next
+ else:
+ grouped.append(SimpleToken(word, tag))
+ i += 1
+
+ # Step 2: Translate individual tokens on the cultivation-grouped tokens
+ for idx, tok in enumerate(grouped):
+ translate_single_token(idx, tok, grouped)
+
+ # Step 3: Greedy merge adjacent tokens if their combination exists in dictionaries
+ i = 0
+ merged = []
+ while i < len(grouped):
+ matched = False
+ for length in range(min(4, len(grouped) - i), 1, -1):
+ combined_word = "".join([grouped[i+k].word for k in range(length)])
+
+ # Prevent merging across '的' particle to preserve root Hán Việt translation and allow reordering
+ should_skip = False
+ if 'đích' in combined_word or '的' in combined_word and combined_word.find('的') > 0:
+ should_skip = True
+ elif i + length < len(grouped) and grouped[i+length].word == '的':
+ # If next token is 'de' (de/的), don't merge if it would swallow a pronoun/noun/verb
+ last_tok = grouped[i+length-1]
+ if last_tok.flag in {'r', 'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'v'}:
+ should_skip = True
+ elif '是' in combined_word and any(p in combined_word for p in {'我', '你', 'he', 'she', 'it', '们', '您', '自己'}):
+ # Prevent merging copula + pronoun phrases (like '这是他', '那是我') to allow proper clause reordering
+ should_skip = True
+
+ # Dict check strategy depends on active_mode
+ if active_mode == 'hanviet':
+ in_dicts = (combined_word in self.proper_names)
+ else:
+ in_dicts = (combined_word in self.vietphrase or combined_word in self.proper_names)
+
+ if not should_skip and in_dicts:
+ combined_tag = None
+ try:
+ cut_res = list(pseg.cut(combined_word))
+ if cut_res:
+ combined_tag = cut_res[0].flag
+ except Exception:
+ pass
+ if not combined_tag:
+ combined_tag = grouped[i].flag
+ for k in range(length):
+ if grouped[i+k].flag in {'nr', 'ns', 'nt', 'nz'}:
+ combined_tag = grouped[i+k].flag
+ break
+ new_tok = SimpleToken(combined_word, combined_tag)
+ # Translate the new merged token immediately
+ translate_single_token(0, new_tok, [new_tok])
+ merged.append(new_tok)
+ i += length
+ matched = True
+ break
+ if not matched:
+ merged.append(grouped[i])
+ i += 1
+
+ # Step 4: Reordering Grammar Rules
+ if active_mode != 'hanviet':
+ # Pass 1: Adjective + Noun reordering
+ changed = True
+ while changed:
+ changed = False
+ i = 0
+ while i < len(merged) - 1:
+ t_a = merged[i]
+ t_n = merged[i+1]
+
+ # Do not swap with prepositions/conjunctions/copulas/particles
+ if t_n.word in {'跟', '和', '与', '與', '同', '在', '从', '從', '自', '由', '向', '往', '朝', '对', '對', '给', '給', '比', '是', '叫', '让', '讓', '被', '把', '使', '令', '到', '了', '的', '而', '&', '并', '並', '以', '或', '者'}:
+ i += 1
+ continue
+
+ 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'}:
+ combined = t_n.translated + " " + t_a.translated
+ new_tok = SimpleToken(t_a.word + t_n.word, t_n.tag)
+ new_tok.translated = combined
+ merged[i:i+2] = [new_tok]
+ changed = True
+ break
+ i += 1
+
+ # Pass 2: "的" reordering (with multi-token noun/verb phrase lookahead)
+ NOUN_PHRASE_TAGS = {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'a', 'b', 'm', 'q', 'j', 'i'}
+ LOOKAHEAD_TAGS = NOUN_PHRASE_TAGS | {'v', 'vd', 'vg', 'vi', 'vn'}
+ i = 1
+ while i < len(merged) - 1:
+ tok = merged[i]
+ if tok.word in {'de', '的'}:
+ t_x = merged[i-1]
+ # Scan forward to collect all consecutive noun or verb phrase tokens
+ k = i + 1
+ has_noun = False
+ while k < len(merged):
+ tok_k = merged[k]
+ # Stop collecting if we hit a locality word/orientation noun
+ if tok_k.word in {'下', '上', '中', '里', '外', '内', '內', '后', '後', '前', '旁', '侧', '側', '底', '间', '間'}:
+ break
+
+ # If we already encountered a noun/verb in the phrase,
+ # we cannot have a subsequent adjective modifying that noun from the right.
+ if has_noun and tok_k.tag in {'a', 'b'}:
+ break
+
+ # Do not collect a verb tag if we already have a noun/verb head
+ is_verb_tag = tok_k.tag in {'v', 'vd', 'vg', 'vi', 'vn'}
+ if has_noun and is_verb_tag:
+ break
+
+ if tok_k.tag in LOOKAHEAD_TAGS or tok_k.word == '色':
+ if tok_k.tag in {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'v', 'vd', 'vg', 'vi', 'vn'}:
+ has_noun = True
+ k += 1
+ else:
+ break
+
+ is_verb_modifier = t_x.tag in {'v', 'vd', 'vg', 'vi', 'vn'}
+
+ # If we collected at least one token AND the modifier is not a verb clause
+ if k > i + 1 and not is_verb_modifier:
+ y_tokens = merged[i+1:k]
+ y_translated = " ".join([t.translated for t in y_tokens if t.translated])
+ y_word = "".join([t.word for t in y_tokens])
+
+ if t_x.tag != 'x':
+ start_idx = i - 1
+ j_back = i - 2
+ while j_back >= 0:
+ tag_back = merged[j_back].tag
+ if tag_back in {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'a', 'b', 'm', 'q', 'j', 'i', 's', 't'}:
+ start_idx = j_back
+ j_back -= 1
+ else:
+ break
+
+ modifier_tokens = merged[start_idx:i]
+ modifier_translated = " ".join([t.translated for t in modifier_tokens if t.translated])
+ modifier_word = "".join([t.word for t in modifier_tokens])
+
+ is_proper_or_pronoun = (
+ t_x.tag in {'nr', 'r'}
+ )
+ is_noun_modifier = is_proper_or_pronoun and not t_x.word.endswith('色')
+ if is_noun_modifier and start_idx == i - 1:
+ combined = y_translated + " của " + modifier_translated
+ else:
+ combined = y_translated + " " + modifier_translated
+
+ new_tok = SimpleToken(modifier_word + tok.word + y_word, 'n')
+ new_tok.translated = combined
+ merged[start_idx:k] = [new_tok]
+ continue
+ else:
+ # If we didn't reorder, set the '的' translation to empty string to avoid translating as 'đấy' / 'đích'
+ tok.translated = ""
+ i += 1
+
+ # Join words
+ translated_text = " ".join([t.translated for t in merged if t.translated])
+
+ # Clean spacing and punctuation
+ translated_text = self.clean_punctuation_spacing(translated_text)
+
+ # Capitalize sentences
+ sentences = re.split(r'([.!?]\s*)', translated_text)
+ start_idx = 0 if capitalize_first else 1
+ for idx in range(start_idx, len(sentences)):
+ s = sentences[idx]
+ if s and not s.isspace() and not s[0] in {'.', '!', '?'}:
+ for c_idx, char in enumerate(s):
+ if char.isalpha():
+ sentences[idx] = s[:c_idx] + char.upper() + s[c_idx+1:]
+ break
+ return "".join(sentences).strip()
+
+ def translate_paragraph(self, paragraph, multi_option=False, mode=None):
+ if not paragraph or paragraph.isspace():
+ return paragraph
+
+ active_mode = mode or self.translation_mode
+
+ if active_mode in ('vietphrase', 'hanviet'):
+ # Ultra-fast Trie-based translation path (50M+ characters/minute)
+ trie = self.vietphrase_trie if active_mode == 'vietphrase' else self.hanviet_trie
+ prefer_hanviet = (active_mode == 'hanviet')
+
+ i = 0
+ text_length = len(paragraph)
+ result_words = []
+
+ while i < text_length:
+ length, translation, priority = trie.search_longest_match(paragraph, i)
+ if length > 0:
+ word = paragraph[i:i+length]
+ formatted = self.format_translation(translation, multi_option, word, prefer_hanviet=prefer_hanviet)
+ # Capitalize if it is a proper name
+ if priority == 1 or word in self.proper_names:
+ formatted = self.capitalize_phrase(formatted)
+ result_words.append(formatted)
+ i += length
+ else:
+ char = paragraph[i]
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', char):
+ punct_map = {
+ ',': ',', '。': '.', '「': '"', '」': '"', '、': ',', '?': '?', '!': '!',
+ ':': ':', ';': ';', '“': '"', '”': '"', '(': '(', ')': ')',
+ '『': '"', '』': '"', '【': '[', '】': ']'
+ }
+ result_words.append(punct_map.get(char, char))
+ else:
+ val = self.char_dict.get(char, char).split("/")[0]
+ result_words.append(val)
+ i += 1
+
+ translated_text = " ".join(result_words)
+ translated_text = self.clean_punctuation_spacing(translated_text)
+
+ # Sentence Capitalization
+ sentences = re.split(r'([.!?]\s*)', translated_text)
+ for idx in range(len(sentences)):
+ s = sentences[idx]
+ if s and not s.isspace() and not s[0] in {'.', '!', '?'}:
+ for c_idx, char in enumerate(s):
+ if char.isalpha():
+ sentences[idx] = s[:c_idx] + char.upper() + s[c_idx+1:]
+ break
+ return "".join(sentences).strip()
+
+ # For advanced & fast modes, use normal sentence splitting & tokenization
+ sentence_ends = re.compile(r'([。!?!?]+)')
+ parts = sentence_ends.split(paragraph)
+
+ translated_parts = []
+ for part in parts:
+ if not part:
+ continue
+ if sentence_ends.match(part):
+ punct_map = {
+ '。': '.', '!': '!', '?': '?', ',': ','
+ }
+ translated_parts.append(punct_map.get(part, part))
+ else:
+ translated_parts.append(self.translate_sentence(part, multi_option, mode=mode))
+
+ return self.clean_punctuation_spacing("".join(translated_parts))
+
+ def translate_text_node(self, text, multi_option=False, mode=None):
+ """
+ Dich mot text node tu DOM.
+ BAO TOAN HOAN TOAN cau truc: xuong dong \n, khoang trang dau/cuoi tung dong.
+ """
+ if not text:
+ return text
+
+ # Tach theo \n truoc -> dich tung dong doc lap -> gop lai
+ lines = text.split('\n')
+ translated_lines = []
+ for line in lines:
+ leading = re.match(r'^\s*', line).group(0)
+ trailing = re.search(r'\s*$', line).group(0)
+ body = line.strip()
+
+ if not body:
+ translated_lines.append(line) # dong rong -> giu nguyen
+ elif not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', body):
+ translated_lines.append(line) # khong co chu Han -> giu nguyen
+ else:
+ translated_body = self.translate_paragraph(body, multi_option, mode=mode)
+ translated_lines.append(leading + translated_body + trailing)
+
+ return '\n'.join(translated_lines)
+
+ def translate(self, text, multi_option=False, mode=None):
+ return self.translate_text_node(text, multi_option=multi_option, mode=mode)
diff --git a/backend/engine/translate_api_server.py b/backend/engine/translate_api_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..d8fabdd5a1dcee74ceeb189180f5b3c0f8e1edf8
--- /dev/null
+++ b/backend/engine/translate_api_server.py
@@ -0,0 +1,85 @@
+import os
+import sys
+import json
+import asyncio
+from fastapi import FastAPI, HTTPException, Body
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from typing import List, Optional
+
+# Add parent directory to path to import engine
+current_dir = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, os.path.dirname(os.path.dirname(current_dir)))
+
+from backend.engine.engine import VietphraseEngine
+from backend.config import Config
+
+app = FastAPI(
+ title="Vietphrase Translation Standalone API Server",
+ description="Standalone API for fast Chinese to Vietnamese translation with 5 modes"
+)
+
+# Initialize engine
+print("Lazy-loading Vietphrase Engine on API Startup...")
+engine = VietphraseEngine()
+print("Vietphrase Engine successfully loaded!")
+
+class TranslateRequest(BaseModel):
+ texts: List[str]
+ mode: Optional[str] = "advanced"
+
+@app.get("/health")
+def health():
+ return {
+ "status": "ok",
+ "modes": ["advanced", "fast", "vietphrase", "hanviet", "advanced_hanviet"],
+ "dictionary_sizes": {
+ "char_dict": len(engine.char_dict),
+ "proper_names": len(engine.proper_names),
+ "vietphrase": len(engine.vietphrase)
+ }
+ }
+
+@app.post("/v1/translate")
+def translate(req: TranslateRequest):
+ if not req.texts:
+ raise HTTPException(status_code=400, detail="Missing 'texts' list")
+
+ translations = []
+ for text in req.texts:
+ if not text.strip():
+ translations.append(text)
+ else:
+ try:
+ translations.append(engine.translate(text, mode=req.mode))
+ except Exception as e:
+ translations.append(text)
+ print(f"Error translating text: {e}")
+
+ return {"translations": translations}
+
+@app.post("/v1/translate_stream")
+async def translate_stream(req: TranslateRequest):
+ if not req.texts:
+ raise HTTPException(status_code=400, detail="Missing 'texts' list")
+
+ async def event_generator():
+ for i, text in enumerate(req.texts):
+ if not text.strip():
+ trans = text
+ else:
+ try:
+ trans = engine.translate(text, mode=req.mode)
+ except Exception as e:
+ trans = text
+ print(f"Error streaming translation: {e}")
+
+ yield f"data: {json.dumps({'index': i, 'text': trans}, ensure_ascii=False)}\n\n"
+ await asyncio.sleep(0.001)
+
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
+
+if __name__ == "__main__":
+ import uvicorn
+ port = int(os.environ.get("PORT", 8050))
+ uvicorn.run("translate_api_server:app", host="0.0.0.0", port=port, reload=False)
diff --git a/backend/engine/trie.py b/backend/engine/trie.py
new file mode 100644
index 0000000000000000000000000000000000000000..989d884e1b4a5b8fa0ef2525e70359d6a7f9dc6b
--- /dev/null
+++ b/backend/engine/trie.py
@@ -0,0 +1,56 @@
+class TrieNode:
+ def __init__(self):
+ self.children = {}
+ self.translation = None
+ self.priority = 0 # Priority level: 0=none, 1=Vietphrase, 2=Names
+
+class Trie:
+ def __init__(self):
+ self.root = TrieNode()
+
+ def insert(self, phrase_zh, translation_vi, priority):
+ """
+ Inserts a Chinese phrase and its translation into the Trie.
+ Overwrites existing translations if the new translation has higher or equal priority.
+ """
+ if not phrase_zh:
+ return
+
+ node = self.root
+ for char in phrase_zh:
+ if char not in node.children:
+ node.children[char] = TrieNode()
+ node = node.children[char]
+
+ # Prioritize names (2) over general phrases (1)
+ if node.translation is None or priority >= node.priority:
+ node.translation = translation_vi
+ node.priority = priority
+
+ def search_longest_match(self, text, start_index):
+ """
+ Finds the longest matching Chinese phrase starting from start_index.
+ Returns a tuple of (matched_length, translation, priority).
+ If no match is found, returns (0, None, 0).
+ """
+ node = self.root
+ longest_length = 0
+ longest_translation = None
+ longest_priority = 0
+
+ current_index = start_index
+ text_length = len(text)
+
+ while current_index < text_length:
+ char = text[current_index]
+ if char in node.children:
+ node = node.children[char]
+ current_index += 1
+ if node.translation is not None:
+ longest_length = current_index - start_index
+ longest_translation = node.translation
+ longest_priority = node.priority
+ else:
+ break
+
+ return longest_length, longest_translation, longest_priority
diff --git a/backend/services/__init__.py b/backend/services/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..99997e0889d14dbbe32cde1e3517049712ae70ec
--- /dev/null
+++ b/backend/services/__init__.py
@@ -0,0 +1 @@
+# backend/services/__init__.py
diff --git a/backend/services/alert_service.py b/backend/services/alert_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..ad92f9c3454a9fb61398ad7629fe045da929df16
--- /dev/null
+++ b/backend/services/alert_service.py
@@ -0,0 +1,83 @@
+import os
+import time
+import logging
+from backend.config import Config
+from backend.services.email_service import send_email_async
+
+logger = logging.getLogger("backend.alert")
+
+# Rate limit: 30 minutes in seconds
+THROTTLE_INTERVAL = 30 * 60
+
+# In-memory store for tracking the last time an alert of a specific type was sent
+# format: { alert_type: timestamp }
+_last_sent_alerts = {}
+
+def send_alert_to_admin(alert_type: str, subject: str, message_html: str):
+ """
+ Sends an email alert to the system administrator.
+
+ Includes throttling to avoid spamming the admin email (maximum 1 alert per 30 minutes per type).
+ """
+ admin_email = os.environ.get("ADMIN_EMAIL") or Config.SMTP_CONFIG.get("username")
+ if not admin_email:
+ logger.warning(f"⚠️ [Alerting] No ADMIN_EMAIL or SMTP username configured. Skipping alert: {subject}")
+ return False
+
+ current_time = time.time()
+ last_sent = _last_sent_alerts.get(alert_type, 0)
+
+ if current_time - last_sent < THROTTLE_INTERVAL:
+ logger.info(f"🔇 [Alerting] Alert '{alert_type}' throttled. Last sent at: {last_sent}. Skipping...")
+ return False
+
+ # Update last sent timestamp
+ _last_sent_alerts[alert_type] = current_time
+
+ # Send email asynchronously
+ logger.info(f"🚨 [Alerting] Sending alert email to Admin ({admin_email}): {subject}")
+
+ # Append styling to alert email
+ styled_html = f"""
+
+
Novel Translator Alert
+
Loại cảnh báo: {alert_type}
+
+ {message_html}
+
+
+ Đâ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.
+
+
+ """
+
+ send_email_async(admin_email, f"[SYSTEM ALERT] {subject}", styled_html)
+ return True
+
+def check_resources_and_alert():
+ """
+ Checks RAM and Disk metrics, and triggers alerts if they exceed thresholds (90%).
+ This should be called periodically or during health queries.
+ """
+ from backend.core.monitoring import get_memory_info, get_disk_info
+
+ try:
+ mem = get_memory_info()
+ if mem["percent"] >= 90.0:
+ subject = f"Cảnh báo: Bộ nhớ RAM cực kỳ thấp ({mem['percent']}%)"
+ body = f"Hệ thống đang chạy trên VM có lượng RAM khả dụng cực kỳ thấp.
" \
+ f"Tổng RAM: {mem['total_bytes'] / (1024*1024*1024):.2f} GB
" \
+ f"Đã dùng: {mem['used_bytes'] / (1024*1024*1024):.2f} GB ({mem['percent']}%)
" \
+ f"Tiến trình python RSS: {mem['process_rss_bytes'] / (1024*1024):.2f} MB"
+ send_alert_to_admin("low_memory", subject, body)
+
+ disk = get_disk_info()
+ if disk["percent"] >= 90.0:
+ subject = f"Cảnh báo: Dung lượng ổ đĩa sắp đầy ({disk['percent']}%)"
+ body = f"Ổ đĩa trên VM sắp hết bộ nhớ.
" \
+ f"Tổng dung lượng: {disk['total_bytes'] / (1024*1024*1024):.2f} GB
" \
+ f"Đã dùng: {disk['used_bytes'] / (1024*1024*1024):.2f} GB ({disk['percent']}%)
" \
+ f"Dung lượng trống: {disk['free_bytes'] / (1024*1024*1024):.2f} GB"
+ send_alert_to_admin("low_disk", subject, body)
+ except Exception as e:
+ logger.error(f"Error checking system resources: {e}")
diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..2fddf883d6282dcc06b154cc8d345e4e5b17dd43
--- /dev/null
+++ b/backend/services/auth_service.py
@@ -0,0 +1,80 @@
+import sqlite3
+from datetime import datetime, timedelta
+from backend.config import Config
+from backend.database.db_manager import get_user_db_conn
+
+def check_vip_expiry(user_id: int, conn=None) -> bool:
+ """Check if a user's VIP has expired and deactivate if so."""
+ should_close = False
+ if conn is None:
+ conn = get_user_db_conn()
+ should_close = True
+
+ try:
+ conn.row_factory = sqlite3.Row
+ except Exception:
+ pass
+
+ user = conn.execute("SELECT vip_status, vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
+ if user and user["vip_status"] == 1 and user["vip_expiry"]:
+ try:
+ expiry_val = user["vip_expiry"]
+ if isinstance(expiry_val, str):
+ expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
+ else:
+ expiry = expiry_val
+
+ if datetime.utcnow() > expiry:
+ conn.execute("UPDATE users SET vip_status = 0, vip_plan = NULL WHERE id = ?", (user_id,))
+ if hasattr(conn, "commit"):
+ conn.commit()
+ if should_close:
+ conn.close()
+ return False # VIP expired
+ except Exception:
+ pass
+ if should_close:
+ conn.close()
+ return user["vip_status"] == 1 if user else False
+
+def activate_vip(user_id: int, plan: str) -> bool:
+ """Activate VIP for a user based on the plan purchased."""
+ plan_info = Config.VIP_PLANS.get(plan)
+ if not plan_info:
+ # Check for lifetime manual plans
+ if "lifetime" in plan.lower():
+ duration_days = 99999
+ else:
+ return False
+ else:
+ duration_days = plan_info["duration_days"]
+
+ conn = get_user_db_conn()
+ conn.row_factory = sqlite3.Row
+ user = conn.execute("SELECT vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
+
+ now = datetime.utcnow()
+ if user and user["vip_expiry"]:
+ try:
+ expiry_val = user["vip_expiry"]
+ if isinstance(expiry_val, str):
+ current_expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
+ else:
+ current_expiry = expiry_val
+
+ if current_expiry > now:
+ new_expiry = current_expiry + timedelta(days=duration_days)
+ else:
+ new_expiry = now + timedelta(days=duration_days)
+ except Exception:
+ new_expiry = now + timedelta(days=duration_days)
+ else:
+ new_expiry = now + timedelta(days=duration_days)
+
+ conn.execute(
+ "UPDATE users SET vip_status = 1, vip_plan = ?, vip_expiry = ? WHERE id = ?",
+ (plan, new_expiry.strftime("%Y-%m-%d %H:%M:%S"), user_id)
+ )
+ conn.commit()
+ conn.close()
+ return True
diff --git a/backend/services/book_service.py b/backend/services/book_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..7146eeeaffc45d4cfd657bf11d34556b2dca1feb
--- /dev/null
+++ b/backend/services/book_service.py
@@ -0,0 +1,398 @@
+import re
+import math
+import unicodedata
+from flask import jsonify
+from backend.database.db_manager import get_db, query_cache, count_cache
+
+CHINESE_CATEGORY_MAP = {
+ "言情穿越": "Ngôn tình Xuyên không",
+ "都市小说": "Đô thị",
+ "历史军事": "Lịch sử Quân sự",
+ "游戏竞技": "Võng du Cạnh tranh",
+ "游戏异界": "Dị giới Trò chơi",
+ "玄幻奇幻": "Huyền huyễn Kỳ huyễn",
+ "武侠仙侠": "Võ hiệp Tiên hiệp",
+ "科幻空间": "Khoa huyễn Không gian",
+ "悬疑灵异": "Huyền bí Linh dị",
+ "耽美纯爱": "Đam mỹ",
+ "同人小说": "Đồng nhân",
+ "都市": "Đô thị",
+ "言情": "Ngôn tình",
+ "穿越": "Xuyên không",
+ "历史": "Lịch sử",
+ "军事": "Quân sự",
+ "男生": "Nam sinh",
+ "女生": "Nữ sinh",
+ "游戏": "Trò chơi",
+ "竞技": "Cạnh tranh",
+ "玄幻": "Huyền huyễn",
+ "奇幻": "Kỳ huyễn",
+ "武侠": "Võ hiệp",
+ "仙侠": "Tiên hiệp",
+ "科幻": "Khoa huyễn",
+ "悬疑": "Huyền bí",
+ "灵异": "Linh dị",
+ "耽美": "Đam mỹ",
+ "轻小说": "Light Novel",
+ "同人": "Đồng nhân"
+}
+
+def translate_categories(categories_str):
+ if not categories_str:
+ return ""
+ parts = re.split(r'[,,/、\s]+', categories_str)
+ vi_parts = []
+ for p in parts:
+ p = p.strip()
+ if not p:
+ continue
+ matched = False
+ for zh, vi in CHINESE_CATEGORY_MAP.items():
+ if zh in p:
+ vi_parts.append(vi)
+ matched = True
+ break
+ if not matched:
+ try:
+ from backend.services.translation import get_engine
+ translated = get_engine().translate(p, multi_option=False)
+ vi_parts.append(translated)
+ except Exception:
+ vi_parts.append(p)
+ seen = set()
+ unique_parts = []
+ for vp in vi_parts:
+ if vp not in seen:
+ seen.add(vp)
+ unique_parts.append(vp)
+ return ", ".join(unique_parts)
+
+CHINESE_TO_ENGLISH_CATEGORY_MAP = {
+ "言情穿越": "Romance & Time Travel",
+ "都市小说": "Urban",
+ "历史军事": "History & Military",
+ "游戏竞技": "Gaming & Competition",
+ "游戏异界": "Gaming & Otherworld",
+ "玄幻奇幻": "Xianxia & Fantasy",
+ "武侠仙侠": "Wuxia & Xianxia",
+ "科幻 space": "Sci-Fi Space",
+ "科幻空间": "Sci-Fi & Space",
+ "悬疑灵异": "Mystery & Supernatural",
+ "耽美纯爱": "Danmei Romance",
+ "同人小说": "Fan-fiction",
+ "都市": "Urban",
+ "言情": "Romance",
+ "穿越": "Time Travel",
+ "历史": "History",
+ "军事": "Military",
+ "男生": "Male Protagonist",
+ "女生": "Female Protagonist",
+ "游戏": "Gaming",
+ "竞技": "Competition",
+ "玄幻": "Fantasy",
+ "奇幻": "Eastern Fantasy",
+ "武侠": "Wuxia",
+ "仙侠": "Xianxia",
+ "科幻": "Sci-Fi",
+ "悬疑": "Mystery",
+ "灵异": "Supernatural",
+ "耽美": "Danmei",
+ "轻小说": "Light Novel",
+ "同人": "Fan-fiction"
+}
+
+def translate_categories_to_english(categories_str):
+ if not categories_str:
+ return ""
+ parts = re.split(r'[,,/、\s]+', categories_str)
+ en_parts = []
+ for p in parts:
+ p = p.strip()
+ if not p:
+ continue
+ matched = False
+ for zh, en in CHINESE_TO_ENGLISH_CATEGORY_MAP.items():
+ if zh in p:
+ en_parts.append(en)
+ matched = True
+ break
+ if not matched:
+ en_parts.append(p)
+ seen = set()
+ unique_parts = []
+ for ep in en_parts:
+ if ep not in seen:
+ seen.add(ep)
+ unique_parts.append(ep)
+ return ", ".join(unique_parts)
+
+def remove_vietnamese_accents(text):
+ if not text:
+ return ""
+ # Normalize to decompose accents
+ nfkd_form = unicodedata.normalize('NFKD', text)
+ only_ascii = nfkd_form.encode('ASCII', 'ignore').decode('utf-8')
+ return only_ascii
+
+def clean_vietnamese_query(text):
+ text = text.replace('đ', 'd').replace('Đ', 'D')
+ return "".join(c for c in unicodedata.normalize('NFKD', text) if not unicodedata.combining(c)).lower()
+
+def parse_book_sources(book_dict):
+ parsed_sources = []
+ raw_urls = book_dict.get("urls", "")
+ if raw_urls:
+ parts = raw_urls.split(" | ")
+ for part in parts:
+ if ":" in part:
+ subparts = part.split(":", 1)
+ src_name = subparts[0].strip()
+ src_url = subparts[1].strip()
+ if src_url.startswith("//"):
+ src_url = "https:" + src_url
+ parsed_sources.append({
+ "source": src_name,
+ "url": src_url
+ })
+ book_dict["parsed_sources"] = parsed_sources
+
+ # Dịch thể loại sang tiếng Việt và tiếng Anh
+ book_dict["categories_vietphrase"] = translate_categories(book_dict.get("categories", ""))
+ book_dict["categories_english"] = translate_categories_to_english(book_dict.get("categories", ""))
+
+ # Sinh tên tác giả tiếng Anh (Hán Việt không dấu)
+ author_hv = book_dict.get("author_hanviet", "")
+ if author_hv:
+ book_dict["author_english"] = remove_vietnamese_accents(author_hv)
+ else:
+ book_dict["author_english"] = book_dict.get("author", "")
+
+ # Use pre-computed description from DB (do NOT translate on-the-fly for listings — too expensive)
+ desc = book_dict.get("description", "")
+ desc_vp = book_dict.get("description_vietphrase", "")
+ if not desc_vp and desc:
+ # Lightweight fallback: just truncate Chinese description, no translation engine call
+ desc_vp = desc[:120]
+ book_dict["description_vietphrase"] = desc_vp or ""
+
+ # Tạo fallback description_english bằng cách bỏ dấu bản Việt hóa
+ if desc_vp:
+ book_dict["description_english"] = remove_vietnamese_accents(desc_vp)
+ else:
+ book_dict["description_english"] = desc[:120] if desc else ""
+
+ return book_dict
+
+def search_books(q, category, source, dup, sort, search_field, min_chapters, page, per_page, limit_k=100):
+ """Business logic for searching books with high-performance FTS5 or LIKE queries."""
+ MAX_PAGES_CEILING = 100
+ if page > MAX_PAGES_CEILING:
+ return {"error": "Deep pagination limit reached. Giới hạn tối đa 100 trang kết quả."}, 400
+
+ # Whitelist sort options
+ allowed_sorts = {
+ "site_count DESC", "chapters_max DESC", "word_count_max DESC", "title ASC", "title DESC", "id ASC"
+ }
+ if sort not in allowed_sorts:
+ sort = "site_count DESC"
+
+ where, params = [], []
+ fts_match_expr = None
+ has_chinese = False
+
+ if q:
+ has_chinese = any('\u4e00' <= char <= '\u9fff' for char in q)
+ if has_chinese:
+ # Use FTS5 for Chinese queries — 10x faster than LIKE scan on 931k rows
+ fts_q = q.replace('"', '').replace("'", '')
+ if search_field == "title":
+ fts_match_expr = f'title:"{fts_q}"'
+ elif search_field == "author":
+ fts_match_expr = f'author:"{fts_q}"'
+ elif search_field == "description":
+ # Description is NOT in FTS index — fallback to LIKE but only on description
+ pq = "%" + q + "%"
+ where.append("description LIKE ?")
+ params.append(pq)
+ else:
+ # Default: search title + author via FTS5 (fast)
+ fts_match_expr = f'title:"{fts_q}" OR author:"{fts_q}"'
+ else:
+ q_clean = clean_vietnamese_query(q)
+ fts_query = q_clean.replace('"', '').replace("'", '')
+ if fts_query:
+ if search_field == "title":
+ fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR title_vietphrase_clean:"{fts_query}"'
+ elif search_field == "author":
+ fts_match_expr = f'author_hanviet_clean:"{fts_query}"'
+ elif search_field == "hanviet":
+ fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR author_hanviet_clean:"{fts_query}"'
+ elif search_field == "vietphrase":
+ fts_match_expr = f'title_vietphrase_clean:"{fts_query}"'
+ elif search_field == "chinese":
+ fts_match_expr = f'title:"{q}" OR author:"{q}"'
+ elif search_field == "description":
+ where.append("(description LIKE ? OR description_vietphrase LIKE ? OR description_hanviet LIKE ?)")
+ pq = "%" + q + "%"
+ params += [pq, pq, pq]
+ else:
+ fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR title_vietphrase_clean:"{fts_query}" OR author_hanviet_clean:"{fts_query}"'
+
+ if category:
+ where.append("categories LIKE ?")
+ params.append("%" + category + "%")
+ if source:
+ where.append("sources LIKE ?")
+ params.append("%" + source + "%")
+ if dup == "multi":
+ where.append("site_count >= 2")
+ elif dup == "single":
+ where.append("site_count = 1")
+ if min_chapters:
+ try:
+ min_ch_val = int(min_chapters)
+ where.append("chapters_max >= ?")
+ params.append(min_ch_val)
+ except ValueError:
+ pass
+
+ where_sql = ("WHERE " + " AND ".join(where)) if where else ""
+
+ if fts_match_expr:
+ cache_key = ("fts_search_v2", fts_match_expr, where_sql, page, per_page, tuple(params), sort, q, limit_k)
+ cached_res = query_cache.get(cache_key)
+ if cached_res is not None:
+ return cached_res, 200
+
+ conn = get_db()
+ query = f"""
+ SELECT b.*, f.score
+ FROM books b
+ JOIN (
+ SELECT rowid, bm25(books_fts) AS score
+ FROM books_fts
+ WHERE books_fts MATCH ?
+ ) f ON b.id = f.rowid
+ {where_sql}
+ LIMIT {limit_k}
+ """
+ rows = conn.execute(query, [fts_match_expr] + params).fetchall()
+
+ if not rows:
+ res_data = {"total": 0, "page": 1, "pages": 1, "books": []}
+ query_cache.set(cache_key, res_data)
+ return res_data, 200
+
+ scores = [r['score'] for r in rows]
+ best_score = min(scores)
+ threshold = best_score * 0.28
+
+ has_accents = any(c != clean_vietnamese_query(c) for c in q)
+
+ def clean_text_for_tier(txt):
+ if not txt:
+ return ""
+ txt = txt.replace('đ', 'd').replace('Đ', 'D')
+ txt = "".join(c for c in unicodedata.normalize('NFKD', txt) if not unicodedata.combining(c)).lower()
+ return " ".join(re.sub(r'[^a-z0-9\s]', ' ', txt).split())
+
+ q_clean_tier = clean_text_for_tier(q)
+ q_norm = q.lower().strip()
+
+ def get_tier(book):
+ t_hv_clean = clean_text_for_tier(book.get('title_hanviet', ''))
+ t_vp_clean = clean_text_for_tier(book.get('title_vietphrase', ''))
+ if t_hv_clean == q_clean_tier or t_vp_clean == q_clean_tier:
+ return 1
+ if t_hv_clean.startswith(q_clean_tier) or t_vp_clean.startswith(q_clean_tier):
+ return 2
+ if q_clean_tier in t_hv_clean or q_clean_tier in t_vp_clean:
+ return 3
+ return 4
+
+ filtered_books = []
+ for r in rows:
+ book_dict = dict(r)
+ tier = get_tier(book_dict)
+ score = book_dict['score']
+
+ if has_accents:
+ t_hv = (book_dict.get('title_hanviet') or '').lower()
+ t_vp = (book_dict.get('title_vietphrase') or '').lower()
+ auth = (book_dict.get('author_hanviet') or '').lower()
+ desc = (book_dict.get('description') or '').lower()
+ 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:
+ continue
+
+ if tier in (3, 4) and score > threshold:
+ continue
+
+ book_dict['tier'] = tier
+ filtered_books.append(book_dict)
+
+ if sort == "title ASC":
+ filtered_books.sort(key=lambda x: (x.get('title_hanviet') or '').lower())
+ elif sort == "title DESC":
+ filtered_books.sort(key=lambda x: (x.get('title_hanviet') or '').lower(), reverse=True)
+ elif sort == "chapters_max DESC":
+ filtered_books.sort(key=lambda x: x.get('chapters_max') or 0, reverse=True)
+ elif sort == "word_count_max DESC":
+ filtered_books.sort(key=lambda x: x.get('word_count_max') or 0, reverse=True)
+ else:
+ filtered_books.sort(key=lambda x: (x['tier'], x['score'], -x['site_count']))
+
+ total = len(filtered_books)
+ pages = max(1, math.ceil(total / per_page))
+ offset = (page - 1) * per_page
+ books_page = [parse_book_sources(b) for b in filtered_books[offset : offset + per_page]]
+
+ res_data = {"total": total, "page": page, "pages": pages, "books": books_page}
+ query_cache.set(cache_key, res_data)
+ return res_data, 200
+
+ else:
+ order_by_sql = sort
+ order_params = []
+ if q and has_chinese:
+ order_by_sql = f"""
+ CASE
+ WHEN title = ? THEN 1
+ WHEN title LIKE ? THEN 2
+ ELSE 3
+ END ASC, {sort}
+ """
+ order_params = [q, q + "%"]
+
+ cache_key = (where_sql, order_by_sql, page, per_page, tuple(params), tuple(order_params))
+ cached_res = query_cache.get(cache_key)
+ if cached_res is not None:
+ return cached_res, 200
+
+ count_cache_key = (where_sql, tuple(params))
+ total = count_cache.get(count_cache_key)
+ if total is None:
+ conn = get_db()
+ total = conn.execute(
+ "SELECT COUNT(*) FROM books " + where_sql, params
+ ).fetchone()[0]
+ count_cache.set(count_cache_key, total)
+
+ pages = max(1, math.ceil(total / per_page))
+ if pages > MAX_PAGES_CEILING:
+ pages = MAX_PAGES_CEILING
+ total = min(total, MAX_PAGES_CEILING * per_page)
+
+ offset = (page - 1) * per_page
+
+ conn = get_db()
+ rows = conn.execute(
+ "SELECT * FROM books " + where_sql +
+ " ORDER BY " + order_by_sql +
+ " LIMIT ? OFFSET ?",
+ params + order_params + [per_page, offset]
+ ).fetchall()
+
+ books = [parse_book_sources(dict(r)) for r in rows]
+ res_data = {"total": total, "page": page, "pages": pages, "books": books}
+ query_cache.set(cache_key, res_data)
+ return res_data, 200
diff --git a/backend/services/email_service.py b/backend/services/email_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..62faa9c72ed4c8ebc21513dcf1277eaa8513944a
--- /dev/null
+++ b/backend/services/email_service.py
@@ -0,0 +1,172 @@
+import os
+import base64
+import smtplib
+import threading
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from backend.config import Config
+
+# Service Account delegation config
+SERVICE_ACCOUNT_FILE = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "gmail-delegator-key.json")
+DELEGATED_USER_EMAIL = os.environ.get("GMAIL_DELEGATED_EMAIL", "support@lyvuha.com")
+
+SCOPES = [
+ "https://www.googleapis.com/auth/gmail.send",
+ "https://www.googleapis.com/auth/gmail.readonly",
+ "https://www.googleapis.com/auth/gmail.modify"
+]
+
+def get_gmail_service():
+ """Builds and returns a Gmail API service client impersonating the DELEGATED_USER_EMAIL."""
+ import json
+ from google.oauth2 import service_account
+ from googleapiclient.discovery import build
+
+ service_account_json = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "")
+ creds = None
+
+ try:
+ # Nếu cấu hình trực tiếp chuỗi JSON key trong biến môi trường
+ if service_account_json and service_account_json.strip().startswith("{"):
+ info = json.loads(service_account_json)
+ creds = service_account.Credentials.from_service_account_info(
+ info,
+ scopes=SCOPES
+ )
+ # Nếu cấu hình bằng đường dẫn file
+ elif os.path.exists(SERVICE_ACCOUNT_FILE):
+ creds = service_account.Credentials.from_service_account_file(
+ SERVICE_ACCOUNT_FILE,
+ scopes=SCOPES
+ )
+
+ if not creds:
+ return None
+
+ delegated_creds = creds.with_subject(DELEGATED_USER_EMAIL)
+ service = build('gmail', 'v1', credentials=delegated_creds)
+ return service
+ except Exception as e:
+ print(f"❌ Error initializing Gmail API Service: {e}")
+ return None
+
+
+def send_email_smtp(to_email, subject, html_content):
+ """Sends an email using standard SMTP with App Password."""
+ smtp_cfg = Config.SMTP_CONFIG
+ if not smtp_cfg["username"] or not smtp_cfg["password"]:
+ print("⚠️ SMTP credentials not configured in Config.")
+ return False
+ try:
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = subject
+ msg["From"] = f"{smtp_cfg['from_name']} <{smtp_cfg['username']}>"
+ msg["To"] = to_email
+ msg.attach(MIMEText(html_content, "html", "utf-8"))
+
+ server = smtplib.SMTP(smtp_cfg["host"], smtp_cfg["port"])
+ server.starttls()
+ server.login(smtp_cfg["username"], smtp_cfg["password"])
+ server.sendmail(smtp_cfg["username"], to_email, msg.as_string())
+ server.quit()
+ print(f"📧 [SMTP] Successfully sent email to {to_email}")
+ return True
+ except Exception as e:
+ print(f"❌ [SMTP] Failed to send email: {e}")
+ return False
+
+def send_email_gmail_api(to_email, subject, html_content):
+ """Sends an email using Gmail API via Service Account impersonation."""
+ service = get_gmail_service()
+ if not service:
+ return send_email_smtp(to_email, subject, html_content)
+ try:
+ message = MIMEText(html_content, 'html', 'utf-8')
+ message['to'] = to_email
+ message['from'] = DELEGATED_USER_EMAIL
+ message['subject'] = subject
+
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
+ body = {'raw': raw_message}
+
+ service.users().messages().send(userId='me', body=body).execute()
+ print(f"📧 [Gmail API] Successfully sent email to {to_email} from {DELEGATED_USER_EMAIL}")
+ return True
+ except Exception as e:
+ print(f"❌ [Gmail API] Failed to send email, falling back to SMTP: {e}")
+ return send_email_smtp(to_email, subject, html_content)
+
+
+def send_email_gmail_oauth2(to_email, subject, html_content):
+ """Sends an email using Gmail REST API via OAuth2 Client ID/Secret and Refresh Token (over HTTPS 443)."""
+ client_id = os.environ.get("GOOGLE_CLIENT_ID", "")
+ client_secret = os.environ.get("GOOGLE_CLIENT_SECRET", "")
+ refresh_token = os.environ.get("GMAIL_REFRESH_TOKEN", "")
+ sender_email = os.environ.get("SMTP_EMAIL", "havucong@lyvuha.com")
+
+
+ if not refresh_token:
+ print("⚠️ GMAIL_REFRESH_TOKEN not configured.")
+ return False
+
+ try:
+ import base64
+ import requests
+
+ # 1. Get Access Token from Refresh Token
+ token_url = "https://oauth2.googleapis.com/token"
+ token_data = {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "refresh_token": refresh_token,
+ "grant_type": "refresh_token"
+ }
+ token_res = requests.post(token_url, data=token_data, timeout=5)
+ if token_res.status_code != 200:
+ print(f"❌ Failed to refresh Gmail access token: {token_res.text}")
+ return False
+
+ access_token = token_res.json().get("access_token")
+
+ # 2. Build MIME message
+ message = MIMEText(html_content, 'html', 'utf-8')
+ message['to'] = to_email
+ message['from'] = sender_email
+ message['subject'] = subject
+
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
+
+ # 3. Send email via Gmail API
+ send_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
+ headers = {
+ "Authorization": f"Bearer {access_token}",
+ "Content-Type": "application/json"
+ }
+ send_res = requests.post(send_url, headers=headers, json={"raw": raw_message}, timeout=5)
+ if send_res.status_code == 200:
+ print(f"📧 [Gmail OAuth2 API] Successfully sent email to {to_email}")
+ return True
+ else:
+ print(f"❌ [Gmail OAuth2 API] Failed to send email: {send_res.text}")
+ return False
+ except Exception as e:
+ print(f"❌ [Gmail OAuth2 API] Exception occurred: {e}")
+ return False
+
+
+def send_email_async(to_email, subject, html_content):
+ """Helper to send emails asynchronously in a background thread."""
+ refresh_token = os.environ.get("GMAIL_REFRESH_TOKEN", "")
+ service_account_json = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "")
+
+ if refresh_token:
+ # Ưu tiên sử dụng OAuth2 Refresh Token (HTTPS port 443)
+ threading.Thread(target=send_email_gmail_oauth2, args=(to_email, subject, html_content)).start()
+ elif service_account_json or os.path.exists(SERVICE_ACCOUNT_FILE):
+ # Sử dụng Google Service Account (HTTPS port 443)
+ threading.Thread(target=send_email_gmail_api, args=(to_email, subject, html_content)).start()
+ else:
+ # Fallback về SMTP
+ threading.Thread(target=send_email_smtp, args=(to_email, subject, html_content)).start()
+
+
diff --git a/backend/services/epub_service.py b/backend/services/epub_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..8501e39b37c4271df4c1d733545fecec5c5f7617
--- /dev/null
+++ b/backend/services/epub_service.py
@@ -0,0 +1,343 @@
+import os
+import re
+import zipfile
+import uuid
+import html
+import tempfile
+import time
+
+class EpubWriter:
+ def __init__(self, title, author="Unknown", cover_img_bytes=None, cover_ext="jpg", description=""):
+ self.title = title
+ self.author = author
+ self.description = description
+ self.chapters = [] # List of tuples (title, content)
+ self.cover_img_bytes = cover_img_bytes
+ self.cover_ext = cover_ext
+
+ def add_chapter(self, title, content_html):
+ self.chapters.append((title, content_html))
+
+ def write_to(self, dest_path):
+ with zipfile.ZipFile(dest_path, 'w', compression=zipfile.ZIP_DEFLATED) as epub:
+ # 1. mimetype (must be uncompressed and first entry)
+ epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
+
+ # 2. META-INF/container.xml
+ container_xml = """
+
+
+
+
+"""
+ epub.writestr('META-INF/container.xml', container_xml)
+
+ # Generate NCX
+ ncx_entries = []
+ for i, (ch_title, _) in enumerate(self.chapters):
+ ncx_entries.append(f"""
+ {html.escape(ch_title)}
+
+ """)
+
+ ncx_joined = "\n".join(ncx_entries)
+ toc_ncx = f"""
+
+
+
+
+
+
+
+ {html.escape(self.title)}
+ {html.escape(self.author)}
+
+
+ Trang Tên Sách
+
+
+{ncx_joined}
+
+"""
+ epub.writestr('OEBPS/toc.ncx', toc_ncx)
+
+ # Generate manifest items
+ manifest_items = [
+ ' ',
+ ' ',
+ ]
+ spine_items = [
+ '',
+ ]
+
+ if self.cover_img_bytes:
+ manifest_items.append(f' ')
+ manifest_items.append(' ')
+ spine_items.insert(0, '')
+
+ for i in range(len(self.chapters)):
+ manifest_items.append(f' ')
+ spine_items.append(f'')
+
+ # Generate content.opf
+ manifest_joined = "\n ".join(manifest_items)
+ spine_joined = "\n ".join(spine_items)
+ desc_meta = f'' if self.description else ''
+ cover_meta = '' if self.cover_img_bytes else ''
+
+ content_opf = f"""
+
+
+ {html.escape(self.title)}
+ {html.escape(self.author)}
+ vi
+ urn:uuid:{uuid.uuid4()}
+ {desc_meta}
+ {cover_meta}
+
+
+ {manifest_joined}
+
+
+ {spine_joined}
+
+"""
+ epub.writestr('OEBPS/content.opf', content_opf)
+
+ # Write cover files
+ if self.cover_img_bytes:
+ epub.writestr(f'OEBPS/images/cover.{self.cover_ext}', self.cover_img_bytes)
+ coverpage_xhtml = f"""
+
+
+
+ Bìa Sách
+
+
+
+
+

+
+
+"""
+ epub.writestr('OEBPS/text/coverpage.xhtml', coverpage_xhtml)
+
+ # Title page
+ titlepage_xhtml = f"""
+
+
+
+ {html.escape(self.title)}
+
+
+
+ {html.escape(self.title)}
+ Tác giả: {html.escape(self.author)}
+
+ {f'Giới thiệu:
{html.escape(self.description)}
' if self.description else ''}
+ Được biên dịch & đóng gói tự động bằng VIP EPUB Tools
+
+"""
+ epub.writestr('OEBPS/text/titlepage.xhtml', titlepage_xhtml)
+
+ # Write chapters XHTML
+ for i, (ch_title, ch_content) in enumerate(self.chapters):
+ if "