完善会话草稿恢复与附件保留,统一人工服务时区

This commit is contained in:
2026-10-30 15:32:31 +08:00
parent 78797d8eca
commit b764fcb33c
20 changed files with 446 additions and 34 deletions
+121 -8
View File
@@ -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);
});
+48
View File
@@ -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),'');
});
+14 -1
View File
@@ -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})
+1 -1
View File
@@ -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)
+14
View File
@@ -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')
+11
View File
@@ -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):