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:
@@ -0,0 +1,66 @@
|
||||
// 登录/注册: 验证码(服务端一次性校验) + 服务端签发会话 token
|
||||
// 注意: 短信/邮件发送仍是假通道 (验证码固定 1111), 后续再接真实通道
|
||||
import { uu, dbCreateUser, dbGetUserByEmail, dbGetUserByPhone, dbGetUserById, dbGetSession, dbCreateSession, dbDeleteSession } from './db.js'
|
||||
|
||||
const VERIFICATION_CODE = '1111'
|
||||
const CODE_EXPIRY_SECONDS = 300
|
||||
const codes = new Map()
|
||||
|
||||
export function sendVerification(target) {
|
||||
codes.set(target, { code: VERIFICATION_CODE, expiresAt: Date.now() + CODE_EXPIRY_SECONDS * 1000 })
|
||||
console.log(`[auth] verification code for ${target}: ${VERIFICATION_CODE}`)
|
||||
return `验证码已发送至 ${target}`
|
||||
}
|
||||
|
||||
function verifyCode(target, code) {
|
||||
const v = codes.get(target)
|
||||
if (!v) return false
|
||||
if (v.code !== code || Date.now() >= v.expiresAt) return false
|
||||
codes.delete(target)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function register({ email, phone, code, nickname }) {
|
||||
const target = email || phone
|
||||
if (!target) return { success: false, message: '请提供邮箱或手机号', user: null, token: null }
|
||||
if (!verifyCode(target, code)) {
|
||||
return { success: false, message: '验证码无效或已过期', user: null, token: null }
|
||||
}
|
||||
if (email && (await dbGetUserByEmail(email))) {
|
||||
return { success: false, message: '该邮箱已注册', user: null, token: null }
|
||||
}
|
||||
if (phone && (await dbGetUserByPhone(phone))) {
|
||||
return { success: false, message: '该手机号已注册', user: null, token: null }
|
||||
}
|
||||
const id = uu()
|
||||
const user = await dbCreateUser(id, email || null, phone || null, nickname || '时尚达人')
|
||||
const token = 'tok_' + uu().replaceAll('-', '')
|
||||
await dbCreateSession(token, id)
|
||||
console.log(`[auth] user registered: ${id}`)
|
||||
return { success: true, message: '注册成功', user, token }
|
||||
}
|
||||
|
||||
export async function login({ email, phone, code }) {
|
||||
const target = email || phone
|
||||
if (!target) return { success: false, message: '请提供邮箱或手机号', user: null, token: null }
|
||||
if (!verifyCode(target, code)) {
|
||||
return { success: false, message: '验证码无效或已过期', user: null, token: null }
|
||||
}
|
||||
const user = email ? await dbGetUserByEmail(email) : await dbGetUserByPhone(phone)
|
||||
if (!user) return { success: false, message: '账号不存在,请先注册', user: null, token: null }
|
||||
const token = 'tok_' + uu().replaceAll('-', '')
|
||||
await dbCreateSession(token, user.id)
|
||||
console.log(`[auth] user login: ${user.id}`)
|
||||
return { success: true, message: '登录成功', user, token }
|
||||
}
|
||||
|
||||
export async function logout(token) {
|
||||
if (token) await dbDeleteSession(token)
|
||||
}
|
||||
|
||||
export async function resolveSession(token) {
|
||||
const s = token ? await dbGetSession(token) : null
|
||||
if (!s) return null
|
||||
const user = await dbGetUserById(s.user_id)
|
||||
return user
|
||||
}
|
||||
+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}` }
|
||||
}
|
||||
}
|
||||
+20
-6
@@ -4,6 +4,8 @@ import fs from 'node:fs'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import otaRouter from './routes/ota.js'
|
||||
import appRouter from './routes/app.js'
|
||||
import { initDb } from './db.js'
|
||||
|
||||
// 默认 3002: 与 iboard (3001) 同机部署时不冲突
|
||||
const PORT = Number(process.env.PORT) || 3002
|
||||
@@ -18,7 +20,10 @@ app.use(express.json())
|
||||
app.get('/healthz', (_req, res) => res.json({ ok: true, ts: Date.now() }))
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
|
||||
|
||||
// OTA 接口公开 (检查/下载更新不需要登录), 必须挂载在最前面
|
||||
// 业务接口 (客户端数据读写, 登录鉴权)
|
||||
app.use('/api', appRouter)
|
||||
|
||||
// OTA 接口公开 (检查/下载更新不需要登录), 必须挂载在最后以防 /api 路由冲突
|
||||
app.use('/api', otaRouter)
|
||||
|
||||
// 静态资源 (Vite build 产物)
|
||||
@@ -39,10 +44,19 @@ if (HAS_DIST) {
|
||||
// 统一错误处理
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[api error]', err)
|
||||
res.status(500).json({ error: err.message || '服务器内部错误' })
|
||||
const code = err && err.status ? Number(err.status) : 500
|
||||
if (code >= 500) console.error(err.stack || err)
|
||||
res.status(code).json({ error: (err && err.message) || '服务器内部错误' })
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[wardrobe-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[wardrobe-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
})
|
||||
initDb()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[wardrobe-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[wardrobe-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[db] failed to init DuckDB:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
// 业务 REST API: 鉴权 + 衣物/搭配/日历/统计/MCP
|
||||
// 客户端不再持有数据库, 全部数据经此接口读写 (瘦客户端)
|
||||
import { Router } from 'express'
|
||||
import * as auth from '../auth.js'
|
||||
import {
|
||||
dbCreateClothing, dbListClothing, getClothing, dbUpdateClothing, dbDeleteClothing,
|
||||
dbCreateOutfit, dbListOutfits, dbUpsertDaily, dbListDaily, dbGetStats,
|
||||
mcpTools, mcpCall,
|
||||
} from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
|
||||
|
||||
// ── 鉴权中间件: Authorization: Bearer <token> ──
|
||||
async function requireAuth(req, res, next) {
|
||||
try {
|
||||
const h = req.headers.authorization || ''
|
||||
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : null
|
||||
const user = await auth.resolveSession(token)
|
||||
if (!user) return res.status(401).json({ error: '未登录或登录已过期' })
|
||||
req.user = user
|
||||
next()
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth ──
|
||||
router.post('/auth/send-code', asyncH(async (req, res) => {
|
||||
const target = String(req.body?.target || '').trim()
|
||||
if (!target) return res.status(400).json({ error: '请提供邮箱或手机号' })
|
||||
res.json({ message: auth.sendVerification(target) })
|
||||
}))
|
||||
|
||||
router.post('/auth/register', asyncH(async (req, res) => {
|
||||
const { email, phone, code, nickname } = req.body || {}
|
||||
res.json(await auth.register({ email: email || null, phone: phone || null, code, nickname }))
|
||||
}))
|
||||
|
||||
router.post('/auth/login', asyncH(async (req, res) => {
|
||||
const { email, phone, code } = req.body || {}
|
||||
res.json(await auth.login({ email: email || null, phone: phone || null, code }))
|
||||
}))
|
||||
|
||||
router.post('/auth/logout', requireAuth, asyncH(async (req, res) => {
|
||||
const h = req.headers.authorization || ''
|
||||
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : null
|
||||
await auth.logout(token)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.get('/auth/me', requireAuth, asyncH(async (req, res) => {
|
||||
res.json({ user: req.user })
|
||||
}))
|
||||
|
||||
// ── Clothing ──
|
||||
router.get('/clothing', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await dbListClothing(req.user.id, req.query.category || null))
|
||||
}))
|
||||
|
||||
router.post('/clothing', requireAuth, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.name || !b.category || !b.color) return res.status(400).json({ error: '缺少必填字段' })
|
||||
res.json(await dbCreateClothing(req.user.id, b))
|
||||
}))
|
||||
|
||||
router.get('/clothing/:id', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await getClothing(req.user.id, req.params.id))
|
||||
}))
|
||||
|
||||
router.put('/clothing/:id', requireAuth, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
await dbUpdateClothing(req.user.id, req.params.id, b.name, b.category, b.color)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.delete('/clothing/:id', requireAuth, asyncH(async (req, res) => {
|
||||
await dbDeleteClothing(req.user.id, req.params.id)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// ── Outfits ──
|
||||
router.get('/outfits', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await dbListOutfits(req.user.id))
|
||||
}))
|
||||
|
||||
router.post('/outfits', requireAuth, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.name || !Array.isArray(b.item_ids)) return res.status(400).json({ error: '缺少必填字段' })
|
||||
res.json(await dbCreateOutfit(req.user.id, b))
|
||||
}))
|
||||
|
||||
// ── Daily Records ──
|
||||
router.get('/daily', requireAuth, asyncH(async (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 30, 500)
|
||||
res.json(await dbListDaily(req.user.id, limit))
|
||||
}))
|
||||
|
||||
router.post('/daily', requireAuth, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.date) return res.status(400).json({ error: '缺少 date' })
|
||||
res.json(await dbUpsertDaily(req.user.id, b))
|
||||
}))
|
||||
|
||||
// ── Stats ──
|
||||
router.get('/stats', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await dbGetStats(req.user.id))
|
||||
}))
|
||||
|
||||
// ── MCP ──
|
||||
router.get('/mcp/tools', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(mcpTools())
|
||||
}))
|
||||
|
||||
router.post('/mcp/call', requireAuth, asyncH(async (req, res) => {
|
||||
const { tool_name, args } = req.body || {}
|
||||
if (!tool_name) return res.status(400).json({ error: '缺少 tool_name' })
|
||||
const r = await mcpCall(tool_name, args || {}, req.user.id)
|
||||
res.json(r.value)
|
||||
}))
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user