feat: 用户自助修改密码与 AI 查询统计筛选增强
- 设置页新增「账号安全」面板,当前登录用户校验原密码后可修改自己的密码 - 新增 PUT /api/user/password 接口,BCrypt 校验原密码,新旧相同与长度非法均拒绝 - 新增随机密码工具,一键生成 12 位大小写字母与数字组合密码(排除易混淆字符) - 随机密码明文展示并支持复制,非安全上下文回退 execCommand 方案 - 新增账号安全相关中英日三语文案 - AI 查询与统计结果新增 untagged/uncategorized 字段,提示词同步说明互斥规则 - 统计面板与交易列表支持「无标签」筛选项,AI 结果可回填无标签/未分类条件 - 备注已隐藏的交易不参与备注关键字搜索,避免隐藏内容被检索命中 - 交易列表操作列将「编辑」调整至「详情」之前
This commit is contained in:
@@ -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