Compare commits

...

5 Commits

Author SHA1 Message Date
b33ebf0879 feat(t03): improve performance and UX
- add category/tag caching and optimistic updates

- add stats dashboard aggregation and batched tracking

- add undo delete snackbar and group read-only view mode

- improve app-ready timeout handling and refresh guards
2026-06-11 16:26:46 +08:00
5a0ae0b28d refactor(iter-v2): T02 code quality - shared components, transactions, utils
- C2: ColorPicker and EditModal reusable components extracted
- C2: tag-manage and category-manage pages use EditModal (remove ~120 dup lines)
- C3: Upload logic extracted to server/src/utils/upload.ts
- C4: /recurring/sync wrapped in DB transaction
- C7: Backup download uses API_BASE (no hardcoded URLs)
- C9: getAvatarUrl() utility function, used in 5 files
- C1: getCurrentMonth() documented as aligned with server date.ts
2026-06-11 09:49:17 +08:00
d170df5c51 fix(iter-v2): T01 QA fixes - race condition, hook regex, deleted user check
- BUG-1: Pre-commit hook allows .env.example (grep -v filter)
- BUG-2: /auth/refresh uses transaction + FOR UPDATE to prevent race condition
- WARN-1: Auth middleware checks deleted_at with 5-min TTL cache
- WARN-2: PUT /admin/users/:id/status filters deleted_at IS NULL
- WARN-3: Install node-cron@^3.0.3
2026-06-11 09:34:27 +08:00
263a7c4616 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
2026-06-11 09:24:10 +08:00
61f9b33f8c feat(iter-v2): T01 partial - TOKEN_SECRET check, SQL param, export credential, docs
- S2: logs.ts LIMIT/OFFSET SQL parameterization (? placeholders)
- S3: checkTokenSecret() startup validation, JWT_SECRET→TOKEN_SECRET in backup.ts
- S3: Access Token expiry shortened to 2h (from 30d)
- S4: Export download uses HMAC signed short-term token (/prepare endpoint)
- S4: Frontend export.ts adapted for prepare-then-download flow
- .env.example reorganized with security notes
- Added PRD and architecture docs for iteration v2
2026-06-11 08:58:24 +08:00
83 changed files with 11721 additions and 730 deletions

10
.githooks/pre-commit Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
# Prevent .env files (except .env.example) from being committed
STAGED_ENV=$(git diff --cached --name-only | grep -E '\.env$|\.env\.' | grep -v '\.env\.example$')
if [ -n "$STAGED_ENV" ]; then
echo "❌ ERROR: Attempting to commit .env file(s). This is blocked for security."
echo " Blocked files:"
echo "$STAGED_ENV"
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

@@ -14,6 +14,6 @@ export function getBudget(month?: string, group_id?: number | null) {
} }
/** 设置预算 */ /** 设置预算 */
export function setBudget(amount: number, month: string) { export function setBudget(amount: number, month: string, group_id?: number | null) {
return request<void>({ url: '/budget', method: 'POST', data: { amount, month } }) return request<void>({ url: '/budget', method: 'POST', data: { amount, month, group_id } })
} }

View File

@@ -9,24 +9,18 @@ export interface ExportParams {
format?: 'csv' | 'json' format?: 'csv' | 'json'
} }
/** 请求服务端导出(返回流) */ /** 准备导出:获取短期签名 token */
export function requestExport(params: ExportParams) { export function prepareExport(params: ExportParams) {
return request<{ downloadUrl: string }>({ return request<{ token: string }>({
url: '/export', url: '/export/prepare',
data: { ...params, format: params.format || 'csv' } data: { ...params, format: params.format || 'csv' }
}) })
} }
/** 获取服务端导出下载 URL */ /** 获取服务端导出下载 URL(使用短期签名 token */
export function getExportUrl(params: ExportParams): string { export async function getExportUrl(params: ExportParams): Promise<string> {
const { token } = await prepareExport(params)
const query = new URLSearchParams() const query = new URLSearchParams()
query.set('startDate', params.startDate) query.set('token', token)
query.set('endDate', params.endDate)
if (params.type) query.set('type', params.type)
if (params.format) query.set('format', params.format)
const token = uni.getStorageSync('xc:token')
if (token) query.set('token', token)
return `${API_BASE}/export?${query.toString()}` return `${API_BASE}/export?${query.toString()}`
} }

View File

@@ -43,6 +43,19 @@ export function getTrend(month?: string, type: string = 'expense', group_id?: nu
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id, period } }) return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id, period } })
} }
/** 聚合统计(一次请求返回 overview + category + trend */
export interface DashboardData {
overview: Overview
category: CategoryStat[]
trend: TrendPoint[]
month: string
}
/** 获取聚合统计数据 */
export function getDashboard(params?: { month?: string; type?: string; period?: 'week' | 'month' | 'year'; group_id?: number | null }) {
return request<DashboardData>({ url: '/stats/dashboard', data: params })
}
/** 智能分类建议结果 */ /** 智能分类建议结果 */
export interface CategorySuggestion { export interface CategorySuggestion {
category: { id: number; name: string; color: string } | null category: { id: number; name: string; color: string } | null

View File

@@ -1,17 +1,17 @@
<template> <template>
<view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]"> <view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]">
<view class="amount-display" @tap="focusAtEnd"> <view class="amount-display" @tap="!disabled && focusAtEnd()">
<text class="currency">¥</text> <text class="currency">¥</text>
<view class="amount-value"> <view class="amount-value">
<template v-if="displayChars.length > 0"> <template v-if="displayChars.length > 0">
<template v-for="(char, index) in displayChars" :key="`${char}-${index}`"> <template v-for="(char, index) in displayChars" :key="`${char}-${index}`">
<text v-if="innerFocused && cursorIndex === index" class="cursor"></text> <text v-if="innerFocused && cursorIndex === index" class="cursor"></text>
<text class="amount-char" @tap.stop="focusAt(index + 1)">{{ char }}</text> <text class="amount-char" @tap.stop="!disabled && focusAt(index + 1)">{{ char }}</text>
</template> </template>
<text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text> <text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text>
</template> </template>
<template v-else> <template v-else>
<text class="placeholder" @tap.stop="focusAtEnd">{{ placeholder }}</text> <text class="placeholder" @tap.stop="!disabled && focusAtEnd()">{{ placeholder }}</text>
<text v-if="innerFocused" class="cursor"></text> <text v-if="innerFocused" class="cursor"></text>
</template> </template>
</view> </view>
@@ -23,7 +23,7 @@
v-model:cursorIndex="cursorIndex" v-model:cursorIndex="cursorIndex"
:max="max" :max="max"
:fixed="fixed" :fixed="fixed"
:visible="innerFocused && visible" :visible="innerFocused && visible && !disabled"
@confirm="emit('confirm')" @confirm="emit('confirm')"
/> />
</view> </view>
@@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
placeholder?: string placeholder?: string
unit?: string unit?: string
disabled?: boolean
}>(), { }>(), {
modelValue: '', modelValue: '',
max: 9999999.99, max: 9999999.99,
@@ -51,7 +52,8 @@ const props = withDefaults(defineProps<{
autoFocus: true, autoFocus: true,
size: 'large', size: 'large',
placeholder: '0', placeholder: '0',
unit: '' unit: '',
disabled: false
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -91,6 +93,7 @@ watch(
) )
function focus() { function focus() {
if (props.disabled || !props.visible) return
cursorIndex.value = innerValue.value.length cursorIndex.value = innerValue.value.length
if (!innerFocused.value) { if (!innerFocused.value) {
innerFocused.value = true innerFocused.value = true
@@ -106,7 +109,7 @@ function blur() {
} }
function focusAt(index: number) { function focusAt(index: number) {
if (!props.visible) return if (props.disabled || !props.visible) return
cursorIndex.value = clampCursorIndex(innerValue.value, index) cursorIndex.value = clampCursorIndex(innerValue.value, index)
if (!innerFocused.value) { if (!innerFocused.value) {
innerFocused.value = true innerFocused.value = true

View File

@@ -0,0 +1,47 @@
<template>
<view class="color-picker">
<view
v-for="c in colors" :key="c"
class="color-dot"
:class="{ selected: modelValue === c }"
:style="{ background: c }"
@tap="$emit('update:modelValue', c)"
/>
</view>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: string
colors?: string[]
}>(), {
colors: () => ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
})
defineEmits<{
'update:modelValue': [color: string]
}>()
</script>
<style lang="scss" scoped>
.color-picker {
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<view class="modal-mask" v-if="visible" @tap="$emit('update:visible', false)">
<view class="modal" @tap.stop>
<text class="modal-title">{{ title }}</text>
<template v-for="field in fields" :key="field.key">
<input
v-if="field.type === 'text'"
class="modal-input"
:value="form[field.key]"
:placeholder="field.placeholder || field.label"
:maxlength="field.maxlength || 50"
@input="form[field.key] = ($event as any).detail.value"
/>
<ColorPicker
v-if="field.type === 'color'"
:modelValue="form[field.key] || colors[0]"
@update:modelValue="form[field.key] = $event"
:colors="colors"
/>
</template>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="$emit('update:visible', false)">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !canConfirm }" @tap="onConfirm">确定</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, computed, watch } from 'vue'
import ColorPicker from '@/components/ColorPicker/ColorPicker.vue'
export interface EditField {
key: string
label: string
type: 'text' | 'color'
placeholder?: string
maxlength?: number
}
const props = withDefaults(defineProps<{
visible: boolean
title: string
fields: EditField[]
modelValue: Record<string, any>
colors?: string[]
}>(), {
colors: () => ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
})
const emit = defineEmits<{
'update:visible': [val: boolean]
'confirm': [data: Record<string, any>]
}>()
const form = reactive<Record<string, any>>({})
// 当 visible 变为 true 时,用 modelValue 初始化 form
watch(() => props.visible, (val) => {
if (val) {
Object.keys(form).forEach(k => delete form[k])
Object.assign(form, { ...props.modelValue })
}
})
const canConfirm = computed(() => {
// 至少有一个 text 字段不为空
return props.fields
.filter(f => f.type === 'text')
.every(f => (form[f.key] || '').toString().trim().length > 0)
})
function onConfirm() {
if (!canConfirm.value) return
// 只返回 fields 中定义的字段
const data: Record<string, any> = {}
props.fields.forEach(f => { data[f.key] = form[f.key] || '' })
emit('confirm', data)
}
</script>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
width: 600rpx;
background: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
display: block;
text-align: center;
margin-bottom: $space-lg;
}
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.modal-btns {
display: flex;
gap: $space-md;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
font-weight: 600;
&.cancel {
background: $bg;
color: $text-sec;
}
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<view class="snackbar" :class="{ show: visible, hide: !visible }">
<text class="snackbar-text">{{ message }}</text>
<view class="snackbar-action" @tap="$emit('undo')" v-if="showUndo">
<text class="snackbar-action-text">撤销</text>
</view>
</view>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
visible: boolean
message: string
showUndo?: boolean
}>(), { showUndo: true })
defineEmits<{ undo: [] }>()
</script>
<style lang="scss" scoped>
.snackbar {
position: fixed;
bottom: calc(120rpx + env(safe-area-inset-bottom));
left: $space-xl;
right: $space-xl;
background: #333;
color: #fff;
border-radius: $radius-md;
padding: $space-md $space-xl;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 999;
transform: translateY(200rpx);
opacity: 0;
transition: all $transition-normal;
&.show {
transform: translateY(0);
opacity: 1;
}
&.hide {
transform: translateY(200rpx);
opacity: 0;
}
}
.snackbar-text {
font-size: $font-lg;
flex: 1;
}
.snackbar-action {
padding: $space-xs $space-md;
border-radius: $radius-xs;
&:active { opacity: 0.7; }
}
.snackbar-action-text {
font-size: $font-lg;
font-weight: 600;
color: $primary;
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<view class="tx-item-wrap" :class="{ 'show-hint': swipeHint && !disableSwipe }"> <view class="tx-item-wrap" :class="{ 'show-hint': swipeHint && !disableSwipe, 'read-only': isOtherCreator }">
<view <view
class="tx-item" class="tx-item"
:style="{ transform: `translateX(${offsetX}rpx)` }" :style="{ transform: `translateX(${offsetX}rpx)` }"
@@ -16,7 +16,11 @@
size="sm" size="sm"
/> />
<view class="info"> <view class="info">
<view class="name-row">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text> <text class="name">{{ item.note || item.category_name || '未分类' }}</text>
<!-- 群组模式下非本人记录显示小锁图标 -->
<text v-if="isOtherCreator" class="lock-icon">🔒</text>
</view>
<view class="meta"> <view class="meta">
<view v-if="isOtherCreator" class="creator-tag"> <view v-if="isOtherCreator" class="creator-tag">
<image v-if="creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-mini-avatar" mode="aspectFill" /> <image v-if="creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-mini-avatar" mode="aspectFill" />
@@ -37,7 +41,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { formatAmount, formatDate } from '@/utils/format' import { formatAmount, formatDate } from '@/utils/format'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
@@ -67,18 +71,13 @@ const props = withDefaults(defineProps<{
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */ /** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
const isOtherCreator = computed(() => { const isOtherCreator = computed(() => {
if (!props.showCreator || !props.item.creator_nickname) return false return !!props.showCreator && !!props.currentUserId && props.item.user_id !== props.currentUserId
return !props.currentUserId || props.item.user_id !== props.currentUserId
}) })
defineEmits(['item-tap', 'delete']) defineEmits(['item-tap', 'delete'])
const creatorAvatarUrl = computed(() => { const creatorAvatarUrl = computed(() => {
if (!props.item.creator_avatar) return '' return getAvatarUrl(props.item.creator_avatar || '')
if (props.item.creator_avatar.startsWith('http') || props.item.creator_avatar.startsWith('blob:')) return props.item.creator_avatar
// 截取服务端根地址(去掉 /api
const base = API_BASE.replace(/\/api$/, '')
return base + '/api/user/avatar/' + props.item.creator_avatar
}) })
const startX = ref(0) const startX = ref(0)
@@ -197,14 +196,29 @@ function onTouchEnd() {
min-width: 0; min-width: 0;
} }
.name-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.lock-icon {
font-size: 24rpx;
flex-shrink: 0;
}
.name { .name {
font-size: 28rpx; font-size: 28rpx;
font-weight: 500; font-weight: 500;
color: $text; color: $text;
display: block;
@include text-ellipsis; @include text-ellipsis;
} }
/* 只读状态:降低整体透明度 */
.read-only .tx-item {
opacity: 0.85;
}
.meta { .meta {
font-size: 24rpx; font-size: 24rpx;
color: $text-sec; color: $text-sec;

View File

@@ -0,0 +1,39 @@
/**
* 页面就绪 composable
* 统一处理 waitForReady 超时 + 数据加载错误状态
*/
import { ref, onMounted } from 'vue'
import { waitForReady } from '@/utils/app-ready'
export function usePageReady(onReady: () => Promise<void>) {
const readyError = ref(false)
const loading = ref(true)
async function init() {
try {
await waitForReady()
} catch {
readyError.value = true
loading.value = false
return
}
try {
await onReady()
} catch (e) {
console.error('[PageReady] loadData error:', e)
} finally {
loading.value = false
}
}
/** 重试(用户点击"重新加载"按钮时调用) */
async function retry() {
readyError.value = false
loading.value = true
await init()
}
onMounted(init)
return { readyError, loading, retry }
}

View File

@@ -12,7 +12,8 @@
"path": "pages/add/index", "path": "pages/add/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "记一笔" "navigationBarTitleText": "记一笔",
"enablePullDownRefresh": false
} }
}, },
{ {
@@ -66,7 +67,8 @@
"path": "pages/profile-edit/index", "path": "pages/profile-edit/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "编辑资料" "navigationBarTitleText": "编辑资料",
"enablePullDownRefresh": false
} }
}, },
{ {
@@ -120,7 +122,8 @@
"path": "pages/feedback/index", "path": "pages/feedback/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "意见反馈" "navigationBarTitleText": "意见反馈",
"enablePullDownRefresh": false
} }
}, },
{ {
@@ -184,7 +187,8 @@
"navigationBarTitleText": "小菜记账", "navigationBarTitleText": "小菜记账",
"navigationBarBackgroundColor": "#FFF8F0", "navigationBarBackgroundColor": "#FFF8F0",
"backgroundColor": "#FFF8F0", "backgroundColor": "#FFF8F0",
"backgroundTextStyle": "dark" "backgroundTextStyle": "dark",
"enablePullDownRefresh": true
}, },
"tabBar": { "tabBar": {
"color": "#BFB3B3", "color": "#BFB3B3",

View File

@@ -6,12 +6,12 @@
<view class="nav-back" @tap="goBack"> <view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" /> <Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view> </view>
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text> <text class="title">{{ viewId ? '查看记录' : editId ? '编辑记录' : '记一笔' }}</text>
<view :style="{ width: capsuleRight + 88 + 'px' }"></view> <view :style="{ width: capsuleRight + 88 + 'px' }"></view>
</view> </view>
</view> </view>
<view class="type-toggle-wrap"> <view class="type-toggle-wrap" v-if="!viewId">
<view class="type-toggle" :class="{ income: type === 'income' }"> <view class="type-toggle" :class="{ income: type === 'income' }">
<view class="slider"></view> <view class="slider"></view>
<view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view> <view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view>
@@ -19,18 +19,26 @@
</view> </view>
</view> </view>
<!-- 只读模式显示收支类型标签 -->
<view class="type-toggle-wrap" v-if="viewId">
<view class="readonly-type-badge" :class="type">
<text>{{ type === 'expense' ? '支出' : '收入' }}</text>
</view>
</view>
<AmountEditor <AmountEditor
ref="amountEditorRef" ref="amountEditorRef"
v-model="amountStr" v-model="amountStr"
:fixed="true" :fixed="true"
size="large" size="large"
:auto-focus="true" :auto-focus="!viewId"
:disabled="!!viewId"
@confirm="save" @confirm="save"
/> />
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="blurAmountEditor"> <scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="!viewId && blurAmountEditor()">
<view class="category-grid"> <view class="category-grid">
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id"> <view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id, 'cat-readonly': !!viewId }" @tap="!viewId && (selectedCat = cat.id)">
<view class="cat-icon-wrap"> <view class="cat-icon-wrap">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" /> <CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge"> <view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
@@ -43,17 +51,21 @@
</scroll-view> </scroll-view>
<view class="extra-fields"> <view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange"> <picker v-if="!viewId" mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<view class="extra-row" @tap="blurAmountEditor"> <view class="extra-row" @tap="blurAmountEditor">
<Icon name="calendar" :size="28" color="#8B7E7E" /> <Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text> <text class="extra-text">{{ dateLabel }}</text>
</view> </view>
</picker> </picker>
<view v-else class="extra-row">
<Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text>
</view>
<view class="extra-row"> <view class="extra-row">
<Icon name="edit" :size="28" color="#8B7E7E" /> <Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" /> <input class="extra-input" v-model="note" :disabled="!!viewId" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="!viewId && blurAmountEditor()" />
</view> </view>
<view class="extra-row" @tap="openTagPicker"> <view class="extra-row" @tap="!viewId && openTagPicker">
<Icon name="tag" :size="28" color="#8B7E7E" /> <Icon name="tag" :size="28" color="#8B7E7E" />
<view class="tag-display"> <view class="tag-display">
<text class="extra-text" v-if="selectedTagIds.length === 0">选择标签选填</text> <text class="extra-text" v-if="selectedTagIds.length === 0">选择标签选填</text>
@@ -70,6 +82,7 @@
<SaveSuccess <SaveSuccess
v-if="!viewId"
v-model:visible="showSuccess" v-model:visible="showSuccess"
:text="editId ? '修改成功' : '记账成功'" :text="editId ? '修改成功' : '记账成功'"
:showContinue="!editId" :showContinue="!editId"
@@ -138,6 +151,7 @@ const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2
const selectedDate = ref(localToday) const selectedDate = ref(localToday)
const saving = ref(false) const saving = ref(false)
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const viewId = ref<number | null>(null)
const showSuccess = ref(false) const showSuccess = ref(false)
const scrollToCat = ref('') const scrollToCat = ref('')
const todayStr = localToday const todayStr = localToday
@@ -178,7 +192,7 @@ watch([amountStr, note], () => {
if (suggestTimer) clearTimeout(suggestTimer) if (suggestTimer) clearTimeout(suggestTimer)
suggestTimer = setTimeout(async () => { suggestTimer = setTimeout(async () => {
const amt = Math.round(parseFloat(amountStr.value || '0') * 100) const amt = Math.round(parseFloat(amountStr.value || '0') * 100)
if (amt <= 0 || editId.value) { if (amt <= 0 || editId.value || viewId.value) {
suggestedCatId.value = null suggestedCatId.value = null
suggestConfidence.value = 0 suggestConfidence.value = 0
return return
@@ -189,7 +203,7 @@ watch([amountStr, note], () => {
suggestedCatId.value = result.category.id suggestedCatId.value = result.category.id
suggestConfidence.value = result.confidence suggestConfidence.value = result.confidence
// 置信度 >= 60 时自动选中 // 置信度 >= 60 时自动选中
if (result.confidence >= 60 && !editId.value) { if (result.confidence >= 60 && !editId.value && !viewId.value) {
selectedCat.value = result.category.id selectedCat.value = result.category.id
} }
} else { } else {
@@ -203,7 +217,13 @@ watch([amountStr, note], () => {
}) })
onMounted(async () => { onMounted(async () => {
try {
await waitForReady() await waitForReady()
} catch {
uni.showToast({ title: '网络异常,请重试', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1200)
return
}
// 加载分类列表,失败时重试一次 // 加载分类列表,失败时重试一次
try { try {
@@ -253,6 +273,29 @@ onMounted(async () => {
setTimeout(() => uni.navigateBack(), 1500) setTimeout(() => uni.navigateBack(), 1500)
} }
} }
// 只读查看模式:群组中非本人记录
if (page?.options?.viewId) {
viewId.value = Number(page.options.viewId)
let existing = txStore.transactions.find(t => t.id === viewId.value)
if (!existing) {
existing = await txStore.fetchTransactionById(viewId.value!)
}
if (existing) {
type.value = existing.type
amountStr.value = (existing.amount / 100).toFixed(2)
selectedCat.value = existing.category_id
lastCatByType[existing.type] = existing.category_id
note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10)
selectedTagIds.value = existing.tags?.map(t => t.id) || []
nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
})
} else {
uni.showToast({ title: '记录不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
}
if (currentCategories.value.length > 0 && !selectedCat.value) { if (currentCategories.value.length > 0 && !selectedCat.value) {
selectedCat.value = currentCategories.value[0].id selectedCat.value = currentCategories.value[0].id
} }
@@ -277,7 +320,7 @@ function blurAmountEditor() {
} }
async function save() { async function save() {
if (saving.value) return if (saving.value || viewId.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100) const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' }) uni.showToast({ title: '请输入金额', icon: 'none' })
@@ -355,6 +398,11 @@ function getTagById(id: number) {
function goBack() { function goBack() {
// 保存成功后直接返回,不弹确认框 // 保存成功后直接返回,不弹确认框
if (showSuccess.value) return if (showSuccess.value) return
// 只读查看模式不应提示放弃编辑
if (viewId.value) {
uni.navigateBack()
return
}
const hasInput = amountStr.value !== '0' || note.value const hasInput = amountStr.value !== '0' || note.value
if (hasInput) { if (hasInput) {
uni.showModal({ uni.showModal({
@@ -633,5 +681,28 @@ function goBack() {
width: 100%; width: 100%;
} }
/* 只读模式样式 */
.readonly-type-badge {
display: inline-flex;
padding: $space-xs $space-lg;
border-radius: $radius-xl;
font-size: $font-lg;
font-weight: 500;
&.expense {
background: rgba(255, 107, 107, 0.1);
color: #FF6B6B;
}
&.income {
background: rgba(123, 198, 126, 0.1);
color: #7BC67E;
}
}
.cat-readonly {
pointer-events: none;
opacity: 0.7;
}
</style> </style>

View File

@@ -69,7 +69,7 @@ const form = reactive({
}) })
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
try { try {
await loadConfig() await loadConfig()
} catch (e: any) { } catch (e: any) {

View File

@@ -120,7 +120,7 @@ import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback' import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback'
import type { Feedback } from '@/api/feedback' import type { Feedback } from '@/api/feedback'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
@@ -153,7 +153,7 @@ const replyText = ref('')
const noMore = computed(() => list.value.length >= total.value && total.value > 0) const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
loadList(true).finally(() => { initialLoaded.value = true }) loadList(true).finally(() => { initialLoaded.value = true })
}) })
@@ -245,12 +245,6 @@ function copyContact(contact: string) {
}) })
} }
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
function formatTime(dateStr: string): string { function formatTime(dateStr: string): string {
const d = new Date(dateStr) const d = new Date(dateStr)
const now = new Date() const now = new Date()

View File

@@ -137,7 +137,7 @@ const dashboard = ref<Dashboard | null>(null)
const health = ref<HealthStatus | null>(null) const health = ref<HealthStatus | null>(null)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
try { try {
dashboard.value = await getDashboard() dashboard.value = await getDashboard()

View File

@@ -125,7 +125,7 @@ function getTodayStr(): string {
} }
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadStats(), loadLogs()]) await Promise.all([loadStats(), loadLogs()])
}) })

View File

@@ -197,7 +197,7 @@ const defaultForm = {
const form = ref({ ...defaultForm }) const form = ref({ ...defaultForm })
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadNotifications(true) await loadNotifications(true)
}) })

View File

@@ -142,7 +142,7 @@ const total = ref(0)
const noMore = computed(() => eventList.value.length >= total.value && total.value > 0) const noMore = computed(() => eventList.value.length >= total.value && total.value > 0)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadStats(), loadEvents()]) await Promise.all([loadStats(), loadEvents()])
}) })

View File

@@ -57,7 +57,7 @@ import { ref, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app' import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import { getUsers, updateUserRole, deleteUser } from '@/api/admin' import { getUsers, updateUserRole, deleteUser } from '@/api/admin'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
@@ -74,7 +74,7 @@ const loading = ref(false)
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadUsers(true) await loadUsers(true)
initialLoaded.value = true initialLoaded.value = true
}) })
@@ -112,12 +112,6 @@ async function loadUsers(reset = false) {
} }
} }
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
const d = new Date(dateStr) const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`

View File

@@ -60,6 +60,7 @@ import type { Backup } from '@/api/backup'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import { API_BASE } from '@/config'
const backups = ref<Backup[]>([]) const backups = ref<Backup[]>([])
const loading = ref(true) const loading = ref(true)
@@ -67,7 +68,7 @@ const creating = ref(false)
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadBackups() await loadBackups()
initialLoaded.value = true initialLoaded.value = true
}) })
@@ -121,7 +122,7 @@ function onDownload(item: Backup) {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
uni.downloadFile({ uni.downloadFile({
url: `https://xiaocai.j35.site/api/backup/${item.id}/download?token=${encodeURIComponent(item.downloadToken)}`, url: `${API_BASE}/backup/${item.id}/download?token=${encodeURIComponent(item.downloadToken)}`,
success: (res) => { success: (res) => {
if (res.statusCode === 200) { if (res.statusCode === 200) {
uni.openDocument({ uni.openDocument({

View File

@@ -104,6 +104,12 @@
<Icon name="edit" :size="48" color="#BFB3B3" /> <Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">暂无账单记录</text> <text class="state-text">暂无账单记录</text>
</view> </view>
<Snackbar
:visible="snackbarVisible"
:message="snackbarMsg"
@undo="onUndoDelete"
/>
</view> </view>
</template> </template>
@@ -113,11 +119,13 @@ import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useTagStore } from '@/stores/tag' import { useTagStore } from '@/stores/tag'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { getTransactions, deleteTransaction } from '@/api/transaction' import { getTransactions } from '@/api/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue' import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue' import Skeleton from '@/components/Skeleton/Skeleton.vue'
import Snackbar from '@/components/Snackbar/Snackbar.vue'
import FilterPanel from './filter-panel.vue' import FilterPanel from './filter-panel.vue'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format' import { formatAmount, formatDate } from '@/utils/format'
@@ -127,6 +135,7 @@ import type { FilterParams } from '@/api/filter'
const userStore = useUserStore() const userStore = useUserStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const tagStore = useTagStore() const tagStore = useTagStore()
const txStore = useTransactionStore()
const loading = ref(false) const loading = ref(false)
const filterType = ref('all') const filterType = ref('all')
const filterTagId = ref<number | null>(null) const filterTagId = ref<number | null>(null)
@@ -137,6 +146,10 @@ const total = ref(0)
const loadError = ref(false) const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖 let loadSeq = 0 // 请求序号,防止并发覆盖
// Snackbar 删除撤销相关
const snackbarVisible = ref(false)
const snackbarMsg = ref('')
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复 // 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
const swipeHintDismissed = ref(false) const swipeHintDismissed = ref(false)
const HINT_KEY = 'xiaocai_swipe_hint_shown' const HINT_KEY = 'xiaocai_swipe_hint_shown'
@@ -197,7 +210,13 @@ const groupedTx = computed(() => {
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
try {
await waitForReady() await waitForReady()
} catch {
// waitForReady 超时:显示网络异常提示
loadError.value = true
return
}
tagStore.fetchTags().catch(() => {}) tagStore.fetchTags().catch(() => {})
loadTx(true).finally(() => { initialLoaded.value = true }) loadTx(true).finally(() => { initialLoaded.value = true })
}) })
@@ -208,6 +227,7 @@ onShow(() => {
}) })
function switchFilter(type: string) { function switchFilter(type: string) {
if (loading.value) return // 防止重复提交
filterType.value = type filterType.value = type
loadTx(true) loadTx(true)
} }
@@ -260,7 +280,8 @@ function onFilterApply(filters: FilterParams) {
function onEdit(item: any) { function onEdit(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) { if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' }) // 非本人记录:进入查看模式(只读)
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
return return
} }
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` }) uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -271,13 +292,8 @@ async function onDelete(item: any) {
uni.showToast({ title: '只能删除自己的记录', icon: 'none' }) uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
return return
} }
uni.showModal({ // 乐观删除:先从列表移除,显示 Snackbar 供撤销
title: '删除记录', txStore.startDelete(item.id)
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}`,
success: async (res) => {
if (res.confirm) {
try {
await deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id) txList.value = txList.value.filter(t => t.id !== item.id)
total.value-- total.value--
// 更新筛选金额 // 更新筛选金额
@@ -286,13 +302,20 @@ async function onDelete(item: any) {
} else { } else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount) filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
} }
uni.showToast({ title: '已删除', icon: 'success' }) // 显示 Snackbar
} catch { snackbarMsg.value = '已删除 1 条记录'
uni.showToast({ title: '删除失败', icon: 'none' }) snackbarVisible.value = true
setTimeout(() => {
snackbarVisible.value = false
}, 3000)
} }
}
} /** 撤销删除 */
}) function onUndoDelete() {
txStore.cancelDelete()
snackbarVisible.value = false
// 重新加载以恢复完整列表
loadTx(true)
} }
// 触底加载更多 // 触底加载更多

