瀏覽代碼

修复过期 404 响应误删活跃会话及缓存附件

samien 1 月之前
父節點
當前提交
aeb51fd334
共有 4 個文件被更改,包括 78 次插入 和 5 次删除
  1. 8 2
      src/server/chatCache.ts
  2. 5 2
      src/server/routes.ts
  3. 35 1
      tests/server/chatCache.test.ts
  4. 30 0
      tests/server/chatCacheRoutes.test.ts

+ 8 - 2
src/server/chatCache.ts

@@ -994,9 +994,13 @@ export class ChatCacheCoordinator {
     load: () => Promise<UpstreamResponse>, ownedList = false,
     listPage: { offset?: number; limit?: number; archived?: string } = {}): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
     if (kind === 'list' && ownedList) this.validateList(owner, profile, load)
+    const revision = sessionID && this.store.ownsSession(owner, profile, sessionID)
+      ? this.store.revision(owner, profile, sessionID) : undefined
     if (this.mode === 'upstream-only') {
       const upstreamResponse = await load()
-      if (sessionID && upstreamResponse.status === 404) {
+      if (sessionID && revision !== undefined && upstreamResponse.status === 404
+        && this.store.ownsSession(owner, profile, sessionID)
+        && this.store.revision(owner, profile, sessionID) === revision) {
         this.store.deleteSession(owner, profile, sessionID)
         this.onSynchronized(owner, profile, sessionID)
       }
@@ -1026,7 +1030,9 @@ export class ChatCacheCoordinator {
     try {
       const requestStartedAt = Date.now()
       const upstreamResponse = await load()
-      if (sessionID && upstreamResponse.status === 404 && this.store.ownsSession(owner, profile, sessionID)) {
+      if (sessionID && revision !== undefined && upstreamResponse.status === 404
+        && this.store.ownsSession(owner, profile, sessionID)
+        && this.store.revision(owner, profile, sessionID) === revision) {
         this.store.deleteSession(owner, profile, sessionID)
         this.onSynchronized(owner, profile, sessionID)
       }

+ 5 - 2
src/server/routes.ts

@@ -758,9 +758,12 @@ async function cachedChatRead(
       return
     }
     const requestStartedAt = Date.now()
+    const revision = options.sessionID && dependencies.chatCache.store.ownsSession(user.id, options.profile, options.sessionID)
+      ? dependencies.chatCache.store.revision(user.id, options.profile, options.sessionID) : undefined
     const upstreamResponse = await load()
-    if (options.sessionID && upstreamResponse.status === 404
-      && dependencies.chatCache.store.ownsSession(user.id, options.profile, options.sessionID)) {
+    if (options.sessionID && revision !== undefined && upstreamResponse.status === 404
+      && dependencies.chatCache.store.ownsSession(user.id, options.profile, options.sessionID)
+      && dependencies.chatCache.store.revision(user.id, options.profile, options.sessionID) === revision) {
       dependencies.chatCache.store.deleteSession(user.id, options.profile, options.sessionID)
       dependencies.chatCache.onSynchronized(user.id, options.profile, options.sessionID)
     }

+ 35 - 1
tests/server/chatCache.test.ts

@@ -1,5 +1,5 @@
 // @vitest-environment node
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { DatabaseSync } from 'node:sqlite'
@@ -30,6 +30,40 @@ afterEach(() => {
 })
 
 describe('durable source=web chat cache', () => {
+  it.each(['prefer-local', 'shadow', 'upstream-only'] as const)(
+    'preserves newer session activity and cached files after a stale 404 in %s mode', async mode => {
+      const f = fixture()
+      const coordinator = new ChatCacheCoordinator(f.store, f.coordinator.upstream, mode)
+      const sourcePath = '/tmp/final-report.png'
+      try {
+        f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
+        f.store.putSnapshot(owner, 'tail', 'messages', profile, sessionID, response({
+          messages: [{ id: 'latest', role: 'assistant', content: `![报告](${sourcePath})` }],
+          pagination: { total: 2, offset: 0, has_more: true },
+        }))
+        f.store.storeAttachment(owner, sourcePath, response('report-bytes', 200, 'image/png'))
+        const asset = f.store.attachment(owner, sourcePath)!
+        const changed = vi.fn()
+        coordinator.onSynchronized = changed
+        const result = await coordinator.messages(owner, 'older', profile, sessionID, 1, 1, async () => {
+          f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: '新消息' })
+          return response({ detail: 'Session not found' }, 404)
+        })
+        expect(result.response.status).toBe(404)
+        expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
+        expect(JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 1)!.response.body.toString()).messages)
+          .toEqual([expect.objectContaining({ id: 'latest' })])
+        expect(readFileSync(asset.localPath, 'utf8')).toBe('report-bytes')
+        expect(changed).not.toHaveBeenCalled()
+
+        await coordinator.messages(owner, 'older', profile, sessionID, 1, 1,
+          async () => response({ detail: 'Session not found' }, 404))
+        expect(f.store.ownsSession(owner, profile, sessionID)).toBe(false)
+        expect(changed).toHaveBeenCalledWith(owner, profile, sessionID)
+      } finally { coordinator.close(); f.store.close() }
+    },
+  )
+
   it('cleans confirmed missing sessions behind a warm list without pruning filtered or unavailable sessions', async () => {
     const f = fixture()
     const ids = ['missing', 'archived', 'unavailable', 'new-session', 'other-profile']

+ 30 - 0
tests/server/chatCacheRoutes.test.ts

@@ -32,6 +32,36 @@ afterEach(() => {
 })
 
 describe('source=web chat cache routes', () => {
+  it.each(['prefer-local', 'shadow', 'upstream-only'] as const)(
+    'preserves newer session activity after a stale bypass 404 in %s mode', async chatCacheMode => {
+      const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-stale-bypass-')); homes.push(home)
+      let duringRead = () => {}
+      const fetchImpl = vi.fn<typeof fetch>(async input => {
+        const url = new URL(String(input))
+        if (url.pathname === '/api/status') return Response.json({ auth_required: false })
+        if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="stale-token-1234567890123456";</script>')
+        if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default' }] })
+        if (url.pathname === '/api/sessions/owned') duringRead()
+        return Response.json({ detail: 'Session not found' }, { status: 404 })
+      })
+      const runtime = createApplication({ config: { ...config(home), chatCacheMode }, fetchImpl }); runtimes.push(runtime)
+      const agent = request.agent(runtime.app.callback())
+      const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
+      const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', origin)
+        .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
+      const owner = String(setup.body.user.id)
+      const store = runtime.chatCache!.store
+      store.recordCommand(owner, 'default', 'owned', 'session.create', { source: 'web' })
+      duringRead = () => { store.recordCommand(owner, 'default', 'owned', 'prompt.submit', { text: '新消息' }) }
+      const path = '/api/app/sessions/owned?profile=default'
+      await agent.get(path).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(404)
+      expect(store.ownsSession(owner, 'default', 'owned')).toBe(true)
+      duringRead = () => {}
+      await agent.get(path).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(404)
+      expect(store.ownsSession(owner, 'default', 'owned')).toBe(false)
+    },
+  )
+
   it('publishes pin changes immediately and deletes owned sessions already absent upstream', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-actions-')); homes.push(home)
     let missing = false