feat(iter-v2): T01 complete - security + infrastructure

- S1: .githooks/pre-commit blocks .env commits, .env.test in .gitignore
- S3: TOKEN_SECRET centralized to config/token.ts, all files import from it
- S3: checkTokenSecret enforces min 32-char TOKEN_SECRET at startup
- S5: Refresh Token mechanism (Access 2h + Refresh 30d, dual token)
- S5: POST /auth/refresh endpoint with rotation and max 5 per user
- S5: Frontend request.ts auto-refresh on 40101, concurrent-safe
- S6: Soft delete (deleted_at column), admin soft-delete with confirmName
- S6: POST /admin/users/:id/restore endpoint
- S6: All user queries filter deleted_at IS NULL
- S7: DB connection requires env vars, no fallback credentials
- Infra: jest.config.js, vitest.config.ts, .env.test
- Infra: node-cron, jest, ts-jest, supertest, vitest, @pinia/testing
This commit is contained in:
2026-06-11 09:24:10 +08:00
parent 61f9b33f8c
commit 263a7c4616
31 changed files with 8365 additions and 131 deletions

7
.githooks/pre-commit Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Prevent .env files from being committed
if git diff --cached --name-only | grep -E '\.env$|\.env\.'; then
echo "❌ ERROR: Attempting to commit .env file(s). This is blocked for security."
echo " If you need to update .env.example, that's allowed."
exit 1
fi

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ server/dist/
# Environment files
.env
.env.local
.env.test
.env.*.local
**/.env

42
CODEBUDDY.md Normal file
View File

@@ -0,0 +1,42 @@
# Team: software-xiaocai-v2
小菜记账 (XiaoCai Bookkeeping) 微信小程序 v2 迭代团队。
## 目标
完成 v2 全量迭代安全性修复、代码质量重构、性能优化、UX 增强、测试体系建设、新功能开发。
## 任务总览
| 任务 | 优先级 | 状态 | 依赖 | 描述 |
|------|--------|------|------|------|
| T01-remaining | P0 | pending | 无 | 完成安全修复剩余项 (S1, S5, S6, S7, 基础设施) |
| T02 | P1 | pending | T01 | 代码质量重构 (C1-C9, 通用组件抽取) |
| T03 | P1 | pending | T01 | 性能优化与 UX 增强 (Perf-1~7, UX-1~7) |
| T04 | P1 | pending | T01 | 测试体系建设 (后端集成测试 + 前端 Store 测试) |
| T05 | P1 | pending | T01 | 新功能 (财务报告推送 + 预算预警) |
## T01 已完成项
- S2: logs.ts SQL 参数化 (LIMIT/OFFSET 改为 ? 占位符)
- S3(部分): TOKEN_SECRET 启动校验 checkTokenSecret()、backup.ts JWT_SECRET→TOKEN_SECRET、Access Token 有效期缩短为 2h
- S4: 导出下载使用 HMAC 签名短期凭证 (/prepare 端点)
## T01 剩余项
- S1: .env 凭据泄露修复 (轮换凭据 + git-filter-repo + pre-commit 钩子)
- S3(完善): TOKEN_SECRET 集中到 config/token.ts移除 auth.ts 内联 fallback
- S5: Refresh Token 双 Token 机制 (refresh_tokens 表 + 前端 401→refresh→重试)
- S6: 软删除 (users.deleted_at + admin 恢复接口 + 全局查询过滤)
- S7: DB 连接移除默认凭据 (connection.ts 移除 || 'localhost' 等 fallback)
- 基础设施: .env.test、jest.config.js、vitest.config.ts
## 并行策略
T01 完成后T02/T03/T04/T05 可并行推进。注意 T02 与 T03 有轻微文件冲突 (stores/),建议 T02 先行或约定合并策略。
## 参考文档
- PRD: `docs/prd-iteration-v2.md`
- 架构设计: `docs/arch-iteration-v2.md`
- 项目规范: `CLAUDE.md` + `DEV.md`

