20 KiB
20 KiB
Storage 个人物品管理 App —— 设计文档
版本:v0.1(草稿) 日期:2026-09 关联:REQUIREMENTS.md
1. 架构总览
沿用 Wardrobe 已验证的 三层薄客户端架构:
┌──────────────────────────────────────────────┐
│ 客户端(Tauri 2 壳 + React SPA) │
│ pages / stores(Zustand) / api(fetch) / types │
└──────────────────────┬───────────────────────┘
│ HTTP REST (Bearer Token)
┌──────────────────────▼───────────────────────┐
│ 服务端(Express Node 22) │
│ routes/app.js → 校验 + 权限(家庭角色) │
│ │ │
│ ▼ │
│ repository(server/db.js)DuckDB DAO + 迁移 │
└──────────────────────┬───────────────────────┘
│ 单机上写串行化(enqueue)
┌──────────────────────▼───────────────────────┐
│ DuckDB 文件(data/storage.duckdb) │
│ 用户/会话/家庭/成员/盒子/物品/图片/日志 │
└──────────────────────────────────────────────┘
- 客户端无本地数据库,仅缓存当前数据到
localStorage(沿用 Wardrobe 的RouterGate恢复方案)。 - 服务端为唯一事实来源;所有写操作经家庭角色权限校验。
- 客户端 UI 仅做展示层隐藏/禁用,不承载鉴权。
2. 技术选型
| 层 | 技术 | 说明 |
|---|---|---|
| 前端 | React 18 + TypeScript 5 + Vite 5 | 沿用 Wardrobe |
| 状态管理 | Zustand | stores/family.ts、stores/data.ts 等 |
| 路由 | React Router v6 | /home /boxes /items /families /profile |
| 桌面壳 | Tauri 2(Rust),hotswap 热更新可选 | 沿用 Wardrobe |
| 后端 | Express 4(Node ≥ 18,--experimental-strip-types) |
沿用 |
| 数据库 | DuckDB(@duckdb/node-api)单写串行化 | 沿用 enqueue |
| 图片 | 本地文件存储 + 鉴权读取代理 | 新建(Wardrobe 未实现) |
| 构建部署 | 单 Docker 镜像 + Gitea Actions | 沿用 Wardrobe |
3. 目录结构
D:\workbench\Storage\
├── index.html / vite.config.ts / tsconfig.json / package.json
├── REQUIREMENTS.md / DESIGN.md
├── Dockerfile / .mise.toml
├── scripts/ # 构建、签名、上传(沿用)
├── server/
│ ├── index.js # 启动、静态、SPA fallback
│ ├── db.js # 连接 + 迁移 + DAO + MCP
│ ├── auth.js # 验证码/会话
│ └── routes/
│ ├── app.js # 业务 REST API
│ ├── images.js # 图片上传/读取(鉴权)
│ └── ota.js # 热更新(沿用)
│ └── data/uploads/ # 图片文件(数据卷)
├── src-tauri/ # Tauri 壳(沿用)
└── src/
├── main.tsx / App.tsx / version.ts
├── api/ # config.ts / index.ts(类型化 fetch)/ images.ts
├── stores/ # auth / family / boxes / items(Zustand)
├── types/index.ts # 全部领域类型(单一来源,避免 Wardrobe 重复定义问题)
├── components/ # Layout, Breadcrumb, BoxCanvas, BoxNode, ItemCard, FamilySwitcher...
├── pages/ # Home, Boxes(树+画布), BoxDetail, ItemList, ItemDetail, ItemEdit, FamilyManage, Profile
└── styles/global.css # 设计令牌(沿用 Wardrobe 暖色系)
改进点(相对 Wardrobe):类型集中到
src/types/index.ts,服务端用server/types.ts与之一致;新增正式迁移机制 §5。
4. 领域模型(ER)
users ──< family_members >── families
family_id 归属 │
┌───────────────────────┼─────────────────────┐
▼ ▼ ▼
categories boxes ──<parent── boxes items
(家庭自定义分类) (无限嵌套盒子) (物品, box_id 可空→根层)
关键概念:
- 家庭根层:不是一张表,而是
box_id IS NULL的语义条件(物品直接挂在家庭根层;没有父盒子的盒子视为根层直接子盒)。 - 盒子树:一套
boxes树(一个家庭 = 一棵树),parent_box_id指向父盒,根节点「隐形」就是家庭本身。 - 位置:每个盒子在父容器内有一个归一化坐标
x,y ∈ [0,1000],父容器内容区为坐标系;w/h预留尺寸(v1 可为 NULL,自动尺寸)。 - 权限:角色存于
family_members.role,写接口在requireFamilyAdmin中间件中校验。
4.1 表结构(DDL,DuckDB 语法)
-- 迁移表:storage.db.migrations 记录版本
CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at VARCHAR NOT NULL);
CREATE TABLE 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 sessions (
token VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
created_at VARCHAR NOT NULL);
CREATE TABLE families (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
icon VARCHAR DEFAULT '🏠',
owner_user_id VARCHAR NOT NULL, -- 创建者(超级管理员)
invite_code VARCHAR UNIQUE,
created_at VARCHAR NOT NULL,
updated_at VARCHAR NOT NULL);
CREATE TABLE family_members (
family_id VARCHAR NOT NULL,
user_id VARCHAR NOT NULL,
role VARCHAR NOT NULL CHECK (role IN ('admin','member')),
joined_at VARCHAR NOT NULL,
PRIMARY KEY (family_id, user_id));
CREATE TABLE categories (
id VARCHAR PRIMARY KEY,
family_id VARCHAR NOT NULL,
name VARCHAR NOT NULL,
sort INTEGER DEFAULT 0,
created_at VARCHAR NOT NULL);
CREATE TABLE boxes (
id VARCHAR PRIMARY KEY,
family_id VARCHAR NOT NULL,
parent_box_id VARCHAR, -- NULL = 家庭根层直接子盒
name VARCHAR NOT NULL,
icon VARCHAR DEFAULT '📦',
color VARCHAR DEFAULT '#E8C4A0',
pos_x INTEGER DEFAULT 0 CHECK (pos_x BETWEEN 0 AND 1000),
pos_y INTEGER DEFAULT 0 CHECK (pos_y BETWEEN 0 AND 1000),
width INTEGER, -- 预留:归一化尺寸(v1 NULL=自动)
height INTEGER,
created_at VARCHAR NOT NULL,
updated_at VARCHAR NOT NULL);
CREATE TABLE items (
id VARCHAR PRIMARY KEY,
family_id VARCHAR NOT NULL,
box_id VARCHAR, -- NULL = 家庭根层
name VARCHAR NOT NULL,
category_id VARCHAR,
tags VARCHAR DEFAULT '[]', -- JSON 数组字符串
quantity INTEGER DEFAULT 1,
brand VARCHAR, model VARCHAR, serial_no VARCHAR,
purchase_date VARCHAR, purchase_price DOUBLE,
expiry_date VARCHAR, -- NULL 表示无期限
images VARCHAR DEFAULT '[]', -- JSON 数组:文件相对路径
notes VARCHAR,
is_favorite BOOLEAN DEFAULT FALSE,
used_count INTEGER DEFAULT 0, last_used VARCHAR,
created_at VARCHAR NOT NULL, updated_at VARCHAR NOT NULL);
-- 审计:跨家庭转移日志
CREATE TABLE transfer_logs (
id VARCHAR PRIMARY KEY,
entity_type VARCHAR NOT NULL, -- 'item' | 'box'
entity_id VARCHAR NOT NULL,
src_family_id VARCHAR NOT NULL,
dst_family_id VARCHAR NOT NULL,
operator_user_id VARCHAR NOT NULL,
created_at VARCHAR NOT NULL);
CREATE INDEX idx_members_user ON family_members(user_id);
CREATE INDEX idx_boxes_family ON boxes(family_id);
CREATE INDEX idx_boxes_parent ON boxes(parent_box_id);
CREATE INDEX idx_items_family ON items(family_id);
CREATE INDEX idx_items_box ON items(box_id);
CREATE INDEX idx_items_family_box ON items(family_id, box_id);
4.2 ID 与时间
- ID:
crypto.randomUUID()(沿用)。 - 时间:UTC ISO 字符串,前端本地化展示。
tags/images:JSON 数组字符串;标签筛选用list_contains(string_split(tags, ','), ?)。
5. 数据迁移机制
区别于 Wardrobe(裸 CREATE TABLE IF NOT EXISTS),Storage 引入版本化迁移:
server/db.js启动时读取schema_version最大版本,按序执行MIGRATIONS[curVersion+1...](每个迁移为{version, up(conn)}),逐条提交并更新版本号。- 迁移内容只增不改:任何 DDL/DML 变更都以新版本追加。
- 幂等:
CREATE TABLE仍带IF NOT EXISTS,INSERT 幂等;失败则中止启动并告警。
6. API 设计(REST)
沿用 Wardrobe 的 request<T> fetch 封装与 Bearer Token。所有写接口经 requireFamilyAdmin(familyId) 校验角色(管理员);读取接口 requireFamilyMember 校验成员身份。
6.1 路由总表
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/auth/send-code /register /login /logout |
公开 | 沿用 Wardrobe |
| GET | /api/auth/me |
登录 | |
| GET | /api/families |
登录 | 我加入的家庭列表 |
| POST | /api/families |
登录 | 创建家庭(成为管理员) |
| POST | /api/families/:id/join |
登录 | 凭 invite_code 加入 |
| GET | /api/families/:id |
成员 | 家庭详情 + 成员列表 |
| PUT | /api/families/:id DELETE |
管理员/创建者 | 改名;解散 |
| POST | /api/families/:id/invite |
管理员 | 生成/刷新/失效邀请码 |
| POST | /api/families/:id/members/:userId/role |
管理员 | 改角色 admin/member |
| DELETE | /api/families/:id/members/:userId |
管理员 | 移除成员;离开家庭为 DELETE /members/me |
| GET | /api/families/:id/categories |
成员 | |
| POST | /api/families/:id/categories |
管理员 | |
| PUT/DELETE | /api/families/:id/categories/:catId |
管理员 | |
| GET | /api/families/:id/boxes?parent=null|:boxId |
成员 | 某容器的直接子盒(根层=parent 缺省) |
| GET | /api/families/:id/boxes/:boxId |
成员 | 盒子详情 + 面包屑路径 |
| POST | /api/families/:id/boxes |
管理员 | 新建盒子(parent_box_id + 位置) |
| PUT | /api/families/:id/boxes/:boxId |
管理员 | 改名/图标/颜色 |
| DELETE | /api/families/:id/boxes/:boxId |
管理员 | 删除(含子树,二次确认) |
| PUT | /api/families/:id/boxes/:boxId/move |
管理员 | {parent_box_id, pos_x, pos_y} 整盒移动(跨容器) |
| PUT | /api/families/:id/boxes/:boxId/position |
管理员 | 仅调整位置(拖拽保存) |
| GET | /api/families/:id/items?box=...&category=...&tag=...&q=&favorite=&expiring= |
成员 | 列表/搜索/筛选;box 支持递归子树 |
| POST | /api/families/:id/items |
管理员 | |
| GET | /api/families/:id/items/:itemId |
成员 | 含所在路径 |
| PUT | /api/families/:id/items/:itemId |
管理员 | 全字段更新 |
| DELETE | /api/families/:id/items/:itemId |
管理员 | |
| PUT | /api/families/:id/items/:itemId/move |
管理员 | {box_id|null} 移动盒/根层 |
| POST | /api/families/:id/transfer |
管理员 | {type:'item'|'box', entityId, dstFamilyId, dstParentBoxId?, pos?} 跨家庭转移 |
| GET | /api/families/:id/stats |
成员 | 概览统计 |
| POST | /api/images |
登录 | multipart 上传,返回路径 |
| GET | /api/images/* |
成员 | 鉴权读取(校验所在家庭成员) |
| GET | /api/transfer-logs?familyId= |
管理员 | P1 |
| GET/POST | /api/mcp/tools /call |
登录 | P1,沿用 Wardrobe |
6.2 盒子树读取与递归
- 单容器展开(懒加载):
GET boxes?parent=X只返回直接子盒,前端按需展开——避免一次拉整棵树。 - 全树(用于画布俯视/统计):
GET boxes?tree=1,服务端用递归 CTE 一次性返回子树(深度 <100 校验防环)。
WITH RECURSIVE subtree AS (
SELECT id, parent_box_id, name FROM boxes WHERE id = ? AND family_id = ?
UNION ALL
SELECT b.id, b.parent_box_id, b.name
FROM boxes b JOIN subtree s ON b.parent_box_id = s.id
WHERE b.family_id = ?
)
SELECT * FROM subtree;
- 子树的物品计数 / 汇总:
items JOIN subtree聚合,一个查询得到整棵子树物品数与价值合计。
6.3 权限校验(中间件)
async function requireFamilyMember(req, res, next) {
// 1) 认证会话 → req.user
// 2) 查 family_members → 非成员 403
}
async function requireFamilyAdmin(familyId) {
// 成员且 role==='admin',否则 403
}
requireFamilyMember用于读取/列表/搜索;requireFamilyAdmin用于一切写操作。transfer需同时满足:操作者是 源家庭管理员,且是 目标家庭成员(防越权散落)。
7. 盒子位置模型(画布)
7.1 坐标系
- 每个父容器(父盒子或家庭根层)的内容区 = 一张 归一化虚拟画布,坐标
x,y ∈ [0,1000](整数)。 - 子盒子渲染为其内部的一张方块,方块尺寸 v1 取「自动」(如 根据内容大小 / 固定网格),
width/height字段为预留尺寸。 - 位置允许重叠;「自动整理」按钮将同一容器的直接子盒按蛇形序、以统一网格间距重新分配坐标(P1)。
7.2 交互
- 画布视图:
BoxCanvas将父容器绘制为比例画布,子盒为绝对定位div(left=x/1000*100%),支持拖拽更新pos_x/pos_y;松手后PUT /boxes/:id/position保存。 - 进入多层:点击子盒 → 进入其内部(面包屑
家庭 / 客厅 / 柜子1 / 收纳盒x);根层面包屑第一项即家庭。 - 列表视图:同容器内容按位置/创建时间扁平排列,与画布视图同一数据源、可自由切换。
- 整盒移动:画布/列表中选择「移动到…」,弹窗选择目标容器(懒加载树选择器)→
PUT /boxes/:id/move(同时提交目标容器内新位置)。
7.3 约束
parent_box_id不允许设置成自身或自身后代(防环):服务端在 move 时先递归校验。- 移动/转移后清空并重载当前容器缓存。
8. 关键业务流程
8.1 跨家庭转移(整盒含物品)
1) 管理员对 box B(或 item I)发起 transfer,目标家庭 F2,目标容器 parent(可选)
2) 服务端校验:源家庭管理员 + 目标家庭成员
3) 防环校验(如为盒子)
4) 事务中:递归取出 B 子树;源框 items 与 boxes 全部重写 family_id=F2;
根盒置位 parent(或 F2 根层)+ 新位置;物品同步迁移
5) 写 transfer_logs 审计
6) 返回新实体;客户端切换当前家庭后可见
8.2 权限矩阵
| 操作 | 管理员 | 成员 |
|---|---|---|
| 查看家成员/盒子树/物品/搜索/统计 | ✅ | ✅ |
| 新建/编辑/删除 盒子、物品、分类 | ✅ | ❌ 403 |
| 整盒移动、物品移动 | ✅ | ❌ |
| 邀请/移员/改角色 | ✅ | ❌ |
| 跨家庭转移 | ✅(需为目标家成员) | ❌ |
| 解散家庭 | 仅创建者 | ❌ |
9. 前端设计
9.1 状态(Zustand)
stores/auth.ts:账号/验证码表单(沿用)。stores/app.ts:user,token,currentFamilyId,families[],boxesById,items[],stats,breadcrumb[];logout清空;RouterGate持久化到localStorage["storage_user"]。- 数据加载沿用「页面 useEffect 拉取写 store」模式;切换家庭时清空该家庭缓存。
9.2 页面与导航
- 底部导航 5 项:首页 · 盒子(树+画布)· +(新增物品 FAB)· 物品(列表/搜索)· 我的(家庭管理/退出)。
- 顶部:家庭切换器(下拉)— 切换即重载全部数据。
- 页面:
Home.tsx(统计+待办)、Boxes.tsx(树+画布俯视)、BoxDetail.tsx(面包屑+子盒+物品)、ItemList.tsx、ItemDetail.tsx、ItemEdit.tsx、FamilyManage.tsx(成员/邀请码/分类)、Profile.tsx。
9.3 关键组件
BoxCanvas:归一化画布 + 绝对定位子盒 + 拖拽(pointer events)+ 自动整理。BoxTreePicker:懒加载树选择器(选择目标容器)。Breadcrumb:位置路径导航。FamilySwitcher:当前家庭下拉。PermissionGuard:按role隐藏/禁用写操作控件(仅展示层,服务端仍校验)。
9.4 图片
- 上传:
POST /api/images(multipart)→ 存server/data/uploads/<uuid>.<ext>,返回相对路径。 - 读取:
GET /api/images/<uuid>.<ext>,中间件解析 Token 并校验当前用户为文件所属家庭(文件名前缀含 family_id 便于校验,或读库校验)成员。 - 前端:
<img>直接引用/api/images/...(带 Token 的 header 方式对<img>不便 → 用 fetch 转 blob 或 cookie 会话,二选一,见 §11 风险)。
10. 统计实现(首页)
沿用 Wardrobe 的服务器端 SQL 聚合思路(GET /stats):
-- 总物品数(当前家庭)
SELECT COUNT(*) FROM items WHERE family_id = ?;
-- 分类分布
SELECT COALESCE(c.name,'未分类'), COUNT(i.id)
FROM periods... -- 简略:LEFT JOIN categories c, GROUP BY 零填充 6 项 → 交给前端补零
-- 盒子占用 Top N(含嵌套子盒物品数,递归 CTE)
-- 临期提醒:expiry_date BETWEEN today AND today+30
-- 深度:递归 CTE 求 family_id 下每盒子最大 depth,取 MAX
11. 安全、并发与风险
| 主题 | 方案 |
|---|---|
| 权限 | 服务端 requireFamilyMember/Admin 为唯一权威;客户端仅隐藏 UI |
| 防环 | move/transfer 前递归校验目标不得为自身或后代 |
| 并发写 | DuckDB 单写 → 全局 enqueue 串行化(沿用);事务包裹转移 |
| 图片鉴权 | 经 /api/images 代理读取,未授权 403;不上传落地任何静态目录 |
| 会话 | Bearer Token + 服务端 sessions 表,可撤销 |
| 验证码 | 开发=固定码 1111;预留 provider 抽象,上线换真实短信 |
Token 与 <img> |
若 header 不便,改用 SSE 前先采用「短时签名 URL」或 cookie;评审时定 |
| 迁移失败 | 启动中止 + 明确报错,禁止隐式降级 |
12. 迁移与部署
- 本地开发:
npm run dev(Vite:1420)→npm run tauri dev。 - 产物:
vite-plugin-singlefile单文件 + 服务端;docker build单镜像node:22-slim,DB_PATH默认./data/storage.duckdb(数据卷storage-data),图片存在data/uploads(同卷),端口沿用习惯(建议 3002 或另定)。 - CI:沿用 Wardrobe 的 Gitea Actions ——
docker-build.yml(主分支)、android-build.yml(APK 签名发布)、可选ota-build.yml(hotswap 热更新,P1 接入)。 - OEM 差异:首发可只做 Web + Docker + Android APK,桌面壳作为增量。
13. 分阶段实施计划
| 阶段 | 内容 | 交付物 |
|---|---|---|
| M1 | 骨架:auth、家庭创建/切换、schema_version 迁移、types 集中 |
登录 + 家庭空壳可运行 |
| M2 | 盒子树 + 画布位置 + 面包屑;物品 CRUD + 移动 | 核心收纳闭环 |
| M3 | 搜索/筛选/定位;分类/标签;图片上传鉴权 | 日常可用 |
| M4 | 权限矩阵(admin/member 中间件);跨家庭转移 + 审计 | 家庭协作闭环 |
| M5 | 统计首页;邀请码/成员管理;MCP;打包部署(Docker/APK) | v0.1 发布 |
| P1+ | 操作日志 UI、导出、备份、真实短信、使用记录 | 迭代 |
14. 待评审决策点
- 图片鉴权采用短时签名 URL。
- 目标家庭转移权限仅能转移到自己拥有管理员的家庭。
- 盒子尺寸(width/height)支持自定义。
- 端口根据部署环境分配。
- 部署环境请参考部署环境.md文件