diff --git a/AGENTS.md b/AGENTS.md index 24cc836..69a4653 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` | | Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` | | Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support | +| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery | | Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` | | Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` | | Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` | @@ -54,7 +55,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a Use `tests/test_.py` and `test_`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`. -Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, and `visitor.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests. +Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests. ## Commit & Pull Request Guidelines diff --git a/README.md b/README.md index 8cc878d..621ba48 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,8 @@ data: {"d": 1} 游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。 +未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。 + 快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入: ```powershell @@ -260,7 +262,7 @@ data: {"d": 1} 首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。 -普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。 +普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。 ## 当前限制 diff --git a/docs/architecture.md b/docs/architecture.md index 7c581a8..c522453 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,6 +88,8 @@ tests/ # 按业务模块组织的回归 - 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。 - 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。 - 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。 +- 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。 +- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。 - 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。 - 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。 diff --git a/logistics_chat/file_service.py b/logistics_chat/file_service.py index a9ad101..2850a6d 100644 --- a/logistics_chat/file_service.py +++ b/logistics_chat/file_service.py @@ -2,21 +2,35 @@ import os import secrets +import re import time from datetime import datetime +from urllib.parse import unquote from .config import get_setting def cleanup_uploads(): - """删除 uploads/ 里 7 天前的图片""" + """Keep attachments referenced by chat history; expire unused uploads after 7 days.""" try: udir = get_setting('UPLOAD_DIR') if not os.path.isdir(udir): return + from .db import connect + connection = connect() + try: + referenced = set() + rows = connection.execute("SELECT content FROM messages WHERE content LIKE '%/uploads/%' " + "UNION ALL SELECT content FROM human_messages WHERE content LIKE '%/uploads/%'") + for (content,) in rows: + referenced.update(unquote(name) for name in re.findall(r'/uploads/([A-Za-z0-9_.%-]+)', content or '')) + finally: + connection.close() cutoff = time.time() - 7 * 86400 for f in os.listdir(udir): + if f in referenced: + continue fp = os.path.join(udir, f) if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff: os.remove(fp) diff --git a/logistics_chat/human_service.py b/logistics_chat/human_service.py index fcb5b73..3be083b 100644 --- a/logistics_chat/human_service.py +++ b/logistics_chat/human_service.py @@ -1,18 +1,21 @@ """Human conversation lifecycle; typing state is supplied by the app.""" import time -from datetime import datetime +from datetime import datetime, timedelta, timezone from .db import connect +SERVICE_TIMEZONE = timezone(timedelta(hours=8)) + def human_request(data): conv_id = data.get('conv_id') if not conv_id: return ({'ok': False, 'error': 'no conv'}, 400) - if not 8 <= datetime.now().hour < 22: - return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'} - now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + service_time = datetime.now(SERVICE_TIMEZONE) + if not 8 <= service_time.hour < 22: + return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'} + now = service_time.strftime('%Y-%m-%d %H:%M:%S') conn = connect() conn.execute('BEGIN IMMEDIATE') if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone(): diff --git a/logistics_chat/routes/files.py b/logistics_chat/routes/files.py index fb69d66..cd508f1 100644 --- a/logistics_chat/routes/files.py +++ b/logistics_chat/routes/files.py @@ -1,12 +1,17 @@ """HTTP adapters for files; business logic lives in services.""" -from flask import Blueprint, abort, request, send_from_directory +from flask import Blueprint, render_template, request, send_from_directory +from werkzeug.exceptions import NotFound from .. import file_service, visitor_service from ..config import get_setting bp = Blueprint('files', __name__) + +def unavailable(staff=False): + return render_template('attachment_unavailable.html', staff=staff), 404, {'Cache-Control':'private, no-store'} + @bp.route('/api/upload', methods=['POST']) def upload_file(): return file_service.upload_file(request.files) @@ -15,14 +20,20 @@ def upload_file(): @bp.route('/uploads/') def uploaded_file(name): if not visitor_service.owns_upload(name): - abort(404) - response = send_from_directory(get_setting('UPLOAD_DIR'), name) + return unavailable() + try: + response = send_from_directory(get_setting('UPLOAD_DIR'), name) + except NotFound: + return unavailable() response.headers['Cache-Control'] = 'private, no-store' return response @bp.route('/api/staff/uploads/') def staff_uploaded_file(name): - response = send_from_directory(get_setting('UPLOAD_DIR'), name) + try: + response = send_from_directory(get_setting('UPLOAD_DIR'), name) + except NotFound: + return unavailable(staff=True) response.headers['Cache-Control'] = 'private, no-store' return response diff --git a/static/css/theme.css b/static/css/theme.css index 62e16bf..56acd66 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -81,6 +81,7 @@ svg { display: block; flex-shrink: 0; } .emoji-picker { position: absolute; bottom: calc(100% + 12px); left: 0; z-index: 20; display: grid; grid-template-columns: repeat(5, 1fr); width: 230px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); } .emoji-picker button { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; } .emoji-picker button:hover { background: var(--surface-hover); } +.attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; } ::-webkit-scrollbar { width: 5px; height: 5px; } diff --git a/static/js/admin.js b/static/js/admin.js index 97187d9..202a466 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -11,6 +11,7 @@ var typeTimer = null; var toolsOpen = false; var toolsOpener = null; var replyInput = document.getElementById("replyText"); +SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。'); function escapeText(value){ return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){ diff --git a/static/js/chat.js b/static/js/chat.js index 6c169a8..41d511e 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -6,6 +6,28 @@ const sendBtn = document.getElementById("send-button"); const uploadBtn = document.getElementById("upload-btn"); const uploadInput = document.getElementById("upload-input"); const composerStatus = document.getElementById("composer-status"); +const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage}); +let draftTimer = null; +let draftSnapshot = null; +function flushDraft(){ + if(draftTimer !== null){ clearTimeout(draftTimer); draftTimer = null; } + const saved = drafts.write(currentConvId, inputDom.value); + drafts.active(currentConvId); + if(!saved && inputDom.value && !composerStatus.textContent) setComposerStatus(t('draftSaveFailed'), true); +} +function restoreDraft(){ + inputDom.value = drafts.read(currentConvId); + draftSnapshot = String(currentConvId) + '\n' + inputDom.value; + drafts.active(currentConvId); + autoGrow(); +} +function queueDraft(){ + const snapshot = String(currentConvId) + '\n' + inputDom.value; + if(snapshot === draftSnapshot) return; + draftSnapshot = snapshot; + if(draftTimer !== null) clearTimeout(draftTimer); + draftTimer = setTimeout(flushDraft, 250); +} function focusComposer(force){ if(force || !isNarrow() || document.activeElement === inputDom){ inputDom.focus({preventScroll:true}); @@ -29,6 +51,7 @@ let humanSendingToken = null; let humanClosing = false; window.humanSid = 0; const emojiPicker = SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); }); +SupportUI.initAttachments(chatDom, ()=>t('attachmentUnavailable')); document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); }; function setComposerStatus(message, isError){ @@ -217,7 +240,7 @@ const I18N = { busyRetry: "服务繁忙,请稍后重试", sessionFail: "会话创建失败,请确认服务已启动", autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。订单查询将在客户账号登录并关联后开放。", - humanHours: "人工客服工作时间:每天 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。", + humanHours: "人工客服工作时间:每天北京时间 8:00-22:00。当前为非工作时间,请留言,工作时间会尽快回复您。", offlinePrompt: "请输入您的留言:", offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。", humanClosed: "人工客服已结束,您可以继续向 AI 提问。", @@ -233,6 +256,10 @@ const I18N = { humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。", humanReturnFailed: "暂时无法结束人工会话,请重试。", historyLoading: "正在加载这段会话,请稍候…", + historyLoadFailed: "会话未加载成功,请重新选择该会话重试,或新建对话。", + draftRestored: "已恢复这段会话的未发送草稿。", + draftSaveFailed: "草稿暂时无法保存到当前浏览器,刷新前请保留输入内容。", + attachmentUnavailable: "图片暂时无法访问,请重新发送附件。", recalled: "该消息已撤回", ratingQuestion: "本次服务满意吗?", ratingThanks: "感谢您的评价!", @@ -330,7 +357,7 @@ const I18N = { busyRetry: "Service busy, please try again later", sessionFail: "Failed to create session, please check the service is running", autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support. Order lookup will be available after signing in and linking a customer account.", - humanHours: "Our team is available daily, 08:00–22:00. Please leave a message and we will reply during service hours.", + humanHours: "Our team is available daily, 08:00–22:00 China Standard Time (UTC+8). Please leave a message and we will reply during service hours.", offlinePrompt: "Please leave your message:", offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).", humanClosed: "This support session has ended. You can continue chatting with the AI assistant.", @@ -346,6 +373,10 @@ const I18N = { humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.", humanReturnFailed: "Could not end the support session. Please try again.", historyLoading: "Loading this conversation. Please wait…", + historyLoadFailed: "Conversation could not load. Select it again to retry, or start a new chat.", + draftRestored: "Your unsent draft has been restored.", + draftSaveFailed: "Your browser could not save this draft. Keep a copy before refreshing.", + attachmentUnavailable: "Image unavailable. Please upload the attachment again.", recalled: "This message was recalled", ratingQuestion: "How was your support experience?", ratingThanks: "Thank you for your feedback!", @@ -653,11 +684,12 @@ clearAllBtn.onclick = function(){ if(newChatBtn){ newChatBtn.onclick = function(){ if(openingConv === 'clearing') return; + const alreadyNew = currentConvId === null; resetConversation(null); - inputDom.value = ""; + if(alreadyNew) inputDom.value = ""; autoGrow(); clearAttachment(); - setComposerStatus(""); + setComposerStatus(inputDom.value ? t('draftRestored') : ''); chatDom.innerHTML = welcomeHTML(); chatDom.scrollTop = 0; followLatest = true; @@ -673,10 +705,13 @@ function loadHistory(){ historyList = data; renderSideHistory(); renderRecentChat(); - }).catch(function(){ /* 保留上次成功加载的历史,避免网络错误清空侧栏。 */ }); + return data; + }).catch(function(){ return null; /* 保留上次成功加载的历史。 */ }); } function removeCachedHistory(id){ + if(id === undefined){ drafts.clear(); flushDraft(); } + else drafts.remove(id); historyRequestVersion += 1; historyList = id === undefined ? [] : historyList.filter(item => item.id !== id); renderSideHistory(); @@ -823,6 +858,8 @@ function ensureConv(){ throw error; } currentConvId = data.id; + drafts.remove('new'); + flushDraft(); if(data.welcome) addMsg(t('autoWelcome'), false); return currentConvId; }).finally(function(){ if(creatingConv === pending) creatingConv = null; }); @@ -831,6 +868,7 @@ function ensureConv(){ } function resetConversation(id){ + flushDraft(); emojiPicker.close(); if(voiceInput) voiceInput.cancel(); if(cancelActive){ cancelActive(); cancelActive = null; } @@ -839,6 +877,7 @@ function resetConversation(id){ clearAttachment(); stopHumanMode(); currentConvId = id; + restoreDraft(); openingConv = null; creatingConv = null; sendPreparing = false; @@ -856,6 +895,7 @@ function openConversation(id){ const version = resetConversation(id); if(isNarrow()){ sideOpen = false; renderSidebar(); } openingConv = id; + chatDom.innerHTML = ''; setComposerStatus(t('historyLoading')); renderSideHistory(); requestJson("/api/get_messages?conv_id=" + id) @@ -863,7 +903,7 @@ function openConversation(id){ if(version !== conversationVersion) return; if(!Array.isArray(msgs)) throw new Error('History failed'); openingConv = null; - setComposerStatus(''); + setComposerStatus(inputDom.value ? t('draftRestored') : ''); chatDom.innerHTML = ""; if(!msgs.length){ chatDom.innerHTML = welcomeHTML(); @@ -886,8 +926,8 @@ function openConversation(id){ requestAnimationFrame(function(){ scrollChatToEnd(true); }); }).catch(function(){ if(version !== conversationVersion) return; - openingConv = null; - setComposerStatus(t('networkFail'), true); + openingConv = 'failed'; + setComposerStatus(t('historyLoadFailed'), true); }); } @@ -1047,7 +1087,30 @@ function addTrackCard(card, timeStr){ return bubble; } -loadHistory(); +const restoreId = drafts.active(); +currentConvId = restoreId === 'new' ? null : Number(restoreId); +restoreDraft(); +if(currentConvId){ + const id = currentConvId, version = conversationVersion, initialDraft = inputDom.value; + openingConv = 'restoring'; + loadHistory().then(function(history){ + if(version !== conversationVersion || currentConvId !== id || openingConv !== 'restoring') return; + if(Array.isArray(history) && !history.some(item=>item.id === id)){ + if(inputDom.value !== initialDraft){ + openingConv = 'failed'; + flushDraft(); + setComposerStatus(t('historyLoadFailed'), true); + return; + } + drafts.remove(id); + currentConvId = null; + restoreDraft(); + openingConv = null; + return; + } + openConversation(id); + }); +}else loadHistory(); window.fillInput = function(words){ inputDom.value = words; @@ -1113,11 +1176,10 @@ async function humanRequest(path, data){ window.showHuman = async function(){ if(voiceInput) voiceInput.cancel(); if(openingConv === 'clearing') return; + if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; } if(humanRequestToken || humanClosing) return; if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; } if(window.humanSid) stopHumanMode(); - const hour = new Date().getHours(); - if(hour < 8 || hour >= 22) return leaveOfflineMessage(); const token = {version:conversationVersion}; humanRequestToken = token; if(cancelActive){ cancelActive(); cancelActive = null; } @@ -1530,7 +1592,7 @@ async function doSend(retryText){ var fullContent = sendWord; if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl; setComposerStatus(""); - if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; } + if(openingConv !== null){ setComposerStatus(t(openingConv === 'failed' ? 'historyLoadFailed' : 'historyLoading'), true); return; } if(humanRequestToken || humanClosing){ updateHumanMode(); return; } // 人工客服模式:直接发给客服,不走AI if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent); @@ -1764,6 +1826,7 @@ sendBtn.addEventListener("click", function(){ // ===== 多行输入框:自动增高 ===== function autoGrow(){ + queueDraft(); if(!inputDom.value){ inputDom.style.height = ''; return; @@ -1775,6 +1838,8 @@ function autoGrow(){ inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px"; } inputDom.addEventListener("input", autoGrow); +window.addEventListener('pagehide', flushDraft); +document.addEventListener('visibilitychange', function(){ if(document.hidden) flushDraft(); }); inputDom.addEventListener("keydown",(e)=>{ // Enter 发送,Shift+Enter 换行 diff --git a/static/js/drafts.js b/static/js/drafts.js new file mode 100644 index 0000000..be9b055 --- /dev/null +++ b/static/js/drafts.js @@ -0,0 +1,77 @@ +// Text drafts stay in this visitor's browser, independently for each conversation. +var ChatDrafts = { + create(options){ + const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null; + const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null; + const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null; + const memory = new Map(); + const dirty = new Set(); + let current = 'new'; + function id(value){ + if(value === null || value === 'new') return 'new'; + const key = String(value); + return /^[1-9][0-9]{0,18}$/.test(key) ? key : null; + } + function storage(name){ + try{ return options[name](); }catch(error){ return null; } + } + function read(value){ + const key = id(value); + if(!key) return ''; + if(dirty.has(key)) return memory.get(key) || ''; + if(prefix){ + try{ + const saved = storage('storage').getItem(prefix + key); + if(saved !== null){ + const data = JSON.parse(saved); + if(data && data.version === 1 && typeof data.text === 'string'){ + memory.set(key, data.text); + return data.text; + } + } + memory.delete(key); + return ''; + }catch(error){} + } + return memory.get(key) || ''; + } + function write(value, text){ + const key = id(value); + if(!key || typeof text !== 'string') return false; + if(text) memory.set(key, text); else memory.delete(key); + dirty.add(key); + if(!prefix) return false; + try{ + const target = storage('storage'); + if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text})); + else target.removeItem(prefix + key); + dirty.delete(key); + return true; + }catch(error){ return false; } + } + function active(value){ + if(arguments.length){ + current = id(value) || 'new'; + if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){} + }else if(activeKey){ + try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){} + } + return current; + } + function clear(){ + memory.clear(); + dirty.clear(); + if(prefix){ + try{ + const target = storage('storage'); + for(let index = target.length - 1; index >= 0; index--){ + const key = target.key(index); + if(key && key.startsWith(prefix)) target.removeItem(key); + } + }catch(error){} + } + active('new'); + } + return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear}; + } +}; diff --git a/static/js/support.js b/static/js/support.js index b66c548..a3a97d7 100644 --- a/static/js/support.js +++ b/static/js/support.js @@ -1,5 +1,17 @@ // Shared controls for the customer page and service workspace. var SupportUI = { + initAttachments(container, label){ + container.addEventListener('error', function(event){ + const image = event.target; + if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return; + image.dataset.unavailable = '1'; + image.hidden = true; + const hint = document.createElement('span'); + hint.className = 'attachment-unavailable'; + hint.textContent = typeof label === 'function' ? label() : label; + image.after(hint); + }, true); + }, async enableNotifications(button){ if(!('Notification' in window)) return; try{ diff --git a/static/js/visitor.js b/static/js/visitor.js index 8b1cf1a..ee53d63 100644 --- a/static/js/visitor.js +++ b/static/js/visitor.js @@ -45,5 +45,5 @@ var VisitorSession = (function(){ } return established ? send() : ensure().then(send); } - return {fetch:request}; + return {fetch:request,localKey:localKey}; })(); diff --git a/templates/attachment_unavailable.html b/templates/attachment_unavailable.html new file mode 100644 index 0000000..b53d344 --- /dev/null +++ b/templates/attachment_unavailable.html @@ -0,0 +1,23 @@ + + + + + + 附件暂时无法访问 · 柏悦物流 + + + +
+

