refactor: 数据层迁往服务端 (Express+DuckDB), APP 变瘦客户端只留缓存
- server: 新增 DuckDB 数据层 (db.js) 与鉴权 (auth.js: 验证码+token), 挂载 /api 业务路由 (衣物/搭配/日历/统计/MCP), 错误统一中间件 - 前端 src/api/index.ts 全部业务调用改为 HTTP (Bearer token), 客户端不再持有数据库 - 删除 Rust 数据层 (db.rs/auth.rs/mcp.rs/models.rs), Cargo.toml 裁剪 duckdb/chrono 等依赖 - Dockerfile: 基镜像 alpine→slim (@duckdb/node-api glibc 预编译), DB 落 /app/data 卷 - docker-build.yml: 移除 prune --volumes, 挂具名卷 wardrobe-data 持久化 - Landing/Profile 文案改为云端同步口径; 退出登录调服务端会话销毁
This commit is contained in:
+403
@@ -0,0 +1,403 @@
|
||||
// 服务端数据层: 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}` }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user