# 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 ──` 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 校验防环)。 ```sql 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 权限校验(中间件) ```js 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/.`,返回相对路径。 - 读取:`GET /api/images/.`,中间件解析 Token 并校验当前用户为文件所属家庭(文件名前缀含 family_id 便于校验,或读库校验)成员。 - 前端:`` 直接引用 `/api/images/...`(带 Token 的 header 方式对 `` 不便 → 用 fetch 转 blob 或 cookie 会话,二选一,见 §11 风险)。 --- ## 10. 统计实现(首页) 沿用 Wardrobe 的服务器端 SQL 聚合思路(`GET /stats`): ```sql -- 总物品数(当前家庭) 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 与 `` | 若 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. 待评审决策点 1. 图片鉴权采用短时签名 URL。 2. 目标家庭转移权限仅能转移到自己拥有管理员的家庭。 3. 盒子尺寸(width/height)支持自定义。 4. 端口根据部署环境分配。 5. 部署环境请参考部署环境.md文件