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 # Environment files
.env .env
.env.local .env.local
.env.test
.env.*.local .env.*.local
**/.env **/.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", "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"
} }
} }

View File

@@ -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 }
})
}

View File

@@ -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
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", "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"
} }

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' 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,

View File

@@ -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,
}) })

View File

@@ -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;

View File

@@ -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;

View File

@@ -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()

View File

@@ -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) {
// 公开路径跳过认证 // 公开路径跳过认证

View File

@@ -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: '需要管理员权限' })

View File

@@ -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

View File

@@ -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 Token2h 过期) */
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 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 演示登录 — 创建/复用演示用户 // 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`)

View File

@@ -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) => {

View File

@@ -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 */

View File

@@ -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 ?`,

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, `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]

View File

@@ -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 ?`,

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] 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: '仅管理员可发布系统公告' })

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 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}

View File

@@ -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 和文件至少有一个正确)

File diff suppressed because it is too large Load Diff