Files
Storage/DESIGN.md
T
2026-09-26 22:46:15 +08:00

20 KiB
Raw Blame History

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. 待评审决策点

  1. 图片鉴权采用短时签名 URL。
  2. 目标家庭转移权限仅能转移到自己拥有管理员的家庭。
  3. 盒子尺寸(width/height)支持自定义。
  4. 端口根据部署环境分配。
  5. 部署环境请参考部署环境.md文件