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

1209
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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