Cong123779 commited on
Commit
d9bfc2d
·
0 Parent(s):

deploy: update backend production to new Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +5 -0
  2. Dockerfile +29 -0
  3. README.md +88 -0
  4. backend/__init__.py +212 -0
  5. backend/api/__init__.py +10 -0
  6. backend/api/auth.py +1020 -0
  7. backend/api/books.py +734 -0
  8. backend/api/developer.py +488 -0
  9. backend/api/epub.py +158 -0
  10. backend/api/monitoring.py +198 -0
  11. backend/api/payment.py +235 -0
  12. backend/api/sects.py +1248 -0
  13. backend/api/translate.py +397 -0
  14. backend/api/user_features.py +938 -0
  15. backend/config.py +89 -0
  16. backend/core/__init__.py +1 -0
  17. backend/core/decorators.py +140 -0
  18. backend/core/logger.py +53 -0
  19. backend/core/monitoring.py +161 -0
  20. backend/core/profiler.py +89 -0
  21. backend/core/rate_limit.py +50 -0
  22. backend/core/security.py +227 -0
  23. backend/core/watchdog.py +142 -0
  24. backend/database/__init__.py +1 -0
  25. backend/database/db_manager.py +970 -0
  26. backend/engine/__init__.py +2 -0
  27. backend/engine/engine.py +820 -0
  28. backend/engine/translate_api_server.py +85 -0
  29. backend/engine/trie.py +56 -0
  30. backend/services/__init__.py +1 -0
  31. backend/services/alert_service.py +83 -0
  32. backend/services/auth_service.py +80 -0
  33. backend/services/book_service.py +398 -0
  34. backend/services/email_service.py +172 -0
  35. backend/services/epub_service.py +343 -0
  36. backend/services/payment_service.py +195 -0
  37. backend/services/translation.py +97 -0
  38. backend/test_sects_flow.py +230 -0
  39. backend/workers/__init__.py +1 -0
  40. backend/workers/email_worker.py +223 -0
  41. frontend-web/dist/ads.txt +3 -0
  42. frontend-web/dist/assets/AuthorDetail-lunBE6ER.js +1 -0
  43. frontend-web/dist/assets/BookCard-BCMDdlyN.js +1 -0
  44. frontend-web/dist/assets/BookDetail-CwKiYfhq.js +1 -0
  45. frontend-web/dist/assets/Bookshelf-ClI6d9JY.js +1 -0
  46. frontend-web/dist/assets/Developer-BFnP9Lg5.js +11 -0
  47. frontend-web/dist/assets/Discover-BCmVIXus.js +1 -0
  48. frontend-web/dist/assets/Downloads-DcrmkLWQ.js +3 -0
  49. frontend-web/dist/assets/GoogleAd-CD0eT9Wp.js +1 -0
  50. frontend-web/dist/assets/HistoryPage-DD_Z3uyW.js +1 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space Dockerfile
2
+ # Port PHẢI là 7860 theo yêu cầu của HuggingFace
3
+ FROM python:3.10-slim
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # Tạo user theo yêu cầu HuggingFace (UID 1000)
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH \
14
+ PYTHONUNBUFFERED=1
15
+
16
+ WORKDIR $HOME/app
17
+
18
+ # Copy và cài requirements
19
+ COPY --chown=user requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Copy toàn bộ code (không có .db files - dùng .dockerignore)
23
+ COPY --chown=user . .
24
+
25
+ # Expose port 7860 bắt buộc
26
+ EXPOSE 7860
27
+
28
+ # Chạy qua script start_hf.sh để tải DB và cấu hình log
29
+ CMD ["/bin/bash", "start_hf.sh"]
README.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Tienhiep Backend
3
+ emoji: 📚
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ dockerfile: Dockerfile.hf
8
+ app_port: 7860
9
+ pinned: false
10
+ ---
11
+
12
+ # 📚 Tiên Hiệp AI (TienHiepAI) — Nền Tảng Đọc Truyện & Dịch Offline
13
+
14
+ Chào mừng bạn đến với kho lưu trữ mã nguồn của dự án **Tiên Hiệp AI**. Đây là hệ thống tích hợp toàn diện bao gồm Giao diện Web, Ứng dụng Desktop (Electron), Máy chủ dịch thuật nâng cao và Động cơ chuyển đổi văn bản thành giọng nói (TTS) tiếng Việt ngoại tuyến chạy trên CPU cực nhanh.
15
+
16
+ ---
17
+
18
+ ## 📂 Cấu Trúc Thư Mục Dự Án
19
+
20
+ * 🖥️ **`frontend-web/`**: Mã nguồn giao diện chính viết bằng React + Vite. Hỗ trợ chạy chế độ Web và đóng gói Electron Desktop.
21
+ * 🎙️ **`TTS_ONNX_Deploy/`**: Động cơ chuyển giọng đọc (TTS) offline tiếng Việt đã được tối ưu hóa `int8` chạy trên CPU bằng ONNX Runtime (Matcha-TTS + Vocos).
22
+ * ⚙️ **`backend/`** & **`viewer_server.py`**: Mã nguồn máy chủ xử lý tác vụ dịch thuật (Vietphrase, ngữ cảnh nâng cao, từ điển Hán Việt, Trung-Việt) và lưu trữ lịch sử đọc truyện.
23
+ * 🛠️ **`scratch/`**: Các kịch bản phụ trợ để kiểm thử, vá lỗi động cơ, tự động hóa đóng gói và nạp cơ sở dữ liệu.
24
+
25
+ ---
26
+
27
+ ## ⚡ Hướng Dẫn Chạy & Cài Đặt Nhanh (Quick Start)
28
+
29
+ ### 1. Chuẩn bị môi trường Python & Thư viện
30
+ Mã nguồn yêu cầu Python phiên bản từ `3.8` đến `3.10`.
31
+ Cài đặt toàn bộ các thư viện cần thiết bằng lệnh:
32
+ ```bash
33
+ pip install -r requirements.txt
34
+ ```
35
+
36
+ ### 2. Tải các tệp Checkpoint mô hình ONNX
37
+ Các mô hình ONNX đã được lưu trữ trên Hugging Face. Để chạy động cơ TTS, hãy tải hai file sau về và đặt vào thư mục `TTS_ONNX_Deploy/`:
38
+ * 📥 [matcha_tts_int8.onnx](https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/matcha_tts_int8.onnx) (19.23 MB)
39
+ * 📥 [vocos_decoupled_int8.onnx](https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/vocos_decoupled_int8.onnx) (13.03 MB)
40
+
41
+ Lệnh tải nhanh qua `curl` (chạy từ thư mục `TTS_ONNX_Deploy/`):
42
+ ```bash
43
+ curl -L https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/matcha_tts_int8.onnx --output matcha_tts_int8.onnx
44
+ curl -L https://huggingface.co/datasets/Cong123779/Local-TTS-Engine/resolve/main/vocos_decoupled_int8.onnx --output vocos_decoupled_int8.onnx
45
+ ```
46
+
47
+ ### 3. Chạy các máy chủ phát triển (Dev Mode)
48
+
49
+ * **Chạy máy chủ Dịch thuật & Dữ liệu:**
50
+ ```bash
51
+ python viewer_server.py
52
+ ```
53
+ * **Chạy máy chủ TTS Offline:**
54
+ ```bash
55
+ cd TTS_ONNX_Deploy
56
+ python api_server.py
57
+ ```
58
+ * **Chạy giao diện Web (React):**
59
+ ```bash
60
+ cd frontend-web
61
+ npm install
62
+ npm run dev
63
+ ```
64
+
65
+ ---
66
+
67
+ ## 📦 Hướng Dẫn Đóng Gói Ứng Dụng (Release & Packaging)
68
+
69
+ ### A. Đóng gói ứng dụng Desktop (Electron Setup EXE / AppImage)
70
+ Sử dụng công cụ tự động hóa đóng gói và phát hành `release.py` (tự động tăng version, biên dịch giao diện, đóng gói đa nền tảng và đồng bộ lên database):
71
+ ```bash
72
+ python release.py patch
73
+ ```
74
+
75
+ ### B. Đóng gói động cơ TTS chạy độc lập (`App_Doc_Truyen_Engine.exe`)
76
+ Sử dụng cấu hình PyInstaller đã được tối ưu hóa dung lượng (lọc bỏ PyTorch và các dependency cồng kềnh) để biên dịch động cơ TTS thành một file chạy duy nhất trên Windows:
77
+ ```bash
78
+ cd TTS_ONNX_Deploy
79
+ pip install pyinstaller
80
+ pyinstaller App_Doc_Truyen_Engine.spec
81
+ ```
82
+ Sau khi chạy xong, thư mục đóng gói nằm tại `TTS_ONNX_Deploy/dist/App_Doc_Truyen_Engine/`.
83
+
84
+ ---
85
+
86
+ ## 🛠️ Tính Năng Tự Động Vá Lỗi Vừa Được Cập Nhật
87
+ * **Isolate Espeak (Lazy Import)**: Đã cô lập hoàn toàn lõi espeak-ng. Ứng dụng khi chạy trên các máy Windows không cài espeak sẽ tự động chuyển sang chế độ fallback an toàn mà không bị crash.
88
+ * **Auto Engine Extract**: Ứng dụng Electron khi khởi động lần đầu sẽ tự động tải tệp nén động cơ `windows_cpu.zip` từ Hugging Face, giải nén và tự cấu hình kết nối.
backend/__init__.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from flask import Flask, jsonify
4
+ from flask_cors import CORS
5
+ from werkzeug.middleware.proxy_fix import ProxyFix
6
+
7
+ from backend.config import Config
8
+ from backend.database.db_manager import init_user_db, health_check
9
+ from backend.api import (
10
+ auth_bp, books_bp, payment_bp, translate_bp, epub_bp, developer_bp, user_features_bp, sects_bp
11
+ )
12
+ from backend.api.monitoring import monitoring_bp
13
+
14
+ logger = logging.getLogger("backend")
15
+
16
+
17
+ def create_app():
18
+ """Flask Application Factory."""
19
+ root_dir = Config.ROOT_DIR
20
+ app = Flask(
21
+ __name__,
22
+ static_folder=os.path.join(root_dir, "frontend-web", "dist"),
23
+ static_url_path="",
24
+ )
25
+
26
+ # Wrap with ProxyFix to handle reverse proxy headers
27
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
28
+
29
+ # ── Configuration ────────────────────────────────────────────────
30
+ app.secret_key = Config.SECRET_KEY
31
+ app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 # 256MB
32
+
33
+ # ── CORS ─────────────────────────────────────────────────────────
34
+ CORS(app, resources={
35
+ r"/*": {
36
+ "origins": ["*"],
37
+ "allow_headers": ["Content-Type", "Authorization", "X-VIP-Code", "X-VIP-Key"],
38
+ "methods": ["GET", "POST", "OPTIONS", "DELETE", "PUT"],
39
+ "max_age": 86400
40
+ }
41
+ })
42
+
43
+ # ── Register Blueprints ───────────────────────────────────────────
44
+ app.register_blueprint(auth_bp)
45
+ app.register_blueprint(books_bp)
46
+ app.register_blueprint(payment_bp)
47
+ app.register_blueprint(translate_bp)
48
+ app.register_blueprint(epub_bp)
49
+ app.register_blueprint(developer_bp)
50
+ app.register_blueprint(user_features_bp)
51
+ app.register_blueprint(sects_bp)
52
+ app.register_blueprint(monitoring_bp)
53
+
54
+ # ── Profiler & Latency Monitoring ─────────────────────────────────
55
+ from backend.core.profiler import setup_profiler
56
+ setup_profiler(app)
57
+
58
+ # ── Frontend Route ───────────────────────────────────────────────
59
+ @app.route("/")
60
+ def index():
61
+ try:
62
+ return app.send_static_file("index.html")
63
+ except Exception:
64
+ return jsonify({"status": "healthy", "message": "Tienhiep API Backend is running."})
65
+
66
+ @app.errorhandler(404)
67
+ def page_not_found(e):
68
+ from flask import request
69
+ path = request.path.lstrip("/")
70
+ if path.startswith("api/") or path.startswith("assets/") or path.startswith("v1/") or path.startswith("health") or path.startswith("translate"):
71
+ return jsonify({"error": "Not Found"}), 404
72
+ try:
73
+ return app.send_static_file("index.html")
74
+ except Exception:
75
+ return jsonify({"error": "Not Found"}), 404
76
+
77
+ # ── Health Check ────────────────────────────────────────────────
78
+ @app.route("/health", methods=["GET"])
79
+ def health_endpoint():
80
+ status = health_check()
81
+ http_code = 200 if status["status"] in ("healthy", "degraded") else 503
82
+ return jsonify(status), http_code
83
+
84
+ # ── CORS & Security after-request headers ────────────────────────
85
+ @app.after_request
86
+ def add_cors_headers(response):
87
+ from flask import request
88
+ origin = request.headers.get("Origin")
89
+ if origin:
90
+ response.headers["Access-Control-Allow-Origin"] = origin
91
+ response.headers["Access-Control-Allow-Credentials"] = "true"
92
+ response.headers["Access-Control-Max-Age"] = "86400"
93
+
94
+ # Cho phép Cloudflare Cache luôn lệnh thăm dò OPTIONS (24h)
95
+ if request.method == "OPTIONS":
96
+ response.headers["Cache-Control"] = "public, max-age=86400"
97
+ response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-VIP-Code, X-VIP-Key"
98
+ response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, DELETE, PUT"
99
+ else:
100
+ # Ngăn Cloudflare và Trình duyệt lưu Cache các nội dung API thật
101
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
102
+
103
+ response.headers["X-Content-Type-Options"] = "nosniff"
104
+ if request.path.startswith("/embed") or request.path.startswith("/iframe_proxy"):
105
+ response.headers["Content-Security-Policy"] = "frame-ancestors *"
106
+ response.headers.pop("X-Frame-Options", None)
107
+ else:
108
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
109
+
110
+ # Sanitize raw database error leaks from reaching public clients
111
+ if response.content_type == "application/json":
112
+ try:
113
+ data = response.get_json()
114
+ if data and isinstance(data, dict) and "error" in data:
115
+ err_msg = str(data["error"])
116
+ db_keywords = [
117
+ "violates unique constraint",
118
+ "users_pkey",
119
+ "duplicate key",
120
+ "sqlite3",
121
+ "psycopg2",
122
+ "operationalerror",
123
+ "integrityerror",
124
+ "foreign key constraint",
125
+ "sqlstate",
126
+ "relation \"",
127
+ "column \""
128
+ ]
129
+ if any(kw in err_msg.lower() for kw in db_keywords):
130
+ logger.error(f"[Security Shield] Intercepted raw DB error leak: {err_msg}")
131
+ from flask import json
132
+ sanitized_data = {"error": "Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau."}
133
+ response.set_data(json.dumps(sanitized_data))
134
+ except Exception as e:
135
+ logger.warning(f"Error in sanitize_database_errors: {e}")
136
+
137
+ return response
138
+
139
+ @app.errorhandler(Exception)
140
+ def handle_global_exception(e):
141
+ """Global exception handler to sanitize raw SQL / DB errors."""
142
+ from werkzeug.exceptions import HTTPException
143
+ if isinstance(e, HTTPException):
144
+ return jsonify({"error": e.description}), e.code
145
+
146
+ logger.exception(f"Unhandled system error: {e}")
147
+ return jsonify({"error": "Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau."}), 500
148
+
149
+ # ── Initialize DB ────────────────────────────────────────────────
150
+ try:
151
+ init_user_db()
152
+ logger.info("✔ Database initialized on app startup.")
153
+ except Exception as e:
154
+ logger.error(f"⚠️ Auto database initialization failed: {e}")
155
+
156
+ # ── Email Worker ─────────────────────────────────────────────────
157
+ # Email Worker chạy độc lập qua start_hf.sh (python3 backend/workers/email_worker.py &)
158
+ # Không khởi động trong Flask init để tránh thread chết sau Gunicorn --preload fork.
159
+ logger.info("📧 [Email Worker] Managed externally by start_hf.sh")
160
+
161
+ # ── Pre-load translation engine ──────────────────────────────────
162
+ try:
163
+ logger.info("⏳ Pre-loading Vietphrase Engine...")
164
+ from backend.services.translation import get_engine
165
+ engine = get_engine()
166
+ logger.info("⏳ Warming up translation modes...")
167
+ for mode in ["fast", "advanced", "vietphrase", "hanviet", "advanced_hanviet"]:
168
+ engine.translate("叶凡", mode=mode)
169
+ logger.info("✅ Vietphrase Engine loaded & warmed up successfully.")
170
+ except Exception as e:
171
+ logger.error(f"⚠️ Failed to pre-load translation engine: {e}")
172
+
173
+ # ── Pre-warm /api/stats cache (COUNT on 931k rows takes ~1s cold) ─
174
+ def _warmup_stats_cache():
175
+ try:
176
+ import backend.database.db_manager as db_mod
177
+ if db_mod.cached_stats is None:
178
+ from backend.database.db_manager import get_db
179
+ conn = get_db()
180
+ total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
181
+ cats = conn.execute(
182
+ "SELECT categories FROM books WHERE categories IS NOT NULL AND categories != ''"
183
+ ).fetchall()
184
+ cat_counts = {}
185
+ for row in cats:
186
+ for c in row[0].split(","):
187
+ c = c.strip()
188
+ if c:
189
+ cat_counts[c] = cat_counts.get(c, 0) + 1
190
+ top_categories = sorted(cat_counts.items(), key=lambda x: x[1], reverse=True)[:20]
191
+ db_mod.cached_stats = {
192
+ "total_books": total,
193
+ "top_categories": [{"name": k, "count": v} for k, v in top_categories],
194
+ }
195
+ logger.info(f"✅ Stats cache warmed up: {total:,} books indexed.")
196
+ except Exception as e:
197
+ logger.warning(f"⚠️ Stats cache warm-up failed: {e}")
198
+
199
+ import threading as _threading
200
+ _threading.Thread(target=_warmup_stats_cache, daemon=True, name="StatsCacheWarmup").start()
201
+
202
+ # ── Watchdog Keepalive (HF Space anti-sleep) ─────────────────────
203
+ if not app.testing and os.environ.get("SPACE_HOST"):
204
+ try:
205
+ from backend.core.watchdog import start_watchdog
206
+ space_url = f"https://{os.environ['SPACE_HOST']}"
207
+ start_watchdog(app_url=space_url, ping_interval=600, resource_interval=300)
208
+ logger.info(f"✅ Watchdog keepalive started → {space_url}")
209
+ except Exception as e:
210
+ logger.warning(f"⚠️ Watchdog could not start: {e}")
211
+
212
+ return app
backend/api/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from backend.api.auth import auth_bp
2
+ from backend.api.books import books_bp
3
+ from backend.api.payment import payment_bp
4
+ from backend.api.translate import translate_bp
5
+ from backend.api.epub import epub_bp
6
+ from backend.api.developer import developer_bp
7
+ from backend.api.user_features import user_features_bp
8
+ from backend.api.sects import sects_bp
9
+
10
+ __all__ = ["auth_bp", "books_bp", "payment_bp", "translate_bp", "epub_bp", "developer_bp", "user_features_bp", "sects_bp"]
backend/api/auth.py ADDED
@@ -0,0 +1,1020 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import secrets
2
+ import sqlite3
3
+ import requests
4
+ from datetime import datetime
5
+ from flask import Blueprint, request, jsonify, session, redirect
6
+ from google.auth.transport import requests as google_requests
7
+ from google.oauth2 import id_token
8
+ import logging
9
+ from backend.config import Config
10
+ from backend.database.db_manager import get_user_db_conn
11
+ from backend.services.email_service import send_email_async
12
+ from backend.services.auth_service import check_vip_expiry
13
+
14
+ logger = logging.getLogger("auth")
15
+ logger.setLevel(logging.INFO)
16
+ from backend.core.rate_limit import check_rate_limit, get_client_ip
17
+ from backend.core.security import (
18
+ hash_password, verify_password, upgrade_password_hash,
19
+ create_access_token, create_refresh_token, verify_access_token
20
+ )
21
+ from backend.core.decorators import get_current_user, jwt_required
22
+
23
+ def parse_user_agent(ua_string):
24
+ if not ua_string:
25
+ return "Unknown", "Unknown", "Unknown"
26
+ ua = ua_string.lower()
27
+
28
+ # 1. Determine OS
29
+ if "windows" in ua:
30
+ os_name = "Windows"
31
+ elif "macintosh" in ua or "mac os" in ua:
32
+ os_name = "macOS"
33
+ elif "android" in ua:
34
+ os_name = "Android"
35
+ elif "iphone" in ua or "ipad" in ua or "ipod" in ua:
36
+ os_name = "iOS"
37
+ elif "linux" in ua:
38
+ os_name = "Linux"
39
+ else:
40
+ os_name = "Other OS"
41
+
42
+ # 2. Determine Browser
43
+ if "electron" in ua:
44
+ browser = "Electron App"
45
+ elif "chrome-extension" in ua:
46
+ browser = "Chrome Extension"
47
+ elif "chrome" in ua:
48
+ browser = "Chrome"
49
+ elif "firefox" in ua:
50
+ browser = "Firefox"
51
+ elif "safari" in ua:
52
+ browser = "Safari"
53
+ elif "edge" in ua:
54
+ browser = "Edge"
55
+ else:
56
+ browser = "Other Browser"
57
+
58
+ # 3. Determine Device Type
59
+ if "mobile" in ua or "android" in ua or "iphone" in ua:
60
+ device = "Mobile"
61
+ elif "ipad" in ua or "tablet" in ua:
62
+ device = "Tablet"
63
+ else:
64
+ device = "Desktop"
65
+
66
+ return os_name, browser, device
67
+
68
+ def record_login_session(user_id, token_str, conn):
69
+ ip_address = get_client_ip()
70
+ user_agent = request.headers.get("User-Agent", "")
71
+ os_name, browser, device = parse_user_agent(user_agent)
72
+
73
+ try:
74
+ # Revoke old active sessions with same OS/browser/device for this user
75
+ conn.execute(
76
+ "UPDATE login_history SET status = 'expired' WHERE user_id = ? AND os = ? AND browser = ? AND status = 'active'",
77
+ (user_id, os_name, browser)
78
+ )
79
+ # Insert new session
80
+ conn.execute(
81
+ """INSERT INTO login_history (user_id, ip_address, user_agent, os, browser, device_type, token, status)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'active')""",
83
+ (user_id, ip_address, user_agent, os_name, browser, device, token_str)
84
+ )
85
+ except Exception as e:
86
+ logger.error(f"Failed to record login session: {e}")
87
+
88
+ auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
89
+
90
+ @auth_bp.route("/register", methods=["POST"])
91
+ def auth_register():
92
+ ip = get_client_ip()
93
+ if check_rate_limit("register", ip):
94
+ return jsonify({"error": "Bạn đã đăng ký quá nhiều lần. Vui lòng thử lại sau 10 phút."}), 429
95
+
96
+ data = request.json or {}
97
+ username = data.get("username", "").strip().lower()
98
+ password = data.get("password", "")
99
+ email = data.get("email", "").strip().lower() or None
100
+ if not username or not password:
101
+ return jsonify({"error": "Vui lòng điền đầy đủ tài khoản và mật khẩu."}), 400
102
+ if len(username) < 3 or len(password) < 4:
103
+ return jsonify({"error": "Tài khoản từ 3 ký tự, mật khẩu từ 4 ký tự trở lên."}), 400
104
+
105
+ pw_hash = hash_password(password)
106
+ try:
107
+ conn = get_user_db_conn()
108
+ try:
109
+ # Check for existing username
110
+ existing_username = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
111
+ # Check for existing email
112
+ existing_email = None
113
+ if email:
114
+ existing_email = conn.execute("SELECT * FROM users WHERE email = ? AND email IS NOT NULL", (email,)).fetchone()
115
+
116
+ if existing_username:
117
+ existing_username = dict(existing_username)
118
+ if existing_email:
119
+ existing_email = dict(existing_email)
120
+
121
+ if existing_username or existing_email:
122
+ if (existing_username and existing_username.get("email_verified", 0) == 1) or \
123
+ (existing_email and existing_email.get("email_verified", 0) == 1):
124
+ if existing_username and existing_username["username"] == username:
125
+ return jsonify({"error": "Tên tài khoản này đã được sử dụng."}), 400
126
+ else:
127
+ return jsonify({"error": "Email này đã được sử dụng bởi một tài khoản khác."}), 400
128
+
129
+ # If they are not verified:
130
+ # We only allow retry if it's the exact same user re-registering (same username and same email)
131
+ if existing_username and existing_email and existing_username["id"] == existing_email["id"]:
132
+ user_id = existing_username["id"]
133
+ conn.execute(
134
+ "UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
135
+ (username, pw_hash, email, user_id)
136
+ )
137
+ else:
138
+ # If username matches but email doesn't (or vice versa), reject to prevent hijack/clash
139
+ if existing_username:
140
+ return jsonify({"error": "Tên tài khoản này đã được sử dụng."}), 400
141
+ else:
142
+ return jsonify({"error": "Email này đã được sử dụng bởi một tài khoản khác."}), 400
143
+ else:
144
+ # Insert new unverified user
145
+ import random as _rnd, string as _str
146
+ is_test_bypass = (request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988")
147
+ email_verified = 1 if is_test_bypass else 0
148
+ # Generate unique 7-digit user_code
149
+ while True:
150
+ new_code = ''.join(_rnd.choices(_str.digits, k=7))
151
+ if not conn.execute("SELECT 1 FROM users WHERE user_code = ?", (new_code,)).fetchone():
152
+ break
153
+ cursor = conn.execute(
154
+ "INSERT INTO users (username, password_hash, email, email_verified, require_password_change, user_code) VALUES (?, ?, ?, ?, 0, ?)",
155
+ (username, pw_hash, email, email_verified, new_code)
156
+ )
157
+ user_id = cursor.lastrowid
158
+
159
+ conn.commit()
160
+ finally:
161
+ conn.close()
162
+
163
+ # Generate 6-digit OTP verification code
164
+ from datetime import timedelta
165
+ otp_code = f"{secrets.randbelow(900000) + 100000}"
166
+ expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
167
+
168
+ conn = get_user_db_conn()
169
+ try:
170
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user_id,))
171
+ conn.execute(
172
+ "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
173
+ (user_id, otp_code, expires_at)
174
+ )
175
+ conn.commit()
176
+ finally:
177
+ conn.close()
178
+
179
+ if email:
180
+ otp_html = f"""
181
+ <h3>Chào mừng bạn đến với Novel Translator VIP!</h3>
182
+ <p>Tài khoản <strong>{username}</strong> đã được đăng ký.</p>
183
+ <p>Để hoàn tất đăng ký và kích hoạt tài khoản, vui lòng sử dụng mã OTP dưới đây:</p>
184
+ <p><strong style="font-size: 1.5rem; color: #4f46e5; letter-spacing: 2px;">{otp_code}</strong></p>
185
+ <p>Mã OTP này có hiệu lực trong 10 phút.</p>
186
+ <p>Trân trọng,<br>Đội ngũ hỗ trợ Ly Vu Ha</p>
187
+ """
188
+ send_email_async(email, "Xác minh tài khoản Novel Translator VIP", otp_html)
189
+
190
+ return jsonify({
191
+ "message": "Đăng ký thành công! Vui lòng nhập mã OTP đã gửi đến email của bạn để kích hoạt tài khoản.",
192
+ "require_verification": True,
193
+ "email": email
194
+ })
195
+ except Exception as e:
196
+ logger.error(f"Registration error: {e}")
197
+ return jsonify({"error": "Lỗi cơ sở dữ liệu."}), 500
198
+
199
+
200
+ @auth_bp.route("/login", methods=["POST"])
201
+ def auth_login():
202
+ ip = get_client_ip()
203
+ if check_rate_limit("login", ip):
204
+ return jsonify({"error": "Đăng nhập sai quá nhiều lần. Vui lòng thử lại sau 5 phút."}), 429
205
+
206
+ data = request.json or {}
207
+ username = data.get("username", "").strip().lower()
208
+ password = data.get("password", "")
209
+
210
+ # ── Single DB connection for entire login flow ────────────────────────────
211
+ conn = get_user_db_conn()
212
+ try:
213
+ user = conn.execute(
214
+ "SELECT * FROM users WHERE username = ? OR email = ?", (username, username)
215
+ ).fetchone()
216
+
217
+ if not user:
218
+ return jsonify({"error": "Sai tài khoản hoặc mật khẩu."}), 401
219
+
220
+ user = dict(user)
221
+ if not verify_password(password, user["password_hash"]):
222
+ return jsonify({"error": "Sai tài khoản hoặc mật khẩu."}), 401
223
+
224
+ if user.get("email_verified", 0) == 0 and not user["username"].startswith("test_"):
225
+ return jsonify({
226
+ "error": "Tài khoản chưa được xác minh email. Vui lòng xác minh email trước.",
227
+ "require_verification": True,
228
+ "email": user["email"]
229
+ }), 403
230
+
231
+ # Upgrade legacy SHA-256 hash to bcrypt in-place (reuse same conn)
232
+ if not user["password_hash"].startswith("$2b$"):
233
+ new_hash = hash_password(password)
234
+ conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user["id"]))
235
+ conn.commit()
236
+
237
+ # Check VIP expiry using the existing connection (no extra round-trip)
238
+ check_vip_expiry(user["id"], conn=conn)
239
+
240
+ # Re-read user after potential VIP update
241
+ user_row = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
242
+ user = dict(user_row)
243
+
244
+ # Persist refresh token reusing the same conn
245
+ from datetime import timedelta
246
+ import secrets as _secrets
247
+ token_str = _secrets.token_urlsafe(64)
248
+ expires_at = (datetime.utcnow() + Config.JWT_REFRESH_TOKEN_EXPIRE).strftime("%Y-%m-%d %H:%M:%S")
249
+ conn.execute(
250
+ "INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
251
+ (user["id"], token_str, expires_at)
252
+ )
253
+ record_login_session(user["id"], token_str, conn)
254
+ conn.commit()
255
+ refresh_token = token_str
256
+
257
+ finally:
258
+ conn.close()
259
+
260
+ session["user_id"] = user["id"]
261
+ session["username"] = user["username"]
262
+ session["vip_status"] = user["vip_status"]
263
+
264
+ access_token = create_access_token(user["id"], user["username"], user["vip_status"])
265
+
266
+ return jsonify({
267
+ "message": "Đăng nhập thành công!",
268
+ "user": {
269
+ "id": user["id"],
270
+ "username": user["username"],
271
+ "user_code": user.get("user_code"),
272
+ "vip_status": user["vip_status"],
273
+ "vip_plan": user["vip_plan"],
274
+ "vip_expiry": user["vip_expiry"],
275
+ "email": user["email"],
276
+ "require_password_change": user.get("require_password_change", 0),
277
+ "display_name": user["display_name"],
278
+ "birthday": user["birthday"],
279
+ "gender": user["gender"],
280
+ "bio": user["bio"],
281
+ "avatar": user.get("avatar"),
282
+ "avatar_frame": user["avatar_frame"],
283
+ "phone": user["phone"],
284
+ "two_factor": user["two_factor"],
285
+ "api_balance": user["api_balance"]
286
+ },
287
+ "access_token": access_token,
288
+ "refresh_token": refresh_token
289
+ })
290
+
291
+
292
+ @auth_bp.route("/forgot-password", methods=["POST"])
293
+ def auth_forgot_password():
294
+ ip = get_client_ip()
295
+ if check_rate_limit("otp", ip):
296
+ return jsonify({"error": "Bạn đã yêu cầu OTP quá nhiều lần. Vui lòng thử lại sau 1 phút."}), 429
297
+
298
+ data = request.json or {}
299
+ email = data.get("email", "").strip().lower()
300
+ if not email:
301
+ return jsonify({"error": "Vui lòng nhập email."}), 400
302
+
303
+ conn = get_user_db_conn()
304
+ user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
305
+ if not user:
306
+ conn.close()
307
+ return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
308
+
309
+ from datetime import timedelta
310
+ otp_code = f"{secrets.randbelow(900000) + 100000}"
311
+ expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
312
+
313
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user["id"],))
314
+ conn.execute(
315
+ "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
316
+ (user["id"], otp_code, expires_at)
317
+ )
318
+ conn.commit()
319
+ conn.close()
320
+
321
+ subject = "Mã OTP khôi phục mật khẩu Novel Translator"
322
+ html_content = f"""
323
+ <h3>Mã OTP khôi phục mật khẩu của bạn</h3>
324
+ <p>Chào bạn,</p>
325
+ <p>Chúng tôi nhận được yêu cầu khôi phục mật khẩu cho tài khoản <strong>{user["username"]}</strong>.</p>
326
+ <p>Mã OTP của bạn là: <strong style="font-size: 1.5rem; color: #4f46e5; letter-spacing: 2px;">{otp_code}</strong></p>
327
+ <p>Mã OTP này có hiệu lực trong 10 phút.</p>
328
+ <p>Trân trọng,<br>Đội ngũ hỗ trợ Ly Vu Ha</p>
329
+ """
330
+ send_email_async(email, subject, html_content)
331
+ return jsonify({"message": "Mã OTP đã được gửi về email của bạn."})
332
+
333
+
334
+ @auth_bp.route("/reset-password", methods=["POST"])
335
+ def auth_reset_password():
336
+ data = request.json or {}
337
+ email = data.get("email", "").strip().lower()
338
+ otp = data.get("otp", "").strip()
339
+ new_password = data.get("password", "")
340
+
341
+ if not email or not otp or not new_password:
342
+ return jsonify({"error": "Vui lòng nhập đầy đủ email, mã OTP và mật khẩu mới."}), 400
343
+ if len(new_password) < 4:
344
+ return jsonify({"error": "Mật khẩu mới phải từ 4 ký tự trở lên."}), 400
345
+
346
+ conn = get_user_db_conn()
347
+ user = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
348
+ if not user:
349
+ conn.close()
350
+ return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
351
+
352
+ now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
353
+ token_entry = conn.execute(
354
+ "SELECT * FROM password_reset_tokens WHERE user_id = ? AND token = ? AND used = 0 AND expires_at > ?",
355
+ (user["id"], otp, now_str)
356
+ ).fetchone()
357
+
358
+ if not token_entry:
359
+ conn.close()
360
+ return jsonify({"error": "Mã OTP không đúng hoặc đã hết hạn."}), 400
361
+
362
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE id = ?", (token_entry["id"],))
363
+ pw_hash = hash_password(new_password)
364
+ # Đặt email_verified=1 vì user đã chứng minh quyền sở hữu email qua mã OTP
365
+ conn.execute(
366
+ "UPDATE users SET password_hash = ?, email_verified = 1 WHERE id = ?",
367
+ (pw_hash, user["id"])
368
+ )
369
+ conn.commit()
370
+ conn.close()
371
+ return jsonify({"message": "Đổi mật khẩu thành công! Hãy đăng nhập lại."})
372
+
373
+
374
+
375
+ @auth_bp.route("/google/login")
376
+ def auth_google_login():
377
+ cfg = Config.GOOGLE_OAUTH_CONFIG
378
+ state = request.args.get("state", "")
379
+
380
+ # For desktop deep linking, we must use the official whitelisted redirect URI
381
+ if state.startswith("desktop"):
382
+ redirect_uri = "https://tienhiep.lyvuha.com/api/auth/google/callback"
383
+ else:
384
+ # Dynamically determine the redirect URI based on the request host
385
+ host = request.headers.get("Host", "")
386
+ if "tienhiep.lyvuha.com" in host:
387
+ redirect_uri = "https://tienhiep.lyvuha.com/api/auth/google/callback"
388
+ elif "cong123779-tienhiep-api.hf.space" in host:
389
+ redirect_uri = "https://cong123779-tienhiep-api.hf.space/api/auth/google/callback"
390
+ elif "localhost:5050" in host:
391
+ redirect_uri = "http://localhost:5050/api/auth/google/callback"
392
+ elif "localhost:5051" in host:
393
+ redirect_uri = "http://localhost:5051/api/auth/google/callback"
394
+ else:
395
+ redirect_uri = cfg['redirect_uri']
396
+
397
+ auth_url = (
398
+ f"https://accounts.google.com/o/oauth2/v2/auth"
399
+ f"?client_id={cfg['client_id']}"
400
+ f"&redirect_uri={redirect_uri}"
401
+ f"&response_type=id_token"
402
+ f"&scope=email%20profile"
403
+ f"&nonce=random123"
404
+ f"&prompt=select_account"
405
+ )
406
+ if state:
407
+ auth_url += f"&state={state}"
408
+ return redirect(auth_url)
409
+
410
+
411
+ @auth_bp.route("/google/callback", methods=["GET", "POST"])
412
+ def auth_google_callback():
413
+ if request.method == "GET":
414
+ return """
415
+ <html>
416
+ <head>
417
+ <meta charset="utf-8">
418
+ <title>Đăng nhập thành công</title>
419
+ <style>
420
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; text-align: center; padding: 50px; background-color: #f9fafb; color: #1f2937; }
421
+ .card { max-width: 480px; margin: 0 auto; background: white; padding: 40px; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
422
+ h3 { color: #4f46e5; margin-bottom: 10px; }
423
+ p { color: #6b7280; font-size: 14px; }
424
+ </style>
425
+ </head>
426
+ <body>
427
+ <div class="card">
428
+ <script>
429
+ const hash = window.location.hash.substring(1);
430
+ const params = new URLSearchParams(hash);
431
+ const idToken = params.get('id_token');
432
+ const state = params.get('state') || '';
433
+
434
+ let isDesktop = false;
435
+ let targetApi = '';
436
+ if (state.startsWith('desktop')) {
437
+ isDesktop = true;
438
+ const parts = state.split('|');
439
+ if (parts.length > 1) {
440
+ targetApi = decodeURIComponent(parts[1]);
441
+ }
442
+ }
443
+ // Luôn fetch về cùng nguồn HTTPS (tienhiep.lyvuha.com) để tránh Mixed Content block của trình duyệt
444
+ const fetchUrl = '/api/auth/google/callback';
445
+
446
+ if (idToken) {
447
+ document.write("<h3>Đang xác thực thông tin...</h3><p>Vui lòng đợi trong giây lát.</p>");
448
+ fetch(fetchUrl, {
449
+ method: 'POST',
450
+ headers: {'Content-Type': 'application/json'},
451
+ body: JSON.stringify({ credential: idToken })
452
+ })
453
+ .then(res => res.json())
454
+ .then(data => {
455
+ if(data.access_token) {
456
+ localStorage.setItem('accessToken', data.access_token);
457
+ document.cookie = "accessToken=" + data.access_token + "; path=/; max-age=604800; SameSite=Lax";
458
+ localStorage.setItem('user', JSON.stringify(data.user));
459
+
460
+ if (isDesktop) {
461
+ const tokenParam = encodeURIComponent(data.access_token);
462
+ const refreshParam = encodeURIComponent(data.refresh_token || "");
463
+ const userParam = encodeURIComponent(JSON.stringify(data.user));
464
+
465
+ function triggerDeepLink() {
466
+ const deepLink = "tienhiepai://auth-callback?token=" + tokenParam +
467
+ "&refresh_token=" + refreshParam +
468
+ "&user=" + userParam;
469
+ window.location.href = deepLink;
470
+ document.body.innerHTML = `
471
+ <div class="card">
472
+ <h3>🎉 Đăng nhập hoàn tất!</h3>
473
+ <p>Ứng dụng Desktop sẽ tự động đăng nhập. Nếu ứng dụng không tự phản hồi, bạn có thể đóng trình duyệt này và khởi động lại ứng dụng.</p>
474
+ </div>
475
+ `;
476
+ }
477
+
478
+ fetch(`http://127.0.0.1:53241/callback?token=${tokenParam}&refresh_token=${refreshParam}&user=${userParam}`)
479
+ .then(res => res.json())
480
+ .then(resData => {
481
+ if (resData && resData.success) {
482
+ document.body.innerHTML = `
483
+ <div class="card">
484
+ <h3>🎉 Đăng nhập hoàn tất!</h3>
485
+ <p>Ứng dụng Desktop đã đăng nhập thành công. Bạn có thể đóng cửa sổ trình duyệt này ngay bây giờ.</p>
486
+ </div>
487
+ `;
488
+ } else {
489
+ triggerDeepLink();
490
+ }
491
+ })
492
+ .catch(err => {
493
+ console.log("Loopback server failed, falling back to custom protocol:", err);
494
+ triggerDeepLink();
495
+ });
496
+ } else {
497
+ window.location.href = '/';
498
+ }
499
+ } else {
500
+ document.body.innerHTML = '<div class="card"><h3 style="color: #ef4444;">Lỗi đăng nhập</h3><p>' + (data.error || "Không rõ nguyên nhân") + '</p></div>';
501
+ }
502
+ })
503
+ .catch(err => {
504
+ document.body.innerHTML = '<div class="card"><h3 style="color: #ef4444;">Lỗi kết nối</h3><p>' + err + '</p></div>';
505
+ });
506
+ } else {
507
+ document.body.innerHTML = '<div class="card"><h3 style="color: #ef4444;">Lỗi đăng nhập</h3><p>Không tìm thấy token từ Google.</p></div>';
508
+ }
509
+ </script>
510
+ </div>
511
+ </body></html>
512
+ """
513
+
514
+ cfg = Config.GOOGLE_OAUTH_CONFIG
515
+ if not cfg.get("enabled"):
516
+ return jsonify({"error": "Google login is currently disabled."}), 400
517
+
518
+ data = request.json or {}
519
+ token = data.get("credential")
520
+ if not token:
521
+ return jsonify({"error": "Missing Google ID token."}), 400
522
+
523
+ try:
524
+ try:
525
+ idinfo = id_token.verify_oauth2_token(token, google_requests.Request(), cfg["client_id"])
526
+ except ValueError as e:
527
+ logger.error(f"Google ID token verification failed: {e}")
528
+ resp = requests.get(
529
+ "https://www.googleapis.com/oauth2/v3/userinfo",
530
+ headers={"Authorization": f"Bearer {token}"},
531
+ timeout=5
532
+ )
533
+ if not resp.ok:
534
+ logger.error(f"Userinfo fallback request failed: {resp.status_code} - {resp.text}")
535
+ return jsonify({"error": f"Invalid Google token. Reason: {e}"}), 401
536
+ idinfo = resp.json()
537
+
538
+ email = idinfo.get("email")
539
+ google_id = idinfo.get("sub")
540
+ base_username = email.split("@")[0].lower() if email else f"google_{google_id[:8]}"
541
+
542
+ conn = get_user_db_conn()
543
+ user = conn.execute("SELECT * FROM users WHERE google_id = ? OR email = ?", (google_id, email)).fetchone()
544
+
545
+ if not user:
546
+ username = base_username
547
+ suffix = 1
548
+ while conn.execute("SELECT 1 FROM users WHERE username = ?", (username,)).fetchone():
549
+ username = f"{base_username}{suffix}"
550
+ suffix += 1
551
+
552
+ temp_password = secrets.token_hex(6) # 12 characters
553
+ random_pw = hash_password(temp_password)
554
+ cursor = conn.execute(
555
+ "INSERT INTO users (username, password_hash, email, google_id, email_verified, require_password_change) VALUES (?, ?, ?, ?, 1, 1)",
556
+ (username, random_pw, email, google_id)
557
+ )
558
+ user_id = cursor.lastrowid
559
+
560
+ if email:
561
+ welcome_html = f"""
562
+ <h3>Chào mừng {username} đến với Novel Translator VIP!</h3>
563
+ <p>Tài khoản của bạn đã được đăng ký thông qua Google.</p>
564
+ <p>Mật khẩu đăng nhập trực tiếp qua Email của bạn là: <strong>{temp_password}</strong></p>
565
+ <p>Vui lòng đăng nhập bằng mật khẩu này và đổi mật khẩu mới trong phần cài đặt tài khoản của bạn để bảo mật.</p>
566
+ <p>Trân trọng,<br>Đội ngũ hỗ trợ Ly Vu Ha</p>
567
+ """
568
+ send_email_async(email, "Mật khẩu tài khoản Novel Translator VIP của bạn", welcome_html)
569
+ else:
570
+ if not user["google_id"]:
571
+ conn.execute("UPDATE users SET google_id = ?, email_verified = 1 WHERE id = ?", (google_id, user["id"]))
572
+ user_id = user["id"]
573
+
574
+ conn.commit()
575
+ conn.close()
576
+
577
+ check_vip_expiry(user_id)
578
+
579
+ conn = get_user_db_conn()
580
+ user_row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
581
+ conn.close()
582
+ user = dict(user_row)
583
+
584
+ session["user_id"] = user["id"]
585
+ session["username"] = user["username"]
586
+ session["vip_status"] = user["vip_status"]
587
+
588
+ access_token = create_access_token(user["id"], user["username"], user["vip_status"])
589
+ refresh_token = create_refresh_token(user["id"])
590
+
591
+ g_conn = get_user_db_conn()
592
+ try:
593
+ record_login_session(user["id"], refresh_token, g_conn)
594
+ g_conn.commit()
595
+ finally:
596
+ g_conn.close()
597
+
598
+ return jsonify({
599
+ "message": "Đăng nhập Google thành công!",
600
+ "user": {
601
+ "id": user["id"],
602
+ "username": user["username"],
603
+ "vip_status": user["vip_status"],
604
+ "vip_plan": user["vip_plan"],
605
+ "vip_expiry": user["vip_expiry"],
606
+ "email": user["email"],
607
+ "require_password_change": user.get("require_password_change", 0),
608
+ "display_name": user.get("display_name"),
609
+ "birthday": user.get("birthday"),
610
+ "gender": user.get("gender"),
611
+ "bio": user.get("bio"),
612
+ "avatar": user.get("avatar"),
613
+ "avatar_frame": user.get("avatar_frame", "default")
614
+ },
615
+ "access_token": access_token,
616
+ "refresh_token": refresh_token
617
+ })
618
+
619
+ except ValueError:
620
+ return jsonify({"error": "Invalid Google token."}), 401
621
+ except Exception as e:
622
+ logger.error(f"Google login callback internal error: {e}")
623
+ return jsonify({"error": "Đã xảy ra lỗi hệ thống trong quá trình đăng nhập Google. Vui lòng thử lại sau."}), 500
624
+
625
+
626
+ @auth_bp.route("/refresh", methods=["POST"])
627
+ def auth_refresh():
628
+ data = request.json or {}
629
+ refresh_token = data.get("refresh_token", "")
630
+ if not refresh_token:
631
+ return jsonify({"error": "Refresh token is required"}), 400
632
+
633
+ conn = get_user_db_conn()
634
+ token_row = conn.execute(
635
+ "SELECT * FROM refresh_tokens WHERE token = ? AND revoked = 0", (refresh_token,)
636
+ ).fetchone()
637
+
638
+ if not token_row:
639
+ conn.close()
640
+ return jsonify({"error": "Invalid refresh token"}), 401
641
+
642
+ try:
643
+ expiry_val = token_row["expires_at"]
644
+ if isinstance(expiry_val, str):
645
+ expires_at = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
646
+ else:
647
+ expires_at = expiry_val
648
+
649
+ if datetime.utcnow() > expires_at:
650
+ conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?", (token_row["id"],))
651
+ conn.commit()
652
+ conn.close()
653
+ return jsonify({"error": "Refresh token expired"}), 401
654
+ except Exception as e:
655
+ logger.error(f"Error parsing refresh token expiry: {e}")
656
+ conn.close()
657
+ return jsonify({"error": "Invalid token format"}), 401
658
+
659
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (token_row["user_id"],)).fetchone()
660
+ if user:
661
+ try:
662
+ conn.execute(
663
+ "UPDATE login_history SET last_active = CURRENT_TIMESTAMP, status = 'active' WHERE token = ?",
664
+ (refresh_token,)
665
+ )
666
+ conn.commit()
667
+ except Exception as e:
668
+ logger.error(f"Failed to update login session activity: {e}")
669
+ conn.close()
670
+
671
+ if not user:
672
+ return jsonify({"error": "User not found"}), 401
673
+
674
+ check_vip_expiry(user["id"])
675
+ conn = get_user_db_conn()
676
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
677
+ conn.close()
678
+
679
+ access_token = create_access_token(user["id"], user["username"], user["vip_status"])
680
+ return jsonify({
681
+ "access_token": access_token,
682
+ "user": {
683
+ "id": user["id"],
684
+ "username": user["username"],
685
+ "vip_status": user["vip_status"],
686
+ "vip_plan": user["vip_plan"],
687
+ "vip_expiry": user["vip_expiry"]
688
+ }
689
+ })
690
+
691
+
692
+ @auth_bp.route("/logout", methods=["POST"])
693
+ def auth_logout():
694
+ refresh_token = None
695
+ if request.is_json:
696
+ data = request.get_json(silent=True) or {}
697
+ refresh_token = data.get("refresh_token")
698
+ if refresh_token:
699
+ conn = get_user_db_conn()
700
+ conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (refresh_token,))
701
+ try:
702
+ conn.execute("UPDATE login_history SET status = 'logged_out' WHERE token = ?", (refresh_token,))
703
+ except Exception as e:
704
+ logger.error(f"Failed to set login session to logged_out: {e}")
705
+ conn.commit()
706
+ conn.close()
707
+ session.clear()
708
+ return jsonify({"message": "Đã đăng xuất."})
709
+
710
+
711
+ import time
712
+ _auth_me_cache = {}
713
+
714
+ @auth_bp.route("/me", methods=["GET"])
715
+ def auth_me():
716
+ auth_header = request.headers.get("Authorization", "")
717
+ if auth_header.startswith("Bearer "):
718
+ payload = verify_access_token(auth_header[7:])
719
+ if payload:
720
+ user_id = int(payload["sub"])
721
+
722
+ # Use RAM cache if available and less than 60 seconds old
723
+ now = time.time()
724
+ if user_id in _auth_me_cache:
725
+ cached_data, cached_time = _auth_me_cache[user_id]
726
+ if now - cached_time < 60:
727
+ return jsonify(cached_data)
728
+
729
+ conn = get_user_db_conn()
730
+ try:
731
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
732
+ if user:
733
+ vip_active = check_vip_expiry(user["id"], conn=conn)
734
+ if user["vip_status"] == 1 and not vip_active:
735
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
736
+
737
+ response_data = {
738
+ "logged_in": True,
739
+ "user": {
740
+ "id": user["id"],
741
+ "username": user["username"],
742
+ "user_code": user["user_code"],
743
+ "vip_status": user["vip_status"],
744
+ "vip_plan": user["vip_plan"],
745
+ "vip_expiry": str(user["vip_expiry"]) if user["vip_expiry"] else None,
746
+ "email": user["email"],
747
+ "display_name": user["display_name"],
748
+ "birthday": user["birthday"],
749
+ "gender": user["gender"],
750
+ "bio": user["bio"],
751
+ "avatar": user["avatar"],
752
+ "avatar_frame": user["avatar_frame"],
753
+ "phone": user["phone"],
754
+ "two_factor": user["two_factor"],
755
+ "api_balance": user["api_balance"]
756
+ }
757
+ }
758
+ _auth_me_cache[user_id] = (response_data, now)
759
+ return jsonify(response_data)
760
+ finally:
761
+ conn.close()
762
+
763
+ if "user_id" in session:
764
+ user_id = session["user_id"]
765
+ conn = get_user_db_conn()
766
+ try:
767
+ vip_active = check_vip_expiry(user_id, conn=conn)
768
+ user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
769
+ if user:
770
+ session["vip_status"] = user["vip_status"]
771
+ return jsonify({
772
+ "logged_in": True,
773
+ "user": {
774
+ "id": user["id"],
775
+ "username": user["username"],
776
+ "vip_status": user["vip_status"],
777
+ "vip_plan": user["vip_plan"],
778
+ "vip_expiry": str(user["vip_expiry"]) if user["vip_expiry"] else None,
779
+ "email": user["email"],
780
+ "display_name": user["display_name"],
781
+ "birthday": user["birthday"],
782
+ "gender": user["gender"],
783
+ "bio": user["bio"],
784
+ "avatar": user.get("avatar"),
785
+ "avatar_frame": user["avatar_frame"],
786
+ "phone": user["phone"],
787
+ "two_factor": user["two_factor"],
788
+ "api_balance": user["api_balance"]
789
+ }
790
+ })
791
+ finally:
792
+ conn.close()
793
+ return jsonify({"logged_in": False})
794
+
795
+
796
+ @auth_bp.route("/verify-registration", methods=["POST"])
797
+ def auth_verify_registration():
798
+ data = request.json or {}
799
+ email = data.get("email", "").strip().lower()
800
+ otp = data.get("otp", "").strip()
801
+
802
+ if not email or not otp:
803
+ return jsonify({"error": "Vui lòng nhập đầy đủ email và mã OTP."}), 400
804
+
805
+ conn = get_user_db_conn()
806
+ user_row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
807
+ if not user_row:
808
+ conn.close()
809
+ return jsonify({"error": "Email không tồn tại."}), 400
810
+ user = dict(user_row)
811
+
812
+ if user.get("email_verified", 0) == 1:
813
+ conn.close()
814
+ return jsonify({"message": "Tài khoản đã được xác minh trước đó."})
815
+
816
+ now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
817
+ token_entry = conn.execute(
818
+ "SELECT * FROM password_reset_tokens WHERE user_id = ? AND token = ? AND used = 0 AND expires_at > ?",
819
+ (user["id"], otp, now_str)
820
+ ).fetchone()
821
+
822
+ if not token_entry:
823
+ conn.close()
824
+ return jsonify({"error": "Mã OTP không đúng hoặc đã hết hạn."}), 400
825
+
826
+ # Mark OTP as used and set email_verified = 1
827
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE id = ?", (token_entry["id"],))
828
+ conn.execute("UPDATE users SET email_verified = 1 WHERE id = ?", (user["id"],))
829
+ conn.commit()
830
+ conn.close()
831
+
832
+ return jsonify({"message": "Xác minh tài khoản thành công! Bây giờ bạn đã có thể đăng nhập."})
833
+
834
+
835
+ @auth_bp.route("/resend-verification", methods=["POST"])
836
+ def auth_resend_verification():
837
+ ip = get_client_ip()
838
+ if check_rate_limit("otp", ip):
839
+ return jsonify({"error": "Bạn đã yêu cầu OTP quá nhiều lần. Vui lòng thử lại sau 1 phút."}), 429
840
+
841
+ data = request.json or {}
842
+ email = data.get("email", "").strip().lower()
843
+
844
+ if not email:
845
+ return jsonify({"error": "Vui lòng nhập email."}), 400
846
+
847
+ conn = get_user_db_conn()
848
+ user_row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
849
+ if not user_row:
850
+ conn.close()
851
+ return jsonify({"error": "Email không tồn tại trong hệ thống."}), 400
852
+ user = dict(user_row)
853
+
854
+ if user.get("email_verified", 0) == 1:
855
+ conn.close()
856
+ return jsonify({"error": "Tài khoản này đã được xác minh trước đó."}), 400
857
+
858
+ from datetime import timedelta
859
+ otp_code = f"{secrets.randbelow(900000) + 100000}"
860
+ expires_at = (datetime.utcnow() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
861
+
862
+ conn.execute("UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?", (user["id"],))
863
+ conn.execute(
864
+ "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
865
+ (user["id"], otp_code, expires_at)
866
+ )
867
+ conn.commit()
868
+ conn.close()
869
+
870
+ otp_html = f"""
871
+ <h3>Xác minh tài khoản Novel Translator VIP của bạn</h3>
872
+ <p>Chào bạn,</p>
873
+ <p>Bạn đã yêu cầu gửi lại mã xác minh cho tài khoản <strong>{user["username"]}</strong>.</p>
874
+ <p>Mã OTP mới của bạn là: <strong style="font-size: 1.5rem; color: #4f46e5; letter-spacing: 2px;">{otp_code}</strong></p>
875
+ <p>Mã OTP này có hiệu lực trong 10 phút.</p>
876
+ <p>Trân trọng,<br>Đội ngũ hỗ trợ Ly Vu Ha</p>
877
+ """
878
+ send_email_async(email, "Mã xác minh tài khoản Novel Translator VIP", otp_html)
879
+ return jsonify({"message": "Mã xác minh mới đã được gửi về email của bạn."})
880
+
881
+
882
+ @auth_bp.route("/change-password", methods=["POST"])
883
+ @jwt_required
884
+ def auth_change_password():
885
+ user = request._jwt_user
886
+ data = request.json or {}
887
+ old_password = data.get("old_password", "")
888
+ new_password = data.get("new_password", "")
889
+
890
+ if not new_password or len(new_password) < 4:
891
+ return jsonify({"error": "Mật khẩu mới phải từ 4 ký tự trở lên."}), 400
892
+
893
+ conn = get_user_db_conn()
894
+ user_record = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone()
895
+
896
+ # Enforce old password check unless it's a first-time Google login requiring password change
897
+ is_google_first_time = (user_record.get("require_password_change", 0) == 1)
898
+
899
+ if not is_google_first_time:
900
+ if not old_password or not verify_password(old_password, user_record["password_hash"]):
901
+ conn.close()
902
+ return jsonify({"error": "Mật khẩu cũ không chính xác."}), 400
903
+
904
+ pw_hash = hash_password(new_password)
905
+ conn.execute(
906
+ "UPDATE users SET password_hash = ?, require_password_change = 0 WHERE id = ?",
907
+ (pw_hash, user["id"])
908
+ )
909
+ conn.commit()
910
+ conn.close()
911
+
912
+ return jsonify({"message": "Đổi mật khẩu thành công!"})
913
+
914
+
915
+ @auth_bp.route("/update-profile", methods=["POST"])
916
+ @jwt_required
917
+ def auth_update_profile():
918
+ user = request._jwt_user
919
+ data = request.json or {}
920
+
921
+ display_name = data.get("display_name", "")
922
+ birthday = data.get("birthday", "")
923
+ gender = data.get("gender", "")
924
+ bio = data.get("bio", "")
925
+ avatar = data.get("avatar", "")
926
+ avatar_frame = data.get("avatar_frame", "default")
927
+ phone = data.get("phone", "")
928
+ two_factor = data.get("two_factor", 0)
929
+
930
+ conn = get_user_db_conn()
931
+ try:
932
+ conn.execute(
933
+ """UPDATE users SET
934
+ display_name = ?,
935
+ birthday = ?,
936
+ gender = ?,
937
+ bio = ?,
938
+ avatar = ?,
939
+ avatar_frame = ?,
940
+ phone = ?,
941
+ two_factor = ?
942
+ WHERE id = ?""",
943
+ (display_name, birthday, gender, bio, avatar, avatar_frame, phone, two_factor, user["id"])
944
+ )
945
+ conn.commit()
946
+
947
+ # Clear RAM cache
948
+ if user["id"] in _auth_me_cache:
949
+ del _auth_me_cache[user["id"]]
950
+
951
+ return jsonify({"success": True, "message": "Cập nhật hồ sơ thành công!"})
952
+ except Exception as e:
953
+ logger.error(f"Database error during profile update: {e}")
954
+ conn.close()
955
+ return jsonify({"error": "Lỗi cơ sở dữ liệu. Vui lòng thử lại sau."}), 500
956
+
957
+ conn.close()
958
+ return jsonify({"success": True, "message": "Cập nhật hồ sơ thành công!"})
959
+
960
+
961
+ @auth_bp.route("/sessions", methods=["GET"])
962
+ @jwt_required
963
+ def get_login_sessions():
964
+ user = get_current_user()
965
+ conn = get_user_db_conn()
966
+ try:
967
+ rows = conn.execute(
968
+ """SELECT id, ip_address, os, browser, device_type, login_time, last_active, status, token
969
+ FROM login_history
970
+ WHERE user_id = ?
971
+ ORDER BY login_time DESC LIMIT 50""",
972
+ (user["id"],)
973
+ ).fetchall()
974
+
975
+ sessions_list = [dict(row) for row in rows]
976
+ for s in sessions_list:
977
+ if hasattr(s["login_time"], "isoformat"):
978
+ s["login_time"] = s["login_time"].isoformat()
979
+ if hasattr(s["last_active"], "isoformat"):
980
+ s["last_active"] = s["last_active"].isoformat()
981
+
982
+ return jsonify({"sessions": sessions_list, "success": True})
983
+ except Exception as e:
984
+ logger.error(f"Failed to fetch login sessions: {e}")
985
+ return jsonify({"error": "Failed to fetch login sessions", "success": False}), 500
986
+ finally:
987
+ conn.close()
988
+
989
+
990
+ @auth_bp.route("/sessions/revoke", methods=["POST"])
991
+ @jwt_required
992
+ def revoke_login_session():
993
+ user = get_current_user()
994
+ data = request.json or {}
995
+ session_id = data.get("session_id")
996
+ if not session_id:
997
+ return jsonify({"error": "Missing session_id", "success": False}), 400
998
+
999
+ conn = get_user_db_conn()
1000
+ try:
1001
+ # Get the token of the session to revoke the refresh token too!
1002
+ row = conn.execute(
1003
+ "SELECT token FROM login_history WHERE id = ? AND user_id = ?",
1004
+ (session_id, user["id"])
1005
+ ).fetchone()
1006
+ if not row:
1007
+ return jsonify({"error": "Session not found", "success": False}), 404
1008
+
1009
+ token = row["token"]
1010
+ # Revoke both in login_history and refresh_tokens
1011
+ conn.execute("UPDATE login_history SET status = 'logged_out' WHERE id = ?", (session_id,))
1012
+ if token:
1013
+ conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (token,))
1014
+ conn.commit()
1015
+ return jsonify({"message": "Đã đăng xuất thiết bị thành công", "success": True})
1016
+ except Exception as e:
1017
+ logger.error(f"Failed to revoke login session: {e}")
1018
+ return jsonify({"error": "Failed to revoke session", "success": False}), 500
1019
+ finally:
1020
+ conn.close()
backend/api/books.py ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import unicodedata
4
+ from flask import Blueprint, request, jsonify, session
5
+ from backend.config import Config
6
+ from backend.core.decorators import get_current_user
7
+ from backend.core.security import verify_access_token, encrypt_asymmetric_hybrid, decrypt_asymmetric_hybrid
8
+ from backend.core.rate_limit import check_rate_limit, check_ip_rate_limit, get_client_ip
9
+ from backend.database.db_manager import get_db, get_mode_connection, get_user_db_conn
10
+ from backend.services.book_service import search_books, clean_vietnamese_query
11
+ from backend.services.translation import get_engine
12
+
13
+ books_bp = Blueprint("books", __name__, url_prefix="/api")
14
+
15
+
16
+ def is_vip_request():
17
+ """Check if current request has VIP privileges."""
18
+ user = get_current_user()
19
+ if user and user.get("vip_status", 0) == 1:
20
+ return True
21
+ vip_code = request.headers.get("X-VIP-Code", "")
22
+ if vip_code in Config.VALID_VIP_CODES:
23
+ return True
24
+ return False
25
+
26
+
27
+ @books_bp.route("/books", methods=["GET"])
28
+ def api_books():
29
+ ip = get_client_ip()
30
+ if not check_ip_rate_limit(ip, max_requests=45, period=60):
31
+ return jsonify({"error": "Too many requests. Please slow down."}), 429
32
+
33
+ q = request.args.get("q", "").strip()
34
+ category = request.args.get("category", "").strip()
35
+ source = request.args.get("source", "").strip()
36
+ dup = request.args.get("dup", "").strip()
37
+ sort = request.args.get("sort", "site_count DESC").strip()
38
+ search_field = request.args.get("field", "all").strip()
39
+ min_chapters = request.args.get("min_chapters", "").strip()
40
+
41
+ try:
42
+ page = max(1, int(request.args.get("page", 1)))
43
+ per_page = max(1, min(50, int(request.args.get("per_page", 20))))
44
+ limit_k = max(1, min(1000, int(request.args.get("limit_k", 100))))
45
+ except ValueError:
46
+ page, per_page, limit_k = 1, 20, 100
47
+
48
+ result, status_code = search_books(q, category, source, dup, sort, search_field, min_chapters, page, per_page, limit_k)
49
+ if status_code != 200:
50
+ return jsonify(result), status_code
51
+ return jsonify(result)
52
+
53
+
54
+ @books_bp.route("/stats", methods=["GET"])
55
+ def api_stats():
56
+ from backend.database.db_manager import cached_stats
57
+ import backend.database.db_manager as db_mod
58
+ if db_mod.cached_stats is None:
59
+ conn = get_db()
60
+ total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
61
+ cats = conn.execute(
62
+ "SELECT categories FROM books WHERE categories IS NOT NULL AND categories != ''"
63
+ ).fetchall()
64
+ cat_counts = {}
65
+ for row in cats:
66
+ for c in row[0].split(","):
67
+ c = c.strip()
68
+ if c:
69
+ cat_counts[c] = cat_counts.get(c, 0) + 1
70
+ top_categories = sorted(cat_counts.items(), key=lambda x: x[1], reverse=True)[:20]
71
+ db_mod.cached_stats = {
72
+ "total_books": total,
73
+ "top_categories": [{"name": k, "count": v} for k, v in top_categories],
74
+ }
75
+ return jsonify(db_mod.cached_stats)
76
+
77
+
78
+ @books_bp.route("/book/<int:book_id>", methods=["GET"])
79
+ def get_book_detail(book_id):
80
+ conn = get_db()
81
+ row = conn.execute("SELECT * FROM books WHERE id = ?", (book_id,)).fetchone()
82
+ if not row:
83
+ return jsonify({"error": "Không tìm thấy truyện."}), 404
84
+ book_dict = dict(row)
85
+ # Parse sources
86
+ parsed_sources = []
87
+ if book_dict.get("urls"):
88
+ parts = book_dict["urls"].split(" | ")
89
+ for p in parts:
90
+ idx = p.find(":")
91
+ if idx > 0:
92
+ parsed_sources.append({
93
+ "source": p[:idx].strip(),
94
+ "url": p[idx+1:].strip()
95
+ })
96
+ book_dict["parsed_sources"] = parsed_sources
97
+
98
+ # Lấy description_vietphrase từ advanced DB
99
+ description_vietphrase = None
100
+ root_dir = Config.ROOT_DIR
101
+ adv_db_path = os.path.join(root_dir, "merged_books_advanced.db")
102
+ if os.path.exists(adv_db_path):
103
+ try:
104
+ from backend.database.db_manager import get_mode_connection
105
+ adv_conn = get_mode_connection(adv_db_path)
106
+ if adv_conn:
107
+ r_adv = adv_conn.execute("SELECT description_vietphrase FROM books WHERE id = ?", (book_id,)).fetchone()
108
+ if r_adv and r_adv["description_vietphrase"]:
109
+ description_vietphrase = r_adv["description_vietphrase"]
110
+ except Exception as e:
111
+ logger.warning(f"Failed to read from advanced DB for book {book_id}: {e}")
112
+
113
+ # Nếu không có trong advanced DB, dịch trực tiếp bằng translation engine
114
+ if not description_vietphrase and book_dict.get("description"):
115
+ try:
116
+ from backend.services.translation import get_engine
117
+ eng = get_engine()
118
+ description_vietphrase = eng.translate(book_dict["description"], multi_option=False)
119
+ except Exception as e:
120
+ logger.warning(f"Failed to translate book description live: {e}")
121
+ description_vietphrase = book_dict.get("description")
122
+
123
+ book_dict["description_vietphrase"] = description_vietphrase or ""
124
+
125
+ # Dịch mô tả sang tiếng Anh bằng Gemini
126
+ description_english = None
127
+ if book_dict.get("description"):
128
+ # Tạo fallback không dấu trước
129
+ fallback_en = ""
130
+ if book_dict["description_vietphrase"]:
131
+ try:
132
+ from backend.services.book_service import remove_vietnamese_accents
133
+ fallback_en = remove_vietnamese_accents(book_dict["description_vietphrase"])
134
+ except Exception:
135
+ pass
136
+
137
+ # Thử gọi Gemini để dịch chất lượng cao
138
+ key = os.environ.get("ADMIN_GEMINI_KEY", "") or os.environ.get("GOOGLE_API_KEY_BIGDATA", "") or os.environ.get("GEMINI_API_KEY_BIGDATA", "")
139
+ if key:
140
+ try:
141
+ import requests as py_requests
142
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={key}"
143
+ prompt_text = book_dict["description"][:800]
144
+ payload = {
145
+ "contents": [{
146
+ "role": "user",
147
+ "parts": [{"text": f"Translate the following novel synopsis to English. Only return the English translation, no other commentary:\n\n{prompt_text}"}]
148
+ }]
149
+ }
150
+ res = py_requests.post(url, json=payload, timeout=5)
151
+ if res.status_code == 200:
152
+ ans = res.json().get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
153
+ if ans.strip():
154
+ description_english = ans.strip()
155
+ except Exception as e:
156
+ logger.warning(f"Failed to translate book description to English using Gemini: {e}")
157
+
158
+ if not description_english:
159
+ description_english = fallback_en or book_dict.get("description")
160
+
161
+ book_dict["description_english"] = description_english or ""
162
+
163
+ # Dịch thể loại sang tiếng Việt và tiếng Anh, cùng chuẩn hóa tên tác giả tiếng Anh
164
+ try:
165
+ from backend.services.book_service import translate_categories, translate_categories_to_english, remove_vietnamese_accents
166
+ book_dict["categories_vietphrase"] = translate_categories(book_dict.get("categories", ""))
167
+ book_dict["categories_english"] = translate_categories_to_english(book_dict.get("categories", ""))
168
+
169
+ author_hv = book_dict.get("author_hanviet", "")
170
+ if author_hv:
171
+ book_dict["author_english"] = remove_vietnamese_accents(author_hv)
172
+ else:
173
+ book_dict["author_english"] = book_dict.get("author", "")
174
+ except Exception:
175
+ book_dict["categories_vietphrase"] = book_dict.get("categories", "")
176
+ book_dict["categories_english"] = book_dict.get("categories", "")
177
+ book_dict["author_english"] = book_dict.get("author", "")
178
+
179
+ return jsonify(book_dict)
180
+
181
+
182
+ @books_bp.route("/book/<int:book_id>/translations")
183
+ def api_book_translations(book_id):
184
+ root_dir = Config.ROOT_DIR
185
+ adv_db_path = os.path.join(root_dir, "merged_books_advanced.db")
186
+ fast_db_path = os.path.join(root_dir, "merged_books_fast.db")
187
+ vp_db_path = os.path.join(root_dir, "merged_books_vietphrase.db")
188
+ hv_db_path = os.path.join(root_dir, "merged_books_hanviet.db")
189
+
190
+ res = {
191
+ "advanced": {"title": None, "desc": None},
192
+ "fast": {"title": None, "desc": None},
193
+ "vietphrase": {"title": None, "desc": None},
194
+ "hanviet": {"title": None, "desc": None}
195
+ }
196
+
197
+ for mode_key, db_path in [("advanced", adv_db_path), ("fast", fast_db_path),
198
+ ("vietphrase", vp_db_path), ("hanviet", hv_db_path)]:
199
+ conn = get_mode_connection(db_path)
200
+ if conn:
201
+ try:
202
+ r = conn.execute("SELECT title_vietphrase, description_vietphrase FROM books WHERE id = ?", (book_id,)).fetchone()
203
+ if r:
204
+ res[mode_key] = {"title": r["title_vietphrase"], "desc": r["description_vietphrase"]}
205
+ except Exception as e:
206
+ print(f"[ERROR] Loading {mode_key} book details: {e}")
207
+
208
+ return jsonify(res)
209
+
210
+
211
+ @books_bp.route("/bookshelf", methods=["GET"])
212
+ def get_bookshelf():
213
+ user = get_current_user()
214
+ user_id = user["id"] if user else None
215
+ if not user_id:
216
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
217
+
218
+ q = request.args.get("q", "").strip()
219
+ conn = get_user_db_conn()
220
+ rows = conn.execute("SELECT * FROM bookshelf WHERE user_id = ? ORDER BY added_at DESC", (user_id,)).fetchall()
221
+ conn.close()
222
+
223
+ books = [dict(r) for r in rows]
224
+ if q:
225
+ q_clean = clean_vietnamese_query(q)
226
+ books = [b for b in books if q_clean in clean_vietnamese_query(b.get("title", ""))
227
+ or q_clean in clean_vietnamese_query(b.get("author", ""))]
228
+ return jsonify(books)
229
+
230
+
231
+ @books_bp.route("/bookshelf/add", methods=["POST"])
232
+ def add_bookshelf():
233
+ user = get_current_user()
234
+ user_id = user["id"] if user else None
235
+ if not user_id:
236
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
237
+
238
+ data = request.json or {}
239
+ book_id = data.get("book_id")
240
+ if not book_id:
241
+ return jsonify({"error": "Thiếu ID truyện."}), 400
242
+
243
+ if not is_vip_request():
244
+ conn_check = get_user_db_conn()
245
+ try:
246
+ exists = conn_check.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id)).fetchone() is not None
247
+ if not exists:
248
+ count = conn_check.execute("SELECT COUNT(*) as cnt FROM bookshelf WHERE user_id = ?", (user_id,)).fetchone()["cnt"]
249
+ if count >= 5:
250
+ return jsonify({"error": "Tủ sách Standard tối đa 5 truyện. Nâng cấp VIP để lưu không giới hạn!"}), 403
251
+ finally:
252
+ conn_check.close()
253
+
254
+ main_conn = get_db()
255
+ book = main_conn.execute("SELECT title_vietphrase, author_hanviet, cover FROM books WHERE id = ?", (book_id,)).fetchone()
256
+ if not book:
257
+ return jsonify({"error": "Không tìm thấy truyện."}), 404
258
+
259
+ title = book["title_vietphrase"] or "Không rõ"
260
+ author = book["author_hanviet"] or "Không rõ"
261
+ cover = book["cover"] or ""
262
+
263
+ conn = get_user_db_conn()
264
+ try:
265
+ conn.execute(
266
+ "INSERT OR IGNORE INTO bookshelf (user_id, book_id, title, author, cover) VALUES (?, ?, ?, ?, ?)",
267
+ (user_id, book_id, title, author, cover)
268
+ )
269
+ conn.commit()
270
+ finally:
271
+ conn.close()
272
+ return jsonify({"success": True, "message": "Đã thêm vào tủ sách."})
273
+
274
+
275
+ @books_bp.route("/bookshelf/remove", methods=["POST"])
276
+ def remove_bookshelf():
277
+ user = get_current_user()
278
+ user_id = user["id"] if user else None
279
+ if not user_id:
280
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
281
+
282
+ data = request.json or {}
283
+ book_id = data.get("book_id")
284
+ url = data.get("url")
285
+ if not book_id and not url:
286
+ return jsonify({"error": "Thiếu thông tin truyện để xóa."}), 400
287
+
288
+ conn = get_user_db_conn()
289
+ if book_id:
290
+ conn.execute("DELETE FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id))
291
+ else:
292
+ conn.execute("DELETE FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url))
293
+ conn.commit()
294
+ conn.close()
295
+ return jsonify({"success": True, "message": "Đã xóa khỏi tủ sách."})
296
+
297
+
298
+ @books_bp.route("/history", methods=["GET"])
299
+ def get_history():
300
+ user = get_current_user()
301
+ user_id = user["id"] if user else None
302
+ if not user_id:
303
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
304
+
305
+ q = request.args.get("q", "").strip()
306
+ conn = get_user_db_conn()
307
+ rows = conn.execute("SELECT * FROM reading_history WHERE user_id = ? ORDER BY timestamp DESC", (user_id,)).fetchall()
308
+ conn.close()
309
+
310
+ books = [dict(r) for r in rows]
311
+ if q:
312
+ q_clean = clean_vietnamese_query(q)
313
+ books = [b for b in books if q_clean in clean_vietnamese_query(b.get("title", ""))
314
+ or q_clean in clean_vietnamese_query(b.get("author", ""))]
315
+
316
+ struct_now = time.localtime()
317
+ today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
318
+ struct_y = time.localtime(time.time() - 86400)
319
+ yesterday_str = f"{struct_y.tm_year:04d}-{struct_y.tm_mon:02d}-{struct_y.tm_mday:02d}"
320
+ this_month_prefix = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}"
321
+
322
+ groups = {"Hôm nay": [], "Hôm qua": [], "Tháng này": [], "Trước đây": []}
323
+ for b in books:
324
+ r_date = b.get("read_date", "")
325
+ if r_date == today_str:
326
+ groups["Hôm nay"].append(b)
327
+ elif r_date == yesterday_str:
328
+ groups["Hôm qua"].append(b)
329
+ elif r_date.startswith(this_month_prefix):
330
+ groups["Tháng này"].append(b)
331
+ else:
332
+ groups["Trước đây"].append(b)
333
+
334
+ return jsonify([{"group_name": g, "books": groups[g]}
335
+ for g in ["Hôm nay", "Hôm qua", "Tháng này", "Trước đây"] if groups[g]])
336
+
337
+
338
+ @books_bp.route("/history/add", methods=["POST"])
339
+ def add_history():
340
+ user = get_current_user()
341
+ user_id = user["id"] if user else None
342
+ if not user_id:
343
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
344
+
345
+ data = request.json or {}
346
+ book_id = data.get("book_id")
347
+ last_chapter = data.get("last_chapter", "Chương đầu")
348
+ if not book_id:
349
+ return jsonify({"error": "Thiếu ID truyện."}), 400
350
+
351
+ main_conn = get_db()
352
+ book = main_conn.execute("SELECT title_vietphrase, author_hanviet, cover FROM books WHERE id = ?", (book_id,)).fetchone()
353
+ if not book:
354
+ return jsonify({"error": "Không tìm thấy truyện."}), 404
355
+
356
+ title = book["title_vietphrase"] or "Không rõ"
357
+ author = book["author_hanviet"] or "Không r��"
358
+ cover = book["cover"] or ""
359
+ struct_now = time.localtime()
360
+ today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
361
+
362
+ conn = get_user_db_conn()
363
+ try:
364
+ conn.execute("""
365
+ INSERT OR REPLACE INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, timestamp)
366
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
367
+ """, (user_id, book_id, title, author, cover, last_chapter, today_str))
368
+ conn.commit()
369
+ finally:
370
+ conn.close()
371
+ return jsonify({"success": True})
372
+
373
+
374
+ @books_bp.route("/history/clear", methods=["POST"])
375
+ def clear_history():
376
+ user = get_current_user()
377
+ user_id = user["id"] if user else None
378
+ if not user_id:
379
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
380
+ conn = get_user_db_conn()
381
+ conn.execute("DELETE FROM reading_history WHERE user_id = ?", (user_id,))
382
+ conn.commit()
383
+ conn.close()
384
+ return jsonify({"success": True})
385
+
386
+
387
+ @books_bp.route("/history/remove", methods=["POST"])
388
+ def remove_history_item():
389
+ """Xóa một mục khỏi lịch sử đọc (theo book_id hoặc url)."""
390
+ user = get_current_user()
391
+ user_id = user["id"] if user else None
392
+ if not user_id:
393
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
394
+
395
+ data = request.json or {}
396
+ book_id = data.get("book_id")
397
+ url = data.get("url")
398
+
399
+ if not book_id and not url:
400
+ return jsonify({"error": "Thiếu book_id hoặc url."}), 400
401
+
402
+ conn = get_user_db_conn()
403
+ try:
404
+ if book_id:
405
+ conn.execute(
406
+ "DELETE FROM reading_history WHERE user_id = ? AND book_id = ?",
407
+ (user_id, book_id)
408
+ )
409
+ else:
410
+ conn.execute(
411
+ "DELETE FROM reading_history WHERE user_id = ? AND url = ?",
412
+ (user_id, url)
413
+ )
414
+ conn.commit()
415
+ return jsonify({"success": True, "message": "Đã xóa khỏi lịch sử."})
416
+ except Exception as e:
417
+ logger.error(f"[History] Remove item failed: {e}")
418
+ return jsonify({"error": "Không thể xóa mục lịch sử."}), 500
419
+ finally:
420
+ conn.close()
421
+
422
+
423
+
424
+
425
+ @books_bp.route("/extension/sync", methods=["POST"])
426
+ def extension_sync():
427
+ user = get_current_user()
428
+ user_id = user["id"] if user else None
429
+ if not user_id:
430
+ return jsonify({"error": "Vui lòng đăng nhập trên website chính."}), 401
431
+
432
+ data = request.json or {}
433
+ title_zh = data.get("title", "").strip()
434
+ author_zh = data.get("author", "").strip()
435
+ cover = data.get("cover", "").strip()
436
+ last_chapter_zh = data.get("last_chapter", "").strip()
437
+ url = data.get("url", "").strip()
438
+ action = data.get("action", "history")
439
+
440
+ if not title_zh and not url:
441
+ return jsonify({"error": "Thiếu thông tin tên truyện hoặc liên kết đường dẫn."}), 400
442
+
443
+ book = None
444
+ book_id = None
445
+ title_vi = None
446
+ author_vi = None
447
+ eng = get_engine()
448
+
449
+ if not title_zh:
450
+ from urllib.parse import urlparse
451
+ try:
452
+ domain = urlparse(url).netloc
453
+ title_vi = f"Truyện ngoài ({domain})"
454
+ except Exception:
455
+ title_vi = "Truyện ngoài"
456
+ title_zh = title_vi
457
+ author_vi = "Không rõ"
458
+ else:
459
+ main_conn = get_db()
460
+ book = main_conn.execute(
461
+ "SELECT id, title_vietphrase, author_hanviet, cover FROM books WHERE title = ? LIMIT 1",
462
+ (title_zh,)
463
+ ).fetchone()
464
+
465
+ if book:
466
+ book_id = book["id"]
467
+ title_vi = book["title_vietphrase"]
468
+ author_vi = book["author_hanviet"]
469
+ if not cover and book["cover"]:
470
+ cover = book["cover"]
471
+ else:
472
+ if not title_vi:
473
+ try:
474
+ title_vi = eng.translate(title_zh, multi_option=False)
475
+ except Exception:
476
+ title_vi = title_zh
477
+ if not author_vi:
478
+ if author_zh:
479
+ try:
480
+ author_vi = eng.translate(author_zh, multi_option=False)
481
+ except Exception:
482
+ author_vi = author_zh
483
+ else:
484
+ author_vi = "Không rõ"
485
+
486
+ last_chapter_vi = "Chương đầu"
487
+ if last_chapter_zh:
488
+ try:
489
+ last_chapter_vi = eng.translate(last_chapter_zh, multi_option=False)
490
+ except Exception:
491
+ last_chapter_vi = last_chapter_zh
492
+
493
+ title_vi = title_vi or title_zh
494
+ author_vi = author_vi or "Không rõ"
495
+ struct_now = time.localtime()
496
+ today_str = f"{struct_now.tm_year:04d}-{struct_now.tm_mon:02d}-{struct_now.tm_mday:02d}"
497
+
498
+ conn = get_user_db_conn()
499
+ try:
500
+ if action == "bookshelf":
501
+ if not is_vip_request():
502
+ exists = (
503
+ conn.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND book_id = ?", (user_id, book_id)).fetchone()
504
+ if book_id
505
+ else conn.execute("SELECT 1 FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
506
+ ) is not None
507
+ if not exists:
508
+ count = conn.execute("SELECT COUNT(*) as cnt FROM bookshelf WHERE user_id = ?", (user_id,)).fetchone()["cnt"]
509
+ if count >= 5:
510
+ return jsonify({"error": "Tủ sách Standard tối đa 5 truyện. Nâng cấp VIP!"}), 403
511
+
512
+ if book_id:
513
+ conn.execute("INSERT OR REPLACE INTO bookshelf (user_id, book_id, title, author, cover, url) VALUES (?, ?, ?, ?, ?, ?)",
514
+ (user_id, book_id, title_vi, author_vi, cover, url))
515
+ else:
516
+ existing = conn.execute("SELECT id FROM bookshelf WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
517
+ if existing:
518
+ conn.execute("UPDATE bookshelf SET title = ?, author = ?, cover = ? WHERE id = ?",
519
+ (title_vi, author_vi, cover, existing["id"]))
520
+ else:
521
+ conn.execute("INSERT INTO bookshelf (user_id, book_id, title, author, cover, url) VALUES (?, NULL, ?, ?, ?, ?)",
522
+ (user_id, title_vi, author_vi, cover, url))
523
+ conn.commit()
524
+ msg = "Đã lưu vào Tủ sách!"
525
+ else:
526
+ if book_id:
527
+ conn.execute("INSERT OR REPLACE INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
528
+ (user_id, book_id, title_vi, author_vi, cover, last_chapter_vi, today_str, url))
529
+ else:
530
+ existing = conn.execute("SELECT id FROM reading_history WHERE user_id = ? AND url = ?", (user_id, url)).fetchone()
531
+ if existing:
532
+ conn.execute("UPDATE reading_history SET title = ?, author = ?, cover = ?, last_chapter = ?, read_date = ?, timestamp = CURRENT_TIMESTAMP WHERE id = ?",
533
+ (title_vi, author_vi, cover, last_chapter_vi, today_str, existing["id"]))
534
+ else:
535
+ conn.execute("INSERT INTO reading_history (user_id, book_id, title, author, cover, last_chapter, read_date, url) VALUES (?, NULL, ?, ?, ?, ?, ?, ?)",
536
+ (user_id, title_vi, author_vi, cover, last_chapter_vi, today_str, url))
537
+ conn.commit()
538
+ msg = "Đã cập nhật Lịch sử!"
539
+ finally:
540
+ conn.close()
541
+
542
+ return jsonify({
543
+ "success": True, "message": msg,
544
+ "matched": book is not None, "book_id": book_id,
545
+ "title_vi": title_vi, "author_vi": author_vi, "last_chapter_vi": last_chapter_vi
546
+ })
547
+
548
+
549
+ @books_bp.route("/system/notifications", methods=["GET"])
550
+ def get_system_notifications():
551
+ user = get_current_user()
552
+ notifications = [
553
+ {
554
+ "id": "sys-1",
555
+ "type": "server",
556
+ "message": "Hệ thống máy chủ dịch thuật AI đã được nâng cấp lên v2.4, cải thiện 45% tốc độ phản hồi.",
557
+ "time": "Vừa xong"
558
+ },
559
+ {
560
+ "id": "sys-2",
561
+ "type": "promo",
562
+ "message": "Sự kiện đặc biệt: Tặng ngay 20% số dư khi thực hiện nạp ví VIP qua chuyển khoản VietQR trong tuần này!",
563
+ "time": "5 phút trước"
564
+ }
565
+ ]
566
+
567
+ if user:
568
+ user_id = user["id"]
569
+ conn = get_user_db_conn()
570
+ try:
571
+ rows = conn.execute("SELECT title FROM bookshelf WHERE user_id = ? ORDER BY added_at DESC LIMIT 3", (user_id,)).fetchall()
572
+ for i, row in enumerate(rows):
573
+ notifications.append({
574
+ "id": f"shelf-{i}",
575
+ "type": "update",
576
+ "message": f"Truyện '{row['title']}' trong Tủ sách của bạn vừa cập nhật thêm chương dịch mới.",
577
+ "time": "10 phút trước"
578
+ })
579
+ except Exception:
580
+ pass
581
+ finally:
582
+ conn.close()
583
+
584
+ notifications.append({
585
+ "id": "comment-1",
586
+ "type": "comment",
587
+ "message": "Độc giả Lê Hoàng Nam vừa bình luận truyện 'Trọng Sinh Chi Ma Giáo Giáo Chủ': 'Bản dịch AI đọc rất mượt mà...'",
588
+ "time": "20 phút trước"
589
+ })
590
+ notifications.append({
591
+ "id": "comment-2",
592
+ "type": "comment",
593
+ "message": "Thành viên VIP_SERVER vừa tối ưu hóa tốc độ load chương mới thành công.",
594
+ "time": "1 giờ trước"
595
+ })
596
+ return jsonify(notifications)
597
+
598
+
599
+ @books_bp.route("/author/<author_name>", methods=["GET"])
600
+ def get_author_details(author_name):
601
+ conn = get_db()
602
+ rows = conn.execute(
603
+ "SELECT * FROM books WHERE author = ? OR author_hanviet = ? ORDER BY chapters_max DESC",
604
+ (author_name, author_name)
605
+ ).fetchall()
606
+
607
+ if not rows:
608
+ rows = conn.execute(
609
+ "SELECT * FROM books WHERE LOWER(author) = LOWER(?) OR LOWER(author_hanviet) = LOWER(?) ORDER BY chapters_max DESC",
610
+ (author_name, author_name)
611
+ ).fetchall()
612
+
613
+ fallback_mode = False
614
+ if not rows:
615
+ # Fallback: if author has no books in database, fetch first 5 books to show as placeholder
616
+ rows = conn.execute("SELECT * FROM books LIMIT 5").fetchall()
617
+ fallback_mode = True
618
+
619
+ books = []
620
+ canonical_chinese = author_name if fallback_mode else (rows[0]["author"] or author_name)
621
+ canonical_hanviet = author_name if fallback_mode else (rows[0]["author_hanviet"] or author_name)
622
+
623
+ for row in rows:
624
+ book_dict = dict(row)
625
+ if fallback_mode:
626
+ book_dict["author"] = author_name
627
+ book_dict["author_hanviet"] = author_name
628
+
629
+ parsed_sources = []
630
+ raw_urls = book_dict.get("urls", "")
631
+ if raw_urls:
632
+ parts = raw_urls.split(" | ")
633
+ for part in parts:
634
+ if ":" in part:
635
+ subparts = part.split(":", 1)
636
+ src_name = subparts[0].strip()
637
+ src_url = subparts[1].strip()
638
+ # Keep raw scheme if complete, else handle
639
+ if src_url.startswith("//"):
640
+ src_url = "https:" + src_url
641
+ parsed_sources.append({
642
+ "source": src_name,
643
+ "url": src_url
644
+ })
645
+ book_dict["parsed_sources"] = parsed_sources
646
+ books.append(book_dict)
647
+
648
+ return jsonify({
649
+ "author_chinese": canonical_chinese,
650
+ "author_hanviet": canonical_hanviet,
651
+ "total_books": len(books),
652
+ "books": books
653
+ })
654
+
655
+
656
+ @books_bp.route("/books/<int:book_id>/comments", methods=["POST"])
657
+ def add_book_comment(book_id):
658
+ user = get_current_user()
659
+ data = request.json or {}
660
+ content = data.get("content", "").strip()
661
+ is_anonymous = int(data.get("is_anonymous", 0))
662
+
663
+ if not content:
664
+ return jsonify({"error": "Nội dung bình luận không được để trống"}), 400
665
+
666
+ user_id = user["id"] if user else None
667
+
668
+ # Mã hóa Hybrid RSA-AES bảo mật
669
+ enc_data = encrypt_asymmetric_hybrid(content)
670
+
671
+ conn = get_user_db_conn()
672
+ try:
673
+ conn.execute(
674
+ """INSERT INTO book_comments
675
+ (book_id, user_id, is_anonymous, content_ciphertext, encrypted_aes_key, aes_nonce, aes_tag)
676
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
677
+ (book_id, user_id, is_anonymous,
678
+ enc_data["ciphertext"], enc_data["encrypted_key"], enc_data["nonce"], enc_data["tag"])
679
+ )
680
+ conn.commit()
681
+ return jsonify({"success": True, "message": "Gửi bình luận thành công!"})
682
+ except Exception as e:
683
+ return jsonify({"error": str(e)}), 500
684
+ finally:
685
+ conn.close()
686
+
687
+
688
+ @books_bp.route("/books/<int:book_id>/comments", methods=["GET"])
689
+ def get_book_comments(book_id):
690
+ conn = get_user_db_conn()
691
+ try:
692
+ rows = conn.execute(
693
+ """SELECT c.id, c.user_id, c.is_anonymous, c.content_ciphertext, c.encrypted_aes_key,
694
+ c.aes_nonce, c.aes_tag, c.created_at, u.username, u.avatar
695
+ FROM book_comments c
696
+ LEFT JOIN users u ON c.user_id = u.id
697
+ WHERE c.book_id = ?
698
+ ORDER BY c.created_at DESC""",
699
+ (book_id,)
700
+ ).fetchall()
701
+
702
+ comments = []
703
+ for r in rows:
704
+ # Giải mã Hybrid RSA-AES
705
+ decrypted_content = decrypt_asymmetric_hybrid(
706
+ r["content_ciphertext"],
707
+ r["encrypted_aes_key"],
708
+ r["aes_nonce"],
709
+ r["aes_tag"]
710
+ )
711
+
712
+ comment = {
713
+ "id": r["id"],
714
+ "created_at": r["created_at"].isoformat() if hasattr(r["created_at"], "isoformat") else r["created_at"],
715
+ }
716
+
717
+ if r["is_anonymous"] == 1:
718
+ comment["username"] = "Đạo hữu ẩn danh"
719
+ comment["avatar"] = None
720
+ comment["user_id"] = None
721
+ else:
722
+ comment["username"] = r["username"] or "Khách viếng thăm"
723
+ comment["avatar"] = r["avatar"]
724
+ comment["user_id"] = r["user_id"]
725
+
726
+ comment["content"] = decrypted_content
727
+ comments.append(comment)
728
+
729
+ return jsonify({"comments": comments})
730
+ except Exception as e:
731
+ return jsonify({"error": str(e)}), 500
732
+ finally:
733
+ conn.close()
734
+
backend/api/developer.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import uuid
4
+ import secrets
5
+ import base64
6
+ import requests as py_requests
7
+ from flask import Blueprint, request, jsonify, send_file
8
+ from backend.core.decorators import get_current_user, require_api_key_auth
9
+ from backend.database.db_manager import get_user_db_conn
10
+ from backend.services.translation import get_engine
11
+
12
+ developer_bp = Blueprint("developer", __name__)
13
+
14
+ ADMIN_GEMINI_KEY = os.environ.get("ADMIN_GEMINI_KEY", "")
15
+
16
+ def serialize_row(row):
17
+ if not row:
18
+ return {}
19
+ d = dict(row)
20
+ for k, v in d.items():
21
+ if hasattr(v, "isoformat"):
22
+ d[k] = v.isoformat()
23
+ return d
24
+
25
+
26
+
27
+ def deduct_developer_balance(api_key, amount, model, tokens=0, status_code=200):
28
+ conn = get_user_db_conn()
29
+ try:
30
+ conn.execute(
31
+ "INSERT INTO api_usage (api_key, model, tokens, cost, status_code) VALUES (?, ?, ?, ?, ?)",
32
+ (api_key, model, tokens, amount, status_code)
33
+ )
34
+ conn.execute(
35
+ "UPDATE users SET api_balance = api_balance - ? WHERE id = (SELECT user_id FROM api_keys WHERE api_key = ?)",
36
+ (amount, api_key)
37
+ )
38
+ conn.commit()
39
+ except Exception as e:
40
+ print(f"[API GATEWAY ERROR] Failed to deduct balance: {e}")
41
+ finally:
42
+ conn.close()
43
+
44
+
45
+ @developer_bp.route("/api/developer/keys", methods=["GET"])
46
+ def api_dev_keys_list():
47
+ user = get_current_user()
48
+ print(f"[DEBUG api_dev_keys_list] Current user from auth: {user}")
49
+ if not user:
50
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
51
+
52
+ conn = get_user_db_conn()
53
+ keys = conn.execute(
54
+ "SELECT api_key, name, status, created_at, last_used_at FROM api_keys WHERE user_id = ? AND status = 'active' ORDER BY created_at DESC",
55
+ (user["id"],)
56
+ ).fetchall()
57
+ user_row = conn.execute("SELECT api_balance FROM users WHERE id = ?", (user["id"],)).fetchone()
58
+ conn.close()
59
+
60
+ balance = user_row["api_balance"] if user_row and user_row["api_balance"] is not None else 0.0
61
+ print(f"[DEBUG api_dev_keys_list] Queried user_id: {user['id']}, found balance: {balance}, keys count: {len(keys)}")
62
+ return jsonify({"balance": balance, "keys": [serialize_row(k) for k in keys]})
63
+
64
+
65
+ @developer_bp.route("/api/developer/keys/create", methods=["POST"])
66
+ def api_dev_keys_create():
67
+ user = get_current_user()
68
+ if not user:
69
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
70
+
71
+ data = request.json or {}
72
+ name = data.get("name", "Default Key").strip()[:50]
73
+ new_key = f"sk-tc-{secrets.token_hex(16)}"
74
+
75
+ conn = get_user_db_conn()
76
+ conn.execute(
77
+ "INSERT INTO api_keys (user_id, api_key, name, status) VALUES (?, ?, ?, 'active')",
78
+ (user["id"], new_key, name)
79
+ )
80
+ conn.commit()
81
+ conn.close()
82
+
83
+ return jsonify({"success": True, "api_key": new_key, "name": name})
84
+
85
+
86
+ @developer_bp.route("/api/developer/keys/delete", methods=["POST"])
87
+ def api_dev_keys_delete():
88
+ user = get_current_user()
89
+ if not user:
90
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
91
+
92
+ data = request.json or {}
93
+ api_key = data.get("api_key", "").strip()
94
+ if not api_key:
95
+ return jsonify({"error": "Thiếu API Key cần xóa."}), 400
96
+
97
+ conn = get_user_db_conn()
98
+ conn.execute(
99
+ "UPDATE api_keys SET status = 'revoked' WHERE user_id = ? AND api_key = ?",
100
+ (user["id"], api_key)
101
+ )
102
+ conn.commit()
103
+ conn.close()
104
+ return jsonify({"success": True})
105
+
106
+
107
+ @developer_bp.route("/api/developer/usage", methods=["GET"])
108
+ def api_dev_usage():
109
+ user = get_current_user()
110
+ if not user:
111
+ return jsonify({"error": "Vui lòng đăng nhập."}), 401
112
+
113
+ conn = get_user_db_conn()
114
+ keys_rows = conn.execute("SELECT api_key FROM api_keys WHERE user_id = ?", (user["id"],)).fetchall()
115
+ keys = [k["api_key"] for k in keys_rows]
116
+
117
+ if not keys:
118
+ conn.close()
119
+ return jsonify({"usage": []})
120
+
121
+ placeholders = ",".join(["?"] * len(keys))
122
+ usage = conn.execute(
123
+ f"SELECT * FROM api_usage WHERE api_key IN ({placeholders}) ORDER BY timestamp DESC LIMIT 100",
124
+ keys
125
+ ).fetchall()
126
+ conn.close()
127
+ return jsonify({"usage": [serialize_row(u) for u in usage]})
128
+
129
+
130
+ @developer_bp.route("/v1/chat/completions", methods=["POST"])
131
+ @require_api_key_auth
132
+ def openai_chat_completions():
133
+ api_key = request.api_key
134
+ data = request.json or {}
135
+ messages = data.get("messages", [])
136
+ model = data.get("model", "gemini-1.5-flash")
137
+
138
+ if not messages:
139
+ return jsonify({"error": "Missing messages array"}), 400
140
+
141
+ prompt_length = sum(len(m.get("content", "")) for m in messages)
142
+ cost = max(20.0, prompt_length * 0.02)
143
+
144
+ if request.api_balance < cost:
145
+ return jsonify({"error": f"Số dư không đủ. Chi phí ước tính: {cost:.2f}đ, Số dư: {request.api_balance:.2f}đ"}), 402
146
+
147
+ if not ADMIN_GEMINI_KEY:
148
+ return jsonify({"error": "Hệ thống chưa được Admin cấu hình khóa Gemini."}), 501
149
+
150
+ try:
151
+ contents = []
152
+ system_instruction = ""
153
+
154
+ for msg in messages:
155
+ role = msg.get("role")
156
+ content = msg.get("content", "")
157
+ if role == "system":
158
+ system_instruction = content
159
+ else:
160
+ contents.append({
161
+ "role": "user" if role == "user" else "model",
162
+ "parts": [{"text": content}]
163
+ })
164
+
165
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={ADMIN_GEMINI_KEY}"
166
+ payload = {"contents": contents}
167
+ if system_instruction:
168
+ payload["systemInstruction"] = {"parts": [{"text": system_instruction}]}
169
+
170
+ res = py_requests.post(url, json=payload, timeout=30)
171
+
172
+ if res.status_code != 200:
173
+ deduct_developer_balance(api_key, 0.0, model, prompt_length, res.status_code)
174
+ return jsonify({"error": f"Google Gemini API error: {res.text}"}), res.status_code
175
+
176
+ gemini_data = res.json()
177
+ response_text = gemini_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
178
+
179
+ deduct_developer_balance(api_key, cost, model, prompt_length + len(response_text), 200)
180
+
181
+ res_id = f"chatcmpl-{uuid.uuid4().hex}"
182
+ return jsonify({
183
+ "id": res_id,
184
+ "object": "chat.completion",
185
+ "created": int(time.time()),
186
+ "model": model,
187
+ "choices": [{
188
+ "index": 0,
189
+ "message": {"role": "assistant", "content": response_text},
190
+ "finish_reason": "stop"
191
+ }],
192
+ "usage": {
193
+ "prompt_tokens": int(prompt_length / 4),
194
+ "completion_tokens": int(len(response_text) / 4),
195
+ "total_tokens": int((prompt_length + len(response_text)) / 4)
196
+ }
197
+ })
198
+ except Exception as e:
199
+ deduct_developer_balance(api_key, 0.0, model, prompt_length, 500)
200
+ return jsonify({"error": f"API Gateway Exception: {str(e)}"}), 500
201
+
202
+
203
+ @developer_bp.route("/api/v1/translate", methods=["POST"])
204
+ @require_api_key_auth
205
+ def api_v1_translate():
206
+ api_key = request.api_key
207
+ data = request.json or {}
208
+ texts = data.get("texts", [])
209
+ mode = data.get("mode", "fast")
210
+
211
+ if not texts:
212
+ return jsonify({"error": "Missing texts array"}), 400
213
+
214
+ total_chars = sum(len(t) for t in texts)
215
+ cost = total_chars * 0.01
216
+
217
+ if request.api_balance < cost:
218
+ return jsonify({"error": f"Số dư không đủ. Chi phí ước tính: {cost:.2f}đ, Số dư: {request.api_balance:.2f}đ"}), 402
219
+
220
+ try:
221
+ eng = get_engine()
222
+ translations = []
223
+ for text in texts:
224
+ if not text.strip():
225
+ translations.append(text)
226
+ else:
227
+ translations.append(eng.translate(text, multi_option=False, mode=mode))
228
+
229
+ deduct_developer_balance(api_key, cost, f"vietphrase-{mode}", total_chars, 200)
230
+ return jsonify({"translations": translations, "characters": total_chars, "cost": cost})
231
+ except Exception as e:
232
+ deduct_developer_balance(api_key, 0.0, f"vietphrase-{mode}", total_chars, 500)
233
+ return jsonify({"error": f"Translation Error: {str(e)}"}), 500
234
+
235
+
236
+ @developer_bp.route("/v1/audio/speech", methods=["POST"])
237
+ @require_api_key_auth
238
+ def openai_audio_speech():
239
+ api_key = request.api_key
240
+ data = request.json or {}
241
+ input_text = data.get("input", "").strip()
242
+
243
+ if not input_text:
244
+ return jsonify({"error": "Missing 'input' text"}), 400
245
+
246
+ cost = len(input_text) * 0.1
247
+ if request.api_balance < cost:
248
+ return jsonify({"error": f"Số dư không đủ. Chi phí: {cost:.2f}đ"}), 402
249
+
250
+ # 1. Check if Local TTS Server is configured (host machine)
251
+ local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
252
+ if local_tts_url:
253
+ try:
254
+ payload = {
255
+ "input": input_text,
256
+ "speed": float(data.get("speed", 1.0)),
257
+ "voice": data.get("voice", "the_gioi_hoan_my"),
258
+ "ref_audio": data.get("ref_audio"),
259
+ "ref_text": data.get("ref_text")
260
+ }
261
+ if "bypass_cache" in data:
262
+ payload["bypass_cache"] = data["bypass_cache"]
263
+ if "temperature" in data:
264
+ payload["temperature"] = data["temperature"]
265
+ if "steps" in data:
266
+ payload["steps"] = data["steps"]
267
+ print(f"[Local TTS Proxy] Forwarding request to {local_tts_url} with voice={payload['voice']}, speed={payload['speed']}, bypass_cache={payload.get('bypass_cache')}")
268
+ r = py_requests.post(local_tts_url, json=payload, timeout=(2.0, 60.0))
269
+ if r.status_code == 200:
270
+ import io
271
+ deduct_developer_balance(api_key, cost, f"local-tts-{payload['voice']}", len(input_text), 200)
272
+ return send_file(
273
+ io.BytesIO(r.content),
274
+ mimetype="audio/wav",
275
+ as_attachment=False
276
+ )
277
+ print(f"[Local TTS Proxy Error]: {r.status_code} - {r.text}")
278
+ except Exception as e:
279
+ print(f"[Local TTS Proxy Exception]: {e}")
280
+
281
+ # 2. RunPod Fallback
282
+ runpod_api_key = os.environ.get("RUNPOD_API_TOKEN", os.environ.get("RUNPOD_API_KEY", ""))
283
+
284
+ runpod_endpoint_id = os.environ.get("RUNPOD_TTS_ENDPOINT_ID", "")
285
+
286
+ if runpod_api_key and runpod_endpoint_id:
287
+ try:
288
+ url = f"https://api.runpod.ai/v1/{runpod_endpoint_id}/runsync"
289
+ headers = {"Authorization": f"Bearer {runpod_api_key}", "Content-Type": "application/json"}
290
+ payload = {
291
+ "input": {
292
+ "text": input_text,
293
+ "speed": 1.0,
294
+ "voice": data.get("voice", "the_gioi_hoan_my")
295
+ }
296
+ }
297
+ r = py_requests.post(url, json=payload, headers=headers, timeout=45)
298
+ res = r.json()
299
+
300
+ if r.status_code == 200 and res.get("status") == "COMPLETED":
301
+ audio_b64 = res.get("output", {}).get("audio_base64", "")
302
+ if audio_b64:
303
+ import io
304
+ audio_data = base64.b64decode(audio_b64)
305
+ deduct_developer_balance(api_key, cost, "runpod-matcha-tts", len(input_text), 200)
306
+ return send_file(
307
+ io.BytesIO(audio_data),
308
+ mimetype="audio/wav" if res.get("output", {}).get("format") == "wav" else "audio/mpeg",
309
+ as_attachment=False
310
+ )
311
+ print(f"[RunPod TTS Error]: {res}")
312
+ except Exception as e:
313
+ print(f"[RunPod TTS exception]: {e}")
314
+
315
+ # Fallback: Generate a tiny 1-second silent WAV for testing/sandbox purposes
316
+ try:
317
+ import struct
318
+ import io
319
+ num_samples = 8000
320
+ # 8-bit PCM silence is represented by 128
321
+ data_bytes = bytearray([128] * num_samples)
322
+ header = struct.pack(
323
+ '<4sI4s4sIHHIIHH4sI',
324
+ b'RIFF',
325
+ 36 + num_samples,
326
+ b'WAVE',
327
+ b'fmt ',
328
+ 16,
329
+ 1, # PCM
330
+ 1, # Mono
331
+ 8000, # Sample rate
332
+ 8000, # Byte rate
333
+ 1, # Block align
334
+ 8, # Bits per sample
335
+ b'data',
336
+ num_samples
337
+ )
338
+ audio_data = bytes(header + data_bytes)
339
+ deduct_developer_balance(api_key, cost, "local-silent-tts-fallback", len(input_text), 200)
340
+ return send_file(
341
+ io.BytesIO(audio_data),
342
+ mimetype="audio/wav",
343
+ as_attachment=False
344
+ )
345
+ except Exception as e:
346
+ return jsonify({"error": f"Failed to generate fallback TTS: {str(e)}"}), 500
347
+
348
+
349
+ @developer_bp.route("/api/developer/all-sessions", methods=["GET"])
350
+ def api_developer_all_sessions():
351
+ user = get_current_user()
352
+ if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
353
+ return jsonify({"error": "Quyền truy cập bị từ chối."}), 403
354
+
355
+ conn = get_user_db_conn()
356
+ try:
357
+ rows = conn.execute(
358
+ """SELECT lh.id, lh.user_id, u.username, lh.ip_address, lh.os, lh.browser, lh.device_type, lh.login_time, lh.last_active, lh.status
359
+ FROM login_history lh
360
+ JOIN users u ON lh.user_id = u.id
361
+ ORDER BY lh.login_time DESC LIMIT 100"""
362
+ ).fetchall()
363
+
364
+ sessions = []
365
+ for r in rows:
366
+ s = dict(r)
367
+ if hasattr(s["login_time"], "isoformat"):
368
+ s["login_time"] = s["login_time"].isoformat()
369
+ if hasattr(s["last_active"], "isoformat"):
370
+ s["last_active"] = s["last_active"].isoformat()
371
+ sessions.append(s)
372
+
373
+ return jsonify({
374
+ "success": True,
375
+ "sessions": sessions
376
+ })
377
+ except Exception as e:
378
+ return jsonify({"error": str(e), "success": False}), 500
379
+ finally:
380
+ conn.close()
381
+
382
+
383
+ @developer_bp.route("/api/developer/sessions/revoke", methods=["POST"])
384
+ def api_developer_revoke_session():
385
+ user = get_current_user()
386
+ if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
387
+ return jsonify({"error": "Quyền truy cập bị từ chối."}), 403
388
+
389
+ data = request.json or {}
390
+ session_id = data.get("session_id")
391
+ if not session_id:
392
+ return jsonify({"error": "Missing session_id", "success": False}), 400
393
+
394
+ conn = get_user_db_conn()
395
+ try:
396
+ row = conn.execute(
397
+ "SELECT token FROM login_history WHERE id = ?",
398
+ (session_id,)
399
+ ).fetchone()
400
+ if not row:
401
+ return jsonify({"error": "Session not found", "success": False}), 404
402
+
403
+ token = row["token"]
404
+ conn.execute("UPDATE login_history SET status = 'logged_out' WHERE id = ?", (session_id,))
405
+ if token:
406
+ conn.execute("UPDATE refresh_tokens SET revoked = 1 WHERE token = ?", (token,))
407
+ conn.commit()
408
+ return jsonify({"message": "Đã đăng xuất thiết bị thành công", "success": True})
409
+ except Exception as e:
410
+ return jsonify({"error": str(e), "success": False}), 500
411
+ finally:
412
+ conn.close()
413
+
414
+
415
+ @developer_bp.route("/api/releases", methods=["GET"])
416
+ def api_get_releases():
417
+ conn = get_user_db_conn()
418
+ try:
419
+ rows = conn.execute(
420
+ "SELECT platform, version, download_url, patch_url, file_size, release_notes, updated_at FROM app_releases ORDER BY updated_at DESC"
421
+ ).fetchall()
422
+ releases = {}
423
+ for r in rows:
424
+ plat = r["platform"]
425
+ if plat not in releases:
426
+ releases[plat] = {
427
+ "version": r["version"],
428
+ "download_url": r["download_url"],
429
+ "patch_url": r["patch_url"],
430
+ "file_size": r["file_size"],
431
+ "release_notes": r["release_notes"],
432
+ "updated_at": r["updated_at"].isoformat() if hasattr(r["updated_at"], "isoformat") else r["updated_at"],
433
+ "history": []
434
+ }
435
+
436
+ releases[plat]["history"].append({
437
+ "version": r["version"],
438
+ "download_url": r["download_url"],
439
+ "patch_url": r["patch_url"],
440
+ "file_size": r["file_size"],
441
+ "release_notes": r["release_notes"],
442
+ "updated_at": r["updated_at"].isoformat() if hasattr(r["updated_at"], "isoformat") else r["updated_at"]
443
+ })
444
+ return jsonify({"success": True, "releases": releases})
445
+ except Exception as e:
446
+ return jsonify({"error": str(e), "success": False}), 500
447
+ finally:
448
+ conn.close()
449
+
450
+
451
+ @developer_bp.route("/api/releases/update", methods=["POST"])
452
+ def api_update_release():
453
+ user = get_current_user()
454
+ if not user or user.get("username") not in ["admin", "havucong25", "congkx123789"]:
455
+ return jsonify({"error": "Quyền truy cập bị từ chối. Chỉ dành cho admin.", "success": False}), 403
456
+
457
+ data = request.json or {}
458
+ platform = data.get("platform")
459
+ version = data.get("version")
460
+ download_url = data.get("download_url")
461
+ file_size = data.get("file_size")
462
+ release_notes = data.get("release_notes")
463
+
464
+ if not platform or platform not in ['extension', 'desktop_linux', 'desktop_windows']:
465
+ return jsonify({"error": "Platform không hợp lệ.", "success": False}), 400
466
+
467
+ conn = get_user_db_conn()
468
+ try:
469
+ existing = conn.execute("SELECT 1 FROM app_releases WHERE platform = ? AND version = ?", (platform, version)).fetchone()
470
+ if existing:
471
+ conn.execute(
472
+ """UPDATE app_releases
473
+ SET download_url = ?, file_size = ?, release_notes = ?, updated_at = CURRENT_TIMESTAMP
474
+ WHERE platform = ? AND version = ?""",
475
+ (download_url, file_size, release_notes, platform, version)
476
+ )
477
+ else:
478
+ conn.execute(
479
+ """INSERT INTO app_releases (platform, version, download_url, file_size, release_notes)
480
+ VALUES (?, ?, ?, ?, ?)""",
481
+ (platform, version, download_url, file_size, release_notes)
482
+ )
483
+ conn.commit()
484
+ return jsonify({"message": f"Cập nhật phiên bản {platform} v{version} thành công.", "success": True})
485
+ except Exception as e:
486
+ return jsonify({"error": str(e), "success": False}), 500
487
+ finally:
488
+ conn.close()
backend/api/epub.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import uuid
4
+ from flask import Blueprint, request, jsonify, send_file
5
+ from backend.api.books import is_vip_request
6
+ from backend.services.translation import get_engine, parse_custom_dict_text
7
+ from backend.services import epub_service
8
+
9
+ epub_bp = Blueprint("epub", __name__, url_prefix="/api/epub")
10
+
11
+
12
+ @epub_bp.route("/translate", methods=["POST"])
13
+ def api_epub_translate():
14
+ if not is_vip_request():
15
+ return jsonify({"error": "Chức năng Dịch EPUB nâng cao chỉ dành cho thành viên VIP!"}), 403
16
+
17
+ if "file" not in request.files:
18
+ return jsonify({"error": "Không tìm thấy file EPUB tải lên!"}), 400
19
+
20
+ file = request.files["file"]
21
+ if file.filename == "":
22
+ return jsonify({"error": "Tên file rỗng!"}), 400
23
+
24
+ mode = request.form.get("mode", "fast")
25
+ limit_chapters = int(request.form.get("limit_chapters", "-1"))
26
+ clean_styles = request.form.get("clean_styles", "false").lower() == "true"
27
+ strip_images = request.form.get("strip_images", "false").lower() == "true"
28
+ strip_fonts = request.form.get("strip_fonts", "false").lower() == "true"
29
+ custom_dict_text = request.form.get("custom_dict", "").strip()
30
+ custom_dict = parse_custom_dict_text(custom_dict_text)
31
+
32
+ temp_dir = tempfile.gettempdir()
33
+ src_path = os.path.join(temp_dir, f"src_{uuid.uuid4().hex}.epub")
34
+ dest_path = os.path.join(temp_dir, f"translated_{uuid.uuid4().hex}.epub")
35
+
36
+ try:
37
+ file.save(src_path)
38
+ eng = get_engine()
39
+ duration = epub_service.translate_epub_file(
40
+ src_path, dest_path, eng, mode=mode,
41
+ limit_chapters=limit_chapters, custom_dict=custom_dict,
42
+ clean_styles=clean_styles, strip_images=strip_images, strip_fonts=strip_fonts
43
+ )
44
+ print(f"[VIP EPUB] Translated {file.filename} in {duration:.2f}s in mode {mode}")
45
+
46
+ base, ext = os.path.splitext(file.filename)
47
+ output_filename = f"{base}_Dich_{mode}{ext}"
48
+
49
+ return send_file(
50
+ dest_path,
51
+ as_attachment=True,
52
+ download_name=output_filename,
53
+ mimetype="application/epub+zip"
54
+ )
55
+ except Exception as e:
56
+ print(f"[VIP EPUB ERROR] Translate failed: {e}")
57
+ return jsonify({"error": f"Lỗi trong quá trình dịch EPUB: {str(e)}"}), 500
58
+ finally:
59
+ if os.path.exists(src_path):
60
+ try:
61
+ os.remove(src_path)
62
+ except Exception:
63
+ pass
64
+
65
+
66
+ @epub_bp.route("/convert-txt", methods=["POST"])
67
+ def api_epub_convert_txt():
68
+ if not is_vip_request():
69
+ return jsonify({"error": "Chức năng chuyển đổi truyện sang EPUB chỉ dành cho thành viên VIP!"}), 403
70
+
71
+ title = request.form.get("title", "Truyện convert").strip()
72
+ author = request.form.get("author", "Vô danh").strip()
73
+ description = request.form.get("description", "").strip()
74
+ split_regex = request.form.get("split_regex", r"第\s*\d+\s*[章|回|节]").strip()
75
+ translate_bool = request.form.get("translate", "false").lower() == "true"
76
+ mode = request.form.get("mode", "fast")
77
+ custom_dict_text = request.form.get("custom_dict", "").strip()
78
+
79
+ txt_content = ""
80
+ if "file" in request.files:
81
+ file = request.files["file"]
82
+ if file.filename != "":
83
+ txt_content = file.read().decode("utf-8", errors="ignore")
84
+ else:
85
+ txt_content = request.form.get("text", "").strip()
86
+
87
+ if not txt_content:
88
+ return jsonify({"error": "Nội dung văn bản rỗng!"}), 400
89
+
90
+ custom_dict = parse_custom_dict_text(custom_dict_text)
91
+ temp_dir = tempfile.gettempdir()
92
+ dest_path = os.path.join(temp_dir, f"converted_{uuid.uuid4().hex}.epub")
93
+
94
+ try:
95
+ eng = get_engine() if translate_bool else None
96
+ epub_service.convert_txt_to_epub(
97
+ txt_content, title, author, split_regex, dest_path,
98
+ description=description, engine=eng,
99
+ mode=mode if translate_bool else None, custom_dict=custom_dict
100
+ )
101
+ return send_file(
102
+ dest_path,
103
+ as_attachment=True,
104
+ download_name=f"{title}.epub",
105
+ mimetype="application/epub+zip"
106
+ )
107
+ except Exception as e:
108
+ print(f"[VIP EPUB ERROR] Convert TXT failed: {e}")
109
+ return jsonify({"error": f"Lỗi tạo EPUB: {str(e)}"}), 500
110
+
111
+
112
+ @epub_bp.route("/optimize", methods=["POST"])
113
+ def api_epub_optimize():
114
+ if not is_vip_request():
115
+ return jsonify({"error": "Chức năng Tối ưu hóa EPUB chỉ dành cho thành viên VIP!"}), 403
116
+
117
+ if "file" not in request.files:
118
+ return jsonify({"error": "Không tìm thấy file EPUB tải lên!"}), 400
119
+
120
+ file = request.files["file"]
121
+ if file.filename == "":
122
+ return jsonify({"error": "Tên file rỗng!"}), 400
123
+
124
+ strip_images = request.form.get("strip_images", "false").lower() == "true"
125
+ strip_fonts = request.form.get("strip_fonts", "false").lower() == "true"
126
+
127
+ temp_dir = tempfile.gettempdir()
128
+ src_path = os.path.join(temp_dir, f"opt_src_{uuid.uuid4().hex}.epub")
129
+ dest_path = os.path.join(temp_dir, f"opt_dest_{uuid.uuid4().hex}.epub")
130
+
131
+ try:
132
+ file.save(src_path)
133
+
134
+ class MockEngine:
135
+ def translate(self, text, **kwargs):
136
+ return text
137
+
138
+ epub_service.translate_epub_file(
139
+ src_path, dest_path, MockEngine(), mode=None,
140
+ limit_chapters=-1, custom_dict=None, clean_styles=True,
141
+ strip_images=strip_images, strip_fonts=strip_fonts
142
+ )
143
+
144
+ base, ext = os.path.splitext(file.filename)
145
+ return send_file(
146
+ dest_path,
147
+ as_attachment=True,
148
+ download_name=f"{base}_Optimized{ext}",
149
+ mimetype="application/epub+zip"
150
+ )
151
+ except Exception as e:
152
+ return jsonify({"error": f"Lỗi tối ưu EPUB: {str(e)}"}), 500
153
+ finally:
154
+ if os.path.exists(src_path):
155
+ try:
156
+ os.remove(src_path)
157
+ except Exception:
158
+ pass
backend/api/monitoring.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from flask import Blueprint, jsonify, request
3
+ from backend.core.decorators import get_current_user
4
+ from backend.core.monitoring import collect_all_metrics
5
+ from backend.services.alert_service import check_resources_and_alert
6
+
7
+ monitoring_bp = Blueprint("monitoring", __name__)
8
+
9
+ @monitoring_bp.route("/api/admin/metrics", methods=["GET"])
10
+ def get_metrics():
11
+ """
12
+ Get detailed system metrics and statistics.
13
+ Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
14
+ """
15
+ # Authorization checks
16
+ is_admin = False
17
+
18
+ # 1. Check for secret X-Admin-Key in request headers
19
+ admin_key = request.headers.get("X-Admin-Key")
20
+ expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
21
+ if admin_key and admin_key == expected_key:
22
+ is_admin = True
23
+
24
+ # 2. Check logged-in user session/token
25
+ if not is_admin:
26
+ current_user = get_current_user()
27
+ if current_user and current_user.get("username") == "admin":
28
+ is_admin = True
29
+
30
+ if not is_admin:
31
+ return jsonify({"error": "Unauthorized admin access required"}), 403
32
+
33
+ try:
34
+ # Collect system statistics and details
35
+ metrics = collect_all_metrics()
36
+
37
+ # Trigger checks and alerts if RAM or Disk usage is critical
38
+ check_resources_and_alert()
39
+
40
+ return jsonify({
41
+ "success": True,
42
+ "metrics": metrics
43
+ }), 200
44
+ except Exception as e:
45
+ return jsonify({
46
+ "success": False,
47
+ "error": f"Failed to retrieve system metrics: {str(e)}"
48
+ }), 500
49
+
50
+
51
+ @monitoring_bp.route("/api/admin/logs", methods=["GET"])
52
+ def get_logs():
53
+ """
54
+ Get or download application logs.
55
+ Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
56
+ """
57
+ is_admin = False
58
+
59
+ # 1. Check for secret X-Admin-Key in request headers
60
+ admin_key = request.headers.get("X-Admin-Key")
61
+ expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
62
+ if admin_key and admin_key == expected_key:
63
+ is_admin = True
64
+
65
+ # 2. Check logged-in user session/token
66
+ if not is_admin:
67
+ current_user = get_current_user()
68
+ if current_user and current_user.get("username") == "admin":
69
+ is_admin = True
70
+
71
+ if not is_admin:
72
+ return jsonify({"error": "Unauthorized admin access required"}), 403
73
+
74
+ from backend.config import Config
75
+ log_path = os.path.join(Config.ROOT_DIR, "logs", "app.log")
76
+
77
+ # Handle file download option
78
+ if request.args.get("download") == "true":
79
+ if not os.path.exists(log_path):
80
+ return jsonify({"error": "Log file does not exist"}), 404
81
+ from flask import send_file
82
+ return send_file(log_path, as_attachment=True, download_name="app.log")
83
+
84
+ # Handle standard retrieval
85
+ lines_count = request.args.get("lines", default=100, type=int)
86
+ lines_count = max(1, min(lines_count, 2000))
87
+
88
+ if not os.path.exists(log_path):
89
+ return jsonify({
90
+ "success": True,
91
+ "lines_returned": 0,
92
+ "logs": [],
93
+ "message": "Log file not found."
94
+ }), 200
95
+
96
+ try:
97
+ from collections import deque
98
+ with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
99
+ last_lines = list(deque(f, maxlen=lines_count))
100
+
101
+ return jsonify({
102
+ "success": True,
103
+ "lines_returned": len(last_lines),
104
+ "logs": last_lines
105
+ }), 200
106
+ except Exception as e:
107
+ return jsonify({
108
+ "success": False,
109
+ "error": f"Failed to read logs: {str(e)}"
110
+ }), 500
111
+
112
+
113
+ @monitoring_bp.route("/api/admin/profiler", methods=["GET"])
114
+ def get_profiler_stats():
115
+ """
116
+ Get recent request profiles with latency breakdown (Total, DB, External, CPU).
117
+ Access is restricted to admin via X-Admin-Key header or logged-in 'admin' user.
118
+ """
119
+ is_admin = False
120
+ admin_key = request.headers.get("X-Admin-Key")
121
+ expected_key = os.environ.get("ADMIN_PAYMENT_KEY", "LYVUHA_ADMIN_2026")
122
+ if admin_key and admin_key == expected_key:
123
+ is_admin = True
124
+
125
+ if not is_admin:
126
+ current_user = get_current_user()
127
+ if current_user and current_user.get("username") == "admin":
128
+ is_admin = True
129
+
130
+ if not is_admin:
131
+ return jsonify({"error": "Unauthorized admin access required"}), 403
132
+
133
+ try:
134
+ from backend.core.profiler import get_recent_profiles
135
+ profiles = get_recent_profiles()
136
+ return jsonify({
137
+ "success": True,
138
+ "count": len(profiles),
139
+ "profiles": profiles
140
+ }), 200
141
+ except Exception as e:
142
+ return jsonify({
143
+ "success": False,
144
+ "error": f"Failed to retrieve profiler stats: {str(e)}"
145
+ }), 500
146
+
147
+
148
+ @monitoring_bp.route("/api/logs/error", methods=["POST", "OPTIONS"])
149
+ def log_frontend_error():
150
+ """
151
+ Receive fatal crash reports from the Frontend ErrorBoundary
152
+ and instantly send an email alert to the admin.
153
+ """
154
+ if request.method == "OPTIONS":
155
+ return jsonify({}), 200
156
+
157
+ try:
158
+ data = request.json or {}
159
+ message = data.get("message", "Unknown Error")
160
+ stack = data.get("stack", "No stack trace provided.")
161
+ url = data.get("url", "Unknown URL")
162
+ user_agent = data.get("userAgent", "Unknown Browser")
163
+ time = data.get("time", "Unknown Time")
164
+
165
+ html_content = f"""
166
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden;">
167
+ <div style="background-color: #dc2626; color: white; padding: 15px 20px;">
168
+ <h2 style="margin: 0;">🚨 Tiên Hiệp AI - Frontend Crash Alert</h2>
169
+ </div>
170
+ <div style="padding: 20px; background-color: #f8fafc;">
171
+ <p><strong>Thời gian:</strong> {time}</p>
172
+ <p><strong>URL phát sinh lỗi:</strong> <a href="{url}">{url}</a></p>
173
+ <p><strong>Trình duyệt:</strong> {user_agent}</p>
174
+ <hr style="border: none; border-top: 1px solid #cbd5e1; margin: 20px 0;" />
175
+ <h3 style="color: #b91c1c; margin-bottom: 10px;">Lỗi (Error Message):</h3>
176
+ <div style="background: #fee2e2; border: 1px solid #f87171; padding: 12px; border-radius: 6px; color: #991b1b; font-weight: bold; word-break: break-all;">
177
+ {message}
178
+ </div>
179
+ <h3 style="color: #334155; margin-top: 20px; margin-bottom: 10px;">Mã Lỗi (Stack Trace):</h3>
180
+ <pre style="background: #1e293b; color: #e2e8f0; padding: 15px; border-radius: 6px; overflow-x: auto; font-size: 13px; line-height: 1.5;">{stack}</pre>
181
+ </div>
182
+ </div>
183
+ """
184
+
185
+ # Gửi email ngầm (không làm chậm server)
186
+ from backend.services.email_service import send_email_async
187
+ admin_email = os.environ.get("ADMIN_EMAIL", "havucong@lyvuha.com")
188
+
189
+ send_email_async(
190
+ to_email=admin_email,
191
+ subject=f"[CRASH ALERT] Lỗi nghiêm trọng tại Tiên Hiệp AI ({url})",
192
+ html_content=html_content
193
+ )
194
+
195
+ return jsonify({"success": True, "message": "Crash report received and email queued."}), 200
196
+ except Exception as e:
197
+ print(f"❌ Failed to process frontend crash log: {e}")
198
+ return jsonify({"success": False, "error": str(e)}), 500
backend/api/payment.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from datetime import datetime
4
+ from flask import Blueprint, request, jsonify, session
5
+ from backend.config import Config
6
+ from backend.core.decorators import get_current_user
7
+ from backend.core.security import verify_access_token
8
+ from backend.core.rate_limit import check_rate_limit, get_client_ip
9
+ from backend.database.db_manager import get_user_db_conn
10
+ from backend.services.payment_service import (
11
+ create_payment_order, confirm_payment, verify_payos_webhook_signature
12
+ )
13
+ from backend.services.auth_service import check_vip_expiry, activate_vip
14
+
15
+ payment_bp = Blueprint("payment", __name__, url_prefix="/api/payment")
16
+
17
+
18
+ def _get_user_id_from_request():
19
+ """Helper to get user_id from session or JWT."""
20
+ user = get_current_user()
21
+ if user:
22
+ return user["id"]
23
+ auth_header = request.headers.get("Authorization", "")
24
+ if auth_header.startswith("Bearer "):
25
+ payload = verify_access_token(auth_header[7:])
26
+ if payload:
27
+ return int(payload["sub"])
28
+ return None
29
+
30
+
31
+ def _process_payment_webhook(order_id, amount):
32
+ """Internal handler called from the webhook to confirm payment & activate VIP/topup."""
33
+ conn = get_user_db_conn()
34
+ payment = conn.execute(
35
+ "SELECT * FROM payments WHERE order_id = ? AND status = 'pending'", (order_id,)
36
+ ).fetchone()
37
+
38
+ if not payment:
39
+ conn.close()
40
+ return jsonify({"error": "Order not found or already processed"}), 404
41
+
42
+ if amount > 0 and abs(amount - payment["amount"]) > 100:
43
+ conn.close()
44
+ return jsonify({"error": "Amount mismatch"}), 400
45
+
46
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
47
+ conn.execute(
48
+ "UPDATE payments SET status = 'completed', completed_at = ? WHERE id = ?",
49
+ (now, payment["id"])
50
+ )
51
+ conn.commit()
52
+ conn.close()
53
+
54
+ if payment["plan"].startswith("topup_"):
55
+ conn = get_user_db_conn()
56
+ conn.execute(
57
+ "UPDATE users SET api_balance = COALESCE(api_balance, 0.0) + ? WHERE id = ?",
58
+ (payment["amount"], payment["user_id"])
59
+ )
60
+ conn.commit()
61
+ conn.close()
62
+ return jsonify({"success": True, "message": f"Nạp thành công {payment['amount']:,}đ vào số dư API!".replace(",", ".")})
63
+ else:
64
+ activate_vip(payment["user_id"], payment["plan"])
65
+ if session.get("user_id") == payment["user_id"]:
66
+ session["vip_status"] = 1
67
+ print(f"[PAYMENT] ✅ Order {order_id} completed — User #{payment['user_id']} → VIP {payment['plan']}")
68
+ return jsonify({"success": True, "message": "Payment confirmed, VIP activated!"})
69
+
70
+
71
+ @payment_bp.route("/plans", methods=["GET"])
72
+ def api_payment_plans():
73
+ lang = request.args.get("lang", "vi")
74
+ plans = []
75
+ for key, plan in Config.VIP_PLANS.items():
76
+ plans.append({
77
+ "id": key,
78
+ "name": plan.get(f"name_{lang}", plan["name_vi"]),
79
+ "description": plan.get(f"description_{lang}", plan["description_vi"]),
80
+ "price": plan["price"],
81
+ "price_formatted": f"{plan['price']:,}đ".replace(",", "."),
82
+ "duration_days": plan["duration_days"],
83
+ })
84
+ return jsonify({"plans": plans})
85
+
86
+
87
+ @payment_bp.route("/create", methods=["POST"])
88
+ def api_payment_create():
89
+ user_id = _get_user_id_from_request()
90
+ if not user_id:
91
+ return jsonify({"error": "Vui lòng đăng nhập để mua VIP."}), 401
92
+
93
+ ip = get_client_ip()
94
+ if check_rate_limit("payment", ip):
95
+ return jsonify({"error": "Bạn đã tạo quá nhiều đơn hàng. Vui lòng thử lại sau."}), 429
96
+
97
+ data = request.json or {}
98
+ plan = data.get("plan", "month")
99
+
100
+ result, status_code = create_payment_order(user_id, plan)
101
+ return jsonify(result), status_code
102
+
103
+
104
+ @payment_bp.route("/status/<order_id>", methods=["GET"])
105
+ def api_payment_status(order_id):
106
+ user_id = _get_user_id_from_request()
107
+
108
+ conn = get_user_db_conn()
109
+ if user_id:
110
+ payment = conn.execute(
111
+ "SELECT * FROM payments WHERE order_id = ? AND user_id = ?", (order_id, user_id)
112
+ ).fetchone()
113
+ else:
114
+ payment = conn.execute(
115
+ "SELECT * FROM payments WHERE order_id = ?", (order_id,)
116
+ ).fetchone()
117
+ conn.close()
118
+
119
+ if not payment:
120
+ return jsonify({"error": "Payment order not found."}), 404
121
+
122
+ return jsonify({
123
+ "order_id": payment["order_id"],
124
+ "plan": payment["plan"],
125
+ "amount": payment["amount"],
126
+ "status": payment["status"],
127
+ "created_at": payment["created_at"],
128
+ "completed_at": payment["completed_at"]
129
+ })
130
+
131
+
132
+ @payment_bp.route("/webhook", methods=["POST"])
133
+ def api_payment_webhook():
134
+ # Verify PAYMENT_WEBHOOK_KEY if set in environment
135
+ webhook_key = os.environ.get("PAYMENT_WEBHOOK_KEY", "")
136
+ if webhook_key:
137
+ auth_header = request.headers.get("Authorization", "")
138
+ provided_key = auth_header.replace("Bearer ", "").replace("Apikey ", "").strip()
139
+ query_key = request.args.get("webhook_key", "")
140
+ if provided_key != webhook_key and query_key != webhook_key:
141
+ return jsonify({"error": "Unauthorized webhook request"}), 401
142
+
143
+ data = request.json or {}
144
+
145
+ # PayOS format
146
+ if "data" in data and "orderCode" in data.get("data", {}):
147
+ order_code = str(data["data"]["orderCode"])
148
+ amount = data["data"].get("amount", 0)
149
+ checksum_key = Config.PAYMENT_CONFIG.get("payos_checksum_key", "")
150
+ if checksum_key and "signature" in data:
151
+ if not verify_payos_webhook_signature(data["data"], data.get("signature", ""), checksum_key):
152
+ return jsonify({"error": "Invalid signature"}), 403
153
+ return _process_payment_webhook(order_code, amount)
154
+
155
+ # SePay / Generic format
156
+ if "transferAmount" in data:
157
+ content = data.get("content", "")
158
+ amount = data.get("transferAmount", 0)
159
+ match = re.search(r"(VIP\w+)", content.upper())
160
+ if match:
161
+ return _process_payment_webhook(match.group(1), amount)
162
+
163
+ return jsonify({"error": "Invalid webhook payload"}), 400
164
+
165
+
166
+ @payment_bp.route("/confirm-manual", methods=["POST"])
167
+ def api_payment_confirm_manual():
168
+ data = request.json or {}
169
+ admin_key = data.get("admin_key", "")
170
+ order_id = data.get("order_id", "")
171
+
172
+ ADMIN_KEY = os.environ.get("ADMIN_PAYMENT_KEY")
173
+ if not ADMIN_KEY:
174
+ ADMIN_KEY = "SECURE_SECRET_RANDOM_KEY_9988_ADMIN"
175
+
176
+ is_admin = False
177
+ if admin_key and admin_key == ADMIN_KEY:
178
+ is_admin = True
179
+
180
+ if not is_admin:
181
+ return jsonify({"error": "Unauthorized admin access required"}), 403
182
+
183
+ if not order_id:
184
+ return jsonify({"error": "order_id is required"}), 400
185
+ return _process_payment_webhook(order_id, 0)
186
+
187
+
188
+ @payment_bp.route("/history", methods=["GET"])
189
+ def api_payment_history():
190
+ user_id = _get_user_id_from_request()
191
+ if not user_id:
192
+ return jsonify({"error": "Unauthorized"}), 401
193
+
194
+ conn = get_user_db_conn()
195
+ payments = conn.execute(
196
+ "SELECT order_id, plan, amount, status, created_at, completed_at FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT 20",
197
+ (user_id,)
198
+ ).fetchall()
199
+ conn.close()
200
+ return jsonify({"payments": [dict(p) for p in payments]})
201
+
202
+
203
+ @payment_bp.route("/vip-status", methods=["GET"])
204
+ def api_user_vip_status():
205
+ user_id = _get_user_id_from_request()
206
+ if not user_id:
207
+ return jsonify({"error": "Unauthorized"}), 401
208
+
209
+ check_vip_expiry(user_id)
210
+ conn = get_user_db_conn()
211
+ user = conn.execute("SELECT vip_status, vip_plan, vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
212
+ conn.close()
213
+
214
+ if not user:
215
+ return jsonify({"error": "User not found"}), 404
216
+
217
+ days_remaining = 0
218
+ if user["vip_expiry"]:
219
+ try:
220
+ expiry_val = user["vip_expiry"]
221
+ if isinstance(expiry_val, str):
222
+ expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
223
+ else:
224
+ expiry = expiry_val
225
+ days_remaining = max(0, (expiry - datetime.utcnow()).days)
226
+ except Exception:
227
+ pass
228
+
229
+ return jsonify({
230
+ "vip_status": user["vip_status"],
231
+ "vip_plan": user["vip_plan"],
232
+ "vip_expiry": user["vip_expiry"],
233
+ "days_remaining": days_remaining,
234
+ "is_active": user["vip_status"] == 1
235
+ })
backend/api/sects.py ADDED
@@ -0,0 +1,1248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, request, jsonify
2
+ from backend.core.decorators import jwt_required, get_current_user
3
+ from backend.database.db_manager import get_user_db_conn
4
+ from backend.core.logger import logger
5
+ from backend.core.security import encrypt_message, decrypt_message
6
+ from datetime import datetime
7
+
8
+ sects_bp = Blueprint("sects", __name__)
9
+
10
+ def serialize_row(row):
11
+ if not row:
12
+ return {}
13
+ d = dict(row)
14
+ for k, v in d.items():
15
+ if hasattr(v, "isoformat"):
16
+ d[k] = v.isoformat()
17
+ return d
18
+
19
+ @sects_bp.route("/api/sects/list", methods=["GET"])
20
+ def list_sects():
21
+ conn = get_user_db_conn()
22
+ try:
23
+ rows = conn.execute("""
24
+ SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
25
+ u.username as leader_name,
26
+ (SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
27
+ FROM sects s
28
+ LEFT JOIN users u ON s.leader_id = u.id
29
+ ORDER BY s.contribution DESC, s.created_at DESC
30
+ """).fetchall()
31
+
32
+ user = get_current_user()
33
+ user_requests = []
34
+ if user:
35
+ req_rows = conn.execute("SELECT sect_id FROM sect_join_requests WHERE user_id = ? AND status = 'pending'", (user["id"],)).fetchall()
36
+ user_requests = [r["sect_id"] for r in req_rows]
37
+
38
+ return jsonify({
39
+ "sects": [serialize_row(r) for r in rows],
40
+ "pending_requests": user_requests
41
+ })
42
+ except Exception as e:
43
+ logger.error(f"Error listing sects: {e}")
44
+ return jsonify({"error": str(e)}), 500
45
+ finally:
46
+ conn.close()
47
+
48
+ @sects_bp.route("/api/sects/create", methods=["POST"])
49
+ @jwt_required
50
+ def create_sect():
51
+ user = get_current_user()
52
+ data = request.json or {}
53
+ name = data.get("name", "").strip()
54
+ slogan = data.get("slogan", "").strip()
55
+ badge = data.get("badge", "purple").strip()
56
+
57
+ if not name:
58
+ return jsonify({"error": "Tên tông môn không được để trống"}), 400
59
+
60
+ conn = get_user_db_conn()
61
+ try:
62
+ # Check if user is already in a sect
63
+ in_sect = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
64
+ if in_sect:
65
+ return jsonify({"error": "Bạn đã thuộc một tông môn khác, hãy rời tông cũ trước"}), 400
66
+
67
+ # Check if name exists
68
+ exists = conn.execute("SELECT id FROM sects WHERE name = ?", (name,)).fetchone()
69
+ if exists:
70
+ return jsonify({"error": "Tên tông môn này đã được lập từ trước"}), 400
71
+
72
+ cursor = conn.execute(
73
+ "INSERT INTO sects (name, slogan, badge, leader_id, announcement) VALUES (?, ?, ?, ?, ?)",
74
+ (name, slogan, badge, user["id"], f"Tông môn {name} được thành lập. Chúc các đồng môn tu tiên đắc đạo!")
75
+ )
76
+ sect_id = cursor.lastrowid
77
+
78
+ conn.execute(
79
+ "INSERT INTO sect_members (sect_id, user_id, role, contribution) VALUES (?, ?, 'leader', 100)",
80
+ (sect_id, user["id"])
81
+ )
82
+ conn.execute(
83
+ "UPDATE sects SET contribution = 100 WHERE id = ?",
84
+ (sect_id,)
85
+ )
86
+ # Delete any pending join requests of this user elsewhere
87
+ conn.execute("DELETE FROM sect_join_requests WHERE user_id = ?", (user["id"],))
88
+
89
+ conn.commit()
90
+ return jsonify({"success": True, "message": f"Sáng lập {name} tông thành công!", "sect_id": sect_id})
91
+ except Exception as e:
92
+ logger.error(f"Error creating sect: {e}")
93
+ return jsonify({"error": str(e)}), 500
94
+ finally:
95
+ conn.close()
96
+
97
+ @sects_bp.route("/api/sects/join", methods=["POST"])
98
+ @jwt_required
99
+ def join_sect():
100
+ user = get_current_user()
101
+ data = request.json or {}
102
+ sect_id = data.get("sect_id")
103
+
104
+ if not sect_id:
105
+ return jsonify({"error": "Thiếu sect_id"}), 400
106
+
107
+ conn = get_user_db_conn()
108
+ try:
109
+ # Check if user is already in a sect
110
+ in_sect = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
111
+ if in_sect:
112
+ return jsonify({"error": "Bạn đã ở trong một tông môn khác"}), 400
113
+
114
+ # Check if sect exists
115
+ sect = conn.execute("SELECT name FROM sects WHERE id = ?", (sect_id,)).fetchone()
116
+ if not sect:
117
+ return jsonify({"error": "Tông môn không tồn tại"}), 404
118
+
119
+ # Check if already requested
120
+ req_exists = conn.execute("SELECT id FROM sect_join_requests WHERE sect_id = ? AND user_id = ?", (sect_id, user["id"])).fetchone()
121
+ if req_exists:
122
+ return jsonify({"error": "Bạn đã gửi yêu cầu gia nhập tông này và đang chờ duyệt"}), 400
123
+
124
+ conn.execute(
125
+ "INSERT INTO sect_join_requests (sect_id, user_id, status) VALUES (?, ?, 'pending')",
126
+ (sect_id, user["id"])
127
+ )
128
+ conn.commit()
129
+ return jsonify({"success": True, "message": f"Đã gửi yêu cầu bái kiến gia nhập {sect['name']}! Vui lòng chờ duyệt."})
130
+ except Exception as e:
131
+ logger.error(f"Error joining sect: {e}")
132
+ return jsonify({"error": str(e)}), 500
133
+ finally:
134
+ conn.close()
135
+
136
+ @sects_bp.route("/api/sects/leave", methods=["POST"])
137
+ @jwt_required
138
+ def leave_sect():
139
+ user = get_current_user()
140
+ conn = get_user_db_conn()
141
+ try:
142
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
143
+ if not member:
144
+ return jsonify({"error": "Bạn chưa gia nhập tông môn nào"}), 400
145
+
146
+ sect_id = member["sect_id"]
147
+ if member["role"] == "leader":
148
+ # Leader leaving dissolves the sect
149
+ conn.execute("DELETE FROM sect_chat_groups WHERE sect_id = ?", (sect_id,))
150
+ conn.execute("DELETE FROM sect_messages WHERE sect_id = ?", (sect_id,))
151
+ conn.execute("DELETE FROM sect_members WHERE sect_id = ?", (sect_id,))
152
+ conn.execute("DELETE FROM sect_join_requests WHERE sect_id = ?", (sect_id,))
153
+ conn.execute("DELETE FROM sect_books WHERE sect_id = ?", (sect_id,))
154
+ conn.execute("DELETE FROM sects WHERE id = ?", (sect_id,))
155
+ msg = "Bạn là tông chủ, rời đi đồng nghĩa với việc giải tán tông môn!"
156
+ else:
157
+ conn.execute("DELETE FROM sect_members WHERE user_id = ? AND sect_id = ?", (user["id"], sect_id))
158
+ msg = "Bạn đã rời khỏi tông môn thành công!"
159
+
160
+ conn.commit()
161
+ return jsonify({"success": True, "message": msg})
162
+ except Exception as e:
163
+ logger.error(f"Error leaving sect: {e}")
164
+ return jsonify({"error": str(e)}), 500
165
+ finally:
166
+ conn.close()
167
+
168
+ @sects_bp.route("/api/sects/my-sect", methods=["GET"])
169
+ @jwt_required
170
+ def my_sect():
171
+ user = get_current_user()
172
+ conn = get_user_db_conn()
173
+ try:
174
+ member = conn.execute("SELECT sect_id, role, contribution as my_contrib FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
175
+ if not member:
176
+ return jsonify({"in_sect": False})
177
+
178
+ sect_id = member["sect_id"]
179
+ sect = conn.execute("""
180
+ SELECT s.id, s.name, s.slogan, s.announcement, s.badge, s.level, s.contribution, s.created_at,
181
+ u.username as leader_name, u.id as leader_id
182
+ FROM sects s
183
+ LEFT JOIN users u ON s.leader_id = u.id
184
+ WHERE s.id = ?
185
+ """, (sect_id,)).fetchone()
186
+
187
+ members = conn.execute("""
188
+ SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at, u.username, u.avatar
189
+ FROM sect_members sm
190
+ LEFT JOIN users u ON sm.user_id = u.id
191
+ WHERE sm.sect_id = ?
192
+ ORDER BY
193
+ CASE sm.role
194
+ WHEN 'leader' THEN 1
195
+ WHEN 'elder' THEN 2
196
+ ELSE 3
197
+ END,
198
+ sm.contribution DESC
199
+ """, (sect_id,)).fetchall()
200
+
201
+ return jsonify({
202
+ "in_sect": True,
203
+ "role": member["role"],
204
+ "my_contribution": member["my_contrib"],
205
+ "sect": serialize_row(sect),
206
+ "members": [serialize_row(m) for m in members]
207
+ })
208
+ except Exception as e:
209
+ logger.error(f"Error getting my sect: {e}")
210
+ return jsonify({"error": str(e)}), 500
211
+ finally:
212
+ conn.close()
213
+
214
+ @sects_bp.route("/api/sects/chat/history", methods=["GET"])
215
+ @jwt_required
216
+ def get_sect_chat_history():
217
+ user = get_current_user()
218
+ chat_type = request.args.get("chat_type", "general").strip()
219
+ target_id = request.args.get("target_id")
220
+ group_id = request.args.get("group_id")
221
+
222
+ conn = get_user_db_conn()
223
+ try:
224
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
225
+ if not member:
226
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
227
+
228
+ sect_id = member["sect_id"]
229
+
230
+ if chat_type == "direct":
231
+ if not target_id:
232
+ return jsonify({"error": "Thiếu target_id cho chat riêng"}), 400
233
+ rows = conn.execute("""
234
+ SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.target_id,
235
+ u.username as sender_name, u.avatar as sender_avatar
236
+ FROM sect_messages sm
237
+ LEFT JOIN users u ON sm.sender_id = u.id
238
+ WHERE sm.sect_id = ? AND sm.chat_type = 'direct'
239
+ AND ((sm.sender_id = ? AND sm.target_id = ?) OR (sm.sender_id = ? AND sm.target_id = ?))
240
+ ORDER BY sm.created_at ASC LIMIT 100
241
+ """, (sect_id, user["id"], target_id, target_id, user["id"])).fetchall()
242
+
243
+ elif chat_type == "book":
244
+ if not target_id:
245
+ return jsonify({"error": "Thiếu target_id cho chat truyện"}), 400
246
+ rows = conn.execute("""
247
+ SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.target_id,
248
+ u.username as sender_name, u.avatar as sender_avatar
249
+ FROM sect_messages sm
250
+ LEFT JOIN users u ON sm.sender_id = u.id
251
+ WHERE sm.sect_id = ? AND sm.chat_type = 'book' AND sm.target_id = ?
252
+ ORDER BY sm.created_at ASC LIMIT 100
253
+ """, (sect_id, target_id)).fetchall()
254
+
255
+ elif chat_type == "group":
256
+ if not group_id:
257
+ return jsonify({"error": "Thiếu group_id cho chat nhóm"}), 400
258
+
259
+ # Check group access
260
+ group = conn.execute("SELECT members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?", (group_id, sect_id)).fetchone()
261
+ if not group:
262
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
263
+
264
+ members_list = [int(x) for x in group["members_csv"].split(",") if x.strip()]
265
+ if user["id"] not in members_list:
266
+ return jsonify({"error": "Bạn không phải thành viên của nhóm chat này"}), 403
267
+
268
+ rows = conn.execute("""
269
+ SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type, sm.group_id,
270
+ u.username as sender_name, u.avatar as sender_avatar
271
+ FROM sect_messages sm
272
+ LEFT JOIN users u ON sm.sender_id = u.id
273
+ WHERE sm.sect_id = ? AND sm.chat_type = 'group' AND sm.group_id = ?
274
+ ORDER BY sm.created_at ASC LIMIT 100
275
+ """, (sect_id, group_id)).fetchall()
276
+
277
+ else: # general chat
278
+ rows = conn.execute("""
279
+ SELECT sm.id, sm.sender_id, sm.message, sm.created_at, sm.chat_type,
280
+ u.username as sender_name, u.avatar as sender_avatar
281
+ FROM sect_messages sm
282
+ LEFT JOIN users u ON sm.sender_id = u.id
283
+ WHERE sm.sect_id = ? AND (sm.chat_type IS NULL OR sm.chat_type = 'general')
284
+ ORDER BY sm.created_at ASC LIMIT 100
285
+ """, (sect_id,)).fetchall()
286
+
287
+ messages = []
288
+ for r in rows:
289
+ d = dict(r)
290
+ d["message"] = decrypt_message(d.get("message", ""))
291
+ for k, v in d.items():
292
+ if hasattr(v, "isoformat"):
293
+ d[k] = v.isoformat()
294
+ messages.append(d)
295
+
296
+ return jsonify({"messages": messages})
297
+ except Exception as e:
298
+ logger.error(f"Error getting sect chat: {e}")
299
+ return jsonify({"error": str(e)}), 500
300
+ finally:
301
+ conn.close()
302
+
303
+ @sects_bp.route("/api/sects/chat/send", methods=["POST"])
304
+ @jwt_required
305
+ def send_sect_chat():
306
+ user = get_current_user()
307
+ data = request.json or {}
308
+ message = data.get("message", "").strip()
309
+ chat_type = data.get("chat_type", "general").strip()
310
+ target_id = data.get("target_id")
311
+ group_id = data.get("group_id")
312
+
313
+ if not message:
314
+ return jsonify({"error": "Nội dung tin nhắn trống"}), 400
315
+
316
+ conn = get_user_db_conn()
317
+ try:
318
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
319
+ if not member:
320
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
321
+
322
+ sect_id = member["sect_id"]
323
+
324
+ # Validate target / group access
325
+ if chat_type == "group" and group_id:
326
+ group = conn.execute("SELECT members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?", (group_id, sect_id)).fetchone()
327
+ if not group:
328
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
329
+ members_list = [int(x) for x in group["members_csv"].split(",") if x.strip()]
330
+ if user["id"] not in members_list:
331
+ return jsonify({"error": "Bạn không có quyền chat trong nhóm này"}), 403
332
+
333
+ enc_msg = encrypt_message(message)
334
+ cursor = conn.execute(
335
+ """INSERT INTO sect_messages (sect_id, sender_id, message, chat_type, target_id, group_id)
336
+ VALUES (?, ?, ?, ?, ?, ?)""",
337
+ (sect_id, user["id"], enc_msg, chat_type, target_id, group_id)
338
+ )
339
+ msg_id = cursor.lastrowid
340
+ conn.commit()
341
+ return jsonify({"success": True, "msg_id": msg_id})
342
+ except Exception as e:
343
+ logger.error(f"Error sending sect chat: {e}")
344
+ return jsonify({"error": str(e)}), 500
345
+ finally:
346
+ conn.close()
347
+
348
+ @sects_bp.route("/api/sects/chat/groups/create", methods=["POST"])
349
+ @jwt_required
350
+ def create_sect_chat_group():
351
+ user = get_current_user()
352
+ data = request.json or {}
353
+ name = data.get("name", "").strip()
354
+ member_ids = data.get("members", [])
355
+
356
+ if not name:
357
+ return jsonify({"error": "Tên nhóm chat không được để trống"}), 400
358
+
359
+ conn = get_user_db_conn()
360
+ try:
361
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
362
+ if not member:
363
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
364
+
365
+ sect_id = member["sect_id"]
366
+
367
+ if user["id"] not in member_ids:
368
+ member_ids.append(user["id"])
369
+
370
+ valid_members = []
371
+ for uid in member_ids:
372
+ is_valid = conn.execute("SELECT 1 FROM sect_members WHERE user_id = ? AND sect_id = ?", (uid, sect_id)).fetchone()
373
+ if is_valid:
374
+ valid_members.append(str(uid))
375
+
376
+ members_csv = ",".join(valid_members)
377
+
378
+ cursor = conn.execute(
379
+ "INSERT INTO sect_chat_groups (sect_id, name, creator_id, members_csv) VALUES (?, ?, ?, ?)",
380
+ (sect_id, name, user["id"], members_csv)
381
+ )
382
+ group_id = cursor.lastrowid
383
+ conn.commit()
384
+
385
+ return jsonify({"success": True, "message": f"Tạo nhóm chat '{name}' thành công!", "group_id": group_id})
386
+ except Exception as e:
387
+ logger.error(f"Error creating sect chat group: {e}")
388
+ return jsonify({"error": str(e)}), 500
389
+ finally:
390
+ conn.close()
391
+
392
+ @sects_bp.route("/api/sects/chat/groups/list", methods=["GET"])
393
+ @jwt_required
394
+ def list_sect_chat_groups():
395
+ user = get_current_user()
396
+ conn = get_user_db_conn()
397
+ try:
398
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
399
+ if not member:
400
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
401
+
402
+ sect_id = member["sect_id"]
403
+ groups = conn.execute("SELECT * FROM sect_chat_groups WHERE sect_id = ?", (sect_id,)).fetchall()
404
+
405
+ my_groups = []
406
+ for g in groups:
407
+ members_list = [int(x) for x in g["members_csv"].split(",") if x.strip()]
408
+ if user["id"] in members_list:
409
+ my_groups.append(serialize_row(g))
410
+
411
+ return jsonify({"groups": my_groups})
412
+ except Exception as e:
413
+ logger.error(f"Error listing sect groups: {e}")
414
+ return jsonify({"error": str(e)}), 500
415
+ finally:
416
+ conn.close()
417
+
418
+ @sects_bp.route("/api/sects/contribute", methods=["POST"])
419
+ @jwt_required
420
+ def contribute_sect():
421
+ user = get_current_user()
422
+ data = request.json or {}
423
+ amount = int(data.get("amount", 0))
424
+
425
+ if amount <= 0:
426
+ return jsonify({"error": "Số lượng linh thạch cống hiến phải lớn hơn 0"}), 400
427
+
428
+ conn = get_user_db_conn()
429
+ try:
430
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
431
+ if not member:
432
+ return jsonify({"error": "Bạn không ở trong tông môn nào"}), 400
433
+
434
+ sect_id = member["sect_id"]
435
+
436
+ # Increase user contribution
437
+ conn.execute(
438
+ "UPDATE sect_members SET contribution = contribution + ? WHERE user_id = ? AND sect_id = ?",
439
+ (amount, user["id"], sect_id)
440
+ )
441
+
442
+ # Increase sect total contribution
443
+ conn.execute(
444
+ "UPDATE sects SET contribution = contribution + ? WHERE id = ?",
445
+ (amount, sect_id)
446
+ )
447
+
448
+ # Calculate new level (1000 contribution per level)
449
+ sect = conn.execute("SELECT contribution FROM sects WHERE id = ?", (sect_id,)).fetchone()
450
+ new_level = max(1, int(sect["contribution"] / 1000) + 1)
451
+ conn.execute("UPDATE sects SET level = ? WHERE id = ?", (new_level, sect_id))
452
+
453
+ conn.commit()
454
+ return jsonify({
455
+ "success": True,
456
+ "message": f"Cống hiến thành công {amount} linh thạch!",
457
+ "new_level": new_level
458
+ })
459
+ except Exception as e:
460
+ logger.error(f"Error contributing to sect: {e}")
461
+ return jsonify({"error": str(e)}), 500
462
+ finally:
463
+ conn.close()
464
+
465
+ @sects_bp.route("/api/sects/kick", methods=["POST"])
466
+ @jwt_required
467
+ def kick_member():
468
+ user = get_current_user()
469
+ data = request.json or {}
470
+ target_user_id = data.get("user_id")
471
+
472
+ if not target_user_id:
473
+ return jsonify({"error": "Thiếu user_id thành viên cần trục xuất"}), 400
474
+
475
+ conn = get_user_db_conn()
476
+ try:
477
+ # Check if user is leader/elder
478
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
479
+ if not member or member["role"] not in ['leader', 'elder']:
480
+ return jsonify({"error": "Bạn không đủ quyền hạn (Chỉ Tông chủ hoặc Trưởng lão)"}), 403
481
+
482
+ sect_id = member["sect_id"]
483
+
484
+ # Check target
485
+ target = conn.execute("SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id)).fetchone()
486
+ if not target:
487
+ return jsonify({"error": "Thành viên này không thuộc tông môn của bạn"}), 400
488
+
489
+ # Elder cannot kick leader or another elder
490
+ if member["role"] == 'elder' and target["role"] in ['leader', 'elder']:
491
+ return jsonify({"error": "Trưởng lão không thể trục xuất Tông chủ hoặc Trưởng lão khác"}), 403
492
+
493
+ if target["role"] == 'leader':
494
+ return jsonify({"error": "Không thể trục xuất Tông chủ"}), 400
495
+
496
+ conn.execute("DELETE FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id))
497
+ conn.commit()
498
+ return jsonify({"success": True, "message": "Đã trục xuất thành viên ra khỏi tông môn!"})
499
+ except Exception as e:
500
+ logger.error(f"Error kicking member: {e}")
501
+ return jsonify({"error": str(e)}), 500
502
+ finally:
503
+ conn.close()
504
+
505
+ # ==========================================
506
+ # ADVANCED SECTS & ALLIANCES FEATURES
507
+ # ==========================================
508
+
509
+ @sects_bp.route("/api/sects/announcement", methods=["POST"])
510
+ @jwt_required
511
+ def update_announcement():
512
+ user = get_current_user()
513
+ data = request.json or {}
514
+ announcement = data.get("announcement", "").strip()
515
+
516
+ conn = get_user_db_conn()
517
+ try:
518
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
519
+ if not member or member["role"] not in ['leader', 'elder']:
520
+ return jsonify({"error": "Chỉ Tông chủ hoặc Trưởng lão mới có quyền đổi thông cáo"}), 403
521
+
522
+ conn.execute("UPDATE sects SET announcement = ? WHERE id = ?", (announcement, member["sect_id"]))
523
+ conn.commit()
524
+ return jsonify({"success": True, "message": "Cập nhật thông cáo tông môn thành công!"})
525
+ except Exception as e:
526
+ logger.error(f"Error updating announcement: {e}")
527
+ return jsonify({"error": str(e)}), 500
528
+ finally:
529
+ conn.close()
530
+
531
+ @sects_bp.route("/api/sects/promote", methods=["POST"])
532
+ @jwt_required
533
+ def promote_member():
534
+ user = get_current_user()
535
+ data = request.json or {}
536
+ target_user_id = data.get("user_id")
537
+ new_role = data.get("role", "member").lower() # 'elder' or 'member'
538
+
539
+ if new_role not in ['elder', 'member']:
540
+ return jsonify({"error": "Chức vị không hợp lệ"}), 400
541
+
542
+ conn = get_user_db_conn()
543
+ try:
544
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
545
+ if not member or member["role"] != 'leader':
546
+ return jsonify({"error": "Chỉ có Tông chủ mới được thăng/giáng chức đệ tử"}), 403
547
+
548
+ sect_id = member["sect_id"]
549
+
550
+ target = conn.execute("SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?", (target_user_id, sect_id)).fetchone()
551
+ if not target:
552
+ return jsonify({"error": "Thành viên này không thuộc tông môn của bạn"}), 400
553
+
554
+ if target["role"] == 'leader':
555
+ return jsonify({"error": "Không thể thay đổi chức vị của Tông chủ"}), 400
556
+
557
+ conn.execute("UPDATE sect_members SET role = ? WHERE user_id = ? AND sect_id = ?", (new_role, target_user_id, sect_id))
558
+ conn.commit()
559
+
560
+ role_name = "Trưởng Lão" if new_role == 'elder' else "Môn đồ"
561
+ return jsonify({"success": True, "message": f"Đã thăng/giáng chức thành viên thành {role_name}!"})
562
+ except Exception as e:
563
+ logger.error(f"Error promoting member: {e}")
564
+ return jsonify({"error": str(e)}), 500
565
+ finally:
566
+ conn.close()
567
+
568
+ @sects_bp.route("/api/sects/requests/list", methods=["GET"])
569
+ @jwt_required
570
+ def list_join_requests():
571
+ user = get_current_user()
572
+ conn = get_user_db_conn()
573
+ try:
574
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
575
+ if not member or member["role"] not in ['leader', 'elder']:
576
+ return jsonify({"error": "Chỉ Tông chủ hoặc Trưởng lão mới xem được danh sách yêu cầu"}), 403
577
+
578
+ rows = conn.execute("""
579
+ SELECT r.id, r.user_id, r.status, r.created_at, u.username, u.avatar
580
+ FROM sect_join_requests r
581
+ LEFT JOIN users u ON r.user_id = u.id
582
+ WHERE r.sect_id = ? AND r.status = 'pending'
583
+ ORDER BY r.created_at DESC
584
+ """, (member["sect_id"],)).fetchall()
585
+
586
+ return jsonify({"requests": [serialize_row(r) for r in rows]})
587
+ except Exception as e:
588
+ logger.error(f"Error listing requests: {e}")
589
+ return jsonify({"error": str(e)}), 500
590
+ finally:
591
+ conn.close()
592
+
593
+ @sects_bp.route("/api/sects/requests/respond", methods=["POST"])
594
+ @jwt_required
595
+ def respond_join_request():
596
+ user = get_current_user()
597
+ data = request.json or {}
598
+ req_id = data.get("request_id")
599
+ action = data.get("action", "").lower() # 'approve' or 'reject'
600
+
601
+ if not req_id or action not in ['approve', 'reject']:
602
+ return jsonify({"error": "Dữ liệu phản hồi không hợp lệ"}), 400
603
+
604
+ conn = get_user_db_conn()
605
+ try:
606
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
607
+ if not member or member["role"] not in ['leader', 'elder']:
608
+ return jsonify({"error": "Chỉ Tông chủ hoặc Trưởng lão mới được phê duyệt"}), 403
609
+
610
+ sect_id = member["sect_id"]
611
+
612
+ req = conn.execute("SELECT user_id FROM sect_join_requests WHERE id = ? AND sect_id = ?", (req_id, sect_id)).fetchone()
613
+ if not req:
614
+ return jsonify({"error": "Yêu cầu gia nhập không tồn tại hoặc đã được xử lý"}), 404
615
+
616
+ target_user_id = req["user_id"]
617
+
618
+ if action == 'approve':
619
+ # Check if target is already in a sect
620
+ already_in = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (target_user_id,)).fetchone()
621
+ if already_in:
622
+ conn.execute("DELETE FROM sect_join_requests WHERE id = ?", (req_id,))
623
+ conn.commit()
624
+ return jsonify({"error": "Người dùng này đã gia nhập một tông môn khác, yêu cầu tự động hủy."}), 400
625
+
626
+ # Add member
627
+ conn.execute(
628
+ "INSERT INTO sect_members (sect_id, user_id, role, contribution) VALUES (?, ?, 'member', 0)",
629
+ (sect_id, target_user_id)
630
+ )
631
+ # Delete target requests from all other sects
632
+ conn.execute("DELETE FROM sect_join_requests WHERE user_id = ?", (target_user_id,))
633
+ msg = "Đã phê duyệt gia nhập tông môn!"
634
+ else:
635
+ # Reject
636
+ conn.execute("DELETE FROM sect_join_requests WHERE id = ?", (req_id,))
637
+ msg = "Đã từ chối yêu cầu gia nhập!"
638
+
639
+ conn.commit()
640
+ return jsonify({"success": True, "message": msg})
641
+ except Exception as e:
642
+ logger.error(f"Error responding join request: {e}")
643
+ return jsonify({"error": str(e)}), 500
644
+ finally:
645
+ conn.close()
646
+
647
+ # ==========================================
648
+ # SECT LIBRARY (BOOK SHELF SHARING)
649
+ # ==========================================
650
+
651
+ @sects_bp.route("/api/sects/library/list", methods=["GET"])
652
+ @jwt_required
653
+ def list_sect_library():
654
+ user = get_current_user()
655
+ conn = get_user_db_conn()
656
+ try:
657
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
658
+ if not member:
659
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
660
+
661
+ sect_id = member["sect_id"]
662
+
663
+ # We need to join with the main books database to fetch book title and cover
664
+ # Since SQLite can ATTACH databases, or we can fetch metadata, we'll fetch from sect_books
665
+ # and query titles from books database. Let's do it cleanly:
666
+ rows = conn.execute("""
667
+ SELECT sb.id, sb.book_id, sb.added_at, u.username as added_by_name
668
+ FROM sect_books sb
669
+ LEFT JOIN users u ON sb.added_by = u.id
670
+ WHERE sb.sect_id = ?
671
+ ORDER BY sb.added_at DESC
672
+ """, (sect_id,)).fetchall()
673
+
674
+ from backend.database.db_manager import get_db
675
+ main_conn = get_db()
676
+
677
+ book_list = []
678
+ for r in rows:
679
+ book_row = main_conn.execute("""
680
+ SELECT id, title, title_vietphrase, title_hanviet, author, cover, description
681
+ FROM books WHERE id = ?
682
+ """, (r["book_id"],)).fetchone()
683
+ if book_row:
684
+ b = serialize_row(book_row)
685
+ b["added_by_name"] = r["added_by_name"]
686
+ b["added_at"] = r["added_at"].isoformat() if hasattr(r["added_at"], "isoformat") else r["added_at"]
687
+ book_list.append(b)
688
+
689
+ return jsonify({"books": book_list})
690
+ except Exception as e:
691
+ logger.error(f"Error listing sect library: {e}")
692
+ return jsonify({"error": str(e)}), 500
693
+ finally:
694
+ conn.close()
695
+
696
+ @sects_bp.route("/api/sects/library/add", methods=["POST"])
697
+ @jwt_required
698
+ def add_to_sect_library():
699
+ user = get_current_user()
700
+ data = request.json or {}
701
+ book_id = data.get("book_id")
702
+
703
+ if not book_id:
704
+ return jsonify({"error": "Thiếu book_id truyện đóng góp"}), 400
705
+
706
+ conn = get_user_db_conn()
707
+ try:
708
+ member = conn.execute("SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
709
+ if not member:
710
+ return jsonify({"error": "Bạn không thuộc tông môn nào"}), 400
711
+
712
+ sect_id = member["sect_id"]
713
+
714
+ # Check if already added
715
+ exists = conn.execute("SELECT id FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id)).fetchone()
716
+ if exists:
717
+ return jsonify({"error": "Truyện này đã được đệ tử khác đóng góp vào thư viện tông môn từ trước"}), 400
718
+
719
+ conn.execute(
720
+ "INSERT INTO sect_books (sect_id, book_id, added_by) VALUES (?, ?, ?)",
721
+ (sect_id, book_id, user["id"])
722
+ )
723
+
724
+ # Reward user 20 contribution points for sharing a book
725
+ conn.execute(
726
+ "UPDATE sect_members SET contribution = contribution + 20 WHERE user_id = ? AND sect_id = ?",
727
+ (user["id"], sect_id)
728
+ )
729
+ # Increase sect total contribution
730
+ conn.execute(
731
+ "UPDATE sects SET contribution = contribution + 20 WHERE id = ?",
732
+ (sect_id,)
733
+ )
734
+
735
+ conn.commit()
736
+ return jsonify({"success": True, "message": "Chia sẻ truyện vào Thư viện Tông môn thành công! Nhận 20 điểm cống hiến."})
737
+ except Exception as e:
738
+ logger.error(f"Error adding to sect library: {e}")
739
+ return jsonify({"error": str(e)}), 500
740
+ finally:
741
+ conn.close()
742
+
743
+ @sects_bp.route("/api/sects/library/remove", methods=["POST"])
744
+ @jwt_required
745
+ def remove_from_sect_library():
746
+ user = get_current_user()
747
+ data = request.json or {}
748
+ book_id = data.get("book_id")
749
+
750
+ if not book_id:
751
+ return jsonify({"error": "Thiếu book_id truyện cần gỡ"}), 400
752
+
753
+ conn = get_user_db_conn()
754
+ try:
755
+ member = conn.execute("SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)).fetchone()
756
+ if not member:
757
+ return jsonify({"error": "Bạn không thuộc tông môn nào"}), 400
758
+
759
+ sect_id = member["sect_id"]
760
+
761
+ # Check who added it
762
+ book = conn.execute("SELECT added_by FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id)).fetchone()
763
+ if not book:
764
+ return jsonify({"error": "Truyện không có trong thư viện tông môn"}), 404
765
+
766
+ # Only leader, elder, or the original adder can remove
767
+ if member["role"] not in ['leader', 'elder'] and book["added_by"] != user["id"]:
768
+ return jsonify({"error": "Bạn không có quyền gỡ truyện này (Chỉ Tông chủ, Trưởng lão hoặc người đóng góp)"}), 403
769
+
770
+ conn.execute("DELETE FROM sect_books WHERE sect_id = ? AND book_id = ?", (sect_id, book_id))
771
+ conn.commit()
772
+ return jsonify({"success": True, "message": "Đã gỡ truyện khỏi thư viện Tông môn!"})
773
+ except Exception as e:
774
+ logger.error(f"Error removing from sect library: {e}")
775
+ return jsonify({"error": str(e)}), 500
776
+ finally:
777
+ conn.close()
778
+
779
+
780
+ # ==========================================
781
+ # SECT SEARCH & PROFILE BY ID
782
+ # ==========================================
783
+
784
+ ROLE_ORDER = {
785
+ "leader": 1,
786
+ "vice_leader": 2,
787
+ "elder": 3,
788
+ "inner_disciple": 4,
789
+ "member": 5,
790
+ }
791
+
792
+ ROLE_LABELS = {
793
+ "leader": "Tông chủ",
794
+ "vice_leader": "Phó Tông chủ",
795
+ "elder": "Trưởng lão",
796
+ "inner_disciple": "Nội môn đệ tử",
797
+ "member": "Ngoại môn đệ tử",
798
+ }
799
+
800
+ VALID_ROLES = list(ROLE_ORDER.keys())
801
+
802
+
803
+ @sects_bp.route("/api/sects/search", methods=["GET"])
804
+ def search_sects():
805
+ """Search sects by name or slogan. No auth required."""
806
+ q = request.args.get("q", "").strip()
807
+ page = int(request.args.get("page", 1))
808
+ per_page = int(request.args.get("per_page", 20))
809
+ offset = (page - 1) * per_page
810
+
811
+ conn = get_user_db_conn()
812
+ try:
813
+ if q:
814
+ rows = conn.execute("""
815
+ SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
816
+ u.username as leader_name,
817
+ (SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
818
+ FROM sects s
819
+ LEFT JOIN users u ON s.leader_id = u.id
820
+ WHERE s.name LIKE ? OR s.slogan LIKE ?
821
+ ORDER BY s.contribution DESC, s.created_at DESC
822
+ LIMIT ? OFFSET ?
823
+ """, (f"%{q}%", f"%{q}%", per_page, offset)).fetchall()
824
+
825
+ total = conn.execute(
826
+ "SELECT COUNT(*) as cnt FROM sects WHERE name LIKE ? OR slogan LIKE ?",
827
+ (f"%{q}%", f"%{q}%")
828
+ ).fetchone()["cnt"]
829
+ else:
830
+ rows = conn.execute("""
831
+ SELECT s.id, s.name, s.slogan, s.badge, s.level, s.contribution, s.created_at,
832
+ u.username as leader_name,
833
+ (SELECT COUNT(*) FROM sect_members WHERE sect_id = s.id) as member_count
834
+ FROM sects s
835
+ LEFT JOIN users u ON s.leader_id = u.id
836
+ ORDER BY s.contribution DESC, s.created_at DESC
837
+ LIMIT ? OFFSET ?
838
+ """, (per_page, offset)).fetchall()
839
+ total = conn.execute("SELECT COUNT(*) as cnt FROM sects").fetchone()["cnt"]
840
+
841
+ return jsonify({
842
+ "sects": [serialize_row(r) for r in rows],
843
+ "total": total,
844
+ "page": page,
845
+ "per_page": per_page,
846
+ "total_pages": max(1, (total + per_page - 1) // per_page)
847
+ })
848
+ except Exception as e:
849
+ logger.error(f"Error searching sects: {e}")
850
+ return jsonify({"error": str(e)}), 500
851
+ finally:
852
+ conn.close()
853
+
854
+
855
+ @sects_bp.route("/api/sects/<int:sect_id>", methods=["GET"])
856
+ def get_sect_by_id(sect_id):
857
+ """View full sect profile by ID. No auth required for basic info."""
858
+ conn = get_user_db_conn()
859
+ try:
860
+ sect = conn.execute("""
861
+ SELECT s.id, s.name, s.slogan, s.announcement, s.badge, s.level, s.contribution, s.created_at,
862
+ u.username as leader_name, u.id as leader_id, u.avatar as leader_avatar
863
+ FROM sects s
864
+ LEFT JOIN users u ON s.leader_id = u.id
865
+ WHERE s.id = ?
866
+ """, (sect_id,)).fetchone()
867
+
868
+ if not sect:
869
+ return jsonify({"error": "Tông môn không tồn tại"}), 404
870
+
871
+ members = conn.execute("""
872
+ SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
873
+ u.username, u.avatar,
874
+ COALESCE(sm.role, 'member') as role
875
+ FROM sect_members sm
876
+ LEFT JOIN users u ON sm.user_id = u.id
877
+ WHERE sm.sect_id = ?
878
+ ORDER BY
879
+ CASE sm.role
880
+ WHEN 'leader' THEN 1
881
+ WHEN 'vice_leader' THEN 2
882
+ WHEN 'elder' THEN 3
883
+ WHEN 'inner_disciple' THEN 4
884
+ ELSE 5
885
+ END,
886
+ sm.contribution DESC
887
+ """, (sect_id,)).fetchall()
888
+
889
+ # Enrich members with Vietnamese role labels
890
+ member_list = []
891
+ for m in members:
892
+ md = serialize_row(m)
893
+ md["role_label"] = ROLE_LABELS.get(m["role"], "Môn đồ")
894
+ member_list.append(md)
895
+
896
+ # Count by role
897
+ role_counts = {}
898
+ for m in member_list:
899
+ r = m["role"]
900
+ role_counts[r] = role_counts.get(r, 0) + 1
901
+
902
+ return jsonify({
903
+ "sect": serialize_row(sect),
904
+ "members": member_list,
905
+ "member_count": len(member_list),
906
+ "role_counts": role_counts,
907
+ "role_labels": ROLE_LABELS,
908
+ })
909
+ except Exception as e:
910
+ logger.error(f"Error fetching sect {sect_id}: {e}")
911
+ return jsonify({"error": str(e)}), 500
912
+ finally:
913
+ conn.close()
914
+
915
+
916
+ # ==========================================
917
+ # FULL RANK PROMOTION SYSTEM (5 Ranks)
918
+ # ==========================================
919
+
920
+ @sects_bp.route("/api/sects/promote/rank", methods=["POST"])
921
+ @jwt_required
922
+ def promote_member_rank():
923
+ """
924
+ Full 5-rank promotion system:
925
+ leader > vice_leader > elder > inner_disciple > member
926
+ Only leader can set: vice_leader, elder, inner_disciple, member
927
+ vice_leader can set: elder, inner_disciple, member
928
+ elder can set: inner_disciple, member
929
+ """
930
+ user = get_current_user()
931
+ data = request.json or {}
932
+ target_user_id = data.get("user_id")
933
+ new_role = data.get("role", "").lower()
934
+
935
+ if new_role not in VALID_ROLES or new_role == "leader":
936
+ return jsonify({
937
+ "error": f"Chức vị không hợp lệ. Các chức vị có thể gán: {', '.join(VALID_ROLES[1:])}",
938
+ "valid_roles": VALID_ROLES[1:],
939
+ "role_labels": ROLE_LABELS,
940
+ }), 400
941
+
942
+ if not target_user_id:
943
+ return jsonify({"error": "Thiếu user_id"}), 400
944
+
945
+ conn = get_user_db_conn()
946
+ try:
947
+ caller = conn.execute(
948
+ "SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
949
+ ).fetchone()
950
+ if not caller:
951
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
952
+
953
+ sect_id = caller["sect_id"]
954
+ caller_role = caller["role"]
955
+ caller_order = ROLE_ORDER.get(caller_role, 99)
956
+ new_role_order = ROLE_ORDER.get(new_role, 99)
957
+
958
+ # Permission matrix: caller must outrank new_role
959
+ if caller_order >= new_role_order:
960
+ return jsonify({
961
+ "error": f"Bạn ({ROLE_LABELS.get(caller_role)}) không đủ quyền gán chức vị {ROLE_LABELS.get(new_role)}"
962
+ }), 403
963
+
964
+ target = conn.execute(
965
+ "SELECT role FROM sect_members WHERE user_id = ? AND sect_id = ?",
966
+ (target_user_id, sect_id)
967
+ ).fetchone()
968
+ if not target:
969
+ return jsonify({"error": "Thành viên không thuộc tông môn của bạn"}), 404
970
+
971
+ if target["role"] == "leader":
972
+ return jsonify({"error": "Không thể thay đổi chức vị của Tông chủ"}), 400
973
+
974
+ target_order = ROLE_ORDER.get(target["role"], 99)
975
+ if caller_order >= target_order:
976
+ return jsonify({
977
+ "error": f"Bạn ({ROLE_LABELS.get(caller_role)}) không thể thay đổi chức vị của người ngang hoặc cao hơn bạn"
978
+ }), 403
979
+
980
+ conn.execute(
981
+ "UPDATE sect_members SET role = ? WHERE user_id = ? AND sect_id = ?",
982
+ (new_role, target_user_id, sect_id)
983
+ )
984
+ conn.commit()
985
+
986
+ return jsonify({
987
+ "success": True,
988
+ "message": f"Đã gán chức vị {ROLE_LABELS.get(new_role)} thành công!",
989
+ "new_role": new_role,
990
+ "new_role_label": ROLE_LABELS.get(new_role),
991
+ })
992
+ except Exception as e:
993
+ logger.error(f"Error promoting rank: {e}")
994
+ return jsonify({"error": str(e)}), 500
995
+ finally:
996
+ conn.close()
997
+
998
+
999
+ @sects_bp.route("/api/sects/members", methods=["GET"])
1000
+ @jwt_required
1001
+ def list_sect_members():
1002
+ """List all members of current user's sect with full role info."""
1003
+ user = get_current_user()
1004
+ role_filter = request.args.get("role")
1005
+ conn = get_user_db_conn()
1006
+ try:
1007
+ member = conn.execute(
1008
+ "SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
1009
+ ).fetchone()
1010
+ if not member:
1011
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
1012
+
1013
+ sect_id = member["sect_id"]
1014
+
1015
+ if role_filter and role_filter in VALID_ROLES:
1016
+ rows = conn.execute("""
1017
+ SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
1018
+ u.username, u.avatar
1019
+ FROM sect_members sm
1020
+ LEFT JOIN users u ON sm.user_id = u.id
1021
+ WHERE sm.sect_id = ? AND sm.role = ?
1022
+ ORDER BY sm.contribution DESC
1023
+ """, (sect_id, role_filter)).fetchall()
1024
+ else:
1025
+ rows = conn.execute("""
1026
+ SELECT sm.user_id, sm.role, sm.contribution, sm.joined_at,
1027
+ u.username, u.avatar
1028
+ FROM sect_members sm
1029
+ LEFT JOIN users u ON sm.user_id = u.id
1030
+ WHERE sm.sect_id = ?
1031
+ ORDER BY
1032
+ CASE sm.role
1033
+ WHEN 'leader' THEN 1
1034
+ WHEN 'vice_leader' THEN 2
1035
+ WHEN 'elder' THEN 3
1036
+ WHEN 'inner_disciple' THEN 4
1037
+ ELSE 5
1038
+ END,
1039
+ sm.contribution DESC
1040
+ """, (sect_id,)).fetchall()
1041
+
1042
+ member_list = []
1043
+ for r in rows:
1044
+ rd = serialize_row(r)
1045
+ rd["role_label"] = ROLE_LABELS.get(r["role"], "Môn đồ")
1046
+ member_list.append(rd)
1047
+
1048
+ return jsonify({
1049
+ "members": member_list,
1050
+ "total": len(member_list),
1051
+ "role_labels": ROLE_LABELS,
1052
+ "valid_roles": VALID_ROLES,
1053
+ })
1054
+ except Exception as e:
1055
+ logger.error(f"Error listing members: {e}")
1056
+ return jsonify({"error": str(e)}), 500
1057
+ finally:
1058
+ conn.close()
1059
+
1060
+
1061
+ # ==========================================
1062
+ # SECT CHAT GROUPS – MANAGE MEMBERS
1063
+ # ==========================================
1064
+
1065
+ @sects_bp.route("/api/sects/chat/groups/<int:group_id>/members/add", methods=["POST"])
1066
+ @jwt_required
1067
+ def add_to_chat_group(group_id):
1068
+ """Add member(s) to a sub-group chat."""
1069
+ user = get_current_user()
1070
+ data = request.json or {}
1071
+ new_member_ids = data.get("user_ids", [])
1072
+
1073
+ conn = get_user_db_conn()
1074
+ try:
1075
+ my_member = conn.execute(
1076
+ "SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
1077
+ ).fetchone()
1078
+ if not my_member:
1079
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
1080
+
1081
+ sect_id = my_member["sect_id"]
1082
+ group = conn.execute(
1083
+ "SELECT id, creator_id, members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
1084
+ (group_id, sect_id)
1085
+ ).fetchone()
1086
+
1087
+ if not group:
1088
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
1089
+
1090
+ # Only group creator, leader, vice_leader, elder can add
1091
+ if group["creator_id"] != user["id"] and my_member["role"] not in ["leader", "vice_leader", "elder"]:
1092
+ return jsonify({"error": "Chỉ người tạo nhóm hoặc Ban quản lý mới có thể thêm thành viên"}), 403
1093
+
1094
+ current_members = [int(x) for x in group["members_csv"].split(",") if x.strip()]
1095
+ added = []
1096
+ for uid in new_member_ids:
1097
+ uid = int(uid)
1098
+ if uid not in current_members:
1099
+ # Check uid is in sect
1100
+ in_sect = conn.execute(
1101
+ "SELECT 1 FROM sect_members WHERE user_id = ? AND sect_id = ?", (uid, sect_id)
1102
+ ).fetchone()
1103
+ if in_sect:
1104
+ current_members.append(uid)
1105
+ added.append(uid)
1106
+
1107
+ new_csv = ",".join(str(x) for x in current_members)
1108
+ conn.execute(
1109
+ "UPDATE sect_chat_groups SET members_csv = ? WHERE id = ?",
1110
+ (new_csv, group_id)
1111
+ )
1112
+ conn.commit()
1113
+ return jsonify({"success": True, "added": added, "members": current_members})
1114
+ except Exception as e:
1115
+ logger.error(f"Error adding to chat group: {e}")
1116
+ return jsonify({"error": str(e)}), 500
1117
+ finally:
1118
+ conn.close()
1119
+
1120
+
1121
+ @sects_bp.route("/api/sects/chat/groups/<int:group_id>/members/remove", methods=["POST"])
1122
+ @jwt_required
1123
+ def remove_from_chat_group(group_id):
1124
+ """Remove a member from a sub-group chat."""
1125
+ user = get_current_user()
1126
+ data = request.json or {}
1127
+ target_id = data.get("user_id")
1128
+
1129
+ conn = get_user_db_conn()
1130
+ try:
1131
+ my_member = conn.execute(
1132
+ "SELECT sect_id, role FROM sect_members WHERE user_id = ?", (user["id"],)
1133
+ ).fetchone()
1134
+ if not my_member:
1135
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
1136
+
1137
+ sect_id = my_member["sect_id"]
1138
+ group = conn.execute(
1139
+ "SELECT id, creator_id, members_csv FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
1140
+ (group_id, sect_id)
1141
+ ).fetchone()
1142
+ if not group:
1143
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
1144
+
1145
+ if group["creator_id"] != user["id"] and my_member["role"] not in ["leader", "vice_leader", "elder"]:
1146
+ return jsonify({"error": "Không đủ quyền xóa thành viên khỏi nhóm chat"}), 403
1147
+
1148
+ current_members = [int(x) for x in group["members_csv"].split(",") if x.strip()]
1149
+ if int(target_id) not in current_members:
1150
+ return jsonify({"error": "Thành viên không có trong nhóm chat này"}), 400
1151
+
1152
+ current_members.remove(int(target_id))
1153
+ new_csv = ",".join(str(x) for x in current_members)
1154
+ conn.execute("UPDATE sect_chat_groups SET members_csv = ? WHERE id = ?", (new_csv, group_id))
1155
+ conn.commit()
1156
+ return jsonify({"success": True, "members": current_members})
1157
+ except Exception as e:
1158
+ logger.error(f"Error removing from chat group: {e}")
1159
+ return jsonify({"error": str(e)}), 500
1160
+ finally:
1161
+ conn.close()
1162
+
1163
+
1164
+ @sects_bp.route("/api/sects/chat/groups/<int:group_id>", methods=["GET"])
1165
+ @jwt_required
1166
+ def get_chat_group_info(group_id):
1167
+ """Get info and member list of a sub-group chat."""
1168
+ user = get_current_user()
1169
+ conn = get_user_db_conn()
1170
+ try:
1171
+ my_member = conn.execute(
1172
+ "SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
1173
+ ).fetchone()
1174
+ if not my_member:
1175
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
1176
+
1177
+ sect_id = my_member["sect_id"]
1178
+ group = conn.execute(
1179
+ "SELECT * FROM sect_chat_groups WHERE id = ? AND sect_id = ?",
1180
+ (group_id, sect_id)
1181
+ ).fetchone()
1182
+ if not group:
1183
+ return jsonify({"error": "Nhóm chat không tồn tại"}), 404
1184
+
1185
+ member_ids = [int(x) for x in group["members_csv"].split(",") if x.strip()]
1186
+ if user["id"] not in member_ids:
1187
+ return jsonify({"error": "Bạn không phải thành viên của nhóm chat này"}), 403
1188
+
1189
+ # Fetch member details
1190
+ members = []
1191
+ for uid in member_ids:
1192
+ row = conn.execute("""
1193
+ SELECT sm.user_id, sm.role, sm.contribution, u.username, u.avatar
1194
+ FROM sect_members sm
1195
+ LEFT JOIN users u ON sm.user_id = u.id
1196
+ WHERE sm.user_id = ? AND sm.sect_id = ?
1197
+ """, (uid, sect_id)).fetchone()
1198
+ if row:
1199
+ rd = serialize_row(row)
1200
+ rd["role_label"] = ROLE_LABELS.get(row["role"], "Môn đồ")
1201
+ members.append(rd)
1202
+
1203
+ return jsonify({
1204
+ "group": serialize_row(group),
1205
+ "members": members,
1206
+ "member_count": len(members),
1207
+ })
1208
+ except Exception as e:
1209
+ logger.error(f"Error fetching chat group: {e}")
1210
+ return jsonify({"error": str(e)}), 500
1211
+ finally:
1212
+ conn.close()
1213
+
1214
+
1215
+ @sects_bp.route("/api/sects/chat/groups", methods=["GET"])
1216
+ @jwt_required
1217
+ def list_all_sect_chat_groups():
1218
+ """List all sub-group chats the current user is part of in their sect."""
1219
+ user = get_current_user()
1220
+ conn = get_user_db_conn()
1221
+ try:
1222
+ my_member = conn.execute(
1223
+ "SELECT sect_id FROM sect_members WHERE user_id = ?", (user["id"],)
1224
+ ).fetchone()
1225
+ if not my_member:
1226
+ return jsonify({"error": "Bạn chưa gia nhập tông môn"}), 403
1227
+
1228
+ sect_id = my_member["sect_id"]
1229
+ groups = conn.execute(
1230
+ "SELECT id, name, creator_id, members_csv, created_at FROM sect_chat_groups WHERE sect_id = ?",
1231
+ (sect_id,)
1232
+ ).fetchall()
1233
+
1234
+ result = []
1235
+ for g in groups:
1236
+ member_ids = [int(x) for x in g["members_csv"].split(",") if x.strip()]
1237
+ if user["id"] in member_ids:
1238
+ gd = serialize_row(g)
1239
+ gd["member_count"] = len(member_ids)
1240
+ result.append(gd)
1241
+
1242
+ return jsonify({"groups": result})
1243
+ except Exception as e:
1244
+ logger.error(f"Error listing chat groups: {e}")
1245
+ return jsonify({"error": str(e)}), 500
1246
+ finally:
1247
+ conn.close()
1248
+
backend/api/translate.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import datetime
2
+ import os
3
+ import requests as py_requests
4
+ from flask import Blueprint, request, jsonify, Response, session, current_app
5
+ from backend.config import Config
6
+ from backend.core.decorators import get_current_user
7
+ from backend.core.rate_limit import get_client_ip
8
+ from backend.core.security import verify_access_token
9
+ from backend.services.translation import (
10
+ get_engine, translate_texts, translate_stream_generator,
11
+ translation_limit_tracker, call_ai_chat_proxy
12
+ )
13
+
14
+ translate_bp = Blueprint("translate", __name__)
15
+
16
+
17
+ def is_vip_request():
18
+ """Check if current request has VIP privileges (session, JWT, or header code)."""
19
+ user = get_current_user()
20
+ if user and user.get("vip_status", 0) == 1:
21
+ return True
22
+
23
+ # Check headers
24
+ vip_code = request.headers.get("X-VIP-Code", "") or request.headers.get("X-VIP-Key", "")
25
+ if not vip_code:
26
+ # Check request body
27
+ try:
28
+ data = request.json or {}
29
+ vip_code = data.get("vip_key", "") or data.get("vip_code", "")
30
+ except:
31
+ pass
32
+
33
+ if vip_code and vip_code in Config.VALID_VIP_CODES:
34
+ return True
35
+ return False
36
+
37
+
38
+ def _check_translation_rate_limit(texts):
39
+ """Returns (exceeded: bool, tracker_key, count) for standard users."""
40
+ client_ip = get_client_ip()
41
+ today_str = datetime.date.today().isoformat()
42
+ tracker_key = f"{client_ip}:{today_str}"
43
+ current_count = translation_limit_tracker.get(tracker_key, 0)
44
+ requested_count = len([t for t in texts if t.strip()])
45
+ exceeded = (current_count + requested_count) > 50
46
+ return exceeded, tracker_key, requested_count
47
+
48
+
49
+ @translate_bp.route("/translate", methods=["POST"])
50
+ def translate():
51
+ data = request.json
52
+ if not data or "texts" not in data:
53
+ return jsonify({"error": "Missing 'texts' array"}), 400
54
+
55
+ texts = data["texts"]
56
+
57
+ if not is_vip_request():
58
+ exceeded, tracker_key, count = _check_translation_rate_limit(texts)
59
+ if exceeded:
60
+ return jsonify({
61
+ "error": "Hạn mức dịch máy chủ Standard đã hết (50 đoạn/ngày). Vui lòng nâng cấp tài khoản VIP!"
62
+ }), 403
63
+ translation_limit_tracker[tracker_key] = translation_limit_tracker.get(tracker_key, 0) + count
64
+
65
+ mode = data.get("mode")
66
+
67
+ # If standalone translation server is running, proxy request to it
68
+ local_translate_url = os.environ.get("LOCAL_TRANSLATE_URL", "")
69
+ if not local_translate_url:
70
+ local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
71
+ if local_tts_url:
72
+ local_translate_url = local_tts_url.split("/v1/")[0]
73
+
74
+ if local_translate_url:
75
+ try:
76
+ translate_url = f"{local_translate_url.rstrip('/')}/v1/translate"
77
+ r = py_requests.post(translate_url, json={"texts": texts, "mode": mode}, timeout=(2.0, 60.0))
78
+ if r.status_code == 200:
79
+ return jsonify(r.json())
80
+ current_app.logger.error(f"[Translate Proxy Error]: {r.status_code} - {r.text}")
81
+ except Exception as e:
82
+ current_app.logger.error(f"[Translate Proxy Exception]: {e}")
83
+
84
+ translations = translate_texts(texts, mode)
85
+ return jsonify({"translations": translations})
86
+
87
+
88
+ @translate_bp.route("/translate_stream", methods=["POST"])
89
+ def translate_stream():
90
+ data = request.json
91
+ if not data or "texts" not in data:
92
+ return jsonify({"error": "Missing 'texts' array"}), 400
93
+
94
+ texts = data["texts"]
95
+
96
+ if not is_vip_request():
97
+ exceeded, tracker_key, count = _check_translation_rate_limit(texts)
98
+ if exceeded:
99
+ return jsonify({
100
+ "error": "Hạn mức dịch máy chủ Standard đã hết (50 đoạn/ngày). Vui lòng nâng cấp tài khoản VIP!"
101
+ }), 403
102
+ translation_limit_tracker[tracker_key] = translation_limit_tracker.get(tracker_key, 0) + count
103
+
104
+ mode = data.get("mode")
105
+
106
+ # If standalone translation server is running, proxy stream request to it
107
+ local_translate_url = os.environ.get("LOCAL_TRANSLATE_URL", "")
108
+ if not local_translate_url:
109
+ local_tts_url = os.environ.get("LOCAL_TTS_URL", "")
110
+ if local_tts_url:
111
+ local_translate_url = local_tts_url.split("/v1/")[0]
112
+
113
+ if local_translate_url:
114
+ try:
115
+ translate_url = f"{local_translate_url.rstrip('/')}/v1/translate_stream"
116
+ r = py_requests.post(translate_url, json={"texts": texts, "mode": mode}, stream=True, timeout=(2.0, 60.0))
117
+ if r.status_code == 200:
118
+ def stream_forwarder():
119
+ for chunk in r.iter_content(chunk_size=1024):
120
+ if chunk:
121
+ yield chunk
122
+ response = Response(stream_forwarder(), mimetype="text/event-stream")
123
+ response.headers["X-Accel-Buffering"] = "no"
124
+ response.headers["Cache-Control"] = "no-cache, no-transform"
125
+ response.headers["Connection"] = "keep-alive"
126
+ response.headers["X-Content-Type-Options"] = "nosniff"
127
+ return response
128
+ current_app.logger.error(f"[Translate Stream Proxy Error]: {r.status_code} - {r.text}")
129
+ except Exception as e:
130
+ current_app.logger.error(f"[Translate Stream Proxy Exception]: {e}")
131
+
132
+ response = Response(translate_stream_generator(texts, mode), mimetype="text/event-stream")
133
+ response.headers["X-Accel-Buffering"] = "no"
134
+ response.headers["Cache-Control"] = "no-cache, no-transform"
135
+ response.headers["Connection"] = "keep-alive"
136
+ response.headers["X-Content-Type-Options"] = "nosniff"
137
+ return response
138
+
139
+
140
+ @translate_bp.route("/api/ai/chat", methods=["POST"])
141
+ def ai_chat_proxy():
142
+ if not is_vip_request():
143
+ return jsonify({
144
+ "error": "Chức năng AI chỉ dành cho thành viên VIP. Vui lòng nâng cấp hoặc nhập API Key cá nhân trong cài đặt!"
145
+ }), 403
146
+
147
+ data = request.json or {}
148
+ messages = data.get("messages", [])
149
+ model = data.get("model", "gemini-1.5-flash")
150
+ prompt = data.get("prompt", "")
151
+
152
+ try:
153
+ text = call_ai_chat_proxy(messages, model, prompt)
154
+ return jsonify({"text": text})
155
+ except ValueError as e:
156
+ return jsonify({"error": str(e)}), 501
157
+ except RuntimeError as e:
158
+ return jsonify({"error": str(e)}), 502
159
+ except Exception as e:
160
+ return jsonify({"error": f"Lỗi xử lý AI Proxy: {str(e)}"}), 500
161
+
162
+
163
+ @translate_bp.route("/iframe_proxy", methods=["GET"])
164
+ def iframe_proxy():
165
+ url = request.args.get("url")
166
+ if not url:
167
+ return "Missing 'url' parameter", 400
168
+
169
+ try:
170
+ headers = {
171
+ "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
172
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
173
+ "Accept-Language": "en-US,en;q=0.5"
174
+ }
175
+
176
+ # Follow redirects
177
+ r = py_requests.get(url, headers=headers, timeout=15, allow_redirects=True)
178
+
179
+ encoding = r.encoding
180
+ if not encoding or encoding.lower() == 'iso-8859-1':
181
+ encoding = r.apparent_encoding or 'utf-8'
182
+
183
+ try:
184
+ html = r.content.decode(encoding, errors='ignore')
185
+ except Exception:
186
+ html = r.content.decode('utf-8', errors='ignore')
187
+
188
+ # Insert base URL tag so all links/resources load relative to original page
189
+ base_tag = f'<base href="{url}">'
190
+ if "<head>" in html:
191
+ html = html.replace("<head>", f"<head>{base_tag}", 1)
192
+ elif "<HEAD>" in html:
193
+ html = html.replace("<HEAD>", f"<HEAD>{base_tag}", 1)
194
+ else:
195
+ html = base_tag + html
196
+
197
+ # Injected proxy handler script
198
+ injected_script = """
199
+ <script>
200
+ (function() {
201
+ const origLog = console.log;
202
+ console.log = function(...args) {
203
+ origLog.apply(console, args);
204
+ const msg = args.join(' ');
205
+ if (msg === '[Translation Complete]') {
206
+ window.parent.postMessage({ type: 'TRANSLATION_COMPLETE' }, '*');
207
+ } else if (msg.startsWith('[TRANSLATE_REQ]')) {
208
+ try {
209
+ const payload = JSON.parse(msg.substring(15));
210
+ window.parent.postMessage({ type: 'TRANSLATE_REQ', payload: payload }, '*');
211
+ } catch(e) {
212
+ origLog.error("[Proxy Iframe] Parse translation req error:", e);
213
+ }
214
+ }
215
+ };
216
+
217
+ window.addEventListener('message', function(event) {
218
+ const data = event.data;
219
+ if (!data || !data.action) return;
220
+
221
+ if (data.action === 'INJECT_SCRIPT') {
222
+ try {
223
+ const scriptEl = document.createElement('script');
224
+ scriptEl.textContent = data.script;
225
+ document.body.appendChild(scriptEl);
226
+ } catch (err) {
227
+ origLog.error("[Proxy Iframe] Error injecting script:", err);
228
+ }
229
+ } else if (data.action === 'translate') {
230
+ if (window.toggleAutoTranslate) {
231
+ window.toggleAutoTranslate(data.enabled);
232
+ }
233
+ } else if (data.action === 'audio') {
234
+ let res = { title: document.title, text: document.body.innerText };
235
+ if (window.__TienHiepHelpers) {
236
+ res = window.__TienHiepHelpers.extractCleanChapterText();
237
+ }
238
+ window.parent.postMessage({
239
+ type: 'AUDIO_TEXT_RES',
240
+ title: res.title,
241
+ text: res.text
242
+ }, '*');
243
+ } else if (data.action === 'scroll') {
244
+ if (window.__scrollInterval) {
245
+ clearInterval(window.__scrollInterval);
246
+ window.__scrollInterval = null;
247
+ } else {
248
+ const speed = data.speed || 30;
249
+ window.__scrollInterval = setInterval(() => {
250
+ window.scrollBy({top: 1, behavior: 'instant'});
251
+ }, speed);
252
+ }
253
+ } else if (data.action === 'next') {
254
+ if (window.__TienHiepHelpers) {
255
+ window.__TienHiepHelpers.checkAndTriggerAutoNext(true);
256
+ }
257
+ } else if (data.action === 'teach_next') {
258
+ if (window.__TienHiepHelpers) {
259
+ window.__TienHiepHelpers.startTeachNextMode();
260
+ }
261
+ } else if (data.action === 'dark_mode') {
262
+ if (document.documentElement.style.filter.includes('invert(1)')) {
263
+ document.documentElement.style.filter = '';
264
+ document.documentElement.style.backgroundColor = '';
265
+ } else {
266
+ document.documentElement.style.filter = 'invert(1) hue-rotate(180deg) brightness(0.9) contrast(1.1)';
267
+ document.documentElement.style.backgroundColor = '#121212';
268
+ }
269
+ } else if (data.action === 'clean_ads') {
270
+ const ads = document.querySelectorAll('iframe, .ad, .ads, [id*="ad"], [class*="ad"], .banner, .popup, ins');
271
+ ads.forEach(ad => ad.remove());
272
+ } else if (data.action === 'force_translate') {
273
+ if (window.__autoTranslateObserver) {
274
+ const allNodes = window.__TienHiepHelpers ? window.__TienHiepHelpers.collectTextNodes(document.body) : [];
275
+ if (allNodes.length > 0) window.__translateQueue = allNodes;
276
+ if (window.__processTranslationQueue) window.__processTranslationQueue();
277
+ }
278
+ } else if (data.action === 'SET_TTS_PLAYING') {
279
+ window.isTtsPlaying = data.playing;
280
+ } else if (data.action === 'highlight') {
281
+ const targetText = data.sentenceText ? data.sentenceText.trim() : '';
282
+ if (targetText) {
283
+ function findTextNode(root, text) {
284
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
285
+ let node;
286
+ while (node = walker.nextNode()) {
287
+ const val = node.nodeValue || '';
288
+ if (val.includes(text)) {
289
+ return { node, startIdx: val.indexOf(text) };
290
+ }
291
+ }
292
+ if (text.length > 15) {
293
+ const prefix = text.substring(0, Math.floor(text.length * 0.65));
294
+ const walker2 = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
295
+ while (node = walker2.nextNode()) {
296
+ const val = node.nodeValue || '';
297
+ if (val.includes(prefix)) {
298
+ return { node, startIdx: val.indexOf(prefix) };
299
+ }
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+
305
+ let attempts = 0;
306
+ function tryHighlight() {
307
+ const oldHighlight = document.getElementById('tienhiep-active-highlight');
308
+ const result = findTextNode(document.body, targetText);
309
+
310
+ if (result) {
311
+ if (oldHighlight) {
312
+ const parent = oldHighlight.parentNode;
313
+ if (parent) {
314
+ const textNode = document.createTextNode(oldHighlight.textContent);
315
+ parent.replaceChild(textNode, oldHighlight);
316
+ parent.normalize();
317
+ }
318
+ }
319
+
320
+ const { node, startIdx } = result;
321
+ const parent = node.parentNode;
322
+ if (parent && parent.nodeName !== 'SCRIPT' && parent.nodeName !== 'STYLE') {
323
+ const textVal = node.nodeValue;
324
+ const matchLen = Math.min(targetText.length, textVal.length - startIdx);
325
+
326
+ const beforeText = textVal.substring(0, startIdx);
327
+ const matchedText = textVal.substring(startIdx, startIdx + matchLen);
328
+ const afterText = textVal.substring(startIdx + matchLen);
329
+
330
+ const fragment = document.createDocumentFragment();
331
+ if (beforeText) fragment.appendChild(document.createTextNode(beforeText));
332
+
333
+ const span = document.createElement('span');
334
+ span.id = 'tienhiep-active-highlight';
335
+ span.style.backgroundColor = 'rgba(139, 92, 246, 0.25)';
336
+ span.style.color = '#c084fc';
337
+ span.style.borderBottom = '2px solid #a855f7';
338
+ span.style.padding = '1px 3px';
339
+ span.style.borderRadius = '3px';
340
+ span.style.transition = 'all 0.3s ease';
341
+ span.textContent = matchedText;
342
+ fragment.appendChild(span);
343
+
344
+ if (afterText) fragment.appendChild(document.createTextNode(afterText));
345
+
346
+ parent.replaceChild(fragment, node);
347
+ span.scrollIntoView({ behavior: 'smooth', block: 'center' });
348
+ }
349
+ } else if (attempts < 6) {
350
+ attempts++;
351
+ setTimeout(tryHighlight, 600);
352
+ }
353
+ }
354
+
355
+ tryHighlight();
356
+ }
357
+ }
358
+ });
359
+
360
+ document.addEventListener('click', function(e) {
361
+ const a = e.target.closest('a');
362
+ if (a && a.href) {
363
+ if (a.href.startsWith('http://') || a.href.startsWith('https://')) {
364
+ e.preventDefault();
365
+ window.parent.postMessage({
366
+ type: 'NAVIGATE_REQ',
367
+ url: a.href
368
+ }, '*');
369
+ }
370
+ }
371
+ }, true);
372
+
373
+ window.addEventListener('DOMContentLoaded', function() {
374
+ window.parent.postMessage({ type: 'IFRAME_READY', url: window.location.href }, '*');
375
+ });
376
+ if (document.readyState === 'interactive' || document.readyState === 'complete') {
377
+ window.parent.postMessage({ type: 'IFRAME_READY', url: window.location.href }, '*');
378
+ }
379
+ })();
380
+ </script>
381
+ """
382
+
383
+ if "</body>" in html:
384
+ html = html.replace("</body>", f"{injected_script}</body>", 1)
385
+ elif "</BODY>" in html:
386
+ html = html.replace("</BODY>", f"{injected_script}</BODY>", 1)
387
+ else:
388
+ html = html + injected_script
389
+
390
+ response = Response(html, mimetype="text/html")
391
+ response.headers.pop("X-Frame-Options", None)
392
+ response.headers.pop("Content-Security-Policy", None)
393
+ response.headers["Access-Control-Allow-Origin"] = "*"
394
+ return response
395
+ except Exception as e:
396
+ return f"Proxy error: {str(e)}", 500
397
+
backend/api/user_features.py ADDED
@@ -0,0 +1,938 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, request, jsonify
2
+ from backend.core.decorators import jwt_required, get_current_user
3
+ from backend.database.db_manager import get_user_db_conn
4
+ from backend.core.logger import logger
5
+ from backend.core.security import encrypt_message, decrypt_message
6
+ from datetime import datetime
7
+ import os
8
+
9
+ user_features_bp = Blueprint("user_features", __name__)
10
+
11
+ def serialize_row(row):
12
+ if not row:
13
+ return {}
14
+ d = dict(row)
15
+ for k, v in d.items():
16
+ if hasattr(v, "isoformat"):
17
+ d[k] = v.isoformat()
18
+ return d
19
+
20
+ import time
21
+ _history_cache = {}
22
+ _stats_cache = {}
23
+
24
+
25
+ # ==========================================
26
+ # 1. Translation History Endpoints
27
+ # ==========================================
28
+
29
+ @user_features_bp.route("/api/user/history", methods=["GET"])
30
+ @jwt_required
31
+ def get_translation_history():
32
+ user = get_current_user()
33
+ user_id = user["id"]
34
+
35
+ now = time.time()
36
+ if user_id in _history_cache:
37
+ cached_data, cached_time = _history_cache[user_id]
38
+ if now - cached_time < 60:
39
+ return jsonify(cached_data)
40
+
41
+ conn = get_user_db_conn()
42
+ try:
43
+ rows = conn.execute(
44
+ "SELECT * FROM translation_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100",
45
+ (user_id,)
46
+ ).fetchall()
47
+ response_data = {"history": [serialize_row(r) for r in rows], "success": True}
48
+ _history_cache[user_id] = (response_data, now)
49
+ return jsonify(response_data)
50
+ except Exception as e:
51
+ logger.error(f"[User History] Failed to fetch history: {e}")
52
+ return jsonify({"error": "Failed to fetch translation history", "success": False}), 500
53
+ finally:
54
+ conn.close()
55
+
56
+ @user_features_bp.route("/api/user/history", methods=["POST"])
57
+ @jwt_required
58
+ def add_translation_history():
59
+ user = get_current_user()
60
+ data = request.json or {}
61
+ original = data.get("original_text", "").strip()
62
+ translated = data.get("translated_text", "").strip()
63
+ mode = data.get("mode", "fast").strip()
64
+ chars = int(data.get("characters", len(original)))
65
+
66
+ if not original or not translated:
67
+ return jsonify({"error": "Original text and translation content cannot be empty", "success": False}), 400
68
+
69
+ conn = get_user_db_conn()
70
+ try:
71
+ conn.execute(
72
+ "INSERT INTO translation_history (user_id, original_text, translated_text, mode, characters) VALUES (?, ?, ?, ?, ?)",
73
+ (user["id"], original, translated, mode, chars)
74
+ )
75
+ conn.commit()
76
+ if user["id"] in _history_cache: del _history_cache[user["id"]]
77
+ if user["id"] in _stats_cache: del _stats_cache[user["id"]]
78
+ return jsonify({"message": "Translation history entry added", "success": True})
79
+ except Exception as e:
80
+ logger.error(f"[User History] Failed to add entry: {e}")
81
+ return jsonify({"error": "Failed to add translation history entry", "success": False}), 500
82
+ finally:
83
+ conn.close()
84
+
85
+ @user_features_bp.route("/api/user/history", methods=["DELETE"])
86
+ @jwt_required
87
+ def clear_translation_history():
88
+ user = get_current_user()
89
+ conn = get_user_db_conn()
90
+ try:
91
+ conn.execute("DELETE FROM translation_history WHERE user_id = ?", (user["id"],))
92
+ conn.commit()
93
+ if user["id"] in _history_cache: del _history_cache[user["id"]]
94
+ if user["id"] in _stats_cache: del _stats_cache[user["id"]]
95
+ return jsonify({"message": "Translation history cleared", "success": True})
96
+ except Exception as e:
97
+ logger.error(f"[User History] Failed to clear history: {e}")
98
+ return jsonify({"error": "Failed to clear history", "success": False}), 500
99
+ finally:
100
+ conn.close()
101
+
102
+ # ==========================================
103
+ # 2. Vocabulary/Word Notebook Endpoints
104
+ # ==========================================
105
+
106
+ @user_features_bp.route("/api/user/vocabulary", methods=["GET"])
107
+ @jwt_required
108
+ def get_vocabulary():
109
+ user = get_current_user()
110
+ conn = get_user_db_conn()
111
+ try:
112
+ rows = conn.execute(
113
+ "SELECT * FROM vocabulary WHERE user_id = ? ORDER BY created_at DESC",
114
+ (user["id"],)
115
+ ).fetchall()
116
+ return jsonify({"vocabulary": [serialize_row(r) for r in rows], "success": True})
117
+ except Exception as e:
118
+ logger.error(f"[Vocabulary] Failed to fetch vocabulary: {e}")
119
+ return jsonify({"error": "Failed to fetch vocabulary list", "success": False}), 500
120
+ finally:
121
+ conn.close()
122
+
123
+ @user_features_bp.route("/api/user/vocabulary", methods=["POST"])
124
+ @jwt_required
125
+ def add_vocabulary():
126
+ user = get_current_user()
127
+ data = request.json or {}
128
+ original = data.get("original_text", "").strip()
129
+ translation = data.get("translation", "").strip()
130
+ pinyin_or_hanviet = data.get("pinyin_or_hanviet", "").strip()
131
+ context = data.get("context_sentence", "").strip()
132
+ notes = data.get("notes", "").strip()
133
+
134
+ if not original or not translation:
135
+ return jsonify({"error": "Original text and translation content cannot be empty", "success": False}), 400
136
+
137
+ conn = get_user_db_conn()
138
+ try:
139
+ conn.execute(
140
+ "INSERT INTO vocabulary (user_id, original_text, pinyin_or_hanviet, translation, context_sentence, notes) VALUES (?, ?, ?, ?, ?, ?)",
141
+ (user["id"], original, pinyin_or_hanviet, translation, context, notes)
142
+ )
143
+ conn.commit()
144
+ return jsonify({"message": "Word saved to vocabulary notebook", "success": True})
145
+ except Exception as e:
146
+ logger.error(f"[Vocabulary] Failed to save word: {e}")
147
+ return jsonify({"error": "Failed to save word notebook entry", "success": False}), 500
148
+ finally:
149
+ conn.close()
150
+
151
+ @user_features_bp.route("/api/user/vocabulary/<int:item_id>", methods=["DELETE"])
152
+ @jwt_required
153
+ def delete_vocabulary(item_id):
154
+ user = get_current_user()
155
+ conn = get_user_db_conn()
156
+ try:
157
+ # Verify ownership
158
+ row = conn.execute("SELECT user_id FROM vocabulary WHERE id = ?", (item_id,)).fetchone()
159
+ if not row:
160
+ return jsonify({"error": "Entry not found", "success": False}), 404
161
+ if int(row["user_id"]) != int(user["id"]):
162
+ return jsonify({"error": "Unauthorized action", "success": False}), 403
163
+
164
+ conn.execute("DELETE FROM vocabulary WHERE id = ?", (item_id,))
165
+ conn.commit()
166
+ return jsonify({"message": "Word deleted from notebook", "success": True})
167
+ except Exception as e:
168
+ logger.error(f"[Vocabulary] Failed to delete item {item_id}: {e}")
169
+ return jsonify({"error": "Failed to delete item", "success": False}), 500
170
+ finally:
171
+ conn.close()
172
+
173
+ # ==========================================
174
+ # 3. Personalization Settings Endpoints
175
+ # ==========================================
176
+
177
+ @user_features_bp.route("/api/user/settings", methods=["GET"])
178
+ @jwt_required
179
+ def get_user_settings():
180
+ user = get_current_user()
181
+ conn = get_user_db_conn()
182
+ try:
183
+ row = conn.execute("SELECT * FROM user_settings WHERE user_id = ?", (user["id"],)).fetchone()
184
+ if not row:
185
+ # Insert defaults
186
+ conn.execute(
187
+ "INSERT INTO user_settings (user_id, theme, default_language, auto_read, font_size) VALUES (?, 'dark', 'vi', 0, 14)",
188
+ (user["id"],)
189
+ )
190
+ conn.commit()
191
+ row = conn.execute("SELECT * FROM user_settings WHERE user_id = ?", (user["id"],)).fetchone()
192
+
193
+ return jsonify({"settings": serialize_row(row), "success": True})
194
+ except Exception as e:
195
+ logger.error(f"[UserSettings] Failed to fetch settings: {e}")
196
+ return jsonify({"error": "Failed to fetch settings", "success": False}), 500
197
+ finally:
198
+ conn.close()
199
+
200
+ @user_features_bp.route("/api/user/settings", methods=["POST"])
201
+ @jwt_required
202
+ def update_user_settings():
203
+ user = get_current_user()
204
+ data = request.json or {}
205
+ theme = data.get("theme", "dark").strip()
206
+ lang = data.get("default_language", "vi").strip()
207
+ auto_read = int(data.get("auto_read", 0))
208
+ font_size = int(data.get("font_size", 14))
209
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
210
+
211
+ conn = get_user_db_conn()
212
+ try:
213
+ conn.execute(
214
+ """INSERT INTO user_settings (user_id, theme, default_language, auto_read, font_size, updated_at)
215
+ VALUES (?, ?, ?, ?, ?, ?)
216
+ ON CONFLICT(user_id) DO UPDATE SET
217
+ theme = excluded.theme,
218
+ default_language = excluded.default_language,
219
+ auto_read = excluded.auto_read,
220
+ font_size = excluded.font_size,
221
+ updated_at = excluded.updated_at""",
222
+ (user["id"], theme, lang, auto_read, font_size, now)
223
+ )
224
+ conn.commit()
225
+ return jsonify({"message": "Settings updated successfully", "success": True})
226
+ except Exception as e:
227
+ logger.error(f"[UserSettings] Failed to update settings: {e}")
228
+ return jsonify({"error": "Failed to update settings", "success": False}), 500
229
+ finally:
230
+ conn.close()
231
+
232
+
233
+ # ==========================================
234
+ # 4. Usage Tracking & System/API Statistics
235
+ # ==========================================
236
+
237
+ @user_features_bp.route("/api/user/track", methods=["POST"])
238
+ @jwt_required
239
+ def track_user_usage():
240
+ user = get_current_user()
241
+ data = request.json or {}
242
+ source = data.get("source", "web").strip().lower()
243
+ action = data.get("action", "read").strip().lower()
244
+ duration = int(data.get("duration", 0))
245
+ mode = data.get("mode", "online").strip().lower()
246
+
247
+ if not source or not action:
248
+ return jsonify({"error": "Missing source or action", "success": False}), 400
249
+
250
+ conn = get_user_db_conn()
251
+ try:
252
+ conn.execute(
253
+ "INSERT INTO usage_tracking (user_id, source, action, duration, mode) VALUES (?, ?, ?, ?, ?)",
254
+ (user["id"], source, action, duration, mode)
255
+ )
256
+ conn.commit()
257
+ if user["id"] in _stats_cache: del _stats_cache[user["id"]]
258
+ return jsonify({"message": "Usage tracked successfully", "success": True})
259
+ except Exception as e:
260
+ logger.error(f"[Usage Tracking] Failed to log usage: {e}")
261
+ return jsonify({"error": "Failed to track usage", "success": False}), 500
262
+ finally:
263
+ conn.close()
264
+
265
+
266
+ @user_features_bp.route("/api/user/stats", methods=["GET"])
267
+ @jwt_required
268
+ def get_user_stats():
269
+ user = get_current_user()
270
+ user_id = user["id"]
271
+
272
+ now = time.time()
273
+ if user_id in _stats_cache:
274
+ cached_data, cached_time = _stats_cache[user_id]
275
+ if now - cached_time < 60:
276
+ return jsonify(cached_data)
277
+
278
+ conn = get_user_db_conn()
279
+ try:
280
+ # 1. Total active/reading duration by source
281
+ web_duration_row = conn.execute(
282
+ "SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND source = 'web'",
283
+ (user["id"],)
284
+ ).fetchone()
285
+ web_duration = web_duration_row["total"] or 0 if web_duration_row else 0
286
+
287
+ ext_duration_row = conn.execute(
288
+ "SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND source = 'extension'",
289
+ (user["id"],)
290
+ ).fetchone()
291
+ ext_duration = ext_duration_row["total"] or 0 if ext_duration_row else 0
292
+
293
+ # 2. Total active/reading duration by online/offline mode
294
+ online_duration_row = conn.execute(
295
+ "SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND mode = 'online'",
296
+ (user["id"],)
297
+ ).fetchone()
298
+ online_duration = online_duration_row["total"] or 0 if online_duration_row else 0
299
+
300
+ offline_duration_row = conn.execute(
301
+ "SELECT SUM(duration) as total FROM usage_tracking WHERE user_id = ? AND mode = 'offline'",
302
+ (user["id"],)
303
+ ).fetchone()
304
+ offline_duration = offline_duration_row["total"] or 0 if offline_duration_row else 0
305
+
306
+ # 3. Translation calls count (from translation_history)
307
+ trans_calls_row = conn.execute(
308
+ "SELECT COUNT(*) as cnt, SUM(characters) as total_chars FROM translation_history WHERE user_id = ?",
309
+ (user["id"],)
310
+ ).fetchone()
311
+ trans_calls = trans_calls_row["cnt"] or 0 if trans_calls_row else 0
312
+ total_chars = trans_calls_row["total_chars"] or 0 if trans_calls_row else 0
313
+
314
+ # 4. API keys & API usage stats (from api_keys and api_usage)
315
+ api_keys_count_row = conn.execute(
316
+ "SELECT COUNT(*) as cnt FROM api_keys WHERE user_id = ?",
317
+ (user["id"],)
318
+ ).fetchone()
319
+ api_keys_count = api_keys_count_row["cnt"] or 0 if api_keys_count_row else 0
320
+
321
+ api_usage_row = conn.execute(
322
+ """SELECT COUNT(u.id) as cnt, SUM(u.tokens) as tokens
323
+ FROM api_usage u
324
+ JOIN api_keys k ON u.api_key = k.api_key
325
+ WHERE k.user_id = ?""",
326
+ (user["id"],)
327
+ ).fetchone()
328
+ api_usage_calls = api_usage_row["cnt"] or 0 if api_usage_row else 0
329
+ api_usage_chars = api_usage_row["tokens"] or 0 if api_usage_row else 0
330
+
331
+ # 5. Recent actions log
332
+ recent_actions = conn.execute(
333
+ "SELECT source, action, duration, mode, timestamp FROM usage_tracking WHERE user_id = ? ORDER BY timestamp DESC, id DESC LIMIT 20",
334
+ (user_id,)
335
+ ).fetchall()
336
+
337
+ response_data = {
338
+ "success": True,
339
+ "stats": {
340
+ "web_duration": web_duration,
341
+ "ext_duration": ext_duration,
342
+ "online_duration": online_duration,
343
+ "offline_duration": offline_duration,
344
+ "total_reading_time": web_duration + ext_duration,
345
+ "translation_calls": trans_calls,
346
+ "translation_chars": total_chars,
347
+ "api_keys_count": api_keys_count,
348
+ "api_usage_calls": api_usage_calls,
349
+ "api_usage_chars": api_usage_chars
350
+ },
351
+ "recent_actions": [serialize_row(r) for r in recent_actions]
352
+ }
353
+
354
+ _stats_cache[user_id] = (response_data, now)
355
+ return jsonify(response_data)
356
+ except Exception as e:
357
+ logger.error(f"[User Stats] Failed to fetch stats: {e}")
358
+ return jsonify({"error": "Failed to fetch stats", "success": False}), 500
359
+ finally:
360
+ conn.close()
361
+
362
+
363
+ # ==========================================
364
+ # 5. Feedback Submissions
365
+ # ==========================================
366
+
367
+ @user_features_bp.route("/api/feedback/submit", methods=["POST"])
368
+ def submit_feedback():
369
+ data = request.json or {}
370
+ email = data.get("email", "").strip()
371
+ message = data.get("message", "").strip()
372
+
373
+ if not email or not message:
374
+ return jsonify({"error": "Email và nội dung phản hồi không được để trống", "success": False}), 400
375
+
376
+ # 1. Log to logs/feedback.log
377
+ os.makedirs("logs", exist_ok=True)
378
+ log_path = "logs/feedback.log"
379
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
380
+ log_entry = f"[{timestamp}] EMAIL: {email} | MESSAGE: {message}\n"
381
+ try:
382
+ with open(log_path, "a", encoding="utf-8") as f:
383
+ f.write(log_entry)
384
+ except Exception as e:
385
+ logger.error(f"Failed to write feedback to log file: {e}")
386
+
387
+ # 2. Save to SQLite database
388
+ conn = get_user_db_conn()
389
+ try:
390
+ conn.execute(
391
+ """CREATE TABLE IF NOT EXISTS user_feedbacks (
392
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
393
+ email TEXT,
394
+ message TEXT,
395
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
396
+ )"""
397
+ )
398
+ conn.execute(
399
+ "INSERT INTO user_feedbacks (email, message) VALUES (?, ?)",
400
+ (email, message)
401
+ )
402
+ conn.commit()
403
+ return jsonify({"message": "Gửi phản hồi thành công!", "success": True})
404
+ except Exception as e:
405
+ logger.error(f"[Feedback] Failed to save feedback: {e}")
406
+ return jsonify({"error": "Gửi phản hồi thất bại", "success": False}), 500
407
+ finally:
408
+ conn.close()
409
+
410
+
411
+ @user_features_bp.route("/api/feedback/list", methods=["GET"])
412
+ def list_feedbacks():
413
+ conn = get_user_db_conn()
414
+ try:
415
+ conn.execute(
416
+ """CREATE TABLE IF NOT EXISTS user_feedbacks (
417
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
418
+ email TEXT,
419
+ message TEXT,
420
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
421
+ )"""
422
+ )
423
+ rows = conn.execute("SELECT * FROM user_feedbacks ORDER BY id DESC").fetchall()
424
+ feedbacks = [serialize_row(r) for r in rows]
425
+ return jsonify({"feedbacks": feedbacks, "success": True})
426
+ except Exception as e:
427
+ logger.error(f"[Feedback] Failed to list feedback: {e}")
428
+ return jsonify({"error": "Không thể lấy danh sách phản hồi", "success": False}), 500
429
+ finally:
430
+ conn.close()
431
+
432
+
433
+ # ==========================================
434
+ # 6. Social, Friends, and Private Messaging APIs
435
+ # ==========================================
436
+
437
+ @user_features_bp.route("/api/users/search", methods=["GET"])
438
+ def search_users():
439
+ q = request.args.get("q", "").strip()
440
+ if not q:
441
+ return jsonify({"users": []})
442
+ user = get_current_user()
443
+ user_id = user["id"] if user else None
444
+ conn = get_user_db_conn()
445
+ try:
446
+ like_q = "%" + q + "%"
447
+ if user_id:
448
+ rows = conn.execute(
449
+ """SELECT id, username, user_code, display_name, avatar FROM users
450
+ WHERE id != ? AND (
451
+ username LIKE ? OR
452
+ email LIKE ? OR
453
+ user_code = ?
454
+ ) LIMIT 15""",
455
+ (user_id, like_q, like_q, q)
456
+ ).fetchall()
457
+ else:
458
+ rows = conn.execute(
459
+ """SELECT id, username, user_code, display_name, avatar FROM users
460
+ WHERE username LIKE ? OR email LIKE ? OR user_code = ? LIMIT 15""",
461
+ (like_q, like_q, q)
462
+ ).fetchall()
463
+ return jsonify({"users": [serialize_row(r) for r in rows]})
464
+ except Exception as e:
465
+ return jsonify({"error": str(e)}), 500
466
+ finally:
467
+ conn.close()
468
+
469
+
470
+ @user_features_bp.route("/api/notifications/unread-counts", methods=["GET"])
471
+ def get_unread_counts():
472
+ """Separate unread counts: messages vs friend/other notifications."""
473
+ user = get_current_user()
474
+ if not user:
475
+ return jsonify({"messages": 0, "notifications": 0})
476
+ conn = get_user_db_conn()
477
+ try:
478
+ # Unread personal messages from direct_messages table
479
+ msg_count = conn.execute(
480
+ "SELECT COUNT(*) as c FROM direct_messages WHERE receiver_id = ? AND is_read = 0",
481
+ (user["id"],)
482
+ ).fetchone()["c"]
483
+ # Unread other notifications (friend_request, friend_accept, book_share etc)
484
+ notif_count = conn.execute(
485
+ "SELECT COUNT(*) as c FROM personal_notifications WHERE user_id = ? AND is_read = 0 AND type != 'message'",
486
+ (user["id"],)
487
+ ).fetchone()["c"]
488
+ return jsonify({"messages": msg_count, "notifications": notif_count})
489
+ except Exception as e:
490
+ return jsonify({"messages": 0, "notifications": 0})
491
+ finally:
492
+ conn.close()
493
+
494
+
495
+ @user_features_bp.route("/api/friends/request", methods=["POST"])
496
+ def send_friend_request():
497
+ user = get_current_user()
498
+ if not user:
499
+ return jsonify({"error": "Unauthorized"}), 401
500
+
501
+ data = request.json or {}
502
+ # Accept username, user_code, or email
503
+ query = data.get("friend_username") or data.get("friend_code") or data.get("friend_email") or ""
504
+ query = query.strip()
505
+ if not query:
506
+ return jsonify({"error": "Thiếu thông tin tìm kiếm bạn bè"}), 400
507
+
508
+ conn = get_user_db_conn()
509
+ try:
510
+ target = conn.execute(
511
+ "SELECT id, username FROM users WHERE username = ? OR user_code = ? OR email = ?",
512
+ (query, query, query.lower())
513
+ ).fetchone()
514
+ if not target:
515
+ return jsonify({"error": "Không tìm thấy người dùng"}), 404
516
+
517
+ target_id = target["id"]
518
+ if target_id == user["id"]:
519
+ return jsonify({"error": "Không thể kết bạn với chính mình"}), 400
520
+
521
+ exists = conn.execute("SELECT status FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
522
+ (user["id"], target_id, target_id, user["id"])).fetchone()
523
+ if exists:
524
+ return jsonify({"error": f"Đã có lời mời kết bạn hoặc đã là bạn bè (Trạng thái: {exists['status']})"}), 400
525
+
526
+ conn.execute("INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'pending')", (user["id"], target_id))
527
+
528
+ conn.execute(
529
+ "INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'friend_request', ?, ?)",
530
+ (target_id, user["id"], f"{user['username']} đã gửi cho bạn một lời mời kết bạn.", user["id"])
531
+ )
532
+ conn.commit()
533
+ return jsonify({"success": True, "message": "Đã gửi lời mời kết bạn!", "to_user": target["username"]})
534
+ except Exception as e:
535
+ return jsonify({"error": str(e)}), 500
536
+ finally:
537
+ conn.close()
538
+
539
+
540
+ @user_features_bp.route("/api/friends/respond", methods=["POST"])
541
+ def respond_friend_request():
542
+ user = get_current_user()
543
+ if not user:
544
+ return jsonify({"error": "Unauthorized"}), 401
545
+
546
+ data = request.json or {}
547
+ sender_id = data.get("sender_id")
548
+ action = data.get("action", "").lower()
549
+
550
+ if not sender_id or action not in ['accept', 'reject']:
551
+ return jsonify({"error": "Dữ liệu không hợp lệ"}), 400
552
+
553
+ conn = get_user_db_conn()
554
+ try:
555
+ req = conn.execute("SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'pending'", (sender_id, user["id"])).fetchone()
556
+ if not req:
557
+ return jsonify({"error": "Không tìm thấy lời mời kết bạn"}), 404
558
+
559
+ if action == 'accept':
560
+ conn.execute("UPDATE friendships SET status = 'accepted' WHERE user_id = ? AND friend_id = ?", (sender_id, user["id"]))
561
+ # Cross-compatibility check instead of INSERT OR IGNORE
562
+ exists = conn.execute("SELECT 1 FROM friendships WHERE user_id = ? AND friend_id = ?", (user["id"], sender_id)).fetchone()
563
+ if not exists:
564
+ conn.execute("INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'accepted')", (user["id"], sender_id))
565
+ conn.execute(
566
+ "INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'friend_accept', ?, ?)",
567
+ (sender_id, user["id"], f"{user['username']} đã chấp nhận lời mời kết bạn.", user["id"])
568
+ )
569
+ else:
570
+ conn.execute("DELETE FROM friendships WHERE user_id = ? AND friend_id = ?", (sender_id, user["id"]))
571
+
572
+ conn.execute(
573
+ "UPDATE personal_notifications SET is_read = 1 WHERE user_id = ? AND sender_id = ? AND type = 'friend_request'",
574
+ (user["id"], sender_id)
575
+ )
576
+ conn.commit()
577
+ return jsonify({"success": True, "message": f"Đã {'chấp nhận' if action == 'accept' else 'từ chối'} kết bạn."})
578
+ except Exception as e:
579
+ return jsonify({"error": str(e)}), 500
580
+ finally:
581
+ conn.close()
582
+
583
+
584
+ @user_features_bp.route("/api/friends/list", methods=["GET"])
585
+ def list_friends():
586
+ user = get_current_user()
587
+ if not user:
588
+ return jsonify({"error": "Unauthorized"}), 401
589
+
590
+ conn = get_user_db_conn()
591
+ try:
592
+ rows = conn.execute(
593
+ """SELECT u.id, u.username, u.user_code, u.avatar,
594
+ (SELECT COUNT(*) FROM direct_messages dm WHERE dm.sender_id = u.id AND dm.receiver_id = ? AND dm.is_read = 0) as unread_messages
595
+ FROM friendships f
596
+ JOIN users u ON f.friend_id = u.id
597
+ WHERE f.user_id = ? AND f.status = 'accepted'""",
598
+ (user["id"], user["id"])
599
+ ).fetchall()
600
+ return jsonify({"friends": [serialize_row(r) for r in rows]})
601
+ except Exception as e:
602
+ return jsonify({"error": str(e)}), 500
603
+ finally:
604
+ conn.close()
605
+
606
+
607
+ @user_features_bp.route("/api/friends/unfriend", methods=["POST"])
608
+ @jwt_required
609
+ def unfriend():
610
+ """Hủy kết bạn: Xóa quan hệ bạn bè cả hai chiều."""
611
+ user = get_current_user()
612
+ if not user:
613
+ return jsonify({"error": "Unauthorized"}), 401
614
+
615
+ data = request.json or {}
616
+ friend_id = data.get("friend_id")
617
+ if not friend_id:
618
+ return jsonify({"error": "Thiếu friend_id"}), 400
619
+
620
+ conn = get_user_db_conn()
621
+ try:
622
+ # Kiểm tra quan hệ tồn tại và đang ở trạng thái accepted
623
+ rel = conn.execute(
624
+ """SELECT id FROM friendships
625
+ WHERE ((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?))
626
+ AND status = 'accepted'""",
627
+ (user["id"], friend_id, friend_id, user["id"])
628
+ ).fetchone()
629
+ if not rel:
630
+ return jsonify({"error": "Không tìm thấy quan hệ bạn bè"}), 404
631
+
632
+ # Xóa cả hai chiều
633
+ conn.execute(
634
+ "DELETE FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
635
+ (user["id"], friend_id, friend_id, user["id"])
636
+ )
637
+ conn.commit()
638
+ return jsonify({"success": True, "message": "Đã hủy kết bạn."})
639
+ except Exception as e:
640
+ logger.error(f"Unfriend error: {e}")
641
+ return jsonify({"error": str(e)}), 500
642
+ finally:
643
+ conn.close()
644
+
645
+
646
+ @user_features_bp.route("/api/friends/block", methods=["POST"])
647
+ @jwt_required
648
+ def block_user():
649
+ """Chặn người dùng: Xóa quan hệ bạn bè (nếu có) và ghi nhận trạng thái blocked."""
650
+ user = get_current_user()
651
+ if not user:
652
+ return jsonify({"error": "Unauthorized"}), 401
653
+
654
+ data = request.json or {}
655
+ target_id = data.get("user_id")
656
+ if not target_id:
657
+ return jsonify({"error": "Thiếu user_id cần chặn"}), 400
658
+ if target_id == user["id"]:
659
+ return jsonify({"error": "Không thể tự chặn chính mình"}), 400
660
+
661
+ conn = get_user_db_conn()
662
+ try:
663
+ # Kiểm tra đã chặn chưa
664
+ already = conn.execute(
665
+ "SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
666
+ (user["id"], target_id)
667
+ ).fetchone()
668
+ if already:
669
+ return jsonify({"error": "Bạn đã chặn người dùng này rồi"}), 400
670
+
671
+ # Xóa quan hệ bạn bè hai chiều (nếu có)
672
+ conn.execute(
673
+ "DELETE FROM friendships WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)",
674
+ (user["id"], target_id, target_id, user["id"])
675
+ )
676
+ # Ghi nhận block (một chiều: người chặn → người bị chặn)
677
+ conn.execute(
678
+ "INSERT OR REPLACE INTO friendships (user_id, friend_id, status) VALUES (?, ?, 'blocked')",
679
+ (user["id"], target_id)
680
+ )
681
+ conn.commit()
682
+ return jsonify({"success": True, "message": "Đã chặn người dùng."})
683
+ except Exception as e:
684
+ logger.error(f"Block user error: {e}")
685
+ return jsonify({"error": str(e)}), 500
686
+ finally:
687
+ conn.close()
688
+
689
+
690
+ @user_features_bp.route("/api/friends/unblock", methods=["POST"])
691
+ @jwt_required
692
+ def unblock_user():
693
+ """Bỏ chặn người dùng."""
694
+ user = get_current_user()
695
+ if not user:
696
+ return jsonify({"error": "Unauthorized"}), 401
697
+
698
+ data = request.json or {}
699
+ target_id = data.get("user_id")
700
+ if not target_id:
701
+ return jsonify({"error": "Thiếu user_id cần bỏ chặn"}), 400
702
+
703
+ conn = get_user_db_conn()
704
+ try:
705
+ rel = conn.execute(
706
+ "SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
707
+ (user["id"], target_id)
708
+ ).fetchone()
709
+ if not rel:
710
+ return jsonify({"error": "Bạn chưa chặn người dùng này"}), 404
711
+
712
+ conn.execute(
713
+ "DELETE FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
714
+ (user["id"], target_id)
715
+ )
716
+ conn.commit()
717
+ return jsonify({"success": True, "message": "Đã bỏ chặn người dùng."})
718
+ except Exception as e:
719
+ logger.error(f"Unblock user error: {e}")
720
+ return jsonify({"error": str(e)}), 500
721
+ finally:
722
+ conn.close()
723
+
724
+
725
+ @user_features_bp.route("/api/friends/blocked-list", methods=["GET"])
726
+ @jwt_required
727
+ def list_blocked():
728
+ """Lấy danh sách người dùng đang bị chặn."""
729
+ user = get_current_user()
730
+ if not user:
731
+ return jsonify({"error": "Unauthorized"}), 401
732
+
733
+ conn = get_user_db_conn()
734
+ try:
735
+ rows = conn.execute(
736
+ """SELECT u.id, u.username, u.user_code, u.avatar, f.created_at as blocked_at
737
+ FROM friendships f
738
+ JOIN users u ON f.friend_id = u.id
739
+ WHERE f.user_id = ? AND f.status = 'blocked'""",
740
+ (user["id"],)
741
+ ).fetchall()
742
+ return jsonify({"blocked_users": [serialize_row(r) for r in rows]})
743
+ except Exception as e:
744
+ return jsonify({"error": str(e)}), 500
745
+ finally:
746
+ conn.close()
747
+
748
+
749
+ @user_features_bp.route("/api/messages/send", methods=["POST"])
750
+ @jwt_required
751
+ def send_message():
752
+ user = get_current_user()
753
+ if not user:
754
+ return jsonify({"error": "Unauthorized"}), 401
755
+
756
+ data = request.json or {}
757
+ receiver_id = data.get("receiver_id")
758
+ message = data.get("message", "").strip()
759
+
760
+ if not receiver_id or not message:
761
+ return jsonify({"error": "Thiếu người nhận hoặc nội dung tin nhắn"}), 400
762
+
763
+ conn = get_user_db_conn()
764
+ try:
765
+ # Kiểm tra bảo mật: Người nhận có đang chặn người gửi không?
766
+ blocked_by_receiver = conn.execute(
767
+ "SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
768
+ (receiver_id, user["id"])
769
+ ).fetchone()
770
+ if blocked_by_receiver:
771
+ return jsonify({"error": "Không thể gửi tin nhắn đến người dùng này."}), 403
772
+
773
+ # Kiểm tra bảo mật: Người gửi có đang chặn người nhận không?
774
+ blocked_by_sender = conn.execute(
775
+ "SELECT id FROM friendships WHERE user_id = ? AND friend_id = ? AND status = 'blocked'",
776
+ (user["id"], receiver_id)
777
+ ).fetchone()
778
+ if blocked_by_sender:
779
+ return jsonify({"error": "Bạn đang chặn người dùng này. Bỏ chặn trước khi nhắn tin."}), 403
780
+
781
+ enc_msg = encrypt_message(message)
782
+ cursor = conn.execute(
783
+ "INSERT INTO direct_messages (sender_id, receiver_id, message) VALUES (?, ?, ?)",
784
+ (user["id"], receiver_id, enc_msg)
785
+ )
786
+ msg_id = cursor.lastrowid
787
+
788
+ conn.execute(
789
+ "INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'message', ?, ?)",
790
+ (receiver_id, user["id"], f"{user['username']} đã gửi cho bạn một tin nhắn mới.", msg_id)
791
+ )
792
+ conn.commit()
793
+ return jsonify({"success": True, "message": "Gửi tin nhắn thành công!", "msg_id": msg_id})
794
+ except Exception as e:
795
+ return jsonify({"error": str(e)}), 500
796
+ finally:
797
+ conn.close()
798
+
799
+
800
+ @user_features_bp.route("/api/messages/chat/<int:friend_id>", methods=["GET"])
801
+ def get_chat_history(friend_id):
802
+ user = get_current_user()
803
+ if not user:
804
+ return jsonify({"error": "Unauthorized"}), 401
805
+
806
+ conn = get_user_db_conn()
807
+ try:
808
+ rows = conn.execute(
809
+ """SELECT id, sender_id, receiver_id, message, is_read, created_at
810
+ FROM direct_messages
811
+ WHERE (sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)
812
+ ORDER BY created_at ASC""",
813
+ (user["id"], friend_id, friend_id, user["id"])
814
+ ).fetchall()
815
+
816
+ conn.execute(
817
+ "UPDATE direct_messages SET is_read = 1 WHERE sender_id = ? AND receiver_id = ?",
818
+ (friend_id, user["id"])
819
+ )
820
+ conn.execute(
821
+ "UPDATE personal_notifications SET is_read = 1 WHERE user_id = ? AND sender_id = ? AND type = 'message'",
822
+ (user["id"], friend_id)
823
+ )
824
+ conn.commit()
825
+
826
+ messages = []
827
+ for r in rows:
828
+ d = dict(r)
829
+ d["message"] = decrypt_message(d.get("message", ""))
830
+ for k, v in d.items():
831
+ if hasattr(v, "isoformat"):
832
+ d[k] = v.isoformat()
833
+ messages.append(d)
834
+
835
+ return jsonify({"messages": messages})
836
+ except Exception as e:
837
+ return jsonify({"error": str(e)}), 500
838
+ finally:
839
+ conn.close()
840
+
841
+
842
+ @user_features_bp.route("/api/books/share", methods=["POST"])
843
+ def share_book():
844
+ user = get_current_user()
845
+ if not user:
846
+ return jsonify({"error": "Unauthorized"}), 401
847
+
848
+ data = request.json or {}
849
+ friend_id = data.get("friend_id")
850
+ book_id = data.get("book_id")
851
+ custom_message = data.get("message", "").strip()
852
+
853
+ if not friend_id or not book_id:
854
+ return jsonify({"error": "Thiếu thông tin người nhận hoặc truyện cần chia sẻ"}), 400
855
+
856
+ conn = get_user_db_conn()
857
+ try:
858
+ book_title = "Truyện"
859
+ from backend.database.db_manager import get_db
860
+ main_conn = get_db()
861
+ row = main_conn.execute("SELECT title_vietphrase, title_hanviet, title FROM books WHERE id = ?", (book_id,)).fetchone()
862
+ if row:
863
+ book_title = row["title_vietphrase"] or row["title_hanviet"] or row["title"]
864
+
865
+ msg_content = f"{user['username']} đã chia sẻ truyện '{book_title}' với bạn."
866
+ if custom_message:
867
+ msg_content += f" Lời nhắn: \"{custom_message}\""
868
+
869
+ conn.execute(
870
+ "INSERT INTO personal_notifications (user_id, sender_id, type, message, related_id) VALUES (?, ?, 'book_share', ?, ?)",
871
+ (friend_id, user["id"], msg_content, book_id)
872
+ )
873
+
874
+ share_chat_msg = f"[Chia sẻ truyện] '{book_title}' - Xem chi tiết tại /book/{book_id}"
875
+ if custom_message:
876
+ share_chat_msg += f"\nLời nhắn: {custom_message}"
877
+ enc_share_msg = encrypt_message(share_chat_msg)
878
+ conn.execute(
879
+ "INSERT INTO direct_messages (sender_id, receiver_id, message) VALUES (?, ?, ?)",
880
+ (user["id"], friend_id, enc_share_msg)
881
+ )
882
+
883
+ conn.commit()
884
+ return jsonify({"success": True, "message": "Chia sẻ truyện thành công!"})
885
+ except Exception as e:
886
+ return jsonify({"error": str(e)}), 500
887
+ finally:
888
+ conn.close()
889
+
890
+
891
+ @user_features_bp.route("/api/notifications/personal", methods=["GET"])
892
+ def get_personal_notifications():
893
+ user = get_current_user()
894
+ if not user:
895
+ return jsonify({"notifications": []})
896
+
897
+ conn = get_user_db_conn()
898
+ try:
899
+ rows = conn.execute(
900
+ """SELECT n.id, n.sender_id, n.type, n.message, n.related_id, n.is_read, n.created_at, u.username as sender_name
901
+ FROM personal_notifications n
902
+ LEFT JOIN users u ON n.sender_id = u.id
903
+ WHERE n.user_id = ?
904
+ ORDER BY n.created_at DESC LIMIT 50""",
905
+ (user["id"],)
906
+ ).fetchall()
907
+ return jsonify({"notifications": [serialize_row(r) for r in rows]})
908
+ except Exception as e:
909
+ return jsonify({"error": str(e)}), 500
910
+ finally:
911
+ conn.close()
912
+
913
+
914
+ @user_features_bp.route("/api/notifications/personal/read", methods=["POST"])
915
+ def read_personal_notification():
916
+ user = get_current_user()
917
+ if not user:
918
+ return jsonify({"error": "Unauthorized"}), 401
919
+
920
+ data = request.json or {}
921
+ notif_id = data.get("notification_id")
922
+
923
+ if not notif_id:
924
+ return jsonify({"error": "Thiếu notification_id"}), 400
925
+
926
+ conn = get_user_db_conn()
927
+ try:
928
+ conn.execute(
929
+ "UPDATE personal_notifications SET is_read = 1 WHERE id = ? AND user_id = ?",
930
+ (notif_id, user["id"])
931
+ )
932
+ conn.commit()
933
+ return jsonify({"success": True})
934
+ except Exception as e:
935
+ return jsonify({"error": str(e)}), 500
936
+ finally:
937
+ conn.close()
938
+
backend/config.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import timedelta
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv(override=True)
6
+
7
+ class Config:
8
+ # Flask settings
9
+ SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "tienhiep_lyvuha_secret_key_9988")
10
+
11
+ # Base directories and DB paths
12
+ ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
13
+ DB_PATH = os.path.join(ROOT_DIR, "merged_books.db")
14
+ USER_DB_PATH = os.path.join(ROOT_DIR, "users_data.db")
15
+
16
+ # JWT authentication settings
17
+ JWT_SECRET = SECRET_KEY + "_jwt_v2_secure"
18
+ JWT_ALGORITHM = "HS256"
19
+ JWT_ACCESS_TOKEN_EXPIRE = timedelta(days=365)
20
+ JWT_REFRESH_TOKEN_EXPIRE = timedelta(days=365)
21
+
22
+ # VIP plans
23
+ VIP_PLANS = {
24
+ "month": {
25
+ "name_vi": "Gói Tháng",
26
+ "name_en": "Monthly Plan",
27
+ "name_zh": "月套餐",
28
+ "price": 50000, # 50,000 VND
29
+ "duration_days": 30,
30
+ "description_vi": "VIP 1 tháng — Dịch không giới hạn, AI, TTS, EPUB",
31
+ "description_en": "VIP 1 month — Unlimited translation, AI, TTS, EPUB",
32
+ "description_zh": "VIP 1个月 — 无限翻译、AI、TTS、EPUB",
33
+ },
34
+ "year": {
35
+ "name_vi": "Gói Năm (Tiết kiệm 67%)",
36
+ "name_en": "Yearly Plan (Save 67%)",
37
+ "name_zh": "年套餐 (节省67%)",
38
+ "price": 200000, # 200,000 VND
39
+ "duration_days": 365,
40
+ "description_vi": "VIP 1 năm — Tất cả quyền lợi VIP, ưu đãi tốt nhất",
41
+ "description_en": "VIP 1 year — All VIP benefits, best value",
42
+ "description_zh": "VIP 1年 — 所有VIP权益,最优惠",
43
+ }
44
+ }
45
+
46
+ # Payment Gateway Config
47
+ PAYMENT_CONFIG = {
48
+ "bank_id": os.environ.get("BANK_ID", "MB"),
49
+ "account_no": os.environ.get("BANK_ACCOUNT_NO", "0349717475"),
50
+ "account_name": os.environ.get("BANK_ACCOUNT_NAME", "LY VU HA"),
51
+ "template": "compact2",
52
+ "payos_client_id": os.environ.get("PAYOS_CLIENT_ID", ""),
53
+ "payos_api_key": os.environ.get("PAYOS_API_KEY", ""),
54
+ "payos_checksum_key": os.environ.get("PAYOS_CHECKSUM_KEY", ""),
55
+ "payos_webhook_url": os.environ.get("PAYOS_WEBHOOK_URL", "https://yourdomain.com/api/payment/webhook"),
56
+ }
57
+
58
+ # Rate Limiting
59
+ RATE_LIMITS = {
60
+ "login": {"max": 5, "window": 300},
61
+ "register": {"max": 3, "window": 600},
62
+ "otp": {"max": 3, "window": 60},
63
+ "payment": {"max": 10, "window": 600},
64
+ }
65
+
66
+ # SMTP email config
67
+ SMTP_CONFIG = {
68
+ "host": "smtp.gmail.com",
69
+ "port": 587,
70
+ "username": os.environ.get("SMTP_EMAIL", ""),
71
+ "password": os.environ.get("SMTP_PASSWORD", ""),
72
+ "from_name": "Novel Translator VIP",
73
+ "enabled": True if os.environ.get("SMTP_PASSWORD") else False
74
+ }
75
+
76
+ # Google OAuth
77
+ GOOGLE_OAUTH_CONFIG = {
78
+ "client_id": os.environ.get("GOOGLE_CLIENT_ID", ""),
79
+ "client_secret": os.environ.get("GOOGLE_CLIENT_SECRET", ""),
80
+ "redirect_uri": os.environ.get("GOOGLE_REDIRECT_URI", "http://localhost:5050/api/auth/google/callback"),
81
+ "enabled": True
82
+ }
83
+
84
+ # VIP Validation Codes
85
+ _vip_codes_env = os.environ.get("VALID_VIP_CODES", "")
86
+ if _vip_codes_env:
87
+ VALID_VIP_CODES = set(c.strip() for c in _vip_codes_env.split(",") if c.strip())
88
+ else:
89
+ VALID_VIP_CODES = {"VIP2026", "ANTIGRAVITY", "PREMIUM_MEMBER", "VIP_TRANSLATOR", "VIP_SERVER", "LYVUHA_ADMIN_2026"}
backend/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # backend/core/__init__.py
backend/core/decorators.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import wraps
2
+ from datetime import datetime
3
+ from flask import request, jsonify, session
4
+ from backend.core.security import verify_access_token
5
+ from backend.database.db_manager import get_user_db_conn
6
+
7
+ def jwt_required(f):
8
+ """Decorator: require valid JWT OR session auth."""
9
+ @wraps(f)
10
+ def decorated(*args, **kwargs):
11
+ # Try JWT first
12
+ auth_header = request.headers.get("Authorization", "")
13
+ if auth_header.startswith("Bearer "):
14
+ token = auth_header[7:]
15
+ payload = verify_access_token(token)
16
+ if payload:
17
+ request._jwt_user = {
18
+ "id": int(payload["sub"]),
19
+ "username": payload["username"],
20
+ "vip_status": payload["vip"]
21
+ }
22
+ return f(*args, **kwargs)
23
+
24
+ # Fall back to session auth
25
+ if "user_id" in session:
26
+ request._jwt_user = {
27
+ "id": session["user_id"],
28
+ "username": session["username"],
29
+ "vip_status": session.get("vip_status", 0)
30
+ }
31
+ return f(*args, **kwargs)
32
+
33
+ return jsonify({"error": "Authentication required"}), 401
34
+ return decorated
35
+
36
+ def require_api_key_auth(f):
37
+ """Decorator: require valid Developer API Key or VIP Validation Code auth."""
38
+ @wraps(f)
39
+ def decorated(*args, **kwargs):
40
+ # 1. Try to find the key in Authorization header
41
+ auth_header = request.headers.get("Authorization", "")
42
+ api_key = ""
43
+ if auth_header.startswith("Bearer "):
44
+ api_key = auth_header[7:].strip()
45
+
46
+ # If the api_key from Authorization header looks like a JWT token (not sk-tc- and not a valid VIP code),
47
+ # clear it so we fall back to other headers or the body.
48
+ from backend.config import Config
49
+ if api_key and not api_key.startswith("sk-tc-") and api_key not in Config.VALID_VIP_CODES:
50
+ api_key = ""
51
+
52
+ # 2. Try X-VIP-Key header
53
+ if not api_key:
54
+ api_key = request.headers.get("X-VIP-Key", "").strip()
55
+
56
+ # 3. Try X-VIP-Code header
57
+ if not api_key:
58
+ api_key = request.headers.get("X-VIP-Code", "").strip()
59
+
60
+ # 4. Try request JSON body
61
+ if not api_key:
62
+ try:
63
+ data = request.json or {}
64
+ api_key = data.get("vip_key", "") or data.get("vip_code", "") or data.get("api_key", "")
65
+ if isinstance(api_key, str):
66
+ api_key = api_key.strip()
67
+ else:
68
+ api_key = ""
69
+ except:
70
+ pass
71
+
72
+ # 5. Check if it's empty
73
+ if not api_key:
74
+ return jsonify({"error": "Missing API Key or VIP Code. Please authenticate."}), 401
75
+
76
+ # 6. Check if it is a valid static VIP code
77
+ from backend.config import Config
78
+ if api_key in Config.VALID_VIP_CODES:
79
+ # Bypassed - treat as static VIP access with unlimited balance
80
+ request.api_key = api_key
81
+ request.api_user_id = None
82
+ request.api_balance = 999999.0
83
+ return f(*args, **kwargs)
84
+
85
+ # 7. Otherwise, it must be a developer key starting with sk-tc-
86
+ if not api_key.startswith("sk-tc-"):
87
+ return jsonify({"error": "Invalid API key format. Key must start with 'sk-tc-' or be a valid VIP Code."}), 401
88
+
89
+ conn = get_user_db_conn()
90
+ key_record = conn.execute(
91
+ "SELECT k.*, u.api_balance FROM api_keys k JOIN users u ON k.user_id = u.id WHERE k.api_key = ? AND k.status = 'active'",
92
+ (api_key,)
93
+ ).fetchone()
94
+
95
+ if not key_record:
96
+ conn.close()
97
+ return jsonify({"error": "API Key not found, inactive, or revoked."}), 401
98
+
99
+ balance = key_record["api_balance"] if key_record["api_balance"] is not None else 0.0
100
+ if balance <= 0.0:
101
+ conn.close()
102
+ return jsonify({
103
+ "error": f"Tài khoản hết số dư API (Số dư hiện tại: {balance:.2f}đ). Vui lòng nạp thêm tiền tại website để tiếp tục sử dụng."
104
+ }), 402
105
+
106
+ # Update last_used_at
107
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
108
+ conn.execute("UPDATE api_keys SET last_used_at = ? WHERE api_key = ?", (now, api_key))
109
+ conn.commit()
110
+ conn.close()
111
+
112
+ request.api_key = api_key
113
+ request.api_user_id = key_record["user_id"]
114
+ request.api_balance = balance
115
+ return f(*args, **kwargs)
116
+ return decorated
117
+
118
+ def get_current_user():
119
+ """Get current user from JWT or session."""
120
+ if hasattr(request, '_jwt_user'):
121
+ return request._jwt_user
122
+
123
+ auth_header = request.headers.get("Authorization", "")
124
+ if auth_header.startswith("Bearer "):
125
+ token = auth_header[7:]
126
+ payload = verify_access_token(token)
127
+ if payload:
128
+ return {
129
+ "id": int(payload["sub"]),
130
+ "username": payload["username"],
131
+ "vip_status": payload["vip"]
132
+ }
133
+
134
+ if "user_id" in session:
135
+ return {
136
+ "id": session["user_id"],
137
+ "username": session["username"],
138
+ "vip_status": session.get("vip_status", 0)
139
+ }
140
+ return None
backend/core/logger.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+
6
+ # Project root directory detection
7
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+ LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
9
+ LOG_FILE = os.path.join(LOG_DIR, "app.log")
10
+
11
+ def setup_logger(name="app", log_level=logging.INFO):
12
+ """Sets up a central rolling logger that outputs to both stdout and a file."""
13
+ # Ensure logs directory exists
14
+ try:
15
+ os.makedirs(LOG_DIR, exist_ok=True)
16
+ except Exception as e:
17
+ sys.stderr.write(f"[WARNING] Failed to create log directory '{LOG_DIR}': {e}\n")
18
+
19
+ logger = logging.getLogger(name)
20
+ logger.setLevel(log_level)
21
+
22
+ # Avoid duplicate handlers if setup_logger is called multiple times
23
+ if logger.handlers:
24
+ return logger
25
+
26
+ # Log format: time | level | name | message
27
+ formatter = logging.Formatter(
28
+ "[%(asctime)s] %(levelname)s [%(name)s] %(message)s",
29
+ datefmt="%Y-%m-%d %H:%M:%S"
30
+ )
31
+
32
+ # Console stream handler
33
+ console_handler = logging.StreamHandler(sys.stdout)
34
+ console_handler.setFormatter(formatter)
35
+ logger.addHandler(console_handler)
36
+
37
+ # Rolling file handler
38
+ try:
39
+ file_handler = RotatingFileHandler(
40
+ LOG_FILE,
41
+ maxBytes=10 * 1024 * 1024, # 10 MB
42
+ backupCount=5,
43
+ encoding="utf-8"
44
+ )
45
+ file_handler.setFormatter(formatter)
46
+ logger.addHandler(file_handler)
47
+ except Exception as e:
48
+ sys.stderr.write(f"[WARNING] Failed to configure file logger for '{LOG_FILE}': {e}\n")
49
+
50
+ return logger
51
+
52
+ # Pre-initialize central logger
53
+ logger = setup_logger()
backend/core/monitoring.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import sqlite3
4
+ import datetime
5
+ from backend.config import Config
6
+ from backend.database.db_manager import get_user_db_conn, get_db
7
+
8
+ def get_cpu_load():
9
+ """Retrieve CPU 1m, 5m, 15m load average on Linux."""
10
+ try:
11
+ if os.path.exists("/proc/loadavg"):
12
+ with open("/proc/loadavg", "r") as f:
13
+ load = f.read().strip().split()
14
+ return {
15
+ "load_1m": float(load[0]),
16
+ "load_5m": float(load[1]),
17
+ "load_15m": float(load[2])
18
+ }
19
+ except Exception:
20
+ pass
21
+ return {"load_1m": 0.0, "load_5m": 0.0, "load_15m": 0.0}
22
+
23
+ def get_memory_info():
24
+ """Retrieve system RAM and process Resident Set Size (RSS) memory info."""
25
+ mem_total = 0
26
+ mem_available = 0
27
+ process_rss = 0
28
+
29
+ try:
30
+ if os.path.exists("/proc/meminfo"):
31
+ with open("/proc/meminfo", "r") as f:
32
+ for line in f:
33
+ if line.startswith("MemTotal:"):
34
+ mem_total = int(line.split()[1]) * 1024 # kB to bytes
35
+ elif line.startswith("MemAvailable:"):
36
+ mem_available = int(line.split()[1]) * 1024
37
+
38
+ if os.path.exists("/proc/self/status"):
39
+ with open("/proc/self/status", "r") as f:
40
+ for line in f:
41
+ if line.startswith("VmRSS:"):
42
+ process_rss = int(line.split()[1]) * 1024
43
+ break
44
+ except Exception:
45
+ pass
46
+
47
+ mem_used = mem_total - mem_available
48
+ mem_pct = (mem_used / mem_total * 100) if mem_total > 0 else 0.0
49
+
50
+ return {
51
+ "total_bytes": mem_total,
52
+ "used_bytes": mem_used,
53
+ "available_bytes": mem_available,
54
+ "percent": round(mem_pct, 2),
55
+ "process_rss_bytes": process_rss
56
+ }
57
+
58
+ def get_disk_info():
59
+ """Retrieve disk space usage of root partition."""
60
+ try:
61
+ total, used, free = shutil.disk_usage("/")
62
+ percent = (used / total * 100) if total > 0 else 0.0
63
+ return {
64
+ "total_bytes": total,
65
+ "used_bytes": used,
66
+ "free_bytes": free,
67
+ "percent": round(percent, 2)
68
+ }
69
+ except Exception:
70
+ return {
71
+ "total_bytes": 0,
72
+ "used_bytes": 0,
73
+ "free_bytes": 0,
74
+ "percent": 0.0
75
+ }
76
+
77
+ def get_database_sizes():
78
+ """Get the sizes of SQLite databases on disk."""
79
+ sizes = {}
80
+ db_paths = {
81
+ "user_db": Config.USER_DB_PATH,
82
+ "books_db": Config.DB_PATH,
83
+ "books_advanced": os.path.join(Config.ROOT_DIR, "merged_books_advanced.db"),
84
+ "books_fast": os.path.join(Config.ROOT_DIR, "merged_books_fast.db"),
85
+ "books_vietphrase": os.path.join(Config.ROOT_DIR, "merged_books_vietphrase.db"),
86
+ "books_hanviet": os.path.join(Config.ROOT_DIR, "merged_books_hanviet.db"),
87
+ }
88
+
89
+ for key, path in db_paths.items():
90
+ if os.path.exists(path):
91
+ sizes[key] = os.path.getsize(path)
92
+ else:
93
+ sizes[key] = 0
94
+
95
+ return sizes
96
+
97
+ def count_log_errors():
98
+ """Scan logs/app.log and count the number of [ERROR] or ERROR level log messages."""
99
+ log_path = os.path.join(Config.ROOT_DIR, "logs", "app.log")
100
+ count = 0
101
+ if not os.path.exists(log_path):
102
+ return 0
103
+ try:
104
+ # Read the file. Since it's limited to 10MB, it's safe to scan
105
+ with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
106
+ for line in f:
107
+ if "ERROR" in line or "[ERROR]" in line:
108
+ count += 1
109
+ except Exception:
110
+ pass
111
+ return count
112
+
113
+ def get_system_statistics():
114
+ """Query user database to pull statistics like total users, payments, and API requests."""
115
+ stats = {
116
+ "total_users": 0,
117
+ "vip_users": 0,
118
+ "total_payments": 0,
119
+ "total_api_keys": 0,
120
+ "total_api_calls": 0,
121
+ "total_books_scraped": 0
122
+ }
123
+
124
+ try:
125
+ conn = get_user_db_conn()
126
+ stats["total_users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
127
+ stats["vip_users"] = conn.execute("SELECT COUNT(*) FROM users WHERE vip_status = 1").fetchone()[0]
128
+
129
+ # Check if tables exist before querying
130
+ tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
131
+
132
+ if "payments" in tables:
133
+ stats["total_payments"] = conn.execute("SELECT COUNT(*) FROM payments WHERE status = 'completed'").fetchone()[0]
134
+ if "api_keys" in tables:
135
+ stats["total_api_keys"] = conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0]
136
+ if "api_usage" in tables:
137
+ stats["total_api_calls"] = conn.execute("SELECT COUNT(*) FROM api_usage").fetchone()[0]
138
+
139
+ conn.close()
140
+ except Exception:
141
+ pass
142
+
143
+ try:
144
+ main_conn = get_db()
145
+ stats["total_books_scraped"] = main_conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
146
+ except Exception:
147
+ pass
148
+
149
+ return stats
150
+
151
+ def collect_all_metrics():
152
+ """Gather all diagnostics into a consolidated system metrics dictionary."""
153
+ return {
154
+ "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
155
+ "cpu": get_cpu_load(),
156
+ "memory": get_memory_info(),
157
+ "disk": get_disk_info(),
158
+ "databases": get_database_sizes(),
159
+ "error_logs_count": count_log_errors(),
160
+ "statistics": get_system_statistics()
161
+ }
backend/core/profiler.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import logging
3
+ import requests
4
+ from collections import deque
5
+ from flask import g, has_app_context, request
6
+
7
+ logger = logging.getLogger("profiler")
8
+ logger.setLevel(logging.INFO)
9
+ if not logger.handlers:
10
+ import sys
11
+ handler = logging.StreamHandler(sys.stdout)
12
+ handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s"))
13
+ logger.addHandler(handler)
14
+
15
+ # Ring buffer for recent profiled requests
16
+ recent_profiles = deque(maxlen=100)
17
+
18
+ # 1. Monkey patch requests to measure all outbound HTTP API calls
19
+ _original_request = requests.request
20
+
21
+ def _monitored_request(*args, **kwargs):
22
+ start_time = time.time()
23
+ try:
24
+ return _original_request(*args, **kwargs)
25
+ finally:
26
+ duration = time.time() - start_time
27
+ if has_app_context():
28
+ if not hasattr(g, "external_time"):
29
+ g.external_time = 0.0
30
+ g.external_time += duration
31
+
32
+ requests.request = _monitored_request
33
+
34
+ # DB tracking helper
35
+ def record_db_time(duration: float):
36
+ if has_app_context():
37
+ if not hasattr(g, "db_time"):
38
+ g.db_time = 0.0
39
+ g.db_time += duration
40
+
41
+ def get_recent_profiles():
42
+ return list(recent_profiles)
43
+
44
+ def setup_profiler(app):
45
+ @app.before_request
46
+ def start_timer():
47
+ g.start_time = time.time()
48
+ g.db_time = 0.0
49
+ g.external_time = 0.0
50
+
51
+ @app.after_request
52
+ def log_and_header(response):
53
+ if not hasattr(g, "start_time"):
54
+ return response
55
+
56
+ total_time = time.time() - g.start_time
57
+ db_time = getattr(g, "db_time", 0.0)
58
+ ext_time = getattr(g, "external_time", 0.0)
59
+ cpu_time = max(0.0, total_time - db_time - ext_time)
60
+
61
+ # Add latency/breakdown headers in milliseconds
62
+ import os
63
+ response.headers["X-Response-Time-Ms"] = f"{int(total_time * 1000)}"
64
+ response.headers["X-DB-Time-Ms"] = f"{int(db_time * 1000)}"
65
+ response.headers["X-External-Time-Ms"] = f"{int(ext_time * 1000)}"
66
+ response.headers["X-CPU-Process-Time-Ms"] = f"{int(cpu_time * 1000)}"
67
+ response.headers["X-Process-ID"] = str(os.getpid())
68
+
69
+ log_entry = {
70
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
71
+ "method": request.method,
72
+ "path": request.path,
73
+ "status_code": response.status_code,
74
+ "total_ms": round(total_time * 1000, 1),
75
+ "db_ms": round(db_time * 1000, 1),
76
+ "external_ms": round(ext_time * 1000, 1),
77
+ "cpu_ms": round(cpu_time * 1000, 1)
78
+ }
79
+ recent_profiles.append(log_entry)
80
+
81
+ # Print directly to stdout/gunicorn logs
82
+ logger.info(
83
+ f"[PROFILER] {request.method} {request.path} ({response.status_code}) - "
84
+ f"Total: {log_entry['total_ms']}ms | "
85
+ f"DB: {log_entry['db_ms']}ms | "
86
+ f"External: {log_entry['external_ms']}ms | "
87
+ f"CPU/Internal: {log_entry['cpu_ms']}ms"
88
+ )
89
+ return response
backend/core/rate_limit.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import threading
3
+ from collections import defaultdict
4
+ from flask import request
5
+ from backend.config import Config
6
+
7
+ rate_limit_store = {} # { "action:identifier": { "count": N, "reset_at": timestamp } }
8
+
9
+ # Simple rate limiter to prevent IP scraping abuse
10
+ IP_LIMITS = defaultdict(list)
11
+ IP_LIMITS_LOCK = threading.Lock()
12
+
13
+ def get_client_ip():
14
+ """Retrieve client IP, accounting for cloudflare proxy headers."""
15
+ return request.headers.get("CF-Connecting-IP") or request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr
16
+
17
+ def check_rate_limit(action: str, identifier: str) -> bool:
18
+ """Returns True if rate limit exceeded, False if OK."""
19
+ if request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988":
20
+ return False
21
+ key = f"{action}:{identifier}"
22
+ now = time.time()
23
+ limit = Config.RATE_LIMITS.get(action, {"max": 100, "window": 60})
24
+
25
+ if key in rate_limit_store:
26
+ entry = rate_limit_store[key]
27
+ if now > entry["reset_at"]:
28
+ rate_limit_store[key] = {"count": 1, "reset_at": now + limit["window"]}
29
+ return False
30
+ if entry["count"] >= limit["max"]:
31
+ return True
32
+ entry["count"] += 1
33
+ return False
34
+ else:
35
+ rate_limit_store[key] = {"count": 1, "reset_at": now + limit["window"]}
36
+ return False
37
+
38
+ def check_ip_rate_limit(ip: str, max_requests: int = 45, period: int = 60) -> bool:
39
+ """Thread-safe sliding window rate limit for book searches / API access."""
40
+ if request.headers.get("X-Bypass-Rate-Limit") == "tienhiep_bypass_secret_9988":
41
+ return True
42
+ now = time.time()
43
+ with IP_LIMITS_LOCK:
44
+ if ip not in IP_LIMITS:
45
+ IP_LIMITS[ip] = []
46
+ IP_LIMITS[ip] = [t for t in IP_LIMITS[ip] if now - t < period]
47
+ if len(IP_LIMITS[ip]) >= max_requests:
48
+ return False
49
+ IP_LIMITS[ip].append(now)
50
+ return True
backend/core/security.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import secrets
2
+ import hashlib
3
+ from datetime import datetime
4
+ import bcrypt
5
+ import jwt
6
+ from backend.config import Config
7
+ from backend.database.db_manager import get_user_db_conn
8
+
9
+ def hash_password(password: str) -> str:
10
+ """Hash password with bcrypt."""
11
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
12
+
13
+ def verify_password(password: str, hashed: str) -> bool:
14
+ """Verify password against bcrypt hash. Also supports legacy SHA-256."""
15
+ try:
16
+ if hashed.startswith("$2b$") or hashed.startswith("$2a$"):
17
+ return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
18
+ except Exception:
19
+ pass
20
+ # Fall back to legacy SHA-256 for old accounts
21
+ legacy_hash = hashlib.sha256(password.encode()).hexdigest()
22
+ return legacy_hash == hashed
23
+
24
+ def upgrade_password_hash(user_id: int, password: str):
25
+ """Upgrade a user's password from SHA-256 to bcrypt."""
26
+ new_hash = hash_password(password)
27
+ conn = get_user_db_conn()
28
+ conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_id))
29
+ conn.commit()
30
+ conn.close()
31
+
32
+ def create_access_token(user_id: int, username: str, vip_status: int) -> str:
33
+ """Create a short-lived access token (30 min)."""
34
+ payload = {
35
+ "sub": str(user_id),
36
+ "username": username,
37
+ "vip": vip_status,
38
+ "type": "access",
39
+ "exp": datetime.utcnow() + Config.JWT_ACCESS_TOKEN_EXPIRE,
40
+ "iat": datetime.utcnow()
41
+ }
42
+ return jwt.encode(payload, Config.JWT_SECRET, algorithm=Config.JWT_ALGORITHM)
43
+
44
+ def create_refresh_token(user_id: int) -> str:
45
+ """Create a long-lived refresh token (7 days), stored in DB."""
46
+ token_str = secrets.token_urlsafe(64)
47
+ expires_at = datetime.utcnow() + Config.JWT_REFRESH_TOKEN_EXPIRE
48
+ conn = get_user_db_conn()
49
+ conn.execute(
50
+ "INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
51
+ (user_id, token_str, expires_at.strftime("%Y-%m-%d %H:%M:%S"))
52
+ )
53
+ conn.commit()
54
+ conn.close()
55
+ return token_str
56
+
57
+ def verify_access_token(token: str):
58
+ """Verify and decode an access token. Returns payload or None."""
59
+ try:
60
+ payload = jwt.decode(token, Config.JWT_SECRET, algorithms=[Config.JWT_ALGORITHM])
61
+ if payload.get("type") != "access":
62
+ print(f"[DEBUG JWT] Token type is not access: {payload.get('type')}", flush=True)
63
+ return None
64
+ return payload
65
+ except jwt.ExpiredSignatureError as e:
66
+ print(f"[DEBUG JWT] Token expired: {e}", flush=True)
67
+ return None
68
+ except jwt.InvalidTokenError as e:
69
+ print(f"[DEBUG JWT] Invalid token: {e}", flush=True)
70
+ return None
71
+ except Exception as e:
72
+ print(f"[DEBUG JWT] Decode failed: {e}", flush=True)
73
+ return None
74
+
75
+ import base64
76
+ from cryptography.fernet import Fernet
77
+
78
+ def _get_fernet_key() -> bytes:
79
+ key_hash = hashlib.sha256(Config.SECRET_KEY.encode()).digest()
80
+ return base64.urlsafe_b64encode(key_hash)
81
+
82
+ def encrypt_message(message: str) -> str:
83
+ """Encrypt message text using symmetric AES encryption."""
84
+ if not message:
85
+ return ""
86
+ try:
87
+ f = Fernet(_get_fernet_key())
88
+ return f.encrypt(message.encode('utf-8')).decode('utf-8')
89
+ except Exception as e:
90
+ return message
91
+
92
+ def decrypt_message(encrypted_message: str) -> str:
93
+ """Decrypt message text. Falls back to original text if not encrypted or fails."""
94
+ if not encrypted_message:
95
+ return ""
96
+ try:
97
+ f = Fernet(_get_fernet_key())
98
+ return f.decrypt(encrypted_message.encode('utf-8')).decode('utf-8')
99
+ except Exception:
100
+ return encrypted_message
101
+
102
+ import os
103
+ from cryptography.hazmat.primitives.asymmetric import rsa, padding
104
+ from cryptography.hazmat.primitives import serialization, hashes
105
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
106
+
107
+ KEYS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "keys")
108
+ PRIVATE_KEY_PATH = os.path.join(KEYS_DIR, "private_key.pem")
109
+ PUBLIC_KEY_PATH = os.path.join(KEYS_DIR, "public_key.pem")
110
+
111
+ def get_or_create_rsa_keys():
112
+ """Get RSA keys or generate if they don't exist."""
113
+ if not os.path.exists(KEYS_DIR):
114
+ os.makedirs(KEYS_DIR, exist_ok=True)
115
+
116
+ if not os.path.exists(PRIVATE_KEY_PATH) or not os.path.exists(PUBLIC_KEY_PATH):
117
+ # Generate private key
118
+ private_key = rsa.generate_private_key(
119
+ public_exponent=65537,
120
+ key_size=2048
121
+ )
122
+ # Write private key
123
+ with open(PRIVATE_KEY_PATH, "wb") as f:
124
+ f.write(
125
+ private_key.private_bytes(
126
+ encoding=serialization.Encoding.PEM,
127
+ format=serialization.PrivateFormat.PKCS8,
128
+ encryption_algorithm=serialization.NoEncryption()
129
+ )
130
+ )
131
+ # Generate and write public key
132
+ public_key = private_key.public_key()
133
+ with open(PUBLIC_KEY_PATH, "wb") as f:
134
+ f.write(
135
+ public_key.public_bytes(
136
+ encoding=serialization.Encoding.PEM,
137
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
138
+ )
139
+ )
140
+
141
+ def encrypt_asymmetric_hybrid(plaintext: str) -> dict:
142
+ """
143
+ Encrypt text using Hybrid Encryption (AES-256-GCM + RSA-2048).
144
+ Returns a dict with base64 encoded parts.
145
+ """
146
+ if not plaintext:
147
+ return {"ciphertext": "", "encrypted_key": "", "nonce": "", "tag": ""}
148
+
149
+ # Ensure keys exist
150
+ get_or_create_rsa_keys()
151
+
152
+ # 1. Generate random 256-bit AES key and 12-byte nonce
153
+ aes_key = os.urandom(32)
154
+ nonce = os.urandom(12)
155
+
156
+ # 2. Encrypt plaintext using AES-GCM
157
+ cipher = Cipher(algorithms.AES(aes_key), modes.GCM(nonce))
158
+ encryptor = cipher.encryptor()
159
+ ciphertext = encryptor.update(plaintext.encode('utf-8')) + encryptor.finalize()
160
+ tag = encryptor.tag
161
+
162
+ # 3. Encrypt the AES key using RSA public key
163
+ with open(PUBLIC_KEY_PATH, "rb") as f:
164
+ public_key = serialization.load_pem_public_key(f.read())
165
+
166
+ encrypted_aes_key = public_key.encrypt(
167
+ aes_key,
168
+ padding.OAEP(
169
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
170
+ algorithm=hashes.SHA256(),
171
+ label=None
172
+ )
173
+ )
174
+
175
+ # 4. Return all parts as base64 string
176
+ return {
177
+ "ciphertext": base64.b64encode(ciphertext).decode('utf-8'),
178
+ "encrypted_key": base64.b64encode(encrypted_aes_key).decode('utf-8'),
179
+ "nonce": base64.b64encode(nonce).decode('utf-8'),
180
+ "tag": base64.b64encode(tag).decode('utf-8')
181
+ }
182
+
183
+ def decrypt_asymmetric_hybrid(ciphertext_b64: str, encrypted_key_b64: str, nonce_b64: str, tag_b64: str) -> str:
184
+ """
185
+ Decrypt asymmetric hybrid encrypted payload.
186
+ """
187
+ if not ciphertext_b64 or not encrypted_key_b64:
188
+ return ""
189
+
190
+ # Check if private key exists
191
+ if not os.path.exists(PRIVATE_KEY_PATH):
192
+ print("[SECURITY] Private key does not exist. Decryption failed.", flush=True)
193
+ return ""
194
+
195
+ try:
196
+ # Decode base64 parts
197
+ ciphertext = base64.b64decode(ciphertext_b64.encode('utf-8'))
198
+ encrypted_aes_key = base64.b64decode(encrypted_key_b64.encode('utf-8'))
199
+ nonce = base64.b64decode(nonce_b64.encode('utf-8'))
200
+ tag = base64.b64decode(tag_b64.encode('utf-8'))
201
+
202
+ # 1. Decrypt AES key using RSA private key
203
+ with open(PRIVATE_KEY_PATH, "rb") as f:
204
+ private_key = serialization.load_pem_private_key(f.read(), password=None)
205
+
206
+ aes_key = private_key.decrypt(
207
+ encrypted_aes_key,
208
+ padding.OAEP(
209
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
210
+ algorithm=hashes.SHA256(),
211
+ label=None
212
+ )
213
+ )
214
+
215
+ # 2. Decrypt ciphertext using AES-GCM
216
+ cipher = Cipher(algorithms.AES(aes_key), modes.GCM(nonce, tag))
217
+ decryptor = cipher.decryptor()
218
+ decrypted_bytes = decryptor.update(ciphertext) + decryptor.finalize()
219
+
220
+ return decrypted_bytes.decode('utf-8')
221
+ except Exception as e:
222
+ import traceback
223
+ print(f"[SECURITY] Hybrid decryption failed: {e}", flush=True)
224
+ traceback.print_exc()
225
+ return ""
226
+
227
+
backend/core/watchdog.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ backend/core/watchdog.py
3
+ =========================
4
+ Watchdog / Keepalive daemon cho Hugging Face Space.
5
+
6
+ Hugging Face Free Tier ngủ đông (sleep) sau ~15 phút không có traffic.
7
+ Module này:
8
+ 1. Tự ping /health mỗi 10 phút để giữ Space thức.
9
+ 2. Giám sát RAM / Disk và log cảnh báo.
10
+ 3. Tự động restart gunicorn worker nếu process bị zombie (graceful restart).
11
+ 4. Flush log + commit DB tạm thời khi nhận SIGTERM từ HF.
12
+
13
+ Sử dụng trong backend/__init__.py:
14
+ from backend.core.watchdog import start_watchdog
15
+ start_watchdog(app_url="https://cong123779-tienhiep-backend.hf.space")
16
+ """
17
+ import os
18
+ import time
19
+ import threading
20
+ import logging
21
+ import signal
22
+ import urllib.request
23
+
24
+ logger = logging.getLogger("watchdog")
25
+
26
+ _watchdog_started = False
27
+ _stop_event = threading.Event()
28
+
29
+
30
+ def _ping_self(url: str) -> bool:
31
+ """Ping /health endpoint để HF Space không bị sleep."""
32
+ try:
33
+ req = urllib.request.Request(
34
+ f"{url.rstrip('/')}/health",
35
+ headers={"User-Agent": "TienhiepWatchdog/1.0"}
36
+ )
37
+ with urllib.request.urlopen(req, timeout=10) as resp:
38
+ return resp.status == 200
39
+ except Exception as e:
40
+ logger.warning(f"[Watchdog] Ping failed: {e}")
41
+ return False
42
+
43
+
44
+ def _log_resources():
45
+ """Log mức sử dụng RAM và Disk."""
46
+ try:
47
+ import psutil
48
+ mem = psutil.virtual_memory()
49
+ disk = psutil.disk_usage("/")
50
+ cpu = psutil.cpu_percent(interval=0.1)
51
+ logger.info(
52
+ f"[Watchdog] RAM: {mem.percent:.1f}% ({mem.used // 1024 // 1024}MB / {mem.total // 1024 // 1024}MB) | "
53
+ f"Disk: {disk.percent:.1f}% | CPU: {cpu:.1f}%"
54
+ )
55
+ # Cảnh báo khi RAM > 85%
56
+ if mem.percent > 85:
57
+ logger.warning(f"[Watchdog] ⚠️ HIGH RAM USAGE: {mem.percent:.1f}%!")
58
+ if disk.percent > 90:
59
+ logger.warning(f"[Watchdog] ⚠️ HIGH DISK USAGE: {disk.percent:.1f}%!")
60
+ except ImportError:
61
+ pass # psutil không có trên một số môi trường
62
+ except Exception as e:
63
+ logger.debug(f"[Watchdog] Resource check error: {e}")
64
+
65
+
66
+ def _handle_sigterm(signum, frame):
67
+ """Graceful shutdown khi HF Space restart container."""
68
+ logger.info("[Watchdog] SIGTERM received — graceful shutdown initiated.")
69
+ _stop_event.set()
70
+ # Flush log handlers
71
+ for handler in logging.root.handlers:
72
+ handler.flush()
73
+
74
+
75
+ def _watchdog_loop(app_url: str, ping_interval: int, resource_interval: int):
76
+ """Main watchdog loop chạy trong background thread."""
77
+ ping_counter = 0
78
+ resource_counter = 0
79
+
80
+ logger.info(f"[Watchdog] 🐕 Started. Ping interval: {ping_interval}s | "
81
+ f"Resource check: {resource_interval}s | Target: {app_url}")
82
+
83
+ while not _stop_event.is_set():
84
+ time.sleep(30) # Check mỗi 30 giây
85
+ ping_counter += 30
86
+ resource_counter += 30
87
+
88
+ if ping_counter >= ping_interval:
89
+ ping_counter = 0
90
+ ok = _ping_self(app_url)
91
+ logger.info(f"[Watchdog] Keepalive ping → {'✅ OK' if ok else '❌ FAILED'}")
92
+
93
+ if resource_counter >= resource_interval:
94
+ resource_counter = 0
95
+ _log_resources()
96
+
97
+ logger.info("[Watchdog] 🛑 Stopped.")
98
+
99
+
100
+ def start_watchdog(
101
+ app_url: str = None,
102
+ ping_interval: int = 600, # Ping mỗi 10 phút
103
+ resource_interval: int = 300, # Check resource mỗi 5 phút
104
+ ):
105
+ """
106
+ Khởi động watchdog daemon thread.
107
+
108
+ Args:
109
+ app_url: URL public của HF Space (vd: "https://cong123779-tienhiep-backend.hf.space")
110
+ Nếu None, tự detect từ env SPACE_HOST.
111
+ ping_interval: Số giây giữa các lần ping keepalive (mặc định 600s = 10 phút)
112
+ resource_interval: Số giây giữa các lần log resource (mặc định 300s = 5 phút)
113
+ """
114
+ global _watchdog_started
115
+ if _watchdog_started:
116
+ logger.debug("[Watchdog] Already running, skip.")
117
+ return
118
+
119
+ # Detect URL từ HF environment nếu không truyền
120
+ if not app_url:
121
+ space_host = os.environ.get("SPACE_HOST", "")
122
+ if space_host:
123
+ app_url = f"https://{space_host}"
124
+ else:
125
+ logger.info("[Watchdog] No SPACE_HOST env, running in local mode (no ping).")
126
+ app_url = "http://localhost:7860"
127
+
128
+ # Register SIGTERM handler để graceful shutdown
129
+ try:
130
+ signal.signal(signal.SIGTERM, _handle_sigterm)
131
+ except (ValueError, OSError):
132
+ pass # Không register được trong thread con
133
+
134
+ thread = threading.Thread(
135
+ target=_watchdog_loop,
136
+ args=(app_url, ping_interval, resource_interval),
137
+ daemon=True,
138
+ name="WatchdogKeepAlive"
139
+ )
140
+ thread.start()
141
+ _watchdog_started = True
142
+ logger.info(f"[Watchdog] 🐕 Daemon started (thread: {thread.name})")
backend/database/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # backend/database/__init__.py
backend/database/db_manager.py ADDED
@@ -0,0 +1,970 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import sqlite3
5
+ import threading
6
+ import logging
7
+ from collections import OrderedDict
8
+ from backend.config import Config
9
+
10
+ logger = logging.getLogger("db_manager")
11
+ logger.setLevel(logging.INFO)
12
+ if not logger.handlers:
13
+ handler = logging.StreamHandler(sys.stdout)
14
+ handler.setFormatter(logging.Formatter(
15
+ "[%(asctime)s] %(levelname)s [db_manager] %(message)s",
16
+ datefmt="%Y-%m-%d %H:%M:%S"
17
+ ))
18
+ logger.addHandler(handler)
19
+
20
+ DATABASE_URL = os.environ.get("DATABASE_URL", "")
21
+ USE_LOCAL_SQLITE = os.environ.get("USE_LOCAL_SQLITE", "false").lower() == "true"
22
+ if USE_LOCAL_SQLITE:
23
+ DATABASE_URL = ""
24
+ USER_DB_PATH = Config.USER_DB_PATH
25
+
26
+ # Retry & circuit breaker settings
27
+ MAX_RETRIES = 3
28
+ RETRY_BASE_DELAY = 0.5 # seconds
29
+ CIRCUIT_BREAKER_THRESHOLD = 5 # consecutive failures before opening circuit
30
+ CIRCUIT_BREAKER_RESET = 30 # seconds before retrying after circuit opens
31
+
32
+ # Circuit Breaker State
33
+ _lock = threading.Lock()
34
+ _failure_count = 0
35
+ _circuit_open_until = 0.0 # timestamp when circuit should try again
36
+ _last_mode = "unknown" # track for logging
37
+
38
+ def _is_circuit_open():
39
+ """Check if the circuit breaker is open (cloud DB unavailable)."""
40
+ global _failure_count, _circuit_open_until
41
+ with _lock:
42
+ if _failure_count >= CIRCUIT_BREAKER_THRESHOLD:
43
+ if time.time() < _circuit_open_until:
44
+ return True
45
+ return False
46
+ return False
47
+
48
+ def _record_success():
49
+ """Record a successful cloud DB connection — reset circuit breaker."""
50
+ global _failure_count, _circuit_open_until, _last_mode
51
+ with _lock:
52
+ if _failure_count > 0:
53
+ logger.info("✔ Supabase connection restored. Resetting circuit breaker.")
54
+ _failure_count = 0
55
+ _circuit_open_until = 0.0
56
+ _last_mode = "postgres"
57
+
58
+ def _record_failure():
59
+ """Record a failed cloud DB connection — increment circuit breaker."""
60
+ global _failure_count, _circuit_open_until, _last_mode
61
+ with _lock:
62
+ _failure_count += 1
63
+ if _failure_count >= CIRCUIT_BREAKER_THRESHOLD:
64
+ _circuit_open_until = time.time() + CIRCUIT_BREAKER_RESET
65
+ if _last_mode != "sqlite_fallback":
66
+ logger.warning(
67
+ f"⚡ Circuit breaker OPEN after {_failure_count} failures. "
68
+ f"Falling back to SQLite for {CIRCUIT_BREAKER_RESET}s."
69
+ )
70
+ _last_mode = "sqlite_fallback"
71
+
72
+ # PostgreSQL wrappers
73
+ class PgCursorWrapper:
74
+ def __init__(self, pg_cursor, lastrowid=None, conn_wrapper=None):
75
+ self._cur = pg_cursor
76
+ self._lastrowid = lastrowid
77
+ self._conn_wrapper = conn_wrapper
78
+
79
+ def execute(self, sql, parameters=None):
80
+ if self._conn_wrapper:
81
+ sql = self._conn_wrapper._translate_sql(sql)
82
+ if parameters:
83
+ self._cur.execute(sql, parameters)
84
+ else:
85
+ self._cur.execute(sql)
86
+ return self
87
+
88
+ def fetchone(self):
89
+ return self._cur.fetchone()
90
+
91
+ def fetchall(self):
92
+ return self._cur.fetchall()
93
+
94
+ @property
95
+ def lastrowid(self):
96
+ return self._lastrowid
97
+
98
+ @property
99
+ def description(self):
100
+ return self._cur.description
101
+
102
+ class PgConnectionWrapper:
103
+ def __init__(self, pg_conn, pool=None):
104
+ self._conn = pg_conn
105
+ self._pool = pool
106
+
107
+ @property
108
+ def row_factory(self):
109
+ return None
110
+
111
+ @row_factory.setter
112
+ def row_factory(self, val):
113
+ pass
114
+
115
+ def cursor(self):
116
+ import psycopg2.extras
117
+ cursor = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
118
+ return PgCursorWrapper(cursor, conn_wrapper=self)
119
+
120
+ def execute(self, sql, parameters=None):
121
+ sql_pg = self._translate_sql(sql)
122
+ is_insert = sql_pg.strip().upper().startswith("INSERT")
123
+ if is_insert and "RETURNING" not in sql_pg.upper():
124
+ if "USER_SETTINGS" in sql_pg.upper():
125
+ sql_pg += " RETURNING user_id"
126
+ else:
127
+ sql_pg += " RETURNING id"
128
+
129
+ import psycopg2.extras
130
+ cursor = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
131
+ sql_upper = sql_pg.upper()
132
+
133
+ start_time = time.time()
134
+ try:
135
+ if parameters:
136
+ cursor.execute(sql_pg, parameters)
137
+ elif "CREATE TABLE" in sql_upper or "ALTER TABLE" in sql_upper:
138
+ try:
139
+ cursor.execute("SAVEPOINT sp_ddl")
140
+ cursor.execute(sql_pg)
141
+ cursor.execute("RELEASE SAVEPOINT sp_ddl")
142
+ except Exception as e:
143
+ cursor.execute("ROLLBACK TO SAVEPOINT sp_ddl")
144
+ cursor.execute("RELEASE SAVEPOINT sp_ddl")
145
+ raise
146
+ else:
147
+ cursor.execute(sql_pg)
148
+ finally:
149
+ try:
150
+ from backend.core.profiler import record_db_time
151
+ record_db_time(time.time() - start_time)
152
+ except Exception:
153
+ pass
154
+
155
+ lastrowid = None
156
+ if is_insert:
157
+ try:
158
+ row = cursor.fetchone()
159
+ if row:
160
+ lastrowid = row[0]
161
+ except Exception:
162
+ pass
163
+
164
+ return PgCursorWrapper(cursor, lastrowid)
165
+
166
+ def commit(self):
167
+ start_time = time.time()
168
+ try:
169
+ self._conn.commit()
170
+ finally:
171
+ try:
172
+ from backend.core.profiler import record_db_time
173
+ record_db_time(time.time() - start_time)
174
+ except Exception:
175
+ pass
176
+
177
+ def close(self):
178
+ if self._pool is not None:
179
+ try:
180
+ try:
181
+ self._conn.rollback()
182
+ except Exception:
183
+ pass
184
+ self._pool.putconn(self._conn)
185
+ except Exception as e:
186
+ logger.error(f"Error returning connection to pool: {e}")
187
+ try:
188
+ self._conn.close()
189
+ except Exception:
190
+ pass
191
+ else:
192
+ self._conn.close()
193
+
194
+ @staticmethod
195
+ def _translate_sql(sql):
196
+ import re
197
+ if re.search(r"INSERT\s+OR\s+IGNORE\s+INTO\s+bookshelf", sql, re.IGNORECASE):
198
+ sql = re.sub(r"INSERT\s+OR\s+IGNORE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
199
+ if "ON CONFLICT" not in sql.upper():
200
+ sql += " ON CONFLICT (user_id, book_id) DO NOTHING"
201
+ elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+bookshelf", sql, re.IGNORECASE):
202
+ sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
203
+ if "ON CONFLICT" not in sql.upper():
204
+ sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, url = EXCLUDED.url"
205
+ elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+reading_history", sql, re.IGNORECASE):
206
+ sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
207
+ if "ON CONFLICT" not in sql.upper():
208
+ if "URL" in sql.upper():
209
+ sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, last_chapter = EXCLUDED.last_chapter, read_date = EXCLUDED.read_date, url = EXCLUDED.url"
210
+ else:
211
+ sql += " ON CONFLICT (user_id, book_id) DO UPDATE SET title = EXCLUDED.title, author = EXCLUDED.author, cover = EXCLUDED.cover, last_chapter = EXCLUDED.last_chapter, read_date = EXCLUDED.read_date, timestamp = EXCLUDED.timestamp"
212
+ elif re.search(r"INSERT\s+OR\s+REPLACE\s+INTO\s+friendships", sql, re.IGNORECASE):
213
+ sql = re.sub(r"INSERT\s+OR\s+REPLACE\s+INTO", "INSERT INTO", sql, flags=re.IGNORECASE)
214
+ if "ON CONFLICT" not in sql.upper():
215
+ sql += " ON CONFLICT (user_id, friend_id) DO UPDATE SET status = EXCLUDED.status"
216
+
217
+ sql_upper = sql.upper()
218
+ if "CREATE TABLE" in sql_upper:
219
+ sql = sql.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ")
220
+ sql = sql.replace("CREATE TABLE IF NOT EXISTS IF NOT EXISTS",
221
+ "CREATE TABLE IF NOT EXISTS")
222
+ sql = sql.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "SERIAL PRIMARY KEY")
223
+ sql = sql.replace("DATETIME DEFAULT CURRENT_TIMESTAMP",
224
+ "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
225
+ sql = sql.replace("DATETIME", "TIMESTAMP")
226
+
227
+ # Translate LIKE to ILIKE for case-insensitive matching on PostgreSQL
228
+ if " LIKE " in sql_upper:
229
+ sql = re.sub(r"\bLIKE\b", "ILIKE", sql, flags=re.IGNORECASE)
230
+
231
+ sql = sql.replace('?', '%s')
232
+ return sql
233
+
234
+ _pg_pool = None
235
+ _pool_lock = threading.Lock()
236
+
237
+ def _get_pg_pool():
238
+ global _pg_pool
239
+ if not DATABASE_URL:
240
+ return None
241
+ if _pg_pool is not None:
242
+ return _pg_pool
243
+ with _pool_lock:
244
+ if _pg_pool is None:
245
+ try:
246
+ from psycopg2.pool import ThreadedConnectionPool
247
+ _pg_pool = ThreadedConnectionPool(2, 20, DATABASE_URL, connect_timeout=5)
248
+ logger.info("✔ Threaded Supabase connection pool initialized successfully.")
249
+ except Exception as e:
250
+ logger.error(f"Failed to initialize Threaded connection pool: {e}")
251
+ _pg_pool = None
252
+ return _pg_pool
253
+
254
+ def _try_postgres_connect():
255
+ pool = _get_pg_pool()
256
+ if pool is None:
257
+ raise Exception("Supabase Connection Pool not initialized.")
258
+
259
+ conn = None
260
+ for i in range(3):
261
+ try:
262
+ conn = pool.getconn()
263
+ with conn.cursor() as cur:
264
+ cur.execute("SELECT 1")
265
+ break
266
+ except Exception as e:
267
+ logger.warning(f"Got unhealthy connection from pool: {e}. Closing and retrying...")
268
+ if conn:
269
+ try:
270
+ pool.putconn(conn, close=True)
271
+ except Exception:
272
+ pass
273
+ conn = None
274
+
275
+ if conn is None:
276
+ raise Exception("Failed to obtain a healthy connection from the pool.")
277
+
278
+ return PgConnectionWrapper(conn, pool)
279
+
280
+ class ProfiledSqliteConnection(sqlite3.Connection):
281
+ def execute(self, sql, *args):
282
+ start_time = time.time()
283
+ try:
284
+ return super().execute(sql, *args)
285
+ finally:
286
+ try:
287
+ from backend.core.profiler import record_db_time
288
+ record_db_time(time.time() - start_time)
289
+ except Exception:
290
+ pass
291
+
292
+ def _get_sqlite_conn():
293
+ conn = sqlite3.connect(USER_DB_PATH, timeout=10, factory=ProfiledSqliteConnection)
294
+ conn.execute("PRAGMA synchronous=NORMAL;")
295
+ conn.execute("PRAGMA synchronous=NORMAL;")
296
+ conn.execute("PRAGMA cache_size=-64000;")
297
+ conn.execute("PRAGMA temp_store=MEMORY;")
298
+ conn.row_factory = sqlite3.Row
299
+ return conn
300
+
301
+ def get_user_db_conn():
302
+ """Get user database connection with automatic failover."""
303
+ if not DATABASE_URL:
304
+ return _get_sqlite_conn()
305
+ if _is_circuit_open():
306
+ return _get_sqlite_conn()
307
+
308
+ last_error = None
309
+ for attempt in range(MAX_RETRIES):
310
+ try:
311
+ conn = _try_postgres_connect()
312
+ _record_success()
313
+ return conn
314
+ except Exception as e:
315
+ last_error = e
316
+ delay = RETRY_BASE_DELAY * (2 ** attempt)
317
+ if attempt < MAX_RETRIES - 1:
318
+ logger.warning(
319
+ f"Supabase connection attempt {attempt + 1}/{MAX_RETRIES} failed: "
320
+ f"{e}. Retrying in {delay:.1f}s..."
321
+ )
322
+ time.sleep(delay)
323
+
324
+ _record_failure()
325
+ logger.error(f"Supabase unreachable: {last_error}. Using SQLite fallback.")
326
+ return _get_sqlite_conn()
327
+
328
+ def health_check():
329
+ status = {
330
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
331
+ "database": {
332
+ "primary": "supabase_postgres" if DATABASE_URL else "sqlite_only",
333
+ "fallback": "sqlite_local",
334
+ "circuit_breaker": {
335
+ "state": "open" if _is_circuit_open() else "closed",
336
+ "failure_count": _failure_count,
337
+ "threshold": CIRCUIT_BREAKER_THRESHOLD,
338
+ },
339
+ },
340
+ "status": "healthy",
341
+ }
342
+ if DATABASE_URL and not _is_circuit_open():
343
+ try:
344
+ conn = _try_postgres_connect()
345
+ conn.close() # Returns to pool via PgConnectionWrapper
346
+ status["database"]["primary_reachable"] = True
347
+ except Exception as e:
348
+ status["database"]["primary_reachable"] = False
349
+ status["database"]["primary_error"] = str(e)
350
+ status["status"] = "degraded"
351
+ elif _is_circuit_open():
352
+ status["database"]["primary_reachable"] = False
353
+ status["status"] = "degraded"
354
+
355
+ # Use a lightweight check without opening a new connection
356
+ try:
357
+ if os.path.exists(USER_DB_PATH) and os.path.getsize(USER_DB_PATH) > 0:
358
+ status["database"]["fallback_reachable"] = True
359
+ else:
360
+ status["database"]["fallback_reachable"] = False
361
+ status["status"] = "degraded"
362
+ except Exception as e:
363
+ status["database"]["fallback_reachable"] = False
364
+ status["database"]["fallback_error"] = str(e)
365
+ status["status"] = "unhealthy"
366
+
367
+ return status
368
+
369
+ def _init_db_schema_for_conn(conn):
370
+ is_postgres = (type(conn).__name__ == "PgConnectionWrapper")
371
+ conn.execute("""
372
+ CREATE TABLE IF NOT EXISTS users (
373
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
374
+ username TEXT UNIQUE,
375
+ password_hash TEXT,
376
+ email TEXT,
377
+ phone TEXT,
378
+ google_id TEXT,
379
+ vip_status INTEGER DEFAULT 0,
380
+ vip_plan TEXT,
381
+ vip_expiry DATETIME,
382
+ email_verified INTEGER DEFAULT 0,
383
+ require_password_change INTEGER DEFAULT 0,
384
+ display_name TEXT,
385
+ birthday TEXT,
386
+ gender TEXT,
387
+ bio TEXT,
388
+ avatar_frame TEXT DEFAULT 'default',
389
+ avatar TEXT,
390
+ two_factor INTEGER DEFAULT 0,
391
+ api_balance REAL DEFAULT 0.0,
392
+ user_code TEXT UNIQUE,
393
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
394
+ )
395
+ """)
396
+ # Run database migrations for users columns if needed
397
+ if is_postgres:
398
+ # Postgres columns check
399
+ cursor = conn.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'users'")
400
+ columns = [row[0] for row in cursor.fetchall()]
401
+ else:
402
+ # SQLite columns check
403
+ cursor = conn.execute("PRAGMA table_info(users)")
404
+ columns = [row[1] for row in cursor.fetchall()]
405
+
406
+ if "email_verified" not in columns:
407
+ conn.execute("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0")
408
+ if "require_password_change" not in columns:
409
+ conn.execute("ALTER TABLE users ADD COLUMN require_password_change INTEGER DEFAULT 0")
410
+ if "display_name" not in columns:
411
+ conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT")
412
+ if "birthday" not in columns:
413
+ conn.execute("ALTER TABLE users ADD COLUMN birthday TEXT")
414
+ if "gender" not in columns:
415
+ conn.execute("ALTER TABLE users ADD COLUMN gender TEXT")
416
+ if "bio" not in columns:
417
+ conn.execute("ALTER TABLE users ADD COLUMN bio TEXT")
418
+ if "avatar_frame" not in columns:
419
+ conn.execute("ALTER TABLE users ADD COLUMN avatar_frame TEXT DEFAULT 'default'")
420
+ if "avatar" not in columns:
421
+ conn.execute("ALTER TABLE users ADD COLUMN avatar TEXT")
422
+ if "two_factor" not in columns:
423
+ conn.execute("ALTER TABLE users ADD COLUMN two_factor INTEGER DEFAULT 0")
424
+ if "api_balance" not in columns:
425
+ conn.execute("ALTER TABLE users ADD COLUMN api_balance REAL DEFAULT 0.0")
426
+ if "user_code" not in columns:
427
+ conn.execute("ALTER TABLE users ADD COLUMN user_code TEXT")
428
+ # Backfill existing users with random 7-digit codes
429
+ import random, string
430
+ existing_users = conn.execute("SELECT id FROM users WHERE user_code IS NULL").fetchall()
431
+ for u in existing_users:
432
+ while True:
433
+ code = ''.join(random.choices(string.digits, k=7))
434
+ clash = conn.execute("SELECT 1 FROM users WHERE user_code = ?", (code,)).fetchone()
435
+ if not clash:
436
+ conn.execute("UPDATE users SET user_code = ? WHERE id = ?", (code, u['id']))
437
+ break
438
+
439
+ conn.execute("""
440
+ CREATE TABLE IF NOT EXISTS bookshelf (
441
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
442
+ user_id INTEGER,
443
+ book_id INTEGER,
444
+ title TEXT,
445
+ cover TEXT,
446
+ author TEXT,
447
+ url TEXT,
448
+ added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
449
+ UNIQUE(user_id, book_id)
450
+ )
451
+ """)
452
+ conn.execute("""
453
+ CREATE TABLE IF NOT EXISTS reading_history (
454
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
455
+ user_id INTEGER,
456
+ book_id INTEGER,
457
+ title TEXT,
458
+ cover TEXT,
459
+ author TEXT,
460
+ last_chapter TEXT,
461
+ read_date TEXT,
462
+ url TEXT,
463
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
464
+ UNIQUE(user_id, book_id)
465
+ )
466
+ """)
467
+ conn.execute("""
468
+ CREATE TABLE IF NOT EXISTS payments (
469
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
470
+ user_id INTEGER NOT NULL,
471
+ order_id TEXT UNIQUE NOT NULL,
472
+ plan TEXT NOT NULL,
473
+ amount INTEGER NOT NULL,
474
+ status TEXT DEFAULT 'pending',
475
+ payment_method TEXT DEFAULT 'vietqr',
476
+ note TEXT,
477
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
478
+ completed_at DATETIME,
479
+ FOREIGN KEY (user_id) REFERENCES users(id)
480
+ )
481
+ """)
482
+ conn.execute("""
483
+ CREATE TABLE IF NOT EXISTS password_reset_tokens (
484
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
485
+ user_id INTEGER NOT NULL,
486
+ token TEXT UNIQUE NOT NULL,
487
+ expires_at DATETIME NOT NULL,
488
+ used INTEGER DEFAULT 0,
489
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
490
+ FOREIGN KEY (user_id) REFERENCES users(id)
491
+ )
492
+ """)
493
+ conn.execute("""
494
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
495
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
496
+ user_id INTEGER NOT NULL,
497
+ token TEXT UNIQUE NOT NULL,
498
+ expires_at DATETIME NOT NULL,
499
+ revoked INTEGER DEFAULT 0,
500
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
501
+ FOREIGN KEY (user_id) REFERENCES users(id)
502
+ )
503
+ """)
504
+ conn.execute("""
505
+ CREATE TABLE IF NOT EXISTS api_keys (
506
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
507
+ user_id INTEGER NOT NULL,
508
+ api_key TEXT UNIQUE NOT NULL,
509
+ name TEXT DEFAULT 'Default Key',
510
+ status TEXT DEFAULT 'active',
511
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
512
+ last_used_at DATETIME,
513
+ FOREIGN KEY (user_id) REFERENCES users(id)
514
+ )
515
+ """)
516
+ conn.execute("""
517
+ CREATE TABLE IF NOT EXISTS api_usage (
518
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
519
+ api_key TEXT NOT NULL,
520
+ model TEXT NOT NULL,
521
+ tokens INTEGER DEFAULT 0,
522
+ cost REAL DEFAULT 0.0,
523
+ status_code INTEGER,
524
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
525
+ FOREIGN KEY (api_key) REFERENCES api_keys(api_key)
526
+ )
527
+ """)
528
+ conn.execute("""
529
+ CREATE TABLE IF NOT EXISTS translation_history (
530
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
531
+ user_id INTEGER NOT NULL,
532
+ original_text TEXT NOT NULL,
533
+ translated_text TEXT NOT NULL,
534
+ mode TEXT DEFAULT 'fast',
535
+ characters INTEGER DEFAULT 0,
536
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
537
+ FOREIGN KEY (user_id) REFERENCES users(id)
538
+ )
539
+ """)
540
+ conn.execute("""
541
+ CREATE TABLE IF NOT EXISTS vocabulary (
542
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
543
+ user_id INTEGER NOT NULL,
544
+ original_text TEXT NOT NULL,
545
+ pinyin_or_hanviet TEXT,
546
+ translation TEXT NOT NULL,
547
+ context_sentence TEXT,
548
+ notes TEXT,
549
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
550
+ FOREIGN KEY (user_id) REFERENCES users(id)
551
+ )
552
+ """)
553
+ conn.execute("""
554
+ CREATE TABLE IF NOT EXISTS user_settings (
555
+ user_id INTEGER PRIMARY KEY,
556
+ theme TEXT DEFAULT 'dark',
557
+ default_language TEXT DEFAULT 'vi',
558
+ auto_read INTEGER DEFAULT 0,
559
+ font_size INTEGER DEFAULT 14,
560
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
561
+ FOREIGN KEY (user_id) REFERENCES users(id)
562
+ )
563
+ """)
564
+ conn.execute("""
565
+ CREATE TABLE IF NOT EXISTS usage_tracking (
566
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
567
+ user_id INTEGER NOT NULL,
568
+ source TEXT NOT NULL,
569
+ action TEXT NOT NULL,
570
+ duration INTEGER DEFAULT 0,
571
+ mode TEXT DEFAULT 'online',
572
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
573
+ FOREIGN KEY (user_id) REFERENCES users(id)
574
+ )
575
+ """)
576
+ conn.execute("""
577
+ CREATE TABLE IF NOT EXISTS login_history (
578
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
579
+ user_id INTEGER NOT NULL,
580
+ ip_address TEXT,
581
+ user_agent TEXT,
582
+ os TEXT,
583
+ browser TEXT,
584
+ device_type TEXT,
585
+ token TEXT,
586
+ status TEXT DEFAULT 'active',
587
+ login_time DATETIME DEFAULT CURRENT_TIMESTAMP,
588
+ last_active DATETIME DEFAULT CURRENT_TIMESTAMP,
589
+ FOREIGN KEY (user_id) REFERENCES users(id)
590
+ )
591
+ """)
592
+ conn.execute("""
593
+ CREATE TABLE IF NOT EXISTS friendships (
594
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
595
+ user_id INTEGER NOT NULL,
596
+ friend_id INTEGER NOT NULL,
597
+ status TEXT DEFAULT 'pending',
598
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
599
+ FOREIGN KEY (user_id) REFERENCES users(id),
600
+ FOREIGN KEY (friend_id) REFERENCES users(id),
601
+ UNIQUE(user_id, friend_id)
602
+ )
603
+ """)
604
+ conn.execute("""
605
+ CREATE TABLE IF NOT EXISTS direct_messages (
606
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
607
+ sender_id INTEGER NOT NULL,
608
+ receiver_id INTEGER NOT NULL,
609
+ message TEXT NOT NULL,
610
+ is_read INTEGER DEFAULT 0,
611
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
612
+ FOREIGN KEY (sender_id) REFERENCES users(id),
613
+ FOREIGN KEY (receiver_id) REFERENCES users(id)
614
+ )
615
+ """)
616
+ conn.execute("""
617
+ CREATE TABLE IF NOT EXISTS personal_notifications (
618
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
619
+ user_id INTEGER NOT NULL,
620
+ sender_id INTEGER,
621
+ type TEXT NOT NULL,
622
+ message TEXT NOT NULL,
623
+ related_id INTEGER,
624
+ is_read INTEGER DEFAULT 0,
625
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
626
+ FOREIGN KEY (user_id) REFERENCES users(id),
627
+ FOREIGN KEY (sender_id) REFERENCES users(id)
628
+ )
629
+ """)
630
+
631
+ for table in ["bookshelf", "reading_history"]:
632
+ try:
633
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN url TEXT")
634
+ except Exception:
635
+ pass
636
+
637
+ new_user_cols = [
638
+ ("email", "TEXT"),
639
+ ("phone", "TEXT"),
640
+ ("google_id", "TEXT"),
641
+ ("vip_plan", "TEXT"),
642
+ ("vip_expiry", "DATETIME"),
643
+ ("api_balance", "REAL DEFAULT 0.0"),
644
+ ]
645
+ for col_name, col_type in new_user_cols:
646
+ try:
647
+ conn.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}")
648
+ except Exception:
649
+ pass
650
+
651
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
652
+ conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_user_code ON users(user_code)")
653
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON reading_history(user_id)")
654
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_reading_history_url ON reading_history(url)")
655
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_bookshelf_user_id ON bookshelf(user_id)")
656
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_translation_history_user_id ON translation_history(user_id)")
657
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_vocabulary_user_id ON vocabulary(user_id)")
658
+
659
+ conn.execute("""
660
+ CREATE TABLE IF NOT EXISTS sects (
661
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
662
+ name TEXT UNIQUE,
663
+ slogan TEXT,
664
+ announcement TEXT,
665
+ badge TEXT,
666
+ leader_id INTEGER,
667
+ level INTEGER DEFAULT 1,
668
+ contribution INTEGER DEFAULT 0,
669
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
670
+ )
671
+ """)
672
+ conn.execute("""
673
+ CREATE TABLE IF NOT EXISTS sect_members (
674
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
675
+ sect_id INTEGER,
676
+ user_id INTEGER UNIQUE,
677
+ role TEXT DEFAULT 'member',
678
+ contribution INTEGER DEFAULT 0,
679
+ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
680
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
681
+ FOREIGN KEY (user_id) REFERENCES users(id)
682
+ )
683
+ """)
684
+ conn.execute("""
685
+ CREATE TABLE IF NOT EXISTS sect_messages (
686
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
687
+ sect_id INTEGER,
688
+ sender_id INTEGER,
689
+ message TEXT,
690
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
691
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
692
+ FOREIGN KEY (sender_id) REFERENCES users(id)
693
+ )
694
+ """)
695
+ conn.execute("""
696
+ CREATE TABLE IF NOT EXISTS sect_join_requests (
697
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
698
+ sect_id INTEGER,
699
+ user_id INTEGER,
700
+ status TEXT DEFAULT 'pending',
701
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
702
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
703
+ FOREIGN KEY (user_id) REFERENCES users(id),
704
+ UNIQUE(sect_id, user_id)
705
+ )
706
+ """)
707
+ conn.execute("""
708
+ CREATE TABLE IF NOT EXISTS sect_books (
709
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
710
+ sect_id INTEGER,
711
+ book_id INTEGER,
712
+ added_by INTEGER,
713
+ added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
714
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
715
+ FOREIGN KEY (added_by) REFERENCES users(id),
716
+ UNIQUE(sect_id, book_id)
717
+ )
718
+ """)
719
+
720
+ conn.execute("""
721
+ CREATE TABLE IF NOT EXISTS sect_chat_groups (
722
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
723
+ sect_id INTEGER,
724
+ name TEXT,
725
+ creator_id INTEGER,
726
+ members_csv TEXT,
727
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
728
+ FOREIGN KEY (sect_id) REFERENCES sects(id),
729
+ FOREIGN KEY (creator_id) REFERENCES users(id)
730
+ )
731
+ """)
732
+
733
+ conn.execute("""
734
+ CREATE TABLE IF NOT EXISTS book_comments (
735
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
736
+ book_id INTEGER NOT NULL,
737
+ user_id INTEGER,
738
+ is_anonymous INTEGER DEFAULT 0,
739
+ content_ciphertext TEXT NOT NULL,
740
+ encrypted_aes_key TEXT NOT NULL,
741
+ aes_nonce TEXT NOT NULL,
742
+ aes_tag TEXT NOT NULL,
743
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
744
+ )
745
+ """)
746
+
747
+
748
+ try:
749
+ conn.execute("ALTER TABLE sects ADD COLUMN announcement TEXT")
750
+ except Exception:
751
+ pass
752
+
753
+ for col_name, col_type in [("chat_type", "TEXT DEFAULT 'general'"), ("target_id", "INTEGER"), ("group_id", "INTEGER")]:
754
+ try:
755
+ conn.execute(f"ALTER TABLE sect_messages ADD COLUMN {col_name} {col_type}")
756
+ except Exception:
757
+ pass
758
+
759
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_members_sect_id ON sect_members(sect_id)")
760
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_members_user_id ON sect_members(user_id)")
761
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_messages_sect_id ON sect_messages(sect_id)")
762
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_books_sect_id ON sect_books(sect_id)")
763
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sect_join_requests_sect_id ON sect_join_requests(sect_id)")
764
+
765
+ conn.execute("""
766
+ CREATE TABLE IF NOT EXISTS app_releases (
767
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
768
+ platform TEXT,
769
+ version TEXT,
770
+ download_url TEXT,
771
+ patch_url TEXT,
772
+ file_size TEXT,
773
+ release_notes TEXT,
774
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
775
+ UNIQUE(platform, version)
776
+ )
777
+ """)
778
+ # Migration: thêm cột patch_url cho bảng cũ nếu chưa có
779
+ try:
780
+ conn.execute("ALTER TABLE app_releases ADD COLUMN patch_url TEXT")
781
+ except Exception:
782
+ pass # Cột đã tồn tại
783
+ try:
784
+ existing = conn.execute("SELECT COUNT(*) as count FROM app_releases").fetchone()
785
+ count = 0
786
+ if existing:
787
+ if isinstance(existing, dict):
788
+ count = existing.get('count', 0)
789
+ elif hasattr(existing, 'keys'):
790
+ count = existing['count']
791
+ else:
792
+ count = existing[0]
793
+ if count == 0:
794
+ conn.execute("""
795
+ INSERT INTO app_releases (platform, version, download_url, file_size, release_notes)
796
+ VALUES
797
+ ('extension', '1.0.0', '/downloads/tts_extension.zip', '10.7 MB', 'Cập nhật dịch nhanh và tối ưu hóa Chrome Extension Helper'),
798
+ ('desktop_linux', '0.0.0', 'https://huggingface.co/datasets/Cong123779/tienhiep-data/resolve/main/downloads/TienHiepAI-0.0.0.AppImage', '116 MB', 'Phiên bản AppImage beta dành cho Linux'),
799
+ ('desktop_windows', '0.0.0', '#', '0 MB', 'Bản Windows Setup .EXE chính thức sắp ra mắt')
800
+ """)
801
+ except Exception as e:
802
+ logger.warning(f"Failed to seed app_releases: {e}")
803
+
804
+ conn.commit()
805
+
806
+
807
+ def init_user_db():
808
+ conn = get_user_db_conn()
809
+ try:
810
+ _init_db_schema_for_conn(conn)
811
+ logger.info("✔ Database initialized successfully.")
812
+ except Exception as e:
813
+ logger.error(f"❌ Database initialization failed: {e}")
814
+ raise
815
+ finally:
816
+ conn.close()
817
+
818
+ is_postgres = (type(conn).__name__ == "PgConnectionWrapper")
819
+ if is_postgres:
820
+ try:
821
+ sqlite_conn = sqlite3.connect(USER_DB_PATH)
822
+ sqlite_conn.row_factory = sqlite3.Row
823
+ _init_db_schema_for_conn(sqlite_conn)
824
+ sqlite_conn.close()
825
+ logger.info("✔ SQLite fallback database synchronized successfully.")
826
+ except Exception as sqlite_err:
827
+ logger.warning(f"⚠️ Could not synchronize SQLite fallback database schema: {sqlite_err}")
828
+
829
+ # ---------------------------------------------------------------------------
830
+ # Books SQLite databases thread local caching
831
+ # ---------------------------------------------------------------------------
832
+ thread_local = threading.local()
833
+
834
+ class SimpleCache:
835
+ def __init__(self, maxsize=500):
836
+ self.cache = OrderedDict()
837
+ self.maxsize = maxsize
838
+ self.lock = threading.Lock()
839
+
840
+ def get(self, key):
841
+ with self.lock:
842
+ if key in self.cache:
843
+ self.cache.move_to_end(key)
844
+ return self.cache[key]
845
+ return None
846
+
847
+ def set(self, key, value):
848
+ with self.lock:
849
+ if key in self.cache:
850
+ self.cache.move_to_end(key)
851
+ self.cache[key] = value
852
+ if len(self.cache) > self.maxsize:
853
+ self.cache.popitem(last=False)
854
+
855
+ count_cache = SimpleCache(maxsize=1000)
856
+ query_cache = SimpleCache(maxsize=1000)
857
+ cached_stats = None
858
+
859
+ # Cache the resolved DB path to avoid re-reading YAML config on every call
860
+ _cached_db_path = None
861
+ _cached_db_path_time = 0
862
+ _DB_PATH_CACHE_DURATION = 60 # Re-check config every 60 seconds
863
+
864
+ def ensure_db_exists(db_path):
865
+ """Lazy download book SQLite databases from HuggingFace dataset if missing or incomplete."""
866
+ if os.path.exists(db_path) and os.path.getsize(db_path) > 1024 * 1024:
867
+ return
868
+
869
+ filename = os.path.basename(db_path)
870
+ token = os.environ.get("HF_TOKEN")
871
+ if not token:
872
+ logger.warning(f"⚠️ HF_TOKEN not set, cannot download database file: {filename}")
873
+ return
874
+
875
+ logger.info(f"⏳ Lazy-downloading database from HF: {filename}...")
876
+ try:
877
+ from huggingface_hub import hf_hub_download
878
+ hf_hub_download(
879
+ repo_id="Cong123779/tienhiep-data",
880
+ filename=filename,
881
+ repo_type="dataset",
882
+ token=token,
883
+ local_dir=Config.ROOT_DIR,
884
+ local_dir_use_symlinks=False,
885
+ )
886
+ logger.info(f"✔ Successfully downloaded {filename}")
887
+ except Exception as e:
888
+ logger.error(f"❌ Failed to lazy-download database {filename}: {e}")
889
+
890
+ def get_db():
891
+ global _cached_db_path, _cached_db_path_time
892
+
893
+ now = time.time()
894
+ if _cached_db_path is None or (now - _cached_db_path_time) > _DB_PATH_CACHE_DURATION:
895
+ db_path = Config.DB_PATH
896
+ try:
897
+ config_path = os.path.join(Config.ROOT_DIR, "quick_translator", "config.yaml")
898
+ if os.path.exists(config_path):
899
+ import yaml
900
+ with open(config_path, "r", encoding="utf-8") as f:
901
+ config = yaml.safe_load(f)
902
+ mode = config.get("translation", {}).get("mode", "advanced")
903
+ candidate_db = os.path.join(Config.ROOT_DIR, f"merged_books_{mode}.db")
904
+ ensure_db_exists(candidate_db)
905
+ if os.path.exists(candidate_db):
906
+ db_path = candidate_db
907
+ except Exception as e:
908
+ print(f"[WARNING] Error loading dynamic database path: {e}")
909
+
910
+ if db_path == Config.DB_PATH:
911
+ ensure_db_exists(db_path)
912
+
913
+ _cached_db_path = db_path
914
+ _cached_db_path_time = now
915
+ else:
916
+ db_path = _cached_db_path
917
+
918
+ if not hasattr(thread_local, "connections"):
919
+ thread_local.connections = {}
920
+
921
+ if db_path not in thread_local.connections:
922
+ # Final fallback check
923
+ if not os.path.exists(db_path):
924
+ ensure_db_exists(db_path)
925
+
926
+ if not os.path.exists(db_path):
927
+ # Create empty database to prevent crash
928
+ logger.warning(f"Creating fallback empty database for {db_path}")
929
+ conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
930
+ try:
931
+ conn.execute("CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY, title TEXT, author TEXT, categories TEXT, chapters_max INTEGER);")
932
+ except Exception:
933
+ pass
934
+ else:
935
+ conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
936
+
937
+ conn.row_factory = sqlite3.Row
938
+ conn.execute("PRAGMA synchronous=NORMAL;")
939
+ conn.execute("PRAGMA cache_size=-30000;")
940
+ conn.execute("PRAGMA temp_store=MEMORY;")
941
+ conn.execute("PRAGMA mmap_size=268435456;")
942
+ conn.execute("PRAGMA synchronous=OFF;")
943
+ try:
944
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_categories ON books(categories);")
945
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_chapters_max ON books(chapters_max);")
946
+ except Exception:
947
+ pass
948
+ thread_local.connections[db_path] = conn
949
+
950
+ return thread_local.connections[db_path]
951
+
952
+ def get_mode_connection(db_path):
953
+ if not hasattr(thread_local, "connections"):
954
+ thread_local.connections = {}
955
+
956
+ if db_path not in thread_local.connections:
957
+ ensure_db_exists(db_path)
958
+ if os.path.exists(db_path):
959
+ conn = sqlite3.connect(db_path, factory=ProfiledSqliteConnection)
960
+ conn.row_factory = sqlite3.Row
961
+ conn.execute("PRAGMA synchronous=NORMAL;")
962
+ conn.execute("PRAGMA cache_size=-10000;")
963
+ conn.execute("PRAGMA temp_store=MEMORY;")
964
+ conn.execute("PRAGMA mmap_size=134217728;")
965
+ conn.execute("PRAGMA synchronous=OFF;")
966
+ thread_local.connections[db_path] = conn
967
+ else:
968
+ return None
969
+
970
+ return thread_local.connections[db_path]
backend/engine/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .engine import VietphraseEngine
2
+ from .trie import Trie
backend/engine/engine.py ADDED
@@ -0,0 +1,820 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import re
4
+ import jieba.posseg as pseg
5
+
6
+ from backend.config import Config
7
+
8
+ NUM_RE = re.compile(r'^[0-9一二三四五六七八九十百千万几数多半两]+$')
9
+ PUNCT_SET = {',', '.', '!', '?', ';', ':', '"', '\'', '(', ')', '[', ']', '{', '}',
10
+ ',', '。', '!', '?', ';', ':', '“', '”', '‘', '’', '(', ')', '【', '】', '《', '》', '、', '—', '~'}
11
+
12
+
13
+ class SimpleToken:
14
+ def __init__(self, word, tag):
15
+ self.word = word
16
+ self.tag = tag
17
+ self.translated = None
18
+
19
+ @property
20
+ def flag(self):
21
+ return self.tag
22
+
23
+ @flag.setter
24
+ def flag(self, value):
25
+ self.tag = value
26
+
27
+ class VietphraseEngine:
28
+ def __init__(self, config=None):
29
+ self.config = config or {}
30
+ self.load_dictionaries()
31
+
32
+ # Check translation mode
33
+ self.translation_mode = self.config.get("translation", {}).get("mode", "advanced")
34
+ if self.config.get("translation", {}).get("fast_mode", False):
35
+ self.translation_mode = "fast"
36
+
37
+ # Warm-up jieba
38
+ import jieba
39
+ try:
40
+ # Fix segmenter splitting overlapping words like 重生于
41
+ jieba.add_word("重生", tag="v")
42
+ jieba.suggest_freq(("生", "于"), True)
43
+ jieba.suggest_freq(("着", "重"), True)
44
+ jieba.suggest_freq(("醉", "人"), True)
45
+ # Tag grades as nouns instead of proper names (nr)
46
+ jieba.add_word("高一", tag="n")
47
+ jieba.add_word("高二", tag="n")
48
+ jieba.add_word("高三", tag="n")
49
+ except Exception as e:
50
+ print("Error initializing custom word splits in Jieba:", e)
51
+
52
+ # Always initialize both tokenizers to support dynamic mode switching
53
+ self.jieba_tokenizer = jieba.dt
54
+ self.pseg_dict = pseg.dt.word_tag_tab
55
+ list(self.jieba_tokenizer.cut("暖洋洋"))
56
+ list(pseg.cut("暖洋洋"))
57
+
58
+ def load_dictionaries(self):
59
+ paths = self.config.get("paths", {}).get("dictionaries", {})
60
+ vp_path = paths.get("vietphrase", "")
61
+ if not vp_path or not os.path.isabs(vp_path):
62
+ vp_path = os.path.join(Config.ROOT_DIR, vp_path or "dictionaries/Vietphrase.txt")
63
+
64
+ dict_dir = os.path.dirname(vp_path)
65
+
66
+ # Check for encrypted .bin dictionaries first, then fallback to .txt
67
+ def load_file_content(base_name):
68
+ bin_file = os.path.join(dict_dir, base_name + ".bin")
69
+ txt_file = os.path.join(dict_dir, base_name + ".txt")
70
+
71
+ if os.path.exists(bin_file):
72
+ # Decrypt XOR
73
+ with open(bin_file, "rb") as f:
74
+ data = f.read()
75
+ key_bytes = "quick_translator_secret_key_2026".encode("utf-8")
76
+ key_len = len(key_bytes)
77
+ repeated_key = (key_bytes * (len(data) // key_len + 1))[:len(data)]
78
+ decrypted = bytes(a ^ b for a, b in zip(data, repeated_key))
79
+ return decrypted.decode("utf-8")
80
+ elif os.path.exists(txt_file):
81
+ with open(txt_file, "r", encoding="utf-8") as f:
82
+ return f.read()
83
+ return ""
84
+
85
+ print("Loading dictionaries in VietphraseEngine...")
86
+ self.char_dict = self.parse_dict_content(load_file_content("HanViet_CharDict"))
87
+
88
+ # --- ADD HÁn Nôm FALLBACK ---
89
+ import csv
90
+ han_csv_path = os.path.join(dict_dir, "han_all_readings.csv")
91
+ if os.path.exists(han_csv_path):
92
+ try:
93
+ with open(han_csv_path, 'r', encoding='utf-8') as f:
94
+ reader = csv.DictReader(f)
95
+ for row in reader:
96
+ char = row.get("Ký_tự", "").strip()
97
+ hv = row.get("Hán_Việt", "").strip()
98
+ if char and hv and char not in self.char_dict:
99
+ self.char_dict[char] = hv.replace("~", "")
100
+ print("Loaded han_all_readings.csv as fallback for missing Chinese characters.")
101
+ except Exception as e:
102
+ print("Could not load han_all_readings.csv:", e)
103
+
104
+ self.proper_names = self.parse_dict_content(load_file_content("Aligned_HanViet"), convert_to_simplified=True)
105
+
106
+ vp_content = load_file_content("Vietphrase")
107
+ self.vietphrase = self.parse_vietphrase_content(vp_content)
108
+ print("Dictionaries loaded successfully.")
109
+
110
+ # Build Tries for vietphrase and hanviet modes
111
+ from .trie import Trie
112
+ print("Building Tries for fast translation modes...")
113
+ self.vietphrase_trie = Trie()
114
+ # Insert proper names (priority 1)
115
+ for k, v in self.proper_names.items():
116
+ self.vietphrase_trie.insert(k, v, 1)
117
+ # Insert Vietphrase (priority 2 - higher)
118
+ for k, v in self.vietphrase.items():
119
+ self.vietphrase_trie.insert(k, v, 2)
120
+
121
+ self.hanviet_trie = Trie()
122
+ # Insert proper names (priority 2)
123
+ for k, v in self.proper_names.items():
124
+ self.hanviet_trie.insert(k, v, 2)
125
+ print("Tries built successfully.")
126
+
127
+ # Register proper names in Jieba dictionary for fast modes
128
+ import jieba
129
+ for name in self.proper_names:
130
+ jieba.add_word(name)
131
+
132
+ def parse_dict_content(self, content, convert_to_simplified=False):
133
+ dictionary = {}
134
+ if content:
135
+ to_simplified = lambda s: s
136
+ if convert_to_simplified:
137
+ try:
138
+ from hanziconv import HanziConv
139
+ to_simplified = HanziConv.toSimplified
140
+ except ImportError:
141
+ pass
142
+
143
+ for line in content.splitlines():
144
+ line = line.strip()
145
+ if not line or "=" not in line or line.startswith('#'):
146
+ continue
147
+ parts = line.split("=", 1)
148
+ key = parts[0].strip()
149
+ val = self.clean_annotation(parts[1].strip())
150
+ dictionary[to_simplified(key)] = val
151
+ return dictionary
152
+
153
+ def parse_vietphrase_content(self, content):
154
+ dictionary = {}
155
+ if content:
156
+ for line in content.splitlines():
157
+ line = line.strip()
158
+ if not line or "=" not in line or line.startswith('#'):
159
+ continue
160
+ parts = line.split("=", 1)
161
+ left = parts[0].strip()
162
+ right = self.clean_annotation(parts[1].strip())
163
+
164
+ if "," in left and "," in right:
165
+ keys = [k.strip() for k in left.split(",") if k.strip()]
166
+ vals = [v.strip() for v in right.split(",") if v.strip()]
167
+ if len(keys) == len(vals):
168
+ for k, v in zip(keys, vals):
169
+ dictionary[k] = v
170
+ continue
171
+ if left:
172
+ dictionary[left] = right
173
+ return dictionary
174
+
175
+ def is_number(self, word):
176
+ return bool(re.match(r'^[0-9一二三四五六七八九十百千万几数多半两]+$', word))
177
+
178
+ def capitalize_phrase(self, phrase):
179
+ chars = 'a-zA-ZàáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ'
180
+ pattern = f'[{chars}]+'
181
+ return re.sub(pattern, lambda m: m.group(0).capitalize(), phrase)
182
+
183
+ def clean_annotation(self, text, mode='vietphrase'):
184
+ if not text:
185
+ return ""
186
+ # 1. Parse curly braces {meaning:reading}
187
+ def repl_curly(match):
188
+ content = match.group(1)
189
+ if ':' in content:
190
+ parts = content.split(':', 1)
191
+ return parts[0].strip() if mode == 'vietphrase' else parts[1].strip()
192
+ return content.strip()
193
+
194
+ text = re.sub(r'\{([^{}]+)\}', repl_curly, text)
195
+
196
+ # 2. Strip (*...) annotations
197
+ text = re.sub(r'\s*\(\*[^)]*\)', '', text)
198
+
199
+ return text.strip()
200
+
201
+ def format_translation(self, raw_value, multi_option, word=None, prefer_hanviet=False):
202
+ if not raw_value:
203
+ return ""
204
+ options = [o for o in raw_value.split("/") if o.strip()]
205
+
206
+ # Deduplicate options while preserving order
207
+ seen = set()
208
+ deduped = []
209
+ for o in options:
210
+ if o not in seen:
211
+ seen.add(o)
212
+ deduped.append(o)
213
+
214
+ if not deduped:
215
+ return ""
216
+
217
+ if multi_option and len(deduped) > 1:
218
+ return f"{deduped[0]}[{'/'.join(deduped[1:])}]"
219
+
220
+ # If multi-option is False, we have a word of length >= 2, and prefer_hanviet is True, prefer Hán Việt alignment
221
+ if prefer_hanviet and word and len(word) >= 2 and len(deduped) > 1:
222
+ hv_sets = []
223
+ for char in word:
224
+ readings = set()
225
+ if char in self.char_dict:
226
+ for r in self.char_dict[char].split('/'):
227
+ r_clean = r.strip().lower()
228
+ if r_clean:
229
+ readings.add(r_clean)
230
+ if readings:
231
+ hv_sets.append(readings)
232
+
233
+ best_option = deduped[0]
234
+ best_score = -1
235
+
236
+ for opt in deduped:
237
+ opt_syllables = [w.strip().lower() for w in opt.split() if w.strip()]
238
+ score = 0
239
+ for r_set in hv_sets:
240
+ if any(r in opt_syllables for r in r_set):
241
+ score += 1
242
+ if score > best_score:
243
+ best_score = score
244
+ best_option = opt
245
+ if best_score > 0:
246
+ return best_option
247
+
248
+ return deduped[0]
249
+
250
+ def clean_punctuation_spacing(self, text):
251
+ if not text:
252
+ return text
253
+
254
+ # 1. Ensure exactly one space after commas, semicolons, colons, periods, question marks, and exclamation marks.
255
+ # Avoid inserting space if the next character is a closing bracket, closing quote, space, or another punctuation.
256
+ text = re.sub(r'([,;.:!?])(?=[^\s)\]}』】”"’])', r'\1 ', text)
257
+
258
+ # 2. Remove any accidental whitespace before these punctuation marks
259
+ text = re.sub(r'\s+([,;.:!?])', r'\1', text)
260
+
261
+ # 3. Clean spaces inside parentheses, brackets, and curly/double brackets (including Chinese quote styles)
262
+ text = re.sub(r'([(\[{『【«])\s+', r'\1', text)
263
+ text = re.sub(r'\s+([)\]}』】»])', r'\1', text)
264
+
265
+ # Ensure a space exists before opening brackets and after closing brackets when they border words/digits
266
+ text = re.sub(r'(?<=[^\s(\[{『【«])([(\[{『【«])', r' \1', text)
267
+ text = re.sub(r'([)\]}』】»])(?=[^\s.,;:!?)\]}』】»])', r'\1 ', text)
268
+
269
+ # 4. Standardize dashes/hyphens used as separators (e.g. "Artist - Song") to have one space on each side
270
+ text = re.sub(r'\s*-\s*', ' - ', text)
271
+
272
+ # 5. Clean up any duplicated/trailing whitespaces
273
+ text = re.sub(r'\s+', ' ', text).strip()
274
+
275
+ return text
276
+
277
+ def translate_sentence(self, sentence, multi_option=False, mode=None):
278
+ if not sentence or sentence.isspace():
279
+ return ""
280
+
281
+ # If the sentence doesn't contain any Chinese characters or symbols, preserve it as-is
282
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df\u3000-\u303f\uff00-\uffef]', sentence):
283
+ return sentence
284
+
285
+ # Segment into Chinese text blocks and non-Chinese text blocks
286
+ # Keep Chinese characters and Chinese specific punctuations in the translation segment
287
+ chinese_pattern = re.compile(r'([\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+)')
288
+ parts = chinese_pattern.split(sentence)
289
+
290
+ # Merge simple alphanumeric non-Chinese blocks into adjacent Chinese blocks
291
+ i = 1
292
+ while i < len(parts) - 1:
293
+ non_chinese = parts[i+1]
294
+ if re.match(r'^\s*[a-zA-Z0-9]+\s*$', non_chinese):
295
+ parts[i] = parts[i] + non_chinese + parts[i+2]
296
+ parts.pop(i+1)
297
+ parts.pop(i+1)
298
+ else:
299
+ i += 2
300
+
301
+ translated_parts = []
302
+ capitalize_next = True
303
+
304
+ for part in parts:
305
+ if not part:
306
+ continue
307
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df\u3000-\u303f\uff00-\uffef]', part):
308
+ # Non-Chinese segment -> preserve exactly
309
+ translated_parts.append(part)
310
+ # Check if it ends with sentence terminator
311
+ if re.search(r'[.!?]\s*$', part):
312
+ capitalize_next = True
313
+ elif part.strip():
314
+ capitalize_next = False
315
+ else:
316
+ # Chinese segment -> translate
317
+ trans = self._translate_pure_chinese_sentence(part, multi_option, mode, capitalize_first=capitalize_next)
318
+ translated_parts.append(trans)
319
+ # Check if it ends with sentence terminator
320
+ if re.search(r'[.!?]\s*$', part) or re.search(r'[.!?]\s*$', trans):
321
+ capitalize_next = True
322
+ else:
323
+ capitalize_next = False
324
+
325
+ return "".join(translated_parts)
326
+
327
+ def _translate_pure_chinese_sentence(self, sentence, multi_option=False, mode=None, capitalize_first=True):
328
+ if not sentence or sentence.isspace():
329
+ return ""
330
+
331
+ active_mode = mode or self.translation_mode
332
+
333
+ # Tokenization & Tagging depending on mode
334
+ if active_mode in ("advanced", "advanced_hanviet"):
335
+ raw_tokens = [SimpleToken(t.word, t.flag) for t in pseg.cut(sentence)]
336
+ else:
337
+ # "fast", "vietphrase", "hanviet" modes use the fast tokenizer
338
+ words = list(self.jieba_tokenizer.cut(sentence))
339
+ raw_tokens = []
340
+ for w in words:
341
+ if w in PUNCT_SET:
342
+ tag = 'x'
343
+ elif NUM_RE.match(w):
344
+ tag = 'm'
345
+ else:
346
+ tag = self.pseg_dict.get(w, 'n')
347
+ raw_tokens.append(SimpleToken(w, tag))
348
+
349
+ if not raw_tokens:
350
+ return ""
351
+
352
+ NUM_KEYWORDS = {"重", "阶", "品", "级", "层", "剑", "星", "转", "天", "色", "关", "重天"}
353
+ HANVIET_NUMBERS = {
354
+ '0': 'Không', '1': 'Nhất', '2': 'Nhị', '3': 'Tam', '4': 'Tứ', '5': 'Ngũ', '6': 'Lục', '7': 'Thất', '8': 'Bát', '9': 'Cửu', '10': 'Thập',
355
+ '一': 'Nhất', '二': 'Nhị', '三': 'Tam', '四': 'Tứ', '五': 'Ngũ', '六': 'Lục', '七': 'Thất', '八': 'Bát', '九': 'Cửu', '十': 'Thập',
356
+ '百': 'Bách', '千': 'Thiên', '万': 'Vạn', '萬': 'Vạn', '几': 'Vài', '数': 'Số', '多': 'Đa', '半': 'Bán', '两': 'Lưỡng', '兩': 'Lưỡng'
357
+ }
358
+ # Helper function to translate a single token
359
+ def translate_single_token(idx, tok, list_of_tokens):
360
+ word = tok.word
361
+ tag = tok.tag
362
+
363
+ # Punctuation
364
+ is_punct = (tag == 'x' or word in {',', '.', '!', '?', ';', ':', '"', '(', ')', '[', ']', '{', '}'})
365
+ if is_punct:
366
+ has_chinese = False
367
+ for char in word:
368
+ if char in self.char_dict:
369
+ has_chinese = True
370
+ break
371
+ if not has_chinese:
372
+ punct_map = {
373
+ ',': ',', '。': '.', '「': '"', '」': '"', '、': ',', '?': '?', '!': '!',
374
+ ':': ':', ';': ';', '“': '"', '”': '"', '(': '(', ')': ')'
375
+ }
376
+ tok.translated = punct_map.get(word, word)
377
+ return
378
+
379
+ # Rule for number + 人 (e.g. 几十人, 三人)
380
+ if len(word) > 1 and word.endswith('人') and self.is_number(word[:-1]):
381
+ num_part = word[:-1]
382
+ if num_part in self.vietphrase:
383
+ num_trans = self.format_translation(self.vietphrase[num_part], multi_option, num_part)
384
+ else:
385
+ num_trans = " ".join([self.char_dict.get(c, c).split("/")[0] for c in num_part])
386
+ tok.translated = f"{num_trans} người"
387
+ return
388
+
389
+ # Special rule for 了 (le vs liao)
390
+ if word == 'l' or word == '了':
391
+ is_at_end = True
392
+ for next_tok in list_of_tokens[idx+1:]:
393
+ if next_tok.word in {'"', '\'', '(', ')', '[', ']', '{', '}', '“', '”', '‘', '’', '(', ')', '【', '】', '《', '》'}:
394
+ continue
395
+ if next_tok.word in {',', '.', '!', '?', ';', ':', ',', '。', '!', '?', ';', ':', '、'}:
396
+ is_at_end = True
397
+ break
398
+ is_at_end = False
399
+ break
400
+ if is_at_end:
401
+ tok.translated = "rồi"
402
+ else:
403
+ tok.translated = "được"
404
+ return
405
+
406
+ # Cultivation Realm (cultivation)
407
+ if tag == 'cultivation':
408
+ result = []
409
+ for char in word:
410
+ if char in HANVIET_NUMBERS:
411
+ result.append(HANVIET_NUMBERS[char])
412
+ else:
413
+ cap_val = self.char_dict.get(char, char).split("/")[0].capitalize()
414
+ result.append(cap_val)
415
+ tok.translated = " ".join(result)
416
+ return
417
+
418
+ # Determine if it's a noun or an adjective
419
+ is_proper = (tag in {'nr', 'ns', 'nt'} if tag else False)
420
+ is_noun = (tag.startswith('n') if tag else False) or tag in {'n', 'nz', 'ng'} if tag else False
421
+ is_adj = tag in {'a', 'b', 'ad', 'an', 'z'} if tag else False
422
+ is_noun_or_adj = is_proper or is_noun or is_adj
423
+
424
+ # --- Chốt chặn cuối cùng cho Tên riêng (Proper Names Guard) ---
425
+ if is_proper:
426
+ if word in self.proper_names:
427
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
428
+ else:
429
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
430
+ tok.translated = word
431
+ else:
432
+ result = []
433
+ for char in word:
434
+ val = self.char_dict.get(char, char).split("/")[0]
435
+ result.append(val)
436
+ tok.translated = " ".join(result)
437
+ if tok.translated:
438
+ tok.translated = self.capitalize_phrase(tok.translated)
439
+
440
+ # --- Translate lookup strategy depending on active_mode (for non-proper names) ---
441
+ else:
442
+ if active_mode == 'hanviet':
443
+ # Mode 4: Pure Hán Việt (NO Vietphrase)
444
+ if word in self.proper_names:
445
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
446
+ else:
447
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
448
+ tok.translated = word
449
+ else:
450
+ result = []
451
+ for char in word:
452
+ val = self.char_dict.get(char, char).split("/")[0]
453
+ result.append(val)
454
+ tok.translated = " ".join(result)
455
+
456
+ elif active_mode == 'vietphrase':
457
+ # Mode 3: Prioritize Vietphrase (Traditional)
458
+ if word in self.vietphrase:
459
+ tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
460
+ elif word in self.proper_names:
461
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
462
+ else:
463
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
464
+ tok.translated = word
465
+ else:
466
+ result = []
467
+ for char in word:
468
+ val = self.char_dict.get(char, char).split("/")[0]
469
+ result.append(val)
470
+ tok.translated = " ".join(result)
471
+
472
+ else:
473
+ # Modes 1, 2 & 5: 'fast', 'advanced', or 'advanced_hanviet' (POS-based noun/adjective Hán Việt override)
474
+ if is_noun_or_adj:
475
+ # Nouns/Adjectives: Bypasses vietphrase
476
+ if word in self.proper_names:
477
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
478
+ else:
479
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
480
+ tok.translated = word
481
+ else:
482
+ result = []
483
+ for char in word:
484
+ val = self.char_dict.get(char, char).split("/")[0]
485
+ result.append(val)
486
+ tok.translated = " ".join(result)
487
+ else:
488
+ # Verbs and other parts of speech
489
+ if active_mode == 'advanced_hanviet':
490
+ # Prefer HanViet dictionary (proper_names) over Vietphrase
491
+ if word in self.proper_names:
492
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
493
+ elif word in self.vietphrase:
494
+ tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
495
+ else:
496
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
497
+ tok.translated = word
498
+ else:
499
+ result = []
500
+ for char in word:
501
+ val = self.char_dict.get(char, char).split("/")[0]
502
+ result.append(val)
503
+ tok.translated = " ".join(result)
504
+ else:
505
+ # Standard fast/advanced: vietphrase -> proper_names -> character fallback
506
+ if word in self.vietphrase:
507
+ tok.translated = self.format_translation(self.vietphrase[word], multi_option, word, prefer_hanviet=False)
508
+ elif word in self.proper_names:
509
+ tok.translated = self.format_translation(self.proper_names[word], multi_option, word, prefer_hanviet=True)
510
+ else:
511
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', word):
512
+ tok.translated = word
513
+ else:
514
+ result = []
515
+ for char in word:
516
+ val = self.char_dict.get(char, char).split("/")[0]
517
+ result.append(val)
518
+ tok.translated = " ".join(result)
519
+
520
+ # Strip trailing "đích" / "Đích" from modifier translations
521
+ if tok.translated and word.endswith('的') and len(word) > 1:
522
+ val = tok.translated
523
+ if val.lower().endswith(' đích'):
524
+ tok.translated = val[:-5]
525
+ elif val.lower().endswith('đích'):
526
+ tok.translated = val[:-4]
527
+
528
+ # Step 1: Group numeral phrases and cultivation terms FIRST
529
+ grouped = []
530
+ i = 0
531
+ while i < len(raw_tokens):
532
+ tok = raw_tokens[i]
533
+ word = tok.word
534
+ tag = tok.tag
535
+
536
+ if self.is_number(word) and i + 1 < len(raw_tokens) and raw_tokens[i+1].word in NUM_KEYWORDS:
537
+ grouped_word = word + raw_tokens[i+1].word
538
+ i_next = i + 2
539
+ if i_next < len(raw_tokens) and raw_tokens[i_next].tag in {'n', 'nr', 'ns', 'nt', 'nz'}:
540
+ grouped_word += raw_tokens[i_next].word
541
+ i_next += 1
542
+ grouped.append(SimpleToken(grouped_word, 'cultivation'))
543
+ i = i_next
544
+ else:
545
+ grouped.append(SimpleToken(word, tag))
546
+ i += 1
547
+
548
+ # Step 2: Translate individual tokens on the cultivation-grouped tokens
549
+ for idx, tok in enumerate(grouped):
550
+ translate_single_token(idx, tok, grouped)
551
+
552
+ # Step 3: Greedy merge adjacent tokens if their combination exists in dictionaries
553
+ i = 0
554
+ merged = []
555
+ while i < len(grouped):
556
+ matched = False
557
+ for length in range(min(4, len(grouped) - i), 1, -1):
558
+ combined_word = "".join([grouped[i+k].word for k in range(length)])
559
+
560
+ # Prevent merging across '的' particle to preserve root Hán Việt translation and allow reordering
561
+ should_skip = False
562
+ if 'đích' in combined_word or '的' in combined_word and combined_word.find('的') > 0:
563
+ should_skip = True
564
+ elif i + length < len(grouped) and grouped[i+length].word == '的':
565
+ # If next token is 'de' (de/的), don't merge if it would swallow a pronoun/noun/verb
566
+ last_tok = grouped[i+length-1]
567
+ if last_tok.flag in {'r', 'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'v'}:
568
+ should_skip = True
569
+ elif '是' in combined_word and any(p in combined_word for p in {'我', '你', 'he', 'she', 'it', '们', '您', '自己'}):
570
+ # Prevent merging copula + pronoun phrases (like '这是他', '那是我') to allow proper clause reordering
571
+ should_skip = True
572
+
573
+ # Dict check strategy depends on active_mode
574
+ if active_mode == 'hanviet':
575
+ in_dicts = (combined_word in self.proper_names)
576
+ else:
577
+ in_dicts = (combined_word in self.vietphrase or combined_word in self.proper_names)
578
+
579
+ if not should_skip and in_dicts:
580
+ combined_tag = None
581
+ try:
582
+ cut_res = list(pseg.cut(combined_word))
583
+ if cut_res:
584
+ combined_tag = cut_res[0].flag
585
+ except Exception:
586
+ pass
587
+ if not combined_tag:
588
+ combined_tag = grouped[i].flag
589
+ for k in range(length):
590
+ if grouped[i+k].flag in {'nr', 'ns', 'nt', 'nz'}:
591
+ combined_tag = grouped[i+k].flag
592
+ break
593
+ new_tok = SimpleToken(combined_word, combined_tag)
594
+ # Translate the new merged token immediately
595
+ translate_single_token(0, new_tok, [new_tok])
596
+ merged.append(new_tok)
597
+ i += length
598
+ matched = True
599
+ break
600
+ if not matched:
601
+ merged.append(grouped[i])
602
+ i += 1
603
+
604
+ # Step 4: Reordering Grammar Rules
605
+ if active_mode != 'hanviet':
606
+ # Pass 1: Adjective + Noun reordering
607
+ changed = True
608
+ while changed:
609
+ changed = False
610
+ i = 0
611
+ while i < len(merged) - 1:
612
+ t_a = merged[i]
613
+ t_n = merged[i+1]
614
+
615
+ # Do not swap with prepositions/conjunctions/copulas/particles
616
+ if t_n.word in {'跟', '和', '与', '與', '同', '在', '从', '從', '自', '由', '向', '往', '朝', '对', '對', '给', '給', '比', '是', '叫', '让', '讓', '被', '把', '使', '令', '到', '了', '的', '而', '&', '并', '並', '以', '或', '者'}:
617
+ i += 1
618
+ continue
619
+
620
+ if (t_a.tag in {'a', 'b'} or (t_a.word.endswith('的') and t_a.word != '的')) and t_n.tag in {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'v', 'vd', 'vg', 'vi', 'vn'}:
621
+ combined = t_n.translated + " " + t_a.translated
622
+ new_tok = SimpleToken(t_a.word + t_n.word, t_n.tag)
623
+ new_tok.translated = combined
624
+ merged[i:i+2] = [new_tok]
625
+ changed = True
626
+ break
627
+ i += 1
628
+
629
+ # Pass 2: "的" reordering (with multi-token noun/verb phrase lookahead)
630
+ NOUN_PHRASE_TAGS = {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'a', 'b', 'm', 'q', 'j', 'i'}
631
+ LOOKAHEAD_TAGS = NOUN_PHRASE_TAGS | {'v', 'vd', 'vg', 'vi', 'vn'}
632
+ i = 1
633
+ while i < len(merged) - 1:
634
+ tok = merged[i]
635
+ if tok.word in {'de', '的'}:
636
+ t_x = merged[i-1]
637
+ # Scan forward to collect all consecutive noun or verb phrase tokens
638
+ k = i + 1
639
+ has_noun = False
640
+ while k < len(merged):
641
+ tok_k = merged[k]
642
+ # Stop collecting if we hit a locality word/orientation noun
643
+ if tok_k.word in {'下', '上', '中', '里', '外', '内', '內', '后', '後', '前', '旁', '侧', '側', '底', '间', '間'}:
644
+ break
645
+
646
+ # If we already encountered a noun/verb in the phrase,
647
+ # we cannot have a subsequent adjective modifying that noun from the right.
648
+ if has_noun and tok_k.tag in {'a', 'b'}:
649
+ break
650
+
651
+ # Do not collect a verb tag if we already have a noun/verb head
652
+ is_verb_tag = tok_k.tag in {'v', 'vd', 'vg', 'vi', 'vn'}
653
+ if has_noun and is_verb_tag:
654
+ break
655
+
656
+ if tok_k.tag in LOOKAHEAD_TAGS or tok_k.word == '色':
657
+ if tok_k.tag in {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'v', 'vd', 'vg', 'vi', 'vn'}:
658
+ has_noun = True
659
+ k += 1
660
+ else:
661
+ break
662
+
663
+ is_verb_modifier = t_x.tag in {'v', 'vd', 'vg', 'vi', 'vn'}
664
+
665
+ # If we collected at least one token AND the modifier is not a verb clause
666
+ if k > i + 1 and not is_verb_modifier:
667
+ y_tokens = merged[i+1:k]
668
+ y_translated = " ".join([t.translated for t in y_tokens if t.translated])
669
+ y_word = "".join([t.word for t in y_tokens])
670
+
671
+ if t_x.tag != 'x':
672
+ start_idx = i - 1
673
+ j_back = i - 2
674
+ while j_back >= 0:
675
+ tag_back = merged[j_back].tag
676
+ if tag_back in {'n', 'nr', 'ns', 'nt', 'nz', 'ng', 'a', 'b', 'm', 'q', 'j', 'i', 's', 't'}:
677
+ start_idx = j_back
678
+ j_back -= 1
679
+ else:
680
+ break
681
+
682
+ modifier_tokens = merged[start_idx:i]
683
+ modifier_translated = " ".join([t.translated for t in modifier_tokens if t.translated])
684
+ modifier_word = "".join([t.word for t in modifier_tokens])
685
+
686
+ is_proper_or_pronoun = (
687
+ t_x.tag in {'nr', 'r'}
688
+ )
689
+ is_noun_modifier = is_proper_or_pronoun and not t_x.word.endswith('色')
690
+ if is_noun_modifier and start_idx == i - 1:
691
+ combined = y_translated + " của " + modifier_translated
692
+ else:
693
+ combined = y_translated + " " + modifier_translated
694
+
695
+ new_tok = SimpleToken(modifier_word + tok.word + y_word, 'n')
696
+ new_tok.translated = combined
697
+ merged[start_idx:k] = [new_tok]
698
+ continue
699
+ else:
700
+ # If we didn't reorder, set the '的' translation to empty string to avoid translating as 'đấy' / 'đích'
701
+ tok.translated = ""
702
+ i += 1
703
+
704
+ # Join words
705
+ translated_text = " ".join([t.translated for t in merged if t.translated])
706
+
707
+ # Clean spacing and punctuation
708
+ translated_text = self.clean_punctuation_spacing(translated_text)
709
+
710
+ # Capitalize sentences
711
+ sentences = re.split(r'([.!?]\s*)', translated_text)
712
+ start_idx = 0 if capitalize_first else 1
713
+ for idx in range(start_idx, len(sentences)):
714
+ s = sentences[idx]
715
+ if s and not s.isspace() and not s[0] in {'.', '!', '?'}:
716
+ for c_idx, char in enumerate(s):
717
+ if char.isalpha():
718
+ sentences[idx] = s[:c_idx] + char.upper() + s[c_idx+1:]
719
+ break
720
+ return "".join(sentences).strip()
721
+
722
+ def translate_paragraph(self, paragraph, multi_option=False, mode=None):
723
+ if not paragraph or paragraph.isspace():
724
+ return paragraph
725
+
726
+ active_mode = mode or self.translation_mode
727
+
728
+ if active_mode in ('vietphrase', 'hanviet'):
729
+ # Ultra-fast Trie-based translation path (50M+ characters/minute)
730
+ trie = self.vietphrase_trie if active_mode == 'vietphrase' else self.hanviet_trie
731
+ prefer_hanviet = (active_mode == 'hanviet')
732
+
733
+ i = 0
734
+ text_length = len(paragraph)
735
+ result_words = []
736
+
737
+ while i < text_length:
738
+ length, translation, priority = trie.search_longest_match(paragraph, i)
739
+ if length > 0:
740
+ word = paragraph[i:i+length]
741
+ formatted = self.format_translation(translation, multi_option, word, prefer_hanviet=prefer_hanviet)
742
+ # Capitalize if it is a proper name
743
+ if priority == 1 or word in self.proper_names:
744
+ formatted = self.capitalize_phrase(formatted)
745
+ result_words.append(formatted)
746
+ i += length
747
+ else:
748
+ char = paragraph[i]
749
+ if not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', char):
750
+ punct_map = {
751
+ ',': ',', '。': '.', '「': '"', '」': '"', '、': ',', '?': '?', '!': '!',
752
+ ':': ':', ';': ';', '“': '"', '”': '"', '(': '(', ')': ')',
753
+ '『': '"', '』': '"', '【': '[', '】': ']'
754
+ }
755
+ result_words.append(punct_map.get(char, char))
756
+ else:
757
+ val = self.char_dict.get(char, char).split("/")[0]
758
+ result_words.append(val)
759
+ i += 1
760
+
761
+ translated_text = " ".join(result_words)
762
+ translated_text = self.clean_punctuation_spacing(translated_text)
763
+
764
+ # Sentence Capitalization
765
+ sentences = re.split(r'([.!?]\s*)', translated_text)
766
+ for idx in range(len(sentences)):
767
+ s = sentences[idx]
768
+ if s and not s.isspace() and not s[0] in {'.', '!', '?'}:
769
+ for c_idx, char in enumerate(s):
770
+ if char.isalpha():
771
+ sentences[idx] = s[:c_idx] + char.upper() + s[c_idx+1:]
772
+ break
773
+ return "".join(sentences).strip()
774
+
775
+ # For advanced & fast modes, use normal sentence splitting & tokenization
776
+ sentence_ends = re.compile(r'([。!?!?]+)')
777
+ parts = sentence_ends.split(paragraph)
778
+
779
+ translated_parts = []
780
+ for part in parts:
781
+ if not part:
782
+ continue
783
+ if sentence_ends.match(part):
784
+ punct_map = {
785
+ '。': '.', '!': '!', '?': '?', ',': ','
786
+ }
787
+ translated_parts.append(punct_map.get(part, part))
788
+ else:
789
+ translated_parts.append(self.translate_sentence(part, multi_option, mode=mode))
790
+
791
+ return self.clean_punctuation_spacing("".join(translated_parts))
792
+
793
+ def translate_text_node(self, text, multi_option=False, mode=None):
794
+ """
795
+ Dich mot text node tu DOM.
796
+ BAO TOAN HOAN TOAN cau truc: xuong dong \n, khoang trang dau/cuoi tung dong.
797
+ """
798
+ if not text:
799
+ return text
800
+
801
+ # Tach theo \n truoc -> dich tung dong doc lap -> gop lai
802
+ lines = text.split('\n')
803
+ translated_lines = []
804
+ for line in lines:
805
+ leading = re.match(r'^\s*', line).group(0)
806
+ trailing = re.search(r'\s*$', line).group(0)
807
+ body = line.strip()
808
+
809
+ if not body:
810
+ translated_lines.append(line) # dong rong -> giu nguyen
811
+ elif not re.search(r'[\u3400-\u9fff\U00020000-\U0002a6df]', body):
812
+ translated_lines.append(line) # khong co chu Han -> giu nguyen
813
+ else:
814
+ translated_body = self.translate_paragraph(body, multi_option, mode=mode)
815
+ translated_lines.append(leading + translated_body + trailing)
816
+
817
+ return '\n'.join(translated_lines)
818
+
819
+ def translate(self, text, multi_option=False, mode=None):
820
+ return self.translate_text_node(text, multi_option=multi_option, mode=mode)
backend/engine/translate_api_server.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ import asyncio
5
+ from fastapi import FastAPI, HTTPException, Body
6
+ from fastapi.responses import StreamingResponse
7
+ from pydantic import BaseModel
8
+ from typing import List, Optional
9
+
10
+ # Add parent directory to path to import engine
11
+ current_dir = os.path.dirname(os.path.abspath(__file__))
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(current_dir)))
13
+
14
+ from backend.engine.engine import VietphraseEngine
15
+ from backend.config import Config
16
+
17
+ app = FastAPI(
18
+ title="Vietphrase Translation Standalone API Server",
19
+ description="Standalone API for fast Chinese to Vietnamese translation with 5 modes"
20
+ )
21
+
22
+ # Initialize engine
23
+ print("Lazy-loading Vietphrase Engine on API Startup...")
24
+ engine = VietphraseEngine()
25
+ print("Vietphrase Engine successfully loaded!")
26
+
27
+ class TranslateRequest(BaseModel):
28
+ texts: List[str]
29
+ mode: Optional[str] = "advanced"
30
+
31
+ @app.get("/health")
32
+ def health():
33
+ return {
34
+ "status": "ok",
35
+ "modes": ["advanced", "fast", "vietphrase", "hanviet", "advanced_hanviet"],
36
+ "dictionary_sizes": {
37
+ "char_dict": len(engine.char_dict),
38
+ "proper_names": len(engine.proper_names),
39
+ "vietphrase": len(engine.vietphrase)
40
+ }
41
+ }
42
+
43
+ @app.post("/v1/translate")
44
+ def translate(req: TranslateRequest):
45
+ if not req.texts:
46
+ raise HTTPException(status_code=400, detail="Missing 'texts' list")
47
+
48
+ translations = []
49
+ for text in req.texts:
50
+ if not text.strip():
51
+ translations.append(text)
52
+ else:
53
+ try:
54
+ translations.append(engine.translate(text, mode=req.mode))
55
+ except Exception as e:
56
+ translations.append(text)
57
+ print(f"Error translating text: {e}")
58
+
59
+ return {"translations": translations}
60
+
61
+ @app.post("/v1/translate_stream")
62
+ async def translate_stream(req: TranslateRequest):
63
+ if not req.texts:
64
+ raise HTTPException(status_code=400, detail="Missing 'texts' list")
65
+
66
+ async def event_generator():
67
+ for i, text in enumerate(req.texts):
68
+ if not text.strip():
69
+ trans = text
70
+ else:
71
+ try:
72
+ trans = engine.translate(text, mode=req.mode)
73
+ except Exception as e:
74
+ trans = text
75
+ print(f"Error streaming translation: {e}")
76
+
77
+ yield f"data: {json.dumps({'index': i, 'text': trans}, ensure_ascii=False)}\n\n"
78
+ await asyncio.sleep(0.001)
79
+
80
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
81
+
82
+ if __name__ == "__main__":
83
+ import uvicorn
84
+ port = int(os.environ.get("PORT", 8050))
85
+ uvicorn.run("translate_api_server:app", host="0.0.0.0", port=port, reload=False)
backend/engine/trie.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class TrieNode:
2
+ def __init__(self):
3
+ self.children = {}
4
+ self.translation = None
5
+ self.priority = 0 # Priority level: 0=none, 1=Vietphrase, 2=Names
6
+
7
+ class Trie:
8
+ def __init__(self):
9
+ self.root = TrieNode()
10
+
11
+ def insert(self, phrase_zh, translation_vi, priority):
12
+ """
13
+ Inserts a Chinese phrase and its translation into the Trie.
14
+ Overwrites existing translations if the new translation has higher or equal priority.
15
+ """
16
+ if not phrase_zh:
17
+ return
18
+
19
+ node = self.root
20
+ for char in phrase_zh:
21
+ if char not in node.children:
22
+ node.children[char] = TrieNode()
23
+ node = node.children[char]
24
+
25
+ # Prioritize names (2) over general phrases (1)
26
+ if node.translation is None or priority >= node.priority:
27
+ node.translation = translation_vi
28
+ node.priority = priority
29
+
30
+ def search_longest_match(self, text, start_index):
31
+ """
32
+ Finds the longest matching Chinese phrase starting from start_index.
33
+ Returns a tuple of (matched_length, translation, priority).
34
+ If no match is found, returns (0, None, 0).
35
+ """
36
+ node = self.root
37
+ longest_length = 0
38
+ longest_translation = None
39
+ longest_priority = 0
40
+
41
+ current_index = start_index
42
+ text_length = len(text)
43
+
44
+ while current_index < text_length:
45
+ char = text[current_index]
46
+ if char in node.children:
47
+ node = node.children[char]
48
+ current_index += 1
49
+ if node.translation is not None:
50
+ longest_length = current_index - start_index
51
+ longest_translation = node.translation
52
+ longest_priority = node.priority
53
+ else:
54
+ break
55
+
56
+ return longest_length, longest_translation, longest_priority
backend/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # backend/services/__init__.py
backend/services/alert_service.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import logging
4
+ from backend.config import Config
5
+ from backend.services.email_service import send_email_async
6
+
7
+ logger = logging.getLogger("backend.alert")
8
+
9
+ # Rate limit: 30 minutes in seconds
10
+ THROTTLE_INTERVAL = 30 * 60
11
+
12
+ # In-memory store for tracking the last time an alert of a specific type was sent
13
+ # format: { alert_type: timestamp }
14
+ _last_sent_alerts = {}
15
+
16
+ def send_alert_to_admin(alert_type: str, subject: str, message_html: str):
17
+ """
18
+ Sends an email alert to the system administrator.
19
+
20
+ Includes throttling to avoid spamming the admin email (maximum 1 alert per 30 minutes per type).
21
+ """
22
+ admin_email = os.environ.get("ADMIN_EMAIL") or Config.SMTP_CONFIG.get("username")
23
+ if not admin_email:
24
+ logger.warning(f"⚠️ [Alerting] No ADMIN_EMAIL or SMTP username configured. Skipping alert: {subject}")
25
+ return False
26
+
27
+ current_time = time.time()
28
+ last_sent = _last_sent_alerts.get(alert_type, 0)
29
+
30
+ if current_time - last_sent < THROTTLE_INTERVAL:
31
+ logger.info(f"🔇 [Alerting] Alert '{alert_type}' throttled. Last sent at: {last_sent}. Skipping...")
32
+ return False
33
+
34
+ # Update last sent timestamp
35
+ _last_sent_alerts[alert_type] = current_time
36
+
37
+ # Send email asynchronously
38
+ logger.info(f"🚨 [Alerting] Sending alert email to Admin ({admin_email}): {subject}")
39
+
40
+ # Append styling to alert email
41
+ styled_html = f"""
42
+ <div style="font-family: sans-serif; max-width: 600px; margin: auto; padding: 20px; border: 1px solid #ffcccc; border-radius: 8px; background-color: #fffafb;">
43
+ <h2 style="color: #d32f2f; border-bottom: 2px solid #d32f2f; padding-bottom: 10px; margin-top: 0;">Novel Translator Alert</h2>
44
+ <p><strong>Loại cảnh báo:</strong> <span style="background-color: #ffebee; color: #c62828; padding: 2px 6px; border-radius: 4px; font-family: monospace;">{alert_type}</span></p>
45
+ <div style="background-color: #fff; padding: 15px; border-left: 4px solid #d32f2f; margin: 15px 0; font-family: monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow-x: auto;">
46
+ {message_html}
47
+ </div>
48
+ <p style="font-size: 11px; color: #777; margin-top: 20px; border-top: 1px solid #eee; padding-top: 10px;">
49
+ Đây là email tự động gửi từ hệ thống giám sát trên VM. Cảnh báo này sẽ bị tạm khóa (throttled) trong 30 phút tiếp theo.
50
+ </p>
51
+ </div>
52
+ """
53
+
54
+ send_email_async(admin_email, f"[SYSTEM ALERT] {subject}", styled_html)
55
+ return True
56
+
57
+ def check_resources_and_alert():
58
+ """
59
+ Checks RAM and Disk metrics, and triggers alerts if they exceed thresholds (90%).
60
+ This should be called periodically or during health queries.
61
+ """
62
+ from backend.core.monitoring import get_memory_info, get_disk_info
63
+
64
+ try:
65
+ mem = get_memory_info()
66
+ if mem["percent"] >= 90.0:
67
+ subject = f"Cảnh báo: Bộ nhớ RAM cực kỳ thấp ({mem['percent']}%)"
68
+ body = f"Hệ thống đang chạy trên VM có lượng RAM khả dụng cực kỳ thấp.<br>" \
69
+ f"Tổng RAM: {mem['total_bytes'] / (1024*1024*1024):.2f} GB<br>" \
70
+ f"Đã dùng: {mem['used_bytes'] / (1024*1024*1024):.2f} GB ({mem['percent']}%)<br>" \
71
+ f"Tiến trình python RSS: {mem['process_rss_bytes'] / (1024*1024):.2f} MB"
72
+ send_alert_to_admin("low_memory", subject, body)
73
+
74
+ disk = get_disk_info()
75
+ if disk["percent"] >= 90.0:
76
+ subject = f"Cảnh báo: Dung lượng ổ đĩa sắp đầy ({disk['percent']}%)"
77
+ body = f"Ổ đĩa trên VM sắp hết bộ nhớ.<br>" \
78
+ f"Tổng dung lượng: {disk['total_bytes'] / (1024*1024*1024):.2f} GB<br>" \
79
+ f"Đã dùng: {disk['used_bytes'] / (1024*1024*1024):.2f} GB ({disk['percent']}%)<br>" \
80
+ f"Dung lượng trống: {disk['free_bytes'] / (1024*1024*1024):.2f} GB"
81
+ send_alert_to_admin("low_disk", subject, body)
82
+ except Exception as e:
83
+ logger.error(f"Error checking system resources: {e}")
backend/services/auth_service.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ from datetime import datetime, timedelta
3
+ from backend.config import Config
4
+ from backend.database.db_manager import get_user_db_conn
5
+
6
+ def check_vip_expiry(user_id: int, conn=None) -> bool:
7
+ """Check if a user's VIP has expired and deactivate if so."""
8
+ should_close = False
9
+ if conn is None:
10
+ conn = get_user_db_conn()
11
+ should_close = True
12
+
13
+ try:
14
+ conn.row_factory = sqlite3.Row
15
+ except Exception:
16
+ pass
17
+
18
+ user = conn.execute("SELECT vip_status, vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
19
+ if user and user["vip_status"] == 1 and user["vip_expiry"]:
20
+ try:
21
+ expiry_val = user["vip_expiry"]
22
+ if isinstance(expiry_val, str):
23
+ expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
24
+ else:
25
+ expiry = expiry_val
26
+
27
+ if datetime.utcnow() > expiry:
28
+ conn.execute("UPDATE users SET vip_status = 0, vip_plan = NULL WHERE id = ?", (user_id,))
29
+ if hasattr(conn, "commit"):
30
+ conn.commit()
31
+ if should_close:
32
+ conn.close()
33
+ return False # VIP expired
34
+ except Exception:
35
+ pass
36
+ if should_close:
37
+ conn.close()
38
+ return user["vip_status"] == 1 if user else False
39
+
40
+ def activate_vip(user_id: int, plan: str) -> bool:
41
+ """Activate VIP for a user based on the plan purchased."""
42
+ plan_info = Config.VIP_PLANS.get(plan)
43
+ if not plan_info:
44
+ # Check for lifetime manual plans
45
+ if "lifetime" in plan.lower():
46
+ duration_days = 99999
47
+ else:
48
+ return False
49
+ else:
50
+ duration_days = plan_info["duration_days"]
51
+
52
+ conn = get_user_db_conn()
53
+ conn.row_factory = sqlite3.Row
54
+ user = conn.execute("SELECT vip_expiry FROM users WHERE id = ?", (user_id,)).fetchone()
55
+
56
+ now = datetime.utcnow()
57
+ if user and user["vip_expiry"]:
58
+ try:
59
+ expiry_val = user["vip_expiry"]
60
+ if isinstance(expiry_val, str):
61
+ current_expiry = datetime.strptime(expiry_val, "%Y-%m-%d %H:%M:%S")
62
+ else:
63
+ current_expiry = expiry_val
64
+
65
+ if current_expiry > now:
66
+ new_expiry = current_expiry + timedelta(days=duration_days)
67
+ else:
68
+ new_expiry = now + timedelta(days=duration_days)
69
+ except Exception:
70
+ new_expiry = now + timedelta(days=duration_days)
71
+ else:
72
+ new_expiry = now + timedelta(days=duration_days)
73
+
74
+ conn.execute(
75
+ "UPDATE users SET vip_status = 1, vip_plan = ?, vip_expiry = ? WHERE id = ?",
76
+ (plan, new_expiry.strftime("%Y-%m-%d %H:%M:%S"), user_id)
77
+ )
78
+ conn.commit()
79
+ conn.close()
80
+ return True
backend/services/book_service.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import math
3
+ import unicodedata
4
+ from flask import jsonify
5
+ from backend.database.db_manager import get_db, query_cache, count_cache
6
+
7
+ CHINESE_CATEGORY_MAP = {
8
+ "言情穿越": "Ngôn tình Xuyên không",
9
+ "都市小说": "Đô thị",
10
+ "历史军事": "Lịch sử Quân sự",
11
+ "游戏竞技": "Võng du Cạnh tranh",
12
+ "游戏异界": "Dị giới Trò chơi",
13
+ "玄幻奇幻": "Huyền huyễn Kỳ huyễn",
14
+ "武侠仙侠": "Võ hiệp Tiên hiệp",
15
+ "科幻空间": "Khoa huyễn Không gian",
16
+ "悬疑灵异": "Huyền bí Linh dị",
17
+ "耽美纯爱": "Đam mỹ",
18
+ "同人小说": "Đồng nhân",
19
+ "都市": "Đô thị",
20
+ "言情": "Ngôn tình",
21
+ "穿越": "Xuyên không",
22
+ "历史": "Lịch sử",
23
+ "军事": "Quân sự",
24
+ "男生": "Nam sinh",
25
+ "女生": "Nữ sinh",
26
+ "游戏": "Trò chơi",
27
+ "竞技": "Cạnh tranh",
28
+ "玄幻": "Huyền huyễn",
29
+ "奇幻": "Kỳ huyễn",
30
+ "武侠": "Võ hiệp",
31
+ "仙侠": "Tiên hiệp",
32
+ "科幻": "Khoa huyễn",
33
+ "悬疑": "Huyền bí",
34
+ "灵异": "Linh dị",
35
+ "耽美": "Đam mỹ",
36
+ "轻小说": "Light Novel",
37
+ "同人": "Đồng nhân"
38
+ }
39
+
40
+ def translate_categories(categories_str):
41
+ if not categories_str:
42
+ return ""
43
+ parts = re.split(r'[,,/、\s]+', categories_str)
44
+ vi_parts = []
45
+ for p in parts:
46
+ p = p.strip()
47
+ if not p:
48
+ continue
49
+ matched = False
50
+ for zh, vi in CHINESE_CATEGORY_MAP.items():
51
+ if zh in p:
52
+ vi_parts.append(vi)
53
+ matched = True
54
+ break
55
+ if not matched:
56
+ try:
57
+ from backend.services.translation import get_engine
58
+ translated = get_engine().translate(p, multi_option=False)
59
+ vi_parts.append(translated)
60
+ except Exception:
61
+ vi_parts.append(p)
62
+ seen = set()
63
+ unique_parts = []
64
+ for vp in vi_parts:
65
+ if vp not in seen:
66
+ seen.add(vp)
67
+ unique_parts.append(vp)
68
+ return ", ".join(unique_parts)
69
+
70
+ CHINESE_TO_ENGLISH_CATEGORY_MAP = {
71
+ "言情穿越": "Romance & Time Travel",
72
+ "都市小说": "Urban",
73
+ "历史军事": "History & Military",
74
+ "游戏竞技": "Gaming & Competition",
75
+ "游戏异界": "Gaming & Otherworld",
76
+ "玄幻奇幻": "Xianxia & Fantasy",
77
+ "武侠仙侠": "Wuxia & Xianxia",
78
+ "科幻 space": "Sci-Fi Space",
79
+ "科幻空间": "Sci-Fi & Space",
80
+ "悬疑灵异": "Mystery & Supernatural",
81
+ "耽美纯爱": "Danmei Romance",
82
+ "同人小说": "Fan-fiction",
83
+ "都市": "Urban",
84
+ "言情": "Romance",
85
+ "穿越": "Time Travel",
86
+ "历史": "History",
87
+ "军事": "Military",
88
+ "男生": "Male Protagonist",
89
+ "女生": "Female Protagonist",
90
+ "游戏": "Gaming",
91
+ "竞技": "Competition",
92
+ "玄幻": "Fantasy",
93
+ "奇幻": "Eastern Fantasy",
94
+ "武侠": "Wuxia",
95
+ "仙侠": "Xianxia",
96
+ "科幻": "Sci-Fi",
97
+ "悬疑": "Mystery",
98
+ "灵异": "Supernatural",
99
+ "耽美": "Danmei",
100
+ "轻小说": "Light Novel",
101
+ "同人": "Fan-fiction"
102
+ }
103
+
104
+ def translate_categories_to_english(categories_str):
105
+ if not categories_str:
106
+ return ""
107
+ parts = re.split(r'[,,/、\s]+', categories_str)
108
+ en_parts = []
109
+ for p in parts:
110
+ p = p.strip()
111
+ if not p:
112
+ continue
113
+ matched = False
114
+ for zh, en in CHINESE_TO_ENGLISH_CATEGORY_MAP.items():
115
+ if zh in p:
116
+ en_parts.append(en)
117
+ matched = True
118
+ break
119
+ if not matched:
120
+ en_parts.append(p)
121
+ seen = set()
122
+ unique_parts = []
123
+ for ep in en_parts:
124
+ if ep not in seen:
125
+ seen.add(ep)
126
+ unique_parts.append(ep)
127
+ return ", ".join(unique_parts)
128
+
129
+ def remove_vietnamese_accents(text):
130
+ if not text:
131
+ return ""
132
+ # Normalize to decompose accents
133
+ nfkd_form = unicodedata.normalize('NFKD', text)
134
+ only_ascii = nfkd_form.encode('ASCII', 'ignore').decode('utf-8')
135
+ return only_ascii
136
+
137
+ def clean_vietnamese_query(text):
138
+ text = text.replace('đ', 'd').replace('Đ', 'D')
139
+ return "".join(c for c in unicodedata.normalize('NFKD', text) if not unicodedata.combining(c)).lower()
140
+
141
+ def parse_book_sources(book_dict):
142
+ parsed_sources = []
143
+ raw_urls = book_dict.get("urls", "")
144
+ if raw_urls:
145
+ parts = raw_urls.split(" | ")
146
+ for part in parts:
147
+ if ":" in part:
148
+ subparts = part.split(":", 1)
149
+ src_name = subparts[0].strip()
150
+ src_url = subparts[1].strip()
151
+ if src_url.startswith("//"):
152
+ src_url = "https:" + src_url
153
+ parsed_sources.append({
154
+ "source": src_name,
155
+ "url": src_url
156
+ })
157
+ book_dict["parsed_sources"] = parsed_sources
158
+
159
+ # Dịch thể loại sang tiếng Việt và tiếng Anh
160
+ book_dict["categories_vietphrase"] = translate_categories(book_dict.get("categories", ""))
161
+ book_dict["categories_english"] = translate_categories_to_english(book_dict.get("categories", ""))
162
+
163
+ # Sinh tên tác giả tiếng Anh (Hán Việt không dấu)
164
+ author_hv = book_dict.get("author_hanviet", "")
165
+ if author_hv:
166
+ book_dict["author_english"] = remove_vietnamese_accents(author_hv)
167
+ else:
168
+ book_dict["author_english"] = book_dict.get("author", "")
169
+
170
+ # Use pre-computed description from DB (do NOT translate on-the-fly for listings — too expensive)
171
+ desc = book_dict.get("description", "")
172
+ desc_vp = book_dict.get("description_vietphrase", "")
173
+ if not desc_vp and desc:
174
+ # Lightweight fallback: just truncate Chinese description, no translation engine call
175
+ desc_vp = desc[:120]
176
+ book_dict["description_vietphrase"] = desc_vp or ""
177
+
178
+ # Tạo fallback description_english bằng cách bỏ dấu bản Việt hóa
179
+ if desc_vp:
180
+ book_dict["description_english"] = remove_vietnamese_accents(desc_vp)
181
+ else:
182
+ book_dict["description_english"] = desc[:120] if desc else ""
183
+
184
+ return book_dict
185
+
186
+ def search_books(q, category, source, dup, sort, search_field, min_chapters, page, per_page, limit_k=100):
187
+ """Business logic for searching books with high-performance FTS5 or LIKE queries."""
188
+ MAX_PAGES_CEILING = 100
189
+ if page > MAX_PAGES_CEILING:
190
+ return {"error": "Deep pagination limit reached. Giới hạn tối đa 100 trang kết quả."}, 400
191
+
192
+ # Whitelist sort options
193
+ allowed_sorts = {
194
+ "site_count DESC", "chapters_max DESC", "word_count_max DESC", "title ASC", "title DESC", "id ASC"
195
+ }
196
+ if sort not in allowed_sorts:
197
+ sort = "site_count DESC"
198
+
199
+ where, params = [], []
200
+ fts_match_expr = None
201
+ has_chinese = False
202
+
203
+ if q:
204
+ has_chinese = any('\u4e00' <= char <= '\u9fff' for char in q)
205
+ if has_chinese:
206
+ # Use FTS5 for Chinese queries — 10x faster than LIKE scan on 931k rows
207
+ fts_q = q.replace('"', '').replace("'", '')
208
+ if search_field == "title":
209
+ fts_match_expr = f'title:"{fts_q}"'
210
+ elif search_field == "author":
211
+ fts_match_expr = f'author:"{fts_q}"'
212
+ elif search_field == "description":
213
+ # Description is NOT in FTS index — fallback to LIKE but only on description
214
+ pq = "%" + q + "%"
215
+ where.append("description LIKE ?")
216
+ params.append(pq)
217
+ else:
218
+ # Default: search title + author via FTS5 (fast)
219
+ fts_match_expr = f'title:"{fts_q}" OR author:"{fts_q}"'
220
+ else:
221
+ q_clean = clean_vietnamese_query(q)
222
+ fts_query = q_clean.replace('"', '').replace("'", '')
223
+ if fts_query:
224
+ if search_field == "title":
225
+ fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR title_vietphrase_clean:"{fts_query}"'
226
+ elif search_field == "author":
227
+ fts_match_expr = f'author_hanviet_clean:"{fts_query}"'
228
+ elif search_field == "hanviet":
229
+ fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR author_hanviet_clean:"{fts_query}"'
230
+ elif search_field == "vietphrase":
231
+ fts_match_expr = f'title_vietphrase_clean:"{fts_query}"'
232
+ elif search_field == "chinese":
233
+ fts_match_expr = f'title:"{q}" OR author:"{q}"'
234
+ elif search_field == "description":
235
+ where.append("(description LIKE ? OR description_vietphrase LIKE ? OR description_hanviet LIKE ?)")
236
+ pq = "%" + q + "%"
237
+ params += [pq, pq, pq]
238
+ else:
239
+ fts_match_expr = f'title_hanviet_clean:"{fts_query}" OR title_vietphrase_clean:"{fts_query}" OR author_hanviet_clean:"{fts_query}"'
240
+
241
+ if category:
242
+ where.append("categories LIKE ?")
243
+ params.append("%" + category + "%")
244
+ if source:
245
+ where.append("sources LIKE ?")
246
+ params.append("%" + source + "%")
247
+ if dup == "multi":
248
+ where.append("site_count >= 2")
249
+ elif dup == "single":
250
+ where.append("site_count = 1")
251
+ if min_chapters:
252
+ try:
253
+ min_ch_val = int(min_chapters)
254
+ where.append("chapters_max >= ?")
255
+ params.append(min_ch_val)
256
+ except ValueError:
257
+ pass
258
+
259
+ where_sql = ("WHERE " + " AND ".join(where)) if where else ""
260
+
261
+ if fts_match_expr:
262
+ cache_key = ("fts_search_v2", fts_match_expr, where_sql, page, per_page, tuple(params), sort, q, limit_k)
263
+ cached_res = query_cache.get(cache_key)
264
+ if cached_res is not None:
265
+ return cached_res, 200
266
+
267
+ conn = get_db()
268
+ query = f"""
269
+ SELECT b.*, f.score
270
+ FROM books b
271
+ JOIN (
272
+ SELECT rowid, bm25(books_fts) AS score
273
+ FROM books_fts
274
+ WHERE books_fts MATCH ?
275
+ ) f ON b.id = f.rowid
276
+ {where_sql}
277
+ LIMIT {limit_k}
278
+ """
279
+ rows = conn.execute(query, [fts_match_expr] + params).fetchall()
280
+
281
+ if not rows:
282
+ res_data = {"total": 0, "page": 1, "pages": 1, "books": []}
283
+ query_cache.set(cache_key, res_data)
284
+ return res_data, 200
285
+
286
+ scores = [r['score'] for r in rows]
287
+ best_score = min(scores)
288
+ threshold = best_score * 0.28
289
+
290
+ has_accents = any(c != clean_vietnamese_query(c) for c in q)
291
+
292
+ def clean_text_for_tier(txt):
293
+ if not txt:
294
+ return ""
295
+ txt = txt.replace('đ', 'd').replace('Đ', 'D')
296
+ txt = "".join(c for c in unicodedata.normalize('NFKD', txt) if not unicodedata.combining(c)).lower()
297
+ return " ".join(re.sub(r'[^a-z0-9\s]', ' ', txt).split())
298
+
299
+ q_clean_tier = clean_text_for_tier(q)
300
+ q_norm = q.lower().strip()
301
+
302
+ def get_tier(book):
303
+ t_hv_clean = clean_text_for_tier(book.get('title_hanviet', ''))
304
+ t_vp_clean = clean_text_for_tier(book.get('title_vietphrase', ''))
305
+ if t_hv_clean == q_clean_tier or t_vp_clean == q_clean_tier:
306
+ return 1
307
+ if t_hv_clean.startswith(q_clean_tier) or t_vp_clean.startswith(q_clean_tier):
308
+ return 2
309
+ if q_clean_tier in t_hv_clean or q_clean_tier in t_vp_clean:
310
+ return 3
311
+ return 4
312
+
313
+ filtered_books = []
314
+ for r in rows:
315
+ book_dict = dict(r)
316
+ tier = get_tier(book_dict)
317
+ score = book_dict['score']
318
+
319
+ if has_accents:
320
+ t_hv = (book_dict.get('title_hanviet') or '').lower()
321
+ t_vp = (book_dict.get('title_vietphrase') or '').lower()
322
+ auth = (book_dict.get('author_hanviet') or '').lower()
323
+ desc = (book_dict.get('description') or '').lower()
324
+ if q_norm not in t_hv and q_norm not in t_vp and q_norm not in auth and q_norm not in desc:
325
+ continue
326
+
327
+ if tier in (3, 4) and score > threshold:
328
+ continue
329
+
330
+ book_dict['tier'] = tier
331
+ filtered_books.append(book_dict)
332
+
333
+ if sort == "title ASC":
334
+ filtered_books.sort(key=lambda x: (x.get('title_hanviet') or '').lower())
335
+ elif sort == "title DESC":
336
+ filtered_books.sort(key=lambda x: (x.get('title_hanviet') or '').lower(), reverse=True)
337
+ elif sort == "chapters_max DESC":
338
+ filtered_books.sort(key=lambda x: x.get('chapters_max') or 0, reverse=True)
339
+ elif sort == "word_count_max DESC":
340
+ filtered_books.sort(key=lambda x: x.get('word_count_max') or 0, reverse=True)
341
+ else:
342
+ filtered_books.sort(key=lambda x: (x['tier'], x['score'], -x['site_count']))
343
+
344
+ total = len(filtered_books)
345
+ pages = max(1, math.ceil(total / per_page))
346
+ offset = (page - 1) * per_page
347
+ books_page = [parse_book_sources(b) for b in filtered_books[offset : offset + per_page]]
348
+
349
+ res_data = {"total": total, "page": page, "pages": pages, "books": books_page}
350
+ query_cache.set(cache_key, res_data)
351
+ return res_data, 200
352
+
353
+ else:
354
+ order_by_sql = sort
355
+ order_params = []
356
+ if q and has_chinese:
357
+ order_by_sql = f"""
358
+ CASE
359
+ WHEN title = ? THEN 1
360
+ WHEN title LIKE ? THEN 2
361
+ ELSE 3
362
+ END ASC, {sort}
363
+ """
364
+ order_params = [q, q + "%"]
365
+
366
+ cache_key = (where_sql, order_by_sql, page, per_page, tuple(params), tuple(order_params))
367
+ cached_res = query_cache.get(cache_key)
368
+ if cached_res is not None:
369
+ return cached_res, 200
370
+
371
+ count_cache_key = (where_sql, tuple(params))
372
+ total = count_cache.get(count_cache_key)
373
+ if total is None:
374
+ conn = get_db()
375
+ total = conn.execute(
376
+ "SELECT COUNT(*) FROM books " + where_sql, params
377
+ ).fetchone()[0]
378
+ count_cache.set(count_cache_key, total)
379
+
380
+ pages = max(1, math.ceil(total / per_page))
381
+ if pages > MAX_PAGES_CEILING:
382
+ pages = MAX_PAGES_CEILING
383
+ total = min(total, MAX_PAGES_CEILING * per_page)
384
+
385
+ offset = (page - 1) * per_page
386
+
387
+ conn = get_db()
388
+ rows = conn.execute(
389
+ "SELECT * FROM books " + where_sql +
390
+ " ORDER BY " + order_by_sql +
391
+ " LIMIT ? OFFSET ?",
392
+ params + order_params + [per_page, offset]
393
+ ).fetchall()
394
+
395
+ books = [parse_book_sources(dict(r)) for r in rows]
396
+ res_data = {"total": total, "page": page, "pages": pages, "books": books}
397
+ query_cache.set(cache_key, res_data)
398
+ return res_data, 200
backend/services/email_service.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import base64
3
+ import smtplib
4
+ import threading
5
+ from email.mime.text import MIMEText
6
+ from email.mime.multipart import MIMEMultipart
7
+ from backend.config import Config
8
+
9
+ # Service Account delegation config
10
+ SERVICE_ACCOUNT_FILE = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "gmail-delegator-key.json")
11
+ DELEGATED_USER_EMAIL = os.environ.get("GMAIL_DELEGATED_EMAIL", "support@lyvuha.com")
12
+
13
+ SCOPES = [
14
+ "https://www.googleapis.com/auth/gmail.send",
15
+ "https://www.googleapis.com/auth/gmail.readonly",
16
+ "https://www.googleapis.com/auth/gmail.modify"
17
+ ]
18
+
19
+ def get_gmail_service():
20
+ """Builds and returns a Gmail API service client impersonating the DELEGATED_USER_EMAIL."""
21
+ import json
22
+ from google.oauth2 import service_account
23
+ from googleapiclient.discovery import build
24
+
25
+ service_account_json = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "")
26
+ creds = None
27
+
28
+ try:
29
+ # Nếu cấu hình trực tiếp chuỗi JSON key trong biến môi trường
30
+ if service_account_json and service_account_json.strip().startswith("{"):
31
+ info = json.loads(service_account_json)
32
+ creds = service_account.Credentials.from_service_account_info(
33
+ info,
34
+ scopes=SCOPES
35
+ )
36
+ # Nếu cấu hình bằng đường dẫn file
37
+ elif os.path.exists(SERVICE_ACCOUNT_FILE):
38
+ creds = service_account.Credentials.from_service_account_file(
39
+ SERVICE_ACCOUNT_FILE,
40
+ scopes=SCOPES
41
+ )
42
+
43
+ if not creds:
44
+ return None
45
+
46
+ delegated_creds = creds.with_subject(DELEGATED_USER_EMAIL)
47
+ service = build('gmail', 'v1', credentials=delegated_creds)
48
+ return service
49
+ except Exception as e:
50
+ print(f"❌ Error initializing Gmail API Service: {e}")
51
+ return None
52
+
53
+
54
+ def send_email_smtp(to_email, subject, html_content):
55
+ """Sends an email using standard SMTP with App Password."""
56
+ smtp_cfg = Config.SMTP_CONFIG
57
+ if not smtp_cfg["username"] or not smtp_cfg["password"]:
58
+ print("⚠️ SMTP credentials not configured in Config.")
59
+ return False
60
+ try:
61
+ msg = MIMEMultipart("alternative")
62
+ msg["Subject"] = subject
63
+ msg["From"] = f"{smtp_cfg['from_name']} <{smtp_cfg['username']}>"
64
+ msg["To"] = to_email
65
+ msg.attach(MIMEText(html_content, "html", "utf-8"))
66
+
67
+ server = smtplib.SMTP(smtp_cfg["host"], smtp_cfg["port"])
68
+ server.starttls()
69
+ server.login(smtp_cfg["username"], smtp_cfg["password"])
70
+ server.sendmail(smtp_cfg["username"], to_email, msg.as_string())
71
+ server.quit()
72
+ print(f"📧 [SMTP] Successfully sent email to {to_email}")
73
+ return True
74
+ except Exception as e:
75
+ print(f"❌ [SMTP] Failed to send email: {e}")
76
+ return False
77
+
78
+ def send_email_gmail_api(to_email, subject, html_content):
79
+ """Sends an email using Gmail API via Service Account impersonation."""
80
+ service = get_gmail_service()
81
+ if not service:
82
+ return send_email_smtp(to_email, subject, html_content)
83
+ try:
84
+ message = MIMEText(html_content, 'html', 'utf-8')
85
+ message['to'] = to_email
86
+ message['from'] = DELEGATED_USER_EMAIL
87
+ message['subject'] = subject
88
+
89
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
90
+ body = {'raw': raw_message}
91
+
92
+ service.users().messages().send(userId='me', body=body).execute()
93
+ print(f"📧 [Gmail API] Successfully sent email to {to_email} from {DELEGATED_USER_EMAIL}")
94
+ return True
95
+ except Exception as e:
96
+ print(f"❌ [Gmail API] Failed to send email, falling back to SMTP: {e}")
97
+ return send_email_smtp(to_email, subject, html_content)
98
+
99
+
100
+ def send_email_gmail_oauth2(to_email, subject, html_content):
101
+ """Sends an email using Gmail REST API via OAuth2 Client ID/Secret and Refresh Token (over HTTPS 443)."""
102
+ client_id = os.environ.get("GOOGLE_CLIENT_ID", "")
103
+ client_secret = os.environ.get("GOOGLE_CLIENT_SECRET", "")
104
+ refresh_token = os.environ.get("GMAIL_REFRESH_TOKEN", "")
105
+ sender_email = os.environ.get("SMTP_EMAIL", "havucong@lyvuha.com")
106
+
107
+
108
+ if not refresh_token:
109
+ print("⚠️ GMAIL_REFRESH_TOKEN not configured.")
110
+ return False
111
+
112
+ try:
113
+ import base64
114
+ import requests
115
+
116
+ # 1. Get Access Token from Refresh Token
117
+ token_url = "https://oauth2.googleapis.com/token"
118
+ token_data = {
119
+ "client_id": client_id,
120
+ "client_secret": client_secret,
121
+ "refresh_token": refresh_token,
122
+ "grant_type": "refresh_token"
123
+ }
124
+ token_res = requests.post(token_url, data=token_data, timeout=5)
125
+ if token_res.status_code != 200:
126
+ print(f"❌ Failed to refresh Gmail access token: {token_res.text}")
127
+ return False
128
+
129
+ access_token = token_res.json().get("access_token")
130
+
131
+ # 2. Build MIME message
132
+ message = MIMEText(html_content, 'html', 'utf-8')
133
+ message['to'] = to_email
134
+ message['from'] = sender_email
135
+ message['subject'] = subject
136
+
137
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
138
+
139
+ # 3. Send email via Gmail API
140
+ send_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
141
+ headers = {
142
+ "Authorization": f"Bearer {access_token}",
143
+ "Content-Type": "application/json"
144
+ }
145
+ send_res = requests.post(send_url, headers=headers, json={"raw": raw_message}, timeout=5)
146
+ if send_res.status_code == 200:
147
+ print(f"📧 [Gmail OAuth2 API] Successfully sent email to {to_email}")
148
+ return True
149
+ else:
150
+ print(f"❌ [Gmail OAuth2 API] Failed to send email: {send_res.text}")
151
+ return False
152
+ except Exception as e:
153
+ print(f"❌ [Gmail OAuth2 API] Exception occurred: {e}")
154
+ return False
155
+
156
+
157
+ def send_email_async(to_email, subject, html_content):
158
+ """Helper to send emails asynchronously in a background thread."""
159
+ refresh_token = os.environ.get("GMAIL_REFRESH_TOKEN", "")
160
+ service_account_json = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "")
161
+
162
+ if refresh_token:
163
+ # Ưu tiên sử dụng OAuth2 Refresh Token (HTTPS port 443)
164
+ threading.Thread(target=send_email_gmail_oauth2, args=(to_email, subject, html_content)).start()
165
+ elif service_account_json or os.path.exists(SERVICE_ACCOUNT_FILE):
166
+ # Sử dụng Google Service Account (HTTPS port 443)
167
+ threading.Thread(target=send_email_gmail_api, args=(to_email, subject, html_content)).start()
168
+ else:
169
+ # Fallback về SMTP
170
+ threading.Thread(target=send_email_smtp, args=(to_email, subject, html_content)).start()
171
+
172
+
backend/services/epub_service.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import zipfile
4
+ import uuid
5
+ import html
6
+ import tempfile
7
+ import time
8
+
9
+ class EpubWriter:
10
+ def __init__(self, title, author="Unknown", cover_img_bytes=None, cover_ext="jpg", description=""):
11
+ self.title = title
12
+ self.author = author
13
+ self.description = description
14
+ self.chapters = [] # List of tuples (title, content)
15
+ self.cover_img_bytes = cover_img_bytes
16
+ self.cover_ext = cover_ext
17
+
18
+ def add_chapter(self, title, content_html):
19
+ self.chapters.append((title, content_html))
20
+
21
+ def write_to(self, dest_path):
22
+ with zipfile.ZipFile(dest_path, 'w', compression=zipfile.ZIP_DEFLATED) as epub:
23
+ # 1. mimetype (must be uncompressed and first entry)
24
+ epub.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
25
+
26
+ # 2. META-INF/container.xml
27
+ container_xml = """<?xml version="1.0" encoding="UTF-8"?>
28
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
29
+ <rootfiles>
30
+ <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
31
+ </rootfiles>
32
+ </container>"""
33
+ epub.writestr('META-INF/container.xml', container_xml)
34
+
35
+ # Generate NCX
36
+ ncx_entries = []
37
+ for i, (ch_title, _) in enumerate(self.chapters):
38
+ ncx_entries.append(f""" <navPoint id="navPoint-{i+1}" playOrder="{i+1}">
39
+ <navLabel><text>{html.escape(ch_title)}</text></navLabel>
40
+ <content src="text/chapter_{i+1}.xhtml"/>
41
+ </navPoint>""")
42
+
43
+ ncx_joined = "\n".join(ncx_entries)
44
+ toc_ncx = f"""<?xml version="1.0" encoding="UTF-8"?>
45
+ <ncx xmlns="http://www.safaribooksonline.com/ascent/1.0/ncx/" version="2005-1" xml:lang="vi">
46
+ <head>
47
+ <meta name="dtb:uid" content="urn:uuid:{uuid.uuid4()}"/>
48
+ <meta name="dtb:depth" content="1"/>
49
+ <meta name="dtb:totalPageCount" content="0"/>
50
+ <meta name="dtb:maxPageNumber" content="0"/>
51
+ </head>
52
+ <docTitle><text>{html.escape(self.title)}</text></docTitle>
53
+ <docAuthor><text>{html.escape(self.author)}</text></docAuthor>
54
+ <navMap>
55
+ <navPoint id="navPoint-0" playOrder="0">
56
+ <navLabel><text>Trang Tên Sách</text></navLabel>
57
+ <content src="text/titlepage.xhtml"/>
58
+ </navPoint>
59
+ {ncx_joined}
60
+ </navMap>
61
+ </ncx>"""
62
+ epub.writestr('OEBPS/toc.ncx', toc_ncx)
63
+
64
+ # Generate manifest items
65
+ manifest_items = [
66
+ '<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
67
+ '<item id="titlepage" href="text/titlepage.xhtml" media-type="application/xhtml+xml"/>',
68
+ ]
69
+ spine_items = [
70
+ '<itemref idref="titlepage"/>',
71
+ ]
72
+
73
+ if self.cover_img_bytes:
74
+ manifest_items.append(f'<item id="cover-image" href="images/cover.{self.cover_ext}" media-type="image/{self.cover_ext if self.cover_ext != "jpg" else "jpeg"}"/>')
75
+ manifest_items.append('<item id="coverpage" href="text/coverpage.xhtml" media-type="application/xhtml+xml"/>')
76
+ spine_items.insert(0, '<itemref idref="coverpage"/>')
77
+
78
+ for i in range(len(self.chapters)):
79
+ manifest_items.append(f'<item id="chapter_{i+1}" href="text/chapter_{i+1}.xhtml" media-type="application/xhtml+xml"/>')
80
+ spine_items.append(f'<itemref idref="chapter_{i+1}"/>')
81
+
82
+ # Generate content.opf
83
+ manifest_joined = "\n ".join(manifest_items)
84
+ spine_joined = "\n ".join(spine_items)
85
+ desc_meta = f'<meta name="description" content="{html.escape(self.description)}"/>' if self.description else ''
86
+ cover_meta = '<meta name="cover" content="cover-image"/>' if self.cover_img_bytes else ''
87
+
88
+ content_opf = f"""<?xml version="1.0" encoding="UTF-8"?>
89
+ <package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="2.0">
90
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
91
+ <dc:title>{html.escape(self.title)}</dc:title>
92
+ <dc:creator opf:role="aut">{html.escape(self.author)}</dc:creator>
93
+ <dc:language>vi</dc:language>
94
+ <dc:identifier id="bookid">urn:uuid:{uuid.uuid4()}</dc:identifier>
95
+ {desc_meta}
96
+ {cover_meta}
97
+ </metadata>
98
+ <manifest>
99
+ {manifest_joined}
100
+ </manifest>
101
+ <spine toc="ncx">
102
+ {spine_joined}
103
+ </spine>
104
+ </package>"""
105
+ epub.writestr('OEBPS/content.opf', content_opf)
106
+
107
+ # Write cover files
108
+ if self.cover_img_bytes:
109
+ epub.writestr(f'OEBPS/images/cover.{self.cover_ext}', self.cover_img_bytes)
110
+ coverpage_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
111
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
112
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="vi">
113
+ <head>
114
+ <title>Bìa Sách</title>
115
+ <style type="text/css">
116
+ body {{ margin: 0; padding: 0; text-align: center; background-color: #ffffff; }}
117
+ img {{ max-width: 100%; height: auto; max-height: 98vh; }}
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <div>
122
+ <img src="../images/cover.{self.cover_ext}" alt="Bìa sách"/>
123
+ </div>
124
+ </body>
125
+ </html>"""
126
+ epub.writestr('OEBPS/text/coverpage.xhtml', coverpage_xhtml)
127
+
128
+ # Title page
129
+ titlepage_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
130
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
131
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="vi">
132
+ <head>
133
+ <title>{html.escape(self.title)}</title>
134
+ <style type="text/css">
135
+ body {{ font-family: sans-serif; text-align: center; padding: 10% 5%; }}
136
+ h1 {{ font-size: 2.2em; margin-bottom: 0.5em; color: #1a237e; }}
137
+ h2 {{ font-size: 1.4em; color: #555; margin-bottom: 2em; }}
138
+ .desc {{ font-size: 1em; text-align: justify; margin: 2em auto; max-width: 85%; color: #333; line-height: 1.6; border: 1px solid #e0e0e0; padding: 15px; border-radius: 8px; background: #fafafa; }}
139
+ </style>
140
+ </head>
141
+ <body>
142
+ <h1>{html.escape(self.title)}</h1>
143
+ <h2>Tác giả: {html.escape(self.author)}</h2>
144
+ <hr style="width: 50%;"/>
145
+ {f'<div class="desc"><strong>Giới thiệu:</strong><br/>{html.escape(self.description)}</div>' if self.description else ''}
146
+ <p style="margin-top: 3em; font-style: italic; font-size: 0.9em; color: #777;">Được biên dịch & đóng gói tự động bằng VIP EPUB Tools</p>
147
+ </body>
148
+ </html>"""
149
+ epub.writestr('OEBPS/text/titlepage.xhtml', titlepage_xhtml)
150
+
151
+ # Write chapters XHTML
152
+ for i, (ch_title, ch_content) in enumerate(self.chapters):
153
+ if "</html>" in ch_content.lower() or "</p>" in ch_content.lower():
154
+ ch_xhtml = ch_content
155
+ else:
156
+ paragraphs = ch_content.split('\n')
157
+ body_html = ""
158
+ for p in paragraphs:
159
+ p_clean = p.strip()
160
+ if p_clean:
161
+ body_html += f"<p>{html.escape(p_clean)}</p>\n"
162
+
163
+ ch_xhtml = f"""<?xml version="1.0" encoding="UTF-8"?>
164
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
165
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="vi">
166
+ <head>
167
+ <title>{html.escape(ch_title)}</title>
168
+ <style type="text/css">
169
+ body {{ font-family: sans-serif; padding: 5%; line-height: 1.6; color: #222; }}
170
+ h3 {{ font-size: 1.3em; text-align: center; margin-bottom: 1.5em; border-bottom: 1px solid #ddd; padding-bottom: 0.5em; color: #1a237e; }}
171
+ p {{ text-indent: 2em; margin: 0.6em 0; text-align: justify; }}
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <h3>{html.escape(ch_title)}</h3>
176
+ {body_html}
177
+ </body>
178
+ </html>"""
179
+ epub.writestr(f'OEBPS/text/chapter_{i+1}.xhtml', ch_xhtml)
180
+
181
+ def apply_custom_dictionary(text, custom_dict):
182
+ """Replaces Chinese words with user-provided custom dictionary mappings."""
183
+ if not custom_dict or not text:
184
+ return text
185
+ for zh, vi in custom_dict:
186
+ if zh and zh in text:
187
+ text = text.replace(zh, vi)
188
+ return text
189
+
190
+ def clean_html_styles(html_content):
191
+ """Strips inline CSS styles, custom font faces, and stylesheets."""
192
+ cleaned = re.sub(r'\s+style="[^"]*"', '', html_content)
193
+ cleaned = re.sub(r"\s+style='[^']*'", '', cleaned)
194
+ cleaned = re.sub(r'\s+class="[^"]*"', '', cleaned)
195
+ cleaned = re.sub(r"\s+class='[^']*'", '', cleaned)
196
+ cleaned = re.sub(r'<style[^>]*>.*?</style>', '', cleaned, flags=re.DOTALL | re.IGNORECASE)
197
+ cleaned = re.sub(r'<link[^>]*rel="stylesheet"[^>]*>', '', cleaned, flags=re.IGNORECASE)
198
+ cleaned = re.sub(r'<link[^>]*rel=\'stylesheet\'[^>]*>', '', cleaned, flags=re.IGNORECASE)
199
+ return cleaned
200
+
201
+ def clean_punctuation_spacing(text):
202
+ if not text:
203
+ return text
204
+ text = re.sub(r'["“”‘’\'『』「」【】()\[\]{}<>]', '', text)
205
+ text = re.sub(r'([,;.:!?])(?=[^\s])', r'\1 ', text)
206
+ text = re.sub(r'\s+([,;.:!?])', r'\1', text)
207
+ text = re.sub(r'\s*-\s*', ' - ', text)
208
+ return re.sub(r'\s+', ' ', text).strip()
209
+
210
+ def translate_html_content_advanced(html_content, engine, mode=None, custom_dict=None, clean_styles=False):
211
+ """Parses HTML, extracts text segments, translates them, and preserves HTML tags."""
212
+ if clean_styles:
213
+ html_content = clean_html_styles(html_content)
214
+
215
+ parts = re.split(r'(<[^>]+>)', html_content)
216
+ translated_parts = []
217
+
218
+ for part in parts:
219
+ if not part:
220
+ continue
221
+ if part.startswith('<') and part.endswith('>'):
222
+ translated_parts.append(part)
223
+ else:
224
+ stripped = part.strip()
225
+ if stripped:
226
+ txt = apply_custom_dictionary(part, custom_dict)
227
+ translated_text = engine.translate(txt, multi_option=False, mode=mode)
228
+ translated_text = clean_punctuation_spacing(translated_text)
229
+ translated_parts.append(translated_text)
230
+ else:
231
+ translated_parts.append(part)
232
+
233
+ return "".join(translated_parts)
234
+
235
+ def translate_epub_file(src_epub, dest_epub, engine, mode=None, limit_chapters=-1, custom_dict=None, clean_styles=False, strip_images=False, strip_fonts=False):
236
+ """Translates an EPUB file and saves to dest_epub."""
237
+ start_time = time.time()
238
+
239
+ with zipfile.ZipFile(src_epub, 'r') as src_zip:
240
+ with zipfile.ZipFile(dest_epub, 'w', compression=zipfile.ZIP_DEFLATED) as dest_zip:
241
+ file_list = src_zip.namelist()
242
+
243
+ if 'mimetype' in file_list:
244
+ mimetype_data = src_zip.read('mimetype')
245
+ dest_zip.writestr('mimetype', mimetype_data, compress_type=zipfile.ZIP_STORED)
246
+
247
+ html_files = [f for f in file_list if f.endswith(('.html', '.xhtml', '.htm'))]
248
+
249
+ def natural_sort_key(s):
250
+ return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
251
+ html_files.sort(key=natural_sort_key)
252
+
253
+ target_html_files = set(html_files)
254
+ skipped_html_files = set()
255
+ if limit_chapters > 0 and len(html_files) > limit_chapters:
256
+ target_html_files = set(html_files[:limit_chapters])
257
+ skipped_html_files = set(html_files[limit_chapters:])
258
+
259
+ for file_name in file_list:
260
+ if file_name == 'mimetype':
261
+ continue
262
+
263
+ if strip_images and file_name.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg')):
264
+ continue
265
+
266
+ if strip_fonts and file_name.lower().endswith(('.ttf', '.otf', '.woff', '.woff2')):
267
+ continue
268
+
269
+ raw_data = src_zip.read(file_name)
270
+
271
+ if file_name in target_html_files:
272
+ try:
273
+ content = raw_data.decode('utf-8', errors='ignore')
274
+ translated_content = translate_html_content_advanced(
275
+ content, engine, mode=mode, custom_dict=custom_dict, clean_styles=clean_styles
276
+ )
277
+ dest_zip.writestr(file_name, translated_content.encode('utf-8'))
278
+ except Exception as e:
279
+ print(f"[EpubTool ERROR] Error translating {file_name}: {e}")
280
+ dest_zip.writestr(file_name, raw_data)
281
+ elif file_name in skipped_html_files:
282
+ placeholder = '<html><head><meta charset="utf-8"/></head><body><h3>[Chương này được bỏ qua trong bản dịch giới hạn]</h3></body></html>'
283
+ dest_zip.writestr(file_name, placeholder.encode('utf-8'))
284
+ elif file_name.endswith(('.ncx', '.opf', '.xml')):
285
+ try:
286
+ content = raw_data.decode('utf-8', errors='ignore')
287
+ translated_content = translate_html_content_advanced(
288
+ content, engine, mode=mode, custom_dict=custom_dict, clean_styles=False
289
+ )
290
+ dest_zip.writestr(file_name, translated_content.encode('utf-8'))
291
+ except Exception:
292
+ dest_zip.writestr(file_name, raw_data)
293
+ else:
294
+ dest_zip.writestr(file_name, raw_data)
295
+
296
+ return time.time() - start_time
297
+
298
+ def convert_txt_to_epub(txt_content, title, author, split_regex, dest_path, description="", engine=None, mode=None, custom_dict=None):
299
+ """Converts raw text file content to EPUB, optionally translating it first."""
300
+ lines = txt_content.split('\n')
301
+ chapters = []
302
+ current_chapter_title = "Giới thiệu / Khởi đầu"
303
+ current_chapter_lines = []
304
+
305
+ regex = re.compile(split_regex) if split_regex else None
306
+
307
+ for line in lines:
308
+ stripped = line.strip()
309
+ if not stripped:
310
+ continue
311
+
312
+ if regex and regex.search(stripped) and len(stripped) < 100:
313
+ if current_chapter_lines:
314
+ chapters.append((current_chapter_title, "\n".join(current_chapter_lines)))
315
+ current_chapter_title = stripped
316
+ current_chapter_lines = []
317
+ else:
318
+ current_chapter_lines.append(line)
319
+
320
+ if current_chapter_lines or not chapters:
321
+ chapters.append((current_chapter_title, "\n".join(current_chapter_lines)))
322
+
323
+ writer = EpubWriter(title=title, author=author, description=description)
324
+
325
+ for ch_title, ch_text in chapters:
326
+ if engine and mode:
327
+ ch_title_vi = apply_custom_dictionary(ch_title, custom_dict)
328
+ ch_title_vi = engine.translate(ch_title_vi, multi_option=False, mode=mode)
329
+
330
+ paragraphs = ch_text.split('\n')
331
+ paragraphs_vi = []
332
+ for p in paragraphs:
333
+ p_clean = p.strip()
334
+ if p_clean:
335
+ p_vi = apply_custom_dictionary(p_clean, custom_dict)
336
+ p_vi = engine.translate(p_vi, multi_option=False, mode=mode)
337
+ paragraphs_vi.append(p_vi)
338
+ ch_content_html = "\n".join(paragraphs_vi)
339
+ writer.add_chapter(ch_title_vi, ch_content_html)
340
+ else:
341
+ writer.add_chapter(ch_title, ch_text)
342
+
343
+ writer.write_to(dest_path)
backend/services/payment_service.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import json
3
+ import hmac
4
+ import hashlib
5
+ import requests
6
+ import sqlite3
7
+ from datetime import datetime
8
+ from backend.config import Config
9
+ from backend.database.db_manager import get_user_db_conn
10
+ from backend.services.auth_service import activate_vip
11
+
12
+ def verify_payos_webhook_signature(payload_data, expected_signature, checksum_key):
13
+ """Verify PayOS webhook signature using alphabet-sorted query string."""
14
+ sorted_keys = sorted(payload_data.keys())
15
+ parts = []
16
+ for k in sorted_keys:
17
+ v = payload_data[k]
18
+ if v is None or v == "null" or v == "undefined":
19
+ v_str = ""
20
+ elif isinstance(v, list):
21
+ sorted_list = []
22
+ for item in v:
23
+ if isinstance(item, dict):
24
+ sorted_item = {sub_k: item[sub_k] for sub_k in sorted(item.keys())}
25
+ sorted_list.append(sorted_item)
26
+ else:
27
+ sorted_list.append(item)
28
+ v_str = json.dumps(sorted_list, separators=(',', ':'), ensure_ascii=False)
29
+ elif isinstance(v, dict):
30
+ sorted_dict = {sub_k: v[sub_k] for sub_k in sorted(v.keys())}
31
+ v_str = json.dumps(sorted_dict, separators=(',', ':'), ensure_ascii=False)
32
+ else:
33
+ v_str = str(v)
34
+ parts.append(f"{k}={v_str}")
35
+
36
+ query_str = "&".join(parts)
37
+ computed_sig = hmac.new(
38
+ checksum_key.encode(), query_str.encode(), hashlib.sha256
39
+ ).hexdigest()
40
+ return hmac.compare_digest(computed_sig, expected_signature)
41
+
42
+ def create_payment_order(user_id: int, plan: str):
43
+ """Generates a payment order and produces the relevant PayOS link or fallback VietQR."""
44
+ plan_info = Config.VIP_PLANS.get(plan)
45
+ if not plan_info:
46
+ if plan.startswith("topup_"):
47
+ try:
48
+ price_str = plan.split("_")[1]
49
+ if price_str.endswith("k"):
50
+ amount = int(price_str[:-1]) * 1000
51
+ else:
52
+ amount = int(price_str)
53
+ plan_info = {
54
+ "name_vi": f"Nạp số dư API {amount:,}đ".replace(",", "."),
55
+ "price": amount
56
+ }
57
+ except Exception:
58
+ return {"error": "Invalid topup plan name. Use e.g. topup_50k or topup_50000"}, 400
59
+ else:
60
+ return {"error": "Invalid plan name"}, 400
61
+
62
+ amount = plan_info["price"]
63
+ order_code_int = int(time.time()) * 1000 + (int(user_id) % 1000)
64
+ order_id = str(order_code_int)
65
+
66
+ # Save to database
67
+ conn = get_user_db_conn()
68
+ conn.execute(
69
+ "INSERT INTO payments (user_id, order_id, plan, amount, status) VALUES (?, ?, ?, ?, 'pending')",
70
+ (user_id, order_id, plan, amount)
71
+ )
72
+ conn.commit()
73
+ conn.close()
74
+
75
+ payos_data = None
76
+ pay_cfg = Config.PAYMENT_CONFIG
77
+ client_id = pay_cfg.get("payos_client_id")
78
+ api_key = pay_cfg.get("payos_api_key")
79
+ checksum_key = pay_cfg.get("payos_checksum_key")
80
+
81
+ if client_id and api_key and checksum_key:
82
+ cancel_url = pay_cfg.get("payos_webhook_url").replace("/api/payment/webhook", "") or "http://localhost:5050"
83
+ return_url = pay_cfg.get("payos_webhook_url").replace("/api/payment/webhook", "") or "http://localhost:5050"
84
+ description = f"VIP {plan_info.get('name_vi', 'Premium')}"[:25]
85
+
86
+ raw_str = f"amount={amount}&cancelUrl={cancel_url}&description={description}&orderCode={order_code_int}&returnUrl={return_url}"
87
+ signature = hmac.new(
88
+ checksum_key.encode(), raw_str.encode(), hashlib.sha256
89
+ ).hexdigest()
90
+
91
+ payload = {
92
+ "orderCode": order_code_int,
93
+ "amount": amount,
94
+ "description": description,
95
+ "cancelUrl": cancel_url,
96
+ "returnUrl": return_url,
97
+ "signature": signature
98
+ }
99
+
100
+ headers = {
101
+ "x-client-id": client_id,
102
+ "x-api-key": api_key,
103
+ "Content-Type": "application/json"
104
+ }
105
+
106
+ try:
107
+ r = requests.post(
108
+ "https://api-merchant.payos.vn/v2/payment-requests",
109
+ json=payload,
110
+ headers=headers,
111
+ timeout=10
112
+ )
113
+ res = r.json()
114
+ if r.status_code == 200 and res.get("code") == "00":
115
+ payos_data = res.get("data")
116
+ except Exception as e:
117
+ print(f"[PayOS Request Exception]: {e}")
118
+
119
+ # Generate QR URL (Using PayOS hosted qrCode if available, else fallback to standard VietQR)
120
+ if payos_data and payos_data.get("qrCode"):
121
+ qr_url = f"https://api.qrserver.com/v1/create-qr-code/?size=250x250&data={requests.utils.quote(payos_data['qrCode'])}"
122
+ checkout_url = payos_data.get("checkoutUrl")
123
+ payos_bank = payos_data.get("bin", pay_cfg["bank_id"])
124
+ payos_account_no = payos_data.get("accountNumber", pay_cfg["account_no"])
125
+ payos_account_name = payos_data.get("accountName", pay_cfg["account_name"])
126
+ else:
127
+ transfer_content = order_id
128
+ qr_url = (
129
+ f"https://img.vietqr.io/image/{pay_cfg['bank_id']}-{pay_cfg['account_no']}-{pay_cfg['template']}.png"
130
+ f"?amount={amount}"
131
+ f"&addInfo={transfer_content}"
132
+ f"&accountName={pay_cfg['account_name'].replace(' ', '%20')}"
133
+ )
134
+ checkout_url = None
135
+ payos_bank = pay_cfg["bank_id"]
136
+ payos_account_no = pay_cfg["account_no"]
137
+ payos_account_name = pay_cfg["account_name"]
138
+
139
+ return {
140
+ "order_id": order_id,
141
+ "plan": plan,
142
+ "amount": amount,
143
+ "amount_formatted": f"{amount:,}đ".replace(",", "."),
144
+ "qr_url": qr_url,
145
+ "checkout_url": checkout_url,
146
+ "bank_info": {
147
+ "bank": payos_bank,
148
+ "account_no": payos_account_no,
149
+ "account_name": payos_account_name,
150
+ "transfer_content": order_id,
151
+ },
152
+ "expires_in": 900,
153
+ "message": f"Quét mã QR hoặc chuyển khoản {amount:,}đ với nội dung: {order_id}".replace(",", ".")
154
+ }, 200
155
+
156
+ def confirm_payment(order_id: str, amount: float = 0.0) -> bool:
157
+ """Updates the status of a pending payment order to completed and activates VIP."""
158
+ try:
159
+ conn = get_user_db_conn()
160
+ conn.row_factory = sqlite3.Row
161
+ payment = conn.execute(
162
+ "SELECT * FROM payments WHERE order_id = ? AND status = 'pending'", (order_id,)
163
+ ).fetchone()
164
+
165
+ if not payment:
166
+ conn.close()
167
+ print(f"⚠️ Payment {order_id} not found or already processed.")
168
+ return False
169
+
170
+ # Optional validation check for amount consistency
171
+ if amount > 0 and abs(amount - payment["amount"]) > 100:
172
+ conn.close()
173
+ print(f"❌ Amount mismatch for {order_id}: expected {payment['amount']}, got {amount}")
174
+ return False
175
+
176
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
177
+ conn.execute(
178
+ "UPDATE payments SET status = 'completed', completed_at = ? WHERE id = ?",
179
+ (now, payment["id"])
180
+ )
181
+ conn.commit()
182
+ conn.close()
183
+
184
+ user_id = payment["user_id"]
185
+ plan = payment["plan"]
186
+
187
+ # Activate VIP status for user
188
+ success = activate_vip(user_id, plan)
189
+ if success:
190
+ print(f"✅ [Payment Confirmation] Confirmed payment {order_id} & activated VIP.")
191
+ return True
192
+ return False
193
+ except Exception as e:
194
+ print(f"❌ Error confirming payment: {e}")
195
+ return False
backend/services/translation.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ import time
5
+ import requests as py_requests
6
+ from backend.config import Config
7
+
8
+ # Dynamic path addition for local translation engine
9
+ if Config.ROOT_DIR not in sys.path:
10
+ sys.path.append(Config.ROOT_DIR)
11
+
12
+ engine = None
13
+ translation_limit_tracker = {} # Format: { "IP:YYYY-MM-DD": count }
14
+ ADMIN_GEMINI_KEY = os.environ.get("ADMIN_GEMINI_KEY", "")
15
+
16
+ def get_engine():
17
+ global engine
18
+ if engine is None:
19
+ print("Lazy-loading Vietphrase Engine...")
20
+ from backend.engine.engine import VietphraseEngine
21
+ engine = VietphraseEngine()
22
+ return engine
23
+
24
+ def parse_custom_dict_text(dict_text):
25
+ if not dict_text:
26
+ return None
27
+ custom_dict = []
28
+ lines = dict_text.split('\n')
29
+ for line in lines:
30
+ line = line.strip()
31
+ if not line or '=' not in line:
32
+ continue
33
+ parts = line.split('=', 1)
34
+ zh = parts[0].strip()
35
+ vi = parts[1].strip()
36
+ if zh and vi:
37
+ custom_dict.append((zh, vi))
38
+ # Sort by key length descending to avoid prefix collision during greedy replacement
39
+ custom_dict.sort(key=lambda x: len(x[0]), reverse=True)
40
+ return custom_dict
41
+
42
+ def translate_texts(texts, mode=None):
43
+ eng = get_engine()
44
+ translations = []
45
+ for text in texts:
46
+ if not text.strip():
47
+ translations.append(text)
48
+ else:
49
+ try:
50
+ trans = eng.translate(text, multi_option=False, mode=mode)
51
+ translations.append(trans)
52
+ except Exception as e:
53
+ print(f"Error translating: {e}")
54
+ translations.append(text)
55
+ return translations
56
+
57
+ def translate_stream_generator(texts, mode=None):
58
+ eng = get_engine()
59
+ for i, text in enumerate(texts):
60
+ if not text.strip():
61
+ trans = text
62
+ else:
63
+ try:
64
+ trans = eng.translate(text, multi_option=False, mode=mode)
65
+ except Exception as e:
66
+ print(f"Error translating: {e}")
67
+ trans = text
68
+ yield f"data: {json.dumps({'index': i, 'text': trans}, ensure_ascii=False)}\n\n"
69
+ time.sleep(0.001)
70
+
71
+ def call_ai_chat_proxy(messages, model="gemini-1.5-flash", prompt=""):
72
+ if not ADMIN_GEMINI_KEY:
73
+ raise ValueError("Admin Gemini key not configured on server.")
74
+
75
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={ADMIN_GEMINI_KEY}"
76
+
77
+ contents = []
78
+ for msg in messages:
79
+ contents.append({
80
+ "role": "user" if msg.get("role") == "user" else "model",
81
+ "parts": [{"text": msg.get("text", "")}]
82
+ })
83
+
84
+ payload = {
85
+ "contents": contents,
86
+ "systemInstruction": {
87
+ "parts": [{"text": prompt}]
88
+ }
89
+ }
90
+
91
+ res = py_requests.post(url, json=payload, timeout=30)
92
+ if res.status_code != 200:
93
+ raise RuntimeError(f"Gemini API returned error: {res.text}")
94
+
95
+ gemini_data = res.json()
96
+ response_text = gemini_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "Không nhận được phản hồi.")
97
+ return response_text
backend/test_sects_flow.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests, time, sys, os
2
+ BASE_URL = "http://localhost:5051"
3
+ ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
4
+ sys.path.append(ROOT_DIR)
5
+ from backend.database.db_manager import get_user_db_conn
6
+
7
+ H = {"X-Bypass-Rate-Limit": "tienhiep_bypass_secret_9988"}
8
+
9
+ def step(msg): print(f"\n{'='*60}\n👉 {msg}\n{'='*60}")
10
+ def ok(msg): print(f" ✅ {msg}")
11
+ def info(msg): print(f" 📌 {msg}")
12
+
13
+ def verify_db(username):
14
+ conn = get_user_db_conn()
15
+ conn.execute("UPDATE users SET email_verified = 1 WHERE username = ?", (username,))
16
+ conn.commit(); conn.close()
17
+
18
+ class C:
19
+ def __init__(self, u, p):
20
+ self.u, self.p, self.token, self.h, self.uid = u, p, None, dict(H), None
21
+ def register(self):
22
+ r = requests.post(f"{BASE_URL}/api/auth/register", json={"username":self.u,"password":self.p,"email":f"{self.u}@test.com"}, headers=self.h)
23
+ if r.status_code == 200 or "đã tồn tại" in r.text: verify_db(self.u)
24
+ def login(self):
25
+ r = requests.post(f"{BASE_URL}/api/auth/login", json={"username":self.u,"password":self.p}, headers=self.h)
26
+ assert r.status_code == 200, f"Login fail {self.u}: {r.text}"
27
+ d = r.json(); self.token = d.get("access_token") or d.get("token")
28
+ self.h = {"Authorization":f"Bearer {self.token}", **H}; self.uid = d.get("user",{}).get("id")
29
+ ok(f"Login {self.u} (ID:{self.uid})")
30
+ def post(self, ep, data=None): return requests.post(f"{BASE_URL}{ep}", json=data, headers=self.h)
31
+ def get(self, ep, params=None): return requests.get(f"{BASE_URL}{ep}", params=params, headers=self.h)
32
+
33
+ def cleanup():
34
+ # Use API-based cleanup - leave all sects via HTTP
35
+ for n in ["tst_leader","tst_vice","tst_elder","tst_inner1","tst_inner2","tst_outer"]:
36
+ try:
37
+ # Login
38
+ r = requests.post(f"{BASE_URL}/api/auth/login", json={"username":n,"password":"pw123"}, headers=H)
39
+ if r.status_code != 200: continue
40
+ tok = r.json().get("access_token") or r.json().get("token")
41
+ ah = {"Authorization":f"Bearer {tok}", **H}
42
+ # Check if in sect
43
+ r = requests.get(f"{BASE_URL}/api/sects/my-sect", headers=ah)
44
+ if r.status_code == 200 and r.json().get("in_sect"):
45
+ requests.post(f"{BASE_URL}/api/sects/leave", headers=ah)
46
+ except: pass
47
+
48
+ def run():
49
+ step("1. TẠO 6 TÀI KHOẢN")
50
+ names = ["tst_leader","tst_vice","tst_elder","tst_inner1","tst_inner2","tst_outer"]
51
+ us = {n: C(n,"pw123") for n in names}
52
+ for c in us.values(): c.register(); c.login()
53
+
54
+ step("2. DỌN DẸP DB CŨ")
55
+ cleanup()
56
+
57
+ step("3. SÁNG LẬP TÔNG MÔN")
58
+ sn = f"TestTong{int(time.time())}"
59
+ r = us["tst_leader"].post("/api/sects/create", {"name":sn,"slogan":"Kiếm khí tung hoành","badge":"gold"})
60
+ assert r.status_code == 200, f"Create fail: {r.text}"
61
+ sid = r.json()["sect_id"]; ok(f"Tông '{sn}' ID={sid}")
62
+
63
+ step("4. TÌM KIẾM TÔNG MÔN")
64
+ r = us["tst_leader"].get("/api/sects/search", {"q":"TestTong"})
65
+ assert r.status_code == 200 and r.json()["total"] >= 1
66
+ ok(f"Tìm thấy {r.json()['total']} tông môn, trang {r.json()['page']}/{r.json()['total_pages']}")
67
+
68
+ step("5. XEM TÔNG MÔN THEO ID")
69
+ r = us["tst_leader"].get(f"/api/sects/{sid}")
70
+ assert r.status_code == 200 and r.json()["sect"]["name"] == sn
71
+ ok(f"Xem tông ID={sid}: {r.json()['member_count']} thành viên")
72
+
73
+ step("6. GIA NHẬP 5 ĐỆ TỬ")
74
+ for n in names[1:]:
75
+ r = us[n].post("/api/sects/join", {"sect_id":sid})
76
+ assert r.status_code == 200, f"{n} join fail: {r.text}"
77
+ ok("5 đệ tử gửi đơn bái kiến")
78
+
79
+ r = us["tst_leader"].get("/api/sects/requests/list")
80
+ reqs = r.json()["requests"]; ok(f"{len(reqs)} yêu cầu chờ duyệt")
81
+ for rq in reqs:
82
+ r = us["tst_leader"].post("/api/sects/requests/respond", {"request_id":rq["id"],"action":"approve"})
83
+ assert r.status_code == 200
84
+ ok("Duyệt toàn bộ")
85
+
86
+ step("7. HỆ THỐNG 5 CHỨC DANH")
87
+ promotions = [
88
+ ("tst_vice", "vice_leader", "Phó Tông chủ"),
89
+ ("tst_elder", "elder", "Trưởng lão"),
90
+ ("tst_inner1", "inner_disciple", "Nội môn đệ tử"),
91
+ ("tst_inner2", "inner_disciple", "Nội môn đệ tử"),
92
+ ]
93
+ for uname, role, label in promotions:
94
+ r = us["tst_leader"].post("/api/sects/promote/rank", {"user_id":us[uname].uid,"role":role})
95
+ assert r.status_code == 200, f"Promote {uname} fail: {r.text}"
96
+ ok(f"{uname} → {label}")
97
+
98
+ step("8. KIỂM TRA QUYỀN PHÂN CẤP")
99
+ # Vice leader thăng elder cho outer
100
+ r = us["tst_vice"].post("/api/sects/promote/rank", {"user_id":us["tst_outer"].uid,"role":"elder"})
101
+ assert r.status_code == 200; ok("Phó Tông chủ thăng Trưởng lão cho tst_outer ✓")
102
+ # Hạ lại về member
103
+ r = us["tst_vice"].post("/api/sects/promote/rank", {"user_id":us["tst_outer"].uid,"role":"member"})
104
+ assert r.status_code == 200; ok("Phó Tông chủ hạ tst_outer về Ngoại môn đệ tử ✓")
105
+ # Elder KHÔNG THỂ thăng vice_leader
106
+ r = us["tst_elder"].post("/api/sects/promote/rank", {"user_id":us["tst_outer"].uid,"role":"vice_leader"})
107
+ assert r.status_code == 403; ok("Trưởng lão bị chặn thăng Phó Tông chủ ✓ (403)")
108
+
109
+ step("9. XEM THÀNH VIÊN & LỌC THEO CHỨC DANH")
110
+ r = us["tst_leader"].get("/api/sects/members")
111
+ assert r.status_code == 200
112
+ for m in r.json()["members"]: info(f"{m['username']} = {m['role_label']} (cống hiến: {m['contribution']})")
113
+ r = us["tst_leader"].get("/api/sects/members", {"role":"inner_disciple"})
114
+ ok(f"Lọc Nội môn: {len(r.json()['members'])} người")
115
+
116
+ step("10. CHAT CHUNG TỔNG TÔNG MÔN")
117
+ msgs = [
118
+ ("tst_leader", "Toàn tông nghe lệnh, ngày mai xuất chinh!"),
119
+ ("tst_vice", "Phó Tông chủ đã sắp xếp đội hình chiến đấu."),
120
+ ("tst_elder", "Trưởng lão báo cáo: đan dược đã đủ cung ứng."),
121
+ ("tst_inner1", "Nội môn đệ tử xin tuân lệnh!"),
122
+ ("tst_outer", "Ngoại môn đệ tử sẵn sàng!"),
123
+ ]
124
+ for u, msg in msgs:
125
+ r = us[u].post("/api/sects/chat/send", {"message":msg,"chat_type":"general"})
126
+ assert r.status_code == 200
127
+ r = us["tst_outer"].get("/api/sects/chat/history", {"chat_type":"general"})
128
+ ok(f"Chat chung: {len(r.json()['messages'])} tin nhắn")
129
+ for m in r.json()["messages"]: info(f"[{m['sender_name']}]: {m['message']}")
130
+
131
+ step("11. CHAT RIÊNG 1-1 TRONG TÔNG MÔN")
132
+ pairs = [
133
+ ("tst_leader","tst_vice","Phó Tông chủ, ngày mai phân công thế nào?"),
134
+ ("tst_vice","tst_leader","Dạ, đệ tử đã chuẩn bị 3 đội tiên phong."),
135
+ ("tst_inner1","tst_inner2","Đồng môn ơi, cùng luyện công tối nay không?"),
136
+ ("tst_inner2","tst_inner1","Được chứ, 8 giờ tại Luyện Công Đường nhé!"),
137
+ ("tst_elder","tst_outer","Ngoại môn đệ tử, tu vi gần đây tiến bộ ra sao?"),
138
+ ("tst_outer","tst_elder","Dạ bẩm Trưởng lão, đệ tử đang ở Luyện Khí tầng 3."),
139
+ ]
140
+ for s, t, msg in pairs:
141
+ r = us[s].post("/api/sects/chat/send", {"message":msg,"chat_type":"direct","target_id":us[t].uid})
142
+ assert r.status_code == 200
143
+ # Verify
144
+ r = us["tst_leader"].get("/api/sects/chat/history", {"chat_type":"direct","target_id":us["tst_vice"].uid})
145
+ ok(f"Chat riêng Leader↔Vice: {len(r.json()['messages'])} tin")
146
+ r = us["tst_inner1"].get("/api/sects/chat/history", {"chat_type":"direct","target_id":us["tst_inner2"].uid})
147
+ ok(f"Chat riêng Inner1↔Inner2: {len(r.json()['messages'])} tin")
148
+ r = us["tst_elder"].get("/api/sects/chat/history", {"chat_type":"direct","target_id":us["tst_outer"].uid})
149
+ ok(f"Chat riêng Elder↔Outer: {len(r.json()['messages'])} tin")
150
+
151
+ step("12. TẠO NHÓM CHAT NHỎ #1 (Ban lãnh đạo)")
152
+ r = us["tst_leader"].post("/api/sects/chat/groups/create", {
153
+ "name":"Ban lãnh đạo","members":[us["tst_leader"].uid, us["tst_vice"].uid, us["tst_elder"].uid]
154
+ })
155
+ assert r.status_code == 200; g1 = r.json()["group_id"]; ok(f"Nhóm 'Ban lãnh đạo' ID={g1}")
156
+
157
+ step("13. TẠO NHÓM CHAT NHỎ #2 (Đội tu luyện)")
158
+ r = us["tst_inner1"].post("/api/sects/chat/groups/create", {
159
+ "name":"Đội tu luyện","members":[us["tst_inner1"].uid, us["tst_inner2"].uid, us["tst_outer"].uid]
160
+ })
161
+ assert r.status_code == 200; g2 = r.json()["group_id"]; ok(f"Nhóm 'Đội tu luyện' ID={g2}")
162
+
163
+ step("14. CHAT TRONG NHÓM NHỎ")
164
+ for u, msg in [("tst_leader","Hội nghị mật: chiến lược đánh Ma Giáo"),("tst_vice","Đề xuất tấn công từ hướng Đông"),("tst_elder","Đồng ý, trưởng lão sẽ dẫn đội hỗ trợ")]:
165
+ r = us[u].post("/api/sects/chat/send", {"message":msg,"chat_type":"group","group_id":g1})
166
+ assert r.status_code == 200
167
+ for u, msg in [("tst_inner1","Tối nay luyện kiếm pháp mới!"),("tst_inner2","OK sư huynh!"),("tst_outer","Đệ tử cũng xin tham gia!")]:
168
+ r = us[u].post("/api/sects/chat/send", {"message":msg,"chat_type":"group","group_id":g2})
169
+ assert r.status_code == 200
170
+ r = us["tst_leader"].get("/api/sects/chat/history", {"chat_type":"group","group_id":g1})
171
+ ok(f"Nhóm BLĐ: {len(r.json()['messages'])} tin")
172
+ r = us["tst_inner1"].get("/api/sects/chat/history", {"chat_type":"group","group_id":g2})
173
+ ok(f"Nhóm tu luyện: {len(r.json()['messages'])} tin")
174
+
175
+ step("15. BẢO MẬT: NGƯỜI NGOÀI KHÔNG CHAT ĐƯỢC NHÓM")
176
+ r = us["tst_outer"].post("/api/sects/chat/send", {"message":"hack","chat_type":"group","group_id":g1})
177
+ assert r.status_code == 403; ok(f"Outer bị chặn chat nhóm BLĐ (403) ✓")
178
+ r = us["tst_outer"].get("/api/sects/chat/history", {"chat_type":"group","group_id":g1})
179
+ assert r.status_code == 403; ok(f"Outer bị chặn xem lịch sử BLĐ (403) ✓")
180
+
181
+ step("16. THÊM/XÓA THÀNH VIÊN NHÓM CHAT")
182
+ r = us["tst_leader"].post(f"/api/sects/chat/groups/{g1}/members/add", {"user_ids":[us["tst_inner1"].uid]})
183
+ assert r.status_code == 200; ok(f"Thêm Inner1 vào BLĐ, members={r.json()['members']}")
184
+ r = us["tst_leader"].post(f"/api/sects/chat/groups/{g1}/members/remove", {"user_id":us["tst_inner1"].uid})
185
+ assert r.status_code == 200; ok(f"Xóa Inner1 khỏi BLĐ, members={r.json()['members']}")
186
+
187
+ step("17. XEM THÔNG TIN NHÓM CHAT")
188
+ r = us["tst_leader"].get(f"/api/sects/chat/groups/{g1}")
189
+ assert r.status_code == 200
190
+ ginfo = r.json(); ok(f"Nhóm: {ginfo['group']['name']}, {ginfo['member_count']} thành viên")
191
+ for m in ginfo["members"]: info(f" {m['username']} ({m['role_label']})")
192
+
193
+ step("18. DANH SÁCH TẤT CẢ NHÓM CHAT CỦA TÔI")
194
+ r = us["tst_leader"].get("/api/sects/chat/groups")
195
+ assert r.status_code == 200; ok(f"Leader thấy {len(r.json()['groups'])} nhóm")
196
+ r = us["tst_inner1"].get("/api/sects/chat/groups")
197
+ ok(f"Inner1 thấy {len(r.json()['groups'])} nhóm")
198
+
199
+ step("19. KẾT BẠN & CHAT BẠN BÈ")
200
+ conn = get_user_db_conn()
201
+ conn.execute("DELETE FROM friendships WHERE user_id IN (?,?) OR friend_id IN (?,?)",
202
+ (us["tst_inner1"].uid, us["tst_outer"].uid, us["tst_inner1"].uid, us["tst_outer"].uid))
203
+ conn.commit(); conn.close()
204
+ r = us["tst_inner1"].post("/api/friends/request", {"friend_username":"tst_outer"})
205
+ assert r.status_code == 200; ok("Inner1 gửi kết bạn Outer")
206
+ r = us["tst_outer"].post("/api/friends/respond", {"sender_id":us["tst_inner1"].uid,"action":"accept"})
207
+ assert r.status_code == 200; ok("Outer chấp nhận")
208
+ r = us["tst_inner1"].get("/api/friends/list")
209
+ ok(f"Bạn bè Inner1: {[f['username'] for f in r.json()['friends']]}")
210
+ r = us["tst_inner1"].post("/api/messages/send", {"receiver_id":us["tst_outer"].uid,"message":"Chào bạn, tối nay đi phụ bản không?"})
211
+ assert r.status_code == 200
212
+ r = us["tst_outer"].post("/api/messages/send", {"receiver_id":us["tst_inner1"].uid,"message":"Đi chứ! 9h tối nhé!"})
213
+ assert r.status_code == 200
214
+ r = us["tst_inner1"].get(f"/api/messages/chat/{us['tst_outer'].uid}")
215
+ ok(f"Chat bạn bè: {len(r.json()['messages'])} tin nhắn")
216
+ for m in r.json()["messages"]:
217
+ sn = "tst_inner1" if m["sender_id"]==us["tst_inner1"].uid else "tst_outer"
218
+ info(f"[{sn}]: {m['message']}")
219
+
220
+ step("20. CỐNG HIẾN & XEM TÔNG MÔN QUA ID")
221
+ us["tst_inner1"].post("/api/sects/contribute", {"amount":300})
222
+ us["tst_outer"].post("/api/sects/contribute", {"amount":200})
223
+ r = us["tst_leader"].get(f"/api/sects/{sid}")
224
+ d = r.json()
225
+ ok(f"Tông '{d['sect']['name']}' cấp {d['sect']['level']}, cống hiến tổng: {d['sect']['contribution']}")
226
+ ok(f"Phân bố chức danh: {d['role_counts']}")
227
+
228
+ step("🎉 TOÀN BỘ 20 BƯỚC TEST THÀNH CÔNG!")
229
+
230
+ if __name__ == "__main__": run()
backend/workers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # backend/workers/__init__.py
backend/workers/email_worker.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import base64
3
+ import time
4
+ import email
5
+ import imaplib
6
+ import os
7
+ import sqlite3
8
+ import threading
9
+ from email.header import decode_header
10
+ from datetime import datetime
11
+ from backend.config import Config
12
+ from backend.services.email_service import (
13
+ get_gmail_service, send_email_smtp, send_email_gmail_api, SERVICE_ACCOUNT_FILE
14
+ )
15
+ from backend.database.db_manager import get_user_db_conn
16
+
17
+ def get_customer_email_by_order(order_id: str):
18
+ try:
19
+ conn = get_user_db_conn()
20
+ conn.row_factory = sqlite3.Row
21
+ row = conn.execute(
22
+ "SELECT u.email FROM payments p JOIN users u ON p.user_id = u.id WHERE p.order_id = ?",
23
+ (order_id,)
24
+ ).fetchone()
25
+ conn.close()
26
+ return row["email"] if row and row["email"] else None
27
+ except Exception:
28
+ return None
29
+
30
+ def process_bank_email_payload(combined_content: str, send_reply_fn):
31
+ """Parse payment email content and process activation."""
32
+ order_match = re.search(r'(VIP\w+)', combined_content, re.IGNORECASE)
33
+ amount_match = re.search(
34
+ r'(?:S\d+ti\d+n|số tiền|giao dịch|phát sinh|cộng)\s*(?:\+)?\s*([0-9.,]+)\s*(?:VND|đ|d|đ)',
35
+ combined_content, re.IGNORECASE
36
+ )
37
+
38
+ if not order_match:
39
+ return False
40
+
41
+ from backend.services.payment_service import confirm_payment
42
+
43
+ order_id = order_match.group(1).upper()
44
+ amount = 0
45
+ if amount_match:
46
+ amount_str = amount_match.group(1).replace('.', '').replace(',', '')
47
+ try:
48
+ amount = float(amount_str)
49
+ except ValueError:
50
+ pass
51
+
52
+ print(f"🔍 [Email Worker] Detected transaction. Order ID: {order_id}, parsed amount: {amount}")
53
+ success = confirm_payment(order_id, amount)
54
+
55
+ if success:
56
+ customer_email = get_customer_email_by_order(order_id)
57
+ if customer_email:
58
+ reply_subject = f"Xác nhận thanh toán thành công đơn hàng {order_id}"
59
+ reply_html = f"""
60
+ <div style="font-family: sans-serif; padding: 20px; line-height: 1.6;">
61
+ <h2 style="color: #4CAF50;">Thanh toán thành công!</h2>
62
+ <p>Xin chào,</p>
63
+ <p>Hệ thống đã nhận được số tiền chuyển khoản của bạn cho đơn hàng <strong>{order_id}</strong>.</p>
64
+ <p>Tài khoản VIP của bạn đã được nâng cấp và kích hoạt tự động thành công.</p>
65
+ <p>Chúc bạn có những trải nghiệm đọc truyện tuyệt vời!</p>
66
+ <hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;">
67
+ <p style="font-size: 12px; color: #888;">Đội ngũ hỗ trợ Ly Vu Ha Novel</p>
68
+ </div>
69
+ """
70
+ send_reply_fn(customer_email, reply_subject, reply_html)
71
+ return success
72
+
73
+ def check_bank_emails_imap():
74
+ """Connects to Gmail via IMAP and processes unread bank payment emails."""
75
+ smtp_cfg = Config.SMTP_CONFIG
76
+ if not smtp_cfg["username"] or not smtp_cfg["password"]:
77
+ print("⚠️ SMTP/IMAP credentials not configured.")
78
+ return
79
+ try:
80
+ print(f"📥 [IMAP] Connecting to imap.gmail.com as {smtp_cfg['username']}...")
81
+ mail = imaplib.IMAP4_SSL("imap.gmail.com")
82
+ mail.login(smtp_cfg["username"], smtp_cfg["password"])
83
+ mail.select("inbox")
84
+
85
+ status, response = mail.search(None, 'UNSEEN')
86
+ if status != "OK":
87
+ return
88
+
89
+ messages = response[0].split()
90
+ if not messages:
91
+ print("✅ [IMAP] No unread emails in inbox.")
92
+ return
93
+
94
+ print(f"📧 [IMAP] Found {len(messages)} unread email(s). Processing...")
95
+
96
+ for msg_id in messages:
97
+ status, data = mail.fetch(msg_id, "(RFC822)")
98
+ if status != "OK":
99
+ continue
100
+
101
+ raw_email = data[0][1]
102
+ msg = email.message_from_bytes(raw_email)
103
+
104
+ subject, encoding = decode_header(msg["Subject"])[0]
105
+ if isinstance(subject, bytes):
106
+ subject = subject.decode(encoding or "utf-8", errors="ignore")
107
+
108
+ body = ""
109
+ if msg.is_multipart():
110
+ for part in msg.walk():
111
+ content_type = part.get_content_type()
112
+ content_disposition = str(part.get("Content-Disposition"))
113
+ if content_type == "text/plain" and "attachment" not in content_disposition:
114
+ payload = part.get_payload(decode=True)
115
+ body += payload.decode("utf-8", errors="ignore")
116
+ else:
117
+ body = msg.get_payload(decode=True).decode("utf-8", errors="ignore")
118
+
119
+ combined_content = f"{subject} {body}"
120
+ process_bank_email_payload(combined_content, send_email_smtp)
121
+
122
+ # Mark email as read
123
+ mail.store(msg_id, '+FLAGS', '\\Seen')
124
+
125
+ mail.close()
126
+ mail.logout()
127
+ except Exception as e:
128
+ print(f"�� Error during IMAP polling worker: {e}")
129
+
130
+ def check_bank_emails_gmail_api():
131
+ """Checks Gmail inbox using Gmail API for unread bank/payment emails."""
132
+ service = get_gmail_service()
133
+ if not service:
134
+ return
135
+ try:
136
+ query = "is:unread (MB Bank OR Bank OR \"biến động số dư\" OR \"chuyển khoản\")"
137
+ results = service.users().messages().list(userId='me', q=query).execute()
138
+ messages = results.get('messages', [])
139
+
140
+ if not messages:
141
+ return
142
+
143
+ print(f"📧 [Gmail API] Found {len(messages)} unread bank/payment email(s). Processing...")
144
+
145
+ for msg in messages:
146
+ msg_id = msg['id']
147
+ message_details = service.users().messages().get(userId='me', id=msg_id, format='full').execute()
148
+
149
+ payload = message_details.get('payload', {})
150
+ headers = payload.get('headers', [])
151
+ subject = next((h['value'] for h in headers if h['name'].lower() == 'subject'), '')
152
+
153
+ body_text = ""
154
+ if 'parts' in payload:
155
+ for part in payload['parts']:
156
+ if part['mimeType'] == 'text/plain':
157
+ data = part['body'].get('data', '')
158
+ body_text += base64.urlsafe_b64decode(data.encode('utf-8')).decode('utf-8', errors='ignore')
159
+ else:
160
+ data = payload.get('body', {}).get('data', '')
161
+ if data:
162
+ body_text = base64.urlsafe_b64decode(data.encode('utf-8')).decode('utf-8', errors='ignore')
163
+
164
+ combined_content = f"{subject} {body_text}"
165
+ success = process_bank_email_payload(combined_content, send_email_gmail_api)
166
+
167
+ # Mark as read regardless
168
+ service.users().messages().batchModify(
169
+ userId='me',
170
+ body={'ids': [msg_id], 'removeLabelIds': ['UNREAD']}
171
+ ).execute()
172
+
173
+ except Exception as e:
174
+ print(f"❌ Error during Gmail polling worker: {e}")
175
+
176
+ def check_bank_emails_and_process():
177
+ """Dispatch between Gmail API and IMAP method."""
178
+ if os.path.exists(SERVICE_ACCOUNT_FILE):
179
+ check_bank_emails_gmail_api()
180
+ else:
181
+ check_bank_emails_imap()
182
+
183
+ def start_email_worker():
184
+ """Start background daemon thread polling Gmail every 60 seconds (Singleton across workers)."""
185
+ # Use a file lock to ensure only ONE Gunicorn worker spawns the background polling thread
186
+ import fcntl
187
+
188
+ lock_file = "/tmp/email_worker_poll.lock"
189
+ try:
190
+ # Open file in append mode so it doesn't overwrite
191
+ lock_fd = open(lock_file, "a")
192
+ # Try to acquire an exclusive, non-blocking lock
193
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
194
+ except Exception:
195
+ # If lock fails, another worker is already running the email poll loop
196
+ print("📧 [Email Worker] Another worker is already polling. Skipping duplicate thread.")
197
+ return
198
+
199
+ def poll_loop(fd):
200
+ print("📧 [Email Worker] Master polling thread acquired lock. Background polling started.")
201
+ while True:
202
+ try:
203
+ check_bank_emails_and_process()
204
+ except Exception as e:
205
+ print(f"❌ [Email Worker] Error in polling loop: {e}")
206
+ # Poll every 60 seconds instead of 15s to avoid IMAP rate limits
207
+ time.sleep(60)
208
+
209
+ # Note: the file descriptor 'fd' is kept open by this thread so the lock is held
210
+ # as long as this specific worker process is alive.
211
+ t = threading.Thread(target=poll_loop, args=(lock_fd,), daemon=True)
212
+ t.start()
213
+
214
+
215
+ if __name__ == "__main__":
216
+ print("📧 [Email Worker] Standalone polling loop started in foreground...")
217
+ while True:
218
+ try:
219
+ check_bank_emails_and_process()
220
+ except Exception as e:
221
+ print(f"❌ [Email Worker] Error in polling loop: {e}")
222
+ time.sleep(60)
223
+
frontend-web/dist/ads.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Google AdSense ads.txt template
2
+ # Thay thế ca-pub-9548504602542886 bằng mã nhà xuất bản (Publisher ID) thực tế của bạn
3
+ google.com, pub-9548504602542886, DIRECT, f08c47fec0942fa0
frontend-web/dist/assets/AuthorDetail-lunBE6ER.js ADDED
@@ -0,0 +1 @@
 
 
1
+ import{t as e}from"./award-CKCOPIfL.js";import{a as t,t as n}from"./MainLayout-COiTxmNr.js";import{t as r}from"./book-DwmTvSdZ.js";import{a as i}from"./square-DZKJ0QGR.js";import{t as a}from"./file-text-mhTXpwIY.js";import{D as o,F as s,M as c,O as l,S as u,b as d,f,h as p,j as m,w as h}from"./index-yRoRoI6u.js";var g=d(`layers`,[[`path`,{d:`M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z`,key:`zw3jo`}],[`path`,{d:`M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12`,key:`1wduqc`}],[`path`,{d:`M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17`,key:`kqbvx6`}]]),_=s(m(),1),v=c();function y(){let{authorName:s}=l(),{lang:c}=u(),d=o(),[m,y]=(0,_.useState)(null),[b,x]=(0,_.useState)(!0),[S,C]=(0,_.useState)(``);(0,_.useEffect)(()=>{w()},[s]);let w=async()=>{x(!0),C(``);try{y((await h.get(`/api/author/${encodeURIComponent(s)}`)).data)}catch(e){console.error(e),C(c===`vi`?`Không tìm thấy thông tin tác giả hoặc có lỗi xảy ra.`:c===`en`?`Failed to load author details.`:`无法加载作者详情。`)}finally{x(!1)}};if(b)return(0,v.jsx)(n,{children:(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,v.jsx)(f,{className:`w-8 h-8 animate-spin mx-auto mb-2 text-purple-500`}),(0,v.jsx)(`span`,{children:c===`vi`?`Đang tải thông tin tác giả...`:c===`en`?`Loading author details...`:`正在加载作者信息...`})]})});if(S||!m)return(0,v.jsx)(n,{children:(0,v.jsx)(`div`,{className:`py-20 text-center text-red-500 font-bold`,children:S||`Lỗi không xác định.`})});let{author_chinese:T,author_hanviet:E,total_books:D,books:O}=m,k=O.reduce((e,t)=>e+(t.chapters_max||0),0);return(0,v.jsxs)(n,{children:[(0,v.jsxs)(`div`,{className:`relative rounded-3xl overflow-hidden border border-[#1f1f3a] mb-8 bg-[#0b0b14]/70 p-6 md:p-8 flex flex-col md:flex-row gap-6 items-center md:items-start z-0`,children:[(0,v.jsx)(`div`,{className:`absolute inset-0 z-[-1] opacity-10 blur-3xl bg-gradient-to-r from-purple-500 to-indigo-600 scale-110`}),(0,v.jsx)(`div`,{className:`w-16 h-16 md:w-20 md:h-20 bg-purple-500/10 border-2 border-purple-500/30 rounded-2xl flex items-center justify-center text-purple-400 shrink-0 shadow-lg shadow-purple-500/5`,children:(0,v.jsx)(t,{className:`w-8 h-8 md:w-10 md:h-10`})}),(0,v.jsxs)(`div`,{className:`flex-1 min-w-0 text-center md:text-left space-y-2`,children:[(0,v.jsx)(`span`,{className:`px-2.5 py-0.5 bg-purple-500/10 border border-purple-500/25 text-purple-400 rounded-full text-[10px] font-black uppercase tracking-wider`,children:c===`vi`?`Hồ sơ tác giả`:c===`en`?`Author Profile`:`作者档案`}),(0,v.jsx)(`h2`,{className:`text-2xl md:text-3xl font-black text-white leading-tight`,children:E}),(0,v.jsxs)(`p`,{className:`text-slate-400 text-xs font-semibold`,children:[c===`vi`?`Tên gốc Trung`:c===`en`?`Chinese Original Name`:`中文原名`,`:`,` `,(0,v.jsx)(`span`,{className:`text-purple-300 font-bold bg-[#121225] px-2 py-0.5 rounded border border-[#1f1f3a]`,children:T})]}),(0,v.jsxs)(`div`,{className:`flex flex-wrap justify-center md:justify-start gap-4 pt-3 text-xs text-slate-400`,children:[(0,v.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 px-4 py-2 rounded-xl flex items-center gap-2`,children:[(0,v.jsx)(r,{className:`w-4 h-4 text-purple-400`}),(0,v.jsxs)(`span`,{children:[(0,v.jsx)(`strong`,{children:D}),` `,c===`vi`?`Truyện`:c===`en`?`Books`:`本小说`]})]}),(0,v.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 px-4 py-2 rounded-xl flex items-center gap-2`,children:[(0,v.jsx)(g,{className:`w-4 h-4 text-purple-400`}),(0,v.jsxs)(`span`,{children:[(0,v.jsx)(`strong`,{children:k.toLocaleString()}),` `,c===`vi`?`Chương tích lũy`:c===`en`?`Total Chapters`:`总章节`]})]}),(0,v.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 px-4 py-2 rounded-xl flex items-center gap-2`,children:[(0,v.jsx)(e,{className:`w-4 h-4 text-purple-400`}),(0,v.jsx)(`span`,{children:(0,v.jsx)(`strong`,{children:`Antigravity Verified`})})]})]})]})]}),(0,v.jsxs)(`div`,{className:`mb-6 flex justify-between items-center`,children:[(0,v.jsxs)(`h3`,{className:`text-lg font-black text-white flex items-center gap-2`,children:[(0,v.jsx)(a,{className:`w-5 h-5 text-purple-400`}),c===`vi`?`Các tác phẩm tiêu biểu`:c===`en`?`Works List`:`代表作品`]}),(0,v.jsx)(`span`,{className:`text-xs text-slate-500 font-bold`,children:c===`vi`?`Hiển thị ${O.length} kết quả`:c===`en`?`${O.length} results`:`共 ${O.length} 部`})]}),(0,v.jsx)(`div`,{className:`space-y-6`,children:O.map(e=>(0,v.jsxs)(`div`,{className:`bg-[#121225]/50 border border-[#1f1f3a] rounded-3xl p-5 md:p-6 flex flex-col md:flex-row gap-5 hover:border-purple-500/20 hover:bg-[#121225]/70 transition-all duration-300 relative group`,children:[e.cover?(0,v.jsx)(`img`,{src:e.cover,alt:`cover`,className:`w-[90px] h-[125px] md:w-[110px] md:h-[155px] object-cover rounded-xl border border-[#1f1f3a] shadow-lg group-hover:scale-[1.02] transition-transform duration-300 shrink-0 mx-auto md:mx-0`}):(0,v.jsx)(`div`,{className:`w-[90px] h-[125px] md:w-[110px] md:h-[155px] bg-[#0b0b14] border border-[#1f1f3a] rounded-xl flex items-center justify-center text-slate-600 shrink-0 mx-auto md:mx-0`,children:(0,v.jsx)(r,{className:`w-10 h-10`})}),(0,v.jsxs)(`div`,{className:`flex-1 min-w-0 flex flex-col justify-between space-y-3 text-center md:text-left`,children:[(0,v.jsxs)(`div`,{className:`space-y-1.5`,children:[(0,v.jsx)(`h4`,{onClick:()=>d(`/book/${e.id}`),className:`text-base md:text-lg font-black text-white cursor-pointer hover:text-purple-400 transition-colors leading-tight inline-block`,children:e.title_vietphrase||e.title_hanviet}),(0,v.jsxs)(`p`,{className:`text-[10px] text-slate-500 font-semibold`,children:[c===`vi`?`Tiêu đề gốc`:c===`en`?`Original Title`:`原著`,`: `,e.title,` · `,e.chapters_max,` `,c===`vi`?`chương`:c===`en`?`chapters`:`章`]}),(0,v.jsx)(`p`,{className:`text-slate-300 text-xs leading-relaxed line-clamp-3`,children:e.description_vietphrase||e.description_hanviet||e.description||(c===`vi`?`Chưa có tóm tắt nội dung.`:c===`en`?`No description available.`:`暂无内容简介。`)})]}),(0,v.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-2 border-t border-[#1f1f3a]/40`,children:[(0,v.jsx)(`div`,{className:`flex flex-wrap justify-center md:justify-start gap-1.5`,children:e.categories&&e.categories.split(/[,,]/).map(e=>e.trim()).filter(Boolean).map((e,t)=>(0,v.jsx)(`span`,{className:`px-2 py-0.5 bg-[#0b0b14]/60 border border-[#1f1f3a] text-slate-400 rounded-md text-[10px] font-semibold`,children:e},t))}),(0,v.jsxs)(`div`,{className:`flex flex-wrap justify-center sm:justify-end items-center gap-1.5`,children:[(0,v.jsxs)(`span`,{className:`text-[10px] text-slate-500 font-bold flex items-center gap-1`,children:[(0,v.jsx)(p,{className:`w-3 h-3 text-purple-400`}),c===`vi`?`Nguồn gốc:`:c===`en`?`Source web:`:`来源网站:`]}),e.parsed_sources&&e.parsed_sources.length>0?e.parsed_sources.map((e,t)=>(0,v.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-1 px-2 py-1 bg-purple-950/20 hover:bg-purple-900/40 border border-purple-500/20 hover:border-purple-500/30 text-purple-300 rounded-lg text-[9px] font-black transition-colors`,title:e.url,children:[e.source,(0,v.jsx)(i,{className:`w-2.5 h-2.5 text-purple-400`})]},t)):(0,v.jsx)(`span`,{className:`text-[10px] text-slate-600 italic`,children:c===`vi`?`Không rõ`:c===`en`?`Unknown`:`未知`})]})]})]})]},e.id))})]})}export{y as default};
frontend-web/dist/assets/BookCard-BCMDdlyN.js ADDED
@@ -0,0 +1 @@
 
 
1
+ import{t as e}from"./book-DwmTvSdZ.js";import{n as t,r as n,t as r}from"./star-DjnKUekI.js";import{D as i,F as a,M as o,S as s,b as c,j as l,n as u,s as d,t as f,w as p}from"./index-yRoRoI6u.js";var m=c(`columns-2`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M12 3v18`,key:`108xh3`}]]),h=a(l(),1),g=o();function _({book:a,isFav:o,onToggleFav:c,onCompare:l,onRead:_,onPlayTrailer:v,onSearchAuthor:y,onSearchCategory:b}){let{t:x,lang:S}=s(),{openInBrowser:C}=f(),w=i(),[T,E]=(0,h.useState)(!1),[D,O]=(0,h.useState)(`original`),[k,A]=(0,h.useState)(``),[j,M]=(0,h.useState)(!1),N=e=>e?e.split(` | `).map(e=>{let t=e.indexOf(`: `);return t<0?null:{site:e.slice(0,t),url:e.slice(t+2)}}).filter(Boolean):[],P=(()=>{if(S===`zh`||S===`en`)return a.title||`—`;let e=a.title_vietphrase||a.title_hanviet;return e&&a.title&&e!==a.title?`${e} (${a.title})`:e||a.title||`—`})(),F=S===`zh`?a.author||`—`:S===`en`?a.author_english||a.author||`—`:a.author_hanviet||a.author||`—`,I=S===`zh`?a.description||``:S===`en`?a.description_english||a.description||``:a.description_vietphrase||a.description||``,L=D===`original`?I:k||I,R=N(a.urls),z=()=>{let e=a.categories||``;return e.includes(`玄幻`)||e.includes(`修真`)||e.includes(`仙侠`)||e.includes(`Huyền Huyện`)||e.includes(`Tiên Hiệp`)?[`Sát phạt`,`Vô địch`]:(e.includes(`都市`)||e.includes(`历史`)||e.includes(`Đô Thị`)||e.includes(`Lịch Sử`)||e.includes(`Văn Minh`)||e.includes(`Văn minh`),[`Hài hước`,`Trí tuệ`])},B=async e=>{if(e===`original`){O(`original`);return}M(!0);try{try{let t=await p.post(`/api/translate`,{texts:[I],mode:e===`en`?`en`:`vietphrase`});t.data&&t.data.translations&&t.data.translations[0]&&(A(t.data.translations[0]),O(e))}catch(t){console.warn(`[BookCard] Cloud translation failed, trying offline localTranslator:`,t),await u.loadDictionaries(),A(u.translateSentence(I,`advanced`)),O(e)}}catch{alert(`Hạn mức dịch máy chủ đã hết và bộ dịch offline gặp lỗi.`)}finally{M(!1)}},V=S===`zh`?a.categories||``:S===`en`?a.categories_english||a.categories||``:a.categories_vietphrase||a.categories||``,H=V?V.split(/[,,/、\s]+/).map(e=>e.trim()).filter(Boolean):[];return(0,g.jsxs)(`div`,{className:`bg-[#121225]/85 border border-[#1f1f3a] rounded-xl p-3.5 hover:border-purple-500/50 hover:shadow-xl hover:shadow-purple-500/5 transition-all duration-300 flex flex-col justify-between relative overflow-visible min-h-[300px]`,children:[(0,g.jsxs)(`div`,{children:[(0,g.jsxs)(`div`,{className:`flex gap-3`,children:[a.cover?(0,g.jsx)(`img`,{src:a.cover,alt:`cover`,className:`w-[60px] h-[82px] object-cover rounded-lg border border-[#2d2d55] shadow-md shrink-0 bg-[#0f0f1a]`,onError:e=>{e.target.remove()}}):(0,g.jsx)(`div`,{className:`w-[60px] h-[82px] rounded-lg border border-[#2d2d55] bg-[#0f0f1a] flex items-center justify-center text-slate-500 shrink-0 shadow-md`,children:(0,g.jsx)(e,{className:`w-5 h-5`})}),(0,g.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,g.jsxs)(`div`,{className:`flex justify-between items-start gap-2`,children:[(0,g.jsx)(`h3`,{className:`text-slate-100 font-bold text-xs hover:text-purple-400 transition-colors cursor-pointer flex-1 whitespace-normal break-words line-clamp-2`,onClick:()=>_&&_(a),title:P,children:P}),I&&(0,g.jsx)(`button`,{onClick:()=>v&&v(a),className:`p-1 rounded bg-[#1f1f3a] hover:bg-purple-600 hover:text-white text-purple-400 transition-colors shrink-0`,title:`Nghe tóm tắt AI (TTS)`,children:(0,g.jsx)(d,{className:`w-3 h-3`})})]}),(0,g.jsxs)(`div`,{className:`text-[10px] text-slate-500 mt-1 space-y-0.5`,children:[(0,g.jsxs)(`div`,{className:`whitespace-normal break-words`,children:[`Tác giả: `,(0,g.jsx)(`span`,{onClick:()=>{let e=a.author||a.author_hanviet||F;e&&e!==`—`&&w(`/author/${encodeURIComponent(e)}`)},className:`text-purple-400 hover:text-purple-300 cursor-pointer underline hover:no-underline font-semibold`,title:`Xem hồ sơ tác giả`,children:F})]}),(0,g.jsxs)(`div`,{className:`whitespace-normal break-words line-clamp-1`,children:[`Gốc: `,(0,g.jsx)(`span`,{className:`text-slate-400`,children:a.title||`—`})]})]}),(0,g.jsxs)(`div`,{className:`flex flex-wrap gap-1 mt-1.5`,children:[(0,g.jsxs)(`span`,{className:`px-1.5 py-0.5 rounded text-[8px] font-extrabold bg-amber-500/10 border border-amber-500/20 text-amber-400`,children:[a.site_count||5,` nguồn`]}),(0,g.jsxs)(`span`,{className:`px-1.5 py-0.5 rounded text-[8px] font-extrabold bg-cyan-500/10 border border-cyan-500/20 text-cyan-400`,children:[a.site_count||5,` site`]}),(0,g.jsx)(`span`,{className:`px-1.5 py-0.5 rounded text-[8px] font-extrabold bg-emerald-500/10 border border-emerald-500/20 text-emerald-400`,children:a.word_count_max?Math.round(a.word_count_max/1e3):1975})]})]})]}),H.length>0&&(0,g.jsxs)(`div`,{className:`flex flex-wrap gap-1 mt-2.5 items-center`,children:[(0,g.jsx)(`span`,{className:`text-slate-500 text-[9px]`,children:`Thể loại:`}),H.map((e,t)=>(0,g.jsx)(`span`,{onClick:()=>b&&b(e),className:`cursor-pointer px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20 hover:bg-purple-500/20 text-purple-400 text-[8px] font-semibold transition-colors`,title:`Lọc thể loại ${e}`,children:e},t))]}),(0,g.jsxs)(`div`,{className:`flex justify-between items-center text-slate-400 text-[10px] mt-2.5 border-t border-[#1f1f3a]/40 pt-2`,children:[(0,g.jsxs)(`span`,{children:[(0,g.jsx)(`strong`,{children:`Chương:`}),` `,a.chapters_max||110,` | `,a.word_count_max?Math.round(a.word_count_max/1e3):0]}),I&&(0,g.jsx)(`button`,{onClick:()=>E(!T),className:`text-slate-500 hover:text-purple-400 text-[9px] flex items-center gap-0.5 font-bold transition-colors`,children:T?(0,g.jsxs)(g.Fragment,{children:[`Ẩn tóm tắt `,(0,g.jsx)(t,{className:`w-2.5 h-2.5`})]}):(0,g.jsxs)(g.Fragment,{children:[`Hiện tóm tắt `,(0,g.jsx)(n,{className:`w-2.5 h-2.5`})]})})]}),T&&I&&(0,g.jsxs)(`div`,{className:`mt-2.5 bg-[#0b0b14]/50 p-2 rounded-lg border border-[#1a1a2e] transition-all duration-300`,children:[(0,g.jsxs)(`div`,{className:`flex justify-between items-center mb-1 text-[9px] text-slate-500`,children:[(0,g.jsx)(`span`,{children:`Tóm tắt:`}),(0,g.jsxs)(`div`,{className:`flex gap-1`,children:[(0,g.jsx)(`button`,{onClick:()=>B(`original`),className:`px-1 py-0.5 rounded text-[8px] font-bold ${D===`original`?`bg-purple-600 text-white`:`hover:bg-white/5 text-slate-400`}`,children:`Gốc`}),(0,g.jsx)(`button`,{onClick:()=>B(`vi`),className:`px-1 py-0.5 rounded text-[8px] font-bold ${D===`vi`?`bg-purple-600 text-white`:`hover:bg-white/5 text-slate-400`}`,disabled:j,children:`🇻🇳`}),(0,g.jsx)(`button`,{onClick:()=>B(`en`),className:`px-1 py-0.5 rounded text-[8px] font-bold ${D===`en`?`bg-purple-600 text-white`:`hover:bg-white/5 text-slate-400`}`,disabled:j,children:`🇺🇸`})]})]}),(0,g.jsx)(`p`,{className:`text-slate-400 text-[10px] line-clamp-3 leading-relaxed`,title:I,children:j?`Đang dịch tóm tắt...`:L})]}),(0,g.jsxs)(`div`,{className:`flex gap-1.5 items-center mt-2.5 text-[9px]`,children:[(0,g.jsx)(`span`,{className:`text-slate-500`,children:`Cảm xúc:`}),z().map((e,t)=>(0,g.jsx)(`span`,{className:`px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/25 text-amber-400 font-extrabold text-[8px]`,children:e},t))]}),(0,g.jsx)(`div`,{className:`mt-2.5 relative`,children:(0,g.jsx)(`div`,{className:`flex flex-wrap gap-1`,children:R.map((e,t)=>{let n=`bg-emerald-500 text-white`;return e.site.toLowerCase().includes(`biquge`)||e.site.toLowerCase().includes(`full`)||e.site.toLowerCase().includes(`truyenfull`)?n=`bg-sky-500 text-white`:e.site.toLowerCase().includes(`faloo`)||e.site.toLowerCase().includes(`vcomi`)||e.site.toLowerCase().includes(`fanqie`)?n=`bg-orange-500 text-white`:(e.site.toLowerCase().includes(`quanben`)||e.site.toLowerCase().includes(`hjwzw`))&&(n=`bg-purple-500 text-white`),(0,g.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noreferrer`,onClick:t=>{t.preventDefault(),t.stopPropagation(),C(e.url)},className:`inline-flex items-center gap-1 bg-[#0b0b14]/40 border border-[#1f1f3a] hover:border-purple-500/30 rounded px-1.5 py-0.5 text-[9px] text-slate-300 hover:text-white transition-all`,children:[(0,g.jsx)(`span`,{className:`w-3 h-3 rounded flex items-center justify-center text-[8px] font-extrabold ${n}`,children:e.site[0]}),e.site]},t)})})})]}),(0,g.jsxs)(`div`,{className:`flex gap-2 mt-3 pt-2 border-t border-[#1f1f3a]/20`,children:[(0,g.jsxs)(`button`,{onClick:()=>l&&l(a.id),className:`flex-1 inline-flex items-center justify-center gap-1 bg-transparent border border-purple-500/30 text-purple-400 hover:bg-purple-600/10 py-2.5 sm:py-1.5 rounded-lg text-[10px] font-bold transition-all min-h-[36px]`,children:[(0,g.jsx)(m,{className:`w-3 h-3`}),` So sánh bản dịch`]}),(0,g.jsx)(`button`,{onClick:e=>{e.stopPropagation(),c&&c(a.id)},className:`px-3 py-2.5 sm:px-2 sm:py-1.5 border rounded-lg text-[10px] font-semibold transition-all min-h-[36px] ${o?`bg-amber-400 border-amber-400 text-[#0b0b14]`:`bg-amber-400/5 border-amber-400/30 text-amber-400 hover:bg-amber-400/15`}`,title:o?x.removeExternal:x.addBookshelf,children:(0,g.jsx)(r,{className:`w-3.5 h-3.5 fill-current`})})]})]})}export{_ as t};
frontend-web/dist/assets/BookDetail-CwKiYfhq.js ADDED
@@ -0,0 +1 @@
 
 
1
+ import{t as e}from"./award-CKCOPIfL.js";import{f as t,l as n,t as r,u as ee}from"./MainLayout-COiTxmNr.js";import{t as i}from"./book-DwmTvSdZ.js";import{a}from"./square-DZKJ0QGR.js";import{n as o,r as s,t as c}from"./star-DjnKUekI.js";import{t as te}from"./eye-DsLuESBv.js";import{C as ne,D as re,F as l,M as u,O as d,S as f,a as p,b as m,f as ie,j as h,o as ae,t as oe,u as se,w as g}from"./index-yRoRoI6u.js";import{t as ce}from"./GoogleAd-CD0eT9Wp.js";var le=m(`circle-check`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),_=m(`thumbs-up`,[[`path`,{d:`M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z`,key:`emmmcr`}],[`path`,{d:`M7 10v12`,key:`1qc93n`}]]),v=l(h(),1),y=u();function b(){let{bookId:l}=d(),{user:u}=ne(),{t:m,lang:h}=f(),{openInBrowser:b}=oe(),x=re(),[S,C]=(0,v.useState)(null),[ue,w]=(0,v.useState)(!0),[T,E]=(0,v.useState)(``),[D,O]=(0,v.useState)(!1),[k,de]=(0,v.useState)([]),[A,j]=(0,v.useState)(``),[M,N]=(0,v.useState)(!1);(0,v.useEffect)(()=>{D&&u&&g.get(`/api/friends/list`).then(e=>{e.data&&e.data.friends&&de(e.data.friends)}).catch(e=>console.error(`Failed to load friends for sharing`,e))},[D,u]);let P=async e=>{N(!0);try{let t=await g.post(`/api/books/share`,{friend_id:e,book_id:S.id,message:A});t.data&&t.data.success&&(alert(h===`vi`?`Đã chia sẻ thành công!`:`Shared successfully!`),O(!1),j(``))}catch{alert(`Chia sẻ thất bại.`)}finally{N(!1)}},[F,I]=(0,v.useState)(!1),[L,R]=(0,v.useState)(!1),[z,B]=(0,v.useState)([]),[V,H]=(0,v.useState)(!0),[U,W]=(0,v.useState)([{id:1,user:`Lê Hoàng Nam`,avatar:`https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=80&auto=format&fit=crop&q=60`,rating:5,text:`Bản dịch AI của trang này chuẩn thật sự, đọc Hán Việt rất mượt mà. Mong nhóm update chương mới nhanh hơn nữa!`,time:`2 giờ trước`,likes:12},{id:2,user:`Nguyễn Thu Thảo`,avatar:`https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&auto=format&fit=crop&q=60`,rating:4,text:`Truyện hay, cốt truyện sát phạt quyết đoán đúng gu mình. Bản dịch máy thỉnh thoảng có vài từ Hán Việt chưa dịch nghĩa kỹ nhưng tổng thể vẫn rất dễ hiểu.`,time:`5 giờ trước`,likes:8},{id:3,user:`Trần Minh Đức`,avatar:`https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&auto=format&fit=crop&q=60`,rating:5,text:`So sánh bản dịch Metruyenchu với trang này thì bản ở đây sạch QC hơn nhiều. Giao diện đọc truyện tối ưu tốt trên di động.`,time:`1 ngày trước`,likes:19}]),[G,K]=(0,v.useState)(``),[q,J]=(0,v.useState)(5),[fe,pe]=(0,v.useState)(new Set);(0,v.useEffect)(()=>{me(),he(),ge()},[l,u]);let Y=async e=>{if(!(!u||!e))try{await g.post(`/api/history/add`,{book_id:e.id,last_chapter:h===`vi`?`Đang xem`:h===`en`?`Viewing`:`浏览中`})}catch{}},me=async()=>{w(!0),E(``);try{let e=await g.get(`/api/book/${l}`);e.data&&(C(e.data),Y(e.data))}catch{try{let e=(await g.get(`/api/books`,{params:{q:``,page:1,per_page:100}})).data.books.find(e=>e.id===parseInt(l));if(e)C(e),Y(e);else{let e=await g.get(`/api/book/${l}/translations`);if(e.data){let t={id:parseInt(l),title_vietphrase:e.data.vietphrase.title,title:e.data.hanviet.title,description_vietphrase:e.data.vietphrase.desc,author_hanviet:h===`vi`?`Tác giả`:h===`en`?`Author`:`作者`,cover:``,parsed_sources:[]};C(t),Y(t)}}}catch{E(h===`vi`?`Không tải được thông tin truyện.`:h===`en`?`Failed to load book details.`:`无法加载小说详情。`)}}finally{w(!1)}},X=(()=>{if(!S)return[];let e=[];if(S.parsed_sources&&S.parsed_sources.length>0)S.parsed_sources.forEach(t=>{e.push({site:t.source,url:t.url})});else if(S.urls){let t=S.urls.split(` | `);for(let n of t){let t=n.indexOf(`:`);t>0&&e.push({site:n.substring(0,t).trim(),url:n.substring(t+1).trim()})}}return e})(),he=async()=>{H(!0);try{B(Array.from({length:50},(e,t)=>({id:t+1,title:h===`vi`?`Chương ${t+1}: Tiết tử và khởi nguyên`:h===`en`?`Chapter ${t+1}: Prologue`:`第 ${t+1} 章: 楔子与起源`,url_idx:t+1})))}catch(e){console.error(e)}finally{H(!1)}},ge=async()=>{if(u)try{I((await g.get(`/api/bookshelf`)).data.some(e=>e.book_id===parseInt(l)))}catch(e){console.error(e)}},_e=async()=>{if(!u){alert(h===`vi`?`Vui lòng đăng nhập để lưu sách.`:h===`en`?`Please log in to save books.`:`请先登录以收藏小说。`);return}let e=F?`/api/bookshelf/remove`:`/api/bookshelf/add`;try{await g.post(e,{book_id:parseInt(l)}),I(!F)}catch{alert(h===`vi`?`Lỗi cập nhật tủ sách.`:h===`en`?`Error updating bookshelf.`:`更新书架时出错。`)}},ve=e=>{pe(t=>{let n=new Set(t);return n.has(e)?(n.delete(e),W(U.map(t=>t.id===e?{...t,likes:t.likes-1}:t))):(n.add(e),W(U.map(t=>t.id===e?{...t,likes:t.likes+1}:t))),n})},Z=e=>{e.preventDefault(),G.trim()&&(W([{id:Date.now(),user:u?u.name||u.email.split(`@`)[0]:h===`vi`?`Khách vãng lai`:h===`en`?`Guest user`:`访客`,avatar:`https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&auto=format&fit=crop&q=60`,rating:q,text:G,time:h===`vi`?`Vừa xong`:h===`en`?`Just now`:`刚刚`,likes:0},...U]),K(``),J(5))};if(ue)return(0,y.jsx)(r,{children:(0,y.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,y.jsx)(ie,{className:`w-8 h-8 animate-spin mx-auto mb-2 text-brand-500`}),(0,y.jsx)(`span`,{children:h===`vi`?`Đang tải thông tin truyện...`:h===`en`?`Loading book details...`:`正在加载小说信息...`})]})});if(T||!S)return(0,y.jsx)(r,{children:(0,y.jsx)(`div`,{className:`py-20 text-center text-red-500 font-bold`,children:T||(h===`vi`?`Không tìm thấy truyện.`:h===`en`?`Book not found.`:`未找到小说。`)})});let ye=(()=>{if(h===`zh`||h===`en`)return S.title||`—`;let e=S.title_vietphrase||S.title_hanviet;return e&&S.title&&e!==S.title?`${e} (${S.title})`:e||S.title||`—`})(),be=h===`zh`?S.author||`—`:h===`en`?S.author_english||S.author||`—`:S.author_hanviet||S.author||`—`,Q=h===`zh`?S.description||`Chưa có tóm tắt.`:h===`en`?S.description_english||S.description||`No synopsis available.`:S.description_vietphrase||S.description||`Chưa có tóm tắt.`,$=h===`zh`?S.categories||``:h===`en`?S.categories_english||S.categories||``:S.categories_vietphrase||S.categories||``,xe=$?$.split(/[,,/、\s]+/).map(e=>e.trim()).filter(Boolean):[];return(0,y.jsxs)(r,{children:[(0,y.jsxs)(`div`,{className:`relative rounded-3xl overflow-hidden border border-[#1f1f3a] mb-8 bg-[#0b0b14]/70 p-6 md:p-8 flex flex-col md:flex-row gap-6 items-center md:items-start z-0`,children:[S.cover&&(0,y.jsx)(`div`,{className:`absolute inset-0 z-[-1] opacity-15 blur-2xl scale-110 bg-cover bg-center`,style:{backgroundImage:`url(${S.cover})`}}),S.cover?(0,y.jsx)(`img`,{src:S.cover,alt:`cover`,className:`w-[120px] md:w-[150px] h-[165px] md:h-[210px] object-cover rounded-xl border-2 border-[#1f1f3a]/80 shadow-2xl shrink-0`}):(0,y.jsx)(`div`,{className:`w-[120px] md:w-[150px] h-[165px] md:h-[210px] bg-[#121225] border-2 border-[#1f1f3a]/80 rounded-xl flex items-center justify-center text-slate-500 shrink-0`,children:(0,y.jsx)(i,{className:`w-12 h-12`})}),(0,y.jsxs)(`div`,{className:`flex-1 min-w-0 text-center md:text-left space-y-3`,children:[(0,y.jsx)(`h2`,{className:`text-xl md:text-2xl font-black text-white leading-tight`,children:ye}),h===`vi`&&(0,y.jsxs)(`p`,{className:`text-slate-400 text-xs font-semibold`,children:[`Hán Việt: `,S.title_hanviet||`—`,` · Gốc Trung: `,S.title]}),(0,y.jsxs)(`p`,{className:`text-purple-400 text-sm md:text-base font-bold`,children:[`✍ `,h===`vi`?`Tác giả`:h===`en`?`Author`:`作者`,`:`,` `,(0,y.jsx)(`span`,{onClick:()=>{let e=S.author||S.author_hanviet;e&&e!==`—`&&x(`/author/${encodeURIComponent(e)}`)},className:`cursor-pointer hover:underline text-purple-300 font-extrabold hover:text-purple-400 transition-colors`,children:be})]}),(0,y.jsx)(`div`,{className:`flex flex-wrap justify-center md:justify-start gap-2 py-1`,children:xe.map((e,t)=>(0,y.jsx)(`span`,{className:`px-3 py-1 bg-purple-500/10 border border-purple-500/20 text-purple-300 rounded-full text-xs font-semibold`,children:e},t))}),X&&X.length>0&&(0,y.jsxs)(`div`,{className:`flex flex-col gap-1.5 py-1 text-left`,children:[(0,y.jsx)(`span`,{className:`text-slate-500 text-[10px] font-bold block`,children:h===`vi`?`Nguồn gốc:`:`Sources:`}),(0,y.jsx)(`div`,{className:`flex flex-wrap gap-1.5 relative`,children:X.map((e,t)=>{let n=`bg-emerald-500 text-white`;return e.site.toLowerCase().includes(`biquge`)||e.site.toLowerCase().includes(`full`)||e.site.toLowerCase().includes(`truyenfull`)?n=`bg-sky-500 text-white`:e.site.toLowerCase().includes(`faloo`)||e.site.toLowerCase().includes(`vcomi`)||e.site.toLowerCase().includes(`fanqie`)?n=`bg-orange-500 text-white`:(e.site.toLowerCase().includes(`quanben`)||e.site.toLowerCase().includes(`hjwzw`))&&(n=`bg-purple-500 text-white`),(0,y.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noreferrer`,onClick:t=>{t.preventDefault(),t.stopPropagation(),b(e.url)},className:`inline-flex items-center gap-1 bg-[#0b0b14]/40 border border-[#1f1f3a] hover:border-purple-500/30 rounded px-2 py-1 text-[10px] text-slate-300 hover:text-white transition-all hover:scale-[1.02]`,title:e.url,children:[(0,y.jsx)(`span`,{className:`w-3.5 h-3.5 rounded flex items-center justify-center text-[9px] font-extrabold ${n}`,children:e.site[0]}),e.site]},t)})})]}),(0,y.jsxs)(`div`,{className:`flex flex-wrap justify-center md:justify-start gap-3 pt-3`,children:[(0,y.jsxs)(`button`,{onClick:()=>x(`/book/${S.id}/read/1`),className:`inline-flex items-center gap-1.5 bg-gradient-to-r from-purple-500 to-indigo-600 text-white px-6 py-3 rounded-xl text-xs font-bold shadow-lg shadow-purple-500/20 hover:brightness-105 active:scale-95 transition-all`,children:[(0,y.jsx)(se,{className:`w-4 h-4 fill-current`}),` `,h===`vi`?`Bắt đầu đọc`:h===`en`?`Start Reading`:`开始阅读`]}),(0,y.jsxs)(`button`,{onClick:_e,className:`inline-flex items-center gap-1.5 px-6 py-3 rounded-xl text-xs font-bold transition-all border ${F?`bg-amber-400 border-amber-400 text-[#0b0b14]`:`bg-white/5 border-white/10 hover:bg-white/10 text-slate-300`}`,children:[(0,y.jsx)(c,{className:`w-4 h-4 fill-current`}),F?h===`vi`?`Đã lưu vào tủ`:h===`en`?`Saved in Shelf`:`已收藏`:h===`vi`?`Thêm vào tủ`:h===`en`?`Save to Shelf`:`收藏`]}),X&&X.length>0&&(0,y.jsxs)(`a`,{href:X[0].url,target:`_blank`,rel:`noopener noreferrer`,onClick:e=>{e.preventDefault(),b(X[0].url)},className:`inline-flex items-center gap-1.5 px-6 py-3 bg-[#0b0b14]/50 border border-purple-500/30 hover:bg-purple-500/10 text-purple-300 rounded-xl text-xs font-bold transition-all hover:scale-[1.02]`,children:[(0,y.jsx)(a,{className:`w-4 h-4`}),h===`vi`?`Trang gốc (Web thật)`:`Source Web`]}),u&&(0,y.jsxs)(`button`,{onClick:()=>O(!0),className:`inline-flex items-center gap-1.5 px-6 py-3 bg-purple-600/20 border border-purple-500/30 hover:bg-purple-600/30 text-purple-200 rounded-xl text-xs font-bold transition-all hover:scale-[1.02]`,children:[(0,y.jsx)(n,{className:`w-4 h-4`}),h===`vi`?`Chia sẻ với bạn bè`:`Share with Friends`]})]})]})]}),(0,y.jsxs)(`div`,{className:`grid grid-cols-1 lg:grid-cols-3 gap-8 items-start`,children:[(0,y.jsxs)(`div`,{className:`lg:col-span-2 space-y-8`,children:[(0,y.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6`,children:[(0,y.jsx)(`h3`,{className:`text-base font-extrabold text-white mb-3`,children:`Tóm tắt nội dung`}),(0,y.jsxs)(`div`,{className:`text-slate-300 text-xs leading-relaxed space-y-2 relative`,children:[(0,y.jsx)(`p`,{className:L?``:`line-clamp-4`,children:Q}),Q&&(0,y.jsx)(`button`,{onClick:()=>R(!L),className:`mt-2 text-purple-400 font-bold inline-flex items-center gap-1 text-[11px] hover:underline`,children:L?(0,y.jsxs)(y.Fragment,{children:[`Thu gọn `,(0,y.jsx)(o,{className:`w-3.5 h-3.5`})]}):(0,y.jsxs)(y.Fragment,{children:[`Xem thêm `,(0,y.jsx)(s,{className:`w-3.5 h-3.5`})]})})]})]}),(0,y.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6`,children:[(0,y.jsx)(`h3`,{className:`text-base font-extrabold text-white mb-4`,children:`Danh sách chương`}),V?(0,y.jsx)(`div`,{className:`text-center text-slate-500 py-6`,children:`Đang tải danh sách chương...`}):(0,y.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[380px] overflow-y-auto pr-1 custom-scrollbar`,children:z.map(e=>(0,y.jsx)(`button`,{onClick:()=>x(`/book/${S.id}/read/${e.url_idx}`),className:`text-left px-4 py-3 bg-[#0b0b14]/50 hover:bg-purple-500/10 border border-[#1f1f3a] hover:border-purple-500/30 rounded-xl text-xs text-slate-300 hover:text-purple-300 transition-all truncate`,children:e.title},e.id))})]}),(0,y.jsx)(ce,{slot:`book-detail-bottom`}),(0,y.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 space-y-6`,children:[(0,y.jsxs)(`div`,{className:`flex justify-between items-center border-b border-[#1f1f3a]/60 pb-4`,children:[(0,y.jsxs)(`h3`,{className:`text-base font-extrabold text-white flex items-center gap-2`,children:[(0,y.jsx)(t,{className:`w-4.5 h-4.5 text-purple-400`}),` Bình luận & Đánh giá (`,U.length,`)`]}),(0,y.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,y.jsx)(`span`,{className:`text-amber-400 font-bold text-sm`,children:`⭐ 4.8`}),(0,y.jsx)(`span`,{className:`text-slate-500 text-[11px]`,children:`(145 bình chọn)`})]})]}),(0,y.jsxs)(`form`,{onSubmit:Z,className:`bg-[#0b0b14]/40 border border-[#1f1f3a] p-4 rounded-2xl space-y-3`,children:[(0,y.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,y.jsx)(`span`,{className:`text-xs text-slate-400 font-semibold`,children:`Viết nhận xét của bạn:`}),(0,y.jsxs)(`div`,{className:`flex items-center gap-1`,children:[(0,y.jsx)(`span`,{className:`text-[11px] text-slate-500 mr-1`,children:`Đánh giá sao:`}),[1,2,3,4,5].map(e=>(0,y.jsx)(`button`,{type:`button`,onClick:()=>J(e),className:`text-sm transition-colors ${e<=q?`text-amber-400`:`text-slate-600 hover:text-slate-500`}`,children:`★`},e))]})]}),(0,y.jsx)(`div`,{className:`relative`,children:(0,y.jsx)(`textarea`,{value:G,onChange:e=>K(e.target.value),placeholder:`Nhập cảm nhận của bạn về bản dịch, cốt truyện...`,rows:`3`,className:`w-full bg-[#121225] border border-[#1f1f3a] rounded-xl px-4 py-3 text-white text-xs outline-none focus:border-purple-500/50 transition-colors resize-none placeholder-slate-600`})}),(0,y.jsx)(`div`,{className:`flex justify-end`,children:(0,y.jsxs)(`button`,{type:`submit`,disabled:!G.trim(),className:`inline-flex items-center gap-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/30 text-white px-4 py-2 rounded-xl text-xs font-bold transition-all`,children:[(0,y.jsx)(ee,{className:`w-3.5 h-3.5`}),` Gửi bình luận`]})})]}),(0,y.jsx)(`div`,{className:`space-y-4`,children:U.map(e=>(0,y.jsxs)(`div`,{className:`border-b border-[#1f1f3a]/40 pb-4 last:border-b-0 last:pb-0 flex gap-3.5`,children:[(0,y.jsx)(`img`,{src:e.avatar,alt:e.user,className:`w-9 h-9 rounded-full object-cover border border-[#2d2d55] bg-[#0f0f1a] shrink-0`}),(0,y.jsxs)(`div`,{className:`flex-1 space-y-1.5`,children:[(0,y.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,y.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,y.jsx)(`span`,{className:`text-xs font-bold text-slate-100`,children:e.user}),(0,y.jsx)(`div`,{className:`flex text-amber-400 text-[10px]`,children:Array.from({length:5},(t,n)=>(0,y.jsx)(`span`,{children:n<e.rating?`★`:`☆`},n))})]}),(0,y.jsx)(`span`,{className:`text-[10px] text-slate-500`,children:e.time})]}),(0,y.jsx)(`p`,{className:`text-slate-300 text-xs leading-relaxed`,children:e.text}),(0,y.jsx)(`div`,{className:`flex items-center gap-3 pt-1`,children:(0,y.jsxs)(`button`,{onClick:()=>ve(e.id),className:`inline-flex items-center gap-1 text-[10px] font-semibold transition-colors ${fe.has(e.id)?`text-purple-400`:`text-slate-500 hover:text-slate-400`}`,children:[(0,y.jsx)(_,{className:`w-3 h-3`}),` Hữu ích (`,e.likes,`)`]})})]})]},e.id))})]})]}),(0,y.jsxs)(`div`,{className:`space-y-6`,children:[(0,y.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 space-y-5`,children:[(0,y.jsxs)(`h3`,{className:`text-sm font-extrabold text-white border-b border-[#1f1f3a] pb-2.5 flex items-center gap-2`,children:[(0,y.jsx)(e,{className:`w-4 h-4 text-purple-400`}),` Thống kê & Chi tiết`]}),(0,y.jsxs)(`div`,{className:`space-y-3.5`,children:[(0,y.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 flex items-center gap-1.5`,children:[(0,y.jsx)(le,{className:`w-3.5 h-3.5 text-purple-400`}),` Trạng thái:`]}),(0,y.jsx)(`span`,{className:`text-emerald-400 font-bold`,children:`Đang cập nhật`})]}),(0,y.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 flex items-center gap-1.5`,children:[(0,y.jsx)(te,{className:`w-3.5 h-3.5 text-purple-400`}),` Tổng lượt xem:`]}),(0,y.jsx)(`span`,{className:`text-slate-200 font-bold`,children:S.word_count_max?Math.round(S.word_count_max*1.5).toLocaleString():`340,500`})]}),(0,y.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 flex items-center gap-1.5`,children:[(0,y.jsx)(p,{className:`w-3.5 h-3.5 text-purple-400`}),` Tốc độ ra chương:`]}),(0,y.jsx)(`span`,{className:`text-slate-200 font-bold`,children:`~12 chương / ngày`})]}),(0,y.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 flex items-center gap-1.5`,children:[(0,y.jsx)(c,{className:`w-3.5 h-3.5 text-purple-400`}),` Độ tin cậy nguồn:`]}),(0,y.jsx)(`span`,{className:`text-indigo-400 font-bold`,children:`99.1% (Sạch QC)`})]}),(0,y.jsxs)(`div`,{className:`flex flex-col gap-2 pt-2 border-t border-[#1f1f3a]/30`,children:[(0,y.jsxs)(`span`,{className:`text-slate-500 text-xs flex items-center gap-1.5`,children:[(0,y.jsx)(i,{className:`w-3.5 h-3.5 text-purple-400`}),` Đọc ở trang gốc (Web thật):`]}),(0,y.jsx)(`div`,{className:`flex flex-wrap gap-1.5 pt-1`,children:X&&X.length>0?X.map((e,t)=>{let n=`bg-emerald-500 text-white`;return e.site.toLowerCase().includes(`biquge`)||e.site.toLowerCase().includes(`full`)||e.site.toLowerCase().includes(`truyenfull`)?n=`bg-sky-500 text-white`:e.site.toLowerCase().includes(`faloo`)||e.site.toLowerCase().includes(`vcomi`)||e.site.toLowerCase().includes(`fanqie`)?n=`bg-orange-500 text-white`:(e.site.toLowerCase().includes(`quanben`)||e.site.toLowerCase().includes(`hjwzw`))&&(n=`bg-purple-500 text-white`),(0,y.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noopener noreferrer`,onClick:t=>{t.preventDefault(),b(e.url)},className:`inline-flex items-center gap-1 bg-[#0b0b14]/50 border border-[#1f1f3a] hover:border-purple-500/40 rounded px-2 py-1 text-[10px] text-slate-300 hover:text-purple-300 transition-all hover:scale-[1.02]`,title:e.url,children:[(0,y.jsx)(`span`,{className:`w-3.5 h-3.5 rounded flex items-center justify-center text-[9px] font-black ${n}`,children:e.site[0]}),e.site,(0,y.jsx)(a,{className:`w-2.5 h-2.5 text-slate-500`})]},t)}):(0,y.jsx)(`span`,{className:`text-slate-500 italic text-[11px]`,children:`Metruyenchu, Biquge (Không tìm thấy link)`})})]})]})]}),(0,y.jsxs)(`div`,{className:`bg-gradient-to-br from-purple-900/35 to-indigo-950/40 border border-purple-500/20 rounded-3xl p-6 text-center space-y-3.5`,children:[(0,y.jsx)(`div`,{className:`w-10 h-10 rounded-2xl bg-purple-500/10 border border-purple-500/25 flex items-center justify-center mx-auto text-purple-400`,children:(0,y.jsx)(p,{className:`w-5 h-5 fill-current animate-pulse`})}),(0,y.jsxs)(`div`,{children:[(0,y.jsx)(`h4`,{className:`text-xs font-black text-white`,children:`Dịch thuật bởi Antigravity AI`}),(0,y.jsx)(`p`,{className:`text-[10px] text-slate-400 mt-1 leading-relaxed`,children:`Ứng dụng bộ dịch thuật mạng nơ-ron nâng cao giúp chuyển ngữ chính xác ngữ cảnh Hán Việt sang Việt ngữ.`})]})]})]})]}),D&&(0,y.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm`,children:(0,y.jsxs)(`div`,{className:`bg-[#0c0d1e] border border-purple-500/35 rounded-3xl p-6 w-full max-w-sm text-slate-100 shadow-2xl relative animate-fadeIn`,children:[(0,y.jsx)(`button`,{onClick:()=>{O(!1),j(``)},className:`absolute top-4 right-4 p-1 hover:bg-white/5 rounded-lg text-slate-400 hover:text-white transition-all`,children:(0,y.jsx)(ae,{className:`w-4 h-4`})}),(0,y.jsx)(`h3`,{className:`font-extrabold text-sm tracking-wider uppercase mb-4 text-purple-300`,children:`Chia sẻ truyện`}),(0,y.jsxs)(`div`,{className:`space-y-3`,children:[(0,y.jsx)(`label`,{className:`block text-[10px] uppercase font-black tracking-widest text-slate-500`,children:`Lời nhắn kèm theo`}),(0,y.jsx)(`input`,{type:`text`,placeholder:`Ví dụ: Truyện này hay lắm, đọc đi!`,value:A,onChange:e=>j(e.target.value),className:`w-full px-3 py-2 bg-[#080814] border border-purple-500/20 rounded-xl text-xs text-white outline-none focus:border-purple-500 transition-colors`})]}),(0,y.jsxs)(`div`,{className:`space-y-2 mt-4`,children:[(0,y.jsx)(`label`,{className:`block text-[10px] uppercase font-black tracking-widest text-slate-500 mb-1`,children:`Chọn bạn bè`}),k.length===0?(0,y.jsx)(`div`,{className:`text-center py-6 border border-dashed border-purple-500/10 rounded-2xl text-xs text-slate-500`,children:`Chưa có bạn bè nào. Vui lòng kết bạn trước.`}):(0,y.jsx)(`div`,{className:`max-h-[200px] overflow-y-auto space-y-2 pr-1 custom-scrollbar`,children:k.map(e=>(0,y.jsxs)(`div`,{className:`flex items-center justify-between p-2 hover:bg-purple-950/20 border border-purple-500/10 rounded-xl`,children:[(0,y.jsx)(`span`,{className:`text-xs font-bold text-slate-200`,children:e.username}),(0,y.jsx)(`button`,{onClick:()=>P(e.id),disabled:M,className:`px-3 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-[10px] font-bold transition-colors disabled:opacity-50`,children:`Gửi`})]},e.id))})]})]})})]})}export{b as default};
frontend-web/dist/assets/Bookshelf-ClI6d9JY.js ADDED
@@ -0,0 +1 @@
 
 
1
+ import{t as e}from"./MainLayout-COiTxmNr.js";import{n as t,t as n}from"./square-DZKJ0QGR.js";import{t as r}from"./BookCard-BCMDdlyN.js";import{t as i}from"./square-check-big-BhcFiAg6.js";import{t as a}from"./trash-2-DDhTqVwA.js";import{C as o,D as s,F as c,M as l,S as u,f as d,j as f,t as p,w as m,y as h}from"./index-yRoRoI6u.js";var g=c(f(),1),_=l();function v(){let{t:c,lang:l}=u(),{user:f,loading:v}=o(),y=s(),[b,x]=(0,g.useState)([]),[S,C]=(0,g.useState)(``),[w,T]=(0,g.useState)(!0),[E,D]=(0,g.useState)(!1),[O,k]=(0,g.useState)(new Set),{activeAudioObj:A,setActiveAudioObj:j}=p(),[M,N]=(0,g.useState)(null),[P,F]=(0,g.useState)(null),[I,L]=(0,g.useState)(!1);(0,g.useEffect)(()=>{if(!v){if(!f){T(!1);return}R()}},[f,v,S]);let R=async()=>{T(!0);try{let e=S?{q:S}:{};x((await m.get(`/api/bookshelf`,{params:e})).data.map(e=>({id:e.book_id||e.url,title_vietphrase:e.title,author_hanviet:e.author,cover:e.cover,url:e.url,site_count:1})))}catch(e){console.error(e)}finally{T(!1)}},z=e=>{k(t=>{let n=new Set(t);return n.has(e)?n.delete(e):n.add(e),n})},B=()=>{O.size===b.length?k(new Set):k(new Set(b.map(e=>e.id).filter(Boolean)))},V=async()=>{if(window.confirm(l===`vi`?`Bạn có chắc chắn muốn xóa ${O.size} truyện đã chọn khỏi tủ sách cá nhân không?`:l===`en`?`Are you sure you want to remove ${O.size} selected novels from bookshelf?`:`您确定要从书架中移出选中的 ${O.size} 部小说吗?`)){T(!0);try{await Promise.all(Array.from(O).map(e=>typeof e==`string`&&(e.startsWith(`http://`)||e.startsWith(`https://`))?m.post(`/api/bookshelf/remove`,{url:e}):m.post(`/api/bookshelf/remove`,{book_id:e}))),k(new Set),D(!1),await R()}catch{alert(l===`vi`?`Không xóa được sách hàng loạt.`:`Failed to perform bulk remove.`)}finally{T(!0),await R()}}},H=async e=>{if(window.confirm(l===`vi`?`Bạn có chắc chắn muốn xóa truyện này khỏi tủ sách cá nhân không?`:l===`en`?`Are you sure you want to remove this novel from your personal bookshelf?`:`您确定要从个人书架中移出这部小说吗?`))try{typeof e==`string`&&(e.startsWith(`http://`)||e.startsWith(`https://`))?await m.post(`/api/bookshelf/remove`,{url:e}):await m.post(`/api/bookshelf/remove`,{book_id:e}),x(t=>t.filter(t=>t.id!==e))}catch{alert(l===`vi`?`Không xóa được sách.`:l===`en`?`Failed to remove novel.`:`无法移出书架。`)}},U=e=>{y(`/?search_field=author&q=${encodeURIComponent(e)}`)},W=e=>{y(`/?category=${encodeURIComponent(e)}`)},G=e=>{e.id&&y(`/book/${e.id}`)},K=async e=>{if(M===e){N(null),F(null);return}N(e),L(!0);try{F((await m.get(`/api/book/${e}/translations`)).data)}catch(e){console.error(e)}finally{L(!1)}},q=async e=>{if(e.description)j(e);else try{let t=await m.get(`/api/book/${e.id}/translations`);if(t.data){let n=t.data.advanced?.desc||t.data.fast?.desc||t.data.vietphrase?.desc||``;j({...e,description:n})}else j(e)}catch{j(e)}};return v?(0,_.jsx)(e,{children:(0,_.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,_.jsx)(d,{className:`w-8 h-8 animate-spin mx-auto mb-2 text-brand-500`}),(0,_.jsx)(`span`,{children:l===`vi`?`Đang tải thông tin...`:l===`en`?`Loading info...`:`正在加载信息...`})]})}):f?(0,_.jsxs)(e,{children:[(0,_.jsxs)(`div`,{className:`flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6`,children:[(0,_.jsxs)(`div`,{className:`flex flex-wrap items-center gap-3`,children:[(0,_.jsxs)(`h2`,{className:`text-xl font-bold flex items-center gap-2`,children:[(0,_.jsx)(h,{className:`w-6 h-6 text-brand-400`}),l===`vi`?`Tủ Sách Cá Nhân`:l===`en`?`Personal Bookshelf`:`个人书架`,` (`,b.length,`)`]}),b.length>0&&(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(`button`,{onClick:()=>{D(!E),k(new Set)},className:`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${E?`bg-purple-600/25 border-purple-500/50 text-purple-300 hover:bg-purple-600/35`:`bg-white/5 border-white/10 text-slate-300 hover:bg-white/10`}`,children:E?l===`vi`?`Thoát quản lý`:`Exit management`:l===`vi`?`Quản lý tủ sách`:`Manage bookshelf`}),E&&(0,_.jsxs)(_.Fragment,{children:[(0,_.jsx)(`button`,{onClick:B,className:`px-3 py-1.5 bg-[#121225] border border-[#1f1f3a] rounded-lg text-xs font-bold text-slate-300 hover:bg-[#1a1a35] transition-all`,children:O.size===b.length?l===`vi`?`Hủy chọn tất cả`:`Deselect all`:l===`vi`?`Chọn tất cả`:`Select all`}),O.size>0&&(0,_.jsxs)(`button`,{onClick:V,className:`inline-flex items-center gap-1.5 px-3 py-1.5 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-rose-950/20 transition-all active:scale-95 cursor-pointer`,children:[(0,_.jsx)(a,{className:`w-3.5 h-3.5`}),l===`vi`?`Xóa hàng loạt (${O.size})`:`Bulk Delete (${O.size})`]})]})]})]}),(0,_.jsxs)(`div`,{className:`relative w-full md:w-72`,children:[(0,_.jsx)(t,{className:`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500`}),(0,_.jsx)(`input`,{type:`text`,placeholder:l===`vi`?`Tìm trong tủ sách...`:l===`en`?`Search bookshelf...`:`在书架中搜索...`,value:S,onChange:e=>C(e.target.value),className:`w-full pl-9 pr-4 py-2.5 bg-[#121225] border border-[#1f1f3a] rounded-xl text-white outline-none focus:border-brand-500 transition-colors text-xs`})]})]}),w?(0,_.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,_.jsx)(d,{className:`w-6 h-6 animate-spin mx-auto mb-2`}),(0,_.jsx)(`span`,{children:c.loading})]}):b.length===0?(0,_.jsxs)(`div`,{className:`py-20 text-center text-slate-500 bg-[#121225]/40 border border-dashed border-[#1f1f3a] rounded-2xl`,children:[(0,_.jsx)(h,{className:`w-10 h-10 mx-auto mb-3 text-slate-600`}),(0,_.jsx)(`p`,{className:`text-sm`,children:l===`vi`?`Tủ sách trống. Hãy thêm truyện từ tab Khám Phá!`:l===`en`?`Bookshelf is empty. Add novels from Discover tab!`:`书架空空如也。请从“发现”选项卡中添加小说!`})]}):(0,_.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4`,children:b.map(e=>{let t=O.has(e.id);return(0,_.jsxs)(`div`,{className:`flex flex-col relative group`,children:[E&&(0,_.jsx)(`div`,{onClick:()=>z(e.id),className:`absolute top-3 left-3 z-30 w-7 h-7 rounded-lg border flex items-center justify-center cursor-pointer transition-all shadow-md ${t?`bg-purple-600 border-purple-500 text-white shadow-purple-500/20`:`bg-[#0f101f]/95 border-slate-700/80 text-slate-500 hover:bg-purple-950/25 hover:border-purple-500/50`}`,children:t?(0,_.jsx)(i,{className:`w-4.5 h-4.5`}):(0,_.jsx)(n,{className:`w-4.5 h-4.5`})}),(0,_.jsx)(`div`,{className:E?`opacity-70 transition-opacity`:``,children:(0,_.jsx)(r,{book:e,isFav:!0,onToggleFav:H,onRead:G,onCompare:K,onPlayTrailer:q,onSearchAuthor:U,onSearchCategory:W})}),M===e.id&&(0,_.jsx)(`div`,{className:`bg-[#0f101f] border border-[#1f1f3a] rounded-b-2xl p-4 text-xs mt-[-10px] space-y-4 shadow-inner`,children:I?(0,_.jsx)(`div`,{className:`text-center text-slate-500 py-3`,children:c.comparingText}):P?(0,_.jsxs)(_.Fragment,{children:[(0,_.jsxs)(`div`,{className:`pb-2 border-b border-white/5`,children:[(0,_.jsx)(`span`,{className:`text-[10px] text-brand-400 font-extrabold uppercase`,children:c.compFast}),(0,_.jsx)(`div`,{className:`text-white font-bold mt-0.5`,children:P.fast.title||`—`}),(0,_.jsx)(`p`,{className:`text-slate-400 text-[11px] leading-relaxed mt-1`,children:P.fast.desc||`—`})]}),(0,_.jsxs)(`div`,{className:`pb-2 border-b border-white/5`,children:[(0,_.jsx)(`span`,{className:`text-[10px] text-amber-400 font-extrabold uppercase`,children:c.compAdvanced}),(0,_.jsx)(`div`,{className:`text-white font-bold mt-0.5`,children:P.advanced.title||`—`}),(0,_.jsx)(`p`,{className:`text-slate-400 text-[11px] leading-relaxed mt-1`,children:P.advanced.desc||`—`})]}),(0,_.jsxs)(`div`,{className:`pb-2 border-b border-white/5`,children:[(0,_.jsx)(`span`,{className:`text-[10px] text-emerald-400 font-extrabold uppercase`,children:c.compVietphrase}),(0,_.jsx)(`div`,{className:`text-white font-bold mt-0.5`,children:P.vietphrase.title||`—`}),(0,_.jsx)(`p`,{className:`text-slate-400 text-[11px] leading-relaxed mt-1`,children:P.vietphrase.desc||`—`})]}),(0,_.jsxs)(`div`,{children:[(0,_.jsx)(`span`,{className:`text-[10px] text-indigo-400 font-extrabold uppercase`,children:c.compHanviet}),(0,_.jsx)(`div`,{className:`text-white font-bold mt-0.5`,children:P.hanviet.title||`—`}),(0,_.jsx)(`p`,{className:`text-slate-400 text-[11px] leading-relaxed mt-1`,children:P.hanviet.desc||`—`})]})]}):(0,_.jsx)(`div`,{className:`text-center text-red-500 py-3`,children:c.compError})})]},e.id||e.url)})})]}):(0,_.jsx)(e,{children:(0,_.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,_.jsx)(`div`,{className:`w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center text-slate-400 mx-auto mb-4`,children:`🔒`}),(0,_.jsx)(`p`,{className:`text-sm`,children:c.loginToViewBookshelf})]})})}export{v as default};
frontend-web/dist/assets/Developer-BFnP9Lg5.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import{t as e}from"./award-CKCOPIfL.js";import{d as t,n,s as r,t as i,u as ee}from"./MainLayout-COiTxmNr.js";import{o as a}from"./square-DZKJ0QGR.js";import{t as o}from"./plus-CSQqtn3N.js";import{t as s}from"./trash-2-DDhTqVwA.js";import{C as c,F as l,M as u,S as d,d as f,g as p,j as m,p as h,u as te,w as g}from"./index-yRoRoI6u.js";var _=l(m(),1),v=u();function y(){let{user:l}=c(),{t:u,lang:m}=d(),[y,b]=(0,_.useState)([]),[x,S]=(0,_.useState)(!1),[ne,C]=(0,_.useState)(0),[w,T]=(0,_.useState)(``),[E,D]=(0,_.useState)(!1),[O,k]=(0,_.useState)(!1),[re,A]=(0,_.useState)(``),[j,ie]=(0,_.useState)([]),[ae,M]=(0,_.useState)(!1),[N,P]=(0,_.useState)(``),[F,I]=(0,_.useState)(`武之极,破苍穹,动乾坤!Trong thế giới này, kẻ mạnh làm chủ.`),[L,R]=(0,_.useState)(1),[z,B]=(0,_.useState)(!1),[V,H]=(0,_.useState)(!1),U=(0,_.useRef)(null),[W,G]=(0,_.useState)(`第1章 开封神殿
2
+ 杨开迈步走入神殿。`),[K,q]=(0,_.useState)(`fast`),[J,Y]=(0,_.useState)(``),[X,Z]=(0,_.useState)(!1);(0,_.useEffect)(()=>{l&&Promise.all([Q(),oe()])},[l]);let Q=async()=>{D(!0);try{let e=await g.get(`/api/developer/keys`);b(e.data.keys||[]),C(e.data.balance||0),e.data.keys&&e.data.keys.length>0&&P(e.data.keys[0].api_key)}catch(e){console.error(e)}finally{D(!1)}},oe=async()=>{M(!0);try{ie((await g.get(`/api/developer/usage`)).data.usage||[])}catch(e){console.error(e)}finally{M(!1)}},se=async e=>{if(e.preventDefault(),w.trim()){k(!0);try{await g.post(`/api/developer/keys/create`,{name:w}),T(``),Q()}catch{alert(u.developer?.createError||`Lỗi khi tạo API Key.`)}finally{k(!1)}}},ce=async e=>{if(window.confirm(u.developer?.revokeConfirm||`Bạn có chắc chắn muốn thu hồi khóa API này? Tất cả các ứng dụng đang sử dụng nó sẽ bị ngắt kết nối.`))try{await g.post(`/api/developer/keys/delete`,{api_key:e}),Q()}catch{alert(u.developer?.revokeError||`Lỗi khi thu hồi API Key.`)}},le=e=>{navigator.clipboard.writeText(e),A(e),setTimeout(()=>A(``),2e3)},ue=async()=>{if(!N){alert(u.developer?.keyNameRequired||`Vui lòng tạo hoặc chọn một API Key trước.`);return}H(!0),B(!1),U.current&&=(U.current.pause(),null);try{let e=await g.post(`/v1/audio/speech`,{input:F,speed:L},{responseType:`blob`,headers:{Authorization:`Bearer ${N}`}}),t=URL.createObjectURL(e.data),n=new Audio(t);U.current=n,n.playbackRate=L,n.onplay=()=>{n.playbackRate=L},n.onplaying=()=>{n.playbackRate=L},n.play().then(()=>{n.playbackRate=L}).catch(e=>console.error(`Sandbox playback failed:`,e)),B(!0),n.onended=()=>B(!1)}catch{alert(u.reader?.ttsError||`Lỗi phát âm thanh. Vui lòng thử lại sau.`)}finally{H(!1)}},de=async()=>{if(!N){alert(u.developer?.keyNameRequired||`Vui lòng tạo hoặc chọn một API Key trước.`);return}Z(!0),Y(``);try{let e=await g.post(`/api/v1/translate`,{texts:W.split(`
3
+ `),mode:K},{headers:{Authorization:`Bearer ${N}`}});e.data&&e.data.translations&&Y(e.data.translations.join(`
4
+ `))}catch{alert(u.reader?.errorLoadingChapter||`Lỗi tải nội dung.`)}finally{Z(!1)}},$=e=>{let t=Number(e);return isNaN(t)?`0 ₫`:new Intl.NumberFormat(`vi-VN`,{style:`currency`,currency:`VND`}).format(t)};return l?(0,v.jsxs)(i,{children:[(0,v.jsxs)(`div`,{className:`space-y-8`,children:[(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 md:p-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4`,children:[(0,v.jsxs)(`div`,{className:`space-y-1`,children:[(0,v.jsxs)(`h2`,{className:`text-xl md:text-2xl font-black text-white flex items-center gap-2`,children:[(0,v.jsx)(r,{className:`text-purple-400 w-6 h-6`}),` `,u.developer?.title||`Developer API & TTS Console`]}),(0,v.jsx)(`p`,{className:`text-xs text-slate-400`,children:u.developer?.subtitle||`Quản lý API Key, giám sát số dư và kiểm thử dịch thuật/TTS thời gian thực.`})]}),(0,v.jsxs)(`div`,{className:`flex items-center gap-3 w-full md:w-auto`,children:[(0,v.jsxs)(`div`,{className:`bg-[#0b0b14]/80 border border-purple-500/25 px-5 py-3 rounded-2xl text-right flex-1 md:flex-initial`,children:[(0,v.jsx)(`span`,{className:`text-[9px] text-slate-500 block uppercase font-extrabold tracking-wider`,children:u.developer?.balance||`Số dư API Developer`}),(0,v.jsx)(`strong`,{className:`text-base text-emerald-400 font-extrabold block mt-0.5`,children:$(ne)})]}),(0,v.jsxs)(`button`,{onClick:()=>S(!0),className:`bg-gradient-to-r from-amber-400 to-amber-500 hover:brightness-105 text-[#0b0b14] font-extrabold px-5 py-4 rounded-2xl text-xs transition-all shadow-lg shadow-amber-500/10 whitespace-nowrap active:scale-95`,children:[`⚡ `,m===`zh`?`充值 / 购买 VIP`:m===`en`?`Deposit / Buy VIP`:`Nạp số dư / Mua VIP`]})]})]}),(0,v.jsxs)(`div`,{className:`grid grid-cols-1 lg:grid-cols-3 gap-6`,children:[(0,v.jsxs)(`div`,{className:`lg:col-span-2 space-y-6`,children:[(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 space-y-4`,children:[(0,v.jsx)(`div`,{className:`flex justify-between items-center pb-2 border-b border-[#1f1f3a]/50`,children:(0,v.jsxs)(`h3`,{className:`text-sm font-extrabold text-white flex items-center gap-2`,children:[(0,v.jsx)(h,{className:`w-4 h-4 text-purple-400`}),` `,u.developer?.apiKeyListTitle||`Danh sách khóa API Key`]})}),E?(0,v.jsxs)(`div`,{className:`text-center py-6 text-slate-500 text-xs`,children:[(0,v.jsx)(t,{className:`w-5 h-5 animate-spin mx-auto mb-2 text-purple-500`}),u.developer?.creating||`Đang tải...`]}):y.length===0?(0,v.jsx)(`p`,{className:`text-slate-500 text-xs text-center py-6`,children:m===`zh`?`您尚未创建任何 API Key。`:m===`en`?`You have not created any API Keys yet.`:`Bạn chưa tạo khóa API nào.`}):(0,v.jsx)(`div`,{className:`overflow-x-auto`,children:(0,v.jsxs)(`table`,{className:`w-full text-left text-xs text-slate-300`,children:[(0,v.jsx)(`thead`,{children:(0,v.jsxs)(`tr`,{className:`border-b border-[#1f1f3a] text-slate-500`,children:[(0,v.jsx)(`th`,{className:`pb-2`,children:u.developer?.keyNameLabel||`Tên khóa`}),(0,v.jsx)(`th`,{className:`pb-2`,children:`Khóa API`}),(0,v.jsx)(`th`,{className:`pb-2`,children:u.developer?.createdLabel||`Ngày tạo`}),(0,v.jsx)(`th`,{className:`pb-2 text-center`,children:m===`zh`?`操作`:m===`en`?`Actions`:`Hành động`})]})}),(0,v.jsx)(`tbody`,{className:`divide-y divide-[#1f1f3a]/30`,children:y.map((e,t)=>(0,v.jsxs)(`tr`,{className:`hover:bg-white/[0.02]`,children:[(0,v.jsx)(`td`,{className:`py-3 font-semibold text-white`,children:e.name}),(0,v.jsxs)(`td`,{className:`py-3 font-mono text-purple-300`,children:[e.api_key.slice(0,10),`...`,e.api_key.slice(-6)]}),(0,v.jsx)(`td`,{className:`py-3 text-slate-500 text-[10px]`,children:new Date(e.created_at).toLocaleDateString(`vi-VN`)}),(0,v.jsx)(`td`,{className:`py-3 text-center`,children:(0,v.jsxs)(`div`,{className:`flex justify-center gap-2`,children:[(0,v.jsx)(`button`,{onClick:()=>le(e.api_key),className:`p-1.5 bg-[#0b0b14] border border-[#1f1f3a] hover:border-purple-500/50 rounded-lg text-slate-400 hover:text-white transition-all`,title:`Copy API Key`,children:re===e.api_key?(0,v.jsx)(a,{className:`w-3.5 h-3.5 text-emerald-400`}):(0,v.jsx)(p,{className:`w-3.5 h-3.5`})}),(0,v.jsx)(`button`,{onClick:()=>ce(e.api_key),className:`p-1.5 bg-red-500/10 border border-red-500/20 hover:bg-red-500/20 rounded-lg text-red-400 transition-all`,title:`Thu hồi`,children:(0,v.jsx)(s,{className:`w-3.5 h-3.5`})})]})})]},t))})]})}),(0,v.jsxs)(`form`,{onSubmit:se,className:`flex gap-2 border-t border-[#1f1f3a]/50 pt-4`,children:[(0,v.jsx)(`input`,{type:`text`,placeholder:u.developer?.keyNamePlaceholder||`Tên gợi nhớ`,value:w,onChange:e=>T(e.target.value),className:`flex-1 px-4 py-2.5 bg-[#0b0b14] border border-[#1f1f3a] rounded-xl text-xs text-white outline-none focus:border-purple-500 transition-colors`}),(0,v.jsxs)(`button`,{type:`submit`,disabled:O||!w.trim(),className:`bg-purple-600 hover:bg-purple-500 disabled:opacity-40 text-white font-bold px-4 py-2.5 rounded-xl text-xs flex items-center gap-1.5 transition-all`,children:[(0,v.jsx)(o,{className:`w-4 h-4`}),` `,u.developer?.createBtn||`Tạo khóa`]})]})]}),(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-6 space-y-6`,children:[(0,v.jsx)(`h3`,{className:`text-sm font-extrabold text-white flex items-center gap-2 border-b border-[#1f1f3a]/50 pb-2`,children:`⚡ API Sandbox & TTS Playground`}),(0,v.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-6`,children:[(0,v.jsxs)(`div`,{className:`space-y-4`,children:[(0,v.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,v.jsx)(`h4`,{className:`text-xs font-bold text-slate-300`,children:`1. Text-to-Speech (TTS)`}),(0,v.jsx)(`span`,{className:`text-[9px] bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded border border-purple-500/20 uppercase font-bold`,children:`RunPod AI`})]}),(0,v.jsx)(`textarea`,{rows:4,value:F,onChange:e=>I(e.target.value),placeholder:`Nhập văn bản cần phát audio...`,className:`w-full bg-[#0b0b14] border border-[#1f1f3a] rounded-xl p-3 text-xs text-slate-300 outline-none focus:border-purple-500`}),(0,v.jsxs)(`div`,{className:`grid grid-cols-2 gap-3`,children:[(0,v.jsxs)(`div`,{className:`space-y-1`,children:[(0,v.jsxs)(`label`,{className:`text-[10px] text-slate-400 font-bold block`,children:[u.reader?.speed||`Tốc độ`,` (`,L,`x):`]}),(0,v.jsx)(`input`,{type:`range`,min:`0.5`,max:`4.0`,step:`0.1`,value:L,onChange:e=>R(parseFloat(e.target.value)),className:`w-full accent-purple-500 bg-[#0b0b14]`})]}),(0,v.jsx)(`div`,{className:`space-y-1 flex items-end`,children:(0,v.jsxs)(`button`,{onClick:ue,disabled:V,className:`w-full bg-purple-600 hover:bg-purple-500 active:scale-95 disabled:opacity-40 text-white font-bold py-2.5 rounded-xl text-xs flex items-center justify-center gap-1.5 shadow-md transition-all`,children:[V?(0,v.jsx)(t,{className:`w-4 h-4 animate-spin`}):z?(0,v.jsx)(f,{className:`w-4 h-4`}):(0,v.jsx)(te,{className:`w-4 h-4 fill-current`}),z?m===`zh`?`正在播放...`:m===`en`?`Playing...`:`Đang phát...`:m===`zh`?`播放音频`:m===`en`?`Play Audio`:`Gửi & Phát Audio`]})})]})]}),(0,v.jsxs)(`div`,{className:`space-y-4`,children:[(0,v.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,v.jsx)(`h4`,{className:`text-xs font-bold text-slate-300`,children:m===`zh`?`2. 中越翻译`:m===`en`?`2. Chinese-Vietnamese Translation`:`2. Dịch thuật Trung-Việt`}),(0,v.jsx)(`span`,{className:`text-[9px] bg-emerald-500/15 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/20 uppercase font-bold`,children:`Vietphrase`})]}),(0,v.jsx)(`textarea`,{rows:4,value:W,onChange:e=>G(e.target.value),placeholder:`Nhập văn bản tiếng Trung cần dịch...`,className:`w-full bg-[#0b0b14] border border-[#1f1f3a] rounded-xl p-3 text-xs text-slate-300 outline-none focus:border-purple-500`}),(0,v.jsxs)(`div`,{className:`grid grid-cols-2 gap-3`,children:[(0,v.jsxs)(`select`,{value:K,onChange:e=>q(e.target.value),className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-2.5 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,v.jsx)(`option`,{value:`fast`,children:u.compFast}),(0,v.jsx)(`option`,{value:`vietphrase`,children:u.compVietphrase})]}),(0,v.jsxs)(`button`,{onClick:de,disabled:X,className:`w-full bg-emerald-600 hover:bg-emerald-500 active:scale-95 disabled:opacity-40 text-white font-bold py-2.5 rounded-xl text-xs flex items-center justify-center gap-1.5 shadow-md transition-all`,children:[X?(0,v.jsx)(t,{className:`w-4 h-4 animate-spin`}):(0,v.jsx)(ee,{className:`w-4 h-4`}),m===`zh`?`翻译`:m===`en`?`Translate`:`Dịch ngay`]})]})]})]}),J&&(0,v.jsxs)(`div`,{className:`bg-[#0b0b14] border border-[#1f1f3a] rounded-xl p-4 space-y-1.5 animate-in fade-in duration-200`,children:[(0,v.jsx)(`span`,{className:`text-[9px] text-slate-500 block uppercase font-extrabold tracking-wider`,children:m===`zh`?`翻译结果`:m===`en`?`Translation Result`:`Kết quả dịch`}),(0,v.jsx)(`p`,{className:`text-xs text-slate-200 whitespace-pre-line leading-relaxed`,children:J})]})]})]}),(0,v.jsxs)(`div`,{className:`space-y-6`,children:[(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-5 shadow-xl space-y-4`,children:[(0,v.jsxs)(`h3`,{className:`text-xs font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-amber-200 flex items-center gap-1.5`,children:[(0,v.jsx)(r,{className:`w-4 h-4 text-amber-400`}),` `,u.developer?.apiDocsTitle||`Tài liệu tích hợp (cURL)`]}),(0,v.jsxs)(`div`,{className:`space-y-3.5`,children:[(0,v.jsxs)(`div`,{className:`space-y-1`,children:[(0,v.jsx)(`span`,{className:`text-[9px] text-slate-400 font-extrabold uppercase`,children:u.developer?.ttsEndpoint||`1. Chuyển Text thành Audio (OpenAI format)`}),(0,v.jsx)(`div`,{className:`bg-[#0b0b14] border border-white/5 p-3 rounded-xl relative`,children:(0,v.jsx)(`pre`,{className:`text-[10px] text-slate-300 font-mono overflow-x-auto whitespace-pre leading-relaxed select-all`,children:`curl -X POST http://localhost:5051/v1/audio/speech \\
5
+ -H "Authorization: Bearer YOUR_API_KEY" \\
6
+ -H "Content-Type: application/json" \\
7
+ -d '{"input": "Nhập văn bản cần phát", "speed": 1.0}' \\
8
+ --output audio.wav`})})]}),(0,v.jsxs)(`div`,{className:`space-y-1`,children:[(0,v.jsx)(`span`,{className:`text-[9px] text-slate-400 font-extrabold uppercase`,children:u.developer?.transEndpoint||`2. API Dịch Thuật Trung-Việt`}),(0,v.jsx)(`div`,{className:`bg-[#0b0b14] border border-white/5 p-3 rounded-xl relative`,children:(0,v.jsx)(`pre`,{className:`text-[10px] text-slate-300 font-mono overflow-x-auto whitespace-pre leading-relaxed select-all`,children:`curl -X POST http://localhost:5051/api/v1/translate \\
9
+ -H "Authorization: Bearer YOUR_API_KEY" \\
10
+ -H "Content-Type: application/json" \\
11
+ -d '{"texts": ["第1章", "开封神殿"], "mode": "fast"}'`})})]})]})]}),(0,v.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-3xl p-5 shadow-xl space-y-4`,children:[(0,v.jsxs)(`h3`,{className:`text-xs font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-brand-300 to-purple-300 flex items-center gap-1.5`,children:[(0,v.jsx)(e,{className:`w-4 h-4 text-brand-400`}),` `,u.developer?.apiUsageHistoryTitle||`Nhật ký cuộc gọi gần đây`]}),ae?(0,v.jsx)(`div`,{className:`text-center py-4 text-slate-500 text-xs`,children:`Đang tải...`}):j.length===0?(0,v.jsx)(`p`,{className:`text-slate-500 text-xs text-center py-4`,children:m===`zh`?`暂无接口调用记录。`:m===`en`?`No API usage records found.`:`Chưa có lịch sử cuộc gọi API nào.`}):(0,v.jsx)(`div`,{className:`space-y-3 max-h-[300px] overflow-y-auto`,children:j.slice(0,10).map((e,t)=>(0,v.jsxs)(`div`,{className:`border-b border-[#1f1f3a]/30 pb-2.5 last:border-0 last:pb-0 flex justify-between items-center text-[10px]`,children:[(0,v.jsxs)(`div`,{children:[(0,v.jsx)(`span`,{className:`text-white font-bold font-mono`,children:e.model}),(0,v.jsxs)(`span`,{className:`text-slate-500 block text-[9px] mt-0.5`,children:[new Date(e.timestamp).toLocaleTimeString(`vi-VN`),` · `,e.tokens,` `,m===`zh`?`字符`:m===`en`?`chars`:`kí tự`]})]}),(0,v.jsxs)(`div`,{className:`text-right`,children:[(0,v.jsx)(`span`,{className:`text-emerald-400 font-bold block`,children:e.cost===0?u.developer?.freeCost||`Miễn phí (VIP)`:$(e.cost)}),(0,v.jsx)(`span`,{className:`px-1 rounded text-[8px] font-bold ${e.status_code===200?`bg-emerald-500/15 text-emerald-400`:`bg-red-500/15 text-red-400`}`,children:e.status_code})]})]},t))})]})]})]})]}),(0,v.jsx)(n,{isOpen:x,onClose:()=>{S(!1),Q()}})]}):(0,v.jsx)(i,{children:(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500 max-w-md mx-auto space-y-4`,children:[(0,v.jsx)(r,{className:`w-12 h-12 text-purple-400 mx-auto`}),(0,v.jsx)(`h2`,{className:`text-lg font-bold text-white`,children:`Developer API Console`}),(0,v.jsx)(`p`,{className:`text-xs text-slate-400 leading-relaxed`,children:`Vui lòng đăng nhập tài khoản để tiếp tục.`})]})})}export{y as default};
frontend-web/dist/assets/Discover-BCmVIXus.js ADDED
@@ -0,0 +1 @@
 
 
1
+ import{t as e}from"./award-CKCOPIfL.js";import{c as t,d as n,f as r,t as i,u as a,v as o}from"./MainLayout-COiTxmNr.js";import{t as s}from"./book-DwmTvSdZ.js";import{n as c}from"./square-DZKJ0QGR.js";import{t as l}from"./BookCard-BCMDdlyN.js";import{C as u,D as d,F as f,M as p,S as m,_ as h,b as g,j as _,k as ee,n as v,o as te,t as ne,v as re,w as y}from"./index-yRoRoI6u.js";import{t as ie}from"./GoogleAd-CD0eT9Wp.js";var ae=g(`funnel`,[[`path`,{d:`M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z`,key:`sc7q7i`}]]),oe=g(`shield-alert`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]]),se=g(`shuffle`,[[`path`,{d:`m18 14 4 4-4 4`,key:`10pe0f`}],[`path`,{d:`m18 2 4 4-4 4`,key:`pucp1d`}],[`path`,{d:`M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22`,key:`1ailkh`}],[`path`,{d:`M2 6h1.972a4 4 0 0 1 3.6 2.2`,key:`km57vx`}],[`path`,{d:`M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45`,key:`os18l9`}]]),b=f(_(),1),x=p();function ce({isOpen:e,onClose:r,onSelectBook:i}){let[c,l]=(0,b.useState)(``),[u,d]=(0,b.useState)(!1),[f,p]=(0,b.useState)(``),[m,h]=(0,b.useState)([]),[g,_]=(0,b.useState)(!1),ee=[`Main thông minh, IQ vô cực, đấu trí nghẹt thở`,`Tiên hiệp hài hước, cười bể bụng, main lầy lội`,`Sát phạt quyết đoán, độc hành, không thánh mẫu`,`Mạt thế xây dựng thế lực, không hậu cung`],v=e=>{l(e)};return e?(0,x.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md`,children:(0,x.jsxs)(`div`,{className:`relative w-full max-w-2xl bg-gradient-to-b from-[#131327] to-[#0d0d1a] border border-[#2d2d6b]/80 rounded-3xl p-6 shadow-2xl overflow-hidden max-h-[85vh] flex flex-col`,children:[(0,x.jsx)(`div`,{className:`absolute -top-20 -left-20 w-40 h-40 bg-brand-500/10 rounded-full blur-3xl pointer-events-none`}),(0,x.jsx)(`div`,{className:`absolute -bottom-20 -right-20 w-40 h-40 bg-amber-500/10 rounded-full blur-3xl pointer-events-none`}),(0,x.jsxs)(`div`,{className:`flex justify-between items-center border-b border-white/5 pb-4 mb-4`,children:[(0,x.jsxs)(`div`,{className:`flex items-center gap-2.5`,children:[(0,x.jsx)(`div`,{className:`p-2 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 text-[#0b0b14] font-bold`,children:(0,x.jsx)(t,{className:`w-5 h-5 fill-current`})}),(0,x.jsxs)(`div`,{children:[(0,x.jsx)(`h3`,{className:`text-lg font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-amber-200`,children:`TÌM KIẾM NÂNG CẤP AI`}),(0,x.jsx)(`p`,{className:`text-slate-400 text-xs mt-0.5`,children:`Tìm và gợi ý truyện thông minh theo cốt truyện, cảm xúc, tính cách nhân vật`})]})]}),(0,x.jsx)(`button`,{onClick:r,className:`p-1.5 hover:bg-white/5 rounded-lg text-slate-400 hover:text-white transition-colors`,children:(0,x.jsx)(te,{className:`w-6 h-6`})})]}),(0,x.jsxs)(`div`,{className:`flex-1 overflow-y-auto space-y-4 pr-1`,children:[(0,x.jsxs)(`div`,{className:`space-y-2`,children:[(0,x.jsx)(`span`,{className:`text-slate-500 text-xs font-bold uppercase tracking-wider block`,children:`Gợi ý câu hỏi AI:`}),(0,x.jsx)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-2`,children:ee.map((e,t)=>(0,x.jsxs)(`button`,{onClick:()=>v(e),className:`text-left text-xs bg-[#1a1a35]/60 hover:bg-[#1a1a35] border border-[#2d2d6b]/30 hover:border-brand-500/50 rounded-xl p-3 text-slate-300 hover:text-white transition-all`,children:[`💡 "`,e,`"`]},t))})]}),(0,x.jsxs)(`form`,{onSubmit:async e=>{if(e.preventDefault(),c.trim()){d(!0),p(``),h([]),_(!1);try{p((await y.post(`/api/ai/chat`,{prompt:`Bạn là trợ lý AI tìm kiếm truyện. Tìm các tác phẩm phù hợp với yêu cầu sau: "${c}". Hãy trả về lời khuyên ngắn gọn và một vài tên truyện gợi ý.`,model:`gemini-1.5-flash`})).data.text);let e=c.replace(/(main|truyện|tìm|có|yếu tố|không)/gi,``).trim(),t=await y.get(`/api/books`,{params:{q:e.split(` `)[0],per_page:3}});t.data&&t.data.books&&h(t.data.books.map(e=>({...e,matchScore:Math.floor(Math.random()*15)+85})))}catch(e){console.log(`AI Proxy Error or Non-VIP:`,e),_(!0),p(`[AI Standard Mode] Để sử dụng đầy đủ mô hình trí tuệ nhân tạo nâng cao, bạn cần nâng cấp VIP. Tuy nhiên, hệ thống AI Cơ Bản đã tìm thấy các bộ truyện phù hợp nhất dựa trên từ khóa phân tích:`);let t=c.replace(/(main|truyện|tìm|có|yếu tố|không|sát phạt|độc hành|vô cực|thế lực|nâng cấp|quyết đoán)/gi,``).trim().split(/[\s,]+/)[0]||`truyện`;try{let e=await y.get(`/api/books`,{params:{q:t,per_page:3}});e.data&&e.data.books&&e.data.books.length>0?h(e.data.books.map(e=>({...e,matchScore:Math.floor(Math.random()*10)+90}))):h(((await y.get(`/api/books`,{params:{per_page:3}})).data.books||[]).map(e=>({...e,matchScore:82})))}catch(e){console.error(e)}}finally{d(!1)}}},className:`flex gap-2`,children:[(0,x.jsx)(`input`,{type:`text`,placeholder:`Ví dụ: Tìm truyện linh dị kinh dị đô thị có nhân vật chính lạnh lùng...`,value:c,onChange:e=>l(e.target.value),className:`flex-1 px-4 py-3.5 bg-[#0b0b14] border border-[#2d2d6b]/50 rounded-2xl text-white outline-none focus:border-amber-400 transition-colors text-sm`}),(0,x.jsx)(`button`,{type:`submit`,disabled:u||!c.trim(),className:`px-5 bg-gradient-to-r from-amber-500 to-amber-600 hover:brightness-105 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-1 text-sm`,children:u?(0,x.jsx)(n,{className:`w-4 h-4 animate-spin`}):(0,x.jsxs)(x.Fragment,{children:[(0,x.jsx)(a,{className:`w-4 h-4`}),` Gửi`]})})]}),u&&(0,x.jsxs)(`div`,{className:`py-8 text-center text-slate-500 space-y-3`,children:[(0,x.jsx)(n,{className:`w-8 h-8 animate-spin mx-auto text-amber-500`}),(0,x.jsx)(`p`,{className:`text-xs`,children:`AI đang phân tích cốt truyện và lọc đầu truyện...`})]}),!u&&(f||m.length>0)&&(0,x.jsxs)(`div`,{className:`space-y-4 border-t border-white/5 pt-4`,children:[f&&(0,x.jsxs)(`div`,{className:`bg-[#121225]/40 border border-[#2d2d6b]/30 rounded-2xl p-4 text-xs text-slate-300 leading-relaxed`,children:[g&&(0,x.jsxs)(`div`,{className:`flex items-center gap-1.5 text-amber-400 font-bold mb-2`,children:[(0,x.jsx)(o,{className:`w-4 h-4`}),` AI Standard Mode (Giới hạn VIP)`]}),(0,x.jsx)(`p`,{className:`whitespace-pre-line`,children:f})]}),m.length>0&&(0,x.jsxs)(`div`,{className:`space-y-2`,children:[(0,x.jsx)(`span`,{className:`text-slate-500 text-xs font-bold uppercase tracking-wider block`,children:`Truyện Khớp Nhiều Nhất:`}),(0,x.jsx)(`div`,{className:`space-y-3`,children:m.map(e=>(0,x.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#2d2d6b]/30 rounded-2xl p-4 flex gap-4 items-center justify-between hover:border-amber-400 transition-all cursor-pointer`,onClick:()=>{i&&i(e),r()},children:[(0,x.jsxs)(`div`,{className:`flex gap-3 items-center min-w-0`,children:[e.cover?(0,x.jsx)(`img`,{src:e.cover,alt:`cover`,className:`w-[45px] h-[60px] object-cover rounded-lg border border-[#2d2d55] shadow-md bg-[#0f0f1a]`,onError:e=>{e.target.remove()}}):(0,x.jsx)(`div`,{className:`w-[45px] h-[60px] rounded-lg border border-[#2d2d55] bg-[#0f0f1a] flex items-center justify-center text-slate-500`,children:(0,x.jsx)(s,{className:`w-5 h-5`})}),(0,x.jsxs)(`div`,{className:`min-w-0`,children:[(0,x.jsx)(`h4`,{className:`text-slate-100 font-bold text-sm truncate`,children:e.title_vietphrase||e.title}),(0,x.jsxs)(`p`,{className:`text-slate-500 text-xs mt-0.5 truncate`,children:[`Tác giả: `,e.author_hanviet||e.author]}),(0,x.jsx)(`div`,{className:`flex gap-1.5 mt-1.5`,children:e.categories&&e.categories.split(`, `).slice(0,2).map((e,t)=>(0,x.jsx)(`span`,{className:`px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[9px]`,children:e},t))})]})]}),(0,x.jsxs)(`div`,{className:`text-right shrink-0`,children:[(0,x.jsxs)(`span`,{className:`text-amber-400 text-xs font-extrabold block`,children:[e.matchScore,`%`]}),(0,x.jsx)(`span`,{className:`text-slate-500 text-[9px]`,children:`AI Match`})]})]},e.id))})]})]})]}),(0,x.jsxs)(`div`,{className:`border-t border-white/5 pt-3 mt-3 flex justify-between items-center text-[10px] text-slate-500`,children:[(0,x.jsx)(`span`,{children:`Hệ thống phân tích từ kho truyện 930k+ đầu mục`}),(0,x.jsx)(`span`,{children:`Nâng cấp VIP để mở khóa GPT/Gemini AI đầy đủ`})]})]})}):null}function S(){let{t:a,lang:o}=m(),{user:s}=u(),[f,p]=ee(),g=d(),_=e=>e?{玄幻:o===`vi`?`Huyền Huyễn`:o===`en`?`Fantasy`:`玄幻`,都市:o===`vi`?`Đô Thị`:o===`en`?`Urban`:`都市`,言情:o===`vi`?`Ngôn Tình`:o===`en`?`Romance`:`言情`,女生:o===`vi`?`Nữ Sinh`:o===`en`?`Female Lead`:`女生`,科幻:o===`vi`?`Khoa Huyễn`:o===`en`?`Sci-Fi`:`科幻`,修真:o===`vi`?`Tu Chân`:o===`en`?`Cultivation`:`修真`,仙侠:o===`vi`?`Tiên Hiệp`:o===`en`?`Xianxia`:`仙侠`,武侠:o===`vi`?`Võ Hiệp`:o===`en`?`Wuxia`:`武侠`,历史:o===`vi`?`Lịch Sử`:o===`en`?`History`:`历史`,网游:o===`vi`?`Võng Du`:o===`en`?`Gaming`:`网游`,同人:o===`vi`?`Đồng Nhân`:o===`en`?`Fanfiction`:`同人`,其他:o===`vi`?`Thể loại khác`:o===`en`?`Others`:`其他`}[e]||e:a.allCategories,[S,C]=(0,b.useState)([]),[le,w]=(0,b.useState)(!0),[T,E]=(0,b.useState)(``),[D,O]=(0,b.useState)(``),[ue,k]=(0,b.useState)(`all`),[A,j]=(0,b.useState)(``),[M,de]=(0,b.useState)(``),[N,fe]=(0,b.useState)(``),[P,F]=(0,b.useState)(``),[I,L]=(0,b.useState)(`site_count DESC`),[pe,me]=(0,b.useState)(!1),[R,z]=(0,b.useState)(1),[B,V]=(0,b.useState)(1),[H,U]=(0,b.useState)(0),[he,ge]=(0,b.useState)({total:931427,duplicates:0}),[_e,ve]=(0,b.useState)(null),[ye,be]=(0,b.useState)(null),[xe,Se]=(0,b.useState)(!1),[Ce,W]=(0,b.useState)(new Set),[we,Te]=(0,b.useState)([]),[Ee,De]=(0,b.useState)(!1),{activeAudioObj:Oe,setActiveAudioObj:G}=ne(),[K,q]=(0,b.useState)(0),[ke,J]=(0,b.useState)(`original`),[Ae,Y]=(0,b.useState)(``),[je,Me]=(0,b.useState)(!1),[X,Ne]=(0,b.useState)([{id:1,title:`Hắc Ám Văn Minh`,title_vietphrase:`Hắc Ám Văn Minh`,author:`Cổ Hi`,author_hanviet:`Cổ Hi`,categories:`Huyền Huyễn, Mạt Thế, Khoa Huyễn`,cover:`https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=200&auto=format&fit=crop&q=60`,description:`Khi bóng tối bao phủ địa cầu, nhân loại đối mặt với kỷ nguyên hắc ám tột cùng. Các nguồn văn minh bị phá hủy hoàn toàn, những loài thú biến dị trỗi dậy, kẻ mạnh mới có quyền sinh tồn...`},{id:2,title:`Đấu Phá Thương Khung`,title_vietphrase:`Đấu Phá Thương Khung`,author:`Thiên Tàm Thổ Đậu`,author_hanviet:`Thiên Tàm Thổ Đậu`,categories:`Tiên Hiệp, Huyền Huyễn`,cover:`https://images.unsplash.com/photo-1543002588-bfa74002ed7e?w=200&auto=format&fit=crop&q=60`,description:`Nơi đây là thế giới của Đấu Khí. Không có ma pháp hoa lệ, chỉ có đấu khí sinh sôi phát triển đến đỉnh phong! Tiêu Viêm - một thiên tài bỗng chốc sa sút, bắt đầu cuộc hành trình nghịch thiên cải mệnh...`},{id:3,title:`Hộc Châu Phu Nhân`,title_vietphrase:`Hộc Châu Phu Nhân`,author:`Tiêu Như Sắt`,author_hanviet:`Tiêu Như Sắt`,categories:`Ngôn Tình, Cổ Đại, Nữ Sinh`,cover:`https://images.unsplash.com/photo-1512820790803-83ca734da794?w=200&auto=format&fit=crop&q=60`,description:`Nơi Giao Châu xa xôi có bộ tộc mò ngọc trai quý hiếm. Cuộc đời nàng Diệp Hải Thị xoay vần giữa tranh đoạt quyền lực nơi cung đình triều đình đại chiến và mối tình đầy ngang trái...`}]),Pe=[{id:1,user:`Minh Quân`,avatar:`https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=80&auto=format&fit=crop&q=60`,source:`Metruyenchu`,comment:`bên nguồn chữ một cho màn Trong 😂`,time:`2 phút trước`},{id:2,user:`Thanh Trúc`,avatar:`https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&auto=format&fit=crop&q=60`,source:`Truyenchu`,comment:`bên thảo của màn Trong 😆`,time:`1 phút trước`},{id:3,user:`Quốc Bảo`,avatar:`https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&auto=format&fit=crop&q=60`,source:`Nady knise`,comment:`trên đàn nên to...`,time:`1 phút trước`},{id:4,user:`Lan Hương`,avatar:`https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=80&auto=format&fit=crop&q=60`,source:`Metruyenchu`,comment:`(Tốt) chặn khỉn hồi Uanb u oại thi mượt 😮`,time:`1 phút trước`}];(0,b.useEffect)(()=>{Fe(),Ie()},[s]),(0,b.useEffect)(()=>{let e=f.get(`q`),t=f.get(`search_field`),n=f.get(`category`),r=!1,i={};e&&(O(e),i.q=e,r=!0),t&&(k(t),i.search_field=t,r=!0),n&&(j(n),i.category=n,r=!0),r?(Z(i),p({},{replace:!0})):Z()},[f]),(0,b.useEffect)(()=>{f.get(`q`)||f.get(`search_field`)||f.get(`category`)||Z()},[R,A,M,N,P,I]),(0,b.useEffect)(()=>{(async()=>{try{let e=[...X];for(let t=0;t<e.length;t++){let n=await y.get(`/api/books`,{params:{q:e[t].title,per_page:1}});if(n.data&&n.data.books&&n.data.books.length>0){let r=n.data.books[0];e[t]={...e[t],id:r.id,cover:r.cover||e[t].cover,title_vietphrase:r.title_vietphrase||r.title,author_hanviet:r.author_hanviet||r.author,urls:r.urls,categories:r.categories||e[t].categories}}}Ne(e)}catch(e){console.error(`Failed to sync hero books with database:`,e)}})()},[]),(0,b.useEffect)(()=>{J(`original`),Y(``)},[K]);let Fe=async()=>{try{let e=await y.get(`/api/stats`);ge({total:e.data.total_books||931427,duplicates:e.data.duplicates||0})}catch(e){console.error(e)}},Ie=async()=>{if(!s){W(new Set);return}try{let e=await y.get(`/api/bookshelf`);W(new Set(e.data.map(e=>e.book_id)))}catch(e){console.error(e)}},Z=async(e={})=>{w(!0),E(``);try{let t={q:e.q===void 0?D:e.q,category:e.category===void 0?A:e.category,source:e.source===void 0?M:e.source,dup:e.dup===void 0?N:e.dup,sort:I,search_field:e.search_field===void 0?ue:e.search_field,min_chapters:P,page:R,per_page:30},n=await y.get(`/api/books`,{params:t});C(n.data.books||[]),V(n.data.pages||1),U(n.data.total||0),n.data.books&&R===1&&!A&&!M&&!D&&Te([{id:1,title:`Hắc Ám Văn Minh`,author:`Cổ Hi`,trend:`up`,diff:1},{id:2,title:`Hộc ẩm chúa tể`,author:`Linh Hạ Cửu Thập Độ`,trend:`up`,diff:2},{id:3,title:`10 Lần Thôn Tương`,author:`Luân Hồi Thiên Trọng`,trend:`down`,diff:1},{id:4,title:`Mộ Ngôn`,author:`Nguôn`,trend:`none`,diff:0}])}catch(e){E(e.response?.data?.error||a.connError)}finally{w(!1)}},Le=e=>{k(`author`),O(e),z(1),Z({search_field:`author`,q:e})},Re=e=>{j(e),z(1),Z({category:e})},ze=e=>{e.preventDefault(),z(1),Z()},Be=e=>{e.id&&g(`/book/${e.id}`)},Ve=async e=>{if(!s){alert(`Vui lòng đăng nhập để lưu sách.`);return}let t=Ce.has(e),n=t?`/api/bookshelf/remove`:`/api/bookshelf/add`;try{await y.post(n,{book_id:e}),W(n=>{let r=new Set(n);return t?r.delete(e):r.add(e),r})}catch(e){alert(e.response?.data?.error||`Lỗi xử lý tủ sách.`)}},He=async e=>{if(_e===e){ve(null),be(null);return}ve(e),Se(!0);try{be((await y.get(`/api/book/${e}/translations`)).data),await y.post(`/api/history/add`,{book_id:e,last_chapter:`Đang xem so sánh`})}catch(e){console.error(e)}finally{Se(!1)}},Ue=()=>{O(``),k(`all`),j(``),de(``),fe(``),F(``),L(`site_count DESC`),z(1)},We=async()=>{w(!0);try{let e=Math.floor(Math.random()*20)+1,t=await y.get(`/api/books`,{params:{page:e,per_page:30}});if(t.data&&t.data.books&&t.data.books.length>0){let e=Math.floor(Math.random()*t.data.books.length),n=t.data.books[e];C([n]),V(1),U(1),G(n)}}catch(e){console.error(`Gacha failed:`,e)}finally{w(!1)}},Q=async e=>{let t=X[K];if(e===`original`){J(`original`);return}Me(!0);try{try{let n=await y.post(`/api/translate`,{texts:[t.description],mode:e===`en`?`en`:`vietphrase`});n.data&&n.data.translations&&n.data.translations[0]&&(Y(n.data.translations[0]),J(e))}catch(n){console.warn(`[Discover] Cloud translation failed, trying offline localTranslator:`,n),await v.loadDictionaries(),Y(v.translateSentence(t.description,`advanced`)),J(e)}}catch{alert(`Hạn mức dịch máy chủ đã hết và bộ dịch offline gặp lỗi.`)}finally{Me(!1)}},Ge=()=>{let e=X[K];G(e)},Ke=e=>{C([e]),V(1),U(1)},$=X[K],qe=ke===`original`?$.description:Ae||$.description;return(0,x.jsxs)(i,{stats:he,children:[(0,x.jsxs)(`div`,{className:`relative w-full rounded-2xl sm:rounded-3xl overflow-hidden mb-6 min-h-[220px] sm:min-h-[360px] bg-[#0b0b14] border border-[#1f1f3a]/80 shadow-2xl flex flex-col justify-end`,children:[(0,x.jsx)(`div`,{className:`absolute inset-0 bg-cover bg-center transition-all duration-700`,style:{backgroundImage:`url('hero_banner.png')`}}),(0,x.jsx)(`div`,{className:`absolute inset-0 bg-gradient-to-r from-[#0b0b14] via-[#0b0b14]/75 to-transparent`}),(0,x.jsxs)(`div`,{className:`relative z-10 p-4 sm:p-8 md:p-12 max-w-2xl text-left space-y-2 sm:space-y-4 self-start`,children:[(0,x.jsx)(`h2`,{className:`text-lg sm:text-3xl md:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-yellow-300 via-amber-400 to-amber-200 tracking-wide uppercase`,children:o===`vi`?`TRUYỆN ĐỀ CỬ XUẤT SẮC TUẦN NÀY`:o===`en`?`WEEKLY FEATURED NOVEL`:`本周精选推荐`}),(0,x.jsx)(`h3`,{className:`text-base sm:text-xl md:text-2xl font-bold text-white tracking-wide`,children:$.title_vietphrase}),(0,x.jsxs)(`p`,{className:`text-slate-400 text-xs mt-1 hidden sm:block`,children:[`✍ `,o===`vi`?`Tác giả:`:o===`en`?`Author:`:`作者:`,` `,(0,x.jsx)(`strong`,{className:`text-brand-300 font-semibold`,children:$.author_hanviet}),` · `,o===`vi`?`Thể loại:`:o===`en`?`Categories:`:`题材:`,` `,(0,x.jsx)(`span`,{className:`text-slate-300 font-medium`,children:$.categories})]}),(0,x.jsx)(`p`,{className:`text-slate-300 text-xs leading-relaxed line-clamp-2 sm:line-clamp-3 bg-[#0b0b14]/60 backdrop-blur-sm p-3 sm:p-4 rounded-xl border border-white/5`,children:je?o===`vi`?`Đang dịch nội dung...`:o===`en`?`Translating content...`:`正在翻译内容...`:qe}),(0,x.jsxs)(`div`,{className:`flex flex-wrap gap-2 sm:gap-4 pt-1 sm:pt-2`,children:[(0,x.jsxs)(`button`,{onClick:Ge,className:`inline-flex items-center gap-2 bg-purple-600 hover:bg-purple-500 active:scale-95 text-white font-extrabold px-4 sm:px-6 py-2.5 sm:py-3 rounded-full shadow-lg transition-all text-xs`,children:[o===`vi`?`Nghe Tóm Tắt (TTS)`:o===`en`?`Listen Summary (TTS)`:`听取大纲 (TTS)`,` `,(0,x.jsx)(`span`,{className:`text-purple-300`,children:`| 🔊`})]}),(0,x.jsxs)(`div`,{className:`inline-flex items-center gap-1.5 bg-[#121225]/80 border border-white/10 px-3 sm:px-4 py-2 sm:py-2.5 rounded-full text-xs font-bold`,children:[(0,x.jsx)(`span`,{className:`text-slate-400 text-[10px] hidden sm:inline`,children:o===`vi`?`Dịch Nhanh`:o===`en`?`Quick Translate`:`快速翻译`}),(0,x.jsx)(`button`,{onClick:()=>Q(`vi`),className:`hover:scale-115 transition-transform`,title:`Thuần Việt`,children:`🇻🇳`}),(0,x.jsx)(`button`,{onClick:()=>Q(`en`),className:`hover:scale-115 transition-transform`,title:`English`,children:`🇺🇸`}),(0,x.jsx)(`button`,{onClick:()=>Q(`original`),className:`hover:scale-115 transition-transform`,title:`Original Chinese`,children:`🇨🇳`})]})]})]}),(0,x.jsxs)(`div`,{className:`absolute right-8 bottom-8 z-10 flex items-center gap-3`,children:[(0,x.jsx)(`button`,{onClick:()=>q(e=>(e-1+X.length)%X.length),className:`p-2 bg-black/50 hover:bg-black/75 rounded-full border border-white/10 text-white transition-all`,children:(0,x.jsx)(re,{className:`w-4 h-4`})}),(0,x.jsx)(`div`,{className:`flex gap-1.5`,children:X.map((e,t)=>(0,x.jsx)(`button`,{onClick:()=>q(t),className:`w-2.5 h-2.5 rounded-full transition-all ${K===t?`bg-purple-500 w-6`:`bg-white/20`}`},t))}),(0,x.jsx)(`button`,{onClick:()=>q(e=>(e+1)%X.length),className:`p-2 bg-black/50 hover:bg-black/75 rounded-full border border-white/10 text-white transition-all`,children:(0,x.jsx)(h,{className:`w-4 h-4`})})]})]}),(0,x.jsxs)(`div`,{className:`grid grid-cols-1 lg:grid-cols-4 gap-6`,children:[(0,x.jsxs)(`div`,{className:`lg:col-span-3 space-y-6`,children:[(0,x.jsxs)(`form`,{onSubmit:ze,className:`bg-[#121225]/80 border border-[#1f1f3a]/80 rounded-2xl p-5 space-y-4 shadow-xl`,children:[(0,x.jsxs)(`div`,{className:`flex flex-col sm:flex-row gap-2`,children:[(0,x.jsxs)(`div`,{className:`relative flex-1`,children:[(0,x.jsx)(c,{className:`absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-purple-400`}),(0,x.jsx)(`input`,{type:`text`,placeholder:a.searchPlaceholder,value:D,onChange:e=>O(e.target.value),className:`w-full pl-10 pr-4 py-3 bg-[#0b0b14] border border-[#1f1f3a] rounded-xl text-white outline-none focus:border-purple-500 transition-colors text-xs`})]}),(0,x.jsxs)(`div`,{className:`flex gap-2 w-full sm:w-auto shrink-0`,children:[(0,x.jsx)(`button`,{type:`submit`,className:`flex-1 sm:flex-initial bg-purple-600 hover:bg-purple-500 text-white font-bold px-5 py-3 rounded-xl shadow-md transition-all text-xs text-center`,children:o===`vi`?`Tìm`:o===`en`?`Search`:`搜索`}),(0,x.jsxs)(`button`,{type:`button`,onClick:()=>me(e=>!e),className:`px-3 py-3 rounded-xl border text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${pe?`bg-purple-600/20 border-purple-500/40 text-purple-300`:`bg-[#0b0b14] border-[#1f1f3a] text-slate-400 hover:text-white`}`,title:o===`vi`?`Bộ lọc nâng cao`:o===`en`?`Advanced filters`:`高级筛选`,children:[(0,x.jsx)(ae,{className:`w-4 h-4`}),(0,x.jsx)(`span`,{className:`hidden sm:inline`,children:o===`vi`?`Bộ lọc`:o===`en`?`Filters`:`筛选`})]}),(0,x.jsxs)(`button`,{type:`button`,onClick:()=>De(!0),className:`flex-1 sm:flex-initial bg-gradient-to-r from-amber-500 to-amber-600 hover:brightness-105 text-[#0b0b14] font-extrabold px-4 py-3 rounded-xl shadow-lg transition-all text-xs flex items-center justify-center gap-1.5`,children:[(0,x.jsx)(t,{className:`w-4 h-4 fill-current animate-pulse`}),(0,x.jsx)(`span`,{children:o===`vi`?`AI`:o===`en`?`AI Search`:`AI`})]})]})]}),(0,x.jsxs)(`div`,{className:`${pe?`grid`:`hidden lg:grid`} grid-cols-2 md:grid-cols-3 gap-3 pt-2`,children:[(0,x.jsxs)(`select`,{value:ue,onChange:e=>k(e.target.value),className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:`all`,children:a.searchFieldAll}),(0,x.jsx)(`option`,{value:`title`,children:a.searchFieldTitle}),(0,x.jsx)(`option`,{value:`author`,children:a.searchFieldAuthor}),(0,x.jsx)(`option`,{value:`hanviet`,children:a.searchFieldHanviet}),(0,x.jsx)(`option`,{value:`vietphrase`,children:a.searchFieldVietphrase}),(0,x.jsx)(`option`,{value:`chinese`,children:a.searchFieldChinese}),(0,x.jsx)(`option`,{value:`description`,children:a.searchFieldDesc})]}),(0,x.jsxs)(`select`,{value:A,onChange:e=>{j(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:``,children:a.allCategories}),[`玄幻`,`都市`,`言情`,`女生`,`科幻`,`修真`,`仙侠`,`武侠`,`历史`,`网游`,`同人`,`其他`].map(e=>(0,x.jsx)(`option`,{value:e,children:_(e)},e))]}),(0,x.jsxs)(`select`,{value:M,onChange:e=>{de(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:``,children:a.allSources}),[`Ixdzs`,`Biquge`,`41nr`,`Quanben`,`Faloo`,`Fanqie`,`Hjwzw`].map(e=>(0,x.jsx)(`option`,{value:e,children:e},e))]}),(0,x.jsxs)(`select`,{value:N,onChange:e=>{fe(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:``,children:a.allDup}),(0,x.jsx)(`option`,{value:`multi`,children:a.dupMulti}),(0,x.jsx)(`option`,{value:`single`,children:a.dupSingle})]}),(0,x.jsxs)(`select`,{value:P,onChange:e=>{F(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:``,children:a.allChapters}),(0,x.jsx)(`option`,{value:`100`,children:a.ch100}),(0,x.jsx)(`option`,{value:`500`,children:a.ch500}),(0,x.jsx)(`option`,{value:`1000`,children:a.ch1000}),(0,x.jsx)(`option`,{value:`2000`,children:a.ch2000})]}),(0,x.jsxs)(`select`,{value:I,onChange:e=>{L(e.target.value),z(1)},className:`bg-[#0b0b14] border border-[#1f1f3a] text-xs font-semibold rounded-xl p-3 text-slate-300 outline-none cursor-pointer focus:border-purple-500`,children:[(0,x.jsx)(`option`,{value:`site_count DESC`,children:a.sortBy.site_count}),(0,x.jsx)(`option`,{value:`chapters_max DESC`,children:a.sortBy.chapters_max}),(0,x.jsx)(`option`,{value:`word_count_max DESC`,children:a.sortBy.word_count_max}),(0,x.jsx)(`option`,{value:`title ASC`,children:a.sortBy.title_asc}),(0,x.jsx)(`option`,{value:`title DESC`,children:a.sortBy.title_desc}),(0,x.jsx)(`option`,{value:`id ASC`,children:a.sortBy.default})]})]}),(0,x.jsxs)(`div`,{className:`flex justify-between items-center border-t border-[#1f1f3a]/30 pt-3`,children:[(0,x.jsx)(`span`,{className:`text-slate-500 text-xs`,children:o===`vi`?(0,x.jsxs)(x.Fragment,{children:[`Tìm thấy `,(0,x.jsx)(`strong`,{children:H.toLocaleString()}),` truyện`]}):o===`en`?(0,x.jsxs)(x.Fragment,{children:[`Found `,(0,x.jsx)(`strong`,{children:H.toLocaleString()}),` novels`]}):(0,x.jsxs)(x.Fragment,{children:[`找到 `,(0,x.jsx)(`strong`,{children:H.toLocaleString()}),` 部小说`]})}),(0,x.jsxs)(`div`,{className:`flex items-center gap-4`,children:[(0,x.jsxs)(`button`,{type:`button`,onClick:We,className:`inline-flex items-center gap-1.5 text-amber-400 hover:text-amber-300 transition-colors text-xs font-extrabold`,children:[(0,x.jsx)(se,{className:`w-3.5 h-3.5`}),` `,o===`vi`?`Random Truyện (Gacha)`:o===`en`?`Random Novel (Gacha)`:`随机小说 (抽卡)`]}),(0,x.jsxs)(`button`,{type:`button`,onClick:Ue,className:`inline-flex items-center gap-1 text-slate-400 hover:text-white transition-colors text-xs font-bold`,children:[(0,x.jsx)(te,{className:`w-3.5 h-3.5`}),` `,a.clearFilter]})]})]})]}),le?(0,x.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,x.jsx)(n,{className:`w-8 h-8 animate-spin mx-auto mb-3 text-brand-500`}),(0,x.jsx)(`span`,{children:a.loading})]}):T?(0,x.jsxs)(`div`,{className:`py-20 text-center text-red-500`,children:[(0,x.jsx)(oe,{className:`w-10 h-10 mx-auto mb-3 text-red-400`}),(0,x.jsx)(`span`,{children:T})]}):S.length===0?(0,x.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,x.jsx)(c,{className:`w-10 h-10 mx-auto mb-3`}),(0,x.jsx)(`span`,{children:a.empty})]}):(0,x.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4`,children:S.map(e=>(0,x.jsxs)(`div`,{className:`flex flex-col`,children:[(0,x.jsx)(l,{book:e,isFav:Ce.has(e.id),onToggleFav:Ve,onCompare:He,onPlayTrailer:G,onSearchAuthor:Le,onSearchCategory:Re,onRead:Be}),_e===e.id&&(0,x.jsx)(`div`,{className:`bg-[#0f101f] border border-[#1f1f3a] rounded-b-2xl p-4 text-xs mt-[-10px] space-y-4 shadow-inner`,children:xe?(0,x.jsx)(`div`,{className:`text-center text-slate-500 py-3`,children:a.comparingText}):(0,x.jsx)(`div`,{className:`overflow-x-auto`,children:(0,x.jsxs)(`table`,{className:`w-full text-left text-[11px] text-slate-300`,children:[(0,x.jsx)(`thead`,{children:(0,x.jsxs)(`tr`,{className:`border-b border-[#2d2d55] text-slate-400 font-bold`,children:[(0,x.jsx)(`th`,{className:`pb-2`,children:o===`vi`?`Chỉ số`:o===`en`?`Metric`:`指标`}),(0,x.jsx)(`th`,{className:`pb-2`,children:o===`vi`?`Điểm số`:o===`en`?`Score`:`评分`}),(0,x.jsx)(`th`,{className:`pb-2`,children:o===`vi`?`Nguồn tiêu biểu`:o===`en`?`Best Source`:`推荐站`})]})}),(0,x.jsxs)(`tbody`,{className:`divide-y divide-[#1f1f3a]`,children:[(0,x.jsxs)(`tr`,{children:[(0,x.jsx)(`td`,{className:`py-2 font-semibold text-slate-400`,children:o===`vi`?`Tốc độ cập nhật`:o===`en`?`Update Speed`:`更新速度`}),(0,x.jsx)(`td`,{className:`py-2 text-emerald-400 font-bold`,children:`4.8`}),(0,x.jsxs)(`td`,{className:`py-2 text-slate-300`,children:[`Metruyenchu `,(0,x.jsx)(`span`,{className:`text-slate-500 text-[10px]`,children:`31 votes`})]})]}),(0,x.jsxs)(`tr`,{children:[(0,x.jsx)(`td`,{className:`py-2 font-semibold text-slate-400`,children:o===`vi`?`Quảng cáo & Sạch`:o===`en`?`Ads & Cleanliness`:`广告与排版`}),(0,x.jsx)(`td`,{className:`py-2 text-emerald-400 font-bold`,children:`4.9`}),(0,x.jsxs)(`td`,{className:`py-2 text-slate-300`,children:[`TruyenFull `,(0,x.jsx)(`span`,{className:`text-slate-500 text-[10px]`,children:`81 votes`})]})]}),(0,x.jsxs)(`tr`,{children:[(0,x.jsx)(`td`,{className:`py-2 font-semibold text-slate-400`,children:o===`vi`?`Độ chuẩn bản dịch`:o===`en`?`Translation Standard`:`翻译准确度`}),(0,x.jsx)(`td`,{className:`py-2 text-emerald-400 font-bold`,children:`4.7`}),(0,x.jsxs)(`td`,{className:`py-2 text-slate-300`,children:[`MeDoc `,(0,x.jsx)(`span`,{className:`text-slate-500 text-[10px]`,children:`63 votes`})]})]})]})]})})})]},e.id))}),B>1&&(0,x.jsxs)(`div`,{className:`flex flex-wrap justify-center gap-2 pt-6`,children:[(0,x.jsxs)(`button`,{onClick:()=>z(e=>Math.max(1,e-1)),disabled:R===1,className:`px-4 py-2 bg-[#121225] border border-[#1f1f3a] rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-brand-500 hover:text-white transition-all text-xs font-semibold`,children:[`« `,o===`vi`?`Trước`:o===`en`?`Prev`:`上一页`]}),Array.from({length:Math.min(5,B)},(e,t)=>{let n=R;return n=R<=3?t+1:R>=B-2?B-4+t:R-2+t,n<1||n>B?null:(0,x.jsx)(`button`,{onClick:()=>z(n),className:`w-9 h-9 rounded-lg border text-xs font-semibold transition-all ${R===n?`bg-purple-600 border-purple-600 text-white shadow-md`:`bg-[#121225] border-[#1f1f3a] hover:bg-white/5 text-slate-400`}`,children:n},n)}),(0,x.jsxs)(`button`,{onClick:()=>z(e=>Math.min(B,e+1)),disabled:R===B,className:`px-4 py-2 bg-[#121225] border border-[#1f1f3a] rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-brand-500 hover:text-white transition-all text-xs font-semibold`,children:[o===`vi`?`Sau`:o===`en`?`Next`:`下一页`,` »`]})]})]}),(0,x.jsxs)(`div`,{className:`space-y-6`,children:[(0,x.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a] rounded-2xl p-5 shadow-xl`,children:[(0,x.jsxs)(`h3`,{className:`text-sm font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-amber-200 flex items-center gap-2 mb-4`,children:[(0,x.jsx)(e,{className:`w-5 h-5 text-amber-400`}),` `,o===`vi`?`BẢNG XẾP HẠNG HOT`:o===`en`?`HOT RANKINGS`:`热门排行`]}),we.length>0?(0,x.jsx)(`div`,{className:`space-y-4`,children:we.map((e,t)=>(0,x.jsxs)(`div`,{className:`flex items-center justify-between border-b border-white/5 pb-3 last:border-0 last:pb-0`,children:[(0,x.jsxs)(`div`,{className:`flex items-center gap-3 min-w-0`,children:[(0,x.jsx)(`span`,{className:`w-6 h-6 rounded-full flex items-center justify-center text-xs font-extrabold shrink-0 shadow-md ${t===0?`bg-gradient-to-br from-yellow-300 to-amber-500 text-white`:t===1?`bg-gradient-to-br from-slate-200 to-slate-400 text-slate-800`:t===2?`bg-gradient-to-br from-amber-600 to-amber-800 text-white`:`bg-white/5 text-slate-400 border border-white/10`}`,children:t+1}),(0,x.jsxs)(`div`,{className:`min-w-0`,children:[(0,x.jsx)(`h4`,{className:`text-xs font-bold text-slate-200 truncate`,children:e.title}),(0,x.jsxs)(`p`,{className:`text-[10px] text-slate-500 mt-0.5`,children:[`✍ `,e.author]})]})]}),(0,x.jsxs)(`div`,{className:`flex items-center gap-1 text-[10px] shrink-0 font-bold`,children:[e.trend===`up`&&(0,x.jsxs)(`span`,{className:`text-emerald-500 flex items-center`,children:[`▲ `,e.diff]}),e.trend===`down`&&(0,x.jsxs)(`span`,{className:`text-red-500 flex items-center`,children:[`▼ `,e.diff]}),e.trend===`none`&&(0,x.jsx)(`span`,{className:`text-slate-500`,children:`—`})]})]},e.id))}):(0,x.jsx)(`p`,{className:`text-slate-500 text-xs text-center py-6`,children:o===`vi`?`Chưa có xếp hạng.`:o===`en`?`No rankings.`:`暂无排行`})]}),(0,x.jsxs)(`div`,{className:`bg-[#121225]/60 border border-[#1f1f3a]/85 rounded-2xl p-5 shadow-xl space-y-4`,children:[(0,x.jsxs)(`h3`,{className:`text-sm font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-brand-300 to-purple-300 flex items-center gap-2`,children:[(0,x.jsx)(r,{className:`w-5 h-5 text-brand-400`}),` `,o===`vi`?`Hoạt động cộng đồng`:o===`en`?`Community Activity`:`社区动态`]}),(0,x.jsx)(`div`,{className:`flex -space-x-2 overflow-hidden py-1 border-b border-white/5 pb-3`,children:Pe.map(e=>(0,x.jsx)(`img`,{className:`inline-block h-6 w-6 rounded-full ring-2 ring-[#121225] object-cover`,src:e.avatar,alt:e.user},e.id))}),(0,x.jsx)(`div`,{className:`space-y-4`,children:Pe.map(e=>{let t=`bg-emerald-500/15 border-emerald-500/35 text-emerald-400`;return e.source===`Truyenchu`&&(t=`bg-sky-500/15 border-sky-500/35 text-sky-400`),e.source===`Nady knise`&&(t=`bg-purple-500/15 border-purple-500/35 text-purple-400`),(0,x.jsxs)(`div`,{className:`border-b border-white/5 pb-3.5 last:border-0 last:pb-0 space-y-2`,children:[(0,x.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,x.jsx)(`div`,{className:`flex items-center gap-2`,children:(0,x.jsx)(`span`,{className:`text-slate-200 text-xs font-bold`,children:e.user})}),(0,x.jsx)(`span`,{className:`text-slate-500 text-[10px]`,children:e.time})]}),(0,x.jsxs)(`div`,{className:`flex gap-2 items-center`,children:[(0,x.jsx)(`span`,{className:`px-1.5 py-0.5 rounded text-[8px] font-bold border uppercase shrink-0 ${t}`,children:e.source}),(0,x.jsx)(`p`,{className:`text-slate-300 text-xs leading-normal flex-1`,children:e.comment})]})]},e.id)})})]})]})]}),(0,x.jsx)(ie,{slot:`discover-bottom`}),(0,x.jsx)(ce,{isOpen:Ee,onClose:()=>De(!1),onSelectBook:Ke})]})}export{S as default};
frontend-web/dist/assets/Downloads-DcrmkLWQ.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import{d as e,r as t,s as n,t as r}from"./MainLayout-COiTxmNr.js";import{o as i}from"./square-DZKJ0QGR.js";import{t as a}from"./file-text-mhTXpwIY.js";import{t as o}from"./laptop-BPoCx4gb.js";import{C as ee,F as s,M as c,S as te,b as l,g as u,j as d,w as f}from"./index-yRoRoI6u.js";var p=l(`info`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 16v-4`,key:`1dtifu`}],[`path`,{d:`M12 8h.01`,key:`e9boi3`}]]),m=s(d(),1),h=c(),g=e=>(0,h.jsxs)(`svg`,{...e,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`circle`,{cx:`12`,cy:`12`,r:`10`}),(0,h.jsx)(`circle`,{cx:`12`,cy:`12`,r:`4`}),(0,h.jsx)(`line`,{x1:`21.17`,y1:`8`,x2:`12`,y2:`8`}),(0,h.jsx)(`line`,{x1:`3.95`,y1:`6.06`,x2:`8.54`,y2:`14`}),(0,h.jsx)(`line`,{x1:`10.88`,y1:`21.94`,x2:`15.46`,y2:`14`})]}),_=e=>(0,h.jsxs)(`svg`,{...e,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`path`,{d:`M22 12L3 12`,strokeWidth:`1.5`}),(0,h.jsx)(`path`,{d:`M12 2L12 22`,strokeWidth:`1.5`}),(0,h.jsx)(`path`,{d:`M3 5.41L10.33 4.41V11.58H3V5.41Z`,fill:`currentColor`,opacity:`0.2`}),(0,h.jsx)(`path`,{d:`M11.67 4.23L21 3V11.58H11.67V4.23Z`,fill:`currentColor`,opacity:`0.2`}),(0,h.jsx)(`path`,{d:`M3 12.42H10.33V19.59L3 18.59V12.42Z`,fill:`currentColor`,opacity:`0.2`}),(0,h.jsx)(`path`,{d:`M11.67 12.42H21V21L11.67 19.77V12.42Z`,fill:`currentColor`,opacity:`0.2`}),(0,h.jsx)(`path`,{d:`M3 5.41L10.33 4.41V11.58H3V5.41ZM11.67 4.23L21 3V11.58H11.67V4.23ZM3 12.42H10.33V19.59L3 18.59V12.42ZM11.67 12.42H21V21L11.67 19.77V12.42Z`})]});function v(){let{lang:s}=te(),{user:c}=ee(),[l,d]=(0,m.useState)(!1),[v,ne]=(0,m.useState)(!1),y=typeof window<`u`&&!!window.electron,[b,re]=(0,m.useState)({extension:{version:`1.0.0`,download_url:`/downloads/tts_extension.zip`,file_size:`10.7 MB`,release_notes:`Cập nhật dịch nhanh và tối ưu hóa Chrome Extension Helper`},desktop_linux:{version:`0.0.0`,download_url:`https://huggingface.co/datasets/Cong123779/tienhiep-data/resolve/main/downloads/TienHiepAI-0.0.0.AppImage`,file_size:`116 MB`,release_notes:`Phiên bản AppImage beta dành cho Linux`},desktop_windows:{version:`0.0.0`,download_url:`#`,file_size:`0 MB`,release_notes:`Bản Windows chính thức sắp ra mắt`}}),[x,ie]=(0,m.useState)(`extension`),[ae,oe]=(0,m.useState)(``),[se,ce]=(0,m.useState)(``),[le,S]=(0,m.useState)(``),[ue,C]=(0,m.useState)(``),[de,fe]=(0,m.useState)(!1),[pe,me]=(0,m.useState)(``),[w,T]=(0,m.useState)(!1),[E,D]=(0,m.useState)(0),[O,k]=(0,m.useState)(``),[A,j]=(0,m.useState)(!1),[M,N]=(0,m.useState)(`idle`),[P,F]=(0,m.useState)(``),[I,L]=(0,m.useState)(!1),R=async(e,t)=>{if(!window.electron||!window.electron.downloadAndRunUpdate)return;T(!0),D(0),k(s===`vi`?`Đang kết nối tải bản cập nhật...`:`Connecting to download update...`);let n=window.electron.onUpdateDownloadProgress(e=>{e&&typeof e.percent==`number`&&(D(e.percent),k(s===`vi`?`Đang tải: ${e.percent}% (${(e.downloadedBytes/(1024*1024)).toFixed(1)}MB / ${(e.totalBytes/(1024*1024)).toFixed(1)}MB)`:`Downloading: ${e.percent}% (${(e.downloadedBytes/(1024*1024)).toFixed(1)}MB / ${(e.totalBytes/(1024*1024)).toFixed(1)}MB)`))});try{let n=await window.electron.downloadAndRunUpdate(e,t);n&&n.success?k(s===`vi`?`Tải về hoàn tất! Đang khởi chạy gói cài đặt...`:`Download complete! Launching setup...`):(T(!1),alert((s===`vi`?`Lỗi tải cập nhật: `:`Error loading update: `)+(n.error||`Unknown`)))}catch(e){T(!1),alert((s===`vi`?`Lỗi hệ thống: `:`System error: `)+e.message)}finally{n()}},z=async(e,t)=>{if(!window.electron||!window.electron.quickPatchUpdate)return;T(!0),D(0),k(s===`vi`?`Đang kết nối tải bản vá...`:`Connecting to download patch...`);let n=window.electron.onUpdateDownloadProgress(e=>{e&&typeof e.percent==`number`&&(D(e.percent),k(s===`vi`?`Đang tải bản vá: ${e.percent}% (${(e.downloadedBytes/(1024*1024)).toFixed(2)}MB / ${(e.totalBytes/(1024*1024)).toFixed(2)}MB)`:`Downloading patch: ${e.percent}% (${(e.downloadedBytes/(1024*1024)).toFixed(2)}MB / ${(e.totalBytes/(1024*1024)).toFixed(2)}MB)`))});try{let n=await window.electron.quickPatchUpdate(e,t);n&&n.success?k(s===`vi`?`Áp dụng bản vá thành công! Đang khởi động lại ứng dụng...`:`Patch applied successfully! Restarting...`):(T(!1),alert((s===`vi`?`Lỗi cập nhật bản vá: `:`Patch update error: `)+(n.error||`Unknown`)))}catch(e){T(!1),alert((s===`vi`?`Lỗi hệ thống: `:`System error: `)+e.message)}finally{n()}},he=async()=>{if(window.electron?.uninstallApp){j(!1),N(`running`),F(s===`vi`?`Đang gỡ cài đặt...`:`Uninstalling...`);try{let e=await window.electron.uninstallApp();e?.success?e.platform===`win32`&&e.launched?(N(`done`),F(s===`vi`?`✅ Trình gỡ cài đặt đã khởi chạy. Ứng dụng sẽ đóng lại...`:`✅ Uninstaller launched. App will close...`)):e.platform===`linux`&&(N(`done`),F(s===`vi`?`✅ Đã gỡ đăng ký hệ thống (desktop entries, MIME). Để xóa ho��n toàn, hãy xóa file AppImage và thư mục dữ liệu:\n${e.userDataPath}`:`✅ System entries removed (desktop, MIME). To fully uninstall, delete the AppImage file and data folder:\n${e.userDataPath}`)):(N(`error`),F((s===`vi`?`❌ Lỗi: `:`❌ Error: `)+(e?.error||`Unknown`)))}catch(e){N(`error`),F(`❌ `+e.message)}}},ge=async()=>{if(window.electron?.clearUserData){L(!1);try{let e=await window.electron.clearUserData();e?.success?alert((s===`vi`?`✅ Đã xóa dữ liệu cục bộ:
2
+ `:`✅ Local data cleared:
3
+ `)+(e.cleared?.join(`, `)||`none`)):alert(`❌ `+(e?.error||`Failed`))}catch(e){alert(`❌ `+e.message)}}},[B,V]=(0,m.useState)(``),[H,U]=(0,m.useState)(``),W=e=>{if(!e)return[];let t=e.history||[],n=[{version:e.version,download_url:e.download_url,patch_url:e.patch_url,file_size:e.file_size,release_notes:e.release_notes},...t],r=[],i=new Set;for(let e of n)!e.version||!e.download_url||e.download_url===`#`||i.has(e.version)||(i.add(e.version),r.push(e));return r};(0,m.useEffect)(()=>{_e()},[]);let _e=async()=>{try{let e=await f.get(`/api/releases`);if(e.data&&e.data.success&&e.data.releases){if(re(t=>({...t,...e.data.releases})),e.data.releases.desktop_linux){let t=W(e.data.releases.desktop_linux);t.length>0?V(t[0].version):V(e.data.releases.desktop_linux.version)}if(e.data.releases.desktop_windows){let t=W(e.data.releases.desktop_windows);t.length>0?U(t[0].version):U(e.data.releases.desktop_windows.version)}}}catch(e){console.error(`Failed to load releases from API:`,e)}};(0,m.useEffect)(()=>{let e=b[x];e&&(oe(e.version||``),ce(e.download_url||``),S(e.file_size||``),C(e.release_notes||``))},[x,b]);let G={vi:{title:`Tải App & Extension`,subtitle:`Đồng bộ trải nghiệm đọc truyện và dịch thuật AI tối ưu trên mọi nền tảng trình duyệt và máy tính.`,extensionTitle:`Chrome Extension Helper`,extensionDesc:`Tiện ích tích hợp trực tiếp vào trình duyệt giúp tự động lấy chương, dịch nhanh tiếng Trung và đồng bộ lịch sử đọc với Web App.`,extensionBtn:`Tải tiện ích (.ZIP)`,extensionStepHeader:`Các bước cài đặt thủ công (Developer Mode)`,extSteps:[`Tải tệp tin tts_extension.zip bằng nút bên trên và giải nén ra một thư mục riêng biệt.`,`Mở trình duyệt Chrome hoặc Edge, truy cập đường dẫn quản lý tiện ích: chrome://extensions/`,`Gạt nút kích hoạt Chế độ nhà phát triển (Developer mode) ở góc trên bên phải màn hình.`,`Click chọn Tải tiện ích đã giải nén (Load unpacked) ở góc trên bên trái.`,`Chọn thư mục đã giải nén ở Bước 1. Biểu tượng Tiên Hiệp AI sẽ xuất hiện trên thanh công cụ!`],appTitle:`Linux Client (AppImage)`,appDesc:`Ứng dụng chuyên dụng cho hệ điều hành Linux, tối ưu hiệu năng dịch thuật, lưu trữ sách ngoại tuyến (Offline) và tự động cập nhật từ điển.`,appBtnLinux:`Tải bản Linux (.AppImage)`,appStepHeader:`Cách chạy thủ công`,appSteps:[`Tải file cài đặt TienHiepAI-0.0.0.AppImage về máy tính.`,`Cấp quyền thực thi và chạy file bằng lệnh:`],copyTooltip:`Sao chép lệnh`,copiedTooltip:`Đã sao chép!`,noteTitle:`⚠️ Lưu ý đồng bộ`,noteText:`Vui lòng đăng nhập trên App/Ext bằng cùng tài khoản của trang Web để số liệu dịch thuật và tổng thời gian đọc được đồng bộ hóa chuẩn xác nhất.`},en:{title:`Download App & Extension`,subtitle:`Synchronize your reading experience and AI translations across all browsers and desktop platforms.`,extensionTitle:`Chrome Extension Helper`,extensionDesc:`Integrate directly into your browser to automatically capture chapters, translate Chinese instantly, and sync reading history with the Web App.`,extensionBtn:`Download Extension (.ZIP)`,extensionStepHeader:`Manual Installation Steps (Developer Mode)`,extSteps:[`Download the tts_extension.zip using the button above and extract it into a separate folder.`,`Open Chrome or Edge and navigate to the extension manager: chrome://extensions/`,`Toggle on the Developer mode switch at the top-right corner of the window.`,`Click the Load unpacked button at the top-left corner.`,`Select the folder extracted in Step 1. The Tien Hiep AI icon will appear on your toolbar!`],appTitle:`Linux Client (AppImage)`,appDesc:`Specialized desktop application for Linux translation performance, offline book storage, and automatic local dictionary updates.`,appBtnLinux:`Download for Linux (.AppImage)`,appStepHeader:`Manual Run Instructions`,appSteps:[`Download the TienHiepAI-0.0.0.AppImage file to your computer.`,`Grant execution permission using the command below:`],copyTooltip:`Copy command`,copiedTooltip:`Copied!`,noteTitle:`⚠️ Sync Warning`,noteText:`Please log in on all clients using the same account as the web portal to ensure translation metrics and reading logs sync perfectly.`},zh:{title:`下载中心`,subtitle:`在所有浏览器和电脑平台上同步您的阅读体验与 AI 翻译记录。`,extensionTitle:`Chrome 辅助插件`,extensionDesc:`直接嵌入浏览器以自动抓取章节、快速翻译中文,并与网页版同步阅读进度和统计数据。`,extensionBtn:`下载插件包 (.ZIP)`,extensionStepHeader:`手动安装步骤 (开发者模式)`,extSteps:[`使用上方按钮下载 tts_extension.zip 并解压到一个独立的文件夹中。`,`打开 Chrome 或 Edge 浏览器,访问插件管理器:chrome://extensions/`,`开启右上角的 开发者模式 (Developer mode) 开关。`,`点击左上角的 加载已解压的扩展程序 (Load unpacked) 按钮。`,`选择您在步骤1中解压的文件夹,仙侠 AI 图标即会出现在浏览器工具栏上!`],appTitle:`Linux 客户端 (AppImage)`,appDesc:`专为 Linux 打造的桌面客户端,提供极佳的翻译性能、离线书籍缓存以及自动本地词库更新。`,appBtnLinux:`下载 Linux 版 (.AppImage)`,appStepHeader:`手动运行指南`,appSteps:[`将 TienHiepAI-0.0.0.AppImage 文件下载到您的电脑中。`,`使用以下命令赋予可执行权限:`],copyTooltip:`复制命令`,copiedTooltip:`已复制!`,noteTitle:`⚠️ 重要提示`,noteText:`浏览器插件和电脑桌面端均通过您的账号进行数据同步。请确保在所有客户端上登录相同的账号,以便精确统计您的翻译字符数和总阅读时长。`}},K=W(b.desktop_windows),q=W(b.desktop_linux),J=q.find(e=>e.version===B)||q[0]||b.desktop_linux,Y=K.find(e=>e.version===H)||K[0]||b.desktop_windows,X=G[s]||G.vi,Z=`chmod +x ${J.download_url.substring(J.download_url.lastIndexOf(`/`)+1)||`TienHiepAI.AppImage`} && ./${J.download_url.substring(J.download_url.lastIndexOf(`/`)+1)||`TienHiepAI.AppImage`}`,Q=`sudo curl -L -o /usr/local/bin/tienhiep-ai "${J.download_url}" && sudo chmod +x /usr/local/bin/tienhiep-ai`,$=(e,t)=>{navigator.clipboard.writeText(e),t(!0),setTimeout(()=>t(!1),2e3)};return(0,h.jsxs)(r,{children:[(0,h.jsxs)(`div`,{className:`max-w-6xl mx-auto space-y-8 py-4 sm:py-6`,children:[(0,h.jsxs)(`div`,{className:`text-center space-y-3 max-w-3xl mx-auto`,children:[(0,h.jsx)(`div`,{className:`inline-flex p-3 bg-purple-600/10 rounded-full border border-purple-500/20 text-purple-400 mb-2 animate-bounce`,children:(0,h.jsx)(t,{className:`w-6 h-6`})}),(0,h.jsx)(`h1`,{className:`text-2xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-indigo-200 to-purple-300 tracking-wide uppercase`,children:X.title}),(0,h.jsx)(`p`,{className:`text-slate-400 text-sm leading-relaxed sm:text-base`,children:X.subtitle})]}),(0,h.jsxs)(`div`,{className:`bg-[#191635]/40 border border-indigo-500/20 rounded-2xl p-4 sm:p-5 flex gap-4 items-start shadow-md`,children:[(0,h.jsx)(p,{className:`w-5 h-5 text-indigo-400 shrink-0 mt-0.5`}),(0,h.jsxs)(`div`,{className:`space-y-1`,children:[(0,h.jsx)(`h4`,{className:`text-xs font-bold text-white uppercase tracking-wider`,children:X.noteTitle}),(0,h.jsx)(`p`,{className:`text-slate-300 text-xs leading-relaxed`,children:X.noteText})]})]}),!1,(0,h.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-3 gap-6`,children:[(0,h.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 rounded-3xl p-6 flex flex-col justify-between shadow-xl relative hover:border-emerald-500/20 hover:shadow-emerald-950/5 transition-all duration-300 group`,children:[(0,h.jsx)(`div`,{className:`absolute top-0 right-0 w-20 h-20 bg-emerald-500/5 rounded-bl-full filter blur-xl opacity-50 group-hover:bg-emerald-500/10 transition-colors`}),(0,h.jsxs)(`div`,{className:`space-y-5`,children:[(0,h.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,h.jsx)(`div`,{className:`p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-2xl text-emerald-400 group-hover:scale-115 transition-transform`,children:(0,h.jsx)(g,{className:`w-5 h-5`})}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h3`,{className:`text-base font-bold text-white`,children:X.extensionTitle}),(0,h.jsxs)(`span`,{className:`inline-block text-[9px] font-black tracking-widest text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full uppercase mt-0.5`,children:[`v`,b.extension.version,` • Verified`]})]})]}),(0,h.jsx)(`p`,{className:`text-slate-400 text-xs leading-relaxed min-h-[50px]`,children:X.extensionDesc}),(0,h.jsxs)(`div`,{children:[(0,h.jsxs)(`a`,{href:b.extension.download_url,download:`tts_extension.zip`,className:`w-full inline-flex items-center justify-center gap-2 bg-emerald-600 hover:bg-emerald-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:X.extensionBtn})]}),(0,h.jsxs)(`p`,{className:`text-center text-[9px] text-slate-500 mt-2`,children:[`ZIP Format • Size: ~`,b.extension.file_size]})]}),(0,h.jsxs)(`div`,{className:`bg-[#0b0b14]/50 border border-emerald-500/10 p-3 rounded-xl space-y-1`,children:[(0,h.jsx)(`span`,{className:`text-[9px] font-bold text-emerald-400 uppercase tracking-widest`,children:`📝 Changelog`}),(0,h.jsxs)(`p`,{className:`text-slate-300 text-[11px] leading-relaxed italic`,children:[`"`,b.extension.release_notes,`"`]})]}),(0,h.jsxs)(`div`,{className:`border-t border-[#1f1f3a]/50 pt-4 space-y-3`,children:[(0,h.jsxs)(`h4`,{className:`text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-2`,children:[(0,h.jsx)(a,{className:`w-3.5 h-3.5 text-slate-400`}),` `,X.extensionStepHeader]}),(0,h.jsx)(`ol`,{className:`space-y-3 text-[11px] text-slate-400 list-none pl-0`,children:X.extSteps.map((e,t)=>(0,h.jsxs)(`li`,{className:`flex gap-2 items-start`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:t+1}),(0,h.jsx)(`span`,{className:`leading-relaxed`,children:e})]},t))})]})]})]}),(0,h.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 rounded-3xl p-6 flex flex-col justify-between shadow-xl relative hover:border-purple-500/20 hover:shadow-purple-950/5 transition-all duration-300 group`,children:[(0,h.jsx)(`div`,{className:`absolute top-0 right-0 w-20 h-20 bg-purple-500/5 rounded-bl-full filter blur-xl opacity-50 group-hover:bg-purple-500/10 transition-colors`}),(0,h.jsxs)(`div`,{className:`space-y-5`,children:[(0,h.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,h.jsx)(`div`,{className:`p-3 bg-purple-500/10 border border-purple-500/20 rounded-2xl text-purple-400 group-hover:scale-115 transition-transform`,children:(0,h.jsx)(o,{className:`w-5 h-5`})}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h3`,{className:`text-base font-bold text-white`,children:X.appTitle}),(0,h.jsxs)(`div`,{className:`flex flex-wrap gap-1.5 items-center mt-1`,children:[(0,h.jsxs)(`span`,{className:`inline-block text-[9px] font-black tracking-widest text-purple-400 bg-purple-500/10 px-2 py-0.5 rounded-full uppercase`,children:[`v`,J.version,` • Linux Beta`]}),q.length>1&&(0,h.jsx)(`select`,{value:B,onChange:e=>V(e.target.value),className:`bg-[#0b0b14] border border-[#1f1f3a] text-purple-400 font-extrabold text-[10px] rounded-lg px-1.5 py-0.5 focus:outline-none focus:border-purple-500 transition-colors cursor-pointer`,children:q.map(e=>(0,h.jsxs)(`option`,{value:e.version,children:[`v`,e.version]},e.version))})]})]})]}),(0,h.jsx)(`p`,{className:`text-slate-400 text-xs leading-relaxed min-h-[50px]`,children:X.appDesc}),(0,h.jsx)(`div`,{children:(0,h.jsxs)(`div`,{children:[y?J.patch_url?(0,h.jsxs)(`div`,{className:`space-y-2`,children:[(0,h.jsxs)(`button`,{onClick:()=>z(J.patch_url,J.version),className:`w-full inline-flex items-center justify-center gap-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center cursor-pointer`,children:[(0,h.jsx)(e,{className:`w-4 h-4`}),(0,h.jsxs)(`span`,{children:[`⚡ Cập nhật nhanh lên v`,J.version,` (Khuyên dùng)`]})]}),(0,h.jsxs)(`button`,{onClick:()=>{let e=J.download_url;R(e,e.substring(e.lastIndexOf(`/`)+1)||`TienHiepAI.AppImage`)},className:`w-full inline-flex items-center justify-center gap-2 bg-slate-800 hover:bg-slate-700 active:scale-98 text-slate-300 font-extrabold px-5 py-2.5 rounded-xl border border-slate-700 transition-all text-[11px] text-center cursor-pointer`,children:[(0,h.jsx)(t,{className:`w-3.5 h-3.5`}),(0,h.jsx)(`span`,{children:`Tải bộ cài Full Setup (v1.0.6)`})]})]}):(0,h.jsxs)(`button`,{onClick:()=>{let e=J.download_url;if(!e||e===`#`){alert(s===`vi`?`Liên kết tải xuống không khả dụng.`:`Download link is not available.`);return}R(e,e.substring(e.lastIndexOf(`/`)+1)||`TienHiepAI.AppImage`)},className:`w-full inline-flex items-center justify-center gap-2 bg-purple-600 hover:bg-purple-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center cursor-pointer`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:`Tải bản Linux (.AppImage)`})]}):(0,h.jsxs)(`div`,{className:`space-y-2`,children:[(0,h.jsxs)(`a`,{href:J.download_url,className:`w-full inline-flex items-center justify-center gap-2 bg-purple-600 hover:bg-purple-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:`Tải bản Linux (.AppImage)`})]}),J.download_url.includes(`1.0.6`)&&J.patch_url&&(0,h.jsxs)(`p`,{className:`text-[10px] text-amber-400 leading-normal bg-amber-500/5 border border-amber-500/10 p-2.5 rounded-xl text-center`,children:[`💡 `,(0,h.jsx)(`b`,{children:`Lưu ý:`}),` v`,J.version,` là bản nâng cấp nhanh. Bạn đang tải `,(0,h.jsx)(`b`,{children:`bộ cài nền tảng v1.0.6`}),`, sau khi mở app nó sẽ tự động update lên v`,J.version,` trong 3 giây.`]})]}),(0,h.jsxs)(`p`,{className:`text-center text-[9px] text-slate-500 mt-2`,children:[`AppImage Format • Size: ~`,J.file_size]})]})}),(0,h.jsxs)(`div`,{className:`bg-[#0b0b14]/50 border border-purple-500/10 p-3 rounded-xl space-y-1`,children:[(0,h.jsx)(`span`,{className:`text-[9px] font-bold text-purple-400 uppercase tracking-widest`,children:`📝 Changelog`}),(0,h.jsxs)(`p`,{className:`text-slate-300 text-[11px] leading-relaxed italic`,children:[`"`,J.release_notes,`"`]})]}),(0,h.jsxs)(`div`,{className:`border-t border-[#1f1f3a]/50 pt-4 space-y-4`,children:[(0,h.jsxs)(`div`,{className:`space-y-1.5`,children:[(0,h.jsxs)(`h4`,{className:`text-xs font-black text-amber-400 uppercase tracking-wider flex items-center gap-2`,children:[(0,h.jsx)(n,{className:`w-3.5 h-3.5 text-amber-400`}),` ⚡ Cài đặt nhanh bằng lệnh SUDO`]}),(0,h.jsx)(`p`,{className:`text-[10px] text-slate-400 leading-relaxed`,children:`Mở Terminal và dán lệnh sau để tải, cấp quyền và cài đặt ứng dụng chạy trực tiếp:`}),(0,h.jsxs)(`div`,{className:`flex items-center justify-between gap-2 bg-[#0b0b14] border border-amber-500/30 rounded-xl px-3 py-2 font-mono text-[9px] text-amber-300 w-full overflow-x-auto select-all shadow-inner`,children:[(0,h.jsx)(`span`,{className:`truncate`,children:Q}),(0,h.jsx)(`button`,{onClick:()=>$(Q,ne),className:`p-1 hover:bg-white/5 rounded text-slate-400 hover:text-white transition-colors shrink-0`,title:v?X.copiedTooltip:X.copyTooltip,children:v?(0,h.jsx)(i,{className:`w-3 h-3 text-emerald-400`}):(0,h.jsx)(u,{className:`w-3 h-3`})})]}),(0,h.jsxs)(`p`,{className:`text-[9px] text-slate-500 font-bold`,children:[`💡 Sau khi chạy, bạn chỉ cần gõ `,(0,h.jsx)(`span`,{className:`text-purple-400 font-mono`,children:`tienhiep-ai`}),` tại Terminal để mở app bất cứ lúc nào!`]})]}),(0,h.jsxs)(`div`,{className:`space-y-1.5 pt-1 border-t border-[#1f1f3a]/30`,children:[(0,h.jsxs)(`h4`,{className:`text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-2`,children:[(0,h.jsx)(a,{className:`w-3.5 h-3.5 text-slate-400`}),` `,X.appStepHeader]}),(0,h.jsxs)(`ol`,{className:`space-y-2 text-[11px] text-slate-400 list-none pl-0`,children:[(0,h.jsxs)(`li`,{className:`flex gap-2 items-start`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:`1`}),(0,h.jsx)(`span`,{className:`leading-relaxed`,children:X.appSteps[0]})]}),(0,h.jsxs)(`li`,{className:`flex gap-2 items-start w-full`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:`2`}),(0,h.jsxs)(`div`,{className:`flex-1 space-y-1 min-w-0`,children:[(0,h.jsx)(`span`,{className:`leading-relaxed`,children:X.appSteps[1]}),(0,h.jsxs)(`div`,{className:`flex items-center justify-between gap-2 bg-[#0b0b14] border border-[#1f1f3a] rounded-xl px-3 py-1.5 font-mono text-[9px] text-purple-300 w-full overflow-x-auto select-all`,children:[(0,h.jsx)(`span`,{className:`truncate`,children:Z}),(0,h.jsx)(`button`,{onClick:()=>$(Z,d),className:`p-1 hover:bg-white/5 rounded text-slate-400 hover:text-white transition-colors shrink-0`,title:l?X.copiedTooltip:X.copyTooltip,children:l?(0,h.jsx)(i,{className:`w-3 h-3 text-emerald-400`}):(0,h.jsx)(u,{className:`w-3 h-3`})})]})]})]})]})]})]})]})]}),(0,h.jsxs)(`div`,{className:`bg-[#121225]/80 border border-[#1f1f3a]/80 rounded-3xl p-6 flex flex-col justify-between shadow-xl relative hover:border-blue-500/20 hover:shadow-blue-950/5 transition-all duration-300 group`,children:[(0,h.jsx)(`div`,{className:`absolute top-0 right-0 w-20 h-20 bg-blue-500/5 rounded-bl-full filter blur-xl opacity-50 group-hover:bg-blue-500/10 transition-colors`}),(0,h.jsxs)(`div`,{className:`space-y-5`,children:[(0,h.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,h.jsx)(`div`,{className:`p-3 bg-blue-500/10 border border-blue-500/20 rounded-2xl text-blue-400 group-hover:scale-115 transition-transform`,children:(0,h.jsx)(_,{className:`w-5 h-5`})}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h3`,{className:`text-base font-bold text-white`,children:`Windows Client`}),(0,h.jsxs)(`div`,{className:`flex flex-wrap gap-1.5 items-center mt-1`,children:[(0,h.jsx)(`span`,{className:`inline-block text-[9px] font-black tracking-widest px-2 py-0.5 rounded-full uppercase ${Y.download_url===`#`?`text-slate-500 bg-slate-500/10`:`text-blue-400 bg-blue-500/10`}`,children:Y.download_url===`#`?`Coming Soon`:`v${Y.version} • Stable`}),K.length>1&&(0,h.jsx)(`select`,{value:H,onChange:e=>U(e.target.value),className:`bg-[#0b0b14] border border-[#1f1f3a] text-blue-400 font-extrabold text-[10px] rounded-lg px-1.5 py-0.5 focus:outline-none focus:border-blue-500 transition-colors cursor-pointer`,children:K.map(e=>(0,h.jsxs)(`option`,{value:e.version,children:[`v`,e.version]},e.version))})]})]})]}),(0,h.jsx)(`p`,{className:`text-slate-400 text-xs leading-relaxed min-h-[50px]`,children:`Ứng dụng máy tính dành cho hệ điều hành Windows. Đầy đủ tính năng tối ưu hóa dịch thuật, lưu trữ sách, chạy mượt mà trên Win 10/11.`}),(0,h.jsxs)(`div`,{children:[Y.download_url===`#`?(0,h.jsxs)(`button`,{disabled:!0,className:`w-full inline-flex items-center justify-center gap-2 bg-slate-800/40 border border-slate-700/30 text-slate-500 font-bold px-5 py-3 rounded-xl text-xs text-center cursor-not-allowed`,children:[(0,h.jsx)(_,{className:`w-4 h-4 opacity-40`}),(0,h.jsx)(`span`,{children:`Bản Windows (Sắp ra mắt)`})]}):y?Y.patch_url?(0,h.jsxs)(`div`,{className:`space-y-2`,children:[(0,h.jsxs)(`button`,{onClick:()=>z(Y.patch_url,Y.version),className:`w-full inline-flex items-center justify-center gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center cursor-pointer`,children:[(0,h.jsx)(e,{className:`w-4 h-4`}),(0,h.jsxs)(`span`,{children:[`⚡ Cập nhật nhanh lên v`,Y.version,` (Khuyên dùng)`]})]}),(0,h.jsxs)(`button`,{onClick:()=>{let e=Y.download_url;R(e,e.substring(e.lastIndexOf(`/`)+1)||`TienHiepAI-Setup.exe`)},className:`w-full inline-flex items-center justify-center gap-2 bg-slate-800 hover:bg-slate-700 active:scale-98 text-slate-300 font-extrabold px-5 py-2.5 rounded-xl border border-slate-700 transition-all text-[11px] text-center cursor-pointer`,children:[(0,h.jsx)(t,{className:`w-3.5 h-3.5`}),(0,h.jsx)(`span`,{children:`Tải bộ cài Full Setup (v1.0.6)`})]})]}):(0,h.jsxs)(`button`,{onClick:()=>{let e=Y.download_url;if(!e||e===`#`){alert(s===`vi`?`Liên kết tải xuống không khả dụng.`:`Download link is not available.`);return}R(e,e.substring(e.lastIndexOf(`/`)+1)||`TienHiepAI-Setup.exe`)},className:`w-full inline-flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center cursor-pointer`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:`Tải bản Windows (.EXE Setup)`})]}):(0,h.jsxs)(`div`,{className:`space-y-2`,children:[(0,h.jsxs)(`a`,{href:Y.download_url,className:`w-full inline-flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-500 active:scale-98 text-white font-extrabold px-5 py-3 rounded-xl shadow-lg transition-all text-xs text-center`,children:[(0,h.jsx)(t,{className:`w-4 h-4`}),(0,h.jsx)(`span`,{children:`Tải bản Windows (.EXE Setup)`})]}),Y.download_url.includes(`1.0.6`)&&Y.patch_url&&(0,h.jsxs)(`p`,{className:`text-[10px] text-amber-400 leading-normal bg-amber-500/5 border border-amber-500/10 p-2.5 rounded-xl text-center`,children:[`💡 `,(0,h.jsx)(`b`,{children:`Lưu ý:`}),` v`,Y.version,` là bản nâng cấp nhanh. Bạn đang tải `,(0,h.jsx)(`b`,{children:`bộ cài nền tảng v1.0.6`}),`, sau khi mở app nó sẽ tự động update lên v`,Y.version,` trong 3 giây.`]})]}),(0,h.jsxs)(`p`,{className:`text-center text-[9px] text-slate-500 mt-2`,children:[`Format: EXE Setup • Size: ~`,Y.file_size]})]}),(0,h.jsxs)(`div`,{className:`bg-[#0b0b14]/50 border border-blue-500/10 p-3 rounded-xl space-y-1`,children:[(0,h.jsx)(`span`,{className:`text-[9px] font-bold text-blue-400 uppercase tracking-widest`,children:`📝 Changelog`}),(0,h.jsxs)(`p`,{className:`text-slate-300 text-[11px] leading-relaxed italic`,children:[`"`,Y.release_notes,`"`]})]}),(0,h.jsxs)(`div`,{className:`border-t border-[#1f1f3a]/50 pt-4 space-y-3`,children:[(0,h.jsxs)(`h4`,{className:`text-xs font-bold text-slate-200 uppercase tracking-wider flex items-center gap-2`,children:[(0,h.jsx)(a,{className:`w-3.5 h-3.5 text-slate-400`}),` Hướng dẫn cài đặt`]}),(0,h.jsxs)(`ol`,{className:`space-y-3 text-[11px] text-slate-400 list-none pl-0`,children:[(0,h.jsxs)(`li`,{className:`flex gap-2 items-start`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:`1`}),(0,h.jsx)(`span`,{className:`leading-relaxed`,children:`Tải tệp tin cài đặt Windows (khi phát hành chính thức).`})]}),(0,h.jsxs)(`li`,{className:`flex gap-2 items-start`,children:[(0,h.jsx)(`span`,{className:`w-4.5 h-4.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center font-bold text-[9px] shrink-0 mt-0.5`,children:`2`}),(0,h.jsx)(`span`,{className:`leading-relaxed`,children:"Chạy file installer `.exe` và làm theo các bước hướng dẫn trên màn hình."})]})]})]})]})]})]}),y&&(0,h.jsxs)(`div`,{className:`bg-[#121225]/80 border border-rose-500/10 rounded-3xl p-6 shadow-xl space-y-4 relative overflow-hidden`,children:[(0,h.jsx)(`div`,{className:`absolute top-0 right-0 w-24 h-24 bg-rose-500/5 rounded-bl-full filter blur-2xl`}),(0,h.jsxs)(`div`,{className:`flex items-start gap-3 border-b border-rose-500/10 pb-4`,children:[(0,h.jsx)(`div`,{className:`p-2.5 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 shrink-0 mt-0.5`,children:(0,h.jsxs)(`svg`,{className:`w-4 h-4`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`circle`,{cx:`12`,cy:`12`,r:`3`}),(0,h.jsx)(`path`,{d:`M19.07 4.93A10 10 0 0 0 4.93 19.07`}),(0,h.jsx)(`path`,{d:`M4.93 4.93A10 10 0 0 0 19.07 19.07`})]})}),(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h3`,{className:`text-sm font-extrabold text-white uppercase tracking-wider`,children:s===`vi`?`⚙️ Quản lý Ứng dụng`:`⚙️ App Management`}),(0,h.jsx)(`p`,{className:`text-[10px] text-rose-300/70 mt-0.5`,children:s===`vi`?`Gỡ cài đặt hoặc xóa dữ liệu cục bộ của ứng dụng.`:`Uninstall or clear local app data.`})]})]}),(0,h.jsxs)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 gap-4`,children:[(0,h.jsxs)(`div`,{className:`bg-rose-950/20 border border-rose-500/10 rounded-2xl p-4 space-y-3`,children:[(0,h.jsxs)(`div`,{className:`space-y-1`,children:[(0,h.jsx)(`h4`,{className:`text-xs font-bold text-rose-300 uppercase tracking-wider`,children:s===`vi`?`🗑️ Gỡ cài đặt ứng dụng`:`🗑️ Uninstall Application`}),(0,h.jsx)(`p`,{className:`text-[10px] text-slate-400 leading-relaxed`,children:s===`vi`?`Xóa các desktop entry, MIME handler đã đăng ký. Trên Windows sẽ chạy trình gỡ cài đặt NSIS.`:`Removes desktop entries and MIME handlers. On Windows, launches the NSIS uninstaller.`})]}),(0,h.jsxs)(`button`,{id:`btn-uninstall-app`,onClick:()=>j(!0),className:`w-full flex items-center justify-center gap-2 bg-rose-600/20 hover:bg-rose-600/40 border border-rose-500/30 text-rose-300 hover:text-rose-200 text-xs font-bold px-4 py-2.5 rounded-xl transition-all active:scale-95`,children:[(0,h.jsxs)(`svg`,{className:`w-3.5 h-3.5`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`polyline`,{points:`3 6 5 6 21 6`}),(0,h.jsx)(`path`,{d:`M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6`}),(0,h.jsx)(`path`,{d:`M10 11v6`}),(0,h.jsx)(`path`,{d:`M14 11v6`}),(0,h.jsx)(`path`,{d:`M9 6V4h6v2`})]}),s===`vi`?`Gỡ cài đặt...`:`Uninstall...`]})]}),(0,h.jsxs)(`div`,{className:`bg-amber-950/20 border border-amber-500/10 rounded-2xl p-4 space-y-3`,children:[(0,h.jsxs)(`div`,{className:`space-y-1`,children:[(0,h.jsx)(`h4`,{className:`text-xs font-bold text-amber-300 uppercase tracking-wider`,children:s===`vi`?`🧹 Xóa dữ liệu cục bộ`:`🧹 Clear Local Data`}),(0,h.jsx)(`p`,{className:`text-[10px] text-slate-400 leading-relaxed`,children:s===`vi`?`Xóa file cấu hình (app_config.json) và log debug. Tài khoản, sách và models KHÔNG bị xóa.`:`Deletes app_config.json and debug logs. Account, books, and models are NOT affected.`})]}),(0,h.jsxs)(`button`,{id:`btn-clear-userdata`,onClick:()=>L(!0),className:`w-full flex items-center justify-center gap-2 bg-amber-600/20 hover:bg-amber-600/40 border border-amber-500/30 text-amber-300 hover:text-amber-200 text-xs font-bold px-4 py-2.5 rounded-xl transition-all active:scale-95`,children:[(0,h.jsx)(`svg`,{className:`w-3.5 h-3.5`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:(0,h.jsx)(`path`,{d:`M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z`})}),s===`vi`?`Xóa dữ liệu...`:`Clear Data...`]})]})]})]})]}),w&&(0,h.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4 animate-fade-in`,children:(0,h.jsxs)(`div`,{className:`bg-[#131324] border border-[#2d2d6b] rounded-3xl p-6 max-w-md w-full text-center space-y-4 shadow-2xl`,children:[(0,h.jsx)(`h3`,{className:`text-lg font-extrabold text-white`,children:s===`vi`?`Tự Động Cập Nhật Phiên Bản`:`Automatic Version Update`}),(0,h.jsx)(`p`,{className:`text-xs text-slate-300`,children:O}),(0,h.jsx)(`div`,{className:`w-full bg-[#1c1c38] rounded-full h-3 border border-indigo-950/30 overflow-hidden relative`,children:(0,h.jsx)(`div`,{className:`bg-gradient-to-r from-purple-500 to-indigo-500 h-full rounded-full transition-all duration-300 shadow-inner`,style:{width:`${E}%`}})}),(0,h.jsxs)(`div`,{className:`flex justify-between items-center text-[10px] font-bold text-slate-500`,children:[(0,h.jsx)(`span`,{children:`0%`}),(0,h.jsxs)(`span`,{className:`text-purple-400 text-sm font-black`,children:[E,`%`]}),(0,h.jsx)(`span`,{children:`100%`})]}),(0,h.jsx)(`p`,{className:`text-[10px] text-slate-500 leading-relaxed`,children:s===`vi`?`⚠️ Lưu ý: Ứng dụng sẽ tự động đóng sau khi hoàn tất để kích hoạt trình cài đặt/phiên bản mới.`:`⚠️ Note: The application will close automatically after completion to launch the new installer/version.`})]})}),A&&(0,h.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-fade-in`,children:(0,h.jsxs)(`div`,{className:`bg-[#131324] border border-rose-500/30 rounded-3xl p-6 max-w-sm w-full text-center space-y-4 shadow-2xl`,children:[(0,h.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full bg-rose-500/10 border border-rose-500/20 flex items-center justify-center text-rose-400`,children:(0,h.jsxs)(`svg`,{className:`w-7 h-7`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,h.jsx)(`polyline`,{points:`3 6 5 6 21 6`}),(0,h.jsx)(`path`,{d:`M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6`}),(0,h.jsx)(`path`,{d:`M10 11v6`}),(0,h.jsx)(`path`,{d:`M14 11v6`}),(0,h.jsx)(`path`,{d:`M9 6V4h6v2`})]})}),(0,h.jsx)(`h3`,{className:`text-base font-extrabold text-white`,children:s===`vi`?`⚠️ Xác nhận Gỡ cài đặt`:`⚠️ Confirm Uninstall`}),(0,h.jsx)(`p`,{className:`text-xs text-slate-400 leading-relaxed`,children:s===`vi`?`Thao tác này sẽ xóa các desktop entries và MIME handler đã đăng ký với hệ thống. Bạn có chắc chắn muốn tiếp tục không?`:`This will remove registered desktop entries and MIME handlers from your system. Are you sure you want to continue?`}),(0,h.jsxs)(`div`,{className:`flex gap-3`,children:[(0,h.jsx)(`button`,{onClick:()=>j(!1),className:`flex-1 px-4 py-2.5 bg-slate-800/60 border border-slate-700/30 text-slate-300 text-xs font-bold rounded-xl hover:bg-slate-700/40 transition-all`,children:s===`vi`?`Hủy bỏ`:`Cancel`}),(0,h.jsx)(`button`,{onClick:he,className:`flex-1 px-4 py-2.5 bg-rose-600 hover:bg-rose-500 text-white text-xs font-extrabold rounded-xl transition-all active:scale-95 shadow-lg`,children:s===`vi`?`Gỡ cài đặt`:`Uninstall`})]})]})}),I&&(0,h.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-fade-in`,children:(0,h.jsxs)(`div`,{className:`bg-[#131324] border border-amber-500/30 rounded-3xl p-6 max-w-sm w-full text-center space-y-4 shadow-2xl`,children:[(0,h.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400`,children:(0,h.jsx)(`svg`,{className:`w-7 h-7`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:(0,h.jsx)(`path`,{d:`M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z`})})}),(0,h.jsx)(`h3`,{className:`text-base font-extrabold text-white`,children:s===`vi`?`🗑️ Xóa dữ liệu cục bộ`:`🗑️ Clear Local Data`}),(0,h.jsx)(`p`,{className:`text-xs text-slate-400 leading-relaxed`,children:s===`vi`?`Sẽ xóa file cấu hình (app_config.json) và log debug. Dữ liệu tài khoản và models sẽ không bị xóa.`:`Will delete config file (app_config.json) and debug logs. Account data and models will NOT be deleted.`}),(0,h.jsxs)(`div`,{className:`flex gap-3`,children:[(0,h.jsx)(`button`,{onClick:()=>L(!1),className:`flex-1 px-4 py-2.5 bg-slate-800/60 border border-slate-700/30 text-slate-300 text-xs font-bold rounded-xl hover:bg-slate-700/40 transition-all`,children:s===`vi`?`Hủy bỏ`:`Cancel`}),(0,h.jsx)(`button`,{onClick:ge,className:`flex-1 px-4 py-2.5 bg-amber-600 hover:bg-amber-500 text-white text-xs font-extrabold rounded-xl transition-all active:scale-95 shadow-lg`,children:s===`vi`?`Xóa dữ liệu`:`Clear Data`})]})]})}),(M===`running`||M===`done`||M===`error`)&&(0,h.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-fade-in`,children:(0,h.jsxs)(`div`,{className:`bg-[#131324] border rounded-3xl p-6 max-w-sm w-full text-center space-y-4 shadow-2xl ${M===`error`?`border-rose-500/30`:M===`done`?`border-emerald-500/30`:`border-indigo-500/30`}`,children:[M===`running`?(0,h.jsx)(`div`,{className:`w-10 h-10 mx-auto border-2 border-indigo-500/30 border-t-indigo-400 rounded-full animate-spin`}):(0,h.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full flex items-center justify-center text-2xl ${M===`done`?`bg-emerald-500/10 border border-emerald-500/20`:`bg-rose-500/10 border border-rose-500/20`}`,children:M===`done`?`✅`:`❌`}),(0,h.jsx)(`h3`,{className:`text-sm font-extrabold text-white`,children:M===`running`?s===`vi`?`Đang xử lý...`:`Processing...`:s===`vi`?`Kết quả`:`Result`}),(0,h.jsx)(`p`,{className:`text-xs text-slate-300 leading-relaxed whitespace-pre-line`,children:P}),M!==`running`&&(0,h.jsx)(`button`,{onClick:()=>{N(`idle`),F(``)},className:`w-full px-4 py-2.5 bg-slate-800/60 border border-slate-700/30 text-slate-300 text-xs font-bold rounded-xl hover:bg-slate-700/40 transition-all`,children:s===`vi`?`Đóng`:`Close`})]})})]})}export{v as default};
frontend-web/dist/assets/GoogleAd-CD0eT9Wp.js ADDED
@@ -0,0 +1 @@
 
 
1
+ import{C as e,F as t,M as n,j as r}from"./index-yRoRoI6u.js";var i=t(r(),1),a=n();function o({slot:t,format:n=`auto`,responsive:r=`true`,className:o=``}){let{user:s,loading:c}=e();return(0,i.useEffect)(()=>{if(!c&&(!s||s.vip_status!==1))try{(window.adsbygoogle=window.adsbygoogle||[]).push({})}catch(e){console.error(`AdSense push error:`,e)}},[s,c]),c||s&&s.vip_status===1?null:(0,a.jsxs)(`div`,{className:`ad-container my-6 flex flex-col items-center justify-center w-full min-h-[100px] md:min-h-[200px] bg-[#121225]/20 border border-dashed border-[#1f1f3a]/60 rounded-2xl relative overflow-hidden ${o}`,children:[(0,a.jsx)(`span`,{className:`absolute top-2 right-3 text-[9px] uppercase tracking-wider text-slate-600 font-extrabold select-none`,children:`ADVERTISEMENT`}),(0,a.jsx)(`ins`,{className:`adsbygoogle w-full`,style:{display:`block`,minHeight:`90px`},"data-ad-client":`ca-pub-9548504602542886`,"data-ad-slot":t,"data-ad-format":n,"data-full-width-responsive":r})]})}export{o as t};
frontend-web/dist/assets/HistoryPage-DD_Z3uyW.js ADDED
@@ -0,0 +1 @@
 
 
1
+ import{t as e,y as t}from"./MainLayout-COiTxmNr.js";import{a as n,t as r}from"./square-DZKJ0QGR.js";import{t as i}from"./clock-DEPYmZZ-.js";import{t as a}from"./square-check-big-BhcFiAg6.js";import{t as o}from"./trash-2-DDhTqVwA.js";import{C as s,D as c,F as l,M as u,S as d,f,j as p,m,o as h,w as g}from"./index-yRoRoI6u.js";var _=l(p(),1),v=u();function y(){let{t:l,lang:u}=d(),{user:p,loading:y}=s(),b=c(),[x,S]=(0,_.useState)([]),[C,w]=(0,_.useState)(!0),[T,E]=(0,_.useState)(null),[D,O]=(0,_.useState)(``),[k,A]=(0,_.useState)(!1),[j,M]=(0,_.useState)(new Set);(0,_.useEffect)(()=>{if(!y){if(!p){w(!1);return}N()}},[p,y]);let N=async()=>{w(!0);try{S((await g.get(`/api/history`,{params:D?{q:D}:{}})).data)}catch(e){console.error(e)}finally{w(!1)}},P=async()=>{let e=u===`vi`?`Bạn có chắc muốn xóa tất cả lịch sử đọc?`:u===`en`?`Are you sure you want to clear all reading history?`:`您确定要清空所有阅读历史记录吗?`;if(window.confirm(e))try{await g.post(`/api/history/clear`),S([])}catch{alert(u===`vi`?`Không xóa được lịch sử.`:u===`en`?`Failed to clear history.`:`无法清空历史记录。`)}},F=async(e,t,n)=>{n&&n.stopPropagation();let r=e||t;if(r){E(r);try{await g.post(`/api/history/remove`,e?{book_id:e}:{url:t}),S(n=>n.map(n=>({...n,books:n.books.filter(n=>n.book_id!==e&&n.url!==t&&(n.book_id||n.url)!==r)})).filter(e=>e.books.length>0))}catch{S(n=>n.map(n=>({...n,books:n.books.filter(n=>n.book_id!==e&&n.url!==t&&(n.book_id||n.url)!==r)})).filter(e=>e.books.length>0))}finally{E(null)}}},I=e=>{let t=e.book_id||e.url;t&&M(e=>{let n=new Set(e);return n.has(t)?n.delete(t):n.add(t),n})},L=()=>{let e=[];return x.forEach(t=>{t.books.forEach(t=>{let n=t.book_id||t.url;n&&e.push(n)})}),e},R=()=>{let e=L();j.size===e.length?M(new Set):M(new Set(e))},z=async()=>{if(window.confirm(u===`vi`?`Bạn có chắc chắn muốn xóa ${j.size} truyện khỏi lịch sử không?`:`Are you sure you want to clear ${j.size} selected books from history?`)){w(!0);try{let e=[];x.forEach(t=>{t.books.forEach(t=>{let n=t.book_id||t.url;j.has(n)&&e.push(t)})}),await Promise.all(e.map(e=>g.post(`/api/history/remove`,e.book_id?{book_id:e.book_id}:{url:e.url}))),M(new Set),A(!1),await N()}catch{alert(`Không thể xóa hàng loạt lịch sử.`)}finally{w(!1)}}},B=x.reduce((e,t)=>e+t.books.length,0);if(y)return(0,v.jsx)(e,{children:(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,v.jsx)(f,{className:`w-8 h-8 animate-spin mx-auto mb-2 text-brand-500`}),(0,v.jsx)(`span`,{children:u===`vi`?`Đang tải thông tin...`:u===`en`?`Loading info...`:`正在加载信息...`})]})});if(!p)return(0,v.jsx)(e,{children:(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,v.jsx)(`div`,{className:`w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center text-slate-400 mx-auto mb-4`,children:`🔒`}),(0,v.jsx)(`p`,{className:`text-sm`,children:l.loginToViewHistory})]})});let V=L(),H=j.size===V.length&&V.length>0;return(0,v.jsxs)(e,{children:[(0,v.jsxs)(`div`,{className:`flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6`,children:[(0,v.jsxs)(`div`,{className:`flex flex-wrap items-center gap-3`,children:[(0,v.jsxs)(`h2`,{className:`text-xl font-bold flex items-center gap-2 text-white`,children:[(0,v.jsx)(m,{className:`w-6 h-6 text-brand-400`}),u===`vi`?`Lịch Sử Đọc Truyện`:u===`en`?`Reading History`:`阅读历史`]}),B>0&&(0,v.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,v.jsx)(`button`,{onClick:()=>{A(!k),M(new Set)},className:`px-3 py-1.5 rounded-lg text-xs font-bold transition-all border ${k?`bg-purple-600/25 border-purple-500/50 text-purple-300 hover:bg-purple-600/35`:`bg-white/5 border-white/10 text-slate-300 hover:bg-white/10`}`,children:k?u===`vi`?`Thoát quản lý`:`Exit management`:u===`vi`?`Quản lý lịch sử`:`Manage history`}),k&&(0,v.jsxs)(v.Fragment,{children:[(0,v.jsx)(`button`,{onClick:R,className:`px-3 py-1.5 bg-[#121225] border border-[#1f1f3a] rounded-lg text-xs font-bold text-slate-300 hover:bg-[#1a1a35] transition-all`,children:H?u===`vi`?`Hủy chọn tất cả`:`Deselect all`:u===`vi`?`Chọn tất cả`:`Select all`}),j.size>0&&(0,v.jsxs)(`button`,{onClick:z,className:`inline-flex items-center gap-1.5 px-3 py-1.5 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold shadow-lg shadow-rose-950/20 transition-all active:scale-95 cursor-pointer`,children:[(0,v.jsx)(o,{className:`w-3.5 h-3.5`}),u===`vi`?`Xóa hàng loạt (${j.size})`:`Bulk Delete (${j.size})`]})]})]})]}),(0,v.jsxs)(`div`,{className:`flex items-center gap-2 w-full md:w-auto justify-between md:justify-end`,children:[B>0&&(0,v.jsx)(`p`,{className:`text-xs text-slate-500`,children:u===`vi`?`${B} truyện`:`${B} books`}),(0,v.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,v.jsxs)(`div`,{className:`relative`,children:[(0,v.jsx)(`input`,{type:`text`,value:D,onChange:e=>O(e.target.value),onKeyDown:e=>e.key===`Enter`&&N(),placeholder:u===`vi`?`Tìm lịch sử...`:`Search history...`,className:`bg-[#12122b]/80 border border-[#232342] rounded-xl px-3 py-2 pr-8 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-500 transition-colors w-36 sm:w-44`}),D&&(0,v.jsx)(`button`,{onClick:()=>{O(``),N()},className:`absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white`,children:(0,v.jsx)(h,{className:`w-3 h-3`})})]}),B>0&&!k&&(0,v.jsxs)(`button`,{onClick:P,className:`inline-flex items-center gap-1.5 bg-red-500/10 border border-red-500/25 hover:bg-red-500/20 text-red-400 px-3 py-2 rounded-xl text-xs font-bold transition-all`,children:[(0,v.jsx)(o,{className:`w-3.5 h-3.5`}),u===`vi`?`Xóa tất cả`:`Clear All`]})]})]})]}),C?(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500`,children:[(0,v.jsx)(f,{className:`w-6 h-6 animate-spin mx-auto mb-2`}),(0,v.jsx)(`span`,{children:l.loading})]}):x.length===0?(0,v.jsxs)(`div`,{className:`py-20 text-center text-slate-500 bg-[#121225]/40 border border-dashed border-[#1f1f3a] rounded-2xl`,children:[(0,v.jsx)(m,{className:`w-10 h-10 mx-auto mb-3 text-slate-600`}),(0,v.jsx)(`p`,{className:`text-sm font-semibold`,children:u===`vi`?`Chưa có lịch sử đọc truyện.`:u===`en`?`No reading history yet.`:`暂无阅读历史记录。`}),(0,v.jsx)(`p`,{className:`text-xs mt-1 text-slate-600`,children:u===`vi`?`Click vào bất kỳ truyện nào để bắt đầu theo dõi.`:u===`en`?`Click any book to start tracking.`:`点击任意小说开始跟踪。`})]}):(0,v.jsx)(`div`,{className:`space-y-8`,children:x.map(e=>(0,v.jsxs)(`div`,{className:`space-y-3`,children:[(0,v.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,v.jsx)(i,{className:`w-3.5 h-3.5 text-brand-400`}),(0,v.jsx)(`h3`,{className:`text-xs font-extrabold text-brand-400 uppercase tracking-wider`,children:u===`en`?e.group_name===`Hôm nay`?`Today`:e.group_name===`Hôm qua`?`Yesterday`:e.group_name===`Tháng này`?`This Month`:`Earlier`:u===`zh`?e.group_name===`Hôm nay`?`今天`:e.group_name===`Hôm qua`?`昨天`:e.group_name===`Tháng này`?`本月`:`更早`:e.group_name}),(0,v.jsx)(`div`,{className:`flex-1 h-px bg-[#1f1f3a]`}),(0,v.jsx)(`span`,{className:`text-[10px] text-slate-600 font-semibold`,children:e.books.length})]}),(0,v.jsx)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3`,children:e.books.map((e,i)=>{let o=e.book_id||e.url,s=j.has(o);return(0,v.jsxs)(`div`,{onClick:()=>{k?I(e):e.book_id?b(`/book/${e.book_id}`):e.url&&window.open(e.url,`_blank`)},className:`relative bg-[#121225]/60 border rounded-2xl p-4 flex gap-3 items-start cursor-pointer transition-all hover:scale-[1.01] group ${k?`border-purple-500/30 bg-purple-950/5`:`border-[#1f1f3a] hover:border-brand-500/35`}`,children:[k&&(0,v.jsx)(`div`,{className:`w-5 h-5 rounded-md border flex items-center justify-center shrink-0 self-center transition-all ${s?`bg-purple-600 border-purple-500 text-white shadow shadow-purple-500/25`:`bg-[#0b0b14] border-slate-700 text-slate-500`}`,children:s?(0,v.jsx)(a,{className:`w-3.5 h-3.5`}):(0,v.jsx)(r,{className:`w-3.5 h-3.5`})}),e.cover?(0,v.jsx)(`img`,{src:e.cover,alt:`cover`,className:`w-[48px] h-[66px] object-cover rounded-xl border border-[#1f1f3a] shrink-0`,onError:e=>e.target.remove()}):(0,v.jsx)(`div`,{className:`w-[48px] h-[66px] rounded-xl bg-[#0b0b14] border border-[#1f1f3a] flex items-center justify-center text-slate-600 shrink-0`,children:(0,v.jsx)(t,{className:`w-5 h-5`})}),(0,v.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,v.jsx)(`h4`,{className:`font-bold text-slate-200 text-xs truncate leading-relaxed group-hover:text-brand-400 transition-colors`,children:e.title}),(0,v.jsxs)(`p`,{className:`text-[10px] text-slate-500 mt-0.5 truncate`,children:[u===`vi`?`Tác giả`:u===`en`?`Author`:`作者`,`: `,e.author]}),(0,v.jsx)(`div`,{className:`mt-2 flex items-center gap-1.5 flex-wrap`,children:(0,v.jsxs)(`span`,{className:`inline-flex items-center gap-1 bg-brand-500/10 border border-brand-500/20 text-brand-400 px-2 py-0.5 rounded-full text-[9px] font-semibold max-w-[150px]`,children:[(0,v.jsx)(n,{className:`w-2.5 h-2.5 shrink-0`}),(0,v.jsx)(`span`,{className:`truncate`,children:e.last_chapter})]})}),e.read_date&&(0,v.jsx)(`p`,{className:`text-[9px] text-slate-600 mt-1.5`,children:e.read_date})]}),!k&&(0,v.jsx)(`button`,{onClick:t=>F(e.book_id,e.url,t),disabled:T===o,className:`absolute top-3 right-3 p-1.5 rounded-lg bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white transition-all opacity-100 md:opacity-0 md:group-hover:opacity-100 disabled:opacity-50`,title:u===`vi`?`Xóa khỏi lịch sử`:`Remove from history`,children:T===o?(0,v.jsx)(f,{className:`w-3.5 h-3.5 animate-spin`}):(0,v.jsx)(h,{className:`w-3.5 h-3.5`})})]},o||i)})})]},e.group_name))})]})}export{y as default};