diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index e3cfdcb..5574825 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -90,10 +90,10 @@ jobs: - name: Remove old container run: docker rm -vf wardrobe || true - - name: Recycle container - run: docker system prune --volumes -f - - name: Run container # 一键镜像: 前端 + OTA + 首页, 端口 3002 (与 iboard 的 3001 同机共存) + # 业务数据放具名卷 wardrobe-data, 部署不丢数据 run: | - docker run -d --name wardrobe -p 3002:3002 wardrobe:latest \ No newline at end of file + docker run -d --name wardrobe -p 3002:3002 \ + -v wardrobe-data:/app/data \ + wardrobe:latest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b0efb14..8b5520e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,10 @@ # Wardrobe 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程 # 构建: docker build -t wardrobe:latest . # 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址 -# 运行: docker run -d --name wardrobe -p 3002:3002 wardrobe:latest +# 运行: docker run -d --name wardrobe -p 3002:3002 -v wardrobe-data:/app/data wardrobe:latest +# 说明: 数据层在服务端 (DB_PATH 指向挂载卷里的 DuckDB 文件), 客户端不存业务数据 # =============================================================== -FROM node:22-alpine AS builder +FROM node:22-slim AS builder WORKDIR /app @@ -25,8 +26,8 @@ ARG VITE_API_BASE= ENV VITE_API_BASE=$VITE_API_BASE RUN npm run build -# 运行时阶段 -FROM node:22-alpine +# 运行时阶段 (slim: @duckdb/node-api 用 glibc 预编译, 不能用 alpine musl) +FROM node:22-slim WORKDIR /app @@ -37,6 +38,10 @@ RUN npm config set registry https://registry.npmmirror.com COPY package*.json ./ RUN npm install --omit=dev +# 业务数据目录: DuckDB 文件落在这里, 运行时挂 -v wardrobe-data:/app/data 持久化 +RUN mkdir -p /app/data +ENV DB_PATH=/app/data/wardrobe.db + # 拷贝构建产物 COPY --from=builder /app/dist ./dist COPY --from=builder /app/server ./server diff --git a/package-lock.json b/package-lock.json index de5a0fc..92e02a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "wardrobe", "version": "0.1.0", "dependencies": { + "@duckdb/node-api": "1.5.5-r.5", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", "cors": "^2.8.5", @@ -293,6 +294,150 @@ "node": ">=6.9.0" } }, + "node_modules/@duckdb/node-api": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-api/-/node-api-1.5.5-r.5.tgz", + "integrity": "sha512-ZMX+sel3DjSKbGuQ44Y5Tp5LwYjlJkhB5faKr+9sSMcxixDMSW+8vaTOD1uRDuN9o0udUoCY/XX9PI1haEZuZg==", + "license": "MIT", + "dependencies": { + "@duckdb/node-bindings": "1.5.5-r.5" + } + }, + "node_modules/@duckdb/node-bindings": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings/-/node-bindings-1.5.5-r.5.tgz", + "integrity": "sha512-sR7x91TfBunLqLbdvrcdiPCU+VGEQFPicxh1STZM402fuHW2ituPTAo2bY3IlntsvJShltrKF4R6B+JYPlAPZg==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "optionalDependencies": { + "@duckdb/node-bindings-darwin-arm64": "1.5.5-r.5", + "@duckdb/node-bindings-darwin-x64": "1.5.5-r.5", + "@duckdb/node-bindings-linux-arm64": "1.5.5-r.5", + "@duckdb/node-bindings-linux-arm64-musl": "1.5.5-r.5", + "@duckdb/node-bindings-linux-x64": "1.5.5-r.5", + "@duckdb/node-bindings-linux-x64-musl": "1.5.5-r.5", + "@duckdb/node-bindings-win32-arm64": "1.5.5-r.5", + "@duckdb/node-bindings-win32-x64": "1.5.5-r.5" + } + }, + "node_modules/@duckdb/node-bindings-darwin-arm64": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-arm64/-/node-bindings-darwin-arm64-1.5.5-r.5.tgz", + "integrity": "sha512-WJdeLTFy4pRg60rkbp9smtlP1iIQ8ErtIJkxS+2LQtM1ZZMSByyW+3WPpNoKe7IwPtVl2fLlPw7tDmQW4Z974g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-darwin-x64": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-darwin-x64/-/node-bindings-darwin-x64-1.5.5-r.5.tgz", + "integrity": "sha512-rN0KjX13MwVQ5V5ufykolty/tf4SaafOpGWcUA1Cegf98jPeRdsJsEE1IMXn8n3u6WvMSZEVJmHJ3ma4Hr7Vzg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@duckdb/node-bindings-linux-arm64": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64/-/node-bindings-linux-arm64-1.5.5-r.5.tgz", + "integrity": "sha512-CafH2AIvXtpwDynG3KB/4uEOHRGlFoB7auCRMIwH3BKNIobsANV5LSpDJ3tfyRCFIsK6e8AT1eseWVnu2Dv3cw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-arm64-musl": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-arm64-musl/-/node-bindings-linux-arm64-musl-1.5.5-r.5.tgz", + "integrity": "sha512-00z3mYHUBAucp3L73EoftKDdaGOaV8ENELejiT+PK7dFjTzdXxqhJwZ1LaQV1/zEhIecz+x23f6WvFhWw+7XXQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-x64": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64/-/node-bindings-linux-x64-1.5.5-r.5.tgz", + "integrity": "sha512-kCyq6BXhn6QXfwF8yVdVKriSSHP+rfVWcJ56cG8qZzlOZpxyvT5WGm6jMshxg4ditLlOsHsIN/D47RYrcjXuHQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-linux-x64-musl": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-linux-x64-musl/-/node-bindings-linux-x64-musl-1.5.5-r.5.tgz", + "integrity": "sha512-S5qEc7F+GjXt85T2xArjjvN5oc/IjtH6zsLJsuo8N3fQolheBCefemIMdZoK2Jik8a0sBxidiS6Ld4qprMPPDw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@duckdb/node-bindings-win32-arm64": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-arm64/-/node-bindings-win32-arm64-1.5.5-r.5.tgz", + "integrity": "sha512-L3Mroc23xhqOowWVElni8LgEmsm5e9DdG1y2pLka/1ug3Pl2vpQMl1Phxwz4UEMF8DOBbMgN5BBNrYwG2BFo+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@duckdb/node-bindings-win32-x64": { + "version": "1.5.5-r.5", + "resolved": "https://registry.npmjs.org/@duckdb/node-bindings-win32-x64/-/node-bindings-win32-x64-1.5.5-r.5.tgz", + "integrity": "sha512-mBGFn8netT/AAhK4TOG6Fw47+Db68CxCVtQ9qfxZc1kZAjds+aYrh17Fd/u5CbTDdxILsrcyuF8q9cUcm54kQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -2203,6 +2348,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "dev": true, diff --git a/package.json b/package.json index 507b33e..676be4c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "ota:genkey": "node scripts/ota-genkey.mjs" }, "dependencies": { + "@duckdb/node-api": "1.5.5-r.5", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", "cors": "^2.8.5", diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..8c2e60a --- /dev/null +++ b/server/auth.js @@ -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 +} \ No newline at end of file diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..47b8ef9 --- /dev/null +++ b/server/db.js @@ -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}` } + } +} \ No newline at end of file diff --git a/server/index.js b/server/index.js index b732706..1f6a61d 100644 --- a/server/index.js +++ b/server/index.js @@ -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)'}`) -}) \ No newline at end of file +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) + }) \ No newline at end of file diff --git a/server/routes/app.js b/server/routes/app.js new file mode 100644 index 0000000..2a91a38 --- /dev/null +++ b/server/routes/app.js @@ -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 ── +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 \ No newline at end of file diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3ae917d..02f33ba 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,17 +14,6 @@ tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-shell = "2" # 前端 OTA 升级 (运行时覆盖 tauri://localhost 资源; 替换 Assets provider) tauri-plugin-hotswap = "0.0.4" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -duckdb = { version = "0.10", features = ["bundled"] } -tokio = { version = "1", features = ["full"] } -uuid = { version = "1", features = ["v4"] } -# 版本上限: chrono 0.4.40 起 Datelike 新增 quarter(), 与 arrow-arith 51 的 -# ChronoDateExt::quarter (duckdb 0.10.2 非可选依赖 arrow 51) 产生 E0034 方法歧义 -chrono = { version = ">=0.4.38, <0.4.40", features = ["serde"] } -sha2 = "0.10" -hex = "0.4" -ctrlc = "3" log = "0.4" env_logger = "0.11" diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs deleted file mode 100644 index ddd80c5..0000000 --- a/src-tauri/src/auth.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std::collections::HashMap; -use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; -use log::info; -use crate::db::Database; -use crate::models::*; - -const VERIFICATION_CODE: &str = "1111"; -const CODE_EXPIRY_SECONDS: i64 = 300; - -pub struct AuthManager { - codes: Mutex>, -} - -impl AuthManager { - pub fn new() -> Self { - Self { - codes: Mutex::new(HashMap::new()), - } - } - - fn now_secs() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64 - } - - pub fn send_verification(&self, target: &str) -> Result { - let code = VERIFICATION_CODE.to_string(); - let now = Self::now_secs(); - let vc = VerificationCode { - target: target.to_string(), - code: code.clone(), - created_at: now, - expires_at: now + CODE_EXPIRY_SECONDS, - }; - self.codes.lock().unwrap().insert(target.to_string(), vc); - info!("Verification code sent to {}: {}", target, code); - Ok(format!("验证码已发送至 {}", target)) - } - - fn verify_code(&self, target: &str, code: &str) -> bool { - let codes = self.codes.lock().unwrap(); - if let Some(vc) = codes.get(target) { - let now = Self::now_secs(); - vc.code == code && now < vc.expires_at - } else { - false - } - } - - pub fn register( - &self, - db: &Database, - email: Option<&str>, - phone: Option<&str>, - code: &str, - nickname: &str, - ) -> Result { - let target = email.or(phone).ok_or("请提供邮箱或手机号")?; - - if !self.verify_code(target, code) { - return Ok(AuthResult { - success: false, - message: "验证码无效或已过期".to_string(), - user: None, - token: None, - }); - } - - if email.is_some() { - if db.get_user_by_email(target).map_err(|e| e.to_string())?.is_some() { - return Ok(AuthResult { - success: false, - message: "该邮箱已注册".to_string(), - user: None, - token: None, - }); - } - } else if phone.is_some() { - if db.get_user_by_phone(target).map_err(|e| e.to_string())?.is_some() { - return Ok(AuthResult { - success: false, - message: "该手机号已注册".to_string(), - user: None, - token: None, - }); - } - } - - let id = uuid::Uuid::new_v4().to_string(); - let user = db.create_user(&id, email, phone, nickname).map_err(|e| e.to_string())?; - let token = format!("tok_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); - - self.codes.lock().unwrap().remove(target); - - info!("User registered: {}", id); - Ok(AuthResult { - success: true, - message: "注册成功".to_string(), - user: Some(user), - token: Some(token), - }) - } - - pub fn login( - &self, - db: &Database, - email: Option<&str>, - phone: Option<&str>, - code: &str, - ) -> Result { - let target = email.or(phone).ok_or("请提供邮箱或手机号")?; - - if !self.verify_code(target, code) { - return Ok(AuthResult { - success: false, - message: "验证码无效或已过期".to_string(), - user: None, - token: None, - }); - } - - let user = if email.is_some() { - db.get_user_by_email(target).map_err(|e| e.to_string())? - } else { - db.get_user_by_phone(target).map_err(|e| e.to_string())? - }; - - self.codes.lock().unwrap().remove(target); - - match user { - Some(u) => { - let token = format!("tok_{}", uuid::Uuid::new_v4().to_string().replace('-', "")); - Ok(AuthResult { - success: true, - message: "登录成功".to_string(), - user: Some(u), - token: Some(token), - }) - } - None => Ok(AuthResult { - success: false, - message: "账号不存在,请先注册".to_string(), - user: None, - token: None, - }), - } - } -} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs deleted file mode 100644 index 97b14cc..0000000 --- a/src-tauri/src/db.rs +++ /dev/null @@ -1,395 +0,0 @@ -use duckdb::{Connection, params}; -use std::sync::Mutex; -use std::path::PathBuf; -use log::info; -use crate::models::*; - -pub struct Database { - conn: Mutex, - path: PathBuf, -} - -impl Database { - pub fn new(data_dir: &PathBuf) -> Result> { - std::fs::create_dir_all(data_dir)?; - let db_path = data_dir.join("wardrobe.duckdb"); - let conn = Connection::open(&db_path)?; - - let db = Self { - conn: Mutex::new(conn), - path: db_path, - }; - db.init_tables()?; - info!("DuckDB initialized at {:?}", db.path); - Ok(db) - } - - fn init_tables(&self) -> Result<(), Box> { - let conn = self.conn.lock().unwrap(); - conn.execute_batch(" - 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 - ); - - 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, - FOREIGN KEY (user_id) REFERENCES users(id) - ); - - 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, - FOREIGN KEY (user_id) REFERENCES users(id) - ); - - 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), - FOREIGN KEY (user_id) REFERENCES users(id) - ); - - CREATE INDEX IF NOT EXISTS idx_clothing_user ON clothing_items(user_id); - CREATE INDEX IF NOT EXISTS idx_clothing_category ON clothing_items(category); - CREATE INDEX IF NOT EXISTS idx_outfits_user ON outfits(user_id); - CREATE INDEX IF NOT EXISTS idx_daily_user_date ON daily_records(user_id, date); - ")?; - Ok(()) - } - - // ── User CRUD ── - - pub fn create_user(&self, id: &str, email: Option<&str>, phone: Option<&str>, nickname: &str) -> Result> { - let conn = self.conn.lock().unwrap(); - let now = chrono::Utc::now().to_rfc3339(); - conn.execute( - "INSERT INTO users (id, email, phone, nickname, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - params![id, email, phone, nickname, now], - )?; - Ok(User { - id: id.to_string(), - email: email.map(String::from), - phone: phone.map(String::from), - nickname: nickname.to_string(), - avatar_path: None, - created_at: now, - }) - } - - pub fn get_user_by_email(&self, email: &str) -> Result, Box> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE email = ?1")?; - let mut rows = stmt.query_map(params![email], |row| { - Ok(User { - id: row.get(0)?, - email: row.get(1)?, - phone: row.get(2)?, - nickname: row.get(3)?, - avatar_path: row.get(4)?, - created_at: row.get(5)?, - }) - })?; - match rows.next() { - Some(row) => Ok(Some(row?)), - None => Ok(None), - } - } - - pub fn get_user_by_phone(&self, phone: &str) -> Result, Box> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE phone = ?1")?; - let mut rows = stmt.query_map(params![phone], |row| { - Ok(User { - id: row.get(0)?, - email: row.get(1)?, - phone: row.get(2)?, - nickname: row.get(3)?, - avatar_path: row.get(4)?, - created_at: row.get(5)?, - }) - })?; - match rows.next() { - Some(row) => Ok(Some(row?)), - None => Ok(None), - } - } - - // ── Clothing CRUD ── - - pub fn create_clothing(&self, item: &ClothingItem) -> Result<(), Box> { - let conn = self.conn.lock().unwrap(); - let seasons_json = serde_json::to_string(&item.seasons)?; - let occasions_json = serde_json::to_string(&item.occasions)?; - conn.execute( - "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 (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21)", - params![ - item.id, item.user_id, item.name, item.category, item.color, - item.brand, item.material, seasons_json, occasions_json, - 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 - ], - )?; - Ok(()) - } - - pub fn list_clothing(&self, user_id: &str, category: Option<&str>) -> Result, Box> { - let conn = self.conn.lock().unwrap(); - let 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 WHERE user_id = ?1"; - let (sql, params_clause): (String, Vec>) = match category { - Some(cat) => ( - format!("{} AND category = ?2 ORDER BY created_at DESC", base), - vec![Box::new(cat.to_string())], - ), - None => ( - format!("{} ORDER BY created_at DESC", base), - Vec::new(), - ), - }; - let mut stmt = conn.prepare(&sql)?; - - // Build unified param list: user_id + optional category - let mut all_params: Vec> = Vec::with_capacity(1 + params_clause.len()); - all_params.push(Box::new(user_id.to_string())); - all_params.extend(params_clause); - - let param_refs: Vec<&dyn duckdb::ToSql> = all_params.iter().map(|b| b.as_ref()).collect(); - - let mut rows = stmt.query(param_refs.as_slice())?; - let mut items = Vec::new(); - while let Some(row) = rows.next()? { - items.push(Self::map_clothing_row(row)?); - } - Ok(items) - } - - pub fn get_clothing(&self, id: &str) -> Result, Box> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare("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 WHERE id = ?1")?; - let mut rows = stmt.query_map(params![id], Self::map_clothing_row)?; - match rows.next() { - Some(row) => Ok(Some(row?)), - None => Ok(None), - } - } - - pub fn update_clothing(&self, id: &str, name: &str, category: &str, color: &str) -> Result<(), Box> { - let conn = self.conn.lock().unwrap(); - let now = chrono::Utc::now().to_rfc3339(); - conn.execute( - "UPDATE clothing_items SET name = ?1, category = ?2, color = ?3, updated_at = ?4 WHERE id = ?5", - params![name, category, color, now, id], - )?; - Ok(()) - } - - pub fn delete_clothing(&self, id: &str) -> Result<(), Box> { - let conn = self.conn.lock().unwrap(); - conn.execute("DELETE FROM clothing_items WHERE id = ?1", params![id])?; - Ok(()) - } - - fn map_clothing_row(row: &duckdb::Row) -> Result { - let seasons_str: String = row.get(7)?; - let occasions_str: String = row.get(8)?; - Ok(ClothingItem { - id: row.get(0)?, - user_id: row.get(1)?, - name: row.get(2)?, - category: row.get(3)?, - color: row.get(4)?, - brand: row.get(5)?, - material: row.get(6)?, - seasons: serde_json::from_str(&seasons_str).unwrap_or_default(), - occasions: serde_json::from_str(&occasions_str).unwrap_or_default(), - formality: row.get(9)?, - thickness: row.get(10)?, - image_path: row.get(11)?, - purchase_date: row.get(12)?, - purchase_price: row.get(13)?, - last_cleaned: row.get(14)?, - clean_method: row.get(15)?, - wear_count: row.get(16)?, - last_worn: row.get(17)?, - is_favorite: row.get(18)?, - created_at: row.get(19)?, - updated_at: row.get(20)?, - }) - } - - // ── Outfit CRUD ── - - pub fn create_outfit(&self, outfit: &Outfit) -> Result<(), Box> { - let conn = self.conn.lock().unwrap(); - let item_ids_json = serde_json::to_string(&outfit.item_ids)?; - conn.execute( - "INSERT INTO outfits (id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", - params![ - outfit.id, outfit.user_id, outfit.name, item_ids_json, - outfit.occasion, outfit.season, outfit.match_score, - outfit.ai_analysis, outfit.is_saved, outfit.created_at - ], - )?; - Ok(()) - } - - pub fn list_outfits(&self, user_id: &str) -> Result, Box> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at FROM outfits WHERE user_id = ?1 ORDER BY created_at DESC")?; - let rows = stmt.query_map(params![user_id], |row| { - let item_ids_str: String = row.get(3)?; - Ok(Outfit { - id: row.get(0)?, - user_id: row.get(1)?, - name: row.get(2)?, - item_ids: serde_json::from_str(&item_ids_str).unwrap_or_default(), - occasion: row.get(4)?, - season: row.get(5)?, - match_score: row.get(6)?, - ai_analysis: row.get(7)?, - is_saved: row.get(8)?, - created_at: row.get(9)?, - }) - })?; - let mut outfits = Vec::new(); - for row in rows { - outfits.push(row?); - } - Ok(outfits) - } - - // ── Daily Record CRUD ── - - pub fn upsert_daily_record(&self, record: &DailyRecord) -> Result<(), Box> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "INSERT INTO daily_records (id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8) - ON CONFLICT (user_id, date) DO UPDATE SET outfit_id = ?4, weather_temp = ?5, weather_condition = ?6, notes = ?7", - params![ - record.id, record.user_id, record.date, record.outfit_id, - record.weather_temp, record.weather_condition, record.notes, record.created_at - ], - )?; - Ok(()) - } - - pub fn list_daily_records(&self, user_id: &str, limit: i32) -> Result, Box> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare("SELECT id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at FROM daily_records WHERE user_id = ?1 ORDER BY date DESC LIMIT ?2")?; - let rows = stmt.query_map(params![user_id, limit], |row| { - Ok(DailyRecord { - id: row.get(0)?, - user_id: row.get(1)?, - date: row.get(2)?, - outfit_id: row.get(3)?, - weather_temp: row.get(4)?, - weather_condition: row.get(5)?, - notes: row.get(6)?, - created_at: row.get(7)?, - }) - })?; - let mut records = Vec::new(); - for row in rows { - records.push(row?); - } - Ok(records) - } - - // ── Stats ── - - pub fn get_stats(&self, user_id: &str) -> Result> { - let conn = self.conn.lock().unwrap(); - - let total_items: i32 = conn.query_row( - "SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1", params![user_id], |r| r.get(0) - )?; - let total_outfits: i32 = conn.query_row( - "SELECT COUNT(*) FROM outfits WHERE user_id = ?1", params![user_id], |r| r.get(0) - )?; - let favorite_count: i32 = conn.query_row( - "SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND is_favorite = TRUE", params![user_id], |r| r.get(0) - )?; - let unworn_30d: i32 = conn.query_row( - "SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND (last_worn IS NULL OR last_worn < strftime('%Y-%m-%d', 'now', '-30 days'))", - params![user_id], |r| r.get(0) - )?; - let needs_cleaning: i32 = conn.query_row( - "SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND (last_cleaned IS NULL OR last_cleaned < strftime('%Y-%m-%d', 'now', '-30 days'))", - params![user_id], |r| r.get(0) - )?; - - let utilization_rate = if total_items > 0 { - ((total_items - unworn_30d) as f64 / total_items as f64 * 100.0).round() - } else { - 0.0 - }; - - let mut category_counts = std::collections::HashMap::new(); - let cats = ["top", "bottom", "outer", "dress", "shoes", "accessory"]; - for cat in cats { - let count: i32 = conn.query_row( - "SELECT COUNT(*) FROM clothing_items WHERE user_id = ?1 AND category = ?2", - params![user_id, cat], - |r| r.get(0), - )?; - category_counts.insert(cat.to_string(), count); - } - - Ok(WardrobeStats { - total_items, - total_outfits, - utilization_rate, - category_counts, - favorite_count, - unworn_30d, - needs_cleaning, - }) - } - - pub fn db_path(&self) -> &PathBuf { - &self.path - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0dae0a4..47e7c84 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,233 +1,19 @@ -mod auth; -mod db; -mod mcp; -mod models; - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - use log::info; -use tauri::Manager; - -use auth::AuthManager; -use db::Database; -use mcp::McpService; -use models::*; - -struct AppState { - db: Arc, - auth: Arc, - mcp: Arc, - shutdown_flag: Arc, -} - -// ══════════════════════════════════════════════════ -// Tauri IPC Commands -// ══════════════════════════════════════════════════ - -#[tauri::command] -fn send_code(state: tauri::State, target: String) -> Result { - state.auth.send_verification(&target) -} - -#[tauri::command] -fn register( - state: tauri::State, - email: Option, - phone: Option, - code: String, - nickname: String, -) -> Result { - state.auth.register(&state.db, email.as_deref(), phone.as_deref(), &code, &nickname) -} - -#[tauri::command] -fn login( - state: tauri::State, - email: Option, - phone: Option, - code: String, -) -> Result { - state.auth.login(&state.db, email.as_deref(), phone.as_deref(), &code) -} - -#[tauri::command] -fn add_clothing( - state: tauri::State, - user_id: String, - name: String, - category: String, - color: String, - brand: Option, - seasons: Option>, - occasions: Option>, -) -> Result { - let now = chrono::Utc::now().to_rfc3339(); - let item = ClothingItem { - id: uuid::Uuid::new_v4().to_string(), - user_id, - name, - category, - color, - brand, - material: None, - seasons: seasons.unwrap_or_default(), - occasions: occasions.unwrap_or_default(), - formality: 50, - thickness: 50, - image_path: None, - purchase_date: None, - purchase_price: None, - last_cleaned: None, - clean_method: None, - wear_count: 0, - last_worn: None, - is_favorite: false, - created_at: now.clone(), - updated_at: now, - }; - state.db.create_clothing(&item).map_err(|e| e.to_string())?; - Ok(item) -} - -#[tauri::command] -fn list_clothing( - state: tauri::State, - user_id: String, - category: Option, -) -> Result, String> { - state.db.list_clothing(&user_id, category.as_deref()).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn get_clothing(state: tauri::State, id: String) -> Result, String> { - state.db.get_clothing(&id).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn update_clothing( - state: tauri::State, - id: String, - name: String, - category: String, - color: String, -) -> Result<(), String> { - state.db.update_clothing(&id, &name, &category, &color).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn delete_clothing(state: tauri::State, id: String) -> Result<(), String> { - state.db.delete_clothing(&id).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn add_outfit( - state: tauri::State, - user_id: String, - name: String, - item_ids: Vec, - occasion: Option, - season: Option, -) -> Result { - let now = chrono::Utc::now().to_rfc3339(); - let outfit = Outfit { - id: uuid::Uuid::new_v4().to_string(), - user_id, - name, - item_ids, - occasion, - season, - match_score: None, - ai_analysis: None, - is_saved: false, - created_at: now, - }; - state.db.create_outfit(&outfit).map_err(|e| e.to_string())?; - Ok(outfit) -} - -#[tauri::command] -fn list_outfits(state: tauri::State, user_id: String) -> Result, String> { - state.db.list_outfits(&user_id).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn record_daily( - state: tauri::State, - user_id: String, - date: String, - outfit_id: Option, - notes: Option, -) -> Result { - let now = chrono::Utc::now().to_rfc3339(); - let record = DailyRecord { - id: uuid::Uuid::new_v4().to_string(), - user_id, - date, - outfit_id, - weather_temp: None, - weather_condition: None, - notes, - created_at: now, - }; - state.db.upsert_daily_record(&record).map_err(|e| e.to_string())?; - Ok(record) -} - -#[tauri::command] -fn list_daily_records( - state: tauri::State, - user_id: String, - limit: Option, -) -> Result, String> { - state.db.list_daily_records(&user_id, limit.unwrap_or(30)).map_err(|e| e.to_string()) -} - -#[tauri::command] -fn get_stats(state: tauri::State, user_id: String) -> Result { - state.db.get_stats(&user_id).map_err(|e| e.to_string()) -} - -// ── MCP Commands ── - -#[tauri::command] -fn mcp_list_tools(state: tauri::State) -> Result, String> { - Ok(state.mcp.list_tools()) -} - -#[tauri::command] -fn mcp_call_tool( - state: tauri::State, - tool_name: String, - args: serde_json::Value, -) -> Result { - state.mcp.call_tool(&tool_name, &args) -} // ══════════════════════════════════════════════════ // Main (桌面 + Android 共用) // ══════════════════════════════════════════════════ - -// 必须用 tauri_plugin_hotswap::init 包装 generate_context, -// 返回 (插件实例, 新的 context), 并把插件注入 Builder。 -// Android 上通过 mobile_entry_point 从这里启动。 +// 业务数据全部放在服务端 (Express + DuckDB), 客户端是瘦客户端: +// 前端统一走 HTTP API, 本地不再持有任何数据库。 +// 这里只保留 Tauri 引导 + hotswap (OTA) + shell 插件。 #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { env_logger::init(); info!("Wardrobe starting..."); - let shutdown_flag = Arc::new(AtomicBool::new(false)); - let ctrlc_flag = shutdown_flag.clone(); - let setup_flag = shutdown_flag.clone(); - let event_flag = shutdown_flag.clone(); - - ctrlc::set_handler(move || { - info!("Ctrl+C received, shutting down gracefully..."); - ctrlc_flag.store(true, Ordering::SeqCst); - }) - .expect("Error setting Ctrl-C handler"); - - // 初始化 hotswap 插件 (OTA 升级), 返回改装后的 context + // 必须用 tauri_plugin_hotswap::init 包装 generate_context, + // 返回 (插件实例, 新的 context), 并把插件注入 Builder。 + // Android 上 OTA 可热替换前端资源。 let context = tauri::generate_context!(); let (hotswap, context) = match tauri_plugin_hotswap::init(context) { Ok(ok) => ok, @@ -240,54 +26,6 @@ pub fn run() { let app = tauri::Builder::default() .plugin(hotswap) .plugin(tauri_plugin_shell::init()) - .setup(move |app| { - info!("Initializing database..."); - - let data_dir = app.path().app_data_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let database = match Database::new(&data_dir) { - Ok(db) => Arc::new(db), - Err(e) => { - eprintln!("Failed to initialize database: {}", e); - std::process::exit(1); - } - }; - - let auth = Arc::new(AuthManager::new()); - let mcp = Arc::new(McpService::new(database.clone())); - - app.manage(AppState { - db: database, - auth, - mcp, - shutdown_flag: setup_flag, - }); - - info!("Wardrobe app initialized, data dir: {:?}", data_dir); - Ok(()) - }) - .on_window_event(move |_window, event| { - if let tauri::WindowEvent::Destroyed = event { - info!("Window destroyed, shutting down gracefully..."); - event_flag.store(true, Ordering::SeqCst); - } - }) - .invoke_handler(tauri::generate_handler![ - send_code, - register, - login, - add_clothing, - list_clothing, - get_clothing, - update_clothing, - delete_clothing, - add_outfit, - list_outfits, - record_daily, - list_daily_records, - get_stats, - mcp_list_tools, - mcp_call_tool, - ]) .build(context) .expect("error while building tauri application"); diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs deleted file mode 100644 index deec3dd..0000000 --- a/src-tauri/src/mcp.rs +++ /dev/null @@ -1,205 +0,0 @@ -use std::sync::Arc; -use log::info; -use crate::db::Database; -use crate::models::*; - -pub struct McpService { - db: Arc, -} - -impl McpService { - pub fn new(db: Arc) -> Self { - Self { db } - } - - pub fn list_tools(&self) -> Vec { - vec![ - McpTool { - name: "list_clothing".to_string(), - description: "获取用户衣橱中的所有衣物列表".to_string(), - input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"category":{"type":"string"}},"required":["user_id"]}"#.to_string(), - }, - McpTool { - name: "get_clothing".to_string(), - description: "根据ID获取单件衣物详情".to_string(), - input_schema: r#"{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}"#.to_string(), - }, - McpTool { - name: "add_clothing".to_string(), - description: "添加新衣物到衣橱".to_string(), - input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"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":["user_id","name","category","color"]}"#.to_string(), - }, - McpTool { - name: "update_clothing".to_string(), - description: "更新衣物信息".to_string(), - input_schema: r#"{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"}},"required":["id"]}"#.to_string(), - }, - McpTool { - name: "delete_clothing".to_string(), - description: "从衣橱中删除衣物".to_string(), - input_schema: r#"{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}"#.to_string(), - }, - McpTool { - name: "list_outfits".to_string(), - description: "获取用户的所有搭配方案".to_string(), - input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"}},"required":["user_id"]}"#.to_string(), - }, - McpTool { - name: "add_outfit".to_string(), - description: "创建新的搭配方案".to_string(), - input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"name":{"type":"string"},"item_ids":{"type":"array","items":{"type":"string"}},"occasion":{"type":"string"},"season":{"type":"string"}},"required":["user_id","name","item_ids"]}"#.to_string(), - }, - McpTool { - name: "get_wardrobe_stats".to_string(), - description: "获取衣橱统计数据(总数、利用率、分类统计等)".to_string(), - input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"}},"required":["user_id"]}"#.to_string(), - }, - McpTool { - name: "record_daily_outfit".to_string(), - description: "记录每日穿搭".to_string(), - input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"date":{"type":"string"},"outfit_id":{"type":"string"},"notes":{"type":"string"}},"required":["user_id","date"]}"#.to_string(), - }, - McpTool { - name: "list_daily_records".to_string(), - description: "获取穿搭日历记录".to_string(), - input_schema: r#"{"type":"object","properties":{"user_id":{"type":"string"},"limit":{"type":"integer"}},"required":["user_id"]}"#.to_string(), - }, - ] - } - - pub fn call_tool(&self, name: &str, args: &serde_json::Value) -> Result { - info!("MCP tool call: {}", name); - match name { - "list_clothing" => { - let user_id = args["user_id"].as_str().ok_or("missing user_id")?; - let category = args["category"].as_str(); - let items = self.db.list_clothing(user_id, category).map_err(|e| e.to_string())?; - Ok(serde_json::to_value(items).unwrap()) - } - "get_clothing" => { - let id = args["id"].as_str().ok_or("missing id")?; - match self.db.get_clothing(id).map_err(|e| e.to_string())? { - Some(item) => Ok(serde_json::to_value(item).unwrap()), - None => Ok(serde_json::json!({"error": "衣物不存在"})), - } - } - "add_clothing" => { - let user_id = args["user_id"].as_str().ok_or("missing user_id")?; - let name = args["name"].as_str().ok_or("missing name")?; - let category = args["category"].as_str().ok_or("missing category")?; - let color = args["color"].as_str().ok_or("missing color")?; - let brand = args["brand"].as_str(); - let seasons: Vec = args["seasons"].as_array() - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_default(); - let occasions: Vec = args["occasions"].as_array() - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_default(); - let now = chrono::Utc::now().to_rfc3339(); - let item = ClothingItem { - id: uuid::Uuid::new_v4().to_string(), - user_id: user_id.to_string(), - name: name.to_string(), - category: category.to_string(), - color: color.to_string(), - brand: brand.map(String::from), - material: None, - seasons, - occasions, - formality: args["formality"].as_i64().unwrap_or(50) as i32, - thickness: args["thickness"].as_i64().unwrap_or(50) as i32, - image_path: None, - purchase_date: None, - purchase_price: None, - last_cleaned: None, - clean_method: None, - wear_count: 0, - last_worn: None, - is_favorite: false, - created_at: now.clone(), - updated_at: now, - }; - self.db.create_clothing(&item).map_err(|e| e.to_string())?; - Ok(serde_json::to_value(item).unwrap()) - } - "update_clothing" => { - let id = args["id"].as_str().ok_or("missing id")?; - let name = args["name"].as_str().unwrap_or(""); - let category = args["category"].as_str().unwrap_or(""); - let color = args["color"].as_str().unwrap_or(""); - self.db.update_clothing(id, name, category, color).map_err(|e| e.to_string())?; - Ok(serde_json::json!({"success": true})) - } - "delete_clothing" => { - let id = args["id"].as_str().ok_or("missing id")?; - self.db.delete_clothing(id).map_err(|e| e.to_string())?; - Ok(serde_json::json!({"success": true})) - } - "list_outfits" => { - let user_id = args["user_id"].as_str().ok_or("missing user_id")?; - let outfits = self.db.list_outfits(user_id).map_err(|e| e.to_string())?; - Ok(serde_json::to_value(outfits).unwrap()) - } - "add_outfit" => { - let user_id = args["user_id"].as_str().ok_or("missing user_id")?; - let name = args["name"].as_str().ok_or("missing name")?; - let item_ids: Vec = args["item_ids"].as_array() - .ok_or("missing item_ids")? - .iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect(); - let now = chrono::Utc::now().to_rfc3339(); - let outfit = Outfit { - id: uuid::Uuid::new_v4().to_string(), - user_id: user_id.to_string(), - name: name.to_string(), - item_ids, - occasion: args["occasion"].as_str().map(String::from), - season: args["season"].as_str().map(String::from), - match_score: None, - ai_analysis: None, - is_saved: false, - created_at: now, - }; - self.db.create_outfit(&outfit).map_err(|e| e.to_string())?; - Ok(serde_json::to_value(outfit).unwrap()) - } - "get_wardrobe_stats" => { - let user_id = args["user_id"].as_str().ok_or("missing user_id")?; - let stats = self.db.get_stats(user_id).map_err(|e| e.to_string())?; - Ok(serde_json::to_value(stats).unwrap()) - } - "record_daily_outfit" => { - let user_id = args["user_id"].as_str().ok_or("missing user_id")?; - let date = args["date"].as_str().ok_or("missing date")?; - let now = chrono::Utc::now().to_rfc3339(); - let record = DailyRecord { - id: uuid::Uuid::new_v4().to_string(), - user_id: user_id.to_string(), - date: date.to_string(), - outfit_id: args["outfit_id"].as_str().map(String::from), - weather_temp: args["weather_temp"].as_f64(), - weather_condition: args["weather_condition"].as_str().map(String::from), - notes: args["notes"].as_str().map(String::from), - created_at: now, - }; - self.db.upsert_daily_record(&record).map_err(|e| e.to_string())?; - Ok(serde_json::to_value(record).unwrap()) - } - "list_daily_records" => { - let user_id = args["user_id"].as_str().ok_or("missing user_id")?; - let limit = args["limit"].as_i64().unwrap_or(30) as i32; - let records = self.db.list_daily_records(user_id, limit).map_err(|e| e.to_string())?; - Ok(serde_json::to_value(records).unwrap()) - } - _ => Err(format!("Unknown tool: {}", name)), - } - } -} - -#[derive(serde::Serialize)] -pub struct McpTool { - pub name: String, - pub description: String, - pub input_schema: String, -} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs deleted file mode 100644 index c7cacfa..0000000 --- a/src-tauri/src/models.rs +++ /dev/null @@ -1,89 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClothingItem { - pub id: String, - pub user_id: String, - pub name: String, - pub category: String, - pub color: String, - pub brand: Option, - pub material: Option, - pub seasons: Vec, - pub occasions: Vec, - pub formality: i32, - pub thickness: i32, - pub image_path: Option, - pub purchase_date: Option, - pub purchase_price: Option, - pub last_cleaned: Option, - pub clean_method: Option, - pub wear_count: i32, - pub last_worn: Option, - pub is_favorite: bool, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Outfit { - pub id: String, - pub user_id: String, - pub name: String, - pub item_ids: Vec, - pub occasion: Option, - pub season: Option, - pub match_score: Option, - pub ai_analysis: Option, - pub is_saved: bool, - pub created_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DailyRecord { - pub id: String, - pub user_id: String, - pub date: String, - pub outfit_id: Option, - pub weather_temp: Option, - pub weather_condition: Option, - pub notes: Option, - pub created_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct User { - pub id: String, - pub email: Option, - pub phone: Option, - pub nickname: String, - pub avatar_path: Option, - pub created_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VerificationCode { - pub target: String, - pub code: String, - pub created_at: i64, - pub expires_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthResult { - pub success: bool, - pub message: String, - pub user: Option, - pub token: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WardrobeStats { - pub total_items: i32, - pub total_outfits: i32, - pub utilization_rate: f64, - pub category_counts: std::collections::HashMap, - pub favorite_count: i32, - pub unworn_30d: i32, - pub needs_cleaning: i32, -} diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..8b6936c --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,2 @@ +// 统一 API 基址: 生产同源 (/api), 也可用 VITE_API_BASE 强制绝对地址 +export const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? ""; \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index b62c214..bd3e2db 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,8 @@ -import { invoke } from "@tauri-apps/api/core"; +// 数据层统一走 HTTP API (服务端 DuckDB 是唯一数据源) +// - 客户端不再持有数据库, 仅保留最近数据的读缓存 (localStorage wardrobe_user 快照) +// - 登录态: localStorage 中的 token, 通过 Authorization: Bearer 携带 +// - Web 与 Tauri 客户端共用同一套接口, web 端因此完整可用 +import { API_BASE } from "./config"; export interface ClothingItem { id: string; @@ -80,43 +84,99 @@ export interface McpTool { input_schema: string; } -// ── Auth ── -export const sendCode = (target: string) => invoke("send_code", { target }); -export const register = (email: string | null, phone: string | null, code: string, nickname: string) => - invoke("register", { email, phone, code, nickname }); -export const login = (email: string | null, phone: string | null, code: string) => - invoke("login", { email, phone, code }); +// ── 网络层 ── -// ── Clothing ── -export const addClothing = (userId: string, name: string, category: string, color: string, brand?: string, seasons?: string[], occasions?: string[]) => - invoke("add_clothing", { userId, name, category, color, brand, seasons, occasions }); -export const listClothing = (userId: string, category?: string) => - invoke("list_clothing", { userId, category }); +function base(p: string) { + return API_BASE ? API_BASE + p : p; +} + +// token 从登录快照取 (RouterGate 会持久化整个 store 到 wardrobe_user) +function getToken(): string | null { + try { + const saved = localStorage.getItem("wardrobe_user"); + if (!saved) return null; + const p = JSON.parse(saved); + return p?.token ?? null; + } catch { + return null; + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const headers: Record = { "Content-Type": "application/json" }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + + let res: Response; + try { + res = await fetch(base(path), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new Error("网络错误,请检查网络连接"); + } + + let data: unknown = null; + try { + data = await res.json(); + } catch { + /* 无 body */ + } + + if (!res.ok) { + const msg = (data as { error?: string; message?: string } | null)?.error + ?? (data as { message?: string } | null)?.message + ?? `请求失败 (${res.status})`; + if (res.status === 401) localStorage.removeItem("wardrobe_user"); + throw new Error(msg); + } + return data as T; +} + +// ── Auth ── +export const sendCode = (target: string) => + request<{ message: string }>("POST", "/api/auth/send-code", { target }).then((r) => r.message); +export const register = (email: string | null, phone: string | null, code: string, nickname: string) => + request("POST", "/api/auth/register", { email, phone, code, nickname }); +export const login = (email: string | null, phone: string | null, code: string) => + request("POST", "/api/auth/login", { email, phone, code }); +export const logout = () => + request<{ success: boolean }>("POST", "/api/auth/logout").then(() => undefined); +export const fetchMe = () => + request<{ user: User }>("GET", "/api/auth/me").then((r) => r.user); + +// ── Clothing (userId 仅作兼容参数, 服务端以登录态为准) ── +export const addClothing = (_userId: string, name: string, category: string, color: string, brand?: string, seasons?: string[], occasions?: string[]) => + request("POST", "/api/clothing", { name, category, color, brand, seasons, occasions }); +export const listClothing = (_userId: string, category?: string) => + request("GET", `/api/clothing${category ? `?category=${encodeURIComponent(category)}` : ""}`); export const getClothing = (id: string) => - invoke("get_clothing", { id }); -export const updateClothing = (id: string, name: string, category: string, color: string) => - invoke("update_clothing", { id, name, category, color }); + request("GET", `/api/clothing/${encodeURIComponent(id)}`); +export const updateClothing = (_userId: string, id: string, name: string, category: string, color: string) => + request("PUT", `/api/clothing/${encodeURIComponent(id)}`, { name, category, color }); export const deleteClothing = (id: string) => - invoke("delete_clothing", { id }); + request("DELETE", `/api/clothing/${encodeURIComponent(id)}`); // ── Outfits ── -export const addOutfit = (userId: string, name: string, itemIds: string[], occasion?: string, season?: string) => - invoke("add_outfit", { userId, name, itemIds, occasion, season }); -export const listOutfits = (userId: string) => - invoke("list_outfits", { userId }); +export const addOutfit = (_userId: string, name: string, itemIds: string[], occasion?: string, season?: string) => + request("POST", "/api/outfits", { name, item_ids: itemIds, occasion, season }); +export const listOutfits = (_userId: string) => + request("GET", "/api/outfits"); // ── Daily Records ── -export const recordDaily = (userId: string, date: string, outfitId?: string, notes?: string) => - invoke("record_daily", { userId, date, outfitId, notes }); -export const listDailyRecords = (userId: string, limit?: number) => - invoke("list_daily_records", { userId, limit }); +export const recordDaily = (_userId: string, date: string, outfitId?: string, notes?: string) => + request("POST", "/api/daily", { date, outfit_id: outfitId, notes }); +export const listDailyRecords = (_userId: string, limit?: number) => + request("GET", `/api/daily${limit ? `?limit=${limit}` : ""}`); // ── Stats ── -export const getStats = (userId: string) => - invoke("get_stats", { userId }); +export const getStats = (_userId: string) => + request("GET", "/api/stats"); // ── MCP ── export const mcpListTools = () => - invoke("mcp_list_tools"); + request("GET", "/api/mcp/tools"); export const mcpCallTool = (toolName: string, args: Record) => - invoke("mcp_call_tool", { toolName, args }); \ No newline at end of file + request("POST", "/api/mcp/call", { tool_name: toolName, args }); \ No newline at end of file diff --git a/src/api/ota.ts b/src/api/ota.ts index 08e5149..346b77f 100644 --- a/src/api/ota.ts +++ b/src/api/ota.ts @@ -10,10 +10,11 @@ import { APP_VERSION, BUILD_ID } from "../version"; // 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境) export const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +import { API_BASE } from "./config"; + // 拼接绝对 URL: web 模式同源 (OTA 服务端同时托管 dist), Tauri 不走这里 function resolveUrl(p: string) { - const base = import.meta.env.VITE_API_BASE as string | undefined; - return base ? base + p : p; + return API_BASE ? API_BASE + p : p; } export interface OtaUpdateInfo { diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index a6fb129..03b82f2 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -6,17 +6,17 @@ import { APP_VERSION, BUILD_ID } from "../version"; const APK_URL = "https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev/data/android/wardrobe.apk"; const FEATURES = [ - { icon: "👕", title: "衣物管理", desc: "分类归档 · 标签 · 季节/场合维度, 一目了然" }, + { icon: "👗", title: "衣物管理", desc: "分类归档 · 标签 · 季节/场合维度, 一目了然" }, { icon: "✨", title: "智能搭配", desc: "根据风格与场合自动搭配, 解锁穿衣灵感" }, { icon: "📅", title: "穿搭日历", desc: "每天记录穿搭与天气, 生成自己的风格轨迹" }, { icon: "📊", title: "数据分析", desc: "统计利用率与偏好, 帮你理性管理衣橱" }, - { icon: "🔒", title: "隐私本地", desc: "数据存储在本地 DuckDB, 不上传云端" }, + { icon: "☁️", title: "云端同步", desc: "数据存储于服务端, 登录账号多端同步, 换机不丢" }, { icon: "🤖", title: "MCP 支持", desc: "支持 MCP 协议, 可被 AI 助手直接调用" }, ]; const STATS = [ { num: "0.1.0", label: "当前版本" }, - { num: "100%", label: "本地离线" }, + { num: "多端", label: "云端同步" }, { num: "Android", label: "移动端" }, ]; @@ -24,7 +24,7 @@ function DownloadSection() { return (

下载 Android 版

-

Wardrobe 是离线优先的本地应用, 数据都保存在你的设备上。

+

登录账号后数据自动云端同步, 换设备也不丢。

@@ -98,7 +98,7 @@ export default function Landing() {

技术栈

跨平台桌面 + 移动端, AI 就绪。

- {["Tauri 2", "React 18 + TypeScript", "DuckDB (本地数据)", "Rust", "MCP Protocol"].map((t) => ( + {["Tauri 2", "React 18 + TypeScript", "DuckDB (服务端)", "Rust", "MCP Protocol"].map((t) => ( {t} @@ -108,7 +108,7 @@ export default function Landing() {
); diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index a77ca3b..24e53e9 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAppStore } from "../stores/app"; +import { logout as logoutApi } from "../api"; import { applyOtaUpdate, checkOtaUpdate, getCurrentVersionInfo, getOtaVersionInfo, reloadApp, rollbackOta } from "../api/ota"; type OtaState = "idle" | "checking" | "available" | "none" | "downloading" | "ready" | "error"; @@ -147,6 +148,7 @@ export default function Profile() { const logout = useAppStore((s) => s.logout); const handleLogout = () => { + logoutApi().catch(() => { /* 服务端会话已废, 本地照常登出 */ }); logout(); localStorage.removeItem("wardrobe_user"); navigate("/login", { replace: true });