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
@@ -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":[]}