1209
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build -p h5",
"build:mp-weixin": "uni build -p mp-weixin",
"lint": "eslint src --ext .ts,.vue"
"lint": "eslint src --ext .ts,.vue",
"test": "vitest run"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5010120260525001",
@@ -25,12 +26,15 @@
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260525001",
"@dcloudio/uni-stacktracey": "3.0.0-alpha-5010120260525001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260525001",
"@pinia/testing": "^0.1.7",
"@types/node": "^20.11.0",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vue/test-utils": "^2.4.11",
"eslint": "^8.56.0",
"sass": "^1.70.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
"vite": "^5.0.0",
"vitest": "^1.6.1"
}
}

View File

@@ -2,7 +2,9 @@ import { request } from '@/utils/request'
/** 登录响应 */
export interface LoginResult {
token: string
accessToken: string
refreshToken: string
expiresIn: number
userId: number
nickname: string
avatar_url: string
@@ -18,3 +20,12 @@ export function demoLogin() {
export function wxLogin(code: string) {
return request<LoginResult>({ url: '/auth/login', method: 'POST', data: { code } })
}
/** 刷新 Access Token */
export function refreshTokenApi(token: string) {
return request<{ accessToken: string; refreshToken: string; expiresIn: number }>({
url: '/auth/refresh',
method: 'POST',
data: { refreshToken: token }
})
}

View File

@@ -8,20 +8,85 @@ interface RequestOptions {
data?: any
}
let isRedirectingToLogin = false
/** 重登录期间挂起的请求,登录成功后统一重试 */
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
/** 是否正在刷新 token并发控制只允许一次刷新 */
let isRefreshing = false
/** 刷新期间挂起的请求,刷新成功后统一重试 */
let pendingRequests: Array<{
resolve: (v: any) => void
reject: (e: any) => void
options: RequestOptions
}> = []
/** 保存登录结果到本地存储 */
export function saveLoginResult(data: LoginResult) {
uni.setStorageSync('xc:token', data.token)
/** 保存登录结果到本地存储(兼容新旧 token 格式) */
export function saveLoginResult(data: any) {
// 向后兼容:旧版返回 token 字段映射到 accessToken
const accessToken = data.accessToken || data.token || ''
const refreshToken = data.refreshToken || ''
const expiresIn = data.expiresIn || 7200
uni.setStorageSync('xc:token', accessToken)
uni.setStorageSync('xc:refreshToken', refreshToken)
uni.setStorageSync('xc:tokenExpiresIn', expiresIn)
uni.setStorageSync('xc:userId', data.userId)
uni.setStorageSync('xc:nickname', data.nickname)
uni.setStorageSync('xc:avatar_url', data.avatar_url)
if (data.role) uni.setStorageSync('xc:role', data.role)
}
/** 执行重登录 */
/** 清除本地登录信息 */
function clearLoginInfo() {
uni.removeStorageSync('xc:token')
uni.removeStorageSync('xc:refreshToken')
uni.removeStorageSync('xc:tokenExpiresIn')
uni.removeStorageSync('xc:userId')
uni.removeStorageSync('xc:nickname')
uni.removeStorageSync('xc:avatar_url')
uni.removeStorageSync('xc:role')
uni.removeStorageSync('xc:currentGroupId')
}
/** 跳转到登录页 */
function redirectToLogin() {
clearLoginInfo()
// H5 环境刷新页面重新走登录流程
if (typeof window !== 'undefined') {
window.location.reload()
}
}
/** 刷新 token用 refreshToken 换取新的双 token */
async function refreshAccessToken(): Promise<string | null> {
const storedRefreshToken = uni.getStorageSync('xc:refreshToken')
if (!storedRefreshToken) return null
try {
// 直接用 uni.request 发起,避免走 request 函数导致循环
const res: any = await new Promise((resolve, reject) => {
uni.request({
url: API_BASE + '/auth/refresh',
method: 'POST',
data: { refreshToken: storedRefreshToken },
timeout: 10000,
header: { 'Content-Type': 'application/json' },
success: resolve,
fail: reject,
})
})
if (res.statusCode === 200 && res.data?.code === 0) {
const { accessToken, refreshToken: newRefreshToken, expiresIn } = res.data.data
uni.setStorageSync('xc:token', accessToken)
uni.setStorageSync('xc:refreshToken', newRefreshToken)
uni.setStorageSync('xc:tokenExpiresIn', expiresIn || 7200)
return accessToken
}
return null
} catch {
return null
}
}
/** 执行重登录(没有 refreshToken 或刷新失败时的兜底) */
async function reLogin(): Promise<void> {
// H5 环境demo 登录
if (typeof window !== 'undefined') {
@@ -56,37 +121,50 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
const { statusCode, data } = res
if (statusCode === 401) {
uni.removeStorageSync('xc:token')
uni.removeStorageSync('xc:userId')
uni.removeStorageSync('xc:nickname')
uni.removeStorageSync('xc:avatar_url')
uni.removeStorageSync('xc:role')
uni.removeStorageSync('xc:currentGroupId')
const errorCode = data?.code
// 加入重试队列
pendingRetries.push({ resolve, reject, options })
// code 40101 = token 过期 → 尝试用 refreshToken 刷新
if (errorCode === 40101) {
// 加入等待队列
pendingRequests.push({ resolve, reject, options })
if (!isRedirectingToLogin) {
isRedirectingToLogin = true
if (!isRefreshing) {
isRefreshing = true
reLogin()
.then(() => {
// 处理快照时已入队的 + reLogin 期间新入队的请求
const retries = [...pendingRetries]
pendingRetries = []
refreshAccessToken()
.then((newToken) => {
if (newToken) {
// 刷新成功:重试所有等待中的请求
const retries = [...pendingRequests]
pendingRequests = []
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
} else {
// 刷新失败:尝试完整重登录
return reLogin().then(() => {
const retries = [...pendingRequests]
pendingRequests = []
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
})
}
})
.catch(() => {
// 重登录也失败
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
const retries = [...pendingRetries]
pendingRetries = []
const retries = [...pendingRequests]
pendingRequests = []
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
})
.finally(() => { isRedirectingToLogin = false })
.finally(() => { isRefreshing = false })
}
return
}
// code 40100 = token 无效/未登录 → 直接清 token 跳登录
redirectToLogin()
reject({ code: 40100, message: data?.message || '未登录' })
return
}
if (data.code === 0) {
resolve(data.data)
} else {

14
client/vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
test: {
environment: 'jsdom',
globals: true,
},
})

File diff suppressed because it is too large Load Diff

7
server/jest.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
}

3923
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"dev": "tsx watch src/index.ts",
"build": "tsc && shx cp src/db/*.sql dist/db/",
"start": "node dist/index.js",
"db:init": "tsx src/db/init.ts"
"db:init": "tsx src/db/init.ts",
"test": "jest"
},
"dependencies": {
"cors": "^2.8.5",
@@ -21,7 +22,11 @@
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.11.0",
"@types/supertest": "^6.0.3",
"jest": "^29.7.0",
"shx": "^0.4.0",
"supertest": "^6.3.4",
"ts-jest": "^29.4.11",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}

View File

@@ -0,0 +1,22 @@
/**
* Token 安全配置 — 集中管理 TOKEN_SECRET 和启动校验
*
* 所有需要 TOKEN_SECRET 的模块统一从此处导入,
* 避免各文件内联定义导致的不一致风险。
*/
/** 从环境变量读取 TOKEN_SECRET无默认值启动时强制校验 */
export const TOKEN_SECRET = process.env.TOKEN_SECRET || ''
/** 启动时检查 TOKEN_SECRET 安全性,使用默认值则拒绝启动 */
export function checkTokenSecret(): void {
const defaults = [
'xiaocai-token-secret-change-in-production',
'change-me-to-a-secure-random-string',
'',
]
if (defaults.includes(TOKEN_SECRET) || TOKEN_SECRET.length < 32) {
console.error('[Security] FATAL: TOKEN_SECRET is missing, using a default, or shorter than 32 chars. Set a secure TOKEN_SECRET (min 32 chars) in .env before starting the server.')
process.exit(1)
}
}

View File

@@ -1,10 +1,18 @@
import mysql from 'mysql2/promise'
// 必需环境变量校验
const REQUIRED_ENV = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']
const missing = REQUIRED_ENV.filter(key => !process.env[key])
if (missing.length > 0) {
console.error(`[DB] FATAL: Missing required environment variables: ${missing.join(', ')}`)
process.exit(1)
}
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
database: process.env.DB_NAME || 'xiaocai',
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,

View File

@@ -311,16 +311,48 @@ async function runMigrations(conn: mysql.Connection) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// users 表添加软删除字段
const hasDeletedAt = await columnExists(conn, 'users', 'deleted_at')
if (!hasDeletedAt) {
console.log('[DB] Migrating: adding deleted_at to users')
await conn.query("ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)'")
await conn.query('ALTER TABLE users ADD INDEX idx_users_deleted (deleted_at)')
console.log('[DB] users.deleted_at added')
}
// Refresh Token 表
const hasRefreshTokens = await tableExists(conn, 'refresh_tokens')
if (!hasRefreshTokens) {
console.log('[DB] Migrating: creating refresh_tokens table')
await conn.query(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间NULL=有效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
console.log('[DB] refresh_tokens table created')
}
}
export async function initDatabase() {
const dbName = process.env.DB_NAME || 'xiaocai'
const dbName = process.env.DB_NAME!
const dbHost = process.env.DB_HOST!
const dbUser = process.env.DB_USER!
const dbPassword = process.env.DB_PASSWORD!
// 先用不指定数据库的连接,确保数据库存在
const bootstrap = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
host: dbHost,
user: dbUser,
password: dbPassword,
multipleStatements: true,
})
@@ -329,9 +361,9 @@ export async function initDatabase() {
// 用独立连接执行建表和填充(需要 multipleStatements
const initConn = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
host: dbHost,
user: dbUser,
password: dbPassword,
database: dbName,
multipleStatements: true,
})

View File

@@ -14,3 +14,22 @@ ALTER TABLE budgets ADD FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CAS
-- categories 表确认 user_id 存在(旧版可能也没有)
ALTER TABLE categories ADD COLUMN IF NOT EXISTS user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义' AFTER id;
-- v2 迭代迁移
-- users 表:新增软删除字段
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)';
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
-- 新增 refresh_tokens 表
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间NULL=有效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -6,8 +6,10 @@ CREATE TABLE IF NOT EXISTS users (
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_users_deleted (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS categories (
@@ -190,3 +192,15 @@ CREATE TABLE IF NOT EXISTS transaction_tags (
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间NULL=有效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -27,6 +27,7 @@ import recurringRoutes from './routes/recurring'
import tagRoutes from './routes/tag'
import exportRoutes from './routes/export'
import { backupDatabase } from './utils/backup'
import { checkTokenSecret } from './config/token'
// 强制检查 TOKEN_SECRET 安全性
checkTokenSecret()

View File

@@ -1,15 +1,15 @@
import { Request, Response, NextFunction } from 'express'
import { createHmac, timingSafeEqual } from 'crypto'
import { TOKEN_SECRET } from '../config/token'
export interface AuthRequest extends Request {
userId?: number
}
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
const TOKEN_EXPIRY = 2 * 60 * 60 * 1000 // 2 hours
// 不需要认证的路径
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/health']
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/auth/refresh', '/api/health']
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
// 公开路径跳过认证

View File

@@ -5,7 +5,7 @@ import { AuthRequest } from './auth'
/** 管理员权限检查中间件 */
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
try {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const [rows] = await pool.query('SELECT role FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
const user = (rows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限' })

View File

@@ -11,7 +11,7 @@ router.use((req, res, next) => requireAdmin(req, res, next))
/** 数据看板 */
router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
try {
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL')
const [txThisMonth] = await pool.query(
`SELECT COUNT(*) as count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
@@ -71,13 +71,13 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
const pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20))
const offset = (pNum - 1) * pSize
let where = ''
let where = 'WHERE u.deleted_at IS NULL'
const params: any[] = []
if (keyword && typeof keyword === 'string' && keyword.trim()) {
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
where = 'WHERE u.nickname LIKE ?'
where += ' AND u.nickname LIKE ?'
params.push(`%${escapedKeyword}%`)
}
@@ -127,7 +127,7 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
// 如果要降级管理员,检查是否至少保留一个管理员
if (role === 'user') {
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND deleted_at IS NULL AND id != ?",
[req.params.id]
)
if ((adminCount as any[])[0].count === 0) {
@@ -146,7 +146,7 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
}
})
/** 删除用户 */
/** 删除用户(需要二次确认用户名) */
router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
try {
// 不能删除自己
@@ -154,12 +154,27 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '不能删除自己' })
}
// 检查是否是管理员,如果是则确保至少保留一个
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.params.id])
// 需要前端传 confirmName 二次确认
const { confirmName } = req.body
if (!confirmName) {
return res.status(400).json({ code: 40001, message: '需要确认用户名' })
}
const [userRows] = await pool.query('SELECT nickname, role FROM users WHERE id = ? AND deleted_at IS NULL', [req.params.id])
const user = (userRows as any[])[0]
if (user && user.role === 'admin') {
if (!user) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
// 确认用户名匹配
if (user.nickname !== confirmName) {
return res.status(400).json({ code: 40001, message: '确认用户名不匹配' })
}
// 如果是管理员,确保至少保留一个
if (user.role === 'admin') {
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND deleted_at IS NULL AND id != ?",
[req.params.id]
)
if ((adminCount as any[])[0].count === 0) {
@@ -167,10 +182,12 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
}
}
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
// 软删除:设置 deleted_at
await pool.query('UPDATE users SET deleted_at = NOW() WHERE id = ?', [req.params.id])
// 撤销该用户所有有效的 Refresh Token
await pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = ? AND revoked_at IS NULL', [req.params.id])
res.json({ code: 0 })
} catch (err) {
console.error('[Admin] delete user error:', err)
@@ -178,4 +195,18 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
}
})
/** 恢复软删除的用户 */
router.post('/users/:id/restore', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query('UPDATE users SET deleted_at = NULL WHERE id = ?', [req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Admin] restore user error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -1,31 +1,65 @@
import { Router, Request, Response } from 'express'
import { createHmac } from 'crypto'
import { createHmac, randomBytes, createHash } from 'crypto'
import pool from '../db/connection'
import { TOKEN_SECRET } from '../config/token'
const router = Router()
const APPID = process.env.WX_APPID || ''
const SECRET = process.env.WX_SECRET || ''
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
/** 启动时检查 TOKEN_SECRET 是否为默认占位符,是则拒绝启动 */
export function checkTokenSecret(): void {
const defaults = [
'xiaocai-token-secret-change-in-production',
'change-me-to-a-secure-random-string',
]
if (defaults.includes(TOKEN_SECRET)) {
console.error('[Auth] FATAL: TOKEN_SECRET is using a default placeholder. Set a secure TOKEN_SECRET in .env before starting the server.')
process.exit(1)
}
}
/** 生成 HMAC 签名的 Access Token2h 过期) */
export function signToken(userId: number): string {
const payload = `${userId}:${Date.now()}`
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
return Buffer.from(`${payload}:${signature}`).toString('base64')
}
/** 生成随机 Refresh Token64 字符十六进制) */
function generateRefreshToken(): string {
return randomBytes(32).toString('hex')
}
/** 对 token 进行 SHA-256 哈希(存储到数据库前使用) */
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
/** 登录成功后的通用逻辑:生成双 token 并存储 Refresh Token */
async function issueTokens(userId: number): Promise<{
accessToken: string
refreshToken: string
expiresIn: number
}> {
const accessToken = signToken(userId)
const refreshToken = generateRefreshToken()
const tokenHash = hashToken(refreshToken)
// 存入 refresh_tokens 表30 天过期
await pool.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[userId, tokenHash]
)
// 检查该用户有效 refresh_tokens 数量,超过 5 个则撤销最旧的
const [tokenRows] = await pool.query(
'SELECT id FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW() ORDER BY created_at ASC',
[userId]
)
const tokens = tokenRows as any[]
if (tokens.length > 5) {
const revokeIds = tokens.slice(0, tokens.length - 5).map((t: any) => t.id)
if (revokeIds.length > 0) {
await pool.query(
`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id IN (${revokeIds.map(() => '?').join(',')})`,
revokeIds
)
}
}
return { accessToken, refreshToken, expiresIn: 7200 }
}
// H5 演示登录 — 创建/复用演示用户
router.post('/demo-login', async (_req: Request, res: Response) => {
try {
@@ -39,10 +73,14 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
// 首个用户自动设为管理员
await autoSetAdmin(userId)
const token = signToken(userId)
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ? AND deleted_at IS NULL', [userId])
const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
if (!userInfo) {
return res.status(403).json({ code: 40300, message: '该账号已被禁用' })
}
const tokens = await issueTokens(userId)
res.json({ code: 0, data: { ...tokens, userId, nickname: userInfo.nickname || '小菜', avatar_url: userInfo.avatar_url || '', role: userInfo.role || 'user' } })
} catch (err: any) {
console.error('[Auth] Demo login failed:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
@@ -78,20 +116,62 @@ router.post('/login', async (req: Request, res: Response) => {
// 首个用户自动设为管理员
await autoSetAdmin(userId)
// HMAC signed token with expiry
const token = signToken(userId)
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
// 检查用户是否已被软删除
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ? AND deleted_at IS NULL', [userId])
const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
if (!userInfo) {
return res.status(403).json({ code: 40300, message: '该账号已被禁用' })
}
const tokens = await issueTokens(userId)
res.json({ code: 0, data: { ...tokens, userId, nickname: userInfo.nickname || '小菜', avatar_url: userInfo.avatar_url || '', role: userInfo.role || 'user' } })
} catch (err: any) {
console.error('[Auth] Login failed:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 刷新 Access Token */
router.post('/refresh', async (req: Request, res: Response) => {
try {
const { refreshToken } = req.body
if (!refreshToken) {
return res.status(400).json({ code: 40001, message: '缺少refreshToken' })
}
const tokenHash = hashToken(refreshToken)
// 查找有效记录
const [rows] = await pool.query(
'SELECT id, user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW()',
[tokenHash]
)
if (!(rows as any[]).length) {
return res.status(401).json({ code: 40100, message: 'refreshToken无效或已过期' })
}
const record = (rows as any[])[0]
// 撤销旧 token
await pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = ?', [record.id])
// 生成新的双 token
const newAccessToken = signToken(record.user_id)
const newRefreshToken = generateRefreshToken()
const newHash = hashToken(newRefreshToken)
await pool.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[record.user_id, newHash]
)
res.json({ code: 0, data: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200 } })
} catch (err: any) {
console.error('[Auth] Refresh token failed:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 首个用户自动设为管理员 */
async function autoSetAdmin(userId: number) {
const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin'])
const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ? AND deleted_at IS NULL', ['admin'])
if ((adminCount as any[])[0].count === 0) {
await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId])
console.log(`[Auth] User ${userId} auto-promoted to admin`)

View File

@@ -3,12 +3,12 @@ import { AuthRequest } from '../middleware/auth'
import { backupDatabase, getBackupList } from '../utils/backup'
import { requireAdmin } from '../middleware/requireAdmin'
import { createHmac } from 'crypto'
import { TOKEN_SECRET } from '../config/token'
import fs from 'fs'
import path from 'path'
const router = Router()
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
router.use((req, res, next) => {

View File

@@ -7,6 +7,7 @@ import fs from 'fs'
import os from 'os'
import path from 'path'
import { createHmac } from 'crypto'
import { TOKEN_SECRET } from '../config/token'
const router = Router()
@@ -15,7 +16,6 @@ const VALID_FORMATS = ['csv', 'json']
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
const LARGE_DATA_THRESHOLD = 10000
const EXPORT_TIMEOUT = 60000
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
const DOWNLOAD_TOKEN_EXPIRY = 10 * 60 * 1000 // 10 minutes
/** 生成短期下载签名 token */

View File

@@ -86,7 +86,7 @@ router.get('/', requireAdmin, async (req, res) => {
const [rows] = await pool.execute(
`SELECT f.*, u.nickname, u.avatar_url
FROM feedbacks f
LEFT JOIN users u ON f.user_id = u.id
LEFT JOIN users u ON f.user_id = u.id AND u.deleted_at IS NULL
WHERE ${where}
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?`,

View File

@@ -246,7 +246,7 @@ router.get('/:id/members', async (req: AuthRequest, res: Response) => {
`SELECT gm.user_id, gm.role, gm.created_at as joined_at,
u.nickname, u.avatar_url
FROM group_members gm
LEFT JOIN users u ON gm.user_id = u.id
LEFT JOIN users u ON gm.user_id = u.id AND u.deleted_at IS NULL
WHERE gm.group_id = ?
ORDER BY gm.role DESC, gm.created_at ASC`,
[groupId]

View File

@@ -160,7 +160,7 @@ router.get('/track/list', requireAdmin, async (req, res) => {
const [rows] = await pool.execute(
`SELECT t.*, u.nickname
FROM track_events t
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL
WHERE ${where}
ORDER BY t.created_at DESC
LIMIT ? OFFSET ?`,

View File

@@ -57,7 +57,7 @@ async function requireNotificationEditAuth(req: AuthRequest, res: Response, next
}
// 检查是否是管理员
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
const user = (userRows as any[])[0]
if (user && user.role === 'admin') {
return next()
@@ -313,7 +313,7 @@ router.post('/', async (req: AuthRequest, res: Response) => {
// 系统公告需要管理员权限
if (type === 'system') {
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
const user = (userRows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '仅管理员可发布系统公告' })

View File

@@ -22,7 +22,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
u.nickname as creator_nickname, u.avatar_url as creator_avatar
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL
WHERE t.id = ? AND (
t.user_id = ?
OR t.user_id IN (SELECT user_id FROM group_members WHERE group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))
@@ -135,7 +135,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
u.nickname as creator_nickname, u.avatar_url as creator_avatar
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL
${tagJoin}
${where}
ORDER BY ${orderBy}

View File

@@ -48,7 +48,7 @@ const upload = multer({
router.get('/me', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ?',
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ? AND deleted_at IS NULL',
[req.userId]
)
const user = (rows as any[])[0]
@@ -123,7 +123,7 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
}
try {
// 先查询旧头像
const [rows] = await pool.query('SELECT avatar_url FROM users WHERE id = ?', [req.userId])
const [rows] = await pool.query('SELECT avatar_url FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
const oldUrl = (rows as any[])[0]?.avatar_url
// 先更新 DB再删旧文件保证 DB 和文件至少有一个正确)

File diff suppressed because it is too large Load Diff