feat: 用户自助修改密码与 AI 查询统计筛选增强

- 设置页新增「账号安全」面板,当前登录用户校验原密码后可修改自己的密码
- 新增 PUT /api/user/password 接口,BCrypt 校验原密码,新旧相同与长度非法均拒绝
- 新增随机密码工具,一键生成 12 位大小写字母与数字组合密码(排除易混淆字符)
- 随机密码明文展示并支持复制,非安全上下文回退 execCommand 方案
- 新增账号安全相关中英日三语文案
- AI 查询与统计结果新增 untagged/uncategorized 字段,提示词同步说明互斥规则
- 统计面板与交易列表支持「无标签」筛选项,AI 结果可回填无标签/未分类条件
- 备注已隐藏的交易不参与备注关键字搜索,避免隐藏内容被检索命中
- 交易列表操作列将「编辑」调整至「详情」之前
This commit is contained in:
2026-09-22 10:49:01 +08:00
parent be7e27805d
commit 66088e8c7e
20 changed files with 408 additions and 14 deletions
+6
View File
@@ -152,8 +152,12 @@ export interface AiStatisticsResult {
types: string[] types: string[]
categoryIds: number[] categoryIds: number[]
categoryNames: string[] categoryNames: string[]
/** 是否仅筛选未分类记录 */
uncategorized: boolean
tagIds: number[] tagIds: number[]
tagNames: string[] tagNames: string[]
/** 是否仅筛选无标签记录;与 tagIds 互斥 */
untagged: boolean
channels: string[] channels: string[]
startTime: string | null startTime: string | null
endTime: string | null endTime: string | null
@@ -220,6 +224,8 @@ export interface AiQueryResult {
remark: string | null remark: string | null
tagIds: number[] tagIds: number[]
tagNames: string[] tagNames: string[]
/** 是否筛选无标签记录;与 tagIds 互斥 */
untagged: boolean
unmatched: string[] unmatched: string[]
} }
+5
View File
@@ -16,6 +16,11 @@ export function getUserInfo() {
return request.get('/user/info') return request.get('/user/info')
} }
/** 修改当前登录用户密码 */
export function changePassword(data: { oldPassword: string; newPassword: string }) {
return request.put('/user/password', data)
}
/** 注册状态 */ /** 注册状态 */
export interface RegistrationStatus { export interface RegistrationStatus {
enabled: boolean enabled: boolean
@@ -0,0 +1,185 @@
<script setup lang="ts">
// 账号安全面板:当前登录用户修改密码,支持一键生成随机密码并显示明文
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace, NIcon, NText,
} from 'naive-ui'
import { KeyOutline, RefreshOutline, CopyOutline, SaveOutline } from '@vicons/ionicons5'
import { changePassword } from '../api/user'
import { generateRandomPassword } from '../utils/password'
const message = useMessage()
const { t } = useI18n()
const form = ref({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
const loading = ref(false)
// 生成随机密码后临时明文显示,便于用户查看与复制
const revealed = ref(false)
// 最近一次生成的随机密码,用于判断用户是否已手动修改
const generated = ref('')
// 用户手动修改新密码后恢复遮挡显示
watch(() => form.value.newPassword, (val) => {
if (val !== generated.value) {
revealed.value = false
}
})
/** 生成随机密码并填充到新密码与确认密码,同时明文显示 */
function handleGenerate() {
const pwd = generateRandomPassword()
form.value.newPassword = pwd
form.value.confirmPassword = pwd
generated.value = pwd
revealed.value = true
}
/**
* 复制文本到剪贴板。
* 优先使用 Clipboard API;在非安全上下文(如通过 IP 访问的 HTTP 页面)
* 该 API 不可用,回退为临时 textarea + execCommand 方案。
*/
async function copyText(text: string): Promise<boolean> {
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch (e) {
// Clipboard API 被拒绝,继续尝试回退方案
}
}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch (e) {
return false
}
}
/** 复制当前新密码到剪贴板 */
async function handleCopy() {
if (!form.value.newPassword) return
const ok = await copyText(form.value.newPassword)
if (ok) {
message.success(t('account.copied'))
} else {
message.error(t('account.copyFailed'))
}
}
/** 校验并提交修改密码 */
async function handleSubmit() {
if (!form.value.oldPassword) {
message.warning(t('account.oldRequired'))
return
}
if (form.value.newPassword.length < 6) {
message.warning(t('account.newMin'))
return
}
if (form.value.newPassword !== form.value.confirmPassword) {
message.warning(t('account.mismatch'))
return
}
if (form.value.newPassword === form.value.oldPassword) {
message.warning(t('account.sameAsOld'))
return
}
loading.value = true
try {
await changePassword({
oldPassword: form.value.oldPassword,
newPassword: form.value.newPassword,
})
message.success(t('account.success'))
// 提交成功后清空表单并恢复遮挡
form.value.oldPassword = ''
form.value.newPassword = ''
form.value.confirmPassword = ''
generated.value = ''
revealed.value = false
} catch (e: any) {
message.error(e.message || t('account.failed'))
} finally {
loading.value = false
}
}
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="KeyOutline" />
<span>{{ t('settings.accountSection') }}</span>
</NSpace>
</template>
<NForm label-placement="top">
<NFormItem :label="t('account.oldPassword')">
<NInput
v-model:value="form.oldPassword"
type="password"
show-password-on="click"
:placeholder="t('account.oldPasswordPlaceholder')"
/>
</NFormItem>
<NFormItem :label="t('account.newPassword')">
<NSpace :size="8" style="width: 100%;">
<NInput
v-model:value="form.newPassword"
:type="revealed ? 'text' : 'password'"
show-password-on="click"
:placeholder="t('account.newPasswordPlaceholder')"
style="flex: 1;"
/>
<NButton secondary @click="handleGenerate">
<template #icon>
<NIcon :component="RefreshOutline" />
</template>
{{ t('account.generate') }}
</NButton>
<NButton secondary :disabled="!form.newPassword" @click="handleCopy">
<template #icon>
<NIcon :component="CopyOutline" />
</template>
{{ t('account.copy') }}
</NButton>
</NSpace>
</NFormItem>
<NFormItem :label="t('account.confirmPassword')">
<NInput
v-model:value="form.confirmPassword"
:type="revealed ? 'text' : 'password'"
show-password-on="click"
:placeholder="t('account.confirmPasswordPlaceholder')"
/>
</NFormItem>
</NForm>
<NText depth="3" style="font-size: 12px;">{{ t('account.hint') }}</NText>
<NSpace justify="end" style="margin-top: 16px; width: 100%;">
<NButton type="primary" :loading="loading" @click="handleSubmit">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('account.submit') }}
</NButton>
</NSpace>
</NCard>
</template>
+23
View File
@@ -136,6 +136,7 @@ export default {
// 系统设置 // 系统设置
settings: { settings: {
title: { zh: '系统设置', en: 'Settings', ja: 'システム設定' }, title: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
accountSection: { zh: '账号安全', en: 'Account Security', ja: 'アカウントセキュリティ' },
databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' }, databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' },
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' }, aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' }, viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
@@ -145,6 +146,28 @@ export default {
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' }, notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
}, },
// 账号安全(修改密码)
account: {
oldPassword: { zh: '原密码', en: 'Current Password', ja: '現在のパスワード' },
oldPasswordPlaceholder: { zh: '请输入当前密码', en: 'Enter your current password', ja: '現在のパスワードを入力してください' },
newPassword: { zh: '新密码', en: 'New Password', ja: '新しいパスワード' },
newPasswordPlaceholder: { zh: '至少6个字符', en: 'At least 6 characters', ja: '6文字以上' },
confirmPassword: { zh: '确认新密码', en: 'Confirm New Password', ja: '新しいパスワード(確認)' },
confirmPasswordPlaceholder: { zh: '请再次输入新密码', en: 'Enter the new password again', ja: '新しいパスワードをもう一度入力してください' },
generate: { zh: '随机生成', en: 'Generate', ja: 'ランダム生成' },
copy: { zh: '复制', en: 'Copy', ja: 'コピー' },
copied: { zh: '密码已复制', en: 'Password copied', ja: 'パスワードをコピーしました' },
copyFailed: { zh: '复制失败,请手动选择', en: 'Copy failed, please select manually', ja: 'コピーに失敗しました。手動で選択してください' },
hint: { zh: '修改成功后请使用新密码登录;随机生成的密码请及时妥善保存', en: 'After changing, sign in with the new password; keep generated passwords safe.', ja: '変更後は新しいパスワードでログインしてください。生成したパスワードは大切に保管してください。' },
submit: { zh: '修改密码', en: 'Change Password', ja: 'パスワードを変更' },
oldRequired: { zh: '请输入原密码', en: 'Please enter your current password', ja: '現在のパスワードを入力してください' },
newMin: { zh: '新密码至少6个字符', en: 'New password must be at least 6 characters', ja: '新しいパスワードは6文字以上で入力してください' },
mismatch: { zh: '两次输入的新密码不一致', en: 'The two new passwords do not match', ja: '新しいパスワードが一致しません' },
sameAsOld: { zh: '新密码不能与原密码相同', en: 'New password must differ from the current password', ja: '新しいパスワードは現在のパスワードと異なる必要があります' },
success: { zh: '密码修改成功', en: 'Password changed successfully', ja: 'パスワードを変更しました' },
failed: { zh: '密码修改失败', en: 'Failed to change password', ja: 'パスワードの変更に失敗しました' },
},
// 账单来源统计默认值 // 账单来源统计默认值
billSourceStats: { billSourceStats: {
hint: { zh: '设置各账单来源导入时是否默认计入统计;不计入的记录仍会导入,但不参与统计', en: 'Set whether bills from each source count toward statistics by default. Excluded records are still imported but not counted.', ja: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。' }, hint: { zh: '设置各账单来源导入时是否默认计入统计;不计入的记录仍会导入,但不参与统计', en: 'Set whether bills from each source count toward statistics by default. Excluded records are still imported but not counted.', ja: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。' },
+49
View File
@@ -0,0 +1,49 @@
// 随机密码工具:生成强度较高且便于输入的密码
// 字符集排除易混淆字符(0/O/o/1/l/I),避免用户手动输入时出错
const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
const LOWER = 'abcdefghijkmnpqrstuvwxyz'
const DIGIT = '23456789'
const CHARSET = UPPER + LOWER + DIGIT
/** 生成指定长度的随机密码,默认 12 位,保证同时包含大小写字母与数字 */
export function generateRandomPassword(length = 12): string {
const size = Math.max(6, length)
const chars: string[] = [randomChar(UPPER), randomChar(LOWER), randomChar(DIGIT)]
// 补齐剩余位数后打乱,避免固定位置出现固定字符类型
while (chars.length < size) {
chars.push(randomChar(CHARSET))
}
return shuffle(chars).join('')
}
/** 从字符集中安全随机取一个字符 */
function randomChar(charset: string): string {
return charset[secureRandomInt(charset.length)]
}
/** 使用 crypto 生成 [0, max) 的随机整数,crypto 不可用时回退 Math.random */
function secureRandomInt(max: number): number {
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
// 取整倍数区间拒绝采样,避免取模带来的分布偏差
const limit = Math.floor(0xffffffff / max) * max
const buffer = new Uint32Array(1)
let value = 0
do {
crypto.getRandomValues(buffer)
value = buffer[0]
} while (value >= limit)
return value % max
}
return Math.floor(Math.random() * max)
}
/** Fisher-Yates 洗牌 */
function shuffle(arr: string[]): string[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const tmp = arr[i]
arr[i] = arr[j]
arr[j] = tmp
}
return arr
}
+23 -4
View File
@@ -110,7 +110,11 @@ interface TagOption {
value: number value: number
} }
const tags = ref<TagOption[]>([]) const tags = ref<TagOption[]>([])
const tagOptions = computed(() => tags.value) // 标签筛选项:额外提供「无标签」选项(仅筛选使用)
const tagOptions = computed(() => [
{ label: t('transaction.untagged'), value: 0 },
...tags.value,
])
/** 分类选项数据结构 */ /** 分类选项数据结构 */
interface CategoryOption { interface CategoryOption {
@@ -136,13 +140,22 @@ function buildCategoryFilter() {
} }
} }
/** 将标签筛选拆分为标签ID与无标签标记 */
function buildTagFilter() {
const ids = filterTag.value.filter((v) => v !== 0)
return {
tagIds: ids.length > 0 ? ids : undefined,
untagged: filterTag.value.includes(0) ? true : undefined,
}
}
/** 统计接口的过滤条件 */ /** 统计接口的过滤条件 */
const filterParams = computed(() => ({ const filterParams = computed(() => ({
statsScope: filterStatsScope.value, statsScope: filterStatsScope.value,
types: filterType.value.length > 0 ? filterType.value : undefined, types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(), ...buildCategoryFilter(),
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined, channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined, ...buildTagFilter(),
minAmount: filterMinAmount.value ?? undefined, minAmount: filterMinAmount.value ?? undefined,
maxAmount: filterMaxAmount.value ?? undefined, maxAmount: filterMaxAmount.value ?? undefined,
remark: filterRemark.value.trim() || undefined, remark: filterRemark.value.trim() || undefined,
@@ -565,9 +578,13 @@ async function applyAiStatistics(result: AiStatisticsResult) {
filterStatsScope.value = result.statsScope filterStatsScope.value = result.statsScope
} }
filterType.value = result.types || [] filterType.value = result.types || []
filterCategory.value = result.categoryIds || [] const categoryIds = [...(result.categoryIds || [])]
if (result.uncategorized) categoryIds.push(0)
filterCategory.value = categoryIds
filterChannel.value = result.channels || [] filterChannel.value = result.channels || []
filterTag.value = result.tagIds || [] const tagIds = [...(result.tagIds || [])]
if (result.untagged) tagIds.push(0)
filterTag.value = tagIds
filterMinAmount.value = result.minAmount filterMinAmount.value = result.minAmount
filterMaxAmount.value = result.maxAmount filterMaxAmount.value = result.maxAmount
filterRemark.value = result.remark || '' filterRemark.value = result.remark || ''
@@ -581,7 +598,9 @@ const aiSearchEmpty = computed(() => {
if (!r) return true if (!r) return true
return (r.types?.length ?? 0) === 0 return (r.types?.length ?? 0) === 0
&& (r.categoryIds?.length ?? 0) === 0 && (r.categoryIds?.length ?? 0) === 0
&& !r.uncategorized
&& (r.tagIds?.length ?? 0) === 0 && (r.tagIds?.length ?? 0) === 0
&& !r.untagged
&& (r.channels?.length ?? 0) === 0 && (r.channels?.length ?? 0) === 0
&& !r.startTime && !r.startTime
&& r.minAmount == null && r.minAmount == null
+3 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// 系统设置页:上下分区展示数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知 // 系统设置页:上下分区展示账号安全、数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import AccountSecurityPanel from '../components/AccountSecurityPanel.vue'
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue' import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue' import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue' import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
@@ -14,6 +15,7 @@ const { t } = useI18n()
<template> <template>
<div class="settings-page"> <div class="settings-page">
<h2 class="page-title">{{ t('settings.title') }}</h2> <h2 class="page-title">{{ t('settings.title') }}</h2>
<AccountSecurityPanel />
<DatabaseConfigPanel /> <DatabaseConfigPanel />
<RegistrationConfigPanel /> <RegistrationConfigPanel />
<BillSourceStatsPanel /> <BillSourceStatsPanel />
+5 -2
View File
@@ -608,8 +608,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
render(row) { render(row) {
return h(NSpace, null, { return h(NSpace, null, {
default: () => [ default: () => [
h(NButton, { size: 'small', onClick: () => openDetail(row) }, { default: () => t('transaction.detail.button') }),
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }), h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(NButton, { size: 'small', onClick: () => openDetail(row) }, { default: () => t('transaction.detail.button') }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }), h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
], ],
}) })
@@ -1666,7 +1666,9 @@ function applyAiQuery() {
filterMinAmount.value = r.minAmount ?? null filterMinAmount.value = r.minAmount ?? null
filterMaxAmount.value = r.maxAmount ?? null filterMaxAmount.value = r.maxAmount ?? null
filterRemark.value = r.remark || '' filterRemark.value = r.remark || ''
filterTag.value = r.tagIds || [] const tagIds = [...(r.tagIds || [])]
if (r.untagged) tagIds.push(0)
filterTag.value = tagIds
handleSearch() handleSearch()
// 成功提示优先展示 AI 识别的查询意图 // 成功提示优先展示 AI 识别的查询意图
message.success(r.summary || t('ai.query.applied')) message.success(r.summary || t('ai.query.applied'))
@@ -1680,6 +1682,7 @@ const aiQueryEmpty = computed(() => {
&& (r.categoryIds?.length ?? 0) === 0 && (r.categoryIds?.length ?? 0) === 0
&& !r.uncategorized && !r.uncategorized
&& (r.tagIds?.length ?? 0) === 0 && (r.tagIds?.length ?? 0) === 0
&& !r.untagged
&& (r.channels?.length ?? 0) === 0 && (r.channels?.length ?? 0) === 0
&& !r.startTime && !r.startTime
&& r.minAmount == null && r.minAmount == null
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import cn.rosercode.finance.common.Result; import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.ChangePasswordRequest;
import cn.rosercode.finance.dto.LoginRequest; import cn.rosercode.finance.dto.LoginRequest;
import cn.rosercode.finance.dto.RegisterRequest; import cn.rosercode.finance.dto.RegisterRequest;
import cn.rosercode.finance.dto.RegistrationUpdateRequest; import cn.rosercode.finance.dto.RegistrationUpdateRequest;
@@ -99,4 +100,17 @@ public class UserController {
Long userId = UserContext.getUserId(); Long userId = UserContext.getUserId();
return Result.success(userService.getUserInfo(userId)); return Result.success(userService.getUserInfo(userId));
} }
/**
* 修改当前登录用户密码
*
* @param request 修改密码请求参数(原密码、新密码)
* @return 操作结果
*/
@PutMapping("/password")
@Operation(summary = "修改密码", description = "校验原密码后修改当前登录用户的密码")
public Result<Void> changePassword(@RequestBody @Valid ChangePasswordRequest request) {
userService.changePassword(UserContext.getUserId(), request);
return Result.success();
}
} }
@@ -0,0 +1,23 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 修改密码请求DTO,用于接收当前登录用户提交的原密码与新密码。
*
* @date 2026-09-22 10:35:41
*/
@Data
public class ChangePasswordRequest {
/** 原密码,不能为空 */
@NotBlank(message = "原密码不能为空")
private String oldPassword;
/** 新密码,长度6-50个字符 */
@NotBlank(message = "新密码不能为空")
@Size(min = 6, max = 50, message = "密码长度6-50个字符")
private String newPassword;
}
@@ -1,5 +1,6 @@
package cn.rosercode.finance.service; package cn.rosercode.finance.service;
import cn.rosercode.finance.dto.ChangePasswordRequest;
import cn.rosercode.finance.dto.LoginRequest; import cn.rosercode.finance.dto.LoginRequest;
import cn.rosercode.finance.dto.RegisterRequest; import cn.rosercode.finance.dto.RegisterRequest;
import cn.rosercode.finance.vo.LoginVO; import cn.rosercode.finance.vo.LoginVO;
@@ -32,4 +33,12 @@ public interface UserService {
* @return 用户信息视图对象 * @return 用户信息视图对象
*/ */
UserVO getUserInfo(Long userId); UserVO getUserInfo(Long userId);
/**
* 修改当前登录用户的密码
*
* @param userId 用户ID
* @param request 修改密码请求参数(原密码、新密码)
*/
void changePassword(Long userId, ChangePasswordRequest request);
} }
@@ -201,6 +201,10 @@ public class AiQueryServiceImpl implements AiQueryService {
vo.setTagIds(tagIds); vo.setTagIds(tagIds);
vo.setTagNames(tagNames); vo.setTagNames(tagNames);
// 无标签筛选;与指定标签互斥,命中标签时以标签为准
boolean untagged = tagIds.isEmpty() && root.path("untagged").asBoolean(false);
vo.setUntagged(untagged);
// 时间范围推算 // 时间范围推算
String[] range = resolveDateRange(root.path("dateRange")); String[] range = resolveDateRange(root.path("dateRange"));
if (range != null) { if (range != null) {
@@ -195,6 +195,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
} }
vo.setCategoryIds(categoryIds); vo.setCategoryIds(categoryIds);
vo.setCategoryNames(categoryNames); vo.setCategoryNames(categoryNames);
vo.setUncategorized(root.path("uncategorized").asBoolean(false));
// 标签名称映射为ID // 标签名称映射为ID
List<Long> tagIds = new ArrayList<>(); List<Long> tagIds = new ArrayList<>();
@@ -212,6 +213,10 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
vo.setTagIds(tagIds); vo.setTagIds(tagIds);
vo.setTagNames(tagNames); vo.setTagNames(tagNames);
// 无标签筛选;与指定标签互斥,命中标签时以标签为准
boolean untagged = tagIds.isEmpty() && root.path("untagged").asBoolean(false);
vo.setUntagged(untagged);
// 时间范围推算 // 时间范围推算
String[] range = resolveDateRange(root.path("dateRange")); String[] range = resolveDateRange(root.path("dateRange"));
if (range != null) { if (range != null) {
@@ -285,9 +285,10 @@ public class StatisticsServiceImpl implements StatisticsService {
if (query.getMaxAmount() != null) { if (query.getMaxAmount() != null) {
wrapper.le("amount", query.getMaxAmount()); wrapper.le("amount", query.getMaxAmount());
} }
// 备注关键字模糊匹配 // 备注关键字模糊匹配;备注已隐藏的记录不参与搜索
if (StringUtils.hasText(query.getRemark())) { if (StringUtils.hasText(query.getRemark())) {
wrapper.like("remark", query.getRemark()); wrapper.like("remark", query.getRemark());
wrapper.and(w -> w.isNull("remark_hidden").or().eq("remark_hidden", false));
} }
// 标签筛选(多选);同时勾选「未打标签」时与其取并集 // 标签筛选(多选);同时勾选「未打标签」时与其取并集
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty(); boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
@@ -145,9 +145,11 @@ public class TransactionServiceImpl implements TransactionService {
if (query.getMaxAmount() != null) { if (query.getMaxAmount() != null) {
wrapper.le(Transaction::getAmount, query.getMaxAmount()); wrapper.le(Transaction::getAmount, query.getMaxAmount());
} }
// 按备注关键字模糊匹配 // 按备注关键字模糊匹配;备注已隐藏的记录不参与搜索
if (StringUtils.hasText(query.getRemark())) { if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark()); wrapper.like(Transaction::getRemark, query.getRemark())
.and(w -> w.isNull(Transaction::getRemarkHidden)
.or().eq(Transaction::getRemarkHidden, false));
} }
// 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集) // 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集)
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty(); boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
@@ -800,9 +802,11 @@ public class TransactionServiceImpl implements TransactionService {
if (query.getMaxAmount() != null) { if (query.getMaxAmount() != null) {
wrapper.le(Transaction::getAmount, query.getMaxAmount()); wrapper.le(Transaction::getAmount, query.getMaxAmount());
} }
// 按备注关键字模糊匹配 // 按备注关键字模糊匹配;备注已隐藏的记录不参与搜索
if (StringUtils.hasText(query.getRemark())) { if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark()); wrapper.like(Transaction::getRemark, query.getRemark())
.and(w -> w.isNull(Transaction::getRemarkHidden)
.or().eq(Transaction::getRemarkHidden, false));
} }
// 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集) // 按标签筛选:命中任一选中标签、有任意标签,或筛选无任何标签的记录(多条件取并集)
boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty(); boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
@@ -11,6 +11,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import cn.rosercode.finance.common.BusinessException; import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter; import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.ChangePasswordRequest;
import cn.rosercode.finance.dto.LoginRequest; import cn.rosercode.finance.dto.LoginRequest;
import cn.rosercode.finance.dto.RegisterRequest; import cn.rosercode.finance.dto.RegisterRequest;
import cn.rosercode.finance.entity.LoginLog; import cn.rosercode.finance.entity.LoginLog;
@@ -132,6 +133,36 @@ public class UserServiceImpl implements UserService {
return voConverter.toUserVO(user); return voConverter.toUserVO(user);
} }
/**
* {@inheritDoc}
*
* 校验原密码后使用 BCrypt 重新编码并保存新密码;失败记录 warn,成功记录 info(不记录密码明文)。
*/
@Override
@Transactional
public void changePassword(Long userId, ChangePasswordRequest request) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new BusinessException("用户不存在");
}
// 校验原密码是否匹配
if (!passwordEncoder.matches(request.getOldPassword(), user.getPasswordHash())) {
log.warn("修改密码失败,原密码错误: userId={}, username={}", userId, user.getUsername());
throw new BusinessException("原密码错误");
}
// 新旧密码相同则视为无效修改
if (request.getOldPassword().equals(request.getNewPassword())) {
throw new BusinessException("新密码不能与原密码相同");
}
// 重新编码并保存新密码
user.setPasswordHash(passwordEncoder.encode(request.getNewPassword()));
userMapper.updateById(user);
log.info("用户修改密码成功: userId={}, username={}", userId, user.getUsername());
}
/** /**
* 记录一条登录日志,从当前请求上下文中提取 IP 与 User-Agent。 * 记录一条登录日志,从当前请求上下文中提取 IP 与 User-Agent。
* *
@@ -55,6 +55,9 @@ public class AiQueryResultVO {
/** 命中的标签名称列表 */ /** 命中的标签名称列表 */
private List<String> tagNames; private List<String> tagNames;
/** 是否筛选无标签记录;与 tagIds 互斥 */
private Boolean untagged;
/** 未能识别的关键词,供用户参考 */ /** 未能识别的关键词,供用户参考 */
private List<String> unmatched; private List<String> unmatched;
} }
@@ -25,12 +25,18 @@ public class AiStatisticsResultVO {
/** 分类名称列表 */ /** 分类名称列表 */
private List<String> categoryNames; private List<String> categoryNames;
/** 是否仅筛选未分类记录;与 categoryIds 同时存在时取并集 */
private Boolean uncategorized;
/** 标签ID列表 */ /** 标签ID列表 */
private List<Long> tagIds; private List<Long> tagIds;
/** 标签名称列表 */ /** 标签名称列表 */
private List<String> tagNames; private List<String> tagNames;
/** 是否仅筛选无标签记录;与 tagIds 互斥 */
private Boolean untagged;
/** 交易渠道列表 */ /** 交易渠道列表 */
private List<String> channels; private List<String> channels;
@@ -7,5 +7,6 @@
若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。 若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。
未提及的字段请留空(数组用 [],其余用 null)。 未提及的字段请留空(数组用 [],其余用 null)。
分类和标签只能使用上面给出的名称。 分类和标签只能使用上面给出的名称。
用户表达「没有标签/无标签」时,将 untagged 置为 true;此时 tagNames 必须为空数组,两者互斥。
今天是 [(${today})]。 今天是 [(${today})]。
输出格式:{"summary":"简要说明识别到的查询意图","types":[],"categoryNames":[],"tagNames":[],"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"includeInStats":null,"uncategorized":false,"unmatched":[]} 输出格式:{"summary":"简要说明识别到的查询意图","types":[],"categoryNames":[],"tagNames":[],"untagged":false,"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"includeInStats":null,"uncategorized":false,"unmatched":[]}
@@ -9,5 +9,6 @@ dimension 表示趋势统计的粒度,取值 day、week、month、quarter、ye
statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。 statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。
未提及的字段请留空(数组用 [],其余用 null)。 未提及的字段请留空(数组用 [],其余用 null)。
分类和标签只能使用上面给出的名称。 分类和标签只能使用上面给出的名称。
用户表达「未分类」时,将 uncategorized 置为 true;表达「没有标签/无标签」时,将 untagged 置为 true,此时 tagNames 必须为空数组,两者互斥。
今天是 [(${today})]。 今天是 [(${today})]。
输出格式:{"summary":"简要说明识别到的统计意图","types":[],"categoryNames":[],"tagNames":[],"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"statsScope":null,"dimension":null,"unmatched":[]} 输出格式:{"summary":"简要说明识别到的统计意图","types":[],"categoryNames":[],"tagNames":[],"uncategorized":false,"untagged":false,"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"statsScope":null,"dimension":null,"unmatched":[]}