feat: 用户级偏好配置与外观设置,枚举统一抽取与账单类型后端下发

- 新增用户偏好表 t_user_preference 与进程内缓存,账单来源统计默认值由全局改为按用户存储(/api/setting)
- 新增外观设置:不计入统计记录支持删除线与灰色标签两种展示样式
- 抽取 ChannelType / TransactionType / StatisticsScope 枚举,替换各处硬编码的渠道、类型与统计口径
- BillType 内聚各来源扩展名,新增 /transaction/import/types 接口,前端改为按后端下发渲染
- 仪表盘趋势图支持点击柱子跳转收支记录并带入筛选条件
- 布局内容区按路由记忆滚动位置;已登录访问登录/注册页自动跳转
This commit is contained in:
2026-09-21 10:51:15 +08:00
parent 2976b85967
commit 1f520f4376
46 changed files with 1565 additions and 284 deletions
@@ -0,0 +1,34 @@
package cn.rosercode.finance.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 缓存配置。
*
* <p>开启 Spring Cache 注解支持,并显式提供基于 {@link ConcurrentMapCacheManager} 的进程内缓存。
* 显式声明可避免 classpath 存在 JCache(JSR-107)API 时被自动装配选为默认缓存类型
* (无 JCache Provider 时无法动态创建缓存)。</p>
*
* @date 2026-09-21 10:20:40
*/
@Configuration
@EnableCaching
public class CacheConfig {
/** 用户偏好缓存名 */
public static final String USER_PREFERENCE_CACHE = "userPreference";
/**
* 进程内缓存管理器。
*
* @return 基于 ConcurrentHashMap 的缓存管理器
*/
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager(USER_PREFERENCE_CACHE);
}
}
@@ -1,8 +1,5 @@
package cn.rosercode.finance.config.app;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@@ -21,9 +18,6 @@ public class AppConfig {
/** 默认日志保留天数 */
public static final int DEFAULT_LOG_RETENTION_DAYS = 30;
/** 系统默认计入统计的账单来源,其余来源默认不计入 */
private static final Set<String> DEFAULT_INCLUDED_SOURCES = Set.of("WECHAT", "ALIPAY");
/** 是否开放注册:true 开放,false 关闭(默认关闭) */
private boolean registrationEnabled = false;
@@ -32,23 +26,4 @@ public class AppConfig {
/** 日志保留天数(访问日志/登录日志),0 或负数表示永久保留,默认 30 天 */
private Integer logRetentionDays = DEFAULT_LOG_RETENTION_DAYS;
/** 按账单来源的「计入统计」默认值,key 为账单来源编码(BillType 名称);为空或缺少某来源时使用系统默认 */
private Map<String, Boolean> billSourceIncludeInStats;
/**
* 解析指定账单来源的「计入统计」默认值。
*
* <p>优先取用户配置;未配置时,微信与支付宝默认计入,其余来源默认不计入。</p>
*
* @param billType 账单来源编码
* @return 是否默认计入统计
*/
public boolean resolveIncludeInStats(String billType) {
if (billType != null && billSourceIncludeInStats != null
&& billSourceIncludeInStats.containsKey(billType)) {
return Boolean.TRUE.equals(billSourceIncludeInStats.get(billType));
}
return billType != null && DEFAULT_INCLUDED_SOURCES.contains(billType);
}
}
@@ -1,8 +1,5 @@
package cn.rosercode.finance.controller;
import java.util.LinkedHashMap;
import java.util.Map;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
@@ -14,10 +11,8 @@ import org.springframework.web.bind.annotation.RestController;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.config.app.AppConfig;
import cn.rosercode.finance.config.app.AppConfigStore;
import cn.rosercode.finance.dto.BillSourceStatsRequest;
import cn.rosercode.finance.dto.LogRetentionRequest;
import cn.rosercode.finance.dto.NotificationUrlRequest;
import cn.rosercode.finance.enums.BillType;
/**
* 应用配置控制器
@@ -90,55 +85,4 @@ public class AppConfigController {
appConfigStore.save(config);
return Result.success(config.getLogRetentionDays());
}
/**
* 查询按账单来源的「计入统计」默认值。
*
* @return 账单来源编码到是否计入统计的映射,包含全部支持的来源
*/
@GetMapping("/bill-source-include-in-stats")
@Operation(summary = "查询账单来源统计默认值", description = "返回各账单来源导入时「是否计入统计」的默认值")
public Result<Map<String, Boolean>> getBillSourceIncludeInStats() {
return Result.success(effectiveBillSourceStats(appConfigStore.load()));
}
/**
* 更新按账单来源的「计入统计」默认值。
*
* @param request 各账单来源的默认值
* @return 更新后的有效映射
*/
@PutMapping("/bill-source-include-in-stats")
@Operation(summary = "更新账单来源统计默认值", description = "设置各账单来源导入时「是否计入统计」的默认值")
public Result<Map<String, Boolean>> updateBillSourceIncludeInStats(@RequestBody BillSourceStatsRequest request) {
AppConfig config = appConfigStore.load();
Map<String, Boolean> defaults = new LinkedHashMap<>();
Map<String, Boolean> requested = request.getDefaults();
if (requested != null) {
// 仅保留系统支持的账单来源,忽略非法键
for (BillType type : BillType.values()) {
Boolean value = requested.get(type.name());
if (value != null) {
defaults.put(type.name(), value);
}
}
}
config.setBillSourceIncludeInStats(defaults);
appConfigStore.save(config);
return Result.success(effectiveBillSourceStats(config));
}
/**
* 构建包含全部账单来源的有效默认值映射。
*
* @param config 应用配置
* @return 账单来源编码到是否计入统计的映射
*/
private Map<String, Boolean> effectiveBillSourceStats(AppConfig config) {
Map<String, Boolean> result = new LinkedHashMap<>();
for (BillType type : BillType.values()) {
result.put(type.name(), config.resolveIncludeInStats(type.name()));
}
return result;
}
}
@@ -1,11 +1,14 @@
package cn.rosercode.finance.controller;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -21,6 +24,7 @@ import cn.rosercode.finance.service.BillImportService;
import cn.rosercode.finance.util.UserContext;
import cn.rosercode.finance.vo.BillImportResultVO;
import cn.rosercode.finance.vo.BillParseResultVO;
import cn.rosercode.finance.vo.BillTypeVO;
/**
* 账单导入控制器
@@ -40,6 +44,24 @@ public class BillImportController {
this.billImportService = billImportService;
}
/**
* 获取系统支持的账单类型及其文件扩展名
*
* @return 账单类型列表
*/
@GetMapping("/types")
@Operation(summary = "获取账单类型列表", description = "返回系统支持的账单来源编码与允许的账单文件扩展名")
public Result<List<BillTypeVO>> types() {
List<BillTypeVO> types = new ArrayList<>();
for (BillType billType : BillType.values()) {
BillTypeVO vo = new BillTypeVO();
vo.setCode(billType.name());
vo.setExtensions(billType.getExtensions());
types.add(vo);
}
return Result.success(types);
}
/**
* 解析账单文件(不落库)
*
@@ -0,0 +1,87 @@
package cn.rosercode.finance.controller;
import java.util.Map;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
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.BillSourceStatsRequest;
import cn.rosercode.finance.dto.ExcludedDisplayStyleRequest;
import cn.rosercode.finance.service.UserPreferenceService;
import cn.rosercode.finance.util.UserContext;
/**
* 用户设置控制器
*
* <p>提供按当前登录用户存储的偏好配置读写接口,包含账单来源统计默认值与
* 「不计入统计」记录的展示样式。</p>
*
* @date 2026-09-21 10:14:12
*/
@RestController
@RequestMapping("/api/setting")
@Tag(name = "用户设置", description = "用户级偏好配置读写接口")
public class UserSettingController {
private final UserPreferenceService userPreferenceService;
public UserSettingController(UserPreferenceService userPreferenceService) {
this.userPreferenceService = userPreferenceService;
}
/**
* 查询当前用户按账单来源的「计入统计」默认值。
*
* @return 账单来源编码到是否计入统计的映射,包含全部支持的来源
*/
@GetMapping("/bill-source-include-in-stats")
@Operation(summary = "查询账单来源统计默认值", description = "返回当前用户各账单来源导入时「是否计入统计」的默认值")
public Result<Map<String, Boolean>> getBillSourceIncludeInStats() {
return Result.success(userPreferenceService.getBillSourceIncludeInStats(UserContext.getUserId()));
}
/**
* 更新当前用户按账单来源的「计入统计」默认值。
*
* @param request 各账单来源的默认值
* @return 更新后的有效映射
*/
@PutMapping("/bill-source-include-in-stats")
@Operation(summary = "更新账单来源统计默认值", description = "设置当前用户各账单来源导入时「是否计入统计」的默认值")
public Result<Map<String, Boolean>> updateBillSourceIncludeInStats(@RequestBody BillSourceStatsRequest request) {
Long userId = UserContext.getUserId();
userPreferenceService.saveBillSourceIncludeInStats(userId, request.getDefaults());
return Result.success(userPreferenceService.getBillSourceIncludeInStats(userId));
}
/**
* 查询当前用户「不计入统计」记录的展示样式。
*
* @return 展示样式:strikethrough-删除线、muted-灰色标签
*/
@GetMapping("/excluded-display-style")
@Operation(summary = "查询不计入统计展示样式", description = "读取当前用户收支记录中「不计入统计」记录使用的展示样式")
public Result<String> getExcludedDisplayStyle() {
return Result.success(userPreferenceService.getExcludedDisplayStyle(UserContext.getUserId()));
}
/**
* 更新当前用户「不计入统计」记录的展示样式。
*
* @param request 展示样式更新请求
* @return 更新后的展示样式,取值非法时回退默认值
*/
@PutMapping("/excluded-display-style")
@Operation(summary = "更新不计入统计展示样式", description = "设置当前用户收支记录中「不计入统计」记录使用的展示样式")
public Result<String> updateExcludedDisplayStyle(@RequestBody ExcludedDisplayStyleRequest request) {
Long userId = UserContext.getUserId();
userPreferenceService.saveExcludedDisplayStyle(userId, request.getStyle());
return Result.success(userPreferenceService.getExcludedDisplayStyle(userId));
}
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* 「不计入统计」记录展示样式更新请求对象。
*
* @date 2026-09-21 09:36:10
*/
@Data
public class ExcludedDisplayStyleRequest {
/** 展示样式:strikethrough-删除线、muted-灰色标签 */
private String style;
}
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
@@ -19,6 +20,7 @@ import org.springframework.stereotype.Component;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.entity.EmailBillMessage;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.enums.EmailBillStatus;
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
import cn.rosercode.finance.service.EmailBillSenderRuleService;
@@ -35,8 +37,22 @@ import cn.rosercode.finance.service.EmailBillSenderRuleService;
@Slf4j
public class EmailBillProcessor {
/** 支持的账单附件扩展名 */
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(".csv", ".xlsx", ".xls", ".pdf", ".zip");
/** 支持的账单附件扩展名:各账单来源扩展名的并集,另含压缩包 */
private static final Set<String> SUPPORTED_EXTENSIONS = buildSupportedExtensions();
/**
* 由账单来源枚举汇总支持的附件扩展名,并补充压缩包扩展名。
*
* @return 支持的扩展名集合(小写,含点)
*/
private static Set<String> buildSupportedExtensions() {
Set<String> extensions = new HashSet<>();
for (BillType type : BillType.values()) {
extensions.addAll(type.getExtensions());
}
extensions.add(".zip");
return Set.copyOf(extensions);
}
private final EmailBillMessageMapper messageMapper;
private final EmailBillSenderRuleService senderRuleService;
@@ -0,0 +1,37 @@
package cn.rosercode.finance.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 用户偏好实体类,对应数据库表 t_user_preference,以键值形式存储每个用户的偏好配置。
*
* @date 2026-09-21 10:14:12
*/
@Data
@TableName("t_user_preference")
public class UserPreference {
/** 偏好ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
/** 偏好键,如 billSourceIncludeInStats / excludedDisplayStyle */
private String prefKey;
/** 偏好值,标量字符串或 JSON */
private String prefValue;
}
@@ -1,34 +1,41 @@
package cn.rosercode.finance.enums;
import java.util.List;
/**
* 账单类型枚举,标识支持的第三方账单来源。
*
* <p>当前支持支付宝、微信支付、美团、招商银行与交通银行账单,后续可扩展其他银行等账单类型。</p>
* <p>当前支持支付宝、微信支付、美团、招商银行与交通银行账单,后续可扩展其他银行等账单类型。
* 各来源支持的账单文件扩展名(小写、含点)随枚举一并维护,供解析与上传校验复用。</p>
*
* @date 2026-09-17 09:49:43
*/
public enum BillType {
/** 支付宝账单 */
ALIPAY("支付宝"),
ALIPAY("支付宝", ".csv"),
/** 微信支付账单 */
WECHAT("微信"),
WECHAT("微信", ".xlsx"),
/** 美团账单 */
MEITUAN("美团"),
MEITUAN("美团", ".csv"),
/** 招商银行账单 */
CMB("招商银行"),
/** 招商银行账单(支持 CSV 交易记录与 PDF 交易流水) */
CMB("招商银行", ".csv", ".pdf"),
/** 交通银行账单 */
BOCOM("交通银行");
BOCOM("交通银行", ".xls");
/** 账单类型的中文名称 */
private final String label;
BillType(String label) {
/** 该来源支持的账单文件扩展名(小写,含点),按优先级排序 */
private final List<String> extensions;
BillType(String label, String... extensions) {
this.label = label;
this.extensions = List.of(extensions);
}
/**
@@ -39,4 +46,13 @@ public enum BillType {
public String getLabel() {
return label;
}
/**
* 获取该来源支持的账单文件扩展名
*
* @return 扩展名列表(小写,含点)
*/
public List<String> getExtensions() {
return extensions;
}
}
@@ -0,0 +1,98 @@
package cn.rosercode.finance.enums;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 交易渠道枚举,统一维护系统支持的交易渠道编码与中文名称。
*
* <p>覆盖账单导入来源(支付宝、微信、美团、招商银行、交通银行)与人工记账渠道(现金),
* 供交易查询、账单导入与 AI 解析等场景复用,避免各处硬编码渠道列表导致口径不一致。</p>
*
* @date 2026-09-21 10:10:11
*/
public enum ChannelType {
/** 支付宝 */
ALIPAY("支付宝"),
/** 微信支付 */
WECHAT("微信"),
/** 美团 */
MEITUAN("美团"),
/** 招商银行 */
CMB("招商银行"),
/** 交通银行 */
BOCOM("交通银行"),
/** 现金(人工记账) */
CASH("现金");
/** 渠道中文名称 */
private final String label;
ChannelType(String label) {
this.label = label;
}
/**
* 获取渠道中文名称。
*
* @return 中文名称
*/
public String getLabel() {
return label;
}
/**
* 获取全部合法渠道编码列表,顺序与枚举声明一致。
*
* @return 渠道编码列表
*/
public static List<String> codes() {
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
}
/**
* 判断是否为合法渠道编码。
*
* @param code 渠道编码
* @return 合法返回 true
*/
public static boolean isValid(String code) {
return code != null && Arrays.stream(values()).anyMatch(type -> type.name().equals(code));
}
/**
* 将渠道编码转换为中文名称,未知编码原样返回。
*
* @param code 渠道编码
* @return 中文名称
*/
public static String labelOf(String code) {
if (code == null) {
return "";
}
for (ChannelType type : values()) {
if (type.name().equals(code)) {
return type.label;
}
}
return code;
}
/**
* 拼接「编码(名称)」形式的渠道说明,供 AI 提示词等场景使用。
*
* @return 以顿号分隔的渠道说明
*/
public static String describe() {
return Arrays.stream(values())
.map(type -> type.name() + "(" + type.label + ")")
.collect(Collectors.joining("、"));
}
}
@@ -0,0 +1,59 @@
package cn.rosercode.finance.enums;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 统计口径枚举,定义统计时对「不计入统计」记录的处理方式。
*
* <p>供统计查询与 AI 统计解析等场景复用,避免各处硬编码口径列表。</p>
*
* @date 2026-09-21 10:19:51
*/
public enum StatisticsScope {
/** 仅计入统计(默认) */
INCLUDED("仅计入统计"),
/** 全部记录 */
ALL("全部"),
/** 仅不计入统计 */
EXCLUDED("仅不计入统计");
/** 口径中文名称 */
private final String label;
StatisticsScope(String label) {
this.label = label;
}
/**
* 获取口径中文名称。
*
* @return 中文名称
*/
public String getLabel() {
return label;
}
/**
* 获取全部合法口径编码列表,顺序与枚举声明一致。
*
* @return 口径编码列表
*/
public static List<String> codes() {
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
}
/**
* 判断是否为合法口径编码。
*
* @param code 口径编码
* @return 合法返回 true
*/
public static boolean isValid(String code) {
return code != null && Arrays.stream(values()).anyMatch(scope -> scope.name().equals(code));
}
}
@@ -0,0 +1,85 @@
package cn.rosercode.finance.enums;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 收支类型枚举,统一维护交易记录的收支方向编码与中文名称。
*
* <p>供 AI 解析、规则校验与账单导入等场景复用,避免各处硬编码类型列表导致口径不一致。</p>
*
* @date 2026-09-21 10:18:45
*/
public enum TransactionType {
/** 收入 */
INCOME("收入"),
/** 支出 */
EXPENSE("支出");
/** 类型中文名称 */
private final String label;
TransactionType(String label) {
this.label = label;
}
/**
* 获取类型中文名称。
*
* @return 中文名称
*/
public String getLabel() {
return label;
}
/**
* 获取全部合法类型编码列表,顺序与枚举声明一致。
*
* @return 类型编码列表
*/
public static List<String> codes() {
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
}
/**
* 判断是否为合法类型编码。
*
* @param code 类型编码
* @return 合法返回 true
*/
public static boolean isValid(String code) {
return code != null && Arrays.stream(values()).anyMatch(type -> type.name().equals(code));
}
/**
* 将类型编码转换为中文名称,未知编码原样返回。
*
* @param code 类型编码
* @return 中文名称
*/
public static String labelOf(String code) {
if (code == null) {
return "";
}
for (TransactionType type : values()) {
if (type.name().equals(code)) {
return type.label;
}
}
return code;
}
/**
* 拼接「编码(名称)」形式的类型说明,供 AI 提示词等场景使用。
*
* @return 以顿号分隔的类型说明
*/
public static String describe() {
return Arrays.stream(values())
.map(type -> type.name() + "(" + type.label + ")")
.collect(Collectors.joining("、"));
}
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import cn.rosercode.finance.entity.UserPreference;
/**
* 用户偏好 Mapper 接口,继承 MyBatis-Plus 的 BaseMapper,提供用户偏好表的基础 CRUD 操作。
*
* @date 2026-09-21 10:14:12
*/
@Mapper
public interface UserPreferenceMapper extends BaseMapper<UserPreference> {
}
@@ -0,0 +1,43 @@
package cn.rosercode.finance.service;
import java.util.Map;
/**
* 用户偏好服务接口,提供按用户存储的偏好配置读写。
*
* @date 2026-09-21 10:14:12
*/
public interface UserPreferenceService {
/**
* 获取当前用户按账单来源的「计入统计」默认值,未配置的来源回退系统默认。
*
* @param userId 用户ID
* @return 账单来源编码到是否计入统计的映射,包含全部支持的来源
*/
Map<String, Boolean> getBillSourceIncludeInStats(Long userId);
/**
* 保存当前用户按账单来源的「计入统计」默认值,仅保留系统支持的来源。
*
* @param userId 用户ID
* @param defaults 账单来源编码到是否计入统计的映射
*/
void saveBillSourceIncludeInStats(Long userId, Map<String, Boolean> defaults);
/**
* 获取当前用户「不计入统计」记录的展示样式,未配置或非法时回退默认值。
*
* @param userId 用户ID
* @return 展示样式:strikethrough-删除线、muted-灰色标签
*/
String getExcludedDisplayStyle(Long userId);
/**
* 保存当前用户「不计入统计」记录的展示样式,非法取值回退默认值。
*
* @param userId 用户ID
* @param style 展示样式
*/
void saveExcludedDisplayStyle(Long userId, String style);
}
@@ -12,6 +12,7 @@ import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.service.AiCategoryService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiUsageService;
@@ -31,13 +32,13 @@ public class AiCategoryServiceImpl implements AiCategoryService {
/** 图标最大长度(按 UTF-16 字符数限制) */
private static final int ICON_MAX_LENGTH = 16;
/** 合法的收支类型 */
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
/** 合法的收支类型,统一取自类型枚举 */
private static final List<String> VALID_TYPES = TransactionType.codes();
/** 系统提示词,要求模型严格返回 JSON */
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的分类助手。"
+ "请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。"
+ "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 INCOME(收入)或 EXPENSE(支出);"
+ "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 " + TransactionType.describe() + ";"
+ "3. icon 为与该分类最贴切的一个 Emoji;4. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"name\":\"交通\",\"type\":\"EXPENSE\",\"icon\":\"🚌\",\"reason\":\"简要中文理由\"}";
@@ -105,9 +106,9 @@ public class AiCategoryServiceImpl implements AiCategoryService {
}
// 类型非法时回退为支出
String type = root.path("type").asText("EXPENSE").trim().toUpperCase();
String type = root.path("type").asText(TransactionType.EXPENSE.name()).trim().toUpperCase();
if (!VALID_TYPES.contains(type)) {
type = "EXPENSE";
type = TransactionType.EXPENSE.name();
}
String icon = root.path("icon").asText("").trim();
@@ -12,6 +12,7 @@ import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiDecorationService;
import cn.rosercode.finance.service.AiUsageService;
@@ -159,11 +160,11 @@ public class AiDecorationServiceImpl implements AiDecorationService {
* @return 中文描述
*/
private String typeLabel(String type) {
if ("INCOME".equalsIgnoreCase(type)) {
return "收入";
if (TransactionType.INCOME.name().equalsIgnoreCase(type)) {
return TransactionType.INCOME.getLabel();
}
if ("EXPENSE".equalsIgnoreCase(type)) {
return "支出";
if (TransactionType.EXPENSE.name().equalsIgnoreCase(type)) {
return TransactionType.EXPENSE.getLabel();
}
return "未指定";
}
@@ -21,6 +21,8 @@ import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.service.AiConfigService;
@@ -42,11 +44,11 @@ public class AiQueryServiceImpl implements AiQueryService {
/** 日期时间格式,与前端筛选参数保持一致 */
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/** 合法的收支类型 */
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
/** 合法的收支类型,统一取自类型枚举 */
private static final List<String> VALID_TYPES = TransactionType.codes();
/** 合法的交易渠道 */
private static final List<String> VALID_CHANNELS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
/** 合法的交易渠道,统一取自渠道枚举 */
private static final List<String> VALID_CHANNELS = ChannelType.codes();
private final AiConfigService aiConfigService;
private final CategoryMapper categoryMapper;
@@ -122,8 +124,8 @@ public class AiQueryServiceImpl implements AiQueryService {
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
String today = LocalDate.now().format(DATE_FMT);
return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n"
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
@@ -21,6 +21,8 @@ import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.service.AiConfigService;
@@ -39,11 +41,11 @@ public class AiRecordServiceImpl implements AiRecordService {
/** 日期时间格式,与前端表单保持一致 */
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/** 合法的收支类型 */
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
/** 合法的收支类型,统一取自类型枚举 */
private static final List<String> VALID_TYPES = TransactionType.codes();
/** 合法的交易渠道 */
private static final List<String> VALID_CHANNELS = List.of("WECHAT", "ALIPAY", "CASH", "BANK");
/** 合法的交易渠道,统一取自渠道枚举 */
private static final List<String> VALID_CHANNELS = ChannelType.codes();
private final AiConfigService aiConfigService;
private final CategoryMapper categoryMapper;
@@ -119,8 +121,8 @@ public class AiRecordServiceImpl implements AiRecordService {
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、CASH(现金)、BANK(银行卡)。\n"
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
+ "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;"
@@ -155,8 +157,8 @@ public class AiRecordServiceImpl implements AiRecordService {
vo.setSummary(root.path("summary").asText(""));
// 类型:仅接受合法值,默认支出
String type = root.path("type").asText("EXPENSE").toUpperCase();
vo.setType(VALID_TYPES.contains(type) ? type : "EXPENSE");
String type = root.path("type").asText(TransactionType.EXPENSE.name()).toUpperCase();
vo.setType(VALID_TYPES.contains(type) ? type : TransactionType.EXPENSE.name());
// 金额
vo.setAmount(decimalOrNull(root.path("amount")));
@@ -33,6 +33,7 @@ import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
@@ -407,7 +408,7 @@ public class AiRuleServiceImpl implements AiRuleService {
if (name.length() > CATEGORY_NAME_MAX_LENGTH) {
continue;
}
String newType = type != null ? type : "EXPENSE";
String newType = type != null ? type : TransactionType.EXPENSE.name();
category = resolveCategory(userId, newType, name,
truncate(item.getNewCategoryIcon(), ICON_MAX_LENGTH), categoryNameMap);
if (type == null) {
@@ -782,7 +783,7 @@ public class AiRuleServiceImpl implements AiRuleService {
return null;
}
String upper = type.trim().toUpperCase();
return "INCOME".equals(upper) || "EXPENSE".equals(upper) ? upper : null;
return TransactionType.isValid(upper) ? upper : null;
}
/**
@@ -5,6 +5,7 @@ import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@@ -21,7 +22,10 @@ import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.StatisticsDimension;
import cn.rosercode.finance.enums.StatisticsScope;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.service.AiConfigService;
@@ -43,26 +47,20 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
/** 日期时间格式,与前端筛选参数保持一致 */
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/** 合法的收支类型 */
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
/** 合法的收支类型,统一取自类型枚举 */
private static final List<String> VALID_TYPES = TransactionType.codes();
/** 合法的统计口径 */
private static final List<String> VALID_STATS_SCOPES = List.of("INCLUDED", "ALL", "EXCLUDED");
/** 合法的统计口径,统一取自口径枚举 */
private static final List<String> VALID_STATS_SCOPES = StatisticsScope.codes();
/** 合法的统计维度(趋势粒度) */
private static final List<String> VALID_DIMENSIONS = List.of("day", "week", "month", "quarter", "year");
/** 合法的统计维度(趋势粒度),取自统计维度枚举中的时间维度 */
private static final List<String> VALID_DIMENSIONS = Arrays.stream(StatisticsDimension.values())
.filter(StatisticsDimension::isTime)
.map(dimension -> dimension.name().toLowerCase())
.collect(Collectors.toList());
/** 合法的交易渠道 */
private static final List<String> VALID_CHANNELS;
static {
List<String> channels = new ArrayList<>();
for (BillType type : BillType.values()) {
channels.add(type.name());
}
channels.add("CASH");
VALID_CHANNELS = List.copyOf(channels);
}
/** 合法的交易渠道,统一取自渠道枚举 */
private static final List<String> VALID_CHANNELS = ChannelType.codes();
private final AiConfigService aiConfigService;
private final CategoryMapper categoryMapper;
@@ -138,8 +136,8 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
String today = LocalDate.now().format(DATE_FMT);
return "你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。\n"
+ "可用的收支类型:INCOME(收入)、EXPENSE(支出)。\n"
+ "可用的交易渠道:WECHAT(微信)、ALIPAY(支付宝)、MEITUAN(美团)、CMB(招商银行)、BOCOM(交通银行)、CASH(现金)。\n"
+ "可用的收支类型:" + TransactionType.describe() + "。\n"
+ "可用的交易渠道:" + ChannelType.describe() + "。\n"
+ "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
+ "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
+ "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
@@ -31,6 +31,7 @@ import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
@@ -213,7 +214,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
*/
@Override
public AiSuggestionVO suggestOne(Long userId, AiSuggestOneRequest request) {
if (!"INCOME".equals(request.getType()) && !"EXPENSE".equals(request.getType())) {
if (!TransactionType.isValid(request.getType())) {
throw new BusinessException("交易类型无效");
}
AiConfig config = requireConfig(userId);
@@ -20,7 +20,6 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.config.app.AppConfigStore;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.BillImportRequest;
import cn.rosercode.finance.entity.Category;
@@ -28,6 +27,7 @@ import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.importer.BillArchiveExtractor;
import cn.rosercode.finance.importer.BillParser;
import cn.rosercode.finance.importer.ParsedBillRow;
@@ -39,6 +39,7 @@ import cn.rosercode.finance.service.BillImportService;
import cn.rosercode.finance.service.RuleMatchContext;
import cn.rosercode.finance.service.RuleMatchResult;
import cn.rosercode.finance.service.RuleService;
import cn.rosercode.finance.service.UserPreferenceService;
import cn.rosercode.finance.vo.BillImportResultVO;
import cn.rosercode.finance.vo.BillParseResultVO;
import cn.rosercode.finance.vo.TagVO;
@@ -63,7 +64,7 @@ public class BillImportServiceImpl implements BillImportService {
private final RuleService ruleService;
private final VoConverter voConverter;
private final BillArchiveExtractor archiveExtractor;
private final AppConfigStore appConfigStore;
private final UserPreferenceService userPreferenceService;
public BillImportServiceImpl(List<BillParser> parsers,
TransactionMapper transactionMapper,
@@ -73,7 +74,7 @@ public class BillImportServiceImpl implements BillImportService {
RuleService ruleService,
VoConverter voConverter,
BillArchiveExtractor archiveExtractor,
AppConfigStore appConfigStore) {
UserPreferenceService userPreferenceService) {
// 按账单类型注册解析器,便于扩展新账单来源
for (BillParser parser : parsers) {
parserMap.put(parser.getBillType(), parser);
@@ -85,7 +86,7 @@ public class BillImportServiceImpl implements BillImportService {
this.ruleService = ruleService;
this.voConverter = voConverter;
this.archiveExtractor = archiveExtractor;
this.appConfigStore = appConfigStore;
this.userPreferenceService = userPreferenceService;
}
/**
@@ -110,7 +111,7 @@ public class BillImportServiceImpl implements BillImportService {
List<ParsedBillRow> rows = parser.parse(fileContent);
// 按账单来源的默认值覆盖「计入统计」标记
applyBillSourceStatsDefault(rows, billType);
applyBillSourceStatsDefault(userId, rows, billType);
// 批量查询已存在的交易号,用于标记重复
List<String> externalNos = rows.stream()
@@ -145,17 +146,7 @@ public class BillImportServiceImpl implements BillImportService {
* @return 候选扩展名列表(小写,含点),按优先级排序
*/
private List<String> expectedExtensions(BillType billType) {
if (billType == BillType.WECHAT) {
return List.of(".xlsx");
}
if (billType == BillType.BOCOM) {
return List.of(".xls");
}
if (billType == BillType.CMB) {
// 招商银行支持 CSV(交易记录)与 PDF(交易流水)两种导出格式
return List.of(".csv", ".pdf");
}
return List.of(".csv");
return billType.getExtensions();
}
/**
@@ -173,9 +164,9 @@ public class BillImportServiceImpl implements BillImportService {
// 分类为可选:仅在用户选择了默认分类时才校验其归属与类型
Category expenseCategory = request.getExpenseCategoryId() == null ? null
: requireCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
: requireCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
Category incomeCategory = request.getIncomeCategoryId() == null ? null
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
: requireCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
// 默认标签:仅保留属于当前用户的标签
List<Long> tagIds = request.getTagIds() == null || request.getTagIds().isEmpty() ? List.of()
@@ -200,7 +191,8 @@ public class BillImportServiceImpl implements BillImportService {
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
// 请求未显式提供「计入统计」时,按账单来源默认值兜底
boolean sourceStatsDefault = appConfigStore.load().resolveIncludeInStats(request.getBillType());
boolean sourceStatsDefault = Boolean.TRUE.equals(
userPreferenceService.getBillSourceIncludeInStats(userId).get(request.getBillType()));
int skipped = 0;
int failed = 0;
@@ -214,7 +206,7 @@ public class BillImportServiceImpl implements BillImportService {
BillImportRequest.Item item = items.get(i);
String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
// 基础字段校验
if (!"INCOME".equals(item.getType()) && !"EXPENSE".equals(item.getType())) {
if (!TransactionType.isValid(item.getType())) {
failed++;
messages.add(label + ":交易类型无效");
continue;
@@ -236,7 +228,7 @@ public class BillImportServiceImpl implements BillImportService {
continue;
}
Category category = "INCOME".equals(item.getType()) ? incomeCategory : expenseCategory;
Category category = TransactionType.INCOME.name().equals(item.getType()) ? incomeCategory : expenseCategory;
// 未选择默认分类时置空,表示未分类;规则建议分类优先于默认分类
Long categoryId = category != null ? category.getId() : null;
if (ruleMatches.get(i).getCategoryId() != null) {
@@ -474,7 +466,7 @@ public class BillImportServiceImpl implements BillImportService {
*/
private Category requireCategory(Long userId, Long categoryId, String type) {
if (categoryId == null) {
throw new BusinessException("请选择" + ("INCOME".equals(type) ? "收入" : "支出") + "默认分类");
throw new BusinessException("请选择" + TransactionType.labelOf(type) + "默认分类");
}
Category category = categoryMapper.selectById(categoryId);
if (category == null || !category.getUserId().equals(userId)) {
@@ -506,11 +498,13 @@ public class BillImportServiceImpl implements BillImportService {
* <p>采用「解析器判定 && 来源默认」的语义:来源默认不计入时整批置为不计入;
* 来源默认计入时保留解析器对「不计收支/中性」行的排除。</p>
*
* @param userId 用户ID
* @param rows 解析结果
* @param billType 账单来源
*/
private void applyBillSourceStatsDefault(List<ParsedBillRow> rows, BillType billType) {
boolean sourceDefault = appConfigStore.load().resolveIncludeInStats(billType.name());
private void applyBillSourceStatsDefault(Long userId, List<ParsedBillRow> rows, BillType billType) {
boolean sourceDefault = Boolean.TRUE.equals(
userPreferenceService.getBillSourceIncludeInStats(userId).get(billType.name()));
for (ParsedBillRow row : rows) {
boolean parserValue = row.getIncludeInStats() == null || row.getIncludeInStats();
row.setIncludeInStats(parserValue && sourceDefault);
@@ -13,10 +13,13 @@ import javax.sql.DataSource;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.config.CacheConfig;
import cn.rosercode.finance.config.datasource.DataSourceContextHolder;
import cn.rosercode.finance.config.datasource.DataSourceType;
import cn.rosercode.finance.config.datasource.DatabaseConfig;
@@ -49,6 +52,7 @@ public class DatabaseServiceImpl implements DatabaseService {
private final DatabaseConfigStore configStore;
private final SchemaInitializer schemaInitializer;
private final SchemaMigrator schemaMigrator;
private final CacheManager cacheManager;
/** 当前生效的数据库配置,未配置时为 null */
private volatile DatabaseConfig currentConfig;
@@ -56,11 +60,13 @@ public class DatabaseServiceImpl implements DatabaseService {
public DatabaseServiceImpl(DynamicDataSource dynamicDataSource,
DatabaseConfigStore configStore,
SchemaInitializer schemaInitializer,
SchemaMigrator schemaMigrator) {
SchemaMigrator schemaMigrator,
CacheManager cacheManager) {
this.dynamicDataSource = dynamicDataSource;
this.configStore = configStore;
this.schemaInitializer = schemaInitializer;
this.schemaMigrator = schemaMigrator;
this.cacheManager = cacheManager;
}
/**
@@ -114,6 +120,8 @@ public class DatabaseServiceImpl implements DatabaseService {
dynamicDataSource.addAndSwitch(config.getType(), newDataSource);
switched = true;
currentConfig = config;
// 切换数据源后清空偏好缓存,避免读到旧库的数据
evictUserPreferenceCache();
log.info("数据库配置已更新并切换到 {}:{}", config.getType().getDisplayName(), config.maskedSummary());
return buildStatus(config);
} catch (BusinessException e) {
@@ -187,6 +195,8 @@ public class DatabaseServiceImpl implements DatabaseService {
dynamicDataSource.addAndSwitch(config.getType(), dataSource);
switched = true;
currentConfig = config;
// 切换数据源后清空偏好缓存,避免读到旧库的数据
evictUserPreferenceCache();
} finally {
if (!switched) {
dataSource.close();
@@ -194,6 +204,16 @@ public class DatabaseServiceImpl implements DatabaseService {
}
}
/**
* 清空用户偏好缓存,在数据源切换后调用。
*/
private void evictUserPreferenceCache() {
Cache cache = cacheManager.getCache(CacheConfig.USER_PREFERENCE_CACHE);
if (cache != null) {
cache.clear();
}
}
/**
* 根据配置创建 Hikari 连接池。
*
@@ -9,6 +9,7 @@ import org.springframework.stereotype.Component;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
import cn.rosercode.finance.mapper.TagMapper;
@@ -98,10 +99,10 @@ public class DefaultDataInitializer {
*/
public void initializeForUser(Long userId) {
for (CategorySeed seed : DEFAULT_EXPENSE_CATEGORIES) {
insertCategory(userId, seed, "EXPENSE");
insertCategory(userId, seed, TransactionType.EXPENSE.name());
}
for (CategorySeed seed : DEFAULT_INCOME_CATEGORIES) {
insertCategory(userId, seed, "INCOME");
insertCategory(userId, seed, TransactionType.INCOME.name());
}
for (TagSeed seed : DEFAULT_TAGS) {
Tag tag = new Tag();
@@ -25,6 +25,7 @@ import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.entity.EmailBillMessage;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.EmailBillConfigMapper;
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
@@ -285,8 +286,8 @@ public class EmailBillConfigServiceImpl implements EmailBillConfigService {
* @param request 配置请求参数
*/
private void validateCategories(Long userId, EmailBillConfigRequest request) {
validateCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
validateCategory(userId, request.getIncomeCategoryId(), "INCOME");
validateCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
validateCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
}
/**
@@ -19,6 +19,7 @@ import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
import cn.rosercode.finance.mapper.TagMapper;
@@ -181,8 +182,8 @@ public class EmailBillSenderRuleServiceImpl implements EmailBillSenderRuleServic
throw new BusinessException("请至少填写一个发件人匹配串");
}
// 默认分类归属与类型一致性
validateCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
validateCategory(userId, request.getIncomeCategoryId(), "INCOME");
validateCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
validateCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
}
/**
@@ -25,6 +25,7 @@ import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Rule;
import cn.rosercode.finance.entity.RuleTag;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.RuleMapper;
import cn.rosercode.finance.mapper.RuleTagMapper;
@@ -325,7 +326,7 @@ public class RuleServiceImpl implements RuleService {
private void validate(Long userId, RuleRequest request) {
// 收支类型白名单
if (StringUtils.hasText(request.getType())
&& !"INCOME".equals(request.getType()) && !"EXPENSE".equals(request.getType())) {
&& !TransactionType.isValid(request.getType())) {
throw new BusinessException("收支类型无效");
}
// 金额区间
@@ -25,7 +25,9 @@ import cn.rosercode.finance.dto.StatisticsQuery;
import cn.rosercode.finance.dto.StatisticsRangeQuery;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.StatisticsDimension;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
import cn.rosercode.finance.service.StatisticsService;
@@ -142,7 +144,7 @@ public class StatisticsServiceImpl implements StatisticsService {
// 最大单笔支出
QueryWrapper<Transaction> maxWrapper = baseWrapper(userId, query);
maxWrapper.eq("type", "EXPENSE").orderByDesc("amount").last("LIMIT 1");
maxWrapper.eq("type", TransactionType.EXPENSE.name()).orderByDesc("amount").last("LIMIT 1");
Transaction maxExpense = transactionMapper.selectOne(maxWrapper);
int dayCount = resolveDayCount(userId, query);
@@ -354,11 +356,11 @@ public class StatisticsServiceImpl implements StatisticsService {
vo.setName(category != null ? category.getName() : "未分类");
vo.setIcon(category != null ? category.getIcon() : "");
vo.setType(category != null ? category.getType()
: (income.compareTo(expense) >= 0 ? "INCOME" : "EXPENSE"));
: (income.compareTo(expense) >= 0 ? TransactionType.INCOME.name() : TransactionType.EXPENSE.name()));
}
case CHANNEL -> vo.setName(channelLabel(key));
case TYPE -> {
vo.setName("INCOME".equals(key) ? "收入" : "支出");
vo.setName(TransactionType.labelOf(key));
vo.setType(key);
}
default -> vo.setName(key);
@@ -448,13 +450,9 @@ public class StatisticsServiceImpl implements StatisticsService {
if (!StringUtils.hasText(code)) {
return "未填写";
}
return switch (code) {
case "WECHAT" -> "微信";
case "ALIPAY" -> "支付宝";
case "CASH" -> "现金";
case "BANK" -> "银行卡";
default -> code;
};
// 兼容历史 BANK 渠道
if ("BANK".equals(code)) return "银行卡";
return ChannelType.labelOf(code);
}
/**
@@ -28,7 +28,8 @@ import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
@@ -344,9 +345,9 @@ public class TransactionServiceImpl implements TransactionService {
// 分类与计入统计均为可选,仅在提供分类时校验归属与类型
Category expenseCategory = request.getExpenseCategoryId() == null ? null
: requireCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
: requireCategory(userId, request.getExpenseCategoryId(), TransactionType.EXPENSE.name());
Category incomeCategory = request.getIncomeCategoryId() == null ? null
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
: requireCategory(userId, request.getIncomeCategoryId(), TransactionType.INCOME.name());
Boolean includeInStats = request.getIncludeInStats();
if (expenseCategory == null && incomeCategory == null && includeInStats == null) {
throw new BusinessException("请至少选择分类或计入统计");
@@ -358,11 +359,11 @@ public class TransactionServiceImpl implements TransactionService {
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids));
List<Long> expenseIds = transactions.stream()
.filter(t -> "EXPENSE".equals(t.getType()))
.filter(t -> TransactionType.EXPENSE.name().equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
List<Long> incomeIds = transactions.stream()
.filter(t -> "INCOME".equals(t.getType()))
.filter(t -> TransactionType.INCOME.name().equals(t.getType()))
.map(Transaction::getId)
.collect(Collectors.toList());
@@ -680,7 +681,7 @@ public class TransactionServiceImpl implements TransactionService {
TransactionExportVO vo = new TransactionExportVO();
Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats();
if (include) {
vo.setType(t.getType().equals("INCOME") ? "收入" : "支出");
vo.setType(TransactionType.labelOf(t.getType()));
} else {
vo.setType("不计入统计");
}
@@ -708,16 +709,9 @@ public class TransactionServiceImpl implements TransactionService {
*/
private String getChannelLabel(String code) {
if (code == null) return "";
return switch (code) {
case "WECHAT" -> "微信";
case "ALIPAY" -> "支付宝";
case "CASH" -> "现金";
case "BANK" -> "银行卡";
case "MEITUAN" -> "美团";
case "CMB" -> "招商银行";
case "BOCOM" -> "交通银行";
default -> code;
};
// 兼容历史 BANK 渠道
if ("BANK".equals(code)) return "银行卡";
return ChannelType.labelOf(code);
}
/**
@@ -728,12 +722,6 @@ public class TransactionServiceImpl implements TransactionService {
*/
@Override
public List<String> listChannels() {
List<String> methods = new ArrayList<>();
for (BillType type : BillType.values()) {
methods.add(type.name());
}
// 人工记账使用的交易渠道,非账单来源
methods.add("CASH");
return methods;
return ChannelType.codes();
}
}
@@ -0,0 +1,192 @@
package cn.rosercode.finance.service.impl;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import cn.rosercode.finance.config.CacheConfig;
import cn.rosercode.finance.entity.UserPreference;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.mapper.UserPreferenceMapper;
import cn.rosercode.finance.service.UserPreferenceService;
/**
* 用户偏好服务实现类,以键值形式读写每个用户的偏好配置。
*
* <p>偏好持久化在 {@code t_user_preference},未配置或取值非法时回退系统默认。</p>
*
* @date 2026-09-21 10:14:12
*/
@Service
@Slf4j
public class UserPreferenceServiceImpl implements UserPreferenceService {
/** 偏好键:按账单来源的「计入统计」默认值 */
private static final String KEY_BILL_SOURCE_INCLUDE_IN_STATS = "billSourceIncludeInStats";
/** 偏好键:「不计入统计」记录的展示样式 */
private static final String KEY_EXCLUDED_DISPLAY_STYLE = "excludedDisplayStyle";
/** 默认计入统计的账单来源,其余来源默认不计入 */
private static final Set<String> DEFAULT_INCLUDED_SOURCES = Set.of("WECHAT", "ALIPAY");
/** 默认的「不计入统计」记录展示样式:删除线 */
private static final String DEFAULT_EXCLUDED_DISPLAY_STYLE = "strikethrough";
/** 合法的「不计入统计」记录展示样式:删除线、灰色标签 */
private static final Set<String> VALID_EXCLUDED_DISPLAY_STYLES = Set.of("strikethrough", "muted");
private final UserPreferenceMapper userPreferenceMapper;
private final ObjectMapper objectMapper;
public UserPreferenceServiceImpl(UserPreferenceMapper userPreferenceMapper, ObjectMapper objectMapper) {
this.userPreferenceMapper = userPreferenceMapper;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*
* 优先取用户配置,缺失的来源回退系统默认(微信、支付宝计入)。结果按用户缓存。
*/
@Override
@Cacheable(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'billSource:' + #userId")
public Map<String, Boolean> getBillSourceIncludeInStats(Long userId) {
Map<String, Boolean> saved = parseBooleanMap(findValue(userId, KEY_BILL_SOURCE_INCLUDE_IN_STATS));
Map<String, Boolean> result = new LinkedHashMap<>();
for (BillType type : BillType.values()) {
Boolean value = saved.get(type.name());
result.put(type.name(), value != null ? value : DEFAULT_INCLUDED_SOURCES.contains(type.name()));
}
// 缓存返回同一实例,包装为不可变避免被调用方修改
return Collections.unmodifiableMap(result);
}
/**
* {@inheritDoc}
*
* 保存后失效该用户的账单来源偏好缓存。
*/
@Override
@CacheEvict(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'billSource:' + #userId")
public void saveBillSourceIncludeInStats(Long userId, Map<String, Boolean> defaults) {
Map<String, Boolean> normalized = new LinkedHashMap<>();
if (defaults != null) {
// 仅保留系统支持的账单来源,忽略非法键
for (BillType type : BillType.values()) {
Boolean value = defaults.get(type.name());
if (value != null) {
normalized.put(type.name(), value);
}
}
}
upsert(userId, KEY_BILL_SOURCE_INCLUDE_IN_STATS, toJson(normalized));
log.info("保存账单来源统计偏好: userId={}, sources={}", userId, normalized.keySet());
}
/**
* {@inheritDoc}
*
* 结果按用户缓存。
*/
@Override
@Cacheable(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'excludedStyle:' + #userId")
public String getExcludedDisplayStyle(Long userId) {
String value = findValue(userId, KEY_EXCLUDED_DISPLAY_STYLE);
return value != null && VALID_EXCLUDED_DISPLAY_STYLES.contains(value) ? value : DEFAULT_EXCLUDED_DISPLAY_STYLE;
}
/**
* {@inheritDoc}
*
* 保存后失效该用户的外观样式缓存。
*/
@Override
@CacheEvict(cacheNames = CacheConfig.USER_PREFERENCE_CACHE, key = "'excludedStyle:' + #userId")
public void saveExcludedDisplayStyle(Long userId, String style) {
String resolved = style != null && VALID_EXCLUDED_DISPLAY_STYLES.contains(style)
? style : DEFAULT_EXCLUDED_DISPLAY_STYLE;
upsert(userId, KEY_EXCLUDED_DISPLAY_STYLE, resolved);
log.info("保存不计入统计展示样式: userId={}, style={}", userId, resolved);
}
/**
* 查询指定用户的某个偏好值。
*
* @param userId 用户ID
* @param key 偏好键
* @return 偏好值,未配置时返回 null
*/
private String findValue(Long userId, String key) {
UserPreference preference = userPreferenceMapper.selectOne(new LambdaQueryWrapper<UserPreference>()
.eq(UserPreference::getUserId, userId)
.eq(UserPreference::getPrefKey, key));
return preference != null ? preference.getPrefValue() : null;
}
/**
* 写入指定用户的偏好值,不存在时插入、存在时更新。
*
* @param userId 用户ID
* @param key 偏好键
* @param value 偏好值
*/
private void upsert(Long userId, String key, String value) {
UserPreference existing = userPreferenceMapper.selectOne(new LambdaQueryWrapper<UserPreference>()
.eq(UserPreference::getUserId, userId)
.eq(UserPreference::getPrefKey, key));
if (existing == null) {
UserPreference preference = new UserPreference();
preference.setUserId(userId);
preference.setPrefKey(key);
preference.setPrefValue(value);
userPreferenceMapper.insert(preference);
} else {
existing.setPrefValue(value);
userPreferenceMapper.updateById(existing);
}
}
/**
* 将 JSON 字符串解析为账单来源布尔映射,解析失败时返回空映射。
*
* @param json JSON 字符串
* @return 账单来源编码到是否计入统计的映射
*/
private Map<String, Boolean> parseBooleanMap(String json) {
if (json == null || json.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<Map<String, Boolean>>() {
});
} catch (JsonProcessingException e) {
log.warn("解析账单来源统计偏好失败:{}", e.getMessage());
return Map.of();
}
}
/**
* 将对象序列化为 JSON 字符串。
*
* @param value 待序列化对象
* @return JSON 字符串
*/
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalStateException("序列化用户偏好失败:" + e.getMessage(), e);
}
}
}
@@ -0,0 +1,22 @@
package cn.rosercode.finance.vo;
import java.util.List;
import lombok.Data;
/**
* 账单类型视图对象,用于向前端下发支持的账单来源及其文件扩展名。
*
* <p>名称与导出提示等文案由前端按语言维护,后端仅提供编码与允许的扩展名。</p>
*
* @date 2026-09-21 10:20:13
*/
@Data
public class BillTypeVO {
/** 账单来源编码,对应 {@link cn.rosercode.finance.enums.BillType} 名称 */
private String code;
/** 该来源支持的账单文件扩展名(小写,含点) */
private List<String> extensions;
}
+11
View File
@@ -260,3 +260,14 @@ CREATE TABLE IF NOT EXISTS t_email_bill_message (
UNIQUE INDEX uk_email_bill_message (config_id, uid, attachment_index) COMMENT '配置+UID+附件序号唯一索引,用于去重',
INDEX idx_email_bill_message_user_status (user_id, status) COMMENT '用户ID+状态联合索引'
);
-- 用户偏好表:按用户存储键值形式的偏好配置
CREATE TABLE IF NOT EXISTS t_user_preference (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '偏好ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
pref_key VARCHAR(50) NOT NULL COMMENT '偏好键,如 billSourceIncludeInStats / excludedDisplayStyle',
pref_value TEXT COMMENT '偏好值,标量字符串或 JSON',
UNIQUE INDEX uk_user_pref (user_id, pref_key) COMMENT '用户+偏好键唯一索引'
);
+18
View File
@@ -325,3 +325,21 @@ WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_email_bill_message SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TABLE IF NOT EXISTS t_user_preference (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
pref_key VARCHAR(50) NOT NULL,
pref_value TEXT
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_pref ON t_user_preference (user_id, pref_key);;
CREATE TRIGGER IF NOT EXISTS trg_user_preference_updated_at
AFTER UPDATE ON t_user_preference
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_user_preference SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;