356 lines
14 KiB
JavaScript
356 lines
14 KiB
JavaScript
// 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 |