diff --git a/Dockerfile b/Dockerfile index 640aa1d..2f32047 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,18 @@ # 使用国内镜像源加速 -FROM mcr.microsoft.com/playwright/python:v1.40.0-jammy +FROM python:3.10-slim-bullseye # 设置工作目录 WORKDIR /app # 设置环境变量 ENV PYTHONUNBUFFERED=1 -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright ENV TZ=Asia/Shanghai +# 安装 wkhtmltopdf(包含 wkhtmltoimage)与中文字体 +RUN apt-get update && \ + apt-get install -y --no-install-recommends wkhtmltopdf curl fonts-noto-cjk && \ + rm -rf /var/lib/apt/lists/* + # 配置 pip 使用国内镜像源 RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && pip config set install.trusted-host mirrors.aliyun.com @@ -18,14 +22,15 @@ COPY requirements.txt . # 安装Python依赖 RUN pip install --no-cache-dir -r requirements.txt +# 安装 Playwright 浏览器依赖与 Chromium +RUN python -m playwright install --with-deps chromium + # 复制应用程序文件 COPY app.py . COPY database.py . COPY db_pool.py . -COPY playwright_automation.py . COPY api_browser.py . COPY browser_pool_worker.py . -COPY browser_installer.py . COPY password_utils.py . COPY crypto_utils.py . COPY task_checkpoint.py . @@ -39,6 +44,7 @@ COPY routes/ ./routes/ COPY services/ ./services/ COPY realtime/ ./realtime/ COPY db/ ./db/ +COPY security/ ./security/ COPY templates/ ./templates/ COPY static/ ./static/ diff --git a/README.md b/README.md index e6d0c17..20b0318 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ ## 项目简介 -本项目是一个 **Docker 容器化应用**,使用 Flask + Playwright + SQLite 构建,提供: +本项目是一个 **Docker 容器化应用**,使用 Flask + Requests + wkhtmltopdf + SQLite 构建,提供: - 多用户注册登录系统 -- 浏览器自动化任务 +- 自动化任务(HTTP 模拟) - 定时任务调度 - 截图管理 - VIP用户管理 @@ -22,7 +22,8 @@ - **后端**: Python 3.8+, Flask - **数据库**: SQLite -- **自动化**: Playwright (Chromium) +- **自动化**: Requests + BeautifulSoup +- **截图**: wkhtmltopdf / wkhtmltoimage - **容器化**: Docker + Docker Compose - **前端**: HTML + JavaScript + Socket.IO @@ -39,10 +40,8 @@ zsglpt/ ├── database.py # 数据库稳定门面(对外 API) ├── db/ # DB 分域实现 + schema/migrations ├── db_pool.py # 数据库连接池 -├── playwright_automation.py # Playwright 自动化 ├── api_browser.py # Requests 自动化(主浏览流程) -├── browser_pool_worker.py # 截图 WorkerPool(浏览器复用) -├── browser_installer.py # 浏览器安装检查 +├── browser_pool_worker.py # 截图 WorkerPool ├── app_config.py # 配置管理 ├── app_logger.py # 日志系统 ├── app_security.py # 安全模块 @@ -122,8 +121,8 @@ cd /www/wwwroot/zsgpt2 ### 步骤4: 创建必要的目录 ```bash -mkdir -p data logs 截图 playwright -chmod 777 data logs 截图 playwright +mkdir -p data logs 截图 +chmod 777 data logs 截图 ``` ### 步骤5: 构建并启动Docker容器 @@ -447,19 +446,19 @@ docker-compose down docker-compose up -d ``` -### 5. 浏览器下载失败 +### 5. 截图工具未安装 -**问题**: Playwright浏览器下载失败 +**问题**: wkhtmltoimage 命令不存在 **解决方案**: ```bash # 进入容器手动安装 docker exec -it knowledge-automation-multiuser bash -playwright install chromium +apt-get update +apt-get install -y wkhtmltopdf -# 或使用国内镜像 -export PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/ -playwright install chromium +# 验证安装 +wkhtmltoimage --version ``` --- @@ -631,7 +630,19 @@ docker logs knowledge-automation-multiuser | grep "数据库" |--------|------|--------| | TZ | 时区 | Asia/Shanghai | | PYTHONUNBUFFERED | Python输出缓冲 | 1 | -| PLAYWRIGHT_BROWSERS_PATH | 浏览器路径 | /ms-playwright | +| WKHTMLTOIMAGE_PATH | wkhtmltoimage 可执行文件路径 | 自动探测 | +| WKHTMLTOIMAGE_JS_DELAY_MS | JS 等待时间(毫秒) | 3000 | +| WKHTMLTOIMAGE_WIDTH | 截图宽度 | 1920 | +| WKHTMLTOIMAGE_HEIGHT | 截图高度(视口高度) | 1080 | +| WKHTMLTOIMAGE_FULL_PAGE | 是否输出全页截图(忽略视口高度/裁剪) | 0 | +| WKHTMLTOIMAGE_ZOOM | 渲染缩放比例 | 1.0 | +| WKHTMLTOIMAGE_CROP_WIDTH | 裁剪宽度(0 表示不裁剪) | 默认跟随截图宽度 | +| WKHTMLTOIMAGE_CROP_HEIGHT | 裁剪高度(0 表示不裁剪) | 默认跟随截图高度 | +| WKHTMLTOIMAGE_CROP_X | 裁剪起点 X | 0 | +| WKHTMLTOIMAGE_CROP_Y | 裁剪起点 Y | 0 | +| WKHTMLTOIMAGE_QUALITY | JPG截图质量 | 95 | +| WKHTMLTOIMAGE_TIMEOUT_SECONDS | 截图超时时间(秒) | 60 | +| WKHTMLTOIMAGE_USER_AGENT | 截图使用的 UA | Chrome 120 | --- @@ -641,13 +652,13 @@ docker logs knowledge-automation-multiuser | grep "数据库" - **项目名称**: 知识管理平台自动化工具 - **版本**: Docker 多用户版 -- **技术栈**: Python + Flask + Playwright + SQLite + Docker +- **技术栈**: Python + Flask + Requests + wkhtmltopdf + SQLite + Docker ### 常用文档链接 - [Docker 官方文档](https://docs.docker.com/) - [Flask 官方文档](https://flask.palletsprojects.com/) -- [Playwright 官方文档](https://playwright.dev/python/) +- [wkhtmltopdf 官方文档](https://wkhtmltopdf.org/) ### 故障排查 @@ -683,8 +694,8 @@ ssh root@your-ip # 3. 进入目录并创建必要目录 cd /www/wwwroot/zsgpt2 -mkdir -p data logs 截图 playwright -chmod 777 data logs 截图 playwright +mkdir -p data logs 截图 +chmod 777 data logs 截图 # 4. 启动容器 docker-compose up -d diff --git a/admin-frontend/src/api/announcements.js b/admin-frontend/src/api/announcements.js index 111020e..6896159 100644 --- a/admin-frontend/src/api/announcements.js +++ b/admin-frontend/src/api/announcements.js @@ -10,6 +10,13 @@ export async function createAnnouncement(payload) { return data } +export async function uploadAnnouncementImage(file) { + const formData = new FormData() + formData.append('file', file) + const { data } = await api.post('/announcements/upload_image', formData) + return data +} + export async function activateAnnouncement(id) { const { data } = await api.post(`/announcements/${id}/activate`) return data @@ -24,4 +31,3 @@ export async function deleteAnnouncement(id) { const { data } = await api.delete(`/announcements/${id}`) return data } - diff --git a/admin-frontend/src/api/browser_pool.js b/admin-frontend/src/api/browser_pool.js new file mode 100644 index 0000000..6c42ed7 --- /dev/null +++ b/admin-frontend/src/api/browser_pool.js @@ -0,0 +1,7 @@ +import { api } from './client' + +export async function fetchBrowserPoolStats() { + const { data } = await api.get('/browser_pool/stats') + return data +} + diff --git a/admin-frontend/src/api/kdocs.js b/admin-frontend/src/api/kdocs.js new file mode 100644 index 0000000..c7ceed6 --- /dev/null +++ b/admin-frontend/src/api/kdocs.js @@ -0,0 +1,17 @@ +import { api } from './client' + +export async function fetchKdocsStatus(params = {}) { + const { data } = await api.get('/kdocs/status', { params }) + return data +} + +export async function fetchKdocsQr(payload = {}) { + const body = { force: true, ...payload } + const { data } = await api.post('/kdocs/qr', body) + return data +} + +export async function clearKdocsLogin() { + const { data } = await api.post('/kdocs/clear-login', {}) + return data +} diff --git a/admin-frontend/src/api/security.js b/admin-frontend/src/api/security.js new file mode 100644 index 0000000..a7aed95 --- /dev/null +++ b/admin-frontend/src/api/security.js @@ -0,0 +1,63 @@ +import { api } from './client' + +export async function getDashboard() { + const { data } = await api.get('/admin/security/dashboard') + return data +} + +export async function getThreats(params) { + const { data } = await api.get('/admin/security/threats', { params }) + return data +} + +export async function getBannedIps() { + const { data } = await api.get('/admin/security/banned-ips') + return data +} + +export async function getBannedUsers() { + const { data } = await api.get('/admin/security/banned-users') + return data +} + +export async function banIp(payload) { + const { data } = await api.post('/admin/security/ban-ip', payload) + return data +} + +export async function unbanIp(ip) { + const { data } = await api.post('/admin/security/unban-ip', { ip }) + return data +} + +export async function banUser(payload) { + const { data } = await api.post('/admin/security/ban-user', payload) + return data +} + +export async function unbanUser(userId) { + const { data } = await api.post('/admin/security/unban-user', { user_id: userId }) + return data +} + +export async function getIpRisk(ip) { + const safeIp = encodeURIComponent(String(ip || '').trim()) + const { data } = await api.get(`/admin/security/ip-risk/${safeIp}`) + return data +} + +export async function clearIpRisk(ip) { + const { data } = await api.post('/admin/security/ip-risk/clear', { ip }) + return data +} + +export async function getUserRisk(userId) { + const safeUserId = encodeURIComponent(String(userId || '').trim()) + const { data } = await api.get(`/admin/security/user-risk/${safeUserId}`) + return data +} + +export async function cleanup() { + const { data } = await api.post('/admin/security/cleanup', {}) + return data +} diff --git a/admin-frontend/src/layouts/AdminLayout.vue b/admin-frontend/src/layouts/AdminLayout.vue index 8376502..270baa4 100644 --- a/admin-frontend/src/layouts/AdminLayout.vue +++ b/admin-frontend/src/layouts/AdminLayout.vue @@ -7,6 +7,7 @@ import { ChatLineSquare, Document, List, + Lock, Message, Setting, Tools, @@ -15,7 +16,6 @@ import { import { api } from '../api/client' import { fetchFeedbackStats } from '../api/feedbacks' -import { fetchPasswordResets } from '../api/passwordResets' import { fetchSystemStats } from '../api/stats' const route = useRoute() @@ -33,15 +33,11 @@ async function refreshStats() { } const loadingBadges = ref(false) -const pendingResetsCount = ref(0) const pendingFeedbackCount = ref(0) let badgeTimer async function refreshNavBadges(partial = null) { if (partial && typeof partial === 'object') { - if (Object.prototype.hasOwnProperty.call(partial, 'pendingResets')) { - pendingResetsCount.value = Number(partial.pendingResets || 0) - } if (Object.prototype.hasOwnProperty.call(partial, 'pendingFeedbacks')) { pendingFeedbackCount.value = Number(partial.pendingFeedbacks || 0) } @@ -52,18 +48,8 @@ async function refreshNavBadges(partial = null) { loadingBadges.value = true try { - const [resetsResult, feedbackResult] = await Promise.allSettled([ - fetchPasswordResets(), - fetchFeedbackStats(), - ]) - - if (resetsResult.status === 'fulfilled') { - pendingResetsCount.value = Array.isArray(resetsResult.value) ? resetsResult.value.length : 0 - } - - if (feedbackResult.status === 'fulfilled') { - pendingFeedbackCount.value = Number(feedbackResult.value?.pending || 0) - } + const feedbackResult = await fetchFeedbackStats() + pendingFeedbackCount.value = Number(feedbackResult?.pending || 0) } finally { loadingBadges.value = false } @@ -99,11 +85,12 @@ onBeforeUnmount(() => { const menuItems = [ { path: '/reports', label: '报表', icon: Document }, - { path: '/users', label: '用户', icon: User, badgeKey: 'resets' }, + { path: '/users', label: '用户', icon: User }, { path: '/feedbacks', label: '反馈', icon: ChatLineSquare, badgeKey: 'feedbacks' }, { path: '/logs', label: '任务日志', icon: List }, { path: '/announcements', label: '公告', icon: Bell }, { path: '/email', label: '邮件', icon: Message }, + { path: '/security', label: '安全防护', icon: Lock }, { path: '/system', label: '系统配置', icon: Tools }, { path: '/settings', label: '设置', icon: Setting }, ] @@ -112,7 +99,6 @@ const activeMenu = computed(() => route.path) function badgeFor(item) { if (!item?.badgeKey) return 0 - if (item.badgeKey === 'resets') return Number(pendingResetsCount.value || 0) if (item.badgeKey === 'feedbacks') { return Number(pendingFeedbackCount.value || 0) } diff --git a/admin-frontend/src/pages/AnnouncementsPage.vue b/admin-frontend/src/pages/AnnouncementsPage.vue index 330193b..e9b9254 100644 --- a/admin-frontend/src/pages/AnnouncementsPage.vue +++ b/admin-frontend/src/pages/AnnouncementsPage.vue @@ -1,6 +1,7 @@ diff --git a/admin-frontend/src/router/index.js b/admin-frontend/src/router/index.js index bf97ed6..93f6799 100644 --- a/admin-frontend/src/router/index.js +++ b/admin-frontend/src/router/index.js @@ -8,6 +8,7 @@ const FeedbacksPage = () => import('../pages/FeedbacksPage.vue') const LogsPage = () => import('../pages/LogsPage.vue') const AnnouncementsPage = () => import('../pages/AnnouncementsPage.vue') const EmailPage = () => import('../pages/EmailPage.vue') +const SecurityPage = () => import('../pages/SecurityPage.vue') const SystemPage = () => import('../pages/SystemPage.vue') const SettingsPage = () => import('../pages/SettingsPage.vue') @@ -25,6 +26,7 @@ const routes = [ { path: '/logs', name: 'logs', component: LogsPage }, { path: '/announcements', name: 'announcements', component: AnnouncementsPage }, { path: '/email', name: 'email', component: EmailPage }, + { path: '/security', name: 'security', component: SecurityPage }, { path: '/system', name: 'system', component: SystemPage }, { path: '/settings', name: 'settings', component: SettingsPage }, ], diff --git a/api_browser.py b/api_browser.py index c059fff..e88cd34 100755 --- a/api_browser.py +++ b/api_browser.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """ API 浏览器 - 用纯 HTTP 请求实现浏览功能 -比 Playwright 快 30-60 倍 +比传统浏览器自动化快 30-60 倍 """ import requests @@ -44,6 +44,27 @@ except Exception: _API_DIAGNOSTIC_SLOW_MS = max(0, _API_DIAGNOSTIC_SLOW_MS) _cookie_domain_fallback = urlsplit(BASE_URL).hostname or "postoa.aidunsoft.com" +_COOKIE_JAR_MAX_AGE_SECONDS = 24 * 60 * 60 + + +def get_cookie_jar_path(username: str) -> str: + """获取截图用的 cookies 文件路径(Netscape Cookie 格式)""" + import hashlib + + os.makedirs(COOKIES_DIR, exist_ok=True) + filename = hashlib.sha256(username.encode()).hexdigest()[:32] + ".cookies.txt" + return os.path.join(COOKIES_DIR, filename) + + +def is_cookie_jar_fresh(cookie_path: str, max_age_seconds: int = _COOKIE_JAR_MAX_AGE_SECONDS) -> bool: + """判断 cookies 文件是否存在且未过期""" + if not cookie_path or not os.path.exists(cookie_path): + return False + try: + file_age = time.time() - os.path.getmtime(cookie_path) + return file_age <= max(0, int(max_age_seconds or 0)) + except Exception: + return False _api_browser_instances: "weakref.WeakSet[APIBrowser]" = weakref.WeakSet() @@ -102,38 +123,37 @@ class APIBrowser: """记录日志""" if self.log_callback: self.log_callback(message) - def save_cookies_for_playwright(self, username: str): - """保存cookies供Playwright使用""" - import os - import json - import hashlib - - os.makedirs(COOKIES_DIR, exist_ok=True) - - # 安全修复:使用SHA256代替MD5作为文件名哈希 - filename = hashlib.sha256(username.encode()).hexdigest()[:32] + '.json' - cookies_path = os.path.join(COOKIES_DIR, filename) - + def save_cookies_for_screenshot(self, username: str): + """保存 cookies 供 wkhtmltoimage 使用(Netscape Cookie 格式)""" + cookies_path = get_cookie_jar_path(username) try: - # 获取requests session的cookies - cookies_list = [] + lines = [ + "# Netscape HTTP Cookie File", + "# This file was generated by zsglpt", + ] for cookie in self.session.cookies: - cookies_list.append({ - 'name': cookie.name, - 'value': cookie.value, - 'domain': cookie.domain or _cookie_domain_fallback, - 'path': cookie.path or '/', - }) - - # Playwright storage_state 格式 - storage_state = { - 'cookies': cookies_list, - 'origins': [] - } - - with open(cookies_path, 'w', encoding='utf-8') as f: - json.dump(storage_state, f) - + domain = cookie.domain or _cookie_domain_fallback + include_subdomains = "TRUE" if domain.startswith(".") else "FALSE" + path = cookie.path or "/" + secure = "TRUE" if getattr(cookie, "secure", False) else "FALSE" + expires = int(getattr(cookie, "expires", 0) or 0) + lines.append( + "\t".join( + [ + domain, + include_subdomains, + path, + secure, + str(expires), + cookie.name, + cookie.value, + ] + ) + ) + + with open(cookies_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + self.log(f"[API] Cookies已保存供截图使用") return True except Exception as e: diff --git a/app-frontend/src/api/auth.js b/app-frontend/src/api/auth.js index cfbbfbb..ca5f962 100644 --- a/app-frontend/src/api/auth.js +++ b/app-frontend/src/api/auth.js @@ -30,11 +30,6 @@ export async function forgotPassword(payload) { return data } -export async function requestPasswordReset(payload) { - const { data } = await publicApi.post('/reset_password_request', payload) - return data -} - export async function confirmPasswordReset(payload) { const { data } = await publicApi.post('/reset-password-confirm', payload) return data diff --git a/app-frontend/src/api/settings.js b/app-frontend/src/api/settings.js index de330d5..fe66ab4 100644 --- a/app-frontend/src/api/settings.js +++ b/app-frontend/src/api/settings.js @@ -30,3 +30,12 @@ export async function changePassword(payload) { return data } +export async function fetchKdocsSettings() { + const { data } = await publicApi.get('/user/kdocs') + return data +} + +export async function updateKdocsSettings(payload) { + const { data } = await publicApi.post('/user/kdocs', payload) + return data +} diff --git a/app-frontend/src/layouts/AppLayout.vue b/app-frontend/src/layouts/AppLayout.vue index 91113a9..9ed3f86 100644 --- a/app-frontend/src/layouts/AppLayout.vue +++ b/app-frontend/src/layouts/AppLayout.vue @@ -11,10 +11,13 @@ import { changePassword, fetchEmailNotify, fetchUserEmail, + fetchKdocsSettings, unbindEmail, + updateKdocsSettings, updateEmailNotify, } from '../api/settings' import { useUserStore } from '../stores/user' +import { validateStrongPassword } from '../utils/password' const route = useRoute() const router = useRouter() @@ -28,6 +31,56 @@ const announcementOpen = ref(false) const announcement = ref(null) const announcementLoading = ref(false) +const announcementPageToken = (() => { + try { + const timeOrigin = window.performance?.timeOrigin + if (typeof timeOrigin === 'number' && Number.isFinite(timeOrigin)) return String(timeOrigin) + } catch { + // ignore + } + return String(Date.now()) +})() + +function announcementOnceKey(announcementId) { + return `announcement_closed_once_${announcementId}` +} + +function announcementPermanentKey(announcementId) { + return `announcement_closed_${announcementId}` +} + +function wasAnnouncementClosedOnce(announcementId) { + try { + return window.sessionStorage.getItem(announcementOnceKey(announcementId)) === announcementPageToken + } catch { + return false + } +} + +function wasAnnouncementClosedPermanently(announcementId) { + try { + return window.localStorage.getItem(announcementPermanentKey(announcementId)) === '1' + } catch { + return false + } +} + +function markAnnouncementClosedOnce(announcementId) { + try { + window.sessionStorage.setItem(announcementOnceKey(announcementId), announcementPageToken) + } catch { + // ignore + } +} + +function markAnnouncementClosedPermanently(announcementId) { + try { + window.localStorage.setItem(announcementPermanentKey(announcementId), '1') + } catch { + // ignore + } +} + const feedbackOpen = ref(false) const feedbackTab = ref('new') const feedbackSubmitting = ref(false) @@ -60,6 +113,10 @@ const passwordForm = reactive({ confirm_password: '', }) +const kdocsLoading = ref(false) +const kdocsSaving = ref(false) +const kdocsUnitValue = ref('') + function syncIsMobile() { isMobile.value = Boolean(mediaQuery?.matches) if (!isMobile.value) drawerOpen.value = false @@ -180,7 +237,7 @@ async function openSettings() { } async function loadSettings() { - await Promise.all([loadEmailInfo(), loadEmailNotify()]) + await Promise.all([loadEmailInfo(), loadEmailNotify(), loadKdocsSettings()]) } async function loadEmailInfo() { @@ -211,6 +268,30 @@ async function loadEmailNotify() { } } +async function loadKdocsSettings() { + kdocsLoading.value = true + try { + const data = await fetchKdocsSettings() + kdocsUnitValue.value = data?.kdocs_unit || '' + } catch { + kdocsUnitValue.value = '' + } finally { + kdocsLoading.value = false + } +} + +async function saveKdocsSettings() { + kdocsSaving.value = true + try { + await updateKdocsSettings({ kdocs_unit: kdocsUnitValue.value.trim() }) + ElMessage.success('已更新表格县区设置') + } catch { + // handled by interceptor + } finally { + kdocsSaving.value = false + } +} + async function onBindEmail() { const email = bindEmailValue.value.trim().toLowerCase() if (!email) { @@ -292,8 +373,9 @@ async function onChangePassword() { ElMessage.error('请填写完整信息') return } - if (String(newPassword).length < 6) { - ElMessage.error('新密码至少6位') + const passwordCheck = validateStrongPassword(newPassword) + if (!passwordCheck.ok) { + ElMessage.error(passwordCheck.message) return } if (newPassword !== confirmPassword) { @@ -327,8 +409,8 @@ async function loadAnnouncement() { const ann = data?.announcement if (!ann?.id) return - const sessionKey = `announcement_closed_${ann.id}` - if (window.sessionStorage.getItem(sessionKey) === '1') return + if (wasAnnouncementClosedPermanently(ann.id)) return + if (wasAnnouncementClosedOnce(ann.id)) return announcement.value = ann announcementOpen.value = true @@ -341,7 +423,7 @@ async function loadAnnouncement() { function closeAnnouncementOnce() { const ann = announcement.value - if (ann?.id) window.sessionStorage.setItem(`announcement_closed_${ann.id}`, '1') + if (ann?.id) markAnnouncementClosedOnce(ann.id) announcementOpen.value = false } @@ -351,6 +433,7 @@ async function dismissAnnouncementPermanently() { announcementOpen.value = false return } + markAnnouncementClosedPermanently(ann.id) try { const res = await dismissAnnouncement(ann.id) if (res?.success) ElMessage.success('已永久关闭') @@ -433,6 +516,9 @@ async function dismissAnnouncementPermanently() {
{{ announcement?.content || '' }}
+
+ 公告图片 +