View File

@@ -127,7 +127,7 @@ async function fetchMyExpense() {
} }
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
loading.value = true loading.value = true
try { try {
await budgetStore.fetchBudget(currentMonth) await budgetStore.fetchBudget(currentMonth)

View File

@@ -45,46 +45,22 @@
</view> </view>
<!-- 添加弹窗 --> <!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showAddModal"
<text class="modal-title">添加自定义分类</text> title="添加自定义分类"
<input class="modal-input" v-model="newName" placeholder="分类名称" maxlength="10" /> :fields="catFields"
<view class="color-picker"> :modelValue="addModel"
<view @confirm="onAdd"
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: selectedColor === c }"
:style="{ background: c }"
@tap="selectedColor = c"
/> />
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showAddModal = false">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showEditModal"
<text class="modal-title">编辑分类</text> title="编辑分类"
<input class="modal-input" v-model="editName" placeholder="分类名称" maxlength="10" /> :fields="catFields"
<view class="color-picker"> :modelValue="editModel"
<view @confirm="onEditConfirm"
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: editColor === c }"
:style="{ background: c }"
@tap="editColor = c"
/> />
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
</view>
</view>
</view>
<!-- 迁移弹窗 --> <!-- 迁移弹窗 -->
<view class="modal-mask" v-if="showMigrateModal" @tap="showMigrateModal = false"> <view class="modal-mask" v-if="showMigrateModal" @tap="showMigrateModal = false">
@@ -120,14 +96,23 @@ import { waitForReady } from '@/utils/app-ready'
import { getCategoryTransactionCount } from '@/api/category' import { getCategoryTransactionCount } from '@/api/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import EditModal from '@/components/EditModal/EditModal.vue'
import type { EditField } from '@/components/EditModal/EditModal.vue'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore() const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense') const tabType = ref<'expense' | 'income'>('expense')
const showAddModal = ref(false) const showAddModal = ref(false)
const newName = ref('') const showEditModal = ref(false)
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const selectedColor = ref(colorOptions[0]) /** 分类添加/编辑弹窗字段配置 */
const catFields: EditField[] = [
{ key: 'name', label: '分类名称', type: 'text', placeholder: '分类名称', maxlength: 10 },
{ key: 'color', label: '颜色', type: 'color' },
]
const addModel = ref({ name: '', color: '#FF8C69' })
const editModel = ref({ name: '', color: '#FF8C69' })
// 拖拽排序相关 // 拖拽排序相关
let dragStartY = 0 let dragStartY = 0
@@ -135,10 +120,7 @@ let dragCurrentIndex = -1
let dragItemEl: any = null let dragItemEl: any = null
// 编辑相关 // 编辑相关
const showEditModal = ref(false)
const editingCat = ref<Category | null>(null) const editingCat = ref<Category | null>(null)
const editName = ref('')
const editColor = ref('')
// 迁移相关 // 迁移相关
const showMigrateModal = ref(false) const showMigrateModal = ref(false)
@@ -156,18 +138,18 @@ const migrateTargets = computed(() => {
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await catStore.fetchCategories() await catStore.fetchCategories()
initialLoaded.value = true initialLoaded.value = true
}) })
// 返回页面时静默刷新 // 返回页面时静默刷新(使用缓存)
onShow(() => { onShow(() => {
if (initialLoaded.value) catStore.fetchCategories() if (initialLoaded.value) catStore.fetchCategories() // 不强制刷新(使用缓存)
}) })
onPullDownRefresh(async () => { onPullDownRefresh(async () => {
await catStore.fetchCategories() await catStore.fetchCategories(true) // 强制刷新
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
}) })
@@ -175,18 +157,18 @@ function goBack() {
uni.navigateBack() uni.navigateBack()
} }
async function onAdd() { async function onAdd(data: Record<string, any>) {
const name = newName.value.trim() const name = (data.name || '').toString().trim()
if (!name) return if (!name) return
try { try {
await catStore.addCategory({ await catStore.addCategory({
name, name,
icon: name[0], icon: name[0],
color: selectedColor.value, color: data.color,
type: tabType.value type: tabType.value
}) })
newName.value = '' addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' }) uni.showToast({ title: '添加成功', icon: 'success' })
} catch { } catch {
@@ -196,21 +178,20 @@ async function onAdd() {
function onEdit(cat: Category) { function onEdit(cat: Category) {
editingCat.value = cat editingCat.value = cat
editName.value = cat.name editModel.value = { name: cat.name, color: cat.color }
editColor.value = cat.color
showEditModal.value = true showEditModal.value = true
} }
async function onEditConfirm() { async function onEditConfirm(data: Record<string, any>) {
if (!editingCat.value) return if (!editingCat.value) return
const name = editName.value.trim() const name = (data.name || '').toString().trim()
if (!name) { if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' }) uni.showToast({ title: '请输入名称', icon: 'none' })
return return
} }
try { try {
await catStore.updateCategory(editingCat.value.id, { name, color: editColor.value }) await catStore.updateCategory(editingCat.value.id, { name, color: data.color })
showEditModal.value = false showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' }) uni.showToast({ title: '修改成功', icon: 'success' })
} catch { } catch {
@@ -454,27 +435,7 @@ function onDragEnd(e: any) {
&:active { transform: scale(0.95); } &:active { transform: scale(0.95); }
} }
.color-picker { /* 迁移弹窗样式 */
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
.modal-mask { .modal-mask {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
@@ -508,15 +469,6 @@ function onDragEnd(e: any) {
margin-bottom: $space-md; margin-bottom: $space-md;
} }
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.migrate-list { .migrate-list {
max-height: 400rpx; max-height: 400rpx;
margin-bottom: $space-md; margin-bottom: $space-md;

View File

@@ -154,7 +154,7 @@ import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue' import Skeleton from '@/components/Skeleton/Skeleton.vue'
import type { GroupMember, Group } from '@/api/group' import type { GroupMember, Group } from '@/api/group'
@@ -176,7 +176,7 @@ const members = ref<GroupMember[]>([])
const currentGroup = ref<Group | null>(null) const currentGroup = ref<Group | null>(null)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await groupStore.fetchGroups() await groupStore.fetchGroups()
initialLoaded.value = true initialLoaded.value = true
}) })
@@ -220,13 +220,6 @@ function copyInviteCode(code: string) {
}) })
} }
/** 获取头像 URL */
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
async function handleCreate() { async function handleCreate() {
if (saving.value) return if (saving.value) return
const name = newGroupName.value.trim() const name = newGroupName.value.trim()

View File

@@ -108,7 +108,17 @@
<text class="state-text">加载失败下拉刷新重试</text> <text class="state-text">加载失败下拉刷新重试</text>
</view> </view>
<view class="state-box" v-if="!loading && !loadError && recentTx.length === 0"> <!-- 网络异常全屏提示 -->
<view class="network-error" v-if="networkError">
<Icon name="alert-circle" :size="64" color="#BFB3B3" />
<text class="network-error-title">网络连接异常</text>
<text class="network-error-desc">请检查网络后点击重试</text>
<view class="network-error-btn" @tap="retryNetwork">
<text class="network-error-btn-text">重新加载</text>
</view>
</view>
<view class="state-box" v-if="!loading && !loadError && !networkError && recentTx.length === 0">
<Icon name="edit" :size="48" color="#BFB3B3" /> <Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text> <text class="state-text">还没有记录快去记一笔吧</text>
</view> </view>
@@ -170,6 +180,7 @@ const budget = computed(() => budgetStore.budget)
const recentTx = ref<Transaction[]>([]) const recentTx = ref<Transaction[]>([])
const loading = ref(true) const loading = ref(true)
const loadError = ref(false) const loadError = ref(false)
const networkError = ref(false)
const todayExpense = ref(0) const todayExpense = ref(0)
const todayIncome = ref(0) const todayIncome = ref(0)
const todayCount = ref(0) const todayCount = ref(0)
@@ -205,7 +216,16 @@ function openLink(url: string) {
} }
async function loadData(silent = false) { async function loadData(silent = false) {
try {
await waitForReady() await waitForReady()
} catch {
// waitForReady 超时:显示网络异常提示
if (!silent) {
networkError.value = true
loading.value = false
}
return
}
try { try {
if (!silent) loading.value = true if (!silent) loading.value = true
@@ -278,7 +298,8 @@ function goNotifications() {
function onTxTap(item: any) { function onTxTap(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) { if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' }) // 非本人记录:进入查看模式(只读)
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
return return
} }
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` }) uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -287,6 +308,12 @@ function onTxTap(item: any) {
function goBills() { function goBills() {
uni.switchTab({ url: '/pages/bills/index' }) uni.switchTab({ url: '/pages/bills/index' })
} }
/** 网络异常重试 */
function retryNetwork() {
networkError.value = false
loadData()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -619,4 +646,41 @@ function goBills() {
font-weight: 600; font-weight: 600;
color: #FF8C69; color: #FF8C69;
} }
/* 网络异常全屏提示 */
.network-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx $space-xl;
gap: $space-md;
}
.network-error-title {
font-size: $font-2xl;
font-weight: 600;
color: $text;
margin-top: $space-lg;
}
.network-error-desc {
font-size: $font-lg;
color: $text-sec;
}
.network-error-btn {
margin-top: $space-lg;
padding: $space-md $space-2xl;
background: $primary;
border-radius: $radius-xl;
&:active { opacity: 0.8; }
}
.network-error-btn-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
}
</style> </style>

View File

@@ -121,7 +121,7 @@ let loadSeq = 0
const noMore = computed(() => list.value.length >= total.value && total.value > 0) const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadNotifications(true) await loadNotifications(true)
initialLoaded.value = true initialLoaded.value = true
}) })

View File

@@ -60,7 +60,7 @@ const uploading = ref(false)
const saving = ref(false) const saving = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
formNickname.value = userStore.nickname formNickname.value = userStore.nickname
formSlogan.value = userStore.slogan formSlogan.value = userStore.slogan
previewUrl.value = userStore.avatarUrl previewUrl.value = userStore.avatarUrl

View File

@@ -274,7 +274,7 @@ async function updateExportCount() {
} }
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
loading.value = true loading.value = true
try { try {
await Promise.all([ await Promise.all([
@@ -457,7 +457,7 @@ async function doExport() {
exportLoading.value = true exportLoading.value = true
try { try {
const { getExportUrl } = await import('@/api/export') const { getExportUrl } = await import('@/api/export')
const url = getExportUrl({ const url = await getExportUrl({
startDate: exportStartDate.value, startDate: exportStartDate.value,
endDate: exportEndDate.value, endDate: exportEndDate.value,
type: exportType.value, type: exportType.value,

View File

@@ -213,7 +213,7 @@ function getTodayStr(): string {
} }
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadList(), syncAndLoad()]) await Promise.all([loadList(), syncAndLoad()])
}) })

View File

@@ -238,7 +238,7 @@ const monthCompareText = computed(() => {
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
loadData().finally(() => { initialLoaded.value = true }) loadData().finally(() => { initialLoaded.value = true })
}) })
@@ -258,10 +258,9 @@ async function loadData(silent = false) {
try { try {
if (!silent) loading.value = true if (!silent) loading.value = true
loadError.value = false loadError.value = false
// 使用聚合接口一次获取 overview + category + trend再并行请求额外数据
await Promise.all([ await Promise.all([
statsStore.fetchOverview(currentMonth.value), statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
fetchMaxSingle(), fetchMaxSingle(),
fetchPrevMonthData() fetchPrevMonthData()
]) ])
@@ -279,11 +278,8 @@ async function loadData(silent = false) {
async function loadTabData() { async function loadTabData() {
const seq = ++loadSeq const seq = ++loadSeq
try { try {
await Promise.all([ await statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value)
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value), await fetchMaxSingle()
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
fetchMaxSingle()
])
if (seq !== loadSeq) return if (seq !== loadSeq) return
} catch (e) { } catch (e) {
if (seq !== loadSeq) return if (seq !== loadSeq) return

View File

@@ -39,47 +39,23 @@
</view> </view>
<!-- 添加弹窗 --> <!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showAddModal"
<text class="modal-title">添加标签</text> title="添加标签"
<input class="modal-input" v-model="newName" placeholder="标签名称" maxlength="20" /> :fields="addFields"
<view class="color-picker"> :modelValue="addModel"
<view @confirm="onAdd"
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: selectedColor === c }"
:style="{ background: c }"
@tap="selectedColor = c"
/> />
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showAddModal = false">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showEditModal"
<text class="modal-title">编辑标签</text> title="编辑标签"
<input class="modal-input" v-model="editName" placeholder="标签名称" maxlength="20" /> :fields="editFields"
<view class="color-picker"> :modelValue="editModel"
<view @confirm="onEditConfirm"
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: editColor === c }"
:style="{ background: c }"
@tap="editColor = c"
/> />
</view> </view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
</view>
</view>
</view>
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -90,23 +66,29 @@ import type { Tag } from '@/api/tag'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import EditModal from '@/components/EditModal/EditModal.vue'
import type { EditField } from '@/components/EditModal/EditModal.vue'
const tagStore = useTagStore() const tagStore = useTagStore()
const showAddModal = ref(false) const showAddModal = ref(false)
const newName = ref('')
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const selectedColor = ref(colorOptions[0])
const showEditModal = ref(false) const showEditModal = ref(false)
const editingTag = ref<Tag | null>(null) const editingTag = ref<Tag | null>(null)
const editName = ref('')
const editColor = ref('') /** 添加/编辑弹窗字段配置 */
const addFields: EditField[] = [
{ key: 'name', label: '标签名称', type: 'text', placeholder: '标签名称', maxlength: 20 },
{ key: 'color', label: '颜色', type: 'color' },
]
const editFields: EditField[] = addFields
const addModel = ref({ name: '', color: '#FF8C69' })
const editModel = ref({ name: '', color: '#FF8C69' })
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await tagStore.fetchTags() await tagStore.fetchTags()
initialLoaded.value = true initialLoaded.value = true
}) })
@@ -124,13 +106,13 @@ function goBack() {
uni.navigateBack() uni.navigateBack()
} }
async function onAdd() { async function onAdd(data: Record<string, any>) {
const name = newName.value.trim() const name = (data.name || '').toString().trim()
if (!name) return if (!name) return
try { try {
await tagStore.addTag({ name, color: selectedColor.value }) await tagStore.addTag({ name, color: data.color })
newName.value = '' addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' }) uni.showToast({ title: '添加成功', icon: 'success' })
} catch { } catch {
@@ -140,21 +122,20 @@ async function onAdd() {
function onEdit(tag: Tag) { function onEdit(tag: Tag) {
editingTag.value = tag editingTag.value = tag
editName.value = tag.name editModel.value = { name: tag.name, color: tag.color }
editColor.value = tag.color
showEditModal.value = true showEditModal.value = true
} }
async function onEditConfirm() { async function onEditConfirm(data: Record<string, any>) {
if (!editingTag.value) return if (!editingTag.value) return
const name = editName.value.trim() const name = (data.name || '').toString().trim()
if (!name) { if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' }) uni.showToast({ title: '请输入名称', icon: 'none' })
return return
} }
try { try {
await tagStore.updateTag(editingTag.value.id, { name, color: editColor.value }) await tagStore.updateTag(editingTag.value.id, { name, color: data.color })
showEditModal.value = false showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' }) uni.showToast({ title: '修改成功', icon: 'success' })
} catch { } catch {
@@ -283,89 +264,4 @@ async function onDelete(tag: Tag) {
z-index: 50; z-index: 50;
&:active { transform: scale(0.95); } &:active { transform: scale(0.95); }
} }
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
width: 600rpx;
background: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
display: block;
text-align: center;
margin-bottom: $space-lg;
}
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.color-picker {
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
.modal-btns {
display: flex;
gap: $space-md;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
font-weight: 600;
&.cancel {
background: $bg;
color: $text-sec;
}
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style> </style>

View File

@@ -13,7 +13,8 @@ export const useBudgetStore = defineStore('budget', () => {
} }
async function setBudget(amount: number, month?: string) { async function setBudget(amount: number, month?: string) {
await api.setBudget(amount, month || getCurrentMonth()) const groupStore = useGroupStore()
await api.setBudget(amount, month || getCurrentMonth(), groupStore.currentGroupId)
await fetchBudget(month) await fetchBudget(month)
} }

View File

@@ -7,9 +7,20 @@ export type Category = api.Category
export const useCategoryStore = defineStore('category', () => { export const useCategoryStore = defineStore('category', () => {
const categories = ref<api.Category[]>([]) const categories = ref<api.Category[]>([])
/** 上次成功获取的时间戳ms用于 5 分钟缓存判断 */
const lastFetchTime = ref(0)
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟
async function fetchCategories() { async function fetchCategories(force = false) {
if (!force && lastFetchTime.value && Date.now() - lastFetchTime.value < CACHE_TTL) {
return // 缓存有效,跳过
}
try {
categories.value = await api.getCategories() categories.value = await api.getCategories()
lastFetchTime.value = Date.now()
} catch {
// 保留上次数据
}
} }
function getByType(type: 'expense' | 'income') { function getByType(type: 'expense' | 'income') {
@@ -20,31 +31,79 @@ export const useCategoryStore = defineStore('category', () => {
return categories.value.find(c => c.id === id) return categories.value.find(c => c.id === id)
} }
/** 乐观新增 */
async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) { async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) {
const tempId = -Date.now()
const optimistic: api.Category = { id: tempId, ...data, is_custom: 1, sort_order: 0 }
categories.value.push(optimistic)
lastFetchTime.value = 0 // 使缓存失效(新增后下次 onShow 会刷新)
try {
const result = await api.createCategory(data) const result = await api.createCategory(data)
await fetchCategories() // 用真实 ID 替换临时 ID
const idx = categories.value.findIndex(c => c.id === tempId)
if (idx !== -1) categories.value[idx].id = result.id
return result.id return result.id
} catch (err) {
// 回滚
categories.value = categories.value.filter(c => c.id !== tempId)
uni.showToast({ title: '添加失败', icon: 'none' })
throw err
}
} }
/** 乐观编辑 */
async function updateCategory(id: number, data: { name: string; color: string }) { async function updateCategory(id: number, data: { name: string; color: string }) {
const idx = categories.value.findIndex(c => c.id === id)
if (idx === -1) return
const backup = { ...categories.value[idx] }
Object.assign(categories.value[idx], data)
lastFetchTime.value = 0
try {
await api.updateCategory(id, data) await api.updateCategory(id, data)
await fetchCategories() } catch (err) {
// 回滚
categories.value[idx] = backup
uni.showToast({ title: '修改失败', icon: 'none' })
throw err
}
} }
async function deleteCategory(id: number) { async function deleteCategory(id: number) {
// 删除操作等 API 确认(防误删)
await api.deleteCategory(id) await api.deleteCategory(id)
await fetchCategories() categories.value = categories.value.filter(c => c.id !== id)
lastFetchTime.value = 0
} }
async function migrateCategory(fromId: number, toId: number) { async function migrateCategory(fromId: number, toId: number) {
await api.migrateCategory(fromId, toId) await api.migrateCategory(fromId, toId)
await fetchCategories() categories.value = categories.value.filter(c => c.id !== fromId)
lastFetchTime.value = 0
} }
/** 乐观排序 */
async function sortCategories(ids: number[]) { async function sortCategories(ids: number[]) {
const backup = [...categories.value]
// 按新顺序重排
const sorted: api.Category[] = []
ids.forEach(id => {
const cat = categories.value.find(c => c.id === id)
if (cat) sorted.push(cat)
})
// 保留未参与排序的分类(不同 type 的)
categories.value.forEach(c => {
if (!ids.includes(c.id)) sorted.push(c)
})
categories.value = sorted
try {
await api.sortCategories(ids) await api.sortCategories(ids)
await fetchCategories() } catch (err) {
// 回滚
categories.value = backup
uni.showToast({ title: '排序失败', icon: 'none' })
throw err
}
} }
return { categories, fetchCategories, getByType, getById, addCategory, updateCategory, deleteCategory, migrateCategory, sortCategories } return { categories, lastFetchTime, fetchCategories, getByType, getById, addCategory, updateCategory, deleteCategory, migrateCategory, sortCategories }
}) })

View File

@@ -35,14 +35,17 @@ export const useGroupStore = defineStore('group', () => {
await refreshAll() await refreshAll()
} }
/** 切换身份后全局刷新所有数据(串行执行避免数据错乱 */ /** 切换身份后全局刷新所有数据(无依赖关系的可并行 */
async function refreshAll() { async function refreshAll() {
const statsStore = useStatsStore() const statsStore = useStatsStore()
const budgetStore = useBudgetStore() const budgetStore = useBudgetStore()
const txStore = useTransactionStore() const txStore = useTransactionStore()
await statsStore.fetchOverview() // stats、budget、transactions 互相独立,可以并行
await budgetStore.fetchBudget() await Promise.all([
await txStore.fetchTransactions({ page: 1 }) statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 })
])
} }
/** 获取群组列表 */ /** 获取群组列表 */

View File

@@ -6,11 +6,18 @@ import type { Notification } from '@/api/notification'
export const useNotificationStore = defineStore('notification', () => { export const useNotificationStore = defineStore('notification', () => {
const unreadCount = ref(0) const unreadCount = ref(0)
const urgentNotifications = ref<Notification[]>([]) const urgentNotifications = ref<Notification[]>([])
/** 上次获取未读计数的时间戳 */
const lastCountFetchTime = ref(0)
const COUNT_CACHE_TTL = 30 * 1000 // 30 秒
async function fetchUnreadCount() { async function fetchUnreadCount(force = false) {
if (!force && lastCountFetchTime.value && Date.now() - lastCountFetchTime.value < COUNT_CACHE_TTL) {
return
}
try { try {
const data = await api.getUnreadCount() const data = await api.getUnreadCount()
unreadCount.value = data.count unreadCount.value = data.count
lastCountFetchTime.value = Date.now()
} catch { } catch {
// 静默失败,不影响页面 // 静默失败,不影响页面
} }
@@ -46,5 +53,5 @@ export const useNotificationStore = defineStore('notification', () => {
} }
} }
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead } return { unreadCount, urgentNotifications, lastCountFetchTime, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
}) })

View File

@@ -53,5 +53,36 @@ export const useStatsStore = defineStore('stats', () => {
})) : [] })) : []
} }
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend } /** 聚合接口:一次请求返回 overview + category + trend减少 HTTP 往返 */
async function fetchDashboard(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
const data = await api.getDashboard({
month: month || getCurrentMonth(),
type,
period,
group_id: getGroupId()
})
if (data) {
const ov = data.overview || EMPTY_OVERVIEW
overview.value = {
expense: Number(ov.expense) || 0,
income: Number(ov.income) || 0,
expenseCount: Number(ov.expenseCount) || 0,
incomeCount: Number(ov.incomeCount) || 0,
count: Number(ov.count) || 0,
daily: Number(ov.daily) || 0,
dailyIncome: Number(ov.dailyIncome) || 0
}
categoryStats.value = Array.isArray(data.category) ? data.category.map(item => ({
...item,
amount: Number(item.amount) || 0,
count: Number(item.count) || 0
})) : []
trendData.value = Array.isArray(data.trend) ? data.trend.map(item => ({
...item,
amount: Number(item.amount) || 0
})) : []
}
}
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend, fetchDashboard }
}) })

View File

@@ -5,13 +5,20 @@ import * as api from '@/api/tag'
export const useTagStore = defineStore('tag', () => { export const useTagStore = defineStore('tag', () => {
const tags = ref<api.Tag[]>([]) const tags = ref<api.Tag[]>([])
const loading = ref(false) const loading = ref(false)
/** 上次成功获取的时间戳ms用于 5 分钟缓存判断 */
const lastFetchTime = ref(0)
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟
/** 获取标签列表 */ /** 获取标签列表 */
async function fetchTags() { async function fetchTags(force = false) {
if (!force && lastFetchTime.value && Date.now() - lastFetchTime.value < CACHE_TTL) {
return // 缓存有效,跳过
}
loading.value = true loading.value = true
try { try {
const data = await api.getTags() const data = await api.getTags()
tags.value = Array.isArray(data) ? data : [] tags.value = Array.isArray(data) ? data : []
lastFetchTime.value = Date.now()
} catch { } catch {
tags.value = [] tags.value = []
} finally { } finally {
@@ -19,23 +26,48 @@ export const useTagStore = defineStore('tag', () => {
} }
} }
/** 创建标签 */ /** 乐观创建标签 */
async function addTag(data: { name: string; color: string }) { async function addTag(data: { name: string; color: string }) {
const tempId = -Date.now()
const optimistic: api.Tag = { id: tempId, name: data.name, color: data.color, created_at: new Date().toISOString() }
tags.value.push(optimistic)
lastFetchTime.value = 0 // 使缓存失效
try {
const result = await api.createTag(data) const result = await api.createTag(data)
await fetchTags() // 用真实 ID 替换临时 ID
const idx = tags.value.findIndex(t => t.id === tempId)
if (idx !== -1) tags.value[idx].id = result.id
return result.id return result.id
} catch (err) {
// 回滚
tags.value = tags.value.filter(t => t.id !== tempId)
uni.showToast({ title: '添加失败', icon: 'none' })
throw err
}
} }
/** 更新标签 */ /** 乐观更新标签 */
async function updateTag(id: number, data: { name?: string; color?: string }) { async function updateTag(id: number, data: { name?: string; color?: string }) {
const idx = tags.value.findIndex(t => t.id === id)
if (idx === -1) return
const backup = { ...tags.value[idx] }
Object.assign(tags.value[idx], data)
lastFetchTime.value = 0
try {
await api.updateTag(id, data) await api.updateTag(id, data)
await fetchTags() } catch (err) {
// 回滚
tags.value[idx] = backup
uni.showToast({ title: '修改失败', icon: 'none' })
throw err
}
} }
/** 删除标签 */ /** 删除标签 */
async function deleteTag(id: number) { async function deleteTag(id: number) {
await api.deleteTag(id) await api.deleteTag(id)
await fetchTags() tags.value = tags.value.filter(t => t.id !== id)
lastFetchTime.value = 0
} }
/** 根据 ID 获取标签 */ /** 根据 ID 获取标签 */
@@ -48,5 +80,5 @@ export const useTagStore = defineStore('tag', () => {
return tags.value.filter(t => ids.includes(t.id)) return tags.value.filter(t => ids.includes(t.id))
} }
return { tags, loading, fetchTags, addTag, updateTag, deleteTag, getById, getByIds } return { tags, loading, lastFetchTime, fetchTags, addTag, updateTag, deleteTag, getById, getByIds }
}) })

View File

@@ -8,6 +8,9 @@ export const useTransactionStore = defineStore('transaction', () => {
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
// 待确认删除:{ id: 被删除的交易ID, item: 被删除的交易对象, index: 在列表中的位置, timer: 定时器句柄 }
const pendingDelete = ref<{ id: number; item: api.Transaction; index: number; timer: number } | null>(null)
async function fetchTransactions(params?: api.TransactionParams) { async function fetchTransactions(params?: api.TransactionParams) {
loading.value = true loading.value = true
try { try {
@@ -65,5 +68,52 @@ export const useTransactionStore = defineStore('transaction', () => {
} }
} }
return { transactions, total, loading, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById } /** 开始删除乐观先从列表移除3秒内可撤销 */
function startDelete(id: number) {
// 如果有上一条待确认删除,立即确认(不能同时有两条)
if (pendingDelete.value) {
confirmDelete()
}
const idx = transactions.value.findIndex(t => t.id === id)
if (idx === -1) return
const item = transactions.value[idx]
transactions.value.splice(idx, 1)
const timer = setTimeout(() => {
confirmDelete()
}, 3000) as unknown as number
pendingDelete.value = { id, item, index: idx, timer }
}
/** 撤销删除(恢复到原位置) */
function cancelDelete() {
if (!pendingDelete.value) return
const { item, index, timer } = pendingDelete.value
clearTimeout(timer)
// 插回原位置
transactions.value.splice(index, 0, item)
pendingDelete.value = null
}
/** 确认删除(实际调接口) */
async function confirmDelete() {
if (!pendingDelete.value) return
const { id, index, timer } = pendingDelete.value
const item = pendingDelete.value.item
clearTimeout(timer)
pendingDelete.value = null
try {
await api.deleteTransaction(id)
} catch {
// 接口失败,恢复
transactions.value.splice(index, 0, item)
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
return { transactions, total, loading, pendingDelete, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById, startDelete, cancelDelete, confirmDelete }
}) })

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import * as api from '@/api/user' import * as api from '@/api/user'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const userInfo = ref<api.UserInfo | null>(null) const userInfo = ref<api.UserInfo | null>(null)
@@ -16,11 +16,7 @@ export const useUserStore = defineStore('user', () => {
const avatarUrl = computed(() => { const avatarUrl = computed(() => {
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || '' const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
if (!filename) return '' return getAvatarUrl(filename)
// 已是完整 URLhttp/blob直接返回
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
// 拼接API_BASE + /user/avatar/ + 文件名
return `${API_BASE}/user/avatar/${filename}`
}) })
async function fetchUserInfo() { async function fetchUserInfo() {

View File

@@ -6,15 +6,19 @@
*/ */
let _resolve: () => void let _resolve: () => void
const _ready = new Promise<void>(resolve => { _resolve = resolve }) let _reject: (err: Error) => void
const _ready = new Promise<void>((resolve, reject) => {
_resolve = resolve
_reject = reject
})
/** 标记 App 启动完成(登录成功) */ /** 标记 App 启动完成(登录成功) */
export function markReady() { _resolve() } export function markReady() { _resolve() }
/** 等待 App 就绪,最多等 8 秒超时(小程序登录可能较慢) */ /** 等待 App 就绪,最多等 8 秒超时 */
export function waitForReady(): Promise<void> { export function waitForReady(): Promise<void> {
return Promise.race([ return Promise.race([
_ready, _ready,
new Promise<void>(resolve => setTimeout(resolve, 8000)) new Promise<void>((_, reject) => setTimeout(() => reject(new Error('App ready timeout')), 8000))
]) ])
} }

View File

@@ -0,0 +1,8 @@
import { API_BASE } from '@/config'
/** 将头像文件名转为完整 URL */
export function getAvatarUrl(filename: string): string {
if (!filename) return ''
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
return `${API_BASE}/user/avatar/${filename}`
}

View File

@@ -48,7 +48,7 @@ export function formatMonth(date: string): string {
return `${year}${parseInt(month)}` return `${year}${parseInt(month)}`
} }
/** 获取当前月份UTC+8 时区,与服务端保持一致) */ /** 获取当前月份UTC+8 时区,与服务端 date.ts 逻辑一致) */
export function getCurrentMonth(): string { export function getCurrentMonth(): string {
const d = new Date() const d = new Date()
const offset = 8 * 60 * 60 * 1000 const offset = 8 * 60 * 60 * 1000

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,37 +121,50 @@ 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]
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)) retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
}) })
}
})
.catch(() => { .catch(() => {
// 重登录也失败
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' }) uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
const retries = [...pendingRetries] const retries = [...pendingRequests]
pendingRetries = [] pendingRequests = []
retries.forEach(r => r.reject({ code: 40100, message: '未登录' })) retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
}) })
.finally(() => { isRedirectingToLogin = false }) .finally(() => { isRefreshing = false })
} }
return return
} }
// code 40100 = token 无效/未登录 → 直接清 token 跳登录
redirectToLogin()
reject({ code: 40100, message: data?.message || '未登录' })
return
}
if (data.code === 0) { if (data.code === 0) {
resolve(data.data) resolve(data.data)
} else { } else {

View File

@@ -14,8 +14,8 @@ interface TrackEvent {
/** 上报队列(批量上报,减少请求) */ /** 上报队列(批量上报,减少请求) */
let eventQueue: TrackEvent[] = [] let eventQueue: TrackEvent[] = []
let flushTimer: number | null = null let flushTimer: number | null = null
const FLUSH_INTERVAL = 5000 // 5秒上报一次 const FLUSH_INTERVAL = 500 // 500ms 攒批
const MAX_QUEUE_SIZE = 20 const MAX_QUEUE_SIZE = 10 // 10 条立即发送
/** 获取当前页面路径 */ /** 获取当前页面路径 */
function getCurrentPage(): string { function getCurrentPage(): string {

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

919
docs/arch-iteration-v2.md Normal file
View File

@@ -0,0 +1,919 @@
# 小菜记账 — 全量迭代架构设计 v2
> 版本: v2.0 | 日期: 2026-06-10
> 架构师: 高见远Gao
> 基线: PRD v2.035 项改进点6 个维度)
> 技术栈: Uni-app + Vue 3 + Pinia + SCSS (前端) | Node.js + Express + MySQL 8.0 (后端)
---
## 一、实现方案 + 技术选型
### 1.1 安全修复方案
#### S1.env 凭据泄露修复
| 项目 | 方案 |
|------|------|
| 核心思路 | 凭据轮换 + Git 历史重写 + pre-commit 防护 |
| 工具 | `git-filter-repo`Python比 BFG 更安全,支持 blob 回调替换) |
| 防护 | `.githooks/pre-commit` 拦截 `.env` 文件提交 |
| 回滚 | 操作前 `git clone --mirror` 完整备份 |
| 执行顺序 | ① 轮换所有凭据 → ② 重写 Git 历史 → ③ 强制推送 → ④ 验证 .gitignore |
#### S2logs.ts SQL 拼接修复
| 项目 | 方案 |
|------|------|
| 当前问题 | `LIMIT ${pSize} OFFSET ${offset}` 模板字符串直接拼接 |
| 修复方案 | `parseInt` + 范围校验后使用 `pool.query` + `?` 占位符 |
| 关键点 | `pool.query` 支持 `?` 占位符用于整数参数;`pool.execute` 的 prepared statement 对 LIMIT/OFFSET 类型要求严格,故选用 `pool.query` |
#### S3TOKEN_SECRET 弱默认值 + 命名不一致
| 项目 | 方案 |
|------|------|
| 核心思路 | 新增 `server/src/config/token.ts` 集中管理,移除所有 fallback |
| 命名统一 | `backup.ts``JWT_SECRET``TOKEN_SECRET`;所有引用改为 `import { TOKEN_SECRET } from '../config/token'` |
| 启动校验 | `TOKEN_SECRET` 未设置或长度 < 32 时 `process.exit(1)` |
| 密钥生成 | `openssl rand -hex 32` 生成 64 字符强密钥 |
#### S4Export/Backup 短期一次性下载凭证
| 项目 | 方案 |
|------|------|
| 核心思路 | 新增 `download_tokens` 表,生成随机凭证(`crypto.randomBytes(32)` |
| 有效期 | 5 分钟,一次性使用后标记 `used_at` |
| 存储 | MySQL 表(写入频率极低,无需 Redis |
| 清理 | 健康检查时附带清理 `expires_at < NOW()` 的记录 |
| 改动 | `backup.ts``export.ts` 的下载逻辑改为先获取凭证再用凭证下载 |
#### S5Refresh Token 机制
| 项目 | 方案 |
|------|------|
| 架构 | 双 Token 机制Access Token2h+ Refresh Token30d |
| 存储 | `refresh_tokens` 表,仅存储 SHA-256 hash不存明文 |
| 轮换 | 每次刷新后旧 Refresh Token 立即失效(`revoked_at`),返回新 Token |
| 前端适配 | `request.ts` 401 → 尝试 refresh → 成功则重试原请求 → refresh 也失败则清 token 跳登录 |
| 安全 | 修改密码时撤销所有 Refresh Token单用户最多 5 个有效 Refresh Token |
| 优势 | 保持现有 HMAC-SHA256 签名方式不变,仅缩短 Access Token 有效期 |
#### S6Admin 硬删除 → 软删除 + 二次确认
| 项目 | 方案 |
|------|------|
| DB 变更 | `users` 表新增 `deleted_at DATETIME DEFAULT NULL` + 索引 |
| 删除操作 | `UPDATE users SET deleted_at = NOW() WHERE id = ?` |
| 恢复接口 | `POST /api/admin/users/:id/restore``SET deleted_at = NULL` |
| 全局过滤 | 所有涉及 `users` 表的查询增加 `WHERE deleted_at IS NULL` |
| 前端确认 | 管理页点击删除 → 弹窗二次确认 → 调用软删除接口 |
#### S7DB 连接移除默认凭据
| 项目 | 方案 |
|------|------|
| 修复 | 移除所有 `|| 'localhost'``|| 'xiaocai'``|| 'xiaocai123'` fallback |
| 启动校验 | 遍历必需环境变量,缺失任一即 `process.exit(1)` |
### 1.2 通用组件抽取方案
#### ManageList 组件
```
client/src/components/ManageList/ManageList.vue
Props:
- items: T[] // 列表数据
- displayField: string // 显示字段名
- colorField?: string // 颜色字段名(标签用)
- iconField?: string // 图标字段名(分类用)
- showDrag?: boolean // 是否显示拖拽排序
- showEdit?: boolean // 是否显示编辑按钮
- showDelete?: boolean // 是否显示删除按钮
- emptyText?: string // 空状态文案
Emits:
- add()
- edit(item: T)
- delete(item: T)
- sort(ids: number[])
Slots:
- item-icon="{ item }" // 自定义图标区域(分类用 CategoryIcon
- item-badge="{ item }" // 自定义标签区域
```
#### ColorPicker 组件
```
client/src/components/ColorPicker/ColorPicker.vue
Props:
- modelValue: string // 当前选中颜色
- colors?: string[] // 可选颜色列表(默认 8 色)
Emits:
- update:modelValue(color: string)
```
#### EditModal 组件
```
client/src/components/EditModal/EditModal.vue
Props:
- visible: boolean
- title: string
- fields: EditField[] // 动态字段配置
- modelValue: Record<string, any>
Emits:
- update:visible(val: boolean)
- confirm(data: Record<string, any>)
interface EditField {
key: string
label: string
type: 'text' | 'color'
placeholder?: string
maxlength?: number
}
```
### 1.3 乐观更新实现模式
```
模式:先写本地 state再异步调接口失败则回滚
async function addCategory(data) {
// 1. 生成临时 ID负数避免与真实 ID 冲突)
const tempId = -Date.now()
const optimisticItem = { id: tempId, ...data }
// 2. 立即更新本地 state
categories.value.push(optimisticItem)
try {
// 3. 调用 API
const result = await api.createCategory(data)
// 4. 用真实 ID 替换临时 ID
const idx = categories.value.findIndex(c => c.id === tempId)
if (idx !== -1) categories.value[idx].id = result.id
} catch (err) {
// 5. 回滚:移除临时项
categories.value = categories.value.filter(c => c.id !== tempId)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
适用范围:
✅ 新增分类/标签、编辑分类/标签、拖拽排序
❌ 删除操作仍等接口确认(防误删)
```
### 1.4 Refresh Token 实现方案
```
┌──────────┐ POST /auth/login ┌──────────┐
│ Client │ ──────────────────────────>│ Server │
│ │<──────────────────────────│ │
│ │ { accessToken, refreshToken } │
│ │ │ │
│ │ GET /api/xxx │ │
│ │ Header: Bearer <accessToken> │
│ │ ──────────────────────────>│ │
│ │<──────────────────────────│ │
│ │ 200 OK │ │
│ │ │ │
│ │ GET /api/xxx (token 过期) │ │
│ │ ──────────────────────────>│ │
│ │<──────────────────────────│ │
│ │ 401 { code: 40101 } │ │
│ │ │ │
│ │ POST /auth/refresh │ │
│ │ { refreshToken } │ │
│ │ ──────────────────────────>│ │
│ │<──────────────────────────│ │
│ │ { accessToken, refreshToken } │
│ │ │ │
│ │ 重试原请求 │ │
│ │ ──────────────────────────>│ │
└──────────┘ └──────────┘
```
**后端关键实现**
- `auth.ts` 登录成功时:生成 Access Token2h+ Refresh TokenrandomBytes(32)),存 hash 到 `refresh_tokens`
- 新增 `POST /auth/refresh`:验证 Refresh Token hash → 撤销旧 Token → 生成新双 Token
- `middleware/auth.ts`Access Token 过期返回 `40101`(区别于无效 Token 的 `40100`
**前端关键实现**
- `request.ts`:收到 `40101` → 调用 `/auth/refresh` → 成功则重试 → 失败则清 token 跳登录
- 存储:`xc:accessToken` + `xc:refreshToken` 分开存储
- 并发请求时只触发一次 refresh其他请求排队等待
### 1.5 软删除方案
```
影响范围:
- users 表:新增 deleted_at 字段
- 所有 JOIN users 的查询:增加 deleted_at IS NULL 过滤
- admin.tsDELETE → UPDATE SET deleted_at
- 新增恢复接口
- 登录验证:检查 deleted_at IS NULL
具体改动位置:
1. schema.sql / migrate.sqlALTER TABLE
2. auth.ts 登录WHERE openid = ? AND deleted_at IS NULL
3. admin.ts 用户列表WHERE deleted_at IS NULL或增加"已禁用"筛选)
4. transaction.ts 列表查询JOIN users 时增加 u.deleted_at IS NULL
5. 群组统计/成员列表:增加 deleted_at 过滤
```
### 1.6 iconfont 迁移方案
```
当前状态Icon.vue 使用 PNG 图片映射
迁移方案:
1. 在 iconfont.cn 创建项目,上传 SVG 图标
2. 生成字体文件ttf放入 client/src/static/iconfont/
3. 新建 client/src/styles/iconfont.scss@font-face 声明
4. Icon.vue 改为 <text class="iconfont icon-xxx" /> 方式渲染
5. 保留 name prop 接口不变,内部映射改为 class 名
6. 额外图标(如 lock直接在 iconfont 项目中添加
收益:
- 包体积减小(字体 < 20KB vs 多个 PNG
- 支持动态颜色color prop 直接生效)
- 新增图标只需上传 SVG无需切图
```
### 1.7 离线队列方案
```
架构设计:
1. 离线队列管理器client/src/utils/offline.ts
- pendingOps: 存储于 uni.setStorageSync('xc:pendingOps')
- 数据结构: Array<{ id: string, type: 'create', data: object, createdAt: number, status: 'pending' | 'failed' }>
- MVP 仅支持 create新增记账
2. 网络状态监听
- App.vue onLaunch 中注册 uni.onNetworkStatusChange
- 网络恢复时自动调用 processPendingOps()
3. 操作流程
- 有网络:正常调接口
- 无网络:
├── 写入 pendingOps
├── 本地 state 立即更新(乐观更新 + "待同步"标记)
└── Toast "已保存,将在网络恢复后同步"
4. 同步流程
- 按时间顺序逐条执行
- 全部成功:清除队列 + toast "同步完成"
- 部分失败:标记失败项 + 保留在队列 + toast "N 条同步失败"
5. 网络检测
- uni.getNetworkType() 获取当前网络状态
- 封装 isOnline(): boolean 工具函数
```
### 1.8 财务报告推送方案
```
架构设计:
1. 定时任务server/src/services/scheduler.ts
- 使用 node-cron 实现
- 每周一 09:00生成上周收支摘要 → 推送
- 每月 1 号 09:00生成上月收支报告 → 推送
2. 报告生成server/src/services/report.ts
- 查询指定时间范围的收支数据
- 生成摘要文本(总支出/收入、Top 分类、日均消费等)
3. 微信订阅消息server/src/services/wechat-subscribe.ts
- 调用微信 subscribeMessage.send 接口
- 模板 ID 需在微信公众平台申请
- 用户需先授权订阅(一次性授权,每次推送需用户主动触发授权)
4. 降级策略
- 优先站内通知(现有 notifications 系统)
- 微信订阅消息作为增强项
- 如果模板审核不通过,仅保留站内通知
5. 用户设置
- user_settings 表存储推送开关
- 客户端设置页增加"财务报告推送"开关
```
### 1.9 预算预警方案
```
架构设计:
1. 双重检查策略
- 实时检查:记账后立即检查预算(精准、低开销)
- 每日全量扫描:凌晨定时扫描所有用户预算(覆盖周期记账等非手动场景)
2. 预警等级
- 80%站内通知type: 'personal', is_urgent: false
- 100%:站内通知 + 微信订阅消息
- 120%站内紧急通知is_urgent: true+ 微信订阅消息
3. 去重机制
- 同一用户同一月同一等级只推送一次
- budget_alerts 表记录已推送的预警user_id + month + level → UNIQUE
4. 触发点
- transaction.ts POST 创建记账后 → 调用 checkBudgetAlert(userId, month)
- scheduler.ts 每日扫描 → 批量 checkBudgetAlert
```
### 1.10 测试框架选型
| 层级 | 框架 | 理由 |
|------|------|------|
| 后端集成测试 | Jest + supertest | Express 生态标配supertest 无需启动真实服务器;与 ts-jest 配合良好 |
| 前端 Store 测试 | Vitest + @pinia/testing | Vitest 与 Vite 原生集成Uni-app 基于 Vite@pinia/testing 提供 createTestingPinia() |
| 工具函数测试 | Jest后端+ Vitest前端 | 纯函数测试最简单,适合作为测试练手 |
| API 契约测试 | Jest + ajvJSON Schema | 轻量级方案,不需要引入完整的契约测试框架 |
---
## 二、文件列表及相对路径
### 2.1 新建文件
| 文件路径 | 用途 |
|----------|------|
| `server/src/config/token.ts` | TOKEN_SECRET 集中配置与启动校验 |
| `server/src/services/scheduler.ts` | 定时任务调度node-cron |
| `server/src/services/report.ts` | 财务报告生成逻辑 |
| `server/src/services/budget-alert.ts` | 预算预警检查与推送逻辑 |
| `server/src/services/wechat-subscribe.ts` | 微信订阅消息发送封装 |
| `server/src/utils/upload.ts` | 公共上传处理函数C3 抽取) |
| `server/src/routes/report.ts` | 财务报告 API 路由 |
| `server/tests/setup.ts` | 测试数据库初始化与清理 |
| `server/tests/auth.test.ts` | 认证路由集成测试 |
| `server/tests/transaction.test.ts` | 交易路由集成测试 |
| `server/tests/category.test.ts` | 分类路由集成测试 |
| `server/tests/budget.test.ts` | 预算路由集成测试 |
| `server/tests/stats.test.ts` | 统计路由集成测试 |
| `server/jest.config.js` | Jest 配置 |
| `server/.env.test` | 测试数据库环境变量 |
| `client/src/components/ManageList/ManageList.vue` | 通用管理列表组件 |
| `client/src/components/ColorPicker/ColorPicker.vue` | 颜色选择器组件 |
| `client/src/components/EditModal/EditModal.vue` | 通用编辑弹窗组件 |
| `client/src/components/Snackbar/Snackbar.vue` | 删除撤销 Snackbar 组件 |
| `client/src/utils/offline.ts` | 离线队列管理器 |
| `client/src/utils/avatar.ts` | 头像 URL 拼接工具函数C9 |
| `client/src/static/iconfont/` | iconfont 字体文件目录 |
| `client/src/styles/iconfont.scss` | iconfont 样式声明 |
| `client/tests/stores/transaction.test.ts` | 交易 Store 单元测试 |
| `client/tests/stores/category.test.ts` | 分类 Store 单元测试 |
| `client/tests/utils/format.test.ts` | 格式化工具函数测试 |
| `client/tests/utils/app-ready.test.ts` | app-ready 工具函数测试 |
| `client/vitest.config.ts` | Vitest 配置 |
| `.githooks/pre-commit` | 防止 .env 提交的 Git 钩子 |
### 2.2 修改文件
| 文件路径 | 主要变更 |
|----------|----------|
| **后端 - 安全** | |
| `server/src/db/connection.ts` | 移除默认凭据 fallback增加启动校验 |
| `server/src/middleware/auth.ts` | 引用 config/token.tsAccess Token 有效期缩短为 2h区分 40100/40101 错误码 |
| `server/src/routes/auth.ts` | 引用 config/token.ts实现双 Token 机制;新增 POST /refresh 接口 |
| `server/src/routes/backup.ts` | JWT_SECRET → TOKEN_SECRET下载改用 download_tokens 凭证 |
| `server/src/routes/admin.ts` | 硬删除 → 软删除新增恢复接口N+1 优化 |
| `server/src/routes/logs.ts` | SQL 拼接改为参数化查询 |
| `server/src/routes/export.ts` | 下载改用 download_tokens 凭证 |
| **后端 - 代码质量** | |
| `server/src/routes/recurring.ts` | sync 接口增加事务包裹 |
| `server/src/routes/user.ts` | 上传逻辑抽取使用公共函数 |
| `server/src/routes/notification.ts` | 上传逻辑抽取使用公共函数 |
| **后端 - 性能** | |
| `server/src/routes/stats.ts` | 新增 GET /dashboard 聚合接口 |
| `server/src/routes/track.ts` | 逐条 INSERT → 批量 INSERT |
| `server/src/routes/budget.ts` | 支持 group_id 参数;记账后触发预算预警 |
| `server/src/routes/transaction.ts` | 创建记账后触发预算预警检查 |
| **后端 - DB** | |
| `server/src/db/schema.sql` | 新增 download_tokens、refresh_tokens、budget_alerts、user_settings 表users 增加 deleted_at |
| `server/src/db/migrate.sql` | 对应迁移脚本 |
| **后端 - 入口** | |
| `server/src/index.ts` | 移除 TOKEN_SECRET 警告;注册 report 路由;初始化 scheduler |
| **前端 - 代码质量** | |
| `client/src/stores/category.ts` | 乐观更新try/catch缓存标记 lastFetchTime |
| `client/src/stores/tag.ts` | 乐观更新try/catch缓存标记 lastFetchTime |
| `client/src/stores/transaction.ts` | 删除撤销try/catch |
| `client/src/stores/budget.ts` | try/catch支持 group_id |
| `client/src/stores/notification.ts` | 未读计数缓存 30s |
| `client/src/stores/group.ts` | refreshAll 并行加载 |
| `client/src/stores/stats.ts` | 使用 dashboard 聚合接口 |
| `client/src/stores/user.ts` | Refresh Token 适配 |
| `client/src/utils/format.ts` | 去重getCurrentMonth 已在后端存在) |
| `client/src/utils/request.ts` | Refresh Token 重试逻辑 |
| `client/src/utils/app-ready.ts` | 超时 reject非静默 resolve |
| `client/src/utils/tracker.ts` | 攒批发送500ms 或 10 条) |
| `client/src/config.ts` | 已集中化,搜索残留硬编码 URL |
| `client/src/components/Icon/Icon.vue` | PNG → iconfont 字体图标 |
| `client/src/components/TransactionItem/TransactionItem.vue` | 群组只读标记 |
| `client/src/components/ChartWrapper/` | 延迟渲染IntersectionObserver |
| **前端 - 页面** | |
| `client/src/pages/category-manage/index.vue` | 使用 ManageList/ColorPicker/EditModal 组件 |
| `client/src/pages/tag-manage/index.vue` | 使用 ManageList/ColorPicker/EditModal 组件 |
| `client/src/pages/bills/index.vue` | 删除撤销 Snackbar |
| `client/src/pages/budget/index.vue` | 群组预算视图 |
| `client/src/pages/stats/index.vue` | 使用 dashboard 接口 |
| `client/src/pages/add/index.vue` | 离线记账支持 |
| `client/src/pages.json` | 全局 enablePullDownRefresh |
| `client/src/App.vue` | 注册网络状态监听 |
| **配置** | |
| `server/package.json` | 新增 jest、ts-jest、supertest、node-cron 依赖 |
| `client/package.json` | 新增 vitest、@pinia/testing@vue/test-utils 依赖 |
---
## 三、数据结构和接口
### 3.1 新增/修改的数据库表结构
#### 新增表
```sql
-- 一次性下载凭证表S4
CREATE TABLE IF NOT EXISTS download_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE COMMENT '随机凭证',
user_id INT NOT NULL,
resource_type ENUM('backup', 'export') NOT NULL COMMENT '资源类型',
resource_id VARCHAR(100) DEFAULT '' COMMENT '资源标识(备份名等)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
used_at DATETIME DEFAULT NULL COMMENT '使用时间NULL=未使用)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token),
INDEX idx_expires (expires_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Refresh Token 表S5
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;
-- 预算预警记录表F-2去重用
CREATE TABLE IF NOT EXISTS budget_alerts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
month VARCHAR(7) NOT NULL COMMENT '格式2026-05',
level ENUM('80', '100', '120') NOT NULL COMMENT '预警等级',
group_id INT DEFAULT NULL COMMENT '群组IDNULL=个人)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_month_level (user_id, month, level, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户设置表F-1 报告推送开关等)
CREATE TABLE IF NOT EXISTS user_settings (
user_id INT PRIMARY KEY,
report_push_enabled TINYINT(1) DEFAULT 1 COMMENT '是否开启财务报告推送',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
#### 修改表
```sql
-- users 表新增软删除字段S6
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)';
CREATE INDEX idx_users_deleted ON users(deleted_at);
-- budgets 表:新增 group_id 支持UX-4
ALTER TABLE budgets DROP INDEX uk_user_month;
ALTER TABLE budgets ADD COLUMN group_id INT DEFAULT NULL COMMENT '群组IDNULL=个人预算)';
ALTER TABLE budgets ADD UNIQUE KEY uk_user_group_month (user_id, group_id, month);
ALTER TABLE budgets ADD INDEX idx_group_month (group_id, month);
```
### 3.2 新增/修改的 API 端点定义
#### 新增端点
| 方法 | 路径 | 说明 | 请求体 / 参数 | 响应 |
|------|------|------|---------------|------|
| POST | `/api/auth/refresh` | 刷新 Access Token | `{ refreshToken: string }` | `{ code: 0, data: { accessToken, refreshToken, expiresIn } }` |
| GET | `/api/stats/dashboard` | 聚合统计(一次返回 overview + category + trend | `?month=2026-06&type=expense&period=month&group_id=null` | `{ code: 0, data: { overview, category, trend } }` |
| POST | `/api/admin/users/:id/restore` | 恢复软删除用户 | — | `{ code: 0 }` |
| POST | `/api/download-tokens` | 生成下载凭证 | `{ resourceType: 'backup'|'export', resourceId: string }` | `{ code: 0, data: { token, downloadUrl } }` |
| GET | `/api/download/:token` | 使用凭证下载文件 | `:token` 路径参数 | 文件流或 403 |
| GET | `/api/reports/weekly` | 获取上周报告 | — | `{ code: 0, data: { startDate, endDate, expense, income, topCategories, dailyAvg } }` |
| GET | `/api/reports/monthly` | 获取上月报告 | — | `{ code: 0, data: { month, expense, income, topCategories, dailyAvg, monthOverMonth } }` |
| GET | `/api/user/settings` | 获取用户设置 | — | `{ code: 0, data: { reportPushEnabled, ... } }` |
| PUT | `/api/user/settings` | 更新用户设置 | `{ reportPushEnabled?: boolean }` | `{ code: 0 }` |
#### 修改端点
| 方法 | 路径 | 变更说明 |
|------|------|----------|
| POST | `/api/auth/login` | 返回值新增 `refreshToken``expiresIn` 字段 |
| POST | `/api/auth/demo-login` | 同上 |
| DELETE | `/api/admin/users/:id` | 改为软删除(`SET deleted_at = NOW()` |
| GET | `/api/budget` | 支持 `group_id` 参数;群组视图返回 `groupTotal` + `myAmount` |
| POST | `/api/budget` | 支持 `group_id` 参数 |
| POST | `/api/transactions` | 创建后触发预算预警检查 |
| POST | `/api/track` | 支持批量 `events[]` 数组,后端批量 INSERT |
| GET | `/api/backup/:id/download` | 改用 download_tokens 凭证 |
| GET | `/api/export` | 改用 download_tokens 凭证GET 带 token 参数改为先获取凭证再下载) |
---
## 四、程序调用流图
### 4.1 Refresh Token 刷新流程
```mermaid
sequenceDiagram
participant C as Client (request.ts)
participant S as Server (auth.ts)
participant M as Server (auth middleware)
participant DB as MySQL (refresh_tokens)
Note over C,S: 1. 正常登录
C->>S: POST /auth/login { code }
S->>DB: INSERT refresh_tokens (user_id, token_hash, expires_at)
S-->>C: { accessToken(2h), refreshToken(30d), userId }
Note over C,S: 2. API 调用Access Token 有效)
C->>M: GET /api/xxx Authorization: Bearer <accessToken>
M->>M: 验证 HMAC 签名 + 有效期
M-->>C: 200 OK { data }
Note over C,S: 3. Access Token 过期
C->>M: GET /api/xxx Authorization: Bearer <expiredToken>
M->>M: 签名有效但已过期
M-->>C: 401 { code: 40101, message: 'token已过期' }
Note over C,S: 4. 自动刷新
C->>S: POST /auth/refresh { refreshToken }
S->>DB: SELECT WHERE token_hash=SHA256(refreshToken) AND revoked_at IS NULL AND expires_at>NOW()
DB-->>S: 找到记录
S->>DB: UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=?
S->>DB: INSERT refresh_tokens (新 token_hash)
S-->>C: { accessToken, refreshToken, expiresIn }
C->>C: 存储新 Token重试原请求
```
### 4.2 删除撤销流程
```mermaid
sequenceDiagram
participant U as User
participant P as BillsPage
participant S as TransactionStore
participant SN as Snackbar
participant API as Server API
U->>P: 左滑删除记录
P->>S: pendingDelete(id, item, index)
S->>S: 从 transactions 列表移除
S->>SN: 显示 Snackbar "已删除 1 条记录 [撤销]"
SN-->>P: 3秒倒计时
alt 3秒内点击撤销
U->>SN: 点击 [撤销]
SN->>S: cancelDelete(id)
S->>S: 恢复到列表原位置
SN->>SN: 消失
else 3秒超时
SN->>S: confirmDelete(id)
S->>API: DELETE /api/transactions/:id
alt 删除成功
API-->>S: 200 OK
S->>S: 清除 pendingDelete 记录
else 删除失败
API-->>S: 500 Error
S->>S: 恢复到列表原位置
S->>U: toast "删除失败"
end
SN->>SN: 消失
end
```
### 4.3 预算预警触发流程
```mermaid
sequenceDiagram
participant C as Client
participant T as Transaction Route
participant BA as BudgetAlert Service
participant DB as MySQL
participant NS as Notification System
participant WX as WeChat API
C->>T: POST /api/transactions { amount, type, date }
T->>DB: INSERT INTO transactions
T->>BA: checkBudgetAlert(userId, month)
BA->>DB: SELECT budget FROM budgets WHERE user_id=? AND month=?
BA->>DB: SELECT SUM(amount) FROM transactions WHERE user_id=? AND month=? AND type='expense'
alt 支出/预算 >= 80% 且未推送过
BA->>DB: INSERT budget_alerts (level='80')
BA->>NS: 创建站内通知
NS->>DB: INSERT INTO notifications
else 支出/预算 >= 100%
BA->>DB: INSERT budget_alerts (level='100')
BA->>NS: 创建站内通知
BA->>WX: 发送微信订阅消息
else 支出/预算 >= 120%
BA->>DB: INSERT budget_alerts (level='120')
BA->>NS: 创建紧急站内通知 (is_urgent=true)
BA->>WX: 发送微信订阅消息
end
T-->>C: { code: 0, data: { id } }
```
---
## 五、任务列表
> 按批次分组,每批次包含 1 个任务,共 5 个任务
> 优先级P0阻塞上线> P1必须有> P2锦上添花
### T01: 项目基础设施与安全修复
| 字段 | 内容 |
|------|------|
| **任务 ID** | T01 |
| **优先级** | P0 |
| **描述** | 搭建项目基础设施(依赖安装、配置文件、入口文件、数据库迁移),并完成所有 P0/P1 安全修复S1-S7 |
| **依赖** | 无 |
| **涉及文件** | **新建**`server/src/config/token.ts`, `.githooks/pre-commit`, `server/.env.test`, `server/jest.config.js`, `client/vitest.config.ts` |
| | **修改**`server/package.json`, `client/package.json`, `server/src/db/schema.sql`, `server/src/db/migrate.sql`, `server/src/db/connection.ts`, `server/src/middleware/auth.ts`, `server/src/routes/auth.ts`, `server/src/routes/backup.ts`, `server/src/routes/admin.ts`, `server/src/routes/logs.ts`, `server/src/routes/export.ts`, `server/src/index.ts`, `client/src/pages.json` |
**详细子项**
1. **S1 .env 凭据泄露修复**:安装 git-filter-repo、重写历史、添加 pre-commit 钩子
2. **S2 SQL 参数化**`logs.ts:167` LIMIT/OFFSET 改为 `?` 占位符 + parseInt 校验
3. **S3 TOKEN_SECRET 统一**:新建 `config/token.ts`,统一命名 + 强制启动校验 + 生成强密钥
4. **S4 下载凭证**:新建 `download_tokens` 表,改造 backup.ts / export.ts 下载逻辑
5. **S5 Refresh Token**:新建 `refresh_tokens` 表,双 Token 机制Access 2h + Refresh 30d前端 request.ts 401 自动刷新
6. **S6 软删除**`users` 表新增 `deleted_at`admin.ts DELETE → UPDATE新增恢复接口全局查询过滤
7. **S7 DB 连接校验**connection.ts 移除 fallback启动时校验必需环境变量
8. **基础设施**package.json 新增依赖jest, supertest, node-cron, vitest 等jest/vitest 配置,.env.test
### T02: 代码质量重构
| 字段 | 内容 |
|------|------|
| **任务 ID** | T02 |
| **优先级** | P1 |
| **描述** | 抽取通用组件消除重复代码ManageList/ColorPicker/EditModal补齐事务与异常处理统一工具函数与硬编码 URL |
| **依赖** | T01 |
| **涉及文件** | **新建**`client/src/components/ManageList/ManageList.vue`, `client/src/components/ColorPicker/ColorPicker.vue`, `client/src/components/EditModal/EditModal.vue`, `server/src/utils/upload.ts`, `client/src/utils/avatar.ts`, `client/src/static/iconfont/*`, `client/src/styles/iconfont.scss` |
| | **修改**`client/src/pages/category-manage/index.vue`, `client/src/pages/tag-manage/index.vue`, `client/src/components/Icon/Icon.vue`, `server/src/routes/recurring.ts`, `server/src/routes/user.ts`, `server/src/routes/notification.ts`, `client/src/utils/format.ts`, `client/src/config.ts` |
**详细子项**
1. **C2 ManageList 抽取**:新建 ManageList 组件category-manage 和 tag-manage 改为使用该组件,统一行为
2. **C2 ColorPicker 抽取**:从两个管理页面的弹窗中抽取颜色选择器
3. **C2 EditModal 抽取**:从两个管理页面的弹窗中抽取编辑弹窗
4. **C4 Recurring 事务**`recurring.ts /sync` 接口用 `getConnection() + beginTransaction()` 包裹
5. **C3 上传逻辑抽取**`user.ts``notification.ts` 的 multer 配置抽取为 `utils/upload.ts`
6. **C1 工具函数去重**:前端 `format.ts``getCurrentMonth` 与后端 `date.ts` 重复,前端保留并标注来源
7. **C7 硬编码 URL**:全局搜索 `http://``xiaocai.j35.site`,统一从 `config.ts` 读取
8. **C8 Icon → iconfont**生成字体图标Icon.vue 改为字体渲染
9. **C9 头像 URL 拼接**:抽取 `getAvatarUrl(filename)` 工具函数
### T03: 性能优化与 UX 增强
| 字段 | 内容 |
|------|------|
| **任务 ID** | T03 |
| **优先级** | P1 |
| **描述** | 实现乐观更新、并行加载、聚合接口、删除撤销、群组只读、离线记账、下拉刷新等性能与体验提升 |
| **依赖** | T01 |
| **涉及文件** | **新建**`client/src/components/Snackbar/Snackbar.vue`, `client/src/utils/offline.ts` |
| | **修改**`client/src/stores/category.ts`, `client/src/stores/tag.ts`, `client/src/stores/group.ts`, `client/src/stores/stats.ts`, `client/src/stores/notification.ts`, `client/src/stores/transaction.ts`, `client/src/stores/budget.ts`, `client/src/utils/request.ts`, `client/src/utils/app-ready.ts`, `client/src/utils/tracker.ts`, `client/src/components/TransactionItem/TransactionItem.vue`, `client/src/components/ChartWrapper/`, `client/src/pages/bills/index.vue`, `client/src/pages/budget/index.vue`, `client/src/pages/stats/index.vue`, `client/src/pages/add/index.vue`, `client/src/App.vue`, `server/src/routes/stats.ts`, `server/src/routes/track.ts`, `server/src/routes/budget.ts` |
**详细子项**
1. **Perf-1 乐观更新**category/tag Store 新增/编辑/排序操作先更新本地 state 再调接口,失败回滚
2. **Perf-2 并行加载**groupStore `refreshAll()` 中无依赖的 `fetchCategories()` + `fetchTags()` 改为 `Promise.all()`
3. **Perf-3 聚合接口**:后端 `GET /stats/dashboard``Promise.all` 并行查询 overview + category + trend
4. **Perf-4 批量 INSERT**:前端 tracker.ts 攒批500ms/10条后端 track.ts 接收数组批量 INSERT
5. **Perf-5 Admin N+1**admin.ts 用户列表改为 LEFT JOIN 单次查询
6. **Perf-6 前端缓存**category/tag Store 增加 `lastFetchTime`5 分钟内 onShow 跳过请求
7. **Perf-7 未读计数缓存**notification Store 缓存 30s
8. **UX-1 删除撤销**transaction Store 实现 pendingDelete Map + Snackbar 组件 + 3 秒超时
9. **UX-2 群组只读**TransactionItem 新增 `isReadOnly` prop非本人记录显示锁图标
10. **UX-3 waitForReady 超时**app-ready.ts 超时后 reject页面级 catch 显示错误提示
11. **UX-4 预算 group_id**budget Store/页面/路由 支持 group_id 参数
12. **UX-5 下拉刷新**pages.json 全局启用,各页面实现 onPullDownRefresh
13. **UX-6 离线记账**offline.ts 队列管理 + App.vue 网络监听 + add 页面适配
14. **UX-7 Loading 态**:异步操作添加 loading ref 守卫 + 按钮禁用
### T04: 测试体系建设
| 字段 | 内容 |
|------|------|
| **任务 ID** | T04 |
| **优先级** | P1 |
| **描述** | 搭建前后端测试框架,编写核心路由集成测试与 Store 单元测试,建立持续测试基线 |
| **依赖** | T01 |
| **涉及文件** | **新建**`server/tests/setup.ts`, `server/tests/auth.test.ts`, `server/tests/transaction.test.ts`, `server/tests/category.test.ts`, `server/tests/budget.test.ts`, `server/tests/stats.test.ts`, `client/tests/stores/transaction.test.ts`, `client/tests/stores/category.test.ts`, `client/tests/utils/format.test.ts`, `client/tests/utils/app-ready.test.ts` |
**详细子项**
1. **后端测试框架搭建**jest.config.js + setup.ts测试数据库初始化与清理
2. **auth 测试**:登录成功/失败、Token 生成与验证、Refresh Token 刷新、无效 Token 拒绝
3. **transaction 测试**CRUD、group_id 过滤、分页、金额精度、权限校验
4. **category 测试**CRUD、默认/自定义分类隔离、排序、迁移
5. **budget 测试**CRUD、月度预算、群组视图
6. **stats 测试**overview/category/trend/dashboard 查询、period 参数校验
7. **前端 Store 测试**transaction/category Store 核心方法测试
8. **工具函数测试**format.tsformatAmount、formatDate、getCurrentMonth、app-ready.ts
### T05: 新功能(财务报告 + 预算预警)
| 字段 | 内容 |
|------|------|
| **任务 ID** | T05 |
| **优先级** | P1 |
| **描述** | 实现周/月财务报告推送、预算超支预警、微信订阅消息集成 |
| **依赖** | T01 |
| **涉及文件** | **新建**`server/src/services/scheduler.ts`, `server/src/services/report.ts`, `server/src/services/budget-alert.ts`, `server/src/services/wechat-subscribe.ts`, `server/src/routes/report.ts`, `client/src/api/report.ts` |
| | **修改**`server/src/index.ts`, `server/src/routes/transaction.ts`, `server/src/routes/budget.ts` |
**详细子项**
1. **scheduler.ts**:使用 node-cron 注册定时任务(每周一 09:00 推送周报、每月 1 号 09:00 推送月报、每日凌晨全量预算扫描)
2. **report.ts**:查询指定时间范围的收支数据,生成摘要文本
3. **budget-alert.ts**:实现 checkBudgetAlert 逻辑80%/100%/120% 分级预警 + 去重
4. **wechat-subscribe.ts**:封装微信订阅消息发送接口
5. **report 路由**GET /reports/weekly、GET /reports/monthly
6. **用户设置**GET/PUT /user/settingsreport_push_enabled 开关
7. **触发集成**transaction.ts 创建记账后调用 budgetAlert.check()budget.ts 设置预算后调用 budgetAlert.check()
---
## 六、任务依赖图
```mermaid
graph TD
T01["T01: 项目基础设施与安全修复<br/>(P0 · S1-S7 + 依赖安装 + DB迁移)"]
T02["T02: 代码质量重构<br/>(P1 · C1-C9 + 通用组件)"]
T03["T03: 性能优化与 UX 增强<br/>(P1 · Perf-1~9 + UX-1~7)"]
T04["T04: 测试体系建设<br/>(P1 · T-1 + T-2 + T-4)"]
T05["T05: 新功能<br/>(P1 · F-1 + F-2)"]
T01 --> T02
T01 --> T03
T01 --> T04
T01 --> T05
style T01 fill:#FF6B6B,color:#fff
style T02 fill:#FFD700,color:#333
style T03 fill:#7BC67E,color:#fff
style T04 fill:#5B9BD5,color:#fff
style T05 fill:#8B5CF6,color:#fff
```
**并行策略**T01 完成后T02/T03/T04/T05 可并行推进。其中 T03 与 T02 有轻微文件冲突stores/),建议 T02 先行或约定合并策略。
---
## 七、依赖包列表
### 后端新增
```
- node-cron@^3.0.3 # 定时任务调度F-1 财务报告、F-2 预算扫描)
- jest@^29.7.0 # 后端测试框架T-1
- ts-jest@^29.1.1 # Jest TypeScript 支持
- supertest@^6.3.3 # HTTP 集成测试T-1
- @types/supertest@^6.0.2 # supertest 类型定义
```
### 前端新增
```
- vitest@^1.2.0 # 前端测试框架T-2
- @pinia/testing@^0.1.3 # Pinia 测试工具
- @vue/test-utils@^2.4.3 # Vue 组件测试工具
```
---
## 八、共享知识(跨文件约定)
### 8.1 API 响应格式
```typescript
// 所有 API 统一响应格式
{ code: 0, data: T, message?: string } // 成功
{ code: 40100, message: '未登录' } // Token 无效
{ code: 40101, message: 'token已过期' } // Token 过期(可刷新)
{ code: 40300, message: '无权访问' } // 权限不足
{ code: 40400, message: '资源不存在' } // 找不到
{ code: 42900, message: '请求过于频繁' } // 限流
{ code: 50000, message: '服务器错误' } // 服务端异常
```
### 8.2 认证相关
```
- Access Token 有效期2 小时HMAC-SHA256 签名格式base64(userId:timestamp:signature)
- Refresh Token 有效期30 天(随机 32 字节,存 SHA-256 hash
- 前端存储 Keyxc:accessToken, xc:refreshToken
- Authorization HeaderBearer <accessToken>
- 40101 表示 Token 过期可刷新40100 表示 Token 无效需重新登录
```
### 8.3 金额处理
```
- 所有金额以「分」为单位存储INT 类型)
- 前端展示使用 formatAmount() 转为 "¥ 1,234.56" 格式
- 输入时以「元」为单位,提交前乘以 100 转为分
- 金额校验1 <= amount <= 999999999
```
### 8.4 日期处理
```
- 数据库存储 DATE 类型YYYY-MM-DD
- 月份参数格式YYYY-MM如 "2026-06"
- 时区统一:服务器 + 客户端均按 UTC+8 处理
- getCurrentMonth() 前后端逻辑一致
```
### 8.5 软删除约定
```
- users 表使用 deleted_at 字段标记软删除
- 所有查询 users 表的 SQL 必须增加 WHERE deleted_at IS NULL
- 登录时检查 deleted_at IS NULL
- 群组成员查询需过滤已删除用户
- 已删除用户在群组统计中标注"已注销用户"
```
### 8.6 乐观更新约定
```
- 新增/编辑操作:先更新本地 state → 再调 API → 失败回滚 + toast
- 删除操作:仍等 API 确认后再移除(防误删)
- 排序操作:全量回滚(排序是完整 ID 数组操作)
- 临时 ID使用负数时间戳-Date.now()API 成功后替换为真实 ID
```
### 8.7 前端缓存约定
```
- 低频变更数据(分类/标签5 分钟缓存onShow 判断是否需要刷新
- 未读计数30 秒缓存
- 缓存失效:手动操作(新增/编辑/删除)后立即清除对应缓存
- 下拉刷新:强制清除缓存并重新请求
```
### 8.8 组件命名约定
```
- 组件目录client/src/components/ComponentName/ComponentName.vue
- Store 命名use{Name}Store
- API 模块client/src/api/{name}.ts
- 页面目录client/src/pages/{name}/index.vue
```
---
## 九、待明确事项
| # | 问题 | 影响范围 | 当前假设 | 风险 |
|---|------|----------|----------|------|
| Q1 | Refresh Token 存储方式 | S5 | 存 MySQL `refresh_tokens` 表;短中期可行,高并发时迁移 Redis | 当前写入频率极低MySQL 足够 |
| Q2 | 软删除级联范围 | S6 | 仅软删除用户账号,交易记录保留但通过 `deleted_at IS NULL` 过滤 | 保留交易可能影响群组统计,需标注"已注销用户" |
| Q3 | 删除撤销批量上限 | UX-1 | 上限 5 条Snackbar 最多显示"已删除 5 条记录"),超过直接删除 | 5 条覆盖绝大多数场景 |
| Q4 | 离线记账冲突策略 | UX-6 | MVP 仅支持离线新增(不涉及修改/删除),新增使用服务端生成 ID不存在主键冲突 | 如需离线编辑需引入版本号机制 |
| Q5 | 微信订阅消息模板审核 | F-1 | 优先站内通知,微信订阅消息作为增强项;需提前申请模板审核 | 微信审核可能被拒,需准备备选方案 |
| Q6 | 预算预警检查时机 | F-2 | 双重策略:记账后实时检查 + 每日凌晨全量扫描 | 仅实时检查可能遗漏周期记账触发的超支 |
| Q7 | 乐观更新回滚粒度 | Perf-1 | 排序操作全量回滚(语义清晰),新增/编辑单条回滚 | 全量回滚可能丢失已做的其他排序操作 |
| Q8 | 测试环境数据隔离 | T-1 | 独立测试数据库(.env.testCI 用 GitHub Actions service container | 本地需额外配置测试数据库 |
| Q9 | budgets 表 group_id 迁移 | UX-4 | 现有数据 group_id 默认 NULL个人预算UNIQUE KEY 从 (user_id, month) 改为 (user_id, group_id, month) | 迁移需确保不破坏现有预算数据 |
| Q10 | iconfont 图标清单 | C8 | 需从现有 Icon.vue 的 PNG 映射中提取完整图标列表,并在 iconfont.cn 重新制作 | 部分图标可能无现成 SVG需设计师配合 |
---
*文档版本: v2.0 | 最后更新: 2026-06-10*

View File

@@ -1,55 +1,212 @@
classDiagram classDiagram
class Tag { direction TB
+int id
+int user_id class TokenConfig {
+string name +TOKEN_SECRET: string
+string color +ACCESS_TOKEN_EXPIRY: number
+string created_at +REFRESH_TOKEN_EXPIRY: number
} }
class TransactionTag { class AuthMiddleware {
+int transaction_id +authMiddleware(req, res, next): void
+int tag_id -verifyHmacToken(token): userId
-isTokenExpired(timestamp): boolean
} }
class Transaction { class AuthRoute {
+int id +POST /login: LoginResult
+int user_id +POST /demo-login: LoginResult
+int amount +POST /refresh: RefreshResult
+string type -signToken(userId): string
+int category_id -generateRefreshToken(userId): string
+string note -hashToken(token): string
+string date -autoSetAdmin(userId): void
+int group_id
+int recurring_id
+Tag[] tags
} }
class Feedback { class LoginResult {
+int id +accessToken: string
+int user_id +refreshToken: string
+string type +expiresIn: number
+string content +userId: number
+string contact +nickname: string
+string status +avatar_url: string
+string admin_reply +role: string
+string created_at
} }
class Category { class RefreshResult {
+int id +accessToken: string
+int user_id +refreshToken: string
+string name +expiresIn: number
+string icon
+string color
+string type
+int sort_order
+int is_custom
} }
Transaction "1" --o "*" TransactionTag : has class RefreshTokenRow {
Tag "1" --o "*" TransactionTag : referenced_by +id: number
Transaction ..> Category : belongs_to +user_id: number
+token_hash: string
+expires_at: Date
+revoked_at: Date|null
+created_at: Date
}
note for Tag "每用户最多 20 个标签\n8 色预设颜色选择器\n标签不区分收支类型" class DownloadTokenRow {
note for TransactionTag "复合主键 (transaction_id, tag_id)\n每笔交易最多 5 个标签" +id: number
+token: string
+user_id: number
+resource_type: string
+resource_id: string
+expires_at: Date
+used_at: Date|null
+created_at: Date
}
class DownloadTokenRoute {
+POST /download-tokens: GenerateDownloadToken
+GET /download/:token: FileStream
-validateToken(token): DownloadTokenRow
-markUsed(tokenId): void
-cleanupExpired(): void
}
class AdminRoute {
+GET /dashboard: DashboardData
+GET /users: UserList
+PUT /users/:id/status: void
+DELETE /users/:id: void~~软删除
+POST /users/:id/restore: void~~恢复
}
class UserRow {
+id: number
+openid: string
+nickname: string
+avatar_url: string
+role: string
+deleted_at: Date|null
+created_at: Date
}
class ManageListComponent {
+items: T[]
+displayField: string
+colorField: string
+showDrag: boolean
+showEdit: boolean
+showDelete: boolean
+emptyText: string
+emit_add(): void
+emit_edit(item): void
+emit_delete(item): void
+emit_sort(ids): void
}
class ColorPickerComponent {
+modelValue: string
+colors: string[]
+emit_update:modelValue(color): void
}
class EditModalComponent {
+visible: boolean
+title: string
+fields: EditField[]
+emit_confirm(data): void
}
class SnackbarComponent {
+message: string
+actionText: string
+duration: number
+visible: boolean
+emit_action(): void
+show(msg, action): void
+hide(): void
}
class OfflineQueue {
-pendingOps: PendingOp[]
-isProcessing: boolean
+enqueue(op): void
+dequeue(id): void
+processPendingOps(): Promise~void~
+isOnline(): boolean
+startNetworkListener(): void
}
class PendingOp {
+id: string
+type: string
+data: object
+createdAt: number
+status: string
}
class SchedulerService {
+start(): void
+stop(): void
-weeklyReportCron(): void
-monthlyReportCron(): void
-dailyBudgetScanCron(): void
}
class ReportService {
+generateWeeklyReport(userId): WeeklyReport
+generateMonthlyReport(userId): MonthlyReport
-queryPeriodData(userId, start, end): PeriodData
}
class BudgetAlertService {
+checkBudgetAlert(userId, month): void
-calculateUsageRatio(userId, month): number
-sendNotification(userId, level): void
-sendWechatMessage(userId, level): void
-hasAlerted(userId, month, level): boolean
}
class WechatSubscribeService {
+sendMessage(userId, templateId, data): void
-getAccessToken(): string
}
class BudgetAlertRow {
+id: number
+user_id: number
+month: string
+level: string
+group_id: number|null
+created_at: Date
}
class UserSettings {
+user_id: number
+report_push_enabled: boolean
+updated_at: Date
}
class StatsRoute {
+GET /overview: Overview
+GET /category: CategoryStat[]
+GET /trend: TrendPoint[]
+GET /dashboard: DashboardData
-buildWhereClause(userId, groupId): WhereResult
}
class DashboardData {
+overview: Overview
+category: CategoryStat[]
+trend: TrendPoint[]
}
TokenConfig <-- AuthMiddleware : uses
TokenConfig <-- AuthRoute : uses
AuthRoute --> LoginResult : returns
AuthRoute --> RefreshResult : returns
AuthRoute --> RefreshTokenRow : creates/revokes
DownloadTokenRoute --> DownloadTokenRow : creates/validates
AdminRoute --> UserRow : soft deletes
SchedulerService --> ReportService : triggers
SchedulerService --> BudgetAlertService : triggers
ReportService --> WechatSubscribeService : uses
BudgetAlertService --> WechatSubscribeService : uses
BudgetAlertService --> BudgetAlertRow : creates
BudgetAlertService --> UserSettings : checks
StatsRoute --> DashboardData : returns

873
docs/prd-iteration-v2.md Normal file
View File

@@ -0,0 +1,873 @@
# 小菜记账 — 全量迭代 PRD v2
> 版本: v2.0 | 日期: 2026-06-10
> 类型: 全量迭代 PRD安全性 · 代码质量 · 性能 · UX · 可测试性 · 新功能)
> 基线: 已完成三轮迭代(代码审查修复 · UI 优化 · 前后端功能对齐 8 项差距)
> 范围: 本次审查发现 35 项改进点,覆盖 6 个维度
---
## 一、产品目标
| # | 目标 | 说明 | 关联维度 |
|---|------|------|----------|
| G1 | 消除生产环境安全风险 | .env 泄露、SQL 注入、弱密钥、Token 暴露等 P0/P1 安全问题必须在上线前全部清零 | 安全性 |
| G2 | 建立可持续的代码质量基线 | 消除重复代码、补齐事务与异常处理、统一规范,使后续迭代不会因技术债减速 | 代码质量 |
| G3 | 核心路径性能感知提升 50% | 首页加载、分类/标签操作、统计查询等高频路径的响应时间减半 | 性能优化 |
| G4 | 交互体验达到主流记账 App 水准 | 删除撤销、群组只读、离线记账、下拉刷新等缺失体验补齐 | 用户体验 |
| G5 | 建立测试体系与核心功能扩展 | 从零测试覆盖到关键路径有保障,并新增财务报告与预算预警等高价值功能 | 可测试性 · 新功能 |
---
## 二、用户故事
### P0 — 安全紧急
> **S1** As a 产品负责人, I want .env 文件中的生产凭据从 Git 历史中彻底清除, so that 攻击者无法通过代码仓库获取数据库密码和微信密钥。
> **S2** As a 用户, I want 后端所有 SQL 查询使用参数化方式, so that 我的财务数据不会被 SQL 注入攻击窃取。
> **S3** As a 用户, I want 认证 Token 使用强密钥且命名统一, so that 伪造 Token 攻击无法成功。
### P1 — 安全高优
> **S4** As a 用户, I want 导出/备份下载链接使用一次性短期凭证, so that 我的财务数据不会被 URL 泄露导致未授权访问。
> **S5** As a 用户, I want 登录 Token 支持轮换机制, so that 即使 Token 泄露也能在短时间内失效。
> **S6** As a 管理员, I want 删除用户时使用软删除并需二次确认, so that 不会因误操作导致用户数据永久丢失。
> **S7** As a 运维人员, I want 数据库连接不硬编码默认凭据, so that 未配置环境变量时服务直接报错而非以弱凭据连接。
### P1 — 代码质量
> **C1** As a 开发者, I want 消除前后端重复的工具函数, so that 修改逻辑时不需要同步多处代码。
> **C2** As a 开发者, I want tag-manage 与 category-manage 共用可复用组件, so that 两个管理页面行为一致且维护成本低。
> **C4** As a 用户, I want 周期记账同步操作在数据库事务中执行, so that 部分失败不会产生脏数据。
> **C5** As a 开发者, I want Store 的异步操作都有 try/catch, so that 未捕获异常不会导致界面卡死。
> **C7** As a 开发者, I want 所有 API 地址从 config.ts 统一读取, so that 环境切换不会遗漏。
### P1 — 性能优化
> **Perf-1** As a 用户, I want 添加/编辑分类后列表立即更新而无需等待接口返回, so that 操作反馈无延迟感。
> **Perf-2** As a 用户, I want 统计页面的多个接口并行请求, so that 页面加载不会串行等待。
### P1 — 用户体验
> **UX-1** As a 用户, I want 删除记录后 3 秒内可以撤销, so that 误删不会导致数据永久丢失。
> **UX-2** As a 群组成员, I want 看到非本人记录时显示只读标记, so that 我不会误编辑他人记录。
> **UX-3** As a 用户, I want 预算设置支持群组维度, so that 群组场景下也能设置和跟踪预算。
### P1 — 可测试性
> **T-1** As a 开发者, I want 后端路由有集成测试覆盖, so that 接口变更不会静默破坏功能。
### P1 — 新功能
> **F-1** As a 用户, I want 每周/月收到财务报告推送, so that 我不用主动打开 App 也能了解收支状况。
> **F-2** As a 用户, I want 预算达到 80%/100%/120% 时收到预警通知, so that 我能及时控制支出。
---
## 三、需求池
### P0 — Must Have安全紧急阻塞上线
| ID | 维度 | 需求 | 影响范围 | 验收标准 |
|----|------|------|----------|----------|
| S1 | 安全 | .env 凭据泄露修复 | 全局 | `.env``.gitignore`已有但需验证Git 历史中不含任何凭据明文;所有已泄露密钥已轮换 |
| S2 | 安全 | logs.ts SQL 拼接修复 | `server/routes/logs.ts` | `LIMIT ${pSize} OFFSET ${offset}` 改为 `pool.query` + 参数占位符 `?`;或改为 `parseInt` + 范围校验后内插 |
| S3 | 安全 | TOKEN_SECRET 弱默认值 + 命名不一致 | `server/middleware/auth.ts`, `server/routes/auth.ts`, `server/routes/backup.ts` | 统一为 `TOKEN_SECRET`;移除所有 fallback 默认值,未配置时进程拒绝启动 |
### P1 — Should Have
#### 安全
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| S4 | Export/Backup URL 传认证 token → 短期一次性下载凭证 | `server/routes/export.ts`, `server/routes/backup.ts` | 下载凭证有效期 5 分钟,一次性使用后失效;不使用 query string 传长期 Token |
| S5 | Refresh Token 机制 | `server/routes/auth.ts`, `server/middleware/auth.ts`, `client/src/utils/request.ts` | Access Token 有效期缩短至 2hRefresh Token 有效期 30dRefresh Token 支持轮换(用后旧 Token 失效) |
| S6 | Admin 硬删除 → 软删除 + 二次确认 | `server/routes/admin.ts`, DB schema | `users` 表新增 `deleted_at` 字段;删除操作改为 `SET deleted_at = NOW()`;需前端二次确认弹窗;查询自动过滤 `deleted_at IS NULL` |
| S7 | DB 连接移除默认凭据 | `server/src/db/connection.ts` | 移除 `|| 'localhost'``|| 'xiaocai'``|| 'xiaocai123'` 等 fallback未配置时 `createPool` 抛出明确错误 |
#### 代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| C1 | 重复工具函数抽取 | 前后端 `utils/` | 识别并合并功能相同的工具函数(如日期格式化、金额处理);抽取为共享模块或分别去重 |
| C2 | tag-manage / category-manage 重复代码 | 前端页面 | 抽取 `ManagePageLayout` 可复用组件(列表 + 新增 + 编辑 + 删除 + 拖拽排序);两页面基于该组件定制 |
| C4 | Recurring 同步缺事务 | `server/routes/recurring.ts` | `/sync` 接口使用 `pool.getConnection()` + `connection.beginTransaction()` 包裹;部分失败时 `rollback()` |
| C5 | Store 缺 try/catch | `client/src/stores/*.ts` | 所有 Store 中 `await api.xxx()` 调用包裹 try/catchcatch 中使用统一错误提示toast不吞错误 |
| C7 | 硬编码 URL | 前端全局 | 全局搜索 `http://``xiaocai.j35.site`,替换为 `config.ts` 导出的变量 |
#### 性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| Perf-1 | 分类/标签乐观更新 | `client/stores/category.ts`, `client/stores/tag.ts` | 新增/编辑/排序操作先更新本地 state 再调接口;失败时回滚并提示;删除仍等接口确认后移除 |
| Perf-2 | groupStore 并行加载 | `client/stores/group.ts` | `refreshAll()` 中多个 `fetchXxx()` 改为 `Promise.all()` 并行执行 |
| Perf-3 | Stats 聚合接口 | `server/routes/stats.ts` | 合并 `/stats/overview` + `/stats/category` + `/stats/trend` 为单一 `/stats/dashboard` 接口;减少 3 次请求为 1 次 |
| Perf-4 | Track 批量 INSERT | `server/routes/track.ts` | 多条埋点数据合并为单条 `INSERT INTO ... VALUES (?,?), (?)` 批量写入 |
| Perf-5 | Admin N+1 查询 | `server/routes/admin.ts` | 用户列表查询改为 JOIN 单次获取关联数据,消除循环内查询 |
| Perf-6 | 前端数据缓存 | `client/stores/*.ts` | 分类、标签等低频变更数据增加内存缓存标记,`onShow` 时判断是否需刷新 |
| Perf-7 | 未读计数缓存 | `client/stores/notification.ts` | 未读计数结果缓存 30s避免每次 `onShow` 都请求 |
#### 用户体验
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| UX-1 | 删除撤销 | `client/pages/bills/`, `client/stores/transaction.ts` | 删除后底部弹出 Snackbar3 秒内可点击"撤销"恢复;撤销成功数据不变;超时后真正删除 |
| UX-2 | 群组只读标记 | `client/pages/bills/`, `client/components/TransactionItem/` | 非本人记录显示小锁图标;点击进入查看模式而非编辑模式;查看模式下字段不可修改 |
| UX-3 | waitForReady 超时处理 | `client/utils/app-ready.ts` | 超时后 reject 并在页面级捕获,展示"网络异常,请检查网络后重试"提示;不再静默继续 |
| UX-4 | 预算 group_id | `server/routes/budget.ts`, `client/stores/budget.ts`, `client/pages/budget/` | 预算设置/查询支持 `group_id` 参数;群组视图下预算卡片显示群组总预算 + 我的份额 |
#### 可测试性
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| T-1 | 后端路由集成测试 | `server/` | 使用 Jest + supertest覆盖 auth、transaction、category、budget 核心 CRUD 路由;断言状态码 + 返回格式 + 权限校验 |
#### 新功能
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| F-1 | 月度/周度财务报告推送 | 后端定时任务 + 微信订阅消息 | 每周一早 9 点推送上周收支摘要;每月 1 号推送上月收支报告;使用微信订阅消息模板;用户可在设置中开关 |
| F-2 | 预算超支预警 | 后端定时任务 + 通知系统 | 80% 时站内通知提醒100% 时站内通知 + 微信订阅消息120% 时站内紧急通知 + 微信订阅消息;预算检查在记账后实时触发 |
### P2 — Nice to Have
#### 代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| C3 | 上传重复逻辑抽取 | `server/routes/user.ts`, `server/routes/notification.ts` | 抽取 `handleUpload(dir, file)` 公共函数,统一 MIME 校验 + 大小限制 + 文件名生成 |
| C6 | Store 吞错误修复 | `client/stores/*.ts` | catch 块中至少 `console.error` + 用户可见 toast 提示;不再空 catch |
| C8 | Icon 组件 → iconfont | `client/components/Icon/` | 将 PNG 图标映射替换为 iconfont 字体图标;减小包体积;支持动态颜色 |
| C9 | 头像 URL 拼接重复 | 前端多处 | 抽取 `getAvatarUrl(filename)` 工具函数,统一拼接 `API_BASE + 路径 + 文件名` |
#### 性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| Perf-8 | Logs 流式读取 | `server/routes/logs.ts` | 大量日志数据使用 stream 分批返回,避免一次性加载到内存 |
| Perf-9 | ChartWrapper 延迟渲染 | `client/components/ChartWrapper/` | 图表组件进入可视区域后才开始渲染IntersectionObserver非可视区域显示占位 |
#### 用户体验
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| UX-5 | 下拉刷新统一 | 前端所有数据页面 | 所有列表页面启用 `onPullDownRefresh`;统一在 `pages.json` 配置 `enablePullDownRefresh` |
| UX-6 | 离线支持 | `client/` 全局 | 记账操作存入本地队列 + 网络状态监听;网络恢复后自动同步;离线期间操作标记为"待同步"状态 |
| UX-7 | Loading 态统一 | 前端全局 | 所有异步操作添加 loading 态(骨架屏 / spinner / 按钮禁用);操作期间禁止重复提交 |
#### 可测试性
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| T-2 | Composables 测试 | `client/` | 抽取可复用逻辑为 composables使用 Vitest 编写单元测试 |
| T-3 | 依赖注入 | `client/` | API 调用通过 provide/inject 注入,便于测试时 mock |
#### 新功能
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| F-3 | 多设备同步 | 后端 + 前端 | 基于 `updated_at` 时间戳的增量同步;冲突策略:后写入优先;同步状态可视化 |
| F-4 | AA 分账 | 后端 + 前端 | 群组内选择多笔交易,按人均/自定义比例分摊;生成每人应付/应收金额;支持结算确认 |
| F-5 | 智能记账 | 后端 + 前端 | 备注关键词自动匹配分类(如"滴滴"→交通);历史金额学习建议;最近使用分类优先 |
### P3 — 远期规划
#### 代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| C10 | 路由中间件一致性 | `server/routes/*.ts` | 统一所有路由的中间件注册方式(统一在 `index.ts` 注册还是路由内自注册) |
#### 性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| Perf-10 | 数据可视化增强 | `client/pages/stats/` | 支持更丰富的图表类型(饼图、折线图、柱状图);支持图表数据导出为图片 |
---
## 四、安全修复方案
### S1.env 凭据泄露修复
**当前状态**`.env` 已在 `.gitignore` 中,但文件内容已在历史提交中暴露,包含数据库地址 `115.120.243.74`、密码 `dnjwYbpMdmASCxfH`、微信 AppID/Secret。
**修复步骤**
1. **立即轮换所有已泄露凭据**
- MySQL: 修改 `xiaocai_test` 用户密码
- 微信: 在微信公众平台重置 AppSecret
- TOKEN_SECRET: 生成新的强随机密钥(`openssl rand -hex 32`
2. **清除 Git 历史**
```bash
# 使用 git-filter-repo推荐比 BFG 更安全)
pip install git-filter-repo
git filter-repo --path server/.env --invert-paths
git filter-repo --blob-callback 'blob.data = blob.data.replace(b"dnjwYbpMdmASCxfH", b"REDACTED")'
```
3. **强制推送并通知协作者**
```bash
git push origin --force --all
```
4. **验证 `.gitignore` 生效**
```bash
git check-ignore server/.env # 应输出 server/.env
```
5. **添加 pre-commit hook 防止再次提交**
```bash
# .githooks/pre-commit
if git diff --cached --name-only | grep -q '\.env'; then
echo "ERROR: .env files must not be committed"
exit 1
fi
```
**回滚方案**Git 历史重写前创建完整备份 `git clone --mirror`。
### S2logs.ts SQL 拼接修复
**当前状态**`server/routes/logs.ts:160-168` 使用模板字符串拼接 LIMIT/OFFSET
```typescript
// ❌ 当前代码
LIMIT ${pSize} OFFSET ${offset}
```
**修复方案**
```typescript
// ✅ 方案 A参数化查询推荐
const safeLimit = Math.min(Math.max(parseInt(String(pSize)) || 10, 1), 100)
const safeOffset = Math.max(parseInt(String(offset)) || 0, 0)
const [rows] = await pool.query(
`SELECT t.*, u.nickname
FROM track_events t
LEFT JOIN users u ON t.user_id = u.id
WHERE ${where}
ORDER BY t.created_at DESC
LIMIT ? OFFSET ?`,
[...params, safeLimit, safeOffset]
)
```
**关键点**`LIMIT`/`OFFSET` 的值必须先 `parseInt` + 范围校验,防止非整数或超大值;`pool.query` 支持 `?` 占位符用于整数参数(与 `pool.execute` 的 prepared statement 不同)。
### S3TOKEN_SECRET 弱默认值 + 命名不一致
**当前状态**
- `auth.ts` / `middleware/auth.ts``process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'`
- `backup.ts``process.env.JWT_SECRET || 'xiaocai-secret'`(命名不一致 + 弱默认值)
- `.env` 中:`TOKEN_SECRET=xiaocai-prod-secret-2026-change-me`(弱密钥)
**修复方案**
1. **统一命名为 `TOKEN_SECRET`**
- `backup.ts` 中 `JWT_SECRET` → `TOKEN_SECRET`
- 删除 `const JWT_SECRET = ...` 声明,改为从 `middleware/auth.ts` 导出或统一读取 `process.env.TOKEN_SECRET`
2. **移除所有 fallback 默认值,强制要求环境变量**
```typescript
// server/src/config/token.ts新增
export const TOKEN_SECRET = process.env.TOKEN_SECRET
if (!TOKEN_SECRET || TOKEN_SECRET.length < 32) {
console.error('[Security] TOKEN_SECRET must be set and at least 32 characters')
process.exit(1)
}
```
3. **生成强密钥**
```bash
# 在服务器上生成并写入 .env
echo "TOKEN_SECRET=$(openssl rand -hex 32)" >> server/.env
```
4. **所有引用处改为**
```typescript
import { TOKEN_SECRET } from '../config/token'
```
### S4Export/Backup URL 短期一次性下载凭证
**当前状态**
- `backup.ts`:下载链接使用 HMAC 签名的 `backupId:timestamp:hmac` 格式 token通过 query string 传递
- `export.ts`:导出接口支持 query string 传 token 兼容
**问题**:虽然已有 HMAC 签名但无过期时间校验backup 有 timestamp 但未校验时效),且 token 可重复使用。
**修复方案**
1. **新增 `download_tokens` 表**
```sql
CREATE TABLE download_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE,
user_id INT NOT NULL,
resource_type ENUM('backup', 'export') NOT NULL,
resource_id VARCHAR(100),
expires_at DATETIME NOT NULL,
used_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token),
INDEX idx_expires (expires_at)
);
```
2. **生成下载凭证**
```typescript
// 有效期 5 分钟
const token = randomBytes(32).toString('hex')
await pool.query(
'INSERT INTO download_tokens (token, user_id, resource_type, resource_id, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE))',
[token, userId, 'backup', backupId]
)
```
3. **验证下载凭证**
```typescript
const [rows] = await pool.query(
'SELECT * FROM download_tokens WHERE token = ? AND user_id = ? AND expires_at > NOW() AND used_at IS NULL',
[token, userId]
)
if (!rows.length) return res.status(403).json({ code: 40300, message: '下载凭证无效或已过期' })
// 标记为已使用
await pool.query('UPDATE download_tokens SET used_at = NOW() WHERE id = ?', [rows[0].id])
```
4. **定期清理过期 token**:在 health check 或定时任务中清理 `expires_at < NOW()` 的记录。
### S5Refresh Token 机制
**当前状态**:单 Token 机制HMAC-SHA256 签名30 天过期,无轮换。
**修复方案**
1. **双 Token 机制**
- Access Token有效期 2 小时,用于 API 调用
- Refresh Token有效期 30 天,仅用于刷新 Access Token
2. **新增 `refresh_tokens` 表**
```sql
CREATE TABLE refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash)
);
```
3. **认证流程**
- 登录:返回 `accessToken` + `refreshToken`
- API 调用Header 带 `Authorization: Bearer <accessToken>`
- Token 过期401前端用 `refreshToken` 调用 `/auth/refresh` 获取新 `accessToken` + 新 `refreshToken`(旧 refresh token 失效)
- Refresh Token 也过期:需重新登录
4. **前端适配**`client/src/utils/request.ts`
- 现有 401 重登录机制改为 401 → refresh → 重试
- refresh 也失败401→ 清除本地 token → 跳转登录
5. **安全措施**
- Refresh Token 存储 hash 值SHA-256不存明文
- 每个 Refresh Token 仅使用一次(用后旧 token 失效)
- 用户修改密码时撤销所有 Refresh Token
### S6Admin 硬删除 → 软删除 + 二次确认
**当前状态**`admin.ts:170` 直接 `DELETE FROM users WHERE id = ?`,无二次确认。
**修复方案**
1. **DB Schema 变更**
```sql
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL;
CREATE INDEX idx_users_deleted ON users(deleted_at);
```
2. **后端修改**
```typescript
// 软删除
router.delete('/users/:id', async (req, res) => {
await pool.query('UPDATE users SET deleted_at = NOW() WHERE id = ?', [req.params.id])
res.json({ code: 0, data: { message: '用户已禁用' } })
})
// 恢复
router.post('/users/:id/restore', async (req, res) => {
await pool.query('UPDATE users SET deleted_at = NULL WHERE id = ?', [req.params.id])
res.json({ code: 0, data: { message: '用户已恢复' } })
})
```
3. **全局查询过滤**:在 `auth.ts` 登录时检查 `deleted_at IS NULL`;其他查询同理。
4. **前端二次确认**
```
管理员点击"删除用户" → 弹出确认弹窗:
"确定要禁用用户「{nickname}」吗?该操作将冻结该用户的所有数据,但不会删除记录。"
[取消] [确认禁用]
```
### S7DB 连接移除默认凭据
**当前状态**`connection.ts` 中 `password: process.env.DB_PASSWORD || 'xiaocai123'` 等硬编码默认值。
**修复方案**
```typescript
// ✅ 修复后
const required = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'] as const
for (const key of required) {
if (!process.env[key]) {
console.error(`[DB] Missing required environment variable: ${key}`)
process.exit(1)
}
}
const pool = mysql.createPool({
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,
})
```
---
## 五、UI 交互说明
### UX-1删除撤销
```
交互流程:
1. 用户在账单列表左滑删除某条记录
2. 记录从列表中移除(动画:向左滑出)
3. 底部弹出 Snackbar
┌───────────────────────────────────────────┐
│ 🗑 已删除 1 条记录 [撤销] │
└───────────────────────────────────────────┘
- 背景色: $surface (#FFFFFF)
- 文字色: $text-sec (#8B7E7E)
- "撤销"按钮: $primary (#FF8C69) + 500 字重
- 自动消失时间: 3 秒
- 从底部上滑动画: 200ms ease-out
4. 3 秒内点击"撤销"
- 记录重新插入列表原位置(动画:从左侧滑入)
- 不调用后端删除接口
- Snackbar 消失
5. 3 秒超时未操作:
- Snackbar 下滑消失
- 调用后端 DELETE /transactions/:id
- 如果接口失败,记录恢复到列表 + 错误提示
实现要点:
- 删除操作先只移除前端列表项,不立即调接口
- 使用 pendingDelete Map 存储待删除记录id → 原始数据 + 原位置索引)
- 多次快速删除时 Snackbar 累加计数:"已删除 N 条记录"
- 撤销时按 LIFO 顺序恢复
```
### UX-2群组只读标记
```
交互设计:
1. TransactionItem 组件变更:
- 新增 props: `isReadOnly: boolean`
- 非本人记录transaction.user_id !== currentUserId
├── 右上角显示小锁图标Lucide Lock, 16rpx, $text-sec 颜色)
├── 左滑不显示删除按钮
└── 整体透明度降为 0.85(区分视觉层级)
2. 点击交互:
- 本人记录:点击 → 编辑页面(现有行为)
- 非本人记录:点击 → 查看页面(只读模式)
├── 页面布局与编辑页相同
├── 所有输入框/选择器为 disabled 态
├── 金额/分类/标签仅展示不可修改
├── 底部无"保存"按钮
└── 顶部标题显示"查看记录"
3. 群组视图下的记账按钮:
- 仍然可以新增自己的记录
- 新增记录自动归属当前用户
```
### UX-3waitForReady 超时处理
```
交互流程:
1. 页面 onMounted 调用 await waitForReady()
2. 如果 8 秒内未就绪:
- waitForReady() reject而非当前 resolve
- 页面 catch 中显示全屏错误状态:
┌─────────────────────────┐
│ │
│ ⚠️ 网络连接异常 │
│ 请检查网络后点击重试 │
│ │
│ [重新加载] │
│ │
└─────────────────────────┘
- "重新加载"按钮点击后重新执行 waitForReady() + loadData()
- 不再静默以未登录状态加载数据
```
### UX-4预算 group_id 支持
```
交互设计:
1. 预算页面budget/index.vue变更
- 个人视图:显示"我的月度预算"(现有行为不变)
- 群组视图:显示两个卡片:
├── 群组总预算卡片:所有成员预算之和 + 群组总支出
│ └── "群组总预算 ¥ 8,000.00"
└── 我的预算卡片:当前用户个人预算 + 个人支出
└── "我的预算 ¥ 3,000.00"
2. 设置预算弹窗变更:
- 个人视图:设置个人月度预算
- 群组视图:设置"我在群组中的月度预算"
- 弹窗标题根据视图切换
3. 预算进度条变更:
- 个人视图:支出/预算(现有)
- 群组视图:
├── 总进度条:群组总支出 / 群组总预算
└── 我的进度条:我的支出 / 我的预算(子进度条样式)
```
### UX-5下拉刷新统一
```
实现规范:
1. pages.json 全局配置:
"globalStyle": {
"enablePullDownRefresh": true
}
2. 所有数据页面统一模式:
onPullDownRefresh(async () => {
await loadData()
uni.stopPullDownRefresh()
})
3. 需要启用下拉刷新的页面:
- 首页 index
- 账单页 bills
- 统计页 stats
- 通知中心 notifications
- 群组管理 group-manage
- 标签管理 tag-manage
4. 不启用下拉刷新的页面:
- 表单页add, profile-edit, budget
- 设置页
```
### UX-6离线支持
```
架构设计:
1. 操作队列localStorage
- Key: `xc:pendingOps`
- 数据结构:
[
{ id: 'uuid', type: 'create', data: {...}, createdAt: timestamp, status: 'pending' },
{ id: 'uuid', type: 'update', data: {...}, createdAt: timestamp, status: 'pending' }
]
2. 网络状态监听:
onMounted(() => {
uni.onNetworkStatusChange(({ isConnected }) => {
if (isConnected) processPendingOps()
})
})
3. 操作执行流程:
- 有网络:正常调接口
- 无网络:
├── 写入操作队列
├── 本地 state 立即更新(乐观更新)
├── 记录标记为"待同步"状态(右下角小图标)
└── Toast"已保存,将在网络恢复后同步"
4. 同步流程processPendingOps
- 按时间顺序逐条执行
- 全部成功:清除队列 + toast "同步完成"
- 部分失败:标记失败项 + toast "N 条同步失败" + 保留在队列中重试
5. 限制范围:
- MVP 阶段仅支持"新增记账"离线操作
- 编辑/删除等操作离线时禁用(提示"请连接网络后操作"
```
### UX-7Loading 态统一
```
规范:
1. 页面级加载:使用 Skeleton 骨架屏组件
- 首次加载 onMounted 时显示
- 下拉刷新不显示骨架屏(数据已存在)
2. 操作级加载:按钮/操作区域 loading 态
- 按钮点击后 disabled + 显示 spinner
- 防止重复提交debounce 300ms 或 loading ref 守卫)
3. 全局 loading
- 页面切换时的导航栏 loadinguni.showNavigationBarLoading
- 长时间操作(导出/备份)使用全屏 loading + 进度提示
4. 空状态:
- 数据为空时显示空状态插画 + 引导文案
- 与加载中状态明确区分(不是空白页)
```
---
## 六、性能优化方案
### Perf-1分类/标签乐观更新
| 项目 | 说明 |
|------|------|
| 当前问题 | 新增/编辑/排序分类后,等待接口返回才更新列表,用户感知延迟 200-500ms |
| 优化策略 | 先更新本地 Pinia state再异步调接口接口失败时回滚 state + toast 提示 |
| 实现方式 | Store 方法内部:先 `state.items.push(newItem)` → `await api.create()` → 失败则 `state.items.pop()` + `toast('操作失败')` |
| 预期效果 | 操作感知延迟从 200-500ms 降至 < 16ms一帧 |
| 适用范围 | 新增分类/标签、编辑分类/标签、拖拽排序;删除操作仍等接口确认(防误删) |
### Perf-2groupStore 并行加载
| 项目 | 说明 |
|------|------|
| 当前问题 | `refreshAll()` 中 `fetchCategories()` → `fetchTags()` → `fetchTransactions()` 串行执行,总耗时 = 3 次请求之和 |
| 优化策略 | 无依赖的请求改为 `Promise.all()` 并行执行 |
| 实现方式 | `await Promise.all([fetchCategories(), fetchTags()]); await fetchTransactions()`transactions 依赖 categories 的映射) |
| 预期效果 | 并行部分耗时 = max(单次请求),总体减少 40-60% |
### Perf-3Stats 聚合接口
| 项目 | 说明 |
|------|------|
| 当前问题 | 统计页加载时串行调用 `/stats/overview` + `/stats/category` + `/stats/trend`3 次 HTTP 往返 |
| 优化策略 | 合并为单一 `/stats/dashboard` 接口,后端并行查询后一次性返回 |
| 实现方式 | 后端新路由中 `Promise.all([queryOverview, queryCategory, queryTrend])` → 返回 `{ overview, category, trend }` |
| 预期效果 | 3 次 HTTP 往返 → 1 次;减少约 200ms 网络开销 |
### Perf-4Track 批量 INSERT
| 项目 | 说明 |
|------|------|
| 当前问题 | 前端一次页面访问可能触发多条埋点,每条单独 INSERTN 条 = N 次数据库写入 |
| 优化策略 | 前端批量发送,后端使用 `INSERT INTO ... VALUES (?), (?), (?)` 批量写入 |
| 实现方式 | 前端 `tracker.ts` 攒批500ms 或 10 条),批量 POST后端 `track.ts` 接收数组后构建批量 INSERT |
| 预期效果 | 10 条埋点从 10 次 INSERT 降至 1 次,减少数据库连接开销 90% |
### Perf-5Admin N+1 查询
| 项目 | 说明 |
|------|------|
| 当前问题 | 用户列表查询后,循环每个用户查询其交易数/群组数等信息N 个用户 = 1 + N 次查询 |
| 优化策略 | 使用 LEFT JOIN + GROUP BY 在一次查询中获取所有关联数据 |
| 实现方式 | `SELECT u.*, COUNT(DISTINCT t.id) as tx_count, COUNT(DISTINCT gm.group_id) as group_count FROM users u LEFT JOIN transactions t ON ... LEFT JOIN group_members gm ON ... GROUP BY u.id` |
| 预期效果 | 1+N 次查询 → 1 次查询;列表加载从 O(n) 降至 O(1) |
### Perf-6前端数据缓存
| 项目 | 说明 |
|------|------|
| 当前问题 | 分类、标签等低频变更数据每次 `onShow` 都重新请求 |
| 优化策略 | 增加 `lastFetchTime` 标记5 分钟内 `onShow` 跳过请求 |
| 实现方式 | Store 中增加 `lastFetchTime: Ref<number>``fetch()` 前判断 `Date.now() - lastFetchTime.value < 5 * 60 * 1000` 则跳过 |
| 预期效果 | 页面切换时减少 60-80% 的冗余请求 |
### Perf-7未读计数缓存
| 项目 | 说明 |
|------|------|
| 当前问题 | 每次进入"我的"页面或 `onShow` 都请求未读通知计数 |
| 优化策略 | 计数结果缓存 30 秒;读取通知后主动清零缓存 |
| 实现方式 | `unreadCount` 请求后记录 `lastFetchTime`30s 内直接返回缓存值;`markAllRead()` 后 `unreadCount = 0` + 清缓存 |
| 预期效果 | 频繁切换页面时减少通知计数请求 80%+ |
### Perf-8Logs 流式读取P2
| 项目 | 说明 |
|------|------|
| 当前问题 | 管理后台查看日志时全量加载到内存 |
| 优化策略 | 使用 Node.js stream 分批返回 |
| 实现方式 | `fs.createReadStream(logPath).pipe(res)` 或数据库 `queryStream()` |
| 预期效果 | 内存占用从 O(n) 降至 O(buffer_size) |
### Perf-9ChartWrapper 延迟渲染P2
| 项目 | 说明 |
|------|------|
| 当前问题 | 统计页所有图表同时渲染,低端设备卡顿 |
| 优化策略 | 使用 IntersectionObserver图表进入可视区域后才渲染 |
| 实现方式 | ChartWrapper 组件内监听可视状态,不可见时显示占位(固定高度 div |
| 预期效果 | 首屏渲染时间减少 30-50% |
---
## 七、测试体系建设计划
> 目标:从零覆盖到核心路径有保障,分 4 阶段逐步建设
### 阶段一后端路由集成测试P1预计 2 周)
**范围**:核心 CRUD 路由的集成测试
**技术栈**Jest + supertest + 测试数据库
**覆盖清单**
| 模块 | 测试用例 | 优先级 |
|------|----------|--------|
| auth | 登录成功/失败、Token 生成与验证、无效 Token 拒绝 | P0 |
| transaction | CRUD、group_id 过滤、分页、金额精度 | P0 |
| category | CRUD、默认/自定义分类隔离、排序、迁移 | P1 |
| budget | CRUD、月度预算、群组视图 | P1 |
| stats | overview/category/trend 查询、period 参数校验 | P2 |
**实现步骤**
1. 安装依赖:`npm i -D jest ts-jest supertest @types/supertest`
2. 创建测试数据库配置:`.env.test` 指向独立测试库
3. 编写 `tests/setup.ts`:每个测试套件前清空测试库 + seed
4. 编写路由测试:使用 `supertest(app)` 发送请求,断言状态码 + 返回格式
5. 配置 `npm test` 命令
**验收标准**:核心路由测试覆盖率 ≥ 80%`npm test` 全绿。
### 阶段二:前端 Store 单元测试P1预计 1.5 周)
**范围**Pinia Store 的核心逻辑测试
**技术栈**Vitest + @pinia/testing
**覆盖清单**
| Store | 测试用例 | 优先级 |
|-------|----------|--------|
| transaction | fetchList/fetchDetail/create/update/delete、乐观更新、删除撤销 | P0 |
| category | fetchAll/create/update/sort/乐观更新 | P1 |
| budget | fetch/set/群组视图 | P1 |
| group | fetchGroups/switchToPersonal/leaveGroupBUG-03 回归) | P0 |
| user | login/logout/token 管理 | P1 |
**实现步骤**
1. 安装依赖:`npm i -D vitest @pinia/testing @vue/test-utils`
2. 创建 `vitest.config.ts`
3. Mock API 调用:使用 `vi.mock('@/api/*')`
4. 编写 Store 测试:使用 `createTestingPinia()`
5. 配置 `npm run test:unit` 命令
**验收标准**Store 核心方法测试覆盖率 ≥ 70%。
### 阶段三API 契约测试P2预计 1 周)
**范围**:确保前后端 API 接口格式一致
**技术栈**Jest + JSON Schema 验证
**实现方式**
1. 定义 API 响应 Schema`tests/schemas/`
```typescript
// transaction-response.schema.ts
export const transactionListSchema = {
type: 'object',
required: ['code', 'data'],
properties: {
code: { const: 0 },
data: {
type: 'object',
required: ['list', 'total'],
properties: {
list: { type: 'array' },
total: { type: 'number' }
}
}
}
}
```
2. 集成测试中同时验证响应格式符合 Schema
3. 前端 `request.ts` 添加开发模式响应校验
**验收标准**:所有已定义 Schema 的接口响应 100% 符合契约。
### 阶段四工具函数单元测试P2预计 1 周)
**范围**:前端 `utils/` 和后端 `utils/` 的纯函数测试
**覆盖清单**
| 模块 | 测试用例 |
|------|----------|
| `format.ts` | `formatAmount`、`formatDate`(含跨年 BUG-02 回归)、`formatAmountRaw` |
| `request.ts` | 401 重试队列、超时处理、请求参数格式 |
| `app-ready.ts` | waitForReady 正常/超时场景 |
| `backup.ts`(后端) | 备份文件生成、签名校验 |
| `date.ts`(后端) | `getCurrentMonth`、`getMonthRange` |
**验收标准**:工具函数测试覆盖率 ≥ 90%。
---
## 八、待确认问题
| # | 问题 | 影响范围 | 建议方案 | 风险 |
|---|------|----------|----------|------|
| Q1 | Refresh Token 存储方式:数据库 vs Redis | S5 | 当前技术栈无 Redis建议存 MySQL `refresh_tokens` 表;如果后续并发量增长,迁移到 Redis | MySQL 写入频率低,短中期可行 |
| Q2 | 软删除的级联范围:是否需要同时软删除该用户的所有交易记录? | S6 | 建议仅软删除用户账号(`deleted_at`),交易记录保留但通过 `deleted_at IS NULL` 过滤;历史数据完整性更重要 | 保留交易可能影响群组统计,需在群组视图中标注"已注销用户" |
| Q3 | 删除撤销的批量操作上限:最多支持同时撤销几条? | UX-1 | 建议上限 5 条Snackbar 最多显示"已删除 5 条记录");超过 5 条时直接删除不走撤销流程 | 5 条覆盖绝大多数场景,过多会增加本地状态管理复杂度 |
| Q4 | 离线记账的冲突策略:如果离线期间同一条记录被其他设备修改了怎么办? | UX-6 | MVP 阶段仅支持离线新增(不涉及修改/删除),新增记录使用服务端生成的 ID不存在主键冲突 | 如需支持离线编辑,需引入版本号或时间戳对比机制 |
| Q5 | 财务报告推送的微信订阅消息模板审核:微信对金融类模板审核严格,是否需要用户额外授权? | F-1 | 建议先实现站内通知(现有通知系统),微信订阅消息作为增强项;需提前申请微信模板消息审核 | 微信审核可能被拒,需准备备选方案(如短信或仅站内推送) |
| Q6 | 预算预警的检查时机:仅在记账后检查 vs 定时全量扫描? | F-2 | 建议双重策略:记账后实时检查(精准、低开销)+ 每日凌晨全量扫描(覆盖非记账场景如周期记账自动生成) | 仅实时检查可能遗漏周期记账触发的超支 |
| Q7 | 乐观更新的回滚粒度:分类排序失败时,是回滚全部排序还是仅回滚失败项? | Perf-1 | 建议回滚全部:排序是全量操作(传递完整 ID 数组),部分回滚语义不清晰;失败后恢复排序前快照 + toast | 全量回滚用户感知更一致,但可能丢失用户已做的其他排序操作 |
| Q8 | 测试环境数据隔离:集成测试使用独立数据库还是 Docker 容器? | T-1 | 建议独立测试数据库(`.env.test`),不用 Docker减少 CI 复杂度CI 环境中用 GitHub Actions service container 启动 MySQL | 本地开发需额外配置测试数据库,但比 Docker 方案简单 |
---
## 九、迭代排期建议
| 阶段 | 时间 | 内容 | 交付物 |
|------|------|------|--------|
| **Phase 0** | 第 1 周 | P0 安全修复S1/S2/S3 | 凭据轮换完成、SQL 参数化、TOKEN_SECRET 强制 |
| **Phase 1** | 第 2-3 周 | P1 安全S4-S7+ P1 代码质量C1/C2/C4/C5/C7 | 双 Token 机制、软删除、代码去重 |
| **Phase 2** | 第 4-5 周 | P1 性能Perf-1~7+ P1 UXUX-1~4 | 乐观更新、聚合接口、删除撤销、群组只读 |
| **Phase 3** | 第 6-7 周 | P1 测试T-1+ P1 新功能F-1/F-2 | 集成测试覆盖、财务报告推送、预算预警 |
| **Phase 4** | 第 8-10 周 | P2 需求C3/C6/C8/C9 + Perf-8~9 + UX-5~7 + T-2~3 + F-3~5 | 离线支持、AA 分账、智能记账 |
| **Phase 5** | 远期 | P3 需求C10 + Perf-10 | 路由中间件统一、数据可视化增强 |
---
*文档版本: v2.0 | 最后更新: 2026-06-10*

View File

@@ -1,53 +1,92 @@
sequenceDiagram sequenceDiagram
participant U as 用户 participant C as Client (request.ts)
participant P as category-manage participant S as Server (auth.ts)
participant D as DragSortList participant M as Server (auth middleware)
participant S as categoryStore participant DB as MySQL (refresh_tokens)
participant A as sortCategories API
participant B as PUT /categories/sort
U->>P: 长按分类项进入排序模式 Note over C,S: 1. 正常登录
P->>P: showSortMode = true C->>S: POST /auth/login { code }
P->>D: 渲染 DragSortList (items=currentCategories) S->>DB: INSERT refresh_tokens (user_id, token_hash, expires_at)
U->>D: touchstart (记录起始位置) S-->>C: { accessToken(2h), refreshToken(30d), userId }
U->>D: touchmove (计算偏移, 交换元素位置)
D->>D: 实时更新 items 数组顺序
U->>D: touchend (拖拽结束)
D->>P: @change事件 (新顺序ids)
P->>S: sortCategories(newIds)
S->>A: sortCategories(ids)
A->>B: PUT /categories/sort { ids }
B-->>A: { code: 0 }
A-->>S: 成功
S->>S: fetchCategories() 刷新
%% 数据导入流程 Note over C,S: 2. API 调用Access Token 有效)
C->>M: GET /api/xxx Authorization: Bearer accessToken
M->>M: 验证 HMAC 签名 + 有效期
M-->>C: 200 OK { code: 0, data }
U->>P2 as data-import: 选择 JSON 文件 Note over C,S: 3. Access Token 过期
P2->>P2: uni.chooseFile / 读取文件内容 C->>M: GET /api/xxx Authorization: Bearer expiredToken
P2->>P2: 解析 JSON显示预览(条数、日期范围) M->>M: 签名有效但已过期
U->>P2: 确认导入 M-->>C: 401 { code: 40101, message: token已过期 }
P2->>A2 as transaction API: importTransactions(items)
A2->>S2 as POST /transactions/import: POST { items }
S2->>S2: 校验每条记录格式
S2->>DB as MySQL: 查询已有记录 (去重比对)
S2->>DB: 批量 INSERT (每批100条)
S2->>DB: 写入 transaction_tags (如有 tag_names)
S2-->>A2: { total, imported, skipped, errors }
A2-->>P2: 导入结果
P2->>U: 显示导入结果(成功X条, 跳过Y条)
%% 标签关联交易流程 Note over C,S: 4. 自动刷新
C->>S: POST /auth/refresh { refreshToken }
S->>DB: SELECT WHERE token_hash=SHA256(refreshToken) AND revoked_at IS NULL AND expires_at>NOW()
DB-->>S: 找到记录
S->>DB: UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=oldId
S->>DB: INSERT refresh_tokens (新 token_hash)
S-->>C: { accessToken, refreshToken, expiresIn }
C->>C: 存储新 Token重试原请求
U->>P3 as add/index: 点击"添加标签" Note over C,S: 5. Refresh Token 也过期
P3->>P3: 显示标签选择面板(已有标签 + 新建入口) C->>S: POST /auth/refresh { expiredRefreshToken }
U->>P3: 选择标签(最多5个) S->>DB: SELECT WHERE token_hash=SHA256(...) AND expires_at>NOW()
P3->>P3: selectedTagIds 更新 DB-->>S: 未找到记录
U->>P3: 保存交易 S-->>C: 401 { code: 40101, message: refresh token已过期 }
P3->>A3 as transaction API: createTransaction({...data, tagIds}) C->>C: 清除所有 Token跳转登录页
A3->>S3 as POST /transactions: POST { amount, type, ..., tagIds }
S3->>S3: 校验 tagIds (≤5, 属于当前用户) Note over C,S: 6. 删除撤销流程
S3->>DB: INSERT INTO transactions participant U as User
S3->>DB: INSERT INTO transaction_tags (批量) participant P as BillsPage
S3-->>A3: { id } participant ST as TransactionStore
A3-->>P3: 成功 participant SN as Snackbar
participant API as Server API
U->>P: 左滑删除记录
P->>ST: pendingDelete(id, item, index)
ST->>ST: 从 transactions 列表移除
ST->>SN: 显示 Snackbar 已删除1条记录 撤销
alt 3秒内点击撤销
U->>SN: 点击 撤销
SN->>ST: cancelDelete(id)
ST->>ST: 恢复到列表原位置
else 3秒超时
SN->>ST: confirmDelete(id)
ST->>API: DELETE /api/transactions/id
alt 删除成功
API-->>ST: 200 OK
ST->>ST: 清除 pendingDelete 记录
else 删除失败
API-->>ST: 500 Error
ST->>ST: 恢复到列表原位置
ST->>U: toast 删除失败
end
end
Note over C,S: 7. 预算预警流程
participant T as Transaction Route
participant BA as BudgetAlert Service
participant NS as Notification System
participant WX as WeChat API
C->>T: POST /api/transactions { amount, type, date }
T->>DB: INSERT INTO transactions
T->>BA: checkBudgetAlert(userId, month)
BA->>DB: SELECT budget FROM budgets WHERE user_id=? AND month=?
BA->>DB: SELECT SUM(amount) FROM transactions WHERE user_id=? AND type=expense
alt 支出/预算 >= 80% 且未推送过
BA->>DB: INSERT budget_alerts (level=80)
BA->>NS: 创建站内通知
else 支出/预算 >= 100%
BA->>DB: INSERT budget_alerts (level=100)
BA->>NS: 创建站内通知
BA->>WX: 发送微信订阅消息
else 支出/预算 >= 120%
BA->>DB: INSERT budget_alerts (level=120)
BA->>NS: 创建紧急站内通知 (is_urgent=true)
BA->>WX: 发送微信订阅消息
end
T-->>C: { code: 0, data: { id } }

View File

@@ -1,24 +1,21 @@
# Server # Server
PORT=3000 PORT=3000
# Security
TOKEN_SECRET=your_random_secret_here
# CORS (comma-separated origins, empty = allow all)
CORS_ORIGINS=
# MySQL # MySQL
DB_HOST=your_mysql_host_here DB_HOST=your_db_host
DB_USER=your_mysql_user_here DB_USER=your_db_user
DB_PASSWORD=your_mysql_password_here DB_PASSWORD=your_db_password
DB_NAME=your_mysql_name_here DB_NAME=xiaocai
# Uploads # Uploads
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
# Backup # Backup
BACKUP_DIR=/var/backups/xiaocai BACKUP_DIR=./backups
# Token (REQUIRED - server will not start with default value)
TOKEN_SECRET=change-me-to-a-secure-random-string
# WeChat Mini Program # WeChat Mini Program
WX_APPID=your_wx_appid_here WX_APPID=your_wx_appid
WX_SECRET=your_wx_secret_here WX_SECRET=your_wx_secret

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

3948
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",
@@ -14,14 +15,19 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"multer": "^2.1.1", "multer": "^2.1.1",
"mysql2": "^3.9.0" "mysql2": "^3.9.0",
"node-cron": "^3.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@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,
@@ -14,4 +22,9 @@ const pool = mysql.createPool({
console.error('[DB] Pool error:', err?.message || err) console.error('[DB] Pool error:', err?.message || err)
}) })
/** 获取单个连接(用于事务) */
export async function getConnection() {
return pool.getConnection()
}
export default pool export default pool

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,11 +27,10 @@ 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'
// Warn if token secret is using default fallback // 强制检查 TOKEN_SECRET 安全性
if (!process.env.TOKEN_SECRET) { checkTokenSecret()
console.warn('[Security] TOKEN_SECRET not set — using default fallback. Set TOKEN_SECRET in .env for production!')
}
const app = express() const app = express()
const PORT = process.env.PORT || 3000 const PORT = process.env.PORT || 3000

View File

@@ -1,17 +1,35 @@
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'
import pool from '../db/connection'
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 = 30 * 24 * 60 * 60 * 1000 // 30 days
// 不需要认证的路径 // 不需要认证的路径
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) { /** 已删除用户 ID 缓存5 分钟 TTL */
const deletedUserCache = new Map<number, number>() // userId → cachedAt
async function isUserDeleted(userId: number): Promise<boolean> {
const cached = deletedUserCache.get(userId)
if (cached && Date.now() - cached < 5 * 60 * 1000) {
return true // 仍在缓存中,视为已删除
}
const [rows] = await pool.query('SELECT deleted_at FROM users WHERE id = ?', [userId])
if (!(rows as any[]).length || (rows as any[])[0].deleted_at !== null) {
deletedUserCache.set(userId, Date.now())
return true
}
deletedUserCache.delete(userId)
return false
}
export async function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
// 公开路径跳过认证 // 公开路径跳过认证
if (PUBLIC_PATHS.some(p => req.path === p)) { if (PUBLIC_PATHS.some(p => req.path === p)) {
return next() return next()
@@ -57,6 +75,11 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
return res.status(401).json({ code: 40100, message: 'token无效' }) return res.status(401).json({ code: 40100, message: 'token无效' })
} }
// 检查用户是否已被软删除
if (await isUserDeleted(userId)) {
return res.status(401).json({ code: 40100, message: '账号已禁用' })
}
req.userId = userId req.userId = userId
} catch { } catch {
return res.status(401).json({ code: 40100, message: 'token无效' }) return res.status(401).json({ code: 40100, message: 'token无效' })

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,22 +71,25 @@ 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}%`)
} }
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at, `SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at,
(SELECT COUNT(*) FROM transactions WHERE user_id = u.id) as tx_count, COUNT(DISTINCT t.id) as tx_count,
(SELECT COUNT(*) FROM group_members WHERE user_id = u.id) as group_count COUNT(DISTINCT gm.group_id) as group_count
FROM users u FROM users u
LEFT JOIN transactions t ON t.user_id = u.id
LEFT JOIN group_members gm ON gm.user_id = u.id
${where} ${where}
GROUP BY u.id, u.nickname, u.avatar_url, u.role, u.created_at
ORDER BY u.created_at DESC ORDER BY u.created_at DESC
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
[...params, pSize, offset] [...params, pSize, offset]
@@ -127,7 +130,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) {
@@ -135,9 +138,9 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
} }
} }
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id]) const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ? AND deleted_at IS NULL', [role, req.params.id])
if ((result as any).affectedRows === 0) { if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' }) return res.status(404).json({ code: 40400, message: '用户不存在或已删除' })
} }
res.json({ code: 0 }) res.json({ code: 0 })
} catch (err) { } catch (err) {
@@ -146,7 +149,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 +157,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 +185,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 +198,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,19 +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'
/** 生成 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 {
@@ -27,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: '服务器错误' })
@@ -66,20 +116,76 @@ 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 || typeof refreshToken !== 'string') {
return res.status(400).json({ code: 40001, message: '缺少refreshToken' })
}
const tokenHash = hashToken(refreshToken)
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 加行级锁查询,防止并发重放
const [rows] = await conn.query(
'SELECT id, user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW() FOR UPDATE',
[tokenHash]
)
if (!(rows as any[]).length) {
await conn.rollback()
return res.status(401).json({ code: 40100, message: 'refreshToken无效或已过期' })
}
const record = (rows as any[])[0]
// 撤销旧 token
await conn.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 conn.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[record.user_id, newHash]
)
await conn.commit()
res.json({ code: 0, data: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200 } })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Auth] Refresh token error:', 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 JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret'
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验) // 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
router.use((req, res, next) => { router.use((req, res, next) => {
@@ -42,7 +42,7 @@ router.get('/:id/download', async (req: AuthRequest, res: Response) => {
} }
// 校验 HMAC // 校验 HMAC
const expectedHmac = createHmac('sha256', JWT_SECRET) const expectedHmac = createHmac('sha256', TOKEN_SECRET)
.update(`${backupId}:${timestamp}`) .update(`${backupId}:${timestamp}`)
.digest('hex') .digest('hex')
if (hmac !== expectedHmac) { if (hmac !== expectedHmac) {
@@ -89,7 +89,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
// 为每条记录生成下载令牌 // 为每条记录生成下载令牌
const listWithToken = list.map(item => { const listWithToken = list.map(item => {
const timestamp = Date.now() const timestamp = Date.now()
const hmac = createHmac('sha256', JWT_SECRET) const hmac = createHmac('sha256', TOKEN_SECRET)
.update(`${item.name}:${timestamp}`) .update(`${item.name}:${timestamp}`)
.digest('hex') .digest('hex')
return { return {

View File

@@ -63,12 +63,23 @@ router.get('/', async (req: AuthRequest, res: Response) => {
router.post('/', async (req: AuthRequest, res: Response) => { router.post('/', async (req: AuthRequest, res: Response) => {
try { try {
const { amount, month } = req.body const { amount, month, group_id } = req.body
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) { if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' }) return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' })
} }
const m = validateMonth(month || getCurrentMonth()) const m = validateMonth(month || getCurrentMonth())
// 群组模式下验证成员身份
if (group_id && group_id !== 'null') {
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group_id, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权操作此群组' })
}
}
await pool.query( await pool.query(
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?', 'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
[req.userId, amount, m, amount] [req.userId, amount, m, amount]

View File

@@ -6,6 +6,8 @@ import { pipeline } from 'stream/promises'
import fs from 'fs' 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 { TOKEN_SECRET } from '../config/token'
const router = Router() const router = Router()
@@ -14,18 +16,45 @@ 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 DOWNLOAD_TOKEN_EXPIRY = 10 * 60 * 1000 // 10 minutes
/** 流式导出交易数据 */ /** 生成短期下载签名 token */
router.get('/', async (req: AuthRequest, res: Response) => { function createDownloadToken(userId: number, startDate: string, endDate: string, type: string, format: string): string {
try { const timestamp = Date.now()
// 支持 query token 认证:如果没有 Authorization header从 query.token 读取 const payload = `${userId}:${startDate}:${endDate}:${type}:${format}:${timestamp}`
if (!req.headers.authorization && req.query.token) { const hmac = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
req.headers.authorization = `Bearer ${req.query.token}` return Buffer.from(`${payload}:${hmac}`).toString('base64')
} }
/** 验证下载签名 token返回解出的参数或 null */
function verifyDownloadToken(token: string): { userId: number; startDate: string; endDate: string; type: string; format: string } | null {
try {
const decoded = Buffer.from(token, 'base64').toString('utf-8')
const parts = decoded.split(':')
// 格式userId:startDate:endDate:type:format:timestamp:hmac
// 但 type/format 不含冒号,所以按 7 段拆分
if (parts.length !== 7) return null
const [userIdStr, startDate, endDate, type, format, timestamp, hmac] = parts
const userId = parseInt(userIdStr)
const ts = parseInt(timestamp)
if (isNaN(userId) || isNaN(ts)) return null
if (Date.now() - ts > DOWNLOAD_TOKEN_EXPIRY) return null
const payload = `${userIdStr}:${startDate}:${endDate}:${type}:${format}:${timestamp}`
const expectedHmac = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
if (hmac !== expectedHmac) return null
if (!VALID_TYPES.includes(type) || !VALID_FORMATS.includes(format)) return null
if (!DATE_REGEX.test(startDate) || !DATE_REGEX.test(endDate)) return null
return { userId, startDate, endDate, type, format }
} catch {
return null
}
}
/** 准备导出:生成短期签名 token需登录认证 */
router.get('/prepare', async (req: AuthRequest, res: Response) => {
try {
const { startDate, endDate, type = 'all', format = 'csv' } = req.query const { startDate, endDate, type = 'all', format = 'csv' } = req.query
// 参数校验
if (!startDate || !endDate || !DATE_REGEX.test(startDate as string) || !DATE_REGEX.test(endDate as string)) { if (!startDate || !endDate || !DATE_REGEX.test(startDate as string) || !DATE_REGEX.test(endDate as string)) {
return res.status(400).json({ code: 40001, message: '日期参数无效' }) return res.status(400).json({ code: 40001, message: '日期参数无效' })
} }
@@ -36,13 +65,36 @@ router.get('/', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '格式参数无效' }) return res.status(400).json({ code: 40001, message: '格式参数无效' })
} }
const token = createDownloadToken(req.userId!, startDate as string, endDate as string, type as string, format as string)
res.json({ code: 0, data: { token } })
} catch (err) {
console.error('[Export] prepare error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 流式导出交易数据(使用短期签名 token 认证,无需 Authorization header */
router.get('/', async (req: AuthRequest, res: Response) => {
try {
// 从 query.token 读取签名 token 并验证
const token = req.query.token as string
if (!token) {
return res.status(401).json({ code: 40100, message: '缺少下载令牌' })
}
const decoded = verifyDownloadToken(token)
if (!decoded) {
return res.status(401).json({ code: 40100, message: '下载令牌无效或已过期' })
}
const { userId, startDate, endDate, type, format } = decoded
const isCsv = format === 'csv' const isCsv = format === 'csv'
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '') const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
const filename = isCsv ? `export_${today}.csv` : `export_${today}.json` const filename = isCsv ? `export_${today}.csv` : `export_${today}.json`
// 构建查询条件 // 构建查询条件
let where = 'WHERE t.user_id = ? AND t.date >= ? AND t.date <= ?' let where = 'WHERE t.user_id = ? AND t.date >= ? AND t.date <= ?'
const params: any[] = [req.userId, startDate, endDate] const params: any[] = [userId, startDate, endDate]
if (type !== 'all') { if (type !== 'all') {
where += ' AND t.type = ?' where += ' AND t.type = ?'
@@ -141,7 +193,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
} else { } else {
// 大数据量:临时文件流 // 大数据量:临时文件流
const tmpDir = os.tmpdir() const tmpDir = os.tmpdir()
const tmpFile = path.join(tmpDir, `export_${Date.now()}_${req.userId}.${format}`) const tmpFile = path.join(tmpDir, `export_${Date.now()}_${userId}.${format}`)
// 分批查询并写入临时文件 // 分批查询并写入临时文件
const BATCH_SIZE = 5000 const BATCH_SIZE = 5000

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

@@ -157,15 +157,14 @@ router.get('/track/list', requireAdmin, async (req, res) => {
) )
const total = (countResult as any[])[0].total const total = (countResult as any[])[0].total
// LIMIT/OFFSET 直接嵌入 SQLmysql2 execute 对这些参数类型要求严格)
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 ${pSize} OFFSET ${offset}`, LIMIT ? OFFSET ?`,
params [...params, pSize, offset]
) )
res.json({ res.json({

View File

@@ -1,48 +1,13 @@
import { Router, Response, NextFunction } from 'express' import { Router, Response, NextFunction } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import pool from '../db/connection' import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth' import { AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin' import { requireAdmin } from '../middleware/requireAdmin'
import { createUploadConfig } from '../utils/upload'
const router = Router() const router = Router()
// 通知图片上传配置 // 通知图片上传配置5MB
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads' const upload = createUploadConfig('notifications', 5 * 1024 * 1024)
const NOTIFICATION_DIR = path.resolve(UPLOAD_DIR, 'notifications')
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
cb(null, NOTIFICATION_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `notif_${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 最大 5MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */ /** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) { async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
@@ -57,7 +22,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 +278,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

@@ -127,12 +127,16 @@ router.delete('/:id', async (req, res) => {
} }
}) })
/** 同步:检查所有活跃模板,生成到期交易 */ /** 同步:检查所有活跃模板,生成到期交易(事务包裹) */
router.post('/sync', async (req, res) => { router.post('/sync', async (req, res) => {
const conn = await pool.getConnection()
try { try {
const userId = (req as any).userId const userId = (req as any).userId
await conn.beginTransaction()
// 获取该用户所有活跃且到期next_date <= 今天)的模板 // 获取该用户所有活跃且到期next_date <= 今天)的模板
const [templates] = await pool.execute( const [templates] = await conn.execute(
`SELECT * FROM recurring_templates `SELECT * FROM recurring_templates
WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE() WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE()
ORDER BY next_date ASC`, ORDER BY next_date ASC`,
@@ -141,7 +145,6 @@ router.post('/sync', async (req, res) => {
let generated = 0 let generated = 0
const today = new Date() const today = new Date()
const todayStr = today.toISOString().slice(0, 10)
for (const t of templates as any[]) { for (const t of templates as any[]) {
let next = new Date(t.next_date) let next = new Date(t.next_date)
@@ -152,13 +155,13 @@ router.post('/sync', async (req, res) => {
if (end && next > end) break if (end && next > end) break
// 检查是否已生成过(避免重复) // 检查是否已生成过(避免重复)
const [existing] = await pool.execute( const [existing] = await conn.execute(
`SELECT id FROM transactions `SELECT id FROM transactions
WHERE user_id = ? AND recurring_id = ? AND date = ?`, WHERE user_id = ? AND recurring_id = ? AND date = ?`,
[userId, t.id, next.toISOString().slice(0, 10)] [userId, t.id, next.toISOString().slice(0, 10)]
) )
if ((existing as any[]).length === 0) { if ((existing as any[]).length === 0) {
await pool.execute( await conn.execute(
`INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id) `INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, t.amount, t.type, t.category_id, t.note, [userId, t.amount, t.type, t.category_id, t.note,
@@ -178,16 +181,20 @@ router.post('/sync', async (req, res) => {
} }
// 更新 next_date循环结束后 next 已推进到今天之后的下一周期,直接使用 // 更新 next_date循环结束后 next 已推进到今天之后的下一周期,直接使用
await pool.execute( await conn.execute(
'UPDATE recurring_templates SET next_date = ? WHERE id = ?', 'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
[next.toISOString().slice(0, 10), t.id] [next.toISOString().slice(0, 10), t.id]
) )
} }
await conn.commit()
res.json({ code: 0, data: { generated } }) res.json({ code: 0, data: { generated } })
} catch (error) { } catch (error) {
await conn.rollback()
console.error('Sync recurring error:', error) console.error('Sync recurring error:', error)
res.status(500).json({ code: 50000, message: '同步失败' }) res.status(500).json({ code: 50000, message: '同步失败' })
} finally {
conn.release()
} }
}) })

View File

@@ -335,4 +335,130 @@ router.get('/suggest-category', async (req: AuthRequest, res: Response) => {
} }
}) })
/** 聚合统计接口:一次请求返回 overview + category + trend */
router.get('/dashboard', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense', period = 'month', group_id } = req.query
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40001, message: '类型无效' })
}
if (!VALID_PERIODS.includes(period as string)) {
return res.status(400).json({ code: 40001, message: 'period参数无效' })
}
const now = new Date()
const m = (month as string) || getCurrentMonth()
let startDate: string
let endDate: string
let groupBy: string
let labelExpr: string
let elapsedDays = 1
if (period === 'week') {
const dayOfWeek = now.getDay() || 7
const monday = new Date(now)
monday.setDate(now.getDate() - dayOfWeek + 1)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
startDate = monday.toISOString().slice(0, 10)
endDate = sunday.toISOString().slice(0, 10)
elapsedDays = 7
groupBy = 't.date'
labelExpr = `CASE DAYOFWEEK(t.date)
WHEN 1 THEN '周日'
WHEN 2 THEN '周一'
WHEN 3 THEN '周二'
WHEN 4 THEN '周三'
WHEN 5 THEN '周四'
WHEN 6 THEN '周五'
WHEN 7 THEN '周六'
END`
} else if (period === 'year') {
const year = m.split('-')[0] || String(now.getFullYear())
startDate = `${year}-01-01`
endDate = `${year}-12-31`
elapsedDays = year === String(now.getFullYear()) ? Math.max(1, Math.ceil((now.getTime() - new Date(startDate).getTime()) / 86400000) + 1) : 365
groupBy = "DATE_FORMAT(t.date, '%Y-%m')"
labelExpr = `CONCAT(MONTH(t.date), '月')`
} else {
// month默认
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
startDate = range.startDate
endDate = range.endDate
const [year, mon] = m.split('-').map(Number)
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
groupBy = 't.date'
labelExpr = `CONCAT(DAY(t.date), '日')`
}
// 验证 group_id 一次即可
const whereBase = await buildWhereClause(req.userId, group_id as string | undefined, ['t.date >= ?', 't.date <= ?'])
if (!whereBase) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const whereTyped = await buildWhereClause(req.userId, group_id as string | undefined, ['t.type = ?', 't.date >= ?', 't.date <= ?'])
if (!whereTyped) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
// 三个查询并行执行
const [overviewRes, categoryRes, trendRes] = await Promise.all([
pool.query(
`SELECT
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as income,
COUNT(*) as count,
SUM(CASE WHEN t.type = 'expense' THEN 1 ELSE 0 END) as expenseCount,
SUM(CASE WHEN t.type = 'income' THEN 1 ELSE 0 END) as incomeCount
FROM transactions t ${whereBase.where}`,
[...whereBase.params, startDate, endDate]
),
pool.query(
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${whereTyped.where}
GROUP BY c.id, c.name, c.icon, c.color
ORDER BY amount DESC
LIMIT 10`,
[...whereTyped.params, type, startDate, endDate]
),
pool.query(
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, ${labelExpr} as label, SUM(t.amount) as amount
FROM transactions t
${whereTyped.where}
GROUP BY ${groupBy}
ORDER BY t.date`,
[...whereTyped.params, type, startDate, endDate]
)
])
const overviewRow = (overviewRes[0] as any[])[0]
const daily = elapsedDays > 0 ? Math.round(overviewRow.expense / elapsedDays) : 0
const dailyIncome = elapsedDays > 0 ? Math.round(overviewRow.income / elapsedDays) : 0
res.json({
code: 0,
data: {
overview: {
expense: overviewRow.expense,
income: overviewRow.income,
count: overviewRow.count,
daily,
dailyIncome,
expenseCount: overviewRow.expenseCount,
incomeCount: overviewRow.incomeCount
},
category: categoryRes[0],
trend: trendRes[0],
month: m
}
})
} catch (err) {
console.error('[Stats] dashboard error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router export default router

View File

@@ -13,15 +13,21 @@ router.post('/', async (req, res) => {
return res.status(400).json({ code: 40001, message: '无效的埋点数据' }) return res.status(400).json({ code: 40001, message: '无效的埋点数据' })
} }
// 批量插入埋点事件(限制每个事件的字段长度) const validEvents = events.slice(0, 50).map(event => ({
for (const event of events.slice(0, 50)) { // 最多 50 个事件 event: typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown',
const eventType = typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown' page: typeof event.page === 'string' ? event.page.slice(0, 200) : '',
const page = typeof event.page === 'string' ? event.page.slice(0, 200) : '' data: event.data && typeof event.data === 'object' ? JSON.stringify(event.data) : '{}',
const data = event.data && typeof event.data === 'object' ? event.data : {} userId
}))
await pool.execute( if (validEvents.length > 0) {
'INSERT INTO track_events (event, page, data, user_id) VALUES (?, ?, ?, ?)', // 批量 INSERT
[eventType, page, JSON.stringify(data), userId] const placeholders = validEvents.map(() => '(?, ?, ?, ?)').join(', ')
const values: any[] = []
validEvents.forEach(e => values.push(e.event, e.page, e.data, e.userId))
await pool.query(
`INSERT INTO track_events (event, page, data, user_id) VALUES ${placeholders}`,
values
) )
} }

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

@@ -1,54 +1,21 @@
import { Router, Response } from 'express' import { Router, Response } from 'express'
import multer from 'multer'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import pool from '../db/connection' import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth' import { AuthRequest } from '../middleware/auth'
import { createUploadConfig } from '../utils/upload'
const router = Router() const router = Router()
// 头像上传配置 // 头像上传配置2MB
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads' const AVATAR_DIR = path.resolve(process.env.UPLOAD_DIR || './uploads', 'avatars')
const AVATAR_DIR = path.resolve(UPLOAD_DIR, 'avatars') const upload = createUploadConfig('avatars', 2 * 1024 * 1024)
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
// 确保目录存在(可能被手动删除)
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
cb(null, AVATAR_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }, // 最大 2MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 获取当前用户信息 */ /** 获取当前用户信息 */
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 +90,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 和文件至少有一个正确)

View File

@@ -0,0 +1,45 @@
import multer from 'multer'
import path from 'path'
import crypto from 'crypto'
import fs from 'fs'
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
/** 创建指定子目录的 multer 配置 */
export function createUploadConfig(subDir: string, maxFileSize = 5 * 1024 * 1024) {
const targetDir = path.resolve(UPLOAD_DIR, subDir)
// 确保目录存在
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
// 运行时再次确认(可能被手动删除)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
cb(null, targetDir)
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg'
const prefix = subDir === 'avatars' ? '' : 'notif_'
cb(null, `${prefix}${Date.now()}_${crypto.randomInt(1000, 9999)}${ext}`)
}
})
return multer({
storage,
limits: { fileSize: maxFileSize },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.webp']
const ext = path.extname(file.originalname).toLowerCase()
if (allowed.includes(ext)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
}

File diff suppressed because it is too large Load Diff