generated from haoqimao/MatchU--
Compare commits
10
Commits
05e24ed486
...
ed34144d26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed34144d26 | ||
|
|
c20e6a9b8e | ||
|
|
428c998537 | ||
|
|
4ea91f9f53 | ||
|
|
5c9a51b8fc | ||
|
|
d8de292cf0 | ||
|
|
7340153fe0 | ||
|
|
8471bde8b7 | ||
|
|
c04059d920 | ||
|
|
3829714fa2 |
@@ -26,3 +26,7 @@ AI_PROVIDER=mock
|
||||
# XUNHUPAY_APPID=
|
||||
# XUNHUPAY_KEY=
|
||||
# XUNHUPAY_NOTIFY_URL=https://your-domain.com/api/payment/webhook
|
||||
|
||||
# 限流 / 日志可调
|
||||
# LOG_LEVEL=info # debug | info | warn | error
|
||||
# HEALTH_CACHE_MS=10000 # /api/health 探针结果缓存毫秒数
|
||||
|
||||
@@ -120,3 +120,16 @@ pm2 logs matchu
|
||||
pm2 restart matchu
|
||||
pm2 stop matchu
|
||||
```
|
||||
|
||||
## 日志轮转(一次性配置)
|
||||
|
||||
pm2 默认把 stdout/stderr 写到 `~/.pm2/logs/` 下,不轮转。在新机器上首次部署后,跑一次:
|
||||
|
||||
```bash
|
||||
pm2 install pm2-logrotate
|
||||
pm2 set pm2-logrotate:max_size 10M
|
||||
pm2 set pm2-logrotate:retain 7
|
||||
pm2 set pm2-logrotate:compress true
|
||||
```
|
||||
|
||||
之后每个 10MB 自动切一份,保留 7 份(约 70MB 上限),老的 gzip 归档。
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AdminChrome } from '@/components/admin/AdminChrome'
|
||||
import { isAdminAuthenticated } from '@/lib/admin/session'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { LogsViewer } from '@/components/admin/LogsViewer'
|
||||
|
||||
export default async function Page() {
|
||||
if (!(await isAdminAuthenticated())) redirect('/admin/login')
|
||||
return (
|
||||
<AdminChrome>
|
||||
<LogsViewer />
|
||||
</AdminChrome>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAdmin } from '@/lib/admin/api'
|
||||
import { seedAnalytics, clearTestData } from '@/lib/db/analytics'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
export async function POST() {
|
||||
const authError = await requireAdmin()
|
||||
@@ -10,7 +11,7 @@ export async function POST() {
|
||||
const result = seedAnalytics(500)
|
||||
return NextResponse.json({ ok: true, ...result })
|
||||
} catch (error) {
|
||||
console.error('[analytics-seed]', error)
|
||||
log.error('admin_analytics_seed_error', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Seed failed' },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isAdminAuthenticated } from '@/lib/admin/session'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
import { listRateLimitEvents } from '@/lib/db/analytics'
|
||||
import type { LogLevel } from '@/lib/observability/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const VALID_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ code: 'UNAUTHORIZED' }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const view = url.searchParams.get('view') ?? 'logs'
|
||||
const limit = Math.min(500, Number(url.searchParams.get('limit') ?? '100'))
|
||||
const sinceRaw = url.searchParams.get('since')
|
||||
const since = sinceRaw ? Number(sinceRaw) : undefined
|
||||
|
||||
if (view === 'rate_limit') {
|
||||
const rows = listRateLimitEvents({ limit, since })
|
||||
return NextResponse.json({ view, rows })
|
||||
}
|
||||
|
||||
const levelParam = url.searchParams.get('level')
|
||||
const level = levelParam && (VALID_LEVELS as string[]).includes(levelParam)
|
||||
? (levelParam as LogLevel)
|
||||
: undefined
|
||||
|
||||
const events = log.recent({ level, limit, since })
|
||||
return NextResponse.json({ view: 'logs', rows: events })
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { z } from 'zod'
|
||||
import { aiProvider } from '@/lib/ai'
|
||||
import type { Message } from '@/lib/ai/types'
|
||||
import type { ChatMessage } from '@/lib/ai/types'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
interface RequestBody {
|
||||
chatHistory: ChatMessage[]
|
||||
@@ -19,7 +21,7 @@ const SuggestionsSchema = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
let body: RequestBody
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -57,7 +59,7 @@ export async function POST(request: Request) {
|
||||
const result = await aiProvider.json({ messages, schema: SuggestionsSchema })
|
||||
return Response.json(result)
|
||||
} catch (err) {
|
||||
console.error('[assist-suggest] error', err)
|
||||
log.error('ai_route_error', err, { route: '/api/ai/assist-suggest' })
|
||||
return Response.json({
|
||||
suggestions: [
|
||||
{ style: 'care', emoji: '🫂', label: '关心', text: '你还好吗,最近怎么样' },
|
||||
@@ -67,3 +69,10 @@ export async function POST(request: Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/ai/assist-suggest',
|
||||
keyPrefix: 'ai',
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
interface HistoryItem {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -14,7 +16,7 @@ interface RequestBody {
|
||||
const DEFAULT_SYSTEM =
|
||||
'你是一个温柔、自然、有情绪价值的AI虚拟陪伴对象。回复要像真人聊天,不要太像机器人,语气简短、有陪伴感。'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: '未配置 DEEPSEEK_API_KEY,请在服务端环境变量中设置' }, { status: 500 })
|
||||
@@ -56,13 +58,13 @@ export async function POST(request: Request) {
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[ai/chat] fetch error', err)
|
||||
log.error('deepseek_fetch_error', err, { route: '/api/ai/chat' })
|
||||
return NextResponse.json({ error: '网络请求失败,请稍后重试' }, { status: 502 })
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error('[ai/chat] DeepSeek error', res.status, text)
|
||||
log.error('deepseek_http_error', undefined, { route: '/api/ai/chat', status: res.status, body: text })
|
||||
return NextResponse.json({ error: 'AI 服务暂时不可用' }, { status: 502 })
|
||||
}
|
||||
|
||||
@@ -75,3 +77,10 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ reply })
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/ai/chat',
|
||||
keyPrefix: 'ai',
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { aiProvider } from '@/lib/ai'
|
||||
import type { Message } from '@/lib/ai/types'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
interface RequestBody {
|
||||
taName: string
|
||||
@@ -9,7 +11,7 @@ interface RequestBody {
|
||||
storyScene: string
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
let body: RequestBody
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -51,7 +53,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
console.error('[heartbeat-story] stream error', err)
|
||||
log.error('ai_stream_error', err, { route: '/api/ai/heartbeat-story' })
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
@@ -64,3 +66,10 @@ export async function POST(request: Request) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/ai/heartbeat-story',
|
||||
keyPrefix: 'ai',
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
const BASE_URL = 'https://api.deepseek.com/v1'
|
||||
|
||||
interface IncomingMsg {
|
||||
@@ -23,7 +26,7 @@ const SYSTEM_PROMPTS: Record<string, string> = {
|
||||
用简短有温度的中文回复(10~30字),纯口语,不用句号结尾,像真实暧昧对象一样聊天,偶尔带可爱颜文字,不要像机器人。`,
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
let body: RequestBody
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -66,7 +69,7 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
console.error('[icebreaker] DeepSeek error', res.status, errText)
|
||||
log.error('deepseek_http_error', undefined, { route: '/api/ai/icebreaker', status: res.status, body: errText })
|
||||
return new Response('AI 请求失败,请稍后重试', { status: 502 })
|
||||
}
|
||||
|
||||
@@ -99,7 +102,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
console.error('[icebreaker] stream read error', err)
|
||||
log.error('ai_stream_error', err, { route: '/api/ai/icebreaker' })
|
||||
controller.close()
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
@@ -115,3 +118,10 @@ export async function POST(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/ai/icebreaker',
|
||||
keyPrefix: 'ai',
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { aiProvider } from '@/lib/ai'
|
||||
import type { Message } from '@/lib/ai/types'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
interface RequestBody {
|
||||
questionPrompt: string
|
||||
@@ -7,7 +9,7 @@ interface RequestBody {
|
||||
newTags: string[]
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
let body: RequestBody
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -39,7 +41,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
console.error('[quiz-commentary] stream error', err)
|
||||
log.error('ai_stream_error', err, { route: '/api/ai/quiz-commentary' })
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
@@ -52,3 +54,10 @@ export async function POST(request: Request) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/ai/quiz-commentary',
|
||||
keyPrefix: 'ai',
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { ChatMessage } from '@/lib/ai/types'
|
||||
import { getUserFromRequest } from '@/lib/supabase/auth-server'
|
||||
import { isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { isVip, getChatUsageToday, incrementChatUsage } from '@/lib/payment/subscription'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
const DAILY_CHAT_LIMIT = 10
|
||||
|
||||
@@ -17,7 +19,7 @@ interface RequestBody {
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
let body: RequestBody
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -78,7 +80,7 @@ ${bio}
|
||||
}
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
console.error('[xiaoyu-reply] stream error', err)
|
||||
log.error('ai_stream_error', err, { route: '/api/ai/xiaoyu-reply' })
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
@@ -92,3 +94,10 @@ ${bio}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/ai/xiaoyu-reply',
|
||||
keyPrefix: 'ai',
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { aiProvider } from '@/lib/ai'
|
||||
import type { Message } from '@/lib/ai/types'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
const ZodiacPairingSchema = z.object({
|
||||
score: z.number().int().min(0).max(100),
|
||||
@@ -32,7 +34,7 @@ const FALLBACK: ZodiacPairingResult = {
|
||||
todayFortune: '今日星象平稳,适合保持现有的互动节奏,小惊喜胜过大表白。',
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
let body: { sign1: string; sign2: string; date: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -75,7 +77,14 @@ export async function POST(request: Request) {
|
||||
const result = await aiProvider.json({ messages, schema: ZodiacPairingSchema })
|
||||
return Response.json(result)
|
||||
} catch (err) {
|
||||
console.error('[zodiac-pairing] error', err)
|
||||
log.error('ai_route_error', err, { route: '/api/ai/zodiac-pairing' })
|
||||
return Response.json(FALLBACK)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/ai/zodiac-pairing',
|
||||
keyPrefix: 'ai',
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
})
|
||||
|
||||
@@ -3,12 +3,13 @@ import { requireUser } from '@/lib/supabase/auth-server'
|
||||
import { routeError } from '@/lib/admin/api'
|
||||
import { isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { acceptFriendRequest } from '@/lib/backend/friends-repository'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
const EXPECTED_ERRORS = new Set(['申请不存在', '无权操作', '申请已处理'])
|
||||
|
||||
export async function POST(
|
||||
async function handler(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
ctx?: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return NextResponse.json({ error: 'Supabase is not configured.' }, { status: 503 })
|
||||
@@ -16,7 +17,7 @@ export async function POST(
|
||||
const { user, response } = await requireUser(request)
|
||||
if (response) return response
|
||||
|
||||
const { id } = await params
|
||||
const { id } = await ctx!.params
|
||||
try {
|
||||
await acceptFriendRequest(id, user!.id)
|
||||
return NextResponse.json({ ok: true })
|
||||
@@ -27,3 +28,10 @@ export async function POST(
|
||||
return routeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/friends/requests/[id]/accept',
|
||||
keyPrefix: 'friends',
|
||||
capacity: 60,
|
||||
refillPerSecond: 1.0,
|
||||
})
|
||||
|
||||
@@ -3,12 +3,13 @@ import { requireUser } from '@/lib/supabase/auth-server'
|
||||
import { routeError } from '@/lib/admin/api'
|
||||
import { isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { rejectFriendRequest } from '@/lib/backend/friends-repository'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
const EXPECTED_ERRORS = new Set(['申请不存在', '无权操作', '申请已处理'])
|
||||
|
||||
export async function POST(
|
||||
async function handler(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
ctx?: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return NextResponse.json({ error: 'Supabase is not configured.' }, { status: 503 })
|
||||
@@ -16,7 +17,7 @@ export async function POST(
|
||||
const { user, response } = await requireUser(request)
|
||||
if (response) return response
|
||||
|
||||
const { id } = await params
|
||||
const { id } = await ctx!.params
|
||||
try {
|
||||
await rejectFriendRequest(id, user!.id)
|
||||
return NextResponse.json({ ok: true })
|
||||
@@ -27,3 +28,10 @@ export async function POST(
|
||||
return routeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/friends/requests/[id]/reject',
|
||||
keyPrefix: 'friends',
|
||||
capacity: 60,
|
||||
refillPerSecond: 1.0,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requireUser } from '@/lib/supabase/auth-server'
|
||||
import { routeError } from '@/lib/admin/api'
|
||||
import { isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { getPendingRequestsForUser, sendFriendRequest, getUnreadFriendRequestCount } from '@/lib/backend/friends-repository'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
const BUSINESS_ERRORS = new Set([
|
||||
'不能添加自己',
|
||||
@@ -29,7 +30,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return NextResponse.json({ error: 'Supabase is not configured.' }, { status: 503 })
|
||||
}
|
||||
@@ -50,3 +51,10 @@ export async function POST(request: Request) {
|
||||
return routeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/friends/requests',
|
||||
keyPrefix: 'friends',
|
||||
capacity: 60,
|
||||
refillPerSecond: 1.0,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getHealth } from '@/lib/observability/health'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
const h = await getHealth()
|
||||
return NextResponse.json(h, { status: h.ok ? 200 : 503 })
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { setUserAvatar } from '@/lib/backend/user-repository'
|
||||
import { saveUploadedImage } from '@/lib/storage/local'
|
||||
import { routeError } from '@/lib/admin/api'
|
||||
import { isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return NextResponse.json({ error: 'Supabase is not configured.' }, { status: 503 })
|
||||
}
|
||||
@@ -26,3 +27,10 @@ export async function POST(request: Request) {
|
||||
return routeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/me/avatar',
|
||||
keyPrefix: 'upload',
|
||||
capacity: 20,
|
||||
refillPerSecond: 20 / 60,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getConversationMessages,
|
||||
sendMessage,
|
||||
} from '@/lib/backend/conversations-repository'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -31,9 +32,9 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
async function handler(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ userId: string }> }
|
||||
ctx?: { params: Promise<{ userId: string }> }
|
||||
) {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return NextResponse.json({ error: 'Not configured' }, { status: 503 })
|
||||
@@ -41,7 +42,7 @@ export async function POST(
|
||||
const { user, response } = await requireUser(request)
|
||||
if (response) return response
|
||||
|
||||
const { userId } = await params
|
||||
const { userId } = await ctx!.params
|
||||
const body = (await request.json()) as { content?: string }
|
||||
|
||||
if (!body.content?.trim()) {
|
||||
@@ -55,3 +56,10 @@ export async function POST(
|
||||
return routeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/messages/[userId]',
|
||||
keyPrefix: 'messages',
|
||||
capacity: 60,
|
||||
refillPerSecond: 1.0,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
|
||||
import { requireUser } from '@/lib/supabase/auth-server'
|
||||
import { supabaseFetch, restPath, isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { createXunhupayOrder, PLAN_AMOUNTS_FEN } from '@/lib/payment/xunhupay'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
const VALID_PLANS = new Set(['month', 'quarter', 'year'])
|
||||
const VALID_PAY_METHODS = new Set(['wechat', 'alipay'])
|
||||
@@ -10,7 +12,7 @@ function isXunhupayConfigured() {
|
||||
return Boolean(process.env.XUNHUPAY_APPID && process.env.XUNHUPAY_KEY)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return NextResponse.json({ error: 'Supabase not configured' }, { status: 503 })
|
||||
}
|
||||
@@ -67,9 +69,16 @@ export async function POST(request: Request) {
|
||||
pay_url = result.pay_url
|
||||
qr_code = result.qr_code
|
||||
} catch (err) {
|
||||
console.error('[payment/create]', err)
|
||||
log.error('payment_create_error', err, { route: '/api/payment/create' })
|
||||
return NextResponse.json({ error: '支付通道暂时不可用,请稍后重试' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ order_id, pay_url, qr_code })
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/payment/create',
|
||||
keyPrefix: 'payment',
|
||||
capacity: 60,
|
||||
refillPerSecond: 1.0,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { verifyWebhookSign } from '@/lib/payment/xunhupay'
|
||||
import { getOrderByOutTradeNo, activateSubscription } from '@/lib/payment/subscription'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const key = process.env.XUNHUPAY_KEY
|
||||
@@ -9,7 +10,7 @@ export async function POST(request: Request) {
|
||||
const params = Object.fromEntries(new URLSearchParams(text)) as Record<string, string>
|
||||
|
||||
if (!verifyWebhookSign(params, key)) {
|
||||
console.warn('[webhook] invalid signature', params)
|
||||
log.warn('payment_webhook_invalid_sig', { hasParams: Object.keys(params).length })
|
||||
return new Response('Bad signature', { status: 400 })
|
||||
}
|
||||
|
||||
@@ -20,7 +21,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const order = await getOrderByOutTradeNo(out_trade_no)
|
||||
if (!order) {
|
||||
console.error('[webhook] order not found', out_trade_no)
|
||||
log.error('payment_webhook_order_missing', undefined, { out_trade_no })
|
||||
return new Response('Order not found', { status: 404 })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPublicPersona } from '@/lib/backend/repository'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -14,7 +15,7 @@ export async function GET(
|
||||
}
|
||||
return NextResponse.json({ persona })
|
||||
} catch (error) {
|
||||
console.error('[personas] error', error)
|
||||
log.error('personas_route_error', error, { route: '/api/personas/[id]' })
|
||||
return NextResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { requireUser } from '@/lib/supabase/auth-server'
|
||||
import { togglePostLike } from '@/lib/backend/user-repository'
|
||||
import { routeError } from '@/lib/admin/api'
|
||||
import { isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
export async function POST(
|
||||
async function handler(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
ctx?: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return NextResponse.json({ error: 'Supabase is not configured.' }, { status: 503 })
|
||||
@@ -14,7 +15,7 @@ export async function POST(
|
||||
const { user, response } = await requireUser(request)
|
||||
if (response) return response
|
||||
|
||||
const { id } = await params
|
||||
const { id } = await ctx!.params
|
||||
try {
|
||||
const result = await togglePostLike(user!, id)
|
||||
return NextResponse.json(result)
|
||||
@@ -22,3 +23,10 @@ export async function POST(
|
||||
return routeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/posts/[id]/like',
|
||||
keyPrefix: 'posts',
|
||||
capacity: 60,
|
||||
refillPerSecond: 1.0,
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { saveUploadedImage, saveUploadedVideo } from '@/lib/storage/local'
|
||||
import { getUserFromRequest, requireUser } from '@/lib/supabase/auth-server'
|
||||
import { isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { routeError } from '@/lib/admin/api'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = isSupabaseConfigured() ? await getUserFromRequest(request).catch(() => null) : null
|
||||
@@ -13,7 +14,7 @@ export async function GET(request: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
async function handler(request: Request) {
|
||||
if (!isSupabaseConfigured()) {
|
||||
return NextResponse.json({ error: 'Supabase is not configured.' }, { status: 503 })
|
||||
}
|
||||
@@ -50,3 +51,10 @@ export async function POST(request: Request) {
|
||||
return routeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/posts',
|
||||
keyPrefix: 'posts',
|
||||
capacity: 60,
|
||||
refillPerSecond: 1.0,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
// Placeholder: returns the original image URL.
|
||||
// Replace with a real AI avatar generation API (e.g. Replicate, FAL.ai) when ready.
|
||||
export async function POST(req: NextRequest) {
|
||||
async function handler(req: Request) {
|
||||
try {
|
||||
const { image_url } = await req.json()
|
||||
if (!image_url) {
|
||||
@@ -14,3 +15,10 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Generation failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/virtual/generate',
|
||||
keyPrefix: 'ai',
|
||||
capacity: 10,
|
||||
refillPerSecond: 10 / 60,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { saveUploadedImage } from '@/lib/storage/local'
|
||||
import { withRateLimit } from '@/lib/observability/with-rate-limit'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
async function handler(req: Request) {
|
||||
try {
|
||||
const form = await req.formData()
|
||||
const file = form.get('file')
|
||||
@@ -15,3 +16,10 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRateLimit(handler, {
|
||||
route: '/api/virtual/upload',
|
||||
keyPrefix: 'upload',
|
||||
capacity: 20,
|
||||
refillPerSecond: 20 / 60,
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
label: '系统',
|
||||
items: [
|
||||
{ href: '/admin/site', label: '站点配置', icon: '⚙️' },
|
||||
{ href: '/admin/logs', label: '日志', icon: '📋' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
|
||||
type View = 'logs' | 'rate_limit'
|
||||
|
||||
interface LogEvent {
|
||||
ts: string
|
||||
level: string
|
||||
msg: string
|
||||
route?: string
|
||||
error?: { name: string; message: string; stack?: string }
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
interface RateLimitRow {
|
||||
id: number
|
||||
ts: number
|
||||
route: string
|
||||
ip: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export function LogsViewer() {
|
||||
const [view, setView] = useState<View>('logs')
|
||||
const [level, setLevel] = useState<string>('all')
|
||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
||||
const [logs, setLogs] = useState<LogEvent[]>([])
|
||||
const [rl, setRl] = useState<RateLimitRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const params = new URLSearchParams({ view, limit: '200' })
|
||||
if (view === 'logs' && level !== 'all') params.set('level', level)
|
||||
const res = await fetch(`/api/admin/logs?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (view === 'logs') setLogs(data.rows ?? [])
|
||||
else setRl(data.rows ?? [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
if (!autoRefresh) return
|
||||
const t = setInterval(fetchData, 10_000)
|
||||
return () => clearInterval(t)
|
||||
}, [view, level, autoRefresh])
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold">日志</h1>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button
|
||||
onClick={() => setView('logs')}
|
||||
className={`px-3 py-1 rounded text-sm ${view === 'logs' ? 'bg-amber-500 text-white' : 'bg-zinc-700 text-zinc-200'}`}
|
||||
>日志</button>
|
||||
<button
|
||||
onClick={() => setView('rate_limit')}
|
||||
className={`px-3 py-1 rounded text-sm ${view === 'rate_limit' ? 'bg-amber-500 text-white' : 'bg-zinc-700 text-zinc-200'}`}
|
||||
>被限流</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'logs' && (
|
||||
<div className="flex gap-2 items-center text-sm">
|
||||
<span className="text-zinc-400">级别:</span>
|
||||
{['all', 'warn', 'error'].map((lv) => (
|
||||
<button
|
||||
key={lv}
|
||||
onClick={() => setLevel(lv)}
|
||||
className={`px-2 py-1 rounded ${level === lv ? 'bg-amber-500 text-white' : 'bg-zinc-800 text-zinc-300'}`}
|
||||
>{lv}</button>
|
||||
))}
|
||||
<label className="ml-auto flex items-center gap-2">
|
||||
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} />
|
||||
<span className="text-zinc-400">10s 自动刷新</span>
|
||||
</label>
|
||||
<button onClick={fetchData} className="px-3 py-1 rounded bg-zinc-700 text-zinc-200">刷新</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded bg-red-900 text-red-100 text-sm">加载失败:{error}</div>
|
||||
)}
|
||||
|
||||
{view === 'logs' && (
|
||||
<div className="bg-zinc-900 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-zinc-800 text-zinc-400">
|
||||
<tr>
|
||||
<th className="text-left p-2 w-44">时间</th>
|
||||
<th className="text-left p-2 w-16">级别</th>
|
||||
<th className="text-left p-2 w-56">route</th>
|
||||
<th className="text-left p-2">消息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 && (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-zinc-500">暂无日志</td></tr>
|
||||
)}
|
||||
{logs.map((ev, idx) => {
|
||||
const id = `${ev.ts}-${idx}`
|
||||
const isOpen = expanded.has(id)
|
||||
return (
|
||||
<Fragment key={id}>
|
||||
<tr
|
||||
className="border-t border-zinc-800 cursor-pointer hover:bg-zinc-800"
|
||||
onClick={() => toggle(id)}
|
||||
>
|
||||
<td className="p-2 text-zinc-400 font-mono text-xs">{ev.ts.replace('T', ' ').slice(0, 19)}</td>
|
||||
<td className="p-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
ev.level === 'error' ? 'bg-red-900 text-red-100' :
|
||||
ev.level === 'warn' ? 'bg-amber-900 text-amber-100' :
|
||||
'bg-zinc-700 text-zinc-200'
|
||||
}`}>{ev.level}</span>
|
||||
</td>
|
||||
<td className="p-2 text-zinc-300 font-mono text-xs">{ev.route ?? '—'}</td>
|
||||
<td className="p-2 text-zinc-200">{ev.msg}</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr className="bg-zinc-950">
|
||||
<td colSpan={4} className="p-3">
|
||||
<pre className="text-xs text-zinc-300 whitespace-pre-wrap overflow-x-auto">
|
||||
{JSON.stringify(ev, null, 2)}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'rate_limit' && (
|
||||
<div className="bg-zinc-900 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-zinc-800 text-zinc-400">
|
||||
<tr>
|
||||
<th className="text-left p-2 w-44">时间</th>
|
||||
<th className="text-left p-2 w-56">route</th>
|
||||
<th className="text-left p-2 w-40">IP</th>
|
||||
<th className="text-left p-2">key</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rl.length === 0 && (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-zinc-500">最近没有被限流的请求</td></tr>
|
||||
)}
|
||||
{rl.map((row) => (
|
||||
<tr key={row.id} className="border-t border-zinc-800">
|
||||
<td className="p-2 text-zinc-400 font-mono text-xs">
|
||||
{new Date(row.ts).toISOString().replace('T', ' ').slice(0, 19)}
|
||||
</td>
|
||||
<td className="p-2 text-zinc-300 font-mono text-xs">{row.route}</td>
|
||||
<td className="p-2 text-zinc-300 font-mono text-xs">{row.ip}</td>
|
||||
<td className="p-2 text-zinc-300 font-mono text-xs">{row.key}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ describe('getHealth', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns ok shape with three checks', async () => {
|
||||
@@ -43,8 +44,8 @@ describe('getHealth', () => {
|
||||
})
|
||||
|
||||
it('marks supabase as skipped when not configured', async () => {
|
||||
delete process.env.SUPABASE_URL
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
vi.stubEnv('SUPABASE_URL', '')
|
||||
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', '')
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 200 })))
|
||||
const h = await getHealth()
|
||||
expect(h.checks.supabase.skipped).toBe(true)
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isAdminAuthenticated } from './session'
|
||||
import { isSupabaseConfigured } from '@/lib/supabase/rest'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
export async function requireAdmin() {
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
@@ -20,7 +21,7 @@ export function requireSupabase() {
|
||||
}
|
||||
|
||||
export function routeError(error: unknown) {
|
||||
console.error(error)
|
||||
log.error('admin_api_error', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Unexpected server error' },
|
||||
{ status: 500 }
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import type { AIProvider, Message } from './types'
|
||||
import type { ZodSchema } from 'zod'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
const DEFAULT_BASE = 'https://api.deepseek.com'
|
||||
const DEFAULT_MODEL = 'deepseek-chat'
|
||||
@@ -38,7 +39,7 @@ export class DeepSeekProvider implements AIProvider {
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.DEEPSEEK_API_KEY ?? ''
|
||||
if (!this.apiKey) console.warn('[deepseek] DEEPSEEK_API_KEY not set')
|
||||
if (!this.apiKey) log.warn('deepseek_key_missing')
|
||||
}
|
||||
|
||||
async *stream(opts: {
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import type { AIProvider } from './types'
|
||||
import { MockProvider } from './mock'
|
||||
import { DeepSeekProvider } from './deepseek'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
function selectProvider(): AIProvider {
|
||||
const provider = process.env.AI_PROVIDER ?? 'mock'
|
||||
@@ -10,7 +11,7 @@ function selectProvider(): AIProvider {
|
||||
case 'deepseek':
|
||||
return new DeepSeekProvider()
|
||||
default:
|
||||
console.warn(`[ai] Unknown AI_PROVIDER "${provider}", falling back to mock`)
|
||||
log.warn('ai_provider_unknown', { provider })
|
||||
return new MockProvider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ProfileRow,
|
||||
} from './types'
|
||||
import { getFallbackPersona, getFallbackProfiles } from './fallback'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
const PROFILE_SELECT =
|
||||
'id,display_name,age,location,distance,mbti,profession,bio,tags,compatibility,avatar_gradient_from,avatar_gradient_to,face_tone,hair_color,photo_url,chat_preview,chat_time,unread_count,online,verified,compatible_tags,status,sort_order,created_at,updated_at'
|
||||
@@ -38,7 +39,7 @@ export async function listPublicProfiles(): Promise<{ profiles: ProfileCardData[
|
||||
const rows = await supabaseFetch<ProfileRow[]>(restPath('profiles', query.toString()))
|
||||
return { profiles: rows.map(mapProfileRow), source: 'supabase' }
|
||||
} catch (error) {
|
||||
console.error('[profiles] falling back to mock data', error)
|
||||
log.error('profiles_fallback', error)
|
||||
return { profiles: getFallbackProfiles(), source: 'mock' }
|
||||
}
|
||||
}
|
||||
@@ -75,7 +76,7 @@ export async function getPublicPersona(
|
||||
|
||||
return { persona: mapChatPersonaRow(row, scripts), source: 'supabase' }
|
||||
} catch (error) {
|
||||
console.error('[persona] falling back to mock data', error)
|
||||
log.error('persona_fallback', error)
|
||||
return { persona: getFallbackPersona(id), source: 'mock' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type UserProfileRow,
|
||||
} from './user-types'
|
||||
import type { SupabaseAuthUser } from '@/lib/supabase/auth-server'
|
||||
import { log } from '@/lib/observability/logger'
|
||||
|
||||
const USER_SELECT =
|
||||
'id,display_name,avatar_url,age,city,profession,mbti,bio,verified,likes_received,matches_count,highest_compatibility,created_at,updated_at'
|
||||
@@ -95,7 +96,7 @@ export async function listPublishedPosts(currentUserId?: string): Promise<{ post
|
||||
source: 'supabase',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[posts] falling back to mock data', error)
|
||||
log.error('posts_fallback', error, { source: 'user-repository' })
|
||||
return { posts: fallbackCommunityPosts, source: 'mock' }
|
||||
}
|
||||
}
|
||||
|
||||
+33
-21
@@ -1,11 +1,13 @@
|
||||
import type { CheckResult, HealthResponse } from './types'
|
||||
import { log } from './logger'
|
||||
|
||||
const CACHE_MS = Number(process.env.HEALTH_CACHE_MS ?? 10_000)
|
||||
const PARSED_CACHE_MS = Number(process.env.HEALTH_CACHE_MS)
|
||||
const CACHE_MS = Number.isFinite(PARSED_CACHE_MS) && PARSED_CACHE_MS >= 0 ? PARSED_CACHE_MS : 10_000
|
||||
const PROBE_TIMEOUT_MS = 5_000
|
||||
const PROCESS_START = Date.now()
|
||||
|
||||
let cached: { at: number; result: HealthResponse } | null = null
|
||||
let inflight: Promise<HealthResponse> | null = null
|
||||
|
||||
async function probeDeepSeek(): Promise<CheckResult> {
|
||||
const key = process.env.DEEPSEEK_API_KEY
|
||||
@@ -72,32 +74,42 @@ async function probeSqlite(): Promise<CheckResult> {
|
||||
|
||||
export async function getHealth(): Promise<HealthResponse> {
|
||||
if (cached && Date.now() - cached.at < CACHE_MS) return cached.result
|
||||
if (inflight) return inflight
|
||||
|
||||
const [deepseek, supabase, sqlite] = await Promise.all([
|
||||
probeDeepSeek().catch((e) => ({ ok: false, latencyMs: 0, error: String(e) })),
|
||||
probeSupabase().catch((e) => ({ ok: false, latencyMs: 0, error: String(e) })),
|
||||
probeSqlite().catch((e) => ({ ok: false, latencyMs: 0, error: String(e) })),
|
||||
])
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const [deepseek, supabase, sqlite] = await Promise.all([
|
||||
probeDeepSeek().catch((e) => ({ ok: false, latencyMs: 0, error: String(e) })),
|
||||
probeSupabase().catch((e) => ({ ok: false, latencyMs: 0, error: String(e) })),
|
||||
probeSqlite().catch((e) => ({ ok: false, latencyMs: 0, error: String(e) })),
|
||||
])
|
||||
|
||||
// ok = supabase and sqlite must be healthy (deepseek failure tolerated thanks to Mock fallback)
|
||||
const result: HealthResponse = {
|
||||
ok: supabase.ok && sqlite.ok,
|
||||
uptimeSec: Math.floor((Date.now() - PROCESS_START) / 1000),
|
||||
checks: { deepseek, supabase, sqlite },
|
||||
}
|
||||
// ok = supabase and sqlite must be healthy (deepseek failure tolerated thanks to Mock fallback)
|
||||
const result: HealthResponse = {
|
||||
ok: supabase.ok && sqlite.ok,
|
||||
uptimeSec: Math.floor((Date.now() - PROCESS_START) / 1000),
|
||||
checks: { deepseek, supabase, sqlite },
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
log.warn('health_check_degraded', {
|
||||
deepseek: deepseek.ok,
|
||||
supabase: supabase.ok,
|
||||
sqlite: sqlite.ok,
|
||||
})
|
||||
}
|
||||
if (!result.ok) {
|
||||
log.warn('health_check_degraded', {
|
||||
deepseek: deepseek.ok,
|
||||
supabase: supabase.ok,
|
||||
sqlite: sqlite.ok,
|
||||
})
|
||||
}
|
||||
|
||||
cached = { at: Date.now(), result }
|
||||
return result
|
||||
cached = { at: Date.now(), result }
|
||||
return result
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function __resetHealthForTests(): void {
|
||||
cached = null
|
||||
inflight = null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user