feat: 用户信息完善 + 一起记功能
- users 表添加 nickname、avatar_url 字段 - 用户信息 API(GET/PUT /me)+ 头像上传(multer) - 头像通过 API 路由获取(公开,不经过 auth) - 登录接口返回用户昵称和头像 - 用户信息 Store + 个人资料编辑页面 - 我的页面和首页显示真实用户信息 - 群组表(groups、group_members)+ transactions 添加 group_id - 群组 API(创建/加入/退出/解散) - 交易和统计 API 支持 group_id 视图切换 - 群组客户端 Store + 身份切换 - 群组管理页面 - App 初始化群组 Store - UPLOAD_DIR 配置化 - Node.js 备份替代 mysqldump - 统一前端 BASE_URL(config.ts) - uploads 加入 .gitignore - 修复 trust proxy 警告
This commit is contained in:
@@ -13,6 +13,9 @@ DB_USER=your_mysql_user_here
|
||||
DB_PASSWORD=your_mysql_password_here
|
||||
DB_NAME=your_mysql_name_here
|
||||
|
||||
# Uploads
|
||||
UPLOAD_DIR=./uploads
|
||||
|
||||
# Backup
|
||||
BACKUP_DIR=/var/backups/xiaocai
|
||||
|
||||
|
||||
112
server/package-lock.json
generated
112
server/package-lock.json
generated
@@ -12,11 +12,13 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"shx": "^0.4.0",
|
||||
"tsx": "^4.7.0",
|
||||
@@ -574,6 +576,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
@@ -643,6 +655,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -695,6 +713,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -733,6 +768,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -1537,6 +1587,25 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.4.tgz",
|
||||
@@ -1798,6 +1867,20 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
|
||||
@@ -2116,6 +2199,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-eof": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
|
||||
@@ -2193,6 +2293,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2222,6 +2328,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"shx": "^0.4.0",
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
@@ -34,6 +34,55 @@ async function runMigrations(conn: mysql.Connection) {
|
||||
await conn.query('ALTER TABLE budgets ADD UNIQUE KEY uk_user_month (user_id, month)')
|
||||
console.log('[DB] budgets.user_id added')
|
||||
}
|
||||
|
||||
// users 表添加 nickname 和 avatar_url
|
||||
const hasNickname = await columnExists(conn, 'users', 'nickname')
|
||||
if (!hasNickname) {
|
||||
console.log('[DB] Migrating: adding nickname to users')
|
||||
await conn.query("ALTER TABLE users ADD COLUMN nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称'")
|
||||
}
|
||||
const hasAvatarUrl = await columnExists(conn, 'users', 'avatar_url')
|
||||
if (!hasAvatarUrl) {
|
||||
console.log('[DB] Migrating: adding avatar_url to users')
|
||||
await conn.query("ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL'")
|
||||
}
|
||||
|
||||
// 群组表 + transactions.group_id
|
||||
const hasGroupId = await columnExists(conn, 'transactions', 'group_id')
|
||||
console.log(`[DB] transactions.group_id exists: ${hasGroupId}`)
|
||||
if (!hasGroupId) {
|
||||
console.log('[DB] Migrating: creating groups tables and adding group_id')
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS \`groups\` (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
invite_code VARCHAR(10) UNIQUE NOT NULL,
|
||||
created_by INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
group_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
role ENUM('owner', 'member') DEFAULT 'member',
|
||||
nickname VARCHAR(50) DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_group_user (group_id, user_id),
|
||||
FOREIGN KEY (group_id) REFERENCES \`groups\`(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await conn.query("ALTER TABLE transactions ADD COLUMN group_id INT DEFAULT NULL COMMENT '群组标签'")
|
||||
await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)")
|
||||
console.log('[DB] Groups migration complete')
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
|
||||
@@ -2,6 +2,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
openid VARCHAR(100) UNIQUE,
|
||||
session_key VARCHAR(100),
|
||||
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
|
||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -28,10 +30,12 @@ CREATE TABLE IF NOT EXISTS transactions (
|
||||
category_id INT,
|
||||
note VARCHAR(200),
|
||||
date DATE NOT NULL,
|
||||
group_id INT DEFAULT NULL COMMENT '群组标签,NULL=纯个人记录',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_date (user_id, date),
|
||||
INDEX idx_user_type_date (user_id, type, date),
|
||||
INDEX idx_group_date (group_id, date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -46,3 +50,25 @@ CREATE TABLE IF NOT EXISTS budgets (
|
||||
UNIQUE KEY uk_user_month (user_id, month),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `groups` (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL COMMENT '群组名称',
|
||||
invite_code VARCHAR(10) UNIQUE NOT NULL COMMENT '邀请码',
|
||||
created_by INT NOT NULL COMMENT '创建者',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
group_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
role ENUM('owner', 'member') DEFAULT 'member' COMMENT '角色',
|
||||
nickname VARCHAR(50) DEFAULT '' COMMENT '群内昵称',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_group_user (group_id, user_id),
|
||||
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dotenv/config'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
import { initDatabase } from './db/init'
|
||||
import pool from './db/connection'
|
||||
@@ -11,6 +13,8 @@ import statsRoutes from './routes/stats'
|
||||
import budgetRoutes from './routes/budget'
|
||||
import categoryRoutes from './routes/category'
|
||||
import backupRoutes from './routes/backup'
|
||||
import userRoutes from './routes/user'
|
||||
import groupRoutes from './routes/group'
|
||||
import { backupDatabase } from './utils/backup'
|
||||
|
||||
// Warn if token secret is using default fallback
|
||||
@@ -22,6 +26,9 @@ const app = express()
|
||||
const PORT = process.env.PORT || 3000
|
||||
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS || '').split(',').filter(Boolean)
|
||||
|
||||
// 反向代理环境下信任 X-Forwarded-* 头(修复 rate-limit 警告)
|
||||
app.set('trust proxy', 1)
|
||||
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
// No origin = WeChat mini-program request (always allow)
|
||||
@@ -54,6 +61,42 @@ const apiLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
})
|
||||
|
||||
// 头像 API(公开,不经过 auth 中间件)
|
||||
app.get('/api/user/avatar/:filename', async (req, res) => {
|
||||
try {
|
||||
const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads')
|
||||
const avatarDir = path.join(uploadDir, 'avatars')
|
||||
const filename = path.basename(req.params.filename)
|
||||
|
||||
// 文件名格式校验:仅允许 数字_数字.ext
|
||||
if (!/^\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) {
|
||||
return res.status(400).json({ code: 40001, message: '无效文件名' })
|
||||
}
|
||||
|
||||
const filepath = path.join(avatarDir, filename)
|
||||
|
||||
// 路径穿越检查:确保解析后仍在 avatarDir 内
|
||||
if (!path.resolve(filepath).startsWith(path.resolve(avatarDir))) {
|
||||
return res.status(400).json({ code: 40001, message: '无效路径' })
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filepath)) {
|
||||
return res.status(404).json({ code: 40400, message: '头像不存在' })
|
||||
}
|
||||
|
||||
const ext = path.extname(filename).toLowerCase()
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.webp': 'image/webp'
|
||||
}
|
||||
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
fs.createReadStream(filepath).pipe(res)
|
||||
} catch {
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
app.use(authMiddleware)
|
||||
|
||||
app.use('/api/auth', authLimiter, authRoutes)
|
||||
@@ -62,6 +105,8 @@ app.use('/api/stats', apiLimiter, statsRoutes)
|
||||
app.use('/api/budget', apiLimiter, budgetRoutes)
|
||||
app.use('/api/categories', apiLimiter, categoryRoutes)
|
||||
app.use('/api/backup', apiLimiter, backupRoutes)
|
||||
app.use('/api/user', apiLimiter, userRoutes)
|
||||
app.use('/api/groups', apiLimiter, groupRoutes)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
@@ -74,19 +119,25 @@ app.get('/api/health', async (_req, res) => {
|
||||
|
||||
let server: any
|
||||
|
||||
initDatabase().then(async () => {
|
||||
// 每次启动时自动备份数据库
|
||||
try {
|
||||
await backupDatabase()
|
||||
console.log('[Backup] 启动备份完成')
|
||||
} catch (err) {
|
||||
console.error('[Backup] 启动备份失败:', err)
|
||||
// 生产环境:先备份再迁移,迁移出问题可恢复
|
||||
async function start() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
try {
|
||||
await backupDatabase()
|
||||
console.log('[Backup] 迁移前备份完成')
|
||||
} catch (err) {
|
||||
console.error('[Backup] 备份失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
await initDatabase()
|
||||
|
||||
server = app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
start()
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { createHmac } from 'crypto'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: number
|
||||
@@ -48,10 +48,12 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
|
||||
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||||
}
|
||||
|
||||
// Verify HMAC signature (full length)
|
||||
// Verify HMAC signature (constant-time comparison)
|
||||
const payload = `${userId}:${timestamp}`
|
||||
const expectedSig = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||
if (signature !== expectedSig) {
|
||||
const sigBuf = Buffer.from(signature, 'hex')
|
||||
const expectedBuf = Buffer.from(expectedSig, 'hex')
|
||||
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
||||
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
|
||||
)
|
||||
const userId = (result as any).insertId
|
||||
const token = signToken(userId)
|
||||
res.json({ code: 0, data: { token, userId } })
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
|
||||
const userInfo = (userRows as any[])[0]
|
||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
|
||||
} catch (err: any) {
|
||||
console.error('[Auth] Demo login failed:', err.message)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
@@ -59,8 +61,9 @@ router.post('/login', async (req: Request, res: Response) => {
|
||||
|
||||
// HMAC signed token with expiry
|
||||
const token = signToken(userId)
|
||||
|
||||
res.json({ code: 0, data: { token, userId } })
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
|
||||
const userInfo = (userRows as any[])[0]
|
||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
|
||||
} catch (err: any) {
|
||||
console.error('[Auth] Login failed:', err.message)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
|
||||
@@ -12,14 +12,36 @@ function validateMonth(m: string): string {
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const month = validateMonth((req.query.month as string) || getCurrentMonth())
|
||||
const group_id = req.query.group_id as string | undefined
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
|
||||
[req.userId, month]
|
||||
)
|
||||
if (group_id && group_id !== 'null') {
|
||||
// 群组视图:所有成员的预算总和
|
||||
const [memberCheck] = await pool.query(
|
||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[group_id, req.userId]
|
||||
)
|
||||
if ((memberCheck as any[]).length === 0) {
|
||||
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
}
|
||||
|
||||
const budget = (rows as any[])[0] || null
|
||||
res.json({ code: 0, data: budget })
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COALESCE(SUM(amount), 0) as amount, month
|
||||
FROM budgets
|
||||
WHERE user_id IN (SELECT user_id FROM group_members WHERE group_id = ?) AND month = ?
|
||||
GROUP BY month`,
|
||||
[group_id, month]
|
||||
)
|
||||
const budget = (rows as any[])[0] || { amount: 0, month }
|
||||
res.json({ code: 0, data: budget })
|
||||
} else {
|
||||
// 个人视图
|
||||
const [rows] = await pool.query(
|
||||
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
|
||||
[req.userId, month]
|
||||
)
|
||||
const budget = (rows as any[])[0] || null
|
||||
res.json({ code: 0, data: budget })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Budget] GET error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
|
||||
255
server/src/routes/group.ts
Normal file
255
server/src/routes/group.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { Router, Response } from 'express'
|
||||
import { randomInt } from 'crypto'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 生成 6 位随机邀请码(密码学安全) */
|
||||
function generateInviteCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(randomInt(chars.length))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
/** 创建群组 */
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name } = req.body
|
||||
const trimmedName = typeof name === 'string' ? name.trim() : ''
|
||||
if (!trimmedName) {
|
||||
return res.status(400).json({ code: 40001, message: '群组名称不能为空' })
|
||||
}
|
||||
if (trimmedName.length > 100) {
|
||||
return res.status(400).json({ code: 40001, message: '群组名称不能超过100个字符' })
|
||||
}
|
||||
|
||||
// 生成唯一邀请码
|
||||
let inviteCode = generateInviteCode()
|
||||
let attempts = 0
|
||||
while (attempts < 10) {
|
||||
const [existing] = await pool.query('SELECT id FROM `groups` WHERE invite_code = ?', [inviteCode])
|
||||
if ((existing as any[]).length === 0) break
|
||||
inviteCode = generateInviteCode()
|
||||
attempts++
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
const [result] = await conn.query(
|
||||
'INSERT INTO `groups` (name, invite_code, created_by) VALUES (?, ?, ?)',
|
||||
[trimmedName, inviteCode, req.userId]
|
||||
)
|
||||
const groupId = (result as any).insertId
|
||||
|
||||
// 创建者自动成为 owner
|
||||
await conn.query(
|
||||
'INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
||||
[groupId, req.userId, 'owner']
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
res.json({ code: 0, data: { id: groupId, invite_code: inviteCode } })
|
||||
} catch (err) {
|
||||
await conn.rollback()
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Group] POST error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取用户的群组列表 */
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT g.id, g.name, g.invite_code, g.created_by, g.created_at,
|
||||
gm.role, gm.nickname as my_nickname,
|
||||
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) as member_count
|
||||
FROM \`groups\` g
|
||||
INNER JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
|
||||
ORDER BY g.created_at DESC`,
|
||||
[req.userId]
|
||||
)
|
||||
res.json({ code: 0, data: rows })
|
||||
} catch (err) {
|
||||
console.error('[Group] GET error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取群组详情 + 成员列表 */
|
||||
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 验证用户是否是群组成员
|
||||
const [memberCheck] = await pool.query(
|
||||
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[req.params.id, req.userId]
|
||||
)
|
||||
if ((memberCheck as any[]).length === 0) {
|
||||
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
}
|
||||
|
||||
const [groupRows] = await pool.query(
|
||||
'SELECT id, name, invite_code, created_by, created_at FROM `groups` WHERE id = ?',
|
||||
[req.params.id]
|
||||
)
|
||||
const group = (groupRows as any[])[0]
|
||||
if (!group) {
|
||||
return res.status(404).json({ code: 40400, message: '群组不存在' })
|
||||
}
|
||||
|
||||
// 获取成员列表
|
||||
const [members] = await pool.query(
|
||||
`SELECT gm.user_id, gm.role, gm.nickname, gm.created_at as joined_at,
|
||||
u.nickname as user_nickname, u.avatar_url
|
||||
FROM group_members gm
|
||||
LEFT JOIN users u ON gm.user_id = u.id
|
||||
WHERE gm.group_id = ?
|
||||
ORDER BY gm.role DESC, gm.created_at ASC`,
|
||||
[req.params.id]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { ...group, members } })
|
||||
} catch (err) {
|
||||
console.error('[Group] GET by ID error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 通过邀请码加入群组 */
|
||||
router.post('/join', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { invite_code } = req.body
|
||||
if (!invite_code || typeof invite_code !== 'string') {
|
||||
return res.status(400).json({ code: 40001, message: '邀请码不能为空' })
|
||||
}
|
||||
|
||||
const [groupRows] = await pool.query(
|
||||
'SELECT id, name FROM `groups` WHERE invite_code = ?',
|
||||
[invite_code.toUpperCase()]
|
||||
)
|
||||
const group = (groupRows as any[])[0]
|
||||
if (!group) {
|
||||
return res.status(404).json({ code: 40400, message: '邀请码无效' })
|
||||
}
|
||||
|
||||
// 检查是否已是成员
|
||||
const [existing] = await pool.query(
|
||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[group.id, req.userId]
|
||||
)
|
||||
if ((existing as any[]).length > 0) {
|
||||
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
||||
[group.id, req.userId, 'member']
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { group_id: group.id, name: group.name } })
|
||||
} catch (err) {
|
||||
console.error('[Group] join error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 退出群组 */
|
||||
router.post('/:id/leave', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const groupId = req.params.id
|
||||
|
||||
// 检查是否是成员
|
||||
const [memberRows] = await pool.query(
|
||||
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[groupId, req.userId]
|
||||
)
|
||||
const member = (memberRows as any[])[0]
|
||||
if (!member) {
|
||||
return res.status(404).json({ code: 40400, message: '你不是该群组成员' })
|
||||
}
|
||||
|
||||
// owner 不能退出,只能解散
|
||||
if (member.role === 'owner') {
|
||||
return res.status(400).json({ code: 40003, message: '群主不能退出,请先转让或解散群组' })
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
// 清除该用户在该群组的记录标签
|
||||
await conn.query(
|
||||
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
|
||||
[req.userId, groupId]
|
||||
)
|
||||
|
||||
// 删除成员关系
|
||||
await conn.query(
|
||||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[groupId, req.userId]
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
await conn.rollback()
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Group] leave error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 解散群组(仅 owner) */
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [groupRows] = await pool.query(
|
||||
'SELECT created_by FROM `groups` WHERE id = ?',
|
||||
[req.params.id]
|
||||
)
|
||||
const group = (groupRows as any[])[0]
|
||||
if (!group) {
|
||||
return res.status(404).json({ code: 40400, message: '群组不存在' })
|
||||
}
|
||||
if (group.created_by !== req.userId) {
|
||||
return res.status(403).json({ code: 40300, message: '仅群主可解散群组' })
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
// 清除该群组所有记录的标签
|
||||
await conn.query('UPDATE transactions SET group_id = NULL WHERE group_id = ?', [req.params.id])
|
||||
|
||||
// 删除群组(CASCADE 自动清理 group_members)
|
||||
await conn.query('DELETE FROM `groups` WHERE id = ?', [req.params.id])
|
||||
|
||||
await conn.commit()
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
await conn.rollback()
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Group] DELETE error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -6,19 +6,49 @@ import { getCurrentMonth, getMonthRange } from '../utils/date'
|
||||
const router = Router()
|
||||
const VALID_TYPES = ['expense', 'income']
|
||||
|
||||
/** 根据 group_id 参数构建 WHERE 条件(含成员验证) */
|
||||
async function buildWhereClause(
|
||||
userId: number | undefined,
|
||||
groupId: string | undefined,
|
||||
extraConditions: string[] = []
|
||||
): Promise<{ where: string; params: any[] } | null> {
|
||||
const params: any[] = []
|
||||
|
||||
if (groupId && groupId !== 'null') {
|
||||
// 验证用户是群组成员
|
||||
const [memberCheck] = await pool.query(
|
||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[groupId, userId]
|
||||
)
|
||||
if ((memberCheck as any[]).length === 0) return null
|
||||
|
||||
params.push(groupId)
|
||||
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
|
||||
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
|
||||
return { where, params }
|
||||
}
|
||||
|
||||
params.push(userId)
|
||||
let where = `WHERE t.user_id = ?`
|
||||
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
|
||||
return { where, params }
|
||||
}
|
||||
|
||||
router.get('/overview', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { month, startDate: qStart, endDate: qEnd } = req.query
|
||||
const { month, startDate: qStart, endDate: qEnd, group_id } = req.query
|
||||
|
||||
let startDate: string
|
||||
let endDate: string
|
||||
let elapsedDays = 1
|
||||
|
||||
if (qStart && qEnd) {
|
||||
// 按日期范围查询(用于今日等场景)
|
||||
startDate = qStart as string
|
||||
endDate = qEnd as string
|
||||
elapsedDays = 1
|
||||
// 计算日期范围天数
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
elapsedDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000) + 1)
|
||||
} else {
|
||||
const m = (month as string) || getCurrentMonth()
|
||||
const range = getMonthRange(m)
|
||||
@@ -31,13 +61,20 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
|
||||
elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
|
||||
}
|
||||
|
||||
const result = await buildWhereClause(
|
||||
req.userId,
|
||||
group_id as string | undefined,
|
||||
['t.date >= ?', 't.date <= ?']
|
||||
)
|
||||
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as expense,
|
||||
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as income,
|
||||
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as expense,
|
||||
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as income,
|
||||
COUNT(*) as count
|
||||
FROM transactions WHERE user_id = ? AND date >= ? AND date <= ?`,
|
||||
[req.userId, startDate, endDate]
|
||||
FROM transactions t ${result.where}`,
|
||||
[...result.params, startDate, endDate]
|
||||
)
|
||||
const row = (rows as any[])[0]
|
||||
const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0
|
||||
@@ -54,7 +91,7 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.get('/category', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { month, type = 'expense' } = req.query
|
||||
const { month, type = 'expense', group_id } = req.query
|
||||
if (!VALID_TYPES.includes(type as string)) {
|
||||
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||
}
|
||||
@@ -63,14 +100,21 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
|
||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||
const { startDate, endDate } = range
|
||||
|
||||
const result = await buildWhereClause(
|
||||
req.userId,
|
||||
group_id as string | undefined,
|
||||
['t.type = ?', 't.date >= ?', 't.date <= ?']
|
||||
)
|
||||
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
WHERE t.user_id = ? AND t.type = ? AND t.date >= ? AND t.date <= ?
|
||||
${result.where}
|
||||
GROUP BY c.id
|
||||
ORDER BY amount DESC`,
|
||||
[req.userId, type, startDate, endDate]
|
||||
[...result.params, type, startDate, endDate]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: rows })
|
||||
@@ -82,7 +126,7 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.get('/trend', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { month, type = 'expense' } = req.query
|
||||
const { month, type = 'expense', group_id } = req.query
|
||||
if (!VALID_TYPES.includes(type as string)) {
|
||||
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||
}
|
||||
@@ -91,13 +135,20 @@ router.get('/trend', async (req: AuthRequest, res: Response) => {
|
||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||
const { startDate, endDate } = range
|
||||
|
||||
const result = await buildWhereClause(
|
||||
req.userId,
|
||||
group_id as string | undefined,
|
||||
['t.type = ?', 't.date >= ?', 't.date <= ?']
|
||||
)
|
||||
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DATE_FORMAT(date, '%Y-%m-%d') as date, SUM(amount) as amount
|
||||
FROM transactions
|
||||
WHERE user_id = ? AND type = ? AND date >= ? AND date <= ?
|
||||
GROUP BY date
|
||||
ORDER BY date`,
|
||||
[req.userId, type, startDate, endDate]
|
||||
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, SUM(t.amount) as amount
|
||||
FROM transactions t
|
||||
${result.where}
|
||||
GROUP BY t.date
|
||||
ORDER BY t.date`,
|
||||
[...result.params, type, startDate, endDate]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: rows })
|
||||
|
||||
@@ -16,12 +16,21 @@ function safeInt(val: any, fallback: number, min: number, max: number): number {
|
||||
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at,
|
||||
c.name as category_name, c.icon as category_icon, c.color as category_color
|
||||
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
|
||||
c.name as category_name, c.icon as category_icon, c.color as category_color,
|
||||
u.nickname as creator_nickname
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
WHERE t.id = ? AND t.user_id = ?`,
|
||||
[req.params.id, req.userId]
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
WHERE t.id = ? AND (
|
||||
t.user_id = ?
|
||||
OR t.user_id IN (
|
||||
SELECT gm2.user_id FROM group_members gm1
|
||||
JOIN group_members gm2 ON gm1.group_id = gm2.group_id
|
||||
WHERE gm1.user_id = ?
|
||||
)
|
||||
)`,
|
||||
[req.params.id, req.userId, req.userId]
|
||||
)
|
||||
const tx = (rows as any[])[0]
|
||||
if (!tx) {
|
||||
@@ -36,13 +45,30 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { page, pageSize, type, startDate, endDate, sortBy } = req.query
|
||||
const { page, pageSize, type, startDate, endDate, sortBy, group_id } = req.query
|
||||
const pSize = safeInt(pageSize, 20, 1, 100)
|
||||
const pNum = safeInt(page, 1, 1, 99999)
|
||||
const offset = (pNum - 1) * pSize
|
||||
|
||||
let where = 'WHERE t.user_id = ?'
|
||||
const params: any[] = [req.userId]
|
||||
let where = ''
|
||||
const params: any[] = []
|
||||
|
||||
if (group_id && group_id !== 'null') {
|
||||
// 群组视图:群组下所有成员的记录
|
||||
const [memberCheck] = await pool.query(
|
||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[group_id, req.userId]
|
||||
)
|
||||
if ((memberCheck as any[]).length === 0) {
|
||||
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
}
|
||||
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
|
||||
params.push(group_id)
|
||||
} else {
|
||||
// 个人视图:我的所有记录(不管 group_id)
|
||||
where = 'WHERE t.user_id = ?'
|
||||
params.push(req.userId)
|
||||
}
|
||||
|
||||
if (type && VALID_TYPES.includes(type as string)) {
|
||||
where += ' AND t.type = ?'; params.push(type)
|
||||
@@ -57,10 +83,12 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at,
|
||||
c.name as category_name, c.icon as category_icon, c.color as category_color
|
||||
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
|
||||
c.name as category_name, c.icon as category_icon, c.color as category_color,
|
||||
u.nickname as creator_nickname
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
${where}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -89,7 +117,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { amount, type, category_id, note, date } = req.body
|
||||
const { amount, type, category_id, note, date, group_id } = req.body
|
||||
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
|
||||
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' })
|
||||
}
|
||||
@@ -106,9 +134,21 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
|
||||
}
|
||||
|
||||
// 如果指定了 group_id,验证类型和用户是群组成员
|
||||
const validGroupId = group_id && typeof group_id === 'number' && Number.isInteger(group_id) ? group_id : null
|
||||
if (validGroupId) {
|
||||
const [memberCheck] = await pool.query(
|
||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[validGroupId, req.userId]
|
||||
)
|
||||
if ((memberCheck as any[]).length === 0) {
|
||||
return res.status(403).json({ code: 40300, message: '无权在此群组记账' })
|
||||
}
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO transactions (user_id, amount, type, category_id, note, date) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[req.userId, amount, type, category_id || null, note || null, date]
|
||||
'INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[req.userId, amount, type, category_id || null, note || null, date, validGroupId]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||
|
||||
133
server/src/routes/user.ts
Normal file
133
server/src/routes/user.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Router, Response } from 'express'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 头像上传配置
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
|
||||
const AVATAR_DIR = path.resolve(UPLOAD_DIR, 'avatars')
|
||||
if (!fs.existsSync(AVATAR_DIR)) {
|
||||
fs.mkdirSync(AVATAR_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
// 确保目录存在(可能被手动删除)
|
||||
if (!fs.existsSync(AVATAR_DIR)) {
|
||||
fs.mkdirSync(AVATAR_DIR, { recursive: true })
|
||||
}
|
||||
cb(null, AVATAR_DIR)
|
||||
},
|
||||
filename: (req: AuthRequest, file, cb) => {
|
||||
const extMap: Record<string, string> = {
|
||||
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
|
||||
}
|
||||
const ext = extMap[file.mimetype] || '.jpg'
|
||||
cb(null, `${req.userId}_${Date.now()}${ext}`)
|
||||
}
|
||||
})
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 最大 2MB
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/webp']
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true)
|
||||
} else {
|
||||
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取当前用户信息 */
|
||||
router.get('/me', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, nickname, avatar_url, created_at FROM users WHERE id = ?',
|
||||
[req.userId]
|
||||
)
|
||||
const user = (rows as any[])[0]
|
||||
if (!user) {
|
||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||
}
|
||||
res.json({ code: 0, data: user })
|
||||
} catch (err) {
|
||||
console.error('[User] GET /me error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新用户昵称 */
|
||||
router.put('/me', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { nickname } = req.body
|
||||
|
||||
if (nickname !== undefined) {
|
||||
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '昵称不能为空' })
|
||||
}
|
||||
if (nickname.length > 50) {
|
||||
return res.status(400).json({ code: 40001, message: '昵称不能超过50个字符' })
|
||||
}
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
|
||||
if (nickname !== undefined) {
|
||||
updates.push('nickname = ?')
|
||||
params.push(nickname.trim())
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
|
||||
}
|
||||
|
||||
params.push(req.userId)
|
||||
await pool.query(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
|
||||
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[User] PUT /me error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 上传头像 */
|
||||
router.post('/avatar', (req: AuthRequest, res: Response) => {
|
||||
upload.single('file')(req, res, async (err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({ code: 40001, message: err.message })
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ code: 40001, message: '请选择图片文件' })
|
||||
}
|
||||
try {
|
||||
// 先查询旧头像
|
||||
const [rows] = await pool.query('SELECT avatar_url FROM users WHERE id = ?', [req.userId])
|
||||
const oldUrl = (rows as any[])[0]?.avatar_url
|
||||
|
||||
// 先更新 DB,再删旧文件(保证 DB 和文件至少有一个正确)
|
||||
await pool.query('UPDATE users SET avatar_url = ? WHERE id = ?', [req.file.filename, req.userId])
|
||||
|
||||
if (oldUrl) {
|
||||
const oldPath = path.join(AVATAR_DIR, oldUrl)
|
||||
if (fs.existsSync(oldPath)) {
|
||||
fs.unlinkSync(oldPath)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ code: 0, data: { avatar_url: req.file.filename } })
|
||||
} catch (err) {
|
||||
console.error('[User] avatar upload error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,21 +1,18 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import zlib from 'zlib'
|
||||
import pool from '../db/connection'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const gzipAsync = promisify(zlib.gzip)
|
||||
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||
const MAX_BACKUPS = 7 // 保留最近 7 天的备份
|
||||
|
||||
/**
|
||||
* 执行数据库备份
|
||||
* @returns 备份文件路径
|
||||
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump)
|
||||
* 导出所有表的 INSERT 语句,gzip 压缩存储
|
||||
*/
|
||||
export async function backupDatabase(): Promise<string> {
|
||||
// 确保备份目录存在
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true })
|
||||
}
|
||||
@@ -24,64 +21,64 @@ export async function backupDatabase(): Promise<string> {
|
||||
const filename = `xiaocai_${date}.sql.gz`
|
||||
const filepath = path.join(BACKUP_DIR, filename)
|
||||
|
||||
const dbHost = process.env.DB_HOST || 'localhost'
|
||||
const dbUser = process.env.DB_USER || 'root'
|
||||
const dbPassword = process.env.DB_PASSWORD || ''
|
||||
const dbName = process.env.DB_NAME || 'xiaocai'
|
||||
const lines: string[] = []
|
||||
lines.push(`-- 小菜记账 数据库备份`)
|
||||
lines.push(`-- 时间: ${new Date().toISOString()}`)
|
||||
lines.push('')
|
||||
|
||||
try {
|
||||
// 使用 execFile 避免命令注入,参数通过数组传递
|
||||
const args = [
|
||||
`-h${dbHost}`,
|
||||
`-u${dbUser}`,
|
||||
...(dbPassword ? [`-p${dbPassword}`] : []),
|
||||
dbName
|
||||
]
|
||||
// 获取所有表名
|
||||
const [tables] = await pool.query('SHOW TABLES')
|
||||
const tableNames = (tables as any[]).map(row => Object.values(row)[0] as string)
|
||||
|
||||
const { stdout } = await execFileAsync('mysqldump', args, { maxBuffer: 50 * 1024 * 1024 })
|
||||
for (const tableName of tableNames) {
|
||||
lines.push(`-- ----------------------------`)
|
||||
lines.push(`-- 表: ${tableName}`)
|
||||
lines.push(`-- ----------------------------`)
|
||||
|
||||
// 使用 Node.js zlib 压缩
|
||||
const compressed = await gzipAsync(Buffer.from(stdout, 'utf-8'))
|
||||
fs.writeFileSync(filepath, compressed)
|
||||
// 获取表数据
|
||||
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
|
||||
const data = rows as any[]
|
||||
|
||||
console.log(`[Backup] 完成: ${filename}`)
|
||||
|
||||
// 清理旧备份
|
||||
await cleanOldBackups()
|
||||
|
||||
return filepath
|
||||
} catch (err) {
|
||||
console.error('[Backup] 失败:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的备份文件
|
||||
*/
|
||||
async function cleanOldBackups(): Promise<void> {
|
||||
try {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return
|
||||
|
||||
const files = fs.readdirSync(BACKUP_DIR)
|
||||
.filter(f => f.startsWith('xiaocai_') && (f.endsWith('.sql.gz') || f.endsWith('.sql')))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
// 删除超出保留数量的文件
|
||||
for (let i = MAX_BACKUPS; i < files.length; i++) {
|
||||
const filepath = path.join(BACKUP_DIR, files[i])
|
||||
fs.unlinkSync(filepath)
|
||||
console.log(`[Backup] 清理旧备份: ${files[i]}`)
|
||||
if (data.length === 0) {
|
||||
lines.push(`-- ${tableName}: 无数据`)
|
||||
lines.push('')
|
||||
continue
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Backup] 清理失败:', err)
|
||||
|
||||
// 获取列名
|
||||
const columns = Object.keys(data[0])
|
||||
const colList = columns.map(c => `\`${c}\``).join(', ')
|
||||
|
||||
// 生成 INSERT 语句(每 100 行一批)
|
||||
const BATCH_SIZE = 100
|
||||
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
||||
const batch = data.slice(i, i + BATCH_SIZE)
|
||||
const values = batch.map(row => {
|
||||
const vals = columns.map(col => {
|
||||
const v = row[col]
|
||||
if (v === null) return 'NULL'
|
||||
if (typeof v === 'number') return String(v)
|
||||
if (typeof v === 'boolean') return v ? '1' : '0'
|
||||
// mysql2 escape 处理所有字符串/日期转义(自带引号)
|
||||
return pool.escape(v)
|
||||
})
|
||||
return `(${vals.join(', ')})`
|
||||
}).join(',\n ')
|
||||
|
||||
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
|
||||
lines.push(` ${values};`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const sql = lines.join('\n')
|
||||
const compressed = await gzipAsync(Buffer.from(sql, 'utf-8'))
|
||||
fs.writeFileSync(filepath, compressed)
|
||||
|
||||
console.log(`[Backup] 完成: ${filename} (${tableNames.length} 表)`)
|
||||
return filepath
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份列表
|
||||
*/
|
||||
export function getBackupList(): Array<{ name: string; size: number; date: string }> {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return []
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"@types/range-parser" "*"
|
||||
"@types/send" "*"
|
||||
|
||||
"@types/express@^4.17.21":
|
||||
"@types/express@*", "@types/express@^4.17.21":
|
||||
version "4.17.25"
|
||||
resolved "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz"
|
||||
integrity sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==
|
||||
@@ -80,6 +80,13 @@
|
||||
resolved "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz"
|
||||
integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
|
||||
|
||||
"@types/multer@^2.1.0":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz"
|
||||
integrity sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==
|
||||
dependencies:
|
||||
"@types/express" "*"
|
||||
|
||||
"@types/node@*", "@types/node@^20.11.0", "@types/node@>= 8":
|
||||
version "20.19.41"
|
||||
resolved "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz"
|
||||
@@ -129,6 +136,11 @@ accepts@~1.3.8:
|
||||
mime-types "~2.1.34"
|
||||
negotiator "0.6.3"
|
||||
|
||||
append-field@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz"
|
||||
integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==
|
||||
|
||||
array-flatten@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz"
|
||||
@@ -164,6 +176,18 @@ braces@^3.0.3:
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
|
||||
buffer-from@^1.0.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz"
|
||||
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
||||
|
||||
busboy@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz"
|
||||
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
|
||||
dependencies:
|
||||
streamsearch "^1.1.0"
|
||||
|
||||
bytes@~3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"
|
||||
@@ -185,6 +209,16 @@ call-bound@^1.0.2:
|
||||
call-bind-apply-helpers "^1.0.2"
|
||||
get-intrinsic "^1.3.0"
|
||||
|
||||
concat-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz"
|
||||
integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==
|
||||
dependencies:
|
||||
buffer-from "^1.0.0"
|
||||
inherits "^2.0.3"
|
||||
readable-stream "^3.0.2"
|
||||
typedarray "^0.0.6"
|
||||
|
||||
content-disposition@~0.5.4:
|
||||
version "0.5.4"
|
||||
resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz"
|
||||
@@ -535,7 +569,7 @@ iconv-lite@~0.4.24:
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3"
|
||||
|
||||
inherits@~2.0.4:
|
||||
inherits@^2.0.3, inherits@~2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
@@ -669,6 +703,16 @@ ms@2.1.3:
|
||||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
multer@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz"
|
||||
integrity sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==
|
||||
dependencies:
|
||||
append-field "^1.0.0"
|
||||
busboy "^1.6.0"
|
||||
concat-stream "^2.0.0"
|
||||
type-is "^1.6.18"
|
||||
|
||||
mysql2@^3.9.0:
|
||||
version "3.22.4"
|
||||
resolved "https://registry.npmjs.org/mysql2/-/mysql2-3.22.4.tgz"
|
||||
@@ -804,6 +848,15 @@ raw-body@~2.5.3:
|
||||
iconv-lite "~0.4.24"
|
||||
unpipe "~1.0.0"
|
||||
|
||||
readable-stream@^3.0.2:
|
||||
version "3.6.2"
|
||||
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz"
|
||||
integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
|
||||
dependencies:
|
||||
inherits "^2.0.3"
|
||||
string_decoder "^1.1.1"
|
||||
util-deprecate "^1.0.1"
|
||||
|
||||
rechoir@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz"
|
||||
@@ -833,7 +886,7 @@ run-parallel@^1.1.9:
|
||||
dependencies:
|
||||
queue-microtask "^1.2.2"
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
safe-buffer@~5.2.0, safe-buffer@5.2.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
@@ -967,6 +1020,18 @@ statuses@~2.0.1, statuses@~2.0.2:
|
||||
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz"
|
||||
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
|
||||
|
||||
streamsearch@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
string_decoder@^1.1.1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
|
||||
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
|
||||
dependencies:
|
||||
safe-buffer "~5.2.0"
|
||||
|
||||
strip-eof@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz"
|
||||
@@ -998,7 +1063,7 @@ tsx@^4.7.0:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
type-is@~1.6.18:
|
||||
type-is@^1.6.18, type-is@~1.6.18:
|
||||
version "1.6.18"
|
||||
resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"
|
||||
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
|
||||
@@ -1006,6 +1071,11 @@ type-is@~1.6.18:
|
||||
media-typer "0.3.0"
|
||||
mime-types "~2.1.24"
|
||||
|
||||
typedarray@^0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz"
|
||||
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
|
||||
|
||||
typescript@^5.3.0:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz"
|
||||
@@ -1021,6 +1091,11 @@ unpipe@~1.0.0:
|
||||
resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
|
||||
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
|
||||
|
||||
util-deprecate@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||
|
||||
utils-merge@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user