diff --git a/docker-compose.yml b/docker-compose.yml index d6f0f4e..dbe9d78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: ports: - "13306:3306" volumes: - - mysql_data:/var/lib/mysql + - /docker/waoowaoo/backup/mysql_data:/var/lib/mysql command: - "--default-authentication-plugin=mysql_native_password" - "--sql_mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION" @@ -30,7 +30,7 @@ services: ports: - "16379:6379" volumes: - - redis_data:/data + - /docker/waoowaoo/backup/redis_data:/data command: ["redis-server", "--appendonly", "yes"] healthcheck: test: ["CMD", "redis-cli", "ping"] @@ -52,7 +52,7 @@ services: - "19000:9000" - "19001:9001" volumes: - - minio_data:/data + - /docker/waoowaoo/backup/minio:/data healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] interval: 5s @@ -62,7 +62,10 @@ services: # ==================== App (Next.js + Workers) ==================== app: - image: ghcr.io/saturndec/waoowaoo:latest + build: + context: . + dockerfile: Dockerfile + image: waoowaoo:local container_name: waoowaoo-app restart: unless-stopped environment: @@ -120,8 +123,8 @@ services: - "13000:3000" - "13010:3010" volumes: - - ./data:/app/data - - ./docker-logs:/app/logs + - /docker/waoowaoo/backup/data:/app/data + - /docker/waoowaoo/backup/docker-logs:/app/logs depends_on: mysql: condition: service_healthy diff --git a/src/app/api/user-preference/route.ts b/src/app/api/user-preference/route.ts index 0520b57..1e618f0 100644 --- a/src/app/api/user-preference/route.ts +++ b/src/app/api/user-preference/route.ts @@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma' import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' import { ApiError, apiHandler } from '@/lib/api-errors' import { isArtStyleValue } from '@/lib/constants' +import { assertPersistedUser } from '@/lib/persisted-user' function validateArtStyleField(value: unknown): string { if (typeof value !== 'string') { @@ -29,6 +30,7 @@ export const GET = apiHandler(async () => { const authResult = await requireUserAuth() if (isErrorResponse(authResult)) return authResult const { session } = authResult + await assertPersistedUser(session.user.id) // 获取或创建用户偏好 const preference = await prisma.userPreference.upsert({ @@ -46,6 +48,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const authResult = await requireUserAuth() if (isErrorResponse(authResult)) return authResult const { session } = authResult + await assertPersistedUser(session.user.id) const body = await request.json() diff --git a/src/app/api/user/api-config/route.ts b/src/app/api/user/api-config/route.ts index e5ff793..494ef1b 100644 --- a/src/app/api/user/api-config/route.ts +++ b/src/app/api/user/api-config/route.ts @@ -10,6 +10,7 @@ import { prisma } from '@/lib/prisma' import { encryptApiKey, decryptApiKey } from '@/lib/crypto-utils' import { requireUserAuth, isErrorResponse } from '@/lib/api-auth' import { apiHandler, ApiError } from '@/lib/api-errors' +import { assertPersistedUser } from '@/lib/persisted-user' import { composeModelKey, parseModelKeyStrict, @@ -1654,6 +1655,7 @@ export const GET = apiHandler(async () => { if (isErrorResponse(authResult)) return authResult const { session } = authResult const userId = session.user.id + await assertPersistedUser(userId) const pref = await prisma.userPreference.findUnique({ where: { userId }, @@ -1766,6 +1768,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (isErrorResponse(authResult)) return authResult const { session } = authResult const userId = session.user.id + await assertPersistedUser(userId) let body: ApiConfigPutBody try { diff --git a/src/lib/generator-api.ts b/src/lib/generator-api.ts index 63ef238..3b0bc95 100644 --- a/src/lib/generator-api.ts +++ b/src/lib/generator-api.ts @@ -23,6 +23,18 @@ import { generateSiliconFlowAudio, generateSiliconFlowImage, generateSiliconFlow const OFFICIAL_ONLY_PROVIDER_KEYS = new Set(['bailian', 'siliconflow']) +const OPENAI_COMPAT_IMAGE_MODEL_ID_ALIASES: Record = { + 'gemini-3-pro-image': 'gemini-3-pro-image-preview', + 'gemini-3.1-flash-image': 'gemini-3.1-flash-image-preview', + 'gemini-2.5-flash-image': 'gemini-2.5-flash-image-preview', +} + +function normalizeOpenAICompatImageModelId(modelId: string): string { + const normalized = modelId.trim() + if (!normalized) return normalized + return OPENAI_COMPAT_IMAGE_MODEL_ID_ALIASES[normalized] || normalized +} + /** * 将 aspectRatio 映射为 OpenAI 兼容的 size */ @@ -105,6 +117,7 @@ export async function generateImage( const { referenceImages, ...generatorOptions } = options || {} if (gatewayRoute === 'openai-compat') { const compatTemplate = selection.compatMediaTemplate + const normalizedCompatModelId = normalizeOpenAICompatImageModelId(selection.modelId) if (providerKey === 'openai-compatible' && !compatTemplate) { throw new Error(`MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED: ${selection.modelKey}`) } @@ -112,7 +125,7 @@ export async function generateImage( return await generateImageViaOpenAICompatTemplate({ userId, providerId: selection.provider, - modelId: selection.modelId, + modelId: normalizedCompatModelId, modelKey: selection.modelKey, prompt, referenceImages, @@ -141,7 +154,7 @@ export async function generateImage( return await generateImageViaOpenAICompat({ userId, providerId: selection.provider, - modelId: selection.modelId, + modelId: normalizedCompatModelId, prompt, referenceImages, options: { diff --git a/src/lib/persisted-user.ts b/src/lib/persisted-user.ts new file mode 100644 index 0000000..5cf511b --- /dev/null +++ b/src/lib/persisted-user.ts @@ -0,0 +1,31 @@ +import { ApiError } from '@/lib/api-errors' +import { prisma } from '@/lib/prisma' +import { withPrismaRetry } from '@/lib/prisma-retry' + +const SESSION_USER_MISSING_MESSAGE = '登录状态已失效,请重新登录' + +export async function assertPersistedUser(userId: string): Promise { + const normalizedUserId = typeof userId === 'string' ? userId.trim() : '' + if (!normalizedUserId) { + throw new ApiError('UNAUTHORIZED', { + code: 'SESSION_USER_MISSING', + field: 'userId', + message: SESSION_USER_MISSING_MESSAGE, + }) + } + + const user = await withPrismaRetry(() => + prisma.user.findUnique({ + where: { id: normalizedUserId }, + select: { id: true }, + }), + ) + + if (user) return + + throw new ApiError('UNAUTHORIZED', { + code: 'SESSION_USER_MISSING', + field: 'userId', + message: SESSION_USER_MISSING_MESSAGE, + }) +} diff --git a/tests/integration/api/specific/user-api-config-put.test.ts b/tests/integration/api/specific/user-api-config-put.test.ts index f4b4c9b..d8d1254 100644 --- a/tests/integration/api/specific/user-api-config-put.test.ts +++ b/tests/integration/api/specific/user-api-config-put.test.ts @@ -34,6 +34,9 @@ type SavedProvider = { } const prismaMock = vi.hoisted(() => ({ + user: { + findUnique: vi.fn<(...args: unknown[]) => Promise<{ id: string } | null>>(), + }, userPreference: { findUnique: vi.fn<(...args: unknown[]) => Promise>(), upsert: vi.fn<(...args: unknown[]) => Promise>(), @@ -103,6 +106,7 @@ describe('api specific - user api-config PUT provider uniqueness', () => { vi.clearAllMocks() resetAuthMockState() + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' }) prismaMock.userPreference.findUnique.mockResolvedValue({ customProviders: null, customModels: null, @@ -111,6 +115,37 @@ describe('api specific - user api-config PUT provider uniqueness', () => { getBillingModeMock.mockResolvedValue('OFF') }) + it('returns unauthorized when session user no longer exists before saving providers', async () => { + installAuthMocks() + mockAuthenticated('user-missing') + prismaMock.user.findUnique.mockResolvedValueOnce(null) + const route = await import('@/app/api/user/api-config/route') + + const req = buildMockRequest({ + path: '/api/user/api-config', + method: 'PUT', + body: { + providers: [ + { id: 'openai-compatible:oa-1', name: 'OpenAI A', baseUrl: 'https://oa-a.test', apiKey: 'oa-key-a' }, + ], + }, + }) + + const res = await route.PUT(req, routeContext) + const body = await res.json() as { + error?: { + code?: string + message?: string + } + } + + expect(res.status).toBe(401) + expect(body.error?.code).toBe('UNAUTHORIZED') + expect(body.error?.message).toBe('登录状态已失效,请重新登录') + expect(prismaMock.userPreference.findUnique).not.toHaveBeenCalled() + expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() + }) + it('allows multiple providers with the same api type when provider ids differ', async () => { installAuthMocks() mockAuthenticated('user-1') diff --git a/tests/integration/api/specific/user-preference-art-style-validation.test.ts b/tests/integration/api/specific/user-preference-art-style-validation.test.ts index 0d9e1f2..a6b19c5 100644 --- a/tests/integration/api/specific/user-preference-art-style-validation.test.ts +++ b/tests/integration/api/specific/user-preference-art-style-validation.test.ts @@ -9,6 +9,9 @@ const authMock = vi.hoisted(() => ({ })) const prismaMock = vi.hoisted(() => ({ + user: { + findUnique: vi.fn<(...args: unknown[]) => Promise<{ id: string } | null>>(), + }, userPreference: { upsert: vi.fn(async () => ({ userId: 'user-1', @@ -25,6 +28,7 @@ describe('api specific - user preference art style validation', () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' }) }) it('accepts valid artStyle and persists normalized value', async () => { @@ -58,4 +62,27 @@ describe('api specific - user preference art style validation', () => { expect(body.error.code).toBe('INVALID_PARAMS') expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() }) + + it('returns unauthorized when session user record is missing', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null) + const mod = await import('@/app/api/user-preference/route') + const req = buildMockRequest({ + path: '/api/user-preference', + method: 'PATCH', + body: { artStyle: 'realistic' }, + }) + + const res = await mod.PATCH(req, routeContext) + const body = await res.json() as { + error?: { + code?: string + message?: string + } + } + + expect(res.status).toBe(401) + expect(body.error?.code).toBe('UNAUTHORIZED') + expect(body.error?.message).toBe('登录状态已失效,请重新登录') + expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() + }) }) diff --git a/tests/regression/user-session-persistence-guard.test.ts b/tests/regression/user-session-persistence-guard.test.ts new file mode 100644 index 0000000..7c43876 --- /dev/null +++ b/tests/regression/user-session-persistence-guard.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildMockRequest } from '../helpers/request' +import { + installAuthMocks, + mockAuthenticated, + resetAuthMockState, +} from '../helpers/auth' + +const prismaMock = vi.hoisted(() => ({ + user: { + findUnique: vi.fn<(...args: unknown[]) => Promise<{ id: string } | null>>(), + }, + userPreference: { + findUnique: vi.fn<(...args: unknown[]) => Promise<{ customProviders: null; customModels: null } | null>>(), + upsert: vi.fn<(...args: unknown[]) => Promise>(), + }, +})) + +const getBillingModeMock = vi.hoisted(() => vi.fn(async () => 'OFF')) + +vi.mock('@/lib/prisma', () => ({ + prisma: prismaMock, +})) + +vi.mock('@/lib/billing/mode', () => ({ + getBillingMode: getBillingModeMock, +})) + +describe('regression - persisted session user guard', () => { + const routeContext = { params: Promise.resolve({}) } + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + resetAuthMockState() + installAuthMocks() + mockAuthenticated('user-missing') + prismaMock.user.findUnique.mockResolvedValue(null) + prismaMock.userPreference.findUnique.mockResolvedValue({ + customProviders: null, + customModels: null, + }) + prismaMock.userPreference.upsert.mockResolvedValue({ id: 'pref-1' }) + getBillingModeMock.mockResolvedValue('OFF') + }) + + it('api-config PUT returns unauthorized before touching userPreference when session user row is missing', async () => { + const route = await import('@/app/api/user/api-config/route') + const req = buildMockRequest({ + path: '/api/user/api-config', + method: 'PUT', + body: { + providers: [ + { + id: 'openai-compatible:oa-1', + name: 'OpenAI A', + baseUrl: 'https://oa-a.test', + apiKey: 'oa-key-a', + }, + ], + }, + }) + + const res = await route.PUT(req, routeContext) + const body = await res.json() as { error?: { code?: string; message?: string } } + + expect(res.status).toBe(401) + expect(body.error?.code).toBe('UNAUTHORIZED') + expect(body.error?.message).toBe('登录状态已失效,请重新登录') + expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'user-missing' }, + select: { id: true }, + }) + expect(prismaMock.userPreference.findUnique).not.toHaveBeenCalled() + expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() + }) + + it('user-preference PATCH returns unauthorized before upsert when session user row is missing', async () => { + const route = await import('@/app/api/user-preference/route') + const req = buildMockRequest({ + path: '/api/user-preference', + method: 'PATCH', + body: { artStyle: 'realistic' }, + }) + + const res = await route.PATCH(req, routeContext) + const body = await res.json() as { error?: { code?: string; message?: string } } + + expect(res.status).toBe(401) + expect(body.error?.code).toBe('UNAUTHORIZED') + expect(body.error?.message).toBe('登录状态已失效,请重新登录') + expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'user-missing' }, + select: { id: true }, + }) + expect(prismaMock.userPreference.upsert).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/generator-api.test.ts b/tests/unit/generator-api.test.ts index 6bd9ebd..4357d59 100644 --- a/tests/unit/generator-api.test.ts +++ b/tests/unit/generator-api.test.ts @@ -118,6 +118,29 @@ describe('generator-api gateway routing', () => { expect(result).toEqual({ success: true, imageUrl: 'compat-template-image' }) }) + it('normalizes legacy openai-compatible gemini image model ids before template gateway call', async () => { + resolveModelSelectionMock.mockResolvedValueOnce({ + provider: 'openai-compatible:oa-1', + modelId: 'gemini-3.1-flash-image', + modelKey: 'openai-compatible:oa-1::gemini-3.1-flash-image', + mediaType: 'image', + compatMediaTemplate: { + version: 1, + mediaType: 'image', + mode: 'sync', + create: { method: 'POST', path: '/v1/images/generations' }, + response: { outputUrlPath: '$.data[0].url' }, + }, + }) + resolveModelGatewayRouteMock.mockReturnValueOnce('openai-compat') + + await generateImage('user-1', 'openai-compatible:oa-1::gemini-3.1-flash-image', 'draw hero') + + expect(generateImageViaOpenAICompatTemplateMock).toHaveBeenCalledWith(expect.objectContaining({ + modelId: 'gemini-3.1-flash-image-preview', + })) + }) + it('routes official image requests to provider generator', async () => { resolveModelSelectionMock.mockResolvedValueOnce({ provider: 'google',