diff --git a/apps/backend/.env.production b/apps/backend/.env.production index 9b25bf2..ed31cb2 100644 --- a/apps/backend/.env.production +++ b/apps/backend/.env.production @@ -22,5 +22,5 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com DEEPSEEK_MODEL=deepseek-chat # Feature flags -AI_ENABLE_CLASSIFICATION=false -AI_AUTO_LEARN_RULES=false +AI_ENABLE_CLASSIFICATION=true +AI_AUTO_LEARN_RULES=true diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 76a43d1..4404086 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,6 +7,7 @@ import { router } from './routes/index.js'; import { notFoundHandler } from './middlewares/not-found.js'; import { errorHandler } from './middlewares/error-handler.js'; import { logger } from './config/logger.js'; +import { auditLogMiddleware } from './middlewares/audit-log.js'; export function createApp() { const app = express(); @@ -34,6 +35,9 @@ export function createApp() { }) ); + // Request/response audit log (after parsers, before routes) + app.use(auditLogMiddleware); + app.get('/', (_req, res) => { res.json({ name: 'AI Bill API', diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts index 7203646..3a32815 100644 --- a/apps/backend/src/config/env.ts +++ b/apps/backend/src/config/env.ts @@ -24,15 +24,16 @@ const envSchema = z NOTIFICATION_ALLOWED_PACKAGES: z.string().optional(), GEMINI_API_KEY: z.string().optional(), OCR_PROJECT_ID: z.string().optional(), - FILE_STORAGE_BUCKET: z.string().optional() + FILE_STORAGE_BUCKET: z.string().optional(), + // DeepSeek / AI + DEEPSEEK_API_KEY: z.string().optional(), + DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'), + DEEPSEEK_MODEL: z.string().default('deepseek-chat'), + AI_ENABLE_CLASSIFICATION: z.coerce.boolean().default(false), + AI_AUTO_LEARN_RULES: z.coerce.boolean().default(false) }) .transform((values) => ({ ...values, - DEEPSEEK_API_KEY: (values as any).DEEPSEEK_API_KEY as string | undefined, - DEEPSEEK_BASE_URL: ((values as any).DEEPSEEK_BASE_URL as string | undefined) ?? 'https://api.deepseek.com', - DEEPSEEK_MODEL: ((values as any).DEEPSEEK_MODEL as string | undefined) ?? 'deepseek-chat', - AI_ENABLE_CLASSIFICATION: ((values as any).AI_ENABLE_CLASSIFICATION as string | undefined) === 'true', - AI_AUTO_LEARN_RULES: ((values as any).AI_AUTO_LEARN_RULES as string | undefined) === 'true', isProduction: values.NODE_ENV === 'production', notificationPackageWhitelist: values.NOTIFICATION_ALLOWED_PACKAGES ? values.NOTIFICATION_ALLOWED_PACKAGES.split(',').map((pkg) => pkg.trim()).filter(Boolean) diff --git a/apps/backend/src/middlewares/audit-log.ts b/apps/backend/src/middlewares/audit-log.ts new file mode 100644 index 0000000..786b233 --- /dev/null +++ b/apps/backend/src/middlewares/audit-log.ts @@ -0,0 +1,64 @@ +import type { NextFunction, Request, Response } from 'express'; +import { logger } from '../config/logger.js'; + +const REDACT_KEYS = new Set(['password', 'token', 'refreshToken', 'authorization', 'apiKey', 'secret']); + +function redact(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map((v) => redact(v)); + if (typeof value === 'object') { + const obj: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + obj[k] = REDACT_KEYS.has(k.toLowerCase()) ? '***redacted***' : redact(v); + } + return obj; + } + return value; +} + +function truncate(str: unknown, max = 2000): unknown { + if (typeof str !== 'string') return str; + if (str.length <= max) return str; + return str.slice(0, max) + `…(+${str.length - max} chars)`; +} + +export function auditLogMiddleware(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + const reqInfo = { + method: req.method, + url: req.originalUrl || req.url, + query: redact(req.query), + body: redact(req.body) + }; + + // Capture response body by monkey-patching res.json/res.send + let responseBody: unknown; + const originalJson = res.json.bind(res); + const originalSend = res.send.bind(res); + + res.json = ((body?: any) => { + responseBody = body; + return originalJson(body); + }) as typeof res.json; + + res.send = ((body?: any) => { + responseBody = body; + return originalSend(body); + }) as typeof res.send; + + res.on('finish', () => { + const duration = Date.now() - start; + const status = res.statusCode; + let out: unknown = responseBody; + if (typeof out === 'string') { + // Try to avoid logging massive HTML or binary-like outputs + out = truncate(out, 2000); + } else if (typeof out === 'object') { + out = redact(out); + } + logger.info({ ...reqInfo, status, durationMs: duration, response: out }, 'HTTP'); + }); + + next(); +} + diff --git a/apps/backend/src/modules/ai/ai.controller.ts b/apps/backend/src/modules/ai/ai.controller.ts new file mode 100644 index 0000000..1d625e9 --- /dev/null +++ b/apps/backend/src/modules/ai/ai.controller.ts @@ -0,0 +1,31 @@ +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { env } from '../../config/env.js'; +import { aiClassifyByText } from '../../services/ai.service.js'; + +const classifySchema = z.object({ + title: z.string().min(1), + body: z.string().optional() +}); + +export async function getAiStatusHandler(_req: Request, res: Response) { + const configured = Boolean(env.DEEPSEEK_API_KEY); + return res.json({ + data: { + configured, + enableClassification: env.AI_ENABLE_CLASSIFICATION, + autoLearnRules: env.AI_AUTO_LEARN_RULES, + model: env.DEEPSEEK_MODEL, + baseURL: env.DEEPSEEK_BASE_URL + } + }); +} + +export async function classifyTextHandler(req: Request, res: Response) { + if (!env.DEEPSEEK_API_KEY) return res.status(503).json({ message: 'AI is not configured' }); + const { title, body } = classifySchema.parse(req.body); + const data = await aiClassifyByText(title, body); + if (!data) return res.status(200).json({ message: 'No AI classification available' }); + return res.json({ data }); +} + diff --git a/apps/backend/src/modules/ai/ai.router.ts b/apps/backend/src/modules/ai/ai.router.ts new file mode 100644 index 0000000..181f38b --- /dev/null +++ b/apps/backend/src/modules/ai/ai.router.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { authenticate } from '../../middlewares/authenticate.js'; +import { getAiStatusHandler, classifyTextHandler } from './ai.controller.js'; + +const router = Router(); + +router.use(authenticate); + +router.get('/status', getAiStatusHandler); +router.post('/classify', classifyTextHandler); + +export default router; + diff --git a/apps/backend/src/modules/transactions/transactions.controller.ts b/apps/backend/src/modules/transactions/transactions.controller.ts index 670e571..4755296 100644 --- a/apps/backend/src/modules/transactions/transactions.controller.ts +++ b/apps/backend/src/modules/transactions/transactions.controller.ts @@ -110,10 +110,10 @@ export const createTransactionFromNotificationHandler = async (req: Request, res export const aiClassifyTransactionHandler = async (req: Request, res: Response) => { if (!req.user) return res.status(401).json({ message: 'Authentication required' }); + if (!env.DEEPSEEK_API_KEY) return res.status(503).json({ message: 'AI is not configured' }); const id = req.params.id; const txn = await getTransactionById(id, req.user.id); if (!txn) return res.status(404).json({ message: 'Transaction not found' }); - if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' }); const bodyText = (txn.notes as string | undefined) ?? (txn.metadata as any)?.rawBody ?? ''; const ai = await aiClassifyByText(txn.title, bodyText); if (!ai || (!ai.category && !ai.type && !ai.amount)) { @@ -176,6 +176,7 @@ export const createRuleFromTransactionHandler = async (req: Request, res: Respon export const aiSuggestRuleFromTransactionHandler = async (req: Request, res: Response) => { if (!req.user) return res.status(401).json({ message: 'Authentication required' }); + if (!env.DEEPSEEK_API_KEY) return res.status(503).json({ message: 'AI is not configured' }); const id = req.params.id; const txn = await getTransactionById(id, req.user.id); if (!txn) return res.status(404).json({ message: 'Transaction not found' }); diff --git a/apps/backend/src/modules/transactions/transactions.service.ts b/apps/backend/src/modules/transactions/transactions.service.ts index a167037..d0c5d26 100644 --- a/apps/backend/src/modules/transactions/transactions.service.ts +++ b/apps/backend/src/modules/transactions/transactions.service.ts @@ -68,19 +68,36 @@ export async function createTransaction(payload: CreateTransactionInput, userId? const needsAutoCategory = !payload.category || ['未分类', '待分类', '待分類', '类别待定', '待确认'].includes(payload.category); const classification = needsAutoCategory ? classifyByText(payload.title, payload.notes) : null; + let aiClassification: Awaited> | null = null; + if (needsAutoCategory && env.AI_ENABLE_CLASSIFICATION) { + try { + aiClassification = await aiClassifyByText(payload.title, payload.notes ?? ''); + } catch { + aiClassification = null; + } + } - const resolvedCategory = classification?.category ?? payload.category ?? (classification?.type === 'income' ? '收入' : '其他支出'); - const resolvedType = classification?.type ?? payload.type; + const resolvedCategory = + aiClassification?.category ?? classification?.category ?? payload.category ?? (classification?.type === 'income' ? '收入' : '其他支出'); + const resolvedType = (aiClassification?.type as any) ?? classification?.type ?? payload.type; const metadata = - classification && classification.ruleId + aiClassification ? { ...payload.metadata, classification: { - ruleId: classification.ruleId, - confidence: classification.confidence + source: 'ai', + confidence: aiClassification.confidence } } - : payload.metadata; + : classification && classification.ruleId + ? { + ...payload.metadata, + classification: { + ruleId: classification.ruleId, + confidence: classification.confidence + } + } + : payload.metadata; const resolvedUserId = userId ?? payload.userId ?? (await getDefaultUserId()); const { userId: _ignoredUserId, ...restPayload } = payload; diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index 6ad2a8f..08f562e 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -6,6 +6,7 @@ import budgetsRouter from '../modules/budgets/budgets.router.js'; import notificationsRouter from '../modules/notifications/notifications.router.js'; import notificationRulesRouter from '../modules/notifications/notification-rules.router.js'; import categoriesRouter from '../modules/categories/categories.router.js'; +import aiRouter from '../modules/ai/ai.router.js'; const router = Router(); @@ -20,5 +21,6 @@ router.use('/budgets', budgetsRouter); router.use('/notifications', notificationsRouter); router.use('/notification-rules', notificationRulesRouter); router.use('/categories', categoriesRouter); +router.use('/ai', aiRouter); export { router }; diff --git a/apps/backend/src/services/ai.service.ts b/apps/backend/src/services/ai.service.ts index 4d1a507..ba04673 100644 --- a/apps/backend/src/services/ai.service.ts +++ b/apps/backend/src/services/ai.service.ts @@ -27,12 +27,32 @@ export interface AiRuleSuggestion { confidence?: number; // 0-1 } -function safeJson(text: string): T | null { +function extractJson(text: string): T | null { + // Try direct parse try { return JSON.parse(text) as T; - } catch { - return null; + } catch {} + + // Try code fences + const fence = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fence?.[1]) { + try { + return JSON.parse(fence[1]) as T; + } catch {} } + + // Try first {...} block + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start !== -1 && end !== -1 && end > start) { + const slice = text.slice(start, end + 1); + try { + return JSON.parse(slice) as T; + } catch {} + } + + logger.warn({ preview: text?.slice(0, 160) }, 'AI JSON parse failed'); + return null; } export async function aiClassifyByText(title: string, body?: string): Promise { @@ -45,15 +65,19 @@ export async function aiClassifyByText(title: string, body?: string): Promise(content); + const parsed = extractJson(content); + logger.info({ contentPreview: content.slice(0, 200), parsed }, 'AI classify response'); return parsed ?? null; } catch (error) { logger.warn({ error }, 'AI classify failed'); @@ -84,19 +108,22 @@ export async function aiSuggestRuleForNotification(input: { 标题: ${input.title}\n正文: ${input.body}\n包名: ${input.packageName}`; try { + logger.info({ model: env.DEEPSEEK_MODEL, packageName: input.packageName, titlePreview: input.title?.slice(0, 80) }, 'AI suggest rule request'); const res = await ai.chat.completions.create({ model: env.DEEPSEEK_MODEL, messages: [ { role: 'system', content: 'You are a helpful assistant that outputs only JSON.' }, { role: 'user', content: prompt } - ] + ], + temperature: 0, + response_format: { type: 'json_object' } as any }); const content = res.choices?.[0]?.message?.content ?? ''; - const parsed = safeJson(content); + const parsed = extractJson(content); + logger.info({ contentPreview: content.slice(0, 200), parsed }, 'AI suggest rule response'); return parsed ?? null; } catch (error) { logger.warn({ error }, 'AI rule suggestion failed'); return null; } } - diff --git a/apps/frontend/src/composables/useAi.ts b/apps/frontend/src/composables/useAi.ts new file mode 100644 index 0000000..284415d --- /dev/null +++ b/apps/frontend/src/composables/useAi.ts @@ -0,0 +1,37 @@ +import { useMutation, useQuery } from '@tanstack/vue-query'; +import { apiClient } from '../lib/api/client'; + +export interface AiStatus { + configured: boolean; + enableClassification: boolean; + autoLearnRules: boolean; + model: string; + baseURL: string; +} + +export interface AiClassification { + category?: string; + type?: 'income' | 'expense'; + amount?: number; + confidence?: number; +} + +export function useAiStatusQuery() { + return useQuery({ + queryKey: ['ai-status'], + queryFn: async () => { + const { data } = await apiClient.get('/ai/status'); + return data.data as AiStatus; + } + }); +} + +export function useAiClassifyMutation() { + return useMutation({ + mutationFn: async (payload) => { + const { data } = await apiClient.post('/ai/classify', payload); + return data.data as AiClassification; + } + }); +} + diff --git a/apps/frontend/src/features/analysis/pages/AnalysisPage.vue b/apps/frontend/src/features/analysis/pages/AnalysisPage.vue index 7e9e350..9730a55 100644 --- a/apps/frontend/src/features/analysis/pages/AnalysisPage.vue +++ b/apps/frontend/src/features/analysis/pages/AnalysisPage.vue @@ -2,6 +2,7 @@ import { reactive, ref } from 'vue'; import LucideIcon from '../../../components/common/LucideIcon.vue'; import { useSpendingInsightQuery, useCalorieEstimationMutation } from '../../../composables/useAnalysis'; +import { useAiStatusQuery, useAiClassifyMutation } from '../../../composables/useAi'; const range = ref<'30d' | '90d'>('30d'); const { data: insight } = useSpendingInsightQuery(range); @@ -43,6 +44,22 @@ const submitCalorieQuery = async () => { calorieForm.query = ''; } }; + +// AI 文本分类(用于测试通知/自由文本) +const aiStatusQuery = useAiStatusQuery(); +const aiClassify = useAiClassifyMutation(); +const aiForm = reactive({ title: '', body: '' }); +const aiResult = ref(''); + +const submitAiClassify = async () => { + if (!aiForm.title.trim() && !aiForm.body.trim()) return; + try { + const res = await aiClassify.mutateAsync({ title: aiForm.title.trim() || '(无标题)', body: aiForm.body.trim() || undefined }); + aiResult.value = `分类: ${res.category ?? '未知'}\n类型: ${res.type ?? '-'}\n金额: ${res.amount ?? '-'}\n置信度: ${res.confidence ?? '-'}`; + } catch (error: any) { + aiResult.value = error?.response?.data?.message ?? 'AI 服务不可用'; + } +};