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
+66
View File
@@ -0,0 +1,66 @@
// 登录/注册: 验证码(服务端一次性校验) + 服务端签发会话 token
// 注意: 短信/邮件发送仍是假通道 (验证码固定 1111), 后续按 NFR-7 接真实短信
import { uu, dbCreateUser, dbGetUserByEmail, dbGetUserByPhone, dbGetUserById, dbGetSession, dbCreateSession, dbDeleteSession } from './db.js'
const VERIFICATION_CODE = '1111'
const CODE_EXPIRY_SECONDS = 300
const codes = new Map()
export function sendVerification(target) {
codes.set(target, { code: VERIFICATION_CODE, expiresAt: Date.now() + CODE_EXPIRY_SECONDS * 1000 })
console.log(`[auth] verification code for ${target}: ${VERIFICATION_CODE}`)
return `验证码已发送至 ${target}`
}
function verifyCode(target, code) {
const v = codes.get(target)
if (!v) return false
if (v.code !== code || Date.now() >= v.expiresAt) return false
codes.delete(target)
return true
}
export async function register({ email, phone, code, nickname }) {
const target = email || phone
if (!target) return { success: false, message: '请提供邮箱或手机号', user: null, token: null }
if (!verifyCode(target, code)) {
return { success: false, message: '验证码无效或已过期', user: null, token: null }
}
if (email && (await dbGetUserByEmail(email))) {
return { success: false, message: '该邮箱已注册', user: null, token: null }
}
if (phone && (await dbGetUserByPhone(phone))) {
return { success: false, message: '该手机号已注册', user: null, token: null }
}
const id = uu()
const user = await dbCreateUser(id, email || null, phone || null, nickname || '收纳达人')
const token = 'tok_' + uu().replaceAll('-', '')
await dbCreateSession(token, id)
console.log(`[auth] user registered: ${id}`)
return { success: true, message: '注册成功', user, token }
}
export async function login({ email, phone, code }) {
const target = email || phone
if (!target) return { success: false, message: '请提供邮箱或手机号', user: null, token: null }
if (!verifyCode(target, code)) {
return { success: false, message: '验证码无效或已过期', user: null, token: null }
}
const user = email ? await dbGetUserByEmail(email) : await dbGetUserByPhone(phone)
if (!user) return { success: false, message: '账号不存在,请先注册', user: null, token: null }
const token = 'tok_' + uu().replaceAll('-', '')
await dbCreateSession(token, user.id)
console.log(`[auth] user login: ${user.id}`)
return { success: true, message: '登录成功', user, token }
}
export async function logout(token) {
if (token) await dbDeleteSession(token)
}
export async function resolveSession(token) {
const s = token ? await dbGetSession(token) : null
if (!s) return null
const user = await dbGetUserById(s.user_id)
return user
}
+841
View File
@@ -0,0 +1,841 @@
// Storage 服务端数据层: DuckDB (@duckdb/node-api)
// 业务数据全部存储在服务端数据库文件 (DB_PATH, 生产建议挂持久化数据卷),
// 客户端不再持有数据库, 仅保留最近数据的读缓存。
// 采用版本化迁移机制 (schema_version), 禁止用裸 CREATE TABLE 演进。
//
// 重要约定: 由于 DuckDB 单写入者 + 全局 enqueue 串行化, 任何"enqueue"的公共函数
// 内部不得再调用其它 enqueue 函数 (会自锁)。因此所有底层读操作都实现为
// 内部函数 (直接 conn.run), 公共 API 一律是薄 enqueue 包装。
import { DuckDBInstance } from '@duckdb/node-api'
import path from 'node:path'
import fs from 'node:fs'
let instance = null
let conn = null
function dbFile() {
const p = process.env.DB_PATH || path.resolve(process.cwd(), 'data', 'storage.duckdb')
fs.mkdirSync(path.dirname(p), { recursive: true })
return p
}
let chain = Promise.resolve()
export function enqueue(fn) {
const p = chain.then(fn)
chain = p.catch(() => {})
return p
}
// ── 内部底层读 (非 enqueue) ──
async function rows(sql, params = []) {
const r = await conn.run(sql, params)
return (await r.getRowObjects()) ?? []
}
async function one(sql, params = []) {
const r = await rows(sql, params)
return r.length ? r[0] : null
}
async function count(sql, params = []) {
const r = await rows(sql, params)
return r.length ? Number(r[0].c) : 0
}
const nowIso = () => new Date().toISOString()
export const uu = () => globalThis.crypto.randomUUID()
function parseArr(v) {
if (Array.isArray(v)) return v
if (typeof v === 'string') {
try {
const p = JSON.parse(v)
return Array.isArray(p) ? p : []
} catch {
return []
}
}
return []
}
function mapBox(x) {
if (!x) return null
return {
...x,
pos_x: Number(x.pos_x) || 0,
pos_y: Number(x.pos_y) || 0,
width: x.width === null || x.width === undefined ? null : Number(x.width),
height: x.height === null || x.height === undefined ? null : Number(x.height),
direct_item_count: x.direct_item_count === undefined ? 0 : Number(x.direct_item_count || 0),
}
}
function mapItem(x) {
if (!x) return null
return {
...x,
tags: parseArr(x.tags),
images: parseArr(x.images),
quantity: Number(x.quantity || 1),
purchase_price: x.purchase_price === null || x.purchase_price === undefined ? null : Number(x.purchase_price),
is_favorite: !!x.is_favorite,
used_count: Number(x.used_count || 0),
}
}
// ── 迁移机制 ──
const MIGRATIONS = [
{
version: 1,
up: async (c) => {
await c.run(`CREATE TABLE IF NOT EXISTS users (
id VARCHAR PRIMARY KEY,
email VARCHAR UNIQUE,
phone VARCHAR UNIQUE,
nickname VARCHAR NOT NULL DEFAULT '',
avatar_path VARCHAR,
created_at VARCHAR NOT NULL
)`)
await c.run(`CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR PRIMARY KEY,
user_id VARCHAR NOT NULL,
created_at VARCHAR NOT NULL
)`)
await c.run(`CREATE TABLE IF NOT EXISTS families (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
icon VARCHAR DEFAULT '🏠',
owner_user_id VARCHAR NOT NULL,
invite_code VARCHAR,
created_at VARCHAR NOT NULL,
updated_at VARCHAR NOT NULL
)`)
await c.run(`CREATE TABLE IF NOT EXISTS 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)
)`)
await c.run(`CREATE TABLE IF NOT EXISTS categories (
id VARCHAR PRIMARY KEY,
family_id VARCHAR NOT NULL,
name VARCHAR NOT NULL,
sort INTEGER DEFAULT 0,
created_at VARCHAR NOT NULL
)`)
await c.run(`CREATE TABLE IF NOT EXISTS boxes (
id VARCHAR PRIMARY KEY,
family_id VARCHAR NOT NULL,
parent_box_id VARCHAR,
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,
height INTEGER,
created_at VARCHAR NOT NULL,
updated_at VARCHAR NOT NULL
)`)
await c.run(`CREATE TABLE IF NOT EXISTS items (
id VARCHAR PRIMARY KEY,
family_id VARCHAR NOT NULL,
box_id VARCHAR,
name VARCHAR NOT NULL,
category_id VARCHAR,
tags VARCHAR DEFAULT '[]',
quantity INTEGER DEFAULT 1,
brand VARCHAR,
model VARCHAR,
serial_no VARCHAR,
purchase_date VARCHAR,
purchase_price DOUBLE,
expiry_date VARCHAR,
images VARCHAR DEFAULT '[]',
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
)`)
await c.run(`CREATE TABLE IF NOT EXISTS transfer_logs (
id VARCHAR PRIMARY KEY,
entity_type VARCHAR NOT NULL,
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
)`)
await c.run(`CREATE INDEX IF NOT EXISTS idx_members_user ON family_members(user_id)`)
await c.run(`CREATE INDEX IF NOT EXISTS idx_boxes_family ON boxes(family_id)`)
await c.run(`CREATE INDEX IF NOT EXISTS idx_boxes_parent ON boxes(parent_box_id)`)
await c.run(`CREATE INDEX IF NOT EXISTS idx_items_family ON items(family_id)`)
await c.run(`CREATE INDEX IF NOT EXISTS idx_items_box ON items(box_id)`)
await c.run(`CREATE INDEX IF NOT EXISTS idx_items_family_box ON items(family_id, box_id)`)
},
},
]
export async function initDb() {
const file = dbFile()
instance = await DuckDBInstance.create(file)
conn = await instance.connect()
await conn.run(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at VARCHAR NOT NULL)`)
const applied = await rows('SELECT version FROM schema_version ORDER BY version')
const appliedSet = new Set(applied.map((r) => Number(r.version)))
for (const m of MIGRATIONS) {
if (appliedSet.has(m.version)) continue
console.log(`[db] applying migration v${m.version} ...`)
await conn.run('BEGIN TRANSACTION')
try {
await m.up(conn)
await conn.run('INSERT INTO schema_version (version, applied_at) VALUES (?, ?)', [m.version, nowIso()])
await conn.run('COMMIT')
console.log(`[db] migration v${m.version} ok`)
} catch (e) {
await conn.run('ROLLBACK')
throw e
}
}
console.log(`[db] DuckDB ready at ${file} (schema v${applied.length ? Math.max(...applied.map((r) => Number(r.version))) : 0})`)
}
async function tx(fn) {
await conn.run('BEGIN TRANSACTION')
try {
const r = await fn()
await conn.run('COMMIT')
return r
} catch (e) {
try { await conn.run('ROLLBACK') } catch { /* ignore */ }
throw e
}
}
// ══════════════ users / sessions ══════════════
export function dbCreateUser(id, email, phone, nickname) {
return enqueue(async () => {
await conn.run('INSERT INTO users (id, email, phone, nickname, created_at) VALUES (?,?,?,?,?)', [id, email, phone, nickname, nowIso()])
return { id, email, phone, nickname, avatar_path: null, created_at: nowIso() }
})
}
export const dbGetUserByEmail = (email) => enqueue(() =>
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE email = ?', [email]))
export const dbGetUserByPhone = (phone) => enqueue(() =>
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE phone = ?', [phone]))
export const dbGetUserById = (id) => enqueue(() =>
one('SELECT id, email, phone, nickname, avatar_path, created_at FROM users WHERE id = ?', [id]))
export const dbUpdateUserNickname = (id, nickname) => enqueue(async () => {
await conn.run('UPDATE users SET nickname = ? WHERE id = ?', [nickname, id])
})
export const dbCreateSession = (token, userId) => enqueue(async () => {
await conn.run('INSERT INTO sessions (token, user_id, created_at) VALUES (?,?,?)', [token, userId, nowIso()])
})
export const dbGetSession = (token) => enqueue(() =>
one('SELECT token, user_id, created_at FROM sessions WHERE token = ?', [token]))
export const dbDeleteSession = (token) => enqueue(async () => {
await conn.run('DELETE FROM sessions WHERE token = ?', [token])
})
// ══════════════ families / members ══════════════
function genInviteCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let s = ''
for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)]
return s
}
export function dbCreateFamily(ownerId, name, icon) {
return enqueue(async () => {
const family = { id: uu(), name: name || '我的家庭', icon: icon || '🏠', owner_user_id: ownerId, invite_code: genInviteCode(), created_at: nowIso(), updated_at: nowIso() }
await tx(async () => {
await conn.run('INSERT INTO families (id, name, icon, owner_user_id, invite_code, created_at, updated_at) VALUES (?,?,?,?,?,?,?)',
[family.id, family.name, family.icon, family.owner_user_id, family.invite_code, family.created_at, family.updated_at])
await conn.run('INSERT INTO family_members (family_id, user_id, role, joined_at) VALUES (?,?,?,?)',
[family.id, ownerId, 'admin', nowIso()])
})
return family
})
}
export const dbListFamiliesForUser = (userId) => enqueue(async () => {
return rows(
`SELECT f.id, f.name, f.icon, f.owner_user_id, f.invite_code, f.created_at, m.role
FROM family_members m JOIN families f ON f.id = m.family_id
WHERE m.user_id = ? ORDER BY f.created_at`,
[userId],
)
})
export const dbGetFamily = (familyId) => enqueue(() =>
one('SELECT id, name, icon, owner_user_id, invite_code, created_at, updated_at FROM families WHERE id = ?', [familyId]))
export const dbUpdateFamily = (familyId, data) => enqueue(async () => {
await conn.run('UPDATE families SET name = ?, icon = ?, updated_at = ? WHERE id = ?',
[data.name, data.icon || '🏠', nowIso(), familyId])
})
export const dbDeleteFamily = (familyId) => enqueue(async () => {
await tx(async () => {
await conn.run('DELETE FROM family_members WHERE family_id = ?', [familyId])
await conn.run('DELETE FROM categories WHERE family_id = ?', [familyId])
const boxIds = (await rows('SELECT id FROM boxes WHERE family_id = ?', [familyId])).map((r) => r.id)
if (boxIds.length) {
const placeholders = boxIds.map(() => '?').join(',')
await conn.run(`DELETE FROM items WHERE family_id = ? OR box_id IN (${placeholders})`, [familyId, ...boxIds])
await conn.run(`DELETE FROM boxes WHERE family_id = ?`, [familyId])
} else {
await conn.run('DELETE FROM items WHERE family_id = ?', [familyId])
}
await conn.run('DELETE FROM transfer_logs WHERE src_family_id = ? OR dst_family_id = ?', [familyId, familyId])
await conn.run('DELETE FROM families WHERE id = ?', [familyId])
})
})
export const dbGetRole = (familyId, userId) => enqueue(async () => {
const r = await one('SELECT role FROM family_members WHERE family_id = ? AND user_id = ?', [familyId, userId])
return r ? r.role : null
})
export const dbAddMember = (familyId, userId, role) => enqueue(async () => {
await conn.run('INSERT INTO family_members (family_id, user_id, role, joined_at) VALUES (?,?,?,?)', [familyId, userId, role || 'member', nowIso()])
})
export const dbRemoveMember = (familyId, userId) => enqueue(async () => {
await conn.run('DELETE FROM family_members WHERE family_id = ? AND user_id = ?', [familyId, userId])
})
export const dbSetRole = (familyId, userId, role) => enqueue(async () => {
await conn.run('UPDATE family_members SET role = ? WHERE family_id = ? AND user_id = ?', [role, familyId, userId])
})
export const dbSetInviteCode = (familyId, code) => enqueue(async () => {
await conn.run('UPDATE families SET invite_code = ?, updated_at = ? WHERE id = ?', [code, nowIso(), familyId])
})
export const dbJoinByCode = (code, userId) => enqueue(async () => {
const family = await one('SELECT id FROM families WHERE invite_code = ?', [code])
if (!family) return { success: false, message: '邀请码无效' }
const existing = await one('SELECT role FROM family_members WHERE family_id = ? AND user_id = ?', [family.id, userId])
if (existing) return { success: false, message: '你已在该家庭中' }
await conn.run('INSERT INTO family_members (family_id, user_id, role, joined_at) VALUES (?,?,?,?)', [family.id, userId, 'member', nowIso()])
return { success: true, message: '加入成功', familyId: family.id }
})
export const dbListMembers = (familyId) => enqueue(async () => {
return rows(
`SELECT m.family_id, m.user_id, m.role, m.joined_at, u.nickname, u.email, u.phone
FROM family_members m JOIN users u ON u.id = m.user_id
WHERE m.family_id = ? ORDER BY m.joined_at`,
[familyId],
)
})
// ══════════════ categories ══════════════
export const dbListCategories = (familyId) => enqueue(async () => {
return rows('SELECT id, family_id, name, sort, created_at FROM categories WHERE family_id = ? ORDER BY sort, created_at', [familyId])
})
export const dbCreateCategory = (familyId, name) => enqueue(async () => {
const cat = { id: uu(), family_id: familyId, name, sort: 0, created_at: nowIso() }
await conn.run('INSERT INTO categories (id, family_id, name, sort, created_at) VALUES (?,?,?,?,?)',
[cat.id, cat.family_id, cat.name, cat.sort, cat.created_at])
return cat
})
export const dbUpdateCategory = (familyId, catId, name) => enqueue(async () => {
await conn.run('UPDATE categories SET name = ? WHERE id = ? AND family_id = ?', [name, catId, familyId])
})
export const dbDeleteCategory = (familyId, catId) => enqueue(async () => {
await tx(async () => {
await conn.run('UPDATE items SET category_id = NULL WHERE category_id = ? AND family_id = ?', [catId, familyId])
await conn.run('DELETE FROM categories WHERE id = ? AND family_id = ?', [catId, familyId])
})
})
// ══════════════ 盒子树内部工具 (非 enqueue) ══════════════
async function allBoxes(familyId) {
const r = await rows(
`SELECT b.*, (
SELECT COUNT(*) FROM items i WHERE i.box_id = b.id
) AS direct_item_count
FROM boxes b WHERE b.family_id = ?`,
[familyId],
)
return r.map(mapBox)
}
function buildPath(boxes, boxId) {
const byId = new Map(boxes.map((b) => [b.id, b]))
const path = []
let cur = byId.get(boxId)
const guard = new Set()
while (cur && !guard.has(cur.id)) {
guard.add(cur.id)
path.unshift({ id: cur.id, name: cur.name })
cur = cur.parent_box_id ? byId.get(cur.parent_box_id) : null
}
return path
}
function directChildren(boxes, parentId) {
return boxes.filter((b) => (b.parent_box_id ?? null) === (parentId ?? null)).sort((a, b) => a.pos_y - b.pos_y || a.pos_x - b.pos_x)
}
async function isDescendant(familyId, boxId, targetId) {
if (boxId === targetId) return true
const all = await rows('SELECT id, parent_box_id FROM boxes WHERE family_id = ?', [familyId])
const parentOf = new Map(all.map((b) => [b.id, b.parent_box_id]))
let cur = parentOf.get(targetId)
while (cur) {
if (cur === boxId) return true
cur = parentOf.get(cur)
}
return false
}
async function subtreeIds(familyId, boxId) {
const all = await rows('SELECT id, parent_box_id FROM boxes WHERE family_id = ?', [familyId])
const childrenOf = new Map()
for (const b of all) {
if (!childrenOf.has(b.parent_box_id)) childrenOf.set(b.parent_box_id, [])
childrenOf.get(b.parent_box_id).push(b.id)
}
const out = []
const stack = [boxId]
while (stack.length) {
const cur = stack.pop()
out.push(cur)
for (const c of childrenOf.get(cur) || []) stack.push(c)
}
return out
}
function subtreeItemCount(boxes, boxId) {
const childrenOf = new Map()
for (const b of boxes) {
const p = b.parent_box_id ?? 'ROOT'
if (!childrenOf.has(p)) childrenOf.set(p, [])
childrenOf.get(p).push(b)
}
let total = 0
const stack = [boxId]
const seen = new Set()
while (stack.length) {
const cur = stack.pop()
if (seen.has(cur)) continue
seen.add(cur)
const b = boxes.find((x) => x.id === cur)
if (b) total += b.direct_item_count || 0
for (const c of childrenOf.get(cur) || []) stack.push(c.id)
}
return total
}
function childBoxes(boxes) {
const childCounts = new Map()
for (const b of boxes) {
const key = b.parent_box_id ?? 'ROOT'
if (!childCounts.has(key)) childCounts.set(key, [])
childCounts.get(key).push(b)
}
return childCounts
}
async function buildTree(familyId) {
const boxes = await allBoxes(familyId)
const childrenOf = childBoxes(boxes)
return boxes.map((box) => {
const path = buildPath(boxes, box.id)
return {
...box,
depth: path.length,
path: path.map((p) => ({ id: p.id, name: p.name })),
total_item_count: subtreeItemCount(boxes, box.id),
}
})
}
// ══════════════ boxes (公共 enqueue API) ══════════════
export const dbAllBoxes = (familyId) => enqueue(() => allBoxes(familyId))
export const dbGetBox = (familyId, boxId) => enqueue(async () => {
const b = await one('SELECT * FROM boxes WHERE id = ? AND family_id = ?', [boxId, familyId])
return mapBox(b)
})
export const dbBoxPath = (familyId, boxId) => enqueue(async () => {
const boxes = await allBoxes(familyId)
return buildPath(boxes, boxId)
})
const clampPos = (v) => {
const n = Number(v)
if (!Number.isFinite(n)) return 0
return Math.max(0, Math.min(1000, Math.round(n)))
}
export function dbCreateBox(familyId, data) {
return enqueue(async () => {
const t = nowIso()
const box = {
id: uu(),
family_id: familyId,
parent_box_id: data.parent_box_id ?? null,
name: data.name,
icon: data.icon || '📦',
color: data.color || '#E8C4A0',
pos_x: clampPos(data.pos_x),
pos_y: clampPos(data.pos_y),
width: data.width ?? null,
height: data.height ?? null,
created_at: t,
updated_at: t,
}
if (box.parent_box_id) {
const parent = await one('SELECT id, family_id FROM boxes WHERE id = ?', [box.parent_box_id])
if (!parent || parent.family_id !== familyId) throw { status: 400, message: '父盒子不存在' }
}
await conn.run(
'INSERT INTO boxes (id, family_id, parent_box_id, name, icon, color, pos_x, pos_y, width, height, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)',
[box.id, box.family_id, box.parent_box_id, box.name, box.icon, box.color, box.pos_x, box.pos_y, box.width, box.height, box.created_at, box.updated_at],
)
return mapBox({ ...box, direct_item_count: 0 })
})
}
export function dbUpdateBox(familyId, boxId, data) {
return enqueue(async () => {
const sets = []
const params = []
for (const f of ['name', 'icon', 'color']) {
if (data[f] !== undefined) { sets.push(`${f} = ?`); params.push(data[f]) }
}
if (data.width !== undefined) { sets.push('width = ?'); params.push(data.width) }
if (data.height !== undefined) { sets.push('height = ?'); params.push(data.height) }
sets.push('updated_at = ?'); params.push(nowIso())
params.push(boxId, familyId)
await conn.run(`UPDATE boxes SET ${sets.join(', ')} WHERE id = ? AND family_id = ?`, params)
})
}
export function dbMoveBox(familyId, boxId, parentBoxId, posX, posY) {
return enqueue(async () => {
const box = await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [boxId, familyId])
if (!box) throw { status: 404, message: '盒子不存在' }
if (parentBoxId) {
const parent = await one('SELECT id, family_id FROM boxes WHERE id = ?', [parentBoxId])
if (!parent || parent.family_id !== familyId) throw { status: 400, message: '目标父盒子不存在' }
if (await isDescendant(familyId, boxId, parentBoxId)) throw { status: 400, message: '不能移动到自身或其子盒中' }
}
await conn.run('UPDATE boxes SET parent_box_id = ?, pos_x = ?, pos_y = ?, updated_at = ? WHERE id = ?',
[parentBoxId ?? null, clampPos(posX), clampPos(posY), nowIso(), boxId])
})
}
export function dbSetBoxPosition(familyId, boxId, posX, posY) {
return enqueue(async () => {
await conn.run('UPDATE boxes SET pos_x = ?, pos_y = ?, updated_at = ? WHERE id = ? AND family_id = ?',
[clampPos(posX), clampPos(posY), nowIso(), boxId, familyId])
})
}
export function dbDeleteBox(familyId, boxId) {
return enqueue(async () => {
const ids = await subtreeIds(familyId, boxId)
await tx(async () => {
const ph = ids.map(() => '?').join(',')
await conn.run(`DELETE FROM items WHERE family_id = ? AND box_id IN (${ph})`, [familyId, ...ids])
await conn.run(`DELETE FROM boxes WHERE id IN (${ph})`, ids)
})
})
}
export function dbListChildBoxes(familyId, parentBoxId) {
return enqueue(async () => {
const boxes = await allBoxes(familyId)
return directChildren(boxes, parentBoxId).map((box) => ({
...box,
total_item_count: subtreeItemCount(boxes, box.id),
}))
})
}
export const dbBuildTree = (familyId) => enqueue(() => buildTree(familyId))
export const dbSubtreeIds = (familyId, boxId) => enqueue(() => subtreeIds(familyId, boxId))
// ══════════════ items ══════════════
export function dbCreateItem(familyId, data) {
return enqueue(async () => {
const t = nowIso()
const item = {
id: uu(),
family_id: familyId,
box_id: data.box_id ?? null,
name: data.name,
category_id: data.category_id ?? null,
tags: data.tags ?? [],
quantity: data.quantity ?? 1,
brand: data.brand ?? null,
model: data.model ?? null,
serial_no: data.serial_no ?? null,
purchase_date: data.purchase_date ?? null,
purchase_price: data.purchase_price === '' ? null : (data.purchase_price ?? null),
expiry_date: data.expiry_date ?? null,
images: data.images ?? [],
notes: data.notes ?? null,
is_favorite: !!data.is_favorite,
used_count: 0,
last_used: null,
created_at: t,
updated_at: t,
}
if (item.box_id && (await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [item.box_id, familyId])) === null) throw { status: 400, message: '盒子不存在' }
await conn.run(
`INSERT INTO items (id, family_id, box_id, name, category_id, tags, quantity, brand, model, serial_no, purchase_date, purchase_price, expiry_date, images, notes, is_favorite, used_count, last_used, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[
item.id, item.family_id, item.box_id, item.name, item.category_id,
JSON.stringify(item.tags), item.quantity, item.brand, item.model, item.serial_no,
item.purchase_date, item.purchase_price, item.expiry_date,
JSON.stringify(item.images), item.notes, item.is_favorite,
item.used_count, item.last_used, item.created_at, item.updated_at,
],
)
return mapItem(item)
})
}
export const dbGetItem = (familyId, itemId) => enqueue(async () => {
const it = await one('SELECT * FROM items WHERE id = ? AND family_id = ?', [itemId, familyId])
return mapItem(it)
})
// 内部 listItems: 建议在 enqueue 内使用 (不嵌套 enqueue)
async function listItems(familyId, filters = {}) {
let sql = 'SELECT * FROM items WHERE family_id = ?'
const params = [familyId]
if (filters.boxId) {
if (filters.recursive) {
const ids = await subtreeIds(familyId, filters.boxId)
if (!ids.length) return []
sql += ` AND box_id IN (${ids.map(() => '?').join(',')})`
params.push(...ids)
} else {
sql += ' AND box_id = ?'
params.push(filters.boxId)
}
} else if (filters.looseOnly) {
sql += ' AND box_id IS NULL'
}
if (filters.categoryId) { sql += ' AND category_id = ?'; params.push(filters.categoryId) }
if (filters.tag) {
// tags 存为 JSON 数组字符串: 去掉 [ ] " 后按逗号拆为列表再判断包含
sql += " AND list_contains(string_split(regexp_replace(tags, '[\\[\\]\" ]', '', 'g'), ','), ?)"
params.push(filters.tag)
}
if (filters.q) {
sql += ' AND (name ILIKE ? OR COALESCE(brand, \'\') ILIKE ? OR COALESCE(model, \'\') ILIKE ? OR COALESCE(notes, \'\') ILIKE ?)'
const like = `%${filters.q}%`
params.push(like, like, like, like)
}
if (filters.favorite) sql += ' AND is_favorite = TRUE'
if (filters.expiring) {
sql += " AND expiry_date IS NOT NULL AND CAST(expiry_date AS DATE) >= CURRENT_DATE AND CAST(expiry_date AS DATE) < CURRENT_DATE + INTERVAL '30 days'"
}
sql += ' ORDER BY (is_favorite = TRUE) DESC, updated_at DESC'
const limit = Math.min(Number(filters.limit) || 200, 1000)
sql += ` LIMIT ${limit}`
const r = await rows(sql, params)
return r.map(mapItem)
}
export const dbListItems = (familyId, filters = {}) => enqueue(() => listItems(familyId, filters))
const ITEM_SET_FIELDS = ['name', 'category_id', 'quantity', 'brand', 'model', 'serial_no', 'purchase_date', 'purchase_price', 'expiry_date', 'notes', 'is_favorite']
export function dbUpdateItem(familyId, itemId, data) {
return enqueue(async () => {
const item = await one('SELECT id FROM items WHERE id = ? AND family_id = ?', [itemId, familyId])
if (!item) throw { status: 404, message: '物品不存在' }
const sets = []
const params = []
for (const f of ITEM_SET_FIELDS) {
if (data[f] === undefined) continue
if (f === 'quantity') { sets.push('quantity = ?'); params.push(Number(data[f]) || 1) }
else if (f === 'is_favorite') { sets.push('is_favorite = ?'); params.push(!!data[f]) }
else if (f === 'category_id' || f === 'brand' || f === 'model' || f === 'serial_no' || f === 'purchase_date' || f === 'purchase_price' || f === 'expiry_date' || f === 'notes') {
sets.push(`${f} = ?`); params.push(data[f] === '' ? null : data[f])
}
else { sets.push(`${f} = ?`); params.push(data[f]) }
}
if (data.box_id !== undefined) { sets.push('box_id = ?'); params.push(data.box_id || null) }
if (data.tags !== undefined) { sets.push('tags = ?'); params.push(JSON.stringify(data.tags ?? [])) }
if (data.images !== undefined) { sets.push('images = ?'); params.push(JSON.stringify(data.images ?? [])) }
if (!sets.length) { sets.push('name = name') }
sets.push('updated_at = ?'); params.push(nowIso())
params.push(itemId, familyId)
await conn.run(`UPDATE items SET ${sets.join(', ')} WHERE id = ? AND family_id = ?`, params)
})
}
export function dbDeleteItem(familyId, itemId) {
return enqueue(async () => {
await conn.run('DELETE FROM items WHERE id = ? AND family_id = ?', [itemId, familyId])
})
}
export function dbMoveItem(familyId, itemId, boxId) {
return enqueue(async () => {
const item = await one('SELECT id FROM items WHERE id = ? AND family_id = ?', [itemId, familyId])
if (!item) throw { status: 404, message: '物品不存在' }
if (boxId) {
const box = await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [boxId, familyId])
if (!box) throw { status: 400, message: '目标盒子不存在' }
}
await conn.run('UPDATE items SET box_id = ?, updated_at = ? WHERE id = ?', [boxId ?? null, nowIso(), itemId])
})
}
// ══════════════ transfer (跨家庭转移) ══════════════
export function dbTransfer(familyId, data) {
return enqueue(async () => {
const { type, entityId, dstFamilyId, dstParentBoxId, posX, posY, operatorUserId } = data
if (!['item', 'box'].includes(type)) throw { status: 400, message: 'type 非法' }
if (familyId === dstFamilyId) throw { status: 400, message: '目标家庭不能是源家庭' }
return tx(async () => {
if (type === 'item') {
const item = await one('SELECT * FROM items WHERE id = ? AND family_id = ?', [entityId, familyId])
if (!item) throw { status: 404, message: '物品不存在' }
if (dstParentBoxId && (await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [dstParentBoxId, dstFamilyId])) === null) throw { status: 400, message: '目标盒子不存在于目标家庭' }
await conn.run('UPDATE items SET family_id = ?, box_id = ?, updated_at = ? WHERE id = ?', [dstFamilyId, dstParentBoxId ?? null, nowIso(), entityId])
await logTransfer('item', entityId, familyId, dstFamilyId, operatorUserId)
return { type, entityId, dstFamilyId, ok: true }
}
const roots = await rows('SELECT id, parent_box_id, pos_x, pos_y FROM boxes WHERE id = ? AND family_id = ?', [entityId, familyId])
if (!roots.length) throw { status: 404, message: '盒子不存在' }
if (dstParentBoxId && (await one('SELECT id FROM boxes WHERE id = ? AND family_id = ?', [dstParentBoxId, dstFamilyId])) === null) throw { status: 400, message: '目标盒子不存在于目标家庭' }
const ids = await subtreeIds(familyId, entityId)
const ph = ids.map(() => '?').join(',')
await conn.run(`UPDATE boxes SET family_id = ? WHERE id IN (${ph})`, [dstFamilyId, ...ids])
await conn.run('UPDATE boxes SET parent_box_id = ?, pos_x = ?, pos_y = ?, updated_at = ? WHERE id = ?',
[dstParentBoxId ?? null, clampPos(posX), clampPos(posY), nowIso(), entityId])
await conn.run(`UPDATE items SET family_id = ? WHERE family_id = ? AND box_id IN (${ph})`, [dstFamilyId, familyId, ...ids])
await logTransfer('box', entityId, familyId, dstFamilyId, operatorUserId)
return { type, entityId, dstFamilyId, ok: true }
})
})
}
async function logTransfer(type, entityId, srcFamilyId, dstFamilyId, operatorUserId) {
await conn.run('INSERT INTO transfer_logs (id, entity_type, entity_id, src_family_id, dst_family_id, operator_user_id, created_at) VALUES (?,?,?,?,?,?,?)',
[uu(), type, entityId, srcFamilyId, dstFamilyId, operatorUserId, nowIso()])
}
export const dbListTransferLogs = (familyId) => enqueue(async () => {
return rows('SELECT * FROM transfer_logs WHERE src_family_id = ? OR dst_family_id = ? ORDER BY created_at DESC LIMIT 100', [familyId, familyId])
})
// ══════════════ stats ══════════════
export function dbGetFamilyStats(familyId) {
return enqueue(async () => {
const [totalItems, totalBoxes] = await Promise.all([
count('SELECT COUNT(*) AS c FROM items WHERE family_id = ?', [familyId]),
count('SELECT COUNT(*) AS c FROM boxes WHERE family_id = ?', [familyId]),
])
const boxes = await allBoxes(familyId)
const rootBoxes = directChildren(boxes, null)
const valueRow = await one('SELECT COALESCE(SUM(purchase_price), 0) AS v FROM items WHERE family_id = ? AND purchase_price IS NOT NULL', [familyId])
const looseCount = await count('SELECT COUNT(*) AS c FROM items WHERE family_id = ? AND box_id IS NULL', [familyId])
const noImageCount = await count("SELECT COUNT(*) AS c FROM items WHERE family_id = ? AND images = '[]'", [familyId])
const maxDepth = boxes.reduce((m, b) => Math.max(m, buildPath(boxes, b.id).length), 0)
const topBoxes = boxes
.map((b) => ({ id: b.id, name: b.name, icon: b.icon, count: subtreeItemCount(boxes, b.id), path: buildPath(boxes, b.id) }))
.filter((b) => b.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 5)
const catRows = await rows(
`SELECT COALESCE(c.name, '未分类') AS name, COUNT(i.id) AS c
FROM items i LEFT JOIN categories c ON c.id = i.category_id
WHERE i.family_id = ? GROUP BY COALESCE(c.name, '未分类') ORDER BY c DESC`,
[familyId],
)
const expiring = (await listItems(familyId, { expiring: true, limit: 50 }))
const recent = (await listItems(familyId, { limit: 8 }))
return {
total_items: totalItems,
total_boxes: totalBoxes,
root_boxes: rootBoxes.length,
max_depth: maxDepth,
total_value: Number(valueRow?.v || 0),
expiring_count: expiring.length,
expiring_items: expiring,
loose_items: looseCount,
no_image_count: noImageCount,
category_distribution: catRows.map((r) => ({ name: r.name, count: Number(r.c) })),
top_boxes: topBoxes,
recent_items: recent,
}
})
}
// ══════════════ MCP (P1: AI 助手查询) ══════════════
export function mcpTools() {
return [
{ name: 'list_my_families', description: '列出我加入的所有家庭', input_schema: '{"type":"object","properties":{},"required":[]}' },
{ name: 'search_items', description: '按关键字或标签搜索物品(需 family_id), 返回物品及所在位置', input_schema: '{"type":"object","properties":{"family_id":{"type":"string"},"q":{"type":"string"},"tag":{"type":"string"}},"required":["family_id"]}' },
{ name: 'get_item', description: '根据 item_id 和 family_id 获取物品详情与所在路径', input_schema: '{"type":"object","properties":{"item_id":{"type":"string"},"family_id":{"type":"string"}},"required":["item_id","family_id"]}' },
{ name: 'get_family_stats', description: '获取家庭物品统计概览', input_schema: '{"type":"object","properties":{"family_id":{"type":"string"}},"required":["family_id"]}' },
]
}
export async function mcpCall(toolName, args = {}, userId) {
switch (toolName) {
case 'list_my_families':
return { ok: true, value: await dbListFamiliesForUser(userId) }
case 'search_items': {
const { family_id, q, tag } = args
const role = await dbGetRole(family_id, userId)
if (!role) throw { status: 403, message: '非该家庭成员' }
return { ok: true, value: await dbListItems(family_id, { q, tag, limit: 100 }) }
}
case 'get_item': {
const { item_id, family_id } = args
const role = await dbGetRole(family_id, userId)
if (!role) throw { status: 403, message: '非该家庭成员' }
const item = await dbGetItem(family_id, item_id)
if (!item) throw { status: 404, message: '物品不存在' }
const boxes = await allBoxes(family_id)
return { ok: true, value: { ...item, box_path: item.box_id ? buildPath(boxes, item.box_id) : [] } }
}
case 'get_family_stats': {
const { family_id } = args
const role = await dbGetRole(family_id, userId)
if (!role) throw { status: 403, message: '非该家庭成员' }
return { ok: true, value: await dbGetFamilyStats(family_id) }
}
default:
throw { status: 400, message: `Unknown tool: ${toolName}` }
}
}
+55
View File
@@ -0,0 +1,55 @@
// Storage 服务端入口: 业务 API + 图片 API + 静态托管 (SPA)
// 端口由部署环境决定 (PORT env), 本地缺省 3002。见 部署环境.md
import 'dotenv/config'
import path from 'node:path'
import fs from 'node:fs'
import express from 'express'
import cors from 'cors'
import appRouter from './routes/app.js'
import imagesRouter from './routes/images.js'
import { initDb } from './db.js'
const PORT = Number(process.env.PORT) || 3002
const DIST_DIR = path.join(process.cwd(), 'dist')
const HAS_DIST = fs.existsSync(DIST_DIR)
const app = express()
app.use(cors())
app.use(express.json({ limit: '2mb' }))
app.get('/healthz', (_req, res) => res.json({ ok: true, ts: Date.now() }))
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
// 业务接口
app.use('/api', appRouter)
// 图片接口 (签名 URL 鉴权), 需在 SPA fallback 之前挂载
app.use('/api', imagesRouter)
if (HAS_DIST) {
app.use(express.static(DIST_DIR, { index: false, maxAge: '1h', etag: true }))
app.get(/^\/(?!api\/).*/, (_req, res, next) => {
const indexHtml = path.join(DIST_DIR, 'index.html')
if (!fs.existsSync(indexHtml)) return next()
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
res.sendFile(indexHtml)
})
}
app.use((err, _req, res, _next) => {
console.error('[api error]', err)
const code = err && err.status ? Number(err.status) : 500
if (code >= 500) console.error(err.stack || err)
res.status(code).json({ error: (err && err.message) || '服务器内部错误' })
})
initDb()
.then(() => {
app.listen(PORT, () => {
console.log(`[storage-api] listening on http://127.0.0.1:${PORT}`)
console.log(`[storage-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
})
})
.catch((e) => {
console.error('[db] failed to init DuckDB:', e)
process.exit(1)
})
+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
+169
View File
@@ -0,0 +1,169 @@
// 图片: multipart 上传 + 短时签名 URL 鉴权 (决策点 1)
// - 上传: POST /api/images (登录即可, 归属家庭由后续挂到物品时确定)
// - 签名: POST /api/images/sign {path} -> {url}, 15 分钟内有效, 校验家庭成员
// - 读取: GET /api/images/:familyId/:file?exp=&sig=, 校验签名与成员身份
// - 物理文件: <UPLOAD_DIR>/<familyId>/<uuid>.<ext>, 不落入任何静态目录
import { Router } from 'express'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import multer from 'multer'
import * as auth from '../auth.js'
import { dbGetRole, dbListFamiliesForUser } from '../db.js'
const router = Router()
const asyncH = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve(process.cwd(), 'data', 'uploads')
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
const SIGN_TTL_MS = 15 * 60 * 1000
const MAX_SIZE = 8 * 1024 * 1024
// 签名密钥: 优先环境变量; 否则持久化到 data/.image-secret, 保证重启后旧签名仍可校验
function secret() {
if (process.env.IMAGE_SECRET) return process.env.IMAGE_SECRET
const f = path.resolve(process.cwd(), 'data', '.image-secret')
fs.mkdirSync(path.dirname(f), { recursive: true })
if (fs.existsSync(f)) return fs.readFileSync(f, 'utf8').trim()
const s = crypto.randomBytes(32).toString('hex')
fs.writeFileSync(f, s, { mode: 0o600 })
return s
}
const extWhitelist = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
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
next()
} catch (e) {
next(e)
}
}
const upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (_req, file, cb) => cb(null, crypto.randomUUID() + path.extname(file.originalname).toLowerCase()),
}),
limits: { fileSize: MAX_SIZE },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase()
cb(extWhitelist.has(ext) ? null : new Error('仅支持 jpg/png/webp/gif'), extWhitelist.has(ext))
},
})
function sign(fileKey, exp) {
return crypto.createHmac('sha256', secret()).update(`${exp}:${fileKey}`).digest('hex')
}
// 上传: 存到 staging 目录, 返回临时文件名 (带 familyId 前缀由客户端后续挂载时确定)
router.post('/images', requireAuth, upload.single('file'), asyncH(async (req, res) => {
if (!req.file) return res.status(400).json({ error: '未收到文件' })
const name = req.file.filename
res.json({ path: name, name, size: req.file.size })
}))
// 生成短时签名 URL; path 形如 <familyId>/<fileName> 或 <fileName>
router.post('/images/sign', requireAuth, asyncH(async (req, res) => {
const rawPath = String(req.body?.path || '').trim()
if (!rawPath) return res.status(400).json({ error: '缺少 path' })
const parts = rawPath.split('/')
const fileName = parts[parts.length - 1]
const familyId = parts.length >= 2 ? parts[0] : null
// 旧格式 (无 familyId 前缀): 从用户所属家庭中匹配该文件
let fileKey
if (familyId) {
const role = await dbGetRole(familyId, req.user.id)
if (role) fileKey = `${familyId}/${fileName}`
} else {
const families = await dbListFamiliesForUser(req.user.id)
for (const f of families) {
const p = path.join(UPLOAD_DIR, f.id, fileName)
if (fs.existsSync(p)) { fileKey = `${f.id}/${fileName}`; break }
const p2 = path.join(UPLOAD_DIR, fileName)
if (fs.existsSync(p2)) { fileKey = fileName; break }
}
}
if (!fileKey) return res.status(403).json({ error: '无权访问该图片' })
res.json({ url: signedUrl(fileKey) })
}))
function signedUrl(fileKey) {
const exp = Date.now() + SIGN_TTL_MS
const sig = sign(fileKey, exp)
return `/api/images/${fileKey}?exp=${exp}&sig=${sig}`
}
// 读取: 验签即鉴权 (签名只能由服务端在成员校验通过后签发, 15 分钟过期)
// 不要求 Bearer 头, 以便 <img> 直接加载 (决策点 1: 短时签名 URL 免登录浏览)
router.get('/images/:familyId/:file', asyncH(async (req, res) => {
const { familyId, file } = req.params
const { exp, sig } = req.query
if (!exp || !sig) return res.status(403).json({ error: '缺少签名参数' })
const expNum = Number(exp)
if (!Number.isFinite(expNum) || expNum < Date.now()) return res.status(403).json({ error: '链接已过期' })
const fileKey = `${familyId}/${file}`
const expect = sign(fileKey, expNum)
const sigBuf = Buffer.from(String(sig), 'utf8')
const expectBuf = Buffer.from(expect, 'utf8')
if (sigBuf.length !== expectBuf.length || !crypto.timingSafeEqual(sigBuf, expectBuf)) {
return res.status(403).json({ error: '签名无效' })
}
const full = path.join(UPLOAD_DIR, familyId, file)
if (!fs.existsSync(full)) return res.status(404).json({ error: '文件不存在' })
res.sendFile(full)
}))
// 旧格式兼容: /api/images/:file (无家庭前缀, 文件名反查; 同样验签)
router.get('/images/:file([a-zA-Z0-9-]+[.][a-zA-Z0-9]+)', asyncH(async (req, res) => {
const { file } = req.params
const { exp, sig } = req.query
if (!exp || !sig) return res.status(403).json({ error: '缺少签名参数' })
const expNum = Number(exp)
if (!Number.isFinite(expNum) || expNum < Date.now()) return res.status(403).json({ error: '链接已过期' })
const fileKey = file
const expect = sign(fileKey, expNum)
const sigBuf = Buffer.from(String(sig), 'utf8')
const expectBuf = Buffer.from(expect, 'utf8')
if (sigBuf.length !== expectBuf.length || !crypto.timingSafeEqual(sigBuf, expectBuf)) {
return res.status(403).json({ error: '签名无效' })
}
const full = path.join(UPLOAD_DIR, file)
if (!fs.existsSync(full)) return res.status(404).json({ error: '文件不存在' })
res.sendFile(full)
}))
// 物品保存时把暂存的上传文件归入家庭目录, 得到规范路径 <familyId>/<file>
// 返回清理后的路径数组; 已是本家庭路径的保持不变
export function finalizeImages(images, familyId) {
const dir = path.join(UPLOAD_DIR, familyId)
fs.mkdirSync(dir, { recursive: true })
const out = []
for (const img of images || []) {
const s = String(img).trim()
if (!s) continue
if (s.startsWith(familyId + '/')) { out.push(s); continue }
const fileName = s.includes('/') ? s.split('/').pop() : s
const staging = path.join(UPLOAD_DIR, fileName)
if (!fs.existsSync(staging)) continue
const dest = path.join(dir, fileName)
if (!fs.existsSync(dest)) fs.copyFileSync(staging, dest)
try { fs.unlink(staging) } catch { /* ignore */ }
out.push(`${familyId}/${fileName}`)
}
return out
}
// 删除物品时顺带清理其图片文件
export function deleteImageFiles(images) {
for (const img of images || []) {
try { fs.unlinkSync(path.join(UPLOAD_DIR, img)) } catch { /* ignore */ }
}
}
export default router