feat: 用户自助修改密码与 AI 查询统计筛选增强
- 设置页新增「账号安全」面板,当前登录用户校验原密码后可修改自己的密码 - 新增 PUT /api/user/password 接口,BCrypt 校验原密码,新旧相同与长度非法均拒绝 - 新增随机密码工具,一键生成 12 位大小写字母与数字组合密码(排除易混淆字符) - 随机密码明文展示并支持复制,非安全上下文回退 execCommand 方案 - 新增账号安全相关中英日三语文案 - AI 查询与统计结果新增 untagged/uncategorized 字段,提示词同步说明互斥规则 - 统计面板与交易列表支持「无标签」筛选项,AI 结果可回填无标签/未分类条件 - 备注已隐藏的交易不参与备注关键字搜索,避免隐藏内容被检索命中 - 交易列表操作列将「编辑」调整至「详情」之前
This commit is contained in:
@@ -152,8 +152,12 @@ export interface AiStatisticsResult {
|
||||
types: string[]
|
||||
categoryIds: number[]
|
||||
categoryNames: string[]
|
||||
/** 是否仅筛选未分类记录 */
|
||||
uncategorized: boolean
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
/** 是否仅筛选无标签记录;与 tagIds 互斥 */
|
||||
untagged: boolean
|
||||
channels: string[]
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
@@ -220,6 +224,8 @@ export interface AiQueryResult {
|
||||
remark: string | null
|
||||
tagIds: number[]
|
||||
tagNames: string[]
|
||||
/** 是否筛选无标签记录;与 tagIds 互斥 */
|
||||
untagged: boolean
|
||||
unmatched: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ export function getUserInfo() {
|
||||
return request.get('/user/info')
|
||||
}
|
||||
|
||||
/** 修改当前登录用户密码 */
|
||||
export function changePassword(data: { oldPassword: string; newPassword: string }) {
|
||||
return request.put('/user/password', data)
|
||||
}
|
||||
|
||||
/** 注册状态 */
|
||||
export interface RegistrationStatus {
|
||||
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>
|
||||
@@ -136,6 +136,7 @@ export default {
|
||||
// 系统设置
|
||||
settings: {
|
||||
title: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
|
||||
accountSection: { zh: '账号安全', en: 'Account Security', ja: 'アカウントセキュリティ' },
|
||||
databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' },
|
||||
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
|
||||
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
|
||||
@@ -145,6 +146,28 @@ export default {
|
||||
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: {
|
||||
hint: { zh: '设置各账单来源导入时是否默认计入统计;不计入的记录仍会导入,但不参与统计', en: 'Set whether bills from each source count toward statistics by default. Excluded records are still imported but not counted.', ja: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -110,7 +110,11 @@ interface TagOption {
|
||||
value: number
|
||||
}
|
||||
const tags = ref<TagOption[]>([])
|
||||
const tagOptions = computed(() => tags.value)
|
||||
// 标签筛选项:额外提供「无标签」选项(仅筛选使用)
|
||||
const tagOptions = computed(() => [
|
||||
{ label: t('transaction.untagged'), value: 0 },
|
||||
...tags.value,
|
||||
])
|
||||
|
||||
/** 分类选项数据结构 */
|
||||
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(() => ({
|
||||
statsScope: filterStatsScope.value,
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
...buildCategoryFilter(),
|
||||
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
|
||||
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
|
||||
...buildTagFilter(),
|
||||
minAmount: filterMinAmount.value ?? undefined,
|
||||
maxAmount: filterMaxAmount.value ?? undefined,
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
@@ -565,9 +578,13 @@ async function applyAiStatistics(result: AiStatisticsResult) {
|
||||
filterStatsScope.value = result.statsScope
|
||||
}
|
||||
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 || []
|
||||
filterTag.value = result.tagIds || []
|
||||
const tagIds = [...(result.tagIds || [])]
|
||||
if (result.untagged) tagIds.push(0)
|
||||
filterTag.value = tagIds
|
||||
filterMinAmount.value = result.minAmount
|
||||
filterMaxAmount.value = result.maxAmount
|
||||
filterRemark.value = result.remark || ''
|
||||
@@ -581,7 +598,9 @@ const aiSearchEmpty = computed(() => {
|
||||
if (!r) return true
|
||||
return (r.types?.length ?? 0) === 0
|
||||
&& (r.categoryIds?.length ?? 0) === 0
|
||||
&& !r.uncategorized
|
||||
&& (r.tagIds?.length ?? 0) === 0
|
||||
&& !r.untagged
|
||||
&& (r.channels?.length ?? 0) === 0
|
||||
&& !r.startTime
|
||||
&& r.minAmount == null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 系统设置页:上下分区展示数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知
|
||||
// 系统设置页:上下分区展示账号安全、数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AccountSecurityPanel from '../components/AccountSecurityPanel.vue'
|
||||
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
|
||||
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
|
||||
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
|
||||
@@ -14,6 +15,7 @@ const { t } = useI18n()
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h2 class="page-title">{{ t('settings.title') }}</h2>
|
||||
<AccountSecurityPanel />
|
||||
<DatabaseConfigPanel />
|
||||
<RegistrationConfigPanel />
|
||||
<BillSourceStatsPanel />
|
||||
|
||||
@@ -608,8 +608,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
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: () => openDetail(row) }, { default: () => t('transaction.detail.button') }),
|
||||
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
|
||||
filterMaxAmount.value = r.maxAmount ?? null
|
||||
filterRemark.value = r.remark || ''
|
||||
filterTag.value = r.tagIds || []
|
||||
const tagIds = [...(r.tagIds || [])]
|
||||
if (r.untagged) tagIds.push(0)
|
||||
filterTag.value = tagIds
|
||||
handleSearch()
|
||||
// 成功提示优先展示 AI 识别的查询意图
|
||||
message.success(r.summary || t('ai.query.applied'))
|
||||
@@ -1680,6 +1682,7 @@ const aiQueryEmpty = computed(() => {
|
||||
&& (r.categoryIds?.length ?? 0) === 0
|
||||
&& !r.uncategorized
|
||||
&& (r.tagIds?.length ?? 0) === 0
|
||||
&& !r.untagged
|
||||
&& (r.channels?.length ?? 0) === 0
|
||||
&& !r.startTime
|
||||
&& r.minAmount == null
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.dto.ChangePasswordRequest;
|
||||
import cn.rosercode.finance.dto.LoginRequest;
|
||||
import cn.rosercode.finance.dto.RegisterRequest;
|
||||
import cn.rosercode.finance.dto.RegistrationUpdateRequest;
|
||||
@@ -99,4 +100,17 @@ public class UserController {
|
||||
Long userId = UserContext.getUserId();
|
||||
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;
|
||||
|
||||
import cn.rosercode.finance.dto.ChangePasswordRequest;
|
||||
import cn.rosercode.finance.dto.LoginRequest;
|
||||
import cn.rosercode.finance.dto.RegisterRequest;
|
||||
import cn.rosercode.finance.vo.LoginVO;
|
||||
@@ -32,4 +33,12 @@ public interface UserService {
|
||||
* @return 用户信息视图对象
|
||||
*/
|
||||
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.setTagNames(tagNames);
|
||||
|
||||
// 无标签筛选;与指定标签互斥,命中标签时以标签为准
|
||||
boolean untagged = tagIds.isEmpty() && root.path("untagged").asBoolean(false);
|
||||
vo.setUntagged(untagged);
|
||||
|
||||
// 时间范围推算
|
||||
String[] range = resolveDateRange(root.path("dateRange"));
|
||||
if (range != null) {
|
||||
|
||||
@@ -195,6 +195,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
|
||||
}
|
||||
vo.setCategoryIds(categoryIds);
|
||||
vo.setCategoryNames(categoryNames);
|
||||
vo.setUncategorized(root.path("uncategorized").asBoolean(false));
|
||||
|
||||
// 标签名称映射为ID
|
||||
List<Long> tagIds = new ArrayList<>();
|
||||
@@ -212,6 +213,10 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
|
||||
vo.setTagIds(tagIds);
|
||||
vo.setTagNames(tagNames);
|
||||
|
||||
// 无标签筛选;与指定标签互斥,命中标签时以标签为准
|
||||
boolean untagged = tagIds.isEmpty() && root.path("untagged").asBoolean(false);
|
||||
vo.setUntagged(untagged);
|
||||
|
||||
// 时间范围推算
|
||||
String[] range = resolveDateRange(root.path("dateRange"));
|
||||
if (range != null) {
|
||||
|
||||
@@ -285,9 +285,10 @@ public class StatisticsServiceImpl implements StatisticsService {
|
||||
if (query.getMaxAmount() != null) {
|
||||
wrapper.le("amount", query.getMaxAmount());
|
||||
}
|
||||
// 备注关键字模糊匹配
|
||||
// 备注关键字模糊匹配;备注已隐藏的记录不参与搜索
|
||||
if (StringUtils.hasText(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();
|
||||
|
||||
@@ -145,9 +145,11 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (query.getMaxAmount() != null) {
|
||||
wrapper.le(Transaction::getAmount, query.getMaxAmount());
|
||||
}
|
||||
// 按备注关键字模糊匹配
|
||||
// 按备注关键字模糊匹配;备注已隐藏的记录不参与搜索
|
||||
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();
|
||||
@@ -800,9 +802,11 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
if (query.getMaxAmount() != null) {
|
||||
wrapper.le(Transaction::getAmount, query.getMaxAmount());
|
||||
}
|
||||
// 按备注关键字模糊匹配
|
||||
// 按备注关键字模糊匹配;备注已隐藏的记录不参与搜索
|
||||
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();
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.converter.VoConverter;
|
||||
import cn.rosercode.finance.dto.ChangePasswordRequest;
|
||||
import cn.rosercode.finance.dto.LoginRequest;
|
||||
import cn.rosercode.finance.dto.RegisterRequest;
|
||||
import cn.rosercode.finance.entity.LoginLog;
|
||||
@@ -132,6 +133,36 @@ public class UserServiceImpl implements UserService {
|
||||
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。
|
||||
*
|
||||
|
||||
@@ -55,6 +55,9 @@ public class AiQueryResultVO {
|
||||
/** 命中的标签名称列表 */
|
||||
private List<String> tagNames;
|
||||
|
||||
/** 是否筛选无标签记录;与 tagIds 互斥 */
|
||||
private Boolean untagged;
|
||||
|
||||
/** 未能识别的关键词,供用户参考 */
|
||||
private List<String> unmatched;
|
||||
}
|
||||
|
||||
@@ -25,12 +25,18 @@ public class AiStatisticsResultVO {
|
||||
/** 分类名称列表 */
|
||||
private List<String> categoryNames;
|
||||
|
||||
/** 是否仅筛选未分类记录;与 categoryIds 同时存在时取并集 */
|
||||
private Boolean uncategorized;
|
||||
|
||||
/** 标签ID列表 */
|
||||
private List<Long> tagIds;
|
||||
|
||||
/** 标签名称列表 */
|
||||
private List<String> tagNames;
|
||||
|
||||
/** 是否仅筛选无标签记录;与 tagIds 互斥 */
|
||||
private Boolean untagged;
|
||||
|
||||
/** 交易渠道列表 */
|
||||
private List<String> channels;
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。
|
||||
未提及的字段请留空(数组用 [],其余用 null)。
|
||||
分类和标签只能使用上面给出的名称。
|
||||
用户表达「没有标签/无标签」时,将 untagged 置为 true;此时 tagNames 必须为空数组,两者互斥。
|
||||
今天是 [(${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。
|
||||
未提及的字段请留空(数组用 [],其余用 null)。
|
||||
分类和标签只能使用上面给出的名称。
|
||||
用户表达「未分类」时,将 uncategorized 置为 true;表达「没有标签/无标签」时,将 untagged 置为 true,此时 tagNames 必须为空数组,两者互斥。
|
||||
今天是 [(${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":[]}
|
||||
Reference in New Issue
Block a user