refactor: 数据层迁往服务端 (Express+DuckDB), APP 变瘦客户端只留缓存
Android Build & Publish / build-android (push) Failing after 6m55s
Docker Build and Push / build-image (push) Successful in 2m38s

- 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:
jif2.zhang
2026-09-15 10:53:58 +08:00
parent 60430f8216
commit c1b630a7ef
19 changed files with 889 additions and 1171 deletions
+403
View File
@@ -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}` }
}
}