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

- server: 新增 DuckDB 数据层 (db.js) 与鉴权 (auth.js: 验证码+token), 挂载 /api 业务路由
  (衣物/搭配/日历/统计/MCP), 错误统一中间件
- 前端 src/api/index.ts 全部业务调用改为 HTTP (Bearer token), 客户端不再持有数据库
- 删除 Rust 数据层 (db.rs/auth.rs/mcp.rs/models.rs), Cargo.toml 裁剪 duckdb/chrono 等依赖
- Dockerfile: 基镜像 alpine→slim (@duckdb/node-api glibc 预编译), DB 落 /app/data 卷
- docker-build.yml: 移除 prune --volumes, 挂具名卷 wardrobe-data 持久化
- Landing/Profile 文案改为云端同步口径; 退出登录调服务端会话销毁
This commit is contained in:
jif2.zhang
2026-09-15 10:53:58 +08:00
parent 60430f8216
commit c1b630a7ef
19 changed files with 889 additions and 1171 deletions
+4 -4
View File
@@ -90,10 +90,10 @@ jobs:
- name: Remove old container - name: Remove old container
run: docker rm -vf wardrobe || true run: docker rm -vf wardrobe || true
- name: Recycle container
run: docker system prune --volumes -f
- name: Run container - name: Run container
# 一键镜像: 前端 + OTA + 首页, 端口 3002 (与 iboard 的 3001 同机共存) # 一键镜像: 前端 + OTA + 首页, 端口 3002 (与 iboard 的 3001 同机共存)
# 业务数据放具名卷 wardrobe-data, 部署不丢数据
run: | run: |
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
+9 -4
View File
@@ -2,9 +2,10 @@
# Wardrobe 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程 # Wardrobe 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程
# 构建: docker build -t wardrobe:latest . # 构建: docker build -t wardrobe:latest .
# 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址 # 可选 --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 WORKDIR /app
@@ -25,8 +26,8 @@ ARG VITE_API_BASE=
ENV VITE_API_BASE=$VITE_API_BASE ENV VITE_API_BASE=$VITE_API_BASE
RUN npm run build RUN npm run build
# 运行时阶段 # 运行时阶段 (slim: @duckdb/node-api 用 glibc 预编译, 不能用 alpine musl)
FROM node:22-alpine FROM node:22-slim
WORKDIR /app WORKDIR /app
@@ -37,6 +38,10 @@ RUN npm config set registry https://registry.npmmirror.com
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev 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/dist ./dist
COPY --from=builder /app/server ./server COPY --from=builder /app/server ./server
+154
View File
@@ -8,6 +8,7 @@
"name": "wardrobe", "name": "wardrobe",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@duckdb/node-api": "1.5.5-r.5",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -293,6 +294,150 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "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" "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": { "node_modules/dir-glob": {
"version": "3.0.1", "version": "3.0.1",
"dev": true, "dev": true,
+1
View File
@@ -18,6 +18,7 @@
"ota:genkey": "node scripts/ota-genkey.mjs" "ota:genkey": "node scripts/ota-genkey.mjs"
}, },
"dependencies": { "dependencies": {
"@duckdb/node-api": "1.5.5-r.5",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
+66
View File
@@ -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
View File
@@ -0,0 +1,403 @@
// 服务端数据层: DuckDB (官方 @duckdb/node-api)
// 业务数据全部存储在服务端数据库文件 (DB_PATH, 生产建议挂持久化数据卷),
// 客户端不再持有任何数据库, 仅保留最近数据的读缓存。
import { DuckDBInstance } from '@duckdb/node-api'
import path from 'node:path'
import fs from 'node:fs'
let instance = null
let conn = null
const CATS = ['top', 'bottom', 'outer', 'dress', 'shoes', 'accessory']
function dbFile() {
const p = process.env.DB_PATH || path.resolve(process.cwd(), 'data', 'wardrobe.duckdb')
fs.mkdirSync(path.dirname(p), { recursive: true })
return p
}
// 单连接 + 操作串行化: DuckDB 单写入者, 避免并发操作交叠
let chain = Promise.resolve()
export function enqueue(fn) {
const p = chain.then(fn)
chain = p.catch(() => {})
return p
}
async function rows(sql, params = []) {
const r = await conn.run(sql, params)
return (await r.getRowObjects()) ?? []
}
async function one(sql, params = []) {
const r = await rows(sql, params)
return r.length ? r[0] : null
}
const nowIso = () => new Date().toISOString()
export const uu = () => globalThis.crypto.randomUUID()
function parseArr(v) {
if (Array.isArray(v)) return v
if (typeof v === 'string') {
try {
const p = JSON.parse(v)
return Array.isArray(p) ? p : []
} catch {
return []
}
}
return []
}
function mapClothing(x) {
return { ...x, seasons: parseArr(x.seasons), occasions: parseArr(x.occasions) }
}
function mapOutfit(x) {
return { ...x, item_ids: parseArr(x.item_ids) }
}
export async function initDb() {
const file = dbFile()
instance = await DuckDBInstance.create(file)
conn = await instance.connect()
await conn.run(`CREATE TABLE IF NOT EXISTS users (
id VARCHAR PRIMARY KEY,
email VARCHAR UNIQUE,
phone VARCHAR UNIQUE,
nickname VARCHAR NOT NULL DEFAULT '',
avatar_path VARCHAR,
created_at VARCHAR NOT NULL
)`)
await conn.run(`CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
created_at VARCHAR NOT NULL
)`)
await conn.run(`CREATE TABLE IF NOT EXISTS clothing_items (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
name VARCHAR NOT NULL,
category VARCHAR NOT NULL,
color VARCHAR NOT NULL,
brand VARCHAR,
material VARCHAR,
seasons VARCHAR DEFAULT '[]',
occasions VARCHAR DEFAULT '[]',
formality INTEGER DEFAULT 50,
thickness INTEGER DEFAULT 50,
image_path VARCHAR,
purchase_date VARCHAR,
purchase_price DOUBLE,
last_cleaned VARCHAR,
clean_method VARCHAR,
wear_count INTEGER DEFAULT 0,
last_worn VARCHAR,
is_favorite BOOLEAN DEFAULT FALSE,
created_at VARCHAR NOT NULL,
updated_at VARCHAR NOT NULL
)`)
await conn.run(`CREATE TABLE IF NOT EXISTS outfits (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
name VARCHAR NOT NULL,
item_ids VARCHAR DEFAULT '[]',
occasion VARCHAR,
season VARCHAR,
match_score INTEGER,
ai_analysis VARCHAR,
is_saved BOOLEAN DEFAULT FALSE,
created_at VARCHAR NOT NULL
)`)
await conn.run(`CREATE TABLE IF NOT EXISTS daily_records (
id VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
date VARCHAR NOT NULL,
outfit_id VARCHAR,
weather_temp DOUBLE,
weather_condition VARCHAR,
notes VARCHAR,
created_at VARCHAR NOT NULL,
UNIQUE(user_id, date)
)`)
await conn.run(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`)
await conn.run(`CREATE INDEX IF NOT EXISTS idx_clothing_user ON clothing_items(user_id)`)
await conn.run(`CREATE INDEX IF NOT EXISTS idx_clothing_category ON clothing_items(category)`)
await conn.run(`CREATE INDEX IF NOT EXISTS idx_outfits_user ON outfits(user_id)`)
await conn.run(`CREATE INDEX IF NOT EXISTS idx_daily_user_date ON daily_records(user_id, date)`)
console.log(`[db] DuckDB ready at ${file}`)
}
// ── users / sessions ──
export function dbCreateUser(id, email, phone, nickname) {
return enqueue(async () => {
await conn.run(
'INSERT INTO users (id, email, phone, nickname, created_at) VALUES (?,?,?,?,?)',
[id, email, phone, nickname, nowIso()],
)
return { id, email, phone, nickname, avatar_path: null, created_at: nowIso() }
})
}
export const dbGetUserByEmail = (email) => enqueue(() =>
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE email = ?', [email]))
export const dbGetUserByPhone = (phone) => enqueue(() =>
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE phone = ?', [phone]))
export const dbGetUserById = (id) => enqueue(() =>
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE id = ?', [id]))
export const dbCreateSession = (token, userId) => enqueue(async () => {
await conn.run('INSERT INTO sessions (token, user_id, created_at) VALUES (?,?,?)', [token, userId, nowIso()])
})
export const dbGetSession = (token) => enqueue(() =>
one('SELECT token, user_id, created_at FROM sessions WHERE token = ?', [token]))
export const dbDeleteSession = (token) => enqueue(async () => {
await conn.run('DELETE FROM sessions WHERE token = ?', [token])
})
// ── clothing ──
export function dbCreateClothing(userId, data) {
return enqueue(async () => {
const t = nowIso()
const item = {
id: uu(),
user_id: userId,
name: data.name,
category: data.category,
color: data.color,
brand: data.brand ?? null,
material: data.material ?? null,
seasons: data.seasons ?? [],
occasions: data.occasions ?? [],
formality: data.formality ?? 50,
thickness: data.thickness ?? 50,
image_path: data.image_path ?? null,
purchase_date: data.purchase_date ?? null,
purchase_price: data.purchase_price ?? null,
last_cleaned: data.last_cleaned ?? null,
clean_method: data.clean_method ?? null,
wear_count: data.wear_count ?? 0,
last_worn: data.last_worn ?? null,
is_favorite: data.is_favorite ?? false,
created_at: t,
updated_at: t,
}
await conn.run(
`INSERT INTO clothing_items (id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[
item.id, item.user_id, item.name, item.category, item.color,
item.brand, item.material, JSON.stringify(item.seasons), JSON.stringify(item.occasions),
item.formality, item.thickness, item.image_path,
item.purchase_date, item.purchase_price, item.last_cleaned,
item.clean_method, item.wear_count, item.last_worn,
item.is_favorite, item.created_at, item.updated_at,
],
)
return item
})
}
export const dbListClothing = (userId, category) => enqueue(async () => {
const base = 'SELECT id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at FROM clothing_items'
const r = category
? await rows(`${base} WHERE user_id = ? AND category = ? ORDER BY created_at DESC`, [userId, category])
: await rows(`${base} WHERE user_id = ? ORDER BY created_at DESC`, [userId])
return r.map(mapClothing)
})
export async function getClothing(userId, id) {
return enqueue(async () => {
const x = await one('SELECT * FROM clothing_items WHERE id = ? AND user_id = ?', [id, userId])
return x ? mapClothing(x) : null
})
}
export const dbUpdateClothing = (userId, id, name, category, color) => enqueue(async () => {
await conn.run(
'UPDATE clothing_items SET name = ?, category = ?, color = ?, updated_at = ? WHERE id = ? AND user_id = ?',
[name, category, color, nowIso(), id, userId],
)
})
export const dbDeleteClothing = (userId, id) => enqueue(async () => {
await conn.run('DELETE FROM clothing_items WHERE id = ? AND user_id = ?', [id, userId])
})
// ── outfits ──
export function dbCreateOutfit(userId, data) {
return enqueue(async () => {
const outfit = {
id: uu(),
user_id: userId,
name: data.name,
item_ids: data.item_ids ?? [],
occasion: data.occasion ?? null,
season: data.season ?? null,
match_score: null,
ai_analysis: null,
is_saved: false,
created_at: nowIso(),
}
await conn.run(
'INSERT INTO outfits (id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)',
[
outfit.id, outfit.user_id, outfit.name, JSON.stringify(outfit.item_ids),
outfit.occasion, outfit.season, outfit.match_score,
outfit.ai_analysis, outfit.is_saved, outfit.created_at,
],
)
return outfit
})
}
export const dbListOutfits = (userId) => enqueue(async () => {
const r = await rows(
'SELECT id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at FROM outfits WHERE user_id = ? ORDER BY created_at DESC',
[userId],
)
return r.map(mapOutfit)
})
// ── daily records ──
export function dbUpsertDaily(userId, data) {
return enqueue(async () => {
const record = {
id: uu(),
user_id: userId,
date: data.date,
outfit_id: data.outfit_id ?? null,
weather_temp: data.weather_temp ?? null,
weather_condition: data.weather_condition ?? null,
notes: data.notes ?? null,
created_at: nowIso(),
}
await conn.run(
`INSERT INTO daily_records (id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT (user_id, date) DO UPDATE SET outfit_id = excluded.outfit_id, weather_temp = excluded.weather_temp, weather_condition = excluded.weather_condition, notes = excluded.notes`,
[
record.id, record.user_id, record.date, record.outfit_id,
record.weather_temp, record.weather_condition, record.notes, record.created_at,
],
)
return record
})
}
export const dbListDaily = (userId, limit) => enqueue(async () => {
const r = await rows(
'SELECT id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at FROM daily_records WHERE user_id = ? ORDER BY date DESC LIMIT ?',
[userId, limit],
)
return r
})
// ── stats ──
export function dbGetStats(userId) {
return enqueue(async () => {
const [totalItems, totalOutfits, favoriteCount] = await Promise.all([
count('SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ?', [userId]),
count('SELECT COUNT(*) AS c FROM outfits WHERE user_id = ?', [userId]),
count('SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND is_favorite = TRUE', [userId]),
])
const cutoff = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10)
const unworn30d = await count(
'SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND (last_worn IS NULL OR last_worn < ?)',
[userId, cutoff],
)
const needsCleaning = await count(
'SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND (last_cleaned IS NULL OR last_cleaned < ?)',
[userId, cutoff],
)
const utilizationRate = totalItems > 0 ? Math.round(((totalItems - unworn30d) / totalItems) * 100) : 0
const catRows = await rows(
'SELECT category, COUNT(*) AS c FROM clothing_items WHERE user_id = ? GROUP BY category',
[userId],
)
const categoryCounts = {}
for (const c of CATS) categoryCounts[c] = 0
for (const r of catRows) categoryCounts[r.category] = Number(r.c)
return {
total_items: totalItems,
total_outfits: totalOutfits,
utilization_rate: utilizationRate,
category_counts: categoryCounts,
favorite_count: favoriteCount,
unworn_30d: unworn30d,
needs_cleaning: needsCleaning,
}
})
}
async function count(sql, params) {
const r = await rows(sql, params)
return r.length ? Number(r[0].c) : 0
}
// ── MCP 工具 (由服务端直接操作数据库, user_id 取鉴权用户) ──
export function mcpTools() {
return [
{ name: 'list_clothing', description: '获取用户衣橱中的所有衣物列表', input_schema: '{"type":"object","properties":{"category":{"type":"string"}},"required":[]}' },
{ name: 'get_clothing', description: '根据ID获取单件衣物详情', input_schema: '{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}' },
{ name: 'add_clothing', description: '添加新衣物到衣橱', input_schema: '{"type":"object","properties":{"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"},"brand":{"type":"string"},"seasons":{"type":"array","items":{"type":"string"}},"occasions":{"type":"array","items":{"type":"string"}}},"required":["name","category","color"]}' },
{ name: 'update_clothing', description: '更新衣物信息', input_schema: '{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"}},"required":["id"]}' },
{ name: 'delete_clothing', description: '从衣橱中删除衣物', input_schema: '{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}' },
{ name: 'list_outfits', description: '获取用户的所有搭配方案', input_schema: '{"type":"object","properties":{},"required":[]}' },
{ name: 'add_outfit', description: '创建新的搭配方案', input_schema: '{"type":"object","properties":{"name":{"type":"string"},"item_ids":{"type":"array","items":{"type":"string"}},"occasion":{"type":"string"},"season":{"type":"string"}},"required":["name","item_ids"]}' },
{ name: 'get_wardrobe_stats', description: '获取衣橱统计数据(总数、利用率、分类统计等)', input_schema: '{"type":"object","properties":{},"required":[]}' },
{ name: 'record_daily_outfit', description: '记录每日穿搭', input_schema: '{"type":"object","properties":{"date":{"type":"string"},"outfit_id":{"type":"string"},"notes":{"type":"string"}},"required":["date"]}' },
{ name: 'list_daily_records', description: '获取穿搭日历记录', input_schema: '{"type":"object","properties":{"limit":{"type":"integer"}},"required":[]}' },
]
}
// 返回 {ok, value} 或 throw {status,message}
export async function mcpCall(toolName, args = {}, userId) {
const a = { ...args, user_id: userId }
switch (toolName) {
case 'list_clothing':
return { ok: true, value: await dbListClothing(userId, a.category || null) }
case 'get_clothing': {
const item = await getClothing(userId, a.id)
return { ok: true, value: item ?? { error: '衣物不存在' } }
}
case 'add_clothing': {
if (!a.name || !a.category || !a.color) throw { status: 400, message: '缺少必填字段' }
return { ok: true, value: await dbCreateClothing(userId, a) }
}
case 'update_clothing':
await dbUpdateClothing(userId, a.id, a.name || '', a.category || '', a.color || '')
return { ok: true, value: { success: true } }
case 'delete_clothing':
await dbDeleteClothing(userId, a.id)
return { ok: true, value: { success: true } }
case 'list_outfits':
return { ok: true, value: await dbListOutfits(userId) }
case 'add_outfit': {
if (!a.name || !Array.isArray(a.item_ids)) throw { status: 400, message: '缺少必填字段' }
return { ok: true, value: await dbCreateOutfit(userId, a) }
}
case 'get_wardrobe_stats':
return { ok: true, value: await dbGetStats(userId) }
case 'record_daily_outfit':
if (!a.date) throw { status: 400, message: '缺少 date' }
return { ok: true, value: await dbUpsertDaily(userId, a) }
case 'list_daily_records':
return { ok: true, value: await dbListDaily(userId, a.limit || 30) }
default:
throw { status: 400, message: `Unknown tool: ${toolName}` }
}
}
+18 -4
View File
@@ -4,6 +4,8 @@ import fs from 'node:fs'
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import otaRouter from './routes/ota.js' import otaRouter from './routes/ota.js'
import appRouter from './routes/app.js'
import { initDb } from './db.js'
// 默认 3002: 与 iboard (3001) 同机部署时不冲突 // 默认 3002: 与 iboard (3001) 同机部署时不冲突
const PORT = Number(process.env.PORT) || 3002 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('/healthz', (_req, res) => res.json({ ok: true, ts: Date.now() }))
app.get('/api/health', (_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) app.use('/api', otaRouter)
// 静态资源 (Vite build 产物) // 静态资源 (Vite build 产物)
@@ -39,10 +44,19 @@ if (HAS_DIST) {
// 统一错误处理 // 统一错误处理
app.use((err, _req, res, _next) => { app.use((err, _req, res, _next) => {
console.error('[api error]', err) 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, () => { initDb()
.then(() => {
app.listen(PORT, () => {
console.log(`[wardrobe-api] listening on http://127.0.0.1:${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)'}`) 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)
})
+123
View File
@@ -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
-11
View File
@@ -14,17 +14,6 @@ tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
# 前端 OTA 升级 (运行时覆盖 tauri://localhost 资源; 替换 Assets provider) # 前端 OTA 升级 (运行时覆盖 tauri://localhost 资源; 替换 Assets provider)
tauri-plugin-hotswap = "0.0.4" 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" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
-151
View File
@@ -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<HashMap<String, VerificationCode>>,
}
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<String, String> {
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<AuthResult, String> {
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<AuthResult, String> {
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,
}),
}
}
}
-395
View File
@@ -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<Connection>,
path: PathBuf,
}
impl Database {
pub fn new(data_dir: &PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<User, Box<dyn std::error::Error>> {
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<Option<User>, Box<dyn std::error::Error>> {
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<Option<User>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Vec<ClothingItem>, Box<dyn std::error::Error>> {
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<Box<dyn duckdb::ToSql>>) = 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<Box<dyn duckdb::ToSql>> = 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<Option<ClothingItem>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<ClothingItem, duckdb::Error> {
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<dyn std::error::Error>> {
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<Vec<Outfit>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Vec<DailyRecord>, Box<dyn std::error::Error>> {
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<WardrobeStats, Box<dyn std::error::Error>> {
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
}
}
+6 -268
View File
@@ -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 log::info;
use tauri::Manager;
use auth::AuthManager;
use db::Database;
use mcp::McpService;
use models::*;
struct AppState {
db: Arc<Database>,
auth: Arc<AuthManager>,
mcp: Arc<McpService>,
shutdown_flag: Arc<AtomicBool>,
}
// ══════════════════════════════════════════════════
// Tauri IPC Commands
// ══════════════════════════════════════════════════
#[tauri::command]
fn send_code(state: tauri::State<AppState>, target: String) -> Result<String, String> {
state.auth.send_verification(&target)
}
#[tauri::command]
fn register(
state: tauri::State<AppState>,
email: Option<String>,
phone: Option<String>,
code: String,
nickname: String,
) -> Result<AuthResult, String> {
state.auth.register(&state.db, email.as_deref(), phone.as_deref(), &code, &nickname)
}
#[tauri::command]
fn login(
state: tauri::State<AppState>,
email: Option<String>,
phone: Option<String>,
code: String,
) -> Result<AuthResult, String> {
state.auth.login(&state.db, email.as_deref(), phone.as_deref(), &code)
}
#[tauri::command]
fn add_clothing(
state: tauri::State<AppState>,
user_id: String,
name: String,
category: String,
color: String,
brand: Option<String>,
seasons: Option<Vec<String>>,
occasions: Option<Vec<String>>,
) -> Result<ClothingItem, String> {
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<AppState>,
user_id: String,
category: Option<String>,
) -> Result<Vec<ClothingItem>, String> {
state.db.list_clothing(&user_id, category.as_deref()).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_clothing(state: tauri::State<AppState>, id: String) -> Result<Option<ClothingItem>, String> {
state.db.get_clothing(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn update_clothing(
state: tauri::State<AppState>,
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<AppState>, id: String) -> Result<(), String> {
state.db.delete_clothing(&id).map_err(|e| e.to_string())
}
#[tauri::command]
fn add_outfit(
state: tauri::State<AppState>,
user_id: String,
name: String,
item_ids: Vec<String>,
occasion: Option<String>,
season: Option<String>,
) -> Result<Outfit, String> {
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<AppState>, user_id: String) -> Result<Vec<Outfit>, String> {
state.db.list_outfits(&user_id).map_err(|e| e.to_string())
}
#[tauri::command]
fn record_daily(
state: tauri::State<AppState>,
user_id: String,
date: String,
outfit_id: Option<String>,
notes: Option<String>,
) -> Result<DailyRecord, String> {
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<AppState>,
user_id: String,
limit: Option<i32>,
) -> Result<Vec<DailyRecord>, 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<AppState>, user_id: String) -> Result<WardrobeStats, String> {
state.db.get_stats(&user_id).map_err(|e| e.to_string())
}
// ── MCP Commands ──
#[tauri::command]
fn mcp_list_tools(state: tauri::State<AppState>) -> Result<Vec<mcp::McpTool>, String> {
Ok(state.mcp.list_tools())
}
#[tauri::command]
fn mcp_call_tool(
state: tauri::State<AppState>,
tool_name: String,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
state.mcp.call_tool(&tool_name, &args)
}
// ══════════════════════════════════════════════════ // ══════════════════════════════════════════════════
// Main (桌面 + Android 共用) // Main (桌面 + Android 共用)
// ══════════════════════════════════════════════════ // ══════════════════════════════════════════════════
// 业务数据全部放在服务端 (Express + DuckDB), 客户端是瘦客户端:
// 必须用 tauri_plugin_hotswap::init 包装 generate_context, // 前端统一走 HTTP API, 本地不再持有任何数据库。
// 返回 (插件实例, 新的 context), 并把插件注入 Builder。 // 这里只保留 Tauri 引导 + hotswap (OTA) + shell 插件。
// Android 上通过 mobile_entry_point 从这里启动。
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
env_logger::init(); env_logger::init();
info!("Wardrobe starting..."); info!("Wardrobe starting...");
let shutdown_flag = Arc::new(AtomicBool::new(false)); // 必须用 tauri_plugin_hotswap::init 包装 generate_context,
let ctrlc_flag = shutdown_flag.clone(); // 返回 (插件实例, 新的 context), 并把插件注入 Builder。
let setup_flag = shutdown_flag.clone(); // Android 上 OTA 可热替换前端资源。
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
let context = tauri::generate_context!(); let context = tauri::generate_context!();
let (hotswap, context) = match tauri_plugin_hotswap::init(context) { let (hotswap, context) = match tauri_plugin_hotswap::init(context) {
Ok(ok) => ok, Ok(ok) => ok,
@@ -240,54 +26,6 @@ pub fn run() {
let app = tauri::Builder::default() let app = tauri::Builder::default()
.plugin(hotswap) .plugin(hotswap)
.plugin(tauri_plugin_shell::init()) .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) .build(context)
.expect("error while building tauri application"); .expect("error while building tauri application");
-205
View File
@@ -1,205 +0,0 @@
use std::sync::Arc;
use log::info;
use crate::db::Database;
use crate::models::*;
pub struct McpService {
db: Arc<Database>,
}
impl McpService {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
pub fn list_tools(&self) -> Vec<McpTool> {
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<serde_json::Value, String> {
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<String> = args["seasons"].as_array()
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let occasions: Vec<String> = 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<String> = 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,
}
-89
View File
@@ -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<String>,
pub material: Option<String>,
pub seasons: Vec<String>,
pub occasions: Vec<String>,
pub formality: i32,
pub thickness: i32,
pub image_path: Option<String>,
pub purchase_date: Option<String>,
pub purchase_price: Option<f64>,
pub last_cleaned: Option<String>,
pub clean_method: Option<String>,
pub wear_count: i32,
pub last_worn: Option<String>,
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<String>,
pub occasion: Option<String>,
pub season: Option<String>,
pub match_score: Option<i32>,
pub ai_analysis: Option<String>,
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<String>,
pub weather_temp: Option<f64>,
pub weather_condition: Option<String>,
pub notes: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub email: Option<String>,
pub phone: Option<String>,
pub nickname: String,
pub avatar_path: Option<String>,
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<User>,
pub token: Option<String>,
}
#[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<String, i32>,
pub favorite_count: i32,
pub unworn_30d: i32,
pub needs_cleaning: i32,
}
+2
View File
@@ -0,0 +1,2 @@
// 统一 API 基址: 生产同源 (/api), 也可用 VITE_API_BASE 强制绝对地址
export const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "";
+88 -28
View File
@@ -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 { export interface ClothingItem {
id: string; id: string;
@@ -80,43 +84,99 @@ export interface McpTool {
input_schema: string; input_schema: string;
} }
// ── Auth ── // ── 网络层 ──
export const sendCode = (target: string) => invoke<string>("send_code", { target });
export const register = (email: string | null, phone: string | null, code: string, nickname: string) =>
invoke<AuthResult>("register", { email, phone, code, nickname });
export const login = (email: string | null, phone: string | null, code: string) =>
invoke<AuthResult>("login", { email, phone, code });
// ── Clothing ── function base(p: string) {
export const addClothing = (userId: string, name: string, category: string, color: string, brand?: string, seasons?: string[], occasions?: string[]) => return API_BASE ? API_BASE + p : p;
invoke<ClothingItem>("add_clothing", { userId, name, category, color, brand, seasons, occasions }); }
export const listClothing = (userId: string, category?: string) =>
invoke<ClothingItem[]>("list_clothing", { userId, category }); // 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<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { "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<AuthResult>("POST", "/api/auth/register", { email, phone, code, nickname });
export const login = (email: string | null, phone: string | null, code: string) =>
request<AuthResult>("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<ClothingItem>("POST", "/api/clothing", { name, category, color, brand, seasons, occasions });
export const listClothing = (_userId: string, category?: string) =>
request<ClothingItem[]>("GET", `/api/clothing${category ? `?category=${encodeURIComponent(category)}` : ""}`);
export const getClothing = (id: string) => export const getClothing = (id: string) =>
invoke<ClothingItem | null>("get_clothing", { id }); request<ClothingItem | null>("GET", `/api/clothing/${encodeURIComponent(id)}`);
export const updateClothing = (id: string, name: string, category: string, color: string) => export const updateClothing = (_userId: string, id: string, name: string, category: string, color: string) =>
invoke<void>("update_clothing", { id, name, category, color }); request<void>("PUT", `/api/clothing/${encodeURIComponent(id)}`, { name, category, color });
export const deleteClothing = (id: string) => export const deleteClothing = (id: string) =>
invoke<void>("delete_clothing", { id }); request<void>("DELETE", `/api/clothing/${encodeURIComponent(id)}`);
// ── Outfits ── // ── Outfits ──
export const addOutfit = (userId: string, name: string, itemIds: string[], occasion?: string, season?: string) => export const addOutfit = (_userId: string, name: string, itemIds: string[], occasion?: string, season?: string) =>
invoke<Outfit>("add_outfit", { userId, name, itemIds, occasion, season }); request<Outfit>("POST", "/api/outfits", { name, item_ids: itemIds, occasion, season });
export const listOutfits = (userId: string) => export const listOutfits = (_userId: string) =>
invoke<Outfit[]>("list_outfits", { userId }); request<Outfit[]>("GET", "/api/outfits");
// ── Daily Records ── // ── Daily Records ──
export const recordDaily = (userId: string, date: string, outfitId?: string, notes?: string) => export const recordDaily = (_userId: string, date: string, outfitId?: string, notes?: string) =>
invoke<DailyRecord>("record_daily", { userId, date, outfitId, notes }); request<DailyRecord>("POST", "/api/daily", { date, outfit_id: outfitId, notes });
export const listDailyRecords = (userId: string, limit?: number) => export const listDailyRecords = (_userId: string, limit?: number) =>
invoke<DailyRecord[]>("list_daily_records", { userId, limit }); request<DailyRecord[]>("GET", `/api/daily${limit ? `?limit=${limit}` : ""}`);
// ── Stats ── // ── Stats ──
export const getStats = (userId: string) => export const getStats = (_userId: string) =>
invoke<WardrobeStats>("get_stats", { userId }); request<WardrobeStats>("GET", "/api/stats");
// ── MCP ── // ── MCP ──
export const mcpListTools = () => export const mcpListTools = () =>
invoke<McpTool[]>("mcp_list_tools"); request<McpTool[]>("GET", "/api/mcp/tools");
export const mcpCallTool = (toolName: string, args: Record<string, unknown>) => export const mcpCallTool = (toolName: string, args: Record<string, unknown>) =>
invoke<unknown>("mcp_call_tool", { toolName, args }); request<unknown>("POST", "/api/mcp/call", { tool_name: toolName, args });
+3 -2
View File
@@ -10,10 +10,11 @@ import { APP_VERSION, BUILD_ID } from "../version";
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境) // 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
export const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; export const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
import { API_BASE } from "./config";
// 拼接绝对 URL: web 模式同源 (OTA 服务端同时托管 dist), Tauri 不走这里 // 拼接绝对 URL: web 模式同源 (OTA 服务端同时托管 dist), Tauri 不走这里
function resolveUrl(p: string) { function resolveUrl(p: string) {
const base = import.meta.env.VITE_API_BASE as string | undefined; return API_BASE ? API_BASE + p : p;
return base ? base + p : p;
} }
export interface OtaUpdateInfo { export interface OtaUpdateInfo {
+8 -8
View File
@@ -6,17 +6,17 @@ import { APP_VERSION, BUILD_ID } from "../version";
const APK_URL = "https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev/data/android/wardrobe.apk"; const APK_URL = "https://pub-98ee77ce79984c2cad0dc1c300901158.r2.dev/data/android/wardrobe.apk";
const FEATURES = [ const FEATURES = [
{ icon: "👕", title: "衣物管理", desc: "分类归档 · 标签 · 季节/场合维度, 一目了然" }, { icon: "👗", title: "衣物管理", desc: "分类归档 · 标签 · 季节/场合维度, 一目了然" },
{ icon: "✨", title: "智能搭配", desc: "根据风格与场合自动搭配, 解锁穿衣灵感" }, { 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 助手直接调用" }, { icon: "🤖", title: "MCP 支持", desc: "支持 MCP 协议, 可被 AI 助手直接调用" },
]; ];
const STATS = [ const STATS = [
{ num: "0.1.0", label: "当前版本" }, { num: "0.1.0", label: "当前版本" },
{ num: "100%", label: "本地离线" }, { num: "多端", label: "云端同步" },
{ num: "Android", label: "移动端" }, { num: "Android", label: "移动端" },
]; ];
@@ -24,7 +24,7 @@ function DownloadSection() {
return ( return (
<section style={styles.section}> <section style={styles.section}>
<h2 style={styles.sectionTitle}>下载 Android 版</h2> <h2 style={styles.sectionTitle}>下载 Android 版</h2>
<p style={styles.sectionSub}>Wardrobe 是离线优先的本地应用, 数据都保存在你的设备上。</p> <p style={styles.sectionSub}>登录账号后数据自动云端同步, 换设备也不丢。</p>
<div style={styles.downloadCard}> <div style={styles.downloadCard}>
<a className="btn btn-primary" href={APK_URL} download="wardrobe.apk" style={styles.downloadBtn}> <a className="btn btn-primary" href={APK_URL} download="wardrobe.apk" style={styles.downloadBtn}>
⬇️ 下载 APK ⬇️ 下载 APK
@@ -64,8 +64,8 @@ export default function Landing() {
<span style={{ color: "var(--accent)" }}>随身穿搭管家</span> <span style={{ color: "var(--accent)" }}>随身穿搭管家</span>
</h1> </h1>
<p style={styles.heroSub}> <p style={styles.heroSub}>
Wardrobe 是一款离线优先的衣物管理应用。记录每一件衣物, 智能生成搭配建议, Wardrobe 是一款衣物管理应用。记录每一件衣物, 智能生成搭配建议,
用数据了解自己的穿搭风格。数据 100% 存在本地, 隐私无忧。 用数据了解自己的穿搭风格。数据云端同步, 登录即用。
</p> </p>
</section> </section>
@@ -98,7 +98,7 @@ export default function Landing() {
<h2 style={styles.sectionTitle}>技术栈</h2> <h2 style={styles.sectionTitle}>技术栈</h2>
<p style={styles.sectionSub}>跨平台桌面 + 移动端, AI 就绪。</p> <p style={styles.sectionSub}>跨平台桌面 + 移动端, AI 就绪。</p>
<div style={styles.techRow}> <div style={styles.techRow}>
{["Tauri 2", "React 18 + TypeScript", "DuckDB (本地数据)", "Rust", "MCP Protocol"].map((t) => ( {["Tauri 2", "React 18 + TypeScript", "DuckDB (服务端)", "Rust", "MCP Protocol"].map((t) => (
<span key={t} className="tag-pill" style={{ fontSize: 13, padding: "8px 14px" }}> <span key={t} className="tag-pill" style={{ fontSize: 13, padding: "8px 14px" }}>
{t} {t}
</span> </span>
@@ -108,7 +108,7 @@ export default function Landing() {
</div> </div>
<footer style={styles.footer}> <footer style={styles.footer}>
<p>Wardrobe · 构建 {BUILD_ID} · 数据保存在本地设备, 我们看不到你的衣橱</p> <p>Wardrobe · 构建 {BUILD_ID} · 数据存储于服务端, 请保管好你的账号</p>
</footer> </footer>
</div> </div>
); );
+2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAppStore } from "../stores/app"; import { useAppStore } from "../stores/app";
import { logout as logoutApi } from "../api";
import { applyOtaUpdate, checkOtaUpdate, getCurrentVersionInfo, getOtaVersionInfo, reloadApp, rollbackOta } from "../api/ota"; import { applyOtaUpdate, checkOtaUpdate, getCurrentVersionInfo, getOtaVersionInfo, reloadApp, rollbackOta } from "../api/ota";
type OtaState = "idle" | "checking" | "available" | "none" | "downloading" | "ready" | "error"; type OtaState = "idle" | "checking" | "available" | "none" | "downloading" | "ready" | "error";
@@ -147,6 +148,7 @@ export default function Profile() {
const logout = useAppStore((s) => s.logout); const logout = useAppStore((s) => s.logout);
const handleLogout = () => { const handleLogout = () => {
logoutApi().catch(() => { /* 服务端会话已废, 本地照常登出 */ });
logout(); logout();
localStorage.removeItem("wardrobe_user"); localStorage.removeItem("wardrobe_user");
navigate("/login", { replace: true }); navigate("/login", { replace: true });