附件暂时无法访问

+

文件可能已过期、被删除或当前无法访问。请返回会话重新发送,或联系人工客服。

+

This attachment is unavailable. Please return to your conversation to upload it again or contact support.

+ {{ '返回客服工作台' if staff else '返回聊天' }} +
+ diff --git a/templates/index.html b/templates/index.html index 3f2a7a0..780050b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -74,6 +74,7 @@ + diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 3420226..3fa32c2 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -6,6 +6,14 @@ const vm = require('node:vm'); const flush = () => new Promise(setImmediate); async function customerPage(pageOptions = {}){ + const storageValues = pageOptions.storage || {}; + if(!storageValues['baiyue-visitor-key']) storageValues['baiyue-visitor-key']='1'.repeat(64); + const tabValues = pageOptions.tabStorage || {}; + function testStorage(values){return { + getItem:key=>values[key] ?? null,setItem:(key,value)=>{values[key]=String(value);}, + removeItem:key=>{delete values[key];},key:index=>Object.keys(values)[index] ?? null, + get length(){return Object.keys(values).length;} + };} const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {}; let intervalId = 0, timerId = 0, nextConvId = 1; let voiceOptions; @@ -71,7 +79,7 @@ async function customerPage(pageOptions = {}){ document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true, FormData:class { append(){} }, requestAnimationFrame:callback => callback(), - localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}}, + localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues), window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800, addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); }, matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}), @@ -85,6 +93,7 @@ async function customerPage(pageOptions = {}){ fetch: async (url, options) => { calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)}); if(hooks[url]) return hooks[url](options); + if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch(); if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url); if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url); if(url === '/stream_chat'){ @@ -97,14 +106,14 @@ async function customerPage(pageOptions = {}){ } const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'} : url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)} - : url === '/api/visitor/human/request' ? {ok:true, session_id:7} + : url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7}) : url.startsWith('/api/visitor/human/poll') ? state : ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true} : url === '/api/get_history' ? (pageOptions.history || []) : []; return {ok:true, json:async () => data}; }, }); - for(const file of ['support.js', 'visitor.js', 'voice.js', 'chat.js']){ + for(const file of ['support.js', 'visitor.js', 'drafts.js', 'voice.js', 'chat.js']){ vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file}); if(file === 'voice.js'){ const createVoice=context.ChatVoice.create; @@ -115,7 +124,7 @@ async function customerPage(pageOptions = {}){ await flush(); if(pageOptions.human !== false) await context.window.showHuman(); await flush(); - return {context, element, calls, hooks, quickButtons, voiceOptions, + return {context, element, calls, hooks, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues, documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); }, resize:async height => { context.window.innerHeight=height; @@ -134,6 +143,7 @@ async function customerPage(pageOptions = {}){ await flush(); }, pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); }, + pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); }, setState:value => { state=value; }, runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); }, tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); }, @@ -488,9 +498,9 @@ test('repeated contact clicks create one human request and show a visible queue }); test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => { - const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'}); + const app = await customerPage({human:false, hour:12,serviceClosed:true, offlineMessage:'请明天跟进报价'}); await app.context.window.showHuman(); - assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 0); + assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/request').length, 1); assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价'); assert.match(app.element('chatDom').textContent, /留言已提交/); assert.equal(app.context.window.humanSid, 0); @@ -505,6 +515,13 @@ test('a failed human send preserves the text and makes retry possible', async () assert.equal(app.element('send-button').disabled, false); }); +test('a visitor whose phone says late at night can still reach an open support service', async () => { + const app=await customerPage({human:false,hour:23}); + await app.context.window.showHuman(); + assert.equal(app.context.window.humanSid,7); + assert.equal(app.calls.filter(call=>call.url==='/api/ticket').length,0); +}); + test('a duplicate human send cannot submit an in-flight message twice', async () => { const app = await customerPage(); let resolve; @@ -841,10 +858,10 @@ test('successful clipboard fallback reports copied and keeps text focus stable', }); test('blank offline messages are ignored and invalid ticket text stays editable with a readable error', async () => { - const empty=await customerPage({human:false,hour:23,offlineMessage:' '}); + const empty=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:' '}); await empty.context.window.showHuman();await flush(); assert.equal(empty.calls.filter(call=>call.url==='/api/ticket').length,0); - const app=await customerPage({human:false,hour:23,offlineMessage:'需要保留的留言'}); + const app=await customerPage({human:false,hour:23,serviceClosed:true,offlineMessage:'需要保留的留言'}); app.hooks['/api/ticket']=()=>response({ok:false,error:'请输入 1–2000 字的留言。'},400); await app.context.window.showHuman();await flush(); assert.equal(app.element('input-text').value,'需要保留的留言'); @@ -879,3 +896,99 @@ test('emoji panel dismisses outside or on Escape, stays open for its own control await app.click(app.element('.new-chat-btn')); assert.equal(panel.hidden,true); }); + +test('drafts stay with their own conversation and restore across a page reload', async () => { + const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}]; + const app=await customerPage({human:false,history}); + await app.click(app.element('historyWrap').children[0]); + app.element('input-text').value='A的未发送草稿';app.element('input-text').listeners.input(); + await app.click(app.element('historyWrap').children[1]); + assert.equal(app.element('input-text').value,''); + app.element('input-text').value='B的未发送草稿';app.element('input-text').listeners.input(); + await app.click(app.element('historyWrap').children[0]); + assert.equal(app.element('input-text').value,'A的未发送草稿'); + await app.pageHide(); + const restored=await customerPage({human:false,history,storage:app.storage,tabStorage:app.tabStorage}); + await flush(); + assert.equal(restored.element('input-text').value,'A的未发送草稿'); + assert.ok(restored.calls.some(call=>call.url==='/api/get_messages?conv_id=9')); + await restored.click(restored.element('historyWrap').children[1]); + assert.equal(restored.element('input-text').value,'B的未发送草稿'); +}); + +test('a new conversation draft restores after refresh and moves to the created conversation on send', async () => { + const app=await customerPage({human:false}); + app.element('input-text').value='尚未发送';app.element('input-text').listeners.input();await app.pageHide(); + const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage}); + assert.equal(restored.element('input-text').value,'尚未发送'); + await restored.context.window.fillInput('尚未发送');await restored.pageHide(); + const store=restored.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>restored.context.localStorage,tabStorage:()=>restored.context.sessionStorage}); + assert.equal(store.read(null),'');assert.equal(store.read(1),'');assert.equal(store.active(),'1'); +}); + +test('restoring an old conversation cannot replace a newer user-selected draft', async () => { + const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]}); + await app.click(app.element('historyWrap').children[0]); + app.element('input-text').value='A草稿';app.element('input-text').listeners.input();await app.pageHide(); + let release; + const restored=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage, + historyFetch:()=>new Promise(resolve=>{release=resolve;})}); + await restored.click(restored.element('.new-chat-btn')); + restored.element('input-text').value='新话题草稿';restored.element('input-text').listeners.input(); + release(response([{id:9,title:'A',ctime:'now'}]));await flush(); + assert.equal(restored.element('input-text').value,'新话题草稿'); +}); + +test('failed history loading cannot send a question under a hidden conversation context', async () => { + const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]}); + await app.context.window.fillInput('原会话');await flush(); + app.hooks.messages=()=>response({ok:false},503); + await app.click(app.element('historyWrap').children[0]); + const before=app.calls.filter(call=>call.url==='/stream_chat').length; + await app.context.window.fillInput('禁止发到未加载会话'); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,before); + assert.match(app.element('composer-status').textContent,/会话未加载成功/); + assert.doesNotMatch(app.element('chatDom').textContent,/原会话/); +}); + +test('startup removes a deleted active conversation draft without erasing a newer edit', async () => { + const app=await customerPage({human:false,history:[{id:9,title:'A',ctime:'now'}]}); + await app.click(app.element('historyWrap').children[0]); + app.element('input-text').value='旧会话草稿';app.element('input-text').listeners.input();await app.pageHide(); + const noEdit=await customerPage({human:false,storage:{...app.storage},tabStorage:{...app.tabStorage}}); + assert.equal(noEdit.element('input-text').value,''); + assert.ok(!noEdit.calls.some(call=>call.url==='/api/get_messages?conv_id=9')); + let release; + const edited=await customerPage({human:false,storage:app.storage,tabStorage:app.tabStorage, + historyFetch:()=>new Promise(resolve=>{release=resolve;})}); + edited.element('input-text').value='加载期间的新输入';edited.element('input-text').listeners.input(); + release(response([]));await flush(); + assert.equal(edited.element('input-text').value,'加载期间的新输入'); + await edited.context.window.fillInput('加载期间的新输入'); + assert.ok(!edited.calls.some(call=>call.url==='/stream_chat')); + assert.match(edited.element('composer-status').textContent,/会话未加载成功/); +}); + +test('deleting a conversation removes only its saved draft', async () => { + const history=[{id:9,title:'A',ctime:'now'},{id:10,title:'B',ctime:'now'}]; + const app=await customerPage({human:false,history}); + await app.click(app.element('historyWrap').children[0]); + app.element('input-text').value='A草稿';app.element('input-text').listeners.input(); + await app.click(app.element('historyWrap').children[1]); + app.element('input-text').value='B草稿';app.element('input-text').listeners.input();await app.pageHide(); + await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}}); + const store=app.context.ChatDrafts.create({scope:'1'.repeat(64),storage:()=>app.context.localStorage,tabStorage:()=>app.context.sessionStorage}); + assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿'); +}); + +test('an unavailable image gets one readable replacement instead of a broken image', async () => { + const app=await customerPage({human:false}); + let shown; + const image={tagName:'IMG',classList:{contains:name=>name==='message-image'},dataset:{},after:hint=>{shown=hint;}}; + app.element('chatDom').listeners.error({target:image}); + assert.equal(image.hidden,true); + assert.match(shown.textContent,/图片暂时无法访问/); + const first=shown; + app.element('chatDom').listeners.error({target:image}); + assert.equal(shown,first); +}); diff --git a/tests/frontend/drafts.test.cjs b/tests/frontend/drafts.test.cjs new file mode 100644 index 0000000..7027097 --- /dev/null +++ b/tests/frontend/drafts.test.cjs @@ -0,0 +1,48 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const fs=require('node:fs'); +const vm=require('node:vm'); +const path=require('node:path'); +const context=vm.createContext({}); +vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context); +function storage(){ + const data=new Map(); + return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null, + getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)}; +} +function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){ + return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab}); +} +test('conversation drafts and active tab restore independently of another visitor or tab',()=>{ + const local=storage(),tab=storage(),a=drafts(undefined,local,tab); + a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9); + const restored=drafts(undefined,local,tab); + assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW'); + assert.equal(restored.active(),'9'); + const otherTab=drafts(undefined,local);otherTab.write(10,'B updated'); + assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A'); + otherTab.remove(10);assert.equal(restored.read(10),''); + assert.equal(drafts('2'.repeat(64),local,tab).read(9),''); +}); +test('malformed records and invalid identifiers cannot become restored drafts',()=>{ + const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9'; + for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){ + local.setItem(key,value);assert.equal(a.read(9),''); + } + assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),''); +}); +test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{ + const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local); + local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE'); + a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B'); + a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark'); +}); +test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{ + const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}}; + const a=drafts(undefined,broken,broken); + assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9'); + const local=storage(),b=drafts(undefined,local);b.write(9,'old'); + local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new'); + const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only'); + assert.equal(drafts(null,local).read(9),''); +}); diff --git a/tests/test_dev_integration.py b/tests/test_dev_integration.py index c9bd7e6..146778f 100644 --- a/tests/test_dev_integration.py +++ b/tests/test_dev_integration.py @@ -1,6 +1,6 @@ """Remote dev behavior on the modular backend and the light workspace.""" -from datetime import datetime +from datetime import datetime, timezone import io from pathlib import Path import tempfile @@ -50,6 +50,19 @@ class DevIntegrationTests(unittest.TestCase): self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True}) self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言') + def test_service_hours_use_china_time_even_when_the_host_clock_is_utc(self): + cid = self.client.post('/api/new_chat').json['id'] + for utc_hour, expected in ((0, True),(13, True),(14, False),(23, False)): + instant = datetime(2026,10,31,utc_hour,tzinfo=timezone.utc) + class HostUtcClock(datetime): + @classmethod + def now(cls, tz=None): + return instant.astimezone(tz) if tz else instant.replace(tzinfo=None) + with self.subTest(utc_hour=utc_hour), patch('logistics_chat.human_service.datetime', HostUtcClock): + result = self.client.post('/api/visitor/human/request',json={'conv_id':cid}).json + self.assertEqual(result['ok'],expected) + if not expected: self.assertTrue(result['closed']) + def test_transfer_returns_to_queue_preserves_messages_and_note(self): sid = self.session() self.client.post('/api/human/accept', json={'sid': sid}) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index cb57c88..55cdff4 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase): def test_reply_workflows(self): root = Path(__file__).resolve().parents[1] result = subprocess.run( - ['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs'], cwd=root, + ['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root, text=True, encoding='utf-8', capture_output=True, timeout=30, ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) diff --git a/tests/test_storage.py b/tests/test_storage.py index 7483502..c1ea0c5 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -123,6 +123,20 @@ class StorageTests(unittest.TestCase): self.assertNotIn('..', name) self.assertFalse(result['is_image']) + def test_old_attachments_referenced_by_ai_or_human_history_survive_cleanup(self): + uploads=self.root/'uploads'; uploads.mkdir() + for name in ('ai.png','human.pdf','unused.txt'): + file=uploads/name;file.write_bytes(b'fixture') + os.utime(file,(time.time()-20*86400,time.time()-20*86400)) + connection=db.connect() + connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(1,'user','[图片]/uploads/ai.png','now')") + connection.execute("INSERT INTO human_messages(session_id,side,content,ctime) VALUES(1,'customer','[文件]/uploads/human.pdf','now')") + connection.commit();connection.close() + file_service.cleanup_uploads() + self.assertTrue((uploads/'ai.png').exists()) + self.assertTrue((uploads/'human.pdf').exists()) + self.assertFalse((uploads/'unused.txt').exists()) + def test_invalid_and_oversized_uploads_are_rejected(self): self.assertEqual(file_service.upload_file({})[1], 400) denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe') diff --git a/tests/test_visitor.py b/tests/test_visitor.py index c03f23a..dd1d5e0 100644 --- a/tests/test_visitor.py +++ b/tests/test_visitor.py @@ -125,6 +125,17 @@ class VisitorTests(unittest.TestCase): self.assertIn('客户账号登录并关联',response.get_data(as_text=True)) lookup.assert_not_called();model.assert_not_called() + def test_missing_attachments_show_a_readable_private_page_without_disclosing_ownership(self): + response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'fixture'),'missing.txt')}) + url=response.json['url'] + (Path(self.app.config['UPLOAD_DIR'])/url.rsplit('/',1)[1]).unlink() + own=self.a.get(url);other=self.b.get(url) + self.assertEqual(own.status_code,404) + self.assertIn('附件暂时无法访问',own.get_data(as_text=True)) + self.assertIn('no-store',own.headers['Cache-Control']) + self.assertEqual(own.data,other.data) + self.assertNotIn(url,own.get_data(as_text=True)) + def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self): cid=self.conversation(self.a,'A_PRIVATE') for key in ('short','x'*64,'1'*65,[],{},123):