chore: Storage 初版 — 家庭共享物品管理 (React+TS+Vite+Express+DuckDB)
Build and Deploy / build-image (push) Failing after 2m26s

This commit is contained in:
jif2.zhang
2026-09-26 22:46:15 +08:00
commit 0a6f048c0d
50 changed files with 11073 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
# 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文件