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:
7
.githooks/pre-commit
Normal file
7
.githooks/pre-commit
Normal 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
1
.gitignore
vendored
@@ -8,6 +8,7 @@ server/dist/
|
|||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.test
|
||||||
.env.*.local
|
.env.*.local
|
||||||
**/.env
|
**/.env
|
||||||
|
|
||||||
|
|||||||
42
CODEBUDDY.md
Normal file
42
CODEBUDDY.md
Normal 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
1209
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
|||||||
"dev:mp-weixin": "uni -p mp-weixin",
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
"build:h5": "uni build -p h5",
|
"build:h5": "uni build -p h5",
|
||||||
"build:mp-weixin": "uni build -p mp-weixin",
|
"build:mp-weixin": "uni build -p mp-weixin",
|
||||||
"lint": "eslint src --ext .ts,.vue"
|
"lint": "eslint src --ext .ts,.vue",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dcloudio/uni-app": "3.0.0-alpha-5010120260525001",
|
"@dcloudio/uni-app": "3.0.0-alpha-5010120260525001",
|
||||||
@@ -25,12 +26,15 @@
|
|||||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260525001",
|
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260525001",
|
||||||
"@dcloudio/uni-stacktracey": "3.0.0-alpha-5010120260525001",
|
"@dcloudio/uni-stacktracey": "3.0.0-alpha-5010120260525001",
|
||||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260525001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260525001",
|
||||||
|
"@pinia/testing": "^0.1.7",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"sass": "^1.70.0",
|
"sass": "^1.70.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { request } from '@/utils/request'
|
|||||||
|
|
||||||
/** 登录响应 */
|
/** 登录响应 */
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
token: string
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
expiresIn: number
|
||||||
userId: number
|
userId: number
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
@@ -18,3 +20,12 @@ export function demoLogin() {
|
|||||||
export function wxLogin(code: string) {
|
export function wxLogin(code: string) {
|
||||||
return request<LoginResult>({ url: '/auth/login', method: 'POST', data: { code } })
|
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,20 +8,85 @@ interface RequestOptions {
|
|||||||
data?: any
|
data?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
let isRedirectingToLogin = false
|
/** 是否正在刷新 token(并发控制:只允许一次刷新) */
|
||||||
/** 重登录期间挂起的请求,登录成功后统一重试 */
|
let isRefreshing = false
|
||||||
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
|
/** 刷新期间挂起的请求,刷新成功后统一重试 */
|
||||||
|
let pendingRequests: Array<{
|
||||||
|
resolve: (v: any) => void
|
||||||
|
reject: (e: any) => void
|
||||||
|
options: RequestOptions
|
||||||
|
}> = []
|
||||||
|
|
||||||
/** 保存登录结果到本地存储 */
|
/** 保存登录结果到本地存储(兼容新旧 token 格式) */
|
||||||
export function saveLoginResult(data: LoginResult) {
|
export function saveLoginResult(data: any) {
|
||||||
uni.setStorageSync('xc:token', data.token)
|
// 向后兼容:旧版返回 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:userId', data.userId)
|
||||||
uni.setStorageSync('xc:nickname', data.nickname)
|
uni.setStorageSync('xc:nickname', data.nickname)
|
||||||
uni.setStorageSync('xc:avatar_url', data.avatar_url)
|
uni.setStorageSync('xc:avatar_url', data.avatar_url)
|
||||||
if (data.role) uni.setStorageSync('xc:role', data.role)
|
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> {
|
async function reLogin(): Promise<void> {
|
||||||
// H5 环境:demo 登录
|
// H5 环境:demo 登录
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -56,34 +121,47 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
const { statusCode, data } = res
|
const { statusCode, data } = res
|
||||||
|
|
||||||
if (statusCode === 401) {
|
if (statusCode === 401) {
|
||||||
uni.removeStorageSync('xc:token')
|
const errorCode = data?.code
|
||||||
uni.removeStorageSync('xc:userId')
|
|
||||||
uni.removeStorageSync('xc:nickname')
|
|
||||||
uni.removeStorageSync('xc:avatar_url')
|
|
||||||
uni.removeStorageSync('xc:role')
|
|
||||||
uni.removeStorageSync('xc:currentGroupId')
|
|
||||||
|
|
||||||
// 加入重试队列
|
// code 40101 = token 过期 → 尝试用 refreshToken 刷新
|
||||||
pendingRetries.push({ resolve, reject, options })
|
if (errorCode === 40101) {
|
||||||
|
// 加入等待队列
|
||||||
|
pendingRequests.push({ resolve, reject, options })
|
||||||
|
|
||||||
if (!isRedirectingToLogin) {
|
if (!isRefreshing) {
|
||||||
isRedirectingToLogin = true
|
isRefreshing = true
|
||||||
|
|
||||||
reLogin()
|
refreshAccessToken()
|
||||||
.then(() => {
|
.then((newToken) => {
|
||||||
// 处理快照时已入队的 + reLogin 期间新入队的请求
|
if (newToken) {
|
||||||
const retries = [...pendingRetries]
|
// 刷新成功:重试所有等待中的请求
|
||||||
pendingRetries = []
|
const retries = [...pendingRequests]
|
||||||
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
pendingRequests = []
|
||||||
})
|
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||||
.catch(() => {
|
} else {
|
||||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
// 刷新失败:尝试完整重登录
|
||||||
const retries = [...pendingRetries]
|
return reLogin().then(() => {
|
||||||
pendingRetries = []
|
const retries = [...pendingRequests]
|
||||||
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
pendingRequests = []
|
||||||
})
|
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||||
.finally(() => { isRedirectingToLogin = false })
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 重登录也失败
|
||||||
|
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||||
|
const retries = [...pendingRequests]
|
||||||
|
pendingRequests = []
|
||||||
|
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
||||||
|
})
|
||||||
|
.finally(() => { isRefreshing = false })
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// code 40100 = token 无效/未登录 → 直接清 token 跳登录
|
||||||
|
redirectToLogin()
|
||||||
|
reject({ code: 40100, message: data?.message || '未登录' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
client/vitest.config.ts
Normal file
14
client/vitest.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
581
client/yarn.lock
581
client/yarn.lock
File diff suppressed because it is too large
Load Diff
7
server/jest.config.js
Normal file
7
server/jest.config.js
Normal 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
3923
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc && shx cp src/db/*.sql dist/db/",
|
"build": "tsc && shx cp src/db/*.sql dist/db/",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:init": "tsx src/db/init.ts"
|
"db:init": "tsx src/db/init.ts",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -21,7 +22,11 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
|
"jest": "^29.7.0",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
|
"supertest": "^6.3.4",
|
||||||
|
"ts-jest": "^29.4.11",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
22
server/src/config/token.ts
Normal file
22
server/src/config/token.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import mysql from 'mysql2/promise'
|
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({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST || 'localhost',
|
host: process.env.DB_HOST!,
|
||||||
user: process.env.DB_USER || 'xiaocai',
|
user: process.env.DB_USER!,
|
||||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
password: process.env.DB_PASSWORD!,
|
||||||
database: process.env.DB_NAME || 'xiaocai',
|
database: process.env.DB_NAME!,
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0,
|
queueLimit: 0,
|
||||||
|
|||||||
@@ -311,16 +311,48 @@ async function runMigrations(conn: mysql.Connection) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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() {
|
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({
|
const bootstrap = await mysql.createConnection({
|
||||||
host: process.env.DB_HOST || 'localhost',
|
host: dbHost,
|
||||||
user: process.env.DB_USER || 'xiaocai',
|
user: dbUser,
|
||||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
password: dbPassword,
|
||||||
multipleStatements: true,
|
multipleStatements: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -329,9 +361,9 @@ export async function initDatabase() {
|
|||||||
|
|
||||||
// 用独立连接执行建表和填充(需要 multipleStatements)
|
// 用独立连接执行建表和填充(需要 multipleStatements)
|
||||||
const initConn = await mysql.createConnection({
|
const initConn = await mysql.createConnection({
|
||||||
host: process.env.DB_HOST || 'localhost',
|
host: dbHost,
|
||||||
user: process.env.DB_USER || 'xiaocai',
|
user: dbUser,
|
||||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
password: dbPassword,
|
||||||
database: dbName,
|
database: dbName,
|
||||||
multipleStatements: true,
|
multipleStatements: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,3 +14,22 @@ ALTER TABLE budgets ADD FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CAS
|
|||||||
|
|
||||||
-- categories 表确认 user_id 存在(旧版可能也没有)
|
-- categories 表确认 user_id 存在(旧版可能也没有)
|
||||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义' AFTER 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;
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||||
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
|
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
|
||||||
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
||||||
|
deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS categories (
|
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 (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import recurringRoutes from './routes/recurring'
|
|||||||
import tagRoutes from './routes/tag'
|
import tagRoutes from './routes/tag'
|
||||||
import exportRoutes from './routes/export'
|
import exportRoutes from './routes/export'
|
||||||
import { backupDatabase } from './utils/backup'
|
import { backupDatabase } from './utils/backup'
|
||||||
|
import { checkTokenSecret } from './config/token'
|
||||||
|
|
||||||
// 强制检查 TOKEN_SECRET 安全性
|
// 强制检查 TOKEN_SECRET 安全性
|
||||||
checkTokenSecret()
|
checkTokenSecret()
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
import { createHmac, timingSafeEqual } from 'crypto'
|
import { createHmac, timingSafeEqual } from 'crypto'
|
||||||
|
import { TOKEN_SECRET } from '../config/token'
|
||||||
|
|
||||||
export interface AuthRequest extends Request {
|
export interface AuthRequest extends Request {
|
||||||
userId?: number
|
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 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) {
|
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
// 公开路径跳过认证
|
// 公开路径跳过认证
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AuthRequest } from './auth'
|
|||||||
/** 管理员权限检查中间件 */
|
/** 管理员权限检查中间件 */
|
||||||
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
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]
|
const user = (rows as any[])[0]
|
||||||
if (!user || user.role !== 'admin') {
|
if (!user || user.role !== 'admin') {
|
||||||
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
|
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ router.use((req, res, next) => requireAdmin(req, res, next))
|
|||||||
/** 数据看板 */
|
/** 数据看板 */
|
||||||
router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
||||||
try {
|
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(
|
const [txThisMonth] = await pool.query(
|
||||||
`SELECT COUNT(*) as count,
|
`SELECT COUNT(*) as count,
|
||||||
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
|
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 pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20))
|
||||||
const offset = (pNum - 1) * pSize
|
const offset = (pNum - 1) * pSize
|
||||||
|
|
||||||
let where = ''
|
let where = 'WHERE u.deleted_at IS NULL'
|
||||||
const params: any[] = []
|
const params: any[] = []
|
||||||
|
|
||||||
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||||
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
|
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
|
||||||
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
|
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
|
||||||
where = 'WHERE u.nickname LIKE ?'
|
where += ' AND u.nickname LIKE ?'
|
||||||
params.push(`%${escapedKeyword}%`)
|
params.push(`%${escapedKeyword}%`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
|
|||||||
// 如果要降级管理员,检查是否至少保留一个管理员
|
// 如果要降级管理员,检查是否至少保留一个管理员
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
const [adminCount] = await pool.query(
|
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]
|
[req.params.id]
|
||||||
)
|
)
|
||||||
if ((adminCount as any[])[0].count === 0) {
|
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) => {
|
router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// 不能删除自己
|
// 不能删除自己
|
||||||
@@ -154,12 +154,27 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ code: 40001, message: '不能删除自己' })
|
return res.status(400).json({ code: 40001, message: '不能删除自己' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是管理员,如果是则确保至少保留一个
|
// 需要前端传 confirmName 二次确认
|
||||||
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.params.id])
|
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]
|
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(
|
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]
|
[req.params.id]
|
||||||
)
|
)
|
||||||
if ((adminCount as any[])[0].count === 0) {
|
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])
|
// 软删除:设置 deleted_at
|
||||||
if ((result as any).affectedRows === 0) {
|
await pool.query('UPDATE users SET deleted_at = NOW() WHERE id = ?', [req.params.id])
|
||||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
|
||||||
}
|
// 撤销该用户所有有效的 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 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Admin] delete user error:', 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
|
export default router
|
||||||
|
|||||||
@@ -1,31 +1,65 @@
|
|||||||
import { Router, Request, Response } from 'express'
|
import { Router, Request, Response } from 'express'
|
||||||
import { createHmac } from 'crypto'
|
import { createHmac, randomBytes, createHash } from 'crypto'
|
||||||
import pool from '../db/connection'
|
import pool from '../db/connection'
|
||||||
|
import { TOKEN_SECRET } from '../config/token'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
const APPID = process.env.WX_APPID || ''
|
const APPID = process.env.WX_APPID || ''
|
||||||
const SECRET = process.env.WX_SECRET || ''
|
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 Token(2h 过期) */
|
||||||
export function signToken(userId: number): string {
|
export function signToken(userId: number): string {
|
||||||
const payload = `${userId}:${Date.now()}`
|
const payload = `${userId}:${Date.now()}`
|
||||||
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||||
return Buffer.from(`${payload}:${signature}`).toString('base64')
|
return Buffer.from(`${payload}:${signature}`).toString('base64')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 生成随机 Refresh Token(64 字符十六进制) */
|
||||||
|
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 演示登录 — 创建/复用演示用户
|
// H5 演示登录 — 创建/复用演示用户
|
||||||
router.post('/demo-login', async (_req: Request, res: Response) => {
|
router.post('/demo-login', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -39,10 +73,14 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
|
|||||||
// 首个用户自动设为管理员
|
// 首个用户自动设为管理员
|
||||||
await autoSetAdmin(userId)
|
await autoSetAdmin(userId)
|
||||||
|
|
||||||
const token = signToken(userId)
|
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ? AND deleted_at IS NULL', [userId])
|
||||||
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
|
|
||||||
const userInfo = (userRows as any[])[0]
|
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) {
|
} catch (err: any) {
|
||||||
console.error('[Auth] Demo login failed:', err)
|
console.error('[Auth] Demo login failed:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
@@ -78,20 +116,62 @@ router.post('/login', async (req: Request, res: Response) => {
|
|||||||
// 首个用户自动设为管理员
|
// 首个用户自动设为管理员
|
||||||
await autoSetAdmin(userId)
|
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 = ? AND deleted_at IS NULL', [userId])
|
||||||
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
|
|
||||||
const userInfo = (userRows as any[])[0]
|
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) {
|
} catch (err: any) {
|
||||||
console.error('[Auth] Login failed:', err)
|
console.error('[Auth] Login failed:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
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) {
|
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) {
|
if ((adminCount as any[])[0].count === 0) {
|
||||||
await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId])
|
await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId])
|
||||||
console.log(`[Auth] User ${userId} auto-promoted to admin`)
|
console.log(`[Auth] User ${userId} auto-promoted to admin`)
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { AuthRequest } from '../middleware/auth'
|
|||||||
import { backupDatabase, getBackupList } from '../utils/backup'
|
import { backupDatabase, getBackupList } from '../utils/backup'
|
||||||
import { requireAdmin } from '../middleware/requireAdmin'
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
import { createHmac } from 'crypto'
|
import { createHmac } from 'crypto'
|
||||||
|
import { TOKEN_SECRET } from '../config/token'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
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 使用签名校验)
|
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import fs from 'fs'
|
|||||||
import os from 'os'
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { createHmac } from 'crypto'
|
import { createHmac } from 'crypto'
|
||||||
|
import { TOKEN_SECRET } from '../config/token'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -15,7 +16,6 @@ const VALID_FORMATS = ['csv', 'json']
|
|||||||
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||||
const LARGE_DATA_THRESHOLD = 10000
|
const LARGE_DATA_THRESHOLD = 10000
|
||||||
const EXPORT_TIMEOUT = 60000
|
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
|
const DOWNLOAD_TOKEN_EXPIRY = 10 * 60 * 1000 // 10 minutes
|
||||||
|
|
||||||
/** 生成短期下载签名 token */
|
/** 生成短期下载签名 token */
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ router.get('/', requireAdmin, async (req, res) => {
|
|||||||
const [rows] = await pool.execute(
|
const [rows] = await pool.execute(
|
||||||
`SELECT f.*, u.nickname, u.avatar_url
|
`SELECT f.*, u.nickname, u.avatar_url
|
||||||
FROM feedbacks f
|
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}
|
WHERE ${where}
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
|
|||||||
@@ -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,
|
`SELECT gm.user_id, gm.role, gm.created_at as joined_at,
|
||||||
u.nickname, u.avatar_url
|
u.nickname, u.avatar_url
|
||||||
FROM group_members gm
|
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 = ?
|
WHERE gm.group_id = ?
|
||||||
ORDER BY gm.role DESC, gm.created_at ASC`,
|
ORDER BY gm.role DESC, gm.created_at ASC`,
|
||||||
[groupId]
|
[groupId]
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ router.get('/track/list', requireAdmin, async (req, res) => {
|
|||||||
const [rows] = await pool.execute(
|
const [rows] = await pool.execute(
|
||||||
`SELECT t.*, u.nickname
|
`SELECT t.*, u.nickname
|
||||||
FROM track_events t
|
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}
|
WHERE ${where}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
|
|||||||
@@ -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]
|
const user = (userRows as any[])[0]
|
||||||
if (user && user.role === 'admin') {
|
if (user && user.role === 'admin') {
|
||||||
return next()
|
return next()
|
||||||
@@ -313,7 +313,7 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
// 系统公告需要管理员权限
|
// 系统公告需要管理员权限
|
||||||
if (type === 'system') {
|
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]
|
const user = (userRows as any[])[0]
|
||||||
if (!user || user.role !== 'admin') {
|
if (!user || user.role !== 'admin') {
|
||||||
return res.status(403).json({ code: 40300, message: '仅管理员可发布系统公告' })
|
return res.status(403).json({ code: 40300, message: '仅管理员可发布系统公告' })
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN categories c ON t.category_id = c.id
|
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 (
|
WHERE t.id = ? AND (
|
||||||
t.user_id = ?
|
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 = ?))
|
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
|
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN categories c ON t.category_id = c.id
|
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}
|
${tagJoin}
|
||||||
${where}
|
${where}
|
||||||
ORDER BY ${orderBy}
|
ORDER BY ${orderBy}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const upload = multer({
|
|||||||
router.get('/me', async (req: AuthRequest, res: Response) => {
|
router.get('/me', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
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]
|
[req.userId]
|
||||||
)
|
)
|
||||||
const user = (rows as any[])[0]
|
const user = (rows as any[])[0]
|
||||||
@@ -123,7 +123,7 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
try {
|
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
|
const oldUrl = (rows as any[])[0]?.avatar_url
|
||||||
|
|
||||||
// 先更新 DB,再删旧文件(保证 DB 和文件至少有一个正确)
|
// 先更新 DB,再删旧文件(保证 DB 和文件至少有一个正确)
|
||||||
|
|||||||
2215
server/yarn.lock
2215
server/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user