Files
2026-09-26 22:46:15 +08:00

427 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 语法)
```sql
-- 迁移表: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 校验防环)。
```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/<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`):
```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 与 `<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. 待评审决策点
1. 图片鉴权采用短时签名 URL。
2. 目标家庭转移权限仅能转移到自己拥有管理员的家庭。
3. 盒子尺寸(width/height)支持自定义。
4. 端口根据部署环境分配。
5. 部署环境请参考部署环境.md文件