Эх сурвалжийг харах

修复会话不存在时的幂等删除与缓存清理

LSamien 1 сар өмнө
parent
commit
4d6f673381

+ 2 - 1
src/server/chatCache.ts

@@ -522,7 +522,8 @@ export class ChatCacheStore {
       for (const table of ['chat_messages', 'chat_events', 'chat_attachments', 'chat_sessions']) {
         this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
       }
-      this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND (session_id=? OR kind='list')").run(owner, sessionID)
+      this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND ((profile=? AND session_id=?) OR kind='list')")
+        .run(owner, profile, sessionID)
       this.db.exec('COMMIT')
       for (const asset of assets) {
         const referenced = this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE local_path=? LIMIT 1').get(asset.local_path)

+ 31 - 2
src/server/routes.ts

@@ -337,6 +337,23 @@ function requireWritableOwnedSession(response: UpstreamResponse, locallyOwned =
   if (!locallyOwned) nativeSessionsReadOnly()
 }
 
+function isMissingSessionResponse(response: UpstreamResponse): boolean {
+  if (response.status !== 404) return false
+  try {
+    const payload = parseJson(response)
+    if (payload.code === 'session_not_found') return true
+    const detail = payload.detail
+    const message = [payload.error, payload.message, detail,
+      detail && typeof detail === 'object' && 'message' in detail ? detail.message : undefined]
+      .find(value => typeof value === 'string')
+    return typeof message === 'string'
+      && /^(session not found|session ?不存在|会话不存在)([.!。!]|\s*[::].*)?$/i
+        .test(message.trim().replace(/\s+/g, ' '))
+  } catch {
+    return false
+  }
+}
+
 function writableSessionPatch(input: JsonObject): JsonObject {
   const allowed = new Set(['title', 'archived', 'pinned'])
   const unknown = Object.keys(input).filter(key => !allowed.has(key))
@@ -2167,18 +2184,30 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async jar => {
       const profile = search.get('profile') || 'default'
       const owner = dependencies.auth.require(ctx).id
+      const existing = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search })
+      if (isMissingSessionResponse(existing)) {
+        // Only this account's cached copy remains; no upstream mutation is needed.
+        dependencies.chatCache?.store.deleteSession(owner, profile, id)
+        ctx.body = { ok: true }
+        return
+      }
       requireWritableOwnedSession(
-        await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
+        existing,
         dependencies.chatCache?.store.ownsSession(owner, profile, id) ?? false,
       )
       const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'DELETE', search })
-      if (response.status >= 200 && response.status < 300) {
+      const alreadyMissing = isMissingSessionResponse(response)
+      if ((response.status >= 200 && response.status < 300) || alreadyMissing) {
         dependencies.chatCache?.store.deleteSession(
           owner,
           profile,
           id,
         )
       }
+      if (alreadyMissing) {
+        ctx.body = { ok: true }
+        return
+      }
       sendUpstreamResponse(ctx, response, jar)
     })
   })

+ 144 - 0
tests/server/sessionDeletion.test.ts

@@ -0,0 +1,144 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+
+const runtimes: ApplicationRuntime[] = []
+const homes: string[] = []
+const host = '127.0.0.1:15300'
+const origin = `http://${host}`
+const sessionID = 'cached-session'
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+async function fixture(upstream: (method: string) => Response) {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-session-deletion-'))
+  homes.push(home)
+  const config: ServerConfig = {
+    host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:19119'),
+    allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+    mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: false,
+    chatCacheMode: 'prefer-local',
+  }
+  const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+    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__="deletion-token-1234567890123456";</script>')
+    if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
+    if (url.pathname === `/api/sessions/${sessionID}`) return upstream(init?.method ?? 'GET')
+    if (url.pathname === '/api/sessions') return Response.json({ sessions: [], total: 0 })
+    return Response.json({ error: 'Unknown fixture route' }, { status: 404 })
+  })
+  const runtime = createApplication({ config, 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
+  for (const [account, profile] of [[owner, 'default'], [owner, 'other'], ['other-owner', 'default']]) {
+    store.recordRoute(account!, profile!, sessionID, `runtime-${account}-${profile}`)
+    store.putSnapshot(account!, `messages-${profile}`, 'messages', profile!, sessionID, {
+      status: 200, headers: new Headers({ 'content-type': 'application/json' }),
+      body: Buffer.from(JSON.stringify({
+        session: { id: sessionID, profile, title: 'Cached history', message_count: 1 },
+        messages: [{ id: 'cached-message', role: 'assistant', content: 'Saved answer' }],
+        pagination: { total: 1, offset: 0, returned: 1, has_more: false },
+      })),
+    })
+  }
+  return {
+    runtime, store, owner, fetchImpl, agent,
+    remove: () => agent.delete(`/api/app/sessions/${sessionID}?profile=default`)
+      .set('Host', host).set('Origin', origin).set('X-CSRF-Token', setup.body.csrfToken),
+    assertOtherCopies: () => {
+      expect(store.localDetail(owner, 'other', sessionID)).toBeDefined()
+      expect(store.messagePage(owner, 'other', sessionID, 0, 100)).toBeDefined()
+      expect(store.snapshot(owner, 'messages-other')).toBeDefined()
+      expect(store.localDetail('other-owner', 'default', sessionID)).toBeDefined()
+      expect(store.messagePage('other-owner', 'default', sessionID, 0, 100)).toBeDefined()
+      expect(store.snapshot('other-owner', 'messages-default')).toBeDefined()
+    },
+  }
+}
+
+describe('deleting sessions retained only in cache', () => {
+  it.each([
+    { error: 'Session not found' },
+    { error: 'session 不存在' },
+    { message: '会话不存在' },
+    { detail: 'Session not found: cached-session' },
+    { detail: { message: 'Session not found' } },
+    { code: 'session_not_found' },
+  ])('cleans a missing session and keeps deletion idempotent: %j', async payload => {
+    const f = await fixture(() => Response.json(payload, { status: 404 }))
+    const listPath = '/api/app/sessions?view=chat&profile=default'
+    const before = await f.agent.get(listPath).set('Host', host).expect(200)
+    expect(before.body.sessions.map((session: { id: string }) => session.id)).toEqual([sessionID])
+    expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
+
+    await f.remove().expect(200, { ok: true })
+    await f.remove().expect(200, { ok: true })
+    expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeUndefined()
+    expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeUndefined()
+    expect(f.store.snapshot(f.owner, 'messages-default')).toBeUndefined()
+    f.assertOtherCopies()
+    const after = await f.agent.get(listPath).set('Host', host).expect(200)
+    expect(after.body.sessions).toEqual([])
+    const fresh = await f.agent.get(listPath).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(200)
+    expect(fresh.body.sessions).toEqual([])
+    expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
+  })
+
+  it('cleans up when the session disappears between lookup and DELETE', async () => {
+    const f = await fixture(method => method === 'DELETE'
+      ? Response.json({ error: 'Session not found' }, { status: 404 })
+      : Response.json({ id: sessionID, profile: 'default' }))
+    await f.remove().expect(200, { ok: true })
+    expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeUndefined()
+    expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeUndefined()
+    f.assertOtherCopies()
+    const deletes = f.fetchImpl.mock.calls.filter(([, init]) => init?.method === 'DELETE')
+    expect(deletes).toHaveLength(1)
+    expect(new URL(String(deletes[0]![0])).searchParams.get('profile')).toBe('default')
+  })
+
+  it.each([
+    [404, 'Not Found'], [404, 'Profile not found'], [404, 'Session route not found'],
+    [401, 'Session not found'], [403, 'Session not found'], [500, 'Session not found'],
+  ])('preserves cache on lookup failure %i: %s', async (status, error) => {
+    const f = await fixture(() => Response.json({ error }, { status }))
+    await f.remove().expect(status)
+    expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeDefined()
+    expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
+    f.assertOtherCopies()
+    expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
+  })
+
+  it('preserves cache when DELETE itself fails', async () => {
+    const f = await fixture(method => method === 'DELETE'
+      ? Response.json({ error: 'Database unavailable' }, { status: 500 })
+      : Response.json({ id: sessionID, profile: 'default' }))
+    await f.remove().expect(500)
+    expect(f.store.localDetail(f.owner, 'default', sessionID)).toBeDefined()
+    expect(f.store.messagePage(f.owner, 'default', sessionID, 0, 100)).toBeDefined()
+    f.assertOtherCopies()
+  })
+
+  it('still rejects upstream mutations of an existing unowned session', async () => {
+    const f = await fixture(() => Response.json({ id: sessionID, profile: 'default' }))
+    f.store.deleteSession(f.owner, 'default', sessionID)
+    await f.remove().expect(410)
+    f.assertOtherCopies()
+    expect(f.fetchImpl.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
+  })
+})