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
+356
View File
@@ -0,0 +1,356 @@
// Storage 业务 REST API: 家庭 / 盒子 / 物品 / 分类 / 转移 / 统计 / MCP
// 权限模型: 管理员(admin)可写, 成员(member)只读。服务端为唯一权威。
import { Router } from 'express'
import * as auth from '../auth.js'
import { finalizeImages, deleteImageFiles } from './images.js'
import {
// users/sessions
dbGetUserById, dbUpdateUserNickname,
// families
dbCreateFamily, dbListFamiliesForUser, dbGetFamily, dbUpdateFamily, dbDeleteFamily,
dbGetRole, dbAddMember, dbRemoveMember, dbSetRole, dbSetInviteCode, dbJoinByCode,
dbListMembers,
// categories
dbListCategories, dbCreateCategory, dbUpdateCategory, dbDeleteCategory,
// boxes
dbCreateBox, dbGetBox, dbUpdateBox, dbMoveBox, dbSetBoxPosition, dbDeleteBox,
dbListChildBoxes, dbBuildTree, dbBoxPath,
// items
dbCreateItem, dbGetItem, dbListItems, dbUpdateItem, dbDeleteItem, dbMoveItem,
// transfer
dbTransfer, dbListTransferLogs,
// stats / mcp
dbGetFamilyStats, mcpTools, mcpCall,
uu,
} from '../db.js'
const router = Router()
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
// ── 鉴权中间件: Authorization: Bearer <token> ──
async function requireAuth(req, res, next) {
try {
const h = req.headers.authorization || ''
const token = h.startsWith('Bearer ') ? h.slice(7).trim() : null
const user = await auth.resolveSession(token)
if (!user) return res.status(401).json({ error: '未登录或登录已过期' })
req.user = user
req.token = token
next()
} catch (e) {
next(e)
}
}
// 家庭成员校验: familyId 取 URL 中的 :id
async function requireMember(req, res, next) {
const role = await dbGetRole(req.params.id, req.user.id)
if (!role) return res.status(403).json({ error: '你不是该家庭成员' })
req.role = role
next()
}
// 家庭管理员校验 (所有写操作)
async function requireAdmin(req, res, next) {
const role = await dbGetRole(req.params.id, req.user.id)
if (role !== 'admin') return res.status(403).json({ error: '仅管理员可执行此操作' })
req.role = role
next()
}
// ── Auth ──
router.post('/auth/send-code', asyncH(async (req, res) => {
const target = String(req.body?.target || '').trim()
if (!target) return res.status(400).json({ error: '请提供邮箱或手机号' })
res.json({ message: auth.sendVerification(target) })
}))
router.post('/auth/register', asyncH(async (req, res) => {
const { email, phone, code, nickname } = req.body || {}
res.json(await auth.register({ email: email || null, phone: phone || null, code, nickname }))
}))
router.post('/auth/login', asyncH(async (req, res) => {
const { email, phone, code } = req.body || {}
res.json(await auth.login({ email: email || null, phone: phone || null, code }))
}))
router.post('/auth/logout', requireAuth, asyncH(async (req, res) => {
await auth.logout(req.token)
res.json({ success: true })
}))
router.get('/auth/me', requireAuth, asyncH(async (req, res) => {
res.json({ user: await dbGetUserById(req.user.id) })
}))
router.put('/auth/me', requireAuth, asyncH(async (req, res) => {
const nickname = String(req.body?.nickname || '').trim()
if (!nickname) return res.status(400).json({ error: '昵称不能为空' })
await dbUpdateUserNickname(req.user.id, nickname)
res.json({ user: await dbGetUserById(req.user.id) })
}))
// ── Families ──
router.get('/families', requireAuth, asyncH(async (req, res) => {
res.json(await dbListFamiliesForUser(req.user.id))
}))
router.post('/families', requireAuth, asyncH(async (req, res) => {
const name = String(req.body?.name || '').trim()
if (!name) return res.status(400).json({ error: '请输入家庭名称' })
res.json(await dbCreateFamily(req.user.id, name, req.body?.icon || '🏠'))
}))
router.get('/families/:id', requireAuth, requireMember, asyncH(async (req, res) => {
res.json({
family: await dbGetFamily(req.params.id),
members: await dbListMembers(req.params.id),
my_role: req.role,
})
}))
router.put('/families/:id', requireAuth, requireAdmin, asyncH(async (req, res) => {
const name = String(req.body?.name || '').trim()
if (!name) return res.status(400).json({ error: '请输入家庭名称' })
await dbUpdateFamily(req.params.id, { name, icon: req.body?.icon || '🏠' })
res.json({ success: true, family: await dbGetFamily(req.params.id) })
}))
router.delete('/families/:id', requireAuth, requireAdmin, asyncH(async (req, res) => {
const family = await dbGetFamily(req.params.id)
if (family.owner_user_id !== req.user.id) return res.status(403).json({ error: '仅创建者可解散家庭' })
await dbDeleteFamily(req.params.id)
res.json({ success: true })
}))
// 通过邀请码加入
router.post('/families/:id/join', requireAuth, asyncH(async (req, res) => {
const code = String(req.body?.invite_code || '').trim().toUpperCase()
if (!code) return res.status(400).json({ error: '请输入邀请码' })
res.json(await dbJoinByCode(code, req.user.id))
}))
// 邀请码管理 (管理员)
router.post('/families/:id/invite', requireAuth, requireAdmin, asyncH(async (req, res) => {
const action = req.body?.action || 'refresh'
if (action === 'clear') {
await dbSetInviteCode(req.params.id, null)
return res.json({ invite_code: null })
}
let code
do { code = 'ST-' + uu().replaceAll('-', '').slice(0, 8).toUpperCase() } while (code.includes('0') || code.includes('O'))
await dbSetInviteCode(req.params.id, code)
res.json({ invite_code: code })
}))
router.post('/families/:id/members/:userId/role', requireAuth, requireAdmin, asyncH(async (req, res) => {
const role = req.body?.role
if (!['admin', 'member'].includes(role)) return res.status(400).json({ error: 'role 非法' })
const family = await dbGetFamily(req.params.id)
const targetExists = (await dbListMembers(req.params.id)).some((m) => m.user_id === req.params.userId)
if (!targetExists) return res.status(404).json({ error: '成员不存在' })
if (req.params.userId === family.owner_user_id) return res.status(403).json({ error: '不能修改创建者角色' })
await dbSetRole(req.params.id, req.params.userId, role)
res.json({ success: true })
}))
router.post('/families/:id/members/:userId/add', requireAuth, requireAdmin, asyncH(async (req, res) => {
await dbAddMember(req.params.id, req.params.userId, req.body?.role || 'member')
res.json({ success: true })
}))
// 移除成员; userId === 'me' 表示自己离开
router.delete('/families/:id/members/:userId', requireAuth, requireAdmin, asyncH(async (req, res) => {
const family = await dbGetFamily(req.params.id)
const target = req.params.userId === 'me' ? req.user.id : req.params.userId
if (target === family.owner_user_id) return res.status(403).json({ error: '创建者不能离开/被移除, 请先解散家庭' })
if (target === req.user.id && req.params.userId === 'me') {
await dbRemoveMember(req.params.id, target)
return res.json({ success: true, left: true })
}
await dbRemoveMember(req.params.id, target)
res.json({ success: true })
}))
// ── Categories ──
router.get('/families/:id/categories', requireAuth, requireMember, asyncH(async (req, res) => {
res.json(await dbListCategories(req.params.id))
}))
router.post('/families/:id/categories', requireAuth, requireAdmin, asyncH(async (req, res) => {
const name = String(req.body?.name || '').trim()
if (!name) return res.status(400).json({ error: '请输入分类名称' })
res.json(await dbCreateCategory(req.params.id, name))
}))
router.put('/families/:id/categories/:catId', requireAuth, requireAdmin, asyncH(async (req, res) => {
const name = String(req.body?.name || '').trim()
if (!name) return res.status(400).json({ error: '请输入分类名称' })
await dbUpdateCategory(req.params.id, req.params.catId, name)
res.json({ success: true })
}))
router.delete('/families/:id/categories/:catId', requireAuth, requireAdmin, asyncH(async (req, res) => {
await dbDeleteCategory(req.params.id, req.params.catId)
res.json({ success: true })
}))
// ── Boxes ──
// 直接子盒 (根层 = 不带 parent 参数)
router.get('/families/:id/boxes', requireAuth, requireMember, asyncH(async (req, res) => {
if (req.query.tree === '1') return res.json(await dbBuildTree(req.params.id))
const parentId = req.query.parent && req.query.parent !== 'root' ? String(req.query.parent) : null
res.json(await dbListChildBoxes(req.params.id, parentId))
}))
router.get('/families/:id/boxes/:boxId', requireAuth, requireMember, asyncH(async (req, res) => {
const box = await dbGetBox(req.params.id, req.params.boxId)
if (!box) return res.status(404).json({ error: '盒子不存在' })
const [path, children] = await Promise.all([
dbBoxPath(req.params.id, req.params.boxId),
dbListChildBoxes(req.params.id, req.params.boxId),
])
res.json({ box, path, children })
}))
router.post('/families/:id/boxes', requireAuth, requireAdmin, asyncH(async (req, res) => {
const b = req.body || {}
if (!b.name) return res.status(400).json({ error: '请输入盒子名称' })
res.json(await dbCreateBox(req.params.id, {
name: String(b.name).trim(),
parent_box_id: b.parent_box_id || null,
icon: b.icon, color: b.color,
pos_x: b.pos_x, pos_y: b.pos_y,
width: b.width ?? null, height: b.height ?? null,
}))
}))
router.put('/families/:id/boxes/:boxId', requireAuth, requireAdmin, asyncH(async (req, res) => {
const b = req.body || {}
const patch = {}
if (b.name !== undefined) patch.name = String(b.name).trim()
if (b.icon !== undefined) patch.icon = b.icon
if (b.color !== undefined) patch.color = b.color
if (b.width !== undefined) patch.width = b.width
if (b.height !== undefined) patch.height = b.height
if (!Object.keys(patch).length) return res.status(400).json({ error: '没有可更新的字段' })
await dbUpdateBox(req.params.id, req.params.boxId, patch)
res.json({ success: true, box: await dbGetBox(req.params.id, req.params.boxId) })
}))
// 整盒移动 (跨容器 / 根层)
router.put('/families/:id/boxes/:boxId/move', requireAuth, requireAdmin, asyncH(async (req, res) => {
const b = req.body || {}
const parent = b.parent_box_id && b.parent_box_id !== 'root' ? String(b.parent_box_id) : null
await dbMoveBox(req.params.id, req.params.boxId, parent, b.pos_x ?? 0, b.pos_y ?? 0)
res.json({ success: true, box: await dbGetBox(req.params.id, req.params.boxId) })
}))
// 仅调整位置 (画布拖拽)
router.put('/families/:id/boxes/:boxId/position', requireAuth, requireAdmin, asyncH(async (req, res) => {
const b = req.body || {}
await dbSetBoxPosition(req.params.id, req.params.boxId, b.pos_x ?? 0, b.pos_y ?? 0)
res.json({ success: true })
}))
router.delete('/families/:id/boxes/:boxId', requireAuth, requireAdmin, asyncH(async (req, res) => {
await dbDeleteBox(req.params.id, req.params.boxId)
res.json({ success: true })
}))
// ── Items ──
router.get('/families/:id/items', requireAuth, requireMember, asyncH(async (req, res) => {
const q = req.query
const filters = {
boxId: q.box && q.box !== 'root' ? String(q.box) : null,
recursive: q.recursive === '1',
looseOnly: q.loose === '1',
categoryId: q.category || null,
tag: q.tag || null,
q: q.q ? String(q.q) : null,
favorite: q.favorite === '1',
expiring: q.expiring === '1',
limit: q.limit,
}
res.json(await dbListItems(req.params.id, filters))
}))
router.post('/families/:id/items', requireAuth, requireAdmin, asyncH(async (req, res) => {
const b = req.body || {}
if (!b.name) return res.status(400).json({ error: '请输入物品名称' })
b.images = finalizeImages(b.images || [], req.params.id)
res.json(await dbCreateItem(req.params.id, b))
}))
router.get('/families/:id/items/:itemId', requireAuth, requireMember, asyncH(async (req, res) => {
const item = await dbGetItem(req.params.id, req.params.itemId)
if (!item) return res.status(404).json({ error: '物品不存在' })
const path = item.box_id ? await dbBoxPath(req.params.id, item.box_id) : []
res.json({ item, box_path: path })
}))
router.put('/families/:id/items/:itemId', requireAuth, requireAdmin, asyncH(async (req, res) => {
const b = req.body || {}
if (b.images !== undefined) b.images = finalizeImages(b.images || [], req.params.id)
await dbUpdateItem(req.params.id, req.params.itemId, b)
res.json({ success: true, item: await dbGetItem(req.params.id, req.params.itemId) })
}))
router.delete('/families/:id/items/:itemId', requireAuth, requireAdmin, asyncH(async (req, res) => {
const item = await dbGetItem(req.params.id, req.params.itemId)
if (item?.images?.length) deleteImageFiles(item.images)
await dbDeleteItem(req.params.id, req.params.itemId)
res.json({ success: true })
}))
// 物品移动 (换盒 / 根层)
router.put('/families/:id/items/:itemId/move', requireAuth, requireAdmin, asyncH(async (req, res) => {
const b = req.body || {}
const boxId = b.box_id && b.box_id !== 'root' ? String(b.box_id) : null
await dbMoveItem(req.params.id, req.params.itemId, boxId)
res.json({ success: true, box_id: boxId })
}))
// ── Transfer (跨家庭转移) ──
// 权限: 操作者必须同时是 源家庭管理员 与 目标家庭管理员 (见 §14 决策点 2)
router.post('/families/:id/transfer', requireAuth, requireAdmin, asyncH(async (req, res) => {
const b = req.body || {}
const dstFamilyId = String(b.dstFamilyId || '')
if (!dstFamilyId) return res.status(400).json({ error: '缺少目标家庭' })
const dstRole = await dbGetRole(dstFamilyId, req.user.id)
if (dstRole !== 'admin') return res.status(403).json({ error: '仅能转移到自己拥有管理权限的家庭' })
const dstParent = b.dstParentBoxId && b.dstParentBoxId !== 'root' ? String(b.dstParentBoxId) : null
const r = await dbTransfer(req.params.id, {
type: b.type,
entityId: String(b.entityId || ''),
dstFamilyId,
dstParentBoxId: dstParent,
posX: b.pos_x, posY: b.pos_y,
operatorUserId: req.user.id,
})
res.json(r)
}))
router.get('/families/:id/transfer-logs', requireAuth, requireAdmin, asyncH(async (req, res) => {
res.json(await dbListTransferLogs(req.params.id))
}))
// ── Stats ──
router.get('/families/:id/stats', requireAuth, requireMember, asyncH(async (req, res) => {
res.json(await dbGetFamilyStats(req.params.id))
}))
// ── MCP (P1: AI 助手查询) ──
router.get('/mcp/tools', requireAuth, asyncH(async (req, res) => {
res.json(mcpTools())
}))
router.post('/mcp/call', requireAuth, asyncH(async (req, res) => {
const { tool_name, args } = req.body || {}
if (!tool_name) return res.status(400).json({ error: '缺少 tool_name' })
const r = await mcpCall(tool_name, args || {}, req.user.id)
res.json(r.value)
}))
export default router