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
|
||||
.env
|
||||
.env.local
|
||||
.env.test
|
||||
.env.*.local
|
||||
**/.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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
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",
|
||||
"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"
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
// 必需环境变量校验
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
// 公开路径跳过认证
|
||||
|
||||
@@ -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: '需要管理员权限' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Token(2h 过期) */
|
||||
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 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 演示登录 — 创建/复用演示用户
|
||||
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`)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 ?`,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 ?`,
|
||||
|
||||
@@ -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: '仅管理员可发布系统公告' })
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 和文件至少有一个正确)
|
||||
|
||||
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