// 服务端数据层: DuckDB (官方 @duckdb/node-api) // 业务数据全部存储在服务端数据库文件 (DB_PATH, 生产建议挂持久化数据卷), // 客户端不再持有任何数据库, 仅保留最近数据的读缓存。 import { DuckDBInstance } from '@duckdb/node-api' import path from 'node:path' import fs from 'node:fs' let instance = null let conn = null const CATS = ['top', 'bottom', 'outer', 'dress', 'shoes', 'accessory'] function dbFile() { const p = process.env.DB_PATH || path.resolve(process.cwd(), 'data', 'wardrobe.duckdb') fs.mkdirSync(path.dirname(p), { recursive: true }) return p } // 单连接 + 操作串行化: DuckDB 单写入者, 避免并发操作交叠 let chain = Promise.resolve() export function enqueue(fn) { const p = chain.then(fn) chain = p.catch(() => {}) return p } async function rows(sql, params = []) { const r = await conn.run(sql, params) return (await r.getRowObjects()) ?? [] } async function one(sql, params = []) { const r = await rows(sql, params) return r.length ? r[0] : null } const nowIso = () => new Date().toISOString() export const uu = () => globalThis.crypto.randomUUID() function parseArr(v) { if (Array.isArray(v)) return v if (typeof v === 'string') { try { const p = JSON.parse(v) return Array.isArray(p) ? p : [] } catch { return [] } } return [] } function mapClothing(x) { return { ...x, seasons: parseArr(x.seasons), occasions: parseArr(x.occasions) } } function mapOutfit(x) { return { ...x, item_ids: parseArr(x.item_ids) } } export async function initDb() { const file = dbFile() instance = await DuckDBInstance.create(file) conn = await instance.connect() await conn.run(`CREATE TABLE IF NOT EXISTS users ( id VARCHAR PRIMARY KEY, email VARCHAR UNIQUE, phone VARCHAR UNIQUE, nickname VARCHAR NOT NULL DEFAULT '', avatar_path VARCHAR, created_at VARCHAR NOT NULL )`) await conn.run(`CREATE TABLE IF NOT EXISTS sessions ( token VARCHAR PRIMARY KEY, user_id VARCHAR NOT NULL, created_at VARCHAR NOT NULL )`) await conn.run(`CREATE TABLE IF NOT EXISTS clothing_items ( id VARCHAR PRIMARY KEY, user_id VARCHAR NOT NULL, name VARCHAR NOT NULL, category VARCHAR NOT NULL, color VARCHAR NOT NULL, brand VARCHAR, material VARCHAR, seasons VARCHAR DEFAULT '[]', occasions VARCHAR DEFAULT '[]', formality INTEGER DEFAULT 50, thickness INTEGER DEFAULT 50, image_path VARCHAR, purchase_date VARCHAR, purchase_price DOUBLE, last_cleaned VARCHAR, clean_method VARCHAR, wear_count INTEGER DEFAULT 0, last_worn VARCHAR, is_favorite BOOLEAN DEFAULT FALSE, created_at VARCHAR NOT NULL, updated_at VARCHAR NOT NULL )`) await conn.run(`CREATE TABLE IF NOT EXISTS outfits ( id VARCHAR PRIMARY KEY, user_id VARCHAR NOT NULL, name VARCHAR NOT NULL, item_ids VARCHAR DEFAULT '[]', occasion VARCHAR, season VARCHAR, match_score INTEGER, ai_analysis VARCHAR, is_saved BOOLEAN DEFAULT FALSE, created_at VARCHAR NOT NULL )`) await conn.run(`CREATE TABLE IF NOT EXISTS daily_records ( id VARCHAR PRIMARY KEY, user_id VARCHAR NOT NULL, date VARCHAR NOT NULL, outfit_id VARCHAR, weather_temp DOUBLE, weather_condition VARCHAR, notes VARCHAR, created_at VARCHAR NOT NULL, UNIQUE(user_id, date) )`) await conn.run(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`) await conn.run(`CREATE INDEX IF NOT EXISTS idx_clothing_user ON clothing_items(user_id)`) await conn.run(`CREATE INDEX IF NOT EXISTS idx_clothing_category ON clothing_items(category)`) await conn.run(`CREATE INDEX IF NOT EXISTS idx_outfits_user ON outfits(user_id)`) await conn.run(`CREATE INDEX IF NOT EXISTS idx_daily_user_date ON daily_records(user_id, date)`) console.log(`[db] DuckDB ready at ${file}`) } // ── users / sessions ── export function dbCreateUser(id, email, phone, nickname) { return enqueue(async () => { await conn.run( 'INSERT INTO users (id, email, phone, nickname, created_at) VALUES (?,?,?,?,?)', [id, email, phone, nickname, nowIso()], ) return { id, email, phone, nickname, avatar_path: null, created_at: nowIso() } }) } export const dbGetUserByEmail = (email) => enqueue(() => one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE email = ?', [email])) export const dbGetUserByPhone = (phone) => enqueue(() => one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE phone = ?', [phone])) export const dbGetUserById = (id) => enqueue(() => one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE id = ?', [id])) export const dbCreateSession = (token, userId) => enqueue(async () => { await conn.run('INSERT INTO sessions (token, user_id, created_at) VALUES (?,?,?)', [token, userId, nowIso()]) }) export const dbGetSession = (token) => enqueue(() => one('SELECT token, user_id, created_at FROM sessions WHERE token = ?', [token])) export const dbDeleteSession = (token) => enqueue(async () => { await conn.run('DELETE FROM sessions WHERE token = ?', [token]) }) // ── clothing ── export function dbCreateClothing(userId, data) { return enqueue(async () => { const t = nowIso() const item = { id: uu(), user_id: userId, name: data.name, category: data.category, color: data.color, brand: data.brand ?? null, material: data.material ?? null, seasons: data.seasons ?? [], occasions: data.occasions ?? [], formality: data.formality ?? 50, thickness: data.thickness ?? 50, image_path: data.image_path ?? null, purchase_date: data.purchase_date ?? null, purchase_price: data.purchase_price ?? null, last_cleaned: data.last_cleaned ?? null, clean_method: data.clean_method ?? null, wear_count: data.wear_count ?? 0, last_worn: data.last_worn ?? null, is_favorite: data.is_favorite ?? false, created_at: t, updated_at: t, } await conn.run( `INSERT INTO clothing_items (id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [ item.id, item.user_id, item.name, item.category, item.color, item.brand, item.material, JSON.stringify(item.seasons), JSON.stringify(item.occasions), item.formality, item.thickness, item.image_path, item.purchase_date, item.purchase_price, item.last_cleaned, item.clean_method, item.wear_count, item.last_worn, item.is_favorite, item.created_at, item.updated_at, ], ) return item }) } export const dbListClothing = (userId, category) => enqueue(async () => { const base = 'SELECT id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at FROM clothing_items' const r = category ? await rows(`${base} WHERE user_id = ? AND category = ? ORDER BY created_at DESC`, [userId, category]) : await rows(`${base} WHERE user_id = ? ORDER BY created_at DESC`, [userId]) return r.map(mapClothing) }) export async function getClothing(userId, id) { return enqueue(async () => { const x = await one('SELECT * FROM clothing_items WHERE id = ? AND user_id = ?', [id, userId]) return x ? mapClothing(x) : null }) } export const dbUpdateClothing = (userId, id, name, category, color) => enqueue(async () => { await conn.run( 'UPDATE clothing_items SET name = ?, category = ?, color = ?, updated_at = ? WHERE id = ? AND user_id = ?', [name, category, color, nowIso(), id, userId], ) }) export const dbDeleteClothing = (userId, id) => enqueue(async () => { await conn.run('DELETE FROM clothing_items WHERE id = ? AND user_id = ?', [id, userId]) }) // ── outfits ── export function dbCreateOutfit(userId, data) { return enqueue(async () => { const outfit = { id: uu(), user_id: userId, name: data.name, item_ids: data.item_ids ?? [], occasion: data.occasion ?? null, season: data.season ?? null, match_score: null, ai_analysis: null, is_saved: false, created_at: nowIso(), } await conn.run( 'INSERT INTO outfits (id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)', [ outfit.id, outfit.user_id, outfit.name, JSON.stringify(outfit.item_ids), outfit.occasion, outfit.season, outfit.match_score, outfit.ai_analysis, outfit.is_saved, outfit.created_at, ], ) return outfit }) } export const dbListOutfits = (userId) => enqueue(async () => { const r = await rows( 'SELECT id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at FROM outfits WHERE user_id = ? ORDER BY created_at DESC', [userId], ) return r.map(mapOutfit) }) // ── daily records ── export function dbUpsertDaily(userId, data) { return enqueue(async () => { const record = { id: uu(), user_id: userId, date: data.date, outfit_id: data.outfit_id ?? null, weather_temp: data.weather_temp ?? null, weather_condition: data.weather_condition ?? null, notes: data.notes ?? null, created_at: nowIso(), } await conn.run( `INSERT INTO daily_records (id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT (user_id, date) DO UPDATE SET outfit_id = excluded.outfit_id, weather_temp = excluded.weather_temp, weather_condition = excluded.weather_condition, notes = excluded.notes`, [ record.id, record.user_id, record.date, record.outfit_id, record.weather_temp, record.weather_condition, record.notes, record.created_at, ], ) return record }) } export const dbListDaily = (userId, limit) => enqueue(async () => { const r = await rows( 'SELECT id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at FROM daily_records WHERE user_id = ? ORDER BY date DESC LIMIT ?', [userId, limit], ) return r }) // ── stats ── export function dbGetStats(userId) { return enqueue(async () => { const [totalItems, totalOutfits, favoriteCount] = await Promise.all([ count('SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ?', [userId]), count('SELECT COUNT(*) AS c FROM outfits WHERE user_id = ?', [userId]), count('SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND is_favorite = TRUE', [userId]), ]) const cutoff = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10) const unworn30d = await count( 'SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND (last_worn IS NULL OR last_worn < ?)', [userId, cutoff], ) const needsCleaning = await count( 'SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND (last_cleaned IS NULL OR last_cleaned < ?)', [userId, cutoff], ) const utilizationRate = totalItems > 0 ? Math.round(((totalItems - unworn30d) / totalItems) * 100) : 0 const catRows = await rows( 'SELECT category, COUNT(*) AS c FROM clothing_items WHERE user_id = ? GROUP BY category', [userId], ) const categoryCounts = {} for (const c of CATS) categoryCounts[c] = 0 for (const r of catRows) categoryCounts[r.category] = Number(r.c) return { total_items: totalItems, total_outfits: totalOutfits, utilization_rate: utilizationRate, category_counts: categoryCounts, favorite_count: favoriteCount, unworn_30d: unworn30d, needs_cleaning: needsCleaning, } }) } async function count(sql, params) { const r = await rows(sql, params) return r.length ? Number(r[0].c) : 0 } // ── MCP 工具 (由服务端直接操作数据库, user_id 取鉴权用户) ── export function mcpTools() { return [ { name: 'list_clothing', description: '获取用户衣橱中的所有衣物列表', input_schema: '{"type":"object","properties":{"category":{"type":"string"}},"required":[]}' }, { name: 'get_clothing', description: '根据ID获取单件衣物详情', input_schema: '{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}' }, { name: 'add_clothing', description: '添加新衣物到衣橱', input_schema: '{"type":"object","properties":{"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"},"brand":{"type":"string"},"seasons":{"type":"array","items":{"type":"string"}},"occasions":{"type":"array","items":{"type":"string"}}},"required":["name","category","color"]}' }, { name: 'update_clothing', description: '更新衣物信息', input_schema: '{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"}},"required":["id"]}' }, { name: 'delete_clothing', description: '从衣橱中删除衣物', input_schema: '{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}' }, { name: 'list_outfits', description: '获取用户的所有搭配方案', input_schema: '{"type":"object","properties":{},"required":[]}' }, { name: 'add_outfit', description: '创建新的搭配方案', input_schema: '{"type":"object","properties":{"name":{"type":"string"},"item_ids":{"type":"array","items":{"type":"string"}},"occasion":{"type":"string"},"season":{"type":"string"}},"required":["name","item_ids"]}' }, { name: 'get_wardrobe_stats', description: '获取衣橱统计数据(总数、利用率、分类统计等)', input_schema: '{"type":"object","properties":{},"required":[]}' }, { name: 'record_daily_outfit', description: '记录每日穿搭', input_schema: '{"type":"object","properties":{"date":{"type":"string"},"outfit_id":{"type":"string"},"notes":{"type":"string"}},"required":["date"]}' }, { name: 'list_daily_records', description: '获取穿搭日历记录', input_schema: '{"type":"object","properties":{"limit":{"type":"integer"}},"required":[]}' }, ] } // 返回 {ok, value} 或 throw {status,message} export async function mcpCall(toolName, args = {}, userId) { const a = { ...args, user_id: userId } switch (toolName) { case 'list_clothing': return { ok: true, value: await dbListClothing(userId, a.category || null) } case 'get_clothing': { const item = await getClothing(userId, a.id) return { ok: true, value: item ?? { error: '衣物不存在' } } } case 'add_clothing': { if (!a.name || !a.category || !a.color) throw { status: 400, message: '缺少必填字段' } return { ok: true, value: await dbCreateClothing(userId, a) } } case 'update_clothing': await dbUpdateClothing(userId, a.id, a.name || '', a.category || '', a.color || '') return { ok: true, value: { success: true } } case 'delete_clothing': await dbDeleteClothing(userId, a.id) return { ok: true, value: { success: true } } case 'list_outfits': return { ok: true, value: await dbListOutfits(userId) } case 'add_outfit': { if (!a.name || !Array.isArray(a.item_ids)) throw { status: 400, message: '缺少必填字段' } return { ok: true, value: await dbCreateOutfit(userId, a) } } case 'get_wardrobe_stats': return { ok: true, value: await dbGetStats(userId) } case 'record_daily_outfit': if (!a.date) throw { status: 400, message: '缺少 date' } return { ok: true, value: await dbUpsertDaily(userId, a) } case 'list_daily_records': return { ok: true, value: await dbListDaily(userId, a.limit || 30) } default: throw { status: 400, message: `Unknown tool: ${toolName}` } } }