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
+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 {
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
View File
@@ -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 {
+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 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>
);
+2
View File
@@ -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 });