Compare commits

...
10 Commits
34 changed files with 495 additions and 70 deletions
+4
View File
@@ -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 探针结果缓存毫秒数
+13
View File
@@ -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 归档。
+13
View File
@@ -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>
)
}
+2 -1
View File
@@ -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 },
+34
View File
@@ -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 })
}
+11 -2
View File
@@ -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,
})
+12 -3
View File
@@ -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,
})
+11 -2
View File
@@ -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,
})
+13 -3
View File
@@ -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,
})
+11 -2
View File
@@ -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,
})
+11 -2
View File
@@ -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,
})
+11 -2
View File
@@ -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,
})
+11 -3
View File
@@ -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,
})
+11 -3
View File
@@ -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,
})
+9 -1
View File
@@ -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,
})
+9
View File
@@ -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 })
}
+9 -1
View File
@@ -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,
})
+11 -3
View File
@@ -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,
})
+11 -2
View File
@@ -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,
})
+3 -2
View File
@@ -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 })
}
+2 -1
View File
@@ -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 })
}
}
+11 -3
View File
@@ -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,
})
+9 -1
View File
@@ -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,
})
+10 -2
View File
@@ -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,
})
+10 -2
View File
@@ -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,
})
+1
View File
@@ -48,6 +48,7 @@ const NAV_SECTIONS: NavSection[] = [
label: '系统',
items: [
{ href: '/admin/site', label: '站点配置', icon: '⚙️' },
{ href: '/admin/logs', label: '日志', icon: '📋' },
],
},
]
+185
View File
@@ -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>
)
}
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
}
+3 -2
View File
@@ -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' }
}
}
+2 -1
View File
@@ -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
View File
@@ -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
}