refactor: 数据层迁往服务端 (Express+DuckDB), APP 变瘦客户端只留缓存
- server: 新增 DuckDB 数据层 (db.js) 与鉴权 (auth.js: 验证码+token), 挂载 /api 业务路由 (衣物/搭配/日历/统计/MCP), 错误统一中间件 - 前端 src/api/index.ts 全部业务调用改为 HTTP (Bearer token), 客户端不再持有数据库 - 删除 Rust 数据层 (db.rs/auth.rs/mcp.rs/models.rs), Cargo.toml 裁剪 duckdb/chrono 等依赖 - Dockerfile: 基镜像 alpine→slim (@duckdb/node-api glibc 预编译), DB 落 /app/data 卷 - docker-build.yml: 移除 prune --volumes, 挂具名卷 wardrobe-data 持久化 - Landing/Profile 文案改为云端同步口径; 退出登录调服务端会话销毁
This commit is contained in:
@@ -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
|
||||
docker run -d --name wardrobe -p 3002:3002 \
|
||||
-v wardrobe-data:/app/data \
|
||||
wardrobe:latest
|
||||
+9
-4
@@ -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
|
||||
|
||||
Generated
+154
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// 登录/注册: 验证码(服务端一次性校验) + 服务端签发会话 token
|
||||
// 注意: 短信/邮件发送仍是假通道 (验证码固定 1111), 后续再接真实通道
|
||||
import { uu, dbCreateUser, dbGetUserByEmail, dbGetUserByPhone, dbGetUserById, dbGetSession, dbCreateSession, dbDeleteSession } from './db.js'
|
||||
|
||||
const VERIFICATION_CODE = '1111'
|
||||
const CODE_EXPIRY_SECONDS = 300
|
||||
const codes = new Map()
|
||||
|
||||
export function sendVerification(target) {
|
||||
codes.set(target, { code: VERIFICATION_CODE, expiresAt: Date.now() + CODE_EXPIRY_SECONDS * 1000 })
|
||||
console.log(`[auth] verification code for ${target}: ${VERIFICATION_CODE}`)
|
||||
return `验证码已发送至 ${target}`
|
||||
}
|
||||
|
||||
function verifyCode(target, code) {
|
||||
const v = codes.get(target)
|
||||
if (!v) return false
|
||||
if (v.code !== code || Date.now() >= v.expiresAt) return false
|
||||
codes.delete(target)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function register({ email, phone, code, nickname }) {
|
||||
const target = email || phone
|
||||
if (!target) return { success: false, message: '请提供邮箱或手机号', user: null, token: null }
|
||||
if (!verifyCode(target, code)) {
|
||||
return { success: false, message: '验证码无效或已过期', user: null, token: null }
|
||||
}
|
||||
if (email && (await dbGetUserByEmail(email))) {
|
||||
return { success: false, message: '该邮箱已注册', user: null, token: null }
|
||||
}
|
||||
if (phone && (await dbGetUserByPhone(phone))) {
|
||||
return { success: false, message: '该手机号已注册', user: null, token: null }
|
||||
}
|
||||
const id = uu()
|
||||
const user = await dbCreateUser(id, email || null, phone || null, nickname || '时尚达人')
|
||||
const token = 'tok_' + uu().replaceAll('-', '')
|
||||
await dbCreateSession(token, id)
|
||||
console.log(`[auth] user registered: ${id}`)
|
||||
return { success: true, message: '注册成功', user, token }
|
||||
}
|
||||
|
||||
export async function login({ email, phone, code }) {
|
||||
const target = email || phone
|
||||
if (!target) return { success: false, message: '请提供邮箱或手机号', user: null, token: null }
|
||||
if (!verifyCode(target, code)) {
|
||||
return { success: false, message: '验证码无效或已过期', user: null, token: null }
|
||||
}
|
||||
const user = email ? await dbGetUserByEmail(email) : await dbGetUserByPhone(phone)
|
||||
if (!user) return { success: false, message: '账号不存在,请先注册', user: null, token: null }
|
||||
const token = 'tok_' + uu().replaceAll('-', '')
|
||||
await dbCreateSession(token, user.id)
|
||||
console.log(`[auth] user login: ${user.id}`)
|
||||
return { success: true, message: '登录成功', user, token }
|
||||
}
|
||||
|
||||
export async function logout(token) {
|
||||
if (token) await dbDeleteSession(token)
|
||||
}
|
||||
|
||||
export async function resolveSession(token) {
|
||||
const s = token ? await dbGetSession(token) : null
|
||||
if (!s) return null
|
||||
const user = await dbGetUserById(s.user_id)
|
||||
return user
|
||||
}
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
// 服务端数据层: DuckDB (官方 @duckdb/node-api)
|
||||
// 业务数据全部存储在服务端数据库文件 (DB_PATH, 生产建议挂持久化数据卷),
|
||||
// 客户端不再持有任何数据库, 仅保留最近数据的读缓存。
|
||||
import { DuckDBInstance } from '@duckdb/node-api'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
let instance = null
|
||||
let conn = null
|
||||
|
||||
const CATS = ['top', 'bottom', 'outer', 'dress', 'shoes', 'accessory']
|
||||
|
||||
function dbFile() {
|
||||
const p = process.env.DB_PATH || path.resolve(process.cwd(), 'data', 'wardrobe.duckdb')
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true })
|
||||
return p
|
||||
}
|
||||
|
||||
// 单连接 + 操作串行化: DuckDB 单写入者, 避免并发操作交叠
|
||||
let chain = Promise.resolve()
|
||||
export function enqueue(fn) {
|
||||
const p = chain.then(fn)
|
||||
chain = p.catch(() => {})
|
||||
return p
|
||||
}
|
||||
|
||||
async function rows(sql, params = []) {
|
||||
const r = await conn.run(sql, params)
|
||||
return (await r.getRowObjects()) ?? []
|
||||
}
|
||||
|
||||
async function one(sql, params = []) {
|
||||
const r = await rows(sql, params)
|
||||
return r.length ? r[0] : null
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString()
|
||||
export const uu = () => globalThis.crypto.randomUUID()
|
||||
|
||||
function parseArr(v) {
|
||||
if (Array.isArray(v)) return v
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
const p = JSON.parse(v)
|
||||
return Array.isArray(p) ? p : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function mapClothing(x) {
|
||||
return { ...x, seasons: parseArr(x.seasons), occasions: parseArr(x.occasions) }
|
||||
}
|
||||
|
||||
function mapOutfit(x) {
|
||||
return { ...x, item_ids: parseArr(x.item_ids) }
|
||||
}
|
||||
|
||||
export async function initDb() {
|
||||
const file = dbFile()
|
||||
instance = await DuckDBInstance.create(file)
|
||||
conn = await instance.connect()
|
||||
await conn.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
email VARCHAR UNIQUE,
|
||||
phone VARCHAR UNIQUE,
|
||||
nickname VARCHAR NOT NULL DEFAULT '',
|
||||
avatar_path VARCHAR,
|
||||
created_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await conn.run(`CREATE TABLE IF NOT EXISTS sessions (
|
||||
token VARCHAR PRIMARY KEY,
|
||||
user_id VARCHAR NOT NULL,
|
||||
created_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await conn.run(`CREATE TABLE IF NOT EXISTS clothing_items (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
user_id VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
category VARCHAR NOT NULL,
|
||||
color VARCHAR NOT NULL,
|
||||
brand VARCHAR,
|
||||
material VARCHAR,
|
||||
seasons VARCHAR DEFAULT '[]',
|
||||
occasions VARCHAR DEFAULT '[]',
|
||||
formality INTEGER DEFAULT 50,
|
||||
thickness INTEGER DEFAULT 50,
|
||||
image_path VARCHAR,
|
||||
purchase_date VARCHAR,
|
||||
purchase_price DOUBLE,
|
||||
last_cleaned VARCHAR,
|
||||
clean_method VARCHAR,
|
||||
wear_count INTEGER DEFAULT 0,
|
||||
last_worn VARCHAR,
|
||||
is_favorite BOOLEAN DEFAULT FALSE,
|
||||
created_at VARCHAR NOT NULL,
|
||||
updated_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await conn.run(`CREATE TABLE IF NOT EXISTS outfits (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
user_id VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
item_ids VARCHAR DEFAULT '[]',
|
||||
occasion VARCHAR,
|
||||
season VARCHAR,
|
||||
match_score INTEGER,
|
||||
ai_analysis VARCHAR,
|
||||
is_saved BOOLEAN DEFAULT FALSE,
|
||||
created_at VARCHAR NOT NULL
|
||||
)`)
|
||||
await conn.run(`CREATE TABLE IF NOT EXISTS daily_records (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
user_id VARCHAR NOT NULL,
|
||||
date VARCHAR NOT NULL,
|
||||
outfit_id VARCHAR,
|
||||
weather_temp DOUBLE,
|
||||
weather_condition VARCHAR,
|
||||
notes VARCHAR,
|
||||
created_at VARCHAR NOT NULL,
|
||||
UNIQUE(user_id, date)
|
||||
)`)
|
||||
await conn.run(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`)
|
||||
await conn.run(`CREATE INDEX IF NOT EXISTS idx_clothing_user ON clothing_items(user_id)`)
|
||||
await conn.run(`CREATE INDEX IF NOT EXISTS idx_clothing_category ON clothing_items(category)`)
|
||||
await conn.run(`CREATE INDEX IF NOT EXISTS idx_outfits_user ON outfits(user_id)`)
|
||||
await conn.run(`CREATE INDEX IF NOT EXISTS idx_daily_user_date ON daily_records(user_id, date)`)
|
||||
console.log(`[db] DuckDB ready at ${file}`)
|
||||
}
|
||||
|
||||
// ── users / sessions ──
|
||||
|
||||
export function dbCreateUser(id, email, phone, nickname) {
|
||||
return enqueue(async () => {
|
||||
await conn.run(
|
||||
'INSERT INTO users (id, email, phone, nickname, created_at) VALUES (?,?,?,?,?)',
|
||||
[id, email, phone, nickname, nowIso()],
|
||||
)
|
||||
return { id, email, phone, nickname, avatar_path: null, created_at: nowIso() }
|
||||
})
|
||||
}
|
||||
|
||||
export const dbGetUserByEmail = (email) => enqueue(() =>
|
||||
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE email = ?', [email]))
|
||||
|
||||
export const dbGetUserByPhone = (phone) => enqueue(() =>
|
||||
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE phone = ?', [phone]))
|
||||
|
||||
export const dbGetUserById = (id) => enqueue(() =>
|
||||
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE id = ?', [id]))
|
||||
|
||||
export const dbCreateSession = (token, userId) => enqueue(async () => {
|
||||
await conn.run('INSERT INTO sessions (token, user_id, created_at) VALUES (?,?,?)', [token, userId, nowIso()])
|
||||
})
|
||||
|
||||
export const dbGetSession = (token) => enqueue(() =>
|
||||
one('SELECT token, user_id, created_at FROM sessions WHERE token = ?', [token]))
|
||||
|
||||
export const dbDeleteSession = (token) => enqueue(async () => {
|
||||
await conn.run('DELETE FROM sessions WHERE token = ?', [token])
|
||||
})
|
||||
|
||||
// ── clothing ──
|
||||
|
||||
export function dbCreateClothing(userId, data) {
|
||||
return enqueue(async () => {
|
||||
const t = nowIso()
|
||||
const item = {
|
||||
id: uu(),
|
||||
user_id: userId,
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
color: data.color,
|
||||
brand: data.brand ?? null,
|
||||
material: data.material ?? null,
|
||||
seasons: data.seasons ?? [],
|
||||
occasions: data.occasions ?? [],
|
||||
formality: data.formality ?? 50,
|
||||
thickness: data.thickness ?? 50,
|
||||
image_path: data.image_path ?? null,
|
||||
purchase_date: data.purchase_date ?? null,
|
||||
purchase_price: data.purchase_price ?? null,
|
||||
last_cleaned: data.last_cleaned ?? null,
|
||||
clean_method: data.clean_method ?? null,
|
||||
wear_count: data.wear_count ?? 0,
|
||||
last_worn: data.last_worn ?? null,
|
||||
is_favorite: data.is_favorite ?? false,
|
||||
created_at: t,
|
||||
updated_at: t,
|
||||
}
|
||||
await conn.run(
|
||||
`INSERT INTO clothing_items (id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[
|
||||
item.id, item.user_id, item.name, item.category, item.color,
|
||||
item.brand, item.material, JSON.stringify(item.seasons), JSON.stringify(item.occasions),
|
||||
item.formality, item.thickness, item.image_path,
|
||||
item.purchase_date, item.purchase_price, item.last_cleaned,
|
||||
item.clean_method, item.wear_count, item.last_worn,
|
||||
item.is_favorite, item.created_at, item.updated_at,
|
||||
],
|
||||
)
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
export const dbListClothing = (userId, category) => enqueue(async () => {
|
||||
const base = 'SELECT id, user_id, name, category, color, brand, material, seasons, occasions, formality, thickness, image_path, purchase_date, purchase_price, last_cleaned, clean_method, wear_count, last_worn, is_favorite, created_at, updated_at FROM clothing_items'
|
||||
const r = category
|
||||
? await rows(`${base} WHERE user_id = ? AND category = ? ORDER BY created_at DESC`, [userId, category])
|
||||
: await rows(`${base} WHERE user_id = ? ORDER BY created_at DESC`, [userId])
|
||||
return r.map(mapClothing)
|
||||
})
|
||||
|
||||
export async function getClothing(userId, id) {
|
||||
return enqueue(async () => {
|
||||
const x = await one('SELECT * FROM clothing_items WHERE id = ? AND user_id = ?', [id, userId])
|
||||
return x ? mapClothing(x) : null
|
||||
})
|
||||
}
|
||||
|
||||
export const dbUpdateClothing = (userId, id, name, category, color) => enqueue(async () => {
|
||||
await conn.run(
|
||||
'UPDATE clothing_items SET name = ?, category = ?, color = ?, updated_at = ? WHERE id = ? AND user_id = ?',
|
||||
[name, category, color, nowIso(), id, userId],
|
||||
)
|
||||
})
|
||||
|
||||
export const dbDeleteClothing = (userId, id) => enqueue(async () => {
|
||||
await conn.run('DELETE FROM clothing_items WHERE id = ? AND user_id = ?', [id, userId])
|
||||
})
|
||||
|
||||
// ── outfits ──
|
||||
|
||||
export function dbCreateOutfit(userId, data) {
|
||||
return enqueue(async () => {
|
||||
const outfit = {
|
||||
id: uu(),
|
||||
user_id: userId,
|
||||
name: data.name,
|
||||
item_ids: data.item_ids ?? [],
|
||||
occasion: data.occasion ?? null,
|
||||
season: data.season ?? null,
|
||||
match_score: null,
|
||||
ai_analysis: null,
|
||||
is_saved: false,
|
||||
created_at: nowIso(),
|
||||
}
|
||||
await conn.run(
|
||||
'INSERT INTO outfits (id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||
[
|
||||
outfit.id, outfit.user_id, outfit.name, JSON.stringify(outfit.item_ids),
|
||||
outfit.occasion, outfit.season, outfit.match_score,
|
||||
outfit.ai_analysis, outfit.is_saved, outfit.created_at,
|
||||
],
|
||||
)
|
||||
return outfit
|
||||
})
|
||||
}
|
||||
|
||||
export const dbListOutfits = (userId) => enqueue(async () => {
|
||||
const r = await rows(
|
||||
'SELECT id, user_id, name, item_ids, occasion, season, match_score, ai_analysis, is_saved, created_at FROM outfits WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[userId],
|
||||
)
|
||||
return r.map(mapOutfit)
|
||||
})
|
||||
|
||||
// ── daily records ──
|
||||
|
||||
export function dbUpsertDaily(userId, data) {
|
||||
return enqueue(async () => {
|
||||
const record = {
|
||||
id: uu(),
|
||||
user_id: userId,
|
||||
date: data.date,
|
||||
outfit_id: data.outfit_id ?? null,
|
||||
weather_temp: data.weather_temp ?? null,
|
||||
weather_condition: data.weather_condition ?? null,
|
||||
notes: data.notes ?? null,
|
||||
created_at: nowIso(),
|
||||
}
|
||||
await conn.run(
|
||||
`INSERT INTO daily_records (id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET outfit_id = excluded.outfit_id, weather_temp = excluded.weather_temp, weather_condition = excluded.weather_condition, notes = excluded.notes`,
|
||||
[
|
||||
record.id, record.user_id, record.date, record.outfit_id,
|
||||
record.weather_temp, record.weather_condition, record.notes, record.created_at,
|
||||
],
|
||||
)
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
export const dbListDaily = (userId, limit) => enqueue(async () => {
|
||||
const r = await rows(
|
||||
'SELECT id, user_id, date, outfit_id, weather_temp, weather_condition, notes, created_at FROM daily_records WHERE user_id = ? ORDER BY date DESC LIMIT ?',
|
||||
[userId, limit],
|
||||
)
|
||||
return r
|
||||
})
|
||||
|
||||
// ── stats ──
|
||||
|
||||
export function dbGetStats(userId) {
|
||||
return enqueue(async () => {
|
||||
const [totalItems, totalOutfits, favoriteCount] = await Promise.all([
|
||||
count('SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ?', [userId]),
|
||||
count('SELECT COUNT(*) AS c FROM outfits WHERE user_id = ?', [userId]),
|
||||
count('SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND is_favorite = TRUE', [userId]),
|
||||
])
|
||||
const cutoff = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10)
|
||||
const unworn30d = await count(
|
||||
'SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND (last_worn IS NULL OR last_worn < ?)',
|
||||
[userId, cutoff],
|
||||
)
|
||||
const needsCleaning = await count(
|
||||
'SELECT COUNT(*) AS c FROM clothing_items WHERE user_id = ? AND (last_cleaned IS NULL OR last_cleaned < ?)',
|
||||
[userId, cutoff],
|
||||
)
|
||||
const utilizationRate = totalItems > 0 ? Math.round(((totalItems - unworn30d) / totalItems) * 100) : 0
|
||||
|
||||
const catRows = await rows(
|
||||
'SELECT category, COUNT(*) AS c FROM clothing_items WHERE user_id = ? GROUP BY category',
|
||||
[userId],
|
||||
)
|
||||
const categoryCounts = {}
|
||||
for (const c of CATS) categoryCounts[c] = 0
|
||||
for (const r of catRows) categoryCounts[r.category] = Number(r.c)
|
||||
|
||||
return {
|
||||
total_items: totalItems,
|
||||
total_outfits: totalOutfits,
|
||||
utilization_rate: utilizationRate,
|
||||
category_counts: categoryCounts,
|
||||
favorite_count: favoriteCount,
|
||||
unworn_30d: unworn30d,
|
||||
needs_cleaning: needsCleaning,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function count(sql, params) {
|
||||
const r = await rows(sql, params)
|
||||
return r.length ? Number(r[0].c) : 0
|
||||
}
|
||||
|
||||
// ── MCP 工具 (由服务端直接操作数据库, user_id 取鉴权用户) ──
|
||||
|
||||
export function mcpTools() {
|
||||
return [
|
||||
{ name: 'list_clothing', description: '获取用户衣橱中的所有衣物列表', input_schema: '{"type":"object","properties":{"category":{"type":"string"}},"required":[]}' },
|
||||
{ name: 'get_clothing', description: '根据ID获取单件衣物详情', input_schema: '{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}' },
|
||||
{ name: 'add_clothing', description: '添加新衣物到衣橱', input_schema: '{"type":"object","properties":{"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"},"brand":{"type":"string"},"seasons":{"type":"array","items":{"type":"string"}},"occasions":{"type":"array","items":{"type":"string"}}},"required":["name","category","color"]}' },
|
||||
{ name: 'update_clothing', description: '更新衣物信息', input_schema: '{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"color":{"type":"string"}},"required":["id"]}' },
|
||||
{ name: 'delete_clothing', description: '从衣橱中删除衣物', input_schema: '{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}' },
|
||||
{ name: 'list_outfits', description: '获取用户的所有搭配方案', input_schema: '{"type":"object","properties":{},"required":[]}' },
|
||||
{ name: 'add_outfit', description: '创建新的搭配方案', input_schema: '{"type":"object","properties":{"name":{"type":"string"},"item_ids":{"type":"array","items":{"type":"string"}},"occasion":{"type":"string"},"season":{"type":"string"}},"required":["name","item_ids"]}' },
|
||||
{ name: 'get_wardrobe_stats', description: '获取衣橱统计数据(总数、利用率、分类统计等)', input_schema: '{"type":"object","properties":{},"required":[]}' },
|
||||
{ name: 'record_daily_outfit', description: '记录每日穿搭', input_schema: '{"type":"object","properties":{"date":{"type":"string"},"outfit_id":{"type":"string"},"notes":{"type":"string"}},"required":["date"]}' },
|
||||
{ name: 'list_daily_records', description: '获取穿搭日历记录', input_schema: '{"type":"object","properties":{"limit":{"type":"integer"}},"required":[]}' },
|
||||
]
|
||||
}
|
||||
|
||||
// 返回 {ok, value} 或 throw {status,message}
|
||||
export async function mcpCall(toolName, args = {}, userId) {
|
||||
const a = { ...args, user_id: userId }
|
||||
switch (toolName) {
|
||||
case 'list_clothing':
|
||||
return { ok: true, value: await dbListClothing(userId, a.category || null) }
|
||||
case 'get_clothing': {
|
||||
const item = await getClothing(userId, a.id)
|
||||
return { ok: true, value: item ?? { error: '衣物不存在' } }
|
||||
}
|
||||
case 'add_clothing': {
|
||||
if (!a.name || !a.category || !a.color) throw { status: 400, message: '缺少必填字段' }
|
||||
return { ok: true, value: await dbCreateClothing(userId, a) }
|
||||
}
|
||||
case 'update_clothing':
|
||||
await dbUpdateClothing(userId, a.id, a.name || '', a.category || '', a.color || '')
|
||||
return { ok: true, value: { success: true } }
|
||||
case 'delete_clothing':
|
||||
await dbDeleteClothing(userId, a.id)
|
||||
return { ok: true, value: { success: true } }
|
||||
case 'list_outfits':
|
||||
return { ok: true, value: await dbListOutfits(userId) }
|
||||
case 'add_outfit': {
|
||||
if (!a.name || !Array.isArray(a.item_ids)) throw { status: 400, message: '缺少必填字段' }
|
||||
return { ok: true, value: await dbCreateOutfit(userId, a) }
|
||||
}
|
||||
case 'get_wardrobe_stats':
|
||||
return { ok: true, value: await dbGetStats(userId) }
|
||||
case 'record_daily_outfit':
|
||||
if (!a.date) throw { status: 400, message: '缺少 date' }
|
||||
return { ok: true, value: await dbUpsertDaily(userId, a) }
|
||||
case 'list_daily_records':
|
||||
return { ok: true, value: await dbListDaily(userId, a.limit || 30) }
|
||||
default:
|
||||
throw { status: 400, message: `Unknown tool: ${toolName}` }
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -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, () => {
|
||||
initDb()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[wardrobe-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[wardrobe-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[db] failed to init DuckDB:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
// 业务 REST API: 鉴权 + 衣物/搭配/日历/统计/MCP
|
||||
// 客户端不再持有数据库, 全部数据经此接口读写 (瘦客户端)
|
||||
import { Router } from 'express'
|
||||
import * as auth from '../auth.js'
|
||||
import {
|
||||
dbCreateClothing, dbListClothing, getClothing, dbUpdateClothing, dbDeleteClothing,
|
||||
dbCreateOutfit, dbListOutfits, dbUpsertDaily, dbListDaily, dbGetStats,
|
||||
mcpTools, mcpCall,
|
||||
} from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
|
||||
|
||||
// ── 鉴权中间件: Authorization: Bearer <token> ──
|
||||
async function requireAuth(req, res, next) {
|
||||
try {
|
||||
const h = req.headers.authorization || ''
|
||||
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : null
|
||||
const user = await auth.resolveSession(token)
|
||||
if (!user) return res.status(401).json({ error: '未登录或登录已过期' })
|
||||
req.user = user
|
||||
next()
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth ──
|
||||
router.post('/auth/send-code', asyncH(async (req, res) => {
|
||||
const target = String(req.body?.target || '').trim()
|
||||
if (!target) return res.status(400).json({ error: '请提供邮箱或手机号' })
|
||||
res.json({ message: auth.sendVerification(target) })
|
||||
}))
|
||||
|
||||
router.post('/auth/register', asyncH(async (req, res) => {
|
||||
const { email, phone, code, nickname } = req.body || {}
|
||||
res.json(await auth.register({ email: email || null, phone: phone || null, code, nickname }))
|
||||
}))
|
||||
|
||||
router.post('/auth/login', asyncH(async (req, res) => {
|
||||
const { email, phone, code } = req.body || {}
|
||||
res.json(await auth.login({ email: email || null, phone: phone || null, code }))
|
||||
}))
|
||||
|
||||
router.post('/auth/logout', requireAuth, asyncH(async (req, res) => {
|
||||
const h = req.headers.authorization || ''
|
||||
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : null
|
||||
await auth.logout(token)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.get('/auth/me', requireAuth, asyncH(async (req, res) => {
|
||||
res.json({ user: req.user })
|
||||
}))
|
||||
|
||||
// ── Clothing ──
|
||||
router.get('/clothing', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await dbListClothing(req.user.id, req.query.category || null))
|
||||
}))
|
||||
|
||||
router.post('/clothing', requireAuth, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.name || !b.category || !b.color) return res.status(400).json({ error: '缺少必填字段' })
|
||||
res.json(await dbCreateClothing(req.user.id, b))
|
||||
}))
|
||||
|
||||
router.get('/clothing/:id', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await getClothing(req.user.id, req.params.id))
|
||||
}))
|
||||
|
||||
router.put('/clothing/:id', requireAuth, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
await dbUpdateClothing(req.user.id, req.params.id, b.name, b.category, b.color)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
router.delete('/clothing/:id', requireAuth, asyncH(async (req, res) => {
|
||||
await dbDeleteClothing(req.user.id, req.params.id)
|
||||
res.json({ success: true })
|
||||
}))
|
||||
|
||||
// ── Outfits ──
|
||||
router.get('/outfits', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await dbListOutfits(req.user.id))
|
||||
}))
|
||||
|
||||
router.post('/outfits', requireAuth, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.name || !Array.isArray(b.item_ids)) return res.status(400).json({ error: '缺少必填字段' })
|
||||
res.json(await dbCreateOutfit(req.user.id, b))
|
||||
}))
|
||||
|
||||
// ── Daily Records ──
|
||||
router.get('/daily', requireAuth, asyncH(async (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 30, 500)
|
||||
res.json(await dbListDaily(req.user.id, limit))
|
||||
}))
|
||||
|
||||
router.post('/daily', requireAuth, asyncH(async (req, res) => {
|
||||
const b = req.body || {}
|
||||
if (!b.date) return res.status(400).json({ error: '缺少 date' })
|
||||
res.json(await dbUpsertDaily(req.user.id, b))
|
||||
}))
|
||||
|
||||
// ── Stats ──
|
||||
router.get('/stats', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(await dbGetStats(req.user.id))
|
||||
}))
|
||||
|
||||
// ── MCP ──
|
||||
router.get('/mcp/tools', requireAuth, asyncH(async (req, res) => {
|
||||
res.json(mcpTools())
|
||||
}))
|
||||
|
||||
router.post('/mcp/call', requireAuth, asyncH(async (req, res) => {
|
||||
const { tool_name, args } = req.body || {}
|
||||
if (!tool_name) return res.status(400).json({ error: '缺少 tool_name' })
|
||||
const r = await mcpCall(tool_name, args || {}, req.user.id)
|
||||
res.json(r.value)
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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<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 共用)
|
||||
// ══════════════════════════════════════════════════
|
||||
|
||||
// 必须用 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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// 统一 API 基址: 生产同源 (/api), 也可用 VITE_API_BASE 强制绝对地址
|
||||
export const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "";
|
||||
+88
-28
@@ -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<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 ──
|
||||
export const addClothing = (userId: string, name: string, category: string, color: string, brand?: string, seasons?: string[], occasions?: string[]) =>
|
||||
invoke<ClothingItem>("add_clothing", { userId, name, category, color, brand, seasons, occasions });
|
||||
export const listClothing = (userId: string, category?: string) =>
|
||||
invoke<ClothingItem[]>("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<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) =>
|
||||
invoke<ClothingItem | null>("get_clothing", { id });
|
||||
export const updateClothing = (id: string, name: string, category: string, color: string) =>
|
||||
invoke<void>("update_clothing", { id, name, category, color });
|
||||
request<ClothingItem | null>("GET", `/api/clothing/${encodeURIComponent(id)}`);
|
||||
export const updateClothing = (_userId: string, id: string, name: string, category: string, color: string) =>
|
||||
request<void>("PUT", `/api/clothing/${encodeURIComponent(id)}`, { name, category, color });
|
||||
export const deleteClothing = (id: string) =>
|
||||
invoke<void>("delete_clothing", { id });
|
||||
request<void>("DELETE", `/api/clothing/${encodeURIComponent(id)}`);
|
||||
|
||||
// ── Outfits ──
|
||||
export const addOutfit = (userId: string, name: string, itemIds: string[], occasion?: string, season?: string) =>
|
||||
invoke<Outfit>("add_outfit", { userId, name, itemIds, occasion, season });
|
||||
export const listOutfits = (userId: string) =>
|
||||
invoke<Outfit[]>("list_outfits", { userId });
|
||||
export const addOutfit = (_userId: string, name: string, itemIds: string[], occasion?: string, season?: string) =>
|
||||
request<Outfit>("POST", "/api/outfits", { name, item_ids: itemIds, occasion, season });
|
||||
export const listOutfits = (_userId: string) =>
|
||||
request<Outfit[]>("GET", "/api/outfits");
|
||||
|
||||
// ── Daily Records ──
|
||||
export const recordDaily = (userId: string, date: string, outfitId?: string, notes?: string) =>
|
||||
invoke<DailyRecord>("record_daily", { userId, date, outfitId, notes });
|
||||
export const listDailyRecords = (userId: string, limit?: number) =>
|
||||
invoke<DailyRecord[]>("list_daily_records", { userId, limit });
|
||||
export const recordDaily = (_userId: string, date: string, outfitId?: string, notes?: string) =>
|
||||
request<DailyRecord>("POST", "/api/daily", { date, outfit_id: outfitId, notes });
|
||||
export const listDailyRecords = (_userId: string, limit?: number) =>
|
||||
request<DailyRecord[]>("GET", `/api/daily${limit ? `?limit=${limit}` : ""}`);
|
||||
|
||||
// ── Stats ──
|
||||
export const getStats = (userId: string) =>
|
||||
invoke<WardrobeStats>("get_stats", { userId });
|
||||
export const getStats = (_userId: string) =>
|
||||
request<WardrobeStats>("GET", "/api/stats");
|
||||
|
||||
// ── MCP ──
|
||||
export const mcpListTools = () =>
|
||||
invoke<McpTool[]>("mcp_list_tools");
|
||||
request<McpTool[]>("GET", "/api/mcp/tools");
|
||||
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
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<section style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>下载 Android 版</h2>
|
||||
<p style={styles.sectionSub}>Wardrobe 是离线优先的本地应用, 数据都保存在你的设备上。</p>
|
||||
<p style={styles.sectionSub}>登录账号后数据自动云端同步, 换设备也不丢。</p>
|
||||
<div style={styles.downloadCard}>
|
||||
<a className="btn btn-primary" href={APK_URL} download="wardrobe.apk" style={styles.downloadBtn}>
|
||||
⬇️ 下载 APK
|
||||
@@ -64,8 +64,8 @@ export default function Landing() {
|
||||
<span style={{ color: "var(--accent)" }}>随身穿搭管家</span>
|
||||
</h1>
|
||||
<p style={styles.heroSub}>
|
||||
Wardrobe 是一款离线优先的衣物管理应用。记录每一件衣物, 智能生成搭配建议,
|
||||
用数据了解自己的穿搭风格。数据 100% 存在本地, 隐私无忧。
|
||||
Wardrobe 是一款衣物管理应用。记录每一件衣物, 智能生成搭配建议,
|
||||
用数据了解自己的穿搭风格。数据云端同步, 登录即用。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function Landing() {
|
||||
<h2 style={styles.sectionTitle}>技术栈</h2>
|
||||
<p style={styles.sectionSub}>跨平台桌面 + 移动端, AI 就绪。</p>
|
||||
<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" }}>
|
||||
{t}
|
||||
</span>
|
||||
@@ -108,7 +108,7 @@ export default function Landing() {
|
||||
</div>
|
||||
|
||||
<footer style={styles.footer}>
|
||||
<p>Wardrobe · 构建 {BUILD_ID} · 数据保存在本地设备, 我们看不到你的衣橱</p>
|
||||
<p>Wardrobe · 构建 {BUILD_ID} · 数据存储于服务端, 请保管好你的账号